重新思考流行的 Node.js 模式和工具

Node.js 正在成熟。许多模式和框架被采用 - 我相信在过去的几年里,开发人员的生产力显著提高了。成熟的一个缺点是习惯 - 我们现在更经常地重用现有的技术。这为什么会成为问题呢?

为了让Node.js活得更久,我们需要鼓励批评,把我们的忠诚集中在创新上,并让讨论继续下去。讨论的结果不是“不要使用这个工具!”而是熟悉在某些情况下可能更适合的其他技术

1. Dotenv 作为您的配置来源

一种超级流行的技术,其中应用程序的可配置值(例如 DB 用户名)存储在一个简单的文本文件中。然后,当应用程序加载时,dotenv 库将所有文本文件值设置为环境变量,以便代码可以读取它们

1
2
3
4
5
6
7
8
9
10
// .env file
USER_SERVICE_URL=https://users.myorg.com

//start.js
require('dotenv').config();

//blog-post-service.js
repository.savePost(post);
//update the user number of posts, read the users service URL from an environment variable
await axios.put(`${process.env.USER_SERVICE_URL}/api/user/${post.userId}/incrementPosts`)

📊 有多受欢迎: 每周下载量 21,806,137 次!

  • 🤔 为什么可能是错误的: Dotenv 是如此易于上手和直观,因此人们可能很容易忽视基本功能: 例如,很难推断配置模式并意识到每个键及其类型的含义。因此,在必需的键丢失时,没有内置的快速失败方式 - 流可能在启动后失败,并呈现一些副作用(例如,在故障之前已经改变了 DB 记录)。在上面的示例中,博客文章将保存到数据库中,只有在代码意识到缺少必需的键之后才会这样做 - 这使应用程序处于无效状态。除此之外,在存在许多键的情况下,无法按层次结构组织它们。如果不够的话,它还会鼓励开发人员提交包含生产值的 .env 文件 - 这是因为没有明确的方法来定义开发默认值。团队通常通过提交 .env.example 文件来解决此问题,然后要求拉取代码的人手动重命名此文件。如果他们记得的话

  • ☀️ 更好的替代方案: 一些配置库提供了所有这些需求的开箱即用解决方案。它们鼓励清晰的模式,并可能在需要时提前验证并失败。查看这里的选项比较。更好的替代方案之一是 'convict',下面是相同的示例,这次使用 Convict,希望现在更好了:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    // config.js
    export default {
    userService: {
    url: {
    // 分层,文档化且强类型化 👇
    doc: "用户管理服务的 URL,包括尾部斜杠",
    format: "url",
    default: "http://localhost:4001",
    nullable: false,
    env: "USER_SERVICE_URL",
    },
    },
    //more keys here
    };

    //start.js
    import convict from "convict";
    import configSchema from "config";
    convict(configSchema);
    // Fail fast!
    convictConfigurationProvider.validate();

    //blog-post.js
    repository.savePost(post);
    // Will never arrive here if the URL is not set
    await axios.put(
    `${convict.get(userService.url)}/api/user/${post.userId}/incrementPosts`
    );

    2. 从 API 控制器调用一个“庞大”的服务

    考虑一个阅读我们代码的人,他希望理解整个高层流程或深入了解某个特定部分。她首先进入 API 控制器,在那里请求开始。与其名称所暗示的不同,这个控制器层只是一个适配器,并且保持非常简单和直接。到目前为止都很好。然后控制器调用一个庞大的“服务”,其中包含数千行代码,代表整个逻辑。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    // user-controller
    router.post('/', async (req, res, next) => {
    await userService.add(req.body);
    // 可能在这里有 try-catch 或错误响应逻辑
    }

    // user-service
    exports function add(newUser){
    // 想要快速了解吗?需要全面了解用户服务,1500 行代码
    // 它使用技术性语言和重用其他流程的叙述
    this.copyMoreFieldsToUser(newUser)
    const doesExist = this.updateIfAlreadyExists(newUser)
    if(!doesExist){
    addToCache(newUser);
    }
    // 还有 20 多行代码需要浏览其他函数才能理解意图
    }
  • 🤔 为什么可能是错误的:我们在这里是为了驯服复杂性。其中一种有用的技术是尽可能推迟复杂性。然而,在这种情况下,代码的阅读者(希望)首先通过测试和控制器开始她的旅程 - 这些领域的东西很简单。然后,当她进入大型服务时 - 她遇到了大量的复杂性和小细节,尽管她专注于理解整体流程或某些特定逻辑。这是不必要的复杂性。

  • ☀️ 更好的替代方案:控制器应该调用一种特定类型的服务,即用例,负责用业务和简单的语言总结流程。每个流程/特性都使用一个用例来描述,每个用例包含 4-10 行代码,讲述了故事而不涉及技术细节。它主要编排其他小服务、客户端和持有所有实现细节的存储库。有了用例,读者可以轻松理解高层流程。她现在可以选择她想要关注的地方。现在,她只暴露给了必要的复杂性。这种技术还鼓励将代码分割成用例编排的较小对象。额外的奖励:通过查看覆盖率报告,您可以了解到哪些特性被覆盖了,而不仅仅是文件/函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// add-order-use-case.js
export async function addOrder(newOrder: addOrderDTO) {
orderValidation.assertOrderIsValid(newOrder);
const userWhoOrdered = await userServiceClient.getUserWhoOrdered(
newOrder.userId
);
paymentTermsService.assertPaymentTerms(
newOrder.paymentTermsInDays,
userWhoOrdered.terms
);

const response = await orderRepository.addOrder(newOrder);

return response;
}

3. Nest.js:使用依赖注入连接一切

💁♂️ 这是什么意思:如果你正在使用 Nest.js,除了手头有一个强大的框架之外,你可能会为每个类使用 DI 并使每个类可注入。假设你有一个 weather-service 依赖于 humidity-service,并且没有要使用替代提供程序替换 humidity-service 的要求。尽管如此,你将 humidity-service 注入到 weather-service 中。它成为你的开发风格的一部分,“为什么不呢”你会想 - 我可能需要在测试期间存根它或将其替换为将来的其他提供者

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// humidity-service.ts - not customer facing
@Injectable()
export class GoogleHumidityService {

async getHumidity(when: Datetime): Promise<number> {
// Fetches from some specific cloud service
}
}

// weather-service.ts - customer facing
import { GoogleHumidityService } from './humidity-service.ts';

export type weatherInfo{
temperature: number,
humidity: number
}

export class WeatherService {
constructor(private humidityService: GoogleHumidityService) {}

async GetWeather(when: Datetime): Promise<weatherInfo> {
// Fetch temperature from somewhere and then humidity from GoogleHumidityService
}
}

// app.module.ts
@Module({
providers: [GoogleHumidityService, WeatherService],
})
export class AppModule {}
  • 🤔 为什么可能是错误的:依赖注入不是无价值的编码风格,而是一种应该在正确时刻引入的模式,就像任何其他模式一样。为什么?因为任何模式都有一个价格。你问,什么价格?首先,违反了封装。weather-service 的客户现在知道在内部使用了其他提供者。一些客户可能会被诱惑重写提供者,而这并不在他们的责任范围内。其次,这是另一层要学习、维护和另一种使自己陷入困境的复杂性。StackOverflow 的一些收入归功于 Nest.js DI - 许多讨论试图解决这个难题(例如,你知道在循环依赖的情况下,导入顺序很重要吗?)。第三,有性能问题 - 例如,Nest.js 为了在无服务器环境中提供体面的启动时间而挣扎,不得不引入了延迟加载模块。不要误会我,有些情况下,DI 是有充分理由的:当需要将依赖关系与其调用方分离,或者允许客户端注入自定义实现时(例如,策略模式)。在这种情况下,当有价值时,你可以考虑依赖注入的价值是否值得其价格。如果你没有这种情况,为什么要为无用的东西付费呢?

  • ☀️ 更好的替代方案:’精简’你的工程方法 - 除非它立即满足了实际需求,否则避免使用任何工具。从简单开始,一个依赖类应该简单地导入它的依赖并使用它 - 是的,使用纯粹的Node.js模块系统(’require’)。面临需要因素动态对象的情况?有一些简单的模式,比 DI 更简单,你应该考虑,比如 'if/else'、工厂函数等等。是否需要单例?考虑使用成本更低的技术,

比如具有工厂函数的模块系统。需要为测试存根/模拟?Monkey patching可能比 DI 更好:最好稍微使你的测试代码凌乱一点,而不是使你的生产代码凌乱。是否需要隐藏一个对象的依赖关系从哪里来?你确定吗?使用 DI

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// humidity-service.ts - not customer facing
export async function getHumidity(when: Datetime): Promise<number> {
// Fetches from some specific cloud service
}

// weather-service.ts - customer facing
import { getHumidity } from "./humidity-service.ts";

// ✅ No wiring is happening externally, all is flat and explicit. Simple
export async function getWeather(when: Datetime): Promise<number> {
// Fetch temperature from somewhere and then humidity from GoogleHumidityService
// Nobody needs to know about it, its an implementation details
await getHumidity(when);
}

4. 使用 Passport.js 进行令牌身份验证

💁♂️ 是什么:通常情况下,你需要发放和/或验证 JWT 令牌。同样,你可能需要允许从一个单一的社交网络(如 Google/Facebook)登录。面对这些需求时,Node.js 开发人员会迅速转向库 Passport.js.

  • 🤔 为什么可能是错误的:当需要保护你的路由时,只需少数几行代码即可完成目标。不要去处理一个新的框架,不要引入额外的间接性(你调用护照,然后它调用你),不要花时间学习新的抽象。类似 jsonwebtoken 或 fast-jwt 这样的库简单且维护良好。对安全加固有顾虑吗?这个观点很好,你的顾虑是合理的。但是隐藏东西在一个框架后面会更好吗?即使你更喜欢经受过实战考验的框架的加固,护照也不能处理一些安全风险,比如密钥/令牌、安全的用户管理、数据库保护等。我的观点是,那些选择 Passport.js 的人可能并不完全意识到哪些需求得到了满足,哪些还是开放的。尽管如此,当寻求一种快速支持多个社交登录提供者的方法时,护照确实发光发亮

  • ☀️ 更好的替代方案:需要令牌身份验证吗?下面这几行代码也许就是你需要的全部。你也可以瞥见一下 Practica.js 对这些库的封装。一个大规模的实际项目通常需要更多:支持异步 JWT(`JWKS)、安全地管理和旋转密钥等等。在这种情况下,像 [keycloak (https://github.com/keycloak/keycloak) 这样的 OSS 解决方案或商业选项像 Auth0[https://github.com/auth0] 是可以考虑的

替代方案

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// jwt-middleware.js,一个简化的版本 - 参考 Practica.js 以查看更多边缘情况
const middleware = (req, res, next) => {
if(!req.headers.authorization){
res.sendStatus(401)
}

jwt.verify(req.headers.authorization, options.secret, (err: any, jwtContent: any) => {
if (err) {
return res.sendStatus(401);
}

req.user = jwtContent.data;

next();
});
};

5. 用于集成/API 测试的 Supertest

💁♂️ 是什么:当针对 API 进行测试时(即组件、集成、端到端测试),库 supertest 提供了一种方便的语法,可以检测到 Web 服务器地址,发起HTTP调用,并对响应进行断言。三合一

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
test("当添加无效用户时,响应为 400", (done) => {
const request = require("supertest");
const app = express();
// 安排
const userToAdd = {
name: undefined,
};

// 行动
request(app)
.post("/user")
.send(userToAdd)
.expect("Content-Type", /json/)
.expect(400, done);

// 断言
// 我们已经在上面进行了断言 ☝🏻 作为请求的一部分
});

📊 受欢迎程度:每周下载量为 2,717,744

  • 🤔 为什么可能是错误的:你已经有了你的断言库(JestChai?),它有很好的错误高亮和比较 - 你信任它。为什么要使用另一种断言语法编写一些测试?更不用说,Supertest 的断言错误不像 Jest Chai 那样描述详细。将 HTTP 客户端 + 断言库混合在一起,而不是选择每个任务的最佳工具,这也很麻烦。说到最佳工具,有更标准、流行和维护良好的HTTP客户端(如 fetchaxios 等)。需要另一个原因吗?Supertest 可能会鼓励将测试与 Express 耦合,因为它提供了一个获取 Express 对象的构造函数。这个构造函数会自动推断 API 地址(在使用动态测试端口时很有用)。这使得测试与实现耦合在一起,不能在希望针对远程进程运行相同测试的情况下工作(API 不与测试一起运行)。我的存储库 ‘Node.js 测试最佳实践’ 包含了如何让测试推断 API 端口和地址的示例

  • ☀️ 更好的替代方案:一个受欢迎和标准的 HTTP 客户端库,如 Node.js FetchAxios。在 Practica.js(一个包含许多最佳实践的 Node.js 入门项目)中,我们使用 Axios。它允许我们配置一个 HTTP 客户端,在所有测试之间共享:我们在其中嵌入了 JWT 令牌、头文件和基本 URL。我们查看的另一个好的模式是,让每个微服务为其消费者生成HTTP客户端库。这为客户端带来了强类型的体验,同步了提供者和消费者的版本,并作为一个奖励 - 提供者可以使用与其消费者相同的库进行测试

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
test("当添加无效用户时,响应为 400,包含原因", (done) => {
const app = express();
// 安排
const userToAdd = {
name: undefined,
};

// 行动
const receivedResponse = axios.post(
`http://localhost:${apiPort}/user`,
userToAdd
);

// 断言
// ✅ 断言发生在一个专门的阶段和一个专门的库中
expect(receivedResponse).toMatchObject({
status: 400,
data: {
reason: "no-name",
},
});
});
  1. Fastify 的 decorate 用于非请求/网络实用程序
    💁♂️ 它是什么:Fastify 引入了很好的模式。就个人而言,我非常欣赏它在保留 Express 简洁性的同时带来更多功能。让我感到困惑的一件事是 ‘decorate’ 功能,它允许将通用的实用程序/服务放置在一个广泛可访问的容器对象内。我在这里指的是当使用跨层次关注点实用程序/服务时的情况。这里是一个示例:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 一个跨层次关注点实用程序的示例。可以是日志记录器或其他任何内容
