かずきのBlog@hatena

すきな言語は C# + XAML の組み合わせ。Azure Functions も好き。最近は Go 言語勉強中。日本マイクロソフトで働いていますが、ここに書いていることは個人的なメモなので会社の公式見解ではありません。

Durable Functions の Entity のクラススタイルプログラミングモデルを試してみよう

Durable Functions の Entity のプログラミング体験が割と辛いのですがクラススタイルのプログラミングモデルを使うと既存のプログラミングと同じような感じでいけて素敵です!

クラススタイルのプログラミングモデルは、2019/08/05 時点では Durable Functions v2.0.0-beta1 で試すことが出来ます。

github.com

試してみよう!

早速 Azure Functions のプロジェクトを作ります。そして Microsoft.Azure.WebJobs.Extensions.DurableTask の 2.0.0-beta1 を追加します。

クラススタイルのエンテティは、普通のクラス!違う点があるとすると、クラス名の FunctionName 属性のついた EntityTrigger の関数があるところですね。

using Microsoft.Azure.WebJobs;
using Newtonsoft.Json;
using Newtonsoft.Json.Serialization;
using System;
using System.Threading.Tasks;

namespace FunctionApp1
{
    public interface IPerson
    {
        void SetName(string name);
        Task CurseAsync();
    }

    public class Person : IPerson
    {
        [JsonProperty("name")]
        public string Name { get; set; }
        [JsonProperty("age")]
        public int Age { get; set; }

        public void SetName(string name) => Name = name;
        // 適当な時間経過後に加齢する呪い
        public async Task CurseAsync()
        {
            var r = new Random();
            await Task.Delay(TimeSpan.FromSeconds(r.Next(10)));
            Age += r.Next(10);
        }

        [FunctionName(nameof(Person))]
        public static Task Run([EntityTrigger] IDurableEntityContext ctx)
            => ctx.DispatchAsync<Person>();
    }
}

この EntityTrigger のついた関数の DispatchAsync が凄くいい仕事します。Person クラスの関数をいい感じに呼び出してくれます。

HttpTrigger などから呼び出してみよう

HttpTrigger などから Entity を使うには、IDurableOrchestrationClient#SignalEntityAsync でメソッドを呼び出して、IDurableOrchestrationClient#ReadEntityStateAsync で Entity のステータスを取得します。ステータスは、先ほど定義した Person クラスのプロパティに値が入ってる感じのものが返ってきます。

例えば…適当に SetName を呼んでステータスを読み取ってみましょう。

[FunctionName("CreatePerson")]
public static async Task<IActionResult> CreatePersonAsync(
    [HttpTrigger(AuthorizationLevel.Function, "get", "post")]HttpRequest req,
    [OrchestrationClient] IDurableOrchestrationClient client)
{
    var id = CreateEntityIdFrom(req);
    if (!id.HasValue)
    {
        return new BadRequestResult();
    }

    await client.SignalEntityAsync<IPerson>(id.Value, proxy => proxy.SetName(id.Value.EntityKey));
    return new OkObjectResult(await client.ReadEntityStateAsync<Person>(id.Value));
}

private static EntityId? CreateEntityIdFrom(HttpRequest req)
{
    var name = req.Query["name"];
    if (string.IsNullOrEmpty(name))
    {
        return null;
    }

    return new EntityId(nameof(Person), name);
}

EntityId の最初の引数が Person クラスに定義した関数の名前です。そして、EntityId の第二引数に Entity の識別子みたいな感じですね。今回は HttpTrigger の URL のクエリーパラメータの name でを使う感じにしました。

POST http://localhost:7071/api/CreatePerson?name=kazuakix HTTP/1.1 こんな感じのURL を叩くと下のような結果が返ってきます。

HTTP/1.1 200 OK
Connection: close
Date: Mon, 05 Aug 2019 11:30:47 GMT
Content-Type: application/json; charset=utf-8
Server: Kestrel
Content-Length: 41

{
  "entityExists": false,
  "entityState": null
}

entityState が null なのが気になりますが、これは SignalEntityAsync は処理の完了を待たずに、処理の要求がキューに入ったら戻ってくる感じです。ReadEntityStateAsync も別に SignalEntityAsync で何か処理が行われるか気にせず結果を返すので、こんな結果になります。

なので、もう一度同じ HttpTrigger の関数を叩くと以下のような結果になります。

HTTP/1.1 200 OK
Connection: close
Date: Mon, 05 Aug 2019 11:34:36 GMT
Content-Type: application/json; charset=utf-8
Server: Kestrel
Content-Length: 63

{
  "entityExists": true,
  "entityState": {
    "name": "kazuakix",
    "age": 0
  }
}

他に、Person#CurseAsync を呼ぶ場合は以下のようなコードになります。

