PATCH в Go стирает поле: различаем отсутствие, null и новое значение
В профиле пользователя есть необязательное имя для выставления счетов. Клиент отправляет PATCH с другим полем, а имя неожиданно исчезает. Обработчик разобрал запрос в структуру с указателем и решил, что nil означает команду очистить значение. Но nil получился и для отсутствующего поля, и для явно переданного null.
У одного поля три действия
Для частичного обновления удобно заранее записать контракт: отсутствие ключа сохраняет прежнее значение, null очищает его, строка заменяет. Пустая строка тоже должна получить определённый смысл: допустимое имя, ошибка или отдельная команда. Это решение API, а не особенность выбранного декодера. В примере пустая строка запрещена, чтобы очищение было выражено однозначно.
Обычный *string после Unmarshal не различает отсутствие и null. Двойной указатель тоже легко даёт неожиданный результат без специальной логики. Для небольшого PATCH можно сначала получить map[string]json.RawMessage. Наличие ключа проверяется отдельно, а RawMessage сохраняет его JSON-значение. Затем конкретное поле разбирается в нужный тип. Так у обработчика остаются и факт присутствия, и содержимое.
Изменяйте состояние только после проверки
В программе каждый запрос применяется к одному исходному имени. Отсутствующее поле сохраняет его, null очищает, строка задаёт новое. Число отвергается, и старое значение остаётся прежним. Отдельно запрещены неизвестные поля: опечатка billng_name не должна выглядеть как успешное пустое обновление.
package main
import (
"bytes"
"encoding/json"
"errors"
"fmt"
)
func apply(current *string, body []byte) (*string, error) {
var fields map[string]json.RawMessage
if err := json.Unmarshal(body, &fields); err != nil {
return current, err
}
if fields == nil {
return current, errors.New("object required")
}
for name := range fields {
if name != "billing_name" {
return current, errors.New("unknown field")
}
}
raw, present := fields["billing_name"]
if !present {
return current, nil
}
if bytes.Equal(bytes.TrimSpace(raw), []byte("null")) {
return nil, nil
}
var value string
if err := json.Unmarshal(raw, &value); err != nil {
return current, err
}
if value == "" {
return current, errors.New("empty name")
}
return &value, nil
}
func show(v *string) string {
if v == nil {
return "null"
}
return *v
}
func main() {
old := "old"
for _, body := range []string{`{}`, `{"billing_name":null}`, `{"billing_name":"new"}`, `{"billing_name":17}`} {
value, err := apply(&old, []byte(body))
fmt.Println(show(value), err == nil)
}
}
В выводе остаются old, null и new для первых трёх случаев, а четвёртый показывает old с ошибкой. Функция сначала вычисляет новое значение и только затем присваивает его. В PATCH с несколькими полями проверять нужно весь запрос до изменения постоянного состояния: ошибка последнего поля не должна оставлять первые поля уже обновлёнными.
Перенесите намерение до базы данных
Слой хранения должен получить различимые команды. Если передать ему только *string, информация о пропущенном поле снова потеряется. Можно сформировать отдельную структуру с Present и Value, список разрешённых присваиваний или доменный метод изменения профиля. В SQL используйте параметры для значений, а имена изменяемых столбцов выбирайте из фиксированного набора приложения.
Три состояния особенно важны для nullable-столбцов. Значение NULL в базе соответствует явной очистке. Отсутствующее свойство запроса означает отсутствие UPDATE для этого столбца. Автоматическое сопоставление DTO целой строке часто ломает именно эту границу. Проверьте, какие SQL-поля реально меняются на пустом объекте, прежде чем считать тест одного HTTP-статуса достаточным.
JSON Merge Patch задаёт собственную семантику null как удаления члена объекта. Если API заявляет поддержку application/merge-patch+json, нужно соблюдать весь соответствующий контракт, включая вложенные объекты. Пример ниже — конкретный частичный контракт одного поля, а не реализация универсального Merge Patch. Это различие меняет поведение вложенных документов и должно быть отражено в описании API.
Пропущенные поля не решают конкуренцию. Два клиента могут прочитать одну версию профиля и затем изменить одинаковое поле. Для защиты от потерянного обновления понадобится проверка версии или согласованный условный запрос. При этом частичное обновление уже полезно: изменение телефона не затрагивает имя, если клиент его не отправлял.
У encoding/json есть ещё особенности входа: повторяющиеся ключи могут перезаписывать предыдущие значения. Если контракт запрещает такие дубли, добавьте проверку токенов. Для HTTP-тела также пригодится проверка единственного объекта и конца потока. Разделяйте полноту входа и смысл отдельных полей: обе проверки выполняются до записи.
Опишите результат для клиента
После успешного PATCH клиенту удобно вернуть актуальное представление ресурса либо согласованный статус без тела. Не собирайте ответ механически из присланных полей: в базе могли примениться нормализация и связанные правила. Контрактный тест должен сравнить сохранённое состояние и ответ для всех трёх команд. Для пустого объекта заранее решите, считается ли он допустимой операцией без изменений. Если такая операция обновляет updated_at или порождает событие, это тоже наблюдаемый результат. Во многих системах полезнее сохранять время и не публиковать событие, когда ни одно значение действительно не изменилось.
Продолжите до взаимодействия сервисов
Три команды PATCH нужно довести до транзакции и согласованного ответа клиенту. В платном курсе микросервисов на Go вы будете проектировать контракты, находить гонки при работе с PostgreSQL и разбирать повтор запроса после таймаута. Это следующий шаг от корректного JSON к предсказуемому изменению данных. Откройте программу курса «Микросервисы на Go: HTTP, PostgreSQL, gRPC, Kafka и Docker».
Продолжить на практике
- Микросервисы на Go: HTTP, PostgreSQL, gRPC, Kafka и Docker ↗
От работающего Go API — к взаимодействию сервисов, которому можно доверять. Разберитесь, как проектировать контракты, сохранять согласованность данных и восстанавливать обработку после сбоев. HTTP, PostgreSQL, gRPC, Kafka и Docker — через код и наглядные разборы.
