6
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

.NET 6 と Daprを使った分散サービス開発 その4 State Management (ステート管理)

Last updated at Posted at 2022-03-03

State Management (ステート管理)によるマイクロサービスの状態維持

今まで、以下の流れで作業を行ってきました。今回はDaprに備わっているState Management (ステート管理)による状態維持について触れていきたいと思います。


State Management (ステート管理)について

State Management (ステート管理)は、各サービスにおいて使うことができる状態維持に扱うストアになります。セッションやECでいう、カートの状態などもその様な状態ストアです。Daprでは、コンポーネント機能として提供しており、以下のようなサービスをコンポーネントで設定する事で抽象化を行い、セッションストアとして使うことで、アクセスを容易にします。

今回はローカルで実行していますので、これ何がうれしいの?と思われるかもしれませんが、後日KubernetesにデプロイしてPodで展開している数が二つになったときにState Management (ステート管理)の存在が便利と思えます。つまり、サービスで展開されているPodをスケールアウトした際、ロードバランサで振り分けてくる状況になったとしても、サービスのPodは同じState Management (ステート管理)を参照する事ができます。

image.png

  • Azure CosmosDB
  • Azure SQL Server
  • MongoDB
  • PostgreSQL
  • Redis
  • Aerospike
  • Azure Blob Storage
  • Azure Table Storage
  • Cassandra
  • Cloudstate
  • Couchbase
  • etcd
  • Google Cloud Firestore
  • Hashicorp Consul
  • Hazelcast
  • Memcached
  • Zookeeper

これも使ってみた方が早いので試してみます。

componentを設定する

まず、前回のプロジェクトからtye.yamlを編集します。 # 以下のコメントを外すとコメントをつけていますが、 components-path: "./ components-path: "./components/"/" の行をコメントアウトします。
componentsフォルダも作っておきましょう。

tye.yaml
# tye application configuration file
# read all about it at https://github.com/dotnet/tye
#
# when you've given us a try, we'd love to know what you think:
#    https://aka.ms/AA7q20u
#
name: dapr
extensions:
- name: dapr

  # log-level configures the log level of the dapr sidecar
  log-level: debug

  # config allows you to pass additional configuration into the dapr sidecar
  # config will be interpreted as a named k8s resource when deployed, and will be interpreted as
  # a file on disk when running locally at `./components/myconfig.yaml`
  #
  # config: myconfig

  # components-path configures the components path of the dapr sidecar
  # 以下のコメントを外す
  components-path: "./components/"

  # If not using the default Dapr placement service or otherwise using a placement service on a nonstandard port,
  # you can configure the Dapr sidecar to use an explicit port.
  # placement-port: 6050
services:
- name: service-a
  project: ServiceA/ServiceA.csproj
- name: service-b
  project: ServiceB/ServiceB.csproj
- name: app
  project: App/App.csproj

# This may conflict with the redis instance that dapr manages.
#
# Doing a `docker ps` can show if its already running. If that's the case
# then comment out out when running locally. 
# - name: redis
#   image: redis
#   bindings: 
#   - port: 6379

プロジェクトは、以下のようになっていると思います。
image.png

State Management (ステート管理)を設定する

先ほど追加したcomponentsフォルダに新しくstatestore.yamlを追加します。以下は状態保存にRedisを用いる場合の一つの例です。このアクセス先のRedisはどこ?って思われるかもしれません、dapr initの際にあらかじめ、この為のRedisがセットアップされています。

statestore.yaml
apiVersion: dapr.io/v1alpha1
kind: Component
metadata:
  name: statestore
spec:
  type: state.redis
  version: v1
  metadata:
  - name: redisHost
    value: localhost:6379
  - name: redisPassword
    value: ""
  - name: actorStateStore
    value: "true"

docker psをしてみると、ローカルの6379でredisが起動しています。

C:\Users\user>docker ps
CONTAINER ID   IMAGE               COMMAND                  CREATED        STATUS                  PORTS                              NAMES
ba8a9bdb62ab   daprio/dapr:1.6.0   "./placement"            18 hours ago   Up 18 hours             0.0.0.0:6050->50005/tcp            dapr_placement
808b6358b4e7   openzipkin/zipkin   "start-zipkin"           5 days ago     Up 27 hours (healthy)   9410/tcp, 0.0.0.0:9411->9411/tcp   dapr_zipkin
bed085e54a77   redis               "docker-entrypoint.s…"   5 days ago     Up 27 hours             0.0.0.0:6379->6379/tcp             dapr_redis

