浅析如何在Nancy中使用Swagger生成API文档

简介: 原文:浅析如何在Nancy中使用Swagger生成API文档前言 上一篇博客介绍了使用Nancy框架内部的方法来创建了一个简单到不能再简单的Document。但是还有许许多多的不足。 为了能稍微完善一下这个Document,这篇引用了当前流行的Swagger,以及另一个开源的Nancy.Swagger项目来完成今天的任务! 注:Swagger是已经相对成熟的了,但Nancy(2.0.0-clinteastwood)和Nancy.Swagger(2.2.6-alpha)是基于目前的最新版本,但目前的都是没有发布正式版,所以后续API可能会有些许变化。
原文: 浅析如何在Nancy中使用Swagger生成API文档

前言

上一篇博客介绍了使用Nancy框架内部的方法来创建了一个简单到不能再简单的Document。但是还有许许多多的不足。

为了能稍微完善一下这个Document,这篇引用了当前流行的Swagger,以及另一个开源的Nancy.Swagger项目来完成今天的任务!

注:Swagger是已经相对成熟的了,但Nancy(2.0.0-clinteastwood)和Nancy.Swagger(2.2.6-alpha)是基于目前的最新版本,但目前的都是没有发布正式版,所以后续API可能会有些许变化。

下面先来简单看看什么是Swagger

何为Swagger

The World's Most Popular Framework for APIs.这是Swagger官方的描述。能说出是世界上最流行的,也是要有一定资本的!

光看这个描述就知道Swagger不会差!毕竟人家敢这样说。当然个人也认为Swagger确实很不错。

通过官方文档,我们都知道要想生成Swagger文档,可以使用YAML或JSON两种方式来书写,由于我们平常写程序用的比较多的是JSON!

所以本文主要是使用了JSON,顺带说一下YAML的语法也是属于易懂易学的。

既然是用JSON书写,那么要怎么写呢?这个其实是有一套规定、约束,我们只要遵守这些来写就可以了。详细内容可以参见OpenAPI Specification

本文后面的内容将默认园友们对Swagger有过了解。

Swagger主要有下面几个东西,要引用基本的样式和脚本就不在多说了。

当然,引用样式和脚本只是最基本的前提,下面这段js(来自swagger-ui项目)才是最为主要的!

<script>
window.onload = function() {
    // Build a system
    const ui = SwaggerUIBundle({
        url: "your url",//返回json数据的url地址
        dom_id: '#swagger-ui',//在这个div展示内容
        presets: [
            SwaggerUIBundle.presets.apis,
            SwaggerUIStandalonePreset
        ],
        plugins: [
            SwaggerUIBundle.plugins.DownloadUrl
        ],
        layout: "StandaloneLayout"
    })

    window.ui = ui
}
</script>

就是在上面加上注释的两个属性:url指定了我们要展示数据(JSON格式)的来源,dom_id指定了在id为swagger-ui的容器中展示我们的文档。

在加载的时候创建了Swagger相关的内容,主要的有下面的两个,其余的用默认的就可以了。

简单来说,我们请求了这个url拿到了这些json数据,再根据这些数据在dom_id中构造出我们所看到的页面。有那么点数据驱动的意思。

当然这些JSON数据是有格式要求的。可以看看下面的简单示例

{
  "swagger": "2.0",
  "info": {
    "title": "Simple API overview",
    "version": "v2"
  },
  "paths": {
    "/": {
      "get": {
        "operationId": "listVersionsv2",
        "summary": "List API versions",
        "produces": [
          "application/json"
        ],
        "responses": {
          "200": {
            "description": "200 300 response",
            "examples": {
              "application/json": "一串json"
            }
          }
        }
      }
    }
  },
  "consumes": [
    "application/json"
  ]
}

这也就意味着我们只需要严格按照Swagger的定义,就可以生成一个即美观,又可执行的API文档了。

更多相关JSON示例可参见

https://github.com/OAI/OpenAPI-Specification/tree/master/examples/v2.0/json

Nancy.Swagger说明

Nancy.Swagger是我们今天的主角,是一个基于MIT协议的开源项目。Github地址:Nancy.Swagger

当然通过上面关于Swagger的说明,也已经大概明白了这个项目主要为我们做了什么。就是构造Swgger所需要的JSON格式的数据!

