technoshop

杉並区和泉のソフトウェアハウス、株式会社テラソフトの技術ブログです。主に.NET MVCとKnockoutJSの情報をまとめます。

.Net MVC4 で KnockoutJS ~【応用編4】Knockoutの多層オブジェクトをWebAPIで一気にセーブ

こんにちは。ザクです。

前回、けっこう手間をかけてセーブのためにモデルまわりの調整をしました。Knockoutで作成されたオブジェクトをMVC4のWebAPI経由でDBにセーブするための準備だったわけですが、ここまで準備できているとセーブの方はわりとさっくり出来てしまいます。さっそくやってみましょう~

モーダルにSaveボタンを設置する

とりあえず、モーダルにSaveボタンを設置します。これを押すことで、モーダルに表示されている状態をDBに保存できるようにするのが本日のお題です。モーダルのビューを直します。

    <div class="modal-footer">
        <div id="mcontrols" class="row">
            <div id="mmessage" class="col-md-6">
                
            </div>
            <div class="col-md-6 text-right">
                <button class="btn btn-primary" data-bind="click: save">Save</button>
                <button type="button" class="btn btn-default" data-dismiss="modal">Close</button>
            </div>
        </div>
    </div>

モーダルのフッター部分にBootstrapのbtn-primaryで新たにボタンを追加し、clickバインディングでsaveに紐付けました。Studentビューモデルの中にself.saveを追加すればファンクションとして呼び出せるはずですね。

さらにフッターをBootstrapのcol-mg-6を使ってボタンが表示されている行を2つに割りました。前の部分をmmessageとして、中は空にします。ここにSaveボタンを押された後に表示するメッセージを入れる予定です。あとで、JavaScriptcssなどで調整することになります。後の半分をボタンの入れ物としました。divのcol数を指定するだけで、勝手にレイアウトしてくれるのでチョー便利ですね。

Saveファンクションをビューモデルに追加する

次にボタンが呼び出すファンクションです。記述先は、Studentモデルの中ですね。モーダル全体がStudentオブジェクトにバインドされているので、この中で押したボタンはStudentの中のファンクション呼び出しになります。self.saveとしてファンクションを作ります。

        self.save = function () {
            var stddata = {
                "StudentId": self.StudentId(),
                "LastName": self.LastName(),
                "FirstMidName": self.FirstMidName(),
                "EnrollmentDate": self.EnrollmentDate(),
                "Enrollments": self.Enrollments(),
            };
            Update(ko.toJSON(stddata), "StudentAPI", self.StudentId());
        }

Studentモデルの一番下、canAddの下に上のコードをアペンドしました。中身は単に、stddataという保存用のオブジェクトの入れ物を用意して、モーダルに使われているStudentのデータを割り当てます。モーダルから呼び出された時点でどのStudentと紐付いているのかKnockoutはわかっているので、そのままself.???とStudentのプロパティを渡してやるだけです。各プロパティに()を付けるのを忘れないで下さい。KOのObservableはファンクションなので、中の値を取るには()が必要です。覚えてますか~

EnrollmentsだけはObservableArrayです。これにはMVC4側のDAL/SchoolContext.csの中でDbSetとして定義されている名前と同じ入れ物を作り、そこへObservableArrayをそのまま渡すことでデータが入ります。

これはKOからWebAPIでPUTする時の重要なポイントです。これが出来るのでセーブの記述が最小限で済みます。Enrollments以下にもいくつか階層があったとしても、セーブの単位となるオブジェクト(ここではStudentですね)にまとめて渡すだけで、全体のセーブが出来るようになるんです。

文だけでは伝わりにくいので、後で実例を見せて説明しますね。

Studentモデルのファンクションが出来たので、実際にWebAPIを呼び出すファンクションを作ります。

