async decoratorパターンによるUnityWebRequestの拡張とUniTaskによる応用的設計例

UniTask v2も2.0.30まで到達し、いい加減そろそろ安定したと言える頃合いです(ほんと!)。GitHub Star数も1000を超えて、準スタンダードとして安心して使ってもらえるレベルまで到達したと思うので、基盤部分から入れ込んで設計するとこんなことができますよ、という一例を出してみます。

UnityWebRequestはかなりプリミティブな代物で、そのまま使うよりかはある程度はアプリケーションに沿ったラッパーを被せることがほとんどなのではないかと思います。しかし、ライブラリ単体でアプリケーションの要求を全て満たそうとするとヘヴィになりすぎたり、というかそもそもアプリケーション固有の要求には絶対に答えられない。というわけで、理想的なラッパーというのは、それ自身が極力軽量で、拡張性を持たせたプラガブルな仕組みが用意されているものということになります。プラガブルな拡張性がないと、例えば基盤ライブラリ側で用意されたラッパーをアプリケーションで使う場合にうまく要件をあわせられなくて、Forkして直接改造しちゃう、という不毛な自体になったりします。

と、いうものを実現するにあたって、非同期リクエストにつきもののコールバックは非常に相性が悪い。コールバックの連鎖は、コード上でその場でネストしていくだけだったら数階層ネストしてもまぁまぁなんとかなりますが、プラガブルで複雑な組み合わせを実現しようとするとハンドリング不可能になります。

そこでasync/await。async/awaitならコンパイラの力に頼ることでそういうものができます!

async decoratorパターンという名前で紹介しますが、一般にはMiddlewareとして知られているものを実装します。ASP.NET Core、node.js(Express)やReactのMiddleware、PythonのWSGI、MagicOnionではFilterとして実装している、サーバーサイドではよく使われるデザインです。これは非常に強力なデザインパターンで、クライアント処理においても有用だと私は考えています。もし知らなければ絶対に覚えるべき……!

MagicOnionのフィルターの図を持ってくるとこんな感じで

メソッドが外から内側に包まれて呼ばれていきます。

await next(
    await next(
        await next()
    )
);

通常やりたいことってざっくり

  • ロギング
  • モック
  • タイムアウト処理
  • リクエスト前のヘッダー処理
  • リクエスト後のヘッダー処理
  • ステータスコードに応じた例外時処理
  • エラー時の処理(ポップアップ/リトライ/画面遷移)

といったことだと思われますが、この仕組みなら、これだけで全て実装できます……!

というわけで、実装例を見ていきましょう。

デコレーター例

まずは共通のインターフェイスとして以下のものを用意します。

public interface IAsyncDecorator
{
    UniTask<ResponseContext> SendAsync(RequestContext context, CancellationToken cancellationToken, Func<RequestContext, CancellationToken, UniTask<ResponseContext>> next);
}

なるほどわからん。RequestContext、ResponseContextがそれぞれリクエスト/レスポンスに必要なデータが詰まっている単純な入れ物ということで特に気にしないこととして、大事なのはFunc nextです。

とりあえず、単純な例としてヘッダーの前後で処理するなにかを。

public class SetupHeaderDecorator : IAsyncDecorator
{
    public async UniTask<ResponseContext> SendAsync(RequestContext context, CancellationToken cancellationToken, Func<RequestContext, CancellationToken, UniTask<ResponseContext>> next)
    {
        context.RequestHeaders["x-app-timestamp"] = context.Timestamp.ToString();
        context.RequestHeaders["x-user-id"] = "132141411"; // どこかから持ってくる
        context.RequestHeaders["x-access-token"] = "fafafawfafewaea"; // どこかから持ってくる2

        var respsonse = await next(context, cancellationToken); // 次のメソッドが呼ばれる

        var nextToken = respsonse.ResponseHeaders["token"];
        UserProfile.Token = nextToken; // どこかにセットするということにする

        return respsonse;
    }
}

