Классический набор операций над сущностями, который содержит любая система — это 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, прошу любить и лайкать :)