9
6

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

2025 実践的なBlazor Server業務アプリケーションの作り方 - 受注管理システムを例に

Last updated at Posted at 2025-08-20

1.はじめに

Blazorを学習する際、多くのチュートリアルは基本的なCRUD操作や簡単な例に留まりがちです。しかし実際の業務システム開発では、認証・認可、排他制御、複雑なバリデーション、サジェスト機能、フォーカス制御など、多くの課題に直面します。
この記事では、実際の業務システムで必要になる機能を盛り込んだBlazor Serverアプリケーションを作成し、Hello Worldを超えた「リアルな開発」のイメージを提供します。

🎯 ターゲット読者

  • Blazorに興味のある方
  • Blazor以外のwebアプリ開発者
  • Blazorの基礎は理解したけれど、実際の業務システム開発のイメージが掴めない開発者

📚 この記事の特徴

2.アプリのイメージ

image.png

image.png

image.png

image.png

以下のURLから実際に動作させることができます。
https://blazororderapp-dsegg0hseqeed6e0.japanwest-01.azurewebsites.net/login

image.png

ログイン用のテストアカウントも用意してます。
image.png

3. 📦このテンプレートに含まれる内容

  • ユーザー認証・セッション管理
  • ユーザー権限(ロール)によるメニュー制御
  • 受注の一覧・検索・登録・更新・削除
  • 得意先・商品のサジェスト機能
  • 楽観的排他制御
  • 複合的なバリデーション
  • ヘッダー・明細の1:N構造

なお、すべてのソースを公開しています。
またDBはSQLiteを利用しているので、ソリューション内にDBファイルを含めています。

4.🔧プロジェクトの技術的背景

  • .NET 9 Blazor Server
  • UIはBootstrapベース、Radzenによるグラフ表示導入あり
  • Cookie認証を利用し、ログイン/ログアウトのみRazor Pages、アプリ本体はBlazor
  • JavaScriptにてエンターキーでフォーカス制御を実現
  • Dapper + SQLite によるシンプルなリポジトリ構成
  • モデルはDataAnnotations+IValidatableObjectで入力チェック
  • レイアウト/ナビゲーション/ページ構成は分かりやすく整理済み

5.データの検索、追加登録、修正、削除(CRUD)

  • 得意先メンテナンスを例に一覧ページと登録ページの全ソースを添付します

いかに少ないコードでアプリが実装できるかイメージをつかんでください

5.1 得意先一覧ページ

image.png

@page "/customers"
@using BlazorOrderApp.Models
@using BlazorOrderApp.Repositories
@attribute [Authorize(Roles = "Administrator")]
@inherits BlazorOrderApp.Components.Commons.InitialFocusComponent


<h3>得意先マスタ一覧</h3>

<div class="mb-3 d-flex align-items-end">
    <div>
        <input type="text" class="form-control" placeholder="キーワード(得意先名/電話番号)"
               @bind="keyword" @bind:event="oninput" style="width: 300px; display:inline-block;"
               data-tab="10" />
    </div>
    <button class="btn btn-primary ms-2" @onclick="Search">検索</button>

    <button class="btn btn-success ms-auto" @onclick="OnAdd">+追加</button>
</div>

<table class="table table-striped table-bordered table-sm">
    <thead class="custom-header">
        <tr>
            <th class="text-center" style="width:55%">得意先名</th>
            <th class="text-center" style="width:30%">電話番号</th>
            <th class="text-center" style="width:15%">操作</th>
        </tr>
    </thead>
    <tbody>
        @if (!表示一覧.Any())
        {
            <tr><td colspan="3">該当する得意先がありません。</td></tr>
        }
        else
        {
            <Virtualize Context="得意先Model" Items="@表示一覧" OverscanCount="5" SpacerElement="tr">
                <tr>
                    <td>@得意先Model.得意先名</td>
                    <td>@得意先Model.電話番号</td>
                    <td class="text-center">
                        <button class="btn btn-sm btn-outline-primary" @onclick="() => OnEdit(得意先Model.得意先ID)">
                            修正・確認
                        </button>
                    </td>
                </tr>
            </Virtualize>
        }
    </tbody>