いったん起動して確認

ここまでできれば、tyeを使って一度起動確認をしましょう。

C:\Users\user\DaprQiita>tye run
Loading Application Details...
Launching Tye Host...

[13:52:20 INF] Executing application from C:\Users\user\DaprQiita\tye.yaml
[13:52:20 INF] Dashboard running on http://127.0.0.1:8000
[13:52:20 INF] Build Watcher: Watching for builds...
[13:52:20 INF] Building projects
[13:52:22 INF] Application dapr started successfully with Pid: 11604
[13:52:22 INF] Launching service service-a-dapr_9f730849-f: C:/dapr/dapr.exe run --app-id service-a --dapr-grpc-port 51097 --dapr-http-port 51098 --metrics-port 51099 --app-port 51091 --components-path ./components/ --log-level debug
[13:52:22 INF] Launching service service-b-dapr_4249ed66-1: C:/dapr/dapr.exe run --app-id service-b --dapr-grpc-port 51100 --dapr-http-port 51101 --metrics-port 51102 --app-port 51093 --components-path ./components/ --log-level debug
[13:52:22 INF] Launching service app-dapr_ba083775-8: C:/dapr/dapr.exe run --app-id app --dapr-grpc-port 51103 --dapr-http-port 51104 --metrics-port 51105 --app-port 51095 --components-path ./components/ --log-level debug

サイドカーのDaprプロセスに --components-path ./components/ ってのが追加されていますね。

[13:52:22 INF] Launching service service-a-dapr_9f730849-f: C:/dapr/dapr.exe run --app-id service-a --dapr-grpc-port 51097 --dapr-http-port 51098 --metrics-port 51099 --app-port 51091 --components-path ./components/ --log-level debug

ステートの保存、読み込み

今回はステート保存用に、新しくサービスを一つ作ってみます。
プロジェクトのルートフォルダで、以下のコマンドを実行しましょう。

dotnet new webapi -o StateService
dotnet sln add StateService

作成後はこのようになっていると思います。

image.png

image.png

忘れずにtye.yamlにもプロジェクトを追加しておきましょう。

tye.yaml
# tye application configuration file
# read all about it at https://github.com/dotnet/tye
#
# when you've given us a try, we'd love to know what you think:
#    https://aka.ms/AA7q20u
#
name: dapr
extensions:
- name: dapr

  # log-level configures the log level of the dapr sidecar
  log-level: debug

  # config allows you to pass additional configuration into the dapr sidecar
  # config will be interpreted as a named k8s resource when deployed, and will be interpreted as
  # a file on disk when running locally at `./components/myconfig.yaml`
  #
  # config: myconfig

  # components-path configures the components path of the dapr sidecar
  components-path: "./components/"

  # If not using the default Dapr placement service or otherwise using a placement service on a nonstandard port,
  # you can configure the Dapr sidecar to use an explicit port.
  # placement-port: 6050
services:
- name: service-a
  project: ServiceA/ServiceA.csproj
- name: service-b
  project: ServiceB/ServiceB.csproj
- name: app
  project: App/App.csproj
- name: stateservice
  project: StateService/StateService.csproj

# This may conflict with the redis instance that dapr manages.
#
# Doing a `docker ps` can show if its already running. If that's the case
# then comment out out when running locally. 
# - name: redis
#   image: redis
#   bindings: 
#   - port: 6379

プロジェクトの編集

まず、プロジェクトにDaprのクライアントライブラリを前回同様追加しましょう。今回は、Visual Studio 2022からGUIで追加しました。

image.png

image.png

追加ができたら、プロジェクトのProgram.csを以下を参考に書き換えてみましょう。

Program.cs
var builder = WebApplication.CreateBuilder(args);

// Add services to the container.

builder.Services.AddControllers();
// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
builder.Services.AddDaprClient(); // この行追加

var app = builder.Build();

// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment())
{
    app.UseSwagger();
    app.UseSwaggerUI();
}

// 以下のリダイレクションはコメントアウト(backgroundでは、httpのみで通信)
//app.UseHttpsRedirection();

app.UseAuthorization();

app.MapControllers();

app.Run();

次に、WeatherForecastController.csを編集します。
こんな感じにしました、雑に書いていますので、例外処理とか書いてないです。使うときにはきちんと書いてくださいね。
中身はシンプルです、GETで呼び出されたら定数で指定されたキーで指定されたステートストア領域から読み出します。
POSTの場合は、同じくステートストア領域に指定したキーで保存しています。

