.NET Aspire でデータベースを扱う - SQL Server 編

この記事は .NET Aspire に関する一連の記事の一部です。

- [.NET Aspire って何？ - 概要](https://qiita.com/takashiuesaka/items/f45e930ef296c5710acc)
- [.NET Aspire を使ってみる](https://qiita.com/takashiuesaka/items/8794f99b0bf3ce3c3106)
- [.NET Aspire を デプロイする](https://qiita.com/takashiuesaka/items/3da744d0511f29270299)
- [.NET Aspire で Prometheus, Jaeger, Grafana を使う](https://qiita.com/takashiuesaka/items/4811e62aa52e39ed834c)
- [Next.js + ASP.NET Core を .NET Aspire で構成する（with YARP）](https://qiita.com/takashiuesaka/items/e167852af299a7b00939)
- [.NET Aspire でデータベースを扱う - PostgreSQL編](https://qiita.com/takashiuesaka/items/137cb1450c3d9fbc8d1e)
- .NET Aspire でデータベースを扱う - SQL Server編
- [.NET Aspire のダッシュボードを単独で使う](https://qiita.com/takashiuesaka/items/338ae64ca2033e078f7f)

.NET Aspire + Dapr についてはこちらをご覧ください。メインは Dapr についてですが、.NET Aspire を使用する場合についても記載があります。

- [Dapr って何？ - 概要編](https://qiita.com/takashiuesaka/items/ecb8d232d00f0a6591a5)
- [Dapr を使ってみる - 入門編](https://qiita.com/takashiuesaka/items/bdae30de41b0daab881f)
- [Dapr を使ってみる - State management 編](https://qiita.com/takashiuesaka/items/713499c531095d5687c2)

# .NET Aspire で SQL Server を使用する

.NET Aspire は データベースのサポートもかなり手厚いです。.NET Aspire は分散アプリケーション開発を楽にするための機能としてローカル環境でコンテナの扱いが得意です。そのため、ローカル開発ではコンテナのDBを使用し、クラウドにデプロイした後はマネージドのDBを使う、というシナリオにも対応しています。

この記事では SQL Server を使う場合の.NET Aspire の使い方についてご紹介します。

## 開発環境

- Windows 11
- Visual Studio 2022 17.11.0 Preview 2
    - .NET Aspire 8.0.1
- Docker Desktop 4.31.1 (153621)
- PowerShell 7.4.2
- Azure Developer CLI 1.9.3

.NET Aspire は VSCode でも扱うことができますが、本記事では Visual Studio 2022 Preview 版を使用します。

## 1. .NET Aspire Starter アプリケーションを作成する

今回は .NET Aspire Starter アプリケーションをベースにアプリケーションを少しずつ段階を踏んで SQL Server 対応していきます。そのため、この記事に辿り着いた方にとって必要な情報がどこかにあると思います。

まずは .NET Aspire Starter アプリケーションを作成します。「プロジェクトの種類」検索ボックスで.NET Aspire を選ぶとすぐ表示されます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/222127/0452ee6e-50cf-e87e-5db7-ff2bbe56decf.png)

この次の次の画面で Redis Cache を使用するかを選択するダイアログが表示されます。使用してもしなくてもどちらも構いませんが、この記事では使用していません。

.NET Aspire Starter アプリケーションには天気予報データを表示する画面があります。この画面では 天気予報データをランダムに作成する ApiService プロジェクトに対して Blazor Web プロジェクトからアクセスしてそれを画面に表示します。

Blazor Web プロジェクトの天気予報画面
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/222127/88e5df0c-97eb-00a4-6a2e-80ec8899dc2e.png)

ApiService のエンドポイントに直接アクセスして結果を確認することもできます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/222127/130e4d23-023c-e674-1e6e-b7d268bf6030.png)

まずは ApiService プロジェクトを修正して、 SQL Server サーバーにアクセスして天気予報データを取得した結果を返却するようにします。

## 2. SQL Server サーバーを作成し、データを用意する

どこかに SQL Server サーバーを構築します。この記事では最後に Azure にデプロイしますので、Azure 上に Azure SQL Database サーバーを構築しました。

**testdb**、というデータベースを作成した後、その中に Weatherforecasts テーブルを作って天気予報データを挿入しておきます。

```sql
CREATE TABLE WeatherForecasts
(
 [Id] UNIQUEIDENTIFIER NOT NULL PRIMARY KEY, 
    [Date] DATE NOT NULL, 
    [TemperatureC] INT NOT NULL, 
    [Summary] NVARCHAR(MAX) NULL
)

INSERT INTO WeatherForecasts (Id, Date, TemperatureC, Summary)
VALUES
    ('6555da29-89f1-4207-b20d-bd8d507e7c32', '2024-03-25', 17, N'肌寒い'),
    ('b3d43a4a-c877-4580-89af-2e26ed7e68e2', '2024-03-22', 9, N'寒い'),
    ('b57deef9-1d55-496d-a4ac-1ac88c93a4e2', '2024-03-23', 14, N'寒い'),
    ('c84c8347-e6f4-44da-a27d-9f5c0f6ca3dd', '2024-03-21', 7, N'寒い'),
    ('ee4f05a1-2435-44b6-b868-5434cc87bcfa', '2024-03-24', 11, N'寒い')
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/222127/c88c22e3-bdc4-41de-27c2-ad6bd9515ecf.png)

## 3. SQL Server 用の .NET Aspire コンポーネントをインストールする

ソリューションエクスプローラーの ApiService プロジェクトを右クリック→追加→.NET Aspire パッケージを選択します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/222127/567d96fc-d1b0-01d8-634d-656d0ec52453.png)

Nuget パッケージマネージャーが立ち上がり、 .NET Aspire パッケージだけが表示されるように検索条件が入力された結果が表示されますので、検索条件に追加で半角スペース＋sqlserver と入力します。すると、SQLServer 用の.NET Aspire コンポーネントだけが表示されます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/222127/1830589c-a1fa-75d2-2f42-fbd67eec5a2e.png)

今回は EntityFrameworkCore を使用しますので、Aspire.Microsoft.EntityFrameworkCore.SqlServer をインストールします。

## 4. 接続文字列をコード内にハードコーディングする

一度の沢山の実験をするとトラブルが起こった場合に原因特定が難しくなります。まずはインストールした SQLServer 用の.NET Aspire コンポーネントが正しく動作することを確認するため、接続文字列はコード内にハードコーディングして問題なく SQLServer に接続できることを確認します。

ApiService プロジェクトの Program.cs を開きます。一番を下までスクロールするとランダム生成した天気予報データを格納する WeatherForecast レコードの宣言があります。これに Guid 型の Id 列を追加します。

```diff_c_sharp:ApiService/Program.cs
- record WeatherForecast(DateOnly Date, int TemperatureC, string? Summary)
+ record WeatherForecast([property: Key]Guid Id, DateOnly Date, int TemperatureC, string? Summary)
{
    public int TemperatureF => 32 + (int)(TemperatureC / 0.5556);
}
```

Keyクラスが見当たらないくてエラーとなりますので、Program.cs の一番上に次の実装を追加します。

```csharp:ApiService/Program.cs
using System.ComponentModel.DataAnnotations;
```

[property: Key]Guid Id という実装に見慣れない方がきっといらっしゃると思います。プライマリコンストラクタは引数から自動的にプロパティを生成します。そのプロパティに属性をつける場合、[property: 属性クラス]と言う実装をします。つまり [property: Key]Guid Id は

```csharp
[Key]
public Guid Id { get; }
```

と同じです。

次に EntityFrameworkCore を使って DB アクセスするために、DbContextを継承したクラスを実装します。今回は ApiService プロジェクトの Program.cs の一番下に実装します。

```csharp:ApiService/Program.cs
internal class WeatherForcastDbContext : DbContext
{
    public WeatherForcastDbContext() { }
    public WeatherForcastDbContext(DbContextOptions<WeatherForcastDbContext> options) : base(options) { }

    public DbSet<WeatherForecast> WeatherForecasts { get; set; }

    protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
    {
        optionsBuilder.UseSqlServer("Server=tcp:<serverurl>,1433;Initial Catalog=testdb;Persist Security Info=False;User ID=<userid>;Password=<password>;MultipleActiveResultSets=False;Encrypt=True;TrustServerCertificate=False;Connection Timeout=30;");
    }
}
```

接続文字列はご自身の環境に合わせて変更してください。Initial Catalog=testdb、をお忘れなく。

/weatherforecast にアクセスされた場合、テンプレートではランダム生成した天気予報データを返却するようになっているのですが、 SQL Server からデータを取得するように変更します。

```diff_c_sharp:ApiService/Program.cs
- 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();
+ app.MapGet("/weatherforecast", async () =>
+ {
+     var forecast = await new WeatherForcastDbContext().WeatherForecasts.ToListAsync();
+ 
+     return forecast;
+ });
```

AppHostを起動して無事にデータが取得できるかどうかを確認します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/222127/c8a65eda-0eee-4f17-5e40-f7476eabae5f.png)

Summary に日本語が入っているデータは SQL Server に格納されているデータです。ランダム生成ではないので、リロードしても常に同じデータしか表示されませんが、それで正しく実装されていることがわかります。

## 5. 接続文字列を設定ファイルに外出しする

コード内にハードコードした接続文字列を設定ファイルに移動します。これに伴い、先ほど作成した DbContext を継承する WeatherForcastDbContext を DI　で受け取るように修正します。

ApiService プロジェクトの appsettings.Development.jsonを開きます。appsettings.jsonに隠れているので、白三角をクリックして開くと見えるようになります。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/222127/dc503364-c72d-0c2f-1ac8-bdd508983052.png)

次のように設定ファイルに接続文字列を設定します。ConnectionStrings の Key として "weatherdb" を使用することにします。このKey名はとても重要で、この後色んなところで使用します。

```diff_json:ApiService/appsettings.Development.json
{
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft.AspNetCore": "Warning"
    }
- }
+ },
+ "ConnectionStrings": {
+   "weatherdb": "Server=tcp:<serverurl>,1433;Initial Catalog=testdb;Persist Security Info=False;User ID=<userid>;Password=<password>;MultipleActiveResultSets=False;Encrypt=True;TrustServerCertificate=False;Connection Timeout=30;"
+ }
}
```

WeatherForcastDbContext にハードコードしていた接続文字列を使用する実装を削除します。また、DI で WeatherForcastDbContext オブジェクトを受け取るため、既定の（パラメータ無しの）コンストラクタは不要になります。同時に削除しておきます。

```diff_c_sharp:ApiService/Program.cs
internal class WeatherForcastDbContext : DbContext
{
-   public WeatherForcastDbContext() { }
    public WeatherForcastDbContext(DbContextOptions<WeatherForcastDbContext> options) : base(options) { }

    public DbSet<WeatherForecast> WeatherForecasts { get; set; }

-   protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
-   {
-       optionsBuilder.UseSqlServer("Server=tcp:<serverurl>,1433;Initial Catalog=testdb;Persist Security Info=False;User ID=<userid>;Password=<password>;MultipleActiveResultSets=False;Encrypt=True;TrustServerCertificate=False;Connection Timeout=30;");
-   }
}
```

WeatherForcastDbContext オブジェクトを DI で受け取るためのセットアップをします。次の実装を ApiService プロジェクトの Program.cs ファイルに追加します。

```diff_c_sharp:ApiService/Program.cs
・・・
+ builder.AddSqlServerDbContext<WeatherForcastDbContext>("weatherdb");

var app = builder.Build();
・・・
```

引数の weatherdb はもちろん設定ファイルの ConnectionStrings に指定したKeyの "weatherdb" です。

/weatherforcast にアクセスがあった時のハンドラメソッドの引数で WeatherForcastDbContext オブジェクトを受け取り、DB アクセスに使用するように実装を変更します。

```diff_c_sharp:ApiService/Program.cs
- app.MapGet("/weatherforecast", async () =>
+ app.MapGet("/weatherforecast", async (WeatherForcastDbContext weatherForcastDbContext) =>
{
-   var forecast = await new WeatherForcastDbContext().WeatherForecasts.ToListAsync();
+   var forecast = await weatherForcastDbContext.WeatherForecasts.ToListAsync();

    return forecast;
});
```

実装は終了です。問題なく動作するか起動して確認してください。完成した ApiService プロジェクトの Program.cs は次のようになります。

```csharp:ApiService/Program.cs
using Microsoft.EntityFrameworkCore;
using System.ComponentModel.DataAnnotations;

var builder = WebApplication.CreateBuilder(args);

// Add service defaults & Aspire components.
builder.AddServiceDefaults();

// Add services to the container.
builder.Services.AddProblemDetails();

builder.AddSqlServerDbContext<WeatherForcastDbContext>("weatherdb");

var app = builder.Build();

// Configure the HTTP request pipeline.
app.UseExceptionHandler();

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

app.MapGet("/weatherforecast", async (WeatherForcastDbContext weatherForcastDb) =>
{
    var forecast = await weatherForcastDb.WeatherForecasts.ToListAsync();

    return forecast;
});

app.MapDefaultEndpoints();

app.Run();

record WeatherForecast([property: Key]Guid Id, DateOnly Date, int TemperatureC, string? Summary)
{
    public int TemperatureF => 32 + (int)(TemperatureC / 0.5556);
}

internal class WeatherForcastDbContext : DbContext
{
    public WeatherForcastDbContext(DbContextOptions<WeatherForcastDbContext> options) : base(options) { }

    public DbSet<WeatherForecast> WeatherForecasts { get; set; }
}
```

## 6. 接続文字列を AppHost プロジェクトに移動する

ここからが .NET Aspire の本領発揮です。現在、接続文字列は ApiService プロジェクトの設定ファイル appsettings.Development.json に記載されています。つまり ApiService プロジェクトで DB への接続先を管理しています。このままであっても本番環境にデプロイすると、接続文字列は環境変数から取得しますので、適切な環境変数を手動で設定すれば問題はありません。

しかし、ApiService プロジェクトのような個別のアプリケーションが沢山ある場合はどうでしょうか。個々のプロジェクトで DB の接続文字列のような別のリソースへの依存情報を管理するのではなく、１箇所でまとめて管理した方が見通しは格段に良くなります。

.NET Aspire では　AppHost の Program.cs でコードを用いて依存を表現することで依存関係をわかりやすくします。やってみましょう。まず ApiService プロジェクトの appsettings.Development.json に記載した接続文字列を、 AppHost プロジェクトの appsettings.Development.json に移動します。

```diff_json:ApiService/appsettings.Development.json
{
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft.AspNetCore": "Warning"
    }
+ }
- },
- "ConnectionStrings": {
-   "weatherdb": "Server=tcp:<serverurl>,1433;Initial Catalog=testdb;Persist Security Info=False;User ID=<userid>;Password=<password>;MultipleActiveResultSets=False;Encrypt=True;TrustServerCertificate=False;Connection Timeout=30;"
- }
}
```

```diff_json:AppHost/appsettings.Development.json
{
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft.AspNetCore": "Warning"
    }
- }
+ },
+ "ConnectionStrings": {
+   "weatherdb": "Server=tcp:<serverurl>,1433;Initial Catalog=testdb;Persist Security Info=False;User ID=<userid>;Password=<password>;MultipleActiveResultSets=False;Encrypt=True;TrustServerCertificate=False;Connection Timeout=30;va"
+ }
}
```

ここで一度アプリケーションを起動してみて、エラーになることを確認しておきましょう。

起動後に Web アプリの画面で天気予報のページを開くと、Loading...と表示されたままデータが表示されません。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/222127/d4617159-0918-ca63-7cf1-b497b4cfda6c.png)

ApiService プロジェクトの ログをダッシュボードで表示してみます。すると、「ConnectionString is missing.」とエラーメッセージがあります。想定通りのエラーですね。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/222127/e0919b4b-d842-da89-44f0-2e4ec93d7e27.png)

では、移動した接続文字列を AppHost プロジェクトで読み込み、 ApiService プロジェクトに渡す実装をします。

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

+ var weatherSampleDb = builder.AddConnectionString("weatherDb");

- var apiService = builder.AddProject<Projects.SQLServerDemo_ApiService>("apiservice");
+ var apiService = builder.AddProject<Projects.SqlServerDemo_ApiService>("apiservice")
+     .WithReference(weatherSampleDb);

builder.AddProject<Projects.SQLServerDemo_Web>("webfrontend")
    .WithExternalHttpEndpoints()
    .WithReference(apiService);

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

buidler.AddConnectionString メソッドを使うことで、設定ファイルの接続文字列を取得します。ApiService プロジェクトでは WithReference 拡張メソッドでそれを受け取るように実装することで、環境変数として接続文字列を受けとるようになります。

アプリケーションを起動し、問題なく動作することを確認してください。

この時、　ApiService プロジェクトのソースコードには何も手を加えることなく動作したことも重要なポイントです。.NET Aspire を使用することで、外部リソースへの依存から切り離して個々のプロジェクトを実装・管理していくことができるようになります。

# SQL Server コンテナを使用する

ここまではサーバーに建てた SQL Server を使用しています。開発時のベストなシナリオは、開発時はローカル環境のコンテナでDBを使用し、本番では既に構築済みのDBを使用する、というシナリオです。ではここから一旦ローカル環境でコンテナのDBを使用する方法を少しずつ構築していきます。

## 1. 最低限のオプションで起動する

AppHost プロジェクトの Program.cs を修正し、SQL Server DBをコンテナで動作するように設定します。

AppHost プロジェクトを右クリック→追加→.NET Aspire パッケージを選択します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/222127/afe33655-d35c-75de-5c8a-3e3ee3427639.png)

NuGet パッケージマネージャーが立ち上がり、検索文字列に既定値が入っています。既定値の後ろに半角スペース+sqlserver と入力します。

検索結果の Aspire.Hosting.SqlServer をインストールします。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/222127/0a69fe15-08b8-7c54-14a7-4d6e4effd80c.png)


Program.cs で次の実装を行います。

```diff_c_sharp:AppHost/Program.cs
- var weatherSampleDb = builder.AddConnectionString("weatherDb");

