DAY 1 · 10:15二、Entity → DTO → VO 对象转换
这是初学者最容易糊在一起的三个概念。先把它们区分清楚,后面写代码会轻松很多。
前端请求
→
DTO
→
Entity
→
DB
↑ 请求进来:DTO 转 Entity 存库
DB
→
Entity
→
VO
→
前端响应
↓ 响应回去:Entity 转 VO 返回
| 对象 |
角色定位 |
对应方向 |
包含内容 |
| Entity |
数据库表的 Java 映射 |
Service ↔ Mapper |
表的所有字段,带主键、创建时间等 |
| DTO |
接收前端入参 |
Controller → Service |
只含创建/查询需要的字段,带校验注解 |
| VO |
返回给前端的视图对象 |
Controller → 前端 |
前端需要展示的字段,可能来自多张表 |
为什么不用同一个类?因为需求会变!
今天订单表有 5 个字段,你用一个 Order 类搞定一切。明天产品说"创建订单时不需要填创建时间(后端自动生成)",后天说"列表页要显示用户昵称(要 join 用户表)"。如果用同一个类,要么前端能传不该传的字段(安全隐患),要么返回一堆前端用不到的字段(浪费带宽)。分开后,改一处不影响另一处。
① Entity — 直接对应数据库表
Order.java - com.example.order.entity
📁 entity
📄 Order.java
📄 OrderItem.java
📁 dto
📁 vo
1package com.example.order.entity;
2
3import lombok.Data;
4import java.time.LocalDateTime;
5
6@Data
7public class Order {
8 // 主键,对应表里的 id 字段
9 private Long id;
10
11 // 订单号,唯一
12 private String orderNo;
13
14 // 用户ID
15 private Long userId;
16
17 // 订单金额(分)
18 private Long amount;
19
20 // 状态:0待支付 1已支付 2已取消
21 private Integer status;
22
23 // 创建时间(数据库自动生成,前端不该传)
24 private LocalDateTime createTime;
25
26 private LocalDateTime updateTime;
27}
Order.java7 fieldsJava 17
② DTO — 接收前端入参,带校验
@Data
public class OrderCreateDTO {
// 订单号由后端生成,不接收前端传入
@NotNull(message = "用户ID不能为空")
private Long userId;
@NotNull(message = "金额不能为空")
@Min(value = 1, message = "金额必须大于0")
private Long amount;
@NotBlank(message = "收货地址不能为空")
private String address;
// 注意:没有 id、orderNo、status、createTime 字段
// 这些都是后端自己生成的,不该让前端传
}
专业定义
DTO + Bean Validation = 入参对象的声明式校验。@NotBlank/@NotNull/@Size 等注解在 DTO 字段上一键声明规则,Controller 方法参数加 @Valid 触发校验。一次定义,处处复用,校验逻辑从业务代码中完全抽离。
安全要点:
DTO 故意不包含 id、status、createTime。如果用 Entity 直接接收前端请求,用户可以在请求体里塞 "status": 1 把订单直接变成已支付 —— 这就是"对象注入攻击"。用 DTO 物理隔离,从根本上杜绝。
③ VO — 返回给前端的视图对象
@Data
public class OrderVO {
private Long id;
private String orderNo;
private Long amount;
// 状态文案:前端不用判断 0/1/2,直接显示
private String statusText;
// 来自用户表(join 查出来的)
private String userName;
private String userPhone;
// 格式化后的时间字符串
private String createTimeText;
// 注意:不返回 userId、updateTime 等前端不需要的字段
}
专业定义
接口+实现分离 = 面向接口编程的标准实践。Spring 注入时用接口类型,实现调用方与实现解耦。这是依赖倒置原则(DIP)的体现。
④ 对象转换(手动 or MapStruct)
// 手动转换:简单直观,字段少时推荐
public class OrderConverter {
public static Order toEntity(OrderCreateDTO dto) {
if (dto == null) return null;
Order order = new Order();
order.setUserId(dto.getUserId());
order.setAmount(dto.getAmount());
// 订单号、状态、时间由 Service 设置
return order;
}
public static OrderVO toVO(Order entity) {
if (entity == null) return null;
OrderVO vo = new OrderVO();
vo.setId(entity.getId());
vo.setOrderNo(entity.getOrderNo());
vo.setAmount(entity.getAmount());
vo.setStatusText(statusToText(entity.getStatus()));
vo.setCreateTimeText(
entity.getCreateTime().format(
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm")
)
);
return vo;
}
private static String statusToText(Integer status) {
return switch (status) {
case 0 -> "待支付";
case 1 -> "已支付";
case 2 -> "已取消";
default -> "未知";
};
}
}
专业定义
VO (View Object) = 返回给前端的视图对象。与 DTO 的区别:DTO 是入参,VO 是出参。VO 可以裁剪敏感字段(如密码、内部 ID)、聚合多个实体数据、格式化日期/金额。永远不要把 Entity 直接返回给前端——这是 P0 级安全规范。
MapStruct 提示:
字段超过 10 个时,手写转换又长又容易漏。这时用 @Mapper(MapStruct 的,不是 MyBatis 的)自动生成转换代码:接口里定义好,编译期自动生成实现类,性能和手写一样。
DAY 1 · 11:30三、Mapper层 — MyBatis XML 实战
Mapper 层是 MyBatis 的核心。接口定义方法签名,XML 写 SQL。两者通过 namespace + 方法名关联。
① Mapper 接口 — 先定义方法
OrderMapper.java - com.example.order.mapper
📁 mapper
📄 OrderMapper.java
📁 resources
📁 mapper
📄 OrderMapper.xml
1package com.example.order.mapper;
2
3import com.example.order.entity.Order;
4import org.apache.ibatis.annotations.Mapper;
5import org.apache.ibatis.annotations.Param;
6import java.util.List;
7
8@Mapper
9public interface OrderMapper {
10
11 // 基本CRUD
12 int insert(Order order);
13 int deleteById(@Param("id") Long id);
14 int updateById(Order order);
15 Order selectById(@Param("id") Long id);
16
17 // 多条件查询(动态SQL)
18 List<Order> selectByCondition(OrderQueryDTO query);
19
20 // 批量插入
21 int batchInsert(@Param("list") List<Order> list);
22}
OrderMapper.java@Mapper registeredMyBatis
② 基本 CRUD — MyBatis XML
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.example.order.mapper.OrderMapper">
<!-- 插入:useGeneratedKeys 回填自增主键 -->
<insert id="insert" parameterType="com.example.order.entity.Order"
useGeneratedKeys="true" keyProperty="id">
INSERT INTO t_order (order_no, user_id, amount, status, create_time)
VALUES (#{orderNo}, #{userId}, #{amount}, #{status}, #{createTime})
</insert>
<delete id="deleteById">
DELETE FROM t_order WHERE id = #{id}
</delete>
<update id="updateById" parameterType="com.example.order.entity.Order">
UPDATE t_order
SET order_no = #{orderNo},
user_id = #{userId},
amount = #{amount},
status = #{status}
WHERE id = #{id}
</update>
<select id="selectById" resultType="com.example.order.entity.Order">
SELECT id, order_no, user_id, amount, status, create_time, update_time
FROM t_order
WHERE id = #{id}
</select>
</mapper>
面试金句
@Transactional 最佳实践 = (1) 加在 public 方法上 (2) rollbackFor = Exception.class (3) 不要在事务内做远程调用 (4) 只读查询加 readOnly = true。
③ 动态 SQL — 多条件查询(重点)
订单列表搜索:状态可选、时间范围可选、关键词可选。用 <where> + <if> 拼动态条件。
<select id="selectByCondition"
resultType="com.example.order.entity.Order">
SELECT id, order_no, user_id, amount, status, create_time
FROM t_order
<where>
<!-- status 不为 null 才加条件 -->
<if test="status != null">
AND status = #{status}
</if>
<!-- 开始时间 -->
<if test="startTime != null">
AND create_time >= #{startTime}
</if>
<!-- 结束时间 -->
<if test="endTime != null">
AND create_time <= #{endTime}
</if>
<!-- 关键词模糊搜索:注意用 CONCAT,不要用 ${} -->
<if test="keyword != null and keyword != ''">
AND order_no LIKE CONCAT('%', #{keyword}, '%')
</if>
</where>
ORDER BY create_time DESC
</select>
专业定义
对象转换 = Entity ↔ DTO ↔ VO 之间的映射。手写转换适合字段少的场景;字段多时用 MapStruct(编译期生成代码,性能等同手写)而非 BeanUtils(运行时反射,性能差 10-50 倍)。面试亮点:说明 MapStruct 的 @Mapper 接口 + 编译期 Lombok 生成原理。
<where> 标签的妙用:它会自动去掉第一个多余的 AND 或 OR。如果所有 if 都不成立,它连 WHERE 这个词都不输出 —— 避免 SELECT ... FROM t_order WHERE 这种语法错误。
LIKE 模糊查询规范:
正确:LIKE CONCAT('%', #{keyword}, '%') —— 参数化,防注入
错误:LIKE '%${keyword}%' —— 字符串拼接,SQL 注入漏洞!
④ <choose> — 类似 switch-case
<!-- 需求:管理员能查所有订单,普通用户只能查自己的 -->
<select id="selectByRole" resultType="Order">
SELECT * FROM t_order
<choose>
<when test="role == 'admin'">
WHERE 1=1
</when>
<when test="role == 'vip'">
WHERE user_id = #{userId} OR amount > 10000
</when>
<otherwise>
WHERE user_id = #{userId}
</otherwise>
</choose>
ORDER BY create_time DESC
</select>
面试金句
@Transactional 自调用失效 = 同类内 A 调 B(加了 @Transactional),B 的事务不生效。原因:Spring AOP 基于代理,自调用绕过代理。修复:注入自己/AopContext/拆类。面试 P0 考点。
⑤ 批量操作 — <foreach>
<!-- 批量插入:一条SQL搞定,比循环insert快50倍 -->
<insert id="batchInsert" parameterType="java.util.List">
INSERT INTO t_order (order_no, user_id, amount, status, create_time)
VALUES
<foreach collection="list" item="item"
separator=",">
(#{item.orderNo}, #{item.userId}, #{item.amount},
#{item.status}, #{item.createTime})
</foreach>
</insert>
<!-- 生成结果:INSERT INTO ... VALUES (...), (...), (...) -->
<!-- 批量插入 + 存在则更新(ON DUPLICATE KEY UPDATE) -->
<insert id="batchInsertOrUpdate">
INSERT INTO t_order (order_no, user_id, amount, status)
VALUES
<foreach collection="list" item="item" separator=",">
(#{item.orderNo}, #{item.userId}, #{item.amount}, #{item.status})
</foreach>
ON DUPLICATE KEY UPDATE
amount = VALUES(amount),
status = VALUES(status)
</insert>
专业定义
MyBatis XML 映射 = SQL 与 Java 解耦的经典方案。namespace 绑定 Mapper 接口,id 对应方法名,resultType/resultMap 处理 ORM 映射。面试考点:#{}(预编译参数)vs ${}(字符串拼接)的区别——前者防注入,后者有注入风险。
$ java -jar order-service.jar --mode=loop
循环插入 1000 条...
耗时: 15.2s | 执行SQL次数: 1000 | 平均每条: 15ms
$ java -jar order-service.jar --mode=batch
批量插入 1000 条(每批500)...
耗时: 0.3s | 执行SQL次数: 2 | 提速 50倍
⑥ #{} vs ${} — 一个能要命的区别
| 写法 |
机制 |
示例 |
安全性 |
#{param} |
预编译占位符 ?,参数后填入 |
WHERE id = ?,参数 1 |
安全,防注入 |
${param} |
字符串直接拼接进SQL |
WHERE id = + 用户输入 |
危险!SQL注入 |
// 假设前端传入 keyword = "'; DROP TABLE t_order; --"
// 危险写法:${} 直接拼接
// 生成的SQL:SELECT * FROM t_order WHERE order_no LIKE '%'; DROP TABLE t_order; --%'
// 后果:表没了!
SELECT * FROM t_order WHERE order_no LIKE '%${keyword}%'
// 安全写法:#{} 预编译
// 生成的SQL:SELECT * FROM t_order WHERE order_no LIKE ?
// 参数:'%'; DROP TABLE t_order; --%' (被当成普通字符串,不会执行)
SELECT * FROM t_order WHERE order_no LIKE CONCAT('%', #{keyword}, '%')
// ${} 唯一合法场景:动态表名、列名(不能预编译的地方)
SELECT * FROM ${tableName} WHERE id = #{id}
// 但这种情况要自己在Service层做白名单校验!
专业定义
Result<T> 统一响应 = 所有 API 返回 {code, message, data}。泛型 T 让 data 类型安全。配合全局异常处理器,Controller 里不需要 try-catch。
面试高频题:为什么 #{} 能防注入?因为 MyBatis 会先用 ? 占位生成预编译SQL,发送给数据库编译。编译完成后,参数才以"纯数据"形式填入 —— 数据库已经知道 ? 位置的内容是值不是指令,不会解析成SQL。这就是 JDBC PreparedStatement 的原理。
⑦ 数据库表结构 — DDL 参考
DAY 1 · 14:00四、Service层 — 业务逻辑与事务
Service 是真正干活的一层。规范的做法:先定义接口,再写实现类。这样便于 Mock 测试、切换实现。
① 接口 + 实现 — 标准写法
// ============ 接口 ============
public interface OrderService {
OrderVO createOrder(OrderCreateDTO dto);
OrderVO getOrderById(Long id);
PageResult<OrderVO> listOrders(OrderQueryDTO query);
void cancelOrder(Long id);
}
// ============ 实现类 ============
@Service
public class OrderServiceImpl implements OrderService {
@Autowired
private OrderMapper orderMapper;
@Autowired
UserService userService; // 调用其他Service
@Override
@Transactional(rollbackFor = Exception.class)
public OrderVO createOrder(OrderCreateDTO dto) {
// 1. 业务校验
User user = userService.getById(dto.getUserId());
if (user == null) {
throw new BusinessException("用户不存在");
}
// 2. DTO 转 Entity,填充后端字段
Order order = OrderConverter.toEntity(dto);
order.setOrderNo(generateOrderNo()); // 生成订单号
order.setStatus(0); // 待支付
order.setCreateTime(LocalDateTime.now());
// 3. 入库
orderMapper.insert(order);
// 4. 返回 VO
return OrderConverter.toVO(order);
}
private String generateOrderNo() {
return "ORD" + System.currentTimeMillis();
}
}
专业定义
MyBatis 动态 SQL = 通过 <if>/<choose>/<where>/<foreach> 标签在运行时拼装 SQL。<if test="name != null"> 条件成立时拼接 SQL 片段。OGNL 表达式做条件判断。<where> 自动处理首个 AND/OR,避免语法错误。
② @Transactional 使用规范
| 写法 |
评价 |
原因 |
@Transactional(rollbackFor = Exception.class) |
✅ 正确 |
所有异常都回滚,推荐默认写法 |
@Transactional(无参数) |
⚠️ 风险 |
默认只回滚 RuntimeException,抛 IOException 不回滚 |
| 放在 private 方法上 |
❌ 无效 |
Spring AOP 基于代理,代理不了 private 方法 |
| 放在 Controller 上 |
❌ 错误 |
职责混乱,事务应该在 Service 层 |
// ✅ 正确写法
@Service
public class OrderServiceImpl {
@Transactional(rollbackFor = Exception.class)
public void payOrder(Long orderId) {
Order order = orderMapper.selectById(orderId);
order.setStatus(1);
orderMapper.updateById(order);
// 如果这里抛异常,上面的 update 自动回滚
inventoryService.deduct(orderId);
}
}
// ❌ 错误写法:没加 rollbackFor,抛 IOException 不回滚
@Transactional
public void uploadAndSave(MultipartFile file) throws IOException {
// 文件上传成功后,数据库保存时抛 IOException
// 此时事务不会回滚!数据不一致
}
专业定义
@RestControllerAdvice = AOP 拦截所有 Controller 异常,通过 @ExceptionHandler 精确匹配异常类型,统一转换为标准响应。
③ 事务失效的 7 个场景(面试必问)
| # |
场景 |
原因 |
修复方法 |
| 1 |
方法不是 public |
Spring 基于代理实现事务,JDK 动态代理只能代理 public 方法 |
把方法改成 public |
| 2 |
自调用(同类内部调用) |
this.method() 不走代理,事务不生效 |
注入自己(AopContext) 或拆到另一个 Service |
| 3 |
异常被 catch 吃掉 |
异常没抛出,Spring 感知不到,无法回滚 |
catch 后 re-throw,或手动 TransactionAspectSupport.currentTransactionStatus().setRollbackOnly() |
| 4 |
抛 checked 异常但没配 rollbackFor |
默认只回滚 RuntimeException,IOException 不回滚 |
加 rollbackFor = Exception.class |
| 5 |
数据库引擎不支持事务 |
MyISAM 不支持事务,InnoDB 才支持 |
改表引擎:ALTER TABLE t_order ENGINE=InnoDB |
| 6 |
传播行为配置错误 |
NOT_SUPPORTED 以非事务方式运行;NEVER 如果有事务则抛异常 |
默认 REQUIRED 即可,别乱改 |
| 7 |
Bean 未被 Spring 管理 |
类没加 @Service,或 new 出来的对象,都不归 Spring 管 |
加 @Service 注解,用 @Autowired 注入 |
④ 自调用失效场景 — 图解
OrderServiceImpl.java - 事务失效案例
📁 service
📄 OrderServiceImpl.java
1@Service
2public class OrderServiceImpl implements OrderService {
3
4 // 外部调用 methodA() —— 走代理,事务生效
5 @Transactional(rollbackFor = Exception.class)
6 public void methodA() {
7 orderMapper.insert(order1);
8 // ⚠️ this调用,不走代理!methodB 的事务失效
9 this.methodB(); // ← 问题在这
10 }
11
12 @Transactional(rollbackFor = Exception.class)
13 public void methodB() {
14 orderMapper.insert(order2);
15 throw new RuntimeException("出错"); // 不会回滚!
16 }
17}
⚠️ Warning: @Transactional on methodB ineffective (self-invocation)
// 修复方法1:注入自己(推荐)
@Service
public class OrderServiceImpl implements OrderService {
@Autowired
@Lazy // 避免循环依赖
private OrderService self; // 注入代理对象
public void methodA() {
orderMapper.insert(order1);
self.methodB(); // ✅ 走代理,事务生效
}
}
// 修复方法2:拆到另一个 Service
// OrderHelperService.methodB(),在 OrderService 里注入并调用
专业定义
<choose> 标签 = MyBatis 版的 switch-case。<when test="..."/> 类似 case,<otherwise> 类似 default。只匹配第一个满足条件的分支就跳出。适合需要互斥条件判断的 SQL 拼装场景。
事务失效排查口诀:一查注解在不在(public + @Transactional),二查异常抛没抛(别 catch 吞掉),三查是不是自调用(this.method 不走代理),四查回滚配没配(rollbackFor = Exception.class)。
事务传播行为速记:
REQUIRED(默认,有就加入,没有就新建)、REQUIRES_NEW(总是新建,挂起当前事务)、NESTED(嵌套事务,基于 savepoint)。99% 场景用默认的 REQUIRED 就够了。
DAY 1 · 15:30五、Controller层 — RESTful API 规范
Controller 做三件事:接请求、校验参数、调 Service。不写业务逻辑,不直接操作数据库。
① RESTful API 设计规范
| 方法 |
路径 |
语义 |
参数来源 |
| GET |
/api/orders |
列表查询(分页) |
Query String (?page=1&size=10) |
| GET |
/api/orders/{id} |
查询单个详情 |
路径参数 (@PathVariable) |
| POST |
/api/orders |
创建新订单 |
请求体 (@RequestBody) |
| PUT |
/api/orders/{id} |
更新订单 |
路径参数 + 请求体 |
| DELETE |
/api/orders/{id} |
删除订单 |
路径参数 |
② Controller 完整写法
OrderController.java - com.example.order.controller
📁 controller
📄 OrderController.java
📁 service
📁 dto
📁 vo
📁 common
1@RestController
2@RequestMapping("/api/orders")
3public class OrderController {
4
5 @Autowired
6 private OrderService orderService;
7
8 // 列表查询(分页)
9 @GetMapping
10 public Result<PageResult<OrderVO>> list(OrderQueryDTO query) {
11 return Result.success(orderService.listOrders(query));
12 }
13
14 // 详情查询
15 @GetMapping("/{id}")
16 public Result<OrderVO> getById(@PathVariable Long id) {
17 return Result.success(orderService.getOrderById(id));
18 }
19
20 // 创建(@Valid 触发参数校验)
21 @PostMapping
22 public Result<OrderVO> create(@Valid @RequestBody OrderCreateDTO dto) {
23 return Result.success(orderService.createOrder(dto));
24 }
25}
OrderController.java4 endpointsSpring MVC
③ 统一响应 Result<T>
@Data
public class Result<T> {
private int code; // 200成功,其他失败
private String message; // 提示信息
private T data; // 业务数据
public static <T> Result<T> success(T data) {
Result<T> r = new Result<>();
r.setCode(200);
r.setMessage("success");
r.setData(data);
return r;
}
public static <T> Result<T> error(int code, String msg) {
Result<T> r = new Result<>();
r.setCode(code);
r.setMessage(msg);
return r;
}
}
专业定义
自定义业务异常 = 继承 RuntimeException(非受检),携带 errorCode。不用 Checked Exception:避免 try-catch 污染 + 配合 @Transactional 回滚 + 全局处理器统一捕获。
④ API 测试 — 创建订单
Headers · Content-Type: application/json
Body (raw JSON):
{
"userId": 8888,
"amount": 19900,
"address": "北京市朝阳区XX路1号"
}
200 OK
· 45ms · application/json
{
"code": 200,
"message": "success",
"data": {
"id": 1004,
"orderNo": "ORD1718260234567",
"amount": 19900,
"statusText": "待支付",
"userName": "张三",
"userPhone": "138****8888",
"createTimeText": "2026-06-13 16:24"
}
}
⑤ API 测试 — 参数校验失败
Body(故意漏传 amount 和 address):
{
"userId": 8888
}
400 Bad Request
· 12ms
{
"code": 400,
"message": "金额不能为空; 收货地址不能为空",
"data": null
}
⑥ API 测试 — 更新订单(PUT)
Headers · Content-Type: application/json
Body (raw JSON):
{
"amount": 25800,
"address": "上海市浦东新区XX路88号"
}
200 OK
· 38ms
{
"code": 200,
"message": "success",
"data": {
"id": 1001,
"orderNo": "ORD20160613001",
"amount": 25800,
"statusText": "待支付",
"createTimeText": "2026-06-13 10:23"
}
}
⑦ API 测试 — 列表分页查询
200 OK
· 58ms
{
"code": 200,
"message": "success",
"data": {
"total": 42,
"pageNum": 1,
"pageSize": 10,
"pages": 5,
"list": [
{ "id": 1003, "orderNo": "ORD...", "amount": 12800, "statusText": "已支付" },
{ "id": 1002, "orderNo": "ORD...", "amount": 5800, "statusText": "已支付" }
]
}
}
@Valid 的工作原理:
Controller 方法参数加 @Valid,Spring 会用 Hibernate Validator 校验 DTO 上的注解(@NotNull、@Min 等)。校验失败抛 MethodArgumentNotValidException,被全局异常处理器捕获后转成友好提示。
DAY 1 · 16:45六、统一异常处理与响应包装
没有全局异常处理时,系统报错会返回一堆 stack trace,前端没法解析,用户看了一头雾水。加了 @RestControllerAdvice 后,所有异常都被拦截,统一返回 Result 格式。
① 没有全局处理器 vs 有全局处理器
| ❌ 没有全局处理器(原始报错) |
✅ 有全局处理器(统一格式) |
HTTP 500 Internal Server Error
{
"timestamp": "2026-06-13T16:30:00",
"status": 500,
"error": "Internal Server Error",
"message": null,
"trace": "java.lang.NullPointerException
at com.example.order.service.OrderServiceImpl
.createOrder(OrderServiceImpl.java:45)
at java.base/jdk.internal.reflect...",
"path": "/api/orders"
}
|
HTTP 500 Internal Server Error
{
"code": 500,
"message": "系统繁忙,请稍后重试",
"data": null
}
|
注意:生产环境绝对不能把 stack trace 返回给前端。一是用户看不懂,二是会暴露技术栈和代码结构,给攻击者可乘之机。stack trace 只记录到日志文件里,前端只看到友好提示。
② GlobalExceptionHandler 完整实现
@RestControllerAdvice // 拦截所有Controller的异常
@Slf4j
public class GlobalExceptionHandler {
// 1. 业务异常(自定义)—— 最常见
@ExceptionHandler(BusinessException.class)
public Result<Void> handleBusiness(BusinessException e) {
log.warn("业务异常: {}", e.getMessage());
return Result.error(e.getCode(), e.getMessage());
}
// 2. 参数校验失败 —— @Valid 触发
@ExceptionHandler(MethodArgumentNotValidException.class)
public Result<Void> handleValidation(MethodArgumentNotValidException e) {
String msg = e.getBindingResult().getFieldErrors().stream()
.map(err -> err.getField() + ": " + err.getDefaultMessage())
.collect(Collectors.joining("; "));
return Result.error(400, msg);
}
// 3. 空指针 —— 兜底
@ExceptionHandler(NullPointerException.class)
public Result<Void> handleNPE(NullPointerException e) {
log.error("空指针异常", e); // 完整栈写日志
return Result.error(500, "系统异常"); // 前端看不到栈
}
// 4. 其他所有异常 —— 最后防线
@ExceptionHandler(Exception.class)
public Result<Void> handleAll(Exception e) {
log.error("未知异常", e);
return Result.error(500, "系统繁忙,请稍后重试");
}
}
面试金句
<foreach> 批量操作 = 一条 SQL 完成批量 INSERT/IN 查询。collection 指定集合,item 元素别名,separator 分隔符。比循环 N 次 INSERT 快 50 倍——减少网络往返和 SQL 解析次数。面试考点:MySQL 默认 max_allowed_packet 限制批量大小。
③ 自定义业务异常
@Getter
public class BusinessException extends RuntimeException {
private final int code;
public BusinessException(String message) {
super(message);
this.code = 400; // 默认业务错误码
}
public BusinessException(int code, String message) {
super(message);
this.code = code;
}
}
// 使用:在 Service 里直接抛
if (user == null) {
throw new BusinessException("用户不存在");
}
专业定义
PageHelper 分页 = MyBatis 物理分页插件。原理:startPage() → ThreadLocal 分页参数 → 拦截 SQL 拼接 LIMIT → 清理 ThreadLocal。巨坑:startPage 和查询之间不能有其他 SQL。
异常处理层次:
精确优先:BusinessException 比 Exception 更精确,优先匹配。所以把具体的异常放前面,通用的 Exception.class 放最后兜底。
全局异常处理 = 统一的错误响应转换层
前端发请求遇到异常,不应该让 Service 层的技术异常(如 NullPointerException、SQLException)直接抛给前端。所有异常统一被 GlobalExceptionHandler(@ControllerAdvice + @ExceptionHandler)捕获,转换成前端能理解的标准响应格式(code + message),而不是把后端的 stack trace 直接暴露给调用方。
DAY 1 · 17:30七、分页规范与批量操作最佳实践
① PageHelper 分页规范用法
@Service
public class OrderServiceImpl implements OrderService {
@Autowired
private OrderMapper orderMapper;
public PageResult<OrderVO> listOrders(OrderQueryDTO query) {
// ✅ 关键:startPage 必须紧挨着查询,中间不能有任何SQL
PageHelper.startPage(query.getPageNum(), query.getPageSize());
// 紧接着执行查询 —— PageHelper 会自动拼接 LIMIT
List<Order> list = orderMapper.selectByCondition(query);
// 用 PageInfo 包装,拿到总数、总页数等
PageInfo<Order> pageInfo = new PageInfo<>(list);
// Entity 转 VO
List<OrderVO> voList = list.stream()
.map(OrderConverter::toVO)
.collect(Collectors.toList());
return new PageResult<>(
pageInfo.getTotal(), // 总记录数
pageInfo.getPageNum(), // 当前页
pageInfo.getPageSize(), // 每页大小
pageInfo.getPages(), // 总页数
voList // 当前页数据
);
}
}
面试金句
#{} vs ${} = MyBatis 最经典面试题。#{} → PreparedStatement 参数占位符(?),预编译防注入;${} → 纯字符串拼接,有 SQL 注入风险。唯一使用 ${} 的合法场景:动态表名/列名(分表路由),且必须白名单校验。
PageHelper 原理:基于 MyBatis 拦截器。startPage() 把分页参数存到 ThreadLocal,下一次查询执行时拦截器取出参数,自动在 SQL 末尾拼 LIMIT,并额外执行一次 SELECT COUNT(*) 查总数。所以 startPage 和查询必须紧挨着,中间隔了别的 SQL 就会分页错乱。
② PageHelper 的常见坑
| 错误写法 |
问题 |
正确做法 |
| startPage 后先查了一次别的表 |
分页参数被别的SQL消费,目标查询没分页 |
startPage 紧挨目标查询 |
| startPage 后查询返回 List 再处理 |
没问题,但要记得包成 PageInfo |
new PageInfo(list) 拿总数 |
| 多线程下用 PageHelper |
ThreadLocal 串号,分页混乱 |
用 PageHelper.startPage 的有参版本,手动清理 |
// ❌ 错误:startPage 和查询之间隔了别的SQL
PageHelper.startPage(1, 10);
User user = userMapper.selectById(8888); // ← 这条被分页了!
List<Order> list = orderMapper.selectByCondition(query); // ← 这条没分页
// ✅ 正确:startPage 紧挨查询
PageHelper.startPage(1, 10);
List<Order> list = orderMapper.selectByCondition(query); // ← 这条被分页
面试金句
PageHelper 三大坑 = (1) startPage 和查询间隔了别的 SQL (2) 分页后 COUNT 也慢 (3) ThreadLocal 线程安全需同线程内完成。
③ 批量操作最佳实践
$ mvn test -Dtest=BatchInsertTest
Running com.example.order.BatchInsertTest...
总数据量: 10000 条
分批大小: 500 条/批
批次: 20 批
总耗时: 0.832s
平均每批: 41ms
④ 分批插入 Service 实现
@Service
public class OrderBatchService {
@Autowired
private OrderMapper orderMapper;
private static final int BATCH_SIZE = 500;
@Transactional(rollbackFor = Exception.class)
public void batchCreate(List<Order> allOrders) {
// 用 Guava Lists.partition 分批
List<List<Order>> batches = Lists.partition(allOrders, BATCH_SIZE);
for (List<Order> batch : batches) {
orderMapper.batchInsert(batch);
}
}
}
专业定义
接口+实现分离 = 面向接口编程的标准实践。Service 接口定义契约,ServiceImpl 实现逻辑。Spring 注入时用接口类型(@Autowired OrderService 而非 OrderServiceImpl),实现调用方与实现解耦。面试加分:这是依赖倒置原则(DIP)的体现。
批量大小的选择:
推荐 500-1000 条/批。太小失去批量优势,太大有两个风险:① MySQL max_allowed_packet 限制(默认 4MB),SQL 太大会报错;② 单次事务持有锁太久,影响并发。500 条是经验上的甜区。
完整工作流回顾 — 一个需求从 0 到上线
建表
→
Entity
→
DTO/VO
→
Mapper.xml
Mapper接口
→
Service
→
Controller
→
异常处理
commit a3f2b1c "feat: 订单管理模块完整实现"
Author: developer <dev@example.com>
Date: 2026-06-13
src/main/java/com/example/order/
+ entity/Order.java (数据库实体)
+ dto/OrderCreateDTO.java (创建请求DTO)
+ dto/OrderQueryDTO.java (查询请求DTO)
+ vo/OrderVO.java (返回视图对象)
+ mapper/OrderMapper.java (Mapper接口)
+ service/OrderService.java (Service接口)
+ service/OrderServiceImpl.java (Service实现+事务)
+ controller/OrderController.java (RESTful API)
+ common/Result.java (统一响应)
+ exception/BusinessException.java (业务异常)
+ exception/GlobalExceptionHandler.java (全局异常处理)
src/main/resources/mapper/
+ OrderMapper.xml (CRUD+动态SQL+批量)
src/test/java/com/example/order/
+ OrderServiceTest.java (单元测试)
+ OrderControllerTest.java (接口测试)
12 files changed, 856 insertions(+)
编码规范速查表(打印贴墙)
| 规范项 | 做法 |
| 分层职责 | Controller 薄、Service 厚、Mapper 纯 |
| 对象分离 | Entity/DTO/VO 各司其职,不混用 |
| 事务注解 | @Transactional(rollbackFor = Exception.class) |
| SQL注入 | 永远用 #{},不用 ${} |
| 模糊查询 | LIKE CONCAT('%', #{kw}, '%') |
| 分页 | PageHelper.startPage 紧挨查询 |
| 批量 | 分批 500-1000 条,别循环insert |
| 异常 | @RestControllerAdvice 统一处理 |
| 响应 | 统一 Result<T> 格式 |
| API风格 | RESTful:GET查/POST增/PUT改/DELETE删 |
分层编码 = 盖楼
地基(数据库)→ 承重墙(Entity)→ 水电管线(Mapper)→ 房间布局(Service)→ 大堂接待(Controller)。每一层都依赖下面的,但每一层都有自己的活。你不能让大堂接待员去修水管,也不能让水管工去指挥客人。盖楼要按顺序,编码也是 —— 先底层数据,再业务逻辑,最后对外接口。