標籤:
前言
剛開始建立MVC與Web API的混合項目時,碰到好多問題,今天拿出來跟大家一起分享下。有朋友私信我問項目的分層及檔案夾結構在我的第一篇部落格中沒說清楚,那麼接下來我就準備從這些檔案怎麼分檔案夾說起。問題大概有以下幾點:
1、項目層的檔案夾結構
2、解決MVC的Controller和Web API的Controller類名不能相同的問題
3、給MVC不同命名空間的Area的註冊不同的路由
4、讓Web API路由配置也支援命名空間參數
5、MVC及Web API添加身分識別驗證及錯誤處理的過濾器
6、MVC添加自訂參數模型繫結ModelBinder
7、Web API添加自訂參數綁定HttpParameterBinding
8、讓Web API同時支援多個Get方法
本文
一、項目層的檔案夾結構
這裡的結構談我自己的項目僅供大家參考,不合理的地方歡迎大家指出。第一篇部落格中我已經跟大家說了下架構的分層及簡單說了下項目層,現在我們再仔細說下。建立MVC或Web API時微軟已經給我們建立好了許多的檔案夾,如App_Start放全域設定,Content放樣式等、Controller放控制器類、Model資料模型、Scripts指令碼、Views視圖。有些人習慣了傳統的三層架構(有些是N層),喜歡把Model檔案夾、Controller檔案夾等單獨一個項目出來,我感覺是沒必要,因為在不同檔案夾下也算是一種分層了,單獨出來最多也就是編譯出來的dll是獨立的,基本沒有太多的區別。所以我還是從簡,沿用微軟分好的檔案夾。先看我的
我添加了地區Areas,我的思路是最外層的Model(已刪除)、Controllers、Views都只放一些共通的東西,真正的項目放在Areas中,比如中Mms代表我的材料管理系統,Psi是另外一個系統,Sys是我的系統管理模組。這樣就可以做到多個系統在一個項目中,架構的重用性不言而喻。再具體看地區中一個項目
這當中微軟產生的檔案夾只有Controllers、Models、Views。其它都是我建的,比如Common放項目共通的一些類,Reports準備放報表檔案、ViewModels放Knouckoutjs的ViewModel指令檔。
接下來再看看UI庫指令碼庫引入的一些控制項要放置在哪裡。如
我把架構的css images js themes等都放置在Content下,css中放置項目樣式及960gs架構,js下面core是自已定義的一些共通的js包括utils.js、common.js及easyui的knouckout綁定實現knouckout.bindings.js,其它一看就懂基本不用介紹了。
二、解決MVC的Controller和Web API的Controller類名不能相同的問題
回到地區下的一個專案檔夾內,在Controller中我們要建立Mvc Controller及Api Controller,假如一個收料的業務(receive)
mvc路由註冊為~/{controller}/{action},我希望的訪問地址應該是 ~/receive/action
api中由註冊為~/api/{controller},我希望的訪問地址應該是 ~/api/receive
那麼問題就產生了,微軟設計這個架構是通過類名去匹配的 mvc下你建立一個 receiveController繼承Controller,就不能再建立一個同名的receiveController繼承ApiController,這樣的話mvc的訪問地址和api的訪問地址必須要有一個名字不能叫receive,是不是很鬱悶。
通過查看微軟System.Web.Http的源碼,我們發現其實這個問題也很好解決,在這個DefaultHttpControllerSelector類中,微軟有定義Controller的尾碼,
我們只要把ApiController的尾碼改成和MVC不一樣,就可以解決問題了。這個欄位是個靜態唯讀Field,我們只要把它改成ApiContrller就解決問題了。我們首先想到的肯定是反射。好吧,就這麼做,在註冊Api路由前添加以下代碼即可完成
var suffix = typeof(DefaultHttpControllerSelector).GetField("ControllerSuffix", BindingFlags.Static | BindingFlags.Public); if (suffix != null) suffix.SetValue(null, "ApiController");
三、給MVC不同命名空間的Area的註冊不同的路由
這個好辦,MVC路由配置支援命名空間,建立地區時架構會自動添加{地區名}AreaRegistration.cs檔案,用於註冊本地區的路由
在這個檔案中的RegisterArea方法中添加以下代碼即可
context.MapRoute( this.AreaName + "default", this.AreaName + "/{controller}/{action}/{id}", new { controller = "Home", action = "Index", id = UrlParameter.Optional }, new string[] { "Zephyr.Areas."+ this.AreaName + ".Controllers" });
其中第四個參數是命名空間參數,表示這個路由設定只在此命名空間下有效。
四、讓Web API路由配置也支援命名空間參數
讓人很頭疼的是Web Api路由配置竟然不支援命名空間參數,這間接讓我感覺它不支援Area,微軟真會開玩笑。好吧我們還是自己動手。在google上找到一篇文章http://netmvc.blogspot.com/2012/06/aspnet-mvc-4-webapi-support-areas-in.html 貌似被牆了,這裡有介紹一種方法替換HttpControllerSelector服務。
我直接把My Code貼出來,大家可以直接用,首先建立一個新的HttpControllerSelector類
using System;using System.Linq;using System.Collections.Concurrent;using System.Collections.Generic;using System.Net.Http;using System.Web.Http;using System.Web.Http.Controllers;using System.Web.Http.Dispatcher;using System.Net;namespace Zephyr.Web{ public class NamespaceHttpControllerSelector : DefaultHttpControllerSelector { private const string NamespaceRouteVariableName = "namespaceName"; private readonly HttpConfiguration _configuration; private readonly Lazy<ConcurrentDictionary<string, Type>> _apiControllerCache; public NamespaceHttpControllerSelector(HttpConfiguration configuration) : base(configuration) { _configuration = configuration; _apiControllerCache = new Lazy<ConcurrentDictionary<string, Type>>(
new Func<ConcurrentDictionary<string, Type>>(InitializeApiControllerCache)); } private ConcurrentDictionary<string, Type> InitializeApiControllerCache() { IAssembliesResolver assembliesResolver = this._configuration.Services.GetAssembliesResolver(); var types = this._configuration.Services.GetHttpControllerTypeResolver()
.GetControllerTypes(assembliesResolver).ToDictionary(t => t.FullName, t => t); return new ConcurrentDictionary<string, Type>(types); } public IEnumerable<string> GetControllerFullName(HttpRequestMessage request, string controllerName) { object namespaceName; var data = request.GetRouteData(); IEnumerable<string> keys = _apiControllerCache.Value.ToDictionary<KeyValuePair<string, Type>, string, Type>(t => t.Key, t => t.Value, StringComparer.CurrentCultureIgnoreCase).Keys.ToList(); if (!data.Values.TryGetValue(NamespaceRouteVariableName, out namespaceName)) { return from k in keys where k.EndsWith(string.Format(".{0}{1}", controllerName,
DefaultHttpControllerSelector.ControllerSuffix), StringComparison.CurrentCultureIgnoreCase) select k; } string[] namespaces = (string[])namespaceName; return from n in namespaces join k in keys on string.Format("{0}.{1}{2}", n, controllerName,
DefaultHttpControllerSelector.ControllerSuffix).ToLower() equals k.ToLower() select k; } public override HttpControllerDescriptor SelectController(HttpRequestMessage request) { Type type; if (request == null) { throw new ArgumentNullException("request"); } string controllerName = this.GetControllerName(request); if (string.IsNullOrEmpty(controllerName)) { throw new HttpResponseException(request.CreateErrorResponse(HttpStatusCode.NotFound, string.Format("No route providing a controller name was found to match request URI ‘{0}‘",
new object[] { request.RequestUri }))); } IEnumerable<string> fullNames = GetControllerFullName(request, controllerName); if (fullNames.Count() == 0) { throw new HttpResponseException(request.CreateErrorResponse(HttpStatusCode.NotFound, string.Format("No route providing a controller name was found to match request URI ‘{0}‘",
new object[] { request.RequestUri }))); } if (this._apiControllerCache.Value.TryGetValue(fullNames.First(), out type)) { return new HttpControllerDescriptor(_configuration, controllerName, type); } throw new HttpResponseException(request.CreateErrorResponse(HttpStatusCode.NotFound, string.Format("No route providing a controller name was found to match request URI ‘{0}‘",
new object[] { request.RequestUri }))); } }}
然後在WebApiConfig類的Register中替換服務即可實現
config.Services.Replace(typeof(IHttpControllerSelector), new NamespaceHttpControllerSelector(config));
好吧,現在看看如何使用,還是在地區的{AreaName}AreaRegistration類下的RegisterArea方法中註冊Api的路由:
GlobalConfiguration.Configuration.Routes.MapHttpRoute( this.AreaName + "Api", "api/" + this.AreaName + "/{controller}/{action}/{id}", new { action = RouteParameter.Optional, id = RouteParameter.Optional,
namespaceName = new string[] { string.Format("Zephyr.Areas.{0}.Controllers",this.AreaName) } }, new { action = new StartWithConstraint() });
第三個參數defaults中的namespaceName,上面的服務已實現支援。第四個參數constraints我在第8個問題時會講到,這裡先略過。
五、MVC及Web API添加身分識別驗證及錯誤處理的過濾器
先說身分識別驗證的問題。無論是mvc還是api都有一個安全性的問題,未通過身分識別驗證的人能不能訪問的問題。我們新一個空項目時,預設是沒有身分識別驗證的,除非你在控制器類或者方法上面加上Authorize屬性才會需要身分識別驗證。但是我的控制器有那麼多,我都要給它加上屬性,多麻煩,所以我們就想到過濾器了。過濾器中加上後,控制器都不用加就相當於有這個屬性了。
Mvc的就直接在FilterConfig類的RegisterGlobalFilters方法中添加以下代碼即可
filters.Add(new System.Web.Mvc.AuthorizeAttribute());
Web Api的過濾器沒有單獨一個配置類,可以寫在WebApiConfig類的Register中
config.Filters.Add(new System.Web.Http.AuthorizeAttribute());
Mvc錯誤處理預設有添加HandleErrorAttribute預設的過濾器,但是我們有可能要捕捉這個錯誤並記錄系統日誌那麼這個過濾器就不夠用了,所以我們要自訂Mvc及Web Api各自的錯誤處理類,下面貼出我的錯誤處理,MvcHandleErrorAttribute
using System.Web;using System.Web.Mvc;using log4net;namespace Zephyr.Web{ public class MvcHandleErrorAttribute : HandleErrorAttribute { public override void OnException(ExceptionContext filterContext) { ILog log = LogManager.GetLogger(filterContext.RequestContext.HttpContext.Request.Url.LocalPath); log.Error(filterContext.Exception); base.OnException(filterContext); } }}
Web API的錯誤處理
using System.Net;using System.Net.Http;using System.Web;using System.Web.Http.Filters;using log4net;namespace Zephyr.Web{ public class WebApiExceptionFilter : ExceptionFilterAttribute { public override void OnException(HttpActionExecutedContext context) { ILog log = LogManager.GetLogger(HttpContext.Current.Request.Url.LocalPath); log.Error(context.Exception); var message = context.Exception.Message; if (context.Exception.InnerException != null) message = context.Exception.InnerException.Message; context.Response = new HttpResponseMessage() { Content = new StringContent(message) }; base.OnException(context); } }}
然後分別註冊到過濾器中,在FilterConfig類的RegisterGlobalFilters方法中
filters.Add(new MvcHandleErrorAttribute());
在WebApiConfig類的Register中
config.Filters.Add(new WebApiExceptionFilter());
這樣過濾器就定義好了。
六、MVC添加自訂模型繫結ModelBinder
在MVC中,我們有可能會自訂一些自己想要接收的參數,那麼可以通過ModelBinder去實現。比如我要在MVC的方法中接收JObject參數
public JsonResult DoAction(dynamic request){}
直接這樣寫的話接收到的request為空白值,因為JObject這個型別參數Mvc未實現,我們必須自己實現,先建立一個JObjectModelBinder類,添加如下代碼實現
using System.IO;using System.Web.Mvc;using Newtonsoft.Json;namespace Zephyr.Web{ public class JObjectModelBinder : IModelBinder { public object BindModel(ControllerContext controllerContext, ModelBindingContext bindingContext) { var stream = controllerContext.RequestContext.HttpContext.Request.InputStream; stream.Seek(0, SeekOrigin.Begin); string json = new StreamReader(stream).ReadToEnd(); return JsonConvert.DeserializeObject<dynamic>(json); } }}
然後在MVC註冊路由後面添加
ModelBinders.Binders.Add(typeof(JObject), new JObjectModelBinder()); //for dynamic model binder
添加之後,在MVC控制器中我們就可以接收JObject參數了。
七、Web API添加自訂參數綁定HttpParameterBinding
不知道微軟搞什麼鬼,Web Api的參數綁定機制跟Mvc的參數綁定有很大的不同,首先Web Api的綁定機制分兩種,一種叫Model Binding,一種叫Formatters,一般情況下Model Binding用於讀取query string中的值,而Formatters用於讀取body中的值,這個東西要深究還有很多東西,大家有興趣自己再去研究,我這裡就簡單說一下如何自訂ModelBinding,比如在Web API中我自己定義了一個叫RequestWrapper的類,我要在Api控制器中接收RequestWrapper的參數,如下
public dynamic Get(RequestWrapper query){ //do something}
那麼我們要建立一個RequestWrapperParameterBinding類
using System.Collections.Specialized;using System.Threading;using System.Threading.Tasks;using System.Web.Http.Controllers;using System.Web.Http.Metadata;using Zephyr.Core;namespace Zephyr.Web{ public class RequestWrapperParameterBinding : HttpParameterBinding { private struct AsyncVoid { } public RequestWrapperParameterBinding(HttpParameterDescriptor desc) : base(desc) { } public override Task ExecuteBindingAsync(ModelMetadataProvider metadataProvider,
HttpActionContext actionContext, CancellationToken cancellationToken) { var request = System.Web.HttpUtility.ParseQueryString(actionContext.Request.RequestUri.Query); var requestWrapper = new RequestWrapper(new NameValueCollection(request)); if (!string.IsNullOrEmpty(request["_xml"])) { var xmlType = request["_xml"].Split(‘.‘); var xmlPath = string.Format("~/Views/Shared/Xml/{0}.xml", xmlType[xmlType.Length – 1]); if (xmlType.Length > 1) xmlPath = string.Format("~/Areas/{0}/Views/Shared/Xml/{1}.xml", xmlType); requestWrapper.LoadSettingXml(xmlPath); } SetValue(actionContext, requestWrapper); TaskCompletionSource<AsyncVoid> tcs = new TaskCompletionSource<AsyncVoid>(); tcs.SetResult(default(AsyncVoid)); return tcs.Task; } }}
接下來要把這個綁定註冊到綁定規則當中,還是在WebApiConfig中添加
config.ParameterBindingRules.Insert(0, param => { if (param.ParameterType == typeof(RequestWrapper)) return new RequestWrapperParameterBinding(param); return null;});
此時RequestWrapper參數綁定已完成,可以使用了
八、讓Web API同時支援多個Get方法
先引用微軟官方的東西把存在的問題跟大家說明白,假如Web Api在路由中註冊的為
routes.MapHttpRoute( name: "API Default", routeTemplate: "api/{controller}/{id}", defaults: new { id = RouteParameter.Optional });
然後我的控制器為
public class ProductsController : ApiController{ public void GetAllProducts() { } public IEnumerable<Product> GetProductById(int id) { } public HttpResponseMessage DeleteProduct(int id){ }}
那麼對應的地址請求到的方法如下
看到上面不知道到大家看到問題了沒,如果我有兩個Get方法(我再加一個GetTop10Products,這種情況很常見),而且參數也相同那麼路由就沒有辦法區分了。有人就想到了修改路由設定,把routeTemplate:修改為"api/{controller}/{action}/{id}",沒錯,這樣是能解決上述問題,但是你的api/products無論是Get Delete Post Input方式都無法請求到對應的方法,你必須要api/products/GetAllProducts、api/products/DeleteProduct/4 ,action名你不能省略。現在明白了問題所在了。我就是要解決這個問題。
還記得我在寫第四點的時候有提到這裡,思路就是要定義一個constraints去實現:
我們先分析下uri path: api/controller/x,問題就在這裡的x,它有可能代表action也有可能代表id,其實我們就是要區分這個x什麼情況下代表action什麼情況下代表id就可以解決問題了,我是想自己定義一系統的動詞,如果你的actoin的名字是以我定義的這些動詞中的一個開頭,那麼我認為你是action,否則認為你是id。
好,思路說明白了,我們開始實現,先定義一個StartWithConstraint類
using System;using System.Collections.Generic;using System.Linq;using System.Web;using System.Web.Http.Routing;namespace Zephyr.Web{ /// <summary> /// 如果請求url如: api/area/controller/x x有可能是actioin或id /// 在url中的x位置出現的是以 get put delete post開頭的字串,則當作action,否則就當作id /// 如果action為空白,則把要求方法賦給action /// </summary> public class StartWithConstraint : IHttpRouteConstraint { public string[] array { get; set; } public bool match { get; set; } private string _id = "id"; public StartWithConstraint(string[] startwithArray = null) { if (startwithArray == null) startwithArray = new string[] { "GET", "PUT", "DELETE", "POST", "EDIT", "UPDATE", "AUDIT", "DOWNLOAD" }; this.array = startwithArray; } public bool Match(System.Net.Http.HttpRequestMessage request, IHttpRoute route, string parameterName,
IDictionary<string, object> values, HttpRouteDirection routeDirection) { if (values == null) // shouldn‘t ever hit this. return true; if (!values.ContainsKey(parameterName) || !values.ContainsKey(_id)) // make sure the parameter is there. return true; var action = values[parameterName].ToString().ToLower(); if (string.IsNullOrEmpty(action)) // if the param key is empty in this case "action" add the method so it doesn‘t hit other methods like "GetStatus" { values[parameterName] = request.Method.ToString(); } else if (string.IsNullOrEmpty(values[_id].ToString())) { var isidstr = true; array.ToList().ForEach(x => { if (action.StartsWith(x.ToLower())) isidstr = false; }); if (isidstr) { values[_id] = values[parameterName]; values[parameterName] = request.Method.ToString(); } } return true; } }}
然後在對應的API路由註冊時,添加第四個參數constraints
GlobalConfiguration.Configuration.Routes.MapHttpRoute( this.AreaName + "Api", "api/" + this.AreaName + "/{controller}/{action}/{id}", new { action = RouteParameter.Optional, id = RouteParameter.Optional,
namespaceName = new string[] { string.Format("Zephyr.Areas.{0}.Controllers",this.AreaName) } }, new { action = new StartWithConstraint() });
這樣就實現了,Api控制器中Action的取名就要注意點就是了,不過還算是一個比較完美的解決方案。
轉自:http://www.cnblogs.com/xqin/archive/2013/05/31/3109569.html
轉-Asp.Net MVC及Web API架構配置會碰到的幾個問題及解決方案