await next() によって連鎖しているデコレーターメソッドの内側に進んでいきます。つまり、その前に書けば前処理、後ろに書けば後処理になります。nextの定義がよくわからなくても、デコレーターを量産していくことは簡単です。そこが大事。そんなんでいいんです。

さて、async/awaitと統合されていることによって、try-catch-finallyも自然に書けます。例えばロギングを用意すると

public class LoggingDecorator : IAsyncDecorator
{
    public async UniTask<ResponseContext> SendAsync(RequestContext context, CancellationToken cancellationToken, Func<RequestContext, CancellationToken, UniTask<ResponseContext>> next)
    {
        var sw = Stopwatch.StartNew();
        try
        {
            UnityEngine.Debug.Log("Start Network Request:" + context.Path);

            var response = await next(context, cancellationToken);

            UnityEngine.Debug.Log($"Complete Network Request: {context.Path} , Elapsed: {sw.Elapsed}, Size: {response.GetRawData().Length}");

            return response;
        }
        catch (Exception ex)
        {
            if (ex is OperationCanceledException)
            {
                UnityEngine.Debug.Log("Request Canceled:" + context.Path);
            }
            else if (ex is TimeoutException)
            {
                UnityEngine.Debug.Log("Request Timeout:" + context.Path);
            }
            else if (ex is UnityWebRequestException webex)
            {
                if (webex.IsHttpError)
                {
                    UnityEngine.Debug.Log($"Request HttpError: {context.Path} Code:{webex.ResponseCode} Message:{webex.Message}");
                }
                else if (webex.IsNetworkError)
                {
                    UnityEngine.Debug.Log($"Request NetworkError: {context.Path} Code:{webex.ResponseCode} Message:{webex.Message}");
                }
            }
            throw;
        }
        finally
        {
            /* log other */
        }
    }
}

また、処理を打ち切ることも簡単に実現できます。nextを呼ばないだけですから。例えばダミーのレスポンスを返す(テストに使ったり、サーバー側の実装が整わない間に進めたりするために)デコレーターが作れます。

public class MockDecorator : IAsyncDecorator
{
    Dictionary<string, object> mock;

    // Pathと型を1:1にして事前定義したオブジェクトを返す辞書を渡す
    public MockDecorator(Dictionary<string, object> mock)
    {
        this.mock = mock;
    }

    public UniTask<ResponseContext> SendAsync(RequestContext context, CancellationToken cancellationToken, Func<RequestContext, CancellationToken, UniTask<ResponseContext>> next)
    {
        // それと if (EditorProfile.EnableMocking) とか用意しておいて、モック使うかの有無をエディタ拡張辺りで切り替えれるようにしとくと楽
        if (mock.TryGetValue(context.Path, out var value))
        {
            // 一致したものがあればそれを返す(実際の通信は行わない)
            return new UniTask<ResponseContext>(new ResponseContext(value));
        }
        else
        {
            return next(context, cancellationToken);
        }
    }
}

リトライ的な処理も考えてみましょう。例えば特殊なレスポンスコードを受信したときは、Tokenを取ってから再度処理し直してくれ、みたいな要求があるとします。

public class AppendTokenDecorator : IAsyncDecorator
{
    public async UniTask<ResponseContext> SendAsync(RequestContext context, CancellationToken cancellationToken, Func<RequestContext, CancellationToken, UniTask<ResponseContext>> next)
    {
        string token = "token"; // どっかから取ってくるということにする
        RETRY:
        try
        {
            context.RequestHeaders["x-accesss-token"] = token;
            return await next(context, cancellationToken);
        }
        catch (UnityWebRequestException ex)
        {
            // 例えば700はTokenを再取得してください的な意味だったとする
            if (ex.ResponseCode == 700)
            {
                // 別口でTokenを取得します的な処理
                var newToken = await new NetworkClient(context.BasePath, context.Timeout).PostAsync<string>("/Auth/GetToken", "access_token", cancellationToken);
                context.Reset(this); // RequestContextの状態が汚れてる(?)ので、nextを最初からやり直す場合はResetする
                token = newToken;
                goto RETRY;
            }

            throw;
        }
    }
}

