Node开发最佳实践-设计架构&工具&测试&性能

963 阅读26分钟

用js的好处中最主要的就是能同时运行在浏览器和服务端。作为一个工程师,你需要去掌握一门语言来实现不同的应用。这就是为什么我在2015年被Node所吸引来做全栈工程师而不必在不同语言和技术栈之前切来切去。Node允许你去重用library,逻辑和类型。而且慢慢发展,从原先被很多人质疑的技术到现在在各个大公司作为关键基础设施。尤其在高IO场景下,相比其他使用多线程的语言,Node的代码复杂度低得多。Node的生态是非常自由的,任何人都可以按照自己的想法来创建应用,但相对的初学者就需要花上一点时间来探索哪些规则是比较好的。在这里我带来自己的经验分享。

结构&最佳实践

想要结构化一个应用属于策略和战术的综合考量。开发者必须同时思考目录安排,分层以及他们之间的通信。忽略以上任意一点都会导致一个有瑕疵的设计。

在module中结构化一个应用

在后端开发中最通用的结构化设计模式就是MVC。基本上适合大多数情况,也基本不出出差错。它是基于技术视角来结构化应用。通过controllers来处理http request和response,使用models来从数据库获取数据,借助views来可视化响应。但这种方式的好处也不是特别多。目前来说,大部分的Node应用还都是REST的服务,所以说view视图层没有必要;另外,model和ORM也不是特别必须,微服务的广泛使用使得不需要太复杂的计算和处理;剩下的controller自然就变成逻辑的书写地方,这就导致大部分开发者会把所有的逻辑都扔进去。

MVC结构的应用基本上每个的结构都差不多。但我认为这是一个缺点。一个应用的结构需要告诉你它做了什么,提供给你相关的领域信息。如果你打开一个目录全都是些controller,那么基本上就没有什么有用的信息来区分上下文,厘清服务逻辑。一长串的model的列表也不能清晰的表现他们之间的关系。

所以说更好的组织方式是基于领域module来结构化应用。每个module都是单独的目录,里面包含所有的handler,model,测试和业务逻辑。这个结构就会很清晰的告诉你服务是什么,基本上你一扫就知道所有的细节功能点,举个user模块的例子,不需要深入代码就会了解所有的信息:

// 👎 MVC
├── src
|   ├── controllers
|   |   ├── user.js
|   |   ├── catalog.js
|   |   ├── order.js
|   ├── models
|   |   ├── user.js
|   |   ├── product.js
|   |   ├── order.js
|   ├── utils
|   |   ├── order.js
|   ├── tests
|   |   ├── user.test.js
|   |   ├── product.test.js

// 👍 领域结构
├── src
|   ├── user
|   |   ├── user-handlers.js
|   |   ├── user-service.js
|   |   ├── user-queries.js
|   |   ├── user-handlers.test.js
|   |   ├── index.js
|   ├── order
|   |   ├── order-handlers.js
|   |   ├── order-service.js
|   |   ├── order-queries.js
|   |   ├── order-handlers.test.js
|   |   ├── calculate-shipping.js
|   |   ├── calculate-shipping.test.js
|   |   ├── index.js
|   ├── catalog
|   |   ├── catalog-handlers.js
|   |   ├── product-queries.js
|   |   ├── catalog-handlers.test.js
|   |   ├── index.js

具体到module内部没有什么特定的规则。甚至在不同module之间也可以有不同的结构。

从以模块划分的monolith项目开始

可能在你开始一个应用之前最重要的问题就是这个应用后续是否会变成一个monolith项目或是拆分成微服务。近些年大多数开发这和架构都倾向于后者,因为更好的扩展性,独立性,冰鞋能够解决更大尺度下的组织挑战。

微服务将一个应用拆分成更多的小服务,通过统一的通信方式来信息传输。就显而易见的例子就是电子商务系统,包含用户、产品和订单3个模块。

