接续 上一篇 MyBatis 接口映射与数据库查询接线。上一篇只查询一列
title,今天把数据库查询推进到多列查询、TicketSummaryDTO、领域对象Ticket转换,以及 Controller → Service → Repository → Mapper 的只读分层。正式/api/tickets仍使用文件仓库,数据库写入还没有形成可调用的完整接口。
1 今天补上的缺口 #
11 月 7 日已经验证:
HTTP GET
→ DatabaseTicketController
→ TicketMapper
→ MyBatis
→ MySQL
→ String / JSON但 Mapper 只返回一列:
String findTitleById(@Param("id") long id);这条链路只能证明数据库连接、参数绑定和单列结果映射成功,不能证明一个包含多个字段的对象也能正确映射。
今天的起点因此是:
SELECT id, title, status, assignee_id
FROM ticket
WHERE id = ?目标不是马上写数据库,而是先回答三个问题:
- 多列结果怎样进入一个 Java 对象;
- 这个对象是领域对象
Ticket,还是专门用于查询的 DTO; - 查询逻辑怎样从 Controller 中移到 Service 和 Repository。
2 TicketSummary 是查询 DTO #
先新建查询数据对象:
public class TicketSummary {
private long id;
private String title;
private String status;
private Long assigneeId;
// getter / setter
}它的职责只有两个:
MyBatis:数据库列 → setter → TicketSummary
Jackson:TicketSummary → getter → JSON它不负责标题校验,也不负责派单规则。
这里有两个类型选择:
id使用long,因为主键非空;assigneeId使用Long,因为assignee_id可以为NULL,未派单时必须能表示null。
一开始我还不清楚 setId、setTitle 中的值从哪里来。后来把链路拆开:
路径中的 id = 2
→ Controller 传入 Mapper
→ SQL 查询数据库
→ 数据库返回 id、title、status、assignee_id
→ MyBatis 调用 setId、setTitle、setStatus、setAssigneeId
→ Jackson 调用 getter 生成 JSONController 只提供了查询条件 id;其它字段是 SQL 查询结果。
3 多列查询与列表映射 #
TicketMapper 先增加单行对象查询:
@Select("""
SELECT id, title, status, assignee_id
FROM ticket
WHERE id = #{id}
""")
TicketSummary findSummaryById(@Param("id") long id);再增加列表查询:
@Select("""
SELECT id, title, status, assignee_id
FROM ticket
ORDER BY id
""")
List<TicketSummary> findAllSummaries();返回值从:
TicketSummary变成:
List<TicketSummary>含义是:
一行查询结果 → 一个 TicketSummary
多行查询结果 → List<TicketSummary>我实际请求 GET /api/db/tickets 后得到 200,数据库里当时有编号 1 和 3,没有编号 2:
[
{
"id": 1,
"status": "IN_PROGRESS",
"title": "空调不制冷(提交练习)"
},
{
"id": 3,
"status": "OPEN",
"title": "打印机卡纸(JDBC 提交练习)"
}
]这同时验证了三件事:
- 多行记录能映射成多个对象;
- 编号不连续是正常现象,查不到 id=2 不等于接口出错;
- JSON 字段顺序和 Java 属性声明顺序不同,不影响按字段名映射。
ORDER BY id 不是装饰。没有排序时,数据库返回顺序可能不稳定,测试结果也更难比较。
4 从 assignee_id 到 assigneeId #
数据库列名是:
assignee_idJava 属性名是:
private Long assigneeId;最开始可以通过 SQL 别名手工对齐:
assignee_id AS assigneeId但这意味着每个查询都要重复写别名。
后来在 application.properties 中开启统一规则:
mybatis.configuration.map-underscore-to-camel-case=true于是:
assignee_id → assigneeId
device_id → deviceId
created_at → createdAt
updated_at → updatedAtSQL 可以保持数据库原本的列名:
SELECT id, title, status, assignee_id
FROM ticket
ORDER BY id这里也分清了三种做法:
| 做法 | 适用场景 |
|---|---|
AS 别名 | 偶尔一个特殊列名 |
map-underscore-to-camel-case | 大量统一的下划线列名 |
@Results / resultMap | 复杂、复用或无法按统一规则转换的映射 |
我确认加上 assigneeId 后,列表输出与预期一致,说明全局映射已经生效。IN_PROGRESS 的工单有处理人,OPEN 工单的 assigneeId 为 null。
5 TicketSummary 与领域对象 Ticket #
TicketSummary 能返回 JSON,为什么不直接把它当工单使用?
因为 Ticket 不是数据袋子,它是领域对象:
public class Ticket {
private final long id;
private String title;
private TicketStatus status;
private Long assigneeId;
}它负责:
- 编号必须为正数;
- 标题去首尾空白后非空且不超过 50 个字符;
- 只有
OPEN状态能派单; OPEN不能有处理人;- 非
OPEN必须有有效处理人。
因此不能这样创建:
Ticket ticket = new Ticket(summary.getId(), summary.getTitle());这会把数据库中的 IN_PROGRESS 和 assigneeId 丢掉,错误地变成一张新的 OPEN 工单。
也不能让 MyBatis 随便通过无参构造器和 setter 填 Ticket,因为当前 Ticket:
- 没有无参构造器;
id是final;- 状态是枚举;
- 字段没有公开 setter;
- 构造和恢复都要经过业务校验。
正确的转换入口是 Ticket.restore。新增转换类:
public final class TicketSummaryConverter {
private TicketSummaryConverter() {
}
public static Ticket toTicket(TicketSummary summary) {
if (summary == null) {
return null;
}
TicketStatus status =
TicketStatus.valueOf(summary.getStatus());
return Ticket.restore(
summary.getId(),
summary.getTitle(),
status,
summary.getAssigneeId()
);
}
}转换过程是:
数据库字符串 "IN_PROGRESS"
→ TicketStatus.IN_PROGRESS
→ Ticket.restore 检查状态与处理人的组合
→ 得到合法 Ticket这也回答了“为什么没有转换类时也能输出 JSON”:
返回 TicketSummary → JSON
返回 Ticket → JSON如果两边 getter 暴露的字段相同,JSON 表面上可能完全一样。区别在 Java 对象的职责:
TicketSummary:搬运查询数据
Ticket:承载业务状态与规则6 从 Controller 直接调用 Mapper 到只读分层 #
完成对象转换后,临时 Controller 中出现了这些职责:
接收 HTTP
调用 Mapper
判断 null
转换 TicketSummary
决定 404这些逻辑都堆在 Controller 中,数据库和领域转换会与 HTTP 耦合。
因此先增加 DatabaseTicketQueryService:
@Service
public class DatabaseTicketQueryService {
private final TicketMapper ticketMapper;
private final TicketQueryRepository ticketQueryRepository;
public DatabaseTicketQueryService(
TicketMapper ticketMapper,
TicketQueryRepository ticketQueryRepository
) {
this.ticketMapper = ticketMapper;
this.ticketQueryRepository = ticketQueryRepository;
}
}当前是迁移中的过渡结构:
- 标题、摘要和摘要列表仍直接使用 Mapper;
- 领域对象和领域对象列表通过 Repository 查询。
查询调度逐步变成:
Controller
→ DatabaseTicketQueryService
→ TicketQueryRepository
→ DatabaseTicketRepository
→ TicketMapper
→ MySQL为什么增加 Repository 接口 #
只读接口是:
public interface TicketQueryRepository {
Optional<Ticket> findById(long id);
List<Ticket> findAll();
}实现类:
@Repository
public class DatabaseTicketRepository
implements TicketQueryRepository, TicketWriteRepository {一个示例方法:
@Override
public Optional<Ticket> findById(long id) {
TicketSummary summary =
ticketMapper.findSummaryById(id);
if (summary == null) {
return Optional.empty();
}
Ticket ticket =
TicketSummaryConverter.toTicket(summary);
return Optional.of(ticket);
}Service 只依赖 TicketQueryRepository,不依赖具体的 DatabaseTicketRepository。以后即使换成缓存、文件或测试假仓库,Service 的查询代码也不用跟着改。
Optional 表示什么 #
Optional<Ticket>表示“可能找到一张工单,也可能没有”。
找到 → Optional.of(ticket)
没找到 → Optional.empty()Service 再把:
ticketQueryRepository.findById(id)转换成当前 Controller 需要的:
ticketQueryRepository.findById(id).orElse(null)Controller 继续用 null 决定是否返回 HTTP 404。HTTP 状态码属于 Controller,不塞进 Repository。
7 Spring Bean 与 MyBatis 代理不是一回事 #
构造器注入并不是 Service 自己创建 Repository:
public DatabaseTicketQueryService(
TicketQueryRepository repository
) {
this.repository = repository;
}Spring 启动时扫描:
@Repository
DatabaseTicketRepository创建普通 Repository 对象,再把 MyBatis 创建的 TicketMapper 代理注入进去。创建 Service 时,Spring 又把这个 Repository 传给构造器。
两条依赖可以概括为:
TicketMapper
→ MyBatis 创建动态代理
→ 调用方法时执行 SQL
DatabaseTicketRepository
→ Spring 创建普通 Bean
→ 内部持有 TicketMapper 代理Spring 容器最终形成:
Controller
→ DatabaseTicketQueryService
→ DatabaseTicketRepository
→ TicketMapper 代理
→ MySQL接口的作用不是让运行时“少调用一次”,而是让 Service 只依赖查询契约。以后换实现时,改动集中在配置和新实现类中。
8 当前数据库只读接口 #
整理后的主要入口是:
| 请求 | 返回对象 | 说明 |
|---|---|---|
GET /api/db/tickets/{id}/title | Map<String,String> | 单列标题 |
GET /api/db/tickets/{id} | TicketSummary | 单张工单摘要 |
GET /api/db/tickets | List<TicketSummary> | 全部摘要 |
GET /api/db/tickets/{id}/domain | Ticket | 单张领域对象 |
GET /api/db/domain/tickets | List<Ticket> | 全部领域对象 |
路径中的 db、domain、tickets 都是我们自己定义的 URL 片段。它们本身不会连接数据库;真正执行 SQL 的是 Mapper。
理解路由时,关键不是 Java 方法名,而是注解:
@GetMapping("/api/db/tickets/{id}")Spring 用它与:
GET /api/db/tickets/1匹配,再把路径中的 1 转成:
@PathVariable(name = "id") long id400 和 404 也不同:
/api/db/tickets/abc → 参数无法转成 long,未进入 Controller
/api/db/tickets/999 → 进入 Controller,SQL 无结果,返回 4049 排错与调试 #
.http 文件中的 #
IDEA 的 .http 文件中,### 用来分隔请求。多个请求前不写分隔符时,后面的 GET 可能被当成上一个请求内容,语法高亮也会异常。
### 查询领域对象
GET http://localhost:8080/api/db/tickets/1/domain### 后面的说明文字可省略,但多个请求之间最好保留分隔符。
看调用链 #
在 IDEA Debug 中暂停后,查看 Frames(帧/调用栈)。从 Controller 的调用使用 F7 Step Into,可以依次进入:
DatabaseTicketController.findDomain
DatabaseTicketQueryService.findDomainById
DatabaseTicketRepository.findByIdFrames 最上面是当前方法,下面是调用者。
数据库没有 id=2 #
实际查询得到编号 1 和 3,没有 2。ticket.id 是自增主键,不代表编号连续。执行:
SELECT id, title, status
FROM ticket
ORDER BY id;能够确认数据库当前的记录。对不存在的 id 返回 404 是正确的。
10 数据库写入的准备与边界 #
今天已经开始准备写入,但还没有完成可调用的 POST 闭环。
已经保存的写入组件包括:
public interface TicketWriteRepository {
Ticket create(String title, long deviceId, long reporterId);
}Mapper 中加入:
@Insert("""
INSERT INTO ticket (
title,
device_id,
reporter_id
)
VALUES (
#{title},
#{deviceId},
#{reporterId}
)
""")
@Options(
useGeneratedKeys = true,
keyProperty = "id",
keyColumn = "id"
)
int insertTicket(TicketInsert ticketInsert);DatabaseTicketRepository.create 的逻辑是:
组装 TicketInsert
→ 执行 INSERT
→ 检查影响行数
→ 从 TicketInsert.id 取回自增主键
→ 再按新 id 查询并恢复成 Ticket这里还没有:
DatabaseTicketCommandService;CreateDatabaseTicketRequest;- POST
/api/db/tickets; - 外键错误、标题约束错误的 HTTP 转换;
- 写入后的真实运行结果和自动测试。
因此不能把“Mapper 和 Repository 已经有 INSERT 代码”写成“数据库创建功能已完成”。
11 本次验证范围 #
本次有学习者运行反馈的证据:
GET /api/db/tickets返回 200;- 数据库中的编号 1 和 3 被映射为两个 JSON 对象;
- 加上
assigneeId后输出符合预期。
本次通过源码核对确认:
TicketSummary增加Long assigneeId;- 开启下划线转驼峰;
TicketSummaryConverter使用Ticket.restore;- 增加
TicketQueryRepository和DatabaseTicketRepository; - 增加单张/全部领域对象查询接口;
- 开始准备
TicketInsert、@Insert、@Options和TicketWriteRepository。
边界:
- 正式
/api/tickets仍使用文件仓库; - 没有新增或重跑 JUnit,正式测试仍按 17 个方法记录;
- 没有新增 Web 自动测试;
- 领域对象接口和完整只读分层主要完成代码核对,没有把未单独反馈的请求写成已运行;
- 数据库写入没有 POST 接口和运行结果。
12 下次接续 #
下次从数据库创建工单继续:
POST /api/db/tickets
→ CreateDatabaseTicketRequest
→ DatabaseTicketCommandService
→ TicketWriteRepository
→ INSERT
→ 自增主键
→ 重新查询 Ticket
→ 201 JSON然后补:
- 设备或用户外键不存在时返回什么;
- 标题违反数据库约束时返回什么;
- 哪些错误属于 400/409,哪些应保持 500;
- 是否需要在 Service 层加事务边界。