BlazorでTreeコンポーネントを使ってみる

Blazor WebAssemblyでTreeコントロールを利用したいと思いググったところ以下のようなコンポーネントを見つけました。

github.com

「Tree / Tag Selector / Page Panel」の3つのコンポーネントが提供されていますが、そのうちTreeについて試してみたいと思います。

1. こんなの作ります

Corporation→Teamの階層構造をTree表示します。
f:id:daigo-knowlbo:20200606172614p:plain

選択した項目名が表示されます。
f:id:daigo-knowlbo:20200606172732p:plain

選択したTeamを上下に移動可能です。
f:id:daigo-knowlbo:20200606172948p:plain

2. 実装手順

2.1. プロジェクト作成

Visual Studioで Blazor WebAssembly App プロジェクトを作成します。

f:id:daigo-knowlbo:20200606171045p:plain

プロジェクト名は「UseMwBlazorTree」としました。

※「dotnet new blazorwasm -o UseMwBlazorTree」でも同様。

2.2. nugetで「MW.Blazor.Tree」を追加

「Nuget パッケージの管理」で「MW.Blazor.Tree」パッケージを追加します。

www.nuget.org

2.3. Teamモデルクラスを追加

ツリーの要素となるモデルクラスを作成します。
ここでは「Teamクラス」としました。
プロジェクトルート配下に Models フォルダを作成し、その下に Team.cs を追加します。

f:id:daigo-knowlbo:20200606171441p:plain

// Models/Team.cs

using System.Collections.Generic;

namespace UseMwBlazorTree.Models
{
  /// <summary>
  /// チームクラス
  /// </summary>
  public class Team
  {
    /// <summary>
    /// インデックス(並び順)を取得または設定します。
    /// </summary>
    public int Index { get; set; }
    
    /// <summary>
    /// 名称を取得または設定します。
    /// </summary>
    public string Name { get; set; }
    
    /// <summary>
    /// 親チームを取得または設定します。
    /// </summary>
    public Team Parent { get; set; }
    
    /// <summary>
    /// 子チームリストを取得または設定します。
    /// </summary>
    public IList<Team> Children { get; } = new List<Team>();

    /// <summary>
    /// 子チームを追加します。
    /// </summary>
    /// <param name="name">チーム名</param>
    /// <returns>追加したTeamオブジェクト</returns>
    public Team AddChild(string name)
    {
      Team team = new Team() { Name = name, Index = this.Children.Count, Parent = this };
      this.Children.Add(team);
      return team;
    }
  }
}

2.4. MW.Blazor.Tree用 css(js)参照の追加

wwwroot/index.html に「MW.Blazor.Treeのcss」および「Treeが利用しているfontawesome css」への StyleSheet Linkを追加します。

// wwwroot/Index.html

<!DOCTYPE html>
<html>

<head>
  <meta charset="utf-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
  <title>UseMwBlazorTree</title>
  <base href="/" />
  <link href="css/bootstrap/bootstrap.min.css" rel="stylesheet" />
  <link href="css/app.css" rel="stylesheet" />

  <link rel="stylesheet" href="https://use.fontawesome.com/releases/v5.13.0/css/all.css" />
  <link rel="stylesheet" href="_content/MW.Blazor.Tree/styles.css" />
</head>

<body>
  <app>Loading...</app>

  <div id="blazor-error-ui">
    An unhandled error has occurred.
    <a href="" class="reload">Reload</a>
    <a class="dismiss">🗙</a>
  </div>
  <script src="_framework/blazor.webassembly.js"></script>
</body>

</html>

※fontawesomeのcssCDN提供が無くなるんでしたっけ?v6をfreeライセンス登録してjsとして参照利用する場合は、以下のような置き換えになります。

<link rel="stylesheet" href="https://use.fontawesome.com/releases/v5.13.0/css/all.css" />
↓↓↓
<script src="https://kit.fontawesome.com/xxxxx.js" crossorigin="anonymous"></script>

2.5. Treeを配置した Pages/Index.razor を実装

Index.razor全体の実装は以下の通りです。

// Pages/Index.razor

@page "/"
@using UseMwBlazorTree.Models
@using MW.Blazor
@using System.Linq

<h1>
  MW.Blazor.Treeの利用サンプル
</h1>

<Tree Nodes="Teams"
    @bind-ExpandedNodes="ExpandedNodes"
    @bind-SelectedNode="SelectedItem"
    HasChildNodes="@(item => item.Children?.Any() == true)"
    ChildSelector="@(item => item.Children.OrderBy(i => i.Index))">
  <TitleTemplate>@context.Name</TitleTemplate>
</Tree>
<br />
<p>
  選択中:@SelectedItem?.Name
</p>

<button class="btn btn-primary" @onclick="OnUpTeam">上に移動</button>
<button class="btn btn-primary" @onclick="OnDownTeam">下に移動</button>

@code
{
  public List<Team> Teams { get; set; } = new List<Team>();
  public Team SelectedItem { get; set; }
  public IList<Team> ExpandedNodes { get; set; } = new List<Team>();

  protected override void OnInitialized()
  {
    // ツリーに表示するTeam要素を作成
    //  ルート要素
    Team org = new Team() { Name = "Sample Corp" };
    //  ルートの1つ目の子要素
    Team team = org.AddChild("Team1");
    team.AddChild("Team1-1");
    team.AddChild("Team1-2");
    //  ルートの2つ目の子要素
    Team team2 = org.AddChild("Team2");
    team2.AddChild("Team2-1");
    team2.AddChild("Team2-2");
    team2.AddChild("Team2-3");
    team2.AddChild("Team2-4");

    // Treeコンポーネントにバインドするルート要素を追加
    this.Teams.Add(org);

    // 初期表示で開いておくTeam要素を指定
    this.ExpandedNodes.Add(org);
    this.ExpandedNodes.Add(team2);

    base.OnInitialized();
  }

  /// <summary>
  /// Team要素を上に移動
  /// </summary>
  protected void OnUpTeam()
  {
    if (this.SelectedItem == null || this.SelectedItem.Parent == null)
      return;

    if (this.SelectedItem.Index > 0)
    {
      var target = this.SelectedItem.Parent.Children.First(c => c.Index == this.SelectedItem.Index - 1);
      target.Index++;
      this.SelectedItem.Index--;
    }
  }

  /// <summary>
  /// Team要素を下に移動
  /// </summary>
  protected void OnDownTeam()
  {
    if (this.SelectedItem == null || this.SelectedItem.Parent == null)
      return;

    if (this.SelectedItem.Index < this.SelectedItem.Parent.Children.Count - 1)
    {
      var target = this.SelectedItem.Parent.Children.First(c => c.Index == this.SelectedItem.Index + 1);
      target.Index--;
      this.SelectedItem.Index++;
    }
  }
}

各実装要素について以下に補足説明を記述します。

using追加

モデルクラス・Treeを使うためのMW.Blazor・Linqへのusingを追加しています。

@using UseMwBlazorTree.Models
@using MW.Blazor
@using System.Linq

Treeeコンポーネント定義

<tree>はusing宣言しているため、つまり「MW.Blazor.Treeコンポーネント」としての宣言となります。

<Tree Nodes="Teams"
      @bind-ExpandedNodes="ExpandedNodes"
      @bind-SelectedNode="SelectedItem"
      HasChildNodes="@(item => item.Children?.Any() == true)"
      ChildSelector="@(item => item.Children.OrderBy(i => i.Index))">
    <TitleTemplate>@context.Name</TitleTemplate>
</Tree>

@codeセクションでの実装と関連しますが、「@bind-ExpandedNodes」は初期状態でツリーの子要素が開いている項目をデータバインドで指定しています。
@bind-SelectedNodeは、選択中の項目をデータバインドしています。
HasChildNodesは、ツリーノード表示時に「+」アイコンを表示する・しないの判定に利用されます。
ChildSelectorは、対象ノードの子要素のセレクタを指定します。今回は並び順を要素(Team)のIndexプロパティの値で表し、Index値の大小により並び順を制御するため、「item.Children.OrderBy(i => i.Index)」という宣言を行っています。
TitleTemplateは、要素の表示テンプレートの宣言になります。ここでは単純に「Nameプロパティ」を表示するテンプレート宣言を行っています。

OnInitialized()でデータ初期化

<tree>にバインドするデータの初期化を行っています。

    protected override void OnInitialized()
    {
        // ツリーに表示するTeam要素を作成
        //  ルート要素
        Team org = new Team() { Name = "Sample Corp" };
        //  ルートの1つ目の子要素
        Team team = org.AddChild("Team1");
        team.AddChild("Team1-1");
        team.AddChild("Team1-2");
        //  ルートの2つ目の子要素
        Team team2 = org.AddChild("Team2");
        team2.AddChild("Team2-1");
        team2.AddChild("Team2-2");
        team2.AddChild("Team2-3");
        team2.AddChild("Team2-4");

        // Treeコンポーネントにバインドするルート要素を追加
        this.Teams.Add(org);

        // 初期表示で開いておくTeam要素を指定
        this.ExpandedNodes.Add(org);
        this.ExpandedNodes.Add(team2);

        base.OnInitialized();
    }

Team要素の上下移動

選択した要素を上下に移動するためのボタンとそのイベントハンドラ定義を行っています。

<button class="btn btn-primary" @onclick="OnUpTeam">上に移動</button>
<button class="btn btn-primary" @onclick="OnDownTeam">下に移動</button>

...省略...

/// <summary>
/// Team要素を上に移動
/// </summary>
protected void OnUpTeam()
{
  if (this.SelectedItem == null || this.SelectedItem.Parent == null)
    return;

  if (this.SelectedItem.Index > 0)
  {
    var target = this.SelectedItem.Parent.Children.First(c => c.Index == this.SelectedItem.Index - 1);
    target.Index++;
    this.SelectedItem.Index--;
  }
}