</table>

@code {
    [Inject]
    private NavigationManager Navigation { get; set; } = default!;
    [Inject]
    public ICustomerRepository 得意先Repository { get; set; } = default!;

    [Parameter, SupplyParameterFromQuery]
    public string keyword { get; set; } = string.Empty;

    private List<CustomerModel>? 得意先一覧;
    private List<CustomerModel> 表示一覧 = new();  // 絞り込みでフィルターされる

    protected override async Task OnInitializedAsync()
    {
        得意先一覧 = (await 得意先Repository.GetAllAsync()).ToList();
        // 初期表示
        Search();
    }

    private void Search()
    {
        // キーワードなし
        if (string.IsNullOrWhiteSpace(keyword))
        {
            表示一覧 = 得意先一覧?.ToList() ?? new();
            return;
        }

        // キーワードあり
        var k = keyword.Trim();
        表示一覧 = 得意先一覧?
            .Where(x => (x.得意先名?.Contains(k) ?? false)
                        || (x.電話番号?.Contains(k) ?? false))
            .ToList() ?? new();
    }

    private void OnAdd()
    {
        var url = string.IsNullOrEmpty(keyword)
            ? "/customers/edit/"
            : $"/customers/edit/?keyword={Uri.EscapeDataString(keyword)}";
        Navigation.NavigateTo(url);
    }

    private void OnEdit(int 得意先ID)
    {
        var url = string.IsNullOrEmpty(keyword)
            ? $"/customers/edit/{得意先ID.ToString()}"
            : $"/customers/edit/{得意先ID.ToString()}?keyword={Uri.EscapeDataString(keyword)}";
        Navigation.NavigateTo(url);
    }
}

5.2 得意先登録ページ

image.png

@page "/customers/edit"
@page "/customers/edit/{得意先ID:int}"

@using BlazorOrderApp.Models
@using BlazorOrderApp.Repositories
@attribute [Authorize(Roles = "Administrator")]
@inherits BlazorOrderApp.Components.Commons.InitialFocusComponent

<h3>得意先マスタ @((isEdit ? "修正" : "追加"))</h3>


@* Enterキーでフォーカス制御したいので、FormのOnSubmitは利用できない *@
<EditForm EditContext="@editContext">
    <DataAnnotationsValidator />

    <div class="row mb-2">
        <div class="mb-3 d-flex align-items-center">
            <label for="得意先名" class="form-label mb-0 me-2" style="width: 80px;">得意先名</label>
            <InputText id="得意先名" required
                       @bind-Value="model.得意先名"
                       class="form-control" style="width:320px;"
                       autocomplete="off" 
                       data-tab="10" />
            <ValidationMessage For="() => model.得意先名" class="text-danger" />
        </div>
        <div class="mb-3 d-flex align-items-center">
            <label for="電話番号" class="form-label mb-0 me-2" style="width: 80px;">電話番号</label>
            <InputText id="電話番号" required
                       @bind-Value="model.電話番号"
                       class="form-control" style="width:320px;"
                       autocomplete="off" 
                       data-tab="20" />
            <ValidationMessage For="() => model.電話番号" class="text-danger" />
        </div>
        <div class="mb-3 d-flex align-items-center">
            <label for="備考" class="form-label mb-0 me-2" style="width: 80px;">備考</label>
            <InputText id="備考"
                       @bind-Value="model.備考"
                       class="form-control"
                       autocomplete="off" 
                       data-tab="30" />
        </div>
    </div>

    <!-- ボタン行 -->
    <div class="mt-3 d-flex justify-content-between">
        @if (isEdit)
        {
            <button type="button" class="btn btn-danger px-5" @onclick="OnDeleteConfirm">削除</button>
        }
        else
        {
            <div style="width: 120px;"></div>
        }
        <div class="d-flex gap-3">
            <button type="button" class="btn btn-primary px-5" @onclick="OnSubmit">保存</button>
            <button type="button" class="btn btn-secondary px-5" @onclick="OnBack">戻る</button>
        </div>
    </div>
</EditForm>

