しばやん雑記

Azure とメイドさんが大好きなフリーランスのプログラマーのブログ

Durable Functions v2 を使った非同期 HTTP API の実装と利用

Durable Functions v2 beta 2 で Durable HTTP という機能が追加されました。クリス氏が Tweet で説明しているように、アクティビティ関数無しでよい感じに 202 Accepted のポーリングを行ってくれる便利機能です。

非同期処理の開始を 202 Accepted で通知して、完了したかどうかを Location ヘッダーで返した URL で確認するパターンを、全く意識させずに扱えます。

しかも完了するまで単純にポーリングするのではなく、Durable Functions の Timer を使っているようなので、待機している間は CPU を使用しないので効率が良いです。とても Consumption 向きです。

非同期 HTTP API の実装

202 Accepted を使った非同期 HTTP API のパターンは一般的なのか気になったので調べてみましたが、仕様として明記されてるわけではないですが使われてはいるようです。ARM API は結構使ってました。

Template Deployment の進捗を見てると、Accepted が返ってくるので分かりやすいです。

f:id:shiba-yan:20190903125642p:plain

時間がかかる処理を行う API は地味に作るのが面倒で、大抵の場合はタイムアウト長くして誤魔化しているのではないかと思いますが、Durable Functions を使えば最初から非同期な API として作られるので簡単です。

ちゃんとドキュメントにも非同期 HTTP API パターンが紹介されています。

テンプレートからオーケストレーターを作成すると CreateCheckStatusResponse でレスポンスを作っているはずなので、非同期に対応した API がこれだけで完成です。

public class Function1
{
    [FunctionName("Function1")]
    public Task<string> RunOrchestrator([OrchestrationTrigger] IDurableOrchestrationContext context)
    {
        return Task.FromResult("Hello, world");
    }

    [FunctionName("Function1_HttpStart")]
    public async Task<HttpResponseMessage> HttpStart(
        [HttpTrigger(AuthorizationLevel.Anonymous, "get", "post")]
        HttpRequestMessage req,
        [DurableClient] IDurableClient starter,
        ILogger log)
    {
        // Function input comes from the request content.
        string instanceId = await starter.StartNewAsync("Function1", null);

        log.LogInformation($"Started orchestration with ID = '{instanceId}'.");

        return starter.CreateCheckStatusResponse(req, instanceId);
    }
}

適当にリクエストを投げてみると、ちゃんと 202 Accepted と Location ヘッダーが返って来ます。

Retry-After は何秒後に再実行するかを表していますが、Durable Functions の場合は 10 秒固定です。

f:id:shiba-yan:20190903004748p:plain

Location ヘッダーで返ってきた URL にさらにリクエストを投げると、処理が完了している場合は結果が受け取れます。終わっていない場合は 202 が返ってくるので、同じことの繰り返しです。

f:id:shiba-yan:20190903014415p:plain

実行が完了していると 200 が返ってくるので、後はペイロードの値を使って良しなにするだけです。

非同期 HTTP API の利用

上の例では手動でリクエストを投げて、Durable Functions が提供する非同期 API を実行して結果を取得してみましたが、単純に実装するとループとウェイトを入れるような形になってしまいます。

実際にコンソールアプリケーションで API を実行して、結果を取得するコードを書いてみました。

class Program
{
    static async Task Main(string[] args)
    {
        var request = new HttpRequestMessage(HttpMethod.Get, "http://localhost:7071/api/Function1_HttpStart");

        var response = await _httpClient.SendAsync(request);

        while (response.StatusCode == System.Net.HttpStatusCode.Accepted)
        {
            await Task.Delay(response.Headers.RetryAfter.Delta.GetValueOrDefault(TimeSpan.FromSeconds(10)));

            var statusRequest = new HttpRequestMessage(HttpMethod.Get, response.Headers.Location);

            response = await _httpClient.SendAsync(statusRequest);
        }

        Console.WriteLine(await response.Content.ReadAsStringAsync());
    }

    private static readonly HttpClient _httpClient = new HttpClient();
}

見て分かるように、202 Accepted が返ってきた場合には、何らかの方法で処理を待機させる必要があります。この場合は Task.Delay を使っていますが、効率は良いとは言えないです。

代わりに Durable Functions v2 で追加された Durable HTTP を使うと、以下のようにシンプルに書けます。Timer を使っているので効率も良いです。

[FunctionName("Function2")]
public static async Task<string> RunOrchestrator([OrchestrationTrigger] IDurableOrchestrationContext context)
{
    var response = await context.CallHttpAsync(HttpMethod.Get, new Uri("http://localhost:7071/api/Function1_HttpStart"));

    return response.Content;
}

制約としては Durable Functions と同じで、リクエストとレスポンスはシリアライズが可能な必要があるため、Stream 系は扱えないです。基本的には全て string して扱われます。

同じような理由で Multipart なリクエストも扱えないので、ファイルアップロードは難しいですね。実装する場合は Blob にアップロードしてから、URL を渡す形になると思います。

実行する HTTP リクエストは DurableHttpRequest を直接扱うことで、もう少し弄れます。この辺りは HttpClientHttpRequestMessage に近いです。

[FunctionName("Function2")]
public async Task<string> RunOrchestrator([OrchestrationTrigger] IDurableOrchestrationContext context)
{
    var request = new DurableHttpRequest(
        HttpMethod.Get, 
        new Uri("https://***.azurewebsites.net/api/Function1_HttpStart"), 
        content: JsonConvert.SerializeObject(new { foo = "bar" }));

    request.Headers.Add("X-Function-Key", new StringValues("<functionkey>"));

    var response = await context.CallHttpAsync(request);

    return response.Content;
}

コンストラクタでいろいろ指定しないといけないのが少し面倒な感じです。オブジェクト初期化子で書きたい気持ちが高まるので、暇な時にフィードバックしてみようかと思いました。

ちなみに透過的なリトライは行ってくれないので、その辺りだけは気を付ける必要があります。

元から Long-running なタスクを実行するのに適していた Durable Functions ですが、v2 で更に進化を遂げそうです。利用シーンは結構広いので、今後も積極的に使って行きます。