.NET の Service Discovery だけを使いたい

# .NET Aspire の Service Discovery が便利すぎる

.NET は Service Discovery を簡単に実現できる機能をリリースしています。この機能は.NET Aspire と共に使用されています。

[.NET Aspire service discovery](https://learn.microsoft.com/en-us/dotnet/aspire/service-discovery/overview)

ServiceA から ServiceB へ http/grpc でアクセスする時、環境の違いでホスト名が異なることはよくあります。ローカルでのホスト名は　localhost で、コンテナでホストする場合はコンテナ名がホスト名になる、というパターンは多いでしょう。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/222127/cd6c22c6-7865-ed62-d558-1c9519795f0a.png)

どちらの環境であっても、ソースコードは同一のものを使用したいわけですが、 .NET Aspire は、これを次のように解決します。

AppHostプロジェクトの Program.cs でプロジェクト間の参照をセットアップします。

```csharp:AppHost/Program.cs
var builder = DistributedApplication.CreateBuilder(args);

var serviceb = builder.AddProject<Projects.ServiceB>("serviceb");

builder.AddProject<Projects.ServiceA>("servicea")
    .WithReference(serviceb);


builder.Build().Run();
```

WithReference メソッドで serviceb 変数を受け付けているところが参照関係です。

ServiceA では　Program.cs で HttpClient　の登録を行います。

```diff_c_sharp:ServiceA/Program.cs
var builder = WebApplication.CreateBuilder(args);
+ builder.Services.AddHttpClient("serviceb", client =>
+ {
+     client.BaseAddress = new Uri("http://serviceb");
+ });
```

次に HttpClientFactory のインスタンスをリクエストを投げる箇所でDIしてもらいます。Controllerを使うならコンストラクタインジェクションで、そうでないなら次のように実装します。

```diff_c_sharp:ServiceA/Program.cs
app.MapGet("/", (IHttpClientFactory httpClientFactory) =>
{
    var client = httpClientFactory.CreateClient("serviceb");
    var forecast = client.GetFromJsonAsync<WeatherForecast[]>("weatherforecast");
    
    return forecast;
});
```

環境が変わったとしても、ServiceA は「http://serviceb」 という URL で ServiceBに対してアクセスできます。とても素敵ですね。.NET Aspire はこの仕組みを実現するために.NET の Service Discovery の仕組みを利用しています。.NET Aspire の独自機能ではありません。つまり、.NET Aspire は使いたくないが、Service Discovery だけを使いたい、というシナリオは実現可能です。

# .NET の Service Discovery 概要

一般的な Service Discovery を構成する場合、Service Discovery を実現するためのコンテナをクラスタで立てる形が多いかと思います。しかし、.NET の機能である Service Discovery は、専用のアプリケーションをホスティングする方式ではありません。Http/grpcで外部へアクセスする時、ホスト名を入れ替える、という方法をとっています。

つまり、先ほどの例であれば "http://serviceb" の　**serviceb** を　HttpClient が作成される時に　localhost:port や コンテナ名に入れ替えます。

入れ替えるためには、環境ごとに serviceb がこの環境ではこれ、こっちの環境ではこれ、となるようにどこかに設定が必要です。.NET の Service Discovery はその設定が .NET の構成プロバイダー にあるかどうかをまず確認します。
例えば、環境変数だったり、appsettings.jsonだったりです。

Azure Container Apps や Kubernetesのようにプラットフォームに Service Discovery 機能が付いている環境にデプロイした場合は、当然そちらを使いたいわけですが、.NET Service Discovery はパススルーリゾルバと呼ばれる機能によって自動的にプラットフォームの Service Discovery を使用してくれるので、特別なセットアップは不要です。

# やってみよう

ServiceA から ServiceB を呼び出す時、 http://serviceb で呼び出せるにしてみましょう。

## 開発環境

- Windows 11
- Visual Studio 17.11.0 Preview 2.1

## Web API プロジェクトを2つ作成する

ServiceA、ServiceB ともに ASP.NET Core Web APIプロジェクトテンプレートを使用して作成します。作成時のオプションですが、次のようにしました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/222127/b2cc1607-620b-0159-456f-4b3299ff84b1.png)

作成直後はこんな感じです。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/222127/f67f0f73-9fba-1846-b277-36285180689b.png)

ServiceB に対して ServiceA がリクエストを投げるようにするので、ServiceB が先に起動し、その後に ServiceAが立ち上がるように設定する必要があります。

ソリューションファイルを右クリック→スタートアップ プロジェクトの構成... を選択します。ダイアログが表示されるので、次のようにします。

- マルチ スタートアップ プロジェクトを選択する
- ServiceB が上になるようにする（ServiceBが先に起動する）
- ServiceA、ServiceBともにアクションを「開始」にする

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/222127/861d8d2a-6044-521a-1222-d42fdfbd9e08.png)