シーケンシャルな処理を強制するために、キューを挟む場合はこのように書けます。私は並列リクエストできるなら極力並列にしたい派なので、あまりこういうのを挟むのは好きではないのですけれど、サーバー側の要求によっては必要な場合もあると思います。

public class QueueRequestDecorator : IAsyncDecorator
{
    readonly Queue<(UniTaskCompletionSource<ResponseContext>, RequestContext, CancellationToken, Func<RequestContext, CancellationToken, UniTask<ResponseContext>>)> q = new Queue<(UniTaskCompletionSource<ResponseContext>, RequestContext, CancellationToken, Func<RequestContext, CancellationToken, UniTask<ResponseContext>>)>();
    bool running;

    public async UniTask<ResponseContext> SendAsync(RequestContext context, CancellationToken cancellationToken, Func<RequestContext, CancellationToken, UniTask<ResponseContext>> next)
    {
        if (q.Count == 0)
        {
            return await next(context, cancellationToken);
        }
        else
        {
            var completionSource = new UniTaskCompletionSource<ResponseContext>();
            q.Enqueue((completionSource, context, cancellationToken, next));
            if (!running)
            {
                Run().Forget();
            }
            return await completionSource.Task;
        }
    }

    async UniTaskVoid Run()
    {
        running = true;
        try
        {
            while (q.Count != 0)
            {
                var (tcs, context, cancellationToken, next) = q.Dequeue();
                try
                {
                    var response = await next(context, cancellationToken);
                    tcs.TrySetResult(response);
                }
                catch (Exception ex)
                {
                    tcs.TrySetException(ex);
                }
            }
        }
        finally
        {
            running = false;
        }
    }
}

簡単なものから結構複雑そうなものまで、そこそこ単純に書けることがわかったと思います!ただのawait nextという仕組みを用意するだけで!

用意したデコレーターはこんな風に使います。

// デコレーターの詰まったClientを生成(これは一度作ったらフィールドに保存可)
var client = new NetworkClient("http://localhost", TimeSpan.FromSeconds(10),
    new QueueRequestDecorator(),
    new LoggingDecorator(),
    new AppendTokenDecorator(),
    new SetupHeaderDecorator());

// 例えばこんな風に呼ぶということにする
var result = await client.PostAsync("/User/Register", new { Id = 100 });

async decoratorを実装する

ちょっと長くなりますが、そんな複雑なわけではありません。

// 基本のインターフェイス
public interface IAsyncDecorator
{
    UniTask<ResponseContext> SendAsync(RequestContext context, CancellationToken cancellationToken, Func<RequestContext, CancellationToken, UniTask<ResponseContext>> next);
}

// リクエスト用の入れ物
public class RequestContext
{
    int decoratorIndex;
    readonly IAsyncDecorator[] decorators;
    Dictionary<string, string> headers;

    public string BasePath { get; }
    public string Path { get; }
    public object Value { get; }
    public TimeSpan Timeout { get; }
    public DateTimeOffset Timestamp { get; private set; }

    public IDictionary<string, string> RequestHeaders
    {
        get
        {
            if (headers == null)
            {
                headers = new Dictionary<string, string>();
            }
            return headers;
        }
    }

    public RequestContext(string basePath, string path, object value, TimeSpan timeout, IAsyncDecorator[] filters)
    {
        this.decoratorIndex = -1;
        this.decorators = filters;
        this.BasePath = basePath;
        this.Path = path;
        this.Value = value;
        this.Timeout = timeout;
        this.Timestamp = DateTimeOffset.UtcNow;
    }