fastify.decorate('metricsService', function (name) {
fireMetric: () => {
// 我的代码发送指标到监控系统
}
})

fastify.get('/api/orders', async function (request, reply) {
this.metricsService.fireMetric({name: 'new-request'})
// 处理请求
})

// my-business-logic.js
exports function calculateSomething(){
// 如何触发一个指标?
}

需要注意的是,’decorate’ 也用于将值(例如用户)放置在请求中 - 这是一个略有不同但合理的情况。

📊 它有多受欢迎:Fastify 每周下载量为 696,122,且正在快速增长。装饰器概念是框架核心的一部分

🤔 为什么可能是错误的:一些服务和实用程序服务于跨层次关注点的需求,并且应该可以从其他层访问,如领域(即业务逻辑、数据访问层)。当将实用程序放置在此对象内时,Fastify 对象可能无法被这些层访问到。你可能不想将 Web 框架与业务逻辑耦合:考虑到一些业务逻辑和存储库可能会被非 REST 客户端(如 CRON、MQ 等)调用 - 在这些情况下,Fastify 完全不会介入,所以最好不要依赖它作为你的服务定位器。

☀️ 更好的替代方案:一个老牌的 Node.js 模块是公开和使用功能的标准方式。需要单例?使用模块系统缓存。需要在 Fastify 生命周期钩子中实例化服务(例如,在启动时连接到数据库)?从该 Fastify 钩子调用它。在需要高度动态和复杂的依赖项实例化时 - DI 也是一个(复杂的)可以考虑的选项。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// ✅ 使用老牌的 Node.js 模块的简单用法
// metrics-service.js

