Веха 1: задачник в терминале

Пятнадцать уроков позади. Ты знаешь переменные, циклы, функции, слайсы, map, структуры, свои типы, интерфейсы, ошибки и работу с файлами. Пора собрать из этого первую вещь, которую можно запустить и показать.

Это веха, а не обычный урок. Здесь нет новой теории кроме одного пакета - только сборка известного в работающую программу:

$ tasks add "Купить хлеб"
добавлено:   1 [ ] Купить хлеб

$ tasks add "Позвонить в банк"
добавлено:   2 [ ] Позвонить в банк

$ tasks done 1
выполнено:   1 [x] Купить хлеб

$ tasks list
  2 [ ] Позвонить в банк

$ tasks list --all
  1 [x] Купить хлеб
  2 [ ] Позвонить в банк

Задачи переживают перезапуск: они лежат в файле рядом с программой.

Всё, что ниже, вставлено из `examples/go-milestones/cli` скриптом, а не написано в тексте руками. Этот проект собирается, проходит `go vet` и покрыт тестами в CI - если код в нём сломается, урок не соберётся. Так уроки перестают расходиться с работающим кодом.

Что мы строим

Три файла, каждый со своей ответственностью:

  • 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 ./....

Ошибку не поймал ни компилятор, ни `go vet`, ни тесты хранилища. Её нашёл запуск программы руками. Тесты проверяют то, о чём ты подумал; запуск показывает то, о чём ты не подумал. Поэтому в этой вехе после каждого шага стоит `go run` - и в конце появился тест именно на этот случай.

Почему вся работа в 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изменение копии элемента вместо самого слайса
TestNextIDDoesNotCollideAfterRemoveFromMiddlelen+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)

Зарегистрируйтесь бесплатно, чтобы пройти квиз, вести прогресс.