Kobarin's Development Blog

C#やASP.NETなどについての記録です。

Umbraco+ASP.NET MVCによるサイト作成

UmbracoとMVCの併存サイトを作ったのでメモします。
まずは当方の環境から。

はじめに

まず参考にさせていただいたのは以下の記事です。
www.buildinsider.net
この記事ではUmbracoのバージョンは6.1.1、MVCはver.4なので、やや古い印象はありますが概ね内容通り進めれば問題ないと思いますが、幾つか追加事項があるのでそれも含めて説明していきます。

A. 新規Webサイトの作成

  1. Visual Studio 2017(以降、VSと略)で新規プロジェクトを作成し、「ASP.NET Web Application (.NET Framework)」を選択
  2. テンプレートから「MVC」を選択
  3. これで空のMVCサイトが出来上がりましたが、この後Umbracoインストール時に上書きされる、ルート直下のweb.configを何処かにバックアップしておきましょう

B. Umbracoのインストー

  1. メニューの[Tools] → [Nuget Package Manager] → [Manage Nuget Packages for Solution]
  2. 「UmbracoCMS」を検索し、プロジェクトに[Install]
  3. ここまでが参考記事2までに沿った流れです

C. web.configの編集

  1. Umbracoによるweb.configとMVCのweb.config(A-3でバックアップしたファイル)をマージする(参考記事3の箇所)。1つ1つタグを追っていき、互いに存在しない行を付け足していきます。

D. 初期設定

  1. プロジェクトをBuildし、[Debug] → [Start Without Debugging]でサイトを起動
  2. この時、web.config周りでエラーが出たら、いったんコメントアウトして再度試してみましょう(私の場合も、忘れましたが幾つか削除しました)
  3. ブラウザが開き、ログインアカウントの設定画面が出ます。そのまま[Install]するとSQL Server Compact EditionとしてApp_Dataフォルダの下の.sdfファイルをデータベースとして使うことになります。SQL serverを使う場合は、[Customize]を押すとサーバー設定が出来ます。後々SQLserverで動作させる予定があるならこの場で設定しておきましょう。なお、後からweb.configのConnetionStringを変更しても上手く動作しませんでした(初期画面の女性が多く写った写真を背景にインジケーターがずっと点滅した場が続きました)。おそらくCEからSQLへのデータ移行を行う必要があるのかもしれません。

E. ルーティング設定

ASP.NET MVCアプリケーションの場合、既定では例えば http://example.jp/Product/Details/123 というアドレスを指定すると、Controller=Product、のAction=Details、id=123 としてルーティングされます。
しかしUmbracoをインストールしたことによりこのルーティング設定は無効化され、あらゆるアクセスがUmbracoで処理されることになります(RouteConrigが削除されたり書き換えられるわけではありません)。
本稿はMVCとの併存サイトを構築することが狙いなので、MVCアプリケーションにもアクセスできるようカスタムルーティングを設定する必要があります。
Umbracoを扱う以上は、基本は全てUmbracoで処理する事になると思われます。その中で、例えば「オンラインショップ部分のみMVCで動かしたい」と考えた場合、http://example.jp/onlineshop/で始まるをMVCアプリケーションとしてルーティング設定することになります。
参考記事5の通り、どこでも良いのでクラスファイルを作り、以下のように作成(参考記事ではGlobal.asax内だが、当方はApp_Code内に記載した)。
Global.asaxのApplication_Start内の文をOnApplicationStarted内に記述。

public class AppStart : IApplicationEventHandler
{
  public void OnApplicationInitialized(UmbracoApplicationBase umbracoApplication,
  ApplicationContext applicationContext)
  { }

  public void OnApplicationStarting(UmbracoApplicationBase umbracoApplication,
     ApplicationContext applicationContext)
  {
  }

  public void OnApplicationStarted(UmbracoApplicationBase umbracoApplication, ApplicationContext applicationContext)
  {
    AreaRegistration.RegisterAllAreas();
    FilterConfig.RegisterGlobalFilters(GlobalFilters.Filters);
    RouteConfig.RegisterRoutes(RouteTable.Routes);
    BundleConfig.RegisterBundles(BundleTable.Bundles);
  }
}

次に、App_Start/RouteConfig.csを開き、以下のように書き直す。

