业务逻辑从路由里拆出来:Service 层的职责边界与复用

2026-09-22 58 浏览 0 评论 0 字

Service 层的核心价值是「去 HTTP 化、可复用、可测试」。本文讲解为何要把业务逻辑从路由回调里拆出来,明确路由/Controller、Service、Model/DAO 三层的职责边界,并给出创建订单这种典型场景的封装示例,演示一个不依赖 req/res 的 Service 如何被路由、定时任务等多处复用。

问题背景

一开始图省事,我习惯把业务逻辑直接写进路由的回调里:拿参数、查库、算价格、改库存、写日志,一口气全堆在 router.post('/order') 的函数体里。结果只要这段逻辑被别的地方复用(比如定时任务、后台接口、消息队列消费者),就只能复制粘贴,改一处漏三处。拆出 Service 层,就是为了让业务逻辑 只写一遍、处处可用 。

原理说明

分层的关键是每个"层"只干自己的活,别越界:

  • 路由 / Controller 层 :只负责 接请求、取参数、校验入参、把结果塞进响应。不写查库、不算业务。
  • Service 层 :负责真正的业务逻辑。它可以编排多个数据模型,做事务、算价格、检查状态,抛出业务错误。它是可复用的核心,不依赖 HTTP 对象( req / res ),这样既能被路由调用,也能被定时任务、脚本调用。
  • Model / DAO 层 :只做数据读写,不含业务判断。

判定标准很简单: 如果一段代码同时又"校验入参"又"算业务"又"管响应格式",那它就横跨了三层 ,就该拆。Service 的方法签名应该是普通函数参数(比如传对象、传 id),而不是 req ,这样它就对 HTTP 无感知。

示例代码

一个"创建订单"的 Service:

// services/orderService.js
const db = require('../models/db');

// 创建订单:扣库存 + 写订单在一个事务里完成
async function createOrder({ userId, items }) {
  // 业务校验:入参不合规直接抛错,由上层统一处理
  if (!items || items.length === 0) {
    const err = new Error('下单商品不能为空');
    err.status = 400;
    throw err;
  }

  const connection = await db.getConnection();
  try {
    await connection.beginTransaction();

    const total = await calcTotal(items);     // 计算总价
    await deductStock(connection, items);     // 扣库存

    const [result] = await connection.query(
      'INSERT INTO orders (user_id, total) VALUES (?, ?)',
      [userId, total]
    );
    await connection.commit();

    return { id: result.insertId, total };
  } catch (e) {
    await connection.rollback();
    throw e;
  } finally {
    connection.release();
  }
}

async function calcTotal(items) {
  // 业务计算逻辑,可单独复用和测试
  return items.reduce((sum, it) => sum + it.price * it.qty, 0);
}

module.exports = { createOrder, calcTotal };

路由层引用它,变得非常干净:

// controllers/orderController.js
const orderService = require('../services/orderService');

async function create(req, res, next) {
  try {
    const order = await orderService.createOrder({ userId: req.user.id, items: req.body.items });
    res.status(201).json({ code: 0, data: order });
  } catch (err) {
    next(err);   // 交给统一异常兜底
  }
}

新的使用方(比如一条定时任务)也能直接复用:

// jobs/retryOrder.js
const orderService = require('../services/orderService');
await orderService.createOrder({ userId, items });

常见坑与解决方案

  • 坑一:Service 方法里写了 req.params 或 res.status ,导致它离不开 HTTP。解法:参数用普通值传入,返回普通数据, res 交给调用方。
  • 坑二:一个 Service 方法做了三件事,又查又算又发通知,改动牵连过大。解法:拆成粒度更小的纯函数,再在高层编排。
  • 坑三:事务忘记提交或回滚,数据错乱。解法:严格 beginTransaction + 成功 commit + 异常 rollback , finally 里释放连接。
  • 坑四:业务错误直接用普通 Error 抛出,前端拿不到合适的提示。解法:给错误对象挂上 status 和业务码,供异常兜底中间件读取(下一篇会细讲统一异常)。

小结

Service 层的价值在于"去 HTTP 化、可复用、可测"。边界守住了,路由永远是薄薄一层,业务可以任意调用。下一节我们把异常处理规范化,让 Service 抛的错误能变成前端看得懂的东西。

FAQ(常见问题)

Q:Service 方法里能不能直接用 req 和 res ?
A:不能。Service 一旦依赖 HTTP 对象就失去了复用价值,只能被路由调用。正确做法是用普通函数参数(对象、id 等)传入,返回普通数据, res 交给调用方去写响应。

Q:一个 Service 方法到底该做几件事?
A:尽量小而专注。如果一个方法既校验入参又算业务又发通知还管响应格式,那就横跨了多层,应该拆成粒度更小的纯函数再在高层编排,否则改动一个点就牵连一大片。

Q:事务处理最容易在哪里出错?
A:最容易漏掉提交或回滚。要用严格的 beginTransaction + 成功 commit + 异常 rollback 结构,并在 finally 里释放连接,保证连接不泄漏、数据不错乱。


发布评论

发布评论前请先 登录。
0 评论
点赞
收藏

评论列表 0

暂无评论