読者です 読者をやめる 読者になる 読者になる

dunno logs

神山生活四年目

ASP.NET MVC で Ajax を使った時の書き方に悩んだ

その前に

気がつけばインポート機能もできてるっぽいので試しにはてなブログで書いて見る事に。良さそうなら移行しよう。

状況

Ajax.BeginForm で UpdateTargetId を指定すると PartialView を返却するとそこに replace or append する挙動をする。それ自体はとても便利なのだが、当然ながら返している PartialView がエラーメッセージだろうが、結果だろうが同じ所にしか差し込めない。(UpdateTargetId で動的に変えられるのかな。)

今やりたいことは

Ajax.BeginForm した先の controller でバリデーションチェックをしており、それが失敗したり、またはバリデーションは通るが、その後の DB への保存処理で失敗した場合はエラーメッセージを出す必要がある。また、逆に全て成功した場合には、一覧表の描画をリフレッシュしたいようだ。

f:id:dany1468:20130108115609p:plain

まあ、なんてことはない処理である。てゆうか正直に言えば、本当にこの機能だけなら ajax なんか使わずに、普通に form 使ってやれば簡単に実装できる。が、今回はケーススタディとして。

つまり

UpdateTargetId を使おうとしてもその Id を一箇所しか指定できない以上は今回の要件は満たせない。
社内の別チームのコードも見てみたが、UpdateTargetId はエラーメッセージエリアを指定し、失敗時は PartialView でそのエリアにメッセージを表示できるようにし、成功時は Json で返却し、AjaxOption の OnSuccess 内で JavaScript でタグを生成して表示するということをしていた。

おそらく以下のページに近い事をしているのだと思う。
http://stackoverflow.com/questions/12599090/ajax-beginform-onfailure-invoked-when-modelstate-is-invalid

確かにエラーメッセージエリアはページ内に複数の form エリアがあっても共通である事が多いので、エラーのために UpdateTargetId を利用するというのは利便性を考えると割りとありかなとも思う。 OnSuccess 内で JavaScript でタグを作る部分は、個人的には文字列でタグを作るのは微妙なので後述する。

Agenda

  1. UpdateTargetId のみでやる
  2. OnSuccess だけでやる
  3. OnSuccess と OnFailure でやる

ワードだけだと意味不明ですが、上記は全て AjaxOptions に指定するものです。

@using (Ajax.BeginForm("Register", new AjaxOptions() { UpdateTargetId = "contents", OnSuccess = "success", OnFailure = "fail"}))

1. UpdateTargetId のみでやる

やってみて思ってのは割りと pjax のサンプルとかで使われそうな形式だなーという感じ。インスパイアされたのは以下のサイトから。

http://www.codeproject.com/Articles/460893/Unobtrusive-AJAX-Form-Validation-in-ASP-NET-MVC

上記サイトベースで説明します。コードは全く同じもの部分的に貼り付けているだけなので、上記サイトからソース落してもらう方が早いです。

Layout は分ける

_ViewStart.cshtml を使って個別の View には Layout の定義を含めなくて良いようにします。

_Layout.cshtml

<!DOCTYPE html>
<html>
	<head>
		<title>@ViewBag.Title</title>
		<link href="@Url.Content("~/Content/Site.css")" rel="stylesheet" type="text/css" />
		<script src="@Url.Content("~/Scripts/jquery-1.7.1.min.js")" type="text/javascript"> </script>
		<script src="@Url.Content("~/Scripts/jquery.unobtrusive-ajax.min.js")" type="text/javascript"> </script>
	</head>

	<body>
		<div id="ParentDiv">
			@RenderBody()
		</div>
	</body>
</html>

_ViewStart.cshtml

@{
    Layout = "~/Views/Shared/_Layout.cshtml";
}

UpdateTargetId は Layout の Id を指定する。

こうする事で、RenderBody の部分のみを書き換えるという事が簡単にできるようになります。

Index.cshtml

<div id="Aj">
    @{ var ajaxOptions = new AjaxOptions
           {
               UpdateTargetId = "ParentDiv",
               HttpMethod = "POST"
           }; }
    @using (Ajax.BeginForm("Index", "Account", ajaxOptions))
    {
        @Html.EditorForModel()
        <input type="submit" />
    }
</div>

Controller の 返り値に Layout 無しの View を返却する。

これは簡単で ASP.NET MVC の場合は PartialView で返せばいいだけ。Rails なら layout を false とかしますよね。

AccountController.cs

[HttpPost]
public ActionResult Index(Account model)
{
	if (ModelState.IsValid)
	{
		return PartialView("Thanks");
	}
	return PartialView("Index");
}

これで OK

