方針
💽Blazorアプリで既存のPostgreSQLを利用 #1
💽Blazorアプリで既存のPostgreSQLを利用 #2
前回は新たにpattern_categoryテーブルを追加して、そのテーブルにデータを追加、編集する処理を実装しました。
今回はpatternsテーブル単体でのフィルタリングとソート機能を実装していきます。
次回はpatternsテーブルとpattern_categoryテーブルをJOINして、フィルタリングを行う機能を実装していきます。
前回差分
Blazor Bootstrapの適用
ビジュアライズなトースト機能を実装したかったので、Blazor Bootstrapを適用しております。
Visual Studio Blazor Server(.NET 6)専用の適用方法が、公式ドキュメントに記載がありました。
下記リンクのドキュメントの通りに行えば、簡単にできます。
使用するテーブル
模型データが9999件保存されているテーブル(patterns)を表示し、フィルタ機能と、ソート機能を実装します。
フィルタリングに使用するpattern_categoryテーブルを作成して、patternsテーブルとのデータ結合までを目標として実装してみます。
完成画面
いつもは実装しながらブログを書いていますが、今回は完成してからブログを書くので、いきなり完成した画面をお見せします。
patternsテーブルはカラムが100件ある壮大なテーブルなので、一部の情報だけに絞って表示しています。
Nicknameカラムの内容を入力して検索することで、フィルタリングの機能を実装しています。
また、テーブルの表題をクリックすることで、昇順、降順でのソートが可能になっています。
HTML部分のコード
フィルタリング用の入力フィールドと検索ボタンが上部に表示されるようになっており、検索とソートの結果を表示するテーブルがその下に記述があります。
テーブルにはEdit列があり、複数のボタンが設置されていて、将来的に編集機能を実装するための下準備がしてあります。
@page "/patterns"
@using BlazorApp1.Models
@inject kdx_metalContext DbContext
@inject NavigationManager Navigation
@using Microsoft.EntityFrameworkCore
@using System.Reflection
@using Microsoft.AspNetCore.WebUtilities
<h3>Patterns Data</h3>
<!-- フィルタリング用の入力フィールドと検索ボタン -->
<div>
<label>Filter by Nickname:</label>
</div>
<div class="d-inline-flex mb-3">
<input type="text" @bind="nicknameFilter" class="form-control" placeholder="Enter nickname..." />
<button class="btn btn-primary" @onclick="ApplyFilter">Search</button>
</div>
@if (filteredPatterns == null)
{
<Preload LoadingText="Loading..." />
}
else if (filteredPatterns.Count == 0)
{
<p>No data available.</p>
}
else
{
<table class="table">
<thead>
<tr>
<th @onclick="() => SortData(nameof(Pattern.Id))">ID @SortIcon(nameof(Pattern.Id))</th>
<th @onclick="() => SortData(nameof(Pattern.CategoryId))">CategoryId @SortIcon(nameof(Pattern.CategoryId))</th>
<th @onclick="() => SortData(nameof(Pattern.Name))">Name @SortIcon(nameof(Pattern.Name))</th>
<th @onclick="() => SortData(nameof(Pattern.Nickname))">Nickname @SortIcon(nameof(Pattern.Nickname))</th>
<th @onclick="() => SortData(nameof(Pattern.CreatedAt))">Created @SortIcon(nameof(Pattern.CreatedAt))</th>
<th>Edit</th>
</tr>
</thead>
<tbody>
@foreach (var item in filteredPatterns)
{
<tr>
<td>@item.Id</td>
<td>@item.CategoryId</td>
<td>@item.Name</td>
<td>@item.Nickname</td>
<td>@item.CreatedAt</td>
<td>
<button class="btn btn-primary" @onclick="() => EditPattern(item)">全般</button>
<button class="btn btn-primary" @onclick="() => EditPattern(item)">下型</button>
<button class="btn btn-primary" @onclick="() => EditPattern(item)">上型</button>
<button class="btn btn-primary" @onclick="() => EditPattern(item)">湯口</button>
<button class="btn btn-primary" @onclick="() => EditPattern(item)">測定</button>
</td>
</tr>
}
</tbody>
</table>
}
@code部分のコード
フィルタ機能とソート機能を実装するにあたり、データベースから読み出した全件リストを保持するpatternsリストと、filteredPatternsリストの2種類を設けています。
html部分の表示にはfilteredPatternsリストを使用することで、最初に読み出したデータを保持しつつ、処理を高速化しています。
@code {
// patterns:全てのPatternデータを保持するリスト
private List<Pattern> patterns;
// filteredPatterns:フィルタリング後のPatternデータを保持するリスト
private List<Pattern> filteredPatterns;
// nicknameFilter:Nicknameフィルタ用の入力値
private string nicknameFilter;
// isAscending:現在のソート順が昇順かどうかを保持するフラグ
private bool isAscending = true;
// sortedColumn:現在ソートされているカラム名を保持する
private string sortedColumn;
// cts:データ取得をキャンセルするためのCancellationTokenSource
private CancellationTokenSource? cts;
// クエリパラメータ "sort" を受け取るためのプロパティ(デフォルトは "id")
[SupplyParameterFromQuery(Name = "sort")]
public string? SortOrder { get; set; } = "id";
// PreloadService:データロード中にローディング表示を管理するサービス
[Inject] protected PreloadService PreloadService { get; set; }
// コンポーネント初期化時にデータをロードするメソッドを呼び出す
protected override async Task OnInitializedAsync()
{
cts = new CancellationTokenSource();
await LoadPatternsWithPreloadAsync(); // データを読み込む際にローディング表示を行う
}
// クエリパラメータが変更された際に呼び出されるメソッド(例:ページ遷移時)
protected override async Task OnParametersSetAsync()
{
await LoadPatternsWithPreloadAsync(); // クエリ変更時にもデータを再度ロード
}
// データをロードする際にローディング表示を行うためのメソッド
private async Task LoadPatternsWithPreloadAsync()
{
PreloadService.Show(); // ローディング表示を開始
cts?.Cancel(); // 既存のデータロード処理があればキャンセル
cts = new CancellationTokenSource(); // 新しいCancellationTokenSourceを生成
try
{
await LoadPatternsAsync(cts.Token); // データロードを非同期で実行
}
finally
{
PreloadService.Hide(); // ローディング表示を非表示にする
}
}
// データベースからPatternデータをロードし、フィルタリングも適用する
private async Task LoadPatternsAsync(CancellationToken cancellationToken)
{
var query = DbContext.Patterns.AsQueryable();
// Nicknameフィルタが設定されている場合、クエリにフィルタを適用する
if (!string.IsNullOrEmpty(nicknameFilter))
{
query = query.Where(p => p.Nickname.Contains(nicknameFilter, StringComparison.OrdinalIgnoreCase));
}
// ソート順を適用する
query = ApplySortOrder(query);
// データを非同期で取得し、patternsリストに格納
patterns = await query.ToListAsync(cancellationToken);
// フィルタを適用してfilteredPatternsリストを更新
filteredPatterns = ApplyFilter(patterns, nicknameFilter);
}
// クエリにソート順を適用するメソッド
private IQueryable<Pattern> ApplySortOrder(IQueryable<Pattern> query)
{
// SortOrderの値に応じて、ソート条件を適用
return SortOrder switch
{
"category" => query.OrderBy(p => p.CategoryId),
"created" => query.OrderBy(p => p.CreatedAt),
"id" or _ => query.OrderBy(p => p.Id), // デフォルトはIDでのソート
};
}
// リストに対してNicknameフィルタを適用するメソッド
private List<Pattern> ApplyFilter(List<Pattern> list, string filter)
{
// Nicknameが空の場合は全件、入力されている場合は部分一致でフィルタリング
return list
.Where(p => string.IsNullOrEmpty(filter) || (p.Nickname?.Contains(filter, StringComparison.OrdinalIgnoreCase) ?? false))
.ToList();
}
// Searchボタンが押されたときに呼び出されるメソッド
private void ApplyFilter()
{
// patternsリストに対してフィルタを適用し、filteredPatternsを更新
filteredPatterns = ApplyFilter(patterns, nicknameFilter);
}
// ソートアイコンがクリックされた際に呼び出されるメソッド
private async Task SortData(string columnName)
{
// 同じカラムをクリックした場合は昇順・降順を切り替え
isAscending = sortedColumn == columnName ? !isAscending : true;
sortedColumn = columnName; // ソート対象のカラムを更新
var query = DbContext.Patterns.AsQueryable();
query = SortByColumn(query, columnName); // カラムに基づいてソートを適用
patterns = await query.ToListAsync(); // ソート後のデータを取得
filteredPatterns = ApplyFilter(patterns, nicknameFilter); // フィルタを適用してリストを更新
}
// ソート対象のカラムに基づいてクエリにソートを適用する
private IQueryable<Pattern> SortByColumn(IQueryable<Pattern> query, string columnName)
{
// カラム名に応じて昇順・降順でソートを適用
return columnName switch
{
nameof(Pattern.CategoryId) => isAscending ? query.OrderBy(p => p.CategoryId) : query.OrderByDescending(p => p.CategoryId),
nameof(Pattern.Name) => isAscending ? query.OrderBy(p => p.Name) : query.OrderByDescending(p => p.Name),
nameof(Pattern.Nickname) => isAscending ? query.OrderBy(p => p.Nickname) : query.OrderByDescending(p => p.Nickname),
nameof(Pattern.CreatedAt) => isAscending ? query.OrderBy(p => p.CreatedAt) : query.OrderByDescending(p => p.CreatedAt),
nameof(Pattern.Id) or _ => isAscending ? query.OrderBy(p => p.Id) : query.OrderByDescending(p => p.Id), // デフォルトはIDでのソート
};
}
// 現在ソートされているカラムの昇順・降順アイコンを表示するメソッド
private RenderFragment SortIcon(string columnName) => @<text>@(sortedColumn == columnName ? (isAscending ? "▲" : "▼") : string.Empty)</text>;
// 編集ボタンが押されたときに呼び出されるメソッド
private void EditPattern(Pattern item)
{
// 編集ロジックをここに実装する
}
}
複雑な処理ですが、次の項目から順に動作を見ていきます。
ロード処理
// cts:データ取得をキャンセルするためのCancellationTokenSource
private CancellationTokenSource? cts;
// コンポーネント初期化時にデータをロードするメソッドを呼び出す
protected override async Task OnInitializedAsync()
{
cts = new CancellationTokenSource();
await LoadPatternsWithPreloadAsync(); // データを読み込む際にローディング表示を行う
}
// データをロードする際にローディング表示を行うためのメソッド
private async Task LoadPatternsWithPreloadAsync()
{
PreloadService.Show(); // ローディング表示を開始
cts?.Cancel(); // 既存のデータロード処理があればキャンセル
cts = new CancellationTokenSource(); // 新しいCancellationTokenSourceを生成
try
{
await LoadPatternsAsync(cts.Token); // データロードを非同期で実行
}
finally
{
PreloadService.Hide(); // ローディング表示を非表示にする
}
}
// データベースからPatternデータをロードし、フィルタリングも適用する
private async Task LoadPatternsAsync(CancellationToken cancellationToken)
{
var query = DbContext.Patterns.AsQueryable();
// Nicknameフィルタが設定されている場合、クエリにフィルタを適用する
if (!string.IsNullOrEmpty(nicknameFilter))
{
query = query.Where(p => p.Nickname.Contains(nicknameFilter, StringComparison.OrdinalIgnoreCase));
}
// ソート順を適用する
query = ApplySortOrder(query);
// データを非同期で取得し、patternsリストに格納
patterns = await query.ToListAsync(cancellationToken);
// フィルタを適用してfilteredPatternsリストを更新
filteredPatterns = ApplyFilter(patterns, nicknameFilter);
}
データベースからPatternsテーブルのデータをロードする処理は、全てLoadPatternsAsyncに集約されています。
コンポーネントの初期化時は(Blazorコンポーネントが最初にレンダリングされる際)、Task OnInitializedAsync内の処理が実行されます。
非同期処理のキャンセル
cts(CancellationTokenSource) は、System.CancellationTokenSourceクラスのインスタンスで、非同期操作のキャンセルをサポートしています。
非同期処理にcts.Tokenを渡すことで、そのメソッドがキャンセルされるかどうかをチェックしながら処理を行います。
cts.Tokenを非同期処理に渡しておくと、cts.Cancel();が実行されると、そのトークンに基づく全ての非同期処理をキャンセルできます。
Patternsが9999件のレコードを含んでいる為、非同期処理の途中で別ページに遷移した場合等、await処理の直前でcts.Cancel();を記述しておくことで、エラーを防いでいます。
非同期処理
データベースからのロード等、時間がかかる処理を実行する際にasync, awaitを記述しておくことで、タスクを非同期処理にすることができます。
// コンポーネント初期化時にデータをロードするメソッドを呼び出す
protected override async Task OnInitializedAsync()
{
cts = new CancellationTokenSource();
await LoadPatternsWithPreloadAsync(); // データを読み込む際にローディング表示を行う
}
イニシャライズの際に、ロード処理をawaitで記述しておくことで、htmlの書き出しを優先させています。
クエリパラメータによるソート処理
サイドバーにて、ID順, カテゴリ順, 登録順でデータをソートできるように、クエリパラメータでのソート処理を実装しています。
// クエリパラメータが変更された際に呼び出されるメソッド(例:ページ遷移時)
protected override async Task OnParametersSetAsync()
{
await LoadPatternsWithPreloadAsync(); // クエリ変更時にもデータを再度ロード
}
// データをロードする際にローディング表示を行うためのメソッド
private async Task LoadPatternsWithPreloadAsync()
{
PreloadService.Show(); // ローディング表示を開始
cts?.Cancel(); // 既存のデータロード処理があればキャンセル
cts = new CancellationTokenSource(); // 新しいCancellationTokenSourceを生成
try
{
await LoadPatternsAsync(cts.Token); // データロードを非同期で実行
}
finally
{
PreloadService.Hide(); // ローディング表示を非表示にする
}
}
// データベースからPatternデータをロードし、フィルタリングも適用する
private async Task LoadPatternsAsync(CancellationToken cancellationToken)
{
var query = DbContext.Patterns.AsQueryable();
// Nicknameフィルタが設定されている場合、クエリにフィルタを適用する
if (!string.IsNullOrEmpty(nicknameFilter))
{
query = query.Where(p => p.Nickname.Contains(nicknameFilter, StringComparison.OrdinalIgnoreCase));
}
// ソート順を適用する
query = ApplySortOrder(query);
// データを非同期で取得し、patternsリストに格納
patterns = await query.ToListAsync(cancellationToken);
// フィルタを適用してfilteredPatternsリストを更新
filteredPatterns = ApplyFilter(patterns, nicknameFilter);
}
// クエリにソート順を適用するメソッド
private IQueryable<Pattern> ApplySortOrder(IQueryable<Pattern> query)
{
// SortOrderの値に応じて、ソート条件を適用
return SortOrder switch
{
"category" => query.OrderBy(p => p.CategoryId),
"created" => query.OrderBy(p => p.CreatedAt),
"id" or _ => query.OrderBy(p => p.Id), // デフォルトはIDでのソート
};
}
ロード処理をまとめているLoadPatternsAsyncの処理の流れは、クエリの変数を宣言後、ソート処理を実行し、その後フィルタリングを行っています。
// データベースからPatternデータをロードし、フィルタリングも適用する
private async Task LoadPatternsAsync(CancellationToken cancellationToken)
{
var query = DbContext.Patterns.AsQueryable();
// Nicknameフィルタが設定されている場合、クエリにフィルタを適用する
if (!string.IsNullOrEmpty(nicknameFilter))
{
query = query.Where(p => p.Nickname.Contains(nicknameFilter, StringComparison.OrdinalIgnoreCase));
}
// ソート順を適用する
query = ApplySortOrder(query);
// データを非同期で取得し、patternsリストに格納
patterns = await query.ToListAsync(cancellationToken);
// フィルタを適用してfilteredPatternsリストを更新
filteredPatterns = ApplyFilter(patterns, nicknameFilter);
}
サイドバーのリンクをクリックした際に、クエリパラメータが更新されますが、その際にTask OnPatternsWithPreloadAsyncが呼び出されます。
ユーザーが途中でクエリの切り替えを実行した場合、cts.Cancel();を実行し、他のロード処理が実行されていた場合にその処理をキャンセルします。
ApplySortOrder
下記のコードでソート用のクエリを設定しています。
クエリパラメータはSupplyParameterFromQueryによって、SortOrderという変数にバインドされています。
クエリパラメータが変更されるたび、LoadPatternsAsyncでApplySortOrderメソッドが呼び出され、queryが更新される仕組みです。
// クエリパラメータ "sort" を受け取るためのプロパティ(デフォルトは "id")
[SupplyParameterFromQuery(Name = "sort")]
public string? SortOrder { get; set; } = "id";
// ソート順を適用する
query = ApplySortOrder(query);
// クエリにソート順を適用するメソッド
private IQueryable<Pattern> ApplySortOrder(IQueryable<Pattern> query)
{
// SortOrderの値に応じて、ソート条件を適用
return SortOrder switch
{
"category" => query.OrderBy(p => p.CategoryId),
"created" => query.OrderBy(p => p.CreatedAt),
"id" or _ => query.OrderBy(p => p.Id), // デフォルトはIDでのソート
};
}
クリックによるソート処理
htmlタグ部にて、テーブルのヘッダをクリックするたびにSortDataメソッドが呼び出されています。
<table class="table">
<thead>
<tr>
<th @onclick="() => SortData(nameof(Pattern.Id))">ID @SortIcon(nameof(Pattern.Id))</th>
<th @onclick="() => SortData(nameof(Pattern.CategoryId))">CategoryId @SortIcon(nameof(Pattern.CategoryId))</th>
<th @onclick="() => SortData(nameof(Pattern.Name))">Name @SortIcon(nameof(Pattern.Name))</th>
<th @onclick="() => SortData(nameof(Pattern.Nickname))">Nickname @SortIcon(nameof(Pattern.Nickname))</th>
<th @onclick="() => SortData(nameof(Pattern.CreatedAt))">Created @SortIcon(nameof(Pattern.CreatedAt))</th>
<th>Edit</th>
</tr>
</thead>
<tbody>
@foreach (var item in filteredPatterns)
{
<tr>
<td>@item.Id</td>
<td>@item.CategoryId</td>
<td>@item.Name</td>
<td>@item.Nickname</td>
<td>@item.CreatedAt</td>
<td>
<button class="btn btn-primary" @onclick="() => EditPattern(item)">全般</button>
<button class="btn btn-primary" @onclick="() => EditPattern(item)">下型</button>
<button class="btn btn-primary" @onclick="() => EditPattern(item)">上型</button>
<button class="btn btn-primary" @onclick="() => EditPattern(item)">湯口</button>
<button class="btn btn-primary" @onclick="() => EditPattern(item)">測定</button>
</td>
</tr>
}
</tbody>
</table>
// ソートアイコンがクリックされた際に呼び出されるメソッド
private async Task SortData(string columnName)
{
// 同じカラムをクリックした場合は昇順・降順を切り替え
isAscending = sortedColumn == columnName ? !isAscending : true;
sortedColumn = columnName; // ソート対象のカラムを更新
var query = DbContext.Patterns.AsQueryable();
query = SortByColumn(query, columnName); // カラムに基づいてソートを適用
patterns = await query.ToListAsync(); // ソート後のデータを取得
filteredPatterns = ApplyFilter(patterns, nicknameFilter); // フィルタを適用してリストを更新
}
引数として、nameof(Pattern.Id)が渡されていますが、実質的にはStringの”ID”という列名が渡されています。
これをqueryとしてSortByColumnメソッドに渡しています。
// ソート対象のカラムに基づいてクエリにソートを適用する
private IQueryable<Pattern> SortByColumn(IQueryable<Pattern> query, string columnName)
{
// カラム名に応じて昇順・降順でソートを適用
return columnName switch
{
nameof(Pattern.CategoryId) => isAscending ? query.OrderBy(p => p.CategoryId) : query.OrderByDescending(p => p.CategoryId),
nameof(Pattern.Name) => isAscending ? query.OrderBy(p => p.Name) : query.OrderByDescending(p => p.Name),
nameof(Pattern.Nickname) => isAscending ? query.OrderBy(p => p.Nickname) : query.OrderByDescending(p => p.Nickname),
nameof(Pattern.CreatedAt) => isAscending ? query.OrderBy(p => p.CreatedAt) : query.OrderByDescending(p => p.CreatedAt),
nameof(Pattern.Id) or _ => isAscending ? query.OrderBy(p => p.Id) : query.OrderByDescending(p => p.Id), // デフォルトはIDでのソート
};
}
SortByColumnメソッドではクエリ変数とクリックされたカラム名が渡されています。
columnNameに応じてqueryが決定します。
昇順と降順の切り替え
三項演算子によって実装されていますので、動作を見てみましょう。
例:
1. 既に同じ列をクリックした場合(現在 Id
列で昇順にソートされている):
ortedColumn = "Id";
columnName = "Id";
isAscending = true; // 現在は昇順
// この場合、!isAscending は false(降順になる)
isAscending = sortedColumn == columnName ? !isAscending : true; // isAscending = false
→ isAscending
は false
となり、次は降順でソートされます。
2. 別の列をクリックした場合(現在 Id
列でソートされていて、新たに CategoryId
列をクリック):
sortedColumn = "Id";
columnName = "CategoryId";
isAscending = true; // 現在は昇順
// この場合、sortedColumn == columnName が false なので、true が返される
isAscending = sortedColumn == columnName ? !isAscending : true; // isAscending = true
→ isAscending
は true
のままで、新しい列では昇順からソートが始まります。
isAscendingは1つの変数なので、複数の昇順降順を組み合わせることはできません。
Inputによるフィルタ処理
ここまででかなり複雑に感じるかもしれませんが、フィルタ処理も見ていきましょう。(blog記事を分けた方がよかったかもしれない。)
まずは、ページ上部にレンダリングされているInputに入力があり、Searchボタンを押した際のフィルタリング処理を見ていきます。
<!-- フィルタリング用の入力フィールドと検索ボタン -->
<div>
<label>Filter by Nickname:</label>
</div>
<div class="d-inline-flex mb-3">
<input type="text" @bind="nicknameFilter" class="form-control" placeholder="Enter nickname..." />
<button class="btn btn-primary" @onclick="OnSearchButtonClick">Search</button>
</div>
// リストに対してNicknameフィルタを適用するメソッド
private List<Pattern> ApplyFilter(List<Pattern> list, string filter)
{
// Nicknameが空の場合は全件、入力されている場合は部分一致でフィルタリング
return list
.Where(p => string.IsNullOrEmpty(filter) || (p.Nickname?.Contains(filter, StringComparison.OrdinalIgnoreCase) ?? false))
.ToList();
}
// Searchボタンが押されたときに呼び出されるメソッド
private void OnSearchButtonClick()
{
// patternsリストに対してフィルタを適用し、filteredPatternsを更新
filteredPatterns = ApplyFilter(patterns, nicknameFilter);
}
InputにNicknameのフィルタ条件を入力し、Searchボタンをクリックすると、OnSearchButtonClickが呼び出され、nicknameFilterと全体のpatternsリストをApplyFilterを渡します。
ApplyFilterではpatternsリストをlistとして受け取り、クエリの実行結果をfilterdPatternsに渡して画面を更新します。
nicknameFilter(Inputの入力)はwhere句として扱われています。
フィルタ処理とソート処理の組み合わせ
Inputに入力がある状態(フィルタ処理)で、ソートボタンを押した際の処理についてみていきます。
// ソートアイコンがクリックされた際に呼び出されるメソッド
private async Task SortData(string columnName)
{
// 同じカラムをクリックした場合は昇順・降順を切り替え
isAscending = sortedColumn == columnName ? !isAscending : true;
sortedColumn = columnName; // ソート対象のカラムを更新
var query = DbContext.Patterns.AsQueryable();
query = SortByColumn(query, columnName); // カラムに基づいてソートを適用
patterns = await query.ToListAsync(); // ソート後のデータを取得
filteredPatterns = ApplyFilter(patterns, nicknameFilter); // フィルタを適用してリストを更新
}
ソートボタンが押された際は、SortDataメソッドが呼び出され、押されたボタンによってクエリが変化した後、patternsにクエリの実行結果を格納しています。
この状態ではfilteredPatternsの値は変化していませんが、filteredPatterns = ApplyFilter(patterns, nicknameFilter);処理によって、nicknameFilterの値を参照してフィルタリングされるため、ソートとフィルタの処理が両立しています。
まとめ
Microsoft.EntityFrameworkCoreの機能をフルに使いながら、ソートとフィルタの処理を実装しています。処理が複雑で取り留めのないまとめかたになってしまい申し訳ございません。
まとまってEntityFrameworkCoreの勉強の時間を取りたいのですが、今月中にテーブルの閲覧と編集機能を実装しないといけないので時間がありません。
実装途中でデバッグしているのですが、ユーザーの動作によって非同期処理が中断された場合のエラーを防ぐ為のcts.Cancel();を入れていないと、ページ遷移時にエラーが発生してしまう等のバグに悩まされています。
さすがにドキュメントをまとめる時間も無くなってきましたが、残るは編集機能とソケット通信によるキーエンスPLCの制御だけ!しかもソケット通信の方は裏で実装とデバッグが終わっている!のでブログの更新も頑張ります。