public static void RegisterRoutes(RouteCollection routes)
{
  routes.IgnoreRoute("{resource}.axd/{*pathInfo}");
 
  routes.LowercaseUrls = true;  //小文字の有効化

  routes.MapRoute(
    name: "OnlineShop",
    url: "OnlineShop/{action}/{id}",
    defaults: new { controller = "OnlineShop", action = "Index", id = UrlParameter.Optional },
    namespaces: new[] { "(アプリケーション名).Controllers" }
  );
}

これにより、/onlineshop/で始まるアドレスのみMVCアプリケーションとして動作する事になります。
ちなみにnamespace部分は必ずしも必要ではなく、参考記事にも書かれていません。
ただ、当方の場合プロジェクト名を途中で変更したせいか旧プロジェクトのControlerが残ってしまったらしく、以下のような例外が発生してしまってました。

Multiple types were found that match the controller named 'OnlineShop'. This can happen if the route that services this request ('OnlineShop/{action}/{id}') does not specify namespaces to search for a controller that matches the request. If this is the case, register this route by calling an overload of the 'MapRoute' method that takes a 'namespaces' parameter.

特に必要なければそのままでよいですが、もし上のようなエラーが現れる場合は、MapRouteでnamespaceを付け加えてみて下さい。
これについては、自分なりに探して以下に情報を参考にしました。
stackoverflow.com

F. UmbracoReservedPathsの設定

最後に、参考記事にもあるようにweb.configのappSettings内のUmbracoReservedPathsに以下のように追記して終了です。

<add key="umbracoReservedPaths" value="~/umbraco,~/install/,~/Content,~/Bundles"  />

以上で動作すると思われます。

SaveChanges()による特定フィールドの更新

EntityFrameworkを使って更新する一般的な事例として、よく以下のようなコードを見かけます。

[HttpPost]
[ValidateAntiForgeryToken]
public ActionResult Edit(Product product)
{
  if(ModelState.IsValid)
  {
    db.Entry(product).State = EntityState.Modified;
    db.SavaChanges();
    
    return RedirectToAction("List");
  }
  return View(product);
}

確かに全フィールドを対象としたフル更新型のフォームではこれで正しいですが、「価格だけの更新」「フラグの項目だけ更新」といったように特定フィールドだけの部分更新をしたい場面も多々あると思います。
以下の回答を参考にしたので、解説いたします。
stackoverflow.com

価格フィールドに限定した更新の例

まず上記コードを基に部分更新に作り変えたのが以下のコードです。

[HttpPost]
[ValidateAntiForgeryToken]
public ActionResult EditPrice([Bind(Include = "ProductId,ListPrice")] Product product)
{
  if(ModelState.IsValid)
  {
    db.Products.Attach(product);  // Attachが必要。後述。
    db.Entry(product).Property(m => m.ListPrice).IsModified = true; // 対象フィールドを更新指定
    db.SavaChanges();
    
    return RedirectToAction("List");
  }
  return View(product);
}

こんな感じで、数行の変更で部分更新が完成です。

説明

忘れないでほしいのは

db.Products.Attach(product);

の部分で、これを書かないと以下のようなエラーが出ます。

Member 'IsModified' cannot be called for property 'ListPrice' because the entity of type 'Product' does not exist in the context. To add an entity to the context call the Add or Attach method of DbSet.

ちょっと意味がわかりにくですが簡単に説明すると、
「いきなり『db.Entry(product)』してもエンティティが出来ないので、AddかAttachしてくれよ」
みたいな意味のようです。
今回は追加ではなく更新なのでAttachになります。

次の行で更新対象のフィールドを指定しています。

db.Entry(product).Property(m => m.ListPrice).IsModified = true;

もちろん、複数ある場合は同じように

db.Entry(product).Property(m => m.ListPrice).IsModified = true;
db.Entry(product).Property(m => m.StandardCost).IsModified = true;
db.Entry(product).Property(m => m.Name).IsModified = true;

のように付け足すだけです。

ASP.NET MVC で複数レコードを一括更新するフォーム

MVCのScaffoldilng機能は秀逸で、Index、Details、Create、Edit、DeleteのController+Viewをものの数秒で作り上げてくれます。
ただ、Create・Edit・Deleteいずれも1レコード単位の機能であり「一括変更」する機能はないため、自前で作る必要があります。
本稿では、特に利用頻度が多い思われるEdit(編集機能)の一括機能を取り上げていきたいと思います。