/// <summary>
/// Team要素を下に移動
/// </summary>
protected void OnDownTeam()
{
  if (this.SelectedItem == null || this.SelectedItem.Parent == null)
    return;

  if (this.SelectedItem.Index < this.SelectedItem.Parent.Children.Count - 1)
  {
    var target = this.SelectedItem.Parent.Children.First(c => c.Index == this.SelectedItem.Index + 1);
    target.Index--;
    this.SelectedItem.Index++;
  }
}

前述の通り、<tree>の子要素セレクタ定義で要素の並び順は Index 順としています。

  ChildSelector="@(item => item.Children.OrderBy(i => i.Index))">

OnUpTeam() / OnDownTeam()の各イベントハンドラでは選択されている要素のIndexプロパティ値を操作するロジックを実装しています。
バインド対象であるTeam要素の変更により、Treeコンポーネント上の表示項目にも並び順が変更された状態が反映されます(データバインディングの仕組み)。

3. まとめ

ということで、Blazor WebAssemblyでもTreeコントロールを気軽に使えるコンポーネント環境が整っていますね。
今回作成したサンプルソースは、以下のGithubに置いておきました。
github.com

ではーー。

BlazorでSPAするぞ!(8) - Validation -正式版対応済

Blazor連載ブログの続きになります。

という事で、↓↓↓の続きです。

ryuichi111std.hatenablog.com

本記事では、Blazorでの入力Validation(入力検証)についてまとめたいと思います。
(本記事は、Blazor WebAssemblyを軸としています)

1. DataAnnotation(データ注釈)ベースでの入力検証

BlazorのValidation機能(入力検証機能)は、ASP.NETでのWeb開発者にはお馴染みの「データ注釈(Data Annotation)」方式で実装されています。
データ注釈による方式とは、[Required] や [StringLength(10)] のような属性設定をモデルクラスのプロパティに指定することで、検証設定を宣言的に定義する方法です。

とりあえず、ValidationExamAppというプロジェクトを作成して話を進めたいと思います。

dotnet new blazorwasm -o ValidationExamApp

1.1. モデルクラス定義

では早速、モデル定義を行います。

Modelsフォルダを作成し、Account.csファイルを追加します。
何らかのサービスアプリにおけるユーザアカウントを想定します。

# Models/Account.cs #

using System.ComponentModel.DataAnnotations;

namespace ValidationExamApp.Models
{
  public class Account
  {
    [Required(ErrorMessage = "アカウント名を入力してください。")]
    public string AccountName { get; set; }

    [Required(ErrorMessage = "年齢を入力してください。")]
    [Range(0, 200, ErrorMessage = "年齢は0~200で設定してください。")]
    public int Age { get; set; }

    [Required(ErrorMessage = "メールアドレスを入力してください。")]
    [RegularExpression(@"[\w!#$%&'*+/=?^_@{}\\|~-]+(\.[\w!#$%&'*+/=?^_{}\\|~-]+)*@([\w][\w-]*\.)+[\w][\w-]*",
     ErrorMessage = "メールアドレスが正しくありません。")]
    public string EMailAddress { get; set; }
  }
}

上記のように、POCOなC#クラス定義のプロパティに、Validation属性が付与されているモデルクラス実装になります。

【Require属性】

AccountName / Age / EMailAddress の各プロパティに「Required」属性を付与しました。
つまり、入力必須であることを表しています。
ErrorMessage値は、検証エラーが発生した場合のエラーメッセージです。
また、Required属性は「System.ComponentModel.DataAnnotations名前空間」の「RequiredAttributeクラス」です。クラス定義の先頭で using 定義を行っており、またxxAttributeクラスは [xx] として属性定義できることはC#の言語仕様になります。

【Range】

Ageプロパティに対してはRange属性を指定しています。
想像できる通り「値の妥当範囲」を指定しています。
0歳から200歳までが妥当な入力範囲としています。

【RegularExpression】

EMailAddressプロパティに対してRegularExpression属性を指定しています。
正規表現により「入力値がメールアドレスであること」をチェックしています。

1.2. EditForm

さて、検証内容が定義されたAccountモデルクラスが用意できました。
Blazorでは、このモデルクラスに対してValidationを有効にして入力項目として表示するコントロールを用意しています。
「EditFormコンポーネントMicrosoft.AspNetCore.Components.Forms名前空間)」がこれに該当します。
Pages/Index.razorにEditFormを定義したサンプルが以下です。

# Pages/Index.razor #

@page "/"
@using ValidationExamApp.Models

<h1>ユーザ登録フォーム</h1>

<EditForm Model="@EntryAccount">
  <DataAnnotationsValidator />
  <ValidationSummary />

  アカウント名:<br />
  <InputText id="accountName" @bind-Value="EntryAccount.AccountName" />
  <br />

  年齢:<br />
  <InputNumber id="age" @bind-Value="EntryAccount.Age" />
  <br />

  メールアドレス:<br />
  <InputText id="eMail" @bind-Value="EntryAccount.EMailAddress" />
  <br />

  <button type="submit">送信</button>
</EditForm>   

@code
{
  private Account EntryAccount {get; set; } = new Account();

}

【EditForm】

EditFormはComponentBaseを継承したコンポーネントです。
このコンポーネントは以下にValidation処理対象の入力項目を定義します。
(本質な実装概念は異なりますが)Webの<form>を思い浮かべれば良いでしょうか。

【DataAnnotationValidator】

DataAnnotationsValidatorクラスは、モデルクラスで定義した「データ注釈」を解釈して検証処理をサポートします。
ひとまず、最初はおまじない的に配置しておけばよいと思います。

【ValidationSummary】

ValidationSummaryは、検証結果(主に検証エラーメッセージ)をまとめて表示してくれるコンポーネントです。

【@bind-value

InputText や InputNumberのような各入力コンポーネントに対してモデルクラスのプロパティ値をデータバインディングしています。

【submitボタン】

type属性値が "submit" のボタンです。
このボタンがクリックされると、データValidationが行われ、「エラーメッセージの表示」および「EditFormのValidation関連イベントが発生」します。

ここまでの実装で、画面を表示して動作を確認することができます。
dotnet run を実行し、http://localhost:5000 をブラウザで表示した画面キャプチャが以下です。

f:id:daigo-knowlbo:20200526033940p:plain

f:id:daigo-knowlbo:20200526033956p:plain

1.3. サブミットイベント(OnSubmit / OnValidSubmit / OnInvalidSubmit)

Validation属性を付与したモデルクラスの用意・EditFormの定義、により入力検証が動作することまで確認できました。
実際の処理では、妥当性をクリアした入力に対して、何らかの処理を行う必要があります。
例えば、「Validationチェックにクリアした入力内容のアカウント情報をサーバに登録する」等ですね。
EditFormは、サブミットボタンがクリックされた際に、イベントを発行します。

OnSubmit

サブミットボタンがクリックされた際にイベントが発生します。
EditForm.OnSubmitイベントが発生しますが、これは「EventCallback」型のイベントになります。
以下のように実装することができます。
イベント引数 EditContext を利用し入力値の検証を行っています。

@page "/"
@using ValidationExamApp.Models

<h1>ユーザ登録フォーム</h1>

<EditForm Model="@EntryAccount" OnSubmit="@OnSubmit">
  <DataAnnotationsValidator />
  <ValidationSummary />

  アカウント名:<br />
  <InputText id="accountName" @bind-Value="EntryAccount.AccountName" />
  <br />

  年齢:<br />
  <InputNumber id="age" @bind-Value="EntryAccount.Age" />
  <br />

  メールアドレス:<br />
  <InputText id="eMail" @bind-Value="EntryAccount.EMailAddress" />
  <br />

  <button type="submit">送信</button>
</EditForm>   

@code
{
  private Account EntryAccount {get; set; } = new Account();

  private void OnSubmit(EditContext context)
  {
    Console.WriteLine($"OnSubmit() => {context.Validate().ToString()}");
  }
}

OnValidSubmit / OnInvalidSubmit

サブミットボタンがクリックされた際に、入力項目が妥当であれば「OnValidSubmitイベント」が発生します。
また、入力項目が不正であった場合「OnInvalidSubmitイベント」が発生します。
EditForm.OnValidSubmitイベント および EditForm.OnInvalidSubmitイベントは、共に「EventCallback」型のイベントになります。
注意点として「OnSubmitイベントとOnValidSubmit / OnInvalidSubmitイベントは併用不可」となっています。同時に定義すると実行時エラーが発生します。
以下のように実装することができます。

@page "/"
@using ValidationExamApp.Models

<h1>ユーザ登録フォーム</h1>

<EditForm Model="@EntryAccount"  OnValidSubmit="@OnValidSubmit" OnInvalidSubmit="@OnInvalidSubmit">
  <DataAnnotationsValidator />
  <ValidationSummary />

  アカウント名:<br />
  <InputText id="accountName" @bind-Value="EntryAccount.AccountName" />
  <br />

  年齢:<br />
  <InputNumber id="age" @bind-Value="EntryAccount.Age" />
  <br />

  メールアドレス:<br />
  <InputText id="eMail" @bind-Value="EntryAccount.EMailAddress" />
  <br />

  <button type="submit">送信</button>
</EditForm>   

@code
{
  private Account EntryAccount {get; set; } = new Account();

  private void OnValidSubmit(EditContext context) 
  {
    Console.WriteLine($"OnValidSubmit()");
  }

  private void OnInvalidSubmit(EditContext context) 
  {
    Console.WriteLine($"OnInvalidSubmit()");
  }
}

1.4. EditForm.Model と EditForm.EditContext

前述の例では、EditFormを利用する際に Modelプロパティ にValidation対象のモデルクラスインスタンスを設定していました。
もう1つのEditFormの利用方法として、「EditContextプロパティを設定する」というものがあります。
以下がその例です。