它并没有像Swashbuckle.AspNetCore一样集成了SwaggerUI的内容到项目中去,只是一个提供数据的项目。

其官方的示例Demo是用跳转到petstore.swagger.io方式来完成的。但是经常性是要等待很长时间的,应该是网络的问题。

为了避免这一情况,可以通过下面的操作避免:

  • 手动下载swagger-ui相关的内容并添加到我们的新项目中。同时我还将这些设置成嵌入式的资源。

image

  • 添加一个用于显示的页面,示例为doc.html,内容可以照搬swagger-ui目录下面的index.html

  • 在Bootstrapper中添加静态资源的引用

protected override void ConfigureConventions(NancyConventions nancyConventions)
{
    base.ConfigureConventions(nancyConventions);
    nancyConventions.StaticContentsConventions.Add(StaticContentConventionBuilder.AddDirectory("swagger-ui"));
}
  • 在访问我们API时,将其重定向到doc.html页面
public class HomeModule : NancyModule
{
    public HomeModule()
    {
        Get("/", _ =>
        {
            return Response.AsRedirect("/swagger-ui");     
        });

        Get("/swagger-ui",_=>
        {                            
            var url = $"{Request.Url.BasePath}/api-docs";
            return View["doc", url];
        });
    }
}
  • 修改doc.html的内容,将上述的url,替换成@Model
window.onload = function() {
// Build a system
const ui = SwaggerUIBundle({
    url: "@Model",
    dom_id: '#swagger-ui',
    presets: [
        SwaggerUIBundle.presets.apis,
        SwaggerUIStandalonePreset
    ],
    plugins: [
        SwaggerUIBundle.plugins.DownloadUrl
    ],
    layout: "StandaloneLayout"
})
window.ui = ui
}

完成上面的内容后,就开始构造我们的文档了。

构造文档的基本信息

这里主要是设置这个API文档的概要信息,比如文档的标题,此api的版本等

需要通过SwaggerMetadataProvider的SetInfo方法来设置这些信息

下面是具体的示例代码,写在Bootstrapper中:

protected override void ApplicationStartup(TinyIoCContainer container, IPipelines pipelines)
{
    SwaggerMetadataProvider.SetInfo("Nancy Swagger Example", "v1.0", "Some open api", new Contact()
    {
        EmailAddress = "catcher_hwq@outlook.com",
        Name = "Catcher Wong",
        Url = "http://www.cnblogs.com/catcher1994"
    }, "http://www.cnblogs.com/catcher1994");

    base.ApplicationStartup(container, pipelines);
}

此时对应的大致效果(这个时候是不能正常运行的,只是显示了这部分的效果)如下:

image

上面代码生成的JSON数据是符合规范的,如下所示:

image

下面要做的就是构造路由相关的信息

不带任何请求参数

先在Module中定义一个简单的路由,这个路由不带任何参数。

Get("/", _ =>
{
    var list = new List<Product>
    {
        new Product{ Name="p1", Price=199 , IsActive = true },
        new Product{ Name="p2", Price=299 , IsActive= true }
    };

    return Negotiate.WithMediaRangeModel(new Nancy.Responses.Negotiation.MediaRange("application/json"), list);                
}, null, "GetProductList");

然后在MetadataModule中添加相应的描述,这里的MetadataModule与上一篇是相似的,这也是为什么我会在上一篇先介绍不使用

第三方组件的来构造的原因,因为这种写法下面,两者没有本质的区别!

 Describe["GetProductList"] = desc => desc.AsSwagger(
    with => with.Operation(
        op => op.OperationId("GetProductList")
        .Tag("Products")
        .Summary("Get all products")
        .Response(r=>r.Schema<IEnumerable<Product>>().Description("OK"))
        .Description("This returns a list of products")
        ));

下面是部分Nancy.Swagger里面的核心内容,也是上一篇所没有的特殊之处。

AsSwagger是RouteDescription一个扩展方法,这个方法是返回我们需要的PathItem。

OperationId是这个路由的一个友好名称,源码里面的字段定义表明它要唯一。对更加详尽的描述可能去看Swagger中对这些参数的说明!

Tag可以理解为这个路由属于那个分组,起分隔符的作用,举个例子,现在有A,B两个模块的API,我们肯定不能把它们交叉排列下去

而是A的放到一个地方,B的一个地方,便于我们的的区分。