Program.cs
using Dapr.Client;
using Microsoft.AspNetCore.Mvc;

namespace StateService.Controllers;

[ApiController]
[Route("[controller]")]
public class WeatherForecastController : ControllerBase
{

    private readonly ILogger<WeatherForecastController> _logger;

    private readonly DaprClient _daprClient;
    const string storeName = "statestore";
    const string key = "LastWeatherForecast";

    public WeatherForecastController(ILogger<WeatherForecastController> logger, DaprClient daprClient)
    {
        _logger = logger;
        _daprClient = daprClient;
    }

    [HttpGet(Name = "GetWeatherForecast")]
    public async Task<WeatherForecast> GetAsync()
    {
        return await _daprClient.GetStateAsync<WeatherForecast>(storeName, key);
    }

    [HttpPost(Name = "PostWeatherForecast")]
    public async Task PostAsync(WeatherForecast weatherForecast)
    {
        await _daprClient.SaveStateAsync(storeName, key, weatherForecast);
        return;
    }

}

確認

ここまで来たら、tye run で起動して、このサービスだけで動作するか確認してみましょう。
デバッグについては、前回の記事を参考にプロセスをアタッチしてみてください。

以下のように、サービスが起動しています。今回はtyeによってstateserviceのプロセスは http://localhost:64377 が割り当てられています。

image.png

今回割り当てられた http://localhost:64377/swagger でSwaggerにアクセスしてみます。

image.png

まずは、POSTして確認します。温度は適当に100度にしました。

image.png

レスポンスは200ですので、正常に動いていそうです。

image.png

Service Invocation と Statestoreのコントロール

さて、ここで気になるのは各サービスのステートはDaprを使って共有されるのか?もしくは、サービスが異なれば別のステートとして扱ってくれるのか?という点です。
そこで、次に以下のような事を試してみたいと思います。

image.png

  1. フロントのアプリAppでは、App側のDaprを使って直接Statestoreに保存します。
  2. フロントのアプリAppでは、App側のDaprを使って直接Statestoreから読み出します。 (1で保存された内容と同じであるはずです)
  3. 次にService invocationを使って、StatestoreサービスからGETで読み出してみます。前述までの所で、Appで書き込んだ値を返却してくるはずです。

まず、AppのWeatherForecastController.csを以下のように書き換えてみました。

WeatherForecastController.cs
using Dapr.Client;
using Microsoft.AspNetCore.Mvc;

namespace App.Controllers;

[ApiController]
[Route("[controller]")]
public class WeatherForecastController : ControllerBase
{
    private readonly ILogger<WeatherForecastController> _logger;

    private readonly DaprClient _daprClient;

    const string storeName = "statestore";
    const string key = "LastWeatherForecast";


    public WeatherForecastController(ILogger<WeatherForecastController> logger, DaprClient daprClient)
    {
        _logger = logger;
        _daprClient = daprClient;
    }

    [HttpGet(Name = "GetWeatherForecast")]
    public async Task<IEnumerable<WeatherForecast>> GetAsync()
    {
        WeatherForecast forcast = new WeatherForecast
        {
            TemperatureC = 1000,
            Summary = "from App",
            Date = DateTime.Now
        };

        // Appからステートを保存
        await _daprClient.SaveStateAsync(storeName, key, forcast);

        // 保存したステートを読み出し
        WeatherForecast forcastFromApp = await _daprClient.GetStateAsync<WeatherForecast>(storeName, key);

        // サービス間起動でバックエンドに存在するStateserviceからも読み出し
        WeatherForecast forcastFromService = await _daprClient.InvokeMethodAsync<WeatherForecast>(HttpMethod.Get, "stateservice", "weatherforecast");

        // それぞれの結果をListに格納して返却
        List<WeatherForecast> weatherForecasts = new List<WeatherForecast>();
        weatherForecasts.Add(forcastFromApp); 
        weatherForecasts.Add(forcastFromService);
        return weatherForecasts;
    }

}

tyeで起動したら、まずはStateservice側のSwaggerからGETでステートの値を読み出してみます。

image.png

stateserviceが記録している現在のデータ
{
  "date": "2022-03-03T08:54:48.258Z",
  "temperatureC": 100,
  "temperatureF": 211,
  "summary": "string"
}

次にApp側のSwaggerからGETしてみます。
App側のコードでは、意図的に "temperatureC": 1000にしています。

以下のように返却されました。

image.png