@page "/"
@using ValidationExamApp.Models

<h1>ユーザ登録フォーム</h1>

<EditForm EditContext="@EntryAccountEditContext" OnValidSubmit="@OnValidSubmit" OnInvalidSubmit="@OnInvalidSubmit">
    <DataAnnotationsValidator />
    <ValidationSummary />

    アカウント名:<br />
    <InputText id="accountName" @bind-Value="EntryAccount.AccountName" />
    <br />

    年齢:<br />
    <InputNumber id="age" @bind-Value="EntryAccount.Age" />
    <br />

    メールアドレス:<br />
    <InputText id="eMail" @bind-Value="EntryAccount.EMailAddress" />
    <br />

    <button type="submit">送信</button>
</EditForm>   

@code
{
    private Account EntryAccount = new Account();
    private EditContext EntryAccountEditContext;

    protected override void OnInitialized()
    {
        this.EntryAccountEditContext = new EditContext(this.EntryAccount);
    }

    private void OnValidSubmit(EditContext context) 
    {
        Console.WriteLine($"OnValidSubmit()");
    }

    private void OnInvalidSubmit(EditContext context) 
    {
        Console.WriteLine($"OnInvalidSubmit()");
    }
}

EditForm定義において Modelプロパティ の設定を削除し、EditContextプロパティ の設定定義を追加しました。
EditContextプロパティにバインドするオブジェクト値は、コンポーネントコード(@code{})定義にて private 変数定義(EntryAccountEditContext)しています。
また、このEntryAccountEditContext値は、コンポーネントの初期化イベントである OnInitialized() においてnew初期化を行っています。

EditForm利用においては、「Modelプロパティ」もしくは「EditContextプロパティ」を必ず設定する必要があります。
これらの違いですが、内部的にはどちらのプロパティを設定するのも同意となります。
以下のEditForm自体の実装が、その解になります。

aspnetcore/EditForm.cs at c79002bf38f2a08f496c645ee04e0a9084601f31 · dotnet/aspnetcore · GitHub

public class EditForm : ComponentBase
{
  ...省略
  
  protected override void OnParametersSet()
  {
    if (_fixedEditContext == null || EditContext != null || Model != _fixedEditContext.Model)
    {
     _fixedEditContext = EditContext ?? new EditContext(Model);
    }
  }
}

EditForm.OnParametersSet()において、「EditContext != nullであれば その値 を」「EditContext == null であれば new EditContext(model) を」、EditFormの内部プロパティ_fixedEditContextに設定しています。

2. カスタム検証

入力必須の Required、値範囲の Range、正規表現チェックの RegularExpression等々、.NET標準のValidationクラスが用意されていますが、独自のValidation実装を追加することができます。
パスワードポリシーとして「8文字以上、#文字が入っていることをチェック」するカスタムValidationクラスを実装してみます。
以下がカスタムValidation 実装である、PasswordPolicyValidatorになります。

# CustomValidators/PasswordPolicyValidator.cs #

using System;
using System.ComponentModel.DataAnnotations;

namespace ValidationExamApp.CustomValidators
{
  public class PasswordPolicyValidator : ValidationAttribute
  {
    protected override ValidationResult IsValid(object value, 
      ValidationContext validationContext)
    {
      string password = value?.ToString();
      if( !String.IsNullOrEmpty(password)  && password.Length > 8 && password.Contains('#') ) 
      {
        return ValidationResult.Success;
      }

      return new ValidationResult("8文字以上で、# 文字を入れてください");
    }
  }
}
# Models/Account.cs #
...
namespace ValidationExamApp.Models
{
  public class Account
  {
    ...

    [PasswordPolicyValidator]
    public string Password { get; set; }
  }
}
# /Pages/Index.razor #

<EditForm EditContext="@EntryAccountEditContext" OnValidSubmit="@OnValidSubmit" OnInvalidSubmit="@OnInvalidSubmit">
  <DataAnnotationsValidator />
  <ValidationSummary />

  ...
  
  パスワード:<br />
  <InputText id="password" @bind-Value="EntryAccount.Password" />
  <br />

  <button type="submit">送信</button>
</EditForm>   

f:id:daigo-knowlbo:20200526034034p:plain

3. 入れ子(ネスト)モデルのValidationとObjectGraphDataAnnotationsValidator

Validation対象が入れ子構造になっている場合、少し工夫をしないとそのままではValidationが正しく動作しません。

このケースでは現行Blazorでは若干の問題があります。
1つの解決策としては「Microsoft.AspNetCore.Components.DataAnnotations.Validation」パッケージを利用します。
ただし、このパッケージは Experimental なパッケージであるため、プロダクション環境での利用には注意が必要です。

https://www.nuget.org/packages/Microsoft.AspNetCore.Components.DataAnnotations.Validation

# パッケージの追加

dotnet add package Microsoft.AspNetCore.Components.DataAnnotations.Validation --version 3.2.0-rc1.20223.4

Microsoft.AspNetCore.Components.DataAnnotations.Validationを適用した入れ子(ネスト)構造のモデルとは、具体的には以下のようなモデルです。

using System.ComponentModel.DataAnnotations;
using ValidationExamApp.CustomValidators;

namespace ValidationExamApp.Models
{
  public class Account
  {
    [Required(ErrorMessage = "アカウント名を入力してください。")]
    public string AccountName { get; set; }

    [ValidateComplexType]
    public Address Address { get; set; } // ← 入れ子構造

    public Account() 
    {
      this.Address = new Address();
    }
  }

  public class Address 
  {
    [Required]
    [RegularExpression(@"^[0-9]{3}-[0-9]{4}$", ErrorMessage = "郵便番号の書式はnnn-nnnnです。")]
    public string PostalCode { get; set; }

    [Required]
    public string Address1 { get; set; }

    public string Address2 { get; set; }
  }
}

【Address】

前述のAccountモデルにAddressプロパティを追加しました。
Addressクラスは PostalCode / Address1 / Address2 の3つのプロパティを持つクラスです。
このようにAccountクラスのプロパティが、カスタムモデルクラスAddressになっており、そのプロパティに対してValidation属性が付与されているようなケースです。
ちなみに、Address.PostalCodeプロパティには正規化表現で「nnn-nnnn」という郵便番号書式が求められています。

【ValidateComplexType】

入れ子対象となるプロパティには [ValidateComplexType] 属性を付与します。

f:id:daigo-knowlbo:20200526034056p:plain

4. まとめ

BlazorのValidation機能は、ASP.NETによるWeb開発者に取り掛かりやすい、基本概念を共にするアーキテクチャで構成されていました。
比較的、感覚的に着手することができるのではないかと思います。
と同時に、もうひとクオリティの改善が行われると、安心してプロダクション環境で取り入れられるような部分もあるかと思います。
総括としては、基本部分の実装は備えているので、サポート機能範囲を把握したうえで十分にプロダクション環境でも使えるのではないかと思いました。

Blazor のPath Base(pathbase)について

1. はじめに

Blazorのプロジェクトを作成すると、テンプレート選択が「Blazor(client)でもBlazor(ASP.NET core hosted)でも」、dotnet run によるデフォルトの実行構成は http://localhost:5000 でアプリをホストするようになります。
開発サーバーなのでhostはlocalhostでportも5000ですが、今回注目する意味としては「ホスト上のルート(/)」での動作が前提となっています。

本投稿は、Path Base設定により「http://hogehoge/MyApp」などのサブディレクトリ配下での動作を行うことができる点の説明になります。

※ ここではClient side Blazorを対象としています。

2. 開発サーバとサブディレクト

まず、開発環境においてサブディレクトリでアプリをホストして起動する方法は dotnet run に「--pathbase」オプションを付与します。

dotnet run --pathbase=/MyApp

上記コマンドを実行すると、http://localhost:5000/MyApp でアプリをホストして開発サーバが実行されます。

http://localhost:5000 にアクセス】

http://localhost:5000 にアクセスすると /MyApp でリクエストをハンドルするように構成されているよ、とレスポンスしてきます。

f:id:daigo-knowlbo:20190519111546p:plain

http://localhost:5000/MyApp にアクセス】

http://localhost:5000/MyApp にアクセスすると、以下のように Loading... で止まります。

f:id:daigo-knowlbo:20190519111722p:plain

なぜ Loading... で止まっているかというと・・・Chrome DevToolsでNetworkを確認してみます。

f:id:daigo-knowlbo:20190519111953p:plain

404 Not foundが出ていますね。
http://localhost:5000/_framework/blazor.webassembly.js」など、ルート(/)配下を取りに行っています(アプリは/MyAppサブディレクトリで動作させているので、当然404します)。

3. index.htmlの<base>タグ設定

Blazorが動作する仕組みとしては、以下です。

①ブラウザがHTTP GETでindex.htmlを読み込む
②(index.htmlの記述きっかけで)Blazorを動作させるためのjs(blazor.webassembly.js)が読み込まれる(その他CSS等も)
③mono wasmが読み込まれる
④Blazorアセンブリ(dll)が読み込まれる

で、index.htmlを確認すると以下のようにbaseタグがルート(/)指定で書かれています(<base href="/" />)。

# wwwroot/index.html #

<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width" />
    <title>clientapp</title>
    <base href="/" />
    <link href="css/bootstrap/bootstrap.min.css" rel="stylesheet" />
    <link href="css/site.css" rel="stylesheet" />
</head>
<body>
    <app>Loading...</app>

    <script src="_framework/blazor.webassembly.js"></script>
</body>
</html>

という事で素直に /MyApp/ に書き換えてあげます(後ろの / も忘れずに)。

<base href="/MyApp/" />

ちなみに baseタグ は、Blazor固有ではなく一般的なHTML仕様です(文書の基底URL指定)。

developer.mozilla.org

