Проблема
Представим, что у вас клиент-серверное приложение, REST API на бекенде и какая-нибудь форма на фронте. И вот наступает момент, когда в баг-трекере появляется запись про "создалось N дубликатов <вставьте ваш ресурс>". Начинаете выяснять, оказывается что на клиенте кнопка сохранения формы не меняла статус на disabled в процессе сохранения, а у клиента ускоренный метаболизм, и он успел много раз по ней нажать, пока интерфейс не сообщил заветное "ок".Решение
Чтобы было проще, под операцией будем понимать оформления заказа, под объектом - сам заказ. Таким образом, у клиента не должно получиться насоздавать дубликатов заказа.Для решения нужно понять, что есть "дубликат". Когда клиент оформляет заказ сегодня и затем такой же завтра - это разные заказы или дубликаты? А если состав заказа в обоих случаях одинаковый? А с интервалом не в день, а в 2 секунды?
Если бы у сервера был надёжный механизм, который поможет отличить уникальный запрос от дубликата, это позволило бы корректно обработать повторяющиеся запросы. Для этого можно было бы применять разного рода критерии для определения уникальности, но на деле работает принцип
Форма на клиенте могла бы генерировать некоторый ключ на этапе заполнения и затем использовать его в запросе сохранения заказа. Сколько бы раз пользователь не нажал на кнопку, каждый запрос сохранения сопровождался бы этим ключом, запросы с одинаковым ключом сервер бы идентифицировал как дубли.
Может показаться, что это лишь подмена одной проблемы другой: вместо обязательства блокировки кнопки клиент появляется обязательство помечать запросы ключом. Да, но это другое Например, ключ помимо прочего обеспечивает воспроизводимость результата в условиях обрыва соединения. Если пользователь нажал на кнопку оформления заказа, въезжая в тоннель, он не узнает о том, что его заказ был сохранён и наверное увидит в интерфейсе что-то вроде "Нет сети". При должной обработке на клиенте он сможет нажать на кнопку ещё раз, как только вернётся в онлайн, и получит ответ о созданном заказе без создания дубликата.
Немного теории
Выше было описано доступно, но теорию тоже неплохо знать. Если вернуться к ней, типовое назначение методов REST API на HTTP выглядит так (как используете вы, можете писать в комментариях):- POST - создать
- GET - получить
- PUT - изменить
- DELETE - удалить
Реализация
Нам понадобится:- net core mvc
- MediatR
- EntityFramework
Сетап
Есть контроллер, который умеет создавать заказ и возвращать его по id:[HttpGet("{id:guid}")]
public async Task<IActionResult> Get([FromRoute] Guid id,
CancellationToken ct)
{
var order = await _mediator.Send(new OrderRequest {Id = id}, ct);
if (order == null)
return BadRequest();
return Ok(new ApiOrder
{
Id = order.Id,
Description = order.Description
});
}
[HttpPost]
public async Task<IActionResult> Create(
[FromBody] CreateOrderApiRequest apiRequest, CancellationToken ct)
{
var command = new CreateOrderRequest
{
Description = apiRequest.Description
};
var id = await _mediator.Send(command, ct);
return await Get(id, ct);
}
Модели создания и чтения заказа:
public class CreateOrderRequest : IRequest<Guid>
{
public string Description { get; set; } // просто поле для примера
}
public class OrderRequest : IQuery<IOrder>
{
public Guid Id { get; set; }
}
Имплементация:
public class OrderRequestHandler
: IRequestHandler<CreateOrderRequest, Guid>,
IRequestHandler<OrderRequest, IOrder>
{
private readonly AppDbContext _dbContext;
public OrderRequestHandler(AppDbContext dbContext)
{
_dbContext = dbContext;
}
public async Task<Guid> Handle(CreateOrderRequest request,
CancellationToken cancellationToken)
{
var dbOrder = new DbOrder
{
Id = Guid.NewGuid(),
Description = request.Description
};
await _dbContext.Orders.AddAsync(dbOrder, cancellationToken);
return dbOrder.Id;
}
public async Task<IOrder> Handle(OrderRequest request,
CancellationToken cancellationToken)
{
var order = await _dbContext.Orders
.FirstOrDefaultAsync(x =>
x.Id.Equals(request.Id), cancellationToken);
return order;
}
}
Как выглядит API:
Справка. Немного про MediatR и PipelineBehavior (если в курсе, можете смело пропускать)
MediatR - это реализация одноимённого паттерна, который служит для снижения связанности кода. В нашем случае это единая входная точка, позволяющая взаимодействовать с приложением в формате "запрос-ответ".Помимо прочего, по аналогии с OWIN pipeline здесь есть возможность встроить дополнительное поведение в обработку запроса. Делается это с помощью PipelineBehavior. Обработчики образуют цепочку. Что-то вроде комбинации chain of responsibility + decorator/proxy (разница тут очень тонкая, можете объяснить в комментариях). Запрос передаётся первому обработчику. Затем каждый обработчик в цепочке, имея ссылку на следующий, может как передать обработку дальше по цепочке, так и не делать этого, при этом он имеет контроль над запросом и результатом. Псевдокод:
handle(request): result
{
if(someCondition)
{
// обработчик может передать вызов следующему или же не делать этого,
// при этом он может трансформировать как запрос,
// так и полученный результат
var res = _next.handle(transform(request));
return transform(res);
}
return anythingElse;
}
Делаем POST идемпотентным
Ранее было решено, что клиент будет передавать некий ключ уникальности запроса. Таким образом, серверу по этому ключу нужно определить, обрабатывался ли этот запрос ранее. Если нет (новый запрос), обрабатываем и сохраняем результат обработки, да (повторный запрос) - загружаем результат и возвращаем его без обработки.PipelineBehavior - это очень удобное место, чтобы сделать это прозрачно и подключаемо (контроллер и имплементация сервиса не будут затронуты вовсе).
Долой текста, вот код:
// код PipelineBehavior
public async Task<TResult> Handle(TRequest request, CancellationToken cancellationToken,
RequestHandlerDelegate<TResult> next)
{
// BTW: Невозможно просто добавить
// 'where TRequest: IIdempotentCommand<TResult> к описанию типа,
// т. к. резолв PipelineBehavior не учитывает constraint'ов.
// Это приведёт к исключению в рантайме,
// поэтому приходится ставить фильтр по типу запроса и игнорировать неподходящие.
if (!(request is IIdempotentCommand<TResult> command)) // расскажу далее
{
return await next();
}
// _idempotencyKeyProvider позволяет абстрагировать способ получения ключа
// в нашем случае это будет HTTP header, покажу далее
var idempotencyKey = await _idempotencyKeyProvider.Get();
if (idempotencyKey == null)
{
return await next();
}
// с помощью _idempotencyRecordProvider получаем результат прошлого выполнения запроса
var idempotencyRecord =
await _idempotencyRecordProvider.Get(
command.CommandTypeId, idempotencyKey, cancellationToken);
if (idempotencyRecord != null)
{
// если он был - загружаем его и не обрабатываем запрос повторно
var prevResult = command.DeserializeResult(
idempotencyRecord.Result);
return prevResult;
}
// если его не было - обрабатываем и сохраняем результат
var result = await next();
var resultSerialized = command.SerializeResult(result);
await _idempotencyRecordProvider.Save(
command.CommandTypeId, idempotencyKey, resultSerialized,
cancellationToken);
return result;
}
public class IdempotencyRecordManager : IIdempotencyRecordManager
{
private readonly DbContext _dbContext;
private readonly IUserIdProvider _userIdProvider;
public IdempotencyRecordManager(DbContext dbContext,
IUserIdProvider userIdProvider)
{
_dbContext = dbContext;
_userIdProvider = userIdProvider;
}
public async Task<IIdempotencyRecord> Get(string scope,
string idempotencyKey, CancellationToken cancel)
{
var userId = await GetCurrentUserId();
var record = await _dbContext.Set<DbIdempotencyRecord>()
.AsNoTracking()
.FirstOrDefaultAsync(x =>
x.UserId.Equals(userId) &&
x.Scope.Equals(scope) &&
x.IdempotencyKey.Equals(idempotencyKey), cancel);
return record;
}
public async Task Save(string scope, string idempotencyKey,
string result, CancellationToken cancel)
{
var userId = await GetCurrentUserId();
var record = new DbIdempotencyRecord
{
UserId = userId,
Scope = scope,
IdempotencyKey = idempotencyKey,
Result = result,
TimestampUtc = DateTime.UtcNow
};
await _dbContext.Set<DbIdempotencyRecord>()
.AddAsync(record, cancel);
}
private async Task<string> GetCurrentUserId()
{
var userId = await _userIdProvider.GetCurrentUserId();
return userId;
}
}
Используется это затем так:
// добавляем необходимые модели в dbContext:
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
...
modelBuilder.ApplyConfigurationsFromAssembly(
typeof(DbIdempotencyRecord).Assembly);
}
// ------------------------------------------
// регистрируем зависимости в Startup:
services.AddScoped(
typeof(IPipelineBehavior<,>), typeof(IdempotencyBehavior<,>));
services.AddTransient
<IIdempotencyRecordManager, IdempotencyRecordManager>();
// HttpContextIdempotencyKeyProvider парсит значение idempotencyKey из хедера запроса
services.AddTransient
<IIdempotencyKeyProvider, HttpContextIdempotencyKeyProvider>();
// эта часть обеспечивает чтение userId из HttpContext
services.AddHttpContextAccessor();
services.AddScoped<IUserIdProvider, HttpContextUserIdProvider>();
// ------------------------------------------
// добавляем команде поддержку idempotencyKey:
public class CreateOrderRequest
: IIdempotentCommand<Guid>, IRequest<Guid>
{
public string CommandTypeId => "createOrder";
public string Description { get; set; }
public string SerializeResult(Guid input)
{
return input.ToString();
}
public Guid DeserializeResult(string input)
{
return Guid.Parse(input);
}
}
Код контроллера при этом остаётся без изменений:
[HttpPost]
public async Task<IActionResult> Create(
[FromBody] CreateOrderApiRequest apiRequest, CancellationToken ct)
{
var command = new CreateOrderRequest
{
Description = apiRequest.Description
};
var id = await _mediator.Send(command, ct);
// а внутри уже используется новый pipeline behavior
return await Get(id, ct);
}
Проверяем:
- 2 запроса без Idempotency-Key обрабатываются как разные (всё внимание на id в ответе). Первый:
Второй:
- 2 запроса с разным Idempotency-Key - тоже (обратите внимание на header Idempotency-Key). Первый:
Второй:
- 2 запроса с одинаковым Idempotency-Key возвращают один и тот же объект. Здесь для наглядности показан лог консоли Postman, чтобы было видно, что это два разных запроса. Обратите внимание, что id в ответах не отличаются. Первый:
Второй:
Подводим итоги
Добавили поддержку идемпотентности для запроса создания ресурса, сохранив обратную совместимость и сделав это без модификации логики приложения. Подключение происходит прозрачно для вызывающего кода. Добавление поддержки для отдельно взятой операции довольно дёшево, требует только константы commandType и пары методов сериализации и десериализации результата выполнения, что ещё и неплохо масштабируется.Что интересного ещё стоит отметить:
- Границы уникальности. Нужно учитывать, что клиент может не обеспечивать глобальной уникальности idempotencyKey. В этом примере я требуют уникальности в контексте отдельной операции, запрошенной пользователем. Здесь проверяется связка userId + commandType + idempotencyKey, таким образом запрос оформления заказа и запрос добавления в избранное будут иметь разные commandType, а запросы от разных пользователей будут помечены разными userId, благодаря чему они не пересекутся.
- Абстрагируем способ получения idempotencyKey. Не редко в приложениях помимо внешних запросов встречаются консюмеры Kafka, таски Hangfire и др. механизмы, для которых способ передачи IdempotencyKey может отличаться.
- Абстрагируем способ получения userId. Причины аналогичны предыдущему пункту.
- Контракт IIdempotencyCommand требует всего 2 вещи:
- определить commandType, который будет использоваться для изоляции;
- определить способ сериализации и десериализации результата. Опять же, спасибо IoC, проблемы обратной совместимости делегируем конкретным командам.
Коллекция postman
Источник статьи: https://habr.com/ru/post/568562/