ASP.NET Core と Ajax

プログラミング
公開 2024年9月5日
Article Image

はじめに

ASP.NET Core Razor Pages で通常のフォーム送信から非同期処理にプログラムを変更すると、変更前に動いていたものが動かなくなる、というのは割とよくありがちな事です(私だけ?)。ASP.NET Core のモデルバインディング と Ajax リクエストの関係について、備忘録もかねて整理します。

ポイントになるのは以下。

  • リクエストのデータ形式と Content-Type ヘッダー
  • サーバー側の定義([BindProperty][FromBody] などの属性の意味)

リクエストとサーバー側の整合性が取れていないと正しくデータを受信できません。正しくない場合でも実行エラーにはならないケースが多いので、なかなか不整合に気づかないこともあります。モデルバインディングの基本から見ていきます。

Razor Pages のモデルバインディング

基本的なモデルバインディングのコード

フォーム送信と Rzaor Pages のモデルバインディングは以下のようなコードになります。

public class HogeClass
{
    public string Name { get; set; } = "Hoge";
    public int Age { get; set; } = 18;
}

public class HogePageModel : PageModel
{
    [BindProperty]
    public HogeClass Hoge { get; set; } = new();

    public void OnPost()
    {
        Console.WriteLine($"Name: {Hoge.Name}, Age: {Hoge.Age}");
    }
}
@page
@model HogePageModel
<form method="post">
    <input asp-for="@Model.Hoge.Name" />
    <input asp-for="@Model.Hoge.Age" />
    <button type="submit">Post</button>
</form>

[BindProperty] 属性が HTTPリクエストの POST データ(フォームの入力値)を Hoge プロパティに自動的にバインド(マッピング)させるための指定になります。

実行すると、このようなページが表示されます(図1)。

図1 ページ表示図1 ページ表示

デバッグ実行で起動し、フォームに入力して Post ボタンを押すと、OnPost メソッドが呼び出され、Hoge の Name、Age に入力値が設定されていることを確認できます。

HTML

出力された html の内容を確認してみましょう。

<form method="post">
    <input type="text" id="Hoge_Name" name="Hoge.Name" value="Hoge" />
    <input type="number" id="Hoge_Age" name="Hoge.Age" value="18" />
    <button type="submit">Post</button>
    <input name="__RequestVerificationToken" type="hidden" value="XXXXXX・・・(省略)" />
</form>

ASP.NET Core によって、id、name、value の各属性が自動生成されています。

  • id: モデルのプロパティ名を _ 区切りで変換(Hoge.Name → Hoge_Name)
    → JavaScript やラベルタグ(<label for="...">)などで使用

  • name: モデルバインディング用のフィールド名(Hoge.Name)
    → POST 時にサーバー側で Hoge.Name というプロパティに値をマッピング

  • value: モデルの現在値("Hoge")
    → ページ表示時のフォームの初期値

自動生成された name 属性がバインディング時のサーバー側のプロパティ名を指定するキーになります。

  • __RequestVerificationToken
    ASP.NET Core により自動生成された CSRF 対策用のトークンです。モデルバインディングには直接関係ありませんが、このトークンが無いと無効なリクエストと判定され、サーバー側の処理は実行されず、400 (Bad Request) のレスポンスが返ります。

リクエストデータ

次にリクエストデータの内容を確認してみましょう。
例として Name に "Fuga"、Age に 25 を入力した場合です。

Content-type: application/x-www-form-urlencoded

リクエストボディ: Hoge.Name=Fuga&Hoge.Age=25&__RequestVerificationToken=CfDJ8PYW-uS7dDZDn6LlNPE8uvosZhCcbGyYoNCYQFcq6heF05q5Q6DN3n6FllCtn9wclH7flJVoHqPZBIj-TC9Q0MunNm1mKLy-0E4p6GOU0zcX9pVTjEQgZvWWJzLJpcn60xHjBytC6p38h83g8WYvFN4
  • Content-type
    Content-type は、リクエストボディのデータ形式を示すヘッダー。サーバーはこの指定にもとづきリクエストボディのデータを解釈します。

  • application/x-www-form-urlencoded
    長い名称ですが、これは URLエンコード形式 または フォームデータ形式 と呼ばれるもので、ブラウザからデータを送信する際の標準的なエンコーディング形式です。データは「キー=」のペアで表され、各ペアは & 記号で連結されます。

  • リクエストボディ
    URLエンコード形式 の文字列データが送信されています。& 記号で分割すると

