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

ASP.NET Core 二重送信に強いAPIを作る② 受付結果をDBに保存する

問い合わせの受付APIを、ASP.NET CoreとPostgreSQLで実装します。

先にSELECTして「まだ存在しない」と確認するだけでは、同時に届いた2件が両方とも登録へ進む可能性があります。今回はDBの一意制約で、同じ操作の登録を1件に制限します。

  1. 操作の単位とキーを決める
  2. 受付結果をDBに保存する(この記事)
  3. 同じ操作をReactから再送する
  4. 同時実行と運用上の限界を確認する

受付と操作キーを同じ行に保存する

受付テーブル
CREATE TABLE inquiry_receipt (
    id uuid PRIMARY KEY,
    user_id uuid NOT NULL,
    operation_key uuid NOT NULL,
    subject text NOT NULL CHECK (char_length(subject) BETWEEN 1 AND 100),
    message text NOT NULL CHECK (char_length(message) BETWEEN 1 AND 2000),
    received_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
    UNIQUE (user_id, operation_key)
);

会員テーブルへの外部キーは、既存の会員管理の構成に合わせて追加します。

受付データとキーを同じ行へ置くため、1回のINSERTで両方が保存されます。「受付だけ作成され、キーの記録に失敗した」という中途半端な状態を避けられます。

複数テーブルを更新する処理へ広げる場合は、それらを同じトランザクションで確定する設計が必要です。

INSERTできなければ既存の受付を取得する

保存処理は.NETのDbDataSourceを受け取り、PostgreSQLへ接続して実行します。

ReceiptStore.cs
using System.Data.Common;

public sealed class ReceiptStore(DbDataSource dataSource)
{
    public async Task<Receipt> GetOrCreateAsync(
        Guid userId, Guid key, InquiryBody body, CancellationToken ct)
    {
        await using var connection = await dataSource.OpenConnectionAsync(ct);
        await using (var insert = connection.CreateCommand())
        {
            insert.CommandText = """
                INSERT INTO inquiry_receipt
                    (id, user_id, operation_key, subject, message)
                VALUES (@id, @user_id, @operation_key, @subject, @message)
                ON CONFLICT (user_id, operation_key) DO NOTHING
                RETURNING id, subject, message, received_at;
                """;
            insert.CommandTimeout = 10;
            Add(insert, "id", Guid.NewGuid());
            Add(insert, "user_id", userId);
            Add(insert, "operation_key", key);
            Add(insert, "subject", body.Subject);
            Add(insert, "message", body.Message);
            await using var reader = await insert.ExecuteReaderAsync(ct);
            if (await reader.ReadAsync(ct)) return Read(reader);
        }

        // INSERTとは別のSQL文で、先行する処理が保存した行を読み取る。
        await using var select = connection.CreateCommand();
        select.CommandText = """
            SELECT id, subject, message, received_at
            FROM inquiry_receipt
            WHERE user_id = @user_id AND operation_key = @operation_key;
            """;
        select.CommandTimeout = 10;
        Add(select, "user_id", userId);
        Add(select, "operation_key", key);
        await using var existing = await select.ExecuteReaderAsync(ct);
        if (!await existing.ReadAsync(ct))
            throw new InvalidOperationException("受付結果を取得できませんでした。");
        return Read(existing);
    }

    private static void Add(DbCommand command, string name, object value)
    {
        var parameter = command.CreateParameter();
        parameter.ParameterName = name;
        parameter.Value = value;
        command.Parameters.Add(parameter);
    }

    private static Receipt Read(DbDataReader reader) => new(
        reader.GetGuid(0), reader.GetString(1), reader.GetString(2),
        reader.GetFieldValue<DateTimeOffset>(3));
}

ON CONFLICT (user_id, operation_key) DO NOTHINGで、同じ組み合わせが既にある場合は追加しません。初回ならRETURNINGから作成した行を取得できます。PostgreSQLのINSERTの説明に、競合時の指定方法が記載されています。

対象とする一意制約を明示しているので、受付IDなど別の制約に違反したエラーまで「再送だった」と解釈しません。

received_atはDBが生成した値を読み取って返します。再送時にAPIサーバーの現在時刻を使うと、同じ操作なのに受付時刻が変わってしまいます。

INSERTとSELECTを別のSQL文にする理由

先行するINSERTがまだ確定していない場合、後続のINSERTは一意制約の判定で待機することがあります。先行処理がコミットすれば、後続は追加せず終了します。

その後、別のSELECTで保存済みの行を読み取ります。1つのSQL文の中にINSERTと検索を詰め込むと、同じスナップショットでは競合先の行が見えないケースがあるためです。

