Kobarin's Development Blog

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

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万件以上とかになった時のパフォーマンスが心配です。