大蜕

Fastify 模块化项目实战(一)

为什么选择 Fastify 而非 NestJS, ExpressJS, Koa?

按照我的个人经验, 之前使用 ExpressJS 来搭建项目, 项目结构基本如下所示:

src/
└───── infrastructures/
    │   ├── db.js
    │   └── server.js
    │
    ├── middlewares/
    │   └── authMiddleware.js
    │
    ├── routes/
    │   ├── authRoutes.js
    │   └── messageRoutes.js
    │
    └── services/
        ├── messagesService.js
        ├── userService.js
        └── authService.js

这次在构思模块化项目的时候, 想尝试一些新东西, 能够满足我的封装性高的需求, 首先对比了 NestJS 和 ExpressJS

对比项 NestJS ExpressJS
定位 进阶型全栈后端框架 轻量级 Web 框架
架构风格 强制 MVC + 模块化 + DI(受 Angular 启发) 无架构规范,自由度高
学习成本 🟠 中等偏高(需要理解装饰器/依赖注入) 🟢 低(API 简洁易用)
TypeScript 支持 原生支持,强类型优先 需要手动配置 TS
依赖注入(DI) 内置支持 无(通常用单例/全局或自定义 DI)
路由定义 注解式控制 手动配置路由
扩展性 ⭐⭐⭐⭐ ⭐⭐
生态和插件 模块化(Auth、Cache、Swagger、GraphQL、Jobs) 需要自行组合中间件
适合场景 企业级复杂业务、大团队合作 小项目、快速原型、灵活业务流程
性能 接近 Express 基于 Express / Fastify,性能优秀
社区 & 文档 成熟活跃 经典稳定

在这之后又了解了 Fastify 和 Koa 框架:
Fastify

  • 性能比 Express 更高
  • 插件机制
  • 原生支持超快 JSON 序列化
  • 适合高性能 API 服务
  • 学习成本略高于 Express

Koa

  • 由 Express 核心团队打造
  • Promise + async/await 原生支持
  • 更现代、更轻量
  • 中间件采用新版洋葱模型

然而 Fastify 的高性能以及插件机制吸引了我, 于是我打算基于 Fastify 开始搭建项目

开始搭建 Fastify 项目之前需要考虑的三件事

选择JS还是TS?

维度 JS TS
学习成本
开发速度(短期) 稍慢
维护成本(长期)
重构安全性
模块扩展能力
工程化能力 极高
架构演进能力
模板复用价值
项目规模适配 小项目 中大型项目
技术资产价值

对于复用价值来说, 选择TS是必须的

选择Common JS 还是 ES Module?

我们都知道JS有两种模块系统

ES Modules (ESM) 是 ECMAScript 标准化的现代 JavaScript 模块系统,使用 import/export,支持静态分析、异步加载和单例模式,适用于浏览器和新版 Node.js。CommonJS (CJS) 是 Node.js 传统的默认系统,使用 require/module.exports,同步加载模块,主要用于服务器端。

所以它们之间的本质区别是底层机制的差异: CSJ是运行时加载

require() 是函数
→ 运行到这行才加载
→ 动态加载
→ 无法静态分析依赖关系

而ESJ(编译期静态分析)

import 是语法
→ 编译期分析依赖
→ 构建依赖图
→ 支持 Tree-shaking
→ 支持模块优化

在语法层面:
CommonJS:

// 导入
const fs = require('fs')

// 导出
module.exports = {
  test() {}
}

ES Module:

// 导入
import fs from 'fs'

// 导出
export function test() {}
export default {}

于是我选择了ESM来搭建项目模块, 这也是当下的工程趋势

选择Node原生测试框架还是其它?

谈到JS测试框架, 立马能想到 Jest, Vitest, Mocha, Chai, 在JS生态里, 一般在前端会测试组件渲染, 在后端是一些简单的断言, 例如测试 Rest API 的请求, 工具库的单元测试等, 接触了 Fastify 我了解到了 Node 原生测试方法.

能力 Jest Vitest node:test
Runner
Mock ✅ 强 ✅ 强 ⚠️ 基础
覆盖率 ⚠️ 外接
Watch
快照测试
并发执行 ⚠️
ESM 支持 ⚠️ 配置复杂 ✅ 原生 ✅ 原生
TS 支持 ⚠️ 依赖 ts-jest ✅ 原生 ⚠️ 手动
生态插件 ✅ 非常成熟 🟡 成长中 ❌ 几乎无

举几个简单的例子吧, 在 vitest 里:
前端的测试:

// 使用 vitest 的方法
import { describe, expect, vi } from "vitest"
import { fireEvent, render, screen, waitFor } from "@testing-library/react"
import App from "../../src/components/App"