Summary是当前路由的精简描述,要小于120个字符。

Description是当前路由的详细描述。

Response是期望的运行结果的相关内容,可以有多个,这里没有标明状态码,而是直接写处理的内容,此时说明这里用的是默认的状态码。

Response里面又是一个委托,里面又有部分定义:

Schema表明当前响应应该返回的类型是什么

Description是这个响应对应的描述信息

这个时候是会出错的,因为我们在Respoonse的时候指定了Schema,但是我们并没有指定它的定义。

我们需要先在MetadataModule中引用ISwaggerModelCatalog这个接口并调用它的AddModel方法把相关的类型添加进去,这样才能正常运行!

public ProductsMetadataModule(ISwaggerModelCatalog modelCatalog)
{
    //添加相应的类型
    modelCatalog.AddModels(typeof(Product), typeof(IEnumerable<Product>));
    
     Describe["GetProductList"] = desc => desc.AsSwagger(
        with => with.Operation(
            op =>
            op.OperationId("GetProductList")
            .Tag("Products")
            .Summary("Get all products")
            //在Schema中使用modelCatalog
            .Response(r => r.Schema<IEnumerable<Product>>(modelCatalog).Description("OK"))
            .Description("This returns a list of products")
            ));
}

示例结果如下:

先来看看上面设置对应的内容:

image

点击Try it out运行的结果

image

可以看到使用curl 去访问我们的实际接口拿到服务器的响应信息(结果和头部)

在终端执行一下这个命令,也是这个结果。

image

带Path参数和Query参数

同样的,先在Module中定义一个路由,这个路由包含了一个Path参数和一个Query参数

Get("/{productid}", _ =>
{

    var productId = _.productid;

    if (string.IsNullOrWhiteSpace(productId))
    {
        return HttpStatusCode.NotFound;
    }

    var isActive = Request.Query.isActive ?? true;

    var product = new Product
    {
        Name = "apple",
        Price = 100,
        IsActive = isActive
    };

    return Negotiate.WithMediaRangeModel(new Nancy.Responses.Negotiation.MediaRange("application/json"), product);
}, null, "GetProductByProductId");

这里作了多一点操作,为的是演示尽可能多的用法。如果传递的产品id为空,则直接返回404。如果没有输入isActive这个Query参数

返回Productr的IsActive就为false。

然后在MetadataModule中添加相应的描述

Describe["GetProductByProductId"] = desc => desc.AsSwagger(
        with => with.Operation(
            op => op.OperationId("GetProductByProductId")
            .Tag("Products2")
            .Summary("Get a product by product's id")
            .Description("This returns a product's infomation by the special id")
            .Parameter(new Parameter
            {
                Name = "productid",
                In = ParameterIn.Path,//指明该参数是对应路由上面的同名参数
                Required = true,//必填
                Description = "id of a product"
            })
            .Parameter(new Parameter
            {
                Name = "isactive",
                In = ParameterIn.Query,//指明该参数是对应QueryString上面的参数
                Description = "get the actived product",
                Required = false//非必填
            })
            .Response(r => r.Schema<Product>(modelCatalog).Description("Here is the product"))
            .Response(404, r => r.Description("Can't find the product"))
            ));

这里多了一个Parameter是上面没有提到的,这个就是我们的请求参数,这里的请求参数包含下面五种:

  • Path
  • Query
  • Body
  • Header
  • Form

下面是运行的效果图,分别演示了下面几种情况

  • 不填productid,不能执行,输入框会变红
  • 填了productid,能执行,但是服务器端返回的isactive是false
  • 填了productid和isactive,能执行,服务器返回的isactive是true

img_fea6295832f643fcb6e07edfc955372c.gif

当然现在在MetadataModule的参数还有其他的写法

 Describe["GetProductByProductId"] = desc => desc.AsSwagger(
        with => with.Operation(
            op => op.OperationId("GetProductByProductId")
            .Tag("Products2")
            .Summary("Get a product by product's id")
            .Description("This returns a product's infomation by the special id")
            .Parameters(new List<Parameter>
            {
                new Parameter{Name = "productid",In = ParameterIn.Path,Required = true,Description = "id of a product"},
                new Parameter{Name = "isactive",In = ParameterIn.Query,Description = "get the actived product",Required = false}
            })
            .ProduceMimeType("application/json")
            .Response(r => r.Schema<Product>(modelCatalog).Description("Here is the product"))
            .Response(404, r => r.Description("Can't find the product"))
            ));

