2009年3月2日月曜日

カスタムViewEngineを試す

ASP.NET MVCに限った話ではないんですが、JavaScriptをページで使う場合、外部ファイル(scriptタグのsrc属性で指定する)にするか、インラインでページに直接書く(scriptタグ内にコードを書く)かどっちかになる。

今時の作り方ならJavaScriptはページの最後で外部ファイルを取り込むのが、パフォーマンス的にもよろし、ということになってますね(ハイパフォーマンスWebサイト)。

ASP.NET MVCでも、もちろん外部JSファイルを使ったアプリケーションを作るわけですが、ここで少し悩みが出てきました。

処理コード(静的)としてのJavaScriptは簡単に外部に出せるから問題にはならないんですが、サーバーサイドで生成したデータを元に処理するコードの場合、そのデータをどうやって外部ファイルで使えばいいでしょうか。分かりにくい説明ですが、例えばサーバー上でデータベースから名前一覧を取得してViewDataに入れておいたとします(こればっかりは動的)。そのViewDataを外部JSファイルではもちろんそのまま参照できません。と、いうのもscriptタグで取り込んだ外部JSファイルとページそのもののリクエスト(MvcHandler)は別物だから、いくらコントローラのアクションでViewDataに値を入れたとしても外部JSファイルを取得したリクエスト(StaticFileHandler)では参照できないというのと、そもそもJSファイルが<%=~%>を解析して処理してくれないから。あたりまえですね~。

じゃぁ、どうすればデータ(サーバーサイドで動的生成)をコード(JavaScriptを静的に取得)に簡単に渡せるんだろう。

ページ専用のJavaScriptコードはどうしても、アクションで生成されるデータを使った動的コードにしたくなってしまう。でも、それだと外部ファイルじゃなくViewにscriptタグを書いてしまうことになって、なんかスッキリしない。

  1. 外部JSファイルを拡張子JSじゃなくASPXで作成し、JavaScript用のコントローラを作成(レスポンスのコンテンツタイプをapplication/x-javascriptに変更)し、動的にJSコードを生成するようにして(ViewファイルにJavaScriptコードを書く)、scriptタグでsrc指定。
  2. 外部JSファイルにはコードだけを書き、Viewに出力されるHTMLにscriptタグを書いておいて適当な変数にデータ(JSON)で入れておいて、外部JSファイルからはこれを参照する。

1の方法をとる場合、凄く分かりにくくなるのがコントローラが違うからViewとJavaScriptそれぞれでデータ取得のロジック(もちろんJavaScript用のコントローラではクライアントで必要なデータのみですが)を書かなきゃいけないからコードが散らばるうえに、VSで開発してるにも関わらずコードハイライトもインテリセンスも効かなくなる。

例えば...

// CharaControllerのViewアクション
public ActionResult Character() {
return View();
}

// JsControllerのJavaScriptアクション
public ActionResult Character() {
ViewData["chara"] = ToJson(new {firstName="ルフィ",lastName="モンキー"});
return View();
}

// ビュー
<body>
<!-- viewの定義 -->
</body>
<script type="text/javascript" src="JsController/Character"></script>

// Js/Character.aspx(JavaScript)
var chara = <% = ViewData["chara"] %>;
// 以降charaを使った処理
これだと2つのコントローラが必要になるし、ViewDataの生成と利用が離れすぎ。

綺麗にコードとデータを分離するなら、2の方法が正解ですよね。その場合Viewページにscriptタグを書く必要がありますが、あくまで処理コードは外部に分離できる。

例えば...

// CharaControllerのViewアクション
public ActionResult Character() {
ViewData["chara"] = ToJson(new {firstName="ルフィ",lastName="モンキー"});
return View();
}

// ビュー
<body>
<!-- viewの定義 -->
</body>
<script type="text/javascript">
var viewData = <% = ViewData["chara"] %>;
</script>
<script type="text/javascript" src="chara.js"></script>

// chara.js
var chara = viewData;
// 以降charaを使った処理

Viewアクション内でJavaScriptで利用するデータを生成しておき、外部スクリプトでのデータ参照はグローバル(この場合ならviewData)を見る。

どうも1の方法に固執しすぎてて、これを解決するためにカスタムのViewEngineを作ればいいんじゃないの?というおかしな路線に走ってしまって...。結局は2の方法にすることでViewEngine作る必要は無かったことに気がついたんだけど、その過程で今ネットで見つかるViewEngineの作り方が少し古いやり方な事に気がついたので、無駄にしないために、ココにメモとして残しておきます。

ASP.NET MVC Tip #25 – Unit Test Your Views without a Web Server Maarten Balliauw {blog} - Creating a custom ViewEngine for the ASP.NET MVC framework SingingEels : Creating a Custom View Engine in ASP.NET MVC Brad Wilson: Partial Rendering View Engines in ASP.NET MVC

ViewEngineを作るといっても、単にViewパスの検索場所を変更するためだけの目的もあれば、テンプレートエンジンを置き換えてしまう目的もあると思います。

