后端开发日记①:多模块工程与业务接口分层

开始接触后端以后,第一件事不是立刻写接口,而是先弄清一次请求会经过哪些模块。这个项目从通用能力、权限框架到无人机配送业务都放在同一个 Maven 聚合工程里,如果没有先确定边界,很容易把业务代码写进入口模块,或者让 Controller 承担过多职责。
1. 工程模块怎么分
父工程统一管理 Java 17、Spring Boot、MyBatis、PageHelper 等依赖版本,实际代码按职责拆分:
| 模块 | 职责 |
|---|---|
uav-admin | Web 服务启动入口与系统级 Controller |
uav-common | 返回结构、分页、异常、工具类等通用能力 |
uav-framework | Spring Security、Token、Redis 与系统配置 |
uav-system | 用户、角色、菜单、字典等后台基础功能 |
uav-quartz | 定时任务调度 |
uav-generator | 数据表代码生成 |
uav-manage | 订单、异常、飞行、用户、报表等配送业务 |
uav-admin 依赖其他模块并负责启动,但真正的配送逻辑集中在 uav-manage。这样既能复用后台框架,也不会让业务代码和系统管理代码互相缠绕。
2. 一次请求的调用链
业务接口基本遵循四层结构:
HTTP 请求 -> Controller:接收参数、鉴权、分页、组装响应 -> Service:校验业务条件、控制事务、组织多表操作 -> Mapper:声明数据访问方法 -> Mapper XML:编写 SQL 与结果映射例如用户查询异常反馈列表时,Controller 只负责开启分页并返回统一表格结构:
@GetMapping("/list")public TableDataInfo list(HyExceptionTicket query) { startPage(); List<HyExceptionTicket> rows = service.selectUserExceptionTicketList(query); return getDataTable(rows);}当前用户是谁、允许查询哪些数据,则由 Service 根据安全上下文补充,不能相信前端传来的用户 ID。
3. 两类接口边界
同一张业务表会同时服务管理端和小程序,但两端权限完全不同。项目在 Controller 层直接区分:
controller/admin面向后台人员,配合@PreAuthorize做菜单权限校验;controller/user面向当前微信用户,只允许访问自己的订单、地址、凭证和消息;- 通用业务规则继续复用同一个 Service,避免两套状态逻辑逐渐不一致。
这种拆分比在一个接口里判断“来源是后台还是小程序”更清楚。接口路径本身就表达调用方,审查权限时也更容易发现遗漏。
4. 统一返回与分页
普通接口使用 AjaxResult,列表接口使用 TableDataInfo。BaseController 已经封装了常见处理:
startPage(); // 从请求参数读取 pageNum、pageSizegetDataTable(list); // 返回 rows、total、code、msgtoAjax(rows); // 根据受影响行数返回成功或失败success(data); // 返回业务数据分页必须在 Mapper 查询之前开启,因为 PageHelper 依赖当前线程中的分页参数。把 startPage() 放到查询之后,返回结构看起来仍然正确,实际却会一次读取全部数据。
5. DTO、实体和 VO 不混用
项目里三类对象承担不同任务:
- DTO 接收某个动作需要的参数,例如下单、审核、异常处置;
- Entity 对应数据库记录,供 Mapper 读写;
- VO 控制返回给前端的字段,例如只返回
hasImage,不暴露磁盘路径。
不是每个简单 CRUD 都必须建立三套对象,但涉及身份、文件路径或后台字段时,直接返回实体会扩大接口暴露面。后续的用户资料、配送凭证和报表接口都采用了这种隔离。
6. Spring 如何找到这些类
项目启动类位于 com.ahebai 根包,@SpringBootApplication 默认从当前包向下扫描。只要各模块最终位于同一个运行时 classpath 中,下面这些类就会被注册到 Spring 容器:
@RestControllerpublic class UserHyDeliveryOrderController {}
@Servicepublic class HyDeliveryOrderServiceImpl implements IHyDeliveryOrderService {}
@Configurationpublic class ApplicationConfig {}这里需要区分“目录存在”和“Bean 已注册”。Java 文件放在 service 目录下不会自动变成 Service,真正触发组件扫描的是 @Service;配置类也必须通过 @Configuration 或显式导入进入容器。
Mapper 稍有不同。它是接口,没有可直接实例化的实现类,因此项目使用:
@MapperScan("com.ahebai.**.mapper")MyBatis 会为扫描到的接口生成代理对象。当 Service 调用 hyDeliveryOrderMapper.select...() 时,运行的并不是手写 Java 实现,而是代理根据 namespace、方法名和参数找到 Mapper XML 中的 SQL。
7. 以查询接口为例看完整代码关系
一个分页查询通常由 Controller、Service、Mapper 和 XML 四段组成。
Controller:
@GetMapping("/list")public TableDataInfo list(HyDeliveryOrder query) { startPage(); List<HyDeliveryOrder> rows = deliveryOrderService.selectUserDeliveryOrderList(query); return getDataTable(rows);}逐行来看:
@GetMapping把 GET 请求映射到当前方法;- Spring MVC 把查询参数绑定到
query; startPage()读取pageNum、pageSize并设置 PageHelper 上下文;- Service 查询当前用户可见的订单;
getDataTable使用实际列表和 PageHelper 统计结果生成rows、total。
Service 不直接信任请求中的用户字段:
public List<HyDeliveryOrder> selectUserDeliveryOrderList( HyDeliveryOrder query) { HyDeliveryOrder safeQuery = query == null ? new HyDeliveryOrder() : query; safeQuery.setWxUserId(SecurityUtils.getUserId()); safeQuery.setDelFlag("0"); return deliveryOrderMapper.selectHyDeliveryOrderList(safeQuery);}这段代码同时补充了用户 ID 和有效数据标记。即使调用者传入另一个 wxUserId,最终也会被安全上下文中的值覆盖。
Mapper 接口只声明能力:
List<HyDeliveryOrder> selectHyDeliveryOrderList( HyDeliveryOrder query);Mapper XML 再根据非空字段动态拼接 SQL。四层各自只处理自己理解的内容:Controller 理解 HTTP,Service 理解当前用户,Mapper 理解数据访问,XML 理解数据库字段。
8. 新增接口为什么更强调 Service
查询失败通常不会改变数据,新增和状态流转则可能同时影响多张表。以创建订单为例:
@Transactional(rollbackFor = Exception.class)public HyDeliveryOrder createUserDeliveryOrder( UserDeliveryOrderCreateDto createDto) { checkUserOrderRequired(createDto); HyDeliveryOrder order = buildUserDeliveryOrder(createDto); deliveryOrderMapper.insertHyDeliveryOrder(order); insertUserOrderProcessLog( order, "CREATE_ORDER", "用户下单", "用户提交订单,订单进入待支付状态"); return order;}如果把“写订单”和“写过程日志”分别放在 Controller 中,第二步失败时第一步可能已经提交。Service 使用事务把它们定义为一个业务动作,异常抛出后由 Spring 事务代理统一回滚。
buildUserDeliveryOrder 的存在也很重要。它把 DTO 中允许使用的字段复制到实体,并由后端补充订单号、用户 ID、初始状态和创建时间。这样请求对象无法直接覆盖系统字段。
9. 模块依赖应该保持单向
可以把当前项目的主要依赖简化为:
uav-admin ├─ uav-system ├─ uav-framework ├─ uav-quartz ├─ uav-generator └─ uav-manage ├─ uav-framework └─ uav-common业务模块依赖通用模块是合理的,通用模块反过来依赖订单实体就会形成问题:以后任何项目想复用 uav-common,都被迫带上无人机配送业务。
代码层也遵循相同原则:
Controller -> Service -> Mapper -> 第三方平台适配层 -> 其他领域 Service当两个业务 Service 相互调用甚至形成循环依赖时,通常说明职责切分需要重新考虑。可以把共同动作抽成领域协作服务,或者通过事件和明确的编排 Service 组织,而不是让两个实现类互相注入。
10. 分层不是越多越好
简单字典查询可能只需要 Controller、Service 和 Mapper,没有必要为每个字段再建立一层转换器。相反,订单、凭证和微信用户包含权限与敏感字段,就值得使用 DTO 和 VO。
判断是否拆层可以看三个问题:
- 这里是否跨越了 HTTP、业务、数据库或第三方平台边界?
- 这段逻辑是否需要被另一个入口复用?
- 这个对象是否包含调用方不应该写入或看到的字段?
只要其中一个答案为“是”,独立层或独立对象通常就有实际价值。分层的目标不是文件数量整齐,而是让变化被限制在正确的位置。
11. 本篇小结
多模块并不是为了把目录变多,而是先划清稳定能力和业务能力的边界。Controller 保持薄、Service 承担业务、Mapper 专注数据访问,再配合统一响应和端侧隔离,后面的订单状态、异常处置与第三方平台接入才有清晰的落点。
支持与分享
如果这篇文章对你有帮助,欢迎分享给更多人或打赏支持!




























