Part 5. ASP.NET Web API を使った SPA 型 Web アプリ開発
では引き続き、ASP.NET Web API を使って SPA 型で同じアプリを開発してみたいと思います。
■ SPA 型 Web アプリとは?
SPA (Single Page Application)型 Web アプリとは、単一ページで機能を提供する Web アプリです。具体的な設計・実装モデルとしては、データを Web API で取得し、画面を構築する処理をブラウザ側にもってくる形になります。
非 SPA 型 Web アプリと SPA 型 Web アプリの作り方の違いについては、de:code 2016 DEV-010 セッションにて詳しく解説しているのでそちらを見ていただくことにして、ここでは具体的な作り方について解説したいと思います。ここでは、① すべての著者データを一覧表示する、② 州によるフィルタリングを行う、という 2 つの画面を作成してみます。
■ ファイルの配置
まず、コントローラクラス Sample01Controller.cs とビューファイル ShowAllAuthors.cshtml, ShowAuthorsByState.cshtml を配置します。作成するのは 2 つのページですが、コントローラクラスは業務(ビューのフォルダ)単位でよいので、ここでは 1 つだけ作ります。(コントローラとビューは、先にビューから考えると配置しやすいです。)
まずは空のページを作っておきましょう。Sample01Controller.cs ファイルにアクションメソッド 2 つを用意しておきます。
using Microsoft.AspNetCore.Mvc;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
namespace Decode2016.WebApp.Controllers
{
public class Sample01Controller : Controller
{
[HttpGet]
public ActionResult ShowAllAuthors()
{
return View();
}
[HttpGet]
public ActionResult ShowAuthorsByState()
{
return View();
}
}
}
*.cshtml ファイルの方はこれから作っていきますので、とりあえず中身はタイトルぐらいで OK です。
@{ ViewData["Title"] = "全著者データの一覧"; }
<h4>全著者データの一覧</h4>
@{ ViewData["Title"] = "州による著者データの検索"; }
<h4>州による著者データの検索</h4>
作成したら、https://localhost:xxx/Sample01/ShowAllAuthors/ や https://localhost:xxx/Sample01/ShowAuthorsByState/ などを呼び出していただき、ページが表示されることを確認します。
■ ASP.NET Web API の作成
次に、ブラウザからデータを取り出すために必要な ASP.NET Web API の作成を行います。ASP.NET MVC Core ランタイムには ASP.NET Web API ランタイムも包含されていますので、このまま Web API を開発していくことが可能です。まずは全件のデータを取り出すための GetAllAuthors() メソッドを、Sample01Controller.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 Sample01Controller : Controller
{
[HttpGet]
public ActionResult ShowAllAuthors()
{
return View();
}
[HttpGet]
public ActionResult ShowAuthorsByState()
{
return View();
}
[HttpGet]
public List<Author> GetAllAuthors()
{
using (PubsEntities pubs = new PubsEntities())
{
var query = from a in pubs.Authors select a;
return query.ToList();
}
}
}
}
MVC と Web API のアクションメソッドはよく似ていますが、戻り値が異なる点に注意してください。実装できたら、ブラウザから https://localhost:xxxx/Sample01/GetAllAuthors を呼び出すと、この Web API の動作を確認することができます。
ここで押さえてほしいポイントは以下の通りです。
- MVC と Web API は、同一のコントローラクラス上に混在させることができる。(もちろんコントローラクラスを分けても構いません)
- サーバからは、JSON と呼ばれる形式でデータが送り返される。
- JSON データの各フィールドの先頭一文字が、小文字に自動的に置換されている。
3 点目に関しては若干注意が必要です。一般的に、サーバ側の C# ではクラス名やフィールド名は先頭を大文字にしますが、ブラウザ側の JavaScript では先頭を小文字にすることが多いです。このため、ASP.NET Web API の背後で利用されている Newtonsoft の Json.NET では、自動的にこの大文字/小文字変換を行うようになっています(※ 正確には ASP.NET Core 1.0 版から変更されています)。この挙動は、カジュアルな開発では便利ですが、きっちり開発する業務アプリ開発では気持ち悪いと感じる人もいると思います(← 私的には気持ち悪い;)。この挙動を抑えるためには、Startup.cs ファイルに以下のコードを追加してください。この blog では、下記コードがあるものとして解説を進めます。
public void ConfigureServices(IServiceCollection services)
{
services.AddMvc()
.AddJsonOptions(options =>
{
// 大文字・小文字の自動補正機能を無効化
options.SerializerSettings.ContractResolver = null; // CamelCasePropertyNamesContractResolver が刺さっているためこれを外す
});
//// 以下は XML フォーマッタを入れたり、単一文字列を返す Web API を作りたい場合に入れるとよい設定。
//services.Configure<MvcOptions>(options =>
//{
// options.RespectBrowserAcceptHeader = true;
// options.OutputFormatters.RemoveType<Microsoft.AspNetCore.Mvc.Formatters.StringOutputFormatter>();
//});
}
これを加えて同じ Web API を呼び出すと、以下のようになります。各フィールドの先頭一文字が C# と同じく大文字になります(=そのまま送出されている)。
さて、このまま使ってもよいのですが、実際の一覧表はすべてのフィールドのデータが必要なわけではないので、一部だけ絞り込むことにします。Models フォルダの下に AuthorOverview.cs クラスを追加し、以下のような構造体クラスを実装します。
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
namespace Decode2016.WebApp.Models
{
public class AuthorOverview
{
public string AuthorId { get; set; }
public string AuthorName { get; set; }
public string Phone { get; set; }
public string State { get; set; }
public bool Contract { get; set; }
}
}
そして、先に実装した Sample01Controller.cs クラスの GetAllAuthors() メソッドを以下のように修正します。
[HttpGet]
public List<AuthorOverview> GetAllAuthors()
{
using (PubsEntities pubs = new PubsEntities())
{
var query = from a in pubs.Authors
select new AuthorOverview()
{
AuthorId = a.AuthorId,
AuthorName = a.AuthorFirstName + " " + a.AuthorLastName,
Phone = a.Phone,
State = a.State,
Contract = a.Contract
};
return query.ToList();
}
}
このように実装すると、列が絞り込まれた JSON データが返されるようになります。(ブラウザから直接叩いて動作確認してみるとよいでしょう)
■ ブラウザ側の実装
引き続き、ブラウザ側の処理を実装します。ブラウザ側では、① Web API からのデータの取得、② 取得したデータの一覧表示、の 2 つが必要です。前者には jQuery を使うのがよいですが、後者については様々な方法があります。考え方や選択方法については de:code のセッションで解説しているのでそちらを確認していただくことにして、ここでは最も簡単(=予備知識が不要)な方法として、knockout.js を使ったデータバインドを使ってみたいと思います。
/Views/Sample01/ShowAllAuthors.cshtml ファイルを修正し、まずは jQuery を使ってすべてのデータを Web API から取り寄せる処理を記述します。下図にあるように、JavaScript のコードはページ固有の JavaScript コードブロックに記述しますので、@section Scripts { … } に記述します。
@{ ViewData["Title"] = "全著者データの一覧"; }
<h4>全著者データの一覧</h4>
@section Scripts {
<script type="text/javascript">
$(function () {
$.getJSON("/Sample01/GetAllAuthors", function (result) {
console.debug(result);
});
});
</script>
}
Ctrl + F5 キーでアプリを実行してから https://localhost:xxx/Sample01/ShowAllAuthors ページを呼び出しますが、その際、F12 ツールを利用すると、リモート通信の中身や、console.debug() 命令でロギングした情報を確認することができます。これにより、Web サーバと正しく通信できているのかが確認できます。
続いて、取り寄せたデータを knockout.js ライブラリを利用して表示してみます。まず、knockout.js ライブラリの組み込みは @section Libraries { … } で行いますが、このライブラリは複数のページで利用する可能性のあるライブラリです。このため、/Views/Sample01/ShowAllAuthors.cshtml ファイルにハードコードしてしまうとメンテナンス性が落ちます。このような場合には、/Views/Shared フォルダ下にファイルを作成しておき、これを組み込むようにしておくとよいでしょう。
[/Views/Shared/_ImportsLibraryKnockout.cshtml] (中身はたったの一行しかありませんが、敢えて切り出しておき、複数のページで再利用する)
<script src="https://ajax.aspnetcdn.com/ajax/knockout/knockout-3.3.0.js"></script>
[/Views/Sample01/ShowAllAuthors.cshtml]
@{ ViewData["Title"] = "全著者データの一覧"; }
@section Libraries {
@Html.Partial("_ImportsLibraryKnockout")
}
<h4>全著者データの一覧</h4>
@section Scripts {
<script type="text/javascript">
$(function () {
$.getJSON("/Sample01/GetAllAuthors", function (result) {
console.debug(result);
});
});
</script>
}
ここまでできたら、knockout.js を使ってデータバインドを行います。基本的な考え方は下図の通りで、ko.observableArray() を利用して ViewModel を作成し、これを介してコードと UI との間でデータバインドを行います。(knockout.js を本格的に使いたい、というのでなければ、細かいコードは理解しなくて構いません。ざっくり「こんな感じ」と理解してもらえれば十分です。)
ソースコードは以下のようになります。
@{ ViewData["Title"] = "全著者データの一覧"; }
@section Libraries {
@Html.Partial("_ImportsLibraryKnockout")
}
<h4>全著者データの一覧</h4>
<div class="table-responsive">
<table 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 data-bind="text: AuthorId"></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" />
<text data-bind="text: (Contract ? '契約あり' : '契約なし')"></text>
</td>
</tr>
</tbody>
</table>
</div>
@section Scripts {
<script type="text/javascript">
$(function () {
var viewModel = {
Authors: ko.observableArray()
};
ko.applyBindings(viewModel);
$.getJSON("/Sample01/GetAllAuthors", function (result) {
viewModel.Authors(result);
});
});
</script>
}
実行結果は下図のようになります。
では、同じ要領で、州によるフィルタリングアプリを開発してみたいと思います。
■ 州によるフィルタリングアプリの実装
州によるフィルタリングアプリを作成するためには、① 州の一覧データを取り出す Web API と、② 指定された州に属する著者の一覧を検索取得する Web API、の 2 つが必要です。Sample01Controller.cs クラスに以下の 2 つのメソッドを追加しましょう。
[HttpGet]
public string[] GetStates()
{
using (PubsEntities pubs = new PubsEntities())
{
var query = pubs.Authors.Select(a => a.State).Distinct();
return query.ToArray();
}
}
[HttpGet]
public List<AuthorOverview> GetAuthorsByState(string state)
{
if (Regex.IsMatch(state, "^[A-Z]{2}$") == false) throw new ArgumentOutOfRangeException("state");
List<AuthorOverview> result;
using (PubsEntities pubs = new PubsEntities())
{
var query = pubs.Authors.Where(a => a.State == state)
.Select(a => new AuthorOverview()
{
AuthorId = a.AuthorId,
AuthorName = a.AuthorFirstName + " " + a.AuthorLastName,
Phone = a.Phone,
State = a.State,
Contract = a.Contract
});
result = query.ToList();
}
return result;
}
なお、GetAuthorsByState() メソッドについては、 [HttpGet] で定義する方法と、[HttpPost] で定義する方法の両方が考えられます。正直なところ、どっちでも動作するのでどっちでもいいっちゃいいのですが;、このケースでは [HttpGet] にしておいたほうが便利です。理由は以下の通り。
- [HttpGet] にしておくと、ブラウザから簡単に動作確認ができます。
- https://localhost:xxx/Sample01/GetAuthorsByState/?state=CA などとして呼び出しを行うと、HTTP-GET でこの Web API を呼び出して、動作確認をとることができて便利です。
- このケースでは、HTTP プロトコルの規約からすると、[HttpGet] のほうが適切です。
- 通常、同じ州に対しては同じ著者一覧が帰ってくるはずです。このような場合には [HttpGet] のほうがよいです。
- 一方で、「処理要求伝票データを送って、処理結果伝票データを受け取る」ような設計の場合には、処理結果が毎回変わる可能性があるため、[HttpPost](伝票を投入する)の方がベターです。
ただし注意点として、HTTP-GET プロトコルで Web API を呼び出す場合、jQuery ライブラリはブラウザ側で呼び出し結果を自動的にキャッシュします。これは HTTP プロトコルの規約の考え方からすると正しいのですが、その一方で、業務アプリだと HTTP-GET の場合でもキャッシュしてほしくない、という場合もあると思います。このような場合には、jQuery の $.ajaxSetup() 処理を使って、キャッシュを無効化してください。集約例外処理も含め、_Layout.cshtml ファイルに以下のようなコードを追加するとよいでしょう。
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>@ViewData["Title"]</title>
<meta name="viewport" content="width=device-width, intial-scale=1.0" />
<link href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.5/css/bootstrap.min.css" rel="stylesheet">
<script src="https://code.jquery.com/jquery-2.1.4.min.js"></script>
<script src="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.5/js/bootstrap.min.js"></script>
<script type="text/javascript">
$(function () {
$.ajaxSetup({
cache: false,
error: function (xhr, status, err) { alert("通信エラーが発生しました。"); } // 集約通信例外ハンドラ
});
window.onerror = function (message, url, lineNumber) {
console.log(message);
var msg = "処理中にエラーが発生しました。" + message;
alert(msg);
return true;
};
});
</script>
@RenderSection("Libraries", required: false)
(以下略...)
続いて、UI を実装します。/Views/Sample01/ShowAuthorsByState.cshtml ファイルを開き、以下を実装します。考え方は上と同じです。
@{ ViewData["Title"] = "州による著者データの検索"; }
@section Libraries {
@Html.Partial("_ImportsLibraryKnockout")
}
<h4>州による著者データの検索</h4>
<div>
<select id="ddlStates" data-bind="options: States"></select>
<button id="btnShowAuthors">データ表示</button>
</div>
<hr />
<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 data-bind="text: AuthorId"></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" />
<text data-bind="text: (Contract ? '契約あり' : '契約なし')"></text>
</td>
</tr>
</tbody>
</table>
</div>
@section Scripts {
<script type="text/javascript">
$(function () {
$("#tblAuthors").hide(); // css('display', 'none') と同じ
// 後から値を入れたい場合には、ko.observable() と ko.observableArray() を割り当てておく
var viewModel = {
States: ko.observableArray(),
Authors: ko.observableArray()
};
ko.applyBindings(viewModel);
// サーバから州一覧を取り寄せてバインド
$.getJSON("/Sample01/GetStates", function (result) {
viewModel.States(result);
});
$("#btnShowAuthors").click(function () {
// クエリ文字列を引数に渡すには、第二パラメータにオブジェクトを渡す
$.getJSON("/Sample01/GetAuthorsByState", { state: $("#ddlStates").val() }, function (result) {
viewModel.Authors(result); // データを observableArray に流し込み
$("#tblAuthors").show(); // css('display', 'block') と同じ
});
});
});
</script>
}
できあがったら https://localhost:xxxx/Sample01/ShowAuthorsByState/ を呼び出して動作を確認してください。SPA 型で作られたデータバインド Web アプリケーションが動作します。
■ 様々な SPA 型アプリケーションの作り方
ここでは、ASP.NET Web API + ASP.NET MVC + jQuery + Bootstrap + knockout.js というライブラリの組み合わせにより SPA 型 Web アプリケーションを開発しました。しかし、ここまでのコードからわかるように、この方法は <table> タグを手で組み上げていく方法であるため、実装効率は必ずしもよくありません。業務アプリケーションのように、表が大量に出てくるようなケースでは、この方法では生産性がどうしても上がらないでしょう。このような場合には、ライセンス料は発生するものの、3rd party 製の高水準 UI ライブラリなどを使ったほうが生産性としてはよくなります。
クライアント側のライブラリをどのように選択するのかは、SPA 型 Web アプリケーション開発における大きな命題です。業務アプリケーションの場合には、下記のように高水準 UI 部品を必要とするか否かによって、基本的な方針を決めていくのがラクだと思いますが、最先端の開発技術を使って高度な UI を作っていくのであれば、まったく別の考え方を採ったほうがよいこともあります。
本エントリは ASP.NET Core 1.0 の概要を解説するものであるため、この部分についてはこれ以上踏み込みませんが、特にクライアント側のライブラリ選択や開発指針についてどのように考えていけばよいのかについては、de:code 2016 のセッション、およびそこからさらに発展させていただいている HTML5 experts さんのサイトが参考になると思います。より深い理解を進めたい方は、これらに目を通していくことをオススメします。
- de:code 2016 DEV-010 セッション「エンプラ系 業務 Web アプリ開発に効く! 実践的 SPA 型モダン Web アプリ開発の選択手法」
- HTML5 Experts エンプラ系Webアプリの講演を聞いて、もやもやしたので対談してみた ~「de:code 2016」セッションレポート