用ASP.NET Core 2.0 建立规范的 REST API -- 预备知识 + 项目准备

什么是REST

REST 是 Representational State Transfer 的缩写. 它是一种架构的风格, 这种风格基于一套预定义的规则, 这些规则描述了网络资源是如何定义和寻址的.

一个实现了REST这些规则的服务就叫做RESTful的服务.

最早是由Roy Fielding提出的.

RPC 风格

/getUsers
/getUser?id=1
/createUser
/deleteUser?id=4
/updateUser?name=dave

上面这些节点是针对User的CRUD操作. 

这种样式风格的web服务更倾向于叫做RPC风格的服务.

在RPC的世界里, 节点仅仅就是可以在远程被触发的函数, 而在REST的世界里, 节点就是实体, 也叫做资源.

REST的原则/约束

REST有6大原则/约束, 每一个原则都是对API有正面或负面影响的设计决定.

RESTful API 最关心的有这几方面: 性能, 可扩展性, 简洁性, 互操作性, 通讯可见性, 组件便携性和可靠性.

这些方面被封装在REST的6个原则里, 它们是: 

1. 客服端-服务端约束: 客户端和服务端是分离的, 它们可以独自的进化.

2. 无状态: 客户端和服务段的通信必须是无状态的, 状态应包含在请求里的. 也就是说请求里要包含服务端需要的所有的信息, 以便服务端可以理解请求并可以创造上下文.

3. 分层系统: 就像其它的软件架构一样, REST也需要分层结构, 但是不允许某层直接访问不相邻的层. 

4. 统一接口: 这里分为4点, 他们是: 资源标识符(URI), 资源的操作(也就是方法Method, HTTP动词), 自描述的响应(可以认为是媒体类型Media-Type), 以及状态管理(超媒体作为应用状态的引擎 HATEOAS, Hypermedia as the Engine of Application State).

5. 缓存: 缓存约束派生于无状态约束, 它要求从服务端返回的响应必须明确表明是可缓存的还是不可缓存的.

6. 按需编码: 这允许客户端可以从服务端访问特定的资源而无须知晓如何处理它们. 服务端可以扩展或自定义客户端的功能.

只有满足了这6个原则的系统才可以真正称得上是RESTful的, 其实大部分系统的RESTful API并不是RESTful的, 但这样并不代表这些API就不好, 利弊需要开发人员去衡量.

Richardson 成熟度模型

Richardson 成熟度模型代表着你的API是否足够成熟, 分为4个级别, 0代表最差, 3代表最好.

0级, 天花沼泽:

这里HTTP协议只是被用来进行远程交互, 协议的其余部分都用错了, 都是RPC风格的实现(例如SOAP, 尤其是使用WCF的时候).

例如:

POST (查询数据信息)
http://host/myapi

POST (创建数据)
http://host/myapi

1级, 资源:

这级里, 每个资源都映射到一个URI上了, 但是HTTP方法并没有正确的使用, 结果的复杂度不算太高.

例如这两个查询:

POST
http://host/api/authors
POST
http://host/api/authors/{id}

2级, 动词:

正确使用了HTTP动词, 状态码也正确的使用了, 同时也去掉了不必要的变种.

例如:

GET
http://host/api/authors
200 Ok (authors)
POST (author representation)
http://host/api/authors
201 Created (author)

3级, 超媒体:

API支持超媒体作为应用状态的引擎 HATEOAS, Hypermedia as the Engine of Application State, 引入了可发现性.

例如:

GET
http://host/api/authors
200 Ok (返回了authors 和 驱动应用程序的超链接)

介绍ASP.NET Core

略.

但是, 你需要知道以下概念: .NET Core, .NET Standard.

还需要会使用下列工具: .NET Core CLI, Visual Studio 2017/Visual Studio Code/Visual Studio for Mac

ASP.NET Core 支持创建Web API, 但并不是直接支持RESTful的 Web API.

ASP.NET Core的基本知识

这部分还是需要简单的介绍下, 如果已经会了, 请略过本文其余部分.

创建ASP.NET Core项目

打开VS2017, 选择ASP.NET Core Web Application项目模板, 写好名字, OK.

选择空模板, OK:

项目建立好了, 结果如下:

然后我们看一下项目文件, 右键编辑MyRestful.Api:

这里, SDK属性表示了我们使用的是哪个SDK, 而目标框架是.NET Core 2.0.

(提示: 如果需要指向多个目标框架的话可以使用TargetFrameworks元素, 注意多了个s)

看一下Program.cs:

Main方法是程序的入口. 而Web的宿主是通过BuildWebHost函数来实例化的, 它调用了WebHost.CreateDefaultBuilder方法, 很明显这是一个建造者模式, 它最终会构建出一个web宿主.

调用WebHost.CreateDefaultBuilder会返回一个IWebHostBuilder, 它允许我们进行一些配置动作.

程序启动

UseStartup方法会注册一个类, 这个类负责配置整个程序的启动过程. 这里默认用的是Startup类.

Startup类有两个方法 ConfigureServices (这个可以没有) 和 Configure (这个必须有):

在Configure方法里, 配置应该遵循Add/Use的风格样式, 首先定义需要什么, 然后定义如何使用它.

而在ConfigureServices方法里, 所有程序级的依赖项都可以在这里注册到默认的IoC容器里, 把它们添加到IServiceCollection即可.

