🌐 Раздел 6 · Вопрос #4

В чём разница между PUT и PATCH

Оба метода — PUT и PATCH — используются в REST API для обновления существующих ресурсов на сервере, но имеют принципиально разную семантику и правила обработки данных.


🟢 Junior Level

Оба метода — PUT и PATCH — используются в REST API для обновления существующих ресурсов на сервере, но имеют принципиально разную семантику и правила обработки данных.

Главные различия

  • PUT (Полная замена ресурса):
    • Клиент передает полное представление ресурса.
    • Все поля, которые не были переданы в теле запроса PUT, сервер обязан удалить или сбросить в null (значения по умолчанию).
    • Метод является строго идемпотентным (повторный запуск запроса с тем же телом не меняет состояние базы данных).
  • PATCH (Частичное обновление ресурса):
    • Клиент передает только те поля, которые требуется изменить.
    • Все остальные поля ресурса остаются без изменений.
    • Экономит сетевой трафик при работе с большими объектами.
    • По спецификации не гарантирует идемпотентность (зависит от логики операции).

Наглядное сравнение запросов

Исходное состояние пользователя в базе данных:

{
  "id": 42,
  "name": "Alex",
  "age": 25,
  "email": "alex@example.com"
}

Запрос методом PUT (Полная замена):

PUT /api/v1/users/42 HTTP/1.1
Content-Type: application/json

{
  "name": "Alex",
  "age": 26
}

Результат в БД: поле email стерто в null, так как клиент не прислал его в запросе полной замены!

Запрос методом PATCH (Частичное обновление):

PATCH /api/v1/users/42 HTTP/1.1
Content-Type: application/json

{
  "age": 26
}

Результат в БД: обновлено только поле age, поле email сохранило значение "alex@example.com".


🟡 Middle Level

Сравнительная таблица спецификаций (RFC 9110 vs RFC 5789)

Критерий PUT (RFC 9110) PATCH (RFC 5789)
Семантика действия Замещение целевого ресурса предоставленным состоянием Применение набора изменений к целевому ресурсу
Идемпотентность ✅ Строго гарантируется спецификацией ❌ Не гарантируется (зависит от типа патча)
Безопасность (Safe) ❌ Нет (модифицирует данные) ❌ Нет (модифицирует данные)
Стандартный Content-Type application/json, application/xml application/merge-patch+json или application/json-patch+json
Аналогия в SQL UPDATE users SET name=?, age=?, email=? UPDATE users SET age=?

Две спецификации стандарта PATCH

В индустрии существует два стандарта реализации метода PATCH:

1. JSON Merge Patch (RFC 7386) — Декларативный подход

Клиент отправляет частичный JSON-документ с измененными полями (Content-Type: application/merge-patch+json). Сервер просто накладывает пришедшие ключи поверх существующего объекта. Недостаток: сложно отличить «поле не передано (оставить как есть)» от «поле передано со значением null (стереть значение в БД)».

2. JSON Patch (RFC 6902) — Императивный подход

Клиент отправляет массив атомарных инструкций (Content-Type: application/json-patch+json):

[
  { "op": "replace", "path": "/age", "value": 26 },
  { "op": "remove", "path": "/temporaryToken" },
  { "op": "add", "path": "/tags/-", "value": "premium" }
]

Поддерживает операции: add, remove, replace, move, copy, test.


🔴 Senior Level

Реализация PUT и PATCH в Spring Boot 3

package com.example.controller;

import com.example.dto.ReplaceUserRequest;
import com.example.dto.UserResponseDto;
import com.example.service.UserService;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/api/v1/users")
public class UserController {

    private final UserService userService;
    private final ObjectMapper objectMapper;

    public UserController(UserService userService, ObjectMapper objectMapper) {
        this.userService = userService;
        this.objectMapper = objectMapper;
    }

    // 1. PUT: Полная замена (все поля валидируются, отсутствующие сбрасываются)
    @PutMapping("/{id}")
    public ResponseEntity<UserResponseDto> replaceUser(@PathVariable Long id, 
                                                      @RequestBody ReplaceUserRequest request) {
        return ResponseEntity.ok(userService.replaceUser(id, request));
    }

    // 2. PATCH: Частичное обновление через JSON Merge Patch
    @PatchMapping(path = "/{id}", consumes = "application/merge-patch+json")
    public ResponseEntity<UserResponseDto> patchUser(@PathVariable Long id, 
                                                    @RequestBody JsonNode patchNode) {
        return ResponseEntity.ok(userService.patchUser(id, patchNode));
    }
}
package com.example.service;

