WebSurfer's Home

トップ > Blog 1   |   Login
Filter by APML

Blazor WASM から ASP.NET Core Web API を呼び出し

by WebSurfer 29. June 2024 11:42

ASP.NET Core Blazor Web Assembly (WASM) からトークン (JWT) ベースの認証が必要な ASP.NET Core Web API にクロスドメインでアクセスしてデータを取得するサンプルを作ってみました。以下に作り方を備忘録として書いておきます。

結果の表示

Visual Studio 2022 のテンプレートを利用して ASP.NET Core Web API と Blazor WASM のソリューションを別々に作成します。完成後、Visual Studio 2022 から両方のプロジェクトを実行し ([デバッグ(D)]⇒[デバッグなしで開始(H)])、Blazor WASM から Web API に要求を出して応答を Blazor WASM の画面上に表示したのが上の画像です。

以下に、まず Web API アプリの作り方、次に Blazor WASM アプリの作り方を書きます。

(1) Web API アプリ

(1.1) プロジェクトの作成

元になる ASP.NET Core Web API アプリのプロジェクトは Visual Studio 2022 V17.10.3 のテンプレートで自動生成されたものを使いました。プロジェクトを作成する際「追加情報」のダイアログで「認証の種類(A)」は「なし」にします。この記事ではターゲットフレームワークは .NET 8.0 にしました。

自動生成されたプロジェクトにはサンプルのコントローラ WeatherForecastController が実装されていて、Visual Studio からプロジェクトを実行し、ブラウザから WeatherForecast を要求すると JSON 文字列が返ってきます。

これに JWT ベースの認証を実装し(即ち、トークンが無いとアクセス拒否するようにし)、さらに Blazor WASM からクロスドメインで呼び出せるようにするため CORS を実装します。

加えて、クライアントからの要求に応じてトークンを発行するための API も追加で実装します。この記事では、トークン要求の際クライアントから ID とパスワードを送信してもらい、それらが既存の ASP.NET Core Identity で有効であることを確認してからトークンを返すようにします。無効の場合は HTTP 401 Unauthorized 応答を返します。

(1.2) NuGet パッケージのインストール

下の画像の赤枠で囲んだ Microsoft.AspNetCore.Authentication.JwtBearer を NuGet からインストールします。

NuGet パッケージのインストール

青枠で囲んだものは、上に述べたトークン発行の際の既存の ASP.NET Core Identity によるユーザー認証を行うために必要です。ASP.NET Core Identity を使わない場合(例えば、ユーザー認証なしで無条件にトークンを返すようにする場合)は必要ありません。

(1.3) JWT 認証スキーマを登録

自動生成された Program.cs に、AddAuthentication メソッドを使って JWT 認証スキーマを登録するコードを追加します。加えて、認証を有効にするため app.UseAuthentication(); も追加します。

app.UseAuthentication(); は既存のコードの app.UseAuthorization(); の前にする必要があるので注意してください。

具体的には以下のコードで「JWT ベースの認証を行うため追加」とコメントしたコードを追加します。

using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Identity;
using Microsoft.IdentityModel.Tokens;
using System.Text;
using WebApi.Data;
using Microsoft.EntityFrameworkCore;

