Веха 1: задачник в терминале
Пятнадцать уроков позади. Ты знаешь переменные, циклы, функции, слайсы, map, структуры, свои типы, интерфейсы, ошибки и работу с файлами. Пора собрать из этого первую вещь, которую можно запустить и показать.
Это веха, а не обычный урок. Здесь нет новой теории кроме одного пакета - только сборка известного в работающую программу:
$ tasks add "Купить хлеб"
добавлено: 1 [ ] Купить хлеб
$ tasks add "Позвонить в банк"
добавлено: 2 [ ] Позвонить в банк
$ tasks done 1
выполнено: 1 [x] Купить хлеб
$ tasks list
2 [ ] Позвонить в банк
$ tasks list --all
1 [x] Купить хлеб
2 [ ] Позвонить в банк
Задачи переживают перезапуск: они лежат в файле рядом с программой.
Что мы строим
Три файла, каждый со своей ответственностью:
task.go- что такое задача и как она превращается в строку файла;store.go- список задач: загрузка, изменение, сохранение;main.go- разбор командной строки и вывод.
Такое деление - не бюрократия. Оно позволяет тестировать хранилище, не запуская программу и не трогая терминал, и это ровно то, что мы сделаем в конце урока.
Задача и её представление
Начнём с типа. Обрати внимание на Status: это не string, а свой тип
поверх строки - приём из урока Определяемые типы.
Компилятор не даст присвоить туда случайную строку, а список допустимых
значений собран в одном месте.
package main
import (
"errors"
"fmt"
"strconv"
"strings"
)
// Status - состояние задачи. Свой тип вместо string: компилятор не даст
// присвоить произвольную строку, а список допустимых значений виден
// в одном месте (урок «Определяемые типы и инкапсуляция»).
type Status string
const (
StatusTodo Status = "todo"
StatusDone Status = "done"
)
// ErrTaskNotFound возвращается, когда задачи с таким id нет.
// Ошибка как значение, а не паника: вызывающий сам решает, что делать
// (урок «Обработка ошибок как философия»).
var ErrTaskNotFound = errors.New("задача не найдена")
// Task - одна задача в списке.
type Task struct {
ID int
Title string
Status Status
}
// Done сообщает, выполнена ли задача.
func (t Task) Done() bool {
return t.Status == StatusDone
}
// String делает Task пригодным для печати: fmt сам вызовет этот метод.
func (t Task) String() string {
mark := " "
if t.Done() {
mark = "x"
}
return fmt.Sprintf("%3d [%s] %s", t.ID, mark, t.Title)
}
// Формат хранения - по одной задаче на строку, поля через табуляцию:
//
// 1<TAB>todo<TAB>Купить хлеб
//
// Табуляция выбрана, а не запятая или пробел: запятые и пробелы в названии
// задачи встречаются постоянно, табуляция - почти никогда. Разбор при этом
// остаётся одной строкой кода вместо полноценного парсера CSV.
const fieldSeparator = "\t"
// encode превращает задачу в строку файла.
func encode(t Task) string {
return strconv.Itoa(t.ID) + fieldSeparator + string(t.Status) + fieldSeparator + t.Title
}
// decode разбирает строку файла в задачу.
//
// Заголовок обрезается по числу полей: SplitN с limit=3 оставляет всё
// после второй табуляции в третьем элементе. Иначе название задачи,
// содержащее табуляцию, развалилось бы на части.
func decode(line string) (Task, error) {
parts := strings.SplitN(line, fieldSeparator, 3)
if len(parts) != 3 {
return Task{}, fmt.Errorf("ожидалось 3 поля, получено %d", len(parts))
}
id, err := strconv.Atoi(parts[0])
if err != nil {
return Task{}, fmt.Errorf("id не число: %w", err)
}
status := Status(parts[1])
if status != StatusTodo && status != StatusDone {
return Task{}, fmt.Errorf("неизвестный статус %q", parts[1])
}
title := strings.TrimSpace(parts[2])
if title == "" {
return Task{}, errors.New("пустое название")
}
return Task{ID: id, Title: title, Status: status}, nil
}
Разберём два решения в этом файле.
Почему табуляция, а не запятая. Формат хранения - по одной задаче
на строку, поля через \t. Запятые и пробелы в названии задачи встречаются
постоянно («Купить хлеб, молоко и сыр»), табуляция - почти никогда.
Полноценный CSV с экранированием кавычек здесь был бы честнее, но он тянет
за собой пакет encoding/csv и разговор про экранирование, а разбор
табуляции - это одна строка.
Почему SplitN с ограничением в три части. Обычный Split разрезал бы
строку по каждой табуляции. Если она случайно попадёт в название задачи,
поля разъедутся. SplitN(line, sep, 3) останавливается после второго
разделителя и отдаёт весь хвост третьим элементом - название остаётся целым,
чем бы оно ни было.
Хранилище
package main
import (
"bufio"
"errors"
"fmt"
"io/fs"
"os"
"strings"
)
// Store - список задач и файл, в котором он живёт.
type Store struct {
path string
tasks []Task
}
// NewStore читает задачи из файла.
//
// Отсутствующий файл - не ошибка: при первом запуске его просто нет,
// и правильное поведение - начать с пустого списка. Отличаем именно
// «файла нет» через errors.Is(err, fs.ErrNotExist), а не по тексту
// сообщения: текст зависит от операционной системы.
func NewStore(path string) (*Store, error) {
s := &Store{path: path}
file, err := os.Open(path)
if errors.Is(err, fs.ErrNotExist) {
return s, nil
}
if err != nil {
return nil, fmt.Errorf("открыть %s: %w", path, err)
}
defer file.Close()
scanner := bufio.NewScanner(file)
lineNo := 0
for scanner.Scan() {
lineNo++
line := scanner.Text()
if strings.TrimSpace(line) == "" {
continue
}
task, err := decode(line)
if err != nil {
// Номер строки в ошибке - разница между «файл битый»
// и «файл битый, строка 17». Второе можно починить.
return nil, fmt.Errorf("%s, строка %d: %w", path, lineNo, err)
}
s.tasks = append(s.tasks, task)
}
if err := scanner.Err(); err != nil {
return nil, fmt.Errorf("читать %s: %w", path, err)
}
return s, nil
}
// Tasks возвращает копию списка задач.
//
// Именно копию: вернув сам слайс, мы бы отдали наружу доступ к внутреннему
// состоянию, и любой вызывающий смог бы молча переписать элемент.
func (s *Store) Tasks() []Task {
out := make([]Task, len(s.tasks))
copy(out, s.tasks)
return out
}
// Add добавляет задачу и возвращает её.
func (s *Store) Add(title string) (Task, error) {
title = strings.TrimSpace(title)
if title == "" {
return Task{}, errors.New("название задачи пустое")
}
task := Task{ID: s.nextID(), Title: title, Status: StatusTodo}
s.tasks = append(s.tasks, task)
return task, nil
}
// Done помечает задачу выполненной.
func (s *Store) Done(id int) (Task, error) {
// Индекс, а не значение: копия элемента слайса не изменит сам слайс.
for i := range s.tasks {
if s.tasks[i].ID != id {
continue
}
s.tasks[i].Status = StatusDone
return s.tasks[i], nil
}
return Task{}, fmt.Errorf("id %d: %w", id, ErrTaskNotFound)
}
// Remove удаляет задачу.
func (s *Store) Remove(id int) error {
for i := range s.tasks {
if s.tasks[i].ID != id {
continue
}
s.tasks = append(s.tasks[:i], s.tasks[i+1:]...)
return nil
}
return fmt.Errorf("id %d: %w", id, ErrTaskNotFound)
}
// nextID выдаёт следующий свободный номер.
//
// Берём максимум, а не длину списка: после удаления задачи длина
// уменьшается, и len+1 выдал бы уже занятый номер.
func (s *Store) nextID() int {
maxID := 0
for _, t := range s.tasks {
if t.ID > maxID {
maxID = t.ID
}
}
return maxID + 1
}
// Save записывает список на диск.
//
// Пишем во временный файл и переименовываем: если процесс упадёт
// на середине записи, целевой файл останется прежним, а не обрежется
// до половины. Переименование внутри одного каталога атомарно.
func (s *Store) Save() error {
tmp, err := os.CreateTemp(dirOf(s.path), "tasks-*.tmp")
if err != nil {
return fmt.Errorf("временный файл: %w", err)
}
tmpName := tmp.Name()
// Если дальше что-то сломается, мусор за собой уберём. После успешного
// Rename файла с этим именем уже нет и Remove вернёт ошибку - её мы
// осознанно игнорируем.
defer os.Remove(tmpName)
writer := bufio.NewWriter(tmp)
for _, t := range s.tasks {
if _, err := fmt.Fprintln(writer, encode(t)); err != nil {
tmp.Close()
return fmt.Errorf("запись: %w", err)
}
}
if err := writer.Flush(); err != nil {
tmp.Close()
return fmt.Errorf("сброс буфера: %w", err)
}
if err := tmp.Close(); err != nil {
return fmt.Errorf("закрыть временный файл: %w", err)
}
if err := os.Rename(tmpName, s.path); err != nil {
return fmt.Errorf("переименовать в %s: %w", s.path, err)
}
return nil
}
// dirOf возвращает каталог файла. Пустой путь означает текущий каталог.
func dirOf(path string) string {
i := strings.LastIndex(path, string(os.PathSeparator))
if i <= 0 {
return "."
}
return path[:i]
}
Здесь стоит остановиться на четырёх местах.
Отсутствие файла - не ошибка
При первом запуске файла нет, и это нормальное состояние, а не сбой. Отличаем именно его:
file, err := os.Open(path)
if errors.Is(err, fs.ErrNotExist) {
return s, nil
}
errors.Is, а не сравнение текста ошибки: сообщение зависит
от операционной системы и языка системы, а fs.ErrNotExist - нет.
Это применение того, что разбирали в уроке про ошибки.
Индекс, а не значение
for i := range s.tasks {
s.tasks[i].Status = StatusDone
}
Если написать for _, task := range s.tasks и поменять task.Status,
не изменится ничего: task - копия элемента. Программа при этом
не упадёт и ничего не скажет. Это одна из самых частых ошибок новичка
в Go, и в тестах на неё есть отдельная проверка.
Максимум, а не длина
func (s *Store) nextID() int {
maxID := 0
for _, t := range s.tasks {
if t.ID > maxID {
maxID = t.ID
}
}
return maxID + 1
}
Соблазнительный вариант len(s.tasks) + 1 работает ровно до первого
удаления. Удалили задачу из середины списка из трёх - длина стала два,
следующий номер три, а он уже занят. Дальше done 3 попадает не в ту
задачу, и понять почему очень трудно.
Запись через временный файл
tmp, err := os.CreateTemp(dirOf(s.path), "tasks-*.tmp")
...
os.Rename(tmpName, s.path)
Прямая запись в целевой файл опасна: если процесс прервётся на середине, файл останется обрезанным, и прошлые задачи пропадут. Пишем в новый файл и переименовываем - переименование внутри одного каталога атомарно, то есть в любой момент на диске лежит либо старая полная версия, либо новая полная.
Этот же приём используют настоящие базы данных и текстовые редакторы.
Командная строка
// Веха 1 Go-трека: задачник в терминале.
//
// Первая законченная программа курса. Всё, что нужно, уже пройдено:
// структуры, свои типы, ошибки как значения, слайсы, работа с файлами.
// Единственная новая вещь - пакет flag, и он занимает десять строк.
//
// go run ./cli add "Купить хлеб"
// go run ./cli list
// go run ./cli done 1
// go run ./cli rm 1
// go run ./cli list --all
package main
import (
"errors"
"flag"
"fmt"
"os"
"strconv"
)
const defaultFile = "tasks.txt"
func main() {
// Вся работа - в run, чтобы defer отработал до выхода из процесса.
// os.Exit не разворачивает стек и не выполняет отложенные вызовы,
// поэтому вызывать его прямо из тела с defer нельзя.
if err := run(os.Args[1:]); err != nil {
fmt.Fprintln(os.Stderr, "ошибка:", err)
os.Exit(1)
}
}
// options - разобранная командная строка.
type options struct {
command string
params []string
file string
all bool
}
// parseArgs разбирает аргументы.
//
// Вынесено из run отдельной функцией ради теста: разбор командной строки -
// это то место, где легко ошибиться молча, и проверять его нужно без
// запуска процесса и без файлов на диске.
func parseArgs(args []string) (options, error) {
if len(args) == 0 {
return options{}, errors.New("не указана команда")
}
// Команда идёт первой, флаги - после неё, как у самого go:
// `go test -v ./...`, а не `go -v test ./...`.
//
// Так приходится делать потому, что flag.Parse останавливается
// на первом аргументе, который не похож на флаг. Если отдать ему
// весь os.Args[1:], то в `tasks list --all` разбор закончится
// на слове «list», а `--all` останется неразобранным - и молча
// не сработает. Именно так и было в первой версии этой программы,
// пока её не запустили руками.
command, rest := args[0], args[1:]
fs := flag.NewFlagSet(command, flag.ContinueOnError)
file := fs.String("file", defaultFile, "файл со списком задач")
all := fs.Bool("all", false, "показывать и выполненные задачи")
fs.Usage = usage
if err := fs.Parse(rest); err != nil {
// flag уже напечатал, что не так, и вызвал Usage.
return options{}, errors.New("не разобраны аргументы")
}
return options{command: command, params: fs.Args(), file: *file, all: *all}, nil
}
func run(args []string) error {
opts, err := parseArgs(args)
if err != nil {
usage()
return err
}
store, err := NewStore(opts.file)
if err != nil {
return err
}
switch opts.command {
case "list":
return list(store, opts.all)
case "add":
return add(store, opts.params)
case "done":
return done(store, opts.params)
case "rm":
return remove(store, opts.params)
default:
usage()
return fmt.Errorf("неизвестная команда %q", opts.command)
}
}
func list(store *Store, all bool) error {
tasks := store.Tasks()
shown := 0
for _, t := range tasks {
if t.Done() && !all {
continue
}
fmt.Println(t)
shown++
}
switch {
case len(tasks) == 0:
fmt.Println("список пуст")
case shown == 0:
fmt.Println("всё выполнено, покажи с --all")
}
return nil
}
func add(store *Store, params []string) error {
if len(params) != 1 {
return errors.New(`использование: add "название задачи"`)
}
task, err := store.Add(params[0])
if err != nil {
return err
}
if err := store.Save(); err != nil {
return err
}
fmt.Println("добавлено:", task)
return nil
}
func done(store *Store, params []string) error {
id, err := parseID(params, "done")
if err != nil {
return err
}
task, err := store.Done(id)
if err != nil {
return err
}
if err := store.Save(); err != nil {
return err
}
fmt.Println("выполнено:", task)
return nil
}
func remove(store *Store, params []string) error {
id, err := parseID(params, "rm")
if err != nil {
return err
}
if err := store.Remove(id); err != nil {
return err
}
if err := store.Save(); err != nil {
return err
}
fmt.Println("удалено:", id)
return nil
}
func parseID(params []string, command string) (int, error) {
if len(params) != 1 {
return 0, fmt.Errorf("использование: %s <id>", command)
}
id, err := strconv.Atoi(params[0])
if err != nil {
return 0, fmt.Errorf("id должен быть числом, получено %q", params[0])
}
if id <= 0 {
return 0, fmt.Errorf("id должен быть положительным, получено %d", id)
}
return id, nil
}
func usage() {
fmt.Fprint(os.Stderr, `Задачник в терминале.
Команды:
list показать незавершённые задачи
add "текст" добавить задачу
done <id> отметить выполненной
rm <id> удалить
Флаги идут после команды:
--file <путь> файл со списком (по умолчанию tasks.txt)
--all в list показывать и выполненные
tasks list --all
tasks add --file work.txt "Задача"
`)
}
Ловушка, на которой мы попались
Первая версия этой программы разбирала флаги так:
fs.Parse(os.Args[1:]) // ← неправильно
rest := fs.Args()
command := rest[0]
Выглядит логично и собирается без единого предупреждения. Но команда
tasks list --all не показывала выполненные задачи.
Причина в том, как устроен flag: Parse идёт по аргументам слева направо
и останавливается на первом, который не начинается с дефиса. В нашей
строке это слово list, поэтому --all даже не рассматривался и остался
со значением по умолчанию.
Правильный порядок - сначала достать команду, потом разбирать остаток:
command, rest := args[0], args[1:]
fs := flag.NewFlagSet(command, flag.ContinueOnError)
...
fs.Parse(rest)
Так устроен и сам go: go test -v ./..., а не go -v test ./....
Почему вся работа в run, а не в main
func main() {
if err := run(os.Args[1:]); err != nil {
fmt.Fprintln(os.Stderr, "ошибка:", err)
os.Exit(1)
}
}
os.Exit завершает процесс немедленно: отложенные вызовы defer
не выполняются, буферы не сбрасываются. Если написать os.Exit(1)
в функции, где есть defer file.Close(), файл не закроется.
Отделив run, мы получаем одно место, где программа завершается,
и заодно функцию, которую можно вызвать из теста.
Ошибки идут в os.Stderr, а полезный вывод - в os.Stdout. Тогда
tasks list > out.txt кладёт в файл только список, а сообщение об ошибке
человек по-прежнему видит на экране.
Проверяем
cd examples/go-milestones
go run ./cli add "Купить хлеб"
go run ./cli add "Позвонить в банк"
go run ./cli done 1
go run ./cli list
go run ./cli list --all
Файл tasks.txt появится в текущем каталоге - открой его и посмотри,
как выглядит формат.
Тесты:
go test ./cli/... -v
О чём говорят тесты
Тесты хранилища написаны не «для покрытия», а под конкретные ошибки. Каждый отвечает на вопрос «что сломается, если сделать наивно»:
| Тест | Какую ошибку ловит |
|---|---|
TestNewStoreMissingFileIsEmpty | первый запуск падает с «нет такого файла» |
TestDoneMutatesStoredTask | изменение копии элемента вместо самого слайса |
TestNextIDDoesNotCollideAfterRemoveFromMiddle | len+1 выдаёт занятый номер |
TestTasksReturnsCopy | наружу отдан внутренний слайс, его правят снаружи |
TestSaveThenLoadRoundTrip | формат записи и разбора разъехались |
TestLoadBrokenFileReportsLineNumber | «файл битый» без указания, где именно |
TestParseArgsFlagsAfterCommand | тот самый --all, который молча не работал |
Отдельно посмотри TestNextIDReusesNumberAfterRemovingLast. Он проверяет
не ошибку, а сознательное ограничение: удалив последнюю задачу, следующая
получит тот же номер. Для задачника это допустимо, и тест существует, чтобы
следующий человек не принял это за баг и не начал чинить.
Что дальше
У тебя есть работающий инструмент. Дальше в треке - пакеты и область видимости, окружение, горутины и каналы, а потом HTTP-сервер. Сразу после него ждёт веха 2: тот же задачник, но управляемый по HTTP, с JSON вместо текстового файла.
Мини-практика
Добавь команду edit <id> "новый текст". Понадобится метод в Store
и ветка в switch. Начни с теста: он должен проверить, что после edit
и Save перечитанный из файла список содержит новый текст, а статус
задачи не изменился.
Мини-задание
Практика выше про код. Это - про то, что вокруг него.
- Собери свой задачник:
go build -o todo .и запусти бинарник, а неgo run - Проверь, где он хранит данные, и что будет, если файла нет при первом запуске
- Запусти
go vet ./...иgofmt -l .на всём проекте. Оба должны молчать - Передай заведомо неверные аргументы и посмотри, понятное ли сообщение получает пользователь
- Скопируй бинарник на другую машину или в контейнер
alpineи запусти. Если не заработает, разберись почему (подсказка: CGO и libc)