WebSurfer's Home

Filter by APML

コレクションのモデルバインディング

by WebSurfer 12. May 2026 18:33

ASP.NET Core MVC または Razor Pages アプリで、ユーザーがコレクションの要素の一部を削除して残りの要素を POST してきた場合に、送信されてきた残りの要素からなるコレクションのモデルバインディングを行うにはどうするかという話を書きます。

コレクションのモデルバインディング

先の記事「コレクションのデータアノテーション検証」に書きましたように、コレクションのモデルバインディングがうまく行われるようにするには、レンダリングされる input 要素の name 属性が連番のインデックスを含むようにします。具体的には、name="prefix[index].Property" というパターンとし、index は 0 から始まる連番とします。

この記事の例として上の画像のようなアプリを考えます。ユーザーが[Remove]ボタンをクリックしてある項目を削除してから[Submit]ボタンをクリックすると、残った項目のコレクションが POST されます。

その場合、POST されたコレクションの input 要素の name="prefix[index].Property" 属性の index が連続しなくなってしまいます。

index の数字の連続が途切れた場合、何も対応がされてなければそれ以降の要素はバインドされません。では、バインドされるようにするにはどういう対応を取ったらいいのでしょうか?

Microsoft のドキュメント「ASP.NET Core でのモデル バインド」の Collections のセクションの 5 つ目のコード例にある、selectedCourses.index=a&selectedCourses.index=b というような index を示すデータを一緒にPOST することにより連番でなくてもモデルバインドされるようになります。

例えば、index が 0 の場合、ASP.NET から以下の様な html 要素がレンダリングされるようにコーディングします。

<input type="hidden" name="prefix.Index" value="0" />
<input name="prefix[0].Property" value="ABC" />

参考に上の画像の ASP.NET Core Razor Pages アプリのコードを以下に載せてておきます。Visual Studio 2026 のテンプレートを使ってターゲットフレームワークを .NET 10 として作成したものです。

Model

using System.ComponentModel.DataAnnotations;

namespace RazorPages.Models
{    public class Country
    {
        [Required(ErrorMessage = "{0} は必須")]
        [StringLength(15, ErrorMessage = "{0} は {1} 文字以内")]
        [Display(Name = "国名")]
        public string Name { get; set; } = string.Empty;

        public CountryInfo Details { get; set; } = new();
    }

    public class CountryInfo
    {
        [Required(ErrorMessage = "{0} は必須")]
        [StringLength(15, ErrorMessage = "{0} は {1} 文字以内")]
        [Display(Name = "首都")]
        public string Capital { get; set; } = string.Empty;

        [Required(ErrorMessage = "{0} は必須")]
        [StringLength(15, ErrorMessage = "{0} は {1} 文字以内")]
        [Display(Name = "大陸")]
        public string Continent { get; set; } = string.Empty;
    }

    public class ListCountriesViewModel
    {
        public List<Country> CountryList { get; set; } = new();
        public List<Country> SelectedCountries { get; set; } = new();
    }
}

CountryList.cshtml.cs

using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using RazorPages.Models;

namespace RazorPages.Pages
{
    public class CountryListModel : PageModel
    {
        [BindProperty]
        public ListCountriesViewModel List { get; set; } = new();

        public void OnGet()
        {
            List = new ListCountriesViewModel
            {
                CountryList = GetDefaultCountries()
            };
        }

        public IActionResult OnPost()
        {
            if (ModelState.IsValid)
            {
                var posted = List.CountryList;
                List = new ListCountriesViewModel
                {
                    CountryList = posted,
                    SelectedCountries = posted
                };

                // POST された値は ModelState ディクショナリ(model ではない)
                // に格納されていて、Tag Helper はまず ModelState ディクショ
                // ナリを調べ、そこに値があればそれを表示する仕組みになっている。
                // なので、posted の値の通り表示するには ModelState をクリア
                // する必要がある
                ModelState.Clear();                
            }
            else
            {
                List = new ListCountriesViewModel
                {
                    CountryList = GetDefaultCountries()
                };
            }

            return Page();
        }

        private static List<Country> GetDefaultCountries()
        {
            var countries = new List<Country>
            {
                new Country()
                {
                    Name = "Italy",
                    Details = new CountryInfo
                    {
                        Capital = "Rome",
                        Continent = "Europe"
                    }
                },
                new Country()
                {
                    Name = "Spain",
                    Details = new CountryInfo
                    {
                        Capital = "Madrid",
                        Continent = "Europe"
                    }
                },
                new Country()
                {
                    Name = "USA",
                    Details = new CountryInfo
                    {
                        Capital = "Washington",
                        Continent = "NorthAmerica"
                    }
                },
                new Country()
                {
                    Name = "Japan",
                    Details = new CountryInfo
                    {
                        Capital = "Tokyo",
                        Continent = "Asia"
                    }
                },
                new Country()
                {
                    Name = "Australia",
                    Details = new CountryInfo
                    {
                        Capital = "Canberra",
                        Continent = "Oceania"
                    }
                }
            };

            return countries;
        }
    }
}