@* ------------ 削除用確認ダイアログ ------------ *@
@if (showDeleteConfirm)
{
    <div class="modal fade show d-block" tabindex="-1" style="background: rgba(0,0,0,0.2);">
        <div class="modal-dialog">
            <div class="modal-content">
                <div class="modal-header"><strong>削除確認</strong></div>
                <div class="modal-body">本当に削除しますか?</div>
                <div class="modal-footer">
                    <button class="btn btn-secondary" @onclick="() => showDeleteConfirm = false">キャンセル</button>
                    <button class="btn btn-danger" @onclick="OnDelete">削除</button>
                </div>
            </div>
        </div>
    </div>
}

@code {
    [Inject]
    private NavigationManager Navigation { get; set; } = default!;
    [Inject]
    public ICustomerRepository 得意先Repository { get; set; } = default!;

    [Parameter, SupplyParameterFromQuery]
    public int? 得意先ID { get; set; }
    [Parameter, SupplyParameterFromQuery]
    public string? keyword { get; set; }

    private CustomerModel model = new();
    private EditContext editContext = default!;

    private bool isEdit => !(得意先ID == null);
    private bool showDeleteConfirm = false;

    protected override async Task OnInitializedAsync()
    {
        if (isEdit)
        {
            // 編集時:既存データ取得
            var item = await 得意先Repository.GetByIdAsync(得意先ID);
            if (item != null)
                model = item;
        }
        else
        {
            // 追加時:初期化
            model = new CustomerModel();
        }

        // OnSubmitで自前でValidationするために必要
        editContext = new EditContext(model);
    }

    private async Task OnSubmit()
    {
        // OnSubmitで自前でValidation
        // (FormのOnSubumitはエンターキーでフォーカス制御しているので使えない)
        if (!editContext.Validate()) return;

        if (isEdit)
        {
            await 得意先Repository.UpdateAsync(model);
        }
        else
        {
            await 得意先Repository.AddAsync(model);
        }
        OnBack();
    }

    private void OnBack()
    {
        var url = string.IsNullOrEmpty(keyword)
            ? "/customers"
            : $"/customers?keyword={Uri.EscapeDataString(keyword)}";
        Navigation.NavigateTo(url);
    }

    private void OnDeleteConfirm()
    {
        showDeleteConfirm = true;
    }

    private async Task OnDelete()
    {
        await 得意先Repository.DeleteAsync(model);
        showDeleteConfirm = false;
        OnBack();
    }
}

6.重要な課題と実現方法

6.1 認証管理

  • Blazor Server は HTTP(初回要求/再接続等) と SignalR(WebSocket/LongPolling) の両方で通信します
  • Cookie の発行/破棄は HTTP レスポンスの Set-Cookie ヘッダーでしか行えません
  • いったん確立された SignalR 接続中はレスポンスヘッダーを書き換えられないため、接続中のまま Blazor コンポーネント側でサインインして Cookie を発行することはできません
  • そのため、ログイン/ログアウトの実処理は Razor Pages(.cshtml)やMVC/Minimal API で行う設計が堅実です

本テンプレートでは、Razorページを利用してログイン/ログアウトを実装しています。

6.2 無操作での自動切断

image.png

  • 一定時間操作がない場合、セッションの自動切断は業務アプリでは良く求められる要件だと思います
  • ただしSignalR通信は、Cookieの有効期限が切れても自動的には切断されません
  • そのためサーバー側から定期的に放置されていないか確認し、一定時間無操作なら自動的にログアウトさせます
// ----------- Cookie認証 --------------------------------
Program.csより抜粋

// ログイン/ログアウト用のRazor Pages
builder.Services.AddRazorPages();

builder.Services
    .AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
    .AddCookie(o =>
    {
        // タイムアウトはサーバー側で管理するので余裕を持たせる
        o.ExpireTimeSpan = TimeSpan.FromHours(10);
        o.LoginPath = "/login";
        o.AccessDeniedPath = "/login";
    });
// appsettingのタイムアウト設定
builder.Services.Configure<IdleTimeoutOptions>(
    builder.Configuration.GetSection("IdleTimeout"));