基于你所在的领域,微服务的边界有时候是很模糊的。所以我一般建议人们先从一个module的monolith开始,然后让它慢慢随着业务增长演化,而后再依据情况是否做微服务化的拆分。相比大家推崇的微服务,我觉得monolith的价值反而被低估了,能将所有模块都统一管理本身就很有价值,然后从monolith中拆一个模块作为微服务也不是很难。所以说在开发应用过程中一个不错的实践就是始终将module作为未来可能会拆分的模块来处理,基于通用的协议来进行交互通信。

将实现分层

MVC最大的设计缺陷就是将一堆的逻辑:包括数据库调用、业务逻辑、数据传输等。这导致了非常强的逻辑耦和,来看个例子:

// 👎 避免handler承担太多指责,除非你的应用范围非常小
const handler = async (req, res) => {
  const { name, email } = req.body

  if (!isValidName(name)) {
    return res.status(httpStatus.BAD_REQUEST).send()
  }

  if (!isValidEmail(email)) {
    return res.status(httpStatus.BAD_REQUEST).send()
  }

  await queryBuilder('user').insert({ name, email })]

  if (!isPromotionalPeriod()) {
    const promotionalCode = await queryBuilder
      .select('name', 'valid_until', 'percentage', 'target')
      .from('promotional_codes')
      .where({ target: 'new_joiners' })

    transport.sendMail({
      // ...
    })
  }

  return res.status(httpStatus.CREATED).send(user)
}

在特别小的应用中这种方式是可以接受的,但如果到了更大的应用,那么这种方式显然就会阻碍项目的扩展,我们要很仔细的理顺逻辑,重构才能剥离原先的整合逻辑。不仅冗长,难以阅读,而且也很难测试。一个函数干一件事基本上是常识,所以不建议handler函数承担太多指责。像验证,业务逻辑和数据获取都不太适合。handler函数就该专注在HTTP层,而其他的东西就应该封装在函数或是模块内部。

// 👍 Handlers 仅处理HTTP逻辑
const handler = async (req, res) => {
  const { name, email } = req.body

  if (!isValidName(name)) {
    return res.status(httpStatus.BAD_REQUEST).send()
  }

  if (!isValidEmail(email)) {
    return res.status(httpStatus.BAD_REQUEST).send()
  }

  try {
    const user = userService.register(name, email)
    return res.status(httpStatus.CREATED).send(user)
  } catch (err) {
    return res.status(httpStatus.INTERNAL_SERVER_ERROR).send()
  }
}

底层的数据逻辑我们统称为services,handler层专门处理HTTP请求,service则专注于领域和数据获取的逻辑。我们通过这种handler和service的分层来分清指责。针对有点复杂性的应用,这个就是非常重要的一步。

// user-service.js
export async function register(name, email) {
  const user = await queryBuilder('user').insert({ name, email })]

  if (!isPromotionalPeriod()) {
    const promotionalCode = await promotionService.getNewJoinerCode()
    await emailService.sendNewJoinerPromotionEmail(promotionalCode)
  }

  return user
}

通过单独封装数据获取的能力,我们进一步提升了可读性和可测性。更重要的是我们慢慢将业务和数据功能分离。

现在我们的应用逻辑已经拆分城了传输层,领域层和数据层。改变一个基本上不会影响其他层的内容。像从REST切到gRPC或是变更数据库都是很少见的情况,但通过我们的框架修改,哪怕我们遇到类似的情况,我们的扩展性、可读性和可测性都得到了提升和保证。

然而,这种结构在Node中并不常见,毕竟不是所有的应用都适合这种。但是通过分层的操作来降低逻辑复杂度的思想倒是通用的。

使用service来进行module间通行 #

再来老生常谈一番我推崇的结构,在一个MVC结构的应用中,各个技术分类下指责边界是模糊的,比如controller下面的众多逻辑很难有一个很好的拆分,而基于module模块的领域模型则能更好的组织相关的领域内容。

举个例子吧,还是考虑电商场景下的用户和订单模块,如果我们需要同时更新用户信息和订单信息该怎么办呢?如果单独放到一个handler里面肯定相当混乱,所以一个方法是在user的service层修改user模块的信息,同时在里面额外掉用delivery 模块的service操作,原本的delivery不用关心user执行的操作。

