Durable Functions v2.5.0 でソースジェネレーターが追加されてました!タイプセーフ!

12 min read読了の目安(約11200字

先日 v2.5.0 がリリースされていました。

https://github.com/Azure/azure-functions-durable-extension/releases/tag/v2.5.0

Durable Functions は、はまると強い最強の Azure Functions の拡張機能だと思ってるのですが一部タイプセーフにコードが書けない(文字列で関数名を指定する、引数の型もシグネチャー上は呼び出し先の関数と呼び出し元で同じ型をプログラマが意識して使わないといけないなど)という不満点はありました。

でも、今回の v2.5.0 のリリースノートを見ると

A new preview experience for a typed experience in .NET to call Durable Activities and Orchestrations. The solution uses .NET source generators to generate new interfaces for ITypedDurableOrchestrationContext and ITypedDurableClient. The source generator looks at your source code for existing orchestrations and activities and creates new methods on these interfaces that have the correct method name, parameters and return type. See here for more details, and download the preview package on nuget.org.

神では?

使ってみた

では、早速試してみましょう。何事もシンプルなものからということで Azure Functions のプロジェクトを作って Durable Functions のひな型の関数を作ります。いつも通り Function1_Hello を呼び出すだけのオーケストレーター関数と Function1_Hello アクティビティ関数が作られるやつです。

Function1.cs
using System.Collections.Generic;
using System.Net.Http;
using System.Threading.Tasks;
using Microsoft.Azure.WebJobs;
using Microsoft.Azure.WebJobs.Extensions.DurableTask;
using Microsoft.Azure.WebJobs.Extensions.Http;
using Microsoft.Azure.WebJobs.Host;
using Microsoft.Extensions.Logging;

namespace FunctionApp2
{
    public static class Function1
    {
        [FunctionName("Function1")]
        public static async Task<List<string>> RunOrchestrator(
            [OrchestrationTrigger] IDurableOrchestrationContext context)
        {
            var outputs = new List<string>();

            // Replace "hello" with the name of your Durable Activity Function.
            outputs.Add(await context.CallActivityAsync<string>("Function1_Hello", "Tokyo"));
            outputs.Add(await context.CallActivityAsync<string>("Function1_Hello", "Seattle"));
            outputs.Add(await context.CallActivityAsync<string>("Function1_Hello", "London"));

            // returns ["Hello Tokyo!", "Hello Seattle!", "Hello London!"]
            return outputs;
        }

        [FunctionName("Function1_Hello")]
        public static string SayHello([ActivityTrigger] string name, ILogger log)
        {
            log.LogInformation($"Saying hello to {name}.");
            return $"Hello {name}!";
        }

        [FunctionName("Function1_HttpStart")]
        public static async Task<HttpResponseMessage> HttpStart(
            [HttpTrigger(AuthorizationLevel.Anonymous, "get", "post")] HttpRequestMessage req,
            [DurableClient] IDurableOrchestrationClient 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);
        }
    }
}

このコードの outputs.Add(await context.CallActivityAsync<string>("Function1_Hello", "Tokyo")); の部分に個人的に不満を感じていたものの、全てが詰まっています。Function1_Hello 関数のほうのメソッドでは引数の型が string で戻り値が string と定義されているにもかかわらず、呼び出し側の CallActivityAsync メソッドではメソッド名は文字列、引数の型は object、戻り値の型は型引数で自分で指定する必要があります。仕組み上仕方ないのですが、書いてて辛いことはありました。

これを解決してくれるのが、今回追加されたソースジェネレーターの DurableFunctions.TypedInterfaces パッケージになります。早速 NuGet からプロジェクトに追加します。
まだ、0.1.0-preview なのでプレリリースを含めるにチェックを入れて Visual Studio の NuGet パッケージマネージャーから入れます。

パッケージを追加してビルドすると、以下のように沢山コードが生成されていることが確認できます。

現行のバージョンでの制限になると思うのですが、アクティビティ関数では IDurableActivityContext を引数に受け取って内部で GetInput を使って引数を取る必要があるみたいです。なので、テンプレートで生成された SayHello メソッドを以下のように変更します。

[FunctionName("Function1_Hello")]
public static string SayHello([ActivityTrigger] IDurableActivityContext context, ILogger log)
{
    var name = context.GetInput<string>(); // 引数の取り出しは GetInput でやる。
    log.LogInformation($"Saying hello to {name}.");
    return $"Hello {name}!";
}

こうすると、オーケストレーター関数で ITypedDurableOrchestrationContext インターフェースを使って快適にコードが書けるようになります。書き換えてみましょう!

