.NET云原生应用实践(二):Sticker微服务RESTful API的实现

本章目标

  1. 完成数据访问层的基本设计
  2. 实现Sticker微服务的RESTful API

引言:应该使用ORM框架吗?

毋庸置疑,Sticker微服务需要访问数据库来管理"贴纸"(也就是"Sticker"),因此,以什么方式来存储数据,就是一个无法绕开的话题。如果你遵循领域驱动设计的思想,那么你可以说,保存到数据库的数据,就是"贴纸"聚合 在持久化到仓储后的一种对象状态。那现在的问题是,我们需要遵循领域驱动设计的思想吗?

在目前的Sticker微服务的设计与实现中,我想暂时应该是不需要的,主要原因是,这里的业务并不复杂,至少在Sticker微服务的Bounded Context中,它主要关注Sticker对象,并且这个对象的行为也非常简单,甚至可以把它看作是一个简单的数据传输对象 (DTO),其作用仅仅是以结构化的方式来保存"贴纸"的数据。你或许会有疑问,那今后如果业务扩展了,是否还是会考虑引入一些领域驱动设计的实现思路甚至是相关的设计模式?我觉得答案是:有可能,但就Sticker微服务而言,除非有比较复杂的业务功能需要实现,否则,继续保持Sticker微服务的简单轻量,或许是一个更好的选择。在差不多3年前,我总结过一篇文章:《何时使用领域驱动设计》,对于领域驱动设计相关的内容做了总结归纳,有兴趣的读者欢迎移步阅读。

所以,目前我们会从"数据传输对象"的角度来看待"贴纸"对象,而不会将其看成是一个聚合。由于我们后端将选择PostgreSQL作为数据库,它是一个关系型数据库,所以回到标题上的问题:应该使用ORM框架吗?我觉得也没有必要,因为我们并不打算从业务对象的角度来处理"贴纸","贴纸"本身的对象结构也非常简单,可能也只有一些属性字段,或许直接使用ADO.NET会更为轻量,即使"贴纸"对象包含简单的层次结构,使用ADO.NET实现也不会特别麻烦。而另一方面,ORM的使用是有一定成本的,不仅仅是在代码执行效率上,在ORM配置、代码编程模型、模型映射、数据库初始化以及模型版本迁移等方面,都会有一些额外的开销,对于Sticker微服务而言,确实没有太大的必要。

总结起来,目前我们不会引入太多的领域驱动设计思想,也不会使用某个ORM框架来做数据持久化,而是会设计一个相对简单的数据访问层,并结合ADO.NET来实现Sticker微服务的数据访问。这个层面的接口定义好,今后如果业务逻辑扩展了,模型对象复杂了,希望能够再引入ORM,也不是不可能的事情。

数据访问层的基本设计

在Sticker微服务中,我引入了一种称之为"简单数据访问器(SDAC,Simplified Data ACcessor)"的东西,通过它可以为调用者提供针对业务实体对象的增删改查的能力。具体地说,它至少会包含如下这些方法:

  1. 将给定的实体对象保存到数据库(增)
  2. 将给定的实体对象从数据库中删除(删)
  3. 更新数据库中的实体(改)
  4. 根据实体的ID来获取实体对象(查)
  5. 根据给定的分页方式和过滤条件,返回满足该过滤条件的某一页的实体(查)
  6. 根据给定的过滤条件,返回满足该过滤条件的实体是否存在(查)

在后面的Sticker微服务API的实现中,就会使用这个SDAC来访问后端数据库,以实现对"贴纸"的管理。根据上面的分析,不难挖掘一个技术需求,就是在今后有可能需要引入ORM来实现数据访问,虽然短期内我们不会这样做,但是在一开始的时候,定好设计的大方向,始终是一个比较好的做法。于是,也就引出了SDAC设计的一个基本思路:把接口定义好,然后基于PostgreSQL实现SDAC,之后在ASP.NET Core Web API中,使用依赖注入,将PostgreSQL的实现注入到框架中,于是,API控制器只需要依赖SDAC的接口即可,今后替换不同的实现方式的时候,也会更加方便。

在本章节我们不做PostgreSQL的实现,这个内容留在下一讲介绍,在本章节中,我们仅基于内存中的列表数据结构来实现一个简单的SDAC,因为本章讨论的重点其实是Sticker微服务中的API实现。很明显,这也得益于面向接口的抽象设计思想。总结起来,SDAC相关的对象及其之间的关系大致会是下面这个样子:

首先,定义一个ISimplifiedDataAccessor接口,这个接口被放在了一个独立的包(.NET中的Assembly)Stickers.Common下,这个接口定义了一套CRUD的基本方法,在另一个独立的包Stickers.DataAccess.InMemory中,有一个实现了该接口的类:InMemoryDataAccessor,它包含了一个IEntity实体的列表数据结构,然后基于这个列表,实现了ISimplifiedDataAccessor下的所有方法。而Stickers.WebApi中的API控制器StickersController则依赖ISimplifiedDataAccessor接口,并由ASP.NET Core的依赖注入框架将InMemoryDataAccessor的实例注入到控制器中。

为了构图美观,类图中所有方法的参数和返回类型都进行了简化,在案例的代码中,各个方法的参数和返回类型都比图中所示稍许复杂一些。

这里我们引入了IEntity接口,所有能够通过SDAC进行数据访问的数据对象,都需要实现这个接口。引入该接口的一个重要目的是为了实现泛型约束,以便可以在ISimplifiedDataAccessor接口上明确指定什么样的对象才可以被用于数据访问。另外,这里还引入了一个泛型类型:Paginated<TEntity>类型,它可以包含分页信息,并且其中的Items属性保存的是某一页的数据(页码由PageIndex属性指定),因为在StickersController控制器中,我们大概率会需要实现能够支持分页的"贴纸"查询功能。

限于篇幅,就不对InMemoryDataAccessor中的每个方法的具体实现进行介绍了,有兴趣的话可以打开本文最后贴出的源代码链接,直接打开代码阅读。这里着重解读一下GetPaginatedEntitiesAsync方法的代码:

复制代码
public Task<Paginated<TEntity>> GetPaginatedEntitiesAsync<TEntity, TField>(
    Expression<Func<TEntity, TField>> orderByExpression, bool sortAscending = true, int pageSize = 25,
    int pageNumber = 0, Expression<Func<TEntity, bool>>? filterExpression = null,
    CancellationToken cancellationToken = default) where TEntity : class, IEntity
{
    var resultSet = filterExpression is not null
        ? _entities.Cast<TEntity>().Where(filterExpression.Compile())
        : _entities.Cast<TEntity>();
    var enumerableResultSet = resultSet.ToList();
    var totalCount = enumerableResultSet.Count;
    var orderedResultSet = sortAscending
        ? enumerableResultSet.OrderBy(orderByExpression.Compile())
        : enumerableResultSet.OrderByDescending(orderByExpression.Compile());
    return Task.FromResult(new Paginated<TEntity>
    {
        Items = orderedResultSet.Skip(pageNumber * pageSize).Take(pageSize).ToList(),
        PageIndex = pageNumber,
        PageSize = pageSize,
        TotalCount = totalCount,
        TotalPages = (totalCount + pageSize - 1) / pageSize
    });
}

这个方法的目的就是为了返回某一页的实体数据,首先分页是需要基于排序的,因此,orderByExpression参数通过Lambda表达式来指定排序的字段;sortAscending很好理解,它指定是否按升序排序;pageSizepageNumber指定分页时每页的数据记录条数以及需要返回的数据页码;通过filterExpression Lambda表达式参数,还可以指定查询过滤条件,比如,只返回"创建日期"大于某一天的数据。在InMemoryDataAccessor中,都是直接对列表数据结构进行操作,所以这个函数的实现还是比较简单易懂的:如果filterExpression有定义,则首先执行过滤操作,然后再进行排序,并构建Paginated<TEntity>对象作为函数的返回值。在下一篇文章介绍PostgreSQL数据访问的实现时,我们还会看到这个函数的另一个不同的实现。

在接口定义上,GetPaginatedEntitiesAsync是一个异步方法,所以,我们应该尽可能地传入CancellationToken对象,以便在该方法中能够支持取消操作。

现在我们已经有了数据访问层,就可以开始实现Sticker微服务的RESTful API了。

StickersController控制器

我们是使用ASP.NET Core Web API创建的StickersController控制器,所以也会默认使用RESTful来实现微服务的API,RESTful API基于HTTP协议,是目前微服务间通信使用最为广泛的协议之一,由于它主要基于JSON数据格式,因此对前端开发和实现也是特别友好。RESTful下对于被访问的数据统一看成资源,是资源就有地址、所支持的访问方式等属性,不过这里我们就不深入讨论这些内容了,重点讲一下StickersController实现的几个要点。