+ var weatherSampleDb = builder.AddSqlServer("mysqlserver")
+     .AddDatabase("weatherdb", "testdb");
```

コンテナとして立ち上げる SQL Server のインスタンス名を mysqlserver としました。この名前はローカルDBにしか関係がありませんので好きな名前を付けてOKです。
そして誰もが誤解するのが AddDatabase メソッドです。第一引数に接続文字列のKeyとなる "weatherdb" をセットし、第二引数にデータベースの中に実際に作成しておく必要がある "testdb" をセットしています。

この "testdb" は、この記事の二番目で Azure 上に構築した Azure SQL Database の中に作成した "testdb" と言うデータベースと同じ名前にしておく必要があります。後でこのアプリケーションを Azure 上にデプロイするのですが、その時 Azure 上に構築した SqlDatabase の testdb にアクセスしたいからです。

また先ほど実装した接続文字列を取得する builder.AddConnectionString メソッドのはもう不要なので削除しました。

ここでアプリケーションを起動します。事前に Docker Desktop を立ち上げることをお忘れなく。ダッシュボードを見ると、初回起動はイメージの Pull に時間がかかります。しばらく待つと、先ほどまでの Webアプリと ApiService に加えて SQL Server のコンテナが立ち上がっていることがわかります。AddDatabase で追加した weatherdb もリソースとして認識しています。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/222127/6cc4750d-e78a-8c31-c302-c0aeca0c86ec.png)

Docker Desktop をみて見ると、確かに SQL Server のコンテナが稼働中です。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/222127/66252bb7-7fbb-e7db-b7b8-c3686b902814.png)

では SQL Server にアクセスしてみましょう。.NET Aspire は SQL Server コンテナを立ち上げる時に毎回パスワードとポート番号を変更します。ダッシュボードから SQL Server コンテナの詳細列の表示リンクをクリックし、一番下の環境変数に表示される MSSQL_SA_PASSWORD　からパスワードを取得します。また、ポート番号はエンドポイントに表示されています。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/222127/95dd712c-0e34-397e-c1ee-b1d7865005d1.png)

[SSMS (SQL Server Management Studio)](https://learn.microsoft.com/ja-jp/sql/ssms/download-sql-server-management-studio-ssms)、または [Azure Data Studio](https://learn.microsoft.com/ja-jp/azure-data-studio/) を使って接続します。ユーザーID はお馴染みの「sa」です。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/222127/d7b144e7-82d2-c061-c5e4-b04dde7f6e39.png)

データベースを見てみます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/222127/9d61c0d0-f81f-947e-a321-f9d2140af89a.png)

接続後、データベース を開いてもフォルダしかありません。本来、ここに testdb と言う名前のデータベースが表示されると予想していたはずです。コードで .AddDatabase("weatherdb", "testdb")と実装したからです。なのに testdb がありません。なぜでしょうか。

実は AddDatabase メソッドはデータベースを作成するメソッドではないのです。このメソッドはデータベースへの参照を表すものであって、データベースの作成はしません。大変ややこしいので覚えておく必要があります。

この紛らわしいメソッド名についてはだいぶ早い段階で issue が上がっていますが、2024/6 現在では修正する方針になっていません。

[Aspire.Npgsql component does not create database](https://github.com/dotnet/aspire/issues/1170)

ちなみにまだセットアップは終わっていないので天気予報ページはアクセスしてもエラーになります。

## 2. 初期値（Seed）を自動挿入する

:::note warn
初期値を設定する方法は Windows のみの対応です。Macの場合はこの下に記載した「テストデータを永続化して管理したい」をご参照ください。
:::

コンテナ立ち上げ時に testdb を作成し、テーブルとデータを自動的に挿入するようにします。まずスクリプトの置き場所を用意します。場所はどこでも良いのですが、今回は　AppHostプロジェクトの直下に data という名前でフォルダを作成することにします。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/222127/dc21ad06-45e7-ec60-bc79-27b58cf191a5.png)

data フォルダの中に init.sql ファイルを新規作成します。ファイル名は　init.sql 以外でもOKですが、拡張子は必ず.sql にしてください。init.sql の中に次のSQLを貼り付けます。

```sql:AppHost/data/init.sql
IF NOT EXISTS (SELECT * FROM sys.databases WHERE name = N'testdb')
BEGIN
  CREATE DATABASE testdb;