import com.example.dto.ReplaceUserRequest;
import com.example.dto.UserResponseDto;
import com.example.entity.User;
import com.example.repository.UserRepository;
import com.fasterxml.jackson.databind.JsonNode;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class UserService {

    private final UserRepository userRepository;

    public UserService(UserRepository userRepository) {
        this.userRepository = userRepository;
    }

    @Transactional
    public UserResponseDto replaceUser(Long id, ReplaceUserRequest req) {
        User user = userRepository.findById(id).orElseThrow();
        // PUT перезаписывает ВСЕ поля, включая null для непереданных
        user.setName(req.name());
        user.setAge(req.age());
        user.setEmail(req.email()); // Если req.email() == null, поле затирается!
        return UserResponseDto.fromEntity(user);
    }

    @Transactional
    public UserResponseDto patchUser(Long id, JsonNode patchNode) {
        User user = userRepository.findById(id).orElseThrow();
        
        // Анализируем только реально пришедшие в JSON узлы:
        if (patchNode.has("name")) {
            user.setName(patchNode.get("name").asText());
        }
        if (patchNode.has("age")) {
            user.setAge(patchNode.get("age").asInt());
        }
        if (patchNode.has("email")) {
            // Если в JSON пришел явный "email": null — затираем, иначе не трогаем
            user.setEmail(patchNode.get("email").isNull() ? null : patchNode.get("email").asText());
        }
        return UserResponseDto.fromEntity(user);
    }
}

Проблема “Three-State Null Problem” в Java DTO

При реализации PATCH библиотека Jackson по умолчанию преобразует и отсутствующее поле, и поле со значением null в обычный null в Java объекте.

Для строгого разделения трех состояний используют библиотеку JsonNullable:

public class PatchUserDto {
    // 1. Поле отсутствует в JSON -> jsonNullable.isUndefined() == true
    // 2. В JSON пришел "email": null -> jsonNullable.isPresent() && jsonNullable.get() == null
    // 3. В JSON пришел "email": "a@b.c" -> jsonNullable.isPresent() && jsonNullable.get() == "a@b.c"
    private JsonNullable<String> email = JsonNullable.undefined();
}

Почему PATCH может быть НЕИДЕМПОТЕНТНЫМ

Хотя PUT всегда идемпотентен, метод PATCH может нарушать идемпотентность в зависимости от выполняемой операции:

  1. Идемпотентный PATCH:
    { "op": "replace", "path": "/status", "value": "ACTIVE" }
    

    Сколько бы раз ни повторялся запрос, статус останется ACTIVE.

  2. Неидемпотентный PATCH:
    { "op": "add", "path": "/tags/-", "value": "vip" }
    

    Каждый повторный вызов будет добавлять новый элемент "vip" в конец массива tags (["vip", "vip", "vip"]).

  3. Операция инкремента:
    { "op": "increment", "path": "/balance", "value": 100 }
    

    Каждый повторный вызов увеличивает баланс еще на 100 единиц.


🎯 Шпаргалка для интервью

30-секундный ответ

  • PUT (RFC 9110) предназначен для полной замены целевого ресурса. Клиент обязан передать все поля сущности; опущенные поля сервер обязан занулить или сбросить. Метод всегда идемпотентен.
  • PATCH (RFC 5789) предназначен для частичного обновления. Передаются только изменяемые поля (экономия сетевого трафика). Метод не гарантирует идемпотентность (например, добавление элемента в массив или операция инкремента).
  • Реализуется по двум стандартам: JSON Merge Patch (RFC 7386) (декларативное слияние) и JSON Patch (RFC 6902) (массив атомарных команд add, remove, replace).

4 каверзных вопроса с ответами

  1. Что обязан сделать сервер по спецификации HTTP, если клиент в запросе PUT не прислал одно из необязательных полей? Ответ: Сервер обязан перезаписать существующий ресурс переданным представлением целиком. Неприсланные поля должны быть сброшены в null или значения по умолчанию. Если требуется обновить только часть полей, не затрагивая остальные, клиент обязан использовать PATCH.

  2. Приведите пример неидемпотентного запроса PATCH. Ответ: Инструкция добавления элемента в список [{"op": "add", "path": "/items/-", "value": "item1"}] или команда увеличения счетчика [{"op": "increment", "value": 5}]. При каждом повторе такого запроса состояние массива или счетчика в базе данных будет изменяться.

  3. В чём разница между стандартами RFC 7386 и RFC 6902 для метода PATCH? Ответ: RFC 7386 (JSON Merge Patch) — декларативный подход: клиент шлет обычный частичный JSON, и сервер объединяет его с оригиналом. RFC 6902 (JSON Patch) — императивный подход: клиент передает массив точных операций (replace, add, remove, move, copy, test) с путями JSON Pointer.

  4. Как в Java решить проблему различия «поле не передано» и «поле явно передано как null» при обработке PATCH? Ответ: С помощью обертки JsonNullable<T> (библиотека jackson-databind-nullable) либо путем парсинга тела запроса в древовидную структуру Jackson JsonNode с проверкой методов patchNode.has("field") и node.isNull().

Красные флаги (чего категорически нельзя говорить)

  • ❌ «PUT и PATCH — это абсолютно одно и то же, просто разные названия» — фундаментальная ошибка понимания семантики HTTP.
  • ❌ «PATCH всегда идемпотентен, так же как и PUT» — PATCH не гарантирует идемпотентность по RFC 5789.
  • ❌ «PUT обновляет только те поля, которые прислали, а остальные оставляет нетронутыми» — это описание PATCH, а не PUT.
  • ❌ «Для PATCH не существует официальных стандартов, каждый пишет как хочет» — существуют официальные спецификации RFC 7386 и RFC 6902.

Связанные темы