@section Scripts {
<script>   
function Update(data, path, id) {
    var url = '/api/' + path + '/' + id;
    $.isLoading({ text: "Updating", tpl: '<span class="isloading-wrapper %wrapper%">%text% <img src="/Content/images/ajax-loader.gif"/></span>' });
    setTimeout(function(){
        $.ajax({
            url: url,
            type: "PUT",
            contentType: 'application/json; charset=utf-8',
            data: data,
            success: function (result) {
                // Handle the response here.
                $.isLoading("hide");
                $("#mmessage").html('<span id="msuccess">Saved!</span>');
                $("#msuccess").fadeOut(5000);
            },
            error: function (jqXHR, textStatus, errorThrown) {
                // Handle error.
                $.isLoading("hide");
                $("#mmessage").html('<span id="merror">Error. Please contact Admin.</span>');
            }
        });
    }, 1000);
}
</script>
<script type="text/javascript">
(function () {
    var COURSES = @Html.Raw(Json.Encode(ViewBag.Courses));

Index.cshtmlの@section Scriptsの先頭に新たにscriptタグで囲まれたUpdateファンクションを作りました。KOのscriptの中に書かなかったのは、後々、ユニットテストをするためにファイルを分けるのに都合がいいからです。また、このUpdateファンクションは、WebAPIの名前がpathで指定できるので、Studentだけでなく別のモデルのセーブにも使えます。

やってることはself.saveから渡されたdata、path、idを使ってStudentAPIのPUTを$.ajaxで呼び出しているだけですが、処理中や処理の結果をうまくユーザーに表示するためのJQueryが色々入っています。

最初のisLoadingはSaveボタンが押されたら、モーダルを含めて画面全体をグレーアウトして、ローディングのアイコンをグルグル回します。これで、「今セーブをやっている」とユーザーに示すわけです。また、ボタンの2重押しの処理とか面倒臭いので、グレーアウトして2回押せなくするという効能もありますね。

successとerrorは処理結果に応じてメッセージを表示します。処理が終わったら、isLoadingをhideで完了し、フッターにつけたdivにメッセージを表示します。successの場合は緑の文字で「Saved!」と表示させ、ずっと表示しても意味がないので5秒でフェードアウトさせます。errorの場合は、赤文字で「Please contact Admin.」を表示させっぱなしにしました。

処理が早すぎるとLoadingが表示されても早すぎて見えないので、seTimeoutで最低1秒は表示するようにしました。ユーザーは必ず1秒待つことになりますが、こうしたほうがUXが統一できてメリットが多かったりします。PUTの呼び出しはこれだけです。

isLoadingとメッセージスタイルの調整

最後は見た目の調整です。これをやるかやらないかで、出来上がりのクオリティが大きく変わるので不思議なものです。手を抜かずにやりたいもんです。

isLoadingはローティングを表示するためのJQueryライブラリなので、ダウンロードしてきてプロジェクトに追加します。

isLoading jQuery plugin

Scriptsフォルダの中にコピーしたら、ビューに取り込まれるようにバンドルするんでしたよね。おぼえてますか~?

        public static void RegisterBundles(BundleCollection bundles)
        {
            bundles.Add(new ScriptBundle("~/bundles/js").Include(
                        "~/Scripts/jquery-{version}.js",
                        "~/Scripts/jquery.unobtrusive*",
                        "~/Scripts/jquery.validate*",
                        "~/Scripts/jquery.isloading*",
                        "~/Scripts/knockout-{version}.js",
                        "~/Scripts/knockout-array-oftype.js",
                        "~/Scripts/bootstrap.js"));

            var commonStylesBundle = new CustomStyleBundle(BootstrapPath);
            commonStylesBundle.Orderer = new NullOrderer();
            commonStylesBundle.Include("~/Content/bootstrap/bootstrap.less");
            bundles.Add(commonStylesBundle);

            bundles.Add(new StyleBundle("~/Content/css").Include("~/Content/site.css"));
        }

次にisLoadingのグルグルやメッセージが綺麗に表示されるようにcssを追加します。ザクは、アプリ用に追加するcssはデフォルトのSite.cssを取っておいて、そこに加えるようにしています。これならBootstrapを直接いじることはないですし、BSだけをアップデートしたり、テーマを変更したりしやすいですよね。

#mmessage {
    text-align: left;
}

#mmessage #msuccess {
    color: #18bc9c;
}

#mmessage #merror {
    color: #e74c3c;
}

.isloading-overlay {
  position: relative;
  text-align: center;
}
.isloading-overlay .isloading-wrapper {
  background: #FFFFFF;
  -webkit-border-radius: 7px;
  -webkit-background-clip: padding-box;
  -moz-border-radius: 7px;
  -moz-background-clip: padding;
  border-radius: 7px;
  background-clip: padding-box;
  display: inline-block;
  margin: 0 auto;
  padding: 10px 20px;
  top: 10%;
  z-index: 9000;
}

さいごに、isLoadingで使うグルグルのgifをContent/imagesに入れておきます。イメージの場所は別の場所でも良いですが、その場合、Updateの中のisLoadingの中身を変更することをお忘れなく。グルグルgifは適当なものを入れてください。大事なのはUpdateの中とファイル名が合っているかだけです。

ここまでできたら、いったんSaveボタンを押してみます。

MVC4の多階層モデルのセーブにはGraph Diffを使う

Saveボタンを押すと、「Saved!」の文字がフッターの左側に表示されてフェードアウトしていきます。とりあえず、期待通りの動きです。ただ問題は、ここでIndexをリロードしても変更点が反映されず、データを取ってきた初期状態に戻ります。それもそのはずで、StudentAPIでセーブしているのはStudentのオブジェクトだけで、肝心のEnrollmentsは無視されるからです。

        // PUT api/StudentAPI/5
        public HttpResponseMessage PutStudent(int id, Student student)
        {
            if (!ModelState.IsValid)
            {
                return Request.CreateErrorResponse(HttpStatusCode.BadRequest, ModelState);
            }

            if (id != student.StudentId)
            {
                return Request.CreateResponse(HttpStatusCode.BadRequest);
            }

            db.Entry(student).State = EntityState.Modified;

            try
            {
                db.SaveChanges();
            }
            catch (DbUpdateConcurrencyException ex)
            {
                return Request.CreateErrorResponse(HttpStatusCode.NotFound, ex);
            }

            return Request.CreateResponse(HttpStatusCode.OK);
        }

