如何构建「大型 Node.js 项目」的项目结构?

8,622 阅读3分钟

项目结构是一个重要的主题,因为您引导应用程序的方式可以决定项目整个生命周期的整个开发体验。

在这个 Node.js 项目结构教程中,我将回答 RisingStack 关于构造高级 Node 应用程序的一些最常见的问题,并帮助您构建一个复杂的项目。

这些是我们的目标:

  • 编写易于扩展和维护的应用程序
  • 配置与业务逻辑完全分离
  • 单应用下包含多服务

Node.js 项目结构

我们的示例应用程序是「监听 Twitter 推文并跟踪某些关键字」。在关键字匹配的情况下,推文将被发送到 RabbitMQ 队列,该队列将被处理并保存到 Redis。我们还提供一个 REST API 用于访问持久化的推文。

你可以看看 GitHub 上的代码。该项目的文件结构如下所示

|-- config
| |-- components
| | |-- common.js
| | |-- logger.js
| | |-- rabbitmq.js
| | |-- redis.js
| | |-- server.js
| | `-- twitter.js
| |-- index.js
| |-- social-preprocessor-worker.js
| |-- twitter-stream-worker.js
| `-- web.js
|-- models
| |-- redis
| | |-- index.js
| | `-- redis.js
| |-- tortoise
| | |-- index.js
| | `-- tortoise.js
| `-- twitter
| |-- index.js
| `-- twitter.js
|-- scripts
|-- test
| `-- setup.js
|-- web
| |-- middleware
| | |-- index.js
| | `-- parseQuery.js
| |-- router
| | |-- api
| | | |-- tweets
| | | | |-- get.js
| | | | |-- get.spec.js
| | | | `-- index.js
| | | `-- index.js
| | `-- index.js
| |-- index.js
| `-- server.js
|-- worker
| |-- social-preprocessor
| | |-- index.js
| | `-- worker.js
| `-- twitter-stream
| |-- index.js
| `-- worker.js
|-- index.js
`-- package.json

在这个例子中,我们有3个进程:

  • twitter-stream-worker:该进程正在 Twitter 上侦听关键字并将推文发送到RabbitMQ 队列。
  • social-preprocessor-worker:该进程正在侦听 RabbitMQ 队列,并将推文保存到 Redis 并删除旧的。
  • web:该流程使用单个端点提供 REST API: GET /api/v1/tweetslimit&offset

我们将讨论 WebWorker 的区别,接下来让我们从配置开始.

如何处理不同的环境和配置?

从环境变量加载特定于您的部署的配置,并且永远不要将它们作为常量添加到代码库中。这些配置可以在部署和运行时环境之间有所不同,如CI,staging 或 production。基本上,你可以在任何地方运行相同的代码。

对于配置是否与应用正确分离的一个很好的验证方式是,代码库是否可以公开。这意味着可以防止意外泄漏秘钥。

如果代码库可以公开,那么您的配置与应用程序正确分离。

环境变量可以通过 process.env 对象访问。请记住,所有值都是字符串类型,因此您可能需要使用类型转换。

// config/config.js
'use strict'

// required environment variables
[
 'NODE_ENV',
 'PORT'
].forEach((name) => {
 if (!process.env[name]) {
   throw new Error(`Environment variable ${name} is missing`)
 }
})


const config = {
 env: process.env.NODE_ENV,
 logger: {
   level: process.env.LOG_LEVEL || 'info',
   enabled: process.env.BOOLEAN ? process.env.BOOLEAN.toLowerCase() === 'true' : false
 },
 server: {
   port: Number(process.env.PORT)
 }
 // ...
}

module.exports = config

配置校验

验证环境变量也是一个非常有用的技术。它可以帮助您在应用程序执行其他任何操作之前捕获启动时的配置错误。

这就是我们改进后的配置文件在使用joi验证器进行模式验证时的样子:

// config/config.js
'use strict'

const joi = require('joi')

const envVarsSchema = joi.object({
  NODE_ENV: joi.string()
    .allow(['development', 'production', 'test', 'provision'])
    .required(),
  PORT: joi.number()
    .required(),
  LOGGER_LEVEL: joi.string()
    .allow(['error', 'warn', 'info', 'verbose', 'debug', 'silly'])
    .default('info'),
  LOGGER_ENABLED: joi.boolean()
    .truthy('TRUE')
    .truthy('true')
    .falsy('FALSE')
    .falsy('false')
    .default(true)
}).unknown()
  .required()

