JSON HTTP API: Task API с валидацией, единообразными ошибками и потокобезопасным хранилищем
Содержание курса
GET /tasks и POST /tasks: декодирование, валидация title, последовательный ID
GET — самый простой случай. В sec_01 мы объявили mu sync.Mutex, поэтому здесь используем обычный Lock/Unlock — у sync.Mutex нет методов RLock/RUnlock, они доступны только у sync.RWMutex:
func (s *Store) listTasks(w http.ResponseWriter, r *http.Request) {
s.mu.Lock()
tasks := make([]Task, len(s.tasks))
copy(tasks, s.tasks)
s.mu.Unlock()
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(tasks)
}
Почему копируем под мьютексом? После Unlock другая горутина может модифицировать s.tasks. Если отдать ссылку на внутренний срез, json.Encode будет читать данные без блокировки — гонка. Копия под Lock — безопасный вариант.
Отдельный момент: json.Encode для пустого []Task{} вернёт [], а не null. Именно поэтому инициализируем срез через make — даже если задач нет, клиент получит массив, а не JSON null.
Вариант с RWMutex. Если заменить поле на
mu sync.RWMutex, GET сможет использоватьs.mu.RLock()/s.mu.RUnlock(), позволяя нескольким горутинам читать одновременно. POST/PATCH/DELETE по-прежнему вызываютs.mu.Lock(). Для нашего API трафик чтения вряд ли критически превысит запись, поэтому базовыйsync.Mutexдостаточен —RWMutexэто осознанная оптимизация.
POST декодирует тело и проверяет title:
func (s *Store) createTask(w http.ResponseWriter, r *http.Request) {
var dto struct {
Title string `json:"title"`
}
if err := json.NewDecoder(r.Body).Decode(&dto); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON")
return
}
if strings.TrimSpace(dto.Title) == "" {
writeError(w, http.StatusBadRequest, "title is required")
return
}
s.mu.Lock()
task := Task{
ID: s.nextID,
Title: strings.TrimSpace(dto.Title),
Done: false,
}
s.nextID++
s.tasks = append(s.tasks, task)
s.mu.Unlock()
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(task)
}
Несколько деталей, которые здесь работают вместе:
json.NewDecoder(r.Body).Decode(&dto)вернёт ошибку, если тело не является валидным JSON. Пустое тело или{bad json}— сразу 400.strings.TrimSpaceубирает пробелы с обоих концов. Строка из одних пробелов не считается корректным названием задачи.- Блокировка берётся только перед изменением состояния — декодирование и валидация происходят до
Lock(). Это короткий критический участок. s.nextID++выполняется под мьютексом, поэтому два одновременных POST получат разные ID.- Ответ 201 с полным
task— клиент сразу видит, какой ID был назначен.
DELETE в обработчике /tasks/{id} устроен тривиально — находим задачу по ID, удаляем из среза, возвращаем 204 без тела:
case http.MethodDelete:
s.mu.Lock()
defer s.mu.Unlock()
for i, t := range s.tasks {
if t.ID == id {
s.tasks = append(s.tasks[:i], s.tasks[i+1:]...)
w.WriteHeader(http.StatusNoContent)
return
}
}
writeError(w, http.StatusNotFound, "task not found")
204 не предполагает тела — поэтому просто w.WriteHeader(http.StatusNoContent) и выход.
