Node.js后端开发:Service/Controller层、业务逻辑与项目结构咨询
Node.js 后端分层架构实践(适配Express生态技术栈)
以下实践完全对齐Java EE/Spring生态成熟的分层设计原则,遵循职责分离、接口隔离、可测试的核心要求,适配你当前使用的Express、Socket.io、RabbitMQ、Passport.js、Sequelize/Mongoose技术栈,也兼容后续GraphQL接入场景。
一、分层边界与职责划分
可以直接映射你熟悉的Spring生态分层逻辑,各层严格遵守单一职责,绝对不跨层写逻辑:
- 路由层:仅做请求与处理逻辑的绑定,对应Spring MVC中
@RequestMapping的注册逻辑,职责包括:Express路由注册、Socket.io事件监听绑定、后续GraphQL的Schema与Resolver绑定。路由层绝对不能承担Controller职责,仅可挂载全局中间件(跨域、Body解析、Passport鉴权),不要在路由回调中写参数处理、业务调用、响应组装逻辑,否则后续做HTTP/Socket/GraphQL多协议适配时,同一份业务逻辑会重复写三遍,和早年JSP嵌Scriptlet、Servlet里硬写JDBC的问题完全一致。 - Controller层:必须独立拆分,和Spring MVC的Controller职责完全等价,仅做三件事:解析对应协议的上下文参数(HTTP请求体/查询参数、Socket连接上下文与事件Payload、Passport注入的
req.user信息、GraphQL入参)、调用对应Service处理业务、捕获业务异常后组装统一格式的响应/错误返回。Controller层不写任何业务规则、不直接操作数据库。 - Service层:是唯一存放业务逻辑的位置,对应Spring的Service层,所有业务规则校验、数据库事务控制、跨Model的数据操作、RabbitMQ消息投递/消费编排、邮件/日志等通用能力的调用组合,全部放在Service层。Service层要做到和协议完全解耦,不感知上层是HTTP、Socket还是GraphQL,不直接引用Express的
req/res对象,保证逻辑可复用。 - Repository/DAO层:对应Spring Data JPA/MyBatis Mapper层,仅做单表/单集合的CRUD封装,Sequelize的Model定义、Mongoose的Schema与基础查询方法放在这一层,不要把跨表/跨集合的业务逻辑写在Model的实例/静态方法里,跨域数据逻辑全部下沉到Service层。
二、Service层的实现方式选择
类写法、高阶函数(工厂函数)写法都可以,没有本质的优劣差异,核心要求只有两点:依赖可注入、与框架解耦,不要在Service文件里硬编码导入数据库连接、MQ客户端等外部依赖,避免测试时无法Mock。
类写法(对齐Java编码习惯)
通过构造函数注入依赖,和Spring构造注入的逻辑完全一致,适合习惯面向对象编码的团队:
// services/user.service.js export class UserService { // 所有依赖通过构造函数传入,测试时可直接传入Mock对象 constructor({ userRepo, mqClient, mailClient }) { this.userRepo = userRepo; this.mqClient = mqClient; this.mailClient = mailClient; } async createUser(createDto) { // 业务逻辑:校验用户名重复、密码加密、创建用户、发通知邮件、投递MQ消息 const existUser = await this.userRepo.findOne({ username: createDto.username }); if (existUser) throw new Error('用户名已存在'); const user = await this.userRepo.create({ ...createDto, password: hashPassword(createDto.password) }); await this.mailClient.sendRegisterMail(user.email); await this.mqClient.sendToQueue('user.registered', { userId: user.id }); return user; } }
高阶函数(工厂函数)写法
通过函数参数注入依赖,返回封装了业务方法的对象,适合偏好函数式编码的团队,可测试性和类写法完全一致:
// services/user.service.js export function createUserService({ userRepo, mqClient, mailClient }) { return { async createUser(createDto) { // 业务逻辑和类写法完全一致 } } }
不要为了追求“写法新潮”强行选择不熟悉的编码方式,只要满足依赖注入要求,两种写法的可维护性、可测试性没有区别。
三、Mocha友好的可测试代码规范
完全对齐JUnit/Spring Test的测试思路,核心是让业务逻辑和外部依赖解耦,不需要启动完整服务就能跑单元测试:
- 所有层的依赖全部通过构造函数/工厂函数参数传入,不要在业务代码里硬编码导入数据库实例、MQ客户端等外部依赖,测试时直接传入Sinon生成的Stub/Mock对象即可,不需要连接真实数据库、不需要启动Express服务就能跑Service层测试。
- Controller层单元测试仅校验三个点:参数解析是否正确、是否调用了对应Service的正确方法、返回的响应格式/状态码是否符合预期,Service层逻辑全部Mock,不需要跑真实业务流程。
- Service层是单元测试的核心,覆盖所有业务分支、异常场景,所有外部依赖(Repository、MQ、邮件客户端)全部Mock,单测运行速度可以达到毫秒级,和Java中单元测试Service的逻辑完全一致。
- 集成测试单独存放,启动测试环境的数据库、MQ实例,通过真实HTTP请求/Socket事件触发全链路流程,对应Spring Boot的集成测试逻辑。
Mocha测试Service的示例代码:
// test/unit/services/user.service.test.js import { expect } from 'chai'; import sinon from 'sinon'; import { UserService } from '../../../services/user.service.js'; describe('UserService', () => { describe('createUser', () => { it('用户名已存在时抛出业务错误', async () => { const mockUserRepo = { findOne: sinon.stub().resolves({ id: 1, username: 'test' }), create: sinon.stub() }; const userService = new UserService({ userRepo: mockUserRepo, mqClient: { sendToQueue: sinon.stub() }, mailClient: { sendRegisterMail: sinon.stub() } }); try { await userService.createUser({ username: 'test', password: '123456' }); expect.fail('应当抛出业务错误'); } catch (err) { expect(err.message).to.equal('用户名已存在'); expect(mockUserRepo.create.called).to.equal(false); } }); }); });
四、适配当前技术栈的注意事项
- Passport.js的鉴权逻辑全部封装在策略实现里,鉴权通过后的用户信息统一挂在
req.user/Socket连接上下文上,Controller直接读取即可,不要在Service层处理鉴权逻辑,保证Service的无状态性。 - Socket.io事件处理、后续GraphQL的Resolver直接复用同一套Controller/Service逻辑,不要单独编写业务代码,仅在Controller层做不同协议的参数适配即可。
- Sequelize/Mongoose的Model仅做数据结构定义和单表基础查询,跨表关联查询、数据一致性逻辑全部放在Service层实现。
- 业务逻辑统一抛自定义业务错误,由Controller层捕获后转换为对应协议的错误格式,不要在Service里直接操作响应对象,避免和框架强绑定。
内容的提问来源于stack exchange,提问作者Jerome Guenon
相关产品推荐
相关产品推荐