const { error, value: envVars } = joi.validate(process.env, envVarsSchema)
if (error) {
  throw new Error(`Config validation error: ${error.message}`)
}

const config = {
  env: envVars.NODE_ENV,
  isTest: envVars.NODE_ENV === 'test',
  isDevelopment: envVars.NODE_ENV === 'development',
  logger: {
    level: envVars.LOGGER_LEVEL,
    enabled: envVars.LOGGER_ENABLED
  },
  server: {
    port: envVars.PORT
  }
  // ...
}

module.exports = config

配置拆分

通过组件拆分配置,是避免单个配置文件不断变大的一个很好的解决方案。

// config/components/logger.js
'use strict'

const joi = require('joi')

const envVarsSchema = joi.object({
  LOGGER_LEVEL: joi.string()
    .allow(['error', 'warn', 'info', 'verbose', 'debug', 'silly'])
    .default('info'),
  LOGGER_ENABLED: joi.boolean()
   .truthy('TRUE')
   .truthy('true')
   .falsy('FALSE')
   .falsy('false')
   .default(true)
}).unknown()
  .required()

const { error, value: envVars } = joi.validate(process.env, envVarsSchema)
if (error) {
  throw new Error(`Config validation error: ${error.message}`)
}

const config = {
  logger: {
    level: envVars.LOGGER_LEVEL,
    enabled: envVars.LOGGER_ENABLED
 }
}

module.exports = config

然后在 config.js 文件中,我们只需要组合这些组件。

// config/config.js
'use strict'

const common = require('./components/common')
const logger = require('./components/logger')
const redis = require('./components/redis')
const server = require('./components/server')

module.exports = Object.assign({}, common, logger, redis, server)

你不应该将你的配置分组到“环境”特定的文件中,比如用于生产的 config/production.js。 随着时间的推移,您的应用将扩展到更多环境,因此不能很好地扩展。

如何组织一个多进程应用程序?

这个过程是现代应用程序的主要组成部分。一个应用可以有多个无状态进程,就像我们的例子一样。 HTTP 请求可以由 Web 进程处理,并由工作人员处理长时间运行或预定的后台任务。 它们是无状态的,因为需要持久化的任何数据都存储在有状态的数据库中。 出于这个原因,添加更多并发进程非常简单。 这些过程可以根据负载或其他度量单独进行缩放。

在上一节中,我们看到了如何将配置根据组件来拆解。处理多个服务时,这将非常方便。 每种服务都可以有自己的配置,只处理它需要的组件配置。

config/index.js文件中:

// config/index.js
'use strict'

const processType = process.env.PROCESS_TYPE

let config
try {
  config = require(`./${processType}`)
} catch (ex) {
  if (ex.code === 'MODULE_NOT_FOUND') {
    throw new Error(`No config for process type: ${processType}`)
  }
  throw ex
}

module.exports = config

在根目录 index.js 文件中,我们启动使用 PROCESS_TYPE 环境变量选择的进程:

// index.js
'use strict'

const processType = process.env.PROCESS_TYPE

if (processType === 'web') {
  require('./web')
} else if (processType === 'twitter-stream-worker') {
  require('./worker/twitter-stream')
} else if (processType === 'social-preprocessor-worker') {
  require('./worker/social-preprocessor')
} else {
  throw new Error(`${processType} is an unsupported process type. Use one of: 'web', 'twitter-stream-worker', 'social-preprocessor-worker'!`)
}

关于这一点的好处是我们仍然只有一个应用程序,但我们已经支持将它分成多个独立的进程。 它们中的每一个都可以单独启动和扩展,而不会影响其他部分。 您可以在不牺牲 DRY(Dont repeat yourself) 代码库的情况下实现此目标,因为部分代码(如模型)可以在不同进程之间共享.

如何组织你的测试文件?

使用某种命名约定将测试文件放在测试模块旁边,如 <module_name>.spec.js<module_name>.e2e.spec.js。 您的测试应该与测试模块一起生活,保持同步。 当测试文件与业务逻辑完全分离时,很难找到并维护测试和相应的功能。

单独的测试文件夹可以容纳应用程序本身未使用的所有附加测试设置和实用程序。

何处放置构建和脚本文件?

我们倾向于创建一个 /scripts 文件夹,在这里我们放置用于数据库同步,前端构建脚本、bash 和 node 脚本。 此文件夹将它们与应用程序代码分开,并防止将太多脚本文件放入根目录。 将它们列在 npm 脚本中以便于使用。

原文地址

此为系列文章, 后续会持续更新

欢迎大家关注我们的官方公众号