全部文章

Node.js 后端的四层结构:一次真实的重构记录

一个路由文件从 200 行长到 1800 行之后,我做了一次分层重构。这篇记录了坏味道长什么样、四层的边界怎么划、事务放哪一层。

11 分钟 · 1768 字

去年我接手维护的一个 Node.js 服务,核心是一个订单相关的模块。最开始只有一个 routes/orders.ts,两百多行,看着挺清爽。半年后它变成了 1800 行,任何改动都要先花二十分钟读代码确认「有没有别的地方也依赖这段逻辑」。

这不是某一个人的问题。路由文件膨胀是所有「先跑起来再说」的服务都会走到的终点,它只是需要一次重构,而不是需要一次追责。

一、重构前的样子

这是当时最典型的一个接口,我做了删减,但保留了它全部的坏味道:

// routes/orders.ts —— 重构前
router.post('/orders', async (req, res) => {
  const { userId, items, couponCode } = req.body;

  if (!userId || !Array.isArray(items) || items.length === 0) {
    return res.status(400).json({ error: 'invalid params' });
  }

  const client = await pool.connect();
  try {
    await client.query('BEGIN');

    // 1. 校验用户
    const user = await client.query('SELECT * FROM users WHERE id = $1', [userId]);
    if (user.rows.length === 0) {
      await client.query('ROLLBACK');
      return res.status(404).json({ error: 'user not found' });
    }

    // 2. 算价格(含优惠券、会员折扣、运费)
    let total = 0;
    for (const item of items) {
      const p = await client.query('SELECT price, stock FROM products WHERE id = $1', [item.productId]);
      total += p.rows[0].price * item.quantity;
      await client.query('UPDATE products SET stock = stock - $1 WHERE id = $2', [item.quantity, item.productId]);
    }
    if (couponCode) {
      const c = await client.query('SELECT * FROM coupons WHERE code = $1', [couponCode]);
      if (c.rows.length && c.rows[0].expires_at > new Date()) total = total * 0.9;
    }
    if (user.rows[0].is_member) total = total * 0.95;

    // 3. 落库
    const order = await client.query(
      'INSERT INTO orders (user_id, total, status) VALUES ($1, $2, $3) RETURNING id',
      [userId, total, 'pending'],
    );

    await client.query('COMMIT');

    // 4. 通知(第三方 SDK,失败会抛异常)
    await notifyService.send(user.rows[0].phone, `订单 ${order.rows[0].id} 已创建`);
    await pool.query('INSERT INTO order_events (order_id, type) VALUES ($1, $2)', [order.rows[0].id, 'created']);

    res.json({ id: order.rows[0].id, total });
  } catch (err) {
    await client.query('ROLLBACK');
    res.status(500).json({ error: 'internal error' });
  } finally {
    client.release();
  }
});

问题可以列一长串,但真正致命的只有三个:

  1. 它同时承担了 HTTP、业务规则、SQL 三种职责,改运费的算法要动接口文件,风险不可控。
  2. 事务里包含了外部调用(notifyService.send)——虽然写在了 COMMIT 之后,但那段 INSERT INTO order_events 的失败会让接口返回 500,而订单其实已经创建成功了。
  3. 它没法测试。要验证「会员再打 95 折」这条规则,必须起一个数据库、准备用户和商品数据、调 HTTP 接口。

二、四层怎么分,边界在哪

我按职责切成四层,划分依据不是「业界都这么分」,而是变更频率和依赖方向:越靠上的层越容易变,越靠下的层越稳定,依赖只能从上往下。

层职责允许依赖不可以做
控制器 ControllerHTTP 出入参、状态码、鉴权服务层写业务规则、碰 SQL
服务 Service业务规则、事务边界、编排仓储接口、领域对象感知 req / res
仓储 Repository数据读写、行到对象的映射数据库客户端写业务判断
基础设施 Infra连接池、日志、配置、外部客户端——依赖上面任何一层

有一条规则是我这次重构里最坚持的:服务层不认识 req 和 res。只要服务层还接收 req,业务逻辑就一定会慢慢长回 HTTP 细节。

三、重构后的样子

