WebAPI使用多个xml文件生成帮助文档(转)

http://www.cnblogs.com/idoudou/p/xmldocumentation-for-web-api-include-documentation-from-beyond-the-main.html

一、前言

上篇有提到在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内的配置注释取消,具体的可以根据需要。

image

并且设置项目的生成属性内的输出,勾选Xml文档文件,同时设置值与~/Areas/HelpPage/HelpPageConfig.cs

内的配置一致。

image

并在Global.asax文件Application_Start方法注册。

  1. AreaRegistration.RegisterAllAreas();  

这时帮助文档已经可用,但却没有样式。你可以选择手动将需要的css及js拷入Areas文件夹内。并添加文件

  1. public class BundleConfig  
  2. {  
  3.     // 有关绑定的详细信息,请访问 http://go.microsoft.com/fwlink/?LinkId=301862  
  4.     public static void RegisterBundles(BundleCollection bundles)  
  5.     {  
  6.         bundles.Add(new ScriptBundle("~/bundles/jquery").Include(  
  7.                     "~/Areas/HelpPage/Scripts/jquery-{version}.js"));  
  8.   
  9.         // 使用要用于开发和学习的 Modernizr 的开发版本。然后,当你做好  
  10.         // 生产准备时,请使用 http://modernizr.com 上的生成工具来仅选择所需的测试。  
  11.         bundles.Add(new ScriptBundle("~/bundles/modernizr").Include(  
  12.                     "~/Areas/HelpPage/Scripts/modernizr-*"));  
  13.   
  14.         bundles.Add(new ScriptBundle("~/bundles/bootstrap").Include(  
  15.                   "~/Areas/HelpPage/Scripts/bootstrap.js",  
  16.                   "~/Areas/HelpPage/Scripts/respond.js"));  
  17.   
  18.         bundles.Add(new StyleBundle("~/Content/css").Include(  
  19.                   "~/Areas/HelpPage/Content/bootstrap.css",  
  20.                   "~/Areas/HelpPage/Content/site.css"));  
  21.     }  
  22. }  

并在Global.asax文件Application_Start方法将其注册。

  1. BundleConfig.RegisterBundles(BundleTable.Bundles);  

最后更改~/Areas/HelpPage/Views/Shared/_Layout.cshtml 为

  1. @using System  
  2. @using System.Web.Optimization  
  3. <!DOCTYPE html>  
  4. <html>  
  5. <head>  
  6.     <meta http-equiv="Content-Type" content="text/html; charset=utf-8" />  
  7.     <meta charset="utf-8" />  
  8.     <meta name="viewport" content="width=device-width" />  
  9.     <title>@ViewBag.Title</title>  
  10.     @Styles.Render("~/Content/css")  
  11.     @Scripts.Render("~/bundles/modernizr")  
  12. </head>  
  13. <meta http-equiv="Content-Type" content="text/html; charset=utf-8" />  
  14. <body>  
  15.     <div class="navbar navbar-inverse navbar-fixed-top">  
  16.         <div class="container">  
  17.             <div class="navbar-header">  
  18.                 <button type="button" class="navbar-toggle" data-toggle="collapse" data-target=".navbar-collapse">  
  19.                     <span class="icon-bar"></span>  
  20.                     <span class="icon-bar"></span>  
  21.                     <span class="icon-bar"></span>  
  22.                 </button>  
  23.             </div>  
  24.             <div class="navbar-collapse collapse">  
  25.                 <ul class="nav navbar-nav">  
  26.                     <li>@Html.Raw("<a href='/Help'>首页</a>")</li>  
  27.                     <li>@Html.Raw("<a href='/PostMan' target='_blank'>PostManFeture</a>")</li>  
  28.                 </ul>  
  29.             </div>  
  30.         </div>  
  31.     </div>  
  32.     <div class="container body-content">  
  33.         @RenderBody()  
  34.         <hr />  
  35.         <footer>  
  36.             <p>&copy; @DateTime.Now.Year - 逗豆豆</p>  
  37.         </footer>  
  38.     </div>  
  39.  
  40.     @Scripts.Render("~/bundles/jquery")  
  41.     @Scripts.Render("~/bundles/bootstrap")  
  42.     @RenderSection("scripts", required: false)  
  43. </body>  
  44. </html>  

此时你看到的才会是如下的文档。

