Part 7. ASP.NET Web API による SPA 型データ更新アプリ

いよいよ最後のエントリです。引き続き、Part 6. で作成したアプリを SPA 型で実装してみることにします。SPA 型でのデータ更新アプリ実装で厄介なのは、下図に示すように、クライアント側では JavaScript、サーバ側では C# を使って単体入力チェックロジックを実装する必要があり、うっかりすると二重実装になってしまう点です。

image

これに関しては、Part 6. で解説した、ASP.NET MVC のモデルバリデーション機能を活用するとうまく回避することができます。では、具体的な作成方法について解説します。

■ ファイルの配置

まずはコントローラクラス、ビュークラスのファイルをそれぞれ配置しておきます。(Part 6. で配置した、入力チェック用の JavaScript, CSS 定義も利用します。)

  • /Controllers/Sample03Controller.cs
  • /Views/Sample03/ListAuthors.cshtml
  • /Views/Sample03/EditAuthor.cshtml

image

■ 著者一覧ページの実装

まずは Part 5 で解説した方法をもとに、著者一覧ページを作成しましょう。 これについては特に説明は不要でしょう。

[/Controllers/Sample03Controller.cs]

 
using Decode2016.WebApp.Models;
using Microsoft.AspNetCore.Mvc;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;

namespace Decode2016.WebApp.Controllers
{
    public class Sample03Controller : Controller
    {
        [HttpGet]
        public ActionResult ListAuthors()
        {
            return View();
        }

        [HttpGet]
        public List<AuthorOverview> GetAuthors()
        {
            using (PubsEntities pubs = new PubsEntities())
            {
                var query = pubs.Authors.Select(a => new AuthorOverview()
                {
                    AuthorId = a.AuthorId,
                    AuthorName = a.AuthorFirstName + " " + a.AuthorLastName,
                    Phone = a.Phone,
                    State = a.State,
                    Contract = a.Contract
                });
                return query.ToList();
            }
        }
    }
}

[/Views/Sample03/ListAuthors.cshtml]

 @{
    ViewBag.Title = "編集対象の著者選択";
}
@section Libraries {
    @Html.Partial("_ImportsLibraryKnockout")
}

<h4>編集対象となる著者を選択してください。</h4>

<div class="table-responsive">
    <table id="tblAuthors" class="table table-condensed table-striped table-hover">
        <thead>
            <tr>
                <th>著者ID</th>
                <th>著者名</th>
                <th>電話番号</th>
                <th>州</th>
                <th>契約有無</th>
            </tr>
        </thead>
        <tbody data-bind="foreach: authors">
            <tr>
                <td><a data-bind="text: AuthorId, attr: { href: 'EditAuthor/' + AuthorId }"></a></td>
                <td data-bind="text: AuthorName"></td>
                <td data-bind="text: Phone"></td>
                <td data-bind="text: State"></td>
                <td>
                    <input type="checkbox" disabled data-bind="checked: Contract" />&nbsp;
                    <text data-bind="text: (Contract ? '契約あり' : '契約なし')"></text>
                </td>
            </tr>
        </tbody>
    </table>
</div>

<hr />

<p>
    <a href="/">業務メニューに戻る</a>
</p>

@section Scripts {
    <script type="text/javascript">
        $(function () {
            // ブラウザキャッシュ無効化 (これを入れておかないと、Edit ページから戻ってきたときにリストが更新されない)
            $.ajaxSetup({
                cache: false
            });

            $("#tblAuthors").hide(); // css('display', 'none') と同じ

            // 後から値を入れたい場合には、ko.observable() と ko.observableArray() を割り当てておく
            var viewModel = {
                states: ko.observableArray(),
                authors: ko.observableArray()
            };
            ko.applyBindings(viewModel);

            $.getJSON("/Sample03/GetAuthors", null, function (result) {
                viewModel.authors(result); // データを observableArray に流し込み
                $("#tblAuthors").show(); // css('display', 'block') と同じ

                console.dir(result); // 全体表示
            });

        });
    </script>
}

引き続き、SPA 型の編集ページを作成します。

■ 編集ページの作成

一般に、SPA 型で Web アプリを作成する場合、サーバ側からは静的な Web ページを返します。しかし、データ入力チェック用の JavaScript を含む Web ページを作成するのは面倒なため、Part 6. で解説した、@model + @Html.TextBoxFor(m => m.XXX) を利用して、動的に C# ViewModel クラスから JavaScript チェックロジックを生成してクライアントに送り返します。

image