MVCではWebPagesに比べると簡単に実現できるのですが、実はちょっと癖があります。
ここでは、一般的に考えつく方法(IEnumerable + foreach)では出来ない事を示しつつ説明していくので、手っ取り早く方法を知りたい方はいきなり後半に飛んで下さい。

Controllerの記述

Controller側としては、こんな流れを想定します。

  • モデルの一覧表示
  • 受取側(HttpPost側)でforeachで1件ずつ更新処理
public ActionResult PostMultiRows()
{
  // 一覧の取得
  var products = from p in db.Products
                 select p;

  return View(products);
}
[HttpPost]
[ValidateAntiForgeryToken]
public ActionResult PostMultiRows(IEnumerable<Product> products)
{
  MyRepository rp = new MyRepository(); // Productの取得や更新を行うレポジトリが既に作成されていると仮定

  // 1件ずつ更新
  foreach(var p in products)
  {
    rp.UpdateProduct(p);
  }

  return View("PostMultiRows");
}

Viewの記述

View側では、モデル一覧をバインドしつつ、EditorForで入力欄を用意します。
ここでは、簡略化するために、ProductId、Name、StandardPrice、ListPriceの4項目に絞っています。

@model IEnumerable<MvcDemo.Models.Product>

@using (Html.BeginForm())
{
  @Html.AntiForgeryToken()

  <table class="table">

    @foreach (var item in Model)
    {
      <tr>
        <td>
          @Html.HiddenFor(m => item.ProductID)
          @Html.EditorFor(m => item.Name, new { htmlAttributes = new { @class = "form-control" } })
        </td>
        <td>
          @Html.EditorFor(m => item.StandardCost, new { htmlAttributes = new { @class = "form-control" } })
        </td>
        <td>
          @Html.EditorFor(m => item.ListPrice, new { htmlAttributes = new { @class = "form-control" } })
        </td>
      </tr>
    }
  </table>

  <div>
    <input type="submit" value="SUBMIT" class="btn btn-primary" />
  </div>
}

ひとまず完成。早速POSTしてみる。

簡単ですがひとまず出来上がりましたので、SUBMITボタンを押すと‥

オブジェクト参照がオブジェクト インスタンスに設定されていません

foreach(var p in products)

こんなエラーメッセージが現れるのではないでしょうか?
ASP.NETで最も良く見るエラーかもしれませんが、foreachの冒頭で発生していることから、うまく受け渡しができていない事がわかります。

foreachでは送信できない。for-loopが必要。

解決方法を言ってしまうと、View側ではforeachでなくforでループさせる必要があります。
当方の力不足のためハッキリした理由は説明できません(StackOverFlowで見つけただけ)ので、HTMLソースを見ての推測になりますが、
inputコントロール名が「[5].StandadCost」等となっている事から、おそらくループ内の各コントロールを識別可能になった事が原因ではないかと考えます。
また、これに伴い、IEnumerableではfor-loop指定ができないため、ViewモデルをIList<モデル名>に変更する必要もあります。

@model IList<MvcDemo.Models.Product>

@using (Html.BeginForm())
{
  @Html.AntiForgeryToken()

  <table class="table">

    @for (int i = 0; i < Model.Count(); i++)
    {
      <tr>
        <td>
          @Html.HiddenFor(m => Model[i].ProductID)
          @Html.EditorFor(m => Model[i].Name, new { htmlAttributes = new { @class = "form-control" } })
        </td>
        <td>
          @Html.EditorFor(m => Model[i].StandardCost, new { htmlAttributes = new { @class = "form-control" } })
        </td>
        <td>
          @Html.EditorFor(m => Model[i].ListPrice, new { htmlAttributes = new { @class = "form-control" } })
        </td>
      </tr>
    }

  </table>

  <div>
    <input type="submit" value="SUBMIT" class="btn btn-primary" />
  </div>
}

これにより、更にContoller側にも変更が生じます。
IEnumerable型をList側に変換してViewに渡すようにして下さい。

public ActionResult PostMultiRows()
{
  var products = _rp.GetProducts(1, null).ToList();

  return View(products);
}

これで実行可能です。

いかがでしたでしょうか?
StackOverFlowでも質問している人が何人かいた事からも結構引っかかりやすいかもしれませんが、知ってしまえばかなり簡単だと思います。

ASP.NET MVC のDisplayFormatを表示用と編集用で分ける方法

タイトルが分かりにくいですが、正確には

  • DisplayFormat属性のDataFormatStringを、表示のみする場面(例:Index、Details)と編集する場面(例:Create、Edit)で別々に作る

