前回に続きTodoリストアプリ開発を題材として、Blazorの基本機能を学びます。
これまで初心者がBlazorでWebアプリ開発をする際にまず押さえるべき以下の要点を学んできました。
- C#コードとHTMLを組み合わせて動的なページを作るRazorコンポーネント
- データベース(DB)アクセスを行うためのEntity Framework Core
- 認証基盤であるASP.NET Core Identity
BlazorのServerモード・DBはSQLiteを使い、前回までで以下の部分を作りました。
- Entity Framework Core(EF Core)でクラス定義からDBスキーマを生成
- EF Coreを使ってTodoに対するCRUD操作のビジネスロジックを実装
- RazorコンポーネントでTodoの一覧表示・編集・追加・削除機能を実装
- 認証基盤Core IdentityでTodoをユーザごとに管理
これまでの記事は以下になりますので、ぜひまずは先にこちらをご覧ください。
今回は以下について学びます。
- Data Anotationsを用いて入力バリデーション機能を作る。
- エラーの処理とユーザフィードバックを行う機能を作る。
今回の演習で、Todoアプリの「最終ゴール」としていた以下のようなTodoリストアプリがひとまず完成です。
当初目的としていた認証付きTodoリストの主な機能は前回までで作りあげています。
しかし、実用的なアプリ開発では「アプリにおける想定外の動作をいかに防ぐか?」という点も重要になります。
エラーが発生した場合は、適切にプログラムで対処し、必要に応じてユーザや開発者へエラー内容や取るべきアクションをフィードバックすることも必要です。
エラー処理の基本については、C#入門編の以下の記事にもまとめましたので、こちらも参考にしてください。
今回は、BlazorによるWeb開発において、「アプリにおける想定外の動作をいかに防ぐか?」を一緒に学びましょう!
演習のコード一式をGitHubへ置きましたので、そちらも参考にしてください。
これまで段階的にTodoリストアプリを作ってきました。
今回は以下の【4】,【5】を演習1,2として行います。
YouTubeの解説動画もあります。
演習1:入力バリデーションを追加 ~Data Annotations~
作る機能
Todo要素を追加・編集する入力フォームで、入力された文字列が適切であるかチェックする機能(入力バリデーション)を追加します。
以下のチェックを追加します。
- 入力は必須
- 文字数は最大10文字
前回のコードをベースとして、そこへ入力バリデーションの機能を追加します。
(Readme.mdに書いていますが、このコードにはDBは含まれていないため、最初に「dotnet ef database update」コマンドで作ってください)
画面・ビジネスロジック・データモデルを修正します。
- 手順1:Models/TotoItem.cs(データモデル)へデータ制約を追加し、DBへのマイグレーションを実施
- 手順2:Components/Shared/TodoItemEditor.razor (画面)を修正
TodoLispAppプロジェクトにおける以下の部分ですね。
追加/編集機能はTodoItemEditor.razorで共通部品化されているから、画面としてはそこだけ修正すればいいんだね。
その通りですね。なるべくコードを共通化しておくことで、修正や機能追加が容易になりますね。
手順1:データモデルへ制約追加とマイグレーション実施
Models/TotoItem.csを次のように修正します。
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using TodoListApp.Data;
namespace TodoListApp.Models;
public class TodoItem
{
[Key]
public int Id { get; set; }
[Required(ErrorMessage = "Title is required.")] //★(a)
[StringLength(10, ErrorMessage = "Title must be less than 10 characters.")] //★(b)
public string Title { get; set; } = string.Empty;
[ForeignKey(nameof(ApplicationUser))]
public string UserId { get; set; } = string.Empty;
}
ASP.NET CoreのData Annotationsという仕組みを使って、データモデルの各フィールドへの制約を記述します。
(a)のRequiredは入力が必須であることを示す制約で、(b)のStringLengthは文字列長に関する制約です。
(a),(b)のErrorMessageはそれぞれ制約に違反する入力があったときに表示するエラーメッセージです。
制約はRequiredやStringLengthを含め組込みでいくつか用意されています。必要に応じて自分で定義することも可能です。
「ASP.NET Core MVC および Razor Pages でのモデルの検証」の記事や、組込み属性の一覧も参考にしてください。
Data Annotationsで記述した制約は2つの役割があります。
- (1) 入力バリデーション
- (2) データベース制約
(2)の役割もあるため、データモデルへ制約を追加したら、DBへマイグレーションも実施しましょう。
次の2ステップでしたね。(「AddConstraintsToTodoItem」の部分は修正内容がわかるような適切な名前ならば何でもよいです)
dotnet ef migrations add AddConstraintsToTodoItem -o Data\Migrations
dotnet ef database update
記述した制約がDBへ反映されるかは、DBの種類によっても異なります。
例えばSQLServerならば広範にData Annotations制約をサポートしており、SQLiteはサポートが限定的、といった違いがあります。
手順2:編集画面を修正
Components/Shared/TodoItemEditor.razor を修正します。
@using TodoListApp.Models
@* ★(a) *@
<EditForm Model="@TodoItem" OnValidSubmit="OnSubmit" style="display: inline">
@* ★(b) *@
<DataAnnotationsValidator />
<InputText @bind-Value="TodoItem.Title" placeholder="@Placeholder" />
@* ★(c) *@
<ValidationMessage For="@(() => TodoItem.Title)" style="display: inline"/>
<button type="submit">@ButtonText</button>
</EditForm>
@code {
[Parameter] public TodoItem TodoItem { get; set; } = new TodoItem();
[Parameter] public string ButtonText { get; set; } = "Submit";
[Parameter] public string Placeholder { get; set; } = "";
[Parameter] public Func<Task> OnSubmit { get; set; } = () => Task.CompletedTask;
}
(b)の<DataAnnotationsValidator />は、フォーム全体のバリデーションを有効にするコンポーネントです。
(c)の<ValidationMessage For…>は特定のフィールド(この場合は TodoItem.Title
)に対するバリデーションエラーメッセージを表示するためのコンポーネントです。
これで、入力に制約違反があると自動でチェックされエラーメッセージが表示されます。
そして、制約違反がなく適切な入力であれば(a)の<EditForm…>のOnValidSubmitに設定しているイベント(DBへの追加・更新)が実行されます。
入力フォームにおける検証については、「ASP.NET Core Blazor フォームの検証」の記事も参考にしてください。
アプリを実行
アプリを実行し、追加もしくは編集で、「空欄」や「10文字を超えるテキスト」で登録を試みてみましょう。
入力バリデーションによりエラーメッセージが表示され、登録ができないはずです。
入力チェックができるようになった!
エラーメッセージも表示されるから、ユーザがどのように修正すればよいかわかるね。
演習2:エラー処理とユーザフィードバック
作る機能
演習1では入力チェックとエラーメッセージ表示(ユーザへのフィードバック)を行う機能を作りました。
演習2では、演習1のコードをベースとしてDB更新結果(成功/失敗)を画面に表示できる機能を作ります。
データの更新を伴う操作では、成功した場合にもユーザへ通知を行うことで、ユーザへ安心感を与えることができますね。
ビジネスロジック・画面を修正します。
- 手順1:ビジネスロジック(Services/TodoListService.cs)へDB関連の例外処理を追加
- 手順2:画面(Compornents/Pages/TodoList.razor)を修正
以下を修正・追加します。
手順1:ビジネスロジックへDB関連の例外処理を追加
Services/TodoListServiceException.csとして、ビジネスロジックTodoListServiceクラスにおける例外処理を扱うクラスを追加します。
namespace TodoListApp.Services;
public class TodoListServiceException : Exception
{
public TodoListServiceException(string message) : base(message) { }
public TodoListServiceException(string message, Exception inner) : base(message, inner) { }
}
TodoListServiceクラスでDBアクセス時に例外が発生した場合に、その例外をキャッチしています。CRUD操作それぞれについて(a)~(d)でキャッチしています。
キャッチした例外をInnerExceptionとして設定し、TodoListServiceExceptionのインスタンスを生成してその例外をthrowしています。
using Microsoft.EntityFrameworkCore;
using TodoListApp.Data;
using TodoListApp.Models;
namespace TodoListApp.Services;
public class TodoListService
{
private readonly ApplicationDbContext _context;
public TodoListService(ApplicationDbContext context)
{
_context = context;
}
public async Task<List<TodoItem>> GetTodosAsync(string userId)
{
//★(a)
try
{
var items = await _context.TodoItems.Where(t => t.UserId == userId).ToListAsync();
return items;
}
catch (Exception ex)
{
throw new TodoListServiceException("Failed to retrieve todo items.", ex);
}
}
public async Task UpdateTodoAsync(string userId, TodoItem todoItemInput)
{
//★(b)
try
{
var existingTodoItem = await _context.TodoItems.FindAsync(todoItemInput.Id);
if (existingTodoItem == null || existingTodoItem.UserId != userId)
{
throw new TodoListServiceException("Todo item not found or access denied.");
}
existingTodoItem.Title = todoItemInput.Title;
await _context.SaveChangesAsync();
}
catch (Exception ex)
{
throw new TodoListServiceException("Failed to update todo item.", ex);
}
}
public async Task DeleteTodoAsync(string userId, int todoId)
{
//★(c)
try
{
var existingTodoItem = await _context.TodoItems.FindAsync(todoId);
if (existingTodoItem == null || existingTodoItem.UserId != userId)
{
throw new TodoListServiceException("Todo item not found or access denied.");
}
_context.TodoItems.Remove(existingTodoItem);
await _context.SaveChangesAsync();
}
catch (Exception ex)
{
throw new TodoListServiceException("Failed to delete todo item.", ex);
}
}
public async Task AddTodoAsync(string userId, TodoItem newTodoItem)
{
//★(d)
try
{
newTodoItem.UserId = userId;
_context.TodoItems.Add(newTodoItem);
await _context.SaveChangesAsync();
}
catch (Exception ex)
{
throw new TodoListServiceException("Failed to add todo item.", ex);
}
}
}
InnerExceptionについては、こちらの記事も参考にしてください。
現在のエラーの原因となるエラーを記録しておく仕組みです。
今回、全ての例外をひとまずExceptionとしてキャッチしていますが、もう少し例外の種類でエラーメッセージを分けるなどしてもよいでしょう。
(あとよくみると、TodoListServiceExceptionが入れ子構造になるような作りなってしまっていて、あまり良い設計ではないかもしれません。。。)
手順2:画面を修正
Components/Pages/TodoList.razorを次のように修正します。例外をキャッチして、ユーザへDB更新の成否を表示します。
@page "/todolist"
@rendermode InteractiveServer
@using Microsoft.AspNetCore.Identity
@using TodoListApp.Components.Shared
@using TodoListApp.Data
@using TodoListApp.Models
@using TodoListApp.Services
@inject AuthenticationStateProvider _authenticationStateProvider
@inject UserManager<ApplicationUser> _userManager
@inject TodoListService _todoService
<PageTitle>TodoList</PageTitle>
<h1>Todo List</h1>
//★(a)
@if (!string.IsNullOrEmpty(_latestMessage))
{
<div class="alert @(_isLatestMessageSuccess ? "alert-success" : "alert-danger")">
@_latestMessage
</div>
}
@if (IsAuthenticated)
{
<p>@_userName (Authenticated)</p>
<ul>
@foreach (var todoItem in _todoItems)
{
<li>
<span>Id: @todoItem.Id, @todoItem.Title</span>
@if (_editingTodoItem != null && _editingTodoItem.Id == todoItem.Id)
{
<TodoItemEditor TodoItem="_editingTodoItem" OnSubmit="UpdateTodoAsync" Placeholder="Edit title" ButtonText="Save" />
}
else
{
<button @onclick="() => EnterEditMode(todoItem)">Edit</button>
}
<button @onclick="() => DeleteTodoAsync(todoItem.Id)">Delete</button>
</li>
}
</ul>
<TodoItemEditor TodoItem="_newTodoItem" OnSubmit="AddTodoAsync" Placeholder="New todo title" ButtonText="Add" />
}
else
{
<p>Not authenticated</p>
}
@code {
//★(b)
private string? _latestMessage;
private bool _isLatestMessageSuccess;
private List<TodoItem> _todoItems = new();
private TodoItem? _newTodoItem;
private TodoItem? _editingTodoItem;
private string _userId = string.Empty;
private string _userName = string.Empty;
private bool IsAuthenticated => !string.IsNullOrEmpty(_userId);
protected override async Task OnInitializedAsync()
{
var authState = await _authenticationStateProvider.GetAuthenticationStateAsync();
var user = authState.User;
if (user.Identity?.IsAuthenticated == true)
{
_userId = _userManager.GetUserId(user) ?? string.Empty;
_userName = user.Identity.Name ?? string.Empty;
if (IsAuthenticated)
{
CreateNewTodo();
await ReloadTodosAsync();
}
}
}
private void CreateNewTodo()
{
_newTodoItem = new TodoItem { UserId = _userId };
}
private async Task ReloadTodosAsync()
{
//★(c)
try
{
_todoItems = await _todoService.GetTodosAsync(_userId);
}
catch (TodoListServiceException ex)
{
SetUserMessage($"Error loading todos: {ex.Message}", false);
}
}
private void EnterEditMode(TodoItem todoItem)
{
_editingTodoItem = new TodoItem
{
Id = todoItem.Id,
Title = todoItem.Title,
UserId = todoItem.UserId
};
}
private async Task UpdateTodoAsync()
{
if (_editingTodoItem != null)
{
//★(d)
try
{
await _todoService.UpdateTodoAsync(_userId, _editingTodoItem);
_editingTodoItem = null;
await ReloadTodosAsync();
SetUserMessage("Todo item updated successfully.", true);
}
catch (TodoListServiceException ex)
{
SetUserMessage($"Error updating todo: {ex.Message}", false);
}
}
}
private async Task DeleteTodoAsync(int todoId)
{
//★(e)
try
{
await _todoService.DeleteTodoAsync(_userId, todoId);
await ReloadTodosAsync();
SetUserMessage("Todo item deleted successfully.", true);
}
catch (TodoListServiceException ex)
{
SetUserMessage($"Error deleting todo: {ex.Message}", false);
}
}
private async Task AddTodoAsync()
{
//★(f)
if (_newTodoItem != null)
{
try
{
await _todoService.AddTodoAsync(_userId, _newTodoItem);
CreateNewTodo();
await ReloadTodosAsync();
SetUserMessage("Todo item added successfully.", true);
}
catch (TodoListServiceException ex)
{
SetUserMessage($"Error adding todo: {ex.Message}", false);
}
}
}
//★(g)
private void SetUserMessage(string message, bool isSuccess)
{
_latestMessage = message + " " + DateTime.Now.ToString("HH:mm:ss");
_isLatestMessageSuccess = isSuccess;
}
}
(a),(b)で、DB更新の成否などユーザへのメッセージを表示しています。
いつの時点のメッセージかがわかりやすいよう、時刻(時:分:秒)もあわせて表示させてみました。
(g)のSetUserMessageはメッセージを設定するためのメソッドです。
(c)~(f)では、DBの成否にあわせてSetUserMessageでメッセージの設定を行っています。
アプリを実行
アプリを実行してみましょう。
せっかくなので、DB更新が失敗するケースを試してみましょう。以下のように操作します。
- アプリで、Todoリスト一覧を表示させる。
- DBを直接操作して、Todoリストの特定要素について削除する。
- アプリで削除した要素について、更新/削除などの操作を行う。
(既に削除されている要素なので更新/削除に失敗する!)
これは、1人のユーザが複数の端末から同時に操作を行った場合に、発生しうるシナリオですね。
SQLiteを使っているので、レコードの削除はDB Browser for SQLite等で行いましょう。
以下のように、存在しないレコードを削除しようとした場合にはエラーメッセージが表示されます。
DBのデータを書き換える・DBファイルを削除するなどしてエラーを発生させて、どのようにメッセージが変わるか色々試してみましょう!
補足
今回、BlazorによるWebアプリ開発における入力チェックやエラーハンドリング、ユーザへのフィードバックについての基本を学びました。
演習1、2を以下のような観点で拡張していくと、さらに使いやすく安全性の高いアプリを作っていくことができるでしょう。
- より多くのエラーへの対応(nullチェックなど)
- よりユーザフレンドリーなエラーメッセージの表現
- ログ出力など開発者向けのフィードバック
まとめ
実用的なアプリ開発では「アプリにおける想定外の動作をいかに防ぐか?」という点が重要です。
今回はユーザ入力のチェックを行うData Annotationsによる入力バリデーションについて学びました。
例外処理を使い、DBアクセスを伴う操作についてその成否をユーザへフィードバックする仕組みも作りました。
これで、ひとまずTodoリストアプリ開発のゴールを達成だね!
次回からは、より実践に近いWebアプリ開発や、そこで必要になる要素技術を紹介していければと思っています。
引き続き、Webアプリ開発とBlazorを一緒に学んでいきましょう!