开发 API 架构
介绍
我很喜欢架构。如何构建应用程序非常重要,如果搞错了,以后肯定会付出惨痛代价。问题是,你往往直到为时已晚才意识到自己错了。我犯过很多次类似的错误,也反复尝试过很多解决方案,所以现在当我开始一个项目时,我很清楚自己想要如何构建它。我已经开发出了一种我认为非常稳健的应用程序开发模式。
本质上,我遵循一种松散的六边形架构模式。我不会过多地解释六边形架构是什么,因为已经有大量关于这个概念和想法的文章了。我只想展示我是如何将它运用到我自己的应用程序中。虽然有很多关于这种模式的文章,但它们很少以 Node 或前端应用程序(通常都是基于 Java 的)的形式进行讨论。
本文将重点介绍我的 Node 应用。我将单独介绍前端,因为尽管核心部分相似,但仍存在一些必要的区别。
我的代码库结构大致如下:
src
│
└───api
| └───route
| | get.ts
| | post.ts
| | delete.ts
|
└───application
| └───feature
| | usecase.ts
|
└───core
| | feature.ts
|
└───infrastructure
| └───feature
| | method.ts
|
└───domain
| | feature.ts
|
└───bootstrap
| setup.ts
这也称为端口和适配器:
- 应用程序=用例
- 核心 = 端口
- 基础设施=适配器
那么所有这些层意味着什么?
我画了一张图来说明这个应用如何融入六边形架构。可惜的是,画图不是我的强项,所以提前致歉:
这看起来和我见过的其他 HA 图没什么两样,除非你“看懂”它,否则我觉得它没什么用。我经常发现像这样表达数据流更容易:
这时你可能会想:“为什么一个请求需要这么多步骤?” 没错。在一个“基础”的 Express 应用中,你的流程可能更像这样:
但这样做的问题在于,你的应用程序在各个方面都高度耦合。这使得测试各个部分变得困难,你将应用程序逻辑与服务器绑定在一起,将数据库与应用程序逻辑绑定在一起,反过来,数据库又与服务器绑定在一起。良好软件设计的基本原则之一是分离你的关注点。
所以是的,这是更多的文件和更多的抽象层,但我保证这是一件好事!
让我们更深入地了解一下每个文件夹:
API
我的 API 层只包含 Express 路由,没有其他内容。你可以将此层视为 MVC 框架中的控制器。路由不包含任何逻辑,它们只是将请求数据传递到应用层,然后返回结果。这不仅保持了路由的精简,也使我的所有应用逻辑与交付方式无关。
async function(req: Request, res: Response) {
const basket = await getBasketUsecase(req.userId);
res.status(200).send(basket);
}
此文件夹的结构与快速路径相对应,例如:/src/api/basket/get.ts
相当于对 的 GET 请求/api/basket
。设置应用程序时,我会自动查找此文件夹中的所有文件并动态计算快速路由。这意味着我无需手动编写,app.get('/api/basket')
因为它只是推断出来的。这种自动路由在大型框架和类似 next.js 的项目中非常常见。我个人很喜欢它,感觉就像“魔术”一样,但又不会太过“幕后”。
应用
这些是我的用例。我的意思是,每个方法都是一个端到端的功能。例如,“获取购物篮”、“向购物篮添加商品”、“从购物篮中移除商品”。每个用例都会处理诸如验证输入、调用执行操作所需的方法、验证响应、将数据转换为输出类型等等。本质上,这就是应用程序的“编排”层。
async function usecase(args) {
await validateArgs(args);
const data = await fetchData(args);
const output = normalizeData(data);
await validateOutput(output);
return output;
}
API 层和应用层之间几乎总是 1:1 的关系。一个 API 端点只会调用一个用例,而一个用例很可能也只会被一个 API 端点使用。为什么不直接将它们合并成一个函数呢?松耦合。
例如,虽然我的服务器使用的是 Express,但我可能希望某些用例也能通过 CLI 访问。应用层并不关心请求是通过 Web API、CLI 还是其他方式发送的。它只关心接收到的参数。
应用程序层、核心层和基础设施层很难孤立地讨论(这很讽刺),所以接下来的几节会有点交织在一起……
核
那么,应用层实际上是如何“做事”的呢?比如,如果我们想获取购物篮,它该怎么做呢?我们不希望应用层直接导入数据库并进行查询,因为这会使我们的底层实现与高层用例耦合过紧。
核心层包含应用程序所有功能的接口。我所说的接口指的是TypeScript 接口,这里没有真正的 JavaScript,纯粹是类型和接口。
例如,如果我们想要得到篮子,就会有一个FetchBasket
类似这样的类型:
export type FetchBasket = (userId: string) => Promise<IBasket>;
我们的应用层纯粹基于这些接口进行操作,我们不会导入任何fetchBasket
函数。相反,我们从核心层导入接口,并使用依赖注入来表示“请获取此类型的实现”。依赖注入实际上是将这些层连接在一起的粘合剂。
例如,我们的获取篮子用例可能看起来像这样:
async function getBasketUsecase({ userId }) {
const fetchBasket = jpex.resolve<FetchBasket>();
const basket = await fetchBasket(userId);
return basket;
}
这意味着在应用层和底层实现细节之间架起了一座“桥梁”,这一点非常重要。上面的函数非常容易测试,因为其fetchBasket
实现并不存在,您可以提供任何您想要的实现。这也意味着您的用例非常简洁,因为所有繁琐的工作都被抽象出来了,您只需说“我想要这个类型的实现”,就可以了。
这样做的一个很棒的好处是,你可以先编写核心层,然后再编写用例层,甚至不用在之后再考虑基础设施层。这对于开发新功能来说非常实用,因为你知道用例是什么(“用户想要查看他们的购物篮”),也知道界面大概是什么样子(“将用户 ID 传递给数据库,然后返回购物篮”),但还不太确定具体实现细节。
基础设施
现在我们有了core
接口,基础架构层包含了它们的所有实现。本质上,任何会引起副作用或超出代码范围的操作(例如访问数据库)都属于基础架构。
有趣的是,基础架构方法可以依赖于其他核心接口,这意味着您可以拥有多个抽象级别。例如,fetchBasket
实现可能依赖于一个IDatabase
接口,而该接口又是对实际数据库的包装器。
如前所述,我使用依赖注入(特别是服务定位器模式)来注册这些基础设施方法:
jpex.factory<FetchBasket>((db: IDatabase) => (userId: string) => {
return db.collection("basket").find({ userId });
});
引导程序
bootstrap 文件夹甚至不是一个层,它的功能和你想象的一样。我们在应用启动时调用了 setup 函数。这会创建 Express 服务器,查找并注册所有 API 路由,查找并注册所有基础架构方法,连接数据库等等。
杂项
我还想添加/澄清几点:
-
值得一提的是,我遵循一种松散函数式编程范式。你不会看到任何服务/存储库类或类似的东西。所有东西都是依赖于其他函数的函数。我发现存储库类通常变得笨重、难以维护、依赖关系混乱,并且难以模拟。(此外,所有数据都被视为不可变的,但这对前端的影响远大于后端)
-
我还应该指出,虽然顶级文件夹不是“域”,但这仍然是领域驱动设计。我们只是首先对域的高层关注点进行了分组。你可以把它翻过来
domain/infrastructure/method.ts
,我也试过这种方法,但你几乎肯定会遇到这种格式不存在的跨域问题。
结论
这就是我后端架构的一次非常漫长(但说实话很简短)的旅程。虽然内容有点多,但我相信(并且经验丰富)这是一个非常简洁、可测试、可扩展的应用程序结构。
文章来源:https://dev.to/jackmellis/developing-an-api-architecture-4g2j