といった内容です。

結論「別々に作る事はできない」

という事で、当然ですがDataFormatStringは1つしか作れないため、プロパティ自体を表示用と編集用に別々に用意する事で対応しましたが、とりあえず1つ1つ流れを見ていきます。

例えば、以下のようなProductエンティティがあるとします。

public class Product {
  public int ProductID { get; set; }
  …
  public decimal StandardCost { get; set; }
  …
}

まずはDisplayFormat

StandardCostはdecimal型なので、上記のままだと表示時も編集時も「1000.00」のような表記になりますが、このままだと見にくいと感じます。
そこで、期待する表記は「3桁区切りカンマ、小数点以下は必要次第で表示」だとします。

  • 例1:「1000」の場合なら「1,000」
  • 例2:「1234.5」の場合なら「1,234.5」

そこでDisplayFormatを下記のように記述します。

public class Product {
  …
  [DisplayFormat(DataFormatString = "{0:#,##0.#}", ApplyFormatInEditMode = true)]
  public decimal StandardCost { get; set; }
  …
}

ApplyFormatInEditMode は、編集時でも同じフォーマットを適用する場合に使う属性です(既定でfalse)。

編集の時はカンマ不要

これで期待する通りの表示になりますが、困った事に編集用のTextboxでも「1,000」のようにカンマが現れてしまいます。
しかもそのままだと検証に引っかかり「The field StandardCost must be a number(StandardCostは数字でなければなりません)」と修正を求められます。変更もないのにカンマを削除するのは面倒ですよね。


それでは…とApplyFormatInEditModeを取り除いてみます。

public class Product {
  …
  [DisplayFormat(DataFormatString = "{0:#,##0.#}")]
  public decimal StandardCost { get; set; }
  …
}

こうすると表示は変化ありませんが、編集では「1000.00」と小数点以下の余計な部分が現れてしまいます。
このままでも問題はないのですが、何となく鬱陶しいですよね。

DisplayFormatの限界…(面倒だけど)プロパティを2つ作る

このようにDisplayFormatは、表示と編集とで表記を統一する機能はあっても、別々に定義することが出来ないのです。
そこで苦肉の策となりますが、別々に定義するのであれば、以下のようにプロパティを2つに分けるしかありません。

public class Product {

  // 編集用
  [DisplayFormat(DataFormatString = "{0:#0.#}", ApplyFormatInEditMode = true)]
  public decimal StandardCost { get; set; }

  // 表示用
  [DisplayFormat(DataFormatString = "{0:#,##0.#}")]
  public decimal StandardCostDisplay { get { return StandardCost; } }
}

「表示用」でStandardCostDisplayというStandardCostを参照するだけのプロパティを作り、希望通りのフォーマットにします。
一方、「編集用」ではApplyFormatInEditModeを付け、カンマは適用しないフォーマットにします。

Viewに少し修正を加えて終了

最後に、View側を少し修正し、IndexやDetailsといった表示のViewではStandardCostDisplayをバインドし、CreateやEditはStandardCostをバインドさせます。

Index, Details
  <td>
    @Html.DisplayFor(model => model.StandardCostDisplay)
  </td>
Create, Edit
  @Html.EditorFor(model => model.StandardCost, new { htmlAttributes = new { @class = "form-control" } })

希望

本当はEditFormatみたいな属性があれば最も話が早いと思うんですが…まぁ現状ではこれしか思いつきませんでした。

ASP.NET MVCで独自DataAnnotation作成

MVCの検証に使うDataAnnotationはかなり便利で、標準のものだけでも例えば以下のようなものがあります。

  • Required : 必須項目化
  • Range : 値の範囲(例:1~100)
  • Compare : 他プロパティと同一判定(例:メルアドを2回入力した時、等)
  • RegularExpression : 正規表現(文字列の規則ならほぼ何でもあり)

これだけでも結構使えるんですが、WebFormsにあったような「greater than(より大きい)」や「less than(より小さい)」といった、他プロパティを参照するタイプの検証がCompareくらいしか用意されていません。
例えば「開始日」と「終了日」とか、「最安値」と「最高値」といった、あらかじめ上下関係が決まっている要素ってありますよね。
開始日が「2017/07/03」なのに「終了日」が「2016/06/30」ではおかしいので、こうした検証が必要な場面で使う属性がMVCにはありません。

DataAnnotation の自作?

