苍穹外卖项目复习笔记
一、 MyBatis-Plus (MP) 核心应用
这部分是开发数据持久层的核心,重点在于理解MP如何简化开发以及如何处理复杂场景。
1. 基础 CRUD 与架构关系
- BaseMapper vs ServiceImpl :
BaseMapper<T>: 位于DAO层。提供了最底层的数据库原子操作(insert, deleteById, update, selectList等)。直接操作数据库。ServiceImpl<M, T>: 位于Service层。实现了IService<T>接口,内部默认注入了BaseMapper。它在BaseMapper的基础上进行了封装,提供了更高级的业务逻辑支持(如批量操作、链式调用)。- 总结: 简单SQL直接调Mapper,复杂业务逻辑或批量操作调Service。
2. 分页查询
- 单表分页 :
- 配置 : 必须配置
MybatisPlusInterceptor并添加PaginationInnerInterceptor拦截器,否则分页无效(只会查全部)。 - 实现 : 使用
Page<T>对象作为参数传入BaseMapper的查询方法中。
- 配置 : 必须配置
- 多表关联分页查询 :
- 难点: MP的Wrapper主要针对单表。
- 解决: 手写SQL(XML或注解)。
- 关键 : 只要Service方法定义的第一个参数是
Page对象,MP拦截器会自动拦截该SQL,执行SELECT count(0)查询总数,然后注入LIMIT语句,无需手动写分页逻辑。
具体实现
自定义 SQL (XML 方式) ------ 推荐
核心思路:编写一个 SQL,使用 LEFT JOIN 连接菜品表和分类表,直接查出你需要的所有字段,让 MP 帮你做分页。
- 定义 DishVO, 确保你的 DishVO 里有 categoryName 字段。
java
@Data
public class DishVO extends Dish {
// 继承了 Dish 的所有属性
// 额外添加分类名称
private String categoryName;
// 可能还需要口味列表,根据你的业务需求决定是否在这里查,或者分开查
// private List<DishFlavor> flavors;
}
- 修改 Mapper 接口
在DishMapper.java中定义一个方法。 注意:
- 入参必须包含 IPage,MP 会自动识别它并进行分页拦截。
- 返回值也是 IPage。
java
@Mapper
public interface DishMapper extends BaseMapper<Dish> {
/**
* 自定义分页查询 DishVO
* @param page MP 分页对象,必须作为第一个参数
* @param dishPageQueryDTO 查询条件
* @return 分页结果
*/
Page<DishVO> pageQuery(IPage<DishVO> page, @Param("dto") DishPageQueryDTO dishPageQueryDTO);
}
- 编写 Mapper XML,在
DishMapper.xml中编写 SQL。
XML
<mapper namespace="com.sky.mapper.DishMapper">
<select id="pageQuery" resultType="com.sky.vo.DishVO">
SELECT
d.*,
c.name AS category_name
FROM dish d
LEFT JOIN category c ON d.category_id = c.id
<where>
<if test="dto.name != null and dto.name != ''">
AND d.name LIKE CONCAT('%', #{dto.name}, '%')
</if>
<if test="dto.categoryId != null">
AND d.category_id = #{dto.categoryId}
</if>
<if test="dto.status != null">
AND d.status = #{dto.status}
</if>
</where>
ORDER BY d.create_time DESC
</select>
</mapper>
- Service 层调用
直接调用 Mapper 即可,非常简洁。
Java
@Override
public PageResult pageQuery(DishPageQueryDTO dishPageQueryDTO) {
// 1. 准备分页对象
Page<DishVO> page = new Page<>(dishPageQueryDTO.getPage(), dishPageQueryDTO.getPageSize());
// 2. 调用 Mapper 自定义方法
// MP 会自动执行两条 SQL:
// 第一条:SELECT COUNT(*) ... LEFT JOIN ...
// 第二条:SELECT d.*, c.name ... LEFT JOIN ... LIMIT ?, ?
Page<DishVO> voPage = dishMapper.pageQuery(page, dishPageQueryDTO);
// 3. 封装结果
return new PageResult(voPage.getTotal(), voPage.getRecords());
}
3. 批量操作与链式调用 (Point 6)
- 批量操作 : 使用
IService接口提供的方法,如saveBatch(List<T>)或updateBatchById(List<T>)。底层是基于JDBC的batch操作,性能优于循环调用Mapper。 - 链式调用 (LambdaQueryChainWrapper) :
- 场景: 避免创建繁琐的Wrapper对象。
- 写法 :
lambdaQuery().eq(User::getId, id).one(); - 优点: 代码更优雅,可读性更强。
4. 公共字段填充 (Point 4)
- MP 原生方式 (
MetaObjectHandler) :- 原理 : 实现
MetaObjectHandler接口,重写insertFill和updateFill方法。 - 配置 : 在实体类字段上添加
@TableField(fill = FieldFill.INSERT)等注解。 - 场景: 适用于纯数据库层面的统一赋值(如create_time, update_time)。
- 原理 : 实现
为什么推荐 MetaObjectHandler?
-
更底层: 它直接介入 MyBatis-Plus 的 CRUD 操作,是专门为数据填充设计的。
-
更简单: 避免了复杂的 AOP 反射和切入点配置。
-
更清晰: 逻辑集中在一个处理器类中,明确区分了
插入和更新两种场景。
实现步骤
步骤 1:创建元对象处理器
创建一个类继承自 com.baomidou.mybatisplus.core.handlers.MetaObjectHandler,并实现 insertFill 和 updateFill 方法。
java
import com.baomidou.mybatisplus.core.handlers.MetaObjectHandler;
import org.apache.ibatis.reflection.MetaObject;
import org.springframework.stereotype.Component;
import java.time.LocalDateTime;
@Component
public class MyMetaObjectHandler implements MetaObjectHandler {
// 假设您有一个工具类来获取当前用户ID
private Long getCurrentUserId() {
// 实际应用中:从 ThreadLocal 或 SecurityContext 获取当前登录用户ID
return 1L; // 示例值
}
/**
* 插入操作时自动填充
*/
@Override
public void insertFill(MetaObject metaObject) {
// 1. 填充创建时间
if (metaObject.hasSetter("createTime")) {
this.strictInsertFill(metaObject, "createTime", LocalDateTime.class, LocalDateTime.now());
}
// 2. 填充创建人
if (metaObject.hasSetter("createUser")) {
this.strictInsertFill(metaObject, "createUser", Long.class, getCurrentUserId());
}
// 3. 插入时通常也填充更新时间和更新人
if (metaObject.hasSetter("updateTime")) {
this.strictInsertFill(metaObject, "updateTime", LocalDateTime.class, LocalDateTime.now());
}
if (metaObject.hasSetter("updateUser")) {
this.strictInsertFill(metaObject, "updateUser", Long.class, getCurrentUserId());
}
}
/**
* 更新操作时自动填充
*/
@Override
public void updateFill(MetaObject metaObject) {
// 1. 填充更新时间
if (metaObject.hasSetter("updateTime")) {
this.strictUpdateFill(metaObject, "updateTime", LocalDateTime.class, LocalDateTime.now());
}
// 2. 填充更新人
if (metaObject.hasSetter("updateUser")) {
this.strictUpdateFill(metaObject, "updateUser", Long.class, getCurrentUserId());
}
}
}
步骤2:在实体类上添加注解
在您的实体类(如 Employee)中,只需在需要自动填充的字段上添加 @TableField 注解,指定填充策略。
java
import com.baomidou.mybatisplus.annotation.FieldFill;
import com.baomidou.mybatisplus.annotation.TableField;
import java.time.LocalDateTime;
public class Employee {
// ... 其他字段
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createTime;
@TableField(fill = FieldFill.INSERT)
private Long createUser;
// 插入和更新时都需要填充
@TableField(fill = FieldFill.INSERT_UPDATE)
private LocalDateTime updateTime;
@TableField(fill = FieldFill.INSERT_UPDATE)
private Long updateUser;
}
- AOP 方式 :
- 原理: 自定义注解 + Aspect切面。在切面中通过反射给参数对象的属性赋值。
- 场景 : 适用于需要获取上下文信息(如从
ThreadLocal获取当前登录用户的ID)并填充到实体的场景。苍穹外卖中常结合两者使用。
苍穹外卖中AOP机制的分析
切面配置使用了 前置通知 和一个非常精确的 切入点表达式。
A. 切面类:AutoFillAspect
- 在项目中,AutoFillAspect 是一个使用 @Aspect 注解的 Spring Bean。它很可能使用了 前置通知 (@Before) 来实现公共字段填充。
- 执行逻辑: 在 Mapper 方法执行之前,通过反射获取方法参数(通常是实体对象),然后根据当前操作类型(INSERT 或 UPDATE)和当前登录的用户信息,为实体对象的 createTime、createUser、updateTime、updateUser 字段赋值。
B. 切入点表达式解析
切入点表达式是:
com.sky.mapper.*.*(..) && @annotation(com.sky.annotation.autoFill)\text{com.sky.mapper.*.*(..) \&\& @annotation(com.sky.annotation.autoFill)}com.sky.mapper.*.*(..) && @annotation(com.sky.annotation.autoFill)方法匹配部分:com.sky.mapper.*.*(..)com.sky.mapper.*: 匹配 com.sky.mapper 包下的所有类 (通常是 Mapper 接口)。.*(...): 匹配这些类中的所有方法 (方法名和参数数量不限)。
效果: 限制了切面只在 mapper 包下的方法上查找连接点。
注解匹配部分:@annotation(com.sky.annotation.autoFill)@annotation(...): 匹配所有被括号内指定的注解标注的方法。
效果: 只有当 com.sky.mapper 包下的某个方法同时被 @AutoFill 注解标记时,切面才会被织入。
总结: 只有当您在 Mapper 接口中定义的方法(如 insert 或 update 方法)上同时使用了 @AutoFill 注解时,AutoFillAspect 的逻辑才会在该方法执行时被触发。
二、 架构设计与难点解决
这部分涉及企业级开发的规范和常见"坑"的解决方案。
1. 雪花算法精度丢失问题
- 现象 : 后端ID为Long类型(19位),前端JS的Number类型最大安全整数是253−12^{53}-1253−1(约16位)。传递到前端时,后几位会被四舍五入,导致ID不一致。
- 解决方案 : 后端将Long转为String传输。
- 实现方式 :
- 局部处理 : 在实体类ID字段上加注解
@JsonSerialize(using = ToStringSerializer.class)。 - 全局处理 : 配置Jackson的消息转换器 (
MappingJackson2HttpMessageConverter),将所有Long类型序列化为String。
- 局部处理 : 在实体类ID字段上加注解
2. 企业级文件上传设计
- 技术栈: 阿里云 OSS (Object Storage Service)。
- 设计模式 :
- XXXProperties : 使用
@ConfigurationProperties读取application.yml中的配置(endpoint, key, secret, bucket),实现配置与代码分离。 - XXXConfiguration : 使用
@Configuration和@Bean,利用Properties创建OSS客户端对象,交给Spring容器管理。 - 接口设计 : 定义标准的
FileStorageService接口,包含upload方法。 - 多实现类: 如果未来切换到腾讯云COS或MinIO,只需新增实现类并修改配置,无需修改业务代码(符合开闭原则)。
- XXXProperties : 使用
三、 中间件集成 (Redis)
StringRedisTemplate vs RedisTemplate
| 特性 | RedisTemplate | StringRedisTemplate |
|---|---|---|
| 序列化方式 | 默认使用 JdkSerializationRedisSerializer |
默认使用 StringRedisSerializer |
| 存储格式 | 二进制流(乱码状,不可读) | 纯字符串(清晰可读) |
| 占用空间 | 较大(包含类信息) | 较小 |
| 适用场景 | 存储复杂的Java对象,且不关心Redis中数据的可读性 | 存储简单的KV结构,或者需要与其他语言交互,或者需要人工排查Redis数据 |
| 推荐 | 一般不推荐直接用默认的。通常会自定义配置JSON序列化器。 | 推荐使用。手动将对象转为JSON字符串存入,取出来再转回对象。 |