zukucode
主にWEB関連の情報を技術メモとして発信しています。

ASP.NET Core ETagとIf-Matchで同時更新を防ぐ

ASP.NET Coreの更新APIで、ETagIf-Matchを使用して同時更新を防ぐ方法を紹介します。

同じデータを2人が表示し、それぞれ編集して保存すると、あとから保存した内容で先に保存した内容が上書きされる場合があります。

更新前のデータが変更されていないことを確認してから保存するために、ETagを使用します。

ETagをレスポンスヘッダーに設定する

データを取得するときに、現在のバージョンを表す値も返します。

更新対象の戻り値
public sealed record ArticleResult(
    Guid ArticleId,
    string Title,
    string Body,
    Guid ETag
);

ETagはレスポンス本文ではなく、HTTPのETagヘッダーにも設定します。

ETagFilter.cs
public sealed class ETagFilter : IActionFilter
{
    public void OnActionExecuting(ActionExecutingContext context)
    {
    }

    public void OnActionExecuted(ActionExecutedContext context)
    {
        if (context.Result is OkObjectResult result &&
            result.Value is IHasETag data)
        {
            context.HttpContext.Response.Headers.ETag =
                $"\"{data.ETag}\"";
        }
    }
}

HTTPのETagは引用符で囲んで返します。

レスポンスヘッダー
ETag: "81ca5d83-27af-4824-abd6-2a7fabc3bd71"

更新時にIf-Matchを送信する

フロントエンドは、取得時に受け取ったETagを保存しておき、更新リクエストのIf-Matchヘッダーへ設定します。

更新リクエスト
await fetch(`/api/articles/${articleId}`, {
  method: 'PUT',
  headers: {
    'Content-Type': 'application/json',
    'If-Match': etag,
  },
  body: JSON.stringify(data),
});

サーバーではIf-Matchの値を更新処理へ渡します。

ArticleController.cs
[HttpPut("{articleId:guid}")]
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
public async Task<IActionResult> Update(
    Guid articleId,
    [FromBody] ArticleUpdateDto data,
    [FromHeader(Name = "If-Match")] string ifMatch,
    CancellationToken cancellationToken
)
{
    var eTag = Guid.Parse(ifMatch.Trim('"'));

    await articleService.UpdateAsync(
        articleId,
        eTag,
        data,
        cancellationToken
    );

    return Ok();
}

実際にはModelBinderを作成して、引用符の除去、形式の検証、Guidへの変換を共通化すると便利です。

UPDATE文でETagを比較する

更新前にデータを取得して比較するのではなく、UPDATE文の条件にETagを含めます。

ETagを使用した更新
UPDATE articles
SET
    title = @Title,
    body = @Body,
    etag = gen_random_uuid(),
    updated_at = @UpdatedAt
WHERE
    article_id = @ArticleId
    AND etag = @ETag;

更新件数が0件の場合は、取得後にほかの処理によって更新された可能性があります。

更新件数の確認
var affectedRows = await database.ExecuteAsync(
    sql,
    parameters,
    cancellationToken
);

if (affectedRows == 0)
{
    throw new PreconditionFailedException();
}

例外は共通のエラーハンドリングで412 Precondition Failedへ変換します。

409 Conflictではなく412を返すことで、If-Matchで指定した前提条件が成立しなかったことを表せます。

更新に成功したらETagを変更する

更新後も同じETagを使用すると、古い画面から再度更新できてしまいます。

更新に成功するたびに新しいETagを生成します。

更新結果に新しい値を含めるか、更新後にデータを再取得して、フロントエンドが保持するETagを置き換えます。

すべての更新にETagが必要とは限らない

複数項目をまとめて編集する画面では、古いデータによる上書きを防ぐ必要があるため、If-Matchを必須にします。

一方、以下のような操作では別の方法が適しています。

  • 表示名だけを絶対値で変更し、last-write-winsを許容する
  • データを新しく追加する
  • 承認、取消などの状態を遷移させる
  • 同じ要求を複数回実行しても結果を変えない

状態遷移では、現在の状態をUPDATE文の条件に含める方法や、悲観的ロック、一意制約、冪等性キーなどを使用します。

操作の種類に関係なくすべてへETagを要求すると、APIを利用する側の処理が複雑になります。

注意点

フロントエンドで更新ボタンを無効にしても、別のブラウザやAPIから更新される可能性があります。

ETagの比較は必ずデータベースの更新条件に含めます。

取得、比較、更新を別々に実行すると、その間に別の更新が入る可能性があるため注意が必要です。


関連記事