あらためて dotnet run すると、すべての関連ファイルが http://localhost:5000/MyApp/ 配下から読み込まれて正しく動作します。

f:id:daigo-knowlbo:20190519113226p:plain

3. まとめ

ということで、 以下がまとめです。

  • 開発サーバは dotnet run --pathbase=xxx オプションを指定するとサブディレクトリでアプリをホストできる。
  • プロジェクトテンプレートが自動生成したindex.html は、<base> タグで読み込むjs/cssのパス調整が行われるようになっている。

Blazor でトースター表示する(sotsera.blazor.toaster)

1. はじめに

Blazorでトースター メッセージを表示する sotsera.blazor.toaster を使ってみました。

github.com

2. サンプル実装

ではサクッと。

2.1. プロジェクト作成

プロジェクトを作成します。
プロジェクト名は use-sotsera-toaster としました。

dotnet new blazor -o use-sotsera-toaster

use-sotsera-toasterディレクトリに移動し、「Sotsera.Blazor.Toaster」パッケージを追加します。
2019.5.16 現在の最新バージョン 0.9.0-preview-3 を入れました。

cd use-sotsera-toaster
dotnet add package Sotsera.Blazor.Toaster --version 0.9.0-preview-3

www.nuget.org

2.2. サービス登録

StartupクラスのConfigureServices()でサービス登録を行います。

# Startup.cs #

using Microsoft.AspNetCore.Components.Builder;
using Microsoft.Extensions.DependencyInjection;

using Sotsera.Blazor.Toaster.Core.Models;

namespace use_sotsera_toaster
{
  public class Startup
  {
    public void ConfigureServices(IServiceCollection services)
    {
      services.AddToaster(config =>
      {
        config.PositionClass = Defaults.Classes.Position.TopRight;
        config.MaximumOpacity = 80;
        config.VisibleStateDuration = 10000;
        config.HideTransitionDuration = 3000;
        config.NewestOnTop = false;
        config.ShowCloseIcon = true;
        config.ShowProgressBar = false;
        config.PreventDuplicates = true;
      });
    }

    public void Configure(IComponentsApplicationBuilder app)
    {
      app.AddComponent<App>("app");
    }
  }
}

【using Sotsera.Blazor.Toaster.Core.Models;】

config設定で「Defaults.Classes.Position.TopRight」を使用するために、このusingを追加しています。
AddToaster()メソッドはSotsera.Blazor.Toasterアセンブリ内でMicrosoft.Extensions.DependencyInjection名前空間に実装されています。

【services.AddToaster()】

トーストサービスを追加します。
config設定を行うことでトースト表示の振る舞いをカスタマイズできます。
サンプルで設定したconfigは以下の通りです。

  • PositionClass
    トーストを表示する画面上の位置を設定します。サンプルでは右上を設定。

  • MaximumOpacity
    初期表示時のトーストの透過率を設定します。
    100%が透過なしです。サンプルではトースト初期表示時に80%の透過率で表示されます。

f:id:daigo-knowlbo:20190516022202p:plain
※よく見ると右上の About 文字とかが透過してるのが分かります。
※トーストは消える際に徐々に薄くなります。

  • VisibleStateDuration
    トーストが表示されてから消え始めるまでの時間をmsで指定します。

  • HideTransitionDuration
    トーストが表示からVisibleStateDuration(ms)経過した後から、徐々に薄くなって完全に消えるまでの時間をmsで指定します。

  • NewestOnTop
    複数のトーストが同時に表示される場合、新しいトーストを上に表示する場合は true 、下に表示する場合は false を指定します。
    trueの場合:
    f:id:daigo-knowlbo:20190516022833p:plain
    falseの場合:
    f:id:daigo-knowlbo:20190516022854p:plain

  • ShowCloseIcon
    トースト右上の閉じるアイコンの表示/非表示設定を行います。

  • ShowProgressBar
    トーストが消えるまでの時間を示すプログレスバーの表示/非表示設定を行います。

  • PreventDuplicates
    同一のメッセージがすでにトースト表示されている場合に、別のトーストとして追加表示するかどうかを設定します。
    falseの場合:
    f:id:daigo-knowlbo:20190516023228p:plain

trueの場合:(同じメッセージのToaster.Info()呼び出しが何回行われてもトーストは1つに制御される)
f:id:daigo-knowlbo:20190516023249p:plain

2.3. ページでトースト表示を実装

Index.razorページにボタンを用意し、ボタンがクリックされたらトーストが表示されるようにします。
トーストには、スタイルの異なる Info / Success / Warning / Error が用意されているので、それぞれを表示するボタンを用意することとします。

# Pages/Index.razor #

@page "/"
@using Sotsera.Blazor.Toaster
@inject Sotsera.Blazor.Toaster.IToaster Toaster
<ToastContainer />

<input type="button" value="Show info" onclick="@ShowInfo" />
<input type="button" value="Show info" onclick="@ShowSuccess" />
<input type="button" value="Show info" onclick="@ShowWarning" />
<input type="button" value="Show info" onclick="@ShowError" />

@functions {
  private void ShowInfo() {
    Toaster.Info("info message");
  }

  private void ShowSuccess() {
    Toaster.Success("Success message");
  }

  private void ShowWarning() {
    Toaster.Warning("Warning message");
  }
  
  private void ShowError() {
    Toaster.Error("Error message");
  }
}

【@using Sotsera.Blazor.Toaster】

ページ内で IToaster を使用するためにusing宣言を行います。

【@inject Sotsera.Blazor.Toaster.IToaster Toaster】

IToasterインターフェイスオブジェクトをインジェクトし Toaster 変数に格納します。

【<ToastContainer />】

ToastContainerオブジェクトをページ内に配置しておきます。
インジェクトした Toaster オブジェクトの「トースト表示メソッド呼び出し時」のUIレンダリングに利用されます。

【Toaster.Info() / Toaster.Success() / Toaster.Warning() / Toaster.Error() 】

各ボタンのonclickイベントハンドラ内でToasterオブジェクトのトースト表示メソッドを呼び出します。

2.4. 実行

以下のコマンドで実行します。

dotnet run

http://localhost:5000 にアクセスし、4つのボタンをクリックした画面が以下です。

f:id:daigo-knowlbo:20190516025121p:plain

3. まとめ

という事で、簡単にトースト表示実装を行えました。

今回作成したサンプルは↓です。

github.com

Blazor Extensions Logging を使ってログ出力する

1. はじめに

Blazorでのロギングを試したいと思います。

まず本投稿で動作確認した各種環境は以下の通りです。

  • Windows 10 x64
  • .NET Core 3.0.100-preview5-011568
  • Blazor Extensions Logging 0.1.11

最もシンプルなロギングは標準機能「Console.WriteLine()」を使う方法です。

Console.WriteLine("Blazorからログ出力!!");

f:id:daigo-knowlbo:20190514012203p:plain

ただし、今回ロギングに使うのはコレ!!↓↓↓。

github.com

Blazor Extensions Loggingを使用すると Console.WriteLine() よりもリッチなロギング処理が可能になります。

2. サンプルで試す

では、早速サンプルで試してみましょう。

2.1. プロジェクトの作成

まず、プロジェクトを作成します。
テンプレートはシンプルに「Client Side Blazor」、プロジェクト名は「use-logging-extension」とました。

dotnet new blazor -o use-logging-extension

2.2. Nugetでパッケージを追加

コマンドプロンプトでuse-logging-extensionフォルダに移動します。
続けて、以下のコマンドで Blazor Extensions Logging パッケージをプロジェクトに追加します。
(バージョンは2019.5.14時点の最新である 0.1.11 )

dotnet add package Blazor.Extensions.Logging --version 0.1.11

NuGet Gallery | Blazor.Extensions.Logging 0.1.11

2.3. 初期化

Startupクラスの ConfigureServices() メソッドでLoggingの初期化(サービスへの追加)を行います。
以下のように「2つのusing」と「services.AddLogging()呼び出し」を追加します。

# Startup.cs #
...省略...

using Microsoft.Extensions.Logging;
using Blazor.Extensions.Logging;

namespace use_logging_extension {
  public class Startup {

    public void ConfigureServices(IServiceCollection services) {
      services.AddLogging(builder => builder
        .AddBrowserConsole()
        .SetMinimumLevel(LogLevel.Trace)
      );    
    }
    ...省略...
  }
}

2.4. テキストログの出力

シンプルなテキストのログ出力を行います。
Pages/Index.razor に実装を行います。
「ログ出力(テキスト)」というキャプションのボタンを配置し、クリックされたら「called WriteTextLog()」というテキストログを出力します。

# Pages/Index.razor #

@page "/"
@using Microsoft.Extensions.Logging
@inject ILogger<Index> logger

<input type="button" 
       onclick="@WriteTextLog"
       value="ログ出力(テキスト)" />

@functions {
  private void WriteTextLog() {
    logger.LogDebug("called WriteTextLog()");
  }
}

【@using Microsoft.Extensions.Logging】

Blazor Extensions Loggingは、.NET CoreのILoggerの仕組みの上にインプリメントされています。
ILoggerを使用するためにまず Microsoft.Extensions.Loggingをusingします。

【@inject ILogger logger】

ILoggerを、DIでこのページの変数 logger にインジェクトします。

【logger.LogDebug()】

LogDebug()メソッドでログ出力を行います。
LogDebug()の他に「LogTrace() / LogInformation() / LogWarning() / LogError() / LogCritical()」メソッドが用意されています。

実行してみましょう。

dotnet run

f:id:daigo-knowlbo:20190514014152p:plain

「ログ出力(テキスト)」ボタンをクリックすると、以下のようにChrome DevToolsのConsoleにログが出力されました。

f:id:daigo-knowlbo:20190514014224p:plain

2.5. カスタムオブジェクトログの出力