Hoge.Name=Fuga
Hoge.Age=25
__RequestVerificationToken=CfDJ8PYW-uS7dDZDn6LlNPE8uvosZhCcbGyYoNCYQFcq6heF05q5Q6DN3n6FllCtn9wclH7flJVoHqPZBIj-TC9Q0MunNm1mKLy-0E4p6GOU0zcX9pVTjEQgZvWWJzLJpcn60xHjBytC6p38h83g8WYvFN4

となっています。
キーには html の name 属性が使用されており、この、Hoge.Name が C# のプロパティ名 Hoge.Name に一致することがバインディングされる条件 になります。

fetch

コード

fetch を使用した Ajax 処理に変更してみましょう。
.cs ファイルを変更する必要はありません。
.cshtml ファイルを以下のように修正します。

@page
@model HogePageModel
<form method="post">
    <input asp-for="@Model.Hoge.Name" />
    <input asp-for="@Model.Hoge.Age" />
    <button type="button" id="button">Post</button>
</form>
<script>
    document.getElementById('button').addEventListener('click', () => {
        const form = document.querySelector('form');
        const formData = new FormData(form);

        fetch('/HogePage', {
            method: 'POST',
            body: formData
        });
    });
</script>

button タグの typebutton に変更するのを忘れないようにします。デフォルトは submit ですので、button にしないと通常のフォーム送信が行われてしまいます。

リクエストデータ

リクエストデータの内容を確認してみます。
最初の例と同様に Name に "Fuga"、Age に 25 を入力した場合です。

Content-type: multipart/form-data; boundary=----WebKitFormBoundary5BiMG5LpjEL9onVI

リクエストボディ: 
------WebKitFormBoundary5BiMG5LpjEL9onVI
Content-Disposition: form-data; name="Hoge.Name"

Fuga
------WebKitFormBoundary5BiMG5LpjEL9onVI
Content-Disposition: form-data; name="Hoge.Age"

25
------WebKitFormBoundary5BiMG5LpjEL9onVI
Content-Disposition: form-data; name="__Invariant"

Hoge.Age
------WebKitFormBoundary5BiMG5LpjEL9onVI
Content-Disposition: form-data; name="__RequestVerificationToken"

CfDJ8PYW-uS7dDZDn6LlNPE8uvrM_Ia3TWGCbZ7iqw9j5rLr_WI7a2SIQ-KDi9YDccFmw0UXgnkiuhKZTf_BW61bmTmHJgjGctDkT7leMPas7Z9__pMqsKlEgRyrziT5iN-yO2E1f3vATsGFPJ_UHw1cQew
------WebKitFormBoundary5BiMG5LpjEL9onVI--
  • Content-type: multipart/form-data
    FormData を使用した場合、Content-type には multipart/form-data が自動的に設定されます。1
    multipart/form-data はフォームから複数種類のデータ(ファイルなど)を送信するための Content-type です。この例ではファイル送信は必要ありませんが、FormData を使用するとリクエストボディのデータ形式が multipart/form-data になりますので、Content-type は multipart/form-data である必要があります。
  • リクエストボディ
    multipart/form-data 形式のデータです。boundary と呼ばれる区切り文字列(この例では「----WebKitFormBoundary5BiMG5LpjEL9onVI」)でデータが区切られています。

  • なぜ受信できるのか?
    サーバー側の .cs ファイルは全く変更していませんが、正しくデータを受信できます。これは [BindProperty] 属性が application/x-www-form-urlencodedmultipart/form-data の両方に対応しているためです。[BindProperty] を付けておけばなんでも受信できるのでは?と思ってしまいますが、そうではありません。次に JSON データを送信する例を見てみます。

fetch(JSON)

コード

送信データの形式を JSON に変更します。

public class HogeClass
{
    public string Name { get; set; } = "Hoge";
    public int Age { get; set; } = 18;
}

public class HogePageModel : PageModel
{
    public HogeClass Hoge { get; set; } = new();

