代码改变世界

从壹开始前后端分离【 .NET Core2.0 +Vue2.0 】框架之二 || 后端项目搭建(转:https://www.cnblogs.com/laozhang-is-phi/p/9495620.html)

2019-05-27 11:19  晓峰如月  阅读(338)  评论(0编辑  收藏  举报

 

正文

前言

至于为什么要搭建.Net Core 平台,这个网上的解释以及铺天盖地,想了想,还是感觉重要的一点,跨平台,嗯!没错,而且比.Net 更容易搭建,速度也更快,所有的包均有Nuget提供,不再像以前的单纯引入组件,

已经没有了之前的Assemblies和COM的引入,初次使用感觉会很别扭,不过使用多了,发现还是很方便的,所以你一定要会使用Nuget,真的很强大,这点儿设计思路感觉更像Linux了。

下边这三点,是先对 .net core  有一个初步的认识,看得懂或者看不懂都没有关系,以后大家肯定都会明白的:

1、.net core 框架性能测试

http://www.techempower.com/benchmarks/ 我们可以通过这个web框架性能测试来看看 aspcore 的性能

 

2、.net core 执行过程

3、中间件执行过程

启动的时候先执行该中间件类的构造函数,然后一路 Next() ;下去,返回的时候,正好是反向的,执行的是该类的逻辑部分:

 

4、AOP切面

 

 

5、整体框架结构与数据库表UML

 

 

 

 

一、创建第一个Core

    说了从零开始,就得从零开始,老生常谈,开始。

1、dotnet core安装与新建

当然,前提是你得安装.Net Core,VS 2015也是可以,只不过需要单独安装.Net Core,首先你得装个vs2015 并且保证已经升级至 update3及以上。

我的VS是2017,我这里只说2017,有不会的网友可以留言,只要在Visual Studio Installer 中安装下图中的Core 平台即可。


 

1、File --> Project (记得文件名不要是中文,不然,你懂的)


 

2、然后选择.Net Core 版本和项目类型,我选择相对稳定的ASP.NET Core 2.0,然后选择API的项目类型

至于其他的,大家可以自己玩一玩,还有就是是否Docker支持,这两年Docker着实很火,我也会在以后的时间里,补上这块儿的使用。。。

 

 

Duang ,然后就出现了,特别简单的一个.Net Core API就这么诞生了,嗯不错,基本的分成这几个部分,是不是特别像一个控制台程序?而且真是简洁了不少。

这里要注意下,关于Https选项问题,有很多小伙伴在以后的接口调用中,勾选了这个,但是还是一直使用 http 协议去访问,导致找不到响应的接口地址。

 

1、是你的项目创建的时候,勾选了 Https 选项,如果你还没有创建,那就可以不要勾选那个 HTTPS选项。

2、如果你的项目已经创建好了,每次访问都是HTTPS的,但是你不想这么做,可以在 launthSettings.json 文件中,把sslPort 端口号改成0即可

 

 

2、项目整体结构分析

接下来咱们看看这个项目都包含了哪些东西: 

 

点开Controllers --> ValuesController 文件,你会发现四个方法,并且每个方法也有各自的特性,分别是HttpGet,HttpPost,HttpPut,HttpDelete,这四个就是传说中的RESTful风格的编程。

为什么会有这种风格呢:

RESTful 风格接口实际情况是,我们在前后端在约定接口的时候,可以约定各种风格的接口,但是,RESTful 接口是目前来说比较流行的,并且在运用中比较方便和常见的接口。

虽然它有一些缺陷,目前 github 也在主推 GraphQL 这种新的接口风格,但目前国内来说还是 RESTful 接口风格比较普遍。并且,在掌握了 RESTful 接口风格之后,会深入的理解这种接口的优缺点,到时候,你自然会去想解决方案,并且在项目中实行新的更好的理念,所以,我这系列的博文,依然采用 http://cnodejs.org/ 网站提供的 RESTful 接口来实战。

了解程序开发的都应该知道,我们所做的大多数操作都是对数据库的四格操作 “增删改查” 对应到我们的接口操作分别是:post 插入新数据delete 删除数据put 修改数据get 查询数据

注意,这里是我们约定,并非这些动作只能干这件事情。从表层来说,除get外的其他方法,没有什么区别,都是一样的。从深层来说包括 get在内的所有方法都是一模一样的,没有任何区别。但是,我们约定,每种动作对应不同的操作,这样方便我们统一规范我们的所有操作。

假设,我们的接口是 /api/v1/love 这样的接口,采用 RESTful 接口风格对应操作是如下的:get 操作 /api/v1/love获取 /api/v1/love 的分页列表数据,得到的主体,将是一个数组,我们可以用数据来遍历循环列表post 操作 /api/v1/love我们会往 /api/v1/love 插入一条新的数据,我们插入的数据,将是JOSN利用对象传输的。get 操作 /api/v1/love/1我们获取到一个 ID 为 1 的数据,数据一般为一个对象,里面包含了 1 的各项字段信息。put 操作 /api/v1/love/1我们向接口提交了一个新的信息,来修改 ID 为 1 的这条信息delete 操作 /api/v1/love/1我们向接口请求,删除 ID 为 1 的这一条数据

由上述例子可知,我们实现了5种操作,但只用了两个接口地址, /api/v1/love 和 /api/v1/love/1 。所以,采用这种接口风格,可以大幅的简化我们的接口设计。

 

 
提醒:2.1以后,新建的controller 所继承的基类的 ControllerBase,导致在接口的返回值中,不能使用 return Json();方法,你可以使用 return Ok(xxx),效果是一样的,
如果你一定要使用 Json,那就修改基类,继承 Controller 吧。

 

然后 F5 运行,就会看到接口地址,以及对应的内容,你可以根据自己的需要进行各种配置合适的路由,

这里要注意下,如果出现特性相同,方法同名,参数一样的,编译会报错,起名字很重要。

还有,这里会自动跳转到默认地址 api/values,当然是可以配置的,就在 Properties --> launchSettings.json 中

 


 

接下来点开 appsettings.json 文件,这里就是整个系统app的配置地址,更类似以前的web.config,以后大家会用到。

 这里还要说一下[HttpGet("{id}"),Name="Get"] ,这个有时候会带 Name,很多小伙伴不知道这是干啥的,我就简单说一下:

[HttpGet("{id}", Name = "GetTodo")]
public IActionResult GetById(long id)

"{id}" 是 todo 项 的 ID 的占位符变量。 调用 GetById 时,它会将 URL 中“{id}”的值分配给方法的 id 参数。Name = "GetTodo" 创建一个命名的路由,使你能够 HTTP 响应中链接到此路由。 稍后将使用示例进行解释。 有关详细信息,请参阅路由到控制器操,还有这个Attribute Routing in Web API 2

 

一般来说,路由名称都是和路由url一一对应的,尽量不要重复,不过也很少有人写这个,没啥用,所以一般不要写。

 

 

继续往下,打开Startup.cs 文件这里是整个项目的启动文件,所有的启动相关的都会在这里配置,比如 依赖注入,跨域请求,Redis缓存等,更多详情在以后的文章中都会有所提起

 


 

2018-09-23 更新

二、重要文件说明

1、Program.cs

复制代码
namespace CoreBackend.Api
{
    public class Program
    {
        public static void Main(string[] args)
        {
            BuildWebHost(args).Run();
        }

        public static IWebHost BuildWebHost(string[] args) =>
            WebHost.CreateDefaultBuilder(args)
                .UseStartup<Startup>()
                .Build();
    }
}
复制代码

这个Program是程序的入口, 看起来很眼熟, 是因为asp.net core application实际就是控制台程序(console application).

它是一个调用asp.net core 相关库的console application. 

Main方法里面的内容主要是用来配置和运行程序的.

因为我们的web程序需要一个宿主, 所以 BuildWebHost这个方法就创建了一个WebHostBuilder. 而且我们还需要Web Server.

asp.net core 自带了两种http servers, 一个是WebListener, 它只能用于windows系统, 另一个是kestrel, 它是跨平台的.

kestrel是默认的web server, 就是通过UseKestrel()这个方法来启用的.

但是我们开发的时候使用的是IIS Express, 调用UseIISIntegration()这个方法是启用IIS Express, 它作为Kestrel的Reverse Proxy server来用.

 

 

如果在windows服务器上部署的话, 就应该使用IIS作为Kestrel的反向代理服务器来管理和代理请求.

如果在linux上的话, 可以使用apache, nginx等等的作为kestrel的proxy server.

当然也可以单独使用kestrel作为web 服务器, 但是使用iis作为reverse proxy还是有很多有优点的: 例如,IIS可以过滤请求, 管理证书, 程序崩溃时自动重启等.

UseStartup<Startup>(), 这句话表示在程序启动的时候, 我们会调用Startup这个类.

Build()完之后返回一个实现了IWebHost接口的实例(WebHostBuilder), 然后调用Run()就会运行Web程序, 并且阻止这个调用的线程, 直到程序关闭.

 

如果想要对AspNetCore源码进行研究,可以查看源码,这里提供两个方法:

1、F12,当然这个不能看到详细的,你需要安装一个组件,VS2017 Resharper

2、查看Github 开源源码 https://github.com/aspnet/MetaPackages/tree/master/src/Microsoft.AspNetCore 

 

2、Startup.cs

复制代码
namespace CoreBackend.Api
{
    public class Startup
    {
        // This method gets called by the runtime. Use this method to add services to the container.
        // For more information on how to configure your application, visit https://go.microsoft.com/fwlink/?LinkID=398940
        public void ConfigureServices(IServiceCollection services)
        {
        }

        // This method gets called by the runtime. Use this method to configure the HTTP request pipeline.
        public void Configure(IApplicationBuilder app, IHostingEnvironment env)
        {
//判断是否是环境变量 if (env.IsDevelopment()) { app.UseDeveloperExceptionPage(); } //这个就是一个简单的中间件写法 app.Run(async (context) => { await context.Response.WriteAsync("Hello World!"); }); } } }
复制代码

其实Startup算是程序真正的切入点.

ConfigureServices方法是用来把services(各种服务, 例如identity, ef, mvc等等包括第三方的, 或者自己写的)加入(register)到container(asp.net core的容器)中去, 并配置这些services. 这个container是用来进行dependency injection的(依赖注入). 所有注入的services(此外还包括一些框架已经注册好的services) 在以后写代码的时候, 都可以将它们注入(inject)进去. 例如上面的Configure方法的参数, app, env, loggerFactory都是注入进去的services.

 

Configure方法是asp.net core程序用来具体指定如何处理每个http请求的, 例如我们可以让这个程序知道我使用mvc来处理http请求, 那就调用app.UseMvc()这个方法就行. 但是目前, 所有的http请求都会导致返回"Hello World!".

 

 

看一看我们项目的最后,Configure方法是如何配置的:

复制代码
        public void Configure(IApplicationBuilder app, IHostingEnvironment env)
        {
            if (env.IsDevelopment())
            {
                // 在开发环境中,使用异常页面,这样可以暴露错误堆栈信息,所以不要放在生产环境。
                app.UseDeveloperExceptionPage();
            }
            else
            {
                app.UseExceptionHandler("/Error");
                // 在非开发环境中,使用HTTP严格安全传输(or HSTS) 对于保护web安全是非常重要的。
                // 强制实施 HTTPS 在 ASP.NET Core,配合 app.UseHttpsRedirection
                //app.UseHsts();

            }

            #region Swagger
            app.UseSwagger();
            app.UseSwaggerUI(c =>
            {
                //之前是写死的
                //c.SwaggerEndpoint("/swagger/v1/swagger.json", "ApiHelp V1");
                //c.RoutePrefix = "";//路径配置,设置为空,表示直接在根域名(localhost:8001)访问该文件,注意localhost:8001/swagger是访问不到的,去launchSettings.json把launchUrl去掉

                //根据版本名称倒序 遍历展示
                typeof(ApiVersions).GetEnumNames().OrderByDescending(e => e).ToList().ForEach(version =>
                {
                    c.SwaggerEndpoint($"/swagger/{version}/swagger.json", $"{ApiName} {version}");
                });
            });
            #endregion

            #region Authen
            //app.UseMiddleware<JwtTokenAuth>();//注意此授权方法已经放弃,请使用下边的官方验证方法。但是如果你还想传User的全局变量,还是可以继续使用中间件
            app.UseAuthentication();
            #endregion

            #region CORS
            //跨域第二种方法,使用策略,详细策略信息在ConfigureService中
            app.UseCors("LimitRequests");//将 CORS 中间件添加到 web 应用程序管线中, 以允许跨域请求。


            //跨域第一种版本,请要ConfigureService中配置服务 services.AddCors();
            //    app.UseCors(options => options.WithOrigins("http://localhost:8021").AllowAnyHeader()
            //.AllowAnyMethod()); 
            #endregion

            // 跳转https
            app.UseHttpsRedirection();
            // 使用静态文件
            app.UseStaticFiles();
            // 使用cookie
            app.UseCookiePolicy();
            // 返回错误码
            app.UseStatusCodePages();//把错误码返回前台,比如是404

            app.UseMvc();
        }
复制代码

 

 

3、调试方法

.net core 调试的两种方法

1、通过IIS调试

 

2、项目自带的Kestrel web应用调式

 

 

 

三、注册并使用MVC

因为asp.net core 2.0使用了一个大而全的metapackage, 所以这些基本的services和middleware是不需要另外安装的.

首先, 在ConfigureServices里面向Container注册MVC: services.AddMvc();

        public void ConfigureServices(IServiceCollection services)
        {
            services.AddMvc(); // 注册MVC到Container
        }

然后再Configure里面告诉程序使用mvc中间件:

复制代码
 
        public void Configure(IApplicationBuilder app, IHostingEnvironment env)
        {
            if (env.IsDevelopment())
            {
                app.UseDeveloperExceptionPage();
            }

            app.UseMvc();
 
复制代码

注意顺序, 应该在处理异常的middleware后边调用app.UseMvc(), 所以处理异常的middleware可以在把request交给mvc之间就处理异常, 更重要的是它还可以捕获并处理返回MVC相关代码执行中的异常.

然后别忘了把app.Run那部分代码去掉. 然后改回到Develpment环境, 跑一下, 试试效果:

Chrome显示了一个空白页, 按F12, 显示了404 Not Found错误.

这是因为我只添加了MVC middleware, 但是它啥也没做, 也没有找到任何可用于处理请求的代码, 所以我们要添加Controller来返回数据/资源等等

四、核心知识点

1、Routing 路由

路由有两种方式: Convention-based (按约定), attribute-based(基于路由属性配置的). 

其中convention-based (基于约定的) 主要用于MVC (返回View或者Razor Page那种的).

Web api 推荐使用attribute-based.

这种基于属性配置的路由可以配置Controller或者Action级别, uri会根据Http method然后被匹配到一个controller里具体的action上.

常用的Http Method有:

  • Get, 查询, Attribute: HttpGet, 例如: '/api/product', '/api/product/1'
  • POST, 创建, HttpPost, '/api/product'
  • PUT 整体修改更新 HttpPut, '/api/product/1'
  • PATCH 部分更新, HttpPatch, '/api/product/1'
  • DELETE 删除, HttpDelete, '/api/product/1

还有一个Route属性(attribute)也可以用于Controller层, 它可以控制action级的URI前缀.

以下不是本系列,就看思路即可,不用敲代码

复制代码
 
//以下不是本系列教程,就看思路即可,不用敲代码
namespace CoreBackend.Api.Controllers { //[Route("api/product")] [Route("api/[controller]")] public class ProductController: Controller { [HttpGet] public JsonResult GetProducts() { return new JsonResult(new List<Product> { new Product { Id = 1, Name = "牛奶", Price = 2.5f }, new Product { Id = 2, Name = "面包", Price = 4.5f } }); } } }
 
复制代码

使用[Route("api/[controller]")], 它使得整个Controller下面所有action的uri前缀变成了"/api/product", 其中[controller]表示XxxController.cs中的Xxx(其实是小写).

也可以具体指定, [Route("api/product")], 这样做的好处是, 如果ProductController重构以后改名了, 只要不改Route里面的内容, 那么请求的地址不会发生变化.

然后在GetProducts方法上面, 写上HttpGet, 也可以写HttpGet(). 它里面还可以加参数,例如: HttpGet("all"), 那么这个Action的请求的地址就变成了 "/api/product/All".

 

2、内容协商 Content Negotiation

如果 web api提供了多种内容格式, 那么可以通过Accept Header来选择最好的内容返回格式: 例如:

application/json, application/xml等等

如果设定的格式在web api里面没有, 那么web api就会使用默认的格式.

asp.net core 默认提供的是json格式, 也可以配置xml等格式.

目前只考虑 Output formatter, 就是返回的内容格式.

 

如果想输出xml格式,就配置这里:

 

 

3、创建Post Action

以下不是本系列,就看思路即可,不用敲代码

复制代码
 
 
//以下不是本系列教程,就看思路即可,不用敲代码     
[Route("{id}", Name = "GetProduct")] public IActionResult GetProduct(int id) { var product = ProductService...(x => x.Id == id); if (product == null) { return NotFound(); } return Ok(product); } [HttpPost] public IActionResult Post([FromBody] ProductCreation product) { if (product == null) { return BadRequest(); } var maxId = ProductService.Max(x => x.Id); var newProduct = new Product { Id = ++maxId, Name = product.Name, Price = product.Price }; ProductService.Add(newProduct); return CreatedAtRoute("GetProduct", new { id = newProduct.Id }, newProduct); }
 
复制代码

 

[HttpPost] 表示请求的谓词是Post. 加上Controller的Route前缀, 那么访问这个Action的地址就应该是: 'api/product'

后边也可以跟着自定义的路由地址, 例如 [HttpPost("create")], 那么这个Action的路由地址就应该是: 'api/product/create'.

[FromBody] , 请求的body里面包含着方法需要的实体数据, 方法需要把这个数据Deserialize成ProductCreation, [FromBody]就是干这些活的.

客户端程序可能会发起一个Bad的Request, 导致数据不能被Deserialize, 这时候参数product就会变成null. 所以这是一个客户端发生的错误, 程序为让客户端知道是它引起了错误, 就应该返回一个Bad Request 400 (Bad Request表示客户端引起的错误)的 Status Code.

传递进来的model类型是 ProductCreation, 而我们最终操作的类型是Product, 所以需要进行一个Map操作, 目前还是挨个属性写代码进行Map吧, 以后会改成Automapper.

返回 CreatedAtRoute: 对于POST, 建议的返回Status Code 是 201 (Created), 可以使用CreatedAtRoute这个内置的Helper Method. 它可以返回一个带有地址Header的Response, 这个Location Header将会包含一个URI, 通过这个URI可以找到我们新创建的实体数据. 这里就是指之前写的GetProduct(int id)这个方法. 但是这个Action必须有一个路由的名字才可以引用它, 所以在GetProduct方法上的Route这个attribute里面加上Name="GetProduct", 然后在CreatedAtRoute方法第一个参数写上这个名字就可以了, 尽管进行了引用, 但是Post方法走完的时候并不会调用GetProduct方法. CreatedAtRoute第二个参数就是对应着GetProduct的参数列表, 使用匿名类即可, 最后一个参数是我们刚刚创建的数据实体

运行程序试验一下, 注意需要在Headers里面设置Content-Type: application/json.

4、Validation 验证

针对上面的Post方法,  如果请求没有Body, 参数product就会是null, 这个我们已经判断了; 如果body里面的数据所包含的属性在product中不存在, 那么这个属性就会被忽略.

但是如果body数据的属性有问题, 比如说name没有填写, 或者name太长, 那么在执行action方法的时候就会报错, 这时候框架会自动抛出500异常, 表示是服务器的错误, 这是不对的. 这种错误是由客户端引起的, 所以需要返回400 Bad Request错误.

验证Model/实体, asp.net core 内置可以使用 Data Annotations进行: 

复制代码
 
//以下不是本系列教程,就看思路即可,不用敲代码
using System; using System.ComponentModel.DataAnnotations; namespace CoreBackend.Api.Dtos { public class ProductCreation { [Display(Name = "产品名称")] [Required(ErrorMessage = "{0}是必填项")] // [MinLength(2, ErrorMessage = "{0}的最小长度是{1}")] // [MaxLength(10, ErrorMessage = "{0}的长度不可以超过{1}")]
     [StringLength(10, MinimumLength = 2, ErrorMessage = "{0}的长度应该不小于{2}, 不大于{1}")] public string Name { get; set; } [Display(Name = "价格")] [Range(0, Double.MaxValue, ErrorMessage = "{0}的值必须大于{1}")] public float Price { get; set; } } }
 
复制代码

这些Data Annotation (理解为用于验证的注解), 可以在System.ComponentModel.DataAnnotation找到, 例如[Required]表示必填, [MinLength]表示最小长度, [StringLength]可以同时验证最小和最大长度, [Range]表示数值的范围等等很多.

[Display(Name="xxx")]的用处是, 给属性起一个比较友好的名字.

其他的验证注解都有一个属性叫做 ErrorMessage (string), 表示如果验证失败, 就会把ErrorMessage的内容添加到错误结果里面去. 这个ErrorMessage可以使用参数, {0}表示Display的Name属性, {1}表示当前注解的第一个变量, {2}表示当前注解的第二个变量.

在Controller里面添加验证逻辑:

复制代码
 
//以下不是本系列教程,就看思路即可,不用敲代码     
[HttpPost] public IActionResult Post([FromBody] ProductCreation product) { if (product == null) { return BadRequest(); } if (!ModelState.IsValid) { return BadRequest(ModelState); } var maxId = ProductService.Max(x => x.Id); var newProduct = new Product { Id = ++maxId, Name = product.Name, Price = product.Price }; ProductService.Add(newProduct); return CreatedAtRoute("GetProduct", new { id = newProduct.Id }, newProduct); }
 
复制代码

ModelState: 是一个Dictionary, 它里面是请求提交到Action的Name和Value的对们, 一个name对应着model的一个属性, 它也包含了一个针对每个提交的属性的错误信息的集合.

每次请求进到Action的时候, 我们在ProductCreationModel添加的那些注解的验证, 就会被检查. 只要其中有一个验证没通过, 那么ModelState.IsValid属性就是False. 可以设置断点查看ModelState里面都有哪些东西.

如果有错误的话, 我们可以把ModelState当作 Bad Request的参数一起返回到前台.

5、PUT请求

put应该用于对model进行完整的更新. 

首先最好还是单独为Put写一个Dto Model, 尽管属性可能都是一样的, 但是也建议这样写, 实在不想写也可以.

ProducModification.cs

复制代码
 
    public class ProductModification
    {
        [Display(Name = "产品名称")]
        [Required(ErrorMessage = "{0}是必填项")]
        [StringLength(10, MinimumLength = 2, ErrorMessage = "{0}的长度应该不小于{2}, 不大于{1}")]
        public string Name { get; set; }

        [Display(Name = "价格")]
        [Range(0, Double.MaxValue, ErrorMessage = "{0}的值必须大于{1}")]
        public float Price { get; set; }
    }
 
复制代码

然后编写Controller的方法:

复制代码
 
//以下不是本系列教程,就看思路即可,不用敲代码     
[HttpPut("{id}")]
        public IActionResult Put(int id, [FromBody] ProductModification product)
        {
            if (product == null)
            {
                return BadRequest();
            }

            if (product.Name == "产品")
            {
                ModelState.AddModelError("Name", "产品的名称不可以是'产品'二字");
            }

            if (!ModelState.IsValid)
            {
                return BadRequest(ModelState);
            }

            var model = ProductService.SingleOrDefault(x => x.Id == id);
            if (model == null)
            {
                return NotFound();
            }
            model.Name = product.Name;
            model.Price = product.Price;

            // return Ok(model);
            return NoContent();
        }
 
复制代码

按照Http Put的约定, 需要一个id这样的参数, 用于查找现有的model.

由于Put做的是完整的更新, 所以把ProducModification整个Model作为参数.

进来之后, 进行了一套和POST一模一样的验证, 这地方肯定可以改进, 如果验证逻辑比较复杂的话, 到处写同样验证逻辑肯定是不好的, 所以建议使用FluentValidation.

然后, 把ProductModification的属性都映射查询找到给Product, 这个以后用AutoMapper来映射.

返回: PUT建议返回NoContent(), 因为更新是客户端发起的, 客户端已经有了最新的值, 无须服务器再给它传递一次, 当然了, 如果有些值是在后台更新的, 那么也可以使用Ok(xxx)然后把更新后的model作为参数一起传到前台.

 

 
 

五、结语

    好啦,项目搭建就这么愉快的解决了,而且你也应该简单了解了.Net Core API是如何安装,创建,各个文件的意义以及如何运作,如何配置等,但是既然是接口,那一定是要前后端一起进行配置,使用,交流的平台,从上文看出,每次都特别麻烦,而且不直观,UI 不友好,怎么办呢?

    下一节我们就使用一个神器 Swagger,一个快速,轻量级的项目RESTFUL接口的文档在线自动生成+功能测试功能软件。

 

Github && Gitee

https://github.com/anjoy8/Blog.Core.git

 

NOTE

如何不会使用Git,可以参考

https://www.jianshu.com/p/2b666a08a3b5

 

一起学习,一起进步 QQ群:867095512