Веха 2: тот же задачник по HTTP

В вехе 1 мы собрали задачник для терминала. С тех пор прошли пакеты, окружение, горутины, каналы и HTTP-сервер. Пора превратить программу в сервис: те же задачи, но управляются по сети, и пользоваться ими может кто угодно, а не только тот, кто сидит за этой машиной.

$ curl -s -X POST localhost:8080/tasks -d '{"title":"Купить хлеб"}' -i
HTTP/1.1 201 Created
Content-Type: application/json; charset=utf-8
Location: /tasks/1

{"id":1,"title":"Купить хлеб","done":false}

$ curl -s -X PUT localhost:8080/tasks/1 -d '{"done":true}'
{"id":1,"title":"Купить хлеб","done":true}

$ curl -s localhost:8080/tasks
[{"id":1,"title":"Купить хлеб","done":true}]

Данные живут в памяти процесса и исчезают при перезапуске. Это осознанно: веха показывает HTTP и JSON, а база появится в вехе 3 следующим шагом. Держать один новый слой за раз проще, чем два сразу.

Как и в первой вехе, весь код вставлен из `examples/go-milestones/api-memory` скриптом. Проект собирается, проходит `go vet` и `go test -race` в CI.

Хранилище: то же, но с мьютексом

package main

import (
	"errors"
	"fmt"
	"sync"
)

// ErrTaskNotFound возвращается, когда задачи с таким id нет.
var ErrTaskNotFound = errors.New("задача не найдена")

// Task - задача. Теги json задают имена полей во внешнем представлении:
// в Go принят CamelCase, в JSON-API - snake_case или camelCase, и тег
// избавляет от необходимости выбирать одно в ущерб другому.
type Task struct {
	ID    int    `json:"id"`
	Title string `json:"title"`
	Done  bool   `json:"done"`
}

// Store - задачи в памяти.
//
// Мьютекс здесь не украшение: net/http обслуживает каждый запрос
// в отдельной горутине, поэтому два PUT могут прийти одновременно.
// Без синхронизации это гонка, и `go test -race` её показывает.
type Store struct {
	mu     sync.RWMutex
	tasks  map[int]Task
	nextID int
}

// NewStore создаёт пустое хранилище.
func NewStore() *Store {
	return &Store{
		tasks:  make(map[int]Task),
		nextID: 1,
	}
}

// List возвращает задачи, отсортированные по id.
//
// Порядок обхода map в Go случайный - специально, чтобы на него нельзя
// было положиться. Для API это означает, что список пришлось бы читать
// каждый раз в новом порядке; поэтому сортируем явно.
func (s *Store) List() []Task {
	s.mu.RLock()
	defer s.mu.RUnlock()

	out := make([]Task, 0, len(s.tasks))
	for id := 1; id < s.nextID; id++ {
		if task, ok := s.tasks[id]; ok {
			out = append(out, task)
		}
	}
	return out
}

// Get возвращает задачу по id.
func (s *Store) Get(id int) (Task, error) {
	s.mu.RLock()
	defer s.mu.RUnlock()

	task, ok := s.tasks[id]
	if !ok {
		return Task{}, fmt.Errorf("id %d: %w", id, ErrTaskNotFound)
	}
	return task, nil
}

// Create добавляет задачу.
func (s *Store) Create(title string) (Task, error) {
	if title == "" {
		return Task{}, errors.New("название задачи пустое")
	}

	s.mu.Lock()
	defer s.mu.Unlock()

	task := Task{ID: s.nextID, Title: title}
	s.tasks[task.ID] = task
	s.nextID++
	return task, nil
}

// SetDone меняет статус задачи.
func (s *Store) SetDone(id int, done bool) (Task, error) {
	s.mu.Lock()
	defer s.mu.Unlock()

	task, ok := s.tasks[id]
	if !ok {
		return Task{}, fmt.Errorf("id %d: %w", id, ErrTaskNotFound)
	}

	// Значение из map - копия. Меняем её и кладём обратно:
	// присвоить полю напрямую (s.tasks[id].Done = done) Go не позволит.
	task.Done = done
	s.tasks[id] = task
	return task, nil
}

