当没有“标准方式”时 Express REST API 的项目结构

2025-06-04

当没有“标准方式”时 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
Enter fullscreen mode Exit fullscreen mode

从上面的代码可以看出,你的 中不应该包含任何逻辑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
}
Enter fullscreen mode Exit fullscreen mode

我认为controllers它们是“协调器”。它们被称为services,其中包含更“纯粹”的业务逻辑。但它们本身controllers除了处理请求和调用 之外,实际上不包含任何其他逻辑servicesservices负责大部分工作,而controllers负责协调服务调用并决定如何处理返回的数据。

如果还不明显的话,它们会接收从路由转发的 HTTP 请求,要么返回响应,要么继续执行调用链。它们也会将 HTTP 状态码作为响应的一部分进行处理。

为什么 Express/HTTP 上下文应该在这里结束

我经常看到的是,Expressreq对象(也就是我们的 HTTP “上下文”)会从 和routes传递controllersservices甚至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
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

在上面的代码中,我没有设置完整的数据库连接,而是直接用伪代码实现了,不过添加起来也很简单。像这样将逻辑隔离开来,就很容易将其限制在数据访问代码中。

如果不太明显,“数据访问层”指的是包含访问持久化数据的逻辑的层。这可以是数据库、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
}
Enter fullscreen mode Exit fullscreen mode

为了将所有内容联系在一起,我提供了一个示例入口点(通常名为app.jsserver.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
PREV
WSL2: Making Windows 10 the perfect dev machine!
NEXT
如何在 JavaScript 中以 Promise 形式和 async/await 形式重写回调函数