// 👎 不要打破领域的边界
const placeOrderHandler = (req, res) => {
  const { products, updateShippingAddress } = req.body

  if (updateShippingAddress) {
    // Update the user's default shipping address
    const { user } = res.locals.user
    const { shippingAddress } = req.body
    knex('users').where('id' '=', user.id).update({ shippingAddress })
  }
}

// 👍 使用service通信
const placeOrderHandler = (req, res) => {
  const { products, updateShippingAddress } = req.body

  if (updateShippingAddress) {
    // Update the user's default shipping address
    const { user } = res.locals..user
    const { shippingAddress } = req.body
    userService.updateShippingAddress(user.id, shippingAddress)
  }
}

这样的好处就是当我想把delivery模块单独拆出去,模块基本都可以保留相同的函数,不用做太多的修改。

创建领域实体(domain entity)

Node服务的主要职责就是拉取和推送数据。形式有很多种,比如HTTP请求,event事件或是一个定时的任务。大多数情况是直接把数据从数据库拿过来返回。

// product-repository.js
// 👎 避免直接从数据库返回
// If the storage imposes constraints on naming and formatting.
export async function getProduct(id) {
  return dbClient.getItem(id)
}

虽然有时侯确实是可行的,service只是从数据库拿数据返回,但这种简单的透传方式其实是危险的,基本会暴露数据库的细节,还是应该避免使用。

大多数service都会在数据给到传输层那边前处理下数据,我觉得这个不太好,因为这就把数据库的细节信息带过来了,还是建议数据在获取后就立即处理掉。

// product-repository.js
// 👍 在获取数据后立马处理
// Rename storage-specific fields and improve its structure.
export async function getProduct(id) {
  const product = dbClient.getItem(id)
  return mapToProductEntity(product)
}

最好能够让领域层之间尽量简单,所以我们需要小心的将数据层的内容隔离好,借助TypesScript和实体间的函数调用来进行通信。

区分util函数和领域逻辑

我看到很多项目都有个全局的utils目录,其他人可能一股脑的把复用函数,业务逻辑和常量都扔进去了。

util函数理想情况下就是些基础的工具函数集合,哪怕移到其他项目也可以正常使用,而业务逻辑肯定不属于这类,所以必须单独分离出来。虽然没什么好办法来处理这个,我还是建议用不同的文件来区分和组合业务逻辑。

// 👎 不要把所有的业务逻辑扔到utils李敏啊
├── src
|   ├── user
|   |   ├── ...
|   ├── order
|   |   ├── ...
|   ├── catalog
|   |   ├── ...
|   ├── utils
|   |   ├── calculate-shipping.js
|   |   ├── watchlist-query.js
|   |   ├── products-query.js
|   |   ├── capitalize.js
|   |   ├── validate-update-request.js

// 👍 将业务逻辑封装在领域内部
├── src
|   ├── user
|   |   ├── ...
|   |   ├── validation
|   |   |   ├── validate-update-request.js
|   ├── order
|   |   ├── ...
|   |   ├── calculate-shipping.js
|   ├── catalog
|   |   ├── ...
|   |   ├── queries
|   |   |   ├── products-query.js
|   |   |   ├── watchlist-query.js
|   ├── utils
|   |   ├── capitalize.js

验证请求结构

基本上有外部数据进来的服务都要去效验数据,不少语言都有一大堆的逻辑效验方式来检查字段和数据类型。这确实蛮重要的,不然都不太放心去操作。

不过大部分是否这种效验逻辑都是比较无聊的,重复的提示,重复的错误处理。所以最好用专门的库来处理。比如Joi就是比较通用的,或者也可以看看ajvexpress-validator

// 👎 下面这种效验字段看起来就很蠢
const createUserHandler = (req, res) => {
  const { name, email, phone } = req.body
  if (name && isValidName(name) && email && isValidEmail(email)) {
    userService.create({
      userName,
      email,
      phone,
      status,
    })
  }

  // Handle error...
}

// 👍 用库来处理就优雅的多
const schema = Joi.object().keys({
  name: Joi.string().required(),
  email: Joi.string().email().required(),
  phone: Joi.string()
    .regex(/^\d{3}-\d{3}-\d{4}$/)
    .required(),
})