こうしておけば、Ajax.BeginForm の返り値として Layout 無しの View が返却され、それが Layout の RenderPartial の部分に入れ替えで配置されるため、構造は全く変わる事なく非同期での更新ができた事になります。

ErrorMessage 用のエリアも予め用意しておき、Error 用の Model が存在する時だけ表示するようにしておけば、今回したかった事も実現できます。

問題

私が携わっているシステムは Layout が結構重いのでこれだけでも結構レスポンスのサイズを削減できた事になります。
ただ、自動で差し替えられる事になるので、処理後に何かするというのは結構難しいです。OnSuccess は当然ながら起動しますが、それがサーバー側での成功なのか失敗なのかは分からないからクライアント側ではどういう事をやればいいかが分からないからです。(もちろん OnSuccess にも PartialView の内容は入ってくるので、解析してどうこうはできますが。)

2. OnSuccess だけでやる

これは、前段で紹介した社内で見たアプローチを少し変形したものです。社内の例では、失敗時は UpdateTargetId に Partial を流しこむ、成功の時は Json を返し、クライアント側で動的に DOM を構築するという手法でした。
これを解決しようと思うと、まっさきに思いつくのはクライアント側にもテンプレートライブラリを入れて、 DOM の直接構築をもう少し楽にしようとするアプローチだと思います。私もそうしようかと思ったのですが、Rails の方では render_to_string で partial view をレスポンスに突っ込んでいるのを見たことがあったので、そちらの手法を模索する事にしました。

Rails の参考: http://d.hatena.ne.jp/rochefort/20120116/p1

render_to_partial が無い

どうやら ASP.NET MVC には (調査当時は ver 3) render_to_string 相当の機能は標準では用意されていないようでした。ただ、同様の事を考えている人がいるようでしたので、そちらを参考にしました。

参考実装

http://blog.janjonas.net/2011-06-18/aspnet-mvc3-controller-extension-method-render-partial-view-string

/// <summary>
/// Renders a (partial) view to string.
/// </summary>
/// <param name="controller">Controller to extend</param>
/// <param name="viewName">(Partial) view to render</param>
/// <param name="model">Model</param>
/// <returns>Rendered (partial) view as string</returns>
public static string RenderPartialViewToString(this Controller controller, string viewName, object model)
{
  if (string.IsNullOrEmpty(viewName))
    viewName = controller.ControllerContext.RouteData.GetRequiredString("action");

    controller.ViewData.Model = model;

    using (var sw = new StringWriter())
    {
      var viewResult = ViewEngines.Engines.FindPartialView(controller.ControllerContext, viewName);
      var viewContext = new ViewContext(controller.ControllerContext, viewResult.View, controller.ViewData, controller.TempData, sw);
      viewResult.View.Render(viewContext, sw);

      return sw.GetStringBuilder().ToString();
    }
  }
}

UpdateTargetId からの書き換え

Index.cshtml を以下のように書き換える

<div id="Aj">
    @{ var ajaxOptions = new AjaxOptions
                         {
                             //UpdateTargetId = "ParentDiv",
                             OnSuccess = "onSuccess",
                             HttpMethod = "POST"
                         }; }
    @using (Ajax.BeginForm("JsonReturn", "Account", ajaxOptions))
    {
        @Html.EditorForModel()
        <input type="submit" />
    }
</div>

<script type="text/javascript">
    function onSuccess(data) {
        if (data.StatusCode == 1) {
            alert(data.StatusMessage)
            $('#ParentDiv').html(data.ResponseView)
        }
    }
</script>

加えて AccountController.cs に新しいメソッドを追加

public ActionResult JsonReturn(Account model)
{
    return Json(new
    {
        StatusCode = 1,
        StatusMessage = "Success",
        ResponseView = this.RenderPartialViewToString("Index")
    });
}

ここでは StatusCode 等も固定値を返しているが、ここを変更すれば OnSuccess 内で分岐もできるため、その結果で ResponseView の更新先を変える等すれば失敗の時はエラーメッセージエリアの更新、成功の場合は一覧の更新、また、プラスαの処理も記述する事ができる。

ちなみに

ASP.NET MVC では成功失敗時の処理を AjaxOption での指定になっているが、Rails の例では ajax:success 等の jQuery のトリガとして扱っている事が分かる。
http://www.simonecarletti.com/blog/2010/06/unobtrusive-javascript-in-rails-3/
上記ページを見て貰えるとわかるが、RailsjQueryajax イベントをトリガに差し替えているだけである。ASP.NET MVC も同様に jquery.unobtrusive-ajax.js の実装を見れば、単純に結果によって、form の data 属性を参照して関数呼び出しを行なっているだけである事が分かる。

1 との違い