    public void OnPost([FromBody] HogeClass hoge)
    {
        Console.WriteLine($"Name: {hoge.Name}, Age: {hoge.Age}");
    }
}
  • [BindProperty] の使用をやめる
  • OnPost の引数に [FromBody] HogeClass hoge を追加

[BindProperty] は JSON データに対応していないので、使用できません。
JSON データを受信するためには、メソッドの引数に [FromBody] 属性を適用するという形式にします。
これで、プロパティではなく、メソッドの引数 hoge にモデルバインディングが行われます。

@page
@model HogePageModel
<form method="post">
    <input asp-for="@Model.Hoge.Name" />
    <input asp-for="@Model.Hoge.Age" />
    <button type="button" id="button">Post</button>
</form>
@inject Microsoft.AspNetCore.Antiforgery.IAntiforgery antiforgery
@{
    var token = antiforgery.GetAndStoreTokens(HttpContext).RequestToken;
}
<script>
    document.getElementById('button').addEventListener('click', () => {
        const name = document.querySelector('[name="Hoge.Name"]').value;
        const age = parseInt(document.querySelector('[name="Hoge.Age"]').value, 10);

        const data = {
            Name: name,
            Age: age
        };

        fetch('/HogePage', {
            method: "POST",
            headers: {
                'RequestVerificationToken': `@token`,
                'Content-Type': 'application/json'
            },
            body: JSON.stringify(data)
        });
    });
</script>

.cshtml ファイルの修正点は以下になります。

  • リクエストヘッダーに CSRF トークンを設定
@inject Microsoft.AspNetCore.Antiforgery.IAntiforgery antiforgery
@{
    var token = antiforgery.GetAndStoreTokens(HttpContext).RequestToken;
}
CSRF token を リクエストヘッダー `RequestVerificationToken` に設定します。通常のフォーム送信や `multipart/form-data` ではリクエストボディに設定されていましたが、JSON データに設定してもトークンとして解釈されないため、ヘッダーに設定する必要があります。
  • Content-type に application/json を設定
    JSON データを送信するので application/json を設定します。指定しなかった場合はデフォルトの text/plain で送信されます。text/plain で送信した場合、サーバー側で受信することができません(NullReferenceException が発生します)。
  • JSON.stringify を使って JavaScript のオブジェクトを JSON 形式の文字列に変換してから body に設定
    リクエストボディのデータは文字列である必要があります。オブジェクトのままではサーバー側で受信することができません(NullReferenceException が発生します)。

リクエストデータ

リクエストデータは以下のようになります。

Content-type: application/json
requestverificationtoken: CfDJ8PYW-uS7dDZDn6LlNPE8uvosZhCcbGyYoNCYQFcq6heF05q5Q6DN3n6FllCtn9wclH7flJVoHqPZBIj-TC9Q0MunNm1mKLy-0E4p6GOU0zcX9pVTjEQgZvWWJzLJpcn60xHjBytC6p38h83g8WYvFN4

リクエストボディ: {"Name":"Fuga","Age":25}

$.ajax

次は jQuery の $.ajax() を使用する場合です。

.cs ファイルは最初の例のコードをそのまま使用できます。

public class HogeClass
{
    public string Name { get; set; } = "Hoge";
    public int Age { get; set; } = 18;
}

public class HogePageModel : PageModel
{
    [BindProperty]
    public HogeClass Hoge { get; set; } = new();

    public void OnPost()
    {
        Console.WriteLine($"Name: {Hoge.Name}, Age: {Hoge.Age}");
    }
}

.cshtml は以下のようになります。

@page
@model HogePageModel
<form method="post">
    <input asp-for="@Model.Hoge.Name" />
    <input asp-for="@Model.Hoge.Age" />
    <button type="button" id="button">Post</button>