exports async function fireMetric(name){
// 我的代码发送指标到监控系统
}

import {fireMetric} from './metrics-service.js'

fastify.get('/api/orders', async function (request, reply) {
metricsService.fireMetric({name: 'new-request'})
})

// my-business-logic.js
exports function calculateSomething(){
metricsService.fireMetric({name: 'new-request'})
}
  1. 从 catch 子句记录日志
    💁♂️ 它是什么:你在代码中某个深层位置捕获了一个错误(不是在路由级别),然后调用 logger.error 使这个错误可观察。看起来简单而必要。
1
2
3
4
5
6
try{
axios.post('https://thatService.io/api/users);
}
catch(error){
logger.error(error, this, {operation: addNewOrder});
}

📊 它有多受欢迎:很难准确了解数据,但它相当受欢迎,对吧?

🤔 为什么可能是错误的:首先,错误应该在一个中心位置处理/记录。错误处理是一个关键路径。各种 catch 子句可能会以不同的方式行为,没有一个统一的行为。例如,可能会出现要求给所有错误打上某些元数据标签的请求,或者除了记录日志之外,还要触发监控指标的情况。在约 100 处放置这些要求不是一件轻松的事情。其次,catch 子句应该被最小化到特定的场景中。默认情况下,错误的自然流向是向下冒泡到路由/入口点 - 从那里,它将被转发到错误处理程序。catch 子句更冗长且容易出错 - 因此它应该服务于两个非常特定的需求:当希望根据错误更改流程或使用更多上下文丰富错误(这在此示例中不是情况)。

☀️ 更好的替代方案:默认情况下,让错误向下层冒泡,并由入口点全局 catch(例如,Express 错误中间件)捕获。在错误应触发不同流程(例如,重试)或者为错误添加更多上下文时 - 使用一个 catch 子句。在这种情况下,确保 .catch 代码也向错误处理程序报告。

1
2
3
4
5
6
7
8
9
// 一个希望在失败时重试的情况
try{
axios.post('https://thatService.io/api/users);
}
catch(error){
// ✅ 一个处理错误的中心位置
errorHandler.handle(error, this, {operation: addNewOrder});
callTheUserService(numOfRetries++);
}

📊 有多受欢迎:很难找到具体数据,但它相当受欢迎,对吧?

🤔 为什么可能是错误的:首先,错误应该在一个中心位置处理/记录。错误处理是一个关键路径。各种 catch 子句可能会以不同的方式行为,没有一个统一的行为。例如,可能会出现要求给所有错误打上某些元数据标签的请求,或者在记录日志的基础上,还要触发监控指标的情况。在大约 100 个位置应用这些要求并不是一件容易的事情。其次,catch 子句应该被最小化到特定的情景中。默认情况下,错误的自然流向是向下冒泡到路由/入口点 - 从那里,它将被转发到错误处理程序。catch 子句更冗长且容易出错 - 因此,它应该满足两个非常具体的需求:当希望根据错误更改流程或使用更多上下文丰富错误时(在此示例中并非如此)。

☀️ 更好的替代方案:默认情况下,让错误向下层冒泡,并由入口点全局 catch(例如,Express 错误中间件)捕获。在错误应触发不同流程(例如,重试)或者为错误添加更多上下文时 - 使用一个 catch 子句。在这种情况下,请确保 .catch 代码也报告给错误处理程序。

1
2
3
4
5
6
7
8
9
// 一个希望在失败时重试的情况
try{
axios.post('https://thatService.io/api/users);
}
catch(error){
// ✅ 一个处理错误的中心位置
errorHandler.handle(error, this, {operation: addNewOrder});
callTheUserService(numOfRetries++);
}
  1. 使用 Morgan 记录器记录 Express Web 请求
    💁♂️ 它是什么:在许多 Web 应用中,你可能会发现一种被长期复制粘贴的模式 - 使用 Morgan 记录器记录请求信息:
1
2
3
4
5
6
const express = require("express");
const morgan = require("morgan");

const app = express();

app.use(morgan("combined"));

📊 有多受欢迎:每周下载量为 2,901,574。

🤔 为什么可能是错误的:等一下,你已经有你的主要日志记录器了,对吧?是 Pino 吗?Winston?还是其他什么?太好了。为什么要处理和配置另一个记录器呢?我确实欣赏 Morgan 的 HTTP 领域特定语言(DSL)。语法很好!但这是否值得使用两个记录器?

☀️ 更好的替代方案:将你选择的记录器放置在中间件中,并记录所需的请求/响应属性:

1
2
3
4
5
6
7
8
// ✅ 为所有任务使用你喜欢的记录器
const logger = require("pino")();
app.use((req, res, next) => {
res.on("finish", () => {
logger.info(`${req.url} ${res.statusCode}`); // 在这里添加其他属性
});
next();
});
  1. 基于 NODE_ENV 值的条件代码
    💁♂️ 它是什么:为了区分开发环境与生产环境的配置,通常会设置环境变量 NODE_ENV 为 “production|test”。这样做允许各种工具以不同方式运行。例如,一些模板引擎只会在生产环境中缓存编译后的模板。除了工具外,自定义应用程序使用此来指定仅适用于开发环境或生产环境的行为:
1
2
3
4
5
6
7
8
if (process.env.NODE_ENV === "production") {
// 这个代码分支不太可能被测试,因为测试运行器通常设置 NODE_ENV=test
setLogger({ stdout: true, prettyPrint: false });
// 如果上面的代码分支存在,为什么不添加更多仅在生产环境下的配置:
collectMetrics();
} else {
setLogger({ splunk: true, prettyPrint: true });
}

📊 有多受欢迎:在 GitHub 上搜索 “NODE_ENV” 有 5,034,323 条代码结果。这似乎不是一个罕见的模式。

🤔 为什么可能是错误的:任何时候你的代码检查是否是生产环境,这个分支在一些测试运行器中默认情况下不会被执行(例如,Jest 设置 NODE_ENV=test)。在任何测试运行器中,开发人员必须记住测试这个环境变量的每个可能值。在上面的示例中,collectMetrics() 将在生产环境中第一次被测试。悲伤的笑脸。此外,放置这些条件打开了在生产环境和开发人员机器之间添加更多差异的大门 - 当这个变量和条件存在时,开发人员会动心只为生产环境添加一些逻辑。理论上,这是可以测试的:可以在测试中设置 NODE_ENV = “production” 并覆盖生产分支(如果她记得的话…)。但是,如果可以使用 NODE_ENV=’production’ 进行测试,那么分离的意义是什么呢?就将所有事情都视为 ‘production’ 并避免这种容易出错的心理负担而言,这是一个更好的选择。

☀️ 更好的替代方案:我们编写的任何代码都必须经过测试。这意味着避免任何形式的 if(production)/else(development

) 条件。开发人员的机器和生产环境的周围基础设施不同(例如,日志系统)吗?它们确实不同,但我们感觉舒适。这些基础设施事务已经经过实战检验,是外在的,不是我们代码的一部分。为了保持相同的代码在开发/生产环境中使用不同的基础设施 - 我们将不同的值放在配置中(而不是代码中)。例如,典型的记录器在生产环境中会输出 JSON,但在开发机器上会输出 ‘pretty-print’ 彩色行。为了满足这一点,我们设置环境变量,告诉我们希望使用何种日志记录风格:

1
2
3
4
5
6
7
8
9
//package.json
"scripts": {
"start": "LOG_PRETTY_PRINT=false index.js",
"test": "LOG_PRETTY_PRINT=true jest"
}

//index.js
//✅ 没有条件,所有环境都使用相同的代码。变化是在配置或部署文件中外部定义的
setLogger({prettyPrint: process.env.LOG_PRETTY_PRINT})

结束语:我希望这些想法,至少其中之一,让你重新考虑将新技术添加到你的工具箱中。无论如何,让我们保持我们的社区充满活力、颠覆性和友善。尊重的讨论几乎与事件循环一样重要。几乎。