可以用Parameters直接将所有的参数,组合成一个集合来进行处理。

此时的效果和上面是一样的。

请求头参数和请求体参数

在Module中添加一个新增商品的方法,这个方法包含两种请求参数,一种是正常POST的json格式的数据,一种是请求头,对于请求头,只是判断了一下客户端发起的请求有没有包含相应的请求头就是了,并没有做严格的判断。同时为了演示多种MIME类型的返回结果,这里兼容了json和xml格式的返回结果。

Post("/", _ =>
{
    var product = this.Bind<Product>();

    if(!Request.Headers.Any(x=>x.Key=="test"))
    {
        return HttpStatusCode.BadRequest;
    }

    return Negotiate
        .WithMediaRangeModel(new Nancy.Responses.Negotiation.MediaRange("application/json"), product)
        .WithMediaRangeModel(new Nancy.Responses.Negotiation.MediaRange("application/xml"), product)
        ;
}, null, "AddProduct");

同样的,MetadataModule中添加如下的描述:

Describe["AddProduct"] = desc => desc.AsSwagger(
with => with.Operation(
    op => op.OperationId("AddProduct")
            .Tag("Products")
            .Summary("Add a new product to database")
            .Description("This returns the added product's infomation")
            .BodyParameter(para=>para.Name("para").Schema<Product>().Description("the infomation of the adding product").Build())//Request body
            .Parameter(new Parameter()
            {
                Name = "test",
                In = ParameterIn.Header,//http请求头
                Description = "must be not null",
                Required = true,
            })           
            .ConsumeMimeType("application/json") //post的参数只允许是json格式            
            .ProduceMimeTypes(new List<string>{ "application/json","application/xml" })//结果支持json和xml
            .Response(r => r.Schema<Product>(modelCatalog).Description("Here is the added product"))
            .Response(400, r => r.Description("Some errors occur during the processing"))
));

BodyParameter是我们在POST等操作时用的,它需要指定我们POST的数据格式(Schema那里的类型),为了演示添加请求头信息,所以这里也加了一个必填的请求头信息。

ConsumeMimeType表示我们发起请求的数据格式必须是json格式的,当然也可以支持多种不同的数据格式。

ProduceMimeTypes表示服务端响应时支持的数据格式,这里指定了json和Xml也是为了和我们Module中的内容相对应。

演示效果:

img_3b8c724e3e087449fa5ebd8fac29fef4.gif

标注过时API和一个API属于多个分组

有时候,API的界限分的不是很清晰或者有交集的时候,可能会出现这样的情况:一个api会属于多个分组。

前面我们都是直接指定了一个tag,也就表示上面的只是对应一个tag。

先来定义一个方法,用于演示多分组和过时、废弃的API

Head("/",_=>
{
    return HttpStatusCode.OK;
},null,"HeadOfProduct");

Metadata内容

 Describe["HeadOfProduct"] = desc => desc.AsSwagger(
    with => with.Operation(
        op => op.OperationId("HeadOfProduct")
                .Tags(new List<string>() { "Products", "Products2" })//同时属于两个分组
                .Summary("Something is deprecated")
                .Description("This returns only http header")
                .IsDeprecated()//过时的,相当于常用的Obsolete,但是还可以用
                .Response(r => r.Description("Nothing will return but http headers"))                
    ));

效果如下:

img_7d819a40e99626a67af06818ec39e1cd.gif

虽说已经标记为过时了,但是本质这个方法还是存在,所以也是能正常调用的。

安全认证问题

Swagger支持3种安全认证折方式:APIKEY、Basic、OAuth2.0,同样的Nancy.Swagger也支持,不过有点坑就是了。

使用的话有两个步骤(这里用最简单的APIKEY演示):

Step 1: 引用定义,在Bootstrapper中添加验证相关的内容