image

对应的路由结构如下

image

查看Route可以发现其 AllowMultiple = true 意味着我们可以对同一个Action定义多个不同的路由,但同时也意味着该Action只允许定义的路由访问。

比如这里的Get方法,这时在浏览器只能以这种方式访问 http://localhost:11488/api/product/{id}

image

用 http://localhost:11488/api/product?id={id} 则会抛出405,如下。

image

为了支持多种方式我们将路由增加,如下。

image

这时文档会将两种路由都生成出来。

image

这里有个原则是同类型的请求且响应的类型相同不允许定义相同的路由,如下,都是HttpGet 且响应类型相同。

  1. /// <summary>  
  2. ///     获取所有产品  
  3. /// </summary>  
  4. [HttpGet, Route("")]  
  5. public IEnumerable<Product> Get()  
  6. {  
  7.     return _products;  
  8. }  
  9. /// <summary>  
  10. ///     获取前三产品  
  11. /// </summary>  
  12. [HttpGet, Route("")]  
  13. public IEnumerable<Product> GetTop3()  
  14. {  
  15.     return _products.Take(3);  
  16. }  

此时访问 http://localhost:11488/api/product 会发现500错误,提示为匹配到多个Action,且这时候查看帮助文档也只会显示一个匹配的Action(前提是你没有指定Route的Order属性)。

image

路由内可以做一些基本的限制,我们将上面的Top3方法改造为可以根据传入参数来决定Top多少,并且最少是前三条。

  1. /// <summary>  
  2. ///     获取前几产品  
  3. /// </summary>  
  4. [HttpGet, Route("Top/{count:min(3)}")]  
  5. public IEnumerable<Product> GetTop(int count)  
  6. {  
  7.     return _products.Take(3);  
  8. }  

这时访问 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”)的路由,我们改造下

  1. /// <summary>  
  2. ///     获取前几产品  
  3. /// </summary>  
  4. [HttpGet, Route("Top/{count:min(3):int=3}")]  
  5. public IEnumerable<Product> GetTop(int count)  
  6. {  
  7.     return _products.Take(3);  
  8. }  

这时在访问 http://localhost:11488/api/product/Top 就会默认返回前3条了,除此之外还有一些定义包括正则可以 看这里 和 这里 。

路由的文档相关的基本就这些,有遗漏的地方欢迎指出。

接下来就是单个接口的Request和Response的文档,先来看看我们分别以Request和Response分开来看。

首先看下 api/Product/All 这个接口的显示,会发现分为两类。

image

api/Product 这个接口本身是就不需要任何参数的,因此都是None。

image

Put api/Product?id={id} 这接口确是都包含。他的定义如下。

  1. /// <summary>  
  2. ///     编辑产品  
  3. /// </summary>  
  4. /// <param name="id">产品编号</param>  
  5. /// <param name="request">编辑后的产品</param>  
  6. [HttpPut, Route(""), Route("{id}")]  
  7. public string Put(int id, Product request)  
  8. {  
  9.     var model = _products.FirstOrDefault(x => x.Id.Equals(id));  
  10.     if (model == null) return "未找到该产品";  
  11.     model.Name = request.Name;  
  12.     model.Price = request.Price;  
  13.     model.Description = request.Description;  
  14.     return "ok";  
  15. }  

那其实,实际中我们可能只会使用Get和Post来完成我们所有的操作。因此,就会是Get只显示URI Parameters 而 Post只显示Body Parameters。

可以看到Description就是我们对属性的注释,Type就是属性的类型,而Additional information 则是“约束”的描述,如我们会约束请求的参数哪些为必填哪些为选填,哪些参数的值具有使用范围。

比如我们改造一下Product。

  1. /// <summary>  
  2. ///     产品  
  3. /// </summary>  
  4. public class Product  
  5. {  
  6.     /// <summary>  
  7.     ///     编号  
  8.     /// </summary>  
  9.     [Required]  
  10.     public int Id { get; set; }  
  11.     /// <summary>  
  12.     ///     名称  
  13.     /// </summary>  
  14.     [Required, MaxLength(36)]  
  15.     public string Name { get; set; }  
  16.     /// <summary>  
  17.     ///     价格  
  18.     /// </summary>  
  19.     [Required, Range(0, 99999999)]  
  20.     public decimal Price { get; set; }  
  21.     /// <summary>  
  22.     ///     描述  
  23.     /// </summary>  
  24.     public string Description { get; set; }  
  25. }  

