Классический набор операций над сущностями, который содержит любая система — это CRUD — Create, Read, Update, Delete. Реализация трех из них (Создания, Чтения и Удаления) в ASP.Net Core не вызывает проблем и контроллер REST API (с HTTP методами POST GET и DELETE) для этих операций можно легко и просто создать из встроенного шаблона Visual Studio. Четвертую операцию — обновление — шаблон тоже создает автоматически. Но есть одна тонкость.
По умолчанию для обновления шаблон использует операцию HTTP PUT. Давайте посмотрим на контроллер целиком, и на операцию PUT в отдельности, чтобы разобрать, что же с ней не так.
// PUT: api/Users/5 [HttpPut("{id}")] public async Task<IActionResult> PutUser(int id, User user) { _context.Entry(user).State = EntityState.Modified; await _context.SaveChangesAsync(); return NoContent(); }
(автосгенерированный VisualStudio метод обновления сущности. Обработка ошибок убрана, как не относящаяся к сути вопроса)
К самому HTTP PUT нет никаких претензий, но и по определению и по коду видно, что PUT операция меняет объект целиком. Однако очень часто более полезной оказывается частичное изменение объекта (например, возможность изменить только имя у сущности User
). Для этого как раз предназначен HTTP метод PATCH, и сам запрос в этом случае выглядит примерно так:
PATCH https://localhost:5001/api/Users/1 { "name": "Artur", }
В этом и последующих примерах предположим, что сущность User
выглядит как-то так:
public class User { public int Id { get; set; } public string Name { get; set; } public int Age { get; set; } public User Mother { get; set; } public int? MotherId { get; set; } public User Father { get; set; } public int? FatherId { get; set; } }
В теории все хорошо, а как же это будет реализовываться на практике с ASP.Net Core? Сигнатура метода, на первый взгляд, измениться не должна — мы по прежнему принимаем id
пользователя и объект:
[HttpPatch("{id}")] public async Task<IActionResult> PatchUser(int id, PatchUserDto patchUserDto)
А как же будет выглядеть тело метода? Какие значения из пришедшей DTO надо записать в сущность User
‘a? Для не-nullable полей (например, int
) можно сделать поля в PatchDto — nullable и обновлять только те значения, которые не null (например, поле int Age
в DTO можно превратить в int? Age
). Но у нас есть и nullable поля — string Name
или int? ParentId.
Для них непонятно, как определить — было ли это поле передано в DTO или нет. Очень показательный пример запроса:
PATCH https://localhost:5001/api/Users/1 { "fatherId": null, }
fatherId передан в запросе, и значение User.FatherId
надо обнулить, а, допустим, MotherId
в запросе не передавался и, соответственно, обнулять его не надо. Однако в patchUserDto
значения этих двух полей будут идентичными — null
.
Как же решать эту типичную проблему? Как ни странно, публичных обсуждений в интернете не слишком много. Есть раз, два, три вопроса на stackoverflow (почитайте их, как минимум чтобы детальнее понимать, какую проблему мы решаем). Из предлагаемых решений:
- Вариация на тему nullable-полей: предлагается поля сделать тип
Settable<T>
, который имеет свойствоIsSet == true
, если поле присутствовало в http-запросе иfalse
, если поля в запросе не было (и, соответственно, его обнулять у нашей сущности не надо).
Выглядеть это будет как-то так:
public class SettablePatchUserDto { public string Name { get; set; } public Settable<int> MotherId { get; set; } public Settable<int> FatherId { get; set; } }
Решение неплохое, но есть и некоторые минусы:,
- неприменим для строк
- нетипичная работа с nullable значениями (к примеру, передать null в поле MotherId можно, но определять это нужно по MotherId.HasValue.
- внедрение нового незнакомого типа (
Settable<T>
).
2. Ответы на второй и третий вопросы в чем-то схожи. Они предлагают расширить PatchDto свойствомHashSet<string> PropertiesInHttpRequest
, которое будет содержать список свойст, которые были переданы в http-запросе. В этом случае можно будет легко и однозначно определить, нужно ли изменять свойство сущности в БД (в том числе в случае null значений).
Второй путь и мне показался более перспективным, и именно им мы и воспользовались. Любопытствующие могут сразу заглянуть в исходники на гитхаб, где выложен пример проекта с использованием PATCH метода.
Наиболее важными участками кода я поделюсь и здесь. Собственно, PATCH метод:
[HttpPatch("{id}")] public async Task<IActionResult> PatchUser(int id, PatchUserDto patchUserDto) { var user = await _context.Users.SingleAsync(x => x.Id == id); // could be as well automated with smth like Automapper if you'd like to user.Age = patchUserDto.IsFieldPresent(nameof(user.Age)) ? patchUserDto.Age : user.Age; user.Name = patchUserDto.IsFieldPresent(nameof(user.Name)) ? patchUserDto.Name : user.Name; user.FatherId = patchUserDto.IsFieldPresent(nameof(user.FatherId)) ? patchUserDto.FatherId : user.FatherId; user.MotherId = patchUserDto.IsFieldPresent(nameof(user.MotherId)) ? patchUserDto.MotherId : user.MotherId; await _context.SaveChangesAsync(); return NoContent(); }
Чтобы этого добиться, нужно кастомизировать ContractResolver в Startup’e таким образом:
services .AddControllers() .AddNewtonsoftJson(options => { options.SerializerSettings.ContractResolver = new PatchRequestContractResolver(); });
Этот способ показал себя как понятный, очевидный и минимально интрузивный.
Мы его внедрили и это успешно работает в наших проектах. Я так же поделился этим способом на stackoverflow, прошу любить и лайкать :)