次にカスタムオブジェクトのログ出力を行います。
以下のように Models/Employee.cs クラスを作成します。

# Models/Employee.cs #

namespace use_logging_extension.Models {
  public class Employee {
    public int ID { get; set; }
    public string FirstName { get; set; }
    public string LastName { get; set; }
  }
}

先程作成した Pages/Index.razor を改良します。
「ログ出力(カスタムオブジェクト)」というキャプションのボタンを追加で配置し、クリックされたら「Employeeオブジェクト」をログ出力します。

# Pages/Index.razor #

@page "/"
@using use_logging_extension.Models
@using Microsoft.Extensions.Logging
@using Blazor.Extensions.Logging
@inject ILogger<Index> logger

<input type="button"
       onclick="@WriteTextLog"
       value="ログ出力(テキスト)" />
<input type="button"
       onclick="@WriteCustomObjectLog"
       value="ログ出力(カスタムオブジェクト)" />


@functions {
  private void WriteTextLog() {
    logger.LogDebug("called WriteTextLog()");
  }

  private void WriteCustomObjectLog() {
    Employee employee = new Employee() {
      ID = 1000, FirstName = "ryuichi", LastName = "daigo" };
    logger.LogDebug<Employee>(employee);
  }
}

【@using use_logging_extension.Models】

Index.razorにおいて、Employeeオブジェクトを使用するためにusingを追加します。

【@using Blazor.Extensions.Logging】

logger.LogDebug<T>()を利用しますが、このメソッド定義は「Blazor.Extensions.Logging.BrowserConsoleLoggerExtensions拡張メソッド」で実装されているため、Blazor.Extensions.Loggingへのusingが必要になります。

【logger.LogDebug<Employee>(employee);】

logger.LogDebug<Employee>()メソッドでカスタムオブジェクトのログ出力を行うことができます。

実行してみましょう。

dotnet run

f:id:daigo-knowlbo:20190514020028p:plain

「ログ出力(カスタムオブジェクト)」ボタンをクリックすると、以下のようにChrome DevToolsのConsoleにログが出力されました。
リッチな階層構造でのログ出力が行われています。

f:id:daigo-knowlbo:20190514020058p:plain

2.5. ログ出力レベル

ロギングライブラリを利用したことがあれば、大体想像がつくと思いますが、ログ出力レベルの調整が可能です。
Startup#ConfigureServices() メソッドでの初期化処理を以下のように書き換えれば Error以上のログのみ出力されるようになります。

# Startup.cs #

services.AddLogging(builder => builder
    .AddBrowserConsole()
    .SetMinimumLevel(LogLevel.Error)
  );    

3. まとめ

Blazor Extensions Loggingは、.NET CoreからのILoggerの作法の上に実装されているライブラリなので、非常にスムーズに感覚的に利用することができました。
開発レベルでは手軽に便利に使えるのではないかと思います。
あと、運用段階でクライアントログをサーバーにの集積したい、等の要望がある場合は、もちろん別途実装ですね。

また、今回作成したサンプルコードは以下に置いてあります。

github.com

Blazor [tips]: bindとonchangeの併用は不可

1. はじめに

Blazorを調査しているときに、データバインディングについて理解していない段階で はまった ことを思い出したのでTipsとして書いておきます。
「inputタグに"bind属性"と"onchange属性"を両方つけると、(おそらく)想定した動きをしないよねー」という話です。

2. サンプル

具体的には以下のコードです。

# Pages/index.razor #

@page "/"

<input type="text"
  bind="@inputValue"
  onchange="@(e => inputValueChanged(e.Value.ToString()) )"/>
<br />
→ inputValue: <div>@inputValue</div>
→ changedValue: <div>@changedValue</div>

@functions {
  private string inputValue { get; set; } = "";
  private string changedValue { get; set; } = "";

  void inputValueChanged(string newValue) {
    changedValue = newValue;
  }
}

実行画面

以下が実行画面です。

f:id:daigo-knowlbo:20190512132037p:plain

テキストボックスに「はろー」と入力して、フォーカスをテキストボックスから外します。
そうすると以下の画面のようになります。

f:id:daigo-knowlbo:20190512132028p:plain

実装コード的には以下を想定したのですが、、、

inputValue: はろー
changedValue: はろー

以下のようになりました。

inputValue: はろー
changedValue: 

結論としては「onchangeが効いていない」という事です。
記事タイトルの通り「bindとonchangeの併用は不可」って事ですね。

onchange="@(e => inputValueChanged(e.Value.ToString()) )"

3. なんで bindとonchangeは併用 できない?

ということで、先程のページ(index.razor)をビルドしたアセンブリ(dll)をILSpyで覗いてみます。
(補足:ここではindex.razorはhello_bind_appというBlazorプロジェクトに実装しました)

// hello_bind_app.Pages.Index
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.RenderTree;

protected override void BuildRenderTree(RenderTreeBuilder builder)
{
  builder.OpenElement(0, "input");
  builder.AddAttribute(1, "type", "text");
  builder.AddAttribute(2, "onchange", EventCallback.Factory.Create(this, delegate(UIChangeEventArgs e)
  {
    inputValueChanged(e.Value.ToString());
  }));
  builder.AddAttribute(3, "value", BindMethods.GetValue(inputValue));
  builder.AddAttribute(4, "onchange", EventCallback.Factory.CreateBinder(this, delegate(string __value)
  {
    inputValue = __value;
  }, inputValue));
  builder.CloseElement();
  builder.AddMarkupContent(5, "\r\n<br>\r\n→ inputValue: ");
  builder.OpenElement(6, "div");
  builder.AddContent(7, inputValue);
  builder.CloseElement();
  builder.AddMarkupContent(8, "\r\n→ changedValue: ");
  builder.OpenElement(9, "div");
  builder.AddContent(10, changedValue);
  builder.CloseElement();
}

Blazorのページレンダリング(RenderTreeの構築)の仕組みとして、ページクラスの BuildRenderTree() メソッドがその役割を担います。
ここに .razor で定義した「HTML / CSS / インラインで記述したrazorコード」が、コードとして展開されています。

onchangeイベントに対するハンドリング処理は以下のように、良い感じに構築されています。

  builder.AddAttribute(2, "onchange", EventCallback.Factory.Create(this, delegate(UIChangeEventArgs e)
  {
    inputValueChanged(e.Value.ToString());
  }));

が、その直後に同じinputタグに対して再度 onchange 属性がaddAttribute()されています。
これは「bind="@inputValue"」によるtwo-wayデータバインディングを構築する実装に該当します。

  builder.AddAttribute(3, "value", BindMethods.GetValue(inputValue));
  builder.AddAttribute(4, "onchange", EventCallback.Factory.CreateBinder(this, delegate(string __value)
  {
    inputValue = __value;
  }, inputValue));

同じinputタグの onchange属性 に対して2回AddAttribute()が行われ、結果として後勝ちとなり「onchange="@(e => inputValueChanged(e.Value.ToString()) )」は無かったものとなってしまいます。

という事で、「なんで bindとonchangeは併用 できない?」の根拠がすっきりしました。

valueとonchangeの併用はおk

bindではなく、valueによるone-wayバインディングではonchangeがハンドルされないので、以下の onchange は有効に働きます。

# Pages/index.razor #

...省略...
<input type="text"
  value="@inputValue"
  onchange="@(e => inputValueChanged(e.Value.ToString()) )"/>
...省略...

ビルド後のアセンブリ実装は以下。

// hello_bind_app.Pages.Index
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.RenderTree;

protected override void BuildRenderTree(RenderTreeBuilder builder)
{
  builder.OpenElement(0, "input");
  builder.AddAttribute(1, "type", "text");
  builder.AddAttribute(2, "value", inputValue);
  builder.AddAttribute(3, "onchange", EventCallback.Factory.Create(this, delegate(UIChangeEventArgs e)
  {
    inputValueChanged(e.Value.ToString());
  }));
  builder.CloseElement();
  builder.AddMarkupContent(4, "\r\n<br>\r\n→ inputValue: ");
  builder.OpenElement(5, "div");
  builder.AddContent(6, inputValue);
  builder.CloseElement();
  builder.AddMarkupContent(7, "\r\n→ changedValue: ");
  builder.OpenElement(8, "div");
  builder.AddContent(9, changedValue);
  builder.CloseElement();
}

まあ、このサンプルではbindによる動作が落ちることになるので、これが正解実装というわけではありません。
以下のような画面になる。changedValueには反映するが、two-wayではなくなったのでinputValueには反映しない。

f:id:daigo-knowlbo:20190512132010p:plain

3. ということは bindとoninputの併用はおk

bindはonchangeに展開されるので、bindとoninputの併用は問題ありません。

# Pages/index.razor #

...省略...
<input type="text"
  bind="@inputValue"
  oninput="@(e => inputValueChanged(e.Value.ToString()) )"/>
...省略...

oninputは入力ごとのイベントが発生するので、値の反映のタイミングは異なりますが、以下の画面のようにbindとoninputは両方が有効に動作していることが確認できます。

f:id:daigo-knowlbo:20190512131958p:plain

4. まとめ

ということで、
「bindはコンパイル後に onchange を含む実装に展開されるから、Developer側のonchange定義が無効になっちゃうよ」
そんなTipsでした。

Blazor-Fluxorで簡易Todoアプリを作る

1. はじめに

Microsoft Build 2019 盛り上がってますね、たぶん(僕は非MS系会社勤務なので分からぬ)。
.NET / Azureのたくさんのサービスの発表・GA等々行われていますが、ここでは今回もPreview版である(Client Side)Blazorについて取り上げます。
今回は Blazor-Fluxor というライブラリを使ってみようと思います。

開発環境

筆者の開発環境は以下の通り。

OS: Windows 10 x64
.net core version: 3.0.100-preview5-011568
Blazor-Fluxor: 0.22.0-pre
コマンドプロンプトVS Code