## まずは Service Discovery しないで呼び出す

ServiceA の Program.cs を開き、ServiceB を直接呼び出す実装をします。ServiceA の Program.cs を開き、次のように実装します。

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

// Add services to the container.

+ builder.Services.AddHttpClient("serviceb", client =>
+ {
+     client.BaseAddress = new Uri("http://localhost:5189");
+ });

var app = builder.Build();

// Configure the HTTP request pipeline.

- var summaries = new[]
- {
-     "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching"
- };

- app.MapGet("/weatherforecast", () =>
- {
-     var forecast = Enumerable.Range(1, 5).Select(index =>
-         new WeatherForecast
-         (
-             DateOnly.FromDateTime(DateTime.Now.AddDays(index)),
-             Random.Shared.Next(-20, 55),
-             summaries[Random.Shared.Next(summaries.Length)]
-         ))
-         .ToArray();
-     return forecast;
- });
+ app.MapGet("/", async (IHttpClientFactory httpClientFactory) =>
+ {
+     var client = httpClientFactory.CreateClient("serviceb");
+     var response = await client.GetAsync("/weatherforecast");
+     response.EnsureSuccessStatusCode();

+     var forecasts = await response.Content.ReadFromJsonAsync<WeatherForecast[]>();
+     return forecast;
+ });

app.Run();

internal record WeatherForecast(DateOnly Date, int TemperatureC, string? Summary)
{
    public int TemperatureF => 32 + (int)(TemperatureC / 0.5556);
}
```

上から7行目、http://localhost:5189 は ServiceBの URL です。プロジェクトを作成するたびにこのPort番号は変わりますので、ご自身が作成した ServiceB の URL に変更してください。（Properties/launchSettings.json のhttp プロファイル内の applicationUrlを確認）

この http://localhost:5189 をこの後 Service Discovery で自動的に入れ替わるように設定していきます。

ついでに ServiceA の Properties/launchSettings.json ファイルを開いて、http プロファイルの launchUrlを空にしておきましょう。デフォルトで起動時に　/weatherforecast にアクセスするようになっているのをやめて、ルートにアクセスするためです。

```diff_json:ServiceA/Properties/launchSettings.json
{
  "$schema": "http://json.schemastore.org/launchsettings.json",
  "iisSettings": {
    "windowsAuthentication": false,
    "anonymousAuthentication": true,
    "iisExpress": {
      "applicationUrl": "http://localhost:45630",
      "sslPort": 0
    }
  },
  "profiles": {
    "http": {
      "commandName": "Project",
      "dotnetRunMessages": true,
      "launchBrowser": true,
-       "launchUrl": "weatherforecast",
+       "launchUrl": "",
      "applicationUrl": "http://localhost:5022",
      "environmentVariables": {
        "ASPNETCORE_ENVIRONMENT": "Development"
      }
    },
    "IIS Express": {
      "commandName": "IISExpress",
      "launchBrowser": true,
      "launchUrl": "weatherforecast",
      "environmentVariables": {
        "ASPNETCORE_ENVIRONMENT": "Development"
      }
    }
  }
}

```

では実行してみます。2つブラウザが立ち上がり、ServiceBの /weatherforcast へのアクセスと、ServiceA　の / へのアクセスの結果どちらも天気予報データが表示されることを確認したら停止します。

## Serivce Discovery の使用に変更する

ServiceA　の Program.cs を開いて、呼び出し先である ServiceB のURLを 変更します。

```diff_c_sharp:ServiceA/Program.cs
var builder = WebApplication.CreateBuilder(args);

builder.Services.AddHttpClient("serviceb", client =>
{
-     client.BaseAddress = new Uri("http://localhost:5189");
+     client.BaseAddress = new Uri("http://serviceb");
});

var app = builder.Build();

app.MapGet("/", async (IHttpClientFactory httpClientFactory) =>
{
    var client = httpClientFactory.CreateClient("serviceb");
    var response = await client.GetAsync("/weatherforecast");
    response.EnsureSuccessStatusCode();

    var forecasts = await response.Content.ReadFromJsonAsync<WeatherForecast[]>();
    return forecasts;
});

app.Run();

internal record WeatherForecast(DateOnly Date, int TemperatureC, string? Summary)
{
    public int TemperatureF => 32 + (int)(TemperatureC / 0.5556);
}

```

では、実行してみます。servicebなんていうホストは名前解決できないのでエラーになるはずです。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/222127/c3b17aa1-4b58-d8f1-94a7-3f25f068d1f7.png)

予定通りエラーになりました。では Service Discovery を設定しましょう。

## Service Discovery を設定する

Microsoft.Extensions.ServiceDiscovery という Nuget パッケージのインストールが必要です。ServiceA に入れましょう。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/222127/c4230eb5-38f2-0e62-8262-293585fbb719.png)

SerivceA の Program.cs を開いて、２箇所設定します。

```diff_c_sharp:ServiceA/Program.cs
var builder = WebApplication.CreateBuilder(args);

