使用MvcSiteMapProvider搭配角色權限來產生Menu
前言
不論是開發什麼樣的專案,一定少不了提供Menu供使用者切換功能頁面,所以筆者就透過MvcSiteMapProvider來實現一個動態產生的Menu,並且搭配設計好的角色權限來顯示功能項,以達到基本的權限控管功能。
角色權限Model設計
筆者在此簡單建立一個基本的使用者權限Model,主要關聯為一個使用者(User)會擁有許多不同角色(Role),一個角色(Role)會對應到菜單(Menu)上許多不同的功能項;在此就不詳述各Model中屬性意義,我們就直接建立出來吧。
public class User
{
// Properties
[Key]
public string UserId { get; set; }
public string Email { get; set; }
public string Password { get; set; }
public string Name { get; set; }
public DateTime RegisterOn { get; set; }
public bool IsEnable { get; set; }
// Navigation Properties
public virtual ICollection<Role> Roles { get; set; }
}
public class Role
{
// Properties
[Key]
public int RoleId { get; set; }
public string Name { get; set; }
public bool IsEnable { get; set; }
// Navigation Properties
public virtual ICollection<User> Users { get; set; }
public virtual ICollection<Menu> Menus { get; set; }
}
public class Menu
{
[Key]
[DatabaseGenerated(DatabaseGeneratedOption.None)]
public int MenuId { get; set; }
[Required]
public string Name { get; set; }
public string Controller { get; set; }
public string Action { get; set; }
public string Url { get; set; }
public string Description { get; set; }
public int? ParentId { get; set; }
public int Status { get; set; }
public string RouteValues { get; set; }
public int? OrderSerial { get; set; }
// Navigation Properties
public virtual ICollection<Role> Roles { get; set; }
}
接著透過Entity Framework Code First方式建立DB,初始DB時建立1個使用者Foo,賦予它2個角色分別是Admin與Staff,並且讓這兩個角色分別關聯至不同的Menu項,請參考以下程式碼。
// DbContext
public class MyDbContext : DbContext
{
// Coustructors
public MyDbContext() : base("MvcSiteMapDb")
{
Database.SetInitializer<MyDbContext>(new MyInitializer());
}
// Properties
public DbSet<User> Users { get; set; }
public DbSet<Role> Roles { get; set; }
public DbSet<Menu> Menus { get; set; }
// Methods
protected override void OnModelCreating(DbModelBuilder modelBuilder)
{
var userTable = modelBuilder.Entity<User>();
var roleTable = modelBuilder.Entity<Role>();
userTable.HasMany(u => u.Roles)
.WithMany(r => r.Users)
.Map(mc =>
{
mc.ToTable("UsersRoles");
mc.MapLeftKey("UserId");
mc.MapRightKey("RoleId");
});
roleTable.HasMany(r => r.Menus)
.WithMany(m => m.Roles)
.Map(mc =>
{
mc.ToTable("RolesMenus");
mc.MapLeftKey("RoleId");
mc.MapRightKey("MenuId");
});
base.OnModelCreating(modelBuilder);
}
}
// Initializer
public class MyInitializer : DropCreateDatabaseIfModelChanges<MyDbContext>
{
// Methods
protected override void Seed(MyDbContext context)
{
base.Seed(context);
// add role
context.Roles.AddOrUpdate(
r => r.Name,
new Role { Name = "Admin", IsEnable = true },
new Role { Name = "Staff", IsEnable = true }
);
context.SaveChanges();
// add user
context.Users.AddOrUpdate(
t => t.UserId,
new User
{
UserId = "Foo",
Email = "foo@ooxx.com",
Name = "Foo chen",
Password = "123",
IsEnable = true,
RegisterOn = DateTime.Now,
Roles = context.Roles.ToList()
}
);
context.SaveChanges();
// add menu
var menus = new List<Menu>
{
new Menu { MenuId = 1, Name = "訂購", Description="訂購", Controller="",
Action="", RouteValues=null, ParentId=null, OrderSerial=1, Status=1, Url="#1"},
new Menu { MenuId = 2, Name = "維護", Description="維護", Controller="",
Action="", RouteValues=null, ParentId=null, OrderSerial=1, Status=1, Url="#2"},
new Menu { MenuId = 3, Name = "訂購模式A", Description="訂購模式A", Controller="Order",
Action="Create", RouteValues="orderType=0", ParentId=1, OrderSerial=1, Status=1, Url=null},
new Menu { MenuId = 4, Name = "訂購模式B", Description="訂購模式B", Controller="Order",
Action="Create", RouteValues="orderType=1", ParentId=1, OrderSerial=1, Status=1, Url=null},
new Menu { MenuId = 5, Name = "編輯帳號", Description="編輯帳號", Controller="User",
Action="Edit", RouteValues=null, ParentId=2, OrderSerial=1, Status=1, Url=null},
};
menus.ForEach(s => context.Menus.AddOrUpdate(p => p.MenuId, s));
context.SaveChanges();
// add role-menu
AddOrUpdateMenu(context, "Admin", "訂購");
AddOrUpdateMenu(context, "Admin", "維護");
AddOrUpdateMenu(context, "Admin", "訂購模式A");
AddOrUpdateMenu(context, "Admin", "訂購模式B");
AddOrUpdateMenu(context, "Admin", "編輯帳號");
AddOrUpdateMenu(context, "Staff", "訂購");
AddOrUpdateMenu(context, "Staff", "訂購模式A");
AddOrUpdateMenu(context, "Staff", "訂購模式B");
context.SaveChanges();
}
void AddOrUpdateMenu(MyDbContext context, string roleName, string menuName)
{
var crs = context.Roles.SingleOrDefault(c => c.Name == roleName);
if (crs.Menus == null)
{
crs.Menus = new List<Menu>();
crs.Menus.Add(context.Menus.Single(i => i.Name == menuName));
}
else
{
var inst = crs.Menus.SingleOrDefault(i => i.Name == menuName);
if (inst == null)
crs.Menus.Add(context.Menus.Single(i => i.Name == menuName));
}
}
}
建立資料如下
我們可以在RolesMenus中發現不同角色(Role)會被賦予不同Menu功能項
安裝及使用MvcSiteMapProvider
從NuGet中取得MvcSiteMapProvider MVC5
加入後會自動加入以下檔案,可以依照畫面CSS套板需求對DisplayTemplates資料夾下的樣板進行調整,而Mvc.sitemap則是主要Menu結構定義之處。
此時只要在_Layout.cshtml加上HtmlHelper擴充方法MvcSiteMap()即可將Mvc.sitemap上靜態Menu結構呈現於頁面。
@Html.MvcSiteMap().Menu()
似乎長的怪怪,修改一下MenuHelperModel.cshtml讓他加上Boostrapt的樣式即可
動態生成Menu項目
使用MvcSiteMapProvider目的絕非僅單純使用靜態方式產生Menu(否則跟直接寫死有何不同),所以這小節要來介紹如何透過動態的方式從資料庫將登入用戶角色可使用的Menu項目列出。
首先,我們必須建立一個繼承自DynamicNodeProviderBase的MyDynamicNodeProvider類別,並覆寫其GetDynamicNodeCollection方法,主要目的就是要產生一個DynamicNode集合,以此集合作為畫面Menu的資料源。
public class MyDynamicNodeProvider : DynamicNodeProviderBase
{
public override IEnumerable<DynamicNode> GetDynamicNodeCollection(ISiteMapNode nodes)
{
var returnValue = new List<DynamicNode>();
using (var uow = new MyDbContext())
{
// 取出此用戶角色關聯的所有Menu項
var loginUserId = "Foo";
var roleMenus = uow.Users.Where(u => u.UserId == loginUserId)
.SelectMany(p => p.Roles)
.SelectMany(r => r.Menus)
.Distinct();
foreach (var menu in roleMenus)
{
DynamicNode node = new DynamicNode()
{
// 顯示的文字
Title = menu.Name,
// 父Menu項目Id
ParentKey = menu.ParentId.HasValue ? menu.ParentId.Value.ToString() : "",
// Node Key
Key = menu.MenuId.ToString(),
// Action Name
Action = menu.Action,
// Controller Name
Controller = menu.Controller,
// Url (只要有值就會以此為主)
Url = menu.Url
};
if (!string.IsNullOrWhiteSpace(menu.RouteValues))
{
// 此部分利用menu.RouteValues欄位文字轉乘key-value pair
// 當作RouteValues使用
// ex. Key1=value1,Key2=value2...
node.RouteValues = menu.RouteValues.Split(',').Select(value => value.Split('='))
.ToDictionary(pair => pair[0], pair => (object)pair[1]);
}
returnValue.Add(node);
}
}
return returnValue;
}
}
調整一下Mvc.sitemap來指定使用dynamicNodeProvider生成Menu
由於我們希望Menu具有從屬關係(下拉式Menu),並且要套用Boostrapt格式來呈現,所以乾脆就不再修改原本的MenuHelperModel.cshtml樣板,我們重新在DisplayTemplates資料夾下建立一個符合我們需求的BootstrapMenuHelperModel.cshtml,如下所示。
@model MvcSiteMapProvider.Web.Html.Models.MenuHelperModel
@using System.Web.Mvc.Html
@using MvcSiteMapProvider.Web.Html.Models
@helper TopMenu(List<SiteMapNodeModel> nodeList)
{
<ul class="nav navbar-nav">
@foreach (SiteMapNodeModel node in nodeList)
{
string url = node.IsClickable ? node.Url : "#";
if (!node.Children.Any())
{
<li><a href="@url">@node.Title</a></li>
}
else
{
<li class="dropdown"><a class="dropdown-toggle" data-toggle="dropdown" href="@url">@node.Title <b class="caret"></b></a>@DropDownMenu(node.Children)</li>
}
if (node != nodeList.Last())
{
<li class="divider-vertical"></li>
}
}
</ul>
}
@helper DropDownMenu(SiteMapNodeModelList nodeList)
{
<ul class="dropdown-menu">
@foreach (SiteMapNodeModel node in nodeList)
{
if (node.Title == "Separator")
{
@:
<li class="divider"></li>
continue;
}
string url = node.IsClickable ? node.Url : "#";
if (!node.Children.Any())
{
@:
<li><a href="@url">@node.Title</a></li>
}
else
{
@:
<li class="dropdown-submenu"><a href="@url">@node.Title</a>@DropDownMenu(node.Children)</li>
}
}
</ul>
}
@TopMenu(Model.Nodes)
最後再View中指定套用剛所建立的BootstrapMenuHelperModel範本
@Html.MvcSiteMap().Menu("BootstrapMenuHelperModel", false)
另外若有2層以上的Menu需求,請於~/Content/Site.css中加上以下CSS
/* Bootstrap Dropdown Submenu */
.dropdown-submenu {
position: relative;
}
.dropdown-submenu > .dropdown-menu {
top: 0;
left: 100%;
margin-top: -6px;
margin-left: -1px;
-webkit-border-radius: 0 6px 6px 6px;
-moz-border-radius: 0 6px 6px 6px;
border-radius: 0 6px 6px 6px;
}
.dropdown-submenu:hover > .dropdown-menu {
display: block;
}
.dropdown-submenu > a:after {
display: block;
content: " ";
float: right;
width: 0;
height: 0;
border-color: transparent;
border-style: solid;
border-width: 5px 0 5px 5px;
border-left-color: #cccccc;
margin-top: 5px;
margin-right: -10px;
}
.dropdown-submenu:hover > a:after {
border-left-color: #ffffff;
}
.dropdown-submenu.pull-left {
float: none;
}
.dropdown-submenu.pull-left > .dropdown-menu {
left: -100%;
margin-left: 10px;
-webkit-border-radius: 6px 0 6px 6px;
-moz-border-radius: 6px 0 6px 6px;
border-radius: 6px 0 6px 6px;
}
成果
快取(Catche)機制調整
預設是無法依據個別用戶進行快取,因此可參考 調整 MvcSiteMapProvider 快取機制 進行調整。
快取(Catche)釋放控制
在開發的過程中會發現並不是每一次執行都會進入MyDynamicNodeProvider去DB取出Menu項目,那為什麼Menu都會一值呈現於畫面中呢? 其實它是存在快取機制的,好讓我們不需要每次載入頁面都要不斷的重複去DB取Menu資料來呈現,避免不必要的資源浪費;反觀,當我們再變更使用者角色時,會希望當下就如實呈現出調整後所具有的功能項Menu,這時當然就不希望Menu是從快取來的舊資料,反而是要去DB撈取最貼近目前狀態的Menu項目。
當我們希望Menu項目資料是要從DB重新撈取時,我們可以透過[SiteMapCacheRelease]標籤來指定進入某個Action前釋放快取,好讓下一次進入頁面時重新透過MyDynamicNodeProvider去DB取出Menu項。
或者直接呼叫以下方法在Controllere Action中直接釋放快取
SiteMaps.ReleaseSiteMap();
另外亦可透過Config來設定相關資訊(如快取有效期),參考資訊如下
https://github.com/maartenba/MvcSiteMapProvider/wiki/Registering-the-provider
參考資料
http://kyleap.blogspot.tw/2014/10/30aspnet-mvc-angularjs-mvc-27.html
https://github.com/maartenba/MvcSiteMapProvider
希望此篇文章可以幫助到需要的人
若內容有誤或有其他建議請不吝留言給筆者喔 !