ただし、以下の点に注意してください。

  • 実際に画面に表示するデータは、HTTP-GET で画面を取り寄せたのち、ASP.NET Web API 経由でサーバから取り出します
  • また、登録ボタンを押した際のデータ送信は、通常の form submit ではなく、JavaScript による制御を行います

では、段階的に実装していきましょう。まず、Sample03Controller.cs クラスの内部クラスとして、UpdateAuthorRequest クラスを追加します。

 public class UpdateAuthorRequest
{
    [Required]
    [RegularExpression(@"^[0-9]{3}-[0-9]{2}-[0-9]{4}$")]
    public string AuthorId { get; set; }

    [Required(ErrorMessage = "著者名(名)は必須入力項目です。")]
    [RegularExpression(@"^[\u0020-\u007e]{1,20}$", ErrorMessage = "著者名(名)は半角 20 文字以内で指定してください。")]
    public string AuthorFirstName { get; set; }

    [Required(ErrorMessage = "著者名(姓)は必須入力項目です。")]
    [RegularExpression(@"^[\u0020-\u007e]{1,40}$", ErrorMessage = "著者名(姓)は半角 40 文字以内で指定してください。")]
    public string AuthorLastName { get; set; }

    [Required(ErrorMessage = "電話番号は必須入力項目です。")]
    [RegularExpression(@"^\d{3} \d{3}-\d{4}$", ErrorMessage = "電話番号は 012 345-6789 のような形式で指定してください。")]
    public string Phone { get; set; }

    [Required(ErrorMessage = "州は必須入力項目です。")]
    [RegularExpression(@"^[A-Z]{2}$", ErrorMessage = "州は半角大文字 2 文字で指定してください。")]
    public string State { get; set; }
}

Part 6 で使った EditViewModel クラスとほぼ同じですが、以下の点が異なります。

  • この構造体は、入力フォームではなく、ASP.NET Web API で受け取るデータ構造に合わせて作ります
  • 著者 ID は画面上での入力項目ではありませんが、ASP.NET Web API で受け取るデータ項目であるため、サーバ側での単体入力チェックが必要です。このため、データアノテーションによるチェックロジックを付与してあります。

続いて、ビューを送出するコード、及び画面に表示する著者データを送り返す Web API をコントローラクラスに作成します。

 [HttpGet]
public ActionResult EditAuthor(string id)
{
    if (Regex.IsMatch(id, @"^[0-9]{3}-[0-9]{2}-[0-9]{4}$") == false) throw new ArgumentOutOfRangeException("id");
    ViewData["AuthorId"] = id;
    return View(new UpdateAuthorRequest());
}


[HttpGet]
public Author GetAuthorByAuthorId(string authorId)
{
    if (Regex.IsMatch(authorId, @"^[0-9]{3}-[0-9]{2}-[0-9]{4}$") == false) throw new ArgumentOutOfRangeException("authorId");

    // 当該著者 ID のデータを読み取る
    Author editAuthor = null;
    using (PubsEntities pubs = new PubsEntities())
    {
        var query = from a in pubs.Authors
                    where a.AuthorId == authorId
                    select a;
        editAuthor = query.FirstOrDefault();
    }
    return editAuthor;
}

続いて、ビューを実装します。とりあえず、画面上にデータを採ってきて表示するところまで。

 @model Decode2016.WebApp.Controllers.Sample03Controller.UpdateAuthorRequest
@{
    ViewBag.Title = "著者データの編集";
}
@section Libraries {
    @Html.Partial("_ImportsLibraryValidation")
}

@section Styles {
    @Html.Partial("_ImportsStyleValidation")
}

<h4>著者データを修正してください。</h4>

<form id="frmInput">
    <dl>
        <dt>著者ID</dt>
        <dd>@ViewData["AuthorId"]</dd>
    </dl>
    <dl>
        <dt>著者名(名)</dt>
        <dd>@Html.TextBoxFor(m => m.AuthorFirstName, new { data_val_specialnamecheck = "指定された名前(名・姓の組み合わせ)は使えません。" }) @Html.ValidationMessageFor(m => m.AuthorFirstName, "*")</dd>
    </dl>
    <dl>
        <dt>著者姓(姓)</dt>
        <dd>@Html.TextBoxFor(m => m.AuthorLastName, new { data_val_specialnamecheck = "指定された名前(名・姓の組み合わせ)は使えません。" }) @Html.ValidationMessageFor(m => m.AuthorLastName, "*")</dd>
    </dl>
    <dl>
        <dt>電話番号</dt>
        <dd>@Html.TextBoxFor(m => m.Phone) @Html.ValidationMessageFor(m => m.Phone, "*")</dd>
    </dl>
    <dl>
        <dt>州</dt>
        <dd><select id="State" name="State"></select></dd>
    </dl>

    <p>
        <input type="button" id="btnUpdate" value="登録" />
        <input type="button" id="btnCancel" value="キャンセル" />
    </p>
    @Html.ValidationSummary("入力にエラーがあります。修正してください。")

    <p id="lblErrorMessage" class="error">
    </p>