</form>
@section Scripts {
    <script src="https://code.jquery.com/jquery-3.7.1.min.js"></script>
    <script>
        $('#button').on('click', () => {
            $.ajax({
                type: 'POST',
                url: '/HogePage',
                data: $('form').serialize()
            });
        })
    </script>

jQuery の serialize() を使用すると、フォーム要素内のすべての入力値を application/x-www-form-urlencoded 形式の文字列に変換してくれます。

リクエストデータ

Content-type: application/x-www-form-urlencoded

リクエストボディ: Hoge.Name=Fuga&Hoge.Age=25&__Invariant=Hoge.Age&__RequestVerificationToken=CfDJ8PYW-uS7dDZDn6LlNPE8uvqnfpJLTuGH7NvfcuVFLJ5_3LMGcH7tqAUE1RwSyMoFfST7WJmloCyFwehhiYiH2TPxF_dnxSkbWDVXrXqhpJhg7f1QxZwyGSUWUpa24AMxeDtSjqxh-nATdZzHh_oyxzs

$.ajax の Content-type のデフォルトは application/x-www-form-urlencoded のため、明示的に指定する必要はありません。

$.ajax(JSON)

jQuery の $.ajax() を使用しつつ、送信データを JSON にする場合は、どうしたらよいでしょうか?

.cs ファイルは fetch(JSON)のコードと全く同じになります。

public class HogeClass
{
    public string Name { get; set; } = "Hoge";
    public int Age { get; set; } = 18;
}

public class HogePageModel : PageModel
{
    public HogeClass Hoge { get; set; } = new();

    public void OnPost([FromBody] HogeClass hoge)
    {
        Console.WriteLine($"Name: {hoge.Name}, Age: {hoge.Age}");
    }
}

[BindProperty] の使用をやめ、OnPost の引数に [FromBody] HogeClass hoge を追加します。

@page
@model HogePageModel
<form method="post">
	<input asp-for="@Model.Hoge.Name" />
	<input asp-for="@Model.Hoge.Age" />
	<button type="button" id="button">Post</button>
</form>
@inject Microsoft.AspNetCore.Antiforgery.IAntiforgery antiforgery
@{
	var token = antiforgery.GetAndStoreTokens(HttpContext).RequestToken;
}
@section Scripts {
	<script src="https://code.jquery.com/jquery-3.7.1.min.js"></script>
	<script>
		$('#button').on('click', () => {
			const name = document.querySelector('[name="Hoge.Name"]').value;
			const age = parseInt(document.querySelector('[name="Hoge.Age"]').value, 10);

			const data = {
				Name: name,
				Age: age
			};

			$.ajax({
				type: 'POST',
				url: '/HogePage',
				headers: { 
					'RequestVerificationToken': '@token'
				},
				contentType: 'application/json',
				data: JSON.stringify(data)
			});
		})
	</script>
}
  • RequestVerificationToken の設定
  • contentType: 'application/json' の設定
  • JSON.stringify を使ってオブジェクトを文字列に変換

必要な修正は fetch(JSON)の場合とほぼ同じになります。

リクエストデータ

Content-type: application/json
Requestverificationtoken: CfDJ8PYW-uS7dDZDn6LlNPE8uvpeLThm5_Z8LiRmjO6nQ5eUodlnqHbI-kjmnqhLB_NuagWlOn4PWSDy_JEAnV9gNVyrhAULTXJe2-JbRbcAWkZCz1blHS68eF-RVG67CAc9nBXEEIqbRGW6V9V48Md2Vvs

リクエストボディ: {"Name":"Fuga","Age":25}

JSON データが送信されていることがわかります。

まとめ

まとめると以下のようになります。

方式 .cs のデータの受け取り方 Content-Type 送信データ形式
フォーム送信 (POST) [BindProperty] プロパティにバインド application/x-www-form-urlencoded URL エンコード
fetch [BindProperty] プロパティにバインド multipart/form-data マルチパート
fetch(JSON) [FromBody] 引数にバインド application/json JSON
$.ajax [BindProperty] プロパティにバインド application/x-www-form-urlencoded URL エンコード
$.ajax(JSON) [FromBody] 引数にバインド application/json JSON

繰り返しになりますが、大切なポイントは

  • 送信データの形式と Content-Type の指定を合わせること
  • 送信データの形式と Content-Type に合わせてサーバー側の実装を変更

になります。
参考になれば幸いです。

参考資料

jQuery.ajax()

contentType (default: 'application/x-www-form-urlencoded; charset=UTF-8') Type: Boolean or String When sending data to the server, use this content type. Default is "application/x-www-form-urlencoded; charset=UTF-8",

Fetch POST リクエスト

本文(body)が文字列の場合、Content-Type にはデフォルトでは text/plain が設定されることに留意してください。 そのため、application/json を代わりに送信するために headers オプションを使用しています。