当没有“标准方式”时 Express REST API 的项目结构
最初发表于coreycleary.me。这篇文章来自我的内容博客。我每周或每两周发布一次新内容,如果您想直接在邮箱中收到我的文章,可以订阅我的新闻通讯!我还会定期发送速查表、其他开发者的精彩教程链接以及其他免费内容!
在 GitHub 或 Google 上搜索使用 Node + Express 的 REST API 结构,您会发现它们中很少有遵循相同组织的。
更糟糕的是,虽然市面上有很多优秀的教程,但很多都毫无结构。他们只是把所有内容塞进一个 200 行的 server.js 文件中,然后就完事了……
Node 的优点之一也可能是最痛苦的一点——真正的约定很少(如果有的话)。
当然,有一些推荐的做法。但它是一个非常灵活的平台,你通常可以选择任何一种做法,而且很可能行得通。
但即使没有约定,开发人员也想知道最佳的做事方式。而当谈到 REST API(以及一般的 Node 项目……)时,每个人似乎都觉得自己在构建项目时就像在盲目摸索!
没有“正确方法”
最终,我不相信Express 项目存在一个最佳的项目结构。
不要问:
“组织我的文件和文件夹的最佳方式是什么?”
我认为最好这样问:
“我的不同类型的逻辑应该放在什么地方?”
对于这个问题,有更明确的答案,以及我们可以遵循的事情。
通过有效地分层划分逻辑,项目结构自然会浮现。这种结构灵活,您可以根据自己的选择进行组织,无论是采用更传统的 MVC(或类似 MVC)还是更酷炫的新兴组件。因为这些结构本身就是分层的!您可以简单地将路由、控制器、服务等分组到组件文件夹中。
只要逻辑处于“正确”的位置,结构/组织就不再是什么大问题。
这样重构起来更容易,不用在奇怪的地方写逻辑;测试起来也更容易,不用在奇怪的地方写逻辑。而且,一旦代码被提交到源代码管理中,修改起来也很容易!想试试组件结构吗?修改起来也很容易!
“好的,我同意了...但是现在怎么办?”
关于本文范围的简要说明:显然,所有项目都可能包含许多不同的东西。为了让本文更容易理解,不至于让您感到不知所措,我将省略请求/模型验证和身份验证。这两个部分本身就比较复杂,但希望以后的文章能够详细介绍它们。
另外,本项目并非旨在成为一个“样板”项目,即复制代码库,npm start
在控制台中输入后即可立即获得一个功能齐全的应用程序。不过,如果您按照步骤操作,最终会得到一个可以运行的应用程序。但样板项目无法真正实现解释应用程序的层级、逻辑的放置位置以及如何在此基础上构建结构的目标。
由于我们处理的是 REST API,而 API 接收请求并返回响应,因此让我们从请求到达应用程序、遍历各个层级,到应用程序返回响应的点开始追踪。在此过程中,我们将弄清楚不同类型的逻辑应该放在哪里。
层?
你剥过洋葱吗?剥掉最外层后,下面还有好几层。
在这种情况下,“层”的含义大致相同,这意味着我们得到:
HTTP 层-- > 位于服务层“外部” --> 位于数据库访问层“外部” --> 也就是... 你明白了
逻辑在哪里?
我们将使用博客应用程序的示例来演示逻辑分离和最终的结构。
我提到的“逻辑类型”指的是 REST API 逻辑的两大“主要”类别:HTTP 逻辑和业务逻辑。当然,你可以根据需要进一步细分“逻辑类型”,但以上两大类别是主要的。
主要层 | 类型 | 这其中的逻辑是怎样的? |
---|---|---|
HTTP逻辑层 | 路线 + 控制器 | 路由 - 处理命中 API 的 HTTP 请求并将其路由到适当的控制器;控制器 - 获取请求对象,从请求中提取数据,验证,然后发送到服务 |
业务逻辑层 | 服务 + 数据访问 | 包含源自业务和技术需求的业务逻辑,以及我们如何访问数据存储** |
**数据访问层逻辑通常是更“技术性”的业务逻辑,我将其与业务逻辑分组,因为需求通常会驱动您需要编写的查询和需要生成的报告。
路线
const express = require('express')
const { blogpost } = require('../controllers')
const router = express.Router()
router.post('/blogpost', blogpost.postBlogpost)
module.exports = router
从上面的代码可以看出,你的 中不应该包含任何逻辑routes/routers
。它们应该只连接你的controller
函数(在本例中,我们只有一个)。所以routes
非常简单。导入你的控制器并将这些函数连接起来。
我通常每个路由只使用一个控制器,当然也有例外。如果你有一个处理身份验证的控制器,并且你的路由也需要身份验证,那么你显然也需要导入这个控制器并将其连接到你的路由上。
除非你有大量路由routes
,否则我通常会把它们都放在一个index.js
文件中。如果你有大量路由,可以将它们分别放在单独的路由文件中,然后将它们全部导入到一个index.js
文件中,再导出。
如果您想了解如何避免手动在每个单独的路由前面添加“/api”,请查看我写的另一篇文章。
控制器
const { blogService } = require('../services')
const { createBlogpost } = blogService
/*
* call other imported services, or same service but different functions here if you need to
*/
const postBlogpost = async (req, res, next) => {
const {user, content} = req.body
try {
await createBlogpost(user, content)
// other service call (or same service, different function can go here)
// i.e. - await generateBlogpostPreview()
res.sendStatus(201)
next()
} catch(e) {
console.log(e.message)
res.sendStatus(500) && next(error)
}
}
module.exports = {
postBlogpost
}
我认为controllers
它们是“协调器”。它们被称为services
,其中包含更“纯粹”的业务逻辑。但它们本身controllers
除了处理请求和调用 之外,实际上不包含任何其他逻辑services
。services
负责大部分工作,而controllers
负责协调服务调用并决定如何处理返回的数据。
如果还不明显的话,它们会接收从路由转发的 HTTP 请求,要么返回响应,要么继续执行调用链。它们也会将 HTTP 状态码作为响应的一部分进行处理。
为什么 Express/HTTP 上下文应该在这里结束
我经常看到的是,Expressreq
对象(也就是我们的 HTTP “上下文”)会从 和routes
传递controllers
到services
甚至database access layer
。但这样做的问题是,现在应用程序的其余部分不仅依赖于请求对象,还依赖于 Express。如果要更换框架,那么查找该req
对象的所有实例并删除它们会更加费力。
这也使得测试更加困难,并且无法实现我们在设计应用程序时所追求的关注点分离。
相反,如果您使用解构从中提取所需的数据req
,则可以直接将这些数据传递给服务。Express逻辑在控制器中就“结束”了。
如果你需要从某个服务调用外部 API,那也没问题,我们将在介绍具体逻辑时进一步讨论services
。但现在需要注意的是,这些调用是在应用程序的 HTTP 上下文之外进行的。
这样,我们就知道该把 REST API 处理的“初始”逻辑(路由 + 控制器)放在哪里了。接下来是业务逻辑层……
服务
const { blogpostDb } = require('../db')
/*
* if you need to make calls to additional tables, data stores (Redis, for example),
* or call an external endpoint as part of creating the blogpost, add them to this service
*/
const createBlogpost = async (user, content) => {
try {
return await blogpostDb(user, content)
} catch(e) {
throw new Error(e.message)
}
}
module.exports = {
createBlogpost
}
Services
应该包含大部分业务逻辑:封装业务需求、调用数据访问层或模型、调用 Node 应用程序外部 API 的逻辑。通常,包含大部分算法代码。
您当然也可以从内部调用外部 API controllers
,但请考虑一下该 API 是否返回应属于“单元”一部分的内容。Services
最终应该返回一个有凝聚力的资源,因此如果需要该外部 API 调用返回的内容来增强您的业务逻辑,请将逻辑保留在那里。
例如,如果创建博客文章的一部分也是将链接发布到 Twitter(外部 API 调用),那么您可以将其放入上述服务中。
controllers
如果这项服务只做这些,为什么不直接从中调用模型/数据层呢?
虽然我们上面的例子很简单,因为它所做的就是通过我们的数据访问层功能访问数据库 -blogpostDb
但随着更多业务需求的增加,您添加 Twitter API 调用,需求发生变化等,它会很快变得复杂。
如果您的控制器处理所有这些逻辑,再加上它已经处理的请求处理逻辑,那么测试起来会非常困难,而且很快就会变得非常困难。记住,控制器可以调用多个不同的服务。因此,如果您将所有逻辑从其他服务中抽离出来,并将它们放在同一个控制器中,它会变得更加难以管理。最终,您会陷入可怕的“胖控制器”噩梦。
数据访问层/模型
const blogpostDb = (user, content) => {
/*
* put code to call database here
* this can be either an ORM model or code to call the database through a driver or querybuilder
* i.e.-
INSERT INTO blogposts (user_name, blogpost_body)
VALUES (user, content);
*/
return 1 //just a dummy return as we aren't calling db right now
}
module.exports = {
blogpostDb
}
在上面的代码中,我没有设置完整的数据库连接,而是直接用伪代码实现了,不过添加起来也很简单。像这样将逻辑隔离开来,就很容易将其限制在数据访问代码中。
如果不太明显,“数据访问层”指的是包含访问持久化数据的逻辑的层。这可以是数据库、Redis 服务器、Elasticsearch 等等。所以,每当你需要访问这些数据时,就把逻辑放在这里。
“模型”是相同的概念,但用作 ORM 的一部分。
尽管两者不同,但它们包含相同类型的逻辑,因此我建议将它们放在同一个db
文件夹中,以使其足够通用。无论您使用的是 ORM 模型,还是使用查询构建器或原始 SQL,都可以将逻辑放在那里,而无需更改目录名称。
实用程序
我们将要介绍的最后一类逻辑是通用逻辑函数,它们不一定特定于您的业务逻辑或领域,甚至不一定是 REST API 的通用函数。实用程序函数的一个很好的例子是将毫秒转换为分钟和/或秒的函数,或者检查两个数组是否包含相似项的函数。这些函数足够通用,并且可重用性足够高,因此应该放在它们自己的文件夹中。
我推荐的方法是把这些都放到一个index.js
文件中,然后导出每个函数。我之所以就这样处理,是因为它们对项目结构的其余部分没有太大影响。
app.js / server.js
const express = require('express')
const bodyParser = require('body-parser')
const app = express()
const routes = require('./routes')
app.use(bodyParser.json());
app.use(bodyParser.urlencoded({ extended: true }));
app.get('/', (req, res) => res.send('App is working'))
app.use('/api', routes)
app.listen(3000, () => console.log('Example app listening on port 3000!'))
module.exports = {
app
}
为了将所有内容联系在一起,我提供了一个示例入口点(通常名为app.js
或server.js
),它位于项目结构的根目录中。您可以在此处添加中间件(例如bodyParser
)并导入您的路由文件。
现在,出现的结构
当你像我在本文中描述的那样分离逻辑时,结构似乎会像上面那样“自然”地形成。这就是为什么我喜欢在 Express 应用程序中像这样分离逻辑,这样很容易弄清楚应该把东西放在哪里!
当然,您可以根据需要在结构中添加更多目录(例如config
,一个文件夹)。但这是一个很好的起点,如果您按照描述的方式分离逻辑,那么 90% 的代码都会落入这些文件夹中。
最后但同样重要的是,测试!
既然我们已经介绍了遵循此模式的结构,那么有必要指出测试应该放在哪里。我认为这不是一个严格的规则,但我倾向于将测试放在一个根文件tests
夹中,并模仿应用程序其余部分的结构。
如果你注意到了,routes
它不见了!这是因为如果你像我一样把逻辑分离出来,你实际上不需要测试路由。supertest
如果你愿意,你可以使用类似的方法,但核心逻辑——那些更容易因代码更改而崩溃的东西!——已经在你的控制器、服务等中测试过了。
或者,您也可以在每个“层”文件夹中添加一个测试文件夹,即控制器内的测试目录、服务内的测试目录等。这仅取决于偏好,不必为此烦恼。
另外,有些开发人员喜欢按单元测试和集成测试来区分测试目录。我的想法是,如果你的应用程序划分清晰,并且有大量集成测试,那么将它们分开可能是个好主意。但我通常将它们放在同一个目录中。
总结
正如我在文章开头所说,我不认为存在“最佳结构”。确保将逻辑划分成不同的角色会更有帮助。这样做不仅能让你自然而然地拥有结构,还能提供所需的灵活性,方便你日后轻松进行更改。
所以,如果你正在启动一个新的 Express 项目,却还在纠结于创建哪些文件夹、如何命名、文件夹里应该放什么内容,又或者你正在重构一个现有的 Express 应用,不妨试试我这里介绍的方法,它能帮你摆脱困境,顺利启动项目。之后就不用再为这些烦恼了。
请记住,只要您的逻辑分离合理,您以后可以随时更改它!
还有一件事!
我能讲的内容有限,以免让人不知所措,让你很快关掉窗口。我很快会补充一些关于结构/逻辑分离的文章。
如果您想直接通过电子邮件收到这些额外的文章,请点击这里订阅我的新闻通讯!我每隔一两周都会发送新文章,此外还有速查表、快速技巧等等。
文章来源:https://dev.to/ccleary00/project-struct-for-an-express-rest-api-when-there-is-no-standard-way-4gk3