const createUserHandler = (req, res) => {
  const { error, value } = schema.validate(req.body)
  // Handle error...
}

自己手写个schema就可以拿来效验了,也可以补充下对应的错误信息。个人比较建议在抛出错误前使用422(Unprocessable Entity)状态码来跟踪。

验证中间件 middleware

下一个问题就是我们什么时候来做这个效验逻辑呢?是属于传输层还是业务逻辑呢?我推荐把效验逻辑放在handler之前。最好能有统一的中间件操作来做验证检查。

// 创建个通用验证中间件
const validateBody = (schema) => (req, res, next) => {
  const { value, error } = Joi.compile(schema).validate(req.body)

  if (error) {
    const errorMessage = error.details
      .map((details) => details.message)
      .join(', ')

    return next(new AppError(httpStatus.BAD_REQUEST, errorMessage))
  }

  Object.assign(req, value)
  return next()
}

// 在路由定义的时候配置中间件
app.put('/user', validate(userSchema), handlers.updateUser)

这里我们推荐奖中间件的粒度尽量往细了拆,通过组合的形式来构建复杂的验证逻辑而不是把一堆的效验统一在一个路由中定义。这样做灵活度更高,复用性好。之前说的express-validator就有非常不错的中间件逻辑。

在中间件中我们如何处理业务逻辑

当我们想要定义layer和边界的时候我们有时候就会遇到两难的境地:middleware属于哪一层?业务逻辑是否要写到里面?middleware由于还是在传输层,所以理应属于传输层,但是我们并不建议将业务逻辑放到中间件中。

// 👎 不要在中间件中实现业务逻辑
const hasAdminPermissions = (req, res, next) => {
  const { user } = res.locals

  const role = knex.select('name').from('roles').where({ 'user_id', user.id })

  if (role !== roles.ADMIN) {
    throw new AppError(httpStatus.UNAUTHORIZED)
  }

  next()
}

// 👍 用一个service来替代
const hasAdminPermissions = (req, res, next) => {
  const { user } = res.locals

  if (!userService.hasPermission(user.id)) {
    throw new AppError(httpStatus.UNAUTHORIZED)
  }

  next()
}

怎么说呢,还是一样的,我们尽量将业务逻辑从middleware中剥离,就比如上面的 hasAdminAccess,这个命名就可以优化成hasPermissions,万一admin这个觉得改了呢,对吧。

在controller中用函数比class好

在传统的MVC框架里面,一般提供class给controller,理由是是可以基于基础类做扩展,而这个基础类大多封装了一些针对request和response的操作。个人觉得除非你的代码完全依赖OOP,不然函数可能更优雅。

// 👎 不建议用class来组装handler
class UserController {
  updateDetails(req, res) {}
  register(req, res) {}
  authenticate(req, res) {}
}

// 👍 用不同的函数就好
export function updateDetails(req, res) {}
export function register(req, res) {}
export function authenticate(req, res) {}

函数呢,更容易移动,复制黏贴。而且哪怕你需要保存状态或是注入写东西,那就直接传参进去就好。另一个好处就是能减少很多class测试时需要的mock。

export function createHandler(logger, userService) {
  return {
    updateDetails: (req, res) => {
      // User the logger and service in here
    },
  }
}

使用error对象或者扩展下

虽然在js中我们可以throw 抛出任意的错误对象,但最好还是将错误收敛到内建的错误,这样一是方便stack追踪和各模块间的一致性。

// 👎 不要随意的抛出错误类型
const { error } = schema.validate(req.body)

if (!product) {
  throw 'The request is not valid!'
}

// 👍 使用内建的Error
const { error } = schema.validate(req.body)

if (!product) {
  throw new Error('The request is not valid')
}

但有时候这些错误信息还不够,我们有时候还需要添加上额外的信息,例如状态码啥的。这就需哟啊扩展下Error对象来补充信息。

// 扩展Error对象
export default class AppError extends Error {
  constructor(statusCode, message, isOperational = true, stack = '') {
    super(message)
    this.statusCode = statusCode
    this.isOperational = isOperational
    if (stack) {
      this.stack = stack
    } else {
      Error.captureStackTrace(this, this.constructor)
    }
  }
}