END;
GO

USE [testdb]
GO

SET ANSI_NULLS ON
GO

SET QUOTED_IDENTIFIER ON
GO

CREATE TABLE [dbo].WeatherForecasts
(
 [Id] UNIQUEIDENTIFIER NOT NULL PRIMARY KEY, 
    [Date] DATE NOT NULL, 
    [TemperatureC] INT NOT NULL, 
    [Summary] NVARCHAR(MAX) NULL
)

INSERT INTO WeatherForecasts (Id, Date, TemperatureC, Summary)
VALUES
    ('6555da29-89f1-4207-b20d-bd8d507e7c32', '2024-03-25', 17, N'Warm'),
    ('b3d43a4a-c877-4580-89af-2e26ed7e68e2', '2024-03-22', 9, N'Cold'),
    ('b57deef9-1d55-496d-a4ac-1ac88c93a4e2', '2024-03-23', 14, N'warm'),
    ('c84c8347-e6f4-44da-a27d-9f5c0f6ca3dd', '2024-03-21', 7, N'Toooo Cold'),
    ('ee4f05a1-2435-44b6-b868-5434cc87bcfa', '2024-03-24', 11, N'Cold')
```

testdb データベースが無かったら作成し、さらに WeatherForecasts テーブルも無かったら作成し、テスト用データを Insert する、というSQLです。

この init.sql を起動時に実行するためには shell を用意する必要があります。shell の保存場所も data フォルダと同様どこでも良いのですが、今回は AppHostプロジェクトの直下に sqlserverconfig という名前でフォルダを作成することにします。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/222127/a8a4e46c-9881-07c1-a9ac-3222c8d314f7.png)

sqlserverconfig フォルダに entrypoint.sh というファイルを作成して次の内容を貼り付けます。

```shell:AppHost/sqlserverconfig/entrypoint.sh
#!/bin/bash