DataAnnotationは完全独自で1からコーディングする事もできますし一番融通は効くと思われますが、やはりちょっと面倒です。興味ある方は以下の最上部の回答が参考になります。
stackoverflow.com

CustomValidation属性は?

RegularExpression等で対応しきれない場合、CustomValidationという手もあります。
エンティティ内に検証用コードを記述できるため自由度は高い反面、自作DataAnnotationのように再利用性は高くない上、やっぱりコーディングが面倒ですね。

ExpressiveAnnotations という選択肢

実際の開発の場ではほとんどは、それほど複雑な検証でなく先述のGreaterThanとかLessThan程度で済むレベルが多いのではないでしょうか。
探したところ、「ExpressiveAnnotations」という拡張機能があり、Nugetからインストールできます。

ExpressiveAnnotations の使い方

Nugetからインストールした後、エンティティ定義をします。
この例では、イベント情報をモデルに進めてみます。

using ExpressiveAnnotations.Attributes;


public class DemoEventModel
{
public int Id { get; set; }


[DisplayName("名称")]
[Required]
public string Name { get; set; }


[DisplayName("最安価格")]
public int? LowPrice { get; set; }


[DisplayName("最高価格")]
[AssertThat("LowPrice < HighPrice", ErrorMessage = "最安価格より金額が大きい必要があります")]
public int? HighPrice { get; set; }


[DisplayName("駐車場可")]
public bool ParkingAvailable { get; set; }


[DisplayName("駐車台数")]
[RequiredIf("ParkingAvailable == true", ErrorMessage = "駐車場可であれば、台数が必要です")]
public int? ParkingSpaces { get; set; }


[DisplayName("開始日")]
[AssertThat("OpenDate < CloseDate", ErrorMessage = "終了日より前である必要があります")]
public DateTime? SmallDate { get; set; }


[DisplayName("終了日")]
public DateTime? CloseDate { get; set; }
}

赤くしてある箇所が、ExpressiveAnnotations による追加部分です。AssertThatとRequireIfがあります。
AssertThatには、自プロパティも含めた式を記述します。複雑な数式も可能らしく、例えば「MaleRatio + FemaleRatio = 100」も可能なようです。
RequireIfは、特定条件下でのみ必須化したい場合に使います。上記では、「駐車場利用可能であれば、駐車可能台数を入力する」というものです。

クライアントサイドは?

上記ではサーバーサイドでの検証のみ有効となります。情報不足であるためクライアントサイドについても出来るか否か現在調べています。
分かり次第追記していきたいと思います。

MvcSiteMapProviderでパンくずリストを作成する

ASP.NET MVCパンくずリストを使う場合、、MvcSiteMapProviderというパッケージを使うケースが多いようです。MvcSiteMapProviderはnugetからインストールでき、WebFormsでいうSiteMapPathと同じような機能が実現できます。

ただ、SiteMapPath同様に静的なサイト…つまり「Home > About」や「Home > Company」のような、サイトマップXML(WebFormsではWeb.sitemap、MVCではMvc.sitemap)で管理できる程度のサイトであれば楽勝です。
しかし実際の運用では「Home > 食品 > オリーブオイル」「HOME > トヨタプリウス」といったように、データベースのレコードからの情報取得を必要とするケースが多いですよね。

WebFormsの頃のSiteMapPathでは私は諦めて自作のかなり面倒なパンくずリストをサイト毎に作ってましたが、正直骨が折れました。
MVCではもっと楽に実現できますが、カスタマイズに関してはあまり情報が多くありません。StackOverFlowあたりの情報を活用しつつ、試行錯誤してようやくそれらしく動いたので、その報告をしたいと思います。
Visual Studioは英語版を基に説明しますがご了承下さい(バージョンは2015)。

まずインストール

VSの[Tools]メニューから[Nuget Package Manager > Manage Nuget Packages for Solution]を実行
[Browse]タブを押し、「Search」とある検索ボックスがあるので、「MvcSiteMapProvider」と入力
私の場合は「MvcSiteMapProvider.Web」と「MvcSiteMapProvider.Mvc5」をインストールしました。後者はMVCのバージョンによって変わるので、適宜選択して下さい。なお、パッケージの説明欄に「This package is obsolete…」等と書かれたパッケージは旧式なのでインストールしないよう注意です。
インストールが終わったら、nugetは閉じましょう

Viewに貼り付け