// 使用AppError
const { error } = schema.validate(req.body)

if (!product) {
  throw new AppError(
    httpStatus.UNPROCESSABLE_ENTITY,
    'The request is not valid'
  )
}

注意isOperational主要是用来区分已知错误和未知错误。有两种方式来扩展错误对象:创建一个通用的AppError错误或者创建具体类型的错误,例如ValidationErrorInternalServerError。我的建议是前者,然后通过状态码注入形式来使用。

监听process信号

虽然大多数时候应用主要来响应外部事件,但同样也需要相应环境变化。操作系统也会向应用发送信息,提示不同的事件。 Most applications are built to react to external events - a request over HTTP or a message coming from an event bus. But they also need to be able to react to the environment they’re running in. The operating system will send signals to your application, notifying it of various events.

process.on('uncaughtException', (err) => {
  // 记录异常并推出
})

process.on('SIGTERM', () => {
  // 做点什么并推出
})

更重要的是你需要知道什么时候你的服务需要被关闭,这样你能够关闭重连到其他服务。

创建错误处理模块

错误的统一处理是非常重要的,对于基于express的应用是很容易建立统一的错误处理模块。我们经常看到某个工程师针对特定错误的处理,但其实这样是不太合适的,最好还是有错误处理模块统一代理掉。

// 👎 不要逐一处理错误
const createUserHandler = (req, res) => {
  // ...
  try {
    await userService.createNewUser(user)
  } catch (err) {
    logger.error(err)
    mailer.sendMail(
      configuration.adminMail,
      'Critical error occured',
      err
    )
    res.status(500).send({ message: 'Error creating user' })
  }
}

// 👍 将错误统一收集处理
const handleError = (err, res) => {
  logger.error(err)
  sendCriticalErrorNotification()

  if (!err.isOperational) {
    // Shut down the application if it's not an AppError
  }

  res.status(err.statusCode).send(err.message)
}

const createUserHandler = (req, res, next) => {
  // ...
  try {
    await userService.createNewUser(user)
  } catch (err) {
    next(err)
  }
}

app.use(async (err, req, res, next) => {
  await handleError(err, res)
})

process.on('uncaughtException', (error) => {
  handleError(error)
})

错误处理机制必须重视,当然也要注意分层,领域层逻辑使用通用的Error对象,如果你想扩展他,放在传输层做。

在middleware中发送404响应

如果你是用包含middleware的应用路由,比如Express,那其实处理404还是很方便的,借助middleware可以很安全的处理404错误。

app.use((err, req, res, next) => {
  if (!err) {
    next(new AppError(httpStatus.NOT_FOUND, 'Not found'))
  }
})

不要在handler中返回错误response

如果你按照之前建议的开发了个统一的错误处理模块,你就可以把所有在handler中错误的处理都交给它处理。保证handler中不用再处理那些错误判断。

当你没法恢复应用的时候关闭它

当你碰到一个你处理不了的问题的时候最好的办法就是记录并优雅的关闭应用。就像我们可以很好的处理传输层或是业务逻辑的错误,但如果一个库或是工具挂了,我们是不清楚如何应对的,关闭就好了。

process.on('uncaughtException', (error) => {
  handleError(error)

  if (!isOperational(error)) {
    process.exit(1)
  }
})

确保把错误记录下来,接下来再关掉,或者依赖你的运行环境来重启它。

增强代码一致性

其实相比制定代码标准,严格的遵守才是最难也最重要的。函数功能其实都差不多,所以这其实属于编码习惯的问题,而且你还真不能统一所有人的编码习惯。这样说吧,不管你选择何种标准,总有人不愿意遵守,所以呢这事不可强求。我们只有自己保持住自己的风格才是最重要的,尤其是在所有的应用或者系统都保持一致的风格,讲给你带来巨大的好处。

我觉得Eslint和Prettier是最好的工具。最好的在husky pre-commit hook中补充下检查和格式化逻辑。然后在你的CI pipeline中也加下,防止未格式化的代码被push到项目里来。但是一致性不止是样式和格式化,命名也非常重要,那种简单的命名会让人一头雾水,命名必须能加贴近直觉,易懂。