本投稿で作成したサンプル

本投稿で作成したサンプルは以下 です。

github.com

2. Blazor-Fluxor とは

Blazor-Fluxorとは、以下のgithubで公開されているOSSライブラリです。

github.com

トップの一文を引用すると以下の通りです。

A low-boilerplate Flux/Redux state library for Blazor

つまり、Blazorで Flux/Reduxな状態管理 を行うためのライブラリです。

公式サンプルが非常に親切なので、以下の「01-CounterSample」「02-WeatherForecastSample」あたりを、説明を読みながら自分で実装してみると分かりやすいと思います。

blazor-fluxor/samples at master · mrpmorris/blazor-fluxor · GitHub

また、「05-FlightFinder」は、より実践的なサンプルになっています。

ただし、Flux/Reduxの基本だけは別途押さえてから取り掛かる方がスムーズかと思います。
Blazor-Fluxorの解説では、Flux/Reduxの思想的な事には触れていないので(概要を知っている前提)。

3. 簡易Todoアプリを作ろう

公式サンプル「02-WeatherForecastSample」と「05-FlightFinder」の中間位の難易度の「簡易Todoアプリ」を作りたいと思います。
完成画面は以下の通りです。

f:id:daigo-knowlbo:20190509011729p:plain

機能としては以下とします。

  • クライアントはBlazorアプリ
  • Todo一覧取得/Todo登録は、ASP.NET CoreのWeb APIで実装
  • Todoを追加することができる(Blazorアプリで入力してWebAPI呼び出し)
  • エラー処理は簡易

3.1. プロジェクトを作成

dotnet new コマンドでプロジェクトを作成します。

dotnet new blazorhosted -o blazor-fluxor-todo-app

プロジェクト名は「blazor-fluxor-todo-app」です。
テンプレート:blazorhostedは、「Blazor (ASP.NET Core hosted)」で以下の3プロジェクト構成のファイルが出力されます。

  • blazor-fluxor-todo-app.Client
    Blazorクライアントアプリプロジェクト
  • blazor-fluxor-todo-app.Server
    ASP.NET Coreサーバープロジェクト(WebAPIを提供)
  • blazor-fluxor-todo-app.Shared
    共通プロジェクト。モデルクラスとか置くと「blazor-fluxor-todo-app.Client」「blazor-fluxor-todo-app.Server」の両方から使える。

3.2. 余分なコードを削除

テンプレートで自動生成されるコードはたくさんありますが、今回の実装でいらないコードを削除しておきます。

以下のファイルを削除します。

  • blazor-fluxor-todo-app.Client/Pages/Counter.razor
  • blazor-fluxor-todo-app.Client/Pages/FetchData.razor
  • blazor-fluxor-todo-app.Server/Controllers/SampleDataController.cs
  • blazor-fluxor-todo-app.Shared/WeatherForecast.cs

blazor-fluxor-todo-app.Client/Shared/NavMenu.razor からCounter/FetchDataへのメニューリンクを削除します。

# blazor-fluxor-todo-app.Client/Shared/NavMenu.razor #

<div class="top-row pl-4 navbar navbar-dark">
    <a class="navbar-brand" href="">blazor-fluxor-todo-app</a>
    <button class="navbar-toggler" onclick="@ToggleNavMenu">
        <span class="navbar-toggler-icon"></span>
    </button>
</div>

<div class="@NavMenuCssClass" onclick="@ToggleNavMenu">
    <ul class="nav flex-column">
        <li class="nav-item px-3">
            <NavLink class="nav-link" href="" Match="NavLinkMatch.All">
                <span class="oi oi-home" aria-hidden="true"></span> Home
            </NavLink>
        </li>
    </ul>
</div>

@functions {
    bool collapseNavMenu = true;

    string NavMenuCssClass => collapseNavMenu ? "collapse" : null;

    void ToggleNavMenu()
    {
        collapseNavMenu = !collapseNavMenu;
    }
}

3.3. Web API を実装

まず、Blazorクライアントから呼び出すWebAPIを実装します。

① TodoItemモデルクラスを追加

Todo項目を表す TodoItem モデルクラスを追加します。
Client / Serverの両方から利用するので blazor-fluxor-todo-app.Shared プロジェクトに TodoItem.cs を追加します。

# blazor-fluxor-todo-app.Shared/TodoItem.cs #

namespace blazor_fluxor_todo_app.Shared {
  public enum Priority
  {
    High,
    Normal,
    Low
  }

  public class TodoItem {
    public int ID { get; set; }
    public Priority Priority { get; set; }
    public string Title { get; set; }
  }
}

ID / Title / Priorityの3つのプロパティを持ちます。Priorityは列挙体とします。
シンプルな.NETのPOCOクラスです。

② TodoItemControllerクラスを追加

WebAPIを実装します。
以下の2つの機能を実装します。

  • Todo一覧を取得する
  • Todoを追加する

ASP.NET Coreプロジェクトである blazor-fluxor-todo-app.Server のControllersフォルダ配下に TodoItemController.cs を追加します。

# blazor-fluxor-todo-app.Server/Controllers/TodoItemController.cs #

using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc;

using blazor_fluxor_todo_app.Shared;

namespace blazor_fluxor_todo_app.Server.Controllers
{
  [Route("api/[controller]")]
  public class TodoItemController : Controller
  {
    private static List<TodoItem> _TodoItems = new List<TodoItem>();

    static TodoItemController()
    {
      _TodoItems.Add(new TodoItem() { ID = 1, Title = "Blazorを調べる", Priority = Priority.High } );
      _TodoItems.Add(new TodoItem() { ID = 2, Title = "ASP.NET Coreで遊ぶ", Priority = Priority.Normal } );
      _TodoItems.Add(new TodoItem() { ID = 3, Title = "flutterでもふもふする", Priority = Priority.Normal } );
      _TodoItems.Add(new TodoItem() { ID = 4, Title = ".NET Framework 3を葬る", Priority = Priority.Low } );
      _TodoItems.Add(new TodoItem() { ID = 5, Title = "Durable Functionsする", Priority = Priority.Normal } );
    }

    [HttpGet("")]
    public IEnumerable<TodoItem> AllTodoItems()
    {
      return _TodoItems;
    }

    [HttpPost]
    public TodoItem AddTodoItem([FromBody] TodoItem todoItem)
    {
      todoItem.ID = _TodoItems.Count + 1;
      _TodoItems.Add(todoItem);
      return todoItem;
    }
  }
}

TodoItemのリストは、「static List _TodoItems」としてTodoItemControllerのstatic変数として保持しています。DBなどの処理を省くためにオンメモリで簡易的に保持する仕組みにしています。
TodoItemControllerのstaticコンストラクタで、初期値として5つのTodoItemを登録しています。
一覧取得(HttpGet) および Todo追加(HttpPost) は、そのままのASP.NET Core WebAPIの実装ですね。

3.4. Blazor-Fluxorパッケージ参照をnugetで追加

※これ以降は Blazorクライアント(blazor-fluxor-todo-app.Client)への修正作業になります。

コマンドプロンプトでBlazor-FluxorパッケージへのNuget参照を追加します。
Blazor-Fluxorパッケージを利用するのはクライアント(Blazor)プロジェクトなので、blazor-fluxor-todo-app.Clientフォルダに移動して「dotnet add package」コマンドを実行します。

cd blazor-fluxor-todo-app.Client
dotnet add package Blazor.Fluxor --version 0.22.0-pre

※ 2019/5/9時点のBlazor-Fluxorの最新は 0.22.0-pre
https://www.nuget.org/packages/Blazor.Fluxor/0.22.0-pre

3.5. Blazor-Fluxorサービスを追加

BlazorクライアントでBlazor-Fluxorサービスを利用する為に、Startupクラスでサービスを追加します。

blazor-fluxor-todo-app.Client/Startup.csに対して以下の2つの修正を行います。

  • using Blazor.Fluxor; を追加
  • Startup#ConfigureServices()に対してservices.AddFluxor()処理を追加
# blazor-fluxor-todo-app.Client/Startup.cs #

using Microsoft.AspNetCore.Components.Builder;
using Microsoft.Extensions.DependencyInjection;

using Blazor.Fluxor;

namespace blazor_fluxor_todo_app.Client
{
  public class Startup
  {
    public void ConfigureServices(IServiceCollection services)
    {
      services.AddFluxor(options => options
        .UseDependencyInjection(typeof(Startup).Assembly)
      );
    }

    public void Configure(IComponentsApplicationBuilder app)
    {
      app.AddComponent<App>("app");
    }
  }
}

3.6. Storeを初期化

Storeの初期化を行います。
blazor-fluxor-todo-app.Client/Shared/MainLayout.razorに対して以下の2つの修正を行います。

  • @inject Blazor.Fluxor.IStore Store を追加
  • @Store.Initialize(); を追加
# blazor-fluxor-todo-app.Client/Shared/MainLayout.razor #

@inherits LayoutComponentBase
@inject Blazor.Fluxor.IStore Store

@Store.Initialize();

<div class="sidebar">
    <NavMenu />
</div>

<div class="main">
    <div class="top-row px-4">
        <a href="http://blazor.net" target="_blank" class="ml-md-auto">About</a>
    </div>

    <div class="content px-4">
        @Body
    </div>
</div>

3.7. TodoStateを追加

Storeに保持するStateを作成します。
ここでは TodoState とします。
blazor-fluxor-todo-app.Client/Store フォルダを作成し、TodoState.csファイルを追加します。

# blazor-fluxor-todo-app.Client/Store/TodoState.cs #

using System;
using System.Collections.Generic;
using System.Linq;

using blazor_fluxor_todo_app.Shared;

namespace blazor_fluxor_todo_app.Client.Store {
  public class TodoState {
    public TodoItem[] TodoItems { get; private set; }

