BlazorとSQL Serverをdocker-composeで立ち上げる

## はじめに

DockerもBlazorも、ほぼ触ったことがないけど、BlazorをDockerで動してみたい

データベースも含めて`docker-compose`する

具体的には[公式チュートリアル](https://docs.microsoft.com/ja-jp/aspnet/core/blazor/tutorials/build-a-blazor-app?view=aspnetcore-6.0&pivots=webassembly#build-a-todo-list-blazor-app)のTodoアプリを発展させて、アイテムを永続化（DBから読み書き）したい

### 環境

- Windows 10 Home
- Visual Studio 2022 Community
  - .NET 6
- SQL Server 2019
- Docker version 20.10.13, build a224086
- Docker Compose version v2.3.3

## Blazorプロジェクトをつくる

`dotnet new`の[引数](https://docs.microsoft.com/ja-jp/dotnet/core/tools/dotnet-new#arguments)([こちらも](https://docs.microsoft.com/ja-jp/dotnet/core/tools/dotnet-new-sdk-templates#blazorwasm))を参考に、プロジェクトをつくる

```powershell
dotnet new blazorwasm --hosted -o ./project -n BlazorHosted
```

| オプション | 意味                          |
| ---------- | ----------------------------- |
| `-o`       | プロジェクトを配置する場所    |
| `-n`       | 作成されるプロジェクトの名前  |
| `--hosted` | ASP.NET Core ホストが含まれる |

### ベースとなるコード

BlazorでTodoアプリを作る[公式チュートリアル](https://docs.microsoft.com/ja-jp/aspnet/core/blazor/tutorials/build-a-blazor-app?view=aspnetcore-6.0&pivots=webassembly#build-a-todo-list-blazor-app)を参考にして、Razorページとモデルをプロジェクトに追加する

この時点で実行すると、ナビゲーションにTodoが追加されて、任意のアイテムを追加できるようになっている

ただし、**データの永続化については未着手なので、リロードするたびにデータは初期状態（空のデータ）になる**

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/272511/5c749c82-cce3-94ca-623b-1cdde48362da.png)

※Visual Studioからデバッグ実行して`item1`を`Add todo`した時の様子

#### Tips

~~`dotnet watch run`しておくとデバッグが楽~~

2022/04/16追記
`dotnet watch`のみでいいかも？

https://github.com/dotnet/aspnetcore/issues/38036#issuecomment-950749664

### データベースをつかう準備

アプリ側でデータベースをつかう準備をする
去年のアドベントカレンダーにて、参考になる記事がある

- [ASP.NET Core Blazor WebAssembly と Web API と Entity Framework Core で SQL Server のデータを取得したり追加したり更新したり削除したりする [.NET 6 版]](https://qiita.com/tamtamyarn/items/876a5cd4b9ec9cdc1044)

### NuGetのパッケージをインストールする

SQL Serverを扱うために、必要なライブラリを揃える

```powershell
Microsoft.EntityFrameworkCore
Microsoft.EntityFrameworkCore.Design
Microsoft.EntityFrameworkCore.SqlServer
Microsoft.EntityFrameworkCore.Tools
```

データベースコンテキストの中身や、シードデータの与え方は、手前味噌過去記事参照

体系立ててまとめられておらず、我ながら情報が探しにくい

- [【Entity Framework Core】コードファーストで移行したDBをリバースエンジニアリングして違いを感じる](https://qiita.com/t13801206/items/cafcda5b4a5664fc3d64)
- [SQL Serverにシードデータを与える](https://qiita.com/t13801206/items/4c0da0936d4f637940de)

### データベースに接続する設定をする 

.NET 6でつくったので、いろいろ過去の記事と齟齬が生じている
（たとえば`Startup.cs`ファイルが無いなど）

- [ASP.NET Core 6.0 の新しい最小ホスティングモデルに移行されたコードサンプル](https://docs.microsoft.com/ja-jp/aspnet/core/migration/50-to-60-samples?view=aspnetcore-6.0)
- [サービスの追加](https://docs.microsoft.com/ja-jp/aspnet/core/migration/50-to-60-samples?view=aspnetcore-6.0#add-services)をする

#### サーバー側の`Program.cs`

.NET 6でプロジェクトをつくると、丁寧に`// Add services to the container.`とガイドされているので、そこにサービスを追加する

```cs:Program.cs
builder.Services.AddDbContext<BlazorHosted.Server.Context.TodoDbContext>(
    options => options.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection")));
```

#### `appsettings.json`に接続文字列を追加する

デバッグ中は`localdb`をつかう
dockerで動かすときは、`docker-compose.yml`で接続文字列を上書きする(後述)

```json:appsettings.json
  "ConnectionStrings": {
    "DefaultConnection": "Server=(localdb)\\mssqllocaldb;Database=BlazorAppDb;Trusted_Connection=True;",
  },
```

[過去記事ではまったポイント](https://qiita.com/t13801206/items/4c2e85fc2732c4de5533)として、**パスワードが8文字以下であるとうまくいかない**ので注意する

### モデルを変更する

チュートリアルでつくった`TodoItem.cs`を変更する
データベースに格納するので、`Id`を追加する

```cs:Sharedプロジェクト
namespace BlazorHosted.Shared;

public class TodoItem
{
    public int Id { get; set; }
    public string? Title { get; set; }
    public bool IsDone { get; set; }
}
```

> 慣例により、Id または <type name>Id という名前のプロパティがエンティティの主キーとして構成されます。（[引用元](https://docs.microsoft.com/ja-jp/ef/core/modeling/keys?tabs=data-annotations)）

- [SQL Serverの主キーはIDENTITYになる](https://docs.microsoft.com/ja-jp/ef/core/modeling/generated-properties?tabs=data-annotations#primary-keys)

### DBコンテキストを新規作成する

サーバー側にDBコンテキストを生成する
これにより、EF CoreのツールからDBを新規作成したり、テーブルの定義を更新できたりする

シードデータとして2つ先にデータを用意しておく

```cs:Server/Context
using Microsoft.EntityFrameworkCore;
using BlazorHosted.Shared;

namespace BlazorHosted.Server.Context
{
    public class TodoDbContext : DbContext
    {
        public TodoDbContext(DbContextOptions<TodoDbContext> options) : base(options) { }

        public virtual DbSet<TodoItem> TodoItems { get; set; }

        protected override void OnModelCreating(ModelBuilder modelBuilder)
        {
            modelBuilder.Entity<TodoItem>().HasData(
                new TodoItem { Id = 1, Title = "item1st", IsDone = true },
                new TodoItem { Id = 2, Title = "item2nd", IsDone = false }
                );
        }
    }
}
```

### スキャフォールディングでコントローラーを追加する

サーバー側のコントローラーを右クリックして、"コントローラー"を追加する
クライアント（ウェブブラウザ）からサーバーにGETやPOSTなどリクエストがあった場合の挙動を記すのがコントローラー
（じっさいのところ、ユーザーはボタンを押すだけでGETなどは意識しないが・・）

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/272511/89fc5d56-8caa-a9b0-cf25-ae0b0eb26b8f.png)

APIのEFをつかったアクション（GET,POSTなど）があるコントローラーをえらぶ

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/272511/7bdc3812-6e9b-70df-1772-8213915b1e96.png)

今さらながら、**モデルはShared**に、**コンテキストはサーバーに**、それぞれ配置した

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/272511/26db5c52-1ae3-d8fb-652b-ed782531332d.png)

#### はまったポイント：GETしたらAPIの生の結果がかえってきた

テストのために`localhost/todo`アクセスすしたところ、ページではなくＡＰＩの結果が生でかえってきた

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/272511/5d606d0c-0d91-60d1-b394-107329e78f96.png)

##### 解決した

[先述の記事のコード](https://github.com/tamtamyarn/BlazorApp6.0)ではそんなことなかったので、比較してみたところ
戻値に違いがあった

```csharp:TodoItemsController.cs
// 自分のコード
[HttpGet]
public async Task<ActionResult<IEnumerable<TodoItem>>> GetTodoItems()
{
    return await _context.TodoItems.ToListAsync();
}

// こちらが正しい
[HttpGet]
public async Task<ActionResult<IEnumerable<TodoItem>>> GetTodoItems()
{
    var items = await _context.TodoItems.ToListAsync();
    return Ok(items);
}
```

修正したところ、ちゃんと`Loading...`が表示されるようになった

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/272511/c8eebcdd-cdeb-829d-394c-257781c9a5a0.png)

### Razor Page を変更する

こちらもチュートリアルから少し変更をくわえる

- アイテムがまだnullの状態であれば、その旨を表示しておく
- `OnInitializedAsync`を追加して、DBにすでに存在するデータを表示する
- アイテムが追加されたらDBに書き込む

```html
@page "/todo"
@using BlazorHosted.Shared
@using System.Linq;
@inject HttpClient Http

<PageTitle>Todo</PageTitle>

@if (todos == null)
{
    <p>No Todo Items found.</p>
}
else
{
    <h1>Todo (@todos.Count(todo => !todo.IsDone))</h1>

    <ul>
        @foreach (var todo in todos)
        {
            <li>
                <input type="checkbox" @bind="todo.IsDone" />
                <input @bind="todo.Title" />
            </li>
        }
    </ul>

    <input placeholder="Something todo" @bind="newTodo" />
    <button @onclick="AddTodo">Add todo</button>
}

@code {
    private List<TodoItem>? todos;
    private string? newTodo;

    private void AddTodo()
    {
        if (!string.IsNullOrWhiteSpace(newTodo))
        {
            TodoItem newTodoItem = new() { Title = newTodo };
            todos?.Add(newTodoItem);
            newTodo = string.Empty;

            Http.PostAsJsonAsync<TodoItem>(requestUri: "api/TodoItems", value: newTodoItem);
        }
    }

    protected override async Task OnInitializedAsync()
    {
        todos = await Http.GetFromJsonAsync<List<TodoItem>>(requestUri: "api/TodoItems");
    }
}
```

### データベースをマイグレーションする

データベースをつくる

```Powershell
Add-Migration Init
Update-Database
```

## 動作している様子

1. 2つのアイテムがはじめから追加されている
2. 更新してもアイテムが残っている

![Animation.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/272511/2d7495c9-3bdc-5ea4-d79d-c2a2500596ba.gif)

## 以上がdockerをつかわないデバッグまで

[移行の適用](https://docs.microsoft.com/ja-jp/ef/core/managing-schemas/migrations/applying?tabs=dotnet-core-cli)によると、バンドルを使う方法が比較的あたらしいらしい
本番環境では安易にコマンド一発でDBを更新するのではなく、SQLスクリプトを出力して中身を精査してから実行すべし。

ということで、dockerfileではそのようにして移行する

## dockerfileとcompose.ymlを準備する

ここからdockerで動かすためのファイルを準備する

### dockerfile

[ASP.NET Core 向けの Docker イメージ](https://docs.microsoft.com/ja-jp/aspnet/core/host-and-deploy/docker/building-net-docker-images?view=aspnetcore-6.0#build-and-deploy-manually)を参考にした
[こちら](https://github.com/jongio/BlazorDocker)や[こちらも](https://qiita.com/taskeknight/items/f07d23151b8bd23e456d)参考にした

| 命令                                                                    | 意味                                                                                                                                       |
| ----------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
| [FROM](https://docs.docker.jp/engine/reference/builder.html#from)       | 以降の命令で使う ベース・イメージ。`AS`で別名をつけられる                                                                                  |
| [WORKDIR](https://docs.docker.jp/engine/reference/builder.html#workdir) | `RUN`や`CMD`, `ENTRYPOINT`が実行される場所                                                                                                 |
| [COPY](https://docs.docker.jp/engine/reference/builder.html#copy)       | コピー元はdockerfileが置かれいてるカレントパス`.`、コピー先は`WORKDIR`からの相対パス。コピー先を`.`とした場合は、すなわち`WORKDIR`を指す。 |

主な命令の一覧は[こちら](https://atmarkit.itmedia.co.jp/ait/articles/1407/08/news031.html)に掲載されている

。。。と、自分で書くのも勉強になるが、ミスを連発しそうなのでVisual Studioの機能を使う

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/272511/bcafa2b9-dd6c-8e95-2f40-d8212afd6b18.png)

[マルチステージビルド](https://matsuand.github.io/docs.docker.jp.onthefly/develop/develop-images/multistage-build/#use-multi-stage-builds)なる方法により、最終的なイメージサイズが削減している

```dockerfile
#See https://aka.ms/containerfastmode to understand how Visual Studio uses this Dockerfile to build your images for faster debugging.

FROM mcr.microsoft.com/dotnet/aspnet:6.0 AS base
WORKDIR /app
EXPOSE 80
EXPOSE 443

FROM mcr.microsoft.com/dotnet/sdk:6.0 AS build
WORKDIR /src
COPY ["Server/BlazorHosted.Server.csproj", "Server/"]
COPY ["Shared/BlazorHosted.Shared.csproj", "Shared/"]
COPY ["Client/BlazorHosted.Client.csproj", "Client/"]
RUN dotnet restore "Server/BlazorHosted.Server.csproj"
COPY . .
WORKDIR "/src/Server"
RUN dotnet build "BlazorHosted.Server.csproj" -c Release -o /app/build

FROM build AS publish
ENV PATH="$PATH:/root/.dotnet/tools"
RUN dotnet publish "BlazorHosted.Server.csproj" -c Release -o /app/publish
# ここから
RUN dotnet tool install --global dotnet-ef --version 6.0.3
RUN dotnet ef migrations bundle -o /app/publish/bundle
RUN chmod +x /app/publish/bundle
# ここまでを追加

FROM base AS final
WORKDIR /app
COPY --from=publish /app/publish .
ENTRYPOINT ["dotnet", "BlazorHosted.Server.dll"]
```

publishのところで`app/publish`直下に`bundle`ファイルを生成している
`docker compose up`したあとにこのバンドルをつかってDBをマイグレーションする

### docker-compose.yml

`ConnectionString`より上まではVisual Studioが生成してくれている

- `ConnectionString__`で接続文字列を上書きしている
- `ports`は8080を指定しているが、**VS Codeから`up`しても8080以外のポートが接続先になることがある**（よくわからない）
- DBは十分に長いパスワードを設定しないと正常にたちあがらない
  - ポート11433へ[SSMS](https://docs.microsoft.com/ja-jp/sql/ssms/download-sql-server-management-studio-ssms?view=sql-server-ver15)からもアクセスできる

```yml
version: '3.4'

services:
  blazorhosted.server:
    image: ${DOCKER_REGISTRY-}blazorhostedserver
    build:
      context: .
      dockerfile: Server/Dockerfile
    environment:
      - ConnectionStrings__DefaultConnection=Server=db;Database=master;User=sa;Password=SqlPass1234;
    ports:
      - '127.0.0.1:8080:80'
      - '127.0.0.1:10443:443'
    depends_on:
      - db

  db:
    image: "mcr.microsoft.com/mssql/server:2019-latest"
    environment:
      - SA_PASSWORD=SqlPass1234
      - ACCEPT_EULA=Y
    ports:
      - 11433:1433
```

#### `version`とは？

`version`から何を指定すればよいかわからない

- [公式のGet Started](https://docs.docker.com/compose/gettingstarted/#step-3-define-services-in-a-compose-file)では`3.9`をつかっている
- [3系](https://docs.docker.com/compose/compose-file/compose-file-v3/)と[2系](https://docs.docker.com/compose/compose-file/compose-file-v2/)があるらしい
- [表](https://matsuand.github.io/docs.docker.jp.onthefly/compose/compose-file/#compose-and-docker-compatibility-matrix)にdockerエンジンとの対応が書かれている
  - 自分のdockerは`ocker version 20.10.13`なので、とりあえず最新のバージョンでもよさそう

ということで、`docker run`のオプションを参考に`yml`ファイルを書いてみる
前記事から引き続き、[こちら](https://qiita.com/taskeknight/items/488c44c46e7282bb514c)の記事も参考にさせていただいた

### ビルドに必要なイメージを`pull`する

SQL Server 2019のイメージを取得しておく --->>> [過去記事](https://qiita.com/t13801206/items/4c2e85fc2732c4de5533)

Microsoftの[docker-hub](https://hub.docker.com/_/microsoft-dotnet/)から必要なイメージを`pull`する

```powershell
docker pull mcr.microsoft.com/dotnet/sdk:6.0
docker pull mcr.microsoft.com/dotnet/aspnet:6.0
```

`docker images`で確認すると、イメージが追加されている

```Powershell
> docker images
REPOSITORY                        TAG           IMAGE ID       CREATED        SIZE
mcr.microsoft.com/dotnet/sdk      6.0           2d1a2c7481f5   11 days ago    721MB
mcr.microsoft.com/dotnet/aspnet   6.0           683c56113596   11 days ago    208MB
mcr.microsoft.com/mssql/server    2019-latest   56beb1db7406   8 months ago   1.54GB
```

`.yml`の書き方について：[こちら](https://qiita.com/yuta-ushijima/items/d3d98177e1b28f736f04)の記事に各リファレンスへのリンクもまとまっている

### イメージを作成して起動する

自作Blazorプロジェクトのイメージをつくる
ビルドにはそれなりの時間がかかるので、Visual Studioであらかじめよくデバッグしておく

自分のPCでは`[+] Building 1070.4s (23/23) FINISHED`とのことで、ビルドに1070秒、15分以上かかった・・・（これはどうなんだ？）

```Powershell
# .ymlファイルがある階層で build->up する
cd ./project
docker compose build
docker compose up -d
```

生成物を確認する

```Powershell
# イメージ
> docker images
REPOSITORY                        TAG           IMAGE ID       CREATED         SIZE
blazorhostedserver                latest        7a9a0dc4eabd   3 minutes ago   464MB
...後略...

# コンテナー
> docker ps
CONTAINER ID   IMAGE                                        COMMAND                  CREATED          STATUS          PORTS                                            NAMES
403182a89b2c   blazorhostedserver                           "dotnet BlazorHosted…"   16 seconds ago   Up 7 seconds    127.0.0.1:8080->80/tcp, 0.0.0.0:63927->443/tcp   project-blazorhosted.server-1    
376c37326b66   mcr.microsoft.com/mssql/server:2019-latest   "/opt/mssql/bin/perm…"   17 seconds ago   Up 12 seconds   0.0.0.0:11433->1433/tcp                          project-db-1
```

現時点ではDBにテーブルができていないので、サイトにアクセスしてもTodoページはエラーが表示される

### DBマイグレーションする

dockerfileでbundleを書き出してある。エントリーポイントを書きかえて実行する

一時的にしか使わないので`--rm`をつけておく

`docker compose run --rm --entrypoint /app/bundle blazorhosted.server`

[バンドルについて](https://docs.microsoft.com/ja-jp/ef/core/managing-schemas/migrations/applying?tabs=dotnet-core-cli#bundles)

> バンドルは、.NET SDK または EF ツールを (または、自己完結型の場合は .NET ランタイムさえも) インストールせずに実行でき、プロジェクトのソース コードは必要ありません。

余計な（？）ものを環境に入れなくても、バンドルファイルさえあればよい

### サイトにアクセスする

ブラウザから`localhost:8080`にアクセスすると、Blazorページが表示される
※タイミングによって？は8080ではないポートがバインドされている。ここはあとでよく調べないと・・・

VS Codeの左ペインのdockerアイコンを押すと、コンテナーの一覧が表示されるので、そこからブラウザを開く

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/272511/520621a5-40cb-9f98-c73f-1493a6411e66.png)

やはり8080ポートではなかったものの、TodoページにはシードしたDBのデータが表示された

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/272511/05f35def-976c-9a97-e151-d0f93f97e6d7.png)

### 後片付け

コンテナーを破棄
`docker compose down`

イメージを破棄
`docker rmi blazorhostedserver`

## おわりに

以下、まだ腑に落ちないところがあるが、とりあえずやりたいことができた

- docker-compose.ymlでportを8080:80などと指定しても、ちがうポートが開く
- docker compose buildに15分かかる

### 追記(22' 4/11)

`http://localhost:8080`でページが表示されないのは、安全が確保されていないからっぽい
コンテナーが立ち上がっていないわけではないっぽい

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/272511/2e09d9fc-1d84-226e-c4ab-050fd203d07b.png)


## 参考にさせていただいたサイトなど

- [MS公式docs](https://docs.microsoft.com/ja-jp/aspnet/core/blazor/blazor-server-ef-core?view=aspnetcore-6.0)と付随する[githubリポジトリ](https://github.com/dotnet/AspNetCore.Docs/tree/main/aspnetcore/blazor/samples/6.0)
- [チュートリアル: ASP.NET Core で Web API を作成する](https://docs.microsoft.com/ja-jp/aspnet/core/tutorials/first-web-api?view=aspnetcore-6.0&tabs=visual-studio)
  - [ASP.NET Core Blazor から Web API を呼び出す](https://docs.microsoft.com/ja-jp/aspnet/core/blazor/call-web-api?view=aspnetcore-6.0&pivots=webassembly)
  - [ASP.NET Core 向けの Docker イメージ](https://docs.microsoft.com/ja-jp/aspnet/core/host-and-deploy/docker/building-net-docker-images?view=aspnetcore-6.0)
  - [Windows 上の Visual Studio で Docker ツールを使用する](https://docs.microsoft.com/ja-jp/dotnet/architecture/containerized-lifecycle/design-develop-containerized-apps/visual-studio-tools-for-docker)
- docker docsに、おあつらえ向きのページがあった
  - [Quickstart: Compose and ASP.NET Core with SQL Server](https://docs.docker.com/samples/aspnet-mssql-compose/)
- Razor Pagesに関するチュートリアルも参考になる
  - [ASP.NET Core での Entity Framework Core を使用した Razor Pages](https://docs.microsoft.com/ja-jp/aspnet/core/data/ef-rp/intro?view=aspnetcore-6.0&tabs=visual-studio) 
  - [ASP.NET Core で Razor Pages Web アプリを作成する](https://docs.microsoft.com/ja-jp/aspnet/core/tutorials/razor-pages/?view=aspnetcore-6.0)

また、上記オフィシャルな記事を補完してくださる記事

- [コンテナ環境でのデータベースマイグレーション](https://qiita.com/karuakun/items/6dc22d2cdbbb43c024e7)
- [Docker で SQL Server を実行する(Expressエディション)](https://www.curict.com/item/99/99ff31e.html)
- [docker-composeでMicrosoft SQL Server 2019を動かすまで](https://qiita.com/fattonton1/items/a622cf5d92d0bae95f42)
- [ASP.NET Core のWebアプリケーションを docker-compose で Dockerアプリケーション として構築する](https://qiita.com/wasimaru/items/4b160b48dae7f2618074)
- [Docker によるコンテナー化アプリでの EF Core (パート 2)](https://docs.microsoft.com/ja-jp/archive/msdn-magazine/2019/may/data-points-ef-core-in-a-docker-containerized-app-part-2)