// 1. 常量就必须全部大写
const CACHE_CONTROL_HEADER = 'public, max-age=300'

// 2. 函数和变量全部小驼峰
const createUserHandler = (req, res) => {}

// 3. class和model全部大驼峰
class AppError extends Error {}

// 4. 文件和目录命名全部kebab case -> user-handler.js

如何放置功能函数

没有一种万能的方法来制定每个应用的结构。哪怕基于上面的一对规则建议,你有时候也不清楚如果摆放一个文件或是函数。虽然我们很容易知道route,handler和service的位置,但总有例外。我的建议是放在调用他的附近,如果有多个module同时掉用它,那就将他的位置提升一级。但这呢有时候就会导致同级目录太多,对于这种情况我建议把相关逻辑放到子目录中。

举个例子比较清楚,比如我要计算一张订单中的各种运费,如果平铺可能长这样:

├── order
|   ├── order-handlers.js
|   ├── order-service.js
|   ├── order-queries.js
|   ├── order-handlers.test.js
|   ├── calculate-shipping.js
|   ├── calculate-shipping.test.js
|   ├── get-courier-cost.js
|   ├── calculate-packaging-cost.js
|   ├── calculate-discount.js
|   ├── is-free-shipping.js
|   ├── index.js

这种calculate多了以后就会干扰其他核心的功能文件,所以我们把它放到子目录中就清晰了:

├── order
|   ├── order-handlers.js
|   ├── order-service.js
|   ├── order-queries.js
|   ├── order-handlers.test.js
|   ├── calculate-shipping
|   |   ├── index.js
|   |   ├── calculate-shipping.js
|   |   ├── calculate-shipping.test.js
|   |   ├── get-courier-cost.js
|   |   ├── calculate-packaging-cost.js
|   |   ├── calculate-discount.js
|   |   ├── is-free-shipping.js
|   ├── index.js

在模块中保存路由

一个module除了管理自己的逻辑,其实还应该管理自己的路有。很多应用喜欢把路有统一扔到一个文件中维护,虽然容易理解,但也增加管理和维护成本,所以还是要避免的。

// 注册主路由
const router = express.Router()

router.use('/user', jobs)
app.use('/v1', router)

// user-routes.js
router.route('/').get(isAuthenticated, handler.getUserDetails)
router
  .route('/')
  .put(
    isAuthenticated,
    validate(userDetailsSchema),
    handler.updateUserDetails
  )

给API 路有设置前缀

目前来看,想让gRPC和GraphQL达到REST的等级还有不少路要走,所以我们还是要好好规划和利用REST。API的一个主要问题就是保证稳定性和管理大版本更新,毕竟需求的变更总会让你需要去做些接口的变更。为了保证向后的兼容性,我建议针对所有路由配置个前置版本号。

app.use('/v1', routes)

处理请求认证

认证请求来了以后一般都需要些用户信息,比如用户id,email等,建议为了防止子请求,这些信息可以存到本地的res.locals,方便各种middleware来调用。

避免回调API

为了避免传统意义上的回调地狱,我们推荐使用.then() or await.

// 👎 不要做回调的嵌套处理
import fs from 'node:fs'

fs.open('./some/file/to/read', (err, file) => {
  // Handle error...

  // Do something with the file...

  fs.close(file, (err) => {
    // Handle closing error...
  })
})

// 👍 使用promise API
import { open } from 'node:fs/promises'

const file = await open('./some/file/to/read')

// Do something with the file...

await file.close()

不过你还是要单独处理错误,可以是全局的try catch或者是针对单独promise的.catch()

工具

基本上每个应用除了你自己代码就是各种生态工具库,了解如何去做权衡和更好的将业务逻辑融入三方库对于代码质量是至关重要的。

喜欢小而精的工具

Node的设计哲学跟linux是差不多的,通过单一原子化命令和工具的建立来组合出复杂的功能模块。工程师们也经常吐槽需要构建业务时需要的npm包的数量比xxx都多。

Express就是这样一个完美的例子,也难怪他还是目前最流行的框架。它实现了一系列基本功能,剩下就看你自己组装了。