既にパンくずを使う準備はできているので、Viewに貼り付けてみましょう。
オーソドックスに全ページに適用されるよう「~/Views/Share/_Layout.cshtml」を開き、「@RenderBody()」の上あたりに適当に「@Html.MvcSiteMap().SiteMapPath()」をコピペします(パンくず用のdiv等で括るのが良いかも)。

Mvc.sitemapには最初からAboutページ等が定義されているので、http://localhost:XXXX/Home/Aboutにアクセスしてみると、パンくずが表示されると思います。

サイトマップの作成

サイトマップを定義するために、ルート直下にある「Mvc.sitemap」を開きます。
この編集の仕方はWebで探すと多くの情報が見つかるため割愛します。
ノード毎に、ControllerとAction、パラメーターを記述する事で、それに対応したViewにアクセスした際にパンくずが表示されます。
以上で、静的なコンテンツでのパンくずは対応できます。

LINQ to Entitiesからパンくずリストを生成

さてここからが本題ですが、冒頭の「データベースからの情報取得」に入りたいと思います。実際にはLINQを使っているケースが多いと思われるため、ここではLINQ to Entitiesを基に説明していきます。

ModelおよびRepository作成

まず冒頭で示した「食品 > オリーブオイル」「トヨタプリウス」を基に説明していきますので、それぞれ「商品分類」、「商品」、「自動車メーカー」、「車種」の4モデルを用意したいところですが、面倒なので単純に示したいと思うので、全部同じModelで共用します(スイマセン)。
また、例は2つあり、1つは「食品 > オリーブオイル」のように多段階で一挙に展開するタイプ、2つ目は「自動車メーカー」「車種」を別々に展開するタイプです(通常は前者を使います)。

それと4モデルを一覧で呼び出すための、簡易的なRepositoryも作ります。
適当に~/Models/Repositories.cs あたりに作ります。

    using System;
    using System.Collections.Generic;

    namespace MyProject.Repositories
    {
        public class MyRepository
        {
          public MyRepository() { }

            public List<ItemModel> GetCategories()
            {
                List<ItemModel> result = new List<ItemModel>();
                result.Add(new ItemModel(1, "食品", 0));
                result.Add(new ItemModel(2, "家具", 0));
                result.Add(new ItemModel(3, "家電", 0));
                return result;
            }

            public List<ItemModel> GetProducts(int catId)
            {
                List<ItemModel> result = new List<ItemModel>();
                switch (catId)
                {
                    case 1:
                        result.Add(new ItemModel(1, "ケチャップ", 1));
                        result.Add(new ItemModel(2, "オリーブオイル", 1));
                        result.Add(new ItemModel(3, "牛乳", 1));
                        break;
                    case 2:
                        result.Add(new ItemModel(4, "ベッド", 2));
                        break;
                    case 3:
                        result.Add(new ItemModel(5, "ドライヤー", 3));
                        result.Add(new ItemModel(6, "除湿機", 3));
                        break;
                    default:
                        break;
                }
                return result;
            }

            public List<ItemModel> GetMakers()
            {
                List<ItemModel> result = new List<ItemModel>();
                result.Add(new ItemModel(1, "トヨタ", 0));
                result.Add(new ItemModel(2, "日産", 0));
                result.Add(new ItemModel(3, "ホンダ", 0));
                return result;
            }

            public List<ItemModel> GetCarModels(int makerId)
            {
                List<ItemModel> result = new List<ItemModel>();
                switch (makerId)
                {
                    case 1:
                        result.Add(new ItemModel(1, "プリウス", 1));
                        result.Add(new ItemModel(2, "ヴィッツ", 1));
                        result.Add(new ItemModel(3, "アクア", 1));
                        result.Add(new ItemModel(4, "ハリアー", 1));
                        break;
                    case 2:
                        result.Add(new ItemModel(5, "マーチ", 2));
                        result.Add(new ItemModel(6, "ノート", 2));
                        result.Add(new ItemModel(7, "セレナ", 2));
                        break;
                    case 3:
                        result.Add(new ItemModel(8, "フィット", 3));
                        result.Add(new ItemModel(9, "フリード", 3));
                        break;
                    default:
                        break;
                }
                return result;
            }
        }

        /// <summary>
        /// 分類、商品、自動車メーカー、車種に共通して使用
        /// </summary>
        public class ItemModel
        {
            public ItemModel() { }
            public ItemModel(int id, string name, int parentId) : base()
            {
                Id = id;
                Name = name;
                ParentId = parentId;
            }
            public int Id { get; set; }
            public string Name { get; set; }
            public int ParentId { get; set; }
        }
    }