先定义层间契约。类型放在独立文件里,让「谁依赖谁」在编译期就能看出来:

// domain/types.ts
export interface OrderItemInput {
  productId: string;
  quantity: number;
}

export interface Order {
  id: string;
  userId: string;
  total: number;
  status: 'pending' | 'paid' | 'cancelled';
  createdAt: Date;
}

// 仓储接口只描述「需要什么能力」,不描述「怎么实现」
export interface OrderRepository {
  create(input: { userId: string; total: number }): Promise<Order>;
  addItems(orderId: string, items: OrderItemInput[]): Promise<void>;
}

export interface ProductRepository {
  findByIds(ids: string[]): Promise<Map<string, { price: number; stock: number }>>;
  decreaseStock(id: string, quantity: number): Promise<void>;
}

数据访问层把这些接口落到 PostgreSQL 上。注意返回的一律是领域对象,snake_case 的行数据在这一层内部就被翻译掉了,不往上泄漏:

// infra/pg-order-repository.ts
import type { Order, OrderRepository } from '../domain/types.ts';
import type { Pool, PoolClient } from 'pg';

const toOrder = (row: any): Order => ({
  id: row.id,
  userId: row.user_id,
  total: Number(row.total),
  status: row.status,
  createdAt: row.created_at,
});

// client 由外部传入:可能来自连接池,也可能来自一个已开启的事务
export const createOrderRepository = (client: Pool | PoolClient): OrderRepository => ({
  async create(input) {
    const { rows } = await client.query(
      'INSERT INTO orders (user_id, total, status) VALUES ($1, $2, $3) RETURNING *',
      [input.userId, input.total, 'pending'],
    );
    return toOrder(rows[0]);
  },
  async addItems(orderId, items) {
    await client.query(
      `INSERT INTO order_items (order_id, product_id, quantity)
       SELECT $1, * FROM UNNEST($2::uuid[], $3::int[])`,
      [orderId, items.map((i) => i.productId), items.map((i) => i.quantity)],
    );
  },
});

服务层承载业务规则,并且事务边界就在这一层。理由很实际:一次下单要跨两个仓储(订单、商品),事务必须比单个仓储更大;而如果放到控制器,控制器就得同时知道数据库的存在,第 4 层和第 1 层的边界会立刻糊掉。

// services/order-service.ts
import type { OrderRepository, ProductRepository } from '../domain/types.ts';

export interface UnitOfWork {
  run<T>(fn: (tx: { orders: OrderRepository; products: ProductRepository }) => Promise<T>): Promise<T>;
}

export const createOrderService = (uow: UnitOfWork, notify: Notifier, logger: Logger) => ({
  async placeOrder(input: { userId: string; items: OrderItemInput[]; couponCode?: string }) {
    const order = await uow.run(async (tx) => {
      const prices = await tx.products.findByIds(input.items.map((i) => i.productId));

      let total = 0;
      for (const item of input.items) {
        const p = prices.get(item.productId);
        if (!p) throw new AppError('商品不存在', { status: 404 });
        if (p.stock < item.quantity) throw new AppError('库存不足', { status: 409 });
        total += p.price * item.quantity;
        await tx.products.decreaseStock(item.productId, item.quantity);
      }
      total = applyDiscounts(total, input.couponCode);

      const created = await tx.orders.create({ userId: input.userId, total });
      await tx.orders.addItems(created.id, input.items);
      return created;
    });

    // 事务提交之后再发通知:外部调用不进事务
    notify.sendOrderCreated(order).catch((err) => logger.warn({ err, orderId: order.id }, 'notify failed'));
    return order;
  },
});

依赖注入没有引入任何容器,就是最朴素的构造参数 + 工厂函数。这是我在这次重构里另一个明确取舍:装饰器和容器在学习成本、调试体验上对小团队不划算,显式传参虽然啰嗦,但顺着一个文件的 createXxx() 就能看完全部依赖。

// main.ts —— 组装根,唯一知道具体实现的地方
const pool = new Pool({ connectionString: config.databaseUrl });

