Azure Static Web AppsのVue.jsアプリに環境変数を設定する方法

Azure Static Web Appsは便利ですごく使いやすいんだけど環境変数を設定するのはまだ簡単ではなさそうだったので、設定方法を備忘録として残しておきます。

まず結論から

Azure Static Web Appsリソースを作成すると自動生成されるGitHub Actionsのワークフローファイルを使って環境変数を記載します。そのため、GitHubにアップロードするコード内に本番環境の環境変数が含まれることになってしまいますが、フロントエンドで動くもので基本的に機密情報ではないのでよしとします。類似サービスであるApp Serviceを使った感覚からするとAzure portalの「アプリケーション設定」で環境変数を設定できれば良さげなのですが、執筆時点はその機能はないそうです。

公式ドキュメントにも記載があります。

フロントエンド Web アプリケーションの構築に必要な環境変数を構成するには、「ビルドの構成」を参照してください。

Azure Static Web Apps のアプリケーション設定を構成する | Microsoft Learn

より良い方法については議論が続いているようです。

実際にやってみる

サンプルアプリについて

「VUE_APP_TEXT」という環境変数の文字列を表示するシンプルなVue.jsアプリです。vue create で生成されるコードをほぼそのまま使っています。ソースコードはGitHubに置いています。

手順の流れ

  1. GitHubにVueアプリを用意する
  2. Azure Static Web Appsのリソースを作成する
  3. .envファイルに記載している環境変数の値が表示されることを確認する
  4. 自動生成されたワークフローファイルに環境変数を記載する
  5. 変更分をGitHubにpushする
  6. ワークフローファイルに記載した環境変数の値が表示されることを確認する

1. GitHubにVueアプリを用意する

サンプルアプリをforkするか、ソースコードをダウンロードして自身のリポジトリを作成します。

2. Azure Static Web Appsのリソースを作成する

普通にリソースを作成します。「ビルドのプリセット」は [Vue.js] を選択してください。GitHubアカウント関連はご自身のリポジトリを指定してください。その他は特筆すべきことはありませんが、参考までにリソース作成時のスクリーンショットを載せておきます。

リソースを作成すると、下の画像のようにGitHub Actionsでデプロイ処理が実行している様子を確認できます。

3. .envファイルの環境変数の値が表示されることを確認する

作成されたリソースのWebサイトにアクセスします。下の画像のように「text from local environment variable」と表示されていることを確認します。これはソースコードの「.env」ファイルが反映されています。

4. 自動生成されたワークフローファイルに環境変数を記載する

