├── LICENSE ├── README.md ├── docs └── ASPNET_Core_WEB_API_Best_Practices.pdf └── images ├── 01-Project-Structure.png ├── 02-Appsettings-development.png └── 03-Appsettings-production.png /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 hippie 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | 3 |

ASP.NET Core Web API 最佳实践

4 | 5 | > 英文版:[ASP.NET-Core-Web-API-Best-Practices-Guide](https://code-maze.us12.list-manage.com/track/click?u=9bb15645129501e5249a9a8e1&id=986d07e1f0&e=1184a539da)。如果你也有好的建议和实践,欢迎 PR 6 | 7 |
8 | 9 | - [介绍](#介绍) 10 | - [Startup 类 和 服务配置](#startup-类-和-服务配置) 11 | - [项目组织](#项目组织) 12 | - [基于环境的设置](#基于环境的设置) 13 | - [数据访问层](#数据访问层) 14 | - [控制器](#控制器) 15 | - [处理全局异常](#处理全局异常) 16 | - [使用过滤器移除重复代码](#使用过滤器移除重复代码) 17 | - [Microsoft.AspNetCore.all 元包](#microsoftaspnetcoreall-元包) 18 | - [路由](#路由) 19 | - [日志](#日志) 20 | - [加密](#加密) 21 | - [内容协商](#内容协商) 22 | - [使用 JWT](#使用-jwt) 23 | - [使用 BasicAuthentication](#使用-basicauthentication) 24 | - [短地址算法](#短地址算法) 25 | - [后台服务](#后台服务) 26 | - [输入验证](#输入验证) 27 | - [缩略图](#缩略图) 28 | - [读取 `app.config`](#读取-appconfig) 29 | - [在集成测试中新增 API](#在集成测试中新增-api) 30 | - [在 `SwaggerUI` 中请求接口防止参数被自动转义](#在-swaggerui-中请求接口防止参数被自动转义) 31 | - [SyndicationFeed](#syndicationfeed) 32 | - [MultipartFormDataContent](#multipartformdatacontent) 33 | - [NewtonsoftJson](#newtonsoftjson) 34 | - [总结](#总结) 35 | 36 | > 如果内容有调整,请使用 [Markdown All in One](https://marketplace.visualstudio.com/items?itemName=yzhang.markdown-all-in-one) 的 `create Table of Contents/update Table of Contents` 进行目录结构更新。 37 | 38 | ## 介绍 39 | 40 | 当我们编写一个项目的时候,我们的主要目标是使它能如期运行,并尽可能地满足所有用户需求。 41 | 42 | 但是,你难道不认为创建一个能正常工作的项目还不够吗?同时这个项目不应该也是可维护和可读的吗? 43 | 44 | 事实证明,我们需要把更多的关注点放到我们项目的可读性和可维护性上。这背后的主要原因是我们或许不是这个项目的唯一编写者。一旦我们完成后,其他人也极有可能会加入到这里面来。 45 | 46 | 因此,我们应该把关注点放到哪里呢? 47 | 48 | 在这一份指南中,关于开发 .NET Core Web API 项目,我们将叙述一些我们认为会是最佳实践的方式。进而让我们的项目变得更好和更加具有可维护性。 49 | 50 | 现在,让我们开始想一些可以应用到 ASP.NET Web API 项目中的一些最佳实践。 51 | 52 | ### Startup 类 和 服务配置 53 | 54 | > STARTUP CLASS AND THE SERVICE CONFIGURATION 55 | 56 | 在 `Startup` 类中,有两个方法:`ConfigureServices` 是用于服务注册,`Configure` 方法是向应用程序的请求管道中添加中间件。 57 | 58 | 因此,最好的方式是保持 `ConfigureServices` 方法简洁,并且尽可能地具有可读性。当然,我们需要在该方法内部编写代码来注册服务,但是我们可以通过使用 `扩展方法` 来让我们的代码更加地可读和可维护。 59 | 60 | 例如,让我们看一个注册 CORS 服务的不好方式: 61 | 62 | ```csharp 63 | public void ConfigureServices(IServiceCollection services) 64 | { 65 | services.AddCors(options => 66 | { 67 | options.AddPolicy("CorsPolicy", builder => builder.AllowAnyOrigin() 68 | .AllowAnyMethod() 69 | .AllowAnyHeader() 70 | .AllowCredentials()); 71 | }); 72 | } 73 | ``` 74 | 75 | 尽管这种方式看起来挺好,也能正常地将 CORS 服务注册成功。但是想象一下,在注册了十几个服务之后这个方法体的长度。 76 | 77 | 这样一点也不具有可读性。 78 | 79 | 一种好的方式是通过在扩展类中创建静态方法: 80 | 81 | ```csharp 82 | public static class ServiceExtensions 83 | { 84 | public static void ConfigureCors(this IServiceCollection services) 85 | { 86 | services.AddCors(options => 87 | { 88 | options.AddPolicy("CorsPolicy", builder => builder.AllowAnyOrigin() 89 | .AllowAnyMethod() 90 | .AllowAnyHeader() 91 | .AllowCredentials()); 92 | }); 93 | } 94 | } 95 | ``` 96 | 97 | 然后,只需要调用这个扩展方法即可: 98 | 99 | ```csharp 100 | public void ConfigureServices(IServiceCollection services) 101 | { 102 | services.ConfigureCors(); 103 | } 104 | ``` 105 | 106 | 了解更多关于 .NET Core 的项目配置,请查看:[.NET Core Project Configuration](https://code-maze.com/net-core-web-development-part2/) 107 | 108 | ### 项目组织 109 | 110 | > PROJECT ORGANIZATION 111 | 112 | 我们应该尝试将我们的应用程序拆分为多个小项目。通过这种方式,我们可以获得最佳的项目组织方式,并能将关注点分离(SoC)。我们的实体、契约、访问数据库操作、记录信息或者发送邮件的业务逻辑应该始终放在单独的 .NET Core 类库项目中。 113 | 114 | 应用程序中的每个小项目都应该包含多个文件夹用来组织业务逻辑。 115 | 116 | 这里有个简单的示例用来展示一个复杂的项目应该如何组织: 117 | 118 |
119 | 120 | ![Project-Structure](images/01-Project-Structure.png) 121 | 122 |
123 | 124 | ### 基于环境的设置 125 | 126 | > ENVIRONMENT BASED SETTINGS 127 | 128 | 当我们开发应用程序时,它处于开发环境。但是一旦我们发布之后,它将处于生产环境。因此,将每个环境进行隔离配置往往是一种好的实践方式。 129 | 130 | 在 .NET Core 中,这一点很容易实现。 131 | 132 | 一旦我们创建好了项目,就已经有一个 `appsettings.json` 文件,当我们展开它时会看到 `appsettings.Development.json` 文件: 133 | 134 |
135 | 136 | ![Appsettings-development](images/02-Appsettings-development.png) 137 | 138 |
139 | 140 | 此文件中的所有设置将用于开发环境。 141 | 142 | 我们应该添加另一个文件 `appsettings.Production.json`,将其用于生产环境: 143 | 144 |
145 | 146 | ![Appsettings-production.png](images/03-Appsettings-production.png) 147 | 148 |
149 | 150 | 生产文件将位于开发文件下面。 151 | 152 | 设置修改后,我们就可以通过不同的 appsettings 文件来加载不同的配置,取决于我们应用程序当前所处环境,.NET Core 将会给我们提供正确的设置。更多关于这一主题,请查阅:[Multiple Environments in ASP.NET Core.](https://docs.microsoft.com/en-us/aspnet/core/fundamentals/environments?view=aspnetcore-3.0) 153 | 154 | ### 数据访问层 155 | 156 | > DATA ACCESS LAYER 157 | 158 | 在一些不同的示例教程中,我们可能看到 DAL 的实现在主项目中,并且每个控制器中都有实例。我们不建议这么做。 159 | 160 | 当我们编写 DAL 时,我们应该将其作为一个独立的服务来创建。在 .NET Core 项目中,这一点很重要,因为当我们将 DAL 作为一个独立的服务时,我们就可以将其直接注入到 IOC(控制反转)容器中。IOC 是 .NET Core 内置功能。通过这种方式,我们可以在任何控制器中通过构造函数注入的方式来使用。 161 | 162 | ```csharp 163 | public class OwnerController: Controller 164 | { 165 | private readonly IRepository _repository; 166 | public OwnerController(IRepository repository) 167 | { 168 | _repository = repository; 169 | } 170 | } 171 | ``` 172 | 173 | ### 控制器 174 | 175 | > CONTROLLERS 176 | 177 | 控制器应该始终尽量保持整洁。我们不应该将任何业务逻辑放置于内。 178 | 179 | 因此,我们的控制器应该通过构造函数注入的方式接收服务实例,并组织 HTTP 的操作方法(GET,POST,PUT,DELETE,PATCH...): 180 | 181 | ```csharp 182 | public class OwnerController : Controller 183 | { 184 | private readonly ILoggerManager _logger; 185 | private readonly IRepository _repository; 186 | public OwnerController(ILoggerManager logger, IRepository repository) 187 | { 188 | _logger = logger; 189 | _repository = repository; 190 | } 191 | 192 | [HttpGet] 193 | public IActionResult GetAllOwners() 194 | { 195 | } 196 | [HttpGet("{id}", Name = "OwnerById")] 197 | public IActionResult GetOwnerById(Guid id) 198 | { 199 | } 200 | [HttpGet("{id}/account")] 201 | public IActionResult GetOwnerWithDetails(Guid id) 202 | { 203 | } 204 | [HttpPost] 205 | public IActionResult CreateOwner([FromBody]Owner owner) 206 | { 207 | } 208 | [HttpPut("{id}")] 209 | public IActionResult UpdateOwner(Guid id, [FromBody]Owner owner) 210 | { 211 | } 212 | [HttpDelete("{id}")] 213 | public IActionResult DeleteOwner(Guid id) 214 | { 215 | } 216 | } 217 | ``` 218 | 219 | 我们的 Action 应该尽量保持简洁,它们的职责应该包括处理 HTTP 请求,验证模型,捕捉异常和返回响应。 220 | 221 | ```csharp 222 | [HttpPost] 223 | public IActionResult CreateOwner([FromBody]Owner owner) 224 | { 225 | try 226 | { 227 | if (owner.IsObjectNull()) 228 | { 229 | return BadRequest("Owner object is null"); 230 | } 231 | if (!ModelState.IsValid) 232 | { 233 | return BadRequest("Invalid model object"); 234 | } 235 | _repository.Owner.CreateOwner(owner); 236 | return CreatedAtRoute("OwnerById", new { id = owner.Id }, owner); 237 | } 238 | catch (Exception ex) 239 | { 240 | _logger.LogError($"Something went wrong inside the CreateOwner action: { ex} "); 241 | return StatusCode(500, "Internal server error"); 242 | } 243 | } 244 | ``` 245 | 246 | 在大多数情况下,我们的 action 应该将 `IActonResult` 作为返回类型(有时我们希望返回一个特定类型或者是 `JsonResult` ...)。通过使用这种方式,我们可以很好地使用 .NET Core 中内置方法的返回值和状态码。 247 | 248 | 使用最多的方法是: 249 | 250 | - OK => returns the 200 status code 251 | - NotFound => returns the 404 status code 252 | - BadRequest => returns the 400 status code 253 | - NoContent => returns the 204 status code 254 | - Created, CreatedAtRoute, CreatedAtAction => returns the 201 status code 255 | - Unauthorized => returns the 401 status code 256 | - Forbid => returns the 403 status code 257 | - StatusCode => returns the status code we provide as input 258 | 259 | ### 处理全局异常 260 | 261 | > HANDLING ERRORS GLOBALLY 262 | 263 | 在上面的示例中,我们的 action 内部有一个 `try-catch` 代码块。这一点很重要,我们需要在我们的 action 方法体中处理所有的异常(包括未处理的)。一些开发者在 action 中使用 `try-catch` 代码块,这种方式明显没有任何问题。但我们希望 action 尽量保持简洁。因此,从我们的 action 中删除 `try-catch` ,并将其放在一个集中的地方会是一种更好的方式。.NET Core 给我们提供了一种处理全局异常的方式,只需要稍加修改,就可以使用内置且完善的的中间件。我们需要做的修改就是在 `Startup` 类中修改 `Configure` 方法: 264 | 265 | ```csharp 266 | public void Configure(IApplicationBuilder app, IWebHostEnvironment env) 267 | { 268 | app.UseExceptionHandler(config => 269 | { 270 | config.Run(async context => 271 | { 272 | context.Response.StatusCode = StatusCodes.Status500InternalServerError; 273 | context.Response.ContentType = "application/json"; 274 | 275 | var error = context.Features.Get(); 276 | var ex = error?.Error; 277 | await context.Response.WriteAsync(new ErrorModel 278 | { 279 | StatusCode = StatusCodes.Status500InternalServerError, 280 | ErrorMessage = ex?.Message 281 | }.ToString()); 282 | }); 283 | }); 284 | 285 | app.UseRouting(); 286 | 287 | app.UseEndpoints(endpoints => 288 | { 289 | endpoints.MapControllers(); 290 | }); 291 | } 292 | ``` 293 | 294 | 我们也可以通过创建自定义的中间件来实现我们的自定义异常处理: 295 | 296 | ```csharp 297 | // You may need to install the Microsoft.AspNetCore.Http.Abstractions package into your project 298 | public class CustomExceptionMiddleware 299 | { 300 | private readonly RequestDelegate _next; 301 | private readonly ILogger _logger; 302 | public CustomExceptionMiddleware(RequestDelegate next, ILogger logger) 303 | { 304 | _next = next; 305 | _logger = logger; 306 | } 307 | 308 | public async Task Invoke(HttpContext httpContext) 309 | { 310 | try 311 | { 312 | await _next(httpContext); 313 | } 314 | catch (Exception ex) 315 | { 316 | _logger.LogError("Unhandled exception....", ex); 317 | await HandleExceptionAsync(httpContext, ex); 318 | } 319 | } 320 | 321 | private Task HandleExceptionAsync(HttpContext httpContext, Exception ex) 322 | { 323 | //todo 324 | return Task.CompletedTask; 325 | } 326 | } 327 | 328 | // Extension method used to add the middleware to the HTTP request pipeline. 329 | public static class CustomExceptionMiddlewareExtensions 330 | { 331 | public static IApplicationBuilder UseCustomExceptionMiddleware(this IApplicationBuilder builder) 332 | { 333 | return builder.UseMiddleware(); 334 | } 335 | } 336 | ``` 337 | 338 | 之后,我们只需要将其注入到应用程序的请求管道中即可: 339 | 340 | ```csharp 341 | public void Configure(IApplicationBuilder app, IWebHostEnvironment env) 342 | { 343 | app.UseCustomExceptionMiddleware(); 344 | } 345 | ``` 346 | 347 | ### 使用过滤器移除重复代码 348 | 349 | > USING ACTIONFILTERS TO REMOVE DUPLICATED CODE 350 | 351 | ASP.NET Core 的过滤器可以让我们在请求管道的特定状态之前或之后运行一些代码。因此如果我们的 action 中有重复验证的话,可以使用它来简化验证操作。 352 | 353 | 当我们在 action 方法中处理 PUT 或者 POST 请求时,我们需要验证我们的模型对象是否符合我们的预期。作为结果,这将导致我们的验证代码重复,我们希望避免出现这种情况,(基本上,我们应该尽我们所能避免出现任何代码重复。)我们可以在代码中通过使用 ActionFilter 来代替我们的验证代码: 354 | 355 | ```csharp 356 | if (!ModelState.IsValid) 357 | { 358 | //bad request and logging logic 359 | } 360 | ``` 361 | 362 | 我们可以创建一个过滤器: 363 | 364 | ```csharp 365 | public class ModelValidationAttribute : ActionFilterAttribute 366 | { 367 | public override void OnActionExecuting(ActionExecutingContext context) 368 | { 369 | if (!context.ModelState.IsValid) 370 | { 371 | context.Result = new BadRequestObjectResult(context.ModelState); 372 | } 373 | } 374 | } 375 | ``` 376 | 377 | 然后在 `Startup` 类的 `ConfigureServices` 函数中将其注入: 378 | 379 | ```csharp 380 | services.AddScoped(); 381 | ``` 382 | 383 | 现在,我们可以将上述注入的过滤器应用到我们的 action 中。 384 | 385 | ### Microsoft.AspNetCore.all 元包 386 | 387 | > MICROSOFT.ASPNETCORE.ALL META-PACKAGE 388 | 389 | 注:如果你使用的是 2.1 和更高版本的 ASP.NET Core。建议使用 Microsoft.AspNetCore.App 包,而不是 Microsoft.AspNetCore.All。这一切都是出于安全原因。此外,如果使用 2.1 版本创建新的 WebAPI 项目,我们将自动获取 AspNetCore.App 包,而不是 AspNetCore.All。 390 | 391 | 这个元包包含了所有 AspNetCore 的相关包,EntityFrameworkCore 包,SignalR 包(version 2.1) 和依赖框架运行的支持包。采用这种方式创建一个新项目很方便,因为我们不需要手动安装一些我们可能使用到的包。 392 | 393 | 当然,为了能使用 Microsoft.AspNetCore.all 元包,需要确保你的机器安装了 .NET Core Runtime。 394 | 395 | ### 路由 396 | 397 | > ROUTING 398 | 399 | 在 .NET Core Web API 项目中,我们应该使用属性路由代替传统路由,这是因为属性路由可以帮助我们匹配路由参数名称与 Action 内的实际参数方法。另一个原因是路由参数的描述,对我们而言,一个名为 "ownerId" 的参数要比 "id" 更加具有可读性。 400 | 401 | 我们可以使用 **[Route]** 属性来在控制器的顶部进行标注: 402 | 403 | ```csharp 404 | [Route("api/[controller]")] 405 | public class OwnerController : Controller 406 | { 407 | [Route("{id}")] 408 | [HttpGet] 409 | public IActionResult GetOwnerById(Guid id) 410 | { 411 | } 412 | } 413 | ``` 414 | 415 | 还有另一种方式为控制器和操作创建路由规则: 416 | 417 | ```csharp 418 | [Route("api/owner")] 419 | public class OwnerController : Controller 420 | { 421 | [Route("{id}")] 422 | [HttpGet] 423 | public IActionResult GetOwnerById(Guid id) 424 | { 425 | } 426 | } 427 | ``` 428 | 429 | 对于这两种方式哪种会好一些存在分歧,但是我们经常建议采用第二种方式。这是我们一直在项目中采用的方式。 430 | 431 | 当我们谈论路由时,我们需要提到路由的命名规则。我们可以为我们的操作使用描述性名称,但对于 路由/节点,我们应该使用 NOUNS 而不是 VERBS。 432 | 433 | 一个较差的示例: 434 | 435 | ```csharp 436 | [Route("api/owner")] 437 | public class OwnerController : Controller 438 | { 439 | [HttpGet("getAllOwners")] 440 | public IActionResult GetAllOwners() 441 | { 442 | } 443 | [HttpGet("getOwnerById/{id}"] 444 | public IActionResult GetOwnerById(Guid id) 445 | { 446 | } 447 | } 448 | ``` 449 | 450 | 一个较好的示例: 451 | 452 | ```csharp 453 | [Route("api/owner")] 454 | public class OwnerController : Controller 455 | { 456 | [HttpGet] 457 | public IActionResult GetAllOwners() 458 | { 459 | } 460 | [HttpGet("{id}"] 461 | public IActionResult GetOwnerById(Guid id) 462 | { 463 | } 464 | } 465 | 466 | ``` 467 | 468 | 更多关于 Restful 实践的细节解释,请查阅:[Top REST API Best Practices](https://code-maze.com/top-rest-api-best-practices/) 469 | 470 | ### 日志 471 | 472 | > LOGGING 473 | 474 | 如果我们打算将我们的应用程序发布到生产环境,我们应该在合适的位置添加一个日志记录机制。在生产环境中记录日志对于我们梳理应用程序的运行很有帮助。 475 | 476 | .NET Core 通过继承 `ILogger` 接口实现了它自己的日志记录。通过借助依赖注入机制,它可以很容易地使用。 477 | 478 | ```csharp 479 | public class TestController: Controller 480 | { 481 | private readonly ILogger _logger; 482 | public TestController(ILogger logger) 483 | { 484 | _logger = logger; 485 | } 486 | } 487 | ``` 488 | 489 | 然后,在我们的 action 中,我们可以通过使用 \_logger 对象借助不同的日志级别来记录日志。 490 | 491 | .NET Core 支持使用于各种日志记录的 Provider。因此,我们可能会在项目中使用不同的 Provider 来实现我们的日志逻辑。 492 | 493 | NLog 是一个很不错的可以用于我们自定义的日志逻辑类库,它极具扩展性。支持结构化日志,且易于配置。我们可以将信息记录到控制台,文件甚至是数据库中。 494 | 495 | 想了解更多关于该类库在 .NET Core 中的应用,请查阅:[.NET Core series – Logging With NLog.](https://code-maze.com/net-core-web-development-part3/) 496 | 497 | Serilog 也是一个很不错的类库,它适用于 .NET Core 内置的日志系统。 498 | 499 | ### 加密 500 | 501 | > CRYPTOHELPER 502 | 503 | 我们不会建议将密码以明文形式存储到数据库中。出于安全原因,我们需要对其进行哈希处理。这超出了本指南的内容范围。互联网上有大量哈希算法,其中不乏一些不错的方法来将密码进行哈希处理。 504 | 505 | 但是如果需要为 .NET Core 的应用程序提供易于使用的加密类库,CryptoHelper 是一个不错的选择。 506 | 507 | CryptoHelper 是适用于 .NET Core 的独立密码哈希库,它是基于 PBKDF2 来实现的。通过创建 `Data Protection` 栈来将密码进行哈希化。这个类库在 NuGet 上是可用的,并且使用也很简单: 508 | 509 | ```csharp 510 | using CryptoHelper; 511 | 512 | // Hash a password 513 | public string HashPassword(string password) 514 | { 515 | return Crypto.HashPassword(password); 516 | } 517 | 518 | // Verify the password hash against the given password 519 | public bool VerifyPassword(string hash, string password) 520 | { 521 | return Crypto.VerifyHashedPassword(hash, password); 522 | } 523 | ``` 524 | 525 | ### 内容协商 526 | 527 | > CONTENT NEGOTIATION 528 | 529 | 默认情况下,.NET Core Web API 会返回 JSON 格式的结果。大多数情况下,这是我们所希望的。 530 | 531 | 但是如果客户希望我们的 Web API 返回其它的响应格式,例如 XML 格式呢? 532 | 533 | 为了解决这个问题,我们需要进行服务端配置,用于按需格式化我们的响应结果: 534 | 535 | ```csharp 536 | public void ConfigureServices(IServiceCollection services) 537 | { 538 | services.AddControllers().AddXmlDataContractSerializerFormatters(); 539 | } 540 | ``` 541 | 542 | 但有时客户端会请求一个我们 Web API 不支持的格式,因此最好的实践方式是对于未经处理的请求格式统一返回 406 状态码。这种方式也同样能在 ConfigureServices 方法中进行简单配置: 543 | 544 | ```csharp 545 | public void ConfigureServices(IServiceCollection services) 546 | { 547 | services.AddControllers(options => options.ReturnHttpNotAcceptable = true).AddXmlDataContractSerializerFormatters(); 548 | } 549 | ``` 550 | 551 | 我们也可以创建我们自己的格式化规则。 552 | 553 | 这一部分内容是一个很大的主题,如果你希望了解更对,请查阅:[Content Negotiation in .NET Core](https://code-maze.com/content-negotiation-dotnet-core/) 554 | 555 | ### 使用 JWT 556 | 557 | 现如今的 Web 开发中,JSON Web Tokens (JWT) 变得越来越流行。得益于 .NET Core 内置了对 JWT 的支持,因此实现起来非常容易。JWT 是一个开发标准,它允许我们以 JSON 格式在服务端和客户端进行安全的数据传输。 558 | 559 | 我们可以在 ConfigureServices 中配置 JWT 认证: 560 | 561 | ```csharp 562 | public void ConfigureServices(IServiceCollection services) 563 | { 564 | services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) 565 | .AddJwtBearer(options => 566 | { 567 | options.TokenValidationParameters = new TokenValidationParameters 568 | { 569 | ValidateIssuer = true, 570 | ValidIssuer = _authToken.Issuer, 571 | 572 | ValidateAudience = true, 573 | ValidAudience = _authToken.Audience, 574 | 575 | ValidateIssuerSigningKey = true, 576 | IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_authToken.Key)), 577 | 578 | RequireExpirationTime = true, 579 | ValidateLifetime = true, 580 | 581 | options.Events = new JwtBearerEvents 582 | { 583 | OnAuthenticationFailed = context => 584 | { 585 | context.Response.StatusCode = StatusCodes.Status401Unauthorized; 586 | context.Response.ContentType = "application/json; charset=utf-8"; 587 | var message = env.IsDevelopment() ? context.Exception.ToString() : "An error occurred processing your authentication."; 588 | var result = JsonConvert.SerializeObject(new { message }); 589 | return context.Response.WriteAsync(result); 590 | } 591 | }; 592 | //others 593 | }; 594 | }); 595 | } 596 | ``` 597 | 598 | 为了能在应用程序中使用它,我们还需要在 Configure 中调用下面一段代码: 599 | 600 | ```csharp 601 | public void Configure(IApplicationBuilder app, IWebHostEnvironment env) 602 | { 603 | app.UseAuthentication(); 604 | } 605 | ``` 606 | 607 | 此外,创建 Token 可以使用如下方式: 608 | 609 | ```csharp 610 | var securityToken = new JwtSecurityToken( 611 | claims: new Claim[] 612 | { 613 | new Claim(ClaimTypes.NameIdentifier,user.Id), 614 | new Claim(ClaimTypes.Email,user.Email) 615 | }, 616 | issuer: _authToken.Issuer, 617 | audience: _authToken.Audience, 618 | notBefore: DateTime.Now, 619 | expires: DateTime.Now.AddDays(_authToken.Expires), 620 | signingCredentials: new SigningCredentials( 621 | new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_authToken.Key)), 622 | SecurityAlgorithms.HmacSha256Signature)); 623 | 624 | Token = new JwtSecurityTokenHandler().WriteToken(securityToken) 625 | ``` 626 | 627 | 基于 Token 的用户验证可以在控制器中使用如下方式: 628 | 629 | ```csharp 630 | var auth = await HttpContext.AuthenticateAsync(); 631 | var id = auth.Principal.Claims.FirstOrDefault(x => x.Type.Equals(ClaimTypes.NameIdentifier))?.Value; 632 | ``` 633 | 634 | 我们也可以将 JWT 用于授权部分,只需添加角色声明到 JWT 配置中即可。 635 | 636 | 更多关于 .NET Core 中 JWT 认证和授权部分,请查阅:[authentication-aspnetcore-jwt-1](https://code-maze.com/authentication-aspnetcore-jwt-1/) 和 [authentication-aspnetcore-jwt-2](https://code-maze.com/authentication-aspnetcore-jwt-2/) 637 | 638 | ### 使用 BasicAuthentication 639 | 640 | 首先,在需要进行 authentication 的 controller 上使用 `AuthorizeAttribute` 进行标注(也可以使用自定的 AuthorizeAttribute)。 641 | 642 | 然后,定义并实现 `BasicAuthenticationHandler` 类,如下所示: 643 | 644 | ```csharp 645 | public class BasicAuthenticationHandler : AuthenticationHandler 646 | { 647 | public BasicAuthenticationHandler( 648 | IOptionsMonitor options, 649 | ILoggerFactory logger, 650 | UrlEncoder encoder, 651 | ISystemClock clock) : base(options, logger, encoder, clock) 652 | { 653 | } 654 | 655 | protected override async Task HandleAuthenticateAsync() 656 | { 657 | await Task.Yield(); 658 | 659 | // skip authentication if endpoint has [AuthorizeAttribute] attribute 660 | var endpoint = Context.GetEndpoint(); 661 | if (endpoint?.Metadata?.GetMetadata() == null) 662 | return AuthenticateResult.NoResult(); 663 | 664 | if (!Request.Headers.ContainsKey("Authorization")) 665 | return AuthenticateResult.Fail("Missing Authorization Header"); 666 | 667 | IUser user; 668 | try 669 | { 670 | user = ExtractUserNameAndPassword(); 671 | } 672 | catch (Exception e) 673 | { 674 | Logger.LogError(e, "Invalid Authorization Header"); 675 | return AuthenticateResult.Fail("Invalid Authorization Header"); 676 | } 677 | 678 | if (user == null) 679 | return AuthenticateResult.Fail("Invalid Username or Password"); 680 | 681 | var principal = new ClaimsPrincipal(new GenericPrincipal(new GenericIdentity(user.Identity), user.Roles)); 682 | var ticket = new AuthenticationTicket(principal, Scheme.Name); 683 | 684 | return AuthenticateResult.Success(ticket); 685 | } 686 | 687 | private IUser ExtractUserNameAndPassword() 688 | { 689 | var authHeader = AuthenticationHeaderValue.Parse(Request.Headers["Authorization"]); 690 | var credentialBytes = Convert.FromBase64String(authHeader.Parameter!); 691 | var credentials = Encoding.UTF8.GetString(credentialBytes).Split(new[] { ':' }, 2); 692 | var username = credentials[0]; 693 | var password = credentials[1]; 694 | 695 | var repository = new ServiceUserRepository(); 696 | return repository.Authenticate(username, password, out _); 697 | } 698 | } 699 | ``` 700 | 701 | 最后,在 `Program` 类中进行注册,如下所示: 702 | 703 | ```csharp 704 | builder.Services 705 | .AddAuthentication("BasicAuthentication") 706 | .AddScheme("BasicAuthentication", null); 707 | 708 | // 在 Swagger 中集成 Basic Authentication 709 | builder.Services.AddSwaggerGen(config => 710 | { 711 | config.CustomSchemaIds(x => x.FullName); 712 | var basicSecurityScheme = new OpenApiSecurityScheme 713 | { 714 | Type = SecuritySchemeType.Http, 715 | Scheme = "basic", 716 | Reference = new OpenApiReference { Id = "BasicAuth", Type = ReferenceType.SecurityScheme } 717 | }; 718 | config.AddSecurityDefinition(basicSecurityScheme.Reference.Id, basicSecurityScheme); 719 | config.AddSecurityRequirement(new OpenApiSecurityRequirement 720 | { 721 | { basicSecurityScheme, Array.Empty() } 722 | }); 723 | }); 724 | 725 | ...... 726 | 727 | app.UseAuthentication(); 728 | app.UseAuthorization(); 729 | 730 | ...... 731 | ``` 732 | 733 | ### 短地址算法 734 | 735 | > Creating a Url Shortener Service 736 | 737 | 如果你想通过 .NET Core 来构建短地址服务,那么这里有一个比较不错的生成算法推荐给你: 738 | 739 | ```csharp 740 | public static string GenerateShortUrl() 741 | { 742 | string urlsafe = string.Empty; 743 | Enumerable.Range(48, 75) 744 | .Where(i => i < 58 || i > 64 && i < 91 || i > 96) 745 | .OrderBy(o => new Random().Next()) 746 | .ToList() 747 | .ForEach(i => urlsafe += Convert.ToChar(i)); 748 | string token = urlsafe.Substring(new Random().Next(0, urlsafe.Length), new Random().Next(2, 6)); 749 | 750 | return token; 751 | } 752 | ``` 753 | 754 | 如果你想了解更多关于如何创建短地址服务,这里有一份教程推荐给你:[Creating a Url Shortener Service From Scratch with .Net Core 3.0](https://blog.usejournal.com/creating-a-url-shortener-service-from-scratch-with-net-core-e8ebacad12c1) 755 | 756 | ### 后台服务 757 | 758 | > BACKGROUNDSERVICE 759 | 760 | 得益于 Asp.Net Core 框架的优越性,我们可以不需要安装任何外部依赖库就可以轻易实现一个功能强大且能长期运行的后台服务。 761 | 762 | 首先,创建一个继承自抽象类的 `BackgroundService` 实现类,然后实现里面的抽象方法 `ExecuteAsync` 即可,你可以参考下述方式: 763 | 764 | ```csharp 765 | public class NotificationService : BackgroundService 766 | { 767 | private readonly NotificationSettings _settings; 768 | private readonly ILogger _logger; 769 | public NotificationService(IOptions settings, ILogger logger) 770 | { 771 | _settings = settings.Value; 772 | _logger = logger; 773 | } 774 | 775 | protected override async Task ExecuteAsync(CancellationToken stoppingToken) 776 | { 777 | _logger.LogDebug($"GracePeriodManagerService is starting."); 778 | 779 | stoppingToken.Register(() => _logger.LogDebug($" GracePeriod background task is stopping.")); 780 | 781 | while (!stoppingToken.IsCancellationRequested) 782 | { 783 | _logger.LogDebug("{0},GracePeriod task doing background work.", new[] { DateTime.Now }); 784 | 785 | // do what you want 786 | 787 | await Task.Delay(_settings.CheckUpdateTime, stoppingToken); 788 | } 789 | } 790 | } 791 | ``` 792 | 793 | > 这里需要注意一点的是,如果我们在构造函数注入的对象具有一定的作用域,这个时候直接注入该对象会发生 `InvalidOperationException` 的异常,此时建议通过注入 `IServiceScopeFactory` 对象来创建作用域从而间接获取目标对象。 794 | 795 | 接着,在 `Startup` 类中的 `ConfigureServices` 进行相关配置,示例如下: 796 | 797 | ```csharp 798 | public void ConfigureServices(IServiceCollection services) 799 | { 800 | services.Configure(Configuration.GetSection(nameof(NotificationSettings))); 801 | services.AddHostedService(); 802 | } 803 | ``` 804 | 805 | 此时,我们就成功创建了一个可以长时间运行的后台服务。为了避免服务能及时在主进程退出时做相应处理,我们可以在 `Program` 类中进行如下配置: 806 | 807 | ```csharp 808 | public static IHostBuilder CreateHostBuilder(string[] args) => 809 | Host.CreateDefaultBuilder(args) 810 | .ConfigureWebHostDefaults(webBuilder => 811 | { 812 | webBuilder.UseShutdownTimeout(TimeSpan.FromSeconds(5)); 813 | webBuilder.UseStartup(); 814 | }); 815 | ``` 816 | 817 | 更多关于后台服务的部分,请查阅:[Background tasks with hosted services in ASP.NET Core](https://docs.microsoft.com/en-us/aspnet/core/fundamentals/host/hosted-services?view=aspnetcore-3.1&tabs=visual-studio) 818 | 819 | ### 输入验证 820 | 821 | 资源的输入验证可以采用多种方式,目前主要要如下方式: 822 | 823 | - 属性级别: 824 | - Data Annotations 825 | - 属性和对象级别: 826 | - IValidatableObject 827 | - ValidationAttribute 828 | 829 | ```csharp 830 | public abstract class ModelResource : IValidatableObject 831 | { 832 | [Display(Name = "名")] 833 | [Required(ErrorMessage = "{0} 是必填项")] 834 | [MaxLength(50, ErrorMessage = "{0} 的长度不能超过{1}")] 835 | public string FirstName { get; set; } 836 | [Display(Name = "姓")] 837 | [Required(ErrorMessage = "{0} 是必填项")] 838 | [MaxLength(50, ErrorMessage = "{0} 的长度不能超过{1}")] 839 | public string LastName { get; set; } 840 | 841 | public IEnumerable Validate(ValidationContext validationContext) 842 | { 843 | if (FirstName == LastName) 844 | { 845 | //yield return new ValidationResult("姓和名不能一样", new[] { nameof(EmployeeAddOrUpdateDto) }); 846 | yield return new ValidationResult("姓和名不能一样", new[] { nameof(FirstName), nameof(LastName) }); 847 | } 848 | } 849 | } 850 | 851 | public class CustomValidationAttribute:ValidationAttribute 852 | { 853 | protected override ValidationResult IsValid(object value, ValidationContext validationContext) 854 | { 855 | var model = (ModelResource)validationContext.ObjectInstance; 856 | 857 | if (model.LastName == model.FirstName) 858 | { 859 | return new ValidationResult(ErrorMessage, new[] { nameof(ModelResource) }); 860 | } 861 | return ValidationResult.Success; 862 | } 863 | } 864 | ``` 865 | 866 | 更多关于输入验证部分,请查阅:[Model validation in ASP.NET Core MVC and Razor Pages](https://docs.microsoft.com/en-us/aspnet/core/mvc/models/validation?view=aspnetcore-3.1) 867 | 868 | ### 缩略图 869 | 870 | 如果你想创建指定图片的缩略图,可以尝试用于 [System.Drawing.Common](https://github.com/dotnet/corefx) 来解决,示例代码如下所示: 871 | 872 | ```csharp 873 | Image image = Image.FromFile(sourceFile); 874 | Image thumb = image.GetThumbnailImage(300, 250, () => false, IntPtr.Zero); 875 | thumb.Save(thumbFile); 876 | ``` 877 | 878 | > 如果在 Linux 上运行,需要提前安装 _libgdiplus_ 库:`apt install libgdiplus` 879 | 880 | 如果想创建视频文件的缩略图,可以尝试使用 [Xabe.FFmpeg](https://xabe.net/product/xabe_ffmpeg/) 来解决,示例代码如下所示: 881 | 882 | ```csharp 883 | await Conversion.Snapshot(sourceFile, thumbFile, TimeSpan.FromSeconds(0)).Start(); 884 | ``` 885 | 886 | > 如果在 Linux 上运行,需要提前安装 _ffmpeg_ 库:`apt install ffmpeg` 887 | 888 | ### 读取 `app.config` 889 | 890 | 在 .NET 应用程序中,`app.config` 是一个特殊文件,它会在程序编译后自动转换为主程序同名的 `xxx.dll.config` 文件。我们可以通过使用 `System.Configuration.ConfigurationManager` 来读取其中内容。为了能正常读取,我们需要确保将该文件的 `build action` 设置为 `Content`, `copy to output directory` 设置为 `Copy if newer/Copy always`。在非主程序类型的项目中如果想读取该文件,我们需要在对应的 `xxx.csproj` 中添加如下配置: 891 | 892 | ``` 893 | 894 | 895 | 896 | 897 | ``` 898 | 899 | 更多内容可参考:[ConfigurationManager doesn't find config file with "dotnet test"](https://github.com/dotnet/runtime/issues/22720) 900 | 901 | ### 在集成测试中新增 API 902 | 903 | 如果我们现在集成测试中新增接口用于测试应用程序的一些全局性配置是否正确,可以考虑使用 `ApplicationParts` 方式来解决这个问题。如下述示例代码: 904 | 905 | ```csharp 906 | 907 | public class PayloadTestController : ApiController 908 | { 909 | [HttpPost("test")] 910 | public IActionResult Process([FromBody] Person person) 911 | { 912 | return Ok(person); 913 | } 914 | } 915 | 916 | client = new ApiTestFactory() 917 | .WithWebHostBuilder(builder => 918 | { 919 | builder.ConfigureServices(services => 920 | { 921 | var partManager = (ApplicationPartManager)services 922 | .Last(descriptor => descriptor.ServiceType == typeof(ApplicationPartManager)) 923 | .ImplementationInstance 924 | partManager.ApplicationParts.Add(new AssemblyPart(Assembly.GetAssembly(type(PayloadTestController)))); 925 | }); 926 | }).CreateClient(); 927 | ``` 928 | 929 | ### 在 `SwaggerUI` 中请求接口防止参数被自动转义 930 | 931 | 如果我们的后端应用集成了 `SwaggerUI` 的话,如果接口参数包含 `/` 时会被自动转义,这种行为和实际的接口请求不一致,为了解决这个问题,我们可以在 `Program.cs` 中使用如下配置: 932 | 933 | ```csharp 934 | if (app.Environment.IsDevelopment()) 935 | { 936 | app.UseSwagger(); 937 | app.UseSwaggerUI(options => 938 | { 939 | var request = "(request) => { console.log(request); request.url = decodeURIComponent(request.url); return request; }"; 940 | options.UseRequestInterceptor(request); 941 | }); 942 | } 943 | ``` 944 | 945 | 相关参考:[Prevent escaping html in path params? ](https://github.com/swagger-api/swagger-ui/issues/1637) 946 | 947 | ### SyndicationFeed 948 | 949 | ```csharp 950 | # write 951 | 952 | private static string Format(SyndicationFeed value) 953 | { 954 | var settings = new XmlWriterSettings 955 | { 956 | Encoding = Encoding.UTF8, 957 | NewLineHandling = NewLineHandling.Entitize, 958 | NewLineOnAttributes = true, 959 | }; 960 | using var stream = new MemoryStream(); 961 | using var xmlWriter = XmlWriter.Create(stream, settings); 962 | var rssFormatter = new Atom10FeedFormatter(value); 963 | rssFormatter.WriteTo(xmlWriter); 964 | xmlWriter.Flush(); 965 | return Encoding.UTF8.GetString(stream.ToArray()); 966 | } 967 | 968 | return Content(Format(feed), "application/atom+xml", Encoding.UTF8); 969 | 970 | # read 971 | private static SyndicationFeed ReadAsSyndicationFeed(ContentResult result) 972 | { 973 | var respBody = result.Content; 974 | if (respBody == null) 975 | return null; 976 | var bytes = Encoding.UTF8.GetBytes(respBody); 977 | using var stream = new MemoryStream(bytes); 978 | using var xmlReader = XmlReader.Create(stream); 979 | var formatter = new Atom10FeedFormatter(); 980 | formatter.ReadFrom(xmlReader); 981 | return formatter.Feed; 982 | } 983 | ``` 984 | 985 | ### MultipartFormDataContent 986 | 987 | 在 .NET Core 中,如果想对 MultipartFormDataContent 类型的数据进行处理,可以参考如下方式 988 | 989 | ```csharp 990 | # write 991 | 992 | using var memoryStream = new MemoryStream(); 993 | using (var archive = new ZipArchive(memoryStream, ZipArchiveMode.Create, true)) 994 | { 995 | var demoFile = archive.CreateEntry("foo.txt"); 996 | 997 | await using (var entryStream = demoFile.Open()) 998 | await using (var streamWriter = new StreamWriter(entryStream)) 999 | { 1000 | await streamWriter.WriteAsync("Bar!"); 1001 | } 1002 | } 1003 | 1004 | using var multipartContent = new MultipartFormDataContent(); 1005 | multipartContent.Add(new StreamContent(memoryStream), "zipArchive", "test.zip"); 1006 | multipartContent.Add(new StringContent("hello world", Encoding.UTF8, MediaTypeNames.Text.Plain), "content"); 1007 | 1008 | var request = new HttpRequestMessage(HttpMethod.Post, requestUri); 1009 | request.Content = multipartContent; 1010 | var response = await httpClient.SendAsync(request); 1011 | 1012 | # read 1013 | var dic = new Dictionary(); 1014 | var form = HttpContext.Request.Form; 1015 | var file = form.Files.FirstOrDefault(x => string.Equals(x.Name, "zipArchive")); 1016 | if (file != null) 1017 | { 1018 | dic.Add("zipArchive", new ZipArchive(file.OpenReadStream())); 1019 | } 1020 | 1021 | var firstName = form.FirstOrDefault(x => string.Equals(x.Key, "content", StringComparison.CurrentCultureIgnoreCase)); 1022 | if (firstName.Key != null) 1023 | { 1024 | dic.Add("content", firstName.Value); 1025 | } 1026 | 1027 | ``` 1028 | 1029 | ### NewtonsoftJson 1030 | 1031 | 由于 `.NET WebAPI` 和 `.NET Core API` 在处理请求时的 ModelBinder 逻辑有些差异(当类型不匹配时,.NET WebAPI 会兼容处理,但是在 .NET Core 中就会显示类型不匹配的问题)。当我们的接口是从 FX 迁移到 .NET Core 时,就需要考虑这种场景,可以添加如下配置,确保迁移过来的接口参数处理逻辑保持不变: 1032 | 1033 | ```csharp 1034 | services.AddControllers(options => 1035 | { 1036 | options.OutputFormatters.RemoveType(); 1037 | options.OutputFormatters.RemoveType(); 1038 | }).AddNewtonsoftJson(options => 1039 | { 1040 | #region this can be removed after upgrading to .NET 7 1041 | const string enableSkipHandledError = "Microsoft.AspNetCore.Mvc.NewtonsoftJson.EnableSkipHandledError"; 1042 | AppContext.SetSwitch(enableSkipHandledError, true); 1043 | #endregion 1044 | 1045 | options.SerializerSettings.Error += (sender, args) => 1046 | { 1047 | var errorCtx = args.ErrorContext.Error; 1048 | LogManager.GetLogger(nameof(Program)).Log(LogLevel.Error, errorCtx, $"can not process request body with incorrect type:{errorCtx.Message}"); 1049 | args.ErrorContext.Handled = true; 1050 | }; 1051 | options.AddSerializerSettings(); 1052 | }); 1053 | 1054 | ``` 1055 | 1056 | 更多信息可以参考这个 [issue](https://github.com/dotnet/aspnetcore/issues/37323) 1057 | 1058 | ## 总结 1059 | 1060 | 在这份指南中,我们的主要目的是让你熟悉关于使用 .NET Core 开发 web API 项目时的一些最佳实践。这里面的部分内容在其它框架中也同样适用。因此,熟练掌握它们很有用。 1061 | 1062 | 非常感谢你能阅读这份指南,希望它能对你有所帮助。 1063 | -------------------------------------------------------------------------------- /docs/ASPNET_Core_WEB_API_Best_Practices.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hippieZhou/ASP.NET-Core-Web-API-Best-Practices-Guide/99c8fe1d83c64fd8b291eef1dedfd737ddeeec46/docs/ASPNET_Core_WEB_API_Best_Practices.pdf -------------------------------------------------------------------------------- /images/01-Project-Structure.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hippieZhou/ASP.NET-Core-Web-API-Best-Practices-Guide/99c8fe1d83c64fd8b291eef1dedfd737ddeeec46/images/01-Project-Structure.png -------------------------------------------------------------------------------- /images/02-Appsettings-development.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hippieZhou/ASP.NET-Core-Web-API-Best-Practices-Guide/99c8fe1d83c64fd8b291eef1dedfd737ddeeec46/images/02-Appsettings-development.png -------------------------------------------------------------------------------- /images/03-Appsettings-production.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hippieZhou/ASP.NET-Core-Web-API-Best-Practices-Guide/99c8fe1d83c64fd8b291eef1dedfd737ddeeec46/images/03-Appsettings-production.png --------------------------------------------------------------------------------