DynamicNodeProviderを作成

Mvc.sitemapから呼び出すためのDynamicNodeProviderを作ります。Mvc.sitemapから呼び出すことで、Mvc.sitemapのXMLファイル内でなく動的なノードを読み込むことが出来るようです(説明がうまく出来なくてスイマセン)。
適当に~/Models/dynamicnode.cs みたいに置いておきましょう。

namespace MyProject.SiteMaps
{
 /// <summary>
 /// 商品分類 > 商品
 /// </summary>
 public class ProductDynamicNodeProvider : DynamicNodeProviderBase
 {
  public override IEnumerable<DynamicNode> GetDynamicNodeCollection(ISiteMapNode node)
  {
   var nodes = new List<DynamicNode>();

   MyRepository rp = new MyRepository();

   foreach(var cat in rp.GetCategories())
   {
    var cnode = new DynamicNode
    {
     Key = $"cat_{cat.Id}",
     Title = cat.Name,
     Controller = "Category",
     Action = "Details",
    };
    cnode.RouteValues.Add("id", cat.Id);
    nodes.Add(cnode);

    foreach(var pro in rp.GetProducts(cat.Id))
    {
     var pnode = new DynamicNode
     {
      Key = $"pro_{pro.Id}",
      ParentKey = cnode.Key,
      Title = pro.Name,
      Controller = "Product",
      Action = "Details",
     };
     pnode.RouteValues.Add("id", pro.Id);
     nodes.Add(pnode);
    }
   }

   return nodes;
  }
 }

 /// <summary>
 /// 自動車メーカー
 /// </summary>
 public class MakerDynamicNodeProvider : DynamicNodeProviderBase
 {
  public override IEnumerable<DynamicNode> GetDynamicNodeCollection(ISiteMapNode node)
  {
   var nodes = new List<DynamicNode>();

   MyRepository rp = new MyRepository();

   foreach (var maker in rp.GetMakers())
   {
    var mnode = new DynamicNode
    {
     Key = $"maker_{maker.Id}",
     Title = maker.Name,
     Controller = "Maker",
     Action = "Details",
    };
    mnode.RouteValues.Add("id", maker.Id);
    nodes.Add(mnode);
   }

   return nodes;
  }
 }

 /// <summary>
 /// 車種
 /// </summary>
 public class CarModelDynamicNodeProvider : DynamicNodeProviderBase
 {
  public override IEnumerable<DynamicNode> GetDynamicNodeCollection(ISiteMapNode node)
  {
   var nodes = new List<DynamicNode>();

   MyRepository rp = new MyRepository();

   foreach (var maker in rp.GetMakers())
   {
    foreach(var car in rp.GetCarModels(maker.Id)){
     var cnode = new DynamicNode
     {
      Key = $"car_{car.Id}",
      ParentKey = $"maker_{car.ParentId}",
      Title = car.Name,
      Controller = "CarModel",
      Action = "Details",
     };
     cnode.RouteValues.Add("id", car.Id);
     nodes.Add(cnode);
    }
   }

   return nodes;
  }
 }
}

ContollerおよびViewの作成

上記のDynamicNodeProverの中で記したContollerを作成します。つまり、以下の4つです。

  • Category
  • Product
  • Maker
  • CarModel

更に、それぞれのActionとして「Details」を各々作成します。ここでは、中身は「return View();」だけで良いです。
4つのDetailsアクションのViewを作成して下さい。これも作成するだけで中身はデフォのままで良いです。

Mvc.sitemapの編集

最後にMvc.sitemapの変更です。
DynamicNodeProviderを呼び出しているノードで、「MyProject」が2度ずつ記述されていますが、これは決め事のようです。カンマ区切りでclass名とプロジェクト名を書く決まりみたいです。