可以看见对应的“约束信息”就改变了。

image

有人可能会说,我自定义了一些约束该怎么显示呢,接下来我们定义一个最小值约束MinAttrbute。

  1. /// <summary>  
  2. ///     最小值特性  
  3. /// </summary>  
  4. [AttributeUsage(AttributeTargets.Property, AllowMultiple = false)]  
  5. public class MinAttribute : ValidationAttribute  
  6. {  
  7.     /// <summary>  
  8.     ///     最小值  
  9.     /// </summary>  
  10.     public int MinimumValue { get; set; }  
  11.   
  12.     /// <summary>  
  13.     ///     构造函数  
  14.     /// </summary>  
  15.     /// <param name="minimun"></param>  
  16.     public MinAttribute(int minimun)  
  17.     {  
  18.         MinimumValue = minimun;  
  19.     }  
  20.   
  21.     /// <summary>  
  22.     ///     验证逻辑  
  23.     /// </summary>  
  24.     /// <param name="value">需验证的值</param>  
  25.     /// <returns>是否通过验证</returns>  
  26.     public override bool IsValid(object value)  
  27.     {  
  28.         int intValue;  
  29.         if (value != null && int.TryParse(value.ToString(), out intValue))  
  30.         {  
  31.             return (intValue >= MinimumValue);  
  32.         }  
  33.         return false;  
  34.     }  
  35.   
  36.     /// <summary>  
  37.     ///     格式化错误信息  
  38.     /// </summary>  
  39.     /// <param name="name">属性名称</param>  
  40.     /// <returns>错误信息</returns>  
  41.     public override string FormatErrorMessage(string name)  
  42.     {  
  43.         return string.Format("{0} 最小值为 {1}", name, MinimumValue);  
  44.     }  
  45. }  

将其加在Price属性上,并将最小值设定为10。

  1. /// <summary>  
  2. ///     价格  
  3. /// </summary>  
  4. [Required, Min(10)]  
  5. public decimal Price { get; set; }  

这时通过PostMan去请求,会发现验证是通过的,并没有预计的错误提示。那是因为我们没有启用验证属性的特性。

我们自定义一个ValidateModelAttribute,可用范围指定为Class和Method,且不允许多次,并将其加到刚才的Put接口上。

  1. /// <summary>  
  2. ///     验证模型过滤器  
  3. /// </summary>  
  4. [AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = false, Inherited = true)]  
  5. public class ValidateModelAttribute : ActionFilterAttribute  
  6. {  
  7.     /// <summary>  
  8.     ///     Action执行前验证  
  9.     /// </summary>  
  10.     /// <param name="actionContext">The action context.</param>  
  11.     public override void OnActionExecuting(HttpActionContext actionContext)  
  12.     {  
  13.         if (actionContext.ActionArguments.Any(kv => kv.Value == null))  
  14.         {  
  15.             actionContext.Response = actionContext.Request.CreateErrorResponse(HttpStatusCode.BadRequest, "参数不能为空");  
  16.         }  
  17.         if (actionContext.ModelState.IsValid) return;  
  18.         actionContext.Response = actionContext.Request.CreateErrorResponse(HttpStatusCode.BadRequest, actionContext.ModelState);  
  19.     }  
  20. }  
  1. /// <summary>  
  2. ///     编辑产品  
  3. /// </summary>  
  4. /// <param name="id">产品编号</param>  
  5. /// <param name="request">编辑后的产品</param>  
  6. [HttpPut, Route(""), Route("{id}")]  
  7. [ValidateModel]  
  8. public string Put(int id, Product request)  
  9. {  
  10.     var model = _products.FirstOrDefault(x => x.Id.Equals(id));  
  11.     if (model == null) return "未找到该产品";  
  12.     model.Name = request.Name;  
  13.     model.Price = request.Price;  
  14.     model.Description = request.Description;  
  15.     return "ok";  
  16. }  

这是我们使用PostMan请求,验证提示便出现了。

image

但这时候看我们的帮助文档,Price的“约束信息”就仅剩Required一个了。

image