    internal Dictionary<string, string> GetRawHeaders() => headers;
    internal IAsyncDecorator GetNextDecorator() => decorators[++decoratorIndex];

    public void Reset(IAsyncDecorator currentFilter)
    {
        decoratorIndex = Array.IndexOf(decorators, currentFilter);
        if (headers != null)
        {
            headers.Clear();
        }
        Timestamp = DateTimeOffset.UtcNow;
    }
}

// レスポンス用の入れ物
public class ResponseContext
{
    readonly byte[] bytes;

    public long StatusCode { get; }
    public Dictionary<string, string> ResponseHeaders { get; }

    public ResponseContext(byte[] bytes, long statusCode, Dictionary<string, string> responseHeaders)
    {
        this.bytes = bytes;
        StatusCode = statusCode;
        ResponseHeaders = responseHeaders;
    }

    public byte[] GetRawData() => bytes;

    public T GetResponseAs<T>()
    {
        return JsonUtility.FromJson<T>(Encoding.UTF8.GetString(bytes));
    }
}

// 本体
public class NetworkClient : IAsyncDecorator
{
    readonly Func<RequestContext, CancellationToken, UniTask<ResponseContext>> next;
    readonly IAsyncDecorator[] decorators;
    readonly TimeSpan timeout;
    readonly IProgress<float> progress;
    readonly string basePath;

    public NetworkClient(string basePath, TimeSpan timeout, params IAsyncDecorator[] decorators)
        : this(basePath, timeout, null, decorators)
    {
    }

    public NetworkClient(string basePath, TimeSpan timeout, IProgress<float> progress, params IAsyncDecorator[] decorators)
    {
        this.next = InvokeRecursive; // setup delegate

        this.basePath = basePath;
        this.timeout = timeout;
        this.progress = progress;
        this.decorators = new IAsyncDecorator[decorators.Length + 1];
        Array.Copy(decorators, this.decorators, decorators.Length);
        this.decorators[this.decorators.Length - 1] = this;
    }

    public async UniTask<T> PostAsync<T>(string path, T value, CancellationToken cancellationToken = default)
    {
        var request = new RequestContext(basePath, path, value, timeout, decorators);
        var response = await InvokeRecursive(request, cancellationToken);
        return response.GetResponseAs<T>();
    }

    UniTask<ResponseContext> InvokeRecursive(RequestContext context, CancellationToken cancellationToken)
    {
        return context.GetNextDecorator().SendAsync(context, cancellationToken, next); // マジカル再帰処理
    }

    async UniTask<ResponseContext> IAsyncDecorator.SendAsync(RequestContext context, CancellationToken cancellationToken, Func<RequestContext, CancellationToken, UniTask<ResponseContext>> _)
    {
        // Postしか興味ないからPostにしかしないよ!
        // パフォーマンスを最大限にしたい場合はuploadHandler, downloadHandlerをカスタマイズすること

        // JSONでbodyに送るというパラメータで送るという雑設定。
        var data = JsonUtility.ToJson(context.Value);
        var formData = new Dictionary<string, string> { { "body", data } };

        using (var req = UnityWebRequest.Post(basePath + context.Path, formData))
        {
            var header = context.GetRawHeaders();
            if (header != null)
            {
                foreach (var item in header)
                {
                    req.SetRequestHeader(item.Key, item.Value);
                }
            }

            // Timeout処理はCancellationTokenSourceのCancelAfterSlim(UniTask拡張)を使ってサクッと処理
            var linkToken = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
            linkToken.CancelAfterSlim(timeout);
            try
            {
                // 完了待ちや終了処理はUniTaskの拡張自体に丸投げ
                await req.SendWebRequest().ToUniTask(progress: progress, cancellationToken: linkToken.Token);
            }
            catch (OperationCanceledException)
            {
                // 元キャンセレーションソースがキャンセルしてなければTimeoutによるものと判定
                if (!cancellationToken.IsCancellationRequested)
                {
                    throw new TimeoutException();
                }
            }
            finally
            {
                // Timeoutに引っかからなかった場合にてるのでCancelAfterSlimの裏で回ってるループをこれで終わらせとく
                if (!linkToken.IsCancellationRequested)
                {
                    linkToken.Cancel();
                }
            }

            // UnityWebRequestを先にDisposeしちゃうので先に必要なものを取得しておく(性能的には無駄なのでパフォーマンスを最大限にしたい場合は更に一工夫を)
            return new ResponseContext(req.downloadHandler.data, req.responseCode, req.GetResponseHeaders());
        }
    }
}