// Delete удаляет задачу.
func (s *Store) Delete(id int) error {
	s.mu.Lock()
	defer s.mu.Unlock()

	if _, ok := s.tasks[id]; !ok {
		return fmt.Errorf("id %d: %w", id, ErrTaskNotFound)
	}
	delete(s.tasks, id)
	return nil
}

Отличие от вехи 1 всего одно, зато принципиальное.

Почему появился мьютекс

net/http запускает отдельную горутину на каждый запрос. Это не деталь реализации, а факт, с которым живёт любой обработчик: два POST /tasks, пришедшие одновременно, выполняются параллельно и оба лезут в одну и ту же map.

Запись в map из нескольких горутин без синхронизации - это не «иногда неправильный результат», а падение всей программы: рантайм Go специально это обнаруживает и завершает процесс с concurrent map writes.

sync.RWMutex вместо sync.Mutex выбран потому, что чтений тут больше, чем записей: RLock пускает сколько угодно читателей одновременно и блокирует только на время записи.

Проверить это можно тестом с флагом гонок:

go test -race ./api-memory/...

В проекте есть TestConcurrentCreatesDoNotRace: пятьдесят горутин одновременно создают задачи. Убери мьютекс - и тест либо покажет DATA RACE, либо потеряет часть задач из-за сбитого счётчика nextID.

Почему список сортируется явно

for id := 1; id < s.nextID; id++ {
	if task, ok := s.tasks[id]; ok {
		out = append(out, task)
	}
}

Порядок обхода map в Go случайный, и это сделано специально - чтобы никто не мог на него положиться. Для API это означало бы, что список приходит каждый раз в новом порядке, а тест «первая задача в ответе - та, что создали первой» падал бы через раз. Самый неприятный вид падения: воспроизводится не всегда.

Копия из map

task.Done = done
s.tasks[id] = task

Написать s.tasks[id].Done = done компилятор не позволит: значение, полученное из map, - копия, и присвоить её полю нельзя. Go здесь честно запрещает то, что выглядело бы работающим кодом.

Обработчики

package main

import (
	"encoding/json"
	"errors"
	"net/http"
	"strconv"
	"strings"
)

// Handler превращает HTTP-запросы в вызовы Store.
//
// Хранилище передаётся снаружи, а не создаётся внутри: тест подставит своё
// и не будет зависеть от глобального состояния (урок «Интерфейсы»
// и «Тестирование»).
type Handler struct {
	store *Store
}

// NewHandler создаёт обработчик поверх хранилища.
func NewHandler(store *Store) *Handler {
	return &Handler{store: store}
}

// Routes собирает маршруты.
//
// В Go 1.22 в стандартный ServeMux добавили метод и параметры пути:
// "GET /tasks/{id}" - это полноценный маршрут, стороннего роутера
// для такого API уже не нужно.
//
// embed:start routes
func (h *Handler) Routes() http.Handler {
	mux := http.NewServeMux()

	mux.HandleFunc("GET /tasks", h.list)
	mux.HandleFunc("POST /tasks", h.create)
	mux.HandleFunc("GET /tasks/{id}", h.get)
	mux.HandleFunc("PUT /tasks/{id}", h.update)
	mux.HandleFunc("DELETE /tasks/{id}", h.delete)

	return mux
}

// embed:end routes

func (h *Handler) list(w http.ResponseWriter, _ *http.Request) {
	writeJSON(w, http.StatusOK, h.store.List())
}

func (h *Handler) create(w http.ResponseWriter, r *http.Request) {
	var req struct {
		Title string `json:"title"`
	}
	if !decodeJSON(w, r, &req) {
		return
	}

	task, err := h.store.Create(strings.TrimSpace(req.Title))
	if err != nil {
		writeError(w, http.StatusBadRequest, err.Error())
		return
	}

	// 201 и Location - не формальность: клиент узнаёт адрес созданного
	// ресурса, не додумывая его из тела ответа.
	w.Header().Set("Location", "/tasks/"+strconv.Itoa(task.ID))
	writeJSON(w, http.StatusCreated, task)
}