    public string ErrorMessage { get; private set; }

    public TodoState() {
      this.TodoItems = Array.Empty<TodoItem>();
      this.ErrorMessage = "";
    }

    public TodoState(IEnumerable<TodoItem> todoItems, string errorMessage) {
      this.TodoItems = todoItems?.ToArray() ?? Array.Empty<TodoItem>();
      this.ErrorMessage = errorMessage;
    }
  }
}

TodoStateクラスはアプリケーションで「保持したい状態」を表すクラスです。
そのため、このアプリケーションで保持したい2つのプロパティを実装しています。
1つは「TodoItems」、画面に表示するTodoアイテム一覧を表します。サーバーのWebAPI経由で取得してBlazorクライアントに保持することを想定します。
もう1つは「ErrorMessage」、これはネットワークエラーが発生した際に表示するエラーメッセージを保持することを想定します。
それ以外には、初期化のためのコンストラクタを2つ実装しています。

3.8. TodoFeatureを追加

Stateとセットにする形でFeatureを実装します。
Storeフォルダに TodoFeature.cs を追加します。

# blazor-fluxor-todo-app.Client/Store/TodoFeature.cs #

using Blazor.Fluxor;

namespace blazor_fluxor_todo_app.Client.Store
{
    public class TodoFeature : Feature<TodoState>
    {
        public override string GetName() => "Todo";
        protected override TodoState GetInitialState() => new TodoState();
    }
}

必ず、Blazor.Fluxor.Feature<T>クラスを継承します。
TはStoreに保持するStateクラスを設定します。
また、GetName()メソッドをオーバーライドします。Stateの名前を任意の名称で返却します。
もう1つ、GetInitialState()メソッドをオーバーライドします。Stateの初期値を返却します(先程定義したTodoStateのデフォルトコンストラクタでのインスタンスオブジェクトを返却しています)。

3.9. Todo一覧取得機能の追加

Todo一覧取得処理機能を実装します。
Reduxテイストでの実装を行います。
つまり Actionクラス と Reducreクラス を実装します。また、Httpリクエストのような副作用は Effectクラス として実装します。

【FetchTodoItemsActionクラス】

FetchTodoItemsActionクラスを実装します。

# blazor-fluxor-todo-app.Client/Store/FetchTodoItemsAction.cs #

using Blazor.Fluxor;

namespace blazor_fluxor_todo_app.Client.Store
{
    public class FetchTodoItemsAction : IAction
    {
    }
}

Actionは「Blazor.Fluxor.IActionインターフェイス」を実装します。
IActionインターフェイスは、プロパティ定義・メソッド定義が1つもないマーカーインターフェイスであるため、必要なければ何も実装する必要はありません。
アクションに対してパラメータが伴う場合には適時プロパティ定義を追加します。
ここでは、一覧取得に際しパラメータは不要なので何もプロパティの追加は行っていません。

【FetchTodoItemsEffectクラス】

Redux的には「Actionに対してReducerを用意します」が、FetchTodoItemsActionに関しては、そのアクションに対応してHttpリクエスト(WebAPI呼び出し)が必要になります。
つまり副作用処理が必要になります。
副作用はBlazor-Fluxorでは Effectクラス として実装を行います。

# blazor-fluxor-todo-app.Client/Store/FetchTodoItemsEffect.cs #

using Blazor.Fluxor;
using blazor_fluxor_todo_app.Shared;
using Microsoft.AspNetCore.Components;
using System;
using System.Net.Http;
using System.Threading.Tasks;

namespace blazor_fluxor_todo_app.Client.Store
{
    public class FetchTodoItemsEffect : Effect<FetchTodoItemsAction>
    {
        private readonly HttpClient HttpClient;

        public FetchTodoItemsEffect(HttpClient httpClient)
        {
            HttpClient = httpClient;
        }

        protected async override Task HandleAsync(FetchTodoItemsAction action, IDispatcher dispatcher)
        {
            TodoItem[] todoItems = Array.Empty<TodoItem>();
            try
            {
                todoItems = await HttpClient.GetJsonAsync<TodoItem[]>("api/todoitem");
            }
            catch
            {
                dispatcher.Dispatch(new NetworkErrorAction("通信エラーが発生しました"));
                return;
            }
            var completeAction = new FetchTodoItemsCompleteAction(todoItems);
            dispatcher.Dispatch(completeAction);
        }
    }
}
Blazor.Fluxor.Effect<T>

Effectは「Blazor.Fluxor.Effectクラス」を継承します。
Effect<T>の「T」は、このEffectを実行するきっかけのactionクラスを定義します。
つまり FetchTodoItemsAction がDispatchされたときに、この FetchTodoItemsエフェクトクラスはキックされます。

HttpClientをインジェクト

Httpリクエスト処理を行うため、コンストラクタで「HttpClient」をDIによりインジェクトしてもらっています。
HttpClientはBlazorにおいてはデフォルトサービスなので、何も事前宣言不要でサービスクラスに対するコンストラクタインジェクションを利用可能です。

HandleAsyncをオーバーライド

「Task HandleAsync(FetchTodoItemsAction action, IDispatcher dispatcher)」をオーバーライドすることで、このEffectの動作を実装することができます。
Todo一覧を取得するWebAPI「api/todoitem」を呼び出し、TodoItemリストを取得しています。
取得が成功したら「FetchTodoItemsCompleteActionクラス」をDispatchしています(FetchTodoItemsCompleteActionクラスは次に説明)。
何らかのエラーが発生したら「NetworkErrorActionクラス」をDispatchしています。

【FetchTodoItemsCompleteActionクラス】

FetchTodoItemsActionのDispatchをきっかけにキックされた、FetchTodoItemsEffectがTodoリストの取得に成功した場合、FetchTodoItemsCompleteActionクラスを生成してDispatchしていました。
その FetchTodoItemsCompleteAction の定義です。

# blazor-fluxor-todo-app.Client/Store/FetchTodoItemsCompleteAction.cs #

using Blazor.Fluxor;
using blazor_fluxor_todo_app.Shared;

namespace blazor_fluxor_todo_app.Client.Store
{
    public class FetchTodoItemsCompleteAction : IAction
    {
        public readonly TodoItem[] TodoItems;

        public FetchTodoItemsCompleteAction(TodoItem[] todoItems)
        {
            this.TodoItems = todoItems;
        }
    }
}

actionなので IAction を実装します。
このアクションはTodoリストの取得完了を表すものなので、パラメータとして「TodoItems」が実装されます。

【FetchTodoItemsCompleteReducerクラス】

FetchTodoItemsCompleteActionに対応するReducerクラスを実装します。
前述のFetchTodoItemsActionとは異なり、FetchTodoItemsCompleteActionは副作用を伴わないので、素直に対応するReducerであるFetchTodoItemsCompleteReducerを定義し、これによりStateの更新を実装します。

# blazor-fluxor-todo-app.Client/Store/FetchTodoItemsCompleteReducer.cs #

using Blazor.Fluxor;

namespace blazor_fluxor_todo_app.Client.Store
{
    public class FetchTodoItemsCompleteReducer : Reducer<TodoState, FetchTodoItemsCompleteAction>
    {
        public override TodoState Reduce(TodoState state, FetchTodoItemsCompleteAction action)
        {
            return new TodoState(
             todoItems: action.TodoItems,
             errorMessage: ""
        );
        }
    }
}
Reducer<TState, TAction>を継承

Reducerは「Blazor.Fluxor.Reducer<TState, TAction>クラス」を継承します。
Reducer<TState, TAction>の「TState」は、このReducerが処理するStateクラスを定義します。「TAction」はこのReducerが処理するActionクラスを定義します。
つまり FetchTodoItemsCompleteReducerは、FetchTodoItemsCompleteAction の影響による状態変更の影響を TodoState に対して行うReducerクラスである、という事になります。
Reduce()メソッドをオーバーライドすることで、状態の更新を実装します。引数には「現在の状態 state」と「発生したアクション action」が渡されてきます。
第2引数 FetchTodoItemsCompleteAction action は、つまり FetchTodoItemsEffect による WebAPI呼び出しの結果得られたTodoリストが格納されています。

※ NetworkErrorAction / NetworkErrorReducerは省略。。。(Github上のコード見てm( )m)  

【画面表示と初期化 index.razor】

Todoリストを取得する裏側の処理について説明・実装してきましたが、画面表示について移ります。
index.razor画面にTodoリスト画面を実装します。
実装したいことは以下の2つです。

  • Storeに保存された TodoState を画面に一覧表示する(優先度 高は赤色表示)
  • 画面の初期化時に FetchTodoItemsAction アクションをDispatchして、WebAPIからTodoリストを取得する処理をキックする
# blazor-fluxor-todo-app.Client/Pages/index.razor #

@page "/"

@using Blazor.Fluxor
@using blazor_fluxor_todo_app.Client.Store
@using blazor_fluxor_todo_app.Shared

@inject IDispatcher Dispatcher
@inject IState<TodoState> TodoState

@TodoState.Value.ErrorMessage

<div class="card">
  <div class="card-header">Your Todo List</div>
  <div class="card-body">
    <table class="table">
      <thead>
        <tr>
          <th>ID</th>
          <th>Title</th>
          <th>Priority</th>
        </tr>
      </thead>
      @foreach(var todoItem in TodoState.Value.TodoItems) {
        string trClass = todoItem.Priority == Priority.High ? "table-danger" : "";
        
        <tr class="@trClass">
          <td>@todoItem.ID</td>
          <td>@todoItem.Title</td>
          <td>@todoItem.Priority</td>
        </tr>
      }
    </table>
  </div>
</div>

@functions {
    protected override void OnInit()
    {
        base.OnInit();
        
        TodoState.Subscribe(this);
        
        Dispatcher.Dispatch(new FetchTodoItemsAction());
    }
}
using と inject