// 独自認証判定(デモ用簡易版)
builder.Services.AddHttpContextAccessor();
builder.Services.AddScoped<MyAuthenticationService>();

builder.Services.AddAuthorization();
builder.Services.AddSingleton<UserActivityService>(); // 活動記録
// サーバー側でCookieセッションの定期監視
builder.Services.AddScoped<AuthenticationStateProvider, CookieRevalidatingAuthStateProvider>();
// -------------------------------------------------------
CookieRevalidatingAuthStateProvider.cs

using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.AspNetCore.Components.Server;
using Microsoft.Extensions.Options;

namespace BlazorOrderApp.Services
{
    // =============================================================
    // サーバー側で定期的にセッションが有効か監視する
    // BlazorのSignalR通信は一度開始されると、セッションがタイムアウトしても
    // 自動では切断されない。
    // =============================================================

    public sealed class CookieRevalidatingAuthStateProvider
    : RevalidatingServerAuthenticationStateProvider
    {
        private readonly UserActivityService _activity;
        private readonly IdleTimeoutOptions _options;

        public CookieRevalidatingAuthStateProvider(
            ILoggerFactory loggerFactory,
            UserActivityService activity,
            IOptions<IdleTimeoutOptions> options) : base(loggerFactory)
        {
            _activity = activity;
            _options = options.Value;
        }

        // サーバー上でセッションが有効か監視する間隔(1分毎)
        protected override TimeSpan RevalidationInterval => TimeSpan.FromMinutes(1);

        // ---------------------------
        // 定期監視イベント
        // ---------------------------
        protected override Task<bool> ValidateAuthenticationStateAsync(
            AuthenticationState state, CancellationToken token)
        {
            // この処理がflaseを返すと、Blazorはセッションが終わったと認識する
            // つまりここでflaseを返すことはlogoutと同じ
 
            // そもそもCookieセッションが無効か?
            var user = state.User;
            if (user?.Identity?.IsAuthenticated != true)
                return Task.FromResult(false);

            var name = user.Identity.Name ?? string.Empty;
            var last = _activity.GetLast(name);
            if (last is null) return Task.FromResult(true);

            // 有効なアイドル時間か判定
            var idle = DateTimeOffset.UtcNow - last.Value;
            var ok = idle < TimeSpan.FromMinutes(_options.IdleTimeoutMinutes);
            return Task.FromResult(ok);
        }
    }
}

ユーザーがブラウザ上で「キー入力/マウスクリック」を行った時、アイドル時間をリセットするようにしています

MainLayout.razorから抜粋

<div @onkeydown="OnUserAction"
     @onclick="OnUserAction">
    @Body
</div>

@code {
    private async Task OnUserAction()
    {
        var s = await AuthState.GetAuthenticationStateAsync();
        var name = s.User?.Identity?.IsAuthenticated == true ? s.User.Identity.Name : null;
        if (!string.IsNullOrEmpty(name)) Activity.Touch(name);
    }
}

6.3 ユーザー権限(ロール)によるメニュー制御

Admin権限でログイン
image.png

Test権限でログイン(マスタ管理機能が無い)
image.png

1) メニューの制御

NavMenu.razor

@* マスタ管理は管理者のみ *@
        <AuthorizeView Roles="Administrator">
            <div>
                <div class="small ms-3 mt-3 px-2 py-1 rounded" style="color: #fff; background: #234; opacity: 0.9;">
                    マスタ管理
                </div>
                <div class="nav-item px-3">
                    <NavLink class="nav-link" href="/products">
                        <span class="bi bi-product-nav-menu" aria-hidden="true"></span> 商品マスタ
                    </NavLink>
                </div>
                <div class="nav-item px-3">
                    <NavLink class="nav-link" href="/customers">
                        <span class="bi bi-customer-nav-menu" aria-hidden="true"></span> 得意先マスタ
                    </NavLink>
                </div>
                <hr class="my-2" style="opacity:0.3" />
            </div>
        </AuthorizeView>

2) 各ページのアクセス制御

  • 通常のページ(Orders.razor)
@page "/orders"
@attribute [Authorize]
  • マスタ管理のページ(Customers.razor)