那我要将自定义的MinAttribute的约束信息也显示出来该怎么办呢,观察文档的生成代码可以发现是在Areas.HelpPage.ModelDescriptions.ModelDescriptionGenerator类中的AnnotationTextGenerator内的定义生成的。

那既然如此就好办了,我将我自定义的也加进去。

  1. // Modify this to support more data annotation attributes.  
  2. private readonly IDictionary<Type, Func<object, string>> AnnotationTextGenerator = new Dictionary<Type, Func<object, string>>  
  3. {  
  4.     { typeof(RequiredAttribute), a => "Required" },  
  5.     { typeof(RangeAttribute), a =>  
  6.         {  
  7.             RangeAttribute range = (RangeAttribute)a;  
  8.             return String.Format(CultureInfo.CurrentCulture, "Range: inclusive between {0} and {1}", range.Minimum, range.Maximum);  
  9.         }  
  10.     },  
  11.     { typeof(MaxLengthAttribute), a =>  
  12.         {  
  13.             MaxLengthAttribute maxLength = (MaxLengthAttribute)a;  
  14.             return String.Format(CultureInfo.CurrentCulture, "Max length: {0}", maxLength.Length);  
  15.         }  
  16.     },  
  17.     { typeof(MinLengthAttribute), a =>  
  18.         {  
  19.             MinLengthAttribute minLength = (MinLengthAttribute)a;  
  20.             return String.Format(CultureInfo.CurrentCulture, "Min length: {0}", minLength.Length);  
  21.         }  
  22.     },  
  23.     { typeof(StringLengthAttribute), a =>  
  24.         {  
  25.             StringLengthAttribute strLength = (StringLengthAttribute)a;  
  26.             return String.Format(CultureInfo.CurrentCulture, "String length: inclusive between {0} and {1}", strLength.MinimumLength, strLength.MaximumLength);  
  27.         }  
  28.     },  
  29.     { typeof(DataTypeAttribute), a =>  
  30.         {  
  31.             DataTypeAttribute dataType = (DataTypeAttribute)a;  
  32.             return String.Format(CultureInfo.CurrentCulture, "Data type: {0}", dataType.CustomDataType ?? dataType.DataType.ToString());  
  33.         }  
  34.     },  
  35.     { typeof(RegularExpressionAttribute), a =>  
  36.         {  
  37.             RegularExpressionAttribute regularExpression = (RegularExpressionAttribute)a;  
  38.             return String.Format(CultureInfo.CurrentCulture, "Matching regular expression pattern: {0}", regularExpression.Pattern);  
  39.         }  
  40.     },  
  41.     { typeof(MinAttribute), a =>  
  42.         {  
  43.             MinAttribute minAttribute = (MinAttribute)a;  
  44.             return String.Format(CultureInfo.CurrentCulture, "最小值: {0}", minAttribute.MinimumValue);  
  45.         }  
  46.     },  
  47. };  

接着再看文档,我们的“约束信息”就出来了。

image

 

Request部分基本也就这些了。Response部分没太多内容,主要就是Sample的显示会有一个问题,你若是一步一步写到这里看到的帮助文档Sample会有三个,分别是

application/json,text/json  application/xml,text/xml   application/x-www-from-urlencoded

image

这里我们会发现它生成不了 application/x-www-form-urlencoded,是因为无法使用JqueryMvcFormUrlEncodeFomatter来格式我们的类。至于为什么,我没有去找,因为除了application/json是我需要的之外其余的我都不需要。

有兴趣的朋友可以找找为什么。然后告知一下~那这里我们将不需要的移除,如下。

  1. public static class WebApiConfig  
  2. {  
  3.     public static void Register(HttpConfiguration config)  
  4.     {  
  5.         // Web API 配置和服务  
  6.         config.Formatters.Remove(config.Formatters.XmlFormatter);  
  7.   
  8.         // Web API 路由  
  9.         config.MapHttpAttributeRoutes();  
  10.     }  
  11. }  

这里只移除了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)))

然后,文档就干净了,这难道是洁癖么…

image

三、使用多个项目生成Xml文件来显示帮助文档

终于到这了,我们首先将Product单独作为一个项目 WebAPI2PostMan.WebModel 并引用他,查看文档如下。

image

