Борьба с дубликатами: делаем POST идемпотентным

Моя цель - предложение широкого ассортимента товаров и услуг на постоянно высоком качестве обслуживания по самым выгодным ценам.

Проблема

Представим, что у вас клиент-серверное приложение, REST API на бекенде и какая-нибудь форма на фронте. И вот наступает момент, когда в баг-трекере появляется запись про "создалось N дубликатов <вставьте ваш ресурс>". Начинаете выяснять, оказывается что на клиенте кнопка сохранения формы не меняла статус на disabled в процессе сохранения, а у клиента ускоренный метаболизм, и он успел много раз по ней нажать, пока интерфейс не сообщил заветное "ок".

Решение

Чтобы было проще, под операцией будем понимать оформления заказа, под объектом - сам заказ. Таким образом, у клиента не должно получиться насоздавать дубликатов заказа.

Для решения нужно понять, что есть "дубликат". Когда клиент оформляет заказ сегодня и затем такой же завтра - это разные заказы или дубликаты? А если состав заказа в обоих случаях одинаковый? А с интервалом не в день, а в 2 секунды?

Если бы у сервера был надёжный механизм, который поможет отличить уникальный запрос от дубликата, это позволило бы корректно обработать повторяющиеся запросы. Для этого можно было бы применять разного рода критерии для определения уникальности, но на деле работает принцип "не можешь сам - требуй от других" IoC, следуя ему мы делегируем это решение клиенту.

Форма на клиенте могла бы генерировать некоторый ключ на этапе заполнения и затем использовать его в запросе сохранения заказа. Сколько бы раз пользователь не нажал на кнопку, каждый запрос сохранения сопровождался бы этим ключом, запросы с одинаковым ключом сервер бы идентифицировал как дубли.

Может показаться, что это лишь подмена одной проблемы другой: вместо обязательства блокировки кнопки клиент появляется обязательство помечать запросы ключом. Да, но это другое ​ Например, ключ помимо прочего обеспечивает воспроизводимость результата в условиях обрыва соединения. Если пользователь нажал на кнопку оформления заказа, въезжая в тоннель, он не узнает о том, что его заказ был сохранён и наверное увидит в интерфейсе что-то вроде "Нет сети". При должной обработке на клиенте он сможет нажать на кнопку ещё раз, как только вернётся в онлайн, и получит ответ о созданном заказе без создания дубликата.

Немного теории

Выше было описано доступно, но теорию тоже неплохо знать. Если вернуться к ней, типовое назначение методов REST API на HTTP выглядит так (как используете вы, можете писать в комментариях):

  1. POST - создать

  2. GET - получить

  3. PUT - изменить

  4. DELETE - удалить

Методы можно разделить по критерию идемпотентности (см. https://ru.wikipedia.org/wiki/Идемпотентность). GET, PUT и DELETE являются идемпотентными, т. е. повторное их использование всегда даст одинаковый результат (в условиях отсутствия внешних факторов, конечно же), POST - нет. Далее мы попробуем это исправить.

Реализация

Нам понадобится:

  1. net core mvc

  2. MediatR

  3. EntityFramework

Почему такой набор? Потому что удобно строить pipeline обработки. А вообще выбрано на вкус автора, можете пофантазировать на свой.

Сетап

Есть контроллер, который умеет создавать заказ и возвращать его по 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);
}

Проверяем:

  1. 2 запроса без Idempotency-Key обрабатываются как разные (всё внимание на id в ответе). Первый:

    Второй:

  2. 2 запроса с разным Idempotency-Key - тоже (обратите внимание на header Idempotency-Key). Первый:

    Второй:

  3. 2 запроса с одинаковым Idempotency-Key возвращают один и тот же объект. Здесь для наглядности показан лог консоли Postman, чтобы было видно, что это два разных запроса. Обратите внимание, что id в ответах не отличаются. Первый:

    Второй:

Подводим итоги

Добавили поддержку идемпотентности для запроса создания ресурса, сохранив обратную совместимость и сделав это без модификации логики приложения. Подключение происходит прозрачно для вызывающего кода. Добавление поддержки для отдельно взятой операции довольно дёшево, требует только константы commandType и пары методов сериализации и десериализации результата выполнения, что ещё и неплохо масштабируется.

Что интересного ещё стоит отметить:

  1. Границы уникальности. Нужно учитывать, что клиент может не обеспечивать глобальной уникальности idempotencyKey. В этом примере я требуют уникальности в контексте отдельной операции, запрошенной пользователем. Здесь проверяется связка userId + commandType + idempotencyKey, таким образом запрос оформления заказа и запрос добавления в избранное будут иметь разные commandType, а запросы от разных пользователей будут помечены разными userId, благодаря чему они не пересекутся.

  2. Абстрагируем способ получения idempotencyKey. Не редко в приложениях помимо внешних запросов встречаются консюмеры Kafka, таски Hangfire и др. механизмы, для которых способ передачи IdempotencyKey может отличаться.

  3. Абстрагируем способ получения userId. Причины аналогичны предыдущему пункту.

  4. Контракт IIdempotencyCommand требует всего 2 вещи:

    1. определить commandType, который будет использоваться для изоляции;

    2. определить способ сериализации и десериализации результата. Опять же, спасибо IoC, проблемы обратной совместимости делегируем конкретным командам.

Код примера

Коллекция postman

Мой первый пост на Хабре, буду оч признателен за обратную связь. В общем, like, share, repost.

Источник: https://habr.com/ru/post/568562/


Интересные статьи

Интересные статьи

Маркетплейс – это сервис от 1С-Битрикс, который позволяет разработчикам делиться своими решениями с широкой аудиторией, состоящей из клиентов и других разработчиков.
Рынок e-commerce в России растет быстро. По данным Яндекс.Кассы, с 2015 по 2020 год малый и средний бизнес вырос в объеме продаж на 148%, в 2 раза увеличилось количество платежей и стал выше сред...
Приветствую всех, кого интересует тема электронных ключей-вездеходов. Сам я, по правде сказать, давно не слежу за новостями в этой области. Но свою разработку трёхлетней давности хочу опубликоват...
Приятно видеть знакомые фамилии в списке Acknowledgments официального релиза PostgreSQL 12. Мы решили свести вместе попавшие в релиз новшества и некоторые багфиксы, над которыми трудились наши ра...
Эта статья для тех, кто собирается открыть интернет-магазин, но еще рассматривает варианты и думает по какому пути пойти, заказать разработку магазина в студии, у фрилансера или выбрать облачный серви...