func (h *Handler) get(w http.ResponseWriter, r *http.Request) {
	id, ok := pathID(w, r)
	if !ok {
		return
	}

	task, err := h.store.Get(id)
	if err != nil {
		writeStoreError(w, err)
		return
	}
	writeJSON(w, http.StatusOK, task)
}

func (h *Handler) update(w http.ResponseWriter, r *http.Request) {
	id, ok := pathID(w, r)
	if !ok {
		return
	}

	var req struct {
		Done bool `json:"done"`
	}
	if !decodeJSON(w, r, &req) {
		return
	}

	task, err := h.store.SetDone(id, req.Done)
	if err != nil {
		writeStoreError(w, err)
		return
	}
	writeJSON(w, http.StatusOK, task)
}

func (h *Handler) delete(w http.ResponseWriter, r *http.Request) {
	id, ok := pathID(w, r)
	if !ok {
		return
	}

	if err := h.store.Delete(id); err != nil {
		writeStoreError(w, err)
		return
	}
	// 204: удалили, отдавать нечего. Тело при этом статусе слать нельзя.
	w.WriteHeader(http.StatusNoContent)
}

// pathID достаёт {id} из пути и проверяет, что это положительное число.
func pathID(w http.ResponseWriter, r *http.Request) (int, bool) {
	raw := r.PathValue("id")
	id, err := strconv.Atoi(raw)
	if err != nil || id <= 0 {
		writeError(w, http.StatusBadRequest, "id должен быть положительным числом, получено "+strconv.Quote(raw))
		return 0, false
	}
	return id, true
}

// decodeJSON читает тело запроса. Возвращает false, если ответ уже отправлен.
func decodeJSON(w http.ResponseWriter, r *http.Request, dst any) bool {
	decoder := json.NewDecoder(r.Body)
	// Незнакомое поле - ошибка, а не тишина. Иначе опечатка в имени поля
	// («titel» вместо «title») выглядит как успешный запрос, создавший
	// задачу с пустым названием.
	decoder.DisallowUnknownFields()

	if err := decoder.Decode(dst); err != nil {
		writeError(w, http.StatusBadRequest, "тело запроса: "+err.Error())
		return false
	}
	return true
}

// writeStoreError переводит ошибку хранилища в код ответа.
//
// errors.Is, а не сравнение строк: ошибка обёрнута через %w, и текст
// у неё уже другой - «id 42: задача не найдена».
func writeStoreError(w http.ResponseWriter, err error) {
	if errors.Is(err, ErrTaskNotFound) {
		writeError(w, http.StatusNotFound, err.Error())
		return
	}
	writeError(w, http.StatusInternalServerError, "внутренняя ошибка")
}

func writeError(w http.ResponseWriter, status int, message string) {
	writeJSON(w, status, map[string]string{"error": message})
}

// writeJSON отправляет значение как JSON.
//
// Заголовок ставится до WriteHeader: после записи статуса менять заголовки
// уже поздно, они улетели клиенту.
func writeJSON(w http.ResponseWriter, status int, value any) {
	w.Header().Set("Content-Type", "application/json; charset=utf-8")
	w.WriteHeader(status)

	// Ошибку кодирования обрабатывать нечем: статус отправлен, соединение
	// в неопределённом состоянии. В настоящем сервисе это пишут в лог.
	_ = json.NewEncoder(w).Encode(value)
}

Роутер из стандартной библиотеки

func (h *Handler) Routes() http.Handler {
	mux := http.NewServeMux()

	mux.HandleFunc("GET /tasks", h.list)
	mux.HandleFunc("POST /tasks", h.create)
	mux.HandleFunc("GET /tasks/{id}", h.get)
	mux.HandleFunc("PUT /tasks/{id}", h.update)
	mux.HandleFunc("DELETE /tasks/{id}", h.delete)

	return mux
}

До Go 1.22 ServeMux умел только пути, поэтому метод проверяли руками внутри обработчика, а параметр из пути вырезали строковыми операциями. Из-за этого в старых уроках и статьях встречается код, где PATCH /tasks отвечает 404 вместо 405, а /tasks/abc роняет сервер.