</form>

<hr />

<p>
    @Html.ActionLink("業務メニューに戻る", "Index", "Home", new { area = "Common" }, null)
</p>

@section Scripts {
    <script type="text/javascript">
        $(function () {
            var authorId = "@ViewData["AuthorId"]";
            var authorToEdit = null;

            $.getJSON("/Sample03/GetAllStates", null, function (result) {
                $.each(result, function () {
                    $("#State")
                        .append($("<option></option>")
                        .attr("value", this)
                        .text(this));
                });
                $.getJSON("/Sample03/GetAuthorByAuthorId", { authorId: authorId }, function (result) {
                    $("#AuthorFirstName").val(result.AuthorFirstName);
                    $("#AuthorLastName").val(result.AuthorLastName);
                    $("#Phone").val(result.Phone);
                    authorToEdit = result;
                    $("#State option[value='" + result.State + "']").attr("selected", true);
                });
            });

            $("#btnUpdate").click(function () {
                // 後述
            });

            $("#btnCancel").click(function () {
                window.location = "@Url.Action("ListAuthors")";
                return false;
            });
        });
    </script>
}

いくつかポイントがありますので解説します。

  • <form> タグについて
    • このページから実際にデータ更新を行う場合は、フォームによるデータ送信ではなく、jQuery を使って Web API へデータを送信することにより処理を行います。この部分だけ見ると <form> タグは不要です。
    • が、実際には単体入力エラーチェックに(内部的に)jQuery Validation を利用します。jQuery Validation では入力フォームが <form> タグで囲まれていることが必要なため、ダミーで <form> タグを差し込んでいます。
  • 登録ボタンについて
    • <form> の送信機能を利用するわけではないので、<input type=submit> ではなく、<input type=button> で定義してください。
  • JavaScript 内での authorId の拾い方について
    • 上記のコードでは、var authorId = "@ViewData["AuthorId"]"; というコードを記述しており、サーバ側で MVC ビューページを作成する際に著者 ID を埋め込むようにしています。これは、厳密にいえば SPA 型の作り方としては不完全です(URL から取り出して使うのが正しい)。
    • ……が、クライアント側 JavaScript チェックロジックの生成に MVC ビューページを使っているので、ここだけ頑張ってもあまり意味がありません;。なので、サーバ側で MVC ビューページを作成する際に著者 ID を埋め込む形で十分だと思います。

では最後に、サーバ側へのデータ送信処理を記述します。ASP.NET Web API では、MVC と同じモデルバインディングとそれによる単体入力エラーチェック機能を利用することができます。このため、以下のようにすれば簡単にサーバ側でのデータ再チェックを行うことができます。(※ サーバ側でのエラー発覚時は不正クライアントからのアクセスとみなせるため、例外を発生させて処理を止めてしまえば十分です。)

 [HttpPost]
public void UpdateAuthor(UpdateAuthorRequest request)
{
    if (ModelState.IsValid == false) throw new ArgumentException();

    // データベースに登録する
    using (PubsEntities pubs = new PubsEntities())
    {
        Author target = pubs.Authors.Where(a => a.AuthorId == request.AuthorId).FirstOrDefault();
        target.AuthorFirstName = request.AuthorFirstName;
        target.AuthorLastName = request.AuthorLastName;
        target.Phone = request.Phone;
        target.State = request.State;
        pubs.SaveChanges();
    }
}

一方、クライアント側については、ボタン押下時に自力で jQuery Validation を呼び出して入力フォームをチェックし、そのうえでサーバに対してデータ送信するようなコードを記述します。(※ エラーサマリメッセージのクリアが厄介ですが、これは ASP.NET MVC の入力検証機能が Web API/SPA 型アプリでの利用を想定していないためです。なのでここはハックする形での実装になります。)

 $("#btnUpdate").click(function () {
    if ($("#frmInput").valid() == true) {
        // エラーサマリメッセージをクリア
        $("#frmInput").find("[data-valmsg-summary=true]").removeClass("validation-summary-errors").addClass("validation-summary-valid").find("ul").empty();

        $.post(
            "/Sample03/UpdateAuthor",
            {
                AuthorId: authorId,
                AuthorFirstName: $("#AuthorFirstName").val(),
                AuthorLastName: $("#AuthorLastName").val(),
                Phone: $("#Phone").val(),
                State: $("#State").val()
            },
            function (result) {
                window.location = "@Url.Action("ListAuthors")";
            }
        );
    }
});