Configure方法才是真正负责配置HTTP请求管道的方法, 并且运行时也需要它.

IApplicationBuilder的扩展方法Run会传递一个RequestDelegate, 其内部功能就是回写Hello World.

ASP.NET Core还允许我们按约定为指定环境建立单独的启动配置. 启动类可以通过这个函数定义UseStartup(startupAssemblyName: xxx); 运行时会在这个指定的组件查找叫做Startup, Startup[环境名]的类, 其中[环境名]就是ASPNETCORE_ENVIRONMENT这个环境变量的值. 如果能找到指定环境的类, 那么它将覆盖默认的启动类. 

例如 环境变量值如果是Developmen的话, 那么运行时就会尝试寻找Startup和StartupDevelopment类, 该约定在启动类里面的方法名上也有效, 环境特定的启动类里的两个方法分别是 Configure[环境名]和Configure[环境名]Services.

除了之前讲的Run方法外, IApplicationBuilder还有一个Use扩展方法.

Use扩展方法接受RequestDelegate作为参数来提供HttpContext, 同时接受也为下一层准备的RequestDelegate参数.

需要注意的是, Run方法和Use方法定义的顺序非常重要, 运行时将会精确的按照创建的顺序来执行.

服务器

ASP.NET Core 服务器的作用是响应客户端发过来的请求, 这些请求会作为HttpContext传递进来. ASP.NET Core 内置两种服务器:

Kestrel, 它是跨平台的服务器, 基于Libuv.

HTTP.sys, 它是仅限Windows系统的服务器, 基于HTTP.sys内核驱动.

下面就是从客户端发请求到应用程序的流图:

其中Kestrel可以作为一个独立进程自行托管, 也可以在IIS里. 但是还是建议使用IIS或Nginx等作为反向代理服务器. 在构建API或微服务时, 这些服务器可以作为网关使用, 因为它们会限制对外暴露的东西也可以更好的与现有系统集成, 所以它们会提供额外的防御层, 

使用反向代理服务器(IIS)之后的流图如下:

让web宿主工作于IIS之后需要使用IWebHostBuilder的UseIISIntegration这个扩展方法.

除了内置的两种服务器, 您还可以使用自定义的服务器, 使用IWebHostBuilder的UserServer扩展方法, 它接受一个实现了IServer接口的实例, 您的自定义服务器需要实现该接口. 这里就不讲了.

中间件

在应用程序请求管道内装配的组件就是中间件, 它们负责处理通过管道的请求和响应.

在HTTP请求管道的上下文里, 中间件可以叫做请求委托, 它们是由Run, Map 和 Use 扩展方法共同组建而成的.

每个中间件可以在它被调用之前和之后执行可选的逻辑, 同时也可以决定该请求是否可以被送到管道的下一个中间件那里.

请求在中间件里的流图如下:

看一下这个例子:

如果我在浏览器地址输入 http://localhost:5000/return, 那么结果就是Returned!

如果输入 http://localhost:5000/end, 那么是The End.

如果输入 http://localhost:5000/xxx?value=1234, 结果是 the number is 1234

如果输入 http://localhost:5000/xxx?value=abcde, 结果是 Hello, the value is abcde!

注意: 应用程序管道里的请求委托(中间件)定义的顺序是非常重要的, 请求的时候按定义的顺序执行, 而响应的顺序正好相反.

中间件最好不要像上面一样写在Startup类里, 每个中间件应该放在单独的类里. 

我把上例中检查是否为数字的中间件写在一个单独的类里:

这种中间件没有实现特定的接口或者继承特定类, 它更像是Duck Typing (你走起路来像个鸭子, 叫起来像个鸭子, 那么你就是个鸭子).

然后在Startup的Configure方法里调用app.UseMiddleware<NumberMiddleware>()即可:

路由

在ASP.NET Core里,使用路由中间件RouterMiddleware来处理路由.

想要使用路由, 同样也是遵循 Add/Use 这个模式. 

首先在ConfigureServices方法里添加(Add):

然后在Configure方法里使用(Use):

UseRouter这个扩展方法可以接受IRouter或者Action<IRouterBuilder>作为参数.

例如:

当发送 http://localhost:5000/ GET请求的时候, 返回 Default route.

当 GET http://localhost:5000/user/dave的时候, 返回 Hi dave

当 POST http://localhost:5000/user/dave的时候, 返回 Hi, posted name is dave

其中{name}, 是名为name的参数.

如果写成"user/{name}/{age:number}", 那么age这个参数的必须可以被解析为数值型.

而"user/{name}/{gender?}", 这里的gender参数可以没有.

Controller

HTTP请求通过管道最终到达Action并返回的流图如下:

默认情况下Controller放在ASP.NET Core项目的Controllers目录下。

在ASP.NET Core项目里可以通过多种方式来创建Controller,当然最建议的方式还是通过继承AspNetCore.Mvc.Controller这个抽象类来建立Controller。

例如:

上例中类名可以不是以Controller结尾。

还有其它的方式创建Controller,按约定类名以Controller结尾的POCO类也会被认为是Controller,例如:

针对POCO类, 即使名称不是以Controller结尾,仍然可以把它作为Controller,这就需要在类上面添加 [Controller] 这个属性:

如果某个类的名字以Controller结尾, 但是你不想把它当作Controller,那么就应该为该类标注 [NonController] 这个属性:

实际上, 看源码就可以知道 Controller 继承于 ControllerBase:

 而ControllerBase上面标注着 [Controller] 属性。

Action

在Controller里面,可以使用public修饰符来定义Action,通常会带有参数,可以返回任何类型,但是大多数情况下应该返回IActionResultAction的方法名要么是以HTTP的动词开头,要么是使用HTTP动词属性标签,包括:[HttpGet], [HttpPut], [HttpPost], [HttpDelete], [HttpHead], [HttpOptions], [HttpPatch].

例如:

其中某个方法名如果恰好是以HTTP的动词开头,那么可以通过标注 [NonAction] 属性来表示这个方法不是Action。

通过继承Controller基类的方法来创建Controller还是有很多好处的,因为它提供了很多帮助方法,例如:Ok, NotFound, BadRequest等,它们分别对应HTTP的状态码 200, 404, 400;此外还有Redirect,LocalRedirect,RedirectToRoute,Json,File,Content等方法。

为MVC定义路由有两种方式:使用IRouteBuilder或者使用基于属性标签的路由。针对Rest,最好还是使用基于属性标签的方式。

路由属性标签可以标注在Controller或者Action方法上,例如:

Controller类上标注的路由“api/[controller]”,其中[controller] 就代表该类的名字去掉结尾Controller的部分,也就是“api/person”。

在Controller上使用[Route]属性就定义了该Controller下所有Action的路由基地址,每个Action可以包含一个或者多个相对的路由模板(地址),这些路由模板可以在[Http...]中定义。但是如果使用 ~ 这个符号的话,该Action的地址将会是绝对路由地址,也就是覆盖了Controller定义的基路由。

实体绑定

传入的请求会映射到Action方法的参数,可以实原始数据类型也可以是复杂的类型例如Dto(data transfer object)或ViewModel。这个把Http请求绑定到参数的过程叫做实体绑定。

例如:

其中id参数是定义在路由里的,而name参数在路由里没有,但是仍然可以从查询参数中把name参数映射出来。

注意路由参数和查询参数的区别,下面这个URL里val1和val2是查询参数,它们是在url的后边使用?和&分隔:

/product?val1=2&val2=10

而针对上面的Action,下面这个URL的路由参数id就是123:

/api/first/123

针对下面这个POST Action:

我们可以通过几种方式为其传递类型为Person的参数。

可以使用查询参数:/api/people?id=1&name=Dave

如果POST Json数据:

那么在Action里面得到的参数person的属性值都是null。这是因为这样的原始数据是包含在请求的Body里面,为了解决这个问题,你需要告诉Action从哪里获取参数,针对这个例子就应该使用 [FromBody] 属性标签:

如果提交的是表单数据,那么就应该使用[FromForm]:

其它的出处还有 [FromHeader], [FromRoute], [FromServices]等。

再看一个FromHeader的例子:

如果使用复杂类型Person来获取person参数好像不行,只能使用原始类型的吧?

实体验证

ASP.NET Core内置的实体验证是通过验证属性标签来实现的,大多数情况下这样会很方便。

例如:

其中Display不是验证标签,但是通过它可以自定义属性的显式名称,在其它错误信息里可以使用{0}来引用该名称。

判断实体参数是否符合要求,可以检查ModelState.IsValid属性,这个属性也是由ControllerBase提供的,例如:

发送一个请求:

这是个不合理的参数,返回的是400 BadRequest,带着验证结果:

尽管大多数情况西,验证属性标签都满足要求,但是有时候还是需要进行一些灵活的验证,你可以使用像FluentValidation这样的第三方库,也可以使用内置的方式来实现自定义验证。

ASP.NET Core内置支持两种方式来进行自定义验证:通过继承ValidationAttribute来创建自定义验证属性标签,或者让实体实现IValidatebleObject接口。

使用自定义验证属性标签:

把该标签放到name属性上

使用刚才的请求,其结果是:

另一种方式,在Person类实现IValidatableObject接口

但是我使用这种方法并不好用,不知道我哪里用错了!

过滤器

和中间件一样,ASP.NET Core MVC的过滤器也可以在请求管道的特定阶段的之前或之后执行某些代码。过滤器还可以有子管道,子管道里面包含着其它过滤器。

过滤器和中间件的区别:中间件是应用程序级别的,它可以处理每个发送过来的请求;而过滤器是针对MVC的,它只会处理发往MVC的请求。

ASP.NET Core MVC的过滤器分为5类:

  • 授权过滤器,它是第一个运行的,它的作用就是判断HTTP Context中的用户是否拥有当前请求的权限,如果用户没有权限,那么它就会“短路”管道。
  • 资源过滤器,在授权过滤器后运行,在管道其它动作之前,和管道动作都结束后运行。它可以实现缓存或由于性能原因执行短路操作。它在实体绑定之前运行,所以它也可以对影响实体绑定。
  • Action过滤器,它在Action方法调用之前和之后立即执行,它可以操作传进Action的参数和返回的结果。
  • 异常过滤器,针对在写入响应Body之前发生的未处理的异常,它可以应用全局的策略,
  • 结果过滤器,它可以在每个Action结果执行之前和之后运行代码,但也只是在Action方法无错误的成功完成后才可以执行。

下图标明了这些过滤器在管道中是如何交互的:

过滤器可以作为属性标签使用,或者也可以在Startup类里面进行全局注册。

例子:

using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc.Filters;