protected override void ApplicationStartup(TinyIoCContainer container, IPipelines pipelines)
{
    SwaggerMetadataProvider.SetInfo("Nancy Swagger Example", "v1.0", "Some open api", new Contact()
    {
        EmailAddress = "catcher_hwq@outlook.com",
        Name = "Catcher Wong",
        Url = "http://www.cnblogs.com/catcher1994",
    }, "http://www.cnblogs.com/catcher1994");
    
    var securitySchemeBuilder = new ApiKeySecuritySchemeBuilder();
    securitySchemeBuilder.Description("Authentication with apikey");
    securitySchemeBuilder.IsInQuery();
    securitySchemeBuilder.Name("Item1");           
    SwaggerMetadataProvider.AddSecuritySchemeBuilder(securitySchemeBuilder, "Item1");

    base.ApplicationStartup(container, pipelines);
}

Step 2 : 在MetadataModule中添加描述

Describe["Head"] = description => description.AsSwagger(
    with => with.Operation(
        op => op.OperationId("Head")
            .Tag("Head method")    
            .SecurityRequirement(SecuritySchemes.ApiKey)
            .Summary("an example head method")
            .Response(r => r.Description("OK"))));

当然,目前是没有办法正常运行的!此时运行效果如下:

img_991a69c65b337abbec539cb8a5376146.png

单独打开/api-docs这个路径时提示如下错误:

img_6dac17a4a814991c2d9d0b81f30dfae4.png

这个十有八九是Nancy.Swagger的安全验证存在bug的,这个项目没有足够多的单元测试可能也是导致问题的一部分原因。

发现的主要bug是在MetadataModule中使用SecurityRequirement(SecuritySchemes.ApiKey)时一直在报错,报错内容如下:

Nancy.RequestExecutionException: Oh noes! ---< System.InvalidCastException: Unable to cast object of type 'Swagger.ObjectModel.SecuritySchemes' to type 'System.String'.
at Swagger.ObjectModel.SwaggerModel.SwaggerSerializerStrategy.ToObject(IDictionary source)

于是调试源码,发现在Swagger.ObjectModel项目下的ToObject方法有问题

private static dynamic ToObject(IDictionary source)
{
    var expando = new ExpandoObject();
    var expandoCollection = (ICollection<KeyValuePair<string, object>>)expando;

    foreach (string key in source.Keys)
    {
        expandoCollection.Add(new KeyValuePair<string, object>(key, source[key]));
    }

    return expando;
}

从上面的出错内容也能清楚的看到,SecuritySchemes不能转成string的,其中SecuritySchemes是一个枚举类型。

为了能正常运行,肯定要修改验证一下!!于是修改成如下 :

private static dynamic ToObject(IDictionary source)
{
    var expando = new ExpandoObject();
    var expandoCollection = (ICollection<KeyValuePair<string, object>>)expando;
    //用了var,在使用的时候强制ToString一下将其转成string
    foreach (var key in source.Keys)
    {
        expandoCollection.Add(new KeyValuePair<string, object>(key.ToString(), source[key]));
    }

    return expando;
}

由于在Mac上无法打开这个项目,所以上面的修改是切换回windows完成的。

进行上面的修改后,项目是已经能正常运行了!但是却少了一个很重要的东西!

img_a4cc49bf9d924b179f8c0444cfb923c7.png

在这个方法里面加了APIKEY验证的,但是小锁的标记却没有出来!

之后对比了Swagger的官方示例**http://petstore.swagger.io/**

img_bbe216771959905dfa899cd5fca78143.png

居然有这么坑爹的事情!security是一个数组啊,不是一个对象啊~~

后面就修改了Nancy.Swagger里面的许多代码(瞎改的,只为了能正常运行),涉及了好几个类文件,就不一一说明了。

第一个问题已经提了PR到这个项目了,第二个问题还没找到比较满意的方案,暂时没提。

直接上最后的效果图,分别演示了,没有验证,验证成功和验证失败这三种情况!

img_8fd14906281059f6de3dc2ac62c27188.gif

注:本文只演示了其中Nancy.Swagger的其中一种用法,而且还有部分内容是没有涉及到的。还有两种其他用法有时间会拿出来和大家分享。

注意事项

在过程中还有一个需要十分注意的地方(本来这个应该是在上一篇提及的):就是XXModule和XXMetadataModule相对应的位置关系。

Nancy在这里限制的比较死,强制了下面三种情况:

Module所在的位置 MetadtaModule应该在的位置
./BlahModule ./BlahMetadataModule
./BlahModule ./Metadata/BlahMetadataModule
./Modules/BlahModule ../Metadata/BlahMetadataModule

