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

  • 主题发起人 主题发起人 Scare
  • 开始时间 开始时间

Scare

0xFF|主权幽灵
07
908
172
奇源币
0
管理成员
工作人员
版主
VIP

引言:应该使用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相关的对象及其之间的关系大致会是下面这个样子:

119825-20241011211239771-1566953309.png


首先,定义一个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很好理解,它指定是否按升序排序;pageSize和pageNumber指定分页时每页的数据记录条数以及需要返回的数据页码;通过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能够生成更为详细的文档:

119825-20241012113220959-597245567.png
 
后退
顶部