CountryList.cshtml

@page
@model RazorPages.Pages.CountryListModel

<h4>Remove countries not to submit</h4>
<form method="post">
    <table class="table">
        <thead>
            <tr>
                <th>
                    <label asp-for="List.CountryList[0].Name"></label>
                </th>
                <th>
                    <label asp-for="List.CountryList[0].Details.Capital"></label>
                </th>
                <th>
                    <label asp-for="List.CountryList[0].Details.Continent"></label>
                </th>
                <th></th>
            </tr>
        </thead>
        <tbody>
            @for (int i = 0; i < Model.List.CountryList.Count; i++)
            {
                <tr class="country-row">
                    <input type="hidden" name="List.CountryList.Index" value="@i" />
                    <td>
                        <input asp-for="List.CountryList[i].Name" class="form-control" />
                        <span asp-validation-for="List.CountryList[i].Name" 
                            class="text-danger"></span>
                    </td>
                    <td>
                        <input asp-for="List.CountryList[i].Details.Capital" 
                            class="form-control" />
                        <span asp-validation-for="List.CountryList[i].Details.Capital" 
                            class="text-danger"></span>
                    </td>
                    <td>
                        <input asp-for="List.CountryList[i].Details.Continent" 
                            class="form-control" />
                        <span asp-validation-for="List.CountryList[i].Details.Continent" 
                            class="text-danger"></span>
                    </td>
                    <td>
                        <button type="button"
                                class="btn btn-danger"
                                onclick="this.closest('.country-row').remove();">
                            Remove
                        </button>
                    </td>
                </tr>
            }
        </tbody>
    </table>
    <input type="submit" value="Submit" class="btn btn-primary" />
</form>

<hr />
     
<h4>Countries submitted</h4>
<ul>
    @if (Model.List.SelectedCountries != null &&
         Model.List.SelectedCountries.Count > 0)
    {
        foreach (var country in Model.List.SelectedCountries)
        {
            <li>@country.Name</li>
        }
    }
    else
    {
        <li>No countries submitted.</li>
    }
</ul>

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

Tags: , , ,

CORE

Controller で作成された匿名型は View でアクセス不可

by WebSurfer 11. October 2024 15:20

.NET Framework 版の ASP.NET MVC アプリでは、Controller で作成されて View に渡された匿名型のオブジェクトには View 内部ではアクセスできず、 アクセスしようとすると以下の画像のように RuntimeBinderException がスローされるということを書きます。

RuntimeBinderException

理由は、Microsoft のドキュメント「匿名型」に書いてあるように、「匿名型のアクセシビリティ レベルは internal であるため」です。internal 型またはメンバは、同じアセンブリのファイル内でのみしかアクセスできません。

.NET Framework 版の MVC アプリでは、Controller など拡張子が cs のファイルは Visual Studio で単一アセンブリにコンパイルされ、bin フォルダに配置されます。

一方、View (.cshtml) は、デフォルトではランタイムコンパイルとなり、アプリをデプロイした後サーバーで動的にアセンブリにコンパイルされ、サーバーの Temporary ASP.NET Files フォルダに保存されます。

という訳で、Controller と View とは違うアセンブリになるため、Controller で作成された匿名クラスのプロパティは View では見えず、アクセスしようとすると上の画像のように RuntimeBinderException がスローされます。

ただし、ASP.NET Core アプリの場合は、Controller と View はデフォルトで単一アセンブリにコンパイルされるので、上に書いたような問題は起きません。(ASP.NET Core のコンパイルについて、詳しくは Microsoft のドキュメント「ASP.NET Core での Razor ファイルのコンパイル」を見てください)

上の画像を表示した MVC アプリの Controler と View のコードを以下に載せておきます。Visual Studio 2022 のテンプレートを使って作成した .NET Framework 4.8 の MVC5 アプリです。

Controller

using System;
using System.Data;
using System.Data.Entity;
using System.Linq;
using System.Threading.Tasks;
using System.Net;
using System.Web.Mvc;
using Mvc5App2.Models;

namespace Mvc5App2.Controllers
{
    public class ProductsController : Controller
    {
        private NORTHWINDEntities db = new NORTHWINDEntities();


        public async Task<ActionResult> Test()
        {
            var products = db.Products
                .Select(p => new
                {
                    Id = p.ProductID,
                    Name = p.ProductName,
                    Price = p.UnitPrice
                });

            ViewBag.List = await products.ToListAsync();

            return View();
        }
    }
}