# Adapted from: https://github.com/microsoft/mssql-docker/blob/80e2a51d0eb1693f2de014fb26d4a414f5a5add5/linux/preview/examples/mssql-customize/entrypoint.sh

# Start the script to create the DB and user
/usr/config/configure-db.sh &

# Start SQL Server
/opt/mssql/bin/sqlservr
```

このスクリプトでは configure-db.sh ファイルを実行していますので、configure-db.sh ファイルも作成して、次の内容を貼り付けます。

```shell:AppHost/sqlserverconfig/configure-db.sh
#!/bin/bash

# set -x

# Adapted from: https://github.com/microsoft/mssql-docker/blob/80e2a51d0eb1693f2de014fb26d4a414f5a5add5/linux/preview/examples/mssql-customize/configure-db.sh

# Wait 60 seconds for SQL Server to start up by ensuring that
# calling SQLCMD does not return an error code, which will ensure that sqlcmd is accessible
# and that system and user databases return "0" which means all databases are in an "online" state
# https://docs.microsoft.com/en-us/sql/relational-databases/system-catalog-views/sys-databases-transact-sql?view=sql-server-2017

dbstatus=1
errcode=1
start_time=$SECONDS
end_by=$((start_time + 60))

echo "Starting check for SQL Server start-up at $start_time, will end at $end_by"