1つ目はAppの中で記録し、読みだした結果です。2つ目はService呼び出しを使って呼び出した結果です。
この結果から、記録したステートは各サービス単位で管理される事がわかります。

[
  {
    "date": "2022-03-03T18:26:21.0188215+09:00",
    "temperatureC": 1000,
    "temperatureF": 1831,
    "summary": "from App"
  },
  {
    "date": "2022-03-03T08:54:48.258Z",
    "temperatureC": 100,
    "temperatureF": 211,
    "summary": "string"
  }
]

ステート状態を各サービス間で共有したい

先ほどの実験では、各サービスが保存したステートは各アプリ個別に保持されている事がわかりました。これはどうしてでしょうか?実際にRedisの中を覗いてみます。(VSCodeでDatabase Clientという拡張を使っています。)

image.png

なるほど、キーを見てみると一目瞭然ですね。サービス名||キー名という形で保持されています。

実は、これはcomponentの設定がデフォルトの状態のままで、初期値であるステートは各サービス単位で保持するというセッティングなっているからです。
詳しくは以下に譲りますが。ステートストアで設定を行う際に、キー管理戦略を変更する事で対応ができます。

以下の行をstatestore.yamlに追加してください。

  - name: keyPrefix
    value: "name"
statestore.yaml
apiVersion: dapr.io/v1alpha1
kind: Component
metadata:
  name: statestore
spec:
  type: state.redis
  version: v1
  metadata:
  - name: redisHost
    value: localhost:6379
  - name: redisPassword
    value: ""
  - name: actorStateStore
    value: "true"
  - name: keyPrefix
    value: "name"

確認、tye runで実行してみましょう。

先ほどと同じように、App側のSwaggerからGETしてみます。
App側のコードでは、意図的に "temperatureC": 1000にしていました。

以下のように返却されました。

image.png

[
  {
    "date": "2022-03-07T16:56:17.3166189+09:00",
    "temperatureC": 1000,
    "temperatureF": 1831,
    "summary": "from App"
  },
  {
    "date": "2022-03-07T16:56:17.3166189+09:00",
    "temperatureC": 1000,
    "temperatureF": 1831,
    "summary": "from App"
  }
]

同じデータが返却されています。これはApp側でステートを保存し、stateserviceに同じデータが共有され、それが返却された為です。
つまり、二つのサービスの間で同じステートが参照できるようになっている事がわかります。

image.png

キー管理戦略がNameになった結果、サービス名||キー名だったのが、メタデータの名前||キー名になっている事がわかると思います。

キー戦略を使い分けよう

サービスの中でしか共有できないキー、サービス間で共有できるキー、これはどちらも使えそうです。
どちらも設定してみます。componentsフォルダの下に新しく sharestore.yaml を追加して、statestore.yaml 微調整します。

どちらも設定

まず、statestore.yamlです。こちらはサービス個別にステートを保持するようにしました。
つまり、影響範囲は同じサービスの中のみで共有されます。例えば、PODでのバッチ処理実行の進行管理とか、サービスの中だけで共有するべきデータなどの場合は便利そうです。

statestore.yaml
apiVersion: dapr.io/v1alpha1
kind: Component
metadata:
  name: statestore
spec:
  type: state.redis
  version: v1
  metadata:
  - name: redisHost
    value: localhost:6379
  - name: redisPassword
    value: ""

次に、sharestore.yamlです。こちらはサービス間での共有ステートを保持するようにしました。先ほどのkeyPrefixを追加し、そして
つまり、影響範囲は同じサービスの中のみで共有されます。例えば、PODでのバッチ処理実行の進行管理とか、サービスの中だけで共有するべきデータなどの場合は便利そうです。

sharestore.yaml
apiVersion: dapr.io/v1alpha1
kind: Component
metadata:
  name: sharestore
spec:
  type: state.redis
  version: v1
  metadata:
  - name: redisHost
    value: localhost:6379
  - name: redisPassword
    value: ""
  - name: actorStateStore
    value: "true"
  - name: keyPrefix
    value: "name"

注意点として、 actorStateStore はどちらか一つにしか設定ができません。これが何故なのか?については、いずれActorサービスモデルについて触れる際にでもトピックにできればと思います。今は、そのまま設定しておいてください。

コードの修正

Appプロジェクトのコードを以下のように書き換えてみました。

WeatherForecastController.cs
using Dapr.Client;
using Microsoft.AspNetCore.Mvc;

namespace App.Controllers;

[ApiController]
[Route("[controller]")]
public class WeatherForecastController : ControllerBase
{
    private readonly ILogger<WeatherForecastController> _logger;