倾向Express框架

我推荐Express因为它的plugin,路由和middleware模式都是其他框架学习借鉴的。学习Express可以让你学会去做权衡工具和配置应用结构,但同时不会限制你的思维必须在某个框架上下文中。任何工具你只要研究过了,就可以复制到其他领域。

大多数通用语言都有个主导的框架,就像Ruby有Rails,Python有Django,PHP有Laravel。到了2022年,Express仍然和Node配合的非常完美。

相比ORM 更喜欢用query builder

在其他语言中ORM被广泛使用,但是在Node中使用的不多。更多Node开发者还是会鼓励你去用更轻量的工具,比如query builder。Knex就是个我比较喜欢用的工具,可能唯一觉得跟ORM有点像的工具就是Prisma。

相比库更倾向于原生函数

通过性能对比,发现lodash 和 underscore等库的性能比原生方法函数差远了。而且他们都会增加额外的项目依赖。

而且大多数功能原生方法也都能覆盖 Object.entitiesObject.keysObject.valuesObject.findArray.fromArray.concatArray.fillArray.filter, Array.map等基本都能满足要求了。我建议用原生函数来自己封装点复用的函数。

使用一个结构话的logger

日志是唯一的方式来跟踪你的业务数据流。虽然console.log能够记录任何的信息数据,但是这种类型的信息还只是针对工程师来说,如果在生产环境,数据瞬间就可以把你淹没。好的结构话日志也方便你快速使用工具检索,比如Splunk和New Relic。

尤其在微服务体系中,能够快速定位数据流是非常重要的。而且基本上也可以给错误日志添加警报。 最广泛使用的logger是winston,用它不会错的。不过最新社区由于嫌弃它更新缓慢和有些瑕疵,对此我推荐pino

记录应用

大多数情况下创建应用的人和最后运营维护应用的人很可能是2拨人,所以就需要针对应用中的细节点做很好的文档梳理,尤其是业务逻辑那块,因为那边基本上是人家一看最迷糊的。像服务特点,参数类型,响应结果等都是值得记录的。Swagger就是一个不错的工具来展示文档,而且支持ts的类型导出。

锁死版本

这很容易忽视,请锁死版本,不要依赖semver等工具。

使用TypeScript

几年前刚接触TS的时候,还记得一堆人恐惧于TS的学习曲线和质疑其所带来的潜在生产力提升。而到了今天,TS的好处已经被广泛接受,相关工具链建设完善,IDE的支持也很不错。大多数npm包都自带类型。

使用Snyk

Log4j的灾难告诫我们哪怕是小如日志的工具也可能作为攻击的媒介。为了防止引入某些有安全问题的包,安全检查时十分必要的。

Synk就是个已知的不错的工具。那如何使用呢?最好的方式就是在CI pipeline中做集成,看看哪些release版本有安全隐患。当然,我们其实只需要关注哪些重要的缺陷,忽略其他不重要的。

配置对应的报警也是不错的方式。

容器化技术

最难定位的问题都是那些由环境引发的问题,很难重现。幸好容器化技术现在已经成熟而且被广泛使用。但是像docker类似的容器技术也不一定是必须的,也是要按场景来看,比如如果没有其他的服务,也没有统一的容器的服务,那就完全没有必要单独做容器化。

容器化最有用的地方是同时启动各类服务:比如数据库,readis缓存和前端应用。如果你的应用依赖多种服务,使用docker-compose是最简单的方式来聚合它们,然后生成一个可复制的环境来给到团队成员。

数据库禁止变更

如果有个人跑来跟你说调整下数据库,我建议你拒绝他们。除非某个改变能带来5到10倍的速度或是花费的提升。

封装配置

错误使用的配置可能会慢慢增加应用的复杂度。每个服务基本都需要API keys,认证和环境变量才能正常工作。如果将这些变量全部在service或是handler中使用就会打破层之间的边界。所以最好将配置单独封装和导出:

const config = {
  environment: process.env.NODE_ENV,
  port: process.env.PORT,
}

export default config