以上でアプリを動作させると、SPA 型でも単体入力チェックの二重実装を回避することができます。

image

■ 参考&補足事項

なお、今回は話を簡単にするため、ASP.NET MVC と同様、以下の 2 点については説明を割愛しています。興味がある方は、各自で調査・実装してみてください。

  • アンチリクエストフォージェリ対策
    • ASP.NET Web API の場合であっても、やはりアンチリクエストフォージェリ対策をしておくことが望ましいです。
    • 上記のコードからわかるように、ASP.NET Web API の内部動作は、ほとんど ASP.NET MVC と同じです。よって、アンチリクエストフォーじぇり機能も Web API で転用することが可能です。
  • 楽観同時実行制御機能
    • ASP.NET MVC 同様、Web API の場合でも楽観同時実行制御について考えることが望ましいですが、ちょっと厄介なのは、特に SPA 型でアプリを作る場合、更新前の状態のデータをどこにどうやって残すのか、という点です。
    • ASP.NET MVC ではサーバ側に Session オブジェクトで残す、という実装方法が最も簡単ですが、Web API で同じことをすると、サーバ側 API をステートレスにすることができません。このため、基本的にはクライアント側で、更新前の状態を残しておき、これをサーバに(更新したデータとともに)渡して処理してもらう必要があります。
    • 具体的なやり方としては、シリアル化したデータを Base64 エンコードしてクライアント側に渡しておき、それをサーバ側に再送してもらってデシリアル化して使う、という方法になりますが、シリアル化/デシリアル化などの処理を自作しなければならないこともあり、ちょっと面倒です。とはいえ実際の開発では必要になるので、力試しも兼ねて頑張って実装してみてください。

また上記の実装では、ASP.NET MVC の入力検証機能を ASP.NET Web API に転用しているのですが、もともとこの方法は非サポートな方法です。このため、現在のバージョンでは動作しますが、今後の実装でも動き続けるかどうかはわかりません;。ただ、今回それでも敢えてこの実装方法を示したのは、実際のシステムで更新系アプリを作る場合、サーバ/クライアントでのロジック二重実装をどうやって避けるのかは必ず考えなければならない問題であるからです。これに対する考え方は何通りかに分かれます。

  • 上記のような実装を、自己責任で行う。
  • 3rd party 製ライブラリ(SPA ライブラリ)でこの問題を解決するものを探して使う。
  • あきらめて、サーバ側とクライアント側を二重実装する。
  • 更新系アプリについては、SPA 型での実装をあきらめ、Part 6 で示した MVC 型での実装にする。(=参照系アプリのみ SPA 型で実装する)

実際には一番最後の手法も一考の余地があります。というのも、SPA 型アーキテクチャにしてリッチにしたい UI の多くは、参照系であることが多いためです。この辺は、実際に開発しようとするアプリによって最適な選択肢が変わると思いますが、いずれにしても無理に SPA 型実装にこだわりすぎないようすることも、業務アプリ開発の場合には重要です。全体のバランスを見ながら、最適な実装指針を決めていただければと思います。

■ さらに学習を進めたい方へ

……というわけで、ASP.NET Core 1.0 を使った Web アプリの開発手法についてここまで解説してきました。今回はあくまで入門編、ということで、わかりやすい実装を心がけていますが、実際のシステム開発では DI コンテナを活用するなど、いろいろ知っておくと便利なテクニックもたくさんあります。さらに ASP.NET Core の学習を進めたい場合には、以下のようなリソースをぜひ参照してみてください。

  • ASP.NET Core Documentation
    • https://docs.asp.net/en/latest/
    • ASP.NET Core の公式ドキュメント。最も詳しくてわかりやすいので、詰まった場合は必ずここをチェック。
  • GitHub ASP.NET
    • https://github.com/aspnet
    • ASP.NET Core のソースコード。この中にテストコードが含まれており、それを見るとライブラリの使い方が確認できたりする。
    • ロードマップ情報や設計・実装に関する議論もすべてここでオープンになって開発されているため、突っ込んだ学習がしたい場合にはここを参照するとよい。