この記事のメインです。リソース作成時に自動生成される「.github/workflows」フォルダ内のymlファイルに環境変数に関する記述を追加します。下のコードの最後の2行(env句)が追加する部分です。(参考

      - name: Build And Deploy
        id: builddeploy
        uses: Azure/static-web-apps-deploy@v1
        with:
          ...
          ###### Repository/Build Configurations - These values can be configured to match your app requirements. ######
          # For more information regarding Static Web App workflow configurations, please visit: https://aka.ms/swaworkflowconfig
          app_location: "/" # App source code path
          api_location: "" # Api source code path - optional
          output_location: "dist" # Built app content directory - optional
          ###### End of Repository/Build Configurations ######
        env: # Add environment variables here
          VUE_APP_TEXT: "text from GitHub workflow environment variable"

5. 変更分をGitHubにpushする

ymlファイルの変更をcommitしGitHubにpushします。再びGitHub Actionsでデプロイ処理が実行している様子を確認できます。

6. ワークフローファイルに記載した環境変数の値が表示されることを確認する

Webサイトにアクセスし、テキストが「text from GitHub workflow environment variable」となっていれば成功です。

PrismのDIコンテナを使ってWPFアプリ実行中に注入クラスを切り替える

Prismを使ったWPFアプリで、アプリ実行中にDIコンテナへの登録を変更できる、という話です。アプリ実行中に設定画面でSQLiteファイルの保存場所を変更したいと思ったけれど良い方法が分からず、ずっと悩んでいたのが最近解決したのでその備忘録です。

今回やりたいことを図にするとこんな感じです。SQLiteのファイルの場所の他にもDBエンジンを切り替えることもできます。
(この図はDDDでいうRepositoryを切り替える様子を表していますが、この記事で紹介する切り替えの方法はDIコンテナに注入するどんなクラスにも適用できます。)

注入クラス切り替えのイメージ

結論

以下の2点がポイントです。

  • RegisterTypes関数の引数であるcontainerRegistryをDIコンテナに登録する。
protected override void RegisterTypes(IContainerRegistry containerRegistry)
{
    containerRegistry.RegisterInstance(containerRegistry);

    // 中略
}
  • 切り替えたいクラスだけでなく、そのクラスを直接的にも間接的にも読み込むクラスたちもDIコンテナに再登録する。

サンプルアプリ作成

検証のために作ったアプリの開発手順を残しておきます。アプリのコードはGitHubに上げてます

検証環境

作成手順

プロジェクト作成

  • Visual Studioを起動してテンプレートに「Prism Blank App (WPF)」を選択
    (※:これは「Prism Template Pack」という拡張機能をインストールすることで利用できます。)
  • 「プロジェクト名」と「場所」を入力
  • 単純なアプリが実行できる状態でプロジェクトが作成される

コーディング

以下の画像のようにファイルを作成しコーディングしていきます(GitHub参照)。Infra1ItemRepositoryとInfra2ItemRepositoryがIItemRepositoryの実装クラスです(Repositoryパターン)。

ファイル一覧

以下にポイントとなるコードの内容を解説します。

App.xaml.csではRegisterTypes関数内で利用するクラスを初期登録します。RegisterInstance関数でcontainerRegistryを登録しているのが結論の1つ目で紹介した部分です。

protected override void RegisterTypes(IContainerRegistry containerRegistry)
{
    containerRegistry.RegisterInstance(containerRegistry);

    containerRegistry.Register<IItemRepository, Infra1ItemRepository>();
    containerRegistry.Register<IItemApplicationService, ItemApplicationService>();

    containerRegistry.Register<IMainUserControlModel, MainUserControlModel>();

    containerRegistry.RegisterForNavigation<MainUserControl>();
}

MVVMのM(モデル)に該当するクラスにボタンを押したときに実行される関数を記述します。このクラスはDIコンテナのレジストリ(IContainerRegistry)型の変数を持ち、コンストラクタの引数で変数を受け取り代入します。再登録する際は切り替えたいRepositoryだけでなく、ApplicationServiceやMVVMのMに該当するクラスも再登録しています。

public class MainUserControlModel : IMainUserControlModel
{
    // 中略

    private readonly IContainerRegistry _containerRegistry;

    public MainUserControlModel(
            // 中略
            IContainerRegistry containerRegistry)
    {
        _containerRegistry = containerRegistry;

        // 中略
    }

    public void SwitchToInfra1()
    {
        _containerRegistry.Register<IItemRepository, Infra1ItemRepository>();
        _containerRegistry.Register<IItemApplicationService, ItemApplicationService>();

        _containerRegistry.Register<IMainUserControlModel, MainUserControlModel>();
    }

    public void SwitchToInfra2()
    {
        _containerRegistry.Register<IItemRepository, Infra2ItemRepository>();
        _containerRegistry.Register<IItemApplicationService, ItemApplicationService>();

        _containerRegistry.Register<IMainUserControlModel, MainUserControlModel>();
    }
}

ViewModelのボタン押下時の処理です(ReactivePropertyというライブラリを使用して書いています)。上述の処理を呼び出した後、RequestNavigate関数というPrismのNavigation機能を使ってページ遷移します。こうすることでViewModelクラスのインスタンスが再生成されます(コンストラクタが実行される)。上述の処理ではViewModelクラスに関連するクラスたちを再登録していたので、ページ遷移後には切り替え後のクラスがアプリに反映されます。

SwitchToInfra1Command = new ReactiveCommand();
SwitchToInfra1Command.Subscribe(() =>
{
    _mainUserControlModel.SwitchToInfra1();

    _regionManager.RequestNavigate("ContentRegion", nameof(MainUserControl));
});

SwitchToInfra2Command = new ReactiveCommand();
SwitchToInfra2Command.Subscribe(() =>
{
    _mainUserControlModel.SwitchToInfra2();

    _regionManager.RequestNavigate("ContentRegion", nameof(MainUserControl));
});

実行

F5を押してアプリを実行します。ボタンを押すとRepositoryの実装クラスが切り替わり、画面に表示されるアイテムが変わるのを確認できます。

まとめと感想

アプリ実行時の変数containerRegistryをDIコンテナに登録することで、他クラスでも依存性注入することができます。注入したクラスをアプリに反映させるには関連する全てのクラスを登録しなおす必要があります。
関連する全てのクラスを再登録することが盲点でした。変更したクラスだけ再登録したのにアプリの実行結果に反映されなかったことから、一度登録したら変更できないのかと考えていました。注入クラスの切り替えが実現できたことで、アプリ実行中に依存クラスを動的に変更できるため、アプリ内で行えることが広がりそうです。

参考

外部API呼び出しを定期実行する処理をAzure Functionsで実装する(C#)

タイトルの通りです。サーバがあれば実行ファイルを置いてcronで実行させるのが手っ取り早いですが、クラウドのサービスを使って手軽にできないか試したら意外と簡単だったのでその時の備忘録です。1時間に1回の時報をslackに通知する、というケースで書いています。

必要なもの

  • Azureアカウント
  • Visual Studio
    • 今回は Visual Studio 2022を使いました。

1. Azureポータルで関数アプリを作成する

  • Azureポータルにアクセス
  • Azureサービスの一覧から [関数アプリ] を選択
  • [+作成] をクリック
  • 以下の画像の通りに必要な情報を入力
    • 今回のケースでは関数アプリ名は [jihou] とした
  • [作成] をクリック

2. Visual Studioでコードを書く

  • 新しいプロジェクトを作成
  • プロジェクト名を入力
  • [Timer trigger] を選択
    • 各時間の0分に通知してほしいので [Schedule] には「0 0 * * * *」と書く(記法について
  • コードを書く。
    • 17行目の変数 [url] にAPIのURLを代入
    • 18行目の変数 [payload] にPOSTするデータを代入
using Microsoft.Azure.WebJobs;
using Microsoft.Extensions.Logging;
using System;
using System.Net.Http;
using System.Text;
using System.Text.Json;

namespace Jihou
{
    public class JihouHour
    {
        [FunctionName("JihouHour")]
        public void Run([TimerTrigger("0 0 * * * *")]TimerInfo myTimer, ILogger log)
        {
            log.LogInformation($"C# Timer trigger function executed at: {DateTime.Now}");

            var url = "https://hooks.slack.com/services/*******************************";
            var payload = new Payload
            {
                text = DateTime.Now.ToString("yyyy/MM/dd HH") + "時です。"
            };

            var json = JsonSerializer.Serialize(payload);

            var client = new HttpClient();
            var content = new StringContent(json, Encoding.UTF8, "application/json");
            var res = client.PostAsync(url, content).Result;
            log.LogInformation(res.ToString());
        }
    }

    public class Payload
    {
        public string text { get; set; }
    }
}

尚、その場で動作を確認したい場合はVisual Studioのデバッグ([F5]キー)機能が使えます。その際は実行間隔を短く設定すればすぐに動作を確認できます。例えば [TimerTrigger("0 */1 * * * *")] とすれば1分ごとに実行してくれます。

3. コードをAzureにアップロードする

  • Visual Studio のプロジェクト名を右クリックして [発行] をクリック
  • ターゲットとして [Azure] を選択
  • [Azure Funciton App (Windows)] を選択
  • 冒頭で作成した関数アプリ [jihou] を選択
  • 公開の準備が完了
  • [発行] を押すとAzureにデプロイされる

確認

1時間に1回slackに通知が来ます。

参考

WPFアプリケーションの公開作業をAzure Pipelinesを使って自動化する

前回の記事で行った作業を自動化できたらいいなと思っていたらAzure Pipelinesでいい感じにできたので、その時の記録です。Azureの他のサービスとの連携が簡単で、Azure Blob Storageにアップロードする作業も簡単に書くことができました。

準備

前回の記事の続きとして書くので、記事の中で作成したWPFアプリケーションが既にあることを前提とします。今回の作業では、前回の「公開」作業を行った時点で生成される「ClickOnceProfile.pubxml」を使います。Azure Blob Storageも前回と同じコンテナーを使う想定で書きます。

必要なもの

前回の記事に加えて、本記事では以下のもの利用します。

記事の構成

この記事は以下の9つの手順でまとめます。

  1. プロジェクトにランタイムを指定する
  2. Azure DevOpsでプロジェクトを作成する
  3. Azure Reposにコードをプッシュする
  4. Azure Pipelinesでパイプラインを作成し、 Gitリポジトリと紐づける
  5. Pipelineを編集する
  6. Pipelineを実行する
  7. バージョンアップしてGitリポジトリにプッシュする
  8. Pipelineが自動実行することを確認する
  9. アプリが自動更新するか試す

1. プロジェクトにランタイムを指定する

SimpleWPFApplication.csprojRuntimeIdentifiers タグを追加します。

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    ...
    <RuntimeIdentifiers>win-x64</RuntimeIdentifiers>
    ...
  </PropertyGroup>
</Project>

ちょっと説明

RuntimeIdentifiers タグを追加しておかないと、手順6でパイプラインを実行したときに以下のようなエラーが出てしまいます。

##[error]C:\Program Files\dotnet\sdk\5.0.402\Sdks\Microsoft.NET.Sdk\targets\Microsoft.PackageDependencyResolution.targets(241,5): Error NETSDK1047: Assets file 'D:\a\1\s\SimpleWPFApplication\obj\project.assets.json' doesn't have a target for 'net5.0-windows/win-x64'. Ensure that restore has run and that you have included 'net5.0-windows' in the TargetFrameworks for your project. You may also need to include 'win-x64' in your project's RuntimeIdentifiers.

このエラーを避けるため、エラーメッセージで勧められている通りランタイムを指定しておきます。

2. Azure DevOpsでプロジェクトを作成する

Azure DevOpsの自分の組織(Organization)のページを開き、 [+ New project] から新しいプロジェクトを作成します。
今回は [SimpleWPFApplication] という名前で作成しました。

3. Azure Reposにコードをプッシュする

プロジェクトの中にいくつかアイコンがありますが、その中の [Repos] を開きます。

クローンしたりプッシュするためのURLやコマンド例が載せられているので、手元の Git クライアントを使ってコードをプッシュします。手順は省略します。ちなみに .gitignore はGitHub公式のこちらが役に立ちます。注意点として、この .gitignore は今回使いたい pubxml ファイルが含まれているので pubxml をGit管理できるように変更する必要があります。

プッシュするとReposの画面は以下のようになります。

4. Azure Pipelinesでパイプラインを作成し、 Gitリポジトリと紐づける

プロジェクトの中から [Pipelines] を開き、 [Create Pipeline] から新しいパイプラインを作成します。接続先を選択する画面では、 [Azure Repos Git] から先程作成したリポジトリを選択します。ちなみに、候補にあるGitHubやBitbucketなどのGitホスティングサービスと連携することもできます。試しにGitHubとの連携をやってみましたがとても簡単にできました。以降の手順はそれらのサービスと連携した場合も同じように行えます。

5. Pipelineを編集する

この記事のメインです。パイプラインのテンプレートを選べるので [.NET Desktop] を選択します。

そうすると画面上で直接yamlが編集できるようになるので、以下のように編集します。yamlの編集が完了したら [Save] します。

# .NET Desktop
# Build and run tests for .NET Desktop or Windows classic desktop solutions.
# Add steps that publish symbols, save build artifacts, and more:
# https://docs.microsoft.com/azure/devops/pipelines/apps/windows/dot-net

trigger:
- main

pr: none

pool:
  vmImage: 'windows-latest'

variables:
  solution: 'SimpleWPFApplication.sln'
  project: 'SimpleWPFApplication\SimpleWPFApplication.csproj'
  buildPlatform: 'Any CPU'
  buildConfiguration: 'Release'

steps:
- task: NuGetToolInstaller@1

- task: NuGetCommand@2
  inputs:
    restoreSolution: '$(solution)'

- task: VSBuild@1
  inputs:
    solution: '$(solution)'
    platform: '$(buildPlatform)'
    configuration: '$(buildConfiguration)'

- task: VSTest@2
  inputs:
    platform: '$(buildPlatform)'
    configuration: '$(buildConfiguration)'

- task: VSBuild@1
  displayName: 'Publish'
  inputs:
    solution: '$(project)'
    platform: '$(buildPlatform)'
    configuration: '$(buildConfiguration)'
    msbuildArgs: '-t:publish -p:PublishProfile=ClickOnceProfile.pubxml -p:PublishDir="bin\publish\\"'

- task: AzureFileCopy@3
  displayName: 'Upload setup.exe'
  inputs:
    SourcePath: 'SimpleWPFApplication\bin\publish\setup.exe'
    azureSubscription: '(サブスクリプション名)'
    Destination: 'AzureBlob'
    storage: '(ストレージアカウント名)'
    ContainerName: '$web'

- task: AzureFileCopy@3
  displayName: 'Upload SimpleWPFApplication.application'
  inputs:
    SourcePath: 'SimpleWPFApplication\bin\publish\SimpleWPFApplication.application'
    azureSubscription: '(サブスクリプション名)'
    Destination: 'AzureBlob'
    storage: '(ストレージアカウント名)'
    ContainerName: '$web'

- task: AzureFileCopy@3
  displayName: 'Upload Application Files'
  inputs:
    SourcePath: 'SimpleWPFApplication\bin\publish\Application Files'
    azureSubscription: '(サブスクリプション名)'
    Destination: 'AzureBlob'
    storage: '(ストレージアカウント名)'
    ContainerName: '$web'
    BlobPrefix: 'Application Files'

ちょっと説明

ブラウザの右側にある [Tasks] から使いたいタスクを選択し、フォーム入力すればタスクのyamlを自動生成してくれます。まずはこの機能を使ってyamlを自動生成し、必要であれば直接編集する、という流れが良いと思います。

pr: none はプルリクエストを作成した時にパイプラインを実行しないように指定します。

ClickOnce公開のためのコマンドは公式ページを参考にしました。yamlでは [VSBuild] となっていますが、おそらくMSBuildを表していて同じオプションで動きました。PublishProfileオプションを使えば、Visual Studioで操作したときに生成された [ClickOnceProfile.pubxml] を指定してオプションをすっきりさせることができます。しかし、公式ページにもある通りPublishDirオプションはここでも指定する必要があります。

[AzureFileCopy] というタスクでAzure Blob Storageにアップロードしています。[azureSubscription] には、自分のAzureサブスクリプションを指定してください。執筆時点では最新版であるバージョン4 [@4] では動作しませんでした。アップロードしているファイルやフォルダは基本的に前回の記事と同じです。ただしインストールするときに使えるWebページ Publish.html はMSBuildコマンドでは出力されないようで、今回は含めていません。インストール用のリンクが載ったページが必要であれば自分で作成する必要がありそうです。

6. Pipelineを実行する

画面右上の [Run] からパイプラインを実行します。うまくいけば下の図のように成功します。

7. バージョンアップしてGitリポジトリにプッシュする

最後に手順7から9で、コードを変更→mainブランチにプッシュ→パイプライン実行→アプリを更新の流れをやってみます。

ここはVisual Studioで作業します。前回の記事の「発行」機能を使えばリビジョンが自動で増加しましたが、今回はそういうわけにはいかないので ClickOnceProfile.pubxml を自分で編集する必要があります。バージョン番号はなんでもいいですが 1.0.1.0 としてみました。

<Project ToolsVersion="4.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
  <PropertyGroup>
    <ApplicationRevision>0</ApplicationRevision>
    <ApplicationVersion>1.0.1.*</ApplicationVersion>
    ...
  </PropertyGroup>
</Project>

前回と同様に MainWindow.xaml を編集して画面にバージョン番号を表示するようにします。

<Window
    ...
    >
    <Grid>
        <TextBlock Text="Ver.1.0.1.0" />
    </Grid>
</Window>

これらの変更分をgit commitしてmainブランチにプッシュします。

8. Pipelineが自動実行することを確認する

Azure DevOpsのパイプラインのページを見にいくとパイプラインが実行しています。

9. アプリが自動更新するか試す

アプリを起動すると更新ダイアログが表示され、更新すると新しいバージョンになっていることが確認できます。

WPFアプリケーションをClickOnce発行してAzure Blob Storageに公開する

タイトル通りなのですが、開発したWindowsデスクトップアプリケーションをインターネットからインストールして使えるようにしたいということがあったので、その時の備忘録です。

必要なもの

この記事で紹介している方法を試すには以下のものが必要です。

記事の構成

この記事は以下の7つの手順でまとめます。

  1. Azure Blob Storageに静的Webサイト用コンテナーを作成する
  2. WPFアプリケーションを作成する
  3. WPFアプリケーションをClickOnce発行する
  4. アプリのファイル一式をAzure Blob Storageにアップロードする
  5. アプリをインストールする
  6. アプリをバージョンアップして再アップロードする
  7. アプリが自動更新するか試す

1. Azure Blob Storageに静的Webサイト用コンテナーを作成する

Azure Portalに移動して、ストレージアカウントを作成します。

作成し終わったら、左メニューから [静的なWebサイト] を選択します。

[有効] を選択して [保存] をクリックします。(裏で $web という名前のコンテナーが生成されます)

表示されるプライマリエンドポイントをコピーしておきます。(手順3で使います)

2. WPFアプリケーションを作成する

ここからはVisual Studioで作業します。新しいプロジェクトの作成で [WPFアプリケーション] を選択します。

プロジェクト名は [SimpleWPFApplication] とします。

ターゲットフレームワークは [.NET5.0] とします。

以下のようなファイルが自動生成されます。

MainWindow.xaml を少し書き換えてバージョン番号を表示するようにしておきます。

<Window
    ...
    >
    <Grid>
        <TextBlock Text="Ver.1.0.0.0" />
    </Grid>
</Window>

3. WPFアプリケーションをClickOnce発行する

Visual Studioのソリューションエクスプローラーでプロジェクトを右クリックし [発行] をクリックします。

[ClickOnce] を選択します。

発行場所はデフォルトの [bin\publish\] のままでいきます。

インストール場所は [Webサイトから] を選択し、手順1でコピーしたBlob Storageのプライマリエンドポイントを貼り付けます。

設定はデフォルトのままでいきます。ClickOnceには更新プログラムを確認して自動で更新する機能があるのでそれを使ってみます。

今回マニフェストの署名はなしでいきます。

構成はいろいろいじります。「配置モード」は [自己完結] 、「ターゲットランタイム」は [win-x64] を選択し、 [単一ファイルの作成] をチェックします。ここは全てのパターンを試してないですが、この構成でうまく動いたのでこれでいきます。

[完了] をクリックすると下の画像のような画面になるので、 [発行] をクリックします。

発行後、発行場所を見に行くといろいろなファイルが生成されています。

4. アプリのファイル一式をAzure Blob Storageにアップロードする

Azure Storage Explorerを使って作業します。以下のファイルとフォルダを$webコンテナーにアップロードします。Azure Storage Explorerを使えば簡単にドラッグアンドドロップでアップロードできます。

  • setup.exe
  • SimpleWPFApplication.application
  • Publish.html
  • Application Files フォルダ

アップロード後、Azure Portalでも確認できます。

5. アプリのインストール

ブラウザで「Publish.html」を見に行きます。(URLは https://ストレージアカウント名.z11.web.core.windows.net/Publish.html のようになります。)

[インストール] をクリックするとsetup.exeがダウンロードされます。setup.exeを起動すると確認ウィンドウが表示されるので発行元を確認して [実行] をクリックします。

[インストール] をクリックするとインストールが始まります。

インストールが完了するとアプリが起動します。

左下のスタートメニューから起動することができるようになりました。

6. アプリをバージョンアップして再アップロード

ここからは少し蛇足になりますが、新バージョン版をアップロードしてアプリが自動更新するところを手順6と7で記載します。まず、バージョンアップしたことが分かるように画面 MainWindow.xaml のバージョン番号を変えておきます。

<Window
    ...
    >
    <Grid>
        <TextBlock Text="Ver.1.0.0.1" />
    </Grid>
</Window>

手順3の再度で行った [発行] を再度クリックします。リビジョン(バージョン番号の一番右)が自動で増加するようになっているので、ここで発行したバージョンは「1.0.0.1」となります。

手順4で行ったアップロードをもう一度行います。

7. アプリの自動更新

手順5でインストールしたアプリを起動します。すると更新を促すウィンドウが出るので、そのまま [OK] をクリックすると更新されます。

更新後にアプリを開くと新しいバージョン(1.0.0.1)になっていることが確認できます。

ItemsControlを使ってWPFでGitHub風ヒートマップを描画する

WPFアプリで日々の実績を図で表現したいと思い、GitHubのContribution Graph(芝生)を参考に描画してみたのでその時の記録を残しておきます。外部データを参照し動的に描画できるように繰り返し描画に便利な ItemsControlを利用しました。xamlはWPFの素の機能だけで書いていますが、裏側はMVVMパターンで実装できるPrismというフレームワークを利用しています。

環境

使用しているプラットフォームやフレームワークは以下の通りです。

実装:xaml

メインテーマであるItemsControlはこんな感じです。

<ItemsControl ItemsSource="{Binding WeekHeatmaps}">
    <ItemsControl.ItemsPanel>
        <ItemsPanelTemplate>
            <StackPanel Orientation="Horizontal" />
        </ItemsPanelTemplate>
    </ItemsControl.ItemsPanel>
    <ItemsControl.ItemTemplate>
        <DataTemplate>

            <ItemsControl ItemsSource="{Binding HeatmapElements}">
                <ItemsControl.ItemsPanel>
                    <ItemsPanelTemplate>
                        <StackPanel Orientation="Vertical" />
                    </ItemsPanelTemplate>
                </ItemsControl.ItemsPanel>
                <ItemsControl.ItemTemplate>
                    <DataTemplate>
                        <Grid
                            Width="15"
                            Height="15"
                            ToolTip="{Binding Comment}">
                            <Border
                                Margin="1"
                                Background="{Binding Color}"
                                CornerRadius="2" />
                        </Grid>
                    </DataTemplate>
                </ItemsControl.ItemTemplate>
            </ItemsControl>

        </DataTemplate>
    </ItemsControl.ItemTemplate>
</ItemsControl>

ちょっと説明

ItemsControlを入れ子にしています。外側が芝生の列を表していて、実績データでいう1週間分に相当する部分です。横方向に連ねていくため、水平方向(Horizontal)に伸びるStackPanelを使います。内側は芝生の一つのマスを表していて、実績データでいう1日分に相当する部分です。縦方向に連ねていくため、垂直方向(Vertical)に伸びるStackPanelを使います。どちらのItemsControlも中身のカスタマイズにItemTemplateを利用しています。

実装:バインディングするデータ

バインディングしているデータWeekHeatmapsですが、 ViewModel内で以下のようなリストとして定義しています。

public class MainWindowViewModel : BindableBase
{
    ...

    public List<WeekHeatmap> WeekHeatmaps { get; }

    ...
}

リストの型として使っているWeekHeatmapクラスと関連クラスは以下の通りです。

public class WeekHeatmap
{
    public WeekHeatmap(List<HeatmapElement> heatmapElements)
    {
        if (heatmapElements.Count > 7) throw new ArgumentOutOfRangeException(nameof(heatmapElements), "ヒートマップの数が7を超えています。");
        HeatmapElements = heatmapElements;
    }

    public List<HeatmapElement> HeatmapElements { get; }
}
public class HeatmapElement
{
    public HeatmapElement(string color, string comment)
    {
        Color = color;
        Comment = comment;
    }

    public string Color { get; }
    public string Comment { get; }
}

ちょっと説明

芝生のマスの情報はHeatmapElementクラスに持たせています。芝生の色を表す文字列とマウスオーバーした時に表示されるTooltip用の文字列をプロパティとしています。芝生縦一列の一週間分の情報はWeekHeatmapクラスに持たせています。HeatmapElementのリストをプロパティとしています。機能には関係ないですが、一週間分という性質を補強するためリストの長さが7以上だった場合はエラーになるようにしています。

実行

サンプルのデータを代入し実行すると以下の画像ように芝生を描画できました。補足情報として赤文字でどの情報がどこを表しているか記しています。

実行画面。赤文字は後付け

参考

コード全体はGitHubにアップロードしています。リポジトリをcloneしてきたらVisualStudioでGitHubStyleHeatmap.slnを開いてF5実行すると上に記載した画像のウィンドウが表示されます。

まとめと感想

WPFでGitHubのContribution Graph(芝生)風のグラフを描画してみました。グラフを動的に生成するためにItemsControlを利用しました。StackPanelを入れ子にすることで芝生の行と列を表現しました。ItemsControlのItemTemplateを使ってマスの描画方法を指定しています。今回の取り組みでItemsControlの柔軟性を実感しました。特に入れ子にすることでリストの中身にもItemsControlが使えるのは表現の幅が広がっていいですね。また、バインディングするデータに自作クラスが使えるので工夫次第でいろんなリスト情報を見やすく描画するためにも使えると思います。発展として、WeekHeatmapクラスに「概要」プロパティを追加することで芝生の上部に情報を描画させることができるかもしれません。GitHubのように月を表示できそうです。

Unityで作ったゲームをWebGLビルドしてAzure Static Web Appsで公開する

Unityで作ったゲームを気軽に遊べるようにしたいと考えていたところ、Azureに静的Webサイトを簡単に無料でデプロイできるサービス(Static Web Apps)があったので使ってみました。

ちなみにサンプルのゲームにはUnity勉強でお世話になっている本「Unity2021 3D/2Dゲーム開発実践入門」のCHAPTER-3で作成するゲーム「Illumiball」を利用しています。

必要なもの

この記事の内容を試すためには以下の準備が必要です。

  • Unity HubでWebGL Build Supportモジュールをインストールしておく
  • Azureアカウント
  • GitHubアカウント

記事の構成

本記事は以下の2部構成で書いています。

  1. UnityでWebGLビルドする
  2. Azure Static Web Appsで公開する

1. UnityでWebGLビルドする

まずはUnityでの操作です。

圧縮を無効化する

デフォルトの設定では生成されるJavaScriptファイルが圧縮されているのですが、恐らくStatic Web Appsが対応していないことによりゲームが動作しないので、圧縮を無効化します。

  • [Edit] -> [Project Settings…] とクリックし、プロジェクト設定ダイアログを開く
  • [Player]メニューを選択し、WebGLのアイコンのタブを開く
  • [Publishing Settings] の [Compression Format] を [Disabled] にする

ビルドする

ビルドしてWebコンテンツを生成します。

  • [File] -> [Build Settings…] とクリックし、ビルド設定ダイアログを開く
  • [Platform] で [WebGL] を選択し、 [Switch Platform] をクリックする
  • プラットフォームの切り替えが完了したら、 [Build] をクリックする
  • フォルダ選択のダイアログが開くので、Webコンテンツを生成してほしいフォルダを指定して [フォルダーの選択] をクリックする

2. Azure Static Web Appsで公開する

先ほど生成したWebコンテンツを公開します。

GitHubにWebコンテンツをアップロードする

Static Web Apps は基本的にGitHubとの連携を前提としているのでまずはWebコンテンツをGitHubにアップロードします。

Static Web Appsを作成する

いよいよ最後の作業です。

  • Azureポータルから [Static Web Apps] を選択し、 [Create] をクリックする
  • 以下の画像を参考に情報を入力する。特筆する点は以下の通り
    • 個人の開発なら [Plan type] はFreeを選べる
    • [Azure Functions and staging details] は地理的に近そうな [East Asia] を選ぶ
    • [Deployment details] は [GitHub] にして、 [Sign in with GitHub] からGitHubアカウントを認証する
    • その後、先ほど作成したGitHubリポジトリを指定する
    • [Build Presets] は [Custom] にする
  • [Review + create] をクリックし、リソースを作成する
  • 少し待つとGitHub Actionsのワークフローが動き出し、WebコンテンツがSite Web Appsにデプロイされる

確認

Static Web Appsのリソースページから [Browse] をクリックし、ゲーム画面が表示されれば成功です。

ASP.NET Core APIでリストを一括更新したいときはAutoMapperが便利だった

APIで扱うデータの中にリストが含まれる場合に更新(PUT)でリストを一括で対応してほしいとき、EntityFrameworkの追跡機能とAutoMapperを使えば楽に実装できた、という話です。

例えば GET した時が以下のようなデータとします。

GET /api/TodoItems/1
{
    "id": 1,
    "name": "Task1",
    "isComplete": false,
    "todoSubItems": [
        {
            "id": 1,
            "name": "Sub task1",
            "isComplete": false,
            "todoItemId": 1
        },
        {
            "id": 2,
            "name": "Sub task2",
            "isComplete": false,
            "todoItemId": 1
        },
    ]
}

このような場合PUT の時は TodoSubItems に追加、変更、削除があってもそのまま上書きしてもらいたいということです。

PUT /api/TodoItems/1
{
    "id": 1,
    "name": "Task1",
    "isComplete": false,
    "todoSubItems": [
        {
            "id": 2,
            "name": "Sub task2 (updated)"
            "isComplete": false,
            "todoItemId": 1
        },
        {
            "id": 0,
            "name": "New task"
            "isComplete": false,
            "todoItemId": 1
        }
    ]
}

この例では TodoSubItem の1を削除、2を編集、一つを新たに追加しています。

補足: サブタスクでAPIのエンドポイント作ればいいんじゃない?

確かに TodoItems と TodoSubItems でエンドポイントを分ける設計もあります。ですが今回サブタスクはメインタスクに従属するもので、サブタスクの変更もメインタスクの更新時に行いたい、というユースケースを想定しています。例えば、伝票と伝票明細の関係、記事と記事タグなど、1対多の関係で1の属性の一つとして多(リスト)を持っている場合には実用的なパターンかと思っています。

準備

今回はデータベースにSQL Serverを利用します。今はDockerで簡単にSQL Serverを起動できます。

docker run -e "ACCEPT_EULA=Y" -e "SA_PASSWORD=<your_strong_password>" -p 1433:1433 --name todosubapi -d mcr.microsoft.com/mssql/server:2019-latest

たったこれだけ。いい時代になりました。

実装

ここから実装していきます。サンプルとして公式サイトのチュートリアルにあるToDoアプリを使います。このアプリにToDoタスクが複数のサブタスクを持てる機能を追加します。基本的にチュートリアル通りに進めて、サブタスク機能を実現するのに必要な最低限の実装を付け足す方針です。

Webプロジェクトの作成

公式チュートリアル通りです。詳細な作成方法は割愛します。プロジェクト名は「 TodoSubApi 」としました。

テスト

初期状態で正常に起動するか確認します。

  • Ctrl + F5 を押して実行する
  • Webブラウザが起動しSwaggerの画面が表示されることを確認する

モデルクラスの追加

基本公式チュートリアル通りですが、サブタスクを表す TodoSubItem を追加します。

  • ソリューションエクスプローラーを右クリックして [追加] -> [新しいフォルダー] を選択する
  • 「 Models 」という名前をつける
  • Models フォルダーを右クリックして [追加] -> [クラス] を選択する
  • クラスに「 TodoItem 」という名前をつける
  • 同様に Models 配下に「 TodoSubItem 」というクラスをつくる
  • TodoItem.cs と TodoSubItem.cs を以下のように作成する
namespace TodoSubApi.Models
{
    public class TodoItem
    {
        public long Id { get; set; }
        public string Name { get; set; }
        public bool IsComplete { get; set; }
        public List<TodoSubItem> TodoSubItems { get; set; }
    }
}
namespace TodoSubApi.Models
{
    public class TodoSubItem
    {
        public long Id { get; set; }
        public string Name { get; set; }
        public bool IsComplete { get; set; }
        public long TodoItemId { get; set; }
    }
}
ちょっと説明

TodoItem クラスの TodoSubItems プロパティはコレクションナビゲーションプロパティと呼ばれるものです。TodoSubItem クラスの TodoItemId は外部キーです。(参照

データベースコンテキストの追加

公式チュートリアルではインメモリデータベースを採用していますが、今回はSQL Serverで試すので、NuGetパッケージ「 Microsoft.EntityFrameworkCore.SqlServer 」を追加します。その後以下のようにデータベースコンテキストを追加します。

  • Models フォルダーを右クリックして [追加] -> [クラス] を選択する
  • クラスに「 TodoContext 」という名前をつける
  • TodoContext.cs を以下のように作成する
using Microsoft.EntityFrameworkCore;

namespace TodoSubApi.Models
{
    public class TodoContext : DbContext
    {
        public TodoContext(DbContextOptions<TodoContext> options)
            :base(options)
        {
        }

        public DbSet<TodoItem> TodoItems { get; set; }
        public DbSet<TodoSubItem> TodoSubItems { get; set; }
    }
}

データベースコンテキストの登録

公式チュートリアルとは異なり、SQL Serverを使う形に Startup.cs を書き換えます。

public void ConfigureServices(IServiceCollection services)
{
    services.AddDbContext<TodoContext>(opt =>
        opt.UseSqlServer("Server=tcp:127.0.0.1,1433;Initial Catalog=todoapi;User ID=sa;Password=<your_strong_password>;Persist Security Info=False;"));

    ...
}

マイグレーションとテーブル作成

この作業は公式チュートリアルにはありません。今回はSQL Serverを使うので事前にSQL Serverにテーブルを作成しておきます。

  • Visual Studioの [パッケージマネージャーコンソール] を開く
  • 以下のコマンドを実行し、マイグレーションを作成する
PM> Add-Migration InitialCreate
Build started...
Build succeeded.
To undo this action, use Remove-Migration.
  • プロジェクト配下に Migrations フォルダーがあることを確認する
  • 以下のコマンドを実行し、データベースを更新する
PM> Update-Database
Build started...
Build succeeded.
Done.

コントローラーのスキャフォールディング

公式チュートリアル通りです。詳細な方法は割愛しますがスクリーンショットだけ載せておきます。

PostTodoItem 作成メソッドの更新

公式チュートリアル通りです。

GetTodoItems メソッドとGetTodoItem メソッドの更新

これは公式チュートリアルにはありません。GET してきたときに TodoSubItems も取得できるように処理を追加します。

  • TodoItemsController.cs を以下のように編集する
[HttpGet]
public async Task<ActionResult<IEnumerable<TodoItem>>> GetTodoItems()
{
    return await _context.TodoItems.Include(x => x.TodoSubItems).ToListAsync();
}

...
[HttpGet("{id}")]
public async Task<ActionResult<TodoItem>> GetTodoItem(long id)
{
    var todoItem = await _context.TodoItems.Include(x => x.TodoSubItems).FirstOrDefaultAsync(x => x.Id == id);

    if (todoItem == null)
    {
        return NotFound();
    }

    return todoItem;
}
ちょっと説明

データコンテキストからデータを取得する処理に Include メソッドを追加して TodoSubItems を読み込んでいます。

PutTodoItem メソッドの更新

この記事のメインです。データ更新時に TodoSubItems の中身を自由に変えても適切に更新してもらうためAutoMapperを使う方法に変えます。

  • NuGetパッケージ「 AutoMapper 」を追加する
  • まず TodoItemsController.cs のコンストラクタにAutomapperの定義と設定を書く
private readonly IMapper _mapper;

...

public TodoItemsController(TodoContext context)
{
    _context = context;

    var config = new MapperConfiguration(cfg =>
    {
        cfg.CreateMap<TodoItem, TodoItem>();
        cfg.CreateMap<TodoSubItem, TodoSubItem>();
    });
    _mapper = config.CreateMapper();
}
  • PutTodoItem メソッドを書き換える
[HttpPut("{id}")]
public async Task<IActionResult> PutTodoItem(long id, TodoItem todoItem)
{
    if (id != todoItem.Id)
    {
        return BadRequest();
    }

    // コメントアウトする
    //_context.Entry(todoItem).State = EntityState.Modified;

    try
    {
        var found = _context.TodoItems.Include(t => t.TodoSubItems).FirstOrDefault(x => x.Id == id);

        _mapper.Map(todoItem, found);

        _context.TodoItems.Update(found);

        await _context.SaveChangesAsync();
    }
    catch (DbUpdateConcurrencyException)
    {
        if (!TodoItemExists(id))
        {
            return NotFound();
        }
        else
        {
            throw;
        }
    }

    return NoContent();
}
ちょっと説明

UPDATE処理のメインは try 句の中です。一度データベースから該当するIDのデータを持ってきて、更新したいデータをAutoMapperで上書きします。Entity Frameworkの追跡機能が変更箇所を判別してくれるのでいい感じにデータ更新してくれます。正直この辺の仕組みは完全には理解できていないですが、とりあえず想定していた動きをしてくれます。

Postman を使ってテスト

最後にAPIが想定通り動作するか Postmanを使って確かめます。

  • Ctrl + F5 で起動する
  • 作成 (POST)。Postmanで以下のクエリを送信する
    • ※:ポート番号は環境によって異なります。
POST: https://localhost:44366/api/TodoItems
{
    "id": 0,
    "name": "Task1",
    "isComplete": false,
    "todoSubItems": [
        {
            "id": 0,
            "name": "Sub task1",
            "isComplete": false,
            "todoItemId": 0
        },
        {
            "id": 0,
            "name": "Sub task2",
            "isComplete": false,
            "todoItemId": 0
        }
    ]
}
  • レスポンス例
{
    "id": 1,
    "name": "Task1",
    "isComplete": false,
    "todoSubItems": [
        {
            "id": 1,
            "name": "Sub task1",
            "isComplete": false,
            "todoItemId": 1
        },
        {
            "id": 2,
            "name": "Sub task2",
            "isComplete": false,
            "todoItemId": 1
        }
    ]
}
  • 取得 (GET)。Postmanで以下のクエリを送信する
GET: https://localhost:44366/api/TodoItems/1
  • レスポンス例
{
    "id": 1,
    "name": "Task1",
    "isComplete": false,
    "todoSubItems": [
        {
            "id": 1,
            "name": "Sub task1",
            "isComplete": false,
            "todoItemId": 1
        },
        {
            "id": 2,
            "name": "Sub task2",
            "isComplete": false,
            "todoItemId": 1
        }
    ]
}
  • 更新 (PUT)。Postmanで以下のクエリを送信する
    • ※:サブタスク1を削除、サブタスク2を変更、新しいサブタスクの一つ追加した、という想定です。
PUT: https://localhost:44366/api/TodoItems/1
{
    "id": 1,
    "name": "Task1",
    "isComplete": false,
    "todoSubItems": [
        {
            "id": 2,
            "name": "Sub task2 (updated)",
            "isComplete": false,
            "todoItemId": 1
        },
        {
            "id": 0,
            "name": "New task",
            "isComplete": false,
            "todoItemId": 1
        }
    ]
}
  • 再取得 (GET)。Postmanで以下のクエリを送信する
GET: https://localhost:44366/api/TodoItems/1
  • レスポンス例
{
    "id": 1,
    "name": "Task1",
    "isComplete": false,
    "todoSubItems": [
        {
            "id": 2,
            "name": "Sub task2 (updated)",
            "isComplete": false,
            "todoItemId": 1
        },
        {
            "id": 3,
            "name": "New task",
            "isComplete": false,
            "todoItemId": 1
        }
    ]
}

サブタスクの削除、変更、追加が全て反映されているのが分かります。

まとめと感想

APIのデータの中にリスト(配列)を設けた場合の実装方法をまとめました。リスト自体をAPIのエンドポイントにして一つずつCRUDさせる設計もありますが、リストが完全に親の要素に従属している場合は今回のような設計のほうが簡単に利用できるので、ここで書いた内容が役立つ場面は意外とあると思います。この記事では書きませんでしたが、実際のAPIのモデルクラスとDBに記録するデータモデルクラスに同一のものを使う必要はありません。今回のサンプルだとサブタスクの外部キー TodoItemId はAPIに含める必要がない項目ですし、サブタスクのIDも本質的には不要です。そのため実際にはモデルクラスとデータモデルクラスを分けて定義します。その場合はクラス間のデータ変換方法が課題になりますが、やはりAutoMapperが便利なのでこの記事の方法と同じようなAutoMapperを使った実装で課題に対応できます。

ASP.NET Core SignalR でルーム付きチャットアプリを作ってみた

最近SignalRが気になったので公式チュートリアルやってみました。
分かりやすくていいんだけどリアルタイムアプリにありがちなチャットルーム毎に通信を分ける機能がなかったので自分で実装してみました。
余計な機能はつけずに、最低限ルーム内通信が成り立つ程度のシンプルな実装にしています。
尚、ルーム内通信を実現するのにSignalRのグループ機能を利用しています。
実装済みのコードはGitHubに上げてます。

バージョン

  •  Visual Studio 2019
  • .NET 5.0

プロジェクト作成

基本公式チュートリアル 通りです。

  • Visual Studioで新しいプロジェクトを作成する
  • 「ASP.NET Core Webアプリ」を選択する
    • ※同じVS2019でもインストール環境によっては名称が少し違うことがあります。
  • プロジェクト名は「SignalRGroupChat」とした

とりあえず実行

プロジェクトが作成出来たら、まずは初期状態で動くことを確認するため「F5」キーを押して実行します。

SignalRクライアントライブラリを追加する

公式チュートリアル 通りです。

  • ソリューションエクスプローラーで、プロジェクトを右クリックし、[追加] -> [クライアント側のライブラリ]を選択する
  • [クライアント側のライブラリを追加します]ダイアログで以下の通り選択する
    • プロバイダー: unpkg
    • ライブラリ:@microsoft/signalr@latest
    • [特定のファイルの選択]を選択する
    • dist/browser フォルダを展開し、 signalr.jssignalr.min.js を選択する
    • [ターゲットロケーション]に wwwroot/js/signalr/ と入力する
    • [インストール]をクリック

SignalRハブを作成する

基本は公式チュートリアル 通りだけど、ハブに実装するコードについてはグループ機能に関する処理に書き換えます。

  • プロジェクト直下に Hubs フォルダを作成する
  • Hubs フォルダ内に ChatHub.cs を作成し、以下のように編集する
using Microsoft.AspNetCore.SignalR;
using System.Threading.Tasks;

namespace SignalRGroupChat.Hubs
{
    public class ChatHub : Hub
    {
        public async Task SendMessageToGroup(string group, string user, string message)
        {
            await Clients.Group(group).SendAsync("ReceiveMessage", user, message);
        }

        public async Task AddToGroup(string groupName)
        {
            await Groups.AddToGroupAsync(Context.ConnectionId, groupName);
        }
    }
}
ちょっと説明
  • SendMessageToGroup 関数について
    • チュートリアルでは全クライアントに送信するが、今回は指定したグループに送信する(参考
  • AddToGroup 関数について
    • クライアントをグループに追加する処理(参考

SignalRを構成する

公式チュートリアル 通りです。

  • Startup.cs を以下のように変更する
using SignalRGroupChat.Hubs;
...
public void ConfigureServices(IServiceCollection services)
{
    services.AddRazorPages();
    services.AddSignalR();
}
...
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
    ...

    app.UseEndpoints(endpoints =>
    {
        endpoints.MapRazorPages();
        endpoints.MapHub<ChatHub>("/chatHub");
    });
}

SignalRクライアントコードを追加する

ここは公式チュートリアルとは少し異なります。
まず、トップページはグループ名を入力してもらう画面にします。
そして、チャットルームページを新たに作成してチャット画面を実装します。

  • Pages\Index.cshtml を以下のように変更する
@page
@model IndexModel
@{
    ViewData["Title"] = "Home page";
}

<div class="container">
    <div class="row">
        <div class="col-2">Room name</div>
        <div class="col-4"><input type="text" id="roomName" /></div>
    </div>
    <div class="row"> </div>
    <div class="row">
        <div class="col-6">
            <input type="button" id="moveToRoom" value="Move to room" />
        </div>
    </div>
</div>
<script src="~/js/index.js"></script>
  • wwwroot\js\index.js を作成し、以下のように編集する
document.getElementById("moveToRoom").addEventListener("click", function (event) {
    var roomName = document.getElementById("roomName").value;
    window.location.href = "Room/" + roomName;
});
  • Pages\Room.cshtml を作成し、以下のように編集する
    • VSを使っている場合はソリューションエクスプローラーで Pages フォルダを右クリックして[追加] -> [新しい項目] -> [Razorページ – 空]を選択すると良い(C#コードビハインドを自動生成してくれる)
@page "{roomname?}"
@model RoomModel
@{
    ViewData["Title"] = "Room";
}

<h1>@ViewData["Title"] : @Model.RoomName</h1>
<input type="hidden" id="roomName" value="@Model.RoomName" />
<div class="container">
    <div class="row"> </div>
    <div class="row">
        <div class="col-2">User</div>
        <div class="col-4"><input type="text" id="userInput" /></div>
    </div>
    <div class="row">
        <div class="col-2">Message</div>
        <div class="col-4"><input type="text" id="messageInput" /></div>
    </div>
    <div class="row"> </div>
    <div class="row">
        <div class="col-6">
            <input type="button" id="sendButton" value="Send Message" />
        </div>
    </div>
</div>
<div class="row">
    <div class="col-12">
        <hr />
    </div>
</div>
<div class="row">
    <div class="col-6">
        <ul id="messagesList"></ul>
    </div>
</div>
<script src="~/js/signalr/dist/browser/signalr.js"></script>
<script src="~/js/chat.js"></script>
  • Pages\Room.cshtml.cs を以下のように変更する
using Microsoft.AspNetCore.Mvc.RazorPages;

namespace SignalRGroupChat.Pages
{
    public class RoomModel : PageModel
    {
        public string RoomName;

        public void OnGet(string roomName)
        {
            RoomName = roomName;
        }
    }
}
ちょっと説明

ここにはURLの末尾をルーム名として設定する処理を書いています。

  • wwwroot\js\chat.js を作成し、以下のように編集する
"use strict";

var connection = new signalR.HubConnectionBuilder().withUrl("/chatHub").build();

document.getElementById("sendButton").disabled = true;

connection.on("ReceiveMessage", function (user, message) {
    var li = document.createElement("li");
    document.getElementById("messagesList").appendChild(li);
    li.textContent = `${user} says ${message}`;
});

connection.start().then(function () {
    document.getElementById("sendButton").disabled = false;

    var roomName = document.getElementById("roomName").value;
    connection.invoke("AddToGroup", roomName).catch(function (err) {
        return console.error(err.toString());
    });

}).catch(function (err) {
    return console.error(err.toString());
});

document.getElementById("sendButton").addEventListener("click", function (event) {
    var roomName = document.getElementById("roomName").value;
    var user = document.getElementById("userInput").value;
    var message = document.getElementById("messageInput").value;
    connection.invoke("SendMessageToGroup", roomName, user, message).catch(function (err) {
        return console.error(err.toString());
    });
    event.preventDefault();
});
ちょっと説明
  • chat.js は公式チュートリアルのコードを参考にしています。
  • 公式チュートリアルに追加したのは以下の2点です。
    • SignalR接続開始時にグループに参加する処理を追加しています。
    • 送信ボタンを押したときにグループを指定してチャットメッセージ送信するようにしています。

アプリを実行する

  • 「F5」キーを押して実行します。
  • ブラウザが起動します。
  • トップページでルーム名を入力するとルーム専用のチャットページに遷移します。
  • 下の画像のようにルームに分かれてメッセージをリアルタイムで送受信できます。
    • 上2つのブラウザウィンドウはRoom1に、下2つはRoom2に参加している、という想定です。
チャットアプリ実行画面
チャットアプリ実行画面

まとめと感想

公式チュートリアルのSignalRサンプルアプリにルーム別メッセージ送受信機能を追加しました。そのためにSignalRのグループの仕組みを使って実装しました。
実装して感じたのはSignalRは高水準ライブラリだということ。クライアントとサーバで情報を送信しあうというより、お互いの関数を実行させる、というイメージでプログラミングできました。
ネットワーク上の流れを意識せずにプログラムの関数レベルでプログラミングできるのは処理内容に集中できるので使いやすいですね。

AutoMapperで詰め替えるDTOクラスにはinitアクセサーが使えそう(C#)

プログラム内のオブジェクトを外部に転送するためにDTO (Data Transfer Object) を使うことがありますが、C#であればAutoMapperという便利なライブラリがあります。

これまでAutoMapperでDTOに詰め替えする際、DTOクラスはプロパティは①getアクセサーのみでコンストラクタで代入するのが一般的でした。もしくは簡単に②setアクセサーを設定する方法もありました。

それぞれの方法の特徴は以下の通りです。

①getアクセサーのみでコンストラクタで代入

  • DTO生成後に意図せず値を書き換えられない(良いこと)
  • コンストラクタを作るのが面倒

②setアクセサーを設定する

  • DTO生成後に値を書き換えられてしまう
  • コンストラクタを作らなくていいので楽

それぞれ良いところと悪いところがありましたが、C#9.0 の新機能に initアクセサーが登場したことでこの2つのいいとこどりができるようになりました。特徴をまとめておきます。

③initアクセサーを使う

  • DTO生成後に値を書き換えられない(良いこと)
  • コンストラクタが不要

参考のため各方法のサンプルコードを載せておきます。

サンプルコード

①getアクセサーのみでコンストラクタで代入

using AutoMapper;
using System;

namespace AutoMapperTest
{
    class Program
    {
        static void Main(string[] args)
        {
            var config = new MapperConfiguration(cfg => cfg.CreateMap<Item, ItemDto>());
            var mapper = config.CreateMapper();

            var item = new Item(1, "商品1", 100);
            ItemDto itemDto = mapper.Map<ItemDto>(item);

            Console.WriteLine("Item:");
            Console.WriteLine($"Id: {item.Id}, Name: {item.Name}, Price: {item.Price}");

            Console.WriteLine("ItemDto:");
            Console.WriteLine($"Id: {itemDto.Id}, Name: {itemDto.Name}, Price: {itemDto.Price}");

        }
    }

    class Item
    {
        public Item(int id, string name, int price)
        {
            Id = id;
            Name = name;
            Price = price;
        }

        public int Id { get; }
        public string Name { get; private set; }
        public int Price { get; private set; }
    }

    class ItemDto
    {
        public ItemDto(Item item) : this(item.Id, item.Name, item.Price)
        {
        }

        public ItemDto(int id, string name, int price)
        {
            Id = id;
            Name = name;
            Price = price;
        }

        public int Id { get; }
        public string Name { get; }
        public int Price { get; }
    }
}

②setアクセサーを設定する

using AutoMapper;
using System;

namespace AutoMapperTest
{
    class Program
    {
        static void Main(string[] args)
        {
            var config = new MapperConfiguration(cfg => cfg.CreateMap<Item, ItemDto>());
            var mapper = config.CreateMapper();

            var item = new Item(1, "商品1", 100);
            ItemDto itemDto = mapper.Map<ItemDto>(item);

            Console.WriteLine("Item:");
            Console.WriteLine($"Id: {item.Id}, Name: {item.Name}, Price: {item.Price}");

            Console.WriteLine("ItemDto:");
            Console.WriteLine($"Id: {itemDto.Id}, Name: {itemDto.Name}, Price: {itemDto.Price}");

        }
    }

    class Item
    {
        public Item(int id, string name, int price)
        {
            Id = id;
            Name = name;
            Price = price;
        }

        public int Id { get; }
        public string Name { get; private set; }
        public int Price { get; private set; }
    }

    class ItemDto
    {
        public int Id { get; set; }
        public string Name { get; set; }
        public int Price { get; set; }
    }
}

③initアクセサーを使う

using AutoMapper;
using System;

namespace AutoMapperTest
{
    class Program
    {
        static void Main(string[] args)
        {
            var config = new MapperConfiguration(cfg => cfg.CreateMap<Item, ItemDto>());
            var mapper = config.CreateMapper();

            var item = new Item(1, "商品1", 100);
            ItemDto itemDto = mapper.Map<ItemDto>(item);

            Console.WriteLine("Item:");
            Console.WriteLine($"Id: {item.Id}, Name: {item.Name}, Price: {item.Price}");

            Console.WriteLine("ItemDto:");
            Console.WriteLine($"Id: {itemDto.Id}, Name: {itemDto.Name}, Price: {itemDto.Price}");

        }
    }

    class Item
    {
        public Item(int id, string name, int price)
        {
            Id = id;
            Name = name;
            Price = price;
        }

        public int Id { get; }
        public string Name { get; private set; }
        public int Price { get; private set; }
    }

    class ItemDto
    {
        public int Id { get; init; }
        public string Name { get; init; }
        public int Price { get; init; }
    }
}