const uow: UnitOfWork = {
  async run(fn) {
    const client = await pool.connect();
    try {
      await client.query('BEGIN');
      const result = await fn({
        orders: createOrderRepository(client),
        products: createProductRepository(client),
      });
      await client.query('COMMIT');
      return result;
    } catch (err) {
      await client.query('ROLLBACK');
      throw err;
    } finally {
      client.release();
    }
  },
};

const orderService = createOrderService(uow, notifyClient, logger);
app.post('/orders', createOrderController(orderService));

控制器被压到十几行,只剩 HTTP 该关心的事:

// controllers/order-controller.ts
export const createOrderController = (service: ReturnType<typeof createOrderService>) =>
  async (req: Request, res: Response, next: NextFunction) => {
    try {
      const order = await service.placeOrder(req.body);
      res.status(201).json({ id: order.id, total: order.total });
    } catch (err) {
      next(err);   // 统一交给错误中间件映射状态码
    }
  };

四、迁移顺序:不要一次重写

我最初的想法是「找个周末全量重写」,很快被自己否决了——这个模块承载着线上交易,两天不发布是不可接受的。实际采用的是绞杀者模式:新旧两条路径并存,按接口逐个迁移。

  1. 先建好四层的骨架和接口定义,此时它们还没有被任何路由使用,随时可以推翻。
  2. 挑风险最低的只读接口(订单详情查询)先迁移,验证分层确实跑得通。
  3. 迁移写接口,每迁完一个,就从旧文件里删掉对应的 handler。
  4. 全部迁完之后删除旧文件,再补上针对服务层的单元测试。

整个过程发了六次版,每次只动一个接口。中途任何一个版本出问题,回滚成本都是一次部署,而不是一次重构。 这里有个实用的小技巧:旧 handler 在删除前先改成抛出一个带堆栈的错误,如果线上还有流量打进来,日志里立刻就能看到漏迁的调用点。

五、重构之后,测试确实变简单了

这是分层最直接的收益。以前测「库存不足要拒绝下单」,需要真实数据库;现在只需要一个假的仓储:

const fakeUow: UnitOfWork = {
  run: (fn) => fn({
    orders: { create: async (i) => ({ id: 'o1', status: 'pending', createdAt: new Date(), ...i }), addItems: async () => {} },
    products: {
      findByIds: async () => new Map([['p1', { price: 100, stock: 1 }]]),
      decreaseStock: async () => {},
    },
  }),
};

const service = createOrderService(fakeUow, { sendOrderCreated: async () => {} }, silentLogger);
await expect(service.placeOrder({ userId: 'u1', items: [{ productId: 'p1', quantity: 5 }] }))
  .rejects.toThrow('库存不足');

这个测试跑完不到 10ms,而且不依赖环境。重构前那 1800 行里,能这样测的逻辑几乎为零——不是没人想写,而是没有任何一个可以被单独调用的单元。

覆盖率上也能看出来:重构前这个模块整体覆盖率是 12%(只有零星几个工具函数被测到),重构后服务层的业务规则覆盖率超过 80%。测试数量并没有增加多少,变化在于终于有东西可以被测了。

六、代价,以及什么时候不要这么分

分层是有成本的,我不想把它说成没有代价的正确答案:

  • 文件数从 3 个变成 17 个,读一个接口的完整链路需要在四个文件之间跳转。
  • 简单的 CRUD 会被写得更长。一个「按 ID 查订单」,四层走下来的代码量是直接查库的三倍。
  • 过度抽象的风险真实存在。我现在的做法是:只有当一个接口里出现了「业务规则」或者「跨两个以上数据源」,才给它建 service;纯查询接口允许控制器直连仓储。

判断标准我总结成一句话:如果一段逻辑我需要在两个以上的入口复用它,或者需要为它单独写测试,它就值得有自己的层。 否则让它留在原地,等它自己长到需要搬家的时候。

重构完成后,routes/orders.ts 从 1800 行变成了 42 行。更重要的是,后面三次需求变更(改运费规则、加优惠券类型、接入新的通知渠道)都只动了单个文件。

本文由 Kyne 撰写,采用 CC BY-NC-SA 4.0 许可,转载请注明出处。