while [[ $SECONDS -lt $end_by && ( $errcode -ne 0 || ( -z "$dbstatus" || $dbstatus -ne 0 ) ) ]]; do
    dbstatus="$(/opt/mssql-tools/bin/sqlcmd -h -1 -t 1 -U sa -P "$MSSQL_SA_PASSWORD" -Q "SET NOCOUNT ON; Select SUM(state) from sys.databases")"
    errcode=$?
    sleep 1
done

elapsed_time=$((SECONDS - start_time))
echo "Stopped checking for SQL Server start-up after $elapsed_time seconds (dbstatus=$dbstatus,errcode=$errcode,seconds=$SECONDS)"

if [[ $dbstatus -ne 0 ]] || [[ $errcode -ne 0 ]]; then
    echo "SQL Server took more than 60 seconds to start up or one or more databases are not in an ONLINE state"
    echo "dbstatus = $dbstatus"
    echo "errcode = $errcode"
    exit 1
fi

# Loop through the .sql files in the /docker-entrypoint-initdb.d and execute them with sqlcmd
for f in /docker-entrypoint-initdb.d/*.sql
do
    echo "Processing $f file..."
    /opt/mssql-tools/bin/sqlcmd -S localhost -U sa -P "$MSSQL_SA_PASSWORD" -d master -i "$f"
done
```

ファイルの内容は、SQL server の起動を待ち、その後 /docker-entrypoint-initdb.d/ フォルダにある拡張子 .sql ファイルを全て実行します。つまり先ほど追加した data フォルダの中の init.sql を実行するように、ローカルの data フォルダとコンテナの /docker-entrypoint-initdb.d フォルダを紐づければ良いわけです。

準備ができたので、次の実装を追加してコンテナ起動時にスクリプトが実行されるようにします。

```diff_c_sharp:AppHost/Program.cs
var weatherSampleDb = builder.AddSqlServer("mysqlserver")
+   .WithBindMount("./sqlserverconfig", "/usr/config")
+   .WithBindMount("./data", "/docker-entrypoint-initdb.d")
+   .WithEntrypoint("/usr/config/entrypoint.sh")
    .AddDatabase("weatherdb", "testdb");
```

Docker のバインドマウント機能を使って、data フォルダと sqlserverconfig フォルダを コンテナ側からはアクセスできるようにします。エントリーポイントに先ほど追加した entrypoint.sh を指定することで、data/init.sql が実行されます。

アプリケーションを実行して　WebFrontend 画面の天気予報ページを開いてみます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/222127/5cc00e1c-bc32-c176-eef6-76ea5862b167.png)

表示された天気予報データが INSERT 文で投入されたデータであることから、コンテナで稼働している SQL Server からデータを取得していることがわかります。

もし、/usr/config/entrypoint.sh not found のような謎のエラーが出て上手く動作しない場合は次のリンク先からファイルをダウンロードして使用してください。sqlserverconfig フォルダごとダウンロードした方が間違いありません。

https://github.com/dotnet/aspire-samples/tree/main/samples/DatabaseContainers/DatabaseContainers.AppHost/sqlserverconfig

### テストデータを永続化して管理したい

アプリケーションを停止する度にコンテナは削除されます。つまりテストデータも消えてしまいます。テストデータを毎回作成するのではなく、永続化し、別のツールからテストデータを入力・編集などの管理をしたい場合もあるでしょう。テストデータを永続化する場合は、次の実装をします。

```diff_c_sharp:AppHost/Program.cs
var weatherSampleDb = builder.AddSqlServer("mysqlserver")
    .WithBindMount("./sqlserverconfig", "/usr/config")
    .WithBindMount("./data", "/docker-entrypoint-initdb.d")
    .WithEntrypoint("/usr/config/entrypoint.sh")
+   .WithDataVolume("VolumeMount.sqlserver.data")
    .AddDatabase("weatherdb", "testdb");
```

ボリュームマウントとは Docker が管理するボリュームです。そしてこの実装方法は MS Learn のSQL Server Docker コンテナーの構成についてのページに解説があります。

https://learn.microsoft.com/ja-jp/sql/linux/sql-server-linux-docker-container-configure?view=sql-server-ver16&pivots=cs1-bash#use-data-volume-containers

Docker Desktop を確認すると、引数に指定した名前でボリュームが作成されたことがわかります。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/222127/4cbbef00-415f-2636-4a5c-92e9df8177d7.png)

確かにこの方法でデータを永続化できますが、永続化の対象はデータだけではなく DB 丸ごとです。そして永続化した DB に対して毎回 Seed 用のスクリプトが起動します。スクリプトは途中でエラーが発生するとそれ以降実行されません。そのため、DB・テーブル作成スクリプトとデータ作成スクリプトを分けるなど工夫が必要です。基本的にはテストデータの Seed を毎回投入するか、永続化したら以降はツールでデータを管理するどちらかを選ぶことになるでしょう。

### DB を永続化する場合の注意

DB を永続化する場合はさらに注意が必要です。永続化はデータだけではなく DB 丸ごとです。そして DB の中には接続するためのユーザー情報が含まれています。一番最初に永続化した時、ユーザーはランダムなパスワードと共に作成されて永続化されます。

つまり問題は、２回目以降アプリケーションを起動した時です。初回起動時に作成したユーザーのパスワードを.NET Aspire は覚えていません。そのため、.NET Aspire はいつものようにランダム生成したパスワードを使った接続文字列をアプリケーションに渡しますが、当然初回起動時のパスワードとは異なるため、アプリケーションは DB にアクセスできずエラーとなってしまいます。

そのため、初回起動時のユーザーパスワードを忘れずにメモしておかなければならないのですが、これは微妙な運用です。そこでパスワードをランダム生成したものではなく、最初から自分で用意したものを使用するように変更しましょう。次のように実装します。

AppHost プロジェクトのシークレットを使用するか、appsettings.Development.json を開いて、次のようにKey
 とパスワードをセットします。

  ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/222127/6cf4ad7e-e5c9-03d9-5470-548f0cb24268.png)

```AppHost/secrets.json
{
  "Parameters": {
    "sqlserverPassword": "p@ssw0rd1234"
  }
}
```
または

```AppHost/appsettings.Development.json
{
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft.AspNetCore": "Warning"
    }
  },
  "ConnectionStrings": {
    "weatherdb": "Server=<serverurl>;Database=testdb;Port=5432;User Id=<userid>;Password=<password>;Ssl Mode=Require;"
  },
  "Parameters": {
    "sqlserverPassword": "p@ssw0rd1234"
  }
}
```

secrets.json または appsettings.Development.json にセットした Key の sqlserverPassword を参照するように実装します。

```diff_c_sharp:AppHost/Program.cs
+ var sqlserverPassword = builder.AddParameter("sqlserverPassword");

- var weatherSampleDb = builder.AddSqlServer("mysqlserver")
+ var weatherSampleDb = builder.AddSqlServer("mysqlserver", password: sqlserverPassword)
    .WithBindMount("./sqlserverconfig", "/usr/config")
    .WithBindMount("./data", "/docker-entrypoint-initdb.d")
    .WithEntrypoint("/usr/config/entrypoint.sh")
    .WithDataVolume("VolumeMount.sqlserver.data")
    .AddDatabase("weatherdb", "testdb");
```

アプリケーションを起動させて先ほどと同様にダッシュボードから確認すると、設定したパスワードに変更されています。言うまでもありませんが、実際に設定するパスワードはお好きな文字列を使用可能です。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/222127/92649ee4-df8e-7a58-2719-13819dd35d8c.png)

secrets.json、appsettings.Development.json どちらかに設定するのが MS Learn　に掲載されている方法([External parameters](https://learn.microsoft.com/en-us/dotnet/aspire/fundamentals/external-parameters))ですが、結局は環境変数を.NET Aspireがうまいこと読み込んでいるだけです。そのため Key 値がわかっていれば環境変数に直接値をセットして逃げることもできます。AppHostプロジェクトの launchSettings.json を開いて profiles が http, https それぞれの environmentVairables に次のように設定を追加します。

```json
"Parameters:sqlserverPassword": "p@ssw0rd1234"
```

これで Web, ApiService アプリケーションから問題なく DB にアクセスできます。
さらにもう一つ、SQL Server コンテナのポート番号も毎回ランダムに変わることも注意が必要です。これも固定しておかないと、 SSMS や Azure Data Stduio からアクセスする時に、毎回違うポート番号を指定して接続することになってしまうため、大変不便です。ポート番号の固定は次のように実装します。

```diff_c_sharp:AppHost/Program.cs
var sqlserverPassword = builder.AddParameter("sqlserverPassword");
 
- var weatherSampleDb = builder.AddSqlServer("mysqlserver", password: sqlserverPassword)
+ var weatherSampleDb = builder.AddSqlServer("mysqlserver", password: sqlserverPassword, port: 6501)
    .WithBindMount("./sqlserverconfig", "/usr/config")
    .WithBindMount("./data", "/docker-entrypoint-initdb.d")
    .WithEntrypoint("/usr/config/entrypoint.sh")
    .WithDataVolume("VolumeMount.sqlserver.data")
    .AddDatabase("weatherdb", "testdb");
```

# Azure Container Apps にデプロイしたら 構築済みの Azure SQL Database を使用するようにする

さて、ローカル開発環境としてコンテナの SQL Server を使用できるようになりました。でもこの状態で Azure Depveloper CLI を使用して Azure Container Apps にデプロイすると、コンテナの SQL Server をデプロイすることになってしまいます。そうではなく、構築済みの Azure SQL Database を使用するようにしたいわけです。

アプリケーションからすると、接続先がコンテナの SQL Server なのか、Azure SQL Database なのかは接続文字列次第です。つまりデプロイする時、コンテナをデプロイしないで Azure SQL Database へ接続する接続文字列をアプリケーションの環境変数にセットしてくれれば良いのです。

まさにこの目的のために、デプロイ時のみ接続文字列を指定できるオプションが Preview 4でリリースされました。次のように実装します。






```diff_c_sharp:AppHost/Program.cs
var sqlserverPassword = builder.AddParameter("sqlserverPassword");

var weatherSampleDb = builder.AddSqlServer("mysqlserver", password: sqlserverPassword, port: 6501)
    .WithBindMount("./sqlserverconfig", "/usr/config")
    .WithBindMount("./data", "/docker-entrypoint-initdb.d")
    .WithEntrypoint("/usr/config/entrypoint.sh")
    .WithDataVolume("VolumeMount.sqlserver.data")
+   .PublishAsConnectionString()
    .AddDatabase("weatherdb", "testdb");
```

この状態で Azure Developer CLI を使って Azure Container Apps へのデプロイをしてみましょう。

azd コマンドで Azure に ログインします。

```bash
azd login
```

次に ソリューションファイルが存在するフォルダで次のコマンドを実行してマニフェストを作成します。

```bash
azd init
```

オプションを選択・入力するように促されます。詳しい説明はこちらをご覧ください。

[.NET Aspire を デプロイする](https://qiita.com/takashiuesaka/items/3da744d0511f29270299)

次に、Azure へのデプロイを行います。

```bash
azd up
```

サブスクリプション、リージョンの選択後、シークレット入力を求められます。ここで Azure SQL Database の接続文字列を入力します。 PublishAsConnectionString メソッドを追加したことで、この入力が求められたのです。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/222127/3c6a4b39-d44b-d1dc-46b9-51fbdf005faf.png)

AddParameter を使用すると、無条件に Publish 時にパスワード値の入力を求められます。本来、ここで値を入力すると KeyVault に格納されて接続文字列をセキュアに管理可能です。今回は接続文字列に本番用のパスワードを設定したので何も入力せずにこのままEnterを押します。

デプロイが正常終了した後、Azure Portalで入力した接続文字列がどのように格納されているのかを見てみましょう。

デプロイしたリソースグループの中に apiservice というアプリがあります。これを開き、左側のメニューからコンテナーを選択し、右画面から「環境変数」をクリックします。すると、一番下に接続文字列の Key である ConnectionStrings__weatherdb を名前に持つ行があります。ソースがシークレットの参照となっていますので、シークレットを見てみましょう。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/222127/e0a2bc7d-8a09-6bb5-c380-6525d71f16d0.png)

左側のメニューのシークレットを選択すると、接続文字列が格納されています。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/222127/345c52d6-eda6-0bf2-c458-2a61ccc71995.png)

これで、Azure Container Apps にデプロした後は 構築済みの SQL Sever を参照するようにする、という目的が達成できました。もちろん、ローカル開発時にはローカルのコンテナでホスティングされた SQL Server を使用するようになったままです。

# まとめ

.NET Aspire を使用することで、データベースへの接続部分についてもローカル開発が簡単になるようにサポートが充実しています。環境構築という、とても煩わしい作業に時間を奪われる事なく、開発に集中できます。是非お試しください。