コアの処理はInvokeRecursiveです。もう少し単純化すると

UniTask<ResponseContext> InvokeRecursive(RequestContext context, CancellationToken cancellationToken)
{
    context.decoratorIndex++;
    return decorators[context.decoratorIndex].SendAsync(context, cancellationToken, InvokeRecursive);
}

というように、IAsyncDecorator[]を少しずつ進めています。nextに入っているのは、配列の次の要素ということで、実際パターンの実装としてはそれだけです。

また、NetworkClient自体がIAsyncDecoratorとなっていて、つまりnextを使わないものが最奥部の、最後の処理となるわけです。

async UniTask<ResponseContext> IAsyncDecorator.SendAsync(RequestContext context, CancellationToken cancellationToken, Func<RequestContext, CancellationToken, UniTask<ResponseContext>> _)
{
    // nextは使わず、ここで実際の通信処理を始める
}

今回はasync decoratorの紹介なので本体の処理は雑なんですが(とりあえずJsonシリアライズ/デシリアライズしたものを受け渡しするだけ、的な)、まぁ概ね雰囲気はわかると思うのでそれでいいでしょう。通常Pathとリクエスト/レスポンス型は1:1のはずなので(そうなってなければサーバー実装者を〆て1:1にさせましょう)、その辺のメソッドを自動生成しておくとかはよくあります。また、戻り値を複数めいたこと(ポリモーフィズム的な)のしたいんだよなあ、という場合にはMessagePack for C#のUnionという機能が使えるので、活用するといい感じになります。

面白要素としてはTimeoutの処理を CancellationTokenSource.CancelAfterSlim で行っているところでしょうか。TimeoutはWhenAnyを使って外側から処理するパターンもありますが、対象がCancellationTokenを受け取れる場合は、こっちのほうがより効率的で良いです。

タイトル画面に戻すなどダイアログとシーン遷移を組み合わせる

ネットワークリクエストに失敗した時って、なんかポップアップ出して 「エラーが発生しました タイトルに戻ります 「OK」」 みたいな画面が出てきますよね?それをやりましょうやりましょう。

public enum DialogResult
{
    Ok,
    Cancel
}

public static class MessageDialog
{
    public static async UniTask<DialogResult> ShowAsync(string message)
    {
        // (例えば)Prefabで作っておいたダイアログを生成する
        var view = await Resources.LoadAsync("Prefabs/Dialog");

        // Ok, Cancelボタンのどちらかが押されるのを待機
        return await (view as GameObject).GetComponent<MessageDialogView>().ClickResult;
    }
}

public class MessageDialogView : MonoBehaviour
{
    [SerializeField] Button okButton = default;
    [SerializeField] Button closeButton = default;

    UniTaskCompletionSource<DialogResult> taskCompletion;

    // これでどちらかが押されるまで無限に待つを表現
    public UniTask<DialogResult> ClickResult => taskCompletion.Task;

    private void Start()
    {
        taskCompletion = new UniTaskCompletionSource<DialogResult>();

        okButton.onClick.AddListener(() =>
        {
            taskCompletion.TrySetResult(DialogResult.Ok);
        });

        closeButton.onClick.AddListener(() =>
        {
            taskCompletion.TrySetResult(DialogResult.Cancel);
        });
    }