Сейчас метод и параметр - часть шаблона: "GET /tasks/{id}". Значение достаётся через r.PathValue("id"), а на неподходящий метод ServeMux сам отвечает 405 Method Not Allowed. Сторонний роутер для такого API больше не нужен.

Хранилище приходит снаружи

type Handler struct {
	store *Store
}

func NewHandler(store *Store) *Handler {
	return &Handler{store: store}
}

Обработчик не создаёт хранилище внутри себя и не берёт его из глобальной переменной. Благодаря этому каждый тест поднимает свой чистый Store, и тесты не зависят от порядка запуска. Это тот же приём, что в уроке про интерфейсы, и к нему мы вернёмся в вехе 3, где хранилище станет базой данных.

Неизвестное поле - ошибка

decoder.DisallowUnknownFields()

Без этой строки запрос {"titel":"опечатка"} пройдёт успешно и создаст задачу с пустым названием: encoding/json молча игнорирует поля, которых нет в структуре. Клиент получит 201 Created и будет уверен, что всё хорошо. Одна строка превращает молчаливую потерю данных в честный 400.

Коды ответов - это интерфейс

СитуацияКодПочему
задача создана201 Created + Locationклиент узнаёт адрес созданного ресурса, а не додумывает его
задача обновлена200 OK + телоклиент видит новое состояние, не делая второй запрос
задача удалена204 No Contentудалять больше нечего, тело при этом статусе слать нельзя
нет такой задачи404 Not Found
id не число400 Bad Requestошибка клиента, повтор не поможет
чужой метод405 Method Not AllowedServeMux отвечает сам

Разница между 400 и 404 не педантизм: по ней клиент решает, повторять запрос или чинить код.

Ошибки различаются через errors.Is

if errors.Is(err, ErrTaskNotFound) {
	writeError(w, http.StatusNotFound, err.Error())
	return
}

Хранилище оборачивает ошибку: fmt.Errorf("id %d: %w", id, ErrTaskNotFound). Текст стал другим - «id 42: задача не найдена», - поэтому сравнивать строки бесполезно. errors.Is разворачивает цепочку и находит исходную ошибку. Сравнение по тексту здесь сломалось бы молча: все ошибки стали бы 500.

Запуск сервера

// Веха 2 Go-трека: HTTP API со списком задач в памяти.
//
// Тот же задачник, что в вехе 1, но управляется по HTTP. Данные живут
// в памяти процесса и исчезают при перезапуске - это осознанно: веха
// показывает HTTP и JSON, база появится в вехе 3.
//
//	go run ./api-memory
//	curl -s localhost:8080/tasks
//	curl -s -X POST localhost:8080/tasks -d '{"title":"Купить хлеб"}'
//	curl -s -X PUT localhost:8080/tasks/1 -d '{"done":true}'
//	curl -s -X DELETE localhost:8080/tasks/1 -i
package main

import (
	"context"
	"errors"
	"fmt"
	"net/http"
	"os"
	"os/signal"
	"syscall"
	"time"
)

const (
	defaultAddr     = ":8080"
	shutdownTimeout = 5 * time.Second
)

func main() {
	if err := run(); err != nil {
		fmt.Fprintln(os.Stderr, "ошибка:", err)
		os.Exit(1)
	}
}

func run() error {
	addr := os.Getenv("ADDR")
	if addr == "" {
		addr = defaultAddr
	}

	server := &http.Server{
		Addr:    addr,
		Handler: NewHandler(NewStore()).Routes(),
		// Таймауты обязательны. Без них клиент, открывший соединение
		// и замолчавший, занимает горутину и файловый дескриптор
		// до бесконечности - и так до исчерпания лимитов процесса.
		ReadHeaderTimeout: 5 * time.Second,
		ReadTimeout:       10 * time.Second,
		WriteTimeout:      10 * time.Second,
		IdleTimeout:       60 * time.Second,
	}

	// Ловим сигналы до запуска сервера: иначе есть окно, в котором
	// Ctrl+C убьёт процесс жёстко.
	ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
	defer stop()

	errCh := make(chan error, 1)
	go func() {
		fmt.Println("слушаю", addr)
		// ErrServerClosed возвращается при штатной остановке - это не сбой.
		if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
			errCh <- err
		}
		close(errCh)
	}()

	select {
	case err := <-errCh:
		return err
	case <-ctx.Done():
		fmt.Println("\nостанавливаюсь")
	}

	// Даём доработать текущим запросам, но не бесконечно: без таймаута
	// один зависший запрос удержит процесс навсегда.
	shutdownCtx, cancel := context.WithTimeout(context.Background(), shutdownTimeout)
	defer cancel()

	if err := server.Shutdown(shutdownCtx); err != nil {
		return fmt.Errorf("остановка сервера: %w", err)
	}
	return nil
}

