knockout.jsをASP.NET MVC 4 で使ってみた

前回に引き続き、knockout.jsをASP.NET MVC 4 で使ってみました。

今回作ったサンプルは席の予約を行うもので、席の追加、削除、編集をクライアント側(View、ViewModel)で行い
一括で登録するものです。

なので今回はMVVMパターン
・データを保持するPOCOのModel
・一覧を表示するView
・データの表示形式を保持し、データのCRUDを行うViewModel
に適用したことになります。

フレームワークASP.NET MVC 4、データベースへの登録は、EF CodeFirstを用います。

以下、実装方法です。

1.事前準備
前回と同様に、ASP.NET MVC4の「Internet application」のテンプレートよりプロジェクトを作成します。
次にScriptsフォルダに、knockout-2.0.0.jsを配置してください。


2.Modelの定義
予約情報を保持するReservationクラス、予約した席を表すReservationSeatクラス、
席のクラス(ファーストクラス等)を表すSeatClassクラスを作成します。
ReservationクラスとReservationSeatクラスは1:Nの関係です。

Model.cs


using System;
using System.Collections.Generic;
using System.Linq;
using System.Web;
using System.ComponentModel.DataAnnotations;

namespace MVVMSeatReservationSample.Models
{
public class Reservation
{
[Key]
[DatabaseGenerated(System.ComponentModel.DataAnnotations.DatabaseGeneratedOption.Identity)]
public int ReservationID {get;set;}
[Required]
public string Name {get;set;}
[Required]
public DateTime ReserveDateTime {get;set;}

public virtual ICollection ReservationSeats {get;set;}
}

public class ReservationSeat
{
[Key]
[DatabaseGenerated(System.ComponentModel.DataAnnotations.DatabaseGeneratedOption.Identity)]
public int SeatID { get; set; }
[Required]
public string Name {get;set;}
[Required]
[ForeignKey("Reservation")]
public int ReservationID { get; set; }
[Required]
[ForeignKey("SeatClass")]
public string SeatLevel { get; set; }

public virtual Reservation Reservation {get;set;}
public virtual SeatClass SeatClass {get;set;}
}

public class SeatClass
{
[Key]
public string SeatLevel {get;set;}
[Required]
public string Name {get;set;}
[Required]
public int Price {get;set;}
}
}


注意点としては、Modelにkey属性を付与するにはusing System.ComponentModel.DataAnnotations;が
必要なことです。


2.DbContextの定義
先ほどのModelをデータベースと関連づけするための、DbContextです。

StoreContext.cs


public class StoreContext : DbContext
{
public DbSet Reservations {get;set;}
public DbSet ReservationSeats { get; set; }
public DbSet SeatClasses {get;set;}
}


3.データベースの初期化
開発用のデータをアプリ起動時に登録するため、Initializerを作成します。

StoreContext.cs


public class StoreInitializer : DropCreateDatabaseIfModelChanges
{
protected override void Seed(StoreContext context)
{
var classes = new List
{
new SeatClass() { SeatLevel = "E", Name = "エコノミー", Price = 50000 },
new SeatClass() { SeatLevel = "B", Name = "ビジネス", Price = 200000 },
new SeatClass() { SeatLevel = "F", Name = "ファースト", Price = 1000000 }
};
classes.ForEach(s => context.SeatClasses.Add(s));
context.SaveChanges();
}
}

DropCreateDatabaseIfModelChangesクラスを継承している以外は
EF CodeFirstのデータを登録するプログラムであることが分かるかと思います。

このInitializerをアプリ起動時に実行するため、Global.asaxのApplication_Start()より
Initializerを呼び出すようにします。

Global.asax.cs


protected void Application_Start()
{
System.Data.Entity.Database.SetInitializer(new StoreInitializer());

(以下略)
}

Initializerは今回はModel層に記述しましたが、実運用では使用しないことを考えると
InitializerはModel層に書くのは良くないかもしれません。
本来なら開発用の別の層を作り、そこに置くべきではないかと・・・。