+ builder.Services.AddServiceDiscovery();

builder.Services.AddHttpClient("serviceb", client =>
{
    client.BaseAddress = new Uri("http://serviceb");
- });
+ }).AddServiceDiscovery();

var app = builder.Build();

app.MapGet("/", (IHttpClientFactory httpClientFactory) =>
{
    var client = httpClientFactory.CreateClient("serviceb");

    var forecast = client.GetFromJsonAsync<WeatherForecast[]>("weatherforecast").Result;
    return forecast;
});

app.Run();

internal record WeatherForecast(DateOnly Date, int TemperatureC, string? Summary)
{
    public int TemperatureF => 32 + (int)(TemperatureC / 0.5556);
}
```

次に serviceb を実際の URL に入れ替える設定を複数パターンやってみます。

## 構成ファイルでセットアップする

.NET の Service Discovery はホスト名の入れ替え設定を構成ファイルでセットアップできます。ローカル環境用だと考えれば良いでしょう。appsettings.json または appsettings.Development.json を開いて、次のように設定します。

```diff_json:ServiceA/appsettings.json
{
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft.AspNetCore": "Warning"
    }
  },
-   "AllowedHosts": "*"
+   "AllowedHosts": "*",
+   "Services": {
+     "serviceb": {
+       "http": [
+         "localhost:5189"
+       ]
+     }
+   }
}
```

ServicesというKeyの下に入れ替えたい serviceb とlocalhost:5189を設定しています。今回は HTTPS 用の設定をしないで WebApi プロジェクトを作成しましたので http だけしか設定していませんが、もし HTTPS 用の設定を ON にした場合は https に変更します。では実行してみてください。うまく天気予報データが取得できるはずです。

:::note info
Services という Key 名は変更できます。ConfigurationServiceEndPointResolverOptionsクラスを使って、SectionNameを任意の名称にします。詳しくはリンクをご確認ください。

[.NET でのサービス検出 - 構成](https://learn.microsoft.com/ja-JP/dotnet/core/extensions/service-discovery?tabs=dotnet-cli#configuration)
:::

## 環境変数でセットアップする

本番環境では、環境変数を使うことも多いでしょう。環境変数に次のどちらかの書き方で設定するだけです。

|key|value|
|:-:|:-----:|
|Services:serviceb:http|localhost:51８９|
|Services__serviceb__http|http://localhost:5189|

appsettings.jsonの設定をもとに戻して、環境変数を設定してみます。SerivceA　の　launchSettings.json を開いて次のように設定します。

```diff_c_sharp:ServiceA/Properties/launchSettings.json
{
  "$schema": "http://json.schemastore.org/launchsettings.json",
  "iisSettings": {
    "windowsAuthentication": false,
    "anonymousAuthentication": true,
    "iisExpress": {
      "applicationUrl": "http://localhost:63947",
      "sslPort": 0
    }
  },
  "profiles": {
    "http": {
      "commandName": "Project",
      "dotnetRunMessages": true,
      "launchBrowser": true,
      "launchUrl": "",
      "applicationUrl": "http://localhost:5253",
      "environmentVariables": {
-         "ASPNETCORE_ENVIRONMENT": "Development"
+         "ASPNETCORE_ENVIRONMENT": "Development",
+         "Services:serviceb:http": "localhost:5189"
      }
    },
    "IIS Express": {
      "commandName": "IISExpress",
      "launchBrowser": true,
      "launchUrl": "weatherforecast",
      "environmentVariables": {
        "ASPNETCORE_ENVIRONMENT": "Development"
      }
    }
  }
}
```

## InMemoryでセットアップする

もしかしたら .NET Aspire のようにリクエスト先のホストを動的セットアップしたい、なんて要件があるかもしれません。その場合は次の実装を ServiceA　の Program.cs に追加します。

```csharp:SerivceA/Program.cs
builder.Configuration.AddInMemoryCollection(new Dictionary<string, string?>()
{
    ["Services:serviceb:http"] = "localhost:5189"
});
```

この実装は、　 var app = builder.Build()という実装より上であればどこに実装しても大丈夫です。
この例では固定文字列で入れ替えるホストを指定していますが、引数で受け取るようにすれば動的になりますね。

# まとめ

入れ替え先の設定として構成ファイルや環境変数を例にしましたが、.NET の Service Discovery は構成プロバイダーを使用していますので、色んな設定が可能です。

[.NET での構成](https://learn.microsoft.com/ja-jp/dotnet/core/extensions/configuration)

ということは Azure Key Vault や App Configuration などの外部にセットアップした構成を使うこともできます。.NET Aspire を使うのが一番簡単ではありますが、デプロイ先が Azure Container Apps に限定されていますので、Serivce Discovery だけを使うパターンを是非活用してみてください。