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

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

开始接触后端以后,第一件事不是立刻写接口,而是先弄清一次请求会经过哪些模块。这个项目从通用能力、权限框架到无人机配送业务都放在同一个 Maven 聚合工程里,如果没有先确定边界,很容易把业务代码写进入口模块,或者让 Controller 承担过多职责。

1. 工程模块怎么分#

父工程统一管理 Java 17、Spring Boot、MyBatis、PageHelper 等依赖版本,实际代码按职责拆分:

模块职责
uav-adminWeb 服务启动入口与系统级 Controller
uav-common返回结构、分页、异常、工具类等通用能力
uav-frameworkSpring 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、pageSize
getDataTable(list); // 返回 rows、total、code、msg
toAjax(rows); // 根据受影响行数返回成功或失败
success(data); // 返回业务数据

分页必须在 Mapper 查询之前开启,因为 PageHelper 依赖当前线程中的分页参数。把 startPage() 放到查询之后,返回结构看起来仍然正确,实际却会一次读取全部数据。

5. DTO、实体和 VO 不混用#

项目里三类对象承担不同任务:

  • DTO 接收某个动作需要的参数,例如下单、审核、异常处置;
  • Entity 对应数据库记录,供 Mapper 读写;
  • VO 控制返回给前端的字段,例如只返回 hasImage,不暴露磁盘路径。

不是每个简单 CRUD 都必须建立三套对象,但涉及身份、文件路径或后台字段时,直接返回实体会扩大接口暴露面。后续的用户资料、配送凭证和报表接口都采用了这种隔离。

6. Spring 如何找到这些类#

项目启动类位于 com.ahebai 根包,@SpringBootApplication 默认从当前包向下扫描。只要各模块最终位于同一个运行时 classpath 中,下面这些类就会被注册到 Spring 容器:

@RestController
public class UserHyDeliveryOrderController {
}
@Service
public class HyDeliveryOrderServiceImpl
implements IHyDeliveryOrderService {
}
@Configuration
public 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);
}

逐行来看:

  1. @GetMapping 把 GET 请求映射到当前方法;
  2. Spring MVC 把查询参数绑定到 query;
  3. startPage() 读取 pageNum、pageSize 并设置 PageHelper 上下文;
  4. Service 查询当前用户可见的订单;
  5. 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。

判断是否拆层可以看三个问题:

  1. 这里是否跨越了 HTTP、业务、数据库或第三方平台边界?
  2. 这段逻辑是否需要被另一个入口复用?
  3. 这个对象是否包含调用方不应该写入或看到的字段?

只要其中一个答案为“是”,独立层或独立对象通常就有实际价值。分层的目标不是文件数量整齐,而是让变化被限制在正确的位置。

11. 本篇小结#

多模块并不是为了把目录变多,而是先划清稳定能力和业务能力的边界。Controller 保持薄、Service 承担业务、Mapper 专注数据访问,再配合统一响应和端侧隔离,后面的订单状态、异常处置与第三方平台接入才有清晰的落点。

支持与分享

如果这篇文章对你有帮助,欢迎分享给更多人或打赏支持!

打赏
后端开发日记①:多模块工程与业务接口分层
https://firefly.cuteleaf.cn/posts/backend-dev-diary-1/
作者
Yore
发布于
2026-06-28
许可协议
CC BY-NC-SA 4.0

评论区

Profile Image of the Author
Yore
Hello, I'm Yore.
公告
欢迎来到Yore的博客!本站基于原创作者夏叶的博客网站模版创作。
分类
标签
最新动态
站点统计
文章
51
分类
2
标签
124
总字数
94,115
运行时长
0 天
最后活动
0 天前
站点信息
构建平台
EdgeOne Pages
博客版本
Firefly v6.15.0
文章许可
CC BY-NC-SA 4.0