パスを変えるだけならIViewEngineの実装はせず、PhilさんのAreasデモソース(Grouping Controllers with ASP.NET MVC)のようにWebFormViewEngineを派生させてViewLocationFormatsとMasterLocationFormatsをセットして、IViewは標準のWebFormViewを使えばいいですね。

テンプレートエンジンを変えてしまいたい場合は、IViewEngineとIViewそれぞれを実装することになりますが、今回は少し楽をしてIViewEngineにはVirtualPathProviderViewEngineを使うことにします。

何を作るかというと、拡張子jsの中に/$Key$/という形でテンプレートを入れとくと、ViewDataCollection内の同名Key値を埋め込むというテンプレート。これならJavaScriptのインテリセンスもコードハイライトも有効。

IViewEngineの実装。

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Web.Mvc;
using System.Web.Hosting;

namespace Sample.Libraries
{
public class JavaScriptViewEngine : VirtualPathProviderViewEngine
{
public JavaScriptViewEngine()
{
  MasterLocationFormats = new string[0];

  ViewLocationFormats = new[]{
   "~/ViewScripts/{0}.js",
   "~/ViewScripts/Shared/{0}.js"
  };

  PartialViewLocationFormats = ViewLocationFormats;
}

protected override IView CreatePartialView(ControllerContext controllerContext, string partialPath)
{
  return CreateView(controllerContext, partialPath, null);
}

protected override IView CreateView(ControllerContext controllerContext, string viewPath, string masterPath)
{
  return new JavaScriptView(viewPath);
}
}
}

IViewの実装。

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Web.Mvc;
using System.IO;
using System.Web;
using System.Text.RegularExpressions;

namespace Sample.Libraries
{
public class JavaScriptView : IView
{
private string _templatePath;

public JavaScriptView(string templatePath)
{
  _templatePath = templatePath;
}

public void Render(ViewContext viewContext, System.IO.TextWriter writer)
{
  var appPath = viewContext.HttpContext.Request.PhysicalApplicationPath;
  var filePath = VirtualPathUtility.ToAbsolute(_templatePath).Substring(1).Replace("/", "\\");
  var fullPath = Path.Combine(appPath, filePath);

  if (!File.Exists(fullPath))
    throw new InvalidOperationException("not exits javascript template file.");

  var template = File.ReadAllText(fullPath);

  writer.Write(Parse(template, viewContext.ViewData));
}

public string Parse(string contents, ViewDataDictionary viewData)
{
  return Regex.Replace(contents, @"\$\/(.+)\/\$", m => GetMatch(m, viewData));
}

protected virtual string GetMatch(Match m, ViewDataDictionary viewData)
{
  if (m.Success)
  {
    string key = m.Result("$1");
    if (viewData.ContainsKey(key))
      return viewData[key].ToString();
  }
  return String.Empty;
}
}
}

置換部分の処理はまるっきりStephenさんのコードです...。

拡張子jsのファイルはViewScriptsフォルダに入れておくようにしたものです。コントローラ名もフォマットに含めようと思ったんですが、そこはルーティングの登録を以下のようにしておくことでとりあえず必要無いな、と。でも、ViewScriptsフォルダ内はコントローラ名フォルダ/スクリプト名.jsでファイルを入れておきます。

      routes.MapRoute(
    "ViewScripts",
    "ViewScripts/{*path}",
    new { controller = "ViewScripts", action = "Index" }
  );

ViewEngineの登録も忘れずに。

ViewEngines.Engines.Add(new JavaScriptViewEngine());

あとはViewScriptsControllerを書くだけ。

using System;
using System.Collections.Generic;
using System.Linq;
using System.Web;
using System.Web.Mvc;
using System.Web.Mvc.Ajax;

namespace Sample.Controllers
{
public class ViewScriptsController : Controller
{
//
// GET: /ViewScripts/

public ActionResult Index(string path)
{
  ViewData["test"] = "{data:'sample'}";

  return View(path);
}

}
}

まずは動くコードを、という簡単なサンプルです。

テスト用にViewScripts/Test.jsを作成。

// test.js
var viewData = /$Test$/;
alert(viewData);

あとは、通常のViewでこれをインクルード。

<script type="text/javascript" src="/ViewScripts/Test.js"></script>

これで一応動くものが出来たわけですが、ここで2の方法でいいじゃん、と思い直してコードを破棄...。しかもこの方法だとViewScriptsControllerでいろんなViewDataを入れるためのコードが必要になって、面倒なことに。もっと早い段階で気がつけば良かったけど、カスタムViewEngineを書いてみる勉強になったから良しとします。

ちなみにカスタムViewEngineで最高のサンプルは今書いたこんな中途半端なコードじゃなくてStringTemplate Template Engineじゃないかと思われます。作ってみたい方は是非そちらを参照してみてください。

string-template-view-engine-mvc - Google Code