ISimplifiedDataAccessor的注入

熟悉ASP.NET Core Web API开发的读者,对于如何注入一个Service应该是非常熟悉的,这里就简单介绍下吧。在Stickers.Api项目的Program.cs文件里,直接加入下面这行代码即可,注意加之前,先向项目添加对Stickers.DataAccess.InMemory项目的引用:

复制代码
builder.Services.AddSingleton<ISimplifiedDataAccessor, InMemoryDataAccessor>();

在这里,我将InMemoryDataAccessor注册为单例实例,虽然它是一个有状态的对象,但使用它的目的也仅仅是让整个应用程序能够运行起来,后面是会用PostgreSQL进行替换的(PostgreSQL的数据访问层是无状态的,因此在这里使用单例是合理的),所以在这里并不需要纠结它本身的实现是否合理、在单例下是否是线程安全。高内聚低耦合的设计原则,让问题变得更为简单。

现在将Stickers.Api项目下的WeatherForecastController删掉,然后新加一个Controller,命名为StickersController,基本代码结构如下:

复制代码
namespace Stickers.WebApi.Controllers;

[ApiController]
[Route("[controller]")]
public class StickersController(ISimplifiedDataAccessor dac) : ControllerBase
{
    // 其它代码暂时省略
}

于是就可以在StickersController控制器中,通过dac实例来访问数据存储了。

控制器代码的可测试性:由于StickersController仅依赖ISimplifiedDataAccessor接口,因此,在进行单元测试时,完全可以通过Mock技术,生成一个ISimplifiedDataAccessor的Mock对象,然后将其注入到StickersController中完成单元测试。

在控制器方法中返回合理的HTTP状态码

对于不同的RESTful API,在不同的情况下应该返回合理的HTTP状态码,这是RESTful API开发的一种最佳实践。尤其是在微服务架构下,合理定义API的返回代码,对于多服务集成是有好处的。我认为可以遵循以下几个原则:

  1. 尽量避免直接返回500 Internal Server Error
  2. 由于客户端传入数据不符合要求而造成API无法顺利执行,应该返回以"4"开头的状态码(4XX),比如:
    1. 如果客户端发出资源查询请求,但实际上这个资源并不存在,则返回404 Not Found
    2. 如果希望创建的资源已经存在,可以返回409 Conflict
    3. 如果客户端传入的资源中的某些数据存在问题,可以返回400 Bad Request
  3. POST方法一般用于资源的新建,所以通常返回201 Created,并在返回体(response body)中,指定新创建资源的地址。当然,也有些情况下POST并不是用来创建新的资源,而是用来执行某个任务,此时也可以用200 OK或者204 No Content返回
  4. PUT、PATCH、DELETE方法,根据是否需要返回资源数据,来决定是应该返回200 OK还是204 No Content

以下面三个RESTful API方法为例:

复制代码
 [HttpGet("{id}")]
 [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(Sticker))]
 [ProducesResponseType(StatusCodes.Status404NotFound)]
 public async Task<IActionResult> GetByIdAsync(int id)
 {
     var sticker = await dac.GetByIdAsync<Sticker>(id);
     if (sticker is null) return NotFound($"Sticker with id {id} was not found.");
     return Ok(sticker);
 }

 [HttpPost]
 [ProducesResponseType(StatusCodes.Status201Created)]
 [ProducesResponseType(StatusCodes.Status409Conflict)]
 [ProducesResponseType(StatusCodes.Status400BadRequest)]
 public async Task<IActionResult> CreateAsync(Sticker sticker)
 {
     var exists = await dac.ExistsAsync<Sticker>(s => s.Title == sticker.Title);
     if (exists) return Conflict($"Sticker {sticker.Title} already exists.");
     var id = await dac.AddAsync(sticker);
     return CreatedAtAction(nameof(GetByIdAsync), new { id }, sticker);
 }

 [HttpDelete("{id}")]
 [ProducesResponseType(StatusCodes.Status204NoContent)]
 [ProducesResponseType(StatusCodes.Status404NotFound)]
 public async Task<IActionResult> DeleteByIdAsync(int id)
 {
     var result = await dac.RemoveByIdAsync<Sticker>(id);
     if (!result) return NotFound($"Sticker with id {id} was not found.");
     return NoContent();
 }