1 と異なり JavaScript の実装を書くことになったのが大きな違いである。社内でもそうだが、ずっとコンパイル言語を触っている人は web アプリを書いていても JavaScript をゴリゴリ書くのに抵抗がある人がいるが、この辺りは ajax を使っている以上は JavaScript を書くものではないだろうかと思ったり。Rails なんかは 3 になってより JS をゴリゴリ書く (書ける?) ような感じになったイメージがある。

Rails と違いサーバー側で View をレンダリングする機能が標準では無いためその使用に若干の躊躇はある。
ASP.NET MVC では validation もクライアントサイドで楽にできる機構が入っていたりと、(サーバーサイドでももちろんバリデーションはするが) できる部分はできるだけクライアントで、という方針があるのかもしれない。そうなると、あえてサーバーサイドでのレンダリング機能は設けず、json を返してクライアント側でテンプレートなりを使ってレンダリングするように仕向けているのかも。
このあたりはもう少し情報を集めてみたいところ。

3. OnSuccess と OnFailure でやる

なぜ 2 ではイマイチか

2 の実装をした時点で Rails エンジニアの話も聞きたかったので Rails やってる人に話を聞きにいったのだが、その人は似たアプローチは取りつつも、明確に異なる事をしている箇所があった。それは、サーバーからのステータスを http status code で表現している事であった。

そう、確かに 2 の実装で一番気持ちが悪かったのは OnSuccess なのにエラーメッセージを出したり、返却しているモデル (json ではあるが) にフラグが紛れ込んでいる事であった。別に普通のメソッド呼び出しならいいのだが、アプリ内とはいえ API 呼び出しに近い感じでサーバーを叩いているのに、成功ステータスでエラー処理という事をやるのはイマイチだなーと感じていた。

その人は Rails3 で同じように jQuery を使っているがバリデーションエラーは 400 (Bad Request) を返し、ajax:failure で処理をしているらしかった。(ASP.NET MVC では AjaxOptoins の OnFailure)

個人的には割りとしっくりいったので、スタディとして実装してみる。

とりあえず status code を返すには

ASP.NET MVC では HttpStatusCodeResult があるので、それを使う。

Index.cshtml を以下のように書き換える

<div id="Aj">
    @{ var ajaxOptions = new AjaxOptions
                         {
                             //UpdateTargetId = "ParentDiv",
                             OnSuccess = "onSuccess",
                             OnFailure = "onFailure",
                             HttpMethod = "POST"
                         }; }
    @using (Ajax.BeginForm("StatusCodeReturn", "Account", ajaxOptions, new { id = "form1" }))
    {
        @Html.EditorForModel()
        <input type="submit" />
    }
</div>

<script type="text/javascript">
    function onSuccess(data) {
        $('#ParentDiv').html(data)
    }
    
    function onFailure(xhr, status, error) {
        if (xhr.status == 400) {
            alert('400 error')
        }
    }
</script>

加えて AccountController.cs に新しいメソッドを追加

public ActionResult StatusCodeReturn(Account model)
{
    if (ModelState.IsValid)
    {
        return PartialView("Thanks");
    }
    return new HttpStatusCodeResult((int) HttpStatusCode.BadRequest, "");
}

これで確かに 400 status code が OnFailure で処理できるようになった。しかし、特に付加情報を入れられる訳でもなく、デバッグ環境においてはエラーページが返却されてきた。

f:id:dany1468:20130108141838p:plain

JsonResult を返し status code だけ変える

AccountController.cs を変更

public ActionResult StatusCodeReturn(Account model)
{
    if (ModelState.IsValid)
    {
        return PartialView("Thanks");
    }
    Response.StatusCode = (int)HttpStatusCode.BadRequest;
    return Json(new { data = "bad error" });
}

Index.cshtml JavaSctip のみ変更

function onFailure(xhr, status, error) {
    if (xhr.status == 400) {
        alert(JSON.parse(xhr.responseText).data) // OnFailure には data は入ってこないため xhr から取り出す
    }
}

これで各種エラーのメッセージも含める事ができるようになった。サーバーサイドで RenderPartial エラーメッセージ部分のみレンダリングしておけば、単に jQuery で差し込むだけにする事もできる。まあ、メッセージだけであればそこまでする必要はないかもしれないが。

まとめ

まあ、最終的には実装担当の後輩さんの好みに合わせようと思う。最後の status code を使った表現は現場によってはやりすぎと取られるかもしれないし、その辺はコーディングの規約をどうするかでしかないような。
そもそもあまり JavaScript を使わない静的なページなのなら 1 つ目で十分だし、Layout が重くないのなら Ajax での部分更新すら使う必要が無い。

とはいえ、ASP.NET MVC は 2 の頃ぐらいに飽きてしまって触っていなかったので、久しぶりにちゃんと見て勉強になりました。きっと他にも方法あると思うので是非知りたいところです。