Kodokon kodokon.com

为 API 分层:路由、控制器、服务

把你的 API 拆分为职责单一的各层,并用轻量的依赖注入把它们连接起来,让它变得可测试。

10 分钟 · 3 题

在 Kodokon 中打开本课

一个把路由、业务逻辑和数据访问混在一起的文件,最终总是代价高昂:你没法在不启动服务器的情况下测试业务规则,也没法在不重写一切的前提下替换基础设施。拆分成三层能同时解决这两个问题。路由声明 URL 并进行委派。控制器把 HTTP 翻译成业务调用:读取请求、选择状态码、序列化响应。服务持有业务规则,且对 Express 一无所知。依赖规则很严格:每一层只认识它下面的那一层,反过来绝不允许。

JAVASCRIPT
import { Router } from 'express';

export function createUserRouter(controller) {
  const router = Router();
  router.get('/', controller.list);
  router.post('/', controller.create);
  return router;
}
路由负责声明,控制器负责行动:这里没有任何逻辑。

控制器是一个把服务作为参数接收进来的工厂。它不持有任何业务规则:它的职责仅限于从 req 中提取数据、调用服务,再把结果转换成 HTTP 响应。任何错误都交给 next,由中央错误中间件来处理,绝不在各个控制器里散落地写 res.status(500)

JAVASCRIPT
export function createUserController(service) {
  return {
    async list(req, res, next) {
      try {
        const users = await service.listUsers();
        res.json(users);
      } catch (err) {
        next(err);
      }
    },
    async create(req, res, next) {
      try {
        const user = await service.createUser(req.body);
        res.status(201).json(user);
      } catch (err) {
        next(err);
      }
    },
  };
}
纯粹的 HTTP 翻译:状态码、序列化、委派。

服务集中承载各项决策:邮箱唯一性、创建规则。它抛出业务错误(最多附带一个 status 字段),同时对 Express 一无所知。它同样把自己的依赖,也就是数据仓储,作为参数接收进来。这就是轻量注入:在启动时调用一次的普通工厂函数,取代了笨重的注入容器,并让每一层在测试中都可以被替换。

JAVASCRIPT
export function createUserService(repository) {
  return {
    listUsers() {
      return repository.findAll();
    },
    createUser(input) {
      const found = repository.findByEmail(input.email);
      if (found) {
        const err = new Error('Email already used');
        err.status = 409;
        throw err;
      }
      return repository.insert(input);
    },
  };
}
业务逻辑住在这里,没有 req、res,也没有 SQL。

剩下的就是组装根:唯一一处把各个工厂拼装到一起的地方。createApp 接收基础设施依赖(这里是数据库,下一课会讲),并返回一个完整但尚未开始监听的 Express 应用。把构造和监听分开看似微不足道;但正是它让测试能把 API 挂载到一个临时端口上,而生产环境的服务器仍然只有三行:创建数据库、创建应用、开始监听。

JAVASCRIPT
import express from 'express';
import { createUserRepository } from './db/users.js';
import { createUserService } from './services/users.js';
import {
  createUserController,
} from './controllers/users.js';
import { createUserRouter } from './routes/users.js';

export function createApp({ db }) {
  const app = express();
  app.use(express.json());
  const repository = createUserRepository(db);
  const service = createUserService(repository);
  const controller = createUserController(service);
  app.use('/api/users', createUserRouter(controller));
  return app;
}
app.js:应用中唯一认识所有人的部分。

知识检测

确认你已牢记本课的重点内容。

  1. 在这种分层里,"一个邮箱只能被使用一次"这条规则应该放在哪儿?
    • 放在路由里,尽可能贴近 URL
    • 放在控制器里,因为它能访问 req.body
    • 放在服务里,因为它持有业务规则
    • 放在一个全局的 Express 中间件里
  2. 把依赖作为工厂参数传入,而不是直接 import 到每个模块里,主要的好处是什么?
    • 工厂能加快模块加载
    • 你可以替换一个依赖(内存数据库、伪仓储)而不必改动使用它的模块
    • Express 要求它的中间件采用这种风格
    • 它省去了编写单独的文件
  3. 为什么 createApp 自己不开始在网络上监听?
    • 因为 Express 禁止在函数内部调用 listen
    • 这样测试就能在不打开固定端口的情况下实例化应用,从而让服务器入口保持极简
    • 因为 listen 是异步的,会阻塞工厂
    • 为了减少启动时的内存占用