[FunctionName("Function1")]
public static async Task<List<string>> RunOrchestrator(
    [OrchestrationTrigger] ITypedDurableOrchestrationContext context)
{
    var outputs = new List<string>();

    // 変更前
    //outputs.Add(await context.CallActivityAsync<string>("Function1_Hello", "Tokyo"));
    //outputs.Add(await context.CallActivityAsync<string>("Function1_Hello", "Seattle"));
    //outputs.Add(await context.CallActivityAsync<string>("Function1_Hello", "London"));

    // 変更後
    outputs.Add(await context.Activities.Function1_Hello("Tokyo"));
    outputs.Add(await context.Activities.Function1_Hello("Seattle"));
    outputs.Add(await context.Activities.Function1_Hello("London"));

    // returns ["Hello Tokyo!", "Hello Seattle!", "Hello London!"]
    return outputs;
}

普通のメソッド呼び出しになってていい感じです。Function1_Hello メソッドは Task<string> Function1_Hello(string name); のように定義されているので型情報もばっちりです。

アクティビティ関数の方の引数を以下のように書き換えると…

[FunctionName("Function1_Hello")]
public static string SayHello([ActivityTrigger] IDurableActivityContext context, ILogger log)
{
    var (firstName, lastName) = context.GetInput<(string, string)>();
    log.LogInformation($"Saying hello to {firstName} {lastName}.");
    return $"Hello {firstName} {lastName}!";
}

以下のようにちゃんとエラーが出ます。

因みに、この状態で実行すると DurableOrchestrationContext から ITypedDurableOrchestrationContext に変換できないというエラーが出ます。

[2021-06-03T03:51:32.537Z] Executing 'Function1' (Reason='(null)', Id=9d379e38-f066-4430-bcca-fa2288aee8b5)
[2021-06-03T03:51:32.709Z] Executed 'Function1' (Failed, Id=9d379e38-f066-4430-bcca-fa2288aee8b5, Duration=172ms)
[2021-06-03T03:51:32.710Z] System.Private.CoreLib: Exception while executing function: Function1. Microsoft.Azure.WebJobs.Host: Exception binding parameter 'context'. Microsoft.Azure.WebJobs.Extensions.DurableTask: Cannot convert Microsoft.Azure.WebJobs.Extensions.DurableTask.DurableOrchestrationContext to ITypedDurableOrchestrationContext.
[2021-06-03T03:51:32.717Z] 584f0c59c803440bbd045a0d21898ec0: Function 'Function1 (Orchestrator)' failed with an error. Reason: Microsoft.Azure.WebJobs.Host.FunctionInvocationException: Exception while executing function: Function1
[2021-06-03T03:51:32.718Z]  ---> System.InvalidOperationException: Exception binding parameter 'context'
[2021-06-03T03:51:32.720Z]  ---> System.ArgumentException: Cannot convert Microsoft.Azure.WebJobs.Extensions.DurableTask.DurableOrchestrationContext to ITypedDurableOrchestrationContext.
[2021-06-03T03:51:32.721Z]    at Microsoft.Azure.WebJobs.Extensions.DurableTask.ObjectValueProvider..ctor(Object value, Type valueType) in D:\a\r1\a\azure-functions-durable-extension\src\WebJobs.Extensions.DurableTask\Bindings\ObjectValueProvider.cs:line 24
[2021-06-03T03:51:32.724Z]    at Microsoft.Azure.WebJobs.Extensions.DurableTask.OrchestrationTriggerAttributeBindingProvider.OrchestrationTriggerBinding.BindAsync(Object value, ValueBindingContext context) in D:\a\r1\a\azure-functions-durable-extension\src\WebJobs.Extensions.DurableTask\Bindings\OrchestrationTriggerAttributeBindingProvider.cs:line 128
[2021-06-03T03:51:32.725Z]    at Microsoft.Azure.WebJobs.Host.Indexers.FunctionIndexer.TriggerWrapper.BindAsync(Object value, ValueBindingContext context) in C:\projects\azure-webjobs-sdk-rqm4t\src\Microsoft.Azure.WebJobs.Host\Indexers\FunctionIndexer.cs:line 492

これを解決するには、Microsoft.Azure.Functions.Extensions パッケージを追加して Startup クラスを追加してあげます。

Startup.cs
using FunctionApp2;
using Microsoft.Azure.Functions.Extensions.DependencyInjection;

[assembly: FunctionsStartup(typeof(Startup))]

namespace FunctionApp2
{
    public class Startup : FunctionsStartup
    {
        public override void Configure(IFunctionsHostBuilder builder)
        {
        }
    }
}

そうすると、以下のように動きます。ばっちりですね。

