ASP.NET Core ETagとIf-Matchで同時更新を防ぐ
ASP.NET Coreの更新APIで、ETagとIf-Matchを使用して同時更新を防ぐ方法を紹介します。
同じデータを2人が表示し、それぞれ編集して保存すると、あとから保存した内容で先に保存した内容が上書きされる場合があります。
更新前のデータが変更されていないことを確認してから保存するために、ETagを使用します。
ETagをレスポンスヘッダーに設定する
データを取得するときに、現在のバージョンを表す値も返します。
更新対象の戻り値public sealed record ArticleResult(
Guid ArticleId,
string Title,
string Body,
Guid ETag
);ETagはレスポンス本文ではなく、HTTPのETagヘッダーにも設定します。
ETagFilter.cspublic 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の比較は必ずデータベースの更新条件に含めます。
取得、比較、更新を別々に実行すると、その間に別の更新が入る可能性があるため注意が必要です。