这样的话我们就把配置单独作为模块来导出,就像其他模块一样。在handler和service中也一眼能看出配置的来源。为了方便测试,我们在测试函数的时候可以将config作为函数参数传入。

使用层级的配置

环境变量少的时候还好,如果多了以后就会出现命名上的重叠问题,因此我们建议用层级的对象来做封装:

// 👎 user的前缀没有必要
const user = {
  userName: '...',
  userEmail: '...',
  userAddress: '...',
}

// 👍 去掉不需要的前缀
const user = {
  name: '...',
  email: '...',
  address: '...',
}

如果对应到我们的应用配置,可以如下设置:

const config = {
  storage: {
    bucketName: process.env.S3_BUCKET_NAME,
  },
  database: {
    name: process.env.DB_NAME,
    username: process.env.DB_USER,
    password: process.env.DB_PASSWORD,
  },
}

export default config

测试

应用如果不能做到足够的测试覆盖都是不放心的。测试代码本身也需要很好的结构化和维护。

倾向集成测试

目前为止测试最大的价值就是集成测试。要针对一个完整的逻辑流做测试,看他们是否符合预期。同时也提前对每个函数做单独的测试。

大多数情况是,单测都顺利通过,而集成测试却有问题。作为工程师,我们只有有限的时间来分配到测试中,为了最大限度的发挥测试的优势,应该优先做集成测试。它们是最好的方式来确保应用的稳定性。

考虑依赖注入而不是mock

有些语言中,必须将依赖mock才能测试。但是mock的操作其实增加了扩展和调整的难度。一般来说,任何增加测试难度的操作都是有潜在问题的。

在Node社区,我们接受mock作为一种常规手段。大多数测试工具都鼓励测试mock,而且会提供很多方法和工具来辅助。我建议依赖注入方式来替代mock,利用初始化service中的构造器来注入额外模块,比如logger。

针对业务逻辑做单测

在HTTP和数据层基本都有放心的三方库和框架来完成,但是业务逻辑层是我们自己续哟啊完成测试覆盖的,所以你必须让他们能按照你预期的工作。

努力达到高覆盖率

我之前也是对测试覆盖做到什么程度表示怀疑,一直觉得吱哟啊主逻辑链路覆盖就好了。知道最近看到一个代码库做到了100%的测试覆盖,近6个月没有出现生产事故。

遵循Arrange-Act-Assert模式

Arrange-Act-Assert模式是结构化测试通用的模式。

describe('User Service', () => {
  it('Should create a user given correct data', async () => {
    // 1. Arrange - 准备数据
    const mockUser = {
      // ...
    }
    const userService = createUserService(
      mockLogger,
      mockQueryBuilder
    )

    // 2. Act - 执行业务逻辑
    const result = userService.create(mockUser)

    // 3. Assert -效验结果
    expect(mockLogger).toHaveBeenCalled()
    expect(mockQueryBuilder).toHaveBeenCalled()
    expect(result).toEqual(/** ... */)
  })
})

性能

关于性能的话题可以写一本书。但是也有不少日常开发中需要遵循的原则来避免一些通用错误。

不要阻塞Event loop

Node如果用的合理可以达到令人震惊的性能水平。一个简单原则就是基本上Node上的业务都不能太大,虽然我们可以不深入Event Loop,但也需要谨记它是单线程的,而且在不断的切换任务。所以应尽量避免CPU高复杂的运算任务,这样我们才能充分利用这种切换机制。

那到底有哪些阻塞操作呢?比如解析很大的JSON对象,对大数据的处理,运行复杂正则匹配和读文件。如果你的应用中有这些操作就需要优化它们,或这扔到外部的队列中。NOde最适合做高IO的工作,如果使用合理,Node的单线程Event Loop可以媲美很多多线程的应用。

不要去优化算法复杂度

你看大多数的服务,代码执行耗时基本都是可以忽略的。因为算法复杂度导致你业务性能瓶颈的场景非常少。更重要的是去考虑和外部服务的通行。比如一个慢查询就会严重降低你的响应时间,要以最快的方式来从数据库获取数据,这也是为什么我不建议ORM的原因。

不要过早优化

在软件工程领域有个著名的谚语是过早的优化是万恶之源。