Blazor.Fluxorおよびプロジェクトで定義したStore・Sharedへのusingを定義します。

Razorレンダリング定義

table形式でTodoリストを一覧表示する実装をRazor記述で行います。
データは「TodoState.Value.TodoItems」といった形式で、「【State名】.Value」の形式で参照可能です。

OnInit()

@functionsブロックのOnInit()は画面の初期化時に呼び出されます。
ここで「Dispatcher.Dispatch(new FetchTodoItemsAction());」とすることで、Todoリスト取得処理がキックされます。
「FetchTodoItemsAction → FetchTodoItemsEffect → FetchTodoItemsCompleteAction → FetchTodoItemsCompleteReducer → TodoState更新 → 画面に反映」

# TodoState.Subscribe(this);

これ、結構重要。
Httpリクエストのような、Blazor側が状態の変更タイミングを認識できない処理では、状態変更に伴う画面の更新が自動的に行われません。
そのため、StateのSubscribe()メソッドにより明示的に画面を登録しておく。
もしくは、.razorが「Blazor.Fluxor.Components.FluxorComponent」を継承する必要があります。

# blazor-fluxor-todo-app.Client/Pages/index.razor(FluxorComponentを継承する場合) #

@page "/"
@inherits Blazor.Fluxor.Components.FluxorComponent

@using Blazor.Fluxor
@using blazor_fluxor_todo_app.Client.Store
@using blazor_fluxor_todo_app.Shared

...省略...

3.10. Todo追加機能の追加

Todoを追加する機能の追加を行います。
Action / Effect / Reducerの追加を行います。
基本的に、前述のTodo一覧取得と同じ流れになるので説明はかなり端折りますm(_ _ )m。

【index.razor】

入力フォームを用意するので index.razor は以下のようになります。

# blazor-fluxor-todo-app.Client/Pages/index.razor #

@page "/"

@using Blazor.Fluxor
@using blazor_fluxor_todo_app.Client.Store
@using blazor_fluxor_todo_app.Shared

@inject IDispatcher Dispatcher
@inject IState<TodoState> TodoState

@TodoState.Value.ErrorMessage

<div class="card">
  <div class="card-header">Your Todo List</div>
  <div class="card-body">
    <table class="table">
      <thead>
        <tr>
          <th>ID</th>
          <th>Title</th>
          <th>Priority</th>
        </tr>
      </thead>
      @foreach(var todoItem in TodoState.Value.TodoItems) {
        string trClass = todoItem.Priority == Priority.High ? "table-danger" : "";
        
        <tr class="@trClass">
          <td>@todoItem.ID</td>
          <td>@todoItem.Title</td>
          <td>@todoItem.Priority</td>
        </tr>
      }
    </table>
  </div>
</div>

<hr />

<div class="card" style="width: 20rem;">
  <div class="card-header">Add new Todo</div>
  <div class="card-body">
    <div class="form-group">
      <label for="title">Title:</label>
      <input type="text" id="title" class="form-control" bind="@NewTodo" />
    </div>
    <div class="form-group">
      <label for="passwd1">Priority:</label>
      <select bind="@Priority">
          <option value=@Priority.High>@Priority.High.ToString()</option>
          <option value=@Priority.Normal>@Priority.Normal.ToString()</option>
          <option value=@Priority.Low>@Priority.Low.ToString()</option>
      </select>
    </div>
    <div class="form-group">
      <input type="button" class="btn btn-primary" onclick="@AddTodo" value="add todo" />
    </div>
  </div>
</div>

@functions {
    private string NewTodo { get; set; } = "";
    private Priority Priority { get; set; } = Priority.Normal;

    private void AddTodo()
    {
        var action = new AddTodoItemAction(this.NewTodo, this.Priority);
        Dispatcher.Dispatch(action);
    }

    protected override void OnInit()
    {
        base.OnInit();
        
        TodoState.Subscribe(this);
        
        Dispatcher.Dispatch(new FetchTodoItemsAction());
    }
}

NewTodo / Priorityプロパティを @functionsブロックに追加し、それぞれUI要素に対してbindしています。
ボタンのonclickに対しては AddTodo() がバインドされています。
AddTodo()メソッドでは、画面からの入力値(bindによるtwo-wayバインディングでプロパティに値反映)を使ってAddTodoItemActionアクションをDispatchしています。

【AddTodoItemActionアクション】

AddTodoItemActionはTodoの内容及び優先度パラメータを持つためプロパティが追加されています。

# blazor-fluxor-todo-app.Client/Store/AddTodoItemAction.cs #

using Blazor.Fluxor;
using blazor_fluxor_todo_app.Shared;

namespace blazor_fluxor_todo_app.Client.Store
{
  public class AddTodoItemAction : IAction
  {
    public readonly string Title;
    public readonly Priority Priority = Priority.Normal;

    public AddTodoItemAction() 
    {
    }
    
    public AddTodoItemAction(string title, Priority priority)
    {
      this.Title = title;
      this.Priority = priority;
    }
  }
}
【AddTodoItemCompleteActionアクション】

AddTodoItemActionはHttpリクエスト(WebAPI)副作用を伴うアクションであるため、その副作用(Effect)経由でDispatchされるアクションがAddTodoItemCompleteActionとなります。

# blazor-fluxor-todo-app.Client/Store/AddTodoItemCompleteAction.cs #

using Blazor.Fluxor;
using blazor_fluxor_todo_app.Shared;

namespace blazor_fluxor_todo_app.Client.Store
{
  public class AddTodoItemCompleteAction : IAction
  {
    public readonly TodoItem TodoItem;

    public AddTodoItemCompleteAction(TodoItem todoItem)
    {
      this.TodoItem = todoItem;
    }
  }
}
【AddTodoItemCompleteReducerリデューサ】

AddTodoItemCompleteActionアクションから状態を更新するReducerクラス実装です。

# blazor-fluxor-todo-app.Client/Store/AddTodoItemCompleteReducer.cs #

using System.Collections.Generic;
using Blazor.Fluxor;
using blazor_fluxor_todo_app.Shared;

namespace blazor_fluxor_todo_app.Client.Store
{
  public class AddTodoItemCompleteReducer : Reducer<TodoState, AddTodoItemCompleteAction>
  {
    public override TodoState Reduce(TodoState state, AddTodoItemCompleteAction action)
    {
      List<TodoItem> newTodoItems = new List<TodoItem>();
      foreach(var todoItem in state.TodoItems)
      {
        newTodoItems.Add(new TodoItem() { ID = todoItem.ID, Title = todoItem.Title, Priority = todoItem.Priority } );
      }
      newTodoItems.Add(action.TodoItem);

      return new TodoState(
        todoItems: newTodoItems,
        errorMessage: ""
        );
    }
  }
}
【AddTodoItemEffectエフェクト】

AddTodoItemActionアクション発生時に実行される副作用(Effect)の実装になります。
api/todoitemに対するHTTP POST処理を実行して、Todoの追加を行います。

# blazor-fluxor-todo-app.Client/Store/AddTodoItemEffect.cs #

using Blazor.Fluxor;
using blazor_fluxor_todo_app.Shared;
using Microsoft.AspNetCore.Components;
using System;
using System.Net.Http;
using System.Threading.Tasks;

namespace blazor_fluxor_todo_app.Client.Store
{
  public class AddTodoItemEffect : Effect<AddTodoItemAction>
  {
    private readonly HttpClient HttpClient;

    public AddTodoItemEffect(HttpClient httpClient)
    {
      HttpClient = httpClient;
    }

    protected async override Task HandleAsync(AddTodoItemAction action, IDispatcher dispatcher)
    {
      TodoItem todoItem = new TodoItem() { Title = action.Title, Priority = action.Priority };
      try
      {
        todoItem = await HttpClient.PostJsonAsync<TodoItem>("api/todoitem", todoItem);
      }
      catch
      {
        dispatcher.Dispatch(new NetworkErrorAction("通信エラーが発生しました"));
        return;
      }
      var completeAction = new AddTodoItemCompleteAction(todoItem);
      dispatcher.Dispatch(completeAction);
    }
  }
}

実装が終わったら、blazor-fluxor-todo-app.Server フォルダで dotnet build / dotnet run で実行できます。

cd blazor-fluxor-todo-app.Server
dotnet build
dotnet run

実行できたら http://localhost:5000 にブラウザでアクセスしてみましょう。

4. Redux DevTools を使う

Blazor-Fluxorでは Redux DevTools を利用することが可能です。
Startup#ConfigureServices()の構成で、ReduxDevToolsMiddlewareミドルウェアを追加したサービス構成を追加するだけで実現されます。
以下が実装サンプル。

# blazor-fluxor-todo-app.Client/Startup.cs #

using Microsoft.AspNetCore.Components.Builder;
using Microsoft.Extensions.DependencyInjection;

using Blazor.Fluxor;

namespace blazor_fluxor_todo_app.Client
{
  public class Startup
  {
    public void ConfigureServices(IServiceCollection services)
    {
      services.AddFluxor(options => options
        .UseDependencyInjection(typeof(Startup).Assembly)
        .AddMiddleware<Blazor.Fluxor.ReduxDevTools.ReduxDevToolsMiddleware>()
      );
    }

    public void Configure(IComponentsApplicationBuilder app)
    {
      app.AddComponent<App>("app");
    }
  }
}

ブラウザの実行画面で Redux DevTools が有効になっています。

f:id:daigo-knowlbo:20190509015716p:plain

StateのChartもこのように

f:id:daigo-knowlbo:20190509015729p:plain

5. まとめ

という事で、Blazor(Client Side)はまだPreview版ではありますが、なかなか実践的な面白い実装ができる環境は整ってきている感じがしています。
Flux/Reduxな実装方針のほかにMVVMな実装方針なども取れるでBlazorの成長、そしてGAを期待しています^^