namespace WebApi
{
    public class Program
    {
        public static void Main(string[] args)
        {
            var builder = WebApplication.CreateBuilder(args);

            // JWT ベースの認証を行うため追加
            builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
                .AddJwtBearer(options =>
                {
                    options.TokenValidationParameters = new TokenValidationParameters
                    {
                        ValidateIssuer = true,
                        ValidateAudience = true,
                        ValidateLifetime = true,
                        ValidateIssuerSigningKey = true,
                        ValidIssuer = builder.Configuration["Jwt:Issuer"],
                        ValidAudience = builder.Configuration["Jwt:Issuer"],
                        IssuerSigningKey = new SymmetricSecurityKey(
                            Encoding.UTF8.GetBytes(builder.Configuration["Jwt:Key"]!))
                    };
                });

            // ・・・中略・・・

            // JWT ベースの認証を行うため追加
            app.UseAuthentication();

            //・・・後略・・・

(1.4) Key と Issuer を appsettings.json に登録

上の (1.3) コードでは Key と Issuer を appsettings.json ファイルより取得するようにしていますので、以下のように "Jwt" 要素を追加します。

{

  ・・・中略・・・

  "AllowedHosts": "*",
  "Jwt": {
    "Key": "veryVerySecretKeyWhichMustBeLongerThan32",
    "Issuer": "https://localhost:44366"
  }
}

Key はパスワードのようなもので任意の文字列を設定できます。32 文字以上にしないとエラーになるので注意してください。.NET Core 3.1 時代は 16 文字以上で良かったのですが、いつからか 32 文字以上に変わったらしいです。Issuer はサービスを行う URL にします。

(1.5) [Authorize] 属性を付与

自動生成された WeatherForecastController コントローラの Get() メソッドに [Authorize] 属性を付与します。

ここまでの設定で JWT トークンベースのアクセス制限の実装は完了しており、トークンなしで WeatherForecastController コントローラの Get() メソッドを要求すると HTTP 401 Unauthorized 応答が返ってくるはずです。

(1.6) トークンを発行する API を実装

クライアントから送信されてきた ID とパスワードでユーザー認証を行った上でトークンを発行する API を実装します。以下のコードでは、UserManager<IdentityUser> オブジェクトへの参照を DI によって取得し、それを使って既存の ASP.NET Core Identity から情報を取得してユーザー認証に用いています。

using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.IdentityModel.Tokens;
using System.IdentityModel.Tokens.Jwt;
using System.Text;

namespace WebApi.Controllers
{
    [Route("api/[controller]")]
    [ApiController]
    public class TokenController : ControllerBase
    {
        private readonly IConfiguration _config;
        private readonly UserManager<IdentityUser> _userManager;

        public TokenController(IConfiguration config,
                               UserManager<IdentityUser> userManager)
        {
            _config = config;
            _userManager = userManager;
        }

        [AllowAnonymous]
        [HttpPost]
        public async Task<IActionResult> CreateToken(LoginModel login)
        {
            string? id = login.Username;
            string? pw = login.Password;
            IActionResult response = Unauthorized();

            if (!string.IsNullOrEmpty(id) && !string.IsNullOrEmpty(pw))
            {
                var user = await _userManager.FindByNameAsync(id);
                if (user != null && 
                    await _userManager.CheckPasswordAsync(user, pw))
                {
                    var tokenString = BuildToken();
                    response = Ok(new { token = tokenString });
                }
            }

            return response;
        }


        private string BuildToken()
        {
            var key = new SymmetricSecurityKey(
                Encoding.UTF8.GetBytes(_config["Jwt:Key"]!));

            var creds = new SigningCredentials(
                key, SecurityAlgorithms.HmacSha256);

            var token = new JwtSecurityToken(
                issuer: _config["Jwt:Issuer"],
                audience: _config["Jwt:Issuer"],
                claims: null,
                notBefore: null,
                expires: DateTime.Now.AddMinutes(30),
                signingCredentials: creds);

            return new JwtSecurityTokenHandler().WriteToken(token);
        }
    }

    public class LoginModel
    {
        public string? Username { get; set; }
        public string? Password { get; set; }
    }
}

既存の ASP.NET Core Identity から情報を取得してユーザー認証を行うためには上記以外にも以下の (a) ~ (d) の追加が必要です。

ただし、ユーザー認証など面倒なことはしないで、CreateToken メソッドが呼ばれたら無条件にトークンを発行して返せばよいという場合は不要です。上のコードの UserManager<IdentityUser> オブジェクトの DI を行う部分も不要です。

(a) 上の (1.2) の画像で青枠で囲んだ NuGet パッケージのインストール。

(b) IdentityDbContext を継承した ApplicationDbContext クラスを追加。Data フォルダを作ってそれにクラスファイルとして実装します。

using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;

namespace WebApi.Data
{
    public class ApplicationDbContext : IdentityDbContext
    {
        public ApplicationDbContext(
            DbContextOptions<ApplicationDbContext> options)
            : base(options)
        {
        }
    }
}

(c) appsettings.json に ASP.NET Core Identity が使う既存の SQL Server DB への接続文字列を追加。

(d) Program.cs に以下の「// 追加」とコメントしたコードを追加。これらは上の「(1.3) JWT 認証スキーマを登録」に書いたコード builder.Services.AddAuthentication(...); より前に追加する必要があるので注意してください。

using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Identity;
using Microsoft.IdentityModel.Tokens;
using System.Text;
using WebApi.Data;
using Microsoft.EntityFrameworkCore;

namespace WebApi
{
    public class Program
    {
        public static void Main(string[] args)
        {
            var builder = WebApplication.CreateBuilder(args);

            // 追加
            builder.Services.AddDbContext<ApplicationDbContext>(options =>
                options.UseSqlServer(
                    builder.Configuration.GetConnectionString(
                        "MvcCoreIdentityContextConnection")));

            // 追加
            builder.Services.AddDefaultIdentity<IdentityUser>()
                            .AddEntityFrameworkStores<ApplicationDbContext>();

            //・・・後略・・・

(1.7) CORS 機能の実装

Blazor WASM からクロスドメインで Web API を呼び出すため、Web API アプリに CORS 機能を実装します。

具体的には、Program.cs に以下のコードを追加します。プリフライトリクエストが行われますので AllowAnyHeader() と AllowAnyMethod() が必要です。AllowCredentials() はトークンベースの認証の場合は不要のようです。

using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Identity;
using Microsoft.IdentityModel.Tokens;
using System.Text;
using WebApi.Data;
using Microsoft.EntityFrameworkCore;

namespace WebApi
{
    public class Program
    {
        public static void Main(string[] args)
        {
            var builder = WebApplication.CreateBuilder(args);

            // 追加
            var MyAllowSpecificOrigins = "_myAllowSpecificOrigins";
            builder.Services.AddCors(options =>
            {
                options.AddPolicy(name: MyAllowSpecificOrigins,
                                  policy =>
                                  {
                                      policy.AllowAnyOrigin()
                                            .AllowAnyHeader()
                                            .AllowAnyMethod();
                                  });
            });

            // ・・・中略・・・

            // 追加 
            app.UseCors(MyAllowSpecificOrigins);

            //・・・後略・・・

(2) Blazor WASM アプリ

(2.1) プロジェクトの作成

Visual Studio 2022 の新しいプロジェクトの作成で「Blazor WabAssembly アプリ」のテンプレート (Blazor Web App を選ばないよう注意)を使って自動生成されたものを使います。「追加情報」のダイアログで「認証の種類(A)」は「なし」にします。この記事ではターゲットフレームワークは .NET 8.0 にしました。

(2.2) Weather.razor の修正

自動生成されたプロジェクトの Weather.razor には、wwwroot 下の json ファイル weather.json を要求して、応答の JSON 文字列をデシリアライズして表示するコードが含まれています。

その部分のコードを、Web API からトークンを取得した後、トークンを要求ヘッダに含めて送信し、応答として返されたデータを表示するように変更します。

変更するのは @code ブロックのみで、以下の通りとなります。

@code {
    private WeatherForecast[]? forecasts;

    protected override async Task OnInitializedAsync()
    {
        // 以下はテンプレートで自動生成されたコードに含まれているもの
        // これを下のように書き換える
        // forecasts = await Http.GetFromJsonAsync<WeatherForecast[]>("sample-data/weather.json");

        var tokenUrl = "https://localhost:44366/api/token";
        var forecastUrl = "https://localhost:44366/WeatherForecast";

        // 送信する ID とパスワード。既存の ASP.NET Core Identity で有効なもの
        var credentials = new {
            Username = "oz@mail.example.com",
            Password = "myPassword"
        };

        // ID とパスワードを送信してトークンを取得
        // content-type: application/json; charset=utf-8 は自動的に
        // ヘッダに付与される
        using var tokenResponse = await Http.PostAsJsonAsync(tokenUrl, credentials);
        var jwt = await tokenResponse.Content.ReadFromJsonAsync<JWT>();

        if (jwt != null && !string.IsNullOrEmpty(jwt.Token))
        {
            // 取得したトークンを Authorization ヘッダに含めて GET 要求
            var request = new HttpRequestMessage(HttpMethod.Get, forecastUrl);
            request.Headers.Authorization = 
                new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", jwt.Token);
            using var forecastResponse = await Http.SendAsync(request);
            forecasts = await forecastResponse.Content.ReadFromJsonAsync<WeatherForecast[]>();
        }
    }

    // 追加
    public class JWT
    {
        public string? Token { get; set; }
    }

    public class WeatherForecast
    {
        public DateOnly Date { get; set; }

        public int TemperatureC { get; set; }

        public string? Summary { get; set; }

        public int TemperatureF => 32 + (int)(TemperatureC / 0.5556);
    }
}

デスクトップアプリで使う HttpClient の場合とほぼ同じコードになりました。。Blazor WASM のクライアント側はブラウザなので、HttpClient は動くはずはなく、XMLHttpRequest とか fetch などを使った JavaScript のコードに変換されてブラウザに送られたコードが動いているのではないかと思います。

ブラウザからのクロスドメインでの要求で、かつシンプルなリクエストとはならないので、CORS 対応のためのプリフライトリクエストが必要なヘッダ情報を含めて送信されます。Fiddler で要求・応答をキャプチャするとそのあたりのことが分かります。

Fiddler で要求・応答をキャプチャ

Tags: , , , ,

CORE

ASP.NET Core MVC ページャー付き一覧画面から編集後同じページに戻る

by WebSurfer 23. June 2024 19:13

ASP.NET Core MVC アプリのレコード一覧画面にページング機能を実装し、例えば、下の画像で言うと検索やソートを行ってからページ 2 を表示し、Edit リンクをクリックして編集画面に遷移し、データベースの UPDATE 完了で元の一覧画面にリダイレクトされた際、同じ検索・ソート条件で同じページ 2 が表示されるようにする機能を実装してみました。

ページャー付き Index

ベースとなるアプリは、Microsoft のチュートリアル「チュートリアル: 並べ替え、フィルター処理、ページングを追加する - ASP.NET MVC と EF Core」です。ただし、データベースは Microsoft のサンプルデータベース Northwind の Products テーブルとし、ページャーを先の記事「ASP.NET Core MVC 用ページャー」のように変更しました。

この記事ではターゲットフレームワークは .NET 8.0 としています。

Microsoft のチュートリアル通り、アクションメソッドで当該ページ部分のレコードのみ切り取って PaginatedList<Product> オブジェクトを作成し、それを View に Model として渡すようにします。

PaginatedList<Product> オブジェクトから PageIndex プロパティで現在のページ番号を取得できます。そのページ番号を「一覧画面」⇒「編集画面」⇒「一覧画面」と遷移していく際に渡していきます。

加えて、一覧画面にはソート、検索の機能を実装していますので、現在のソート・検索情報も「一覧画面」⇒「編集画面」⇒「一覧画面」と遷移していく際に渡していきます。

そうして最初の「一覧画面」のページ・ソート・検索の条件と同じ条件で最後の「一覧画面」を表示すれば要件を満たすことができます。

その手順をもう少し詳しく書くと以下の通りです。

  1. 一覧画面から編集画面に遷移するには、上の画像にある[Edit]リンクボタンをクリックして編集用アクションメソッドを要求します。その際、pageIndex, sortInfo, filterInfo という名前のクエリ文字列で現在のページ・ソート・検索条件の情報を渡せるように View の既存の a タグヘルパーに設定します。View ではページ番号は Model.PageIndex で取得できます。ソート・検索条件は既存のコードで ViewData で渡されているのでそれを使います。
  2. 編集用アクションメソッドの引数 pageIndex, sortInfo, filterInfo にクエリ文字列からページ・ソート・検索情報が渡されるので、それらを取得してから ViewBag を使って編集画面用の View に渡します。
  3. 編集画面の View の form タグ内に隠しフィールドを 3 つ追加し、それに ViewBag で受け取ったページ・ソート・検索情報を保存します。
  4. 編集画面でユーザーが編集を完了し[Save]ボタンをクリックすると [HttpPost] 属性が付与された方の編集用のアクションメソッドが呼び出されます。その際、隠しフィールドに保存されたページ・ソート・検索情報も一緒に送信されてきます。
  5. 編集画面のアクションメソッドでデータベースの UPDATE が完了すると一覧画面にリダイレクトされるので、クエリ文字列でページ・ソート・検索情報を渡せるように、RedirectToAction メソッドの第 2 引数に new { pageNumber = pageIndex, ... } というようにパラメータを追加します。
  6. 一覧画面がリダイレクトにより GET 要求されますが、その際クエリ文字列でページ・ソート・検索情報が指定されるので、指定された条件で一覧画面が表示されます。

以下にサンプルコードを載せておきます。

コンテキストクラス、エンティティクラス

Microsoft のサンプルデータベース Northwind からリバースエンジニアリングで生成したものを利用しました。ここでの話にはほとんど関係ないのですが、参考に自動生成されたエンティティクラス Product のコードを以下に載せておきます。

using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using Microsoft.EntityFrameworkCore;

namespace MvcNet8App.Models;

[Index("CategoryId", Name = "CategoriesProducts")]
[Index("CategoryId", Name = "CategoryID")]
[Index("ProductName", Name = "ProductName")]
[Index("SupplierId", Name = "SupplierID")]
[Index("SupplierId", Name = "SuppliersProducts")]
public partial class Product
{
    [Key]
    [Column("ProductID")]
    public int ProductId { get; set; }

    [StringLength(40)]
    public string ProductName { get; set; } = null!;

    [Column("SupplierID")]
    public int? SupplierId { get; set; }

    [Column("CategoryID")]
    public int? CategoryId { get; set; }

    [StringLength(20)]
    public string? QuantityPerUnit { get; set; }

    [Column(TypeName = "money")]
    public decimal? UnitPrice { get; set; }

    public short? UnitsInStock { get; set; }

    public short? UnitsOnOrder { get; set; }

    public short? ReorderLevel { get; set; }

    public bool Discontinued { get; set; }

    [ForeignKey("CategoryId")]
    [InverseProperty("Products")]
    public virtual Category? Category { get; set; }

    [InverseProperty("Product")]
    public virtual ICollection<OrderDetail> OrderDetails { get; set; } = 
        new List<OrderDetail>();

    [ForeignKey("SupplierId")]
    [InverseProperty("Products")]
    public virtual Supplier? Supplier { get; set; }
}

PaginatedList.cs

上に紹介した Microsoft のチュートリアルのコードと同じですが、少しコメントを加えて以下にアップしておきます。

このクラスがページングの中核を担うもので、コントローラーが生成した IQueryable<Product> オブジェクトを CreateAsync メソッドで受け取って、Skip, Take メソッドでページに表示する部分のみをデータベースから取得し、PaginatedList<Product> オブジェクトを生成して戻り値として返すようにしています。

using Microsoft.EntityFrameworkCore;

namespace MvcNet8App.Services
{
    public class PaginatedList<T> : List<T>
    {
        // 表示するページのページ番号
        public int PageIndex { get; private set; }

        // ページ総数
        public int TotalPages { get; private set; }

        // コンストラクタ。下の CreateAsync メソッドから呼ばれる
        public PaginatedList(List<T> items,
                             int count,
                             int pageIndex,
                             int pageSize)
        {
            PageIndex = pageIndex;
            TotalPages = (int)Math.Ceiling(count / (double)pageSize);

            this.AddRange(items);
        }

        // 表示するページの前にページがあるか?
        public bool HasPreviousPage
        {
            get
            {
                return (PageIndex > 1);
            }
        }

        // 表示するページの後にページがあるか?
        public bool HasNextPage
        {
            get
            {
                return (PageIndex < TotalPages);
            }
        }

        // 下の静的メソッドがコントローラーから呼ばれて戻り値がモデルとして
        // ビューに渡される。引数の pageSize は 1 ページに表示するレコード
        // 数でコントローラーから渡される
        public static async Task<PaginatedList<T>> CreateAsync(IQueryable<T> source,
                                                               int pageIndex,
                                                               int pageSize)
        {
            var count = await source.CountAsync();
            var items = await source.Skip((pageIndex - 1) * pageSize)
                                    .Take(pageSize)
                                    .ToListAsync();

            return new PaginatedList<T>(items, count, pageIndex, pageSize);
        }
    }
}

Controller / Action Method

ベースは Visual Studio のスキャフォールディング機能を使って自動生成させたものです。その Index, Edit アクションメソッドをコピーして名前をそれぞれ PagedIndex, PagedEdit に変え、それに手を加えてページング機能を実装したものです。下に載せたコードでは PagedIndex, PagedEdit 以外は省略していますので注意してください。

using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Rendering;
using Microsoft.EntityFrameworkCore;
using MvcNet8App.Data;
using MvcNet8App.Models;
using MvcNet8App.Services;

namespace MvcNet8App.Controllers
{
    public class ProductsController : Controller
    {
        private readonly NorthwindContext _context;

        public ProductsController(NorthwindContext context)
        {
            _context = context;
        }

        public async Task<IActionResult> PagedIndex(string? sortOrder,
                                                    string? currentFilter,
                                                    string? searchString,
                                                    int? pageNumber)
        {
            ViewData["CurrentSort"] = sortOrder;
            ViewData["NameSortParm"] = String.IsNullOrEmpty(sortOrder) ? "name_desc" : "";
            ViewData["PriceSortParm"] = sortOrder == "Price" ? "price_desc" : "Price";

            if (searchString != null)
            {
                pageNumber = 1;
            }
            else
            {
                searchString = currentFilter;
            }

            ViewData["CurrentFilter"] = searchString;

            // Include(p => p.Category) を使ったのは CategoryName を表示するため
            var products = _context.Products
                                   .Include(p => p.Category)
                                   .Select(p => p);

            // 検索条件の設定
            if (!String.IsNullOrEmpty(searchString))
            {
                products = products.Where(p => p.ProductName.Contains(searchString));
            }

            // ソート条件の設定
            // 条件は Name 昇順、Name 降順、Price 昇順、Price 降順のどれか一つ
            // デフォルトは Name 昇順
            switch (sortOrder)
            {
                case "name_desc":
                    products = products.OrderByDescending(p => p.ProductName);
                    break;
                case "Price":
                    products = products.OrderBy(p => p.UnitPrice);
                    break;
                case "price_desc":
                    products = products.OrderByDescending(p => p.UnitPrice);
                    break;
                default:
                    products = products.OrderBy(p => p.ProductName);
                    break;
            }

            int pageSize = 5;   // 1 ページに表示するレコードの数

            // 第 1 引数は検索・ソート条件込みの IQueryable<Products>
            // それから pageNumber で指定されたページを切り取った
            // PaginatedList<Product> を取得して View に渡す
            return View(await PaginatedList<Product>
                              .CreateAsync(products.AsNoTracking(),
                                           pageNumber ?? 1,
                                           pageSize));
        }


        // 自動生成された Edit メソッドをコピーして以下のように手を加えた
        // ・名前を Edit ⇒ PagedEdit に変更
        // ・クエリ文字列で渡される現在のページ、ソート、検索の条件を取得
        //  する引数 pageIndex, sortInfo, filterInfo を追加
        // ・それらの情報を View に渡すため ViewBag を追加。View ではそれら
        //  を隠しフィールドに保存する
        public async Task<IActionResult> PagedEdit(int? id, 
                                                   int? pageIndex,
                                                   string? sortInfo,
                                                   string? filterInfo)
        {
            if (id == null)
            {
                return NotFound();
            }

            var product = await _context.Products.FindAsync(id);
            if (product == null)
            {
                return NotFound();
            }

            // View に現在のページ、ソート、検索の条件を渡すための ViewBag
            ViewBag.PageIndex = pageIndex ?? 1;
            ViewBag.SoreOrder = sortInfo;
            ViewBag.CurrentFilter = filterInfo;

            ViewData["CategoryId"] = new SelectList(_context.Categories, 
                                                    "CategoryId", 
                                                    "CategoryName", 
                                                    product.CategoryId);

            ViewData["SupplierId"] = new SelectList(_context.Suppliers, 
                                                    "SupplierId",
                                                    "CompanyName", 
                                                    product.SupplierId);
            return View(product);
        }

        // 自動生成された Edit メソッドをコピーして以下のように手を加えた
        // ・名前を Edit ⇒ PagedEdit に変更
        // ・クエリ文字列で渡される現在のページ、ソート、検索の条件を取得
        //  する引数 pageIndex, sortInfo, filterInfo を追加
        // ・リダイレクトの際クエリ文字列でページ番号を渡せるよう第 2 引数
        //  に new { pageNumber = pageIndex, sortOrder = sortInfo,
        //  currentFilter = filterInfo } を追加
        [HttpPost]
        [ValidateAntiForgeryToken]
        public async Task<IActionResult> PagedEdit(int id, 
            [Bind("ProductId,ProductName,SupplierId,CategoryId," + 
            "QuantityPerUnit,UnitPrice,UnitsInStock,UnitsOnOrder," + 
            "ReorderLevel,Discontinued")] Product product, 
            int? pageIndex, 
            string? sortInfo, 
            string? filterInfo)
        {
            if (id != product.ProductId)
            {
                return NotFound();
            }

            if (ModelState.IsValid)
            {
                try
                {
                    _context.Update(product);
                    await _context.SaveChangesAsync();
                }
                catch (DbUpdateConcurrencyException)
                {
                    if (!ProductExists(product.ProductId))
                    {
                        return NotFound();
                    }
                    else
                    {
                        throw;
                    }
                }

                // リダイレクトの際クエリ文字列でページ、ソート、検索情報
                // を渡せるよう第 2 引数 に new { pageNumber = pageIndex,
                // sortOrder = sortInfo, currentFilter = filterInfo } を追加
                return RedirectToAction(nameof(PagedIndex),
                                        new { pageNumber = pageIndex,
                                              sortOrder = sortInfo,
                                              currentFilter = filterInfo});
            }

            ViewData["CategoryId"] = new SelectList(_context.Categories, 
                                                    "CategoryId", 
                                                    "CategoryName", 
                                                    product.CategoryId);

            ViewData["SupplierId"] = new SelectList(_context.Suppliers, 
                                                    "SupplierId",
                                                    "CompanyName", 
                                                    product.SupplierId);

            return View(product);
        }
    }
}

一覧画面用の View

上に書いたように、[Edit]リンクボタン用の a タグヘルパーに asp-route を設定し、クエリ文字列でページ、ソート、検索条件を PagedEdit アクションメソッドに渡せるようにしているところに注目してください。table 要素の下にページャーも実装しています。

@model MvcNet8App.Services.PaginatedList<Product>

@{
    ViewData["Title"] = "PagedIndex";
}

<h1>PagedIndex</h1>

<p>
    <a asp-action="Create">Create New</a>
</p>

<form asp-action="PagedIndex" method="get">
    <div class="form-actions no-color">
        <p>
            Find by Product Name:
            <input type="text" name="SearchString"
                   value="@ViewData["CurrentFilter"]" />
            <input type="submit" value="Search" class="btn btn-primary" /> |
            <a asp-action="PagedIndex">Back to Full List</a>
        </p>
    </div>
</form>

<table class="table">
    <thead>
        <tr>
            <th>
                <a asp-action="PagedIndex"
                   asp-route-sortOrder="@ViewData["NameSortParm"]"
                   asp-route-currentFilter="@ViewData["CurrentFilter"]">
                    Product Name
                </a>
            </th>
            <th>
                Category
            </th>
            <th>
                <a asp-action="PagedIndex"
                   asp-route-sortOrder="@ViewData["PriceSortParm"]"
                   asp-route-currentFilter="@ViewData["CurrentFilter"]">
                    Unit Price
                </a>
            </th>
            <th>
                Discontinued
            </th>

            <th></th>
        </tr>
    </thead>
    <tbody>
        @foreach (var item in Model)
        {
            <tr>
                <td>
                    @Html.DisplayFor(modelItem => item.ProductName)
                </td>
                <td>
                    @Html.DisplayFor(modelItem => item.Category!.CategoryName)
                </td>
                <td>
                    @Html.DisplayFor(modelItem => item.UnitPrice)
                </td>
                <td>
                    @Html.DisplayFor(modelItem => item.Discontinued)
                </td>

                <td>
                    @* [Edit]リンクボタン用の a tag helper のパラメータに
                    asp-route-pageIndex="@Model.PageIndex"
                    sp-route-sortInfo="@ViewData["CurrentSort"]"
                    asp-route-filterInfo="@ViewData["CurrentFilter"]"
                    を追加し、クエリ文字列でページ、ソート、検索条件を Edit アクション
                    メソッドに渡せるように設定 *@
                    <a asp-action="PagedEdit" 
                       asp-route-id="@item.ProductId"
                       asp-route-pageIndex="@Model.PageIndex"
                       asp-route-sortInfo="@ViewData["CurrentSort"]"
                       asp-route-filterInfo="@ViewData["CurrentFilter"]">Edit</a> |
                    <a asp-action="Details" asp-route-id="@item.ProductId">Details</a> |
                    <a asp-action="Delete" asp-route-id="@item.ProductId">Delete</a>
                </td>
            </tr>
        }
    </tbody>
</table>

@{
    // buttonCount はページャーの First Prev 1 2 3 ... n Next Last の
    // 1 ~ n 部分のボタン数 n。奇数に設定してください
    int buttonCount = 7;
}

<span>Page @Model.PageIndex of @Model.TotalPages</span>

<br />

<nav aria-label="Page navigation">
    <ul class="pagination">
        @if (Model.HasPreviousPage)
        {
            <li class="page-item">
                <a asp-action="PagedIndex"
                   asp-route-sortOrder="@ViewData["CurrentSort"]"
                   asp-route-pageNumber="1"
                   asp-route-currentFilter="@ViewData["CurrentFilter"]"
                   class="page-link">
                    First
                </a>
            </li>
        }

        @if (Model.HasPreviousPage)
        {
            <li class="page-item">
                <a asp-action="PagedIndex"
                   asp-route-sortOrder="@ViewData["CurrentSort"]"
                   asp-route-pageNumber="@(Model.PageIndex - 1)"
                   asp-route-currentFilter="@ViewData["CurrentFilter"]"
                   class="page-link">
                    Prev
                </a>
            </li>
        }

        @{
            int startPage;
            int stopPage;

            if (Model.TotalPages > buttonCount)
            {
                if (Model.PageIndex <= buttonCount / 2 + 1)
                {
                    startPage = 1;
                    stopPage = buttonCount;
                }
                else if (Model.PageIndex < (Model.TotalPages - buttonCount / 2))
                {
                    startPage = Model.PageIndex - buttonCount / 2;
                    stopPage = Model.PageIndex + buttonCount / 2;
                }
                else
                {
                    startPage = Model.TotalPages - buttonCount + 1;
                    stopPage = Model.TotalPages;
                }
            }
            else
            {
                startPage = 1;
                stopPage = Model.TotalPages;
            }

            for (int i = startPage; i <= stopPage; i++)
            {
                if (Model.PageIndex == i)
                {
                    <li class="page-item active">
                        <span class="page-link">@i</span>
                    </li>
                }
                else
                {
                    <li class="page-item">
                        <a asp-action="PagedIndex"
                           asp-route-sortOrder="@ViewData["CurrentSort"]"
                           asp-route-pageNumber="@i"
                           asp-route-currentFilter="@ViewData["CurrentFilter"]"
                           class="page-link">
                            @i
                        </a>
                    </li>
                }
            }
        }

        @if (Model.HasNextPage)
        {
            <li class="page-item">
                <a asp-action="PagedIndex"
                   asp-route-sortOrder="@ViewData["CurrentSort"]"
                   asp-route-pageNumber="@(Model.PageIndex + 1)"
                   asp-route-currentFilter="@ViewData["CurrentFilter"]"
                   class="page-link">
                    Next
                </a>
            </li>
        }

        @if (Model.HasNextPage)
        {
            <li class="page-item">
                <a asp-action="PagedIndex"
                   asp-route-sortOrder="@ViewData["CurrentSort"]"
                   asp-route-pageNumber="@(Model.TotalPages)"
                   asp-route-currentFilter="@ViewData["CurrentFilter"]"
                   class="page-link">
                    Last
                </a>
            </li>
        }
    </ul>
</nav>

編集画面用の View

スキャフォールディングで自動生成されたものに、現在のページ・ソート・検索情報を保存する隠しフィールドを追加し、編集を途中で止めて一覧ページに戻る際同じページ・ソート・検索条件の一覧画面を表示するためのクエリ文字列を a タグヘルパーに追加しました。

@model MvcNet8App.Models.Product

@{
    ViewData["Title"] = "PagedEdit";
}

<h1>PagedEdit</h1>

<h4>Product</h4>
<hr />
<div class="row">
    <div class="col-md-4">
        <form asp-action="PagedEdit">
            <div asp-validation-summary="ModelOnly" class="text-danger"></div>
            <input type="hidden" asp-for="ProductId" />
            <div class="form-group">
                <label asp-for="ProductName" class="control-label"></label>
                <input asp-for="ProductName" class="form-control" />
                <span asp-validation-for="ProductName" class="text-danger"></span>
            </div>
            <div class="form-group">
                <label asp-for="SupplierId" class="control-label"></label>
                <select asp-for="SupplierId" class="form-control" 
                  asp-items="ViewBag.SupplierId"></select>
                <span asp-validation-for="SupplierId" class="text-danger"></span>
            </div>
            <div class="form-group">
                <label asp-for="CategoryId" class="control-label"></label>
                <select asp-for="CategoryId" class="form-control" 
                  asp-items="ViewBag.CategoryId"></select>
                <span asp-validation-for="CategoryId" class="text-danger"></span>
            </div>
            <div class="form-group">
                <label asp-for="QuantityPerUnit" class="control-label"></label>
                <input asp-for="QuantityPerUnit" class="form-control" />
                <span asp-validation-for="QuantityPerUnit" class="text-danger"></span>
            </div>
            <div class="form-group">
                <label asp-for="UnitPrice" class="control-label"></label>
                <input asp-for="UnitPrice" class="form-control" />
                <span asp-validation-for="UnitPrice" class="text-danger"></span>
            </div>
            <div class="form-group">
                <label asp-for="UnitsInStock" class="control-label"></label>
                <input asp-for="UnitsInStock" class="form-control" />
                <span asp-validation-for="UnitsInStock" class="text-danger"></span>
            </div>
            <div class="form-group">
                <label asp-for="UnitsOnOrder" class="control-label"></label>
                <input asp-for="UnitsOnOrder" class="form-control" />
                <span asp-validation-for="UnitsOnOrder" class="text-danger"></span>
            </div>
            <div class="form-group">
                <label asp-for="ReorderLevel" class="control-label"></label>
                <input asp-for="ReorderLevel" class="form-control" />
                <span asp-validation-for="ReorderLevel" class="text-danger"></span>
            </div>
            <div class="form-group form-check">
                <label class="form-check-label">
                    <input class="form-check-input" asp-for="Discontinued" /> 
                    @Html.DisplayNameFor(model => model.Discontinued)
                </label>
            </div>
            <div class="form-group">
                <input type="submit" value="Save" class="btn btn-primary" />
            </div>

            @* 隠しフィールドにページ番号・ソート・検索の条件をを保存するため追加 *@
            <input type="hidden" name="pageIndex" value="@ViewBag.PageIndex" />
            <input type="hidden" name="sortInfo" value="@ViewBag.SoreOrder" />
            <input type="hidden" name="filterInfo" value="@ViewBag.CurrentFilter" />
        </form>
    </div>
</div>

<div>
    @* 編集を途中で止めて一覧ページに戻る際、同じページに戻るため
    asp-route-pageNumber="@ViewBag.PageIndex" 
    asp-route-sortOrder="@ViewBag.SoreOrder"
    asp-route-currentFilter="@ViewBag.CurrentFilter"
    を追加*@
    <a asp-action="PagedIndex" 
       asp-route-pageNumber="@ViewBag.PageIndex"
       asp-route-sortOrder="@ViewBag.SoreOrder"
       asp-route-currentFilter="@ViewBag.CurrentFilter">Back to List</a>
</div>

@section Scripts {
    @{
        await Html.RenderPartialAsync("_ValidationScriptsPartial");
    }
}

Tags: , , , ,

Paging

ASP.NET Core MVC で三点リーダー表示

by WebSurfer 19. May 2024 13:21

ASP.NET Core MVC で表示するテーブルで css の overflow: hidden と text-overflow: ellipsis を使って文字列の長さ制限するとともに末尾に三点リーダーを表示する例を紹介します。下の画像がその例です。ターゲットフレームワークは .NET 8.0、ブラウザは Chrome、フォントはメイリオ、サイズは 16px です。

文字列の長さ制限、三点リーダー表示

先の記事「文字列の長さ制限、三点リーダー表示」では、ASP.NET Web Forms アプリの GridView の例を紹介しました。

その記事と同様に、description 列の文字列を div 要素に入れて、それに以下の css を適用しています。

<style type="text/css">
    div.style1
    {
        width: 320px;
        overflow: hidden;
        white-space: nowrap;
        text-overflow: ellipsis;
    }
</style>

html と css の機能なのでアプリが ASP.NET Web Forms から ASP.NET Core MVC に変わっても関係ないと思われるかもしれませんが、ブラウザに送信される文字の形式が異なります。結果は同じになりますが。

参考までに以下に違いを書いておきます。

先の記事の Web Forms の例では、コードビハインドで C# のコードで設定した文字列は UTF-8 に変換されてブラウザに送信されています。文字列は Literal コントロールの Text プロパティに設定したのでエスケープはされません。(なので、先の記事では < > & という文字は C# のコードでは &lt; &gt; &amp; としています)

一方、この記事の ASP.NET Core MVC の場合は、< > & という文字は ASP.NET がエスケープして &lt; &gt; &amp; という文字列としてブラウザに送信されます。また、日本語や絵文字(たぶん非 ASCII 文字すべて)は数値文字参照 &#xNNNN; 形式(NNNN は 16 進 Unicode)に変換されてブラウザに送信されます。以下の画像を見てください。

html ソース

ちなみに、例えば、🍎 は上の画像では &#x1F34E; に該当します。🍎 を IME パッドで見ると Unicode: U+1F34E となっています。

🍎 のコード

一番上の画像の結果から分かるように、サーバーからブラウザに送信される各文字の形式は関係なく、ブラウザ上に表示された文字列の長さで制限がかかり、css の width: 320px で指定された幅いっぱいに三点リーダを含めて表示されています。

フォントはメイリオ、サイズは 16px ですが、MS Gothic などの等幅フォントを使った場合も、フォントサイズを変えた場合も、ブラウザ上に表示される文字列の長さで制限がかかるのは同じです。

三点リーダーを表示する text-overflow:ellipsis はもともと IE の独自拡張だそうですが、最近は他のブラウザでも取り入れられているようです。Windows 10 の Chrome 125.0.6422.61, Edge 125.0.2535.51, Firefox 126.0, Opera 110.0.5130.23 で試してみましたが、同じ結果が得られました。

参考に、上の画像を表示するのに使った ASP.NET Core MVC アプリのコードを載せておきます。

Model

namespace MvcNet8App2.Models
{
    public class Ellipsis
    {
        public int Id { get; set; }
        public string Description { get; set; } = null!;
    }
}

View

@model IEnumerable<MvcNet8App2.Models.Ellipsis>

@{
    ViewData["Title"] = "Ellipsis";
}

<style type="text/css">
    body {
        font-family: "メイリオ";
        font-size: 16px;
    }

    div.style1 {
        width: 320px;
        overflow: hidden;
        white-space: nowrap;
        text-overflow: ellipsis;
    }
</style>

<h1>Ellipsis</h1>

<table class="table">
    <thead>
        <tr>
            <th>
                @Html.DisplayNameFor(model => model.Id)
            </th>
            <th>
                @Html.DisplayNameFor(model => model.Description)
            </th>
        </tr>
    </thead>
    <tbody>
        @foreach (var item in Model)
        {
            <tr>
                <td>
                    @Html.DisplayFor(modelItem => item.Id)
                </td>
                <td>
                    <div class="style1">
                        @Html.DisplayFor(modelItem => item.Description)
                    </div>
                </td>                
            </tr>
        }
    </tbody>
</table>

Controller / Action Method

using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
using MvcNet8App2.Models;
using System.Diagnostics;
using System.Security.Claims;
using System.Security.Principal;

namespace MvcNet8App2.Controllers
{
    public class HomeController : Controller
    {
        private readonly ILogger<HomeController> _logger;
        private readonly CookieAuthenticationOptions _options;

        public HomeController(ILogger<HomeController> logger,
                              IOptions<CookieAuthenticationOptions> options)
        {
            _logger = logger;
            _options = options.Value;
        }


        // ・・・中略・・・

        public IActionResult Ellipsis()
        {
            var model = new List<Ellipsis>
            {
                new ()
                {
                    Id = 1,
                    Description = "エスケープされた < > & " +
                    "などの文字はどのようになるか?"
                },
                new() {
                    Id = 2,
                    Description = "Proportional Font WWWWWWWWWWW " +
                    "iiiiiiii llllll などは?"
                },
                new ()
                {
                    Id = 3,
                    Description = "サロゲートペア 𠀋 𡈽 𠮟 などは" +
                    "どのようになるか?"
                },
                new() {
                    Id = 4,
                    Description = "絵文字 🍎 🍏 (サロゲートペア) " +
                    "👨‍🌾 (ZWJ で結合) などは?"
                }
            };

            return View(model);
        }

        // ・・・中略・・・
    }
}

Tags: , , , , ,

CORE

About this blog

2010年5月にこのブログを立ち上げました。主に ASP.NET Web アプリ関係の記事です。

Calendar

<<  July 2024  >>
MoTuWeThFrSaSu
24252627282930
1234567
891011121314
15161718192021
22232425262728
2930311234

View posts in large calendar