// 通过 vi.hoisted 来 mock 方法
const { mockGetNotes, mockDeleteNote, mockPutNote } = vi.hoisted(() => {
  return { mockGetNotes: vi.fn(), mockDeleteNote: vi.fn(), mockPutNote: vi.fn() }
})

// 使用上面的mock方法来替换后端的具体方法
vi.mock('../../src/services/notesService', () => {
  return {
    default: { getNotes: mockGetNotes, deleteNote: mockDeleteNote, putNote: mockPutNote }
  }
})

describe("render app", () => {
  it("should show notes successfully", async () => {
    //mock实现
    mockGetNotes.mockResolvedValue(["fake note1"])
    render(<App />)
    await waitFor(() => screen.getByText("fake note1"))

    expect(screen.getByText("fake note1")).toBeInTheDocument()
  })

  it("should delete note successfully when click delete note button", async () => {
    mockGetNotes.mockResolvedValue(["fake note1"])
    render(<App />)
    await waitFor(() => screen.getByText("fake note1"))

    const deleteNoteBtn = screen.getByTestId("delete-note")
    fireEvent.click(deleteNoteBtn)

    expect(mockDeleteNote).toHaveBeenCalled(1)
  })
})

后端的测试例子:

// vitest 的方法
import { beforeEach, describe, it, vi, expect } from "vitest"
import notesService from "../../src/services/notesService"

const mockSelect = vi.fn()
const mockInsert = vi.fn()
const mockDelete = vi.fn()
const mockEq = vi.fn()

// mock 基础设施(DB)的 client 的相关方法
const { supabaseMockClient } = vi.hoisted(() => {
  return {
    supabaseMockClient: {
      from: vi.fn(() => ({
        select: mockSelect,
        insert: mockInsert,
        delete: vi.fn(() => ({
          eq: mockEq
        })),
      }))
    }
  }
})

// 替换外部依赖的具体方法为mock方法
vi.mock('@supabase/supabase-js', () => ({
  createClient: vi.fn(() => supabaseMockClient),
}))

describe("notes service layer tests", () => {
  beforeEach(() => {
    vi.clearAllMocks()
  })

  it("should return notes array", async () => {
    mockSelect.mockResolvedValue({ data: [{ note: 'note1' }, { note: 'note2' }], error: null })

    const result = await notesService.getNotes()

    expect(supabaseMockClient.from).toHaveBeenCalledWith('notes-online')
    expect(supabaseMockClient.from().select).toHaveBeenCalledWith('note')
    expect(result).toEqual(['note1', 'note2'])
  })

  it("should return empty array when return error", async () => {
    mockSelect.mockResolvedValue({ data: null, error: "some errors" })

    const result = await notesService.getNotes()

    expect(result).toEqual([])
  })

  it("should save note successfully", async () => {
    mockInsert.mockResolvedValue()

    await notesService.putNote("new fake note")

    expect(supabaseMockClient.from).toHaveBeenCalledWith('notes-online')
    expect(supabaseMockClient.from().insert).toHaveBeenCalledWith({ note: "new fake note", creator: "anonymous" }, { returning: 'minimal' })
  })

  it("should delete note successfully", async () => {
    mockDelete.mockResolvedValue()
    mockEq.mockResolvedValue()

    await notesService.deleteNote("fake note")

    expect(supabaseMockClient.from().delete().eq).toHaveBeenCalledWith('note', 'fake note')
  })
})

而最近使用了 node:test, 例如:

import { describe, it } from 'node:test'
import assert from 'node:assert'
import { build, expectValidationError } from '../../../../helper.js'

describe('Security api', () => {
  describe('POST /api/v1/auth/login', () => {
    it('Transaction should rollback on error', async (t) => {
      const app = await build(t)

      const { mock: mockCompare } = t.mock.method(app.passwordManager, 'compare')
      mockCompare.mockImplementationOnce((value: string, hash: string) => {
        throw new Error('Kaboom!')
      })

      const { mock: mockLogError } = t.mock.method(app.log, 'error')

      const res = await app.inject({
        method: 'POST',
        url: '/api/v1/auth/login',
        payload: {
          email: 'basic@example.com',
          password: 'Password123$'
        }
      })

      assert.strictEqual(mockCompare.callCount(), 1)

      const arg = mockLogError.calls[0].arguments[0] as unknown as {
        err: Error;
      }

      assert.strictEqual(res.statusCode, 500)
      assert.deepStrictEqual(arg.err.message, 'Kaboom!')
    })
})

使用之后也很顺手, 一个是没有外部依赖, 另外也能满足需求, 所以目前我也并未使用其它测试框架.

开始搭建Fastify项目

TBC