1. 公共字段自动填充的实现与优化
在开发企业级应用时,我们经常需要处理一些公共字段的填充问题。比如每条记录的创建时间、创建人、修改时间、修改人等字段,如果每次都手动设置,不仅繁琐而且容易遗漏。今天我就来分享一个通过AOP实现公共字段自动填充的实战方案。
1.1 为什么需要公共字段自动填充
在数据库设计中,几乎每张表都会有create_time、create_user、update_time、update_user这样的公共字段。传统做法是在每个业务方法中手动设置这些值,但这会导致:
- 代码重复度高,维护困难
- 容易遗漏设置某些字段
- 业务逻辑与非业务逻辑混杂
- 修改公共字段规则时需要改动多处代码
通过AOP实现自动填充可以完美解决这些问题,让业务代码更专注于核心逻辑。
1.2 核心实现方案设计
我们的自动填充方案基于Spring AOP实现,主要包含以下几个关键组件:
- 自定义注解:标记需要自动填充的方法
- 枚举类:定义操作类型(INSERT/UPDATE)
- 切面类:实现字段填充的核心逻辑
- 反射工具:动态设置字段值
这种设计遵循了"约定优于配置"的原则,只需要在方法上添加注解,就能自动完成字段填充。
1.3 详细实现步骤
1.3.1 定义自定义注解
首先创建一个@AutoFill注解,用于标记需要自动填充的方法:
java复制@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface AutoFill {
OperationType value();
}
这里的OperationType是一个枚举类,定义了两种操作类型:
java复制public enum OperationType {
INSERT,
UPDATE
}
1.3.2 实现切面逻辑
切面类是自动填充的核心,它需要完成以下工作:
- 拦截带有
@AutoFill注解的方法 - 根据操作类型确定需要填充的字段
- 通过反射设置字段值
java复制@Aspect
@Component
@Slf4j
public class AutoFillAspect {
@Pointcut("@annotation(com.sky.annotation.AutoFill)")
public void autoFillPointCut() {}
@Before("autoFillPointCut()")
public void autoFill(JoinPoint joinPoint) {
// 获取方法签名和方法对象
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
Method method = signature.getMethod();
// 获取方法上的AutoFill注解
AutoFill autoFill = method.getAnnotation(AutoFill.class);
OperationType operationType = autoFill.value();
// 获取方法参数
Object[] args = joinPoint.getArgs();
if(args == null || args.length == 0) {
return;
}
Object entity = args[0];
// 准备填充的数据
LocalDateTime now = LocalDateTime.now();
Long currentUserId = BaseContext.getCurrentId();
// 根据操作类型填充不同字段
if(operationType == OperationType.INSERT) {
try {
Method setCreateTime = entity.getClass().getDeclaredMethod("setCreateTime", LocalDateTime.class);
Method setCreateUser = entity.getClass().getDeclaredMethod("setCreateUser", Long.class);
Method setUpdateTime = entity.getClass().getDeclaredMethod("setUpdateTime", LocalDateTime.class);
Method setUpdateUser = entity.getClass().getDeclaredMethod("setUpdateUser", Long.class);
setCreateTime.invoke(entity, now);
setCreateUser.invoke(entity, currentUserId);
setUpdateTime.invoke(entity, now);
setUpdateUser.invoke(entity, currentUserId);
} catch (Exception e) {
log.error("自动填充公共字段失败", e);
}
} else if(operationType == OperationType.UPDATE) {
try {
Method setUpdateTime = entity.getClass().getDeclaredMethod("setUpdateTime", LocalDateTime.class);
Method setUpdateUser = entity.getClass().getDeclaredMethod("setUpdateUser", Long.class);
setUpdateTime.invoke(entity, now);
setUpdateUser.invoke(entity, currentUserId);
} catch (Exception e) {
log.error("自动填充公共字段失败", e);
}
}
}
}
1.3.3 使用示例
在Service方法上添加注解即可实现自动填充:
java复制@AutoFill(OperationType.INSERT)
public void save(Dish dish) {
dishMapper.insert(dish);
}
@AutoFill(OperationType.UPDATE)
public void update(Dish dish) {
dishMapper.update(dish);
}
1.4 性能优化与注意事项
-
反射性能:反射调用比直接调用慢,但在这个场景下影响可以忽略不计。如果特别关注性能,可以考虑使用字节码增强技术。
-
线程安全:切面类本身是无状态的,是线程安全的。但要注意
BaseContext.getCurrentId()的实现需要保证线程安全。 -
字段检查:在实际项目中,建议先检查实体类是否包含这些字段,避免抛出异常。
-
日志记录:添加适当的日志记录,方便排查问题。
-
异常处理:反射调用可能会抛出各种异常,要做好异常处理,避免影响主流程。
提示:如果项目中使用Lombok,确保实体类的setter方法存在。可以通过
@Setter注解或IDE生成。
2. 菜品管理模块实现
2.1 新增菜品功能实现
新增菜品是一个典型的复杂表单提交场景,通常涉及多张表的操作:
- 菜品基本信息表
- 菜品分类表
- 菜品口味表(一对多关系)
2.1.1 事务管理
由于涉及多表操作,必须使用事务保证数据一致性:
java复制@Transactional
public void saveWithFlavor(DishDTO dishDTO) {
// 保存菜品基本信息
Dish dish = new Dish();
BeanUtils.copyProperties(dishDTO, dish);
dishMapper.insert(dish);
// 获取插入后生成的主键值
Long dishId = dish.getId();
// 保存口味数据
List<DishFlavor> flavors = dishDTO.getFlavors();
if(flavors != null && !flavors.isEmpty()) {
flavors.forEach(flavor -> {
flavor.setDishId(dishId);
});
dishFlavorMapper.insertBatch(flavors);
}
}
这里使用了@Transactional注解,确保所有操作要么全部成功,要么全部回滚。
2.1.2 主键回填问题
在MyBatis中,执行insert操作后,自增主键默认不会回填到实体对象中。需要通过以下两种方式之一解决:
- 在Mapper接口方法上添加
@Options注解:
java复制@Options(useGeneratedKeys = true, keyProperty = "id")
void insert(Dish dish);
- 在XML映射文件中配置:
xml复制<insert id="insert" useGeneratedKeys="true" keyProperty="id">
INSERT INTO dish (...)
VALUES (...)
</insert>
2.1.3 文件上传实现
菜品图片通常需要上传到云存储。我们使用阿里云OSS作为示例:
- 首先配置OSS相关参数:
yaml复制sky:
alioss:
endpoint: oss-cn-beijing.aliyuncs.com
access-key-id: your-access-key-id
access-key-secret: your-access-key-secret
bucket-name: your-bucket-name
- 创建配置类读取这些参数:
java复制@Data
@Component
@ConfigurationProperties(prefix = "sky.alioss")
public class AliOssProperties {
private String endpoint;
private String accessKeyId;
private String accessKeySecret;
private String bucketName;
}
- 实现工具类:
java复制@Component
@RequiredArgsConstructor
public class AliOssUtil {
private final AliOssProperties aliOssProperties;
public String upload(MultipartFile file) throws IOException {
// 初始化OSS客户端
OSS ossClient = new OSSClientBuilder().build(
aliOssProperties.getEndpoint(),
aliOssProperties.getAccessKeyId(),
aliOssProperties.getAccessKeySecret());
try {
// 生成唯一文件名
String originalFilename = file.getOriginalFilename();
String extension = originalFilename.substring(originalFilename.lastIndexOf("."));
String objectName = UUID.randomUUID().toString() + extension;
// 上传文件
ossClient.putObject(aliOssProperties.getBucketName(), objectName, file.getInputStream());
// 拼接访问URL
return "https://" + aliOssProperties.getBucketName() + "."
+ aliOssProperties.getEndpoint() + "/" + objectName;
} finally {
ossClient.shutdown();
}
}
}
- 在Controller中使用:
java复制@PostMapping
public Result<String> save(@RequestPart DishDTO dishDTO, @RequestPart MultipartFile file) {
try {
// 上传图片
String imageUrl = aliOssUtil.upload(file);
dishDTO.setImage(imageUrl);
// 保存菜品信息
dishService.saveWithFlavor(dishDTO);
return Result.success();
} catch (IOException e) {
throw new RuntimeException("文件上传失败", e);
}
}
2.2 删除菜品功能实现
删除菜品需要考虑以下几点:
- 菜品可能被套餐引用,不能直接删除
- 需要同时删除关联的口味数据
- 需要保证操作的原子性
2.2.1 实现方案
java复制@Transactional
public void deleteByIds(List<Long> ids) {
// 检查菜品是否可以被删除(是否被套餐关联)
Integer count = setmealDishMapper.countByDishIds(ids);
if(count > 0) {
throw new DeletionNotAllowedException(MessageConstant.DISH_BE_RELATED_BY_SETMEAL);
}
// 删除菜品数据
dishMapper.deleteByIds(ids);
// 删除关联的口味数据
dishFlavorMapper.deleteByDishIds(ids);
}
2.2.2 为什么套餐-菜品关联表需要单独Mapper
- 查询效率:检查菜品是否被套餐引用是一个高频操作,单独Mapper可以优化查询性能
- 业务清晰:套餐-菜品关系是一个独立的业务概念,单独Mapper更符合单一职责原则
- 复用性:其他业务场景也可能需要查询套餐-菜品关系
相比之下,菜品口味是菜品的附属信息,通常只在菜品上下文中使用,所以可以直接在菜品Mapper中处理。
2.3 动态SQL拼接技巧
在实现菜品查询功能时,经常需要根据条件动态拼接SQL。MyBatis提供了多种方式实现动态SQL:
- 使用
<if>标签:
xml复制<select id="list" resultType="Dish">
SELECT * FROM dish
<where>
<if test="name != null and name != ''">
AND name LIKE concat('%', #{name}, '%')
</if>
<if test="categoryId != null">
AND category_id = #{categoryId}
</if>
<if test="status != null">
AND status = #{status}
</if>
</where>
</select>
- 使用
concat函数处理模糊查询:
java复制@Select("SELECT * FROM dish WHERE name LIKE concat('%', #{name}, '%')")
List<Dish> findByName(String name);
- 使用
<foreach>处理批量操作:
xml复制<delete id="deleteByIds">
DELETE FROM dish WHERE id IN
<foreach collection="ids" item="id" open="(" separator="," close=")">
#{id}
</foreach>
</delete>
3. 开发中的常见问题与解决方案
3.1 事务失效的常见原因
- 方法访问权限问题:Spring AOP要求代理方法必须是public的
- 自调用问题:同一个类中方法A调用方法B,即使B有
@Transactional也不会生效 - 异常被捕获:只有异常传播到事务切面才会触发回滚
- 数据库引擎不支持:如使用MyISAM引擎
提示:可以通过在方法中抛出
new RuntimeException()来测试事务是否生效
3.2 MyBatis使用中的坑
-
实体类属性名与数据库字段名不一致:
- 使用
@Result注解手动映射 - 在配置文件中开启驼峰命名自动转换
- 在SQL中使用别名
- 使用
-
动态SQL中的
test表达式:- 判断字符串空要同时检查null和空字符串:
name != null and name != '' - 判断集合非空:
list != null and !list.isEmpty()
- 判断字符串空要同时检查null和空字符串:
-
批量插入优化:
- 使用
<foreach>拼接SQL - 使用
ExecutorType.BATCH模式 - 考虑使用MyBatis-Plus的
saveBatch方法
- 使用
3.3 枚举的使用技巧
-
数据库存枚举值:
- 存ordinal(不推荐,顺序改变会出问题)
- 存name(推荐,可读性好)
- 使用自定义code(最灵活)
-
MyBatis处理枚举:
- 实现
TypeHandler接口 - 使用MyBatis-Plus的
@EnumValue注解
- 实现
-
枚举与JSON转换:
- Jackson默认使用name
- 可以通过
@JsonValue指定序列化值
4. 项目架构思考与优化建议
4.1 分层架构的边界划分
-
Controller层:
- 只做参数校验和格式转换
- 不包含业务逻辑
- 统一异常处理
-
Service层:
- 实现核心业务逻辑
- 处理事务
- 调用多个Mapper或外部服务
-
Mapper层:
- 只做数据访问
- 不包含业务判断
- 简单的CRUD操作
4.2 代码复用策略
-
公共字段处理:
- 使用AOP自动填充
- 定义BaseEntity包含公共字段
- 使用MyBatis拦截器
-
工具类封装:
- 通用分页查询
- 文件上传下载
- 数据转换
-
异常处理:
- 定义业务异常体系
- 全局异常处理器
- 统一返回格式
4.3 性能优化方向
-
数据库层面:
- 合理设计索引
- 避免全表扫描
- 使用连接池
-
缓存策略:
- 热点数据缓存
- 多级缓存架构
- 缓存一致性保证
-
并发控制:
- 乐观锁/悲观锁
- 分布式锁
- 限流降级
在实际开发中,我发现在处理复杂表单提交时,将业务逻辑分解为多个小方法,每个方法只做一件事,可以大大提高代码的可读性和可维护性。同时,合理使用设计模式如策略模式、模板方法模式等,可以让代码结构更清晰。