这几个方法都用到了Sticker类,这个类代表了"贴纸"对象,它其实是一个领域对象,但正如上文所说,目前我们仅将其用作数据传输对象,它的定义如下:

复制代码
public class Sticker(string title, string content) : IEntity
{
    public int Id { get; set; }

    [Required]
    [StringLength(50)]
    public string Title { get; set; } = title;

    public string Content { get; set; } = content;

    public DateTime CreatedOn { get; set; } = DateTime.UtcNow;
    
    public DateTime? ModifiedOn { get; set; }
}

Sticker类实现了IEntity接口,它是Stickers.WebApi项目中的一个类,它被定义在了Stickers.WebApi项目中,而不是定义在Stickers.Common项目中,是因为从Bounded Context的划分角度,它是Stickers.WebApi项目的一个内部业务对象,并不会被其它微服务所使用。

CreateAsync方法中,它会首先判断相同标题的"贴纸"是否存在,如果存在,则返回409;否则就直接创建贴纸,并返回201,同时带上创建成功后"贴纸"资源的地址(CreatedAtAction方法表示,资源创建成功,可以通过GetByIdAsync方法所在的HTTP路径,带上新建"贴纸"资源的Id来访问到该资源)。而在DeleteByIdAsync方法中,API会直接尝试删除指定Id的"贴纸",如果贴纸不存在,则返回404,否则就是成功删除,返回204。

顺便提一下在各个方法上所使用的ProducesResponseType特性,一般我们可以将当前API方法能够返回的HTTP状态码都用这个特性(Attribute)标注一下,以便Swagger能够生成更为详细的文档:

ASP.NET Core Web API中的模型验证

ASP.NET Core Web API在一个Controller方法被调用前,是可以自动完成模型验证的。比如在上面的CreateAsync方法中,为什么我没有对"贴纸"的标题(Title)字段判空?而在这个API的返回状态定义中,却明确表示它有可能返回400?因为,在Sticker类的Title属性上,我使用了RequiredStringLength这两个特性:

复制代码
[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时间。

在服务端如果需要存储时间信息,一般都应该保存为UTC时间,或者本地时间+时区信息,这样也能推断出UTC时间,总之,在服务端,应该以UTC时间作为标准,这样在不同时区的客户端就可以根据服务端返回的UTC时间来计算并显示本地时间,这样就不会出现混乱。

在ASP.NET Core中使用JSON Patch还需要引入Newtonsoft JSON Input Formatter,请按照微软官方文档的步骤进行设置即可。

在分页查询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/**S** tickers,其中"S tickers"的"S"就是大写。然而,实际中大多数情况下,都希望能够跟前端开发保持一致,也就是希望开头第一个字母是小写,比如像http://localhost:5141/**s** tickers这样。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微服务的数据库,从这一讲开始,我将逐步引入容器技术。

源代码

本章源代码请参考这里:https://gitee.com/daxnet/stickers/tree/chapter_2/

对代码有任何问题欢迎留言讨论。

相关推荐
专注VB编程开发20年1 小时前
C#全面超越JAVA,主要还是跨平台用的人少
java·c#·.net·跨平台
一个帅气昵称啊11 小时前
.Net通过EFCore和仓储模式实现统一数据权限管控并且相关权限配置动态生成
.net·efcore·仓储模式
helloworddm13 小时前
CalculateGrainDirectoryPartition
服务器·c#·.net
步步为营DotNet14 小时前
深度剖析.NET中HttpClient的请求重试机制:可靠性提升与实践优化
开发语言·php·.net
ChaITSimpleLove15 小时前
使用 .net10 构建 AI 友好的 RSS 订阅机器人
人工智能·.net·mcp·ai bot·rss bot
专注VB编程开发20年15 小时前
vb.net宿主程序通过统一接口直接调用,命名空间要一致
服务器·前端·.net
ChaITSimpleLove1 天前
基于 .NET Garnet 1.0.91 实现高性能分布式锁(使用 Lua 脚本)
分布式·.net·lua
用户4488466710602 天前
.NET进阶——深入理解线程(2)Thread入门到精通
c#·.net
一个帅气昵称啊2 天前
.Net——AI智能体开发基于 Microsoft Agent Framework 实现第三方聊天历史存储
人工智能·microsoft·.net