namespace MyRestful.Api.Filters
{
    public class DefaultNameFilter: IActionFilter, IAsyncActionFilter
    {
        public void OnActionExecuting(ActionExecutingContext context)
        {
            context.ActionDescriptor.RouteValues["name"] = "Anonymous";
        }

        public void OnActionExecuted(ActionExecutedContext context)
        {
            context.HttpContext.Response.Headers["X-Name"] = context.ActionDescriptor.RouteValues["name"];
        }

        public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next)
        {
            OnActionExecuting(context);
            var result = await next();
            OnActionExecuted(result);
        }
    }
}

全局注册,在Startup里:

        public void ConfigureServices(IServiceCollection services)
        {
            services.AddMvc(options =>
            {
                options.Filters.Add<DefaultNameFilter>();
            });
        }

或者自定义一个属性标签,内部的代码是一样的:

using System;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc.Filters;

namespace MyRestful.Api.Filters
{
    public class DefaultUserNameFilterAttribute: Attribute, IActionFilter, IAsyncActionFilter
    {
        public void OnActionExecuting(ActionExecutingContext context)
        {
            context.ActionDescriptor.RouteValues["name"] = "Anonymous";
        }

        public void OnActionExecuted(ActionExecutedContext context)
        {
            context.HttpContext.Response.Headers["X-Name"] = context.ActionDescriptor.RouteValues["name"];
        }

        public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next)
        {
            OnActionExecuting(context);
            var result = await next();
            OnActionExecuted(result);
        }
    }
}

然后把该标签用在Action方法上即可:

        [DefaultUserNameFilter]
        [HttpGet("first/{id}")]
        public IActionResult FindFirstPerson(int id, string name)
        {
            return null;
        }

格式化响应结果

Action的结果最好使用IActionResult, 但也可以使用其他类型,例如IEnumerable<T>等。强制结果输出为特定的类型可以通过调用特定的方法来实现,例如JsonResponse就是输出JSON,ContentResponse就是输出文本。另外也可以使用[Produces(xxx)] 这个过滤器,它可以应用于全局,controller或者Action。

在REST服务里,有个词叫内容协商,它表示客户端通过Accept Header里的media-type来指定所需的结果格式。

ASP.NET Core MVC 默认实现并使用JSON格式化,但也支持其它格式,这需要在startup里面注册。

客户端浏览器可能在请求的Accept Headers里提供了多种的格式,但是ASP.NET Core MVC 默认是忽略浏览器的Accept Header的,并使用标准的输出格式。但是修改MvcOptions的RespectBrowserAcceptHeader值为true,可以改变这个行为:

ASP.NET Core还提供了 XML 格式,可以在MvcOptions里面添加:

项目配置

假设在项目的根目录有这样一个json文件, 在ASP.NET Core项目里我们可以使用IConfigurationRoot来使用该json文件作为配置文件, 而IConfigurationRoot是使用ConfigurationBuilder来创建的:

可以看到ConfigurationBuilder加载了firstConfig.json文件, 使用的是AddJsonFile这个扩展方法. 调用builder的Build方法会得到一个IConfigurationRoot的实例, 它实现了IConfiguration接口, 随后我们便可以通过遍历它的键值对.

其中json文件里的结构数据都最为键值对被扁平化到IConfiguration里了, 我们可以通过它的key找到对应的值:

像childkey1这种带层次结构的值可以使用冒号 作为层次分隔符.

配置文件总会包含这种多层结构的, 更好的办法是把类似的配置进行分组获取, 可以使用IConfiguration的GetSection()方法来获取局部的配置:

当有多个配置文件的时候, 配置数据的加载和它们在程序中指定的顺序是一样的, 如果多个文件都有同一个键的话, 那么最后加载的值将会覆盖先前加载的值.

下面是另一个配置文件:

在firstConfig后加载secondConfig:

最后key1的值是后加载的secondConfig里面的值.

当然了, 如果firstConfig里面有而secondConfig却没有的键, 它的值肯定来自firstConfig.

配置提供商

配置数据可以来自多种数据源, 它们可能是不同格式的.

ASP.NET Core 默认支持从下列方式获得配置:

  • 文件格式(INI, JSON, XML)
  • 命令行参数
  • 环境变量
  • 内存中的.NET对象
  • 未加密的Secret管理存储
  • 加密的用户存储, 例如Azure秘钥库
  • 自定义的提供商

这些东西还是看官方文档吧, 本文使用JSON格式的就够用了.

强类型的配置

ASP.NET Core允许把配置数据映射到一个对象类上面.

针对上面的firstConfig.json文件, 我们创建以下这个类:

然后调用IConfiguration的Bind扩展方法来把键值对集合对值映射到这个强类型对POCO实例里:

在标准的ASP.NET Core 2.0的项目模版里, 加载配置文件的步骤被封装了, 默认或加载appSettings.json 以及 appSettings.{环境}.json.

我记得是封装在这里了:

我把firstConfig.json改名为appSettings.json.

然后在Startup里面可以获得IConfiguration:

从打印结果可以看到, 加载的不只是appSettings里面的内容, 还有系统环境变量的值.

这种情况下, 使用IServiceCollectionConfigure扩展方法可以把配置映射到指定的类上面:

同时这也允许在程序的任何地方注入IOptions<FirstConfig>了:

这个Configure方法不仅仅可以映射ConfigurationRoot, 还可以映射配置的一部分:

配置变化

在项目运行的时候, 项目的配置信息可能会发生变化.

当采用的是基于文件的配置时, 如果配置数据有变化了, 我们应该让配置模型重新加载, 这就需要把AddJsonFile里面的配置属性 ReloadOnChange 设置为 true:

这时, 无论在哪各地方使用了IConfigurationRoot和IConfiguration, 它们都会反映出最新的值, 但是IOptions<T>却不行. 即使文件变化了并且配置模型也通过文件提供商进行了更新, IOptions<T>的实例仍然包含的是原始值.

为了让配置数据可以在这种强类型映射的类上体现, 就需要使用IOptionsSnapshot<T>:

IOptionsSnapshot<T> 的开销很小, 可以放心使用

日志 

ASP.NET Core 提供了6个内置的日志提供商。

需要使用日志的话,只需注入一个ILogger对象即可,不过该对象首先要在DI容器中注册。

这个ILogger接口主要是提供了Log方法:

记录Log的时候使用Log方法即可:

不过可以看到,该方法参数很多,用起来还是略显麻烦的。

幸运的是,针对Log还有几个扩展方法,他们就简单了很多:

  • LogCritical,用来记录严重的事情
  • LogDebug,记录调试信息
  • LogError,记录异常
  • LogInformation,记录信息性的事情
  • LogTrace,记录追踪信息
  • LogWarning,记录警告信息

在项目中配置和使用Log,只需在Program.cs里调用IWebHostBuilder的ConfigureLogging扩展方法即可:

本例中,我们把log配置成在控制台输出。

如果只是输出到控制台,其实我们就多此一举了,因为CreateDefaultBuilder这个方法里已经做了一些Log的配置,看一下反编译的源码:

可以看到logging的一些配置数据是从整体配置的Logging部分取出来的,然后配置了使用输出到控制台和Debug窗口的提供商。

记录Log的时候,通常情况下使用那几个扩展方法就足够了:

请注意,这里我注入的是ILogger<T>类型的logger,其中T可以用来表示日志的分类,它可以是任何类型,但通常是记录日志时所在的类。

运行项目后,可以看到我记录的日志:

同样也可以在一个类里面把记录的日志分为不同的分类,这时候你可以使用ILoggerFactory,这样就可以随时创建logger了,并把它绑定到特定的区域:

不知道您有没有发现上面这几个例子中日志输出的时候都有个数字 [0], 它是事件的标识符。因为上面的例子中我们没有指定事件的ID,所以就取默认值0。使用事件ID还是可以帮助我们区分和关联记录的日志的。

每次写日志的时候, 都需要通过不同的方式指明LogLevel, LogLevel表明的是严重性.

下面是ASP.NET Core里面定义的LogLevel(它是个枚举), 按严重性从低到高排序的:

Trace = 0, 它可以包含敏感拘束, 默认在生产环境中它是被禁用掉的.

Debug = 1, 也是在调试使用, 应该在生产环境中禁用, 但是遇到问题需要调试可以临时启用.

Information = 2, 用来追踪应用程序的总体流程.

Warning = 3, 通常用于记录非正常或意外的事件, 也可以包括不会导致应用程序停止的错误和其他事件, 例如验证错误等.

Error = 4, 用于记录无法处理的错误和异常, 这些信息意味着当前的活动或操作发生了错误, 但不是应用程序级别的错误.

Critical = 5, 用于记录需要立即处理的事件, 例如数据丢失或磁盘空间不足.

None = 6, 如果你不想输出日志, 你可以把程序的最低日志级别设置为None, 此外还可以用来过滤日志.

记录的日志信息是可以带参数的, 使用消息模板(也就是消息主题和参数分开), 格式如下:

同样也支持字符串插值:

第二种方式代码的可读性更强一些, 而且它们输出的结果没有什么区别:

但是对于日志系统来说, 这两种方式是不一样的. 通过消息模板的方式(消息和参数分开的方式), 日志提供商可以实现语义日志或叫做结构化日志, 它们可以把参数单独的出入到日志系统里面进行单独存储, 不仅仅是格式化的日志信息.

此外, 用重载的方法, 记录日志时也可以包含异常对象.

日志分组

我们可以使用相同的日志信息来表示一组操作, 这需要使用scope, scope继承了IDisposable接口, 通过ILogger.BeginScope<TState>可以得到scope:

使用scope, 还有一点需要注意, 需要在日志提供商上把IncludeScopes属性设置为true:

您可以发现, 日志被输出了两遍, 这是因为WebHost.CreateDefaultBuilder方法里面已经配置使用了AddConsole()方法, 我再配置一遍的话就相当于又添加了一个输出到控制台的日志提供商.

所以, 我可以不采用这个构建模式创建IWebHost, 改为直接new一个:

这样就正确了. 可以看到日志信息的第一行内容是一样的, 第二行是各自的日志信息.

日志的过滤

我们可以为整个程序设定日志记录的最低级别, 也可以为某个日志提供商和分类指定特定的过滤器.

设置全局最低记录日志的级别使用SetMinimumLevel()扩展方法:

如果想完全不输出日志的话, 可以把最低记录的级别设为LogLevel.None.

我们还可以为不同场景设置不同的最低记录级别:

然后分别建立这两个分类的logger, 并记录:

查看输出结果, 已经按配置进行了过滤:

这里可以使用完整的类名作为分类名:

