一、为什么需要清晰的接口边界?
很多团队做前后端分离时,习惯直接把数据库表结构暴露成接口。比如用户表有id, name, email, password, created_at,后端就直接给前端返回这些字段,前端想改密码就发PUT /api/user带上所有字段。这种设计看似省事,但一旦业务逻辑复杂起来,问题就来了。
举个例子:线下餐馆点菜。厨师直接对着顾客的原始需求炒菜,顾客说“我要一份番茄炒蛋不要葱”,厨师就得从这句话里自己琢磨“蛋要几个?番茄要不要去皮?”。如果顾客换了个说法,厨师又得重新理解。而干净的做法是:前厅服务员(控制器)把顾客需求整理成标准单据(用例输入),传给后厨(领域层)。后厨只关心“一份标准番茄炒蛋,去葱花”,不管单据从哪来、怎么送。这就是Clean Architecture的核心理念——让业务逻辑(领域层)不知道自己在被HTTP调用。
前后端分离的API设计也一样。如果我们直接把HTTP请求里的数据塞给业务逻辑,业务逻辑就变成了“HTTP处理逻辑”,无法脱离网络框架测试,也无法轻松替换成别的传输方式(比如消息队列)。所以我们需要一条明确的边界:控制器负责解析HTTP请求,转换成领域理解的输入对象,然后调用用例;用例返回结果后,控制器再转换成HTTP响应。
二、核心概念:用例与接口的映射
2.1 什么是用例?
用例(Use Case)是业务系统中的一项完整功能。比如“用户注册”是一个用例,“修改密码”是另一个。用例只关心“做什么”,不关心“怎么传输”。在Clean Architecture中,用例属于应用层,它依赖领域层的实体和接口,但不依赖外部框架。
每个用例通常有一个输入数据对象(Request)和一个输出数据对象(Response)。输入对象包含用例需要的数据,输出对象包含用例执行后的结果。这些对象是纯粹的数据结构,不沾任何HTTP相关的东西。
2.2 如何将用例暴露为REST API?
把用例映射成REST接口,关键在于“一对一”或“一对多”?通常一个用例对应一个HTTP端点。比如“用户注册”对应POST /api/users,“获取用户信息”对应GET /api/users/:id。控制器从请求中提取参数,组装成用例输入对象,然后执行用例,最后把用例输出对象序列化成JSON返回。
注意:一个HTTP端点可能涉及多个用例吗?有可能,比如复杂查询需要调用多个用例,但站在Clean Architecture角度,那应该拆成组合用例或者引入一个编排层,而不是让控制器同时调多个用例。控制器的职责是薄薄一层,只做转换。
三、实战示例:以用户注册功能为例
下文所有代码使用技术栈:JavaScript + Node.js + Express。
3.1 领域层:注册用例
我们先定义用例输入和输出对象,这些是纯JavaScript对象,不含任何请求/响应依赖。
// src/application/use-cases/register-user.use-case.js
/**
* 注册用例的输入数据
* @typedef {Object} RegisterUserInput
* @property {string} name - 用户名
* @property {string} email - 邮箱
* @property {string} password - 明文密码
*/
/**
* 注册用例的输出数据
* @typedef {Object} RegisterUserOutput
* @property {string} id - 用户ID
* @property {string} name - 用户名
* @property {string} email - 邮箱
* @property {Date} createdAt - 创建时间
*/
/**
* 注册用例
* 它只依赖领域接口(比如用户仓储),不依赖HTTP
*/
class RegisterUserUseCase {
/**
* @param {Object} dependencies
* @param {import('../../domain/repositories/user-repository')} dependencies.userRepository
* @param {import('../../domain/services/password-hasher')} dependencies.passwordHasher
*/
constructor({ userRepository, passwordHasher }) {
this.userRepository = userRepository;
this.passwordHasher = passwordHasher;
}
/**
* 执行注册
* @param {RegisterUserInput} input
* @returns {Promise<RegisterUserOutput>}
*/
async execute(input) {
// 1. 验证数据(简单示例,实际应抛出自定义错误)
if (!input.email || !input.password) {
throw new Error('邮箱和密码不能为空'); // 真实项目中应使用领域异常
}
// 2. 检查邮箱是否已存在
const existingUser = await this.userRepository.findByEmail(input.email);
if (existingUser) {
throw new Error('邮箱已被注册');
}
// 3. 加密密码
const hashedPassword = await this.passwordHasher.hash(input.password);
// 4. 创建用户实体
const user = {
name: input.name,
email: input.email,
password: hashedPassword,
createdAt: new Date()
};
// 5. 保存用户
const savedUser = await this.userRepository.save(user);
// 6. 返回输出对象(不包含密码)
return {
id: savedUser.id,
name: savedUser.name,
email: savedUser.email,
createdAt: savedUser.createdAt
};
}
}
module.exports = RegisterUserUseCase;
3.2 控制器层:处理HTTP请求
控制器负责接收Express的req和res,从中提取数据传给用例,并把用例结果格式化成HTTP响应。
// src/interface-adapters/controllers/register-user.controller.js
/**
* 注册用户控制器
* 职责:从HTTP请求提取数据,调用用例,构造HTTP响应
*/
class RegisterUserController {
/**
* @param {import('../../application/use-cases/register-user.use-case')} registerUserUseCase
*/
constructor(registerUserUseCase) {
this.registerUserUseCase = registerUserUseCase;
}
/**
* Express中间件风格的处理函数
*/
async handle(req, res) {
try {
// 1. 从请求体中提取数据,构造用例输入
const input = {
name: req.body.name,
email: req.body.email,
password: req.body.password
};
// 2. 调用用例(领域层完全不知道HTTP存在)
const output = await this.registerUserUseCase.execute(input);
// 3. 将用例输出转换为HTTP响应
res.status(201).json({
success: true,
data: output
});
} catch (error) {
// 简单的错误处理:区分业务错误和系统错误
if (error.message === '邮箱已被注册' || error.message === '邮箱和密码不能为空') {
return res.status(400).json({
success: false,
error: error.message
});
}
console.error('注册失败:', error);
res.status(500).json({
success: false,
error: '服务器内部错误'
});
}
}
}
module.exports = RegisterUserController;
3.3 路由层:定义端点
路由层只是把HTTP方法和路径绑定到控制器的处理方法上。
// src/infrastructure/http/routes/user.routes.js
const express = require('express');
const RegisterUserController = require('../../../interface-adapters/controllers/register-user.controller');
/**
* 用户相关路由
* 注意:这里不直接引用用例,而是通过依赖注入获取控制器实例
* 实际项目中应该用IoC容器管理依赖
*/
function createUserRoutes(registerUserUseCase) {
const router = express.Router();
const controller = new RegisterUserController(registerUserUseCase);
/**
* POST /api/users - 注册用户
*/
router.post('/users', (req, res) => controller.handle(req, res));
return router;
}
module.exports = createUserRoutes;
3.4 完整应用组装
最后在Express入口文件里,组装所有依赖并启动服务。
// src/app.js
const express = require('express');
const createUserRoutes = require('./infrastructure/http/routes/user.routes');
const RegisterUserUseCase = require('./application/use-cases/register-user.use-case');
// 假设已经有对应的仓储和密码哈希器实现
const InMemoryUserRepository = require('./infrastructure/persistence/in-memory-user-repository');
const BcryptPasswordHasher = require('./infrastructure/services/bcrypt-password-hasher');
const app = express();
app.use(express.json());
// 实例化领域依赖
const userRepository = new InMemoryUserRepository();
const passwordHasher = new BcryptPasswordHasher();
const registerUserUseCase = new RegisterUserUseCase({ userRepository, passwordHasher });
// 挂载路由
app.use('/api', createUserRoutes(registerUserUseCase));
// 启动服务器
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});
通过这个示例可以看到,RegisterUserUseCase里完全没有req、res、status、json这些词,它只和纯JavaScript对象打交道。即使以后要把注册功能改成通过gRPC或者消息队列调用,用例本身不需要改任何代码,只需要新增一个对应的控制器即可。
四、应用场景分析
这种设计特别适合大型项目或者需要长期维护的系统。比如:
- 微服务架构:每个服务内部都是Clean Architecture,HTTP只作为入口之一,还可以通过事件驱动调用用例。
- 多端支持:同样的用例可以被Web端、移动端、第三方开放API调用,只需要为每个端写不同的控制器,但用例复用。
- 单元测试:测试用例时不需要启动HTTP服务器,直接构造输入对象调用
execute方法即可,速度极快。
不适合的场景:
- 非常简单的CRUD应用,比如只有一张表的增删改查,过度分层反而增加代码量。
- 快速原型开发,追求迭代速度时,先精简架构后期再重构。
五、技术优缺点
优点:
- 业务逻辑与框架解耦:更换Web框架(从Express到Koa甚至Fastify)只需要改控制器和路由,领域代码不动。
- 可测试性强:用例可以脱离网络层独立测试。
- 清晰的责任边界:新成员一看就知道该在哪添加代码——修改业务逻辑进用例,修改API格式进控制器。
- 更容易进行代码审查和变更管理。
缺点:
- 初期代码量多:每个用例都要写输入/输出对象、控制器、路由,比直接写
router.post('/api/users', (req,res)=>{...})多几步。 - 需要团队熟悉Clean Architecture的概念,学习成本较高。
- 对于极简单的接口,可能感觉“杀鸡用牛刀”。
六、注意事项
- 不要过度抽象:只有真正存在多种传输方式或技术栈替换需求时,才需要严格隔离。如果项目永远不会换框架,适当简化控制器层是可以接受的。
- 错误处理分离:用例抛出的错误应该是业务错误(如“邮箱重复”),而不是HTTP状态码。控制器负责把这些业务错误映射成合适的HTTP响应(比如400、409等)。
- 输入验证位置:简单的格式验证(如邮箱格式)可以放在控制器层,因为那属于网络传输层的校验;但业务校验(如余额是否充足)必须放在用例中。不要把业务规则泄露到控制器。
- DTO(数据传输对象)的选择:用例输入输出尽量使用简单的Plain Object,不要使用ORM实体或数据库模型,避免领域层依赖数据框架。
- 事务管理:如果用例需要跨多个仓储操作,事务通常应该由用例控制,但数据库事务往往依赖特定框架(如TypeORM的
transaction)。此时可以定义一个事务接口(ITransactionManager)在领域层,然后在基础设施层实现,这样领域层仍然保持透明。
七、文章总结
前后端分离的接口设计并不是写几个REST端点那么简单。Clean Architecture告诉我们,把HTTP请求和响应的转换限制在控制器层,让领域层专注于业务逻辑,是让代码保持灵活、可测试、可演化的关键。通过用例来定义API边界,每个端点对应一个明确的业务功能,控制器只做“翻译官”的工作。虽然初期需要多写一些胶水代码,但长期来看,当需求频繁变更、团队扩大时,这种投入绝对值得。
记住一点:不要让HTTP请求像病毒一样感染你的业务逻辑。保持领域层纯净,你的系统才能健康生长。
Comments