@page "/customers"
@attribute [Authorize(Roles = "Administrator")]

6.4 得意先・商品のサジェスト機能

image.png

image.png

  • 見た目はどちらもおなじサジェストですが、実装は完全に別物です

👤 得意先のサジェスト

特徴 説明
利用条件 分母が少なく全件表示してもせいぜい20件程度の場合
データ取得タイミング ページのGETの時に一度だけ
サジェストを表示するタイミング フォーカスを受けたらすぐに表示

📦商品のサジェスト

特徴 説明
利用条件 分母が多く全件表示できない。一部を入力し絞り込まれた結果を表示する場合
データ取得タイミング N文字以上入力され一息ついたタイミング(0.3秒のデバウンス処理)
サジェストを表示するタイミング データ取得タイミングと同じ

6.5 サジェスト機能コンポーネント化

  • ここがBlazorの強力な要素の1つと思います
  • 汎用的な部品を独立したコンポーネント化し再利用が可能になります
SuggestTextBox.razorから抜粋

<div class="position-relative w-100">
    <InputText id="@Id"
               @bind-Value="_input"
               class="@InputClass"
               placeholder="@Placeholder"
               @onfocus="OnFocus"
               @oninput="OnInput"
               @onkeydown="OnKeyDown"
               data-tab="@DataTab"
               @onblur="OnBlur"
               autocomplete="off" />
    <div class="list-group position-absolute w-100" style="z-index:10;"
         hidden="@(SuggestList.Count == 0)"
         @onmousedown="@(() => _isListClicked = true)">
        @foreach (var item in SuggestList)
        {
            <button type="button" class="list-group-item list-group-item-action"
                    @onclick="() => On候補選択(item)">
                @item.DspOnList
            </button>
        }
    </div>
</div>

@code {
    [Inject]
    private IJSRuntime JS { get; set; } = default!;

    [Parameter] public string? Id { get; set; }

    以下省略
}

サジェストコンポーネントを利用する側のサンプル(OrderEdit.razor)

<div class="col-md-4 position-relative">
    <label for="得意先名" class="form-label">得意先名</label>
    <SuggestTextBox
        Id="得意先名"
        Value="@受注Model.得意先名"
        Placeholder="(得意先名で検索)"
        OnValueChanged="val => {
            受注Model.得意先名 = val;
            if (string.IsNullOrEmpty(val)) 受注Model.得意先ID = 0;
        }"
        StartSearchChars=0
        FetchSuggestions="Fetch得意先サジェスト"
        OnSelect="On得意先Select"
        DisplayValueSelector="@(item => item.Name)" 
        DataTab="20"
        />
    <ValidationMessage For="() => 受注Model.得意先名" class="text-danger" />
    <input type="hidden" value="@受注Model.得意先ID" />
</div>

6.6 受注明細をあらかじめ必要な行数を固定で用意

  • 技術的には1行だけ明細を用意し、追加ボタンで動的に明細を追加することも可能です
  • ただし、本テンプレートでは操作性を重視し「エンターキーでフォーカス制御」を実現してます
  • ブラウザ上で動的にエレメントが追加され、かつフォーカス制御はかなりコードが複雑になります
  • 複雑化による保守性の低下を嫌い、明細行は固定で表示するようにしてます

7.🏗️ソリューションのレイヤ分割

  • /Services:DI登録・認証サービス
  • /Models:業務モデル
  • /Repositories:Dapperリポジトリ
  • /Components/Layout:共通レイアウト
  • /Components/Pages:業務ページ
  • /Pages:ログイン/ログアウト用 Razor Pages

8.最後に

本記事では2025年の技術スタックを活用したBlazorによるwebアプリ用テンプレートを紹介しました。まだまだマイナーなBlazorですが、

  • 各業務ページを少ないC#コードで組める(データバインディング・DI・検証が効く)
  • 共通処理はコンポーネント化して再利用しやすい
  • 多くのケースで JavaScript なしに動的UIを構築できる

こうした特徴を備えたBlazorの開発者がもっと増えて欲しいなと思ってます。
この記事がその発展のお役に立てれば嬉しいです。

9
6
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
9
6

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?