然后使用ILogger<T>即可:

针对上面这个例子, 我们还可以使用配置文件:

相应的, 代码也需要改一下:

输出的效果是一样的.

日志提供商

ASP.NET Core 内置了6个日志提供商:

  • Console, 使用logging.AddConsole()来启用.
  • Debug, 使用logging.AddDebug()来启用. 它使用的是System.Diagnostics.Debug的Debug.WriteLine()方法, 由于Debug类的所有成员都是被[Conditional("DEBUG")]修饰过了, 所以无法被构建到Release Build里, 也就是生产环境是无法输出的, 除非你把Debug Build作为部署到生产环境😰.
  • EventSource, 使用logging.AddEventSourceLogger()来启用. 它可以把日志记录到事件追踪器, 它是跨平台的, 在windows上, 会记录到Event Tracing for Windows (ETW)
  • EventLog (仅限Windows), 使用logging.AddEventLog()来启用. 它会记录到Windows Event Log.
  • TraceSource (仅限Windows),, 使用logging.AddTraceSource(sourceSwitchName)来启用. 它允许我们把日志记录到各种的追踪监听器上, 例如 TextWriterTraceListener
  • Azure App Service, 在本地运行程序的时候, 这个提供商并不会起作用, 部署到Azure App Service的.NET Core程序会自动采用该提供商, .NET Core无须调用logging.AddAzureWebAppDiagnostics();该方法. 它会把日志记录到Azure App Service app的文件系统还会写进Azure Storage账户的blob storage里. 

第三方日志提供商

第三方的提供商有很多: Serilog, NLog, Elmah.IO, Loggr, JSNLog等等.

处理异常

ASP.NET Core 未开发人员提供了一个异常信息页面, 它是运行时生成的, 它封装了异常的各种信息, 例如Stack trace.

可以看到只有运行环境是开发时才启用该页面, 上面我抛出了一个异常, 看看访问时会出现什么结果:

这就是异常页面, 里面包含异常相关的信息.

注意: 该页面之应该在开发时启用, 因为你不想把这些敏感信息在生产环境中暴露.

当发送一个请求后, HTTP机制提供的响应总是带着一个状态码, 这些状态码主要有:

  • 1xx, 用于通知报告.
  • 2xx, 表示响应是成功的, 例如 200 OK, 201 Created, 204 No Content.
  • 3xx, 表示某种重定向, 
  • 4xx, 表示客户端引起的错误, 例如 400 Bad Request, 401 Unauthorized, 404 Not Found
  • 5xx, 表示服务器错误, 例如 500 Internal Server Error.

默认情况下, ASP.NET Core 项目不提供状态码的细节信息, 但是通过启用StatusCodePagesMiddleware中间件, 我们可以启用状态码细节信息:

然后当我们访问一个不存在的路由时, 就会返回以下信息:

我们也可以自定义返回的状态码信息:

OK, 预备知识先介绍到这, 其它相关的知识在建立API的时候穿插着讲吧.

项目开始模板

非常的简单, 先看一下Program.cs:

我们使用了WebHost.CreateDefaultBuilder()方法, 这个方法的默认配置大约如下:

采用Kestrel服务器, 使用项目个目录作为内容根目录, 默认首先加载appSettings.json, 然后加载appSettings.{环境}.json. 还加载了一些其它的东西例如环境变量, UserSecrect, 命令行参数. 然后配置Log, 会读取配置数据的Logging部分的数据, 使用控制台Log提供商和Debug窗口Log提供商, 最后设置了默认的服务提供商.

然后我添加了自己的一些配置:

使用IIS作为反向代理服务器, 使用Url地址为http://localhost:5000, 使用Startup作为启动类.

然后看Startup:

主要是注册mvc并使用mvc.

随后建立Controllers文件夹, 然后可以添加一个Controller试试是否好用:

可选项目配置

注意, 在使用VS2017启动项目的时候, 上面有很多选项:

为了开发时方便, 我把IISExpress这个去掉, 打开并编辑这个文件:

删掉IISExpress的部分, 然后修改一下applicationUrl:

然后启动选项就只剩下一个了:

如果你喜欢使用dotnet cli, 可以为项目添加dotnet watch, 打开并编辑 MyRestful.Api.csproj, 添加这行即可:

然后命令行执行 dotnet watch run 即可, 每次程序文件发生变化, 它都会重新编译运行程序:

为项目添加EntityFrameworkCore 2.0

关于EFCore 2.0的知识, 还是请看官方文档吧, 我也写了一篇非常非常入门级的文章, 仅供参考: http://www.cnblogs.com/cgzl/p/8543772.html

新建立两个.NET Core class library类型的项目:

这几个项目的关系是: MyRestful.Infrastructure 需要引用 MyRestful.Core, MyRestful.Api 需要引用其他两个.

 并把它们添加到MyRestful.Api项目的引用里.

然后要为MyRestful.Infrastructure项目添加几个包, 可以通过Nuget或者Package Manager Console或者dotnet cli:

Microsoft.EntityFrameworkCore.SqlServer (我打算使用内存数据库, 所以没安装这个)

Microsoft.EntityFrameworkCore.Tools

然后在MyRestful.Infrastructure项目里面建立一个DbContext:

再建立一个Domain Model, 因为Model和项目的合约(接口)一样都是项目的核心内容, 所以把Model放在MyRestful.Core项目下:

然后把这个Model放到MyContext里面:

在Startup.cs里面注册DbContext, 我使用的是内存数据库:

这里要注意: 由于使用的是内存数据库, 所以迁移等一些配置都可以省略了....

做一些种子数据:

这时需要修改一下Program.cs 来添加种子数据:

 好的, 到现在我写一些临时的代码测试一下MyContext:

直接从数据库中读取Domain Model 然后返回, 看看效果(这次使用的是POSTMAN):

可以看到, MyContext是OK的.

到这里, 就会出现一个问题, Controller的Action方法(也就是Web API吧)应该直接返回Domain Model吗?

你也可能知道答案, 不应该这样做. 因为:

像上面例子中的Country这样的Domain Model对于整个程序来说是内部实现细节, 我们肯定是不想把内部实现细节暴露给外部的, 因为程序是会变化的, 这样就会对所有依赖于这个内部实现的客户端造成破坏. 所以我们需要在内部实现外面再加上另外一层, 这层里面的类就会作为整个程序的公共合约或公共接口(界面的意思, 不是指C#接口).

可以把这件事想象比喻成组装电脑:

组装电脑机箱里有很多零件: 主板, 硬盘, CPU, 内存.....这就就是内部实现细节, 而用户能看到和用到的是前后面板的接口和按钮, 这就是我所说的电脑机箱的公共合约或公共接口. 更重要的是, 组装电脑的零件可能会更新换代, 也许添加一条内存, 换个固态硬盘.....但是所有的这些变化都不会改变(基本上)机箱前后面板的接口和按钮. 这个概念对于软件程序来说是一样的, 我们不想暴露我们的Domain Model给客户端, 所以我们需要另外一套Model类, 它们要看起来很像我们的Domain Model, 但是这两种model可以独立的进化和改变.

这类Model会到达程序的边界, 作为Controller的输入, 然后Controller把它们串行化之后再输出. 

用REST的术语来说, 我们把客户端请求服务器返回的对象叫做资源(Resources).

所以我会在MyRestful.Api项目里建立一个Resources文件夹, 并创建一个类叫做CountryResource.cs (以前我把它叫ViewModel或Dto, 在这里我叫它Resource, 都是一个意思):

现在来说, 它的属性和Country是一样的.

现在的问题是我要把MyContext查询出来的Country映射成CountryResource, 你可以手动编写映射关系, 但是最好的办法还是使用AutoMapper库(有两个), 安装到MyRestful.Api项目:

AutoMapperAutoMapper.Extensions.Microsoft.DependencyInjection

然后我们要做两个映射配置文件, 分别是Domain Model ==> Resource 和 Resource ==> Domain Model:

当然了, 也可以做一个配置文件, 我还是做一个吧:

然后在Startup里面注册AutoMapper即可:

 修改Controller测试下:

结果是OK的:

Repository 模式

概念不说了, 你可以把Repository想象成就是一堆Domain Models, 我们可以使用这个模式来封装查询等操作. 例如下面红框里面的查询:

这个查询有可能在整个项目中的多个地方被使用, 在稍微大一点的项目里可能会有很多类似的查询, 而Repository模式就是可以解决这个问题的一种方式. 

所以我在MyRestful.Infrastructure项目里建立Repostitories文件夹并建立CountryRepostsitory类:

这里需要注入MyContext, 暂时只需要一个查询方法.

现在Repository做好了, 为了在Controller里面使用(依赖注入), 我们需要为它抽取出一个接口, 因为我们不想让Controller与这些实现紧密的耦合在一起, 我们需要做的是把Controller和接口给耦合到一起, 这也就是依赖反转原则(DIP, 也就是SOLID里面的D, 高级别的模块不应该依赖于低级别的模块, 它们都应该依赖于抽象):

此外, 单元测试的时候, 我们可以用实现了IRepository的假Repository, 因为单元测试的时候最好不要依赖外界的资源, 例如数据库, 文件系统等, 最好只用内存中的数据.

所以先抽取接口:

然后配置DI:

在这里ASP.NET Core 提供了三种模式注册实现给接口, 它们代表着不同的生命周期:

  • Transient: 每次请求(不是指HTTP Request)都会创建一个新的实例,它比较适合轻量级的无状态的(Stateless)的service。
  • Scope: 每次http请求会创建一个实例。
  • Singleton: 在第一次请求的时候就会创建一个实例,以后也只有这一个实例,或者在ConfigureServices这段代码运行的时候创建唯一一个实例。

由于Repository依赖于DbContext, 而DbContext在ASP.NET Core项目配置里是Scope的, 所以每次HTTP请求的生命周期中只有一个DbContext实例, 所以IRepository就应该是Scope的.

修改Controller, 注入并使用IRepository, 去掉MyContext:

经测试, 结果是一样的, 我就不贴图了.

还有一个问题, 因为每次HTTP请求只会存在一个MyContext的实例, 而引用该实例的Repository可能是多个. 也就是说会存在这种情况, 某个Controller的Action方法里, 使用了多个不同的Repository, 分别做了个新增, 修改, 删除等操作, 但是保存的时候还是需要MyContext来做, 把保存动作放到任何一个Repository里面都是不合理的. 而且我之前讲过应该把Repository看作是Domain Models的集合, 例如list, 而list.Save()也没有什么意义. 所以Controller还是依赖于MyContext, 因为需要它的Save动作, 还是需要解耦. 

之前讲的使用Repository和依赖注入解耦的方式很大程度上较少了重复的代码, 而把Controller和EFCore解耦还有另外一个好处, 因为我有可能会把EFCore换掉, 去使用Dapper 😂, 因为如果项目比较大, 或者越来越大, 有一部分业务可能会需要性能比较好的Micro ORM来代替或者其它存储方式等. 所以引用EFCore的地方越少, 就越容易替换.

这时, 就应该使用Unit Of Work 模式了, 首先我添加一个IUnitOfWork的接口, 我把它放在MyRestful.Core项目的interfaces文件夹下了:

只有一个异步方法SaveAsync(). 然后是它的实现类UnitOfWork:

就是这样, 如果你想要替换掉Entity Framework Core的话, 只需要修改UnitOfWork和Repository, 无须修改IUnitOfWork和IRepository, 因为这些接口是项目的合约, 可以看作是不变的 (所以IRepository也应该放在MyRestful.Core里面, 这个以后再改).

然后注册DI:

修改Controller注入IUnitOfWork试试:

这里我又给Repository添加了一个Add方法用于测试, 结果如下:

好的, 没问题.

整体结构调整

差不多了, 让我们再回顾以下DIP原则(依赖反转): 高级别模块不应该依赖于低级别模块, 它们都应该依赖于抽象. 如果把Repository看作是服务的话, 那么使用服务的模块(Controller)就是高级别模块, 服务(Repository)就是低级别模块. 这个问题我们已经解决了. 

为什么要遵循这个原则? 因为要减少程序变化带来的影响.

看这张图:

就从一个方面来说, 如果Repository变化或重编译了, 那么Controller很有可能会变化并肯定需要重新编译, 也就是所有依赖于Repository的类都会被重新编译.

而使用DIP原则之后:

我们可以在Repository里面做出很多更改, 但是这些变化都不会影响到Controller, 因为Controller并不是依赖于这个实现.

只要IRepository这个接口不发生变化, Controller就不会被影响到. 这也就可能会较少对整个项目的影响.

Interface 代表的是 "是什么样的", 而实现代表的是 "如何去实现".

Interface一旦完成后是很少改变的.

针对使用Repository+UnitOfWork模式的项目结构, 有时会有一点错误的理解, 可能会把项目的结构这样划分:

这样一来, 从命名空间角度讲. 其实就是这样的:

高级别的包/模块依赖于低级别的包/模块.

也就违反了DIP原则, 所以如果想按原则执行, 就需要引进一个新的模块:

把所有的抽象相关的类都放在Core里面.

这样就满足了DIP原则.

所以我们把项目稍微重构以下, 把合约/接口以及项目的核心都放在MyRestful.Core项目里:

好的, 这次先写道这里, 项目已经做好了最基本的准备, 其余功能的扩展会随着后续文章进行.

下面应该快要切入REST的正题了.

原创声明,本文系作者授权云+社区发表,未经许可,不得转载。

如有侵权,请联系 yunjia_community@tencent.com 删除。

发表于

我来说两句

0 条评论
登录 后参与评论

相关文章

来自专栏逸鹏说道

小解Redis 系列

官网:http://redis.io/ 推荐一个开源组件:StackExchange.Redis https://github.com/StackExchang...

31390
来自专栏Java帮帮-微信公众号-技术文章全总结

Web-第三十一天 WebService学习【悟空教程】

简单的网络应用使用单一语言写成,它的唯一外部程序就是它所依赖的数据库。大家想想是不是这样呢?

23640
来自专栏NetCore

保护连接字符串

保护连接字符串 摘自MSDN 保护对数据源的访问是安全应用程序最重要的目标之一。为了帮助限制对数据源的访问,必须保护连接信息(例如用户标识、密码和数据源名称)的...

21750
来自专栏北京马哥教育

ls 命令还能这么玩?看一下这 20 个实用范例

20940
来自专栏张戈的专栏

Linux运维工程师:30道面试题整理

前段时间,我在准备面试的时搜到的一套 Linux 运维工程师面试题,感觉比较全面,一直保存在草稿,刚在整理后台时翻了出来,干脆就发出来好了,以备不时之需。 1....

2.2K50
来自专栏闵开慧

tomcat6.0下找不到jasper-runtime.jar

今天有点需求,需要用jasper-runtime.jar包。但是我在我的\apache-tomcat-6.0.16\lib目录下,怎么也找不到这个jar包。结果...

34950
来自专栏Java架构

前沪江高级架构师学习笔记分享:分布式框架设计与实现

20960
来自专栏编码小白

ofbiz初级教程

本教程是ofbiz 基本应用,它涵盖了OFBiz应用程序开发过程的基本原理。目标是使开发人员熟悉最佳实践,编码惯例,基本控制流程以及开发人员对OFBiz定制所需...

1.9K30
来自专栏谈补锅

iOS之ProtocolBuffer搭建和示例demo

     这次搭建iOS的ProtocolBuffer编译器和把*.proto源文件编译成*.pbobjc.h 和 *.pbobjc.m文件时,碰到不少问题!

26330
来自专栏大内老A

谈谈你最熟悉的System.DateTime[下篇]

在《上篇》中,我们实现了将保存有消息条目的XML向CodeDOM的转换,即是将XML文件生成一个CodeCompileUnit对象,而该CodeCompileU...

18350

扫码关注云+社区

领取腾讯云代金券