    private readonly DaprClient _daprClient;

    const string shareStore = "sharestore";
    const string storeName = "statestore";
    const string key = "LastWeatherForecast";


    public WeatherForecastController(ILogger<WeatherForecastController> logger, DaprClient daprClient)
    {
        _logger = logger;
        _daprClient = daprClient;
    }

    [HttpGet(Name = "GetWeatherForecast")]
    public async Task<IEnumerable<WeatherForecast>> GetAsync()
    {
        WeatherForecast forcast = new WeatherForecast
        {
            TemperatureC = 1000,
            Summary = "from App",
            Date = DateTime.Now
        };

        // Sharestoreに保存(これはサービスをまたいで保持参照可能)
        // ここでは、仮に全体で識別できるユーザーが以下として、データを保持
        var username = "410e0136-f7d5-437f-a844-e40c0ce40e00";
        await _daprClient.SaveStateAsync(shareStore, username, "from_users_data");

        // Appからステートを保存(これは、Appサービス範囲内でのみ保持)
        await _daprClient.SaveStateAsync(storeName, key, forcast);

        // Appからステートを読み取り(これは、Appサービス範囲内でのみ保持)
        WeatherForecast forcastFromApp = await _daprClient.GetStateAsync<WeatherForecast>(storeName, key);

        // サービス間起動でバックエンドに存在するStateserviceからも読み出し
        // つまり、Stateservice側でのみ、保持されている値をService Invocation経由で読み出している
        WeatherForecast forcastFromService = await _daprClient.InvokeMethodAsync<WeatherForecast>(HttpMethod.Get, "stateservice", "weatherforecast");

        // それぞれの結果をListに格納して返却
        List<WeatherForecast> weatherForecasts = new List<WeatherForecast>();
        weatherForecasts.Add(forcastFromApp); 
        weatherForecasts.Add(forcastFromService);
        return weatherForecasts;
    }

}

試してみる

以下の想定のように動いてくれるか確認しましょう。そろそろ、慣れてきたと思いますが、 tye run で実行しましょう。

image.png

確認しやすいように、Redisも一度キーを削除しました。
image.png

まず、Statestore側のSwaggerから以下のようにPOSTしてみます。ここで記録される値はStatestoreを通じてのみ取得ができるはずです。

image.png

statestoreサービスに記録した値
{
  "date": "2022-03-07T08:34:29.650Z",
  "temperatureC": 2000,
  "summary": "from state store"
}

GETしても、今回記録した値が読み出せます。

image.png

Redisにもstateservice||キー名で登録されているのがわかります。

image.png

次にApp側です。App側のSwaggerからGETしてみます。
App側では、以下のように帰ってくる事が期待動作になります。

  1. フロントのアプリAppでは、App側のDaprを使って直接Statestoreに保存します。温度はわかりやすいように1000にしました。
appで記録した値
{
  "date": "2022-03-07T08:34:29.650Z",
  "temperatureC": 1000,
  "summary": "from app"
}
  1. フロントのアプリAppでは、App側のDaprを使って直接Statestoreから読み出します。 1で保存された内容と同じであるはずです。

3.sharestoreに仮のユーザーIDで保存しました。Redis側にはsharestore||キー名で保存されているはずです。

  1. 次にService invocationを使って、StatestoreサービスからGETで読み出してみます。これは、先ほどStateservice側で記録した以下の値が返却されるはずです。
statestoreサービスに記録した値
{
  "date": "2022-03-07T08:34:29.650Z",
  "temperatureC": 2000,
  "summary": "from state store"
}

image.png

[
  {
    "date": "2022-03-07T17:45:16.8532645+09:00",
    "temperatureC": 1000,
    "temperatureF": 1831,
    "summary": "from App"
  },
  {
    "date": "2022-03-07T08:34:29.65Z",
    "temperatureC": 2000,
    "temperatureF": 3631,
    "summary": "from state store"
  }
]

Redisの中も見てみます。それぞれ、サービス間共有用のステートストアとサービス個別のみで使えるステートストアのどちらとも動作しているようです。

image.png

非常に便利ですね、これならサービス間でデータ保持する為に、Service InvokeでPOSTとか、不要になるんじゃないかって思えちゃいます。もちろん、このあたりは設計次第だと思いますし、目的やサービスドメインの分離とか、思想も考えもあると思うので、いずれ書きたくなったら書く日が来るかもしれません。

6
4
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
6
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?