4.Controllerの定義
スキャフォールドを使い、Reservationモデル、SeatClassモデルよりControllerを作成します。
ReservationsControllerにはデータを登録するための処理を、SeatClassesControllerには
データを取得して画面に渡す処理を記述します。

ReservationsController.cs


using MVVMSeatReservationSample.Models;

namespace MVVMSeatReservationSample.Controllers
{
public class ReservationsController : Controller
{
private StoreContext db = new StoreContext();

[HttpPost]
public JsonResult Save(ICollection seats)
{
var errorMessage = validateReservation(seats);

if (errorMessage == null)
{
var rsrv = new Reservation();
rsrv.Name = User.Identity.Name;
rsrv.ReserveDateTime = System.DateTime.Now;
db.Reservations.Add(rsrv);

foreach(var seat in seats)
{
var rsrvSt = new ReservationSeat();
rsrvSt.Name = seat.Name;
rsrvSt.SeatLevel = seat.SeatLevel;
db.ReservationSeats.Add(rsrvSt);
}

db.SaveChanges();

return Json("登録完了しました。", JsonRequestBehavior.AllowGet);
}
else
{
return Json(errorMessage, JsonRequestBehavior.AllowGet);
}

}

private string validateReservation(ICollection seats)
{
if (seats == null) return "最低一つの予約をして下さい。";

foreach(var seat in seats)
{
if(seat.Name == null) return "名前を入力して下さい。";
}
return null;
}
(以下略)


データを登録するためにSave()アクションです。引数に予約する席を保持したリストを受け取ります。
引数の型は、ModelのReservationSeat型としています。
後で見るViewModelで定義するクラスと、ReservationSeatクラスのプロパティ名を同じにしてあるため
View側で入力した値を、そのままControllerに渡すことが可能となっています。

またエラーメッセージ、登録完了のメッセージを、return Json()を使いJson形式でViewに渡しています。

SeatClassesController.cs


using MVVMSeatReservationSample.Models;

namespace MVVMSeatReservationSample.Controllers
{
public class SeatClassesController : Controller
{
private StoreContext db = new StoreContext();

public JsonResult IndexJson()
{
var list = from p in db.SeatClasses
orderby p.Price
select p;
return Json(list, JsonRequestBehavior.AllowGet);
}
(以下略)


こちらは簡単で、SeatClassを全件取得し、Json形式でクライアント側に渡しています。


5.Viewの定義
ViewとViewModelはスキャフォールドで作成したViews\Reservations\Create.cshtmlに記述します。


@model MVVMSeatReservationSample.Models.Reservation

@{
ViewBag.Title = "Create";
}

<table>
<thead><tr>
<th>名前</th><th>クラス</th><th>料金</th><th></th>
</tr></thead>
<tbody data-bind="foreach: seats">
<tr>
<td><input data-bind="value: name" /></td>
<td><select data-bind="options: $root.availableClasses, value: seatClass, optionsText: 'Name'"></select></td>
<td align="right">¥<span data-bind="text: formattedPrice"></span></td>
<td><a href="#" data-bind="click: $root.removeSeat">削除</a></td>
</tr>
</tbody>
</table>

<button data-bind="click: addSeat, enable: seats().length < 5">追加</button>
<button data-bind="click: saveSeats">保存</button>

<h3>
予約合計<br />
席数 : <span data-bind="text: seats().length"></span>席<br />
金額 : ¥<span data-bind="text: totalPrice()"></span>
</h3>

<div>
@Html.ActionLink("Back to List", "Index")
</div>


foreach文でバインドされるViewModelをループして一覧表示しています。

ループして表示する項目を一つずつみると
・名前を入力するname
・選択項目であるavailableClasses
・クライアント側で表示形式を整えるformattedPrice
・クライアント側で削除を実行するremoveSeat
は、全てViewModelと紐づいています。

その下の追加ボタンのaddSeat、保存ボタンのsaveSeatsも、やはり実際の動きは
ViewModelに紐づいています。

次はそのViewModelです。


6.ViewModelの定義


<script type="text/javascript">
// Class to represent a row in the seat reservations grid
function SeatReservation(name, initialClass) {
var self = this;
self.name = name;
self.seatClass = ko.observable(initialClass);

self.formattedPrice = ko.computed(function () {
return formatPrice(self.seatClass().Price);
});

self.SeatLevel = ko.computed(function(){
return self.seatClass().SeatLevel;
});
}

// Overall viewmodel for this screen, along with initial state
function ReservationsViewModel() {
var self = this;

// Non-editable data
$.getJSON("@Url.Action("IndexJson", "SeatClasses")", function (data) {
self.availableClasses = data;
});

// Editable data
self.seats = ko.observableArray();

// Computed data
self.totalPrice = ko.computed(function () {
var total = 0;
for (var i = 0; i < self.seats().length; i++)
total += self.seats()[i].seatClass().Price;
return formatPrice(total);
});

// Operations
self.addSeat = function () {
self.seats.push(new SeatReservation("", self.availableClasses[0]));
}
self.removeSeat = function (seat) {
self.seats.remove(seat)
}
self.saveSeats = function () {
$.ajax({
url: "/Reservations/Save/",
type: 'post',
data: ko.toJSON(this),
contentType: 'application/json',
success: function (result) {
alert(result);
}
});
}
}

function formatPrice(str) {
var num = new String(str).replace(/,/g, "");
while (num != (num = num.replace(/^(-?\d+)(\d{3})/, "$1,$2")));
return num;
}

ko.applyBindings(new ReservationsViewModel());

</script>


Viewの各項目が、ViewModelのどれに紐づいているのかを箇条書きします。

・名前を入力するname
SeatReservationクラスのnameプロパティ。
このプロパティは、ReservationControllerの引数として参照される。

・選択項目であるavailableClasses
ReservationsViewModelクラスのavailableClassesプロパティ。
getJSON()にてSeatClassesControllerのIndexJson()アクションを呼び出しています。
これによりSeatClassesController内で取得したSeatClassの全件を、
Viewのドロップダウンリストに表示しています。

・クライアント側で表示形式を整えるformattedPrice
SeatReservationクラスのformattedPriceプロパティ。
プロパティ内で別関数を呼び出すことで、クライアント側で金額の表示形式を整えています。

・クライアント側で削除を実行するremoveSeat
ReservationsViewModelクラスのremoveSeatメソッド。
メソッド内で呼び出しているremove()はknockout.jsが用意している機能で、文字通り一覧より削除しています。

・追加ボタンのaddSeat
ReservationsViewModelクラスのaddSeatメソッド。
メソッド内で呼び出しているpush()も、knockout.jsが用意している機能で、一覧に追加しています。
引数に一件毎のデータを表すSeatReservationクラスのインスタンスを渡しています。

・保存ボタンのsaveSeats
ReservationsViewModelクラスのsaveSeatsメソッド。
ajaxでReservationsControllerのSaveアクションを呼び出しています。
data: ko.toJSON(this)で引数にReservationsViewModelをJson形式に変換して渡し、
success: function (result)で戻り値を受け取っています。


以上でソースコードの解説は終わりです。
実行すると以下のようになります。

初期表示。
f:id:UnderSourceCode:20130504100612j:plain

追加ボタンを押す。
f:id:UnderSourceCode:20130504100626j:plain

値を入力すると赤枠の金額などが自動的に計算される。
f:id:UnderSourceCode:20130504100640j:plain

名前を空のまま保存ボタンを押すと、Controllerから返されたエラーメッセージを表示する。
f:id:UnderSourceCode:20130504100653j:plain

全てを入力して保存すると、登録完了のメッセージが表示される。
f:id:UnderSourceCode:20130504100706j:plain

Githubにソースを公開しました。
https://github.com/UnderSourceCode/MVVMSeatReservationSample