2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

一次のリクエスト、二度の対話?ASP.NETでユーザーデータ補完をエレガントに扱う

Posted at

最近の開発で、私は以下のような要件に直面しました。1回のHTTPリクエスト内でビジネスロジック上、ユーザーデータが完全であるかどうかを判定する必要があります。もしデータが不完全であれば、その論理処理の流れを一時停止し、ユーザーに必要な情報の補完を促します。そしてユーザーがデータを補完した後、システムは残りの処理を続行する必要があります。

この要件は、従来のASP.NETアプリケーションではあまり見かけません。なぜなら、HTTPリクエストは一般的にステートレスであり、一度に処理が完了するため、途中で「一時停止して再開する」ようなフローの実現が容易ではないからです。ここからは、私がASP.NETでどのようにこの機能を実現したかを紹介します。


バックエンドコード

using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.Caching.Distributed;
using System.Collections.Concurrent;

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddDistributedMemoryCache();
builder.Services.AddSingleton<PendingRequestManager>();
builder.Services.AddControllers();
builder.Services.AddSignalR();
builder.Services.AddCors(options =>
{
    options.AddDefaultPolicy(policy =>
    {
        policy.AllowAnyOrigin();
    });
});
var app = builder.Build();
app.UseCors();
app.MapControllers();
app.MapHub<NotifyHub>("/notifyHub");
app.UseStaticFiles();
app.MapPost("consume", async (string userId, PendingRequestManager manager, IDistributedCache cache, IHubContext<NotifyHub> hubContext, CancellationToken cancellationToken) =>
{
    var tcs = manager.Create(userId);
    await hubContext.Clients.Group(userId).SendAsync("NeedSupplement", "サーバーの返答:データを補充してください!");
    // リクエストを保留し、補完を待機(最大300秒)
    var finished = await Task.WhenAny(tcs.Task, Task.Delay(TimeSpan.FromSeconds(300)));
    var message = await cache.GetStringAsync(userId, cancellationToken);
    Console.WriteLine(message);
    if (finished != tcs.Task)
    {
        manager.Remove(userId);
        return Results.BadRequest("サーバーの返答:タイムアウトしました");
    }
    return Results.Ok("サーバーの返答:処理成功!");
});
app.MapPost("/supplement", async (string userId, DataMessage dataMessage, IDistributedCache cache, PendingRequestManager manager, CancellationToken cancellationToken) =>
{
    await cache.SetStringAsync(userId, dataMessage.Data, new DistributedCacheEntryOptions { SlidingExpiration = TimeSpan.FromSeconds(2) }, cancellationToken);
    // ここで実際にデータの検証や補完処理を行う必要があります
    var tcs = manager.Get(userId);
    if (tcs != null)
    {
        tcs.SetResult(true);
        manager.Remove(userId);
    }
    return Results.Ok("サーバーの返答:補完送信成功");
});
app.Run();

public class PendingRequestManager
{
    private ConcurrentDictionary<string, TaskCompletionSource<bool>> _pendingRequests = new();

    public TaskCompletionSource<bool> Create(string userId)
    {
        var tcs = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
        _pendingRequests[userId] = tcs;
        return tcs;
    }

    public TaskCompletionSource<bool>? Get(string userId)
    {
        _pendingRequests.TryGetValue(userId, out var tcs);
        return tcs;
    }

    public void Remove(string userId)
    {
        _pendingRequests.TryRemove(userId, out _);
    }
}

public class NotifyHub : Hub
{
    public override Task OnConnectedAsync()
    {
        var userId = Context.GetHttpContext()?.Request.Query["userId"];
        if (!string.IsNullOrEmpty(userId))
        {
            Groups.AddToGroupAsync(Context.ConnectionId, userId);
        }
        return base.OnConnectedAsync();
    }
}

public class DataMessage
{
    public string Data { get; set; }
}

フロントエンドコード

<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8">
    <title>SignalRデータ補完デモ</title>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/microsoft-signalr/7.0.5/signalr.min.js"></script>
</head>
<body>
    <button id="consume">データ処理開始</button>
    <br/>
    <textarea id="data" style="display:none;" rows="4" cols="50">私はブラウザ上で補ったデータData</textarea>
    <br/>
    <button id="supplement" style="display:none;">データ補完開始</button>
    <div id="msg"></div>

    <script>
        const userId = "user1";
        let connection = new signalR.HubConnectionBuilder()
            .withUrl("/notifyHub?userId=" + userId)
            .build();

        connection.on("NeedSupplement", function (message) {
            document.getElementById('msg').innerText = message;
            document.getElementById('supplement').style.display = "";
            document.getElementById('data').style.display = "";
        });

        connection.start().then(function () {
            console.log("SignalR Connected.");
        }).catch(function (err) {
            return console.error(err.toString());
        });

        document.getElementById('consume').onclick = function() {
            fetch('/consume?userId=' + userId, {
                method: 'POST'
            })
            .then(resp => resp.json())
            .then(data => {
                document.getElementById('msg').innerText = JSON.stringify(data);
            });
        };

        document.getElementById('supplement').onclick = function() {
            fetch('/supplement?userId=' + userId, {
                method: 'POST',
                headers: {
                    'Content-Type': 'application/json'
                },
                body: JSON.stringify({
                    data: document.getElementById('data').value,
                })
            })
            .then(resp => resp.json())
            .then(data => {
                document.getElementById('msg').innerText = JSON.stringify(data);
                document.getElementById('supplement').style.display = "none";
                document.getElementById('data').style.display = "none";
            });
        };
    </script>
</body>
</html>

TaskCompletionSourceとは?

  • TaskCompletionSource<T> は、.NETが提供する、Taskの完了を手動で制御できるオブジェクトです。
  • これを利用することで、ある場所でawaitによる非同期待機を行いながら、他所(別メソッドやスレッドでも可)でSetResult/SetException/SetCanceledを呼び出してTaskの完了タイミングを決定できます。
  • 外部イベントや特定条件の発生を非同期で待ち受けるシナリオに最適です。

元のリンク:https://mp.weixin.qq.com/s/-YVGbFnkCqwzCijxZRQKAA?token=1840160119&lang=zh_CN&wt.mc_id=MVP_325642

2
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?