上はスキャフォールディングで作られたまんまのPutStudnetです。ModelState.IsValidで送られてきたデータを確認し、問題なければdb.Entry(student).State = EntityState.Modified;で変更を反映させてからSaveChangesします。よくあるアップデートのパターンです。

EntityState.Modifiedを使ったUpdateはMVC4の不便ポイントの一つで、階層のあるデータを受け取った時に、一層目のデータしか更新できません。シングルページアプリでは、データは何階層にもなっていて、下の層のデータは追加されたり、変更されたり、削除されたり様々な状態になっていることがほとんどです。せっかくシングルページアプリのデータの状態を正確にキャプチャしMVC4側に渡しても、MVC4側ではデータの状態を分析して良きにはからってはくれません。これをやるには、コードの中で受け取ったオブジェクトの奥のほうまでループで掘り下げて、一個一個状態確認し、Add、Update、Deleteのどれを行うか決めて処理する必要が出てきます。

これ、今回のモデルのようにプロパティが少なく、2層くらいなら我慢してコードを書こうかと思いますが、プロパティが大量にあり、また階層も4層以上になるとコントローラーに大量のコードを書くことになりゲンナリします。

そこで、この問題を解決するために使うのが「GraphDiff」です!


Introducing GraphDiff for Entity Framework Code First - Allowing automated updates of a graph of detached entities - Software Development Blog. AngularJS, C#, .NET, EntityFramework, NodeJS, MongoDB. Tips, Tricks & Gotchas.

GraphDiffはBrent Mckendrickさんが作った、デタッチされたエンティティグラフをアップデートするためのツールです。よくわからない説明です。つまり、Jsonで渡されたデータ(MVC4ではデタッチされたエンティティとみなされる)も、データの型がMVC4の型と合致していたら、そのデータをMVC4のモデルとして状態を判断しAdd、Update、Deleteを行います!

説明書くより、見せたほうが早いですね。GraphDiffはNugetのパッケージになっているのでVSのパッケージマネージャーからインストールできますが、最新版はMVC5/EF6用となっているため、MVC4/EF5用のものを使うには、プロジェクトをダウンロードしてきて自分でコンパイルしてパッケージを作る必要があります。コンパイル用のプロジェクトはこちらからダウンロードできます。(EF4-5のブランチに切り替えます。)


refactorthis/GraphDiff · GitHub

GraphDiffをインストールできたら、PutStudentのコードを修正します。

// 上にusing RefactorThis.GraphDiff;も追加する!!
// PUT api/StudentAPI/5
        public HttpResponseMessage PutStudent(int id, Student student)
        {
            if (!ModelState.IsValid)
            {
                return Request.CreateErrorResponse(HttpStatusCode.BadRequest, ModelState);
            }

            if (id != student.StudentId)
            {
                return Request.CreateResponse(HttpStatusCode.BadRequest);
            }

            //db.Entry(student).State = EntityState.Modified;

            try
            {
                db.UpdateGraph(student, map => map
                    .OwnedCollection(s => s.Enrollments));
                db.SaveChanges();
            }
            catch (DbUpdateConcurrencyException ex)
            {
                return Request.CreateErrorResponse(HttpStatusCode.NotFound, ex);
            }

            return Request.CreateResponse(HttpStatusCode.OK);
        }

真ん中のdb.Entryはコメントアウトします。代わりにtryの中で、db.UpdateGraphを追加しました。EnrollmentsはStundentに対して一対多のモデルになるのでGraphDiffでは.OwnedCollectionを使います。GraphDiffの説明によると、一対一ならOwnedEntity、多対多ならAssociatedCollectionを使います。ここではやりませんが、アップデートする階層を掘り下げる場合はOwnedCollectionのなかでwithを使って、更に下の層のオブジェクトと関連付けられます。また、同じレベルで複数のオブジェクトと関連付ける場合は、.OwnedCollectionを複数並べます。文では説明しづらいですが、下のリンク先の記述サンプルを見たら、なんとなく記述方法がわかると思います。

The art of simplicity: Entity Framework Tooling: GraphDiff

ビルドして、セーブしてみます。生徒のモーダルを開き、適当に授業を追加してSaveボタンを押してみます。緑で「Saved!」と表示されました。Indexをリロードします。最初に取ってくるデータに今追加した授業の分が反映されています!

f:id:technoshop:20141110142456j:plain

Graph DiffはJson/WebAPI専用というわけではなく、普通にMVC4のセーブにも使えます。今回のチュートリアルではAddにしか使っていませんが、Update、Deleteもできますので、これらの実装にもチャレンジしてみてください!