一、前言
上篇有提到在WebAPI項目內,通過在Nuget里安裝(Microsoft.AspNet.WebApi.HelpPage)可以根據注釋生成幫助文檔,查看代碼實現會發現是基於解析項目生成的xml文檔來作為數據源從而展示出來的。在我們的項目幫助文檔需要的類(特指定義的Request和Response)與項目在同一個項目時是沒有問題的,但是我們實際工作中會因為其他項目也需要引用該(Request和Response)時,我們會將其抽出來單獨作為一個項目供其它調用來引用,這時,查看幫助文檔不會報錯,但是注釋以及附加信息將會丟失,因為這些信息是我們的代碼注釋和數據注釋(如 [Required]標識為必填),也是生成到xml文檔中的信息,但因不在同一項目內,將讀取不到從而導致幫助文檔無法顯示我們的注釋(對應的描述)和附加信息(是否必填、默認值、Range等).
二、幫助文檔注釋概要
我們的注釋就是幫助文檔的說明或者說是描述,那么這個功能是安裝了HelpPage就直接具有的嗎,這里分兩種方式。
1:創建項目時是直接選擇的Web API,那么這時在創建初始化項目時就配置好此功能的。
2:創建項目時選擇的是Empty,選擇的核心引用選擇Web API是不具有此功能。
對於方式1來說生成的項目代碼有一部分我們是不需要的,我們可以做減法來刪掉不必要的文件。
對於方式2來說,需要在Nuget內安裝HelpPage,需要將文件~/Areas/HelpPage/HelpPageConfig.cs內的配置注釋取消,具體的可以根據需要。
並且設置項目的生成屬性內的輸出,勾選Xml文檔文件,同時設置值與~/Areas/HelpPage/HelpPageConfig.cs
內的配置一致。
並在Global.asax文件Application_Start方法注冊。
AreaRegistration.RegisterAllAreas();這時幫助文檔已經可用,但卻沒有樣式。你可以選擇手動將需要的css及js拷入Areas文件夾內。並添加文件
public class BundleConfig { // 有關綁定的詳細信息,請訪問 http://go.microsoft.com/fwlink/?LinkId=301862 public static void RegisterBundles(BundleCollection bundles) { bundles.Add(new ScriptBundle("~/bundles/jquery").Include( "~/Areas/HelpPage/Scripts/jquery-{version}.js")); // 使用要用於開發和學習的 Modernizr 的開發版本。然后,當你做好 // 生產准備時,請使用 http://modernizr.com 上的生成工具來僅選擇所需的測試。 bundles.Add(new ScriptBundle("~/bundles/modernizr").Include( "~/Areas/HelpPage/Scripts/modernizr-*")); bundles.Add(new ScriptBundle("~/bundles/bootstrap").Include( "~/Areas/HelpPage/Scripts/bootstrap.js", "~/Areas/HelpPage/Scripts/respond.js")); bundles.Add(new StyleBundle("~/Content/css").Include( "~/Areas/HelpPage/Content/bootstrap.css", "~/Areas/HelpPage/Content/site.css")); } }並在Global.asax文件Application_Start方法將其注冊。
BundleConfig.RegisterBundles(BundleTable.Bundles);最后更改~/Areas/HelpPage/Views/Shared/_Layout.cshtml 為
@using System @using System.Web.Optimization <!DOCTYPE html> <html> <head> <meta http-equiv="Content-Type" content="text/html; charset=utf-8" /> <meta charset="utf-8" /> <meta name="viewport" content="width=device-width" /> <title>@ViewBag.Title</title> @Styles.Render("~/Content/css") @Scripts.Render("~/bundles/modernizr") </head> <meta http-equiv="Content-Type" content="text/html; charset=utf-8" /> <body> <div class="navbar navbar-inverse navbar-fixed-top"> <div class="container"> <div class="navbar-header"> <button type="button" class="navbar-toggle" data-toggle="collapse" data-target=".navbar-collapse"> <span class="icon-bar"></span> <span class="icon-bar"></span> <span class="icon-bar"></span> </button> </div> <div class="navbar-collapse collapse"> <ul class="nav navbar-nav"> <li>@Html.Raw("<a href='/Help'>首頁</a>")</li> <li>@Html.Raw("<a href='/PostMan' target='_blank'>PostManFeture</a>")</li> </ul> </div> </div> </div> <div class="container body-content"> @RenderBody() <hr /> <footer> <p>© @DateTime.Now.Year - 逗豆豆</p> </footer> </div> @Scripts.Render("~/bundles/jquery") @Scripts.Render("~/bundles/bootstrap") @RenderSection("scripts", required: false) </body> </html>此時你看到的才會是如下的文檔。
對應的路由結構如下
查看Route可以發現其 AllowMultiple = true 意味着我們可以對同一個Action定義多個不同的路由,但同時也意味着該Action只允許定義的路由訪問。
比如這里的Get方法,這時在瀏覽器只能以這種方式訪問 http://localhost:11488/api/product/{id}
用 http://localhost:11488/api/product?id={id} 則會拋出405,如下。
為了支持多種方式我們將路由增加,如下。
這時文檔會將兩種路由都生成出來。
這里有個原則是同類型的請求且響應的類型相同不允許定義相同的路由,如下,都是HttpGet 且響應類型相同。
/// <summary> /// 獲取所有產品 /// </summary> [HttpGet, Route("")] public IEnumerable<Product> Get() { return _products; } /// <summary> /// 獲取前三產品 /// </summary> [HttpGet, Route("")] public IEnumerable<Product> GetTop3() { return _products.Take(3); }此時訪問 http://localhost:11488/api/product 會發現500錯誤,提示為匹配到多個Action,且這時候查看幫助文檔也只會顯示一個匹配的Action(前提是你沒有指定Route的Order屬性)。
路由內可以做一些基本的限制,我們將上面的Top3方法改造為可以根據傳入參數來決定Top多少,並且最少是前三條。
/// <summary> /// 獲取前幾產品 /// </summary> [HttpGet, Route("Top/{count:min(3)}")] public IEnumerable<Product> GetTop(int count) { return _products.Take(3); }這時訪問 http://localhost:11488/api/product/Top/1 或 http://localhost:11488/api/product/Top/2 將會是拋出404
但是我希望直接訪問 http://localhost:11488/api/product/Top 默認取前3條,這時直接訪問會是405,因為並沒有定義出Route(“Top”)的路由,我們改造下
/// <summary> /// 獲取前幾產品 /// </summary> [HttpGet, Route("Top/{count:min(3):int=3}")] public IEnumerable<Product> GetTop(int count) { return _products.Take(3); }這時在訪問 http://localhost:11488/api/product/Top 就會默認返回前3條了,除此之外還有一些定義包括正則可以 看這里 和 這里 。
路由的文檔相關的基本就這些,有遺漏的地方歡迎指出。
接下來就是單個接口的Request和Response的文檔,先來看看我們分別以Request和Response分開來看。
首先看下 api/Product/All 這個接口的顯示,會發現分為兩類。
api/Product 這個接口本身是就不需要任何參數的,因此都是None。
Put api/Product?id={id} 這接口確是都包含。他的定義如下。
/// <summary> /// 編輯產品 /// </summary> /// <param name="id">產品編號</param> /// <param name="request">編輯后的產品</param> [HttpPut, Route(""), Route("{id}")] public string Put(int id, Product request) { var model = _products.FirstOrDefault(x => x.Id.Equals(id)); if (model == null) return "未找到該產品"; model.Name = request.Name; model.Price = request.Price; model.Description = request.Description; return "ok"; }那其實,實際中我們可能只會使用Get和Post來完成我們所有的操作。因此,就會是Get只顯示URI Parameters 而 Post只顯示Body Parameters。
可以看到Description就是我們對屬性的注釋,Type就是屬性的類型,而Additional information 則是“約束”的描述,如我們會約束請求的參數哪些為必填哪些為選填,哪些參數的值具有使用范圍。
比如我們改造一下Product。
/// <summary> /// 產品 /// </summary> public class Product { /// <summary> /// 編號 /// </summary> [Required] public int Id { get; set; } /// <summary> /// 名稱 /// </summary> [Required, MaxLength(36)] public string Name { get; set; } /// <summary> /// 價格 /// </summary> [Required, Range(0, 99999999)] public decimal Price { get; set; } /// <summary> /// 描述 /// </summary> public string Description { get; set; } }可以看見對應的“約束信息”就改變了。
有人可能會說,我自定義了一些約束該怎么顯示呢,接下來我們定義一個最小值約束MinAttrbute。
/// <summary> /// 最小值特性 /// </summary> [AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] public class MinAttribute : ValidationAttribute { /// <summary> /// 最小值 /// </summary> public int MinimumValue { get; set; } /// <summary> /// 構造函數 /// </summary> /// <param name="minimun"></param> public MinAttribute(int minimun) { MinimumValue = minimun; } /// <summary> /// 驗證邏輯 /// </summary> /// <param name="value">需驗證的值</param> /// <returns>是否通過驗證</returns> public override bool IsValid(object value) { int intValue; if (value != null && int.TryParse(value.ToString(), out intValue)) { return (intValue >= MinimumValue); } return false; } /// <summary> /// 格式化錯誤信息 /// </summary> /// <param name="name">屬性名稱</param> /// <returns>錯誤信息</returns> public override string FormatErrorMessage(string name) { return string.Format("{0} 最小值為 {1}", name, MinimumValue); } }將其加在Price屬性上,並將最小值設定為10。
/// <summary> /// 價格 /// </summary> [Required, Min(10)] public decimal Price { get; set; }這時通過PostMan去請求,會發現驗證是通過的,並沒有預計的錯誤提示。那是因為我們沒有啟用驗證屬性的特性。
我們自定義一個ValidateModelAttribute,可用范圍指定為Class和Method,且不允許多次,並將其加到剛才的Put接口上。
/// <summary> /// 驗證模型過濾器 /// </summary> [AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = false, Inherited = true)] public class ValidateModelAttribute : ActionFilterAttribute { /// <summary> /// Action執行前驗證 /// </summary> /// <param name="actionContext">The action context.</param> public override void OnActionExecuting(HttpActionContext actionContext) { if (actionContext.ActionArguments.Any(kv => kv.Value == null)) { actionContext.Response = actionContext.Request.CreateErrorResponse(HttpStatusCode.BadRequest, "參數不能為空"); } if (actionContext.ModelState.IsValid) return; actionContext.Response = actionContext.Request.CreateErrorResponse(HttpStatusCode.BadRequest, actionContext.ModelState); } }/// <summary> /// 編輯產品 /// </summary> /// <param name="id">產品編號</param> /// <param name="request">編輯后的產品</param> [HttpPut, Route(""), Route("{id}")] [ValidateModel] public string Put(int id, Product request) { var model = _products.FirstOrDefault(x => x.Id.Equals(id)); if (model == null) return "未找到該產品"; model.Name = request.Name; model.Price = request.Price; model.Description = request.Description; return "ok"; }這是我們使用PostMan請求,驗證提示便出現了。
但這時候看我們的幫助文檔,Price的“約束信息”就僅剩Required一個了。
那我要將自定義的MinAttribute的約束信息也顯示出來該怎么辦呢,觀察文檔的生成代碼可以發現是在Areas.HelpPage.ModelDescriptions.ModelDescriptionGenerator類中的AnnotationTextGenerator內的定義生成的。
那既然如此就好辦了,我將我自定義的也加進去。
// Modify this to support more data annotation attributes. private readonly IDictionary<Type, Func<object, string>> AnnotationTextGenerator = new Dictionary<Type, Func<object, string>> { { typeof(RequiredAttribute), a => "Required" }, { typeof(RangeAttribute), a => { RangeAttribute range = (RangeAttribute)a; return String.Format(CultureInfo.CurrentCulture, "Range: inclusive between {0} and {1}", range.Minimum, range.Maximum); } }, { typeof(MaxLengthAttribute), a => { MaxLengthAttribute maxLength = (MaxLengthAttribute)a; return String.Format(CultureInfo.CurrentCulture, "Max length: {0}", maxLength.Length); } }, { typeof(MinLengthAttribute), a => { MinLengthAttribute minLength = (MinLengthAttribute)a; return String.Format(CultureInfo.CurrentCulture, "Min length: {0}", minLength.Length); } }, { typeof(StringLengthAttribute), a => { StringLengthAttribute strLength = (StringLengthAttribute)a; return String.Format(CultureInfo.CurrentCulture, "String length: inclusive between {0} and {1}", strLength.MinimumLength, strLength.MaximumLength); } }, { typeof(DataTypeAttribute), a => { DataTypeAttribute dataType = (DataTypeAttribute)a; return String.Format(CultureInfo.CurrentCulture, "Data type: {0}", dataType.CustomDataType ?? dataType.DataType.ToString()); } }, { typeof(RegularExpressionAttribute), a => { RegularExpressionAttribute regularExpression = (RegularExpressionAttribute)a; return String.Format(CultureInfo.CurrentCulture, "Matching regular expression pattern: {0}", regularExpression.Pattern); } }, { typeof(MinAttribute), a => { MinAttribute minAttribute = (MinAttribute)a; return String.Format(CultureInfo.CurrentCulture, "最小值: {0}", minAttribute.MinimumValue); } }, };接着再看文檔,我們的“約束信息”就出來了。
Request部分基本也就這些了。Response部分沒太多內容,主要就是Sample的顯示會有一個問題,你若是一步一步寫到這里看到的幫助文檔Sample會有三個,分別是
application/json,text/json application/xml,text/xml application/x-www-from-urlencoded
這里我們會發現它生成不了 application/x-www-form-urlencoded,是因為無法使用JqueryMvcFormUrlEncodeFomatter來格式我們的類。至於為什么,我沒有去找,因為除了application/json是我需要的之外其余的我都不需要。
有興趣的朋友可以找找為什么。然后告知一下~那這里我們將不需要的移除,如下。
public static class WebApiConfig { public static void Register(HttpConfiguration config) { // Web API 配置和服務 config.Formatters.Remove(config.Formatters.XmlFormatter); // Web API 路由 config.MapHttpAttributeRoutes(); } }這里只移除了XmlFormatter,因為application/x-www-form-urlencoded我們在請求的時候還需要,但我不想讓他顯示在文檔中,於是…
在Areas.HelpPage.SampleGeneration.HelpPageSampleGenerator類中的 GetSample 方法內將
foreach (var formatter in formatters)
更改為
foreach (var formatter in formatters.Where(x => x.GetType() != typeof(JQueryMvcFormUrlEncodedFormatter)))
然后,文檔就干凈了,這難道是潔癖么…
三、使用多個項目生成Xml文件來顯示幫助文檔
終於到這了,我們首先將Product單獨作為一個項目 WebAPI2PostMan.WebModel 並引用他,查看文檔如下。
你會發現,你的注釋也就是屬性的描述沒有了。打開App_Data/XmlDocument.xml文件對比之前P沒移動roduct的xml文件確實Product類的描述確實沒有了,因為此處的XmlDocument.xml文件是項目的生成描述文件,不在此項目
內定義的文件是不會生成在這個文件內的,那真實的需求是我們確確實實需要將所有Request和Response單獨定義在一個項目內供其它項目引用,可能是單元測試也可能是我們封裝的WebAPI客戶端(此處下篇文章介紹)。
帶着這個疑問找到了這樣一篇文章 http://stackoverflow.com/questions/21895257/how-can-xml-documentation-for-web-api-include-documentation-from-beyond-the-main
該文章提供了3種辦法,這里只介紹我認為合理的方法,那那就是我們就需要將 WebAPI2PostMan.WebModel 的生成屬性也勾選XML文檔文件,就是也生成一個xml文檔,同時拓展出一個新的Xml文檔加載方式
在目錄 ~/Areas/HelpPage/ 下新增一個名為 MultiXmlDocumentationProvider.cs 的類。
using System; using System.Linq; using System.Reflection; using System.Web.Http.Controllers; using System.Web.Http.Description; using Xlobo.RechargeService.Areas.HelpPage.ModelDescriptions; namespace Xlobo.RechargeService.Areas.HelpPage { /// <summary>A custom <see cref="IDocumentationProvider"/> that reads the API documentation from a collection of XML documentation files.</summary> public class MultiXmlDocumentationProvider : IDocumentationProvider, IModelDocumentationProvider { /********* ** Properties *********/ /// <summary>The internal documentation providers for specific files.</summary> private readonly XmlDocumentationProvider[] Providers; /********* ** Public methods *********/ /// <summary>Construct an instance.</summary> /// <param name="paths">The physical paths to the XML documents.</param> public MultiXmlDocumentationProvider(params string[] paths) { this.Providers = paths.Select(p => new XmlDocumentationProvider(p)).ToArray(); } /// <summary>Gets the documentation for a subject.</summary> /// <param name="subject">The subject to document.</param> public string GetDocumentation(MemberInfo subject) { return this.GetFirstMatch(p => p.GetDocumentation(subject)); } /// <summary>Gets the documentation for a subject.</summary> /// <param name="subject">The subject to document.</param> public string GetDocumentation(Type subject) { return this.GetFirstMatch(p => p.GetDocumentation(subject)); } /// <summary>Gets the documentation for a subject.</summary> /// <param name="subject">The subject to document.</param> public string GetDocumentation(HttpControllerDescriptor subject) { return this.GetFirstMatch(p => p.GetDocumentation(subject)); } /// <summary>Gets the documentation for a subject.</summary> /// <param name="subject">The subject to document.</param> public string GetDocumentation(HttpActionDescriptor subject) { return this.GetFirstMatch(p => p.GetDocumentation(subject)); } /// <summary>Gets the documentation for a subject.</summary> /// <param name="subject">The subject to document.</param> public string GetDocumentation(HttpParameterDescriptor subject) { return this.GetFirstMatch(p => p.GetDocumentation(subject)); } /// <summary>Gets the documentation for a subject.</summary> /// <param name="subject">The subject to document.</param> public string GetResponseDocumentation(HttpActionDescriptor subject) { return this.GetFirstMatch(p => p.GetDocumentation(subject)); } /********* ** Private methods *********/ /// <summary>Get the first valid result from the collection of XML documentation providers.</summary> /// <param name="expr">The method to invoke.</param> private string GetFirstMatch(Func<XmlDocumentationProvider, string> expr) { return this.Providers .Select(expr) .FirstOrDefault(p => !String.IsNullOrWhiteSpace(p)); } } }接着替換掉原始 ~/Areas/HelpPage/HelpPageConfig.cs 內的配置。
//config.SetDocumentationProvider(new XmlDocumentationProvider(HttpContext.Current.Server.MapPath("~/App_Data/XmlDocument.xml"))); config.SetDocumentationProvider(new MultiXmlDocumentationProvider(HttpContext.Current.Server.MapPath("~/App_Data/XmlDocument.xml"), HttpContext.Current.Server.MapPath("~/App_Data/WebAPI2PostMan.WebModel.XmlDocument.xml")));那這里你可以選擇多個文檔xml放置於不同位置也可以采用將其都放置於WebAPI項目下的App_Data下。
為了方便我們在WebAPI項目下,這里指 WebAPI2PostMan,對其添加生成事件
copy $(SolutionDir)WebAPI2PostMan.WebModel\App_Data\XmlDocument.xml $(ProjectDir)\App_Data\WebAPI2PostMan.WebModel.XmlDocument.xml每次生成成功后將 WebAPI2PostMan.WebModel.XmlDocument.xml 文件拷貝到 WebAPI2PostMan項目的App_Data目錄下,並更名為 WebAPI2PostMan.WebModel.XmlDocument.xml。
至此,重新生成項目,我們的描述就又回來了~
這篇文章若耐心看完會發現其實就改動幾處而已,沒必要花這么大篇幅來說明,但是對需要的人來說還是有一點幫助的。
為了方便,源代碼依然在:https://github.com/yanghongjie/WebAPI2PostMan ,若你都看到這里了,順手點下【推薦】吧(●'◡'●)。