Два момента, которых не было в уроке про HTTP.

Таймауты не опциональны

ReadHeaderTimeout: 5 * time.Second,
ReadTimeout:       10 * time.Second,
WriteTimeout:      10 * time.Second,
IdleTimeout:       60 * time.Second,

http.Server по умолчанию не ограничивает время запроса ничем. Клиент, который открыл соединение и замолчал, удерживает горутину и файловый дескриптор до бесконечности. Достаточно нескольких тысяч таких соединений, чтобы сервер перестал принимать новые - и заметить это по нагрузке на процессор невозможно, он будет near-нулевым.

Graceful shutdown

ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
...
server.Shutdown(shutdownCtx)

Без этого Ctrl+C или docker stop обрывают соединения на середине: клиент получает разорванный ответ, а начатая работа не заканчивается. Shutdown перестаёт принимать новые запросы и ждёт завершения текущих.

Ждёт, но не бесконечно - иначе один зависший запрос удержит процесс навсегда, и docker stop через десять секунд убьёт его жёстко, ровно как если бы никакого graceful shutdown не было. Отсюда таймаут в пять секунд.

ErrServerClosed при этом не ошибка: ListenAndServe всегда возвращает ошибку, и при штатной остановке она именно такая.

Проверяем

cd examples/go-milestones
go run ./api-memory

В другом терминале:

curl -s -X POST localhost:8080/tasks -d '{"title":"Купить хлеб"}' -i
curl -s localhost:8080/tasks
curl -s -X PUT localhost:8080/tasks/1 -d '{"done":true}'
curl -s -o /dev/null -w "%{http_code}\n" localhost:8080/tasks/99      # 404
curl -s -o /dev/null -w "%{http_code}\n" -X PATCH localhost:8080/tasks # 405
curl -s -X POST localhost:8080/tasks -d '{"titel":"опечатка"}'         # 400

Тесты, обязательно с флагом гонок:

go test -race ./api-memory/...

Тесты через httptest

Тесты поднимают настоящий сервер на случайном порту:

server := httptest.NewServer(NewHandler(NewStore()).Routes())
t.Cleanup(server.Close)

Это не мок и не эмуляция: запросы идут через реальный сетевой стек, с настоящими заголовками и кодами. При этом порт выбирается свободный, а сервер останавливается вместе с тестом - параллельный запуск не мешает сам себе.

Подробнее про подходы к тестированию - в уроке Тестирование, он идёт дальше по треку.

Что дальше

Задачник работает по сети, но при перезапуске всё исчезает. В вехе 3, сразу после урока про работу с базами данных, мы заменим map на PostgreSQL - и это окажется куда меньшим изменением, чем кажется.

Мини-практика

Добавь GET /tasks?done=true - фильтр по статусу. Параметр читается через r.URL.Query().Get("done"), разбирается strconv.ParseBool. Продумай, что должно происходить при ?done=привет: молча игнорировать параметр или отвечать 400? Второе честнее - клиент узнает, что его запрос поняли не так, как он рассчитывал.

Мини-задание

Практика выше про код. Это - про то, как он ведёт себя снаружи.

  • Подними своё API и пройди весь маршрут curl: создать, прочитать, обновить, удалить
  • Проверь коды ответов на неверный метод и несуществующий путь. 404 и 405 - разные вещи
  • Отправь битый JSON и убедись, что сервер отвечает 400, а не падает
  • Убей сервер по Ctrl+C во время запроса и посмотри, что увидит клиент. Потом добавь graceful shutdown и сравни
  • Прогони go vet ./... и gofmt -l . перед тем, как считать веху закрытой

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