View

@{
    ViewBag.Title = "Test";
}

<h2>Test</h2>

<br />
<table  class="table">
    <tr>
        <th>Id</th>
        <th>Name</th>
        <th>Price</th>
    </tr>
    @foreach (var item in ViewBag.List)
    {
        <tr>
            <td>@item.Id</td>
            <td>@item.Name</td>
            <td>@item.Price</td>
        </tr>
    }
</table>

解決策は、匿名型を使うのは止めて、以下のようなカスタムクラスを定義し、

public class DTO
{
    public int Id { get; set; }
    public string Name { get; set; }
    public decimal? Price { get; set; }
}

以下のように List<DTO> 型のデータを生成して View に渡すことです。

public async Task<ActionResult> Test()
{
    var products = db.Products
        .Select(p => new DTO  // List<DTO> を生成
        {
            Id = p.ProductID,
            Name = p.ProductName,
            Price = p.UnitPrice
        });

    ViewBag.List = await products.ToListAsync();

    return View();
}

なお、上にも書きましたように、ASP.NET Core アプリの場合は、Controller と View はデフォルトで同じアセンブリにコンパイルされるので、上に書いた問題は起きません。

なので、匿名型を使っても以下の画像の通り期待した結果が得られます。

ASP.NET Core での結果

Tags: , ,

MVC

ASP.NET Core MVC でチャンク形式でダウンロード

by WebSurfer 4. August 2024 19:20

ASP.NET Core MVC のアクションメソッドを使って、ファイルをチャンク形式でエンコーディングしてブラウザにダウンロードする方法を書きます。(Core 版の MVC アプリの話です。.NET Framework 版は先の記事「MVC でチャンク形式でダウンロード」を見てください)

チャンク形式でダウンロード

チャンク形式エンコーディングとは、 HTTP/1.1 で定義されている方式で、送信したいデータを任意のサイズのチャンク(塊)に分割し、各々のチャンクにサイズ情報を付与するエンコード方式です。(HTTP/2 はチャンク方式に対応しておらず、もっと効率的なデータストリーミングの仕組みを提供しているそうです)

メリットは、例えば、作成に時間がかかる大量のデータを動的に作成していて、作成中は全体のサイズが分からないが、部分的にでも作成でき次第送信を始められるというところにあるようです。(一旦全データをバッファして全体のサイズを調べ、Content-Length に設定するということをしなくても済みます)

概略方法を書きますと、(1) HttpResponse オブジェクトを取得、(2) それから Body プロパティを使って出力ストリームを取得、(3) コンテンツをチャンクに分割して WriteAsync メソッドでストリームに書き込む、(4) FlushAsync メソッドでクライアントに送信する、(5) 全チャンクを送信するまで (3) と (4) の操作を繰り返す・・・ということになります。

具体例はこの記事の下に載せたコードを見てください。test.pdf は 34,547 バイトの pdf ファイルで、下のコード例にあるアクションメソッドをブラウザから要求すると、その pdf ファイルのデータを 10,000 バイトずつチャンクに分けて送信するようになっています。

結果はこの記事の一番上の画像を見てください。Fiddler を使って要求・応答をキャプチャしたものです。応答ヘッダの赤線で示した部分を見るとチャンク形式エンコーディングになっていることが分かります。コンテンツの反転表示させた部分 32 37 31 30 に最初に送信されたチャンクのサイズが示されていることが分かります (文字コードは ASCII なので 32 37 31 30 は 2710 ⇒ 10 進数に直すと 10000)。

(注: Fiddler で応答コンテンツを見る際「Response body is encoded, Click to decode.」はクリックしないよう注意してください。クリックするとチャンクはまとめられ、さらに応答ヘッダには Content-Length が追加されて、チャンク形式ではなく普通にダウンロードされたように表示されます)

また、上のコードで送信データの最後を示す長さ 0 のチャンクも送信されています (Fiddler で最後のバイト列が 30 0D 0A 0D 0A となっているのを確認)。もちろん pdf ファイルも Content-Disposition に指定した名前で正しくダウンロードされます。

using Microsoft.AspNetCore.Mvc;

namespace MvcNet8App.Controllers
{
    public class DownloadController : Controller
    {
        // Core では Server.MapPath が使えないことの対応
        private readonly IWebHostEnvironment _hostingEnvironment;

        public DownloadController(IWebHostEnvironment hostingEnvironment)
        {
            _hostingEnvironment = hostingEnvironment;
        }

