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 — через код и наглядные разборы.