ASP.NET Core と Ajax

Programming
公開: 2024-09-05
Article Image

はじめに

ASP.NET Core(主に Razor Pages)で通常のフォーム送信をやめて Ajax で非同期処理するようにプログラムを変更すると、今まで取得できていた値が取得できなくなったりする、というのは割とよくありがちな事なので、ASP.NET Core のモデルバインディング と Ajax リクエストの関係について整理してみる。

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

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

サーバー側の実装とリクエストの整合性が取れていないと正しくデータを受信できない。正しくない場合でもエラーにはならず、モデルのプロパティにデフォルト値が設定され、そのまま動く場合も多いので、すぐには問題に気づかないこともある。

Razor 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>

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

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

適当に入力して Post ボタンを押下すると OnPost メソッドが呼び出され、Hoge の Name、Age に入力値が設定されていることを確認できる。[BindProperty] 属性は、HTTPリクエストの POST データ(フォームの入力値)を Hoge プロパティに自動的にバインド(マッピング)させるための指定。

[BindProperty] 属性を付けない場合、バインディングは行われない。ただしこの時でも Hoge が null になるわけではない。 OnPost のメソッドが実行される前に public HogeClass Hoge { get; set; } = new(); でインスタンスを代入しているため。なので、このプログラムを実行してもヌル参照例外(Null Reference Exception)は発生しない。(入力して Post を押しても元の値に戻る動作になる)

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) のレスポンスが戻ってくる。
    Ajax ではこのトークンを明示的に送る必要がある

リクエストデータ

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

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

リクエストボディ: Hoge.Name=Fuga&Hoge.Age=25&__RequestVerificationToken=XXXXXX・・・(省略)
  • Content-Type
    Content-Type は、リクエストボディのデータ形式を示すヘッダー。サーバーはこの指定にもとづきリクエストボディのデータを解釈する。

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

  • リクエストボディ
    URLエンコード形式 の文字列データ。& 記号で分割すると

Hoge.Name=Fuga
Hoge.Age=25
__RequestVerificationToken=XXXXXX・・・(省略)

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

jQuery を使用して Ajax 化

jQuery の $.ajax() を使用して Ajax 送信する。
サーバー側(C#)のコードは変更しない。

@page
@model HogePageModel
<form method="post">
    <input asp-for="@Model.Hoge.Name" />
    <input asp-for="@Model.Hoge.Age" />
    <button type="submit">Post</button>
    <button type="button" id="ajaxButton">Ajax</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>
        const token = '@token';

        $("#ajaxButton").on("click", () => {
            $.ajax({
                type: "POST",
                url: "/HogePage",
                headers: { "RequestVerificationToken": token },
                data: { 
                    "Hoge.Name": "Fuga", 
                    "Hoge.Age": 25
                }
            });
        })
    </script>
}

簡単のため、入力値ではなく固定値を送信するようにしている。

headers: { "RequestVerificationToken": token },

は先述した CSRF 対策用のトークン。

data: { 
    "Hoge.Name": "Fuga", 
    "Hoge.Age": 25
}

ここが送信データを指定している箇所。
JSON 風な記述になっているが、これは JSON ではなく JavaScript のオブジェクトリテラル(オブジェクトを作るための構文)。JSON と JavaScript のオブジェクトリテラルの記述形式が似ているので勘違いしやすいが、これは JSON ではない。

  • リクエストデータ
    リクエストデータの内容を確認してみる。
Content-type: application/x-www-form-urlencoded; charset=UTF-8

リクエストボディ: Hoge.Name=Fuga&Hoge.Age=25

URLエンコード形式 で送信されているのが分かる。

JSON で送りたい

JSON で送信する場合は以下のように修正する。

  • サーバー側(C#)

    • PageModel の Hoge プロパティから [BindProperty] を外す。
      [BindProperty] は JSON データをバインドに対応していない。
    • OnPost の引数に [FromBody] HogeClass hoge を追加
      [FromBody] はリクエストボディを メソッドの引数にバインド する。
  • クライアント側(JavaScript)

    • contentType: "application/json" を指定
    • data: JSON.stringify 経由でデータを設定
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;

namespace WebApplication1.Pages;

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}");
    }
}
$("#ajaxButton").on("click", () => {
    $.ajax({
        type: "POST",
        url: "/HogePage",
        headers: { "RequestVerificationToken": token },
        contentType: "application/json",
        data: JSON.stringify({
            Name: "Fuga",
            Age: 25
        })
    });
})
  • リクエストデータ
Content-type: application/json

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

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

  • Content-type を指定しないとどうなる?
Content-type: application/x-www-form-urlencoded; charset=UTF-8

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

このようなデータが送信される。
Content-type とリクエストボディの形式が一致しないので、サーバー側でデータを解釈できず、引数 hoge は null になる。

  • JSON.stringify を指定しないとどうなる?
Content-type: application/json

リクエストボディ: Name=Fuga&Age=25

リクエストボディは jQuery が URLエンコード形式 に変換するが、これも Content-type に合っていないため、引数 hoge は null になる。

  • [FromBody] を指定しないとどうなる? 送信データは正しい形式だが、[FromBody] が無いため、引数 hoge に送信データの内容は反映されない。ただし、HogeClass のデフォルトインスタンスが渡され、初期化時のプロパティ値(Name="Hoge"、Age=18)が設定された状態になる。(null にはならない)

[FromBody] バインディングは JSON デシリアライザ(System.Text.Jsonなど)が直接インスタンスを生成するため、デフォルト値(プロパティの初期値など)は無視され、JSON にないプロパティは初期値なしで null や 0 になる。JSON が空()や null なら、hoge は null になる。Content-Type とリクエストボディのアンマッチの場合も hoge は null になる。

fetch を使う場合

URL エンコード形式のデータを送信する

サーバー側のコードは [BindProperty] を使ったコード。
クライアント側のコードは以下。

$("#ajaxButton").on("click", () => {
    const formData = new URLSearchParams();
    formData.append("Hoge.Name", "Fuga");
    formData.append("Hoge.Age", "25");

    fetch("/HogePage", {
        method: "POST",
        headers: {
            "RequestVerificationToken": token,
            "Content-Type": "application/x-www-form-urlencoded"
        },
        body: formData.toString()
    })
})

Content-Type を省略した場合、リクエストの Content-Type には text/plain が設定される。この場合もやはりサーバー側は受信できない。

JSON 形式のデータを送信する

$("#ajaxButton").on("click", () => {
    const data = {
        Name: "Fuga",
        Age: 25
    };

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

まとめ

  • フォーム送信 (POST)

    • [BindProperty] を使ってプロパティにバインド
    • リクエスト形式は application/x-www-form-urlencoded
  • Ajax送信 (URL エンコード形式)

    • フォーム送信と同様に [BindProperty] を使ってプロパティにバインド
    • フォーム送信と同様にリクエスト形式は application/x-www-form-urlencoded
  • Ajax送信 (JSON 形式)

    • [FromBody] を使い、メソッドの引数にバインドする
    • リクエスト形式は application/json
    • リクエストボディは JSON.stringify() で文字列化

参考

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 オプションを使用しています。