        // アクションメソッドの戻り値は void または Task にできる
        [HttpGet("/ChunkedDownload")]
        [ResponseCache(Duration = 0, 
                       Location = ResponseCacheLocation.None, 
                       NoStore = true)]
        public async Task ChunkedDownload(CancellationToken token)
        {
            // この例では、アプリケーションルート直下の Files という名前の
            // フォルダの中の test.pdf というファイルをダウンロードする
            string contentRootPath = _hostingEnvironment.ContentRootPath;
            string physicalPath = contentRootPath + "\\" + 
                                  "Files\\" + "test.pdf";

            // チャンクサイズは 10000 とした
            int chunkSize = 10000;
            Byte[] buffer = new Byte[chunkSize];

            using (var stream = new FileStream(physicalPath, FileMode.Open))
            {
                long length = stream.Length;

                // 応答ヘッダに Content-Type と Content-Disposition を含める
                Response.ContentType = "application/pdf";
                Response.Headers.Append("Content-Disposition", 
                                        "attachment;filename=test.pdf");

                // MVC5 の Response.IsClientConnected は使えないので代わりに
                // !token.IsCancellationRequested を使う
                // アクションメソッドの引数に CancellationToken を追加してお
                // けば、フレームワークが HttpContext.RequestAborted から取
                // 得した CancellationToken を引数にバインドしてくれる
                while (length > 0 && !token.IsCancellationRequested)
                {
                    // チャンク形式でダウンロードされていることを確認するため
                    // 入れたコード。コメントアウトを外すとここで 3 秒待つ
                    //await Task.Delay(3000, CancellationToken.None);

                    int lengthRead = await stream.ReadAsync(
                                            buffer.AsMemory(0, chunkSize),
                                            token);

                    // MVC5 の Response.OutputStream は使えない
                    // 同期版のWrite メソッドは AllowSynchronousIO がデフォル
                    // トで false なので使えない
                    await Response.Body.WriteAsync(
                                            buffer.AsMemory(0, lengthRead),
                                            token);

                    // MVC5 の Response.Flush() は使えない
                    await Response.Body.FlushAsync(token);

                    length -= lengthRead;

                }
            }
        }
    }
}

注意点があるので以下に書いておきます。

注 1: IIS を使ってのインプロセスホスティングモデルでホストされる ASP.NET Core Web アプリは、クライアントによる要求の中断を検出してサーバー側の処理をキャンセルすることができます (詳しくは先の記事「要求の中断による処理のキャンセル (CORE)」を見てください)。

しかしながら、上のコ���ドの最初の FlushAsync で応答ヘッダと最初のチャンクがブラウザに送信された時点で、ブラウザの X ボタンは表示されなくなり Esc キーは効かなくなって、それらの操作で処理は中断できなくなります。Windows 10 の Chrome 127.0.6533.89, Edge 127.0.2651.86, Firefox 128.0.3, Opera 112.0.5197.39 ですべて同じになることを確認しました。

ブラウザを閉じた場合、Edge 以外では処理は中断されますが、Edge では処理が続行されてダウンロードが完了してしまいます。理由はクライアントによる要求の中断情報を IIS に送れなくて、サーバー側で CancellationToken がキャンセル状態にならないためと思われますが、詳細は調べ切れておらず不明です。

注 2: 上の注 1 のクライアントによる要求の中断は、プロキシが入ると CancellationToken が IIS に届かなくなり、上のコードでは検出できなくなるので注意してください。(何故で検出できないのか悩んでいたら Fiddler を使っていたというのは内緒です(笑))

注 3: チャンクのバイト列を応答ストリームに書き込むのに同期メソッドの Write は使えません。使うと InvalidOperationException がスローされ、"Synchronous operations are disallowed. Call WriteAsync or set AllowSynchronousIO to true instead." というエラーになります。理由は AllowSynchronousIO が Keatrel を使う場合でも IIS を使う場合でもデフォルトで false に設定されているからだそうです。AllowSynchronousIO を true に設定するのではなく、上のコードのように WriteAsync メソッドを使うのが正解と思います。

注 4: ReadAsync および WriteAsync メソッドで、引数に Byte[], Int32, Int32, CancellationToken を取るオーバーロードを使うと、"より効率的なメモリベースのオーバーロードを呼び出すことをお勧めします" とのことでパフォーマンスルール CA1835 が出るので、それに従って Memory<Byte> / ReadOnlyMemory<Byte>, CancellationToken を引数に取るオーバーロードを使いました。

Tags: , , ,

Upload Download

About this blog

2010年5月にこのブログを立ち上げました。主に ASP.NET Web アプリ関係の記事です。ブログ2はそれ以外の日々の出来事などのトピックスになっています。

Calendar

<<  August 2026  >>
MoTuWeThFrSaSu
272829303112
3456789
10111213141516
17181920212223
24252627282930
31123456

View posts in large calendar