Веха 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 следующим шагом. Держать один новый слой за раз проще, чем два сразу.
Хранилище: то же, но с мьютексом
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 Allowed | ServeMux отвечает сам |
Разница между 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 .перед тем, как считать веху закрытой