这是文件分布所要注意的问题。

还有一个命名应该注意的问题:当我们对一个Module起名为ProductsModule时,它对应的MetadataModule一定要是ProductsMetadataModule。

而不能是其它,有一次由于粗心,忘记把s字母带上,花了不少时间去找原因~~

上述两个问题的答案在Nancy.Metadata.Modules项目的DefaultMetadataModuleConventions类中。

简单总结

Nancy.Swagger给我们API文档化的道路上带来了不少的便利之处,除了安全验证这一块的问题有点坑,其他的算是比较正常,用起来也还算简单。

对于Swagger来说,通用性很好,只要提供的指定格式的数据就能很好的渲染出让人舒适的界面,或许这就是它这么流行的一个关键点吧。

下面是一张脑图简单的概括相关的内容 :

img_706696e5009fd92dc767f3cbc4ee47a6.jpe

本文已同步到Catcher写的Nancy汇总博客:Nancy之大杂烩

目录
相关文章
|
2月前
|
API
阿里云短信服务文档与实际API不符
阿里云短信服务文档与实际API不符
|
1月前
|
Java 测试技术 API
详解Swagger:Spring Boot中的API文档生成与测试工具
详解Swagger:Spring Boot中的API文档生成与测试工具
41 4
|
1月前
|
JSON 前端开发 API
后端开发中的API设计与文档编写指南####
本文探讨了后端开发中API设计的重要性,并详细阐述了如何编写高效、可维护的API接口。通过实际案例分析,文章强调了清晰的API设计对于前后端分离项目的关键作用,以及良好的文档习惯如何促进团队协作和提升开发效率。 ####
|
2月前
|
安全 Java API
SpringSecurity结合knife4j实现swagger文档
通过将Spring Security与Knife4j相结合,我们不仅能够为RESTful API提供强大的安全防护,还能保证API文档的易用性和可访问性,这对于API的设计、开发和维护来说至关重要。这种集成方式不仅提升了开发效率,也优化了API使用者的体验,是现代API驱动开发中不可或缺的一环。
127 0
|
4月前
|
JSON 测试技术 API
Python开发解析Swagger文档小工具
文章介绍了如何使用Python开发一个解析Swagger文档的小工具,该工具可以生成符合httprunner测试框架的json/yaml测试用例,同时还能输出Excel文件,以方便测试人员根据不同需求使用。文章提供了详细的开发步骤、环境配置和使用示例,并鼓励读者为该开源项目贡献代码和建议。
120 1
Python开发解析Swagger文档小工具
|
4月前
|
Java API 数据中心
百炼平台Java 集成API上传文档到数据中心并添加索引
本文主要演示阿里云百炼产品,如何通过API实现数据中心文档的上传和索引的添加。
157 3
|
4月前
|
XML 开发框架 .NET
ASP.NET Web Api 如何使用 Swagger 管理 API
ASP.NET Web Api 如何使用 Swagger 管理 API
134 1
|
4月前
|
JSON API 数据格式
【Azure API 管理】是否可以将Swagger 的API定义导入导Azure API Management中
【Azure API 管理】是否可以将Swagger 的API定义导入导Azure API Management中
|
5月前
|
安全 Java API
Nest.js 实战 (三):使用 Swagger 优雅地生成 API 文档
这篇文章介绍了Swagger,它是一组开源工具,围绕OpenAPI规范帮助设计、构建、记录和使用RESTAPI。文章主要讨论了Swagger的主要工具,包括SwaggerEditor、SwaggerUI、SwaggerCodegen等。然后介绍了如何在Nest框架中集成Swagger,展示了安装依赖、定义DTO和控制器等步骤,以及如何使用Swagger装饰器。文章最后总结说,集成Swagger文档可以自动生成和维护API文档,规范API标准化和一致性,但会增加开发者工作量,需要保持注释和装饰器的准确性。
151 0
Nest.js 实战 (三):使用 Swagger 优雅地生成 API 文档
|
NoSQL 前端开发 API
Nancy之实现API的功能
原文:Nancy之实现API的功能 0x01、前言 现阶段,用来实现API的可能大部分用的是ASP.NET Web API或者是ASP.NET MVC,毕竟是微软官方出产的,用的人也多。 但是呢,NancyFx也是一个很不错的选择。
1039 0