[2021-06-03T03:53:34.064Z] Started orchestration with ID = '5162a110bcf0492c95043439142aa41a'.
[2021-06-03T03:53:34.124Z] Executed 'Function1_HttpStart' (Succeeded, Id=8e4b2719-67f9-4825-aeba-3eecc885f5b0, Duration=464ms)
[2021-06-03T03:53:36.120Z] Executing 'Function1' (Reason='(null)', Id=0a528b12-2501-4703-86db-d37edab7218b)
[2021-06-03T03:53:36.164Z] Executed 'Function1' (Succeeded, Id=0a528b12-2501-4703-86db-d37edab7218b, Duration=44ms)
[2021-06-03T03:53:36.392Z] Executing 'Function1_Hello' (Reason='(null)', Id=33b17b14-8822-4bec-a5cc-f9b3ce4ba5cb)
[2021-06-03T03:53:36.449Z] Saying hello to Tokyo.
[2021-06-03T03:53:36.451Z] Executed 'Function1_Hello' (Succeeded, Id=33b17b14-8822-4bec-a5cc-f9b3ce4ba5cb, Duration=61ms)
[2021-06-03T03:53:36.937Z] Executing 'Function1' (Reason='(null)', Id=b06529ea-469c-4129-9b04-a5a79a1b1a0c)
[2021-06-03T03:53:36.942Z] Executed 'Function1' (Succeeded, Id=b06529ea-469c-4129-9b04-a5a79a1b1a0c, Duration=5ms)
[2021-06-03T03:53:37.073Z] Executing 'Function1_Hello' (Reason='(null)', Id=dffefc6d-337b-49bf-ace8-757a1ecf62a8)
[2021-06-03T03:53:37.087Z] Saying hello to Seattle.
[2021-06-03T03:53:37.088Z] Executed 'Function1_Hello' (Succeeded, Id=dffefc6d-337b-49bf-ace8-757a1ecf62a8, Duration=35ms)
[2021-06-03T03:53:37.392Z] Executing 'Function1' (Reason='(null)', Id=364eebeb-2a42-47c8-905e-610bb80537ed)
[2021-06-03T03:53:37.410Z] Executed 'Function1' (Succeeded, Id=364eebeb-2a42-47c8-905e-610bb80537ed, Duration=19ms)
[2021-06-03T03:53:37.581Z] Executing 'Function1_Hello' (Reason='(null)', Id=34f75164-da84-4f62-85e2-8d7a95a74adf)
[2021-06-03T03:53:37.601Z] Saying hello to London.
[2021-06-03T03:53:37.603Z] Executed 'Function1_Hello' (Succeeded, Id=34f75164-da84-4f62-85e2-8d7a95a74adf, Duration=37ms)
[2021-06-03T03:53:37.993Z] Executing 'Function1' (Reason='(null)', Id=9a522433-bdcb-4e82-bda2-4263dffc0147)
[2021-06-03T03:53:38.010Z] Executed 'Function1' (Succeeded, Id=9a522433-bdcb-4e82-bda2-4263dffc0147, Duration=17ms)
[2021-06-03T03:53:38.024Z] Host lock lease acquired by instance ID '000000000000000000000000A85146F7'.

因みにソースジェネレーターで生成されているコードを眺めていると ITypedDurableClient も生成されているので、Starter の関数もタイプセーフにいけます。

[FunctionName("Function1_HttpStart")]
public static async Task<HttpResponseMessage> HttpStart(
    [HttpTrigger(AuthorizationLevel.Anonymous, "get", "post")] HttpRequestMessage req,
    [DurableClient] ITypedDurableClient starter,
    ILogger log)
{
    // Function input comes from the request content.
    // string instanceId = await starter.StartNewAsync("Function1", null);
    string instanceId = await starter.Orchestrations.StartFunction1(); // ここもタイプセーフ

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

    return starter.CreateCheckStatusResponse(req, instanceId);
}

まだまだ、出たてのプレビューバージョンですが生成されるコードも複雑じゃないので、お作法を守って使っていると結構快適に使えるようになるのではないかと思います。

使用例も Examples がリポジトリにあるので、そこを見るとわかりやすいと思います。実際に Startup クラスが必要そうだというのは以下のリポジトリを見て気づきました。多分 Startup クラスがあることで各種インスタンス生成の処理を色々出来るようになる仕組みが入ってる感じなんだろうなぁと勝手に思ってます。(内部実装は追いかけてません。)

https://github.com/Azure/azure-functions-durable-extension/tree/dev/src/DurableFunctions.TypedInterfaces/Example

ということで、Durable Functions を使う上で不満だった(不満を上回るメリットがあるので使う時は使います!)タイプセーフじゃないという問題が今後解決されていきそうで、控え目に言って最高でした。正式リリースが楽しみですね!