Asp.Net MVC及Web API框架配置會碰到的幾個問題及解決方案(轉)
前言
剛開始創建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路由前添加以下代碼即可完成
1 var suffix = typeof(DefaultHttpControllerSelector).GetField("ControllerSuffix", BindingFlags.Static | BindingFlags.Public); 2 if (suffix != null) suffix.SetValue(null, "ApiController");
三、給MVC不同命名空間的Area的註冊不同的路由 這個好辦,MVC路由配置支持命名空間,新建區域時框架會自動添加{區域名}AreaRegistration.cs文件,用於註冊本區域的路由 在這個文件中的RegisterArea方法中添加以下代碼即可
1 context.MapRoute( 2 this.AreaName + "default", 3 this.AreaName + "/{controller}/{action}/{id}", 4 new { controller = "Home", action = "Index", id = UrlParameter.Optional }, 5 new string[] { "Zephyr.Areas."+ this.AreaName + ".Controllers" } 6 );
其中第四個參數是命名空間參數,表示這個路由設置只在此命名空間下有效。
四、讓Web API路由配置也支持命名空間參數 讓人很頭疼的是Web Api路由配置竟然不支持命名空間參數,這間接讓我感覺它不支持Area,微軟真會開玩笑。好吧我們還是自己動手。在google上找到一篇文章http://netmvc.blogspot.com/2012/06/aspnet-mvc-4-webapi-support-areas-in.html 貌似被墻了,這裏有介紹一種方法替換HttpControllerSelector服務。 我直接把我的代碼貼出來,大家可以直接用,首先創建一個新的HttpControllerSelector類
1 using System; 2 using System.Linq; 3 using System.Collections.Concurrent; 4 using System.Collections.Generic; 5 using System.Net.Http; 6 using System.Web.Http; 7 using System.Web.Http.Controllers; 8 using System.Web.Http.Dispatcher; 9 using System.Net; 10 11 namespace Zephyr.Web 12 { 13 public class NamespaceHttpControllerSelector : DefaultHttpControllerSelector 14 { 15 private const string NamespaceRouteVariableName = "namespaceName"; 16 private readonly HttpConfiguration _configuration; 17 private readonly Lazy<ConcurrentDictionary<string, Type>> _apiControllerCache; 18 19 public NamespaceHttpControllerSelector(HttpConfiguration configuration) 20 : base(configuration) 21 { 22 _configuration = configuration; 23 _apiControllerCache = new Lazy<ConcurrentDictionary<string, Type>>( 24 new Func<ConcurrentDictionary<string, Type>>(InitializeApiControllerCache)); 25 } 26 27 private ConcurrentDictionary<string, Type> InitializeApiControllerCache() 28 { 29 IAssembliesResolver assembliesResolver = this._configuration.Services.GetAssembliesResolver(); 30 var types = this._configuration.Services.GetHttpControllerTypeResolver() 31 .GetControllerTypes(assembliesResolver).ToDictionary(t => t.FullName, t => t); 32 33 return new ConcurrentDictionary<string, Type>(types); 34 } 35 36 public IEnumerable<string> GetControllerFullName(HttpRequestMessage request, string controllerName) 37 { 38 object namespaceName; 39 var data = request.GetRouteData(); 40 IEnumerable<string> keys = _apiControllerCache.Value.ToDictionary<KeyValuePair<string, Type>, string, Type>(t => t.Key, 41 t => t.Value, StringComparer.CurrentCultureIgnoreCase).Keys.ToList(); 42 43 if (!data.Values.TryGetValue(NamespaceRouteVariableName, out namespaceName)) 44 { 45 return from k in keys 46 where k.EndsWith(string.Format(".{0}{1}", controllerName, 47 DefaultHttpControllerSelector.ControllerSuffix), StringComparison.CurrentCultureIgnoreCase) 48 select k; 49 } 50 51 string[] namespaces = (string[])namespaceName; 52 return from n in namespaces 53 join k in keys on string.Format("{0}.{1}{2}", n, controllerName, 54 DefaultHttpControllerSelector.ControllerSuffix).ToLower() equals k.ToLower() 55 select k; 56 } 57 58 public override HttpControllerDescriptor SelectController(HttpRequestMessage request) 59 { 60 Type type; 61 if (request == null) 62 { 63 throw new ArgumentNullException("request"); 64 } 65 string controllerName = this.GetControllerName(request); 66 if (string.IsNullOrEmpty(controllerName)) 67 { 68 throw new HttpResponseException(request.CreateErrorResponse(HttpStatusCode.NotFound, 69 string.Format("No route providing a controller name was found to match request URI ‘{0}‘", 70 new object[] { request.RequestUri }))); 71 } 72 IEnumerable<string> fullNames = GetControllerFullName(request, controllerName); 73 if (fullNames.Count() == 0) 74 { 75 throw new HttpResponseException(request.CreateErrorResponse(HttpStatusCode.NotFound, 76 string.Format("No route providing a controller name was found to match request URI ‘{0}‘", 77 new object[] { request.RequestUri }))); 78 } 79 80 if (this._apiControllerCache.Value.TryGetValue(fullNames.First(), out type)) 81 { 82 return new HttpControllerDescriptor(_configuration, controllerName, type); 83 } 84 throw new HttpResponseException(request.CreateErrorResponse(HttpStatusCode.NotFound, 85 string.Format("No route providing a controller name was found to match request URI ‘{0}‘", 86 new object[] { request.RequestUri }))); 87 } 88 } 89 }
然後在WebApiConfig類的Register中替換服務即可實現
1 config.Services.Replace(typeof(IHttpControllerSelector), new NamespaceHttpControllerSelector(config));
好吧,現在看看如何使用,還是在區域的{AreaName}AreaRegistration類下的RegisterArea方法中註冊Api的路由:
1 GlobalConfiguration.Configuration.Routes.MapHttpRoute( 2 this.AreaName + "Api", 3 "api/" + this.AreaName + "/{controller}/{action}/{id}", 4 new { action = RouteParameter.Optional, id = RouteParameter.Optional, 5 namespaceName = new string[] { string.Format("Zephyr.Areas.{0}.Controllers",this.AreaName) } }, 6 new { action = new StartWithConstraint() } 7 );
第三個參數defaults中的namespaceName,上面的服務已實現支持。第四個參數constraints我在第8個問題時會講到,這裏先略過。
五、MVC及Web API添加身份驗證及錯誤處理的過濾器 先說身份驗證的問題。無論是mvc還是api都有一個安全性的問題,未通過身份驗證的人能不能訪問的問題。我們新一個空項目時,默認是沒有身份驗證的,除非你在控制器類或者方法上面加上Authorize屬性才會需要身份驗證。但是我的控制器有那麽多,我都要給它加上屬性,多麻煩,所以我們就想到過濾器了。過濾器中加上後,控制器都不用加就相當於有這個屬性了。 Mvc的就直接在FilterConfig類的RegisterGlobalFilters方法中添加以下代碼即可
1 filters.Add(new System.Web.Mvc.AuthorizeAttribute());
Web Api的過濾器沒有單獨一個配置類,可以寫在WebApiConfig類的Register中
1 config.Filters.Add(new System.Web.Http.AuthorizeAttribute());
Mvc錯誤處理默認有添加HandleErrorAttribute默認的過濾器,但是我們有可能要捕捉這個錯誤並記錄系統日誌那麽這個過濾器就不夠用了,所以我們要自定義Mvc及Web Api各自的錯誤處理類,下面貼出我的錯誤處理,MvcHandleErrorAttribute
1 using System.Web; 2 using System.Web.Mvc; 3 using log4net; 4 5 namespace Zephyr.Web 6 { 7 public class MvcHandleErrorAttribute : HandleErrorAttribute 8 { 9 public override void OnException(ExceptionContext filterContext) 10 { 11 ILog log = LogManager.GetLogger(filterContext.RequestContext.HttpContext.Request.Url.LocalPath); 12 log.Error(filterContext.Exception); 13 base.OnException(filterContext); 14 } 15 } 16 }
Web API的錯誤處理
1 using System.Net; 2 using System.Net.Http; 3 using System.Web; 4 using System.Web.Http.Filters; 5 using log4net; 6 7 namespace Zephyr.Web 8 { 9 public class WebApiExceptionFilter : ExceptionFilterAttribute 10 { 11 public override void OnException(HttpActionExecutedContext context) 12 { 13 ILog log = LogManager.GetLogger(HttpContext.Current.Request.Url.LocalPath); 14 log.Error(context.Exception); 15 16 var message = context.Exception.Message; 17 if (context.Exception.InnerException != null) 18 message = context.Exception.InnerException.Message; 19 20 context.Response = new HttpResponseMessage() { Content = new StringContent(message) }; 21 22 base.OnException(context); 23 } 24 } 25 }
然後分別註冊到過濾器中,在FilterConfig類的RegisterGlobalFilters方法中
1 filters.Add(new MvcHandleErrorAttribute());
在WebApiConfig類的Register中
1 config.Filters.Add(new WebApiExceptionFilter());
這樣過濾器就定義好了。
六、MVC添加自定義模型綁定ModelBinder 在MVC中,我們有可能會自定義一些自己想要接收的參數,那麽可以通過ModelBinder去實現。比如我要在MVC的方法中接收JObject參數
1 public JsonResult DoAction(dynamic request) 2 { 3 4 }
直接這樣寫的話接收到的request為空值,因為JObject這個類型參數Mvc未實現,我們必須自己實現,先新建一個JObjectModelBinder類,添加如下代碼實現
1 using System.IO; 2 using System.Web.Mvc; 3 using Newtonsoft.Json; 4 5 namespace Zephyr.Web 6 { 7 public class JObjectModelBinder : IModelBinder 8 { 9 public object BindModel(ControllerContext controllerContext, ModelBindingContext bindingContext) 10 { 11 var stream = controllerContext.RequestContext.HttpContext.Request.InputStream; 12 stream.Seek(0, SeekOrigin.Begin); 13 string json = new StreamReader(stream).ReadToEnd(); 14 return JsonConvert.DeserializeObject<dynamic>(json); 15 } 16 } 17 }
然後在MVC註冊路由後面添加
1 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的參數,如下
1 public dynamic Get(RequestWrapper query) 2 { 3 //do something 4 }
那麽我們要新建一個RequestWrapperParameterBinding類
1 using System.Collections.Specialized; 2 using System.Threading; 3 using System.Threading.Tasks; 4 using System.Web.Http.Controllers; 5 using System.Web.Http.Metadata; 6 using Zephyr.Core; 7 8 namespace Zephyr.Web 9 { 10 public class RequestWrapperParameterBinding : HttpParameterBinding 11 { 12 private struct AsyncVoid { } 13 public RequestWrapperParameterBinding(HttpParameterDescriptor desc) : base(desc) { } 14 public override Task ExecuteBindingAsync(ModelMetadataProvider metadataProvider, 15 HttpActionContext actionContext, CancellationToken cancellationToken) 16 { 17 var request = System.Web.HttpUtility.ParseQueryString(actionContext.Request.RequestUri.Query); 18 var requestWrapper = new RequestWrapper(new NameValueCollection(request)); 19 if (!string.IsNullOrEmpty(request["_xml"])) 20 { 21 var xmlType = request["_xml"].Split(‘.‘); 22 var xmlPath = string.Format("~/Views/Shared/Xml/{0}.xml", xmlType[xmlType.Length – 1]); 23 if (xmlType.Length > 1) 24 xmlPath = string.Format("~/Areas/{0}/Views/Shared/Xml/{1}.xml", xmlType); 25 26 requestWrapper.LoadSettingXml(xmlPath); 27 } 28 29 SetValue(actionContext, requestWrapper); 30 31 TaskCompletionSource<AsyncVoid> tcs = new TaskCompletionSource<AsyncVoid>(); 32 tcs.SetResult(default(AsyncVoid)); 33 return tcs.Task; 34 } 35 } 36 }
接下來要把這個綁定註冊到綁定規則當中,還是在WebApiConfig中添加
1 config.ParameterBindingRules.Insert(0, param => { 2 if (param.ParameterType == typeof(RequestWrapper)) 3 return new RequestWrapperParameterBinding(param); 4 5 return null; 6 });
此時RequestWrapper參數綁定已完成,可以使用了
八、讓Web API同時支持多個Get方法 先引用微軟官方的東西把存在的問題跟大家說明白,假如Web Api在路由中註冊的為
1 routes.MapHttpRoute( 2 name: "API Default", 3 routeTemplate: "api/{controller}/{id}", 4 defaults: new { id = RouteParameter.Optional } 5 );
然後我的控制器為
1 public class ProductsController : ApiController 2 { 3 public void GetAllProducts() { } 4 public IEnumerable<Product> GetProductById(int id) { } 5 public HttpResponseMessage DeleteProduct(int id){ } 6 }
那麽對應的地址請求到的方法如下
看到上面不知道到大家看到問題了沒,如果我有兩個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類
1 using System; 2 using System.Collections.Generic; 3 using System.Linq; 4 using System.Web; 5 using System.Web.Http.Routing; 6 7 namespace Zephyr.Web 8 { 9 /// <summary> 10 /// 如果請求url如: api/area/controller/x x有可能是actioin或id 11 /// 在url中的x位置出現的是以 get put delete post開頭的字符串,則當作action,否則就當作id 12 /// 如果action為空,則把請求方法賦給action 13 /// </summary> 14 public class StartWithConstraint : IHttpRouteConstraint 15 { 16 public string[] array { get; set; } 17 public bool match { get; set; } 18 private string _id = "id"; 19 20 public StartWithConstraint(string[] startwithArray = null) 21 { 22 if (startwithArray == null) 23 startwithArray = new string[] { "GET", "PUT", "DELETE", "POST", "EDIT", "UPDATE", "AUDIT", "DOWNLOAD" }; 24 25 this.array = startwithArray; 26 } 27 28 public bool Match(System.Net.Http.HttpRequestMessage request, IHttpRoute route, string parameterName, 29 IDictionary<string, object> values, HttpRouteDirection routeDirection) 30 { 31 if (values == null) // shouldn‘t ever hit this. 32 return true; 33 34 if (!values.ContainsKey(parameterName) || !values.ContainsKey(_id)) // make sure the parameter is there. 35 return true; 36 37 var action = values[parameterName].ToString().ToLower(); 38 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" 39 { 40 values[parameterName] = request.Method.ToString(); 41 } 42 else if (string.IsNullOrEmpty(values[_id].ToString())) 43 { 44 var isidstr = true; 45 array.ToList().ForEach(x => 46 { 47 if (action.StartsWith(x.ToLower())) 48 isidstr = false; 49 }); 50 51 if (isidstr) 52 { 53 values[_id] = values[parameterName]; 54 values[parameterName] = request.Method.ToString(); 55 } 56 } 57 return true; 58 } 59 } 60 }
然後在對應的API路由註冊時,添加第四個參數constraints
1 GlobalConfiguration.Configuration.Routes.MapHttpRoute( 2 this.AreaName + "Api", 3 "api/" + this.AreaName + "/{controller}/{action}/{id}", 4 new { action = RouteParameter.Optional, id = RouteParameter.Optional, 5 namespaceName = new string[] { string.Format("Zephyr.Areas.{0}.Controllers",this.AreaName) } }, 6 new { action = new StartWithConstraint() } 7 );
這樣就實現了,Api控制器中Action的取名就要註意點就是了,不過還算是一個比較完美的解決方案。
Asp.Net MVC及Web API框架配置會碰到的幾個問題及解決方案(轉)