<?xml version="1.0" encoding="utf-8" ?>
<mvcSiteMap xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
            xmlns="http://mvcsitemap.codeplex.com/schemas/MvcSiteMap-File-4.0"
            xsi:schemaLocation="http://mvcsitemap.codeplex.com/schemas/MvcSiteMap-File-4.0 MvcSiteMapSchema.xsd">

  <mvcSiteMapNode title="Home" controller="Home" action="Index">
    <mvcSiteMapNode title="商品分類>商品のサイトマップ" controller="Category" action="Index">
      <mvcSiteMapNode title="" controller="Category" action="Details" dynamicNodeProvider="MyProject.SiteMaps.ProductDynamicNodeProvider, MyProject" />
    </mvcSiteMapNode>
    <mvcSiteMapNode title="自動車メーカー>車種のサイトマップ" controller="Maker" action="Index">
      <mvcSiteMapNode title="" controller="Maker" action="Details" dynamicNodeProvider="MyProject.SiteMaps.MakerDynamicNodeProvider, MyProject">
        <mvcSiteMapNode title="" controller="CarModel" action="Index" dynamicNodeProvider="MyProject.SiteMaps.CarModelDynamicNodeProvider, MyProject" />
      </mvcSiteMapNode>
    </mvcSiteMapNode>
  </mvcSiteMapNode>
</mvcSiteMap>

サイトマップページの作成

HomeContollerに、SiteMapアクションを作成し、Viewも作成して下さい。
~/Views/Home/SiteMap.cshtml を開き、ページの何処かに「@Html.MvcSiteMap().Menu()」を貼り付けてましょう。

動作確認

ビルドして、ブラウザでhttp://(hostname)/Home/SiteMap を開いてみて下さい。
定義したとおりの階層と各ページへのリンクが表示されれば成功です。

更に、冒頭で_Layout.cshtmlでパンくずを記載してあるはずなので、各ページへのリンク先に行くと、パンくずも表示されていると思います。

さいごに

いかがででしたでしょうか。
このように、MvcSiteMapProviderを使うことで、ノードを動的に生成する事が出来ます。
各コンテンツのContollerやView側では、何もする事無く階層的なパンくず、更にはサイトマップまでできてしまいます。
今まで「ASP.NET(特にWebForms)は便利だが、パンくずはちょっと…」と思っていた方も、これでもう楽ちんですね。
ただ、コードを見てもらうと分かる通り、レコードの全件呼び出しをしているため、レコード数が10万件以上とかになった時のパフォーマンスが心配です。

jStatMapで「グラフ」を保存する方法

商圏調査等をする上でかなり有用なjStatMapですが、その機能の多さ故に初めて使うと戸惑う事も多いと思います。

jStatMapにはグラフ保存機能がない

その中で1つ、わかりづらいというか注意しなければならないのは、「グラフの保存機能がない」点です。
jStatMapの主な機能は「プロット」「エリア」「グラフ」「レポート」の4つから構成されます。その内「プロット」と「エリア」はクラウド上に保存してくれるのですが、「グラフ」は保存できません。

f:id:fskkoba:20160603133205p:plain

ログアウトするまでは存在しますし名前変更等も可能なので一見保存してくれているのかと思いきや、ログアウトして再度ログインすると、空になってしまいます。

これでは、せっかく集計データを地図上に展開しても、毎回同じ作業を繰り返すことになり、何度もやっているうちに間違いも起きやすいです。

「エクスポート」機能でローカル保存

これを解決する方法として「エクスポート」があります。

f:id:fskkoba:20160603135324p:plain

  1. 統計データを作成後、「グラフ」を選択してレイヤーを選択
  2. 上記の「エクスポート」ボタンを押す
  3. 「ファイル種別選択」ウィンドウが開くので「グラフファイル」を選択
  4. 「エクスポートファイルのダウンロード」ボタンが現れるので、クリック

以上で、.gsfファイルをダウンロードできます。
つまりグラフについては、ローカルで保存するという運用方法のようです。

インポート方法

次に、一度ログアウトしてから、先ほどの.gsfファイルをインポートする方法です。

  1. 先ほどのエクスポートボタンの右にある「インポート」ボタンを押す
  2. 「緯度経度リスト」「シェープファイル」「GMLファイル」「グラフファイル」の4つのボタンが現れるので、「グラフファイル」を選択
  3. 「参照」ボタンで先ほどの.gsfファイルを選択
  4. 「アップロードする」ボタンを押す

以上です。ちなみに、ファイル名がそのままレイヤー名になるので、それを踏まえてファイル名をつけておくと手間がかかりません。

容量制限の回避方法としても

なお、ローカル保存できるのは「グラフ」の他に「エリア」も可能です(プロットは不可)。
jStatMapには5MBという容量制限があるので、このローカル保存を活用すれば、プロットを除けば制限に縛られること無く使用する事ができます。