你会发现,你的注释也就是属性的描述没有了。打开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 的类。

  1. using System;  
  2. using System.Linq;  
  3. using System.Reflection;  
  4. using System.Web.Http.Controllers;  
  5. using System.Web.Http.Description;  
  6. using Xlobo.RechargeService.Areas.HelpPage.ModelDescriptions;  
  7.   
  8. namespace Xlobo.RechargeService.Areas.HelpPage  
  9. {  
  10.     /// <summary>A custom <see cref="IDocumentationProvider"/> that reads the API documentation from a collection of XML documentation files.</summary>  
  11.     public class MultiXmlDocumentationProvider : IDocumentationProvider, IModelDocumentationProvider  
  12.     {  
  13.         /********* 
  14.         ** Properties 
  15.         *********/  
  16.         /// <summary>The internal documentation providers for specific files.</summary>  
  17.         private readonly XmlDocumentationProvider[] Providers;  
  18.   
  19.   
  20.         /********* 
  21.         ** Public methods 
  22.         *********/  
  23.         /// <summary>Construct an instance.</summary>  
  24.         /// <param name="paths">The physical paths to the XML documents.</param>  
  25.         public MultiXmlDocumentationProvider(params string[] paths)  
  26.         {  
  27.             this.Providers = paths.Select(p => new XmlDocumentationProvider(p)).ToArray();  
  28.         }  
  29.   
  30.         /// <summary>Gets the documentation for a subject.</summary>  
  31.         /// <param name="subject">The subject to document.</param>  
  32.         public string GetDocumentation(MemberInfo subject)  
  33.         {  
  34.             return this.GetFirstMatch(p => p.GetDocumentation(subject));  
  35.         }  
  36.   
  37.         /// <summary>Gets the documentation for a subject.</summary>  
  38.         /// <param name="subject">The subject to document.</param>  
  39.         public string GetDocumentation(Type subject)  
  40.         {  
  41.             return this.GetFirstMatch(p => p.GetDocumentation(subject));  
  42.         }  
  43.   
  44.         /// <summary>Gets the documentation for a subject.</summary>  
  45.         /// <param name="subject">The subject to document.</param>  
  46.         public string GetDocumentation(HttpControllerDescriptor subject)  
  47.         {  
  48.             return this.GetFirstMatch(p => p.GetDocumentation(subject));  
  49.         }  
  50.   
  51.         /// <summary>Gets the documentation for a subject.</summary>  
  52.         /// <param name="subject">The subject to document.</param>  
  53.         public string GetDocumentation(HttpActionDescriptor subject)  
  54.         {  
  55.             return this.GetFirstMatch(p => p.GetDocumentation(subject));  
  56.         }  
  57.   
  58.         /// <summary>Gets the documentation for a subject.</summary>  
  59.         /// <param name="subject">The subject to document.</param>  
  60.         public string GetDocumentation(HttpParameterDescriptor subject)  
  61.         {  
  62.             return this.GetFirstMatch(p => p.GetDocumentation(subject));  
  63.         }  
  64.   
  65.         /// <summary>Gets the documentation for a subject.</summary>  
  66.         /// <param name="subject">The subject to document.</param>  
  67.         public string GetResponseDocumentation(HttpActionDescriptor subject)  
  68.         {  
  69.             return this.GetFirstMatch(p => p.GetDocumentation(subject));  
  70.         }  
  71.   
  72.   
  73.         /********* 
  74.         ** Private methods 
  75.         *********/  
  76.         /// <summary>Get the first valid result from the collection of XML documentation providers.</summary>  
  77.         /// <param name="expr">The method to invoke.</param>  
  78.         private string GetFirstMatch(Func<XmlDocumentationProvider, string> expr)  
  79.         {  
  80.             return this.Providers  
  81.                 .Select(expr)  
  82.                 .FirstOrDefault(p => !String.IsNullOrWhiteSpace(p));  
  83.         }  
  84.     }  
  85. }  

接着替换掉原始 ~/Areas/HelpPage/HelpPageConfig.cs 内的配置。

  1. //config.SetDocumentationProvider(new XmlDocumentationProvider(HttpContext.Current.Server.MapPath("~/App_Data/XmlDocument.xml")));  
  2. 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,对其添加生成事件

  1. 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 ,若你都看到这里了,顺手点下【推荐】吧(●'◡'●)。

posted on 2015-05-30 15:23  风雨者2  阅读(1694)  评论(5编辑  收藏  举报

导航