[FunctionName("Curse")]
public static async Task<IActionResult> CurseAsync(
    [HttpTrigger(AuthorizationLevel.Function, "get", "post")]HttpRequest req,
    [OrchestrationClient] IDurableOrchestrationClient client)
{
    var id = CreateEntityIdFrom(req);
    if (!id.HasValue)
    {
        return new BadRequestResult();
    }

    await client.SignalEntityAsync<IPerson>(id.Value, proxy => proxy.CurseAsync());
    return new OkObjectResult(await client.ReadEntityStateAsync<Person>(id.Value));
}

この例でも CurseAsync を処理するという要求をするだけなので、最後の結果の ReadEntityStateAsync の結果は加齢前のものになります。試しに呼んでみると…

HTTP/1.1 200 OK
Connection: close
Date: Mon, 05 Aug 2019 11:36:45 GMT
Content-Type: application/json; charset=utf-8
Server: Kestrel
Content-Length: 63

{
  "entityExists": true,
  "entityState": {
    "name": "kazuakix",
    "age": 0
  }
}

年齢は変わりません。ちょっと何もせずステータスを返す以下のような関数を作って…

[FunctionName("GetPerson")]
public static async Task<IActionResult> GetPersonAsync(
    [HttpTrigger(AuthorizationLevel.Function, "get", "post")]HttpRequest req,
    [OrchestrationClient] IDurableOrchestrationClient client)
{
    var id = CreateEntityIdFrom(req);
    if (!id.HasValue)
    {
        return new BadRequestResult();
    }

    return new OkObjectResult(await client.ReadEntityStateAsync<Person>(id.Value));
}

適当な時間経過後に呼んでみましょう。

HTTP/1.1 200 OK
Connection: close
Date: Mon, 05 Aug 2019 11:37:46 GMT
Content-Type: application/json; charset=utf-8
Server: Kestrel
Content-Length: 63

{
  "entityExists": true,
  "entityState": {
    "name": "kazuakix",
    "age": 4
  }
}

年齢増えてますね。

処理結果を待ちつつ結果を取りたい

そういうときは OrchestrationTrigger の中で Entity を使うと良さそう。

[FunctionName("CurseAndWait")]
public static async Task<IActionResult> CurseAndWaitAsync(
    [HttpTrigger(AuthorizationLevel.Function, "get", "post")]HttpRequest req,
    [OrchestrationClient] IDurableOrchestrationClient client)
{
    var id = CreateEntityIdFrom(req);
    if (!id.HasValue)
    {
        return new BadRequestResult();
    }

    var instanceId = await client.StartNewAsync("CurseAndWaitOrchestrator", id.Value);
    while((await client.GetStatusAsync(instanceId)).RuntimeStatus != OrchestrationRuntimeStatus.Completed)
    {
        await Task.Delay(5000);
    }

    return new OkObjectResult(await client.ReadEntityStateAsync<Person>(id.Value));
}

[FunctionName("CurseAndWaitOrchestrator")]
public static async Task CurseAndWaitOrchestratorAsync(
    [OrchestrationTrigger]IDurableOrchestrationContext context)
{
    var id = context.GetInput<EntityId>();

    var proxy = context.CreateEntityProxy<IPerson>(id);
    await proxy.CurseAsync();
}

オーケストレーター関数の終了待つのに、WaitForCompletionOrCreateCheckStatusResponseAsync 使えばいいやって思ったけど、この関数は、あくまでレスポンス受け取った先の人のためのメソッドであって、HttpTrigger の中でこの関数を await しても必ずしも完了まで待ってくれないんですね。勉強になった。 ということでステータスが完了になるまで自分で待ってます。

そうすると、関数を呼ぶと加齢が完了して、結果に加齢後のオブジェクトが入ってます。 呼び出し前の状態を確認するために GetPerson 関数を呼んで結果を見てます。

HTTP/1.1 200 OK
Connection: close
Date: Mon, 05 Aug 2019 12:02:23 GMT
Content-Type: application/json; charset=utf-8
Server: Kestrel
Content-Length: 64

{
  "entityExists": true,
  "entityState": {
    "name": "kazuakix",
    "age": 47
  }
}

47 歳ですね。では先ほど作った CurseAndWait 関数をこんな感じ( GET http://localhost:7071/api/CurseAndWait?name=kazuakix HTTP/1.1 )で呼んでみます。

HTTP/1.1 200 OK
Connection: close
Date: Mon, 05 Aug 2019 12:02:50 GMT
Content-Type: application/json; charset=utf-8
Server: Kestrel
Content-Length: 64

{
  "entityExists": true,
  "entityState": {
    "name": "kazuakix",
    "age": 53
  }
}

53 歳になりましたね。あってるのかな。

まとめ

Durable Functions の Entity が本当に Entity 書いてるみたいになるのでとてもいい。Durable Functions v2 のリリースが楽しみになりますね。