    // もしボタンが押されずに消滅した場合にネンノタメ。
    private void OnDestroy()
    {
        taskCompletion.TrySetResult(DialogResult.Cancel);
    }
}

UniTaskCompletionSourceを活用して、ボタンが押されるまで待機というのを表現できます。こういう使い方、めっちゃするので覚えましょう。UniTaskCompletionSourceめっちゃ大事。

では、これとasync decoratorを組み合わせていきます。

public class ReturnToTitleDecorator : IAsyncDecorator
{
    public async UniTask<ResponseContext> SendAsync(RequestContext context, CancellationToken cancellationToken, Func<RequestContext, CancellationToken, UniTask<ResponseContext>> next)
    {
        try
        {
            return await next(context, cancellationToken);
        }
        catch (Exception ex)
        {
            if (ex is OperationCanceledException)
            {
                // キャンセルはきっと想定されている処理なのでそのまんまスルー(呼び出し側でOperationCanceledExceptionとして飛んでいく)
                throw;
            }

            if (ex is UnityWebRequestException uwe)
            {
                // ステータスコードを使って、タイトルに戻す例外です、とかリトライさせる例外です、とかハンドリングさせると便利
                // if (uwe.ResponseCode) { }...
            }

            // サーバー例外のMessageを直接出すなんて乱暴なことはデバッグ時だけですよ勿論。
            var result = await MessageDialog.ShowAsync(ex.Message);

            // OK か Cancelかで分岐するなら。今回はボタン一個、OKのみの想定なので無視
            // if (result == DialogResult.Ok) { }...

            // シーン呼び出しはawaitしないこと!awaitして正常終了しちゃうと、この通信の呼び出し元に処理が戻って続行してしまいます
            // のでForget。
            SceneManager.LoadSceneAsync("TitleScene").ToUniTask().Forget();


            // そしてOperationCanceledExceptionを投げて、この通信の呼び出し元の処理はキャンセル扱いにして終了させる
            throw new OperationCanceledException();
        }
    }
}

await使ってサクサク書いていけるので、道具が揃っていれば非同期処理とは思えないほど難なく書けます。

一つ注意なのは、呼び出し元に処理を戻すか戻さないか。普通にreturnすると処理が戻っていってしまいますが、Exceptionを再スローすればそれはそれでエラーとして出てしまってウザい。タイトル画面に戻すということは、その通信処理はキャンセルされたということなので、ここは処理がキャンセルされたとマークするのが正解です。asyncメソッドでキャンセル扱いするにはOperationCanceledExceptionを投げる必要があります。これは初見だと???という感じになると思いますが、そういうものなのでそういうものとして受け入れませう。

まとめ

UniTaskで道具を揃えたんだから、別に普通にばんばん書けるでしょ、便利に使ってね!ぐらいの気持ちでいたのであんまり応用例みたいなのの発信をしてこなかったんですが、よくよく考えると別にそんなことないよね……。ということにやっと気づいたので、色々盛りだくさんで紹介してみましたがどうでしょう。

最初はコールバックに毛が生えたもの程度でもいいとは思いますが、それだけじゃあ勿体ないわけです。せっかく言語機能として用意されているので、コールバックでは実現不可能なもう一段階上の設計が狙えるので、コールバックのことは忘れて使いこなしていって欲しいですね。

キャンセル処理に癖があるのは事実ですが(実際、最後に書いた明示的にOperationCanceledExceptionを投げよう、とかは一から発想していくのは難しいかもしれません)、「引数の最後に渡す」「明示的に投げてもいい」の二点だけなので、これは慣れるしかないし、それを理由にして利用範囲を限定的にするのはよくないかなー、と思ってます。

まぁ、ようするに普通に使ってね!便利ですよ実際!ということで。

Profile

Yoshifumi Kawai

Cysharp, Inc
CEO/CTO

Microsoft MVP for Developer Technologies(C#)
April 2011
|
July 2024

Twitter:@neuecc GitHub:neuecc

Archive