このコードは、明示的なトランザクションや外側のTransactionScopeを使わず、通常のRead Committedでコマンドを順に実行する前提です。Repeatable Readなどへ変える場合は、同じ読み取り結果になると考えないでください。PostgreSQLのRead Committedの説明も参照してください。

この流れは、受付行を途中で削除・変更しない契約があって成立します。SELECTで見つからなかった場合は、コードのようにエラーとして扱います。新しいキーを作って勝手に別件を追加する処理にはしません。

APIで認証と本文の一致を確認する

第1回のInquiryRulesと、保存処理を使ってコントローラーを作ります。

InquiriesController.cs
using System.Security.Claims;
using Microsoft.AspNetCore.Antiforgery;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;

[ApiController]
[Authorize]
[AutoValidateAntiforgeryToken]
[Route("api/inquiries")]
[ResponseCache(NoStore = true, Location = ResponseCacheLocation.None)]
public sealed class InquiriesController(ReceiptStore store) : ControllerBase
{
    [HttpGet("csrf")]
    public IActionResult Csrf([FromServices] IAntiforgery antiforgery) =>
        Ok(new { token = antiforgery.GetAndStoreTokens(HttpContext).RequestToken });

    [HttpPost]
    public async Task<IActionResult> Create(
        InquiryInput input, CancellationToken ct)
    {
        if (!Guid.TryParse(User.FindFirstValue(ClaimTypes.NameIdentifier), out var userId))
            return Unauthorized();
        var values = Request.Headers["Idempotency-Key"];
        if (values.Count != 1 || !Guid.TryParseExact(values[0], "D", out var key) || key == Guid.Empty)
            return Problem(statusCode: 400, title: "操作キーが不正です。");
        var body = InquiryRules.Normalize(input);
        if (!InquiryRules.IsValid(body))
            return Problem(statusCode: 400, title: "件名は1〜100文字、本文は1〜2000文字で入力してください。");

        var receipt = await store.GetOrCreateAsync(userId, key, body, ct);
        if (!InquiryRules.Matches(receipt, body))
            return Problem(statusCode: 409, title: "同じ操作キーで異なる内容は送信できません。");

        return StatusCode(201, new ReceiptResult(receipt.Id, receipt.ReceivedAt));
    }
}

利用者IDはGUID形式のNameIdentifierを前提にしています。既存アプリの会員ID形式が異なる場合は変更してください。

キーが同じでも内容が異なれば、Matchesで拒否します。先に保存された本文を上書きすることはありません。同時に異なる本文が届いた場合も、先に保存できた側の内容が基準になります。

DB接続エラーやタイムアウトは、アプリ共通の例外処理で記録します。すべてのDBエラーを一律に「重複」として扱う処理は追加しません。

接続と認証設定を組み込む

PostgreSQLのドライバーにはNpgsqlを使います。対象の.NETに対応するバージョンを選んでプロジェクトへ追加してください。保存処理はNpgsqlのデータソースをDbDataSourceとして受け取れます。

Program.csへ統合する登録
using System.Data.Common;
using Npgsql;

builder.Services.AddSingleton<DbDataSource>(_ =>
    NpgsqlDataSource.Create(
        builder.Configuration.GetConnectionString("App")
        ?? throw new InvalidOperationException("DB接続設定がありません。")));
builder.Services.AddScoped<ReceiptStore>();
builder.Services.AddControllersWithViews();
builder.Services.AddAntiforgery(options =>
{
    options.HeaderName = "X-CSRF-TOKEN";
    options.Cookie.SecurePolicy = CookieSecurePolicy.Always;
});

接続文字列は環境変数や開発用シークレットなどで管理します。データソースを共有し、必要なときに接続を取得する方式はNpgsqlの基本的な使い方を参照してください。

AddControllersWithViewsは、AutoValidateAntiforgeryTokenのフィルターを登録するために使用します。既存のCookie認証、認可、UseAuthentication、UseAuthorization、MapControllersも必要です。

APIが未認証ならログインHTMLへリダイレクトせず401を返す設定にします。画面とAPIはHTTPSの同じOriginで公開し、開発中は/apiをバックエンドへプロキシしてください。

再送でも認証を省略しない

保存済みの結果を返すだけでも、現在の認証と閲覧範囲の確認は必要です。キーを知っていることは、利用者本人である証明にはなりません。

この例では、すべてのPOSTに認証とCSRF検証を適用し、DBの検索にも認証済み利用者IDを含めています。組織や権限による制限がある機能へ流用する場合は、その認可も再送のたびに実施してください。

次は同じ操作をReactから再送するで、キーと本文を画面に保持します。


関連記事