ASP.NET Core Web API中的模型验证
ASP.NET Core Web API在一个Controller方法被调用前,是可以自动完成模型验证的。比如在上面的CreateAsync方法中,为什么我没有对“贴纸”的标题(Title)字段判空?而在这个API的返回状态定义中,却明确表示它有可能返回400?因为,在Sticker类的Title属性上,我使用了Required和StringLength这两个特性:| [Required] | |
| [StringLength(50)] | |
| public string Title { get; set; } = title; |
于是,在Sticker类被用于RESTful API的POST请求体(request body)时,ASP.NET Core Web API框架会自动根据这些特性来完成数据模型的验证,比如,在启动程序后,执行下面的命令:
| $ curl -X POST http://localhost:5141/stickers \ | |
| -d '{"content": "hell world!"}' \ | |
| -H 'Content-Type: application/json' \ | |
| -v && echo |
会得到下面的返回结果:
不仅如此,开发人员还可以扩展System.ComponentModel.DataAnnotations.ValidationAttribute来实现自定义的验证逻辑。
PUT还是PATCH?
在开发RESTful API时,有个比较纠结的问题是,在修改资源时,是应该用PUT还是PATCH?其实很简单,PUT的定义是:使用数据相同的另一个资源来替换已有资源,而PATCH则是针对某个已有资源进行修改。所以,单从修改对象的角度,PATCH要比PUT更高效,它不需要客户端将需要修改的对象整个性地下载下来,修改之后又整个性地发送到后端进行保存。于是,又产生另一个问题:服务端如何得知应该修改资源的哪个属性字段以及修改的方式是什么呢?一个比较直接的做法是,在服务端仍然接收来自客户端由PATCH方法发送过来的Sticker对象,然后判断这个对象中的每个字段的值是否有值,如果有,则表示客户端希望修改这个字段,否则就跳过这个字段的修改。如果对象结构比较简单,这种做法可能也还行,但是如果对象中包含了大量属性字段,或者它有一定的层次结构,那么这种做法就会显得比较笨拙,不仅费时费力,而且容易出错。在RESTful API的实现中,一个比较好的做法是采用JSON Patch,它是一套国际标准(RFC6902),它定义了JSON文档(JSON document)修改的基本格式和规范,而微软的ASP.NET Core Web API原生支持JSON Patch。以下是StickersController控制器中使用JSON Patch的方法:
| [HttpPatch("{id}")] | |
| [ProducesResponseType(StatusCodes.Status200OK)] | |
| [ProducesResponseType(StatusCodes.Status404NotFound)] | |
| [ProducesResponseType(StatusCodes.Status400BadRequest)] | |
| public async Task<IActionResult> UpdateStickerAsync(int id, [FromBody] JsonPatchDocument<Sticker>? patchDocument) | |
| { | |
| if (patchDocument is null) return BadRequest(); | |
| var sticker = await dac.GetByIdAsync<Sticker>(id); | |
| if (sticker is null) return NotFound(); | |
| sticker.ModifiedOn = DateTime.UtcNow; | |
| patchDocument.ApplyTo(sticker, ModelState); | |
| if (!ModelState.IsValid) return BadRequest(ModelState); | |
| await dac.UpdateAsync(id, sticker); | |
| return Ok(sticker); | |
| } |
代码逻辑很简单,首先通过Id获得“贴纸”对象,然后使用patchDocument.ApplyTo方法,将客户端的修改请求应用到贴纸对象上,然后调用SDAC更新后端存储中的数据,最后返回修改后的贴纸对象。让我们测试一下,首先新建一个贴纸:
| $ curl -X POST http://localhost:5141/stickers \ | |
| > -H 'Content-Type: application/json' \ | |
| > -d '{"title": "Hello", "content": "Hello daxnet"}' -v | |
| Note: Unnecessary use of -X or --request, POST is already inferred. | |
| * Host localhost:5141 was resolved. | |
| * IPv6: ::1 | |
| * IPv4: 127.0.0.1 | |
| * Trying [::1]:5141... | |
* Connected to localhost :1) port 5141 | |
| > POST /stickers HTTP/1.1 | |
| > Host: localhost:5141 | |
| > User-Agent: curl/8.5.0 | |
| > Accept: */* | |
| > Content-Type: application/json | |
| > Content-Length: 45 | |
| > | |
| < HTTP/1.1 201 Created | |
| < Content-Type: application/json; charset=utf-8 | |
| < Date: Sat, 12 Oct 2024 07:50:00 GMT | |
| < Server: Kestrel | |
| < Location: http://localhost:5141/stickers/1 | |
| < Transfer-Encoding: chunked | |
| < | |
| * Connection #0 to host localhost left intact | |
| {"id":1,"title":"Hello","content":"Hello daxnet","createdOn":"2024-10-12T07:50:00.9075598Z","modifiedOn":null} |
然后,查看这个贴纸的数据是否正确:
| $ curl http://localhost:5141/stickers/1 | jq | |
| % Total % Received % Xferd Average Speed Time Time Time Current | |
| Dload Upload Total Spent Left Speed | |
| 100 110 0 110 0 0 9650 0 --:--:-- --:--:-- --:--:-- 10000 | |
| { | |
| "id": 1, | |
| "title": "Hello", | |
| "content": "Hello daxnet", | |
| "createdOn": "2024-10-12T07:50:00.9075598Z", | |
| "modifiedOn": null | |
| } |
现在,使用PATCH方法,将content改为"Hello World":
| $ curl -X PATCH http://localhost:5141/stickers/1 \ | |
| > -H 'Content-Type: application/json-patch+json' \ | |
| > -d '[{"op": "replace", "path": "/content", "value": "Hello World"}]' -v && echo | |
| * Host localhost:5141 was resolved. | |
| * IPv6: ::1 | |
| * IPv4: 127.0.0.1 | |
| * Trying [::1]:5141... | |
* Connected to localhost :1) port 5141 | |
| > PATCH /stickers/1 HTTP/1.1 | |
| > Host: localhost:5141 | |
| > User-Agent: curl/8.5.0 | |
| > Accept: */* | |
| > Content-Type: application/json-patch+json | |
| > Content-Length: 63 | |
| > | |
| < HTTP/1.1 200 OK | |
| < Content-Type: application/json; charset=utf-8 | |
| < Date: Sat, 12 Oct 2024 07:56:04 GMT | |
| < Server: Kestrel | |
| < Transfer-Encoding: chunked | |
| < | |
| * Connection #0 to host localhost left intact | |
| {"id":1,"title":"Hello","content":"Hello World","createdOn":"2024-10-12T07:50:00.9075598Z","modifiedOn":"2024-10-12T07:56:04.815507Z"} |
注意上面命令中需要将Content-Type设置为application/json-patch+json,再执行一次GET请求验证一下:
| $ curl http://localhost:5141/stickers/1 | jq | |
| % Total % Received % Xferd Average Speed Time Time Time Current | |
| Dload Upload Total Spent Left Speed | |
| 100 134 0 134 0 0 43819 0 --:--:-- --:--:-- --:--:-- 44666 | |
| { | |
| "id": 1, | |
| "title": "Hello", | |
| "content": "Hello World", | |
| "createdOn": "2024-10-12T07:50:00.9075598Z", | |
| "modifiedOn": "2024-10-12T07:56:04.815507Z" | |
| } |
可以看到,content已经被改为了Hello World,同时modifiedOn字段也更新为了当前资源被更改的UTC时间。
在ASP.NET Core中使用JSON Patch还需要引入Newtonsoft JSON Input Formatter,请按照微软官方文档的步骤进行设置即可。在服务端如果需要存储时间信息,一般都应该保存为UTC时间,或者本地时间+时区信息,这样也能推断出UTC时间,总之,在服务端,应该以UTC时间作为标准,这样在不同时区的客户端就可以根据服务端返回的UTC时间来计算并显示本地时间,这样就不会出现混乱。
在分页查询API上支持排序字段表达式
在前端应用中,通常都可以支持用户自定义的数据排序,也就是用户可以自己决定是按数据的哪个字段以升序还是降序的顺序进行排序,然后基于这样的排序完成分页功能。其实实现的基本原理我已经在《在ASP.NET Core Web API上动态构建Lambda表达式实现指定字段的数据排序》一文中介绍过了,思路就是根据输入的字段名构建Lambda表达式,然后将Lambda表达式应用到对象列表的OrderBy/OrderByDescending方法,或者是应用到数据库访问组件上,以实现排序功能。下面就是StickersController控制器中的相关代码:| [HttpGet] | |
| [ProducesResponseType(StatusCodes.Status200OK)] | |
| public async Task<IActionResult> GetStickersAsync( | |
| [FromQuery(Name = "sort")] string? sortField = null, | |
| [FromQuery(Name = "asc")] bool ascending = true, | |
| [FromQuery(Name = "size")] int pageSize = 20, | |
| [FromQuery(Name = "page")] int pageNumber = 0) | |
| { | |
| Expression<Func<Sticker, object>> sortExpression = s => s.Id; | |
| if (sortField is not null) sortExpression = ConvertToExpression<Sticker, object>(sortField); | |
| return Ok( | |
| await dac.GetPaginatedEntitiesAsync(sortExpression, ascending, pageSize, pageNumber) | |
| ); | |
| } | |
| private static Expression<Func<TEntity, TProperty>> ConvertToExpression<TEntity, TProperty>(string propertyName) | |
| { | |
| if (string.IsNullOrWhiteSpace(propertyName)) | |
| throw new ArgumentNullException($"{nameof(propertyName)} cannot be null or empty."); | |
| var propertyInfo = typeof(TEntity).GetProperty(propertyName); | |
| if (propertyInfo is null) throw new ArgumentNullException($"Property {propertyName} is not defined."); | |
| var parameterExpression = Expression.Parameter(typeof(TEntity), "p"); | |
| var memberExpression = Expression.Property(parameterExpression, propertyInfo); | |
| if (propertyInfo.PropertyType.IsValueType) | |
| return Expression.Lambda<Func<TEntity, TProperty>>( | |
| Expression.Convert(memberExpression, typeof(object)), | |
| parameterExpression); | |
| return Expression.Lambda<Func<TEntity, TProperty>>(memberExpression, parameterExpression); | |
| } |
下面展示了根据Id字段进行降序排列的命令行以及API调用输出:
| $ curl 'http://localhost:5141/stickers?sort=Id&asc=false&size=20&page=0' | jq | |
| % Total % Received % Xferd Average Speed Time Time Time Current | |
| Dload Upload Total Spent Left Speed | |
| 100 453 0 453 0 0 205k 0 --:--:-- --:--:-- --:--:-- 221k | |
| { | |
| "items": [ | |
| { | |
| "id": 4, | |
| "title": "c", | |
| "content": "5", | |
| "createdOn": "2024-10-12T11:55:10.8708238Z", | |
| "modifiedOn": null | |
| }, | |
| { | |
| "id": 3, | |
| "title": "d", | |
| "content": "1", | |
| "createdOn": "2024-10-12T11:54:37.9055791Z", | |
| "modifiedOn": null | |
| }, | |
| { | |
| "id": 2, | |
| "title": "b", | |
| "content": "7", | |
| "createdOn": "2024-10-12T11:54:32.4162609Z", | |
| "modifiedOn": null | |
| }, | |
| { | |
| "id": 1, | |
| "title": "a", | |
| "content": "3", | |
| "createdOn": "2024-10-12T11:54:23.3103948Z", | |
| "modifiedOn": null | |
| } | |
| ], | |
| "pageIndex": 0, | |
| "pageSize": 20, | |
| "totalCount": 4, | |
| "totalPages": 1 | |
| } |
Tip:在URL中使用小写命名规范
由于C#编程规定对于标识符都使用Pascal命名规范,而ASP.NET Core Web API在产生URL时,是根据Controller和Action的名称来决定的,所以,在路径中都是默认使用Pascal命名规范,也就是第一个字符是大写字母。比如:http://localhost:5141/Stickers,其中“Stickers”的“S”就是大写。然而,实际中大多数情况下,都希望能够跟前端开发保持一致,也就是希望开头第一个字母是小写,比如像http://localhost:5141/stickers这样。ASP.NET Core Web API提供了解决方案,在Program.cs文件中加入如下代码即可:| builder.Services.AddRouting(options => | |
| { | |
| options.LowercaseUrls = true; | |
| options.LowercaseQueryStrings = true; | |
| }); |
Tip:让控制器方法支持Async后缀
在StickersController控制器中,我们使用了async/await来实现每个API方法,根据C#编程规范,异步方法应该以Async字样作为后缀,但如果这样做的话,那么在CreateAsync这个方法返回CreatedAtAction(nameof(GetByIdAsync), new { id }, sticker)时,就会报如下的错误:| System.InvalidOperationException: No route matches the supplied values. |
解决方案很简单,在Program.cs文件中,调用builder.Services.AddControllers();方法时,将它改为:
| builder.Services.AddControllers(options => | |
| { | |
| options.SuppressAsyncSuffixInActionNames = false; | |
| // 其它代码省略... | |
| }); |
至此,StickersController的基本部分已经完成了,启动整个项目,打开Swagger页面,就可以看到我们所开发的几个API。现在就可以直接在Swagger页面中调用这些方法来体验我们的Sticker微服务所提供的这些RESTful API了:
总结
本文介绍了我们案例中Sticker微服务的基本实现,包括数据访问部分和Sticker RESTful API的设计与实现,虽然目前我们只是使用一个InMemoryDataAccessor来模拟后端的数据存储,但Sticker微服务的基本功能都已经有了。然而,为了实现云原生,我们还需要向这个Sticker微服务加入一些与业务无关的东西,比如:加入日志功能以支持运行时问题的追踪和诊断;加入健康状态检测机制(health check)以支持服务状态监控和运行实例调度,此外还有RESTful API Swagger文档的完善、使用版本号和Git Hash来支持持续集成与持续部署等等,这些内容看起来挺简单,但也是需要花费一定的时间和精力来遵循标准的最佳实践。在我们真正完成了Sticker微服务后,我会使用独立的篇幅来介绍这些内容。此外,ASP.NET Core Web API的功能也不仅仅局限于我们目前用到的这些,由于我们的重点不在ASP.NET Core Web API本身的学习上,所以这里也只会涵盖用到的这些功能,对ASP.NET Core Web API整套体系知识结构感兴趣的读者,建议阅读微软官方文档。
下一讲我将介绍如何使用PostgreSQL作为Sticker微服务的数据库,从这一讲开始,我将逐步引入容器技术。
:1) port 5141