JavaWeb-AI-Tlias 技术亮点
这篇文章记录了在开发 Tlias 教学管理系统过程中,几个值得深入理解的技术点。包括 AOP 操作日志、声明式事务管理、以及自定义 SpringBoot Starter 的完整实现。
一、AOP 面向切面编程 -- 操作日志记录
1.1 为什么需要 AOP?
在 Web 项目中,很多需求是"横切"多个模块的,比如操作日志记录、权限校验、性能监控。如果在每个 Controller 方法里都手动写日志记录逻辑,代码会变得又臭又长。
AOP 的思想就是把这些与业务无关但又必须做的逻辑抽离出来,做成一个"切面",在运行时自动织入到目标方法上。
1.2 自定义注解 @Log
首先定义一个标记注解,用来标注哪些方法需要记录操作日志:
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Log {
}这个注解本身没有任何逻辑,它只是一个"标记"。我们把它加到 Controller 的写操作方法上:
@DeleteMapping
@Log
public Result deleteDeptById(@RequestParam Integer id) {
deptService.deleteDeptById(id);
return Result.success();
}
@PostMapping
@Log
public Result addDept(@RequestBody Dept dept) {
deptService.addDept(dept);
return Result.success();
}可以看到,Controller 层完全不关心日志是怎么记录的,只管加个 @Log 注解就完事了。
1.3 切面类实现
核心逻辑在 OperationLogAspect 中:
@Slf4j
@Aspect
@Component
public class OperationLogAspect {
@Autowired
private OperateLogMapper operateLogMapper;
@Around("@annotation(com.carrot.anno.Log)")
public Object recordLog(ProceedingJoinPoint joinPoint) throws Throwable {
// 1. 记录方法执行开始时间
long startTime = System.currentTimeMillis();
// 2. 执行目标方法
Object result = null;
try {
result = joinPoint.proceed();
} finally {
// 3. 计算方法执行耗时
long endTime = System.currentTimeMillis();
long costTime = endTime - startTime;
// 4. 收集日志信息
OperateLog operateLog = new OperateLog();
operateLog.setOperateEmpId(getCurrentUserId());
operateLog.setOperateTime(new Date());
operateLog.setClassName(joinPoint.getTarget().getClass().getName());
operateLog.setMethodName(joinPoint.getSignature().getName());
operateLog.setCostTime(costTime);
operateLog.setMethodParams(Arrays.toString(joinPoint.getArgs()));
operateLog.setReturnValue(result != null ? result.toString() : "void");
// 5. 保存日志到数据库
operateLogMapper.insert(operateLog);
}
return result;
}
}几个值得注意的设计细节:
- 使用
@Around环绕通知:这是最强的通知类型,可以在方法执行前后都插入逻辑,能拿到方法入参、返回值、执行耗时。 - 把日志记录放在
finally块中:这样无论目标方法成功还是抛异常,日志都能被记录下来。 - 通过
ProceedingJoinPoint获取元信息:joinPoint.getTarget()拿到目标对象,joinPoint.getSignature()拿到方法签名,joinPoint.getArgs()拿到参数列表。
1.4 操作日志实体类
@Data
@AllArgsConstructor
@NoArgsConstructor
public class OperateLog {
private Integer id;
private Integer operateEmpId; // 操作人ID
private Date operateTime; // 操作时间
private String className; // 操作类名
private String methodName; // 操作方法名
private String methodParams; // 操作方法参数
private String returnValue; // 操作方法返回值
private Long costTime; // 操作耗时(ms)
}记录的信息非常完整:谁、什么时候、调了哪个类的哪个方法、传了什么参数、返回了什么、花了多久。这些信息在排查问题时非常有用。
1.5 用 ThreadLocal 获取当前用户
public class CurrentHolder {
private static final ThreadLocal<Integer> CURRENT_HOLDER = new ThreadLocal<>();
public static void setCurrentId(Integer empId) {
CURRENT_HOLDER.set(empId);
}
public static Integer getCurrentId() {
return CURRENT_HOLDER.get();
}
public static void remove() {
CURRENT_HOLDER.remove();
}
}ThreadLocal 是一个线程级别的变量容器,每个请求线程都有自己独立的一份。在登录拦截器中把用户 ID 存进去,在 AOP 切面中取出来,这样就能知道是谁执行了操作。注意用完一定要 remove(),防止内存泄漏。
二、声明式事务管理
2.1 事务的必要性
在员工保存的业务中,需要同时操作两张表:先往 emp 表插入员工记录,再往 emp_expr 表插入工作经历。如果第一条插入成功、第二条失败了,数据就不一致了。事务保证了这两步操作要么全部成功,要么全部回滚。
2.2 @Transactional 的使用
@Override
@Transactional(rollbackFor = {Exception.class}, propagation = Propagation.REQUIRED)
public void save(Emp emp) {
try {
emp.setCreateTime(new Date());
emp.setUpdateTime(new Date());
empMapper.insert(emp);
List<EmpExpr> empExprs = emp.getExprList();
if (!CollectionUtils.isEmpty(empExprs)) {
empExprs.forEach(empExpr -> {
empExpr.setEmpId(emp.getId());
});
empExprMapper.insertBatch(empExprs);
}
} finally {
// 无论成功还是失败都需要记录日志
EmpLog empLog = new EmpLog(null, new Date(), "新增员工:" + emp);
empLogService.insertLog(empLog);
}
}两个关键属性:
rollbackFor = Exception.class:默认情况下,Spring 只对RuntimeException回滚。加上这个配置后,所有Exception及其子类都会触发回滚,更加安全。propagation = Propagation.REQUIRED:如果当前已经存在事务,就加入这个事务;如果不存在,就创建一个新事务。这是最常用的传播行为。
2.3 REQUIRES_NEW -- 日志独立事务
这里有一个精妙的设计。日志记录的 Service 使用了 REQUIRES_NEW 传播行为:
@Service
public class EmpLogServiceImpl implements EmpLogService {
@Autowired
private EmpLogMapper empLogMapper;
@Override
@Transactional(rollbackFor = Exception.class, propagation = Propagation.REQUIRES_NEW)
public void insertLog(EmpLog empLog) {
empLogMapper.insert(empLog);
}
}为什么日志要用 REQUIRES_NEW?
想象一下:员工保存成功了,但在记录日志时出了问题。如果日志和业务操作在同一个事务中,日志失败会导致整个事务回滚,连带已经成功的业务操作也被撤销。这显然不合理。
REQUIRES_NEW 会挂起当前事务,开启一个全新的独立事务。这样日志记录的失败不会影响业务操作的结果,反过来也一样。
2.4 在 finally 中记录日志的智慧
try {
// 业务操作...
} finally {
EmpLog empLog = new EmpLog(null, new Date(), "新增员工:" + emp);
empLogService.insertLog(empLog);
}把日志记录放在 finally 块中,确保无论业务成功还是失败,操作日志都会被记录。这对于审计和问题排查至关重要。
三、自定义 SpringBoot Starter
3.1 为什么要自定义 Starter?
项目中用到了阿里云 OSS 做文件上传。如果每个用 OSS 的项目都写一遍初始化代码,太冗余了。自定义一个 Starter 之后,其他项目只需要引入依赖、写几行配置就能直接用。
3.2 模块划分
自定义 Starter 分为两个模块:
aliyun-oss-spring-boot-starter -- 依赖管理模块(空壳,只管依赖)
aliyun-oss-spring-boot-autoconfigure -- 自动配置模块(核心逻辑)这种分离是 SpringBoot Starter 的标准做法。starter 模块只负责把 autoconfigure 模块和第三方 SDK 的依赖打包在一起,真正的自动配置逻辑全在 autoconfigure 里。
starter 的 pom.xml:
<dependencies>
<dependency>
<groupId>com.carrot</groupId>
<artifactId>aliyun-oss-spring-boot-autoconfigure</artifactId>
<version>0.0.1-SNAPSHOT</version>
</dependency>
</dependencies>3.3 配置属性类
@Data
@ConfigurationProperties(prefix = "aliyun.oss")
public class AliyunOSSProperties {
private String endpoint;
private String bucketName;
private String region;
}@ConfigurationProperties 让 Spring 自动把 application.yml 中 aliyun.oss 下的配置绑定到这个类的字段上:
aliyun:
oss:
endpoint: https://oss-cn-beijing.aliyuncs.com
bucketName: javaweb-ai-tlias-carrot
region: cn-beijing3.4 OSS 操作类
public class AliyunOSSOperator {
private final AliyunOSSProperties aliyunOSSProperties;
public AliyunOSSOperator(AliyunOSSProperties aliyunOSSProperties) {
this.aliyunOSSProperties = aliyunOSSProperties;
}
public String upload(byte[] content, String originalFilename) throws Exception {
String endpoint = aliyunOSSProperties.getEndpoint();
String bucketName = aliyunOSSProperties.getBucketName();
String region = aliyunOSSProperties.getRegion();
// 从环境变量中获取访问凭证
EnvironmentVariableCredentialsProvider credentialsProvider =
CredentialsProviderFactory.newEnvironmentVariableCredentialsProvider();
// 按日期分目录,UUID 防止文件名冲突
String dir = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy/MM"));
String newFileName = UUID.randomUUID()
+ originalFilename.substring(originalFilename.lastIndexOf("."));
String objectName = dir + "/" + newFileName;
// 创建 OSS 客户端并上传
ClientBuilderConfiguration clientBuilderConfiguration = new ClientBuilderConfiguration();
clientBuilderConfiguration.setSignatureVersion(SignVersion.V4);
OSS ossClient = OSSClientBuilder.create()
.endpoint(endpoint)
.credentialsProvider(credentialsProvider)
.clientConfiguration(clientBuilderConfiguration)
.region(region)
.build();
try {
ossClient.putObject(bucketName, objectName, new ByteArrayInputStream(content));
} finally {
ossClient.shutdown();
}
return endpoint.split("//")[0] + "//" + bucketName + "."
+ endpoint.split("//")[1] + "/" + objectName;
}
}几个设计亮点:
- 日期分目录:
yyyy/MM格式让 OSS 上的文件按月份归档,方便管理。 - UUID 文件名:防止多个用户上传同名文件导致覆盖。
- 环境变量存凭证:AK/SK 不写死在代码里,通过环境变量注入,更安全。
- finally 关闭客户端:确保 OSS 客户端资源被释放。
3.5 自动配置类
@Configuration
@EnableConfigurationProperties(AliyunOSSProperties.class)
public class AliyunOSSAutoConfiguration {
@Bean
@ConditionalOnMissingBean
public AliyunOSSOperator aliyunOSSOperator(AliyunOSSProperties aliyunOSSProperties) {
return new AliyunOSSOperator(aliyunOSSProperties);
}
}关键注解说明:
@Configuration:标记这是一个配置类。@EnableConfigurationProperties:启用属性绑定,让AliyunOSSProperties生效。@ConditionalOnMissingBean:只有当容器中没有AliyunOSSOperator类型的 Bean 时才创建。这给了使用者覆盖默认实现的能力。
3.6 注册自动配置
最后一步,在 META-INF/spring/ 下创建文件:
org.springframework.boot.autoconfigure.AutoConfiguration.imports文件内容只有一行:
com.carrot.AliyunOSSAutoConfigurationSpringBoot 启动时会扫描这个文件,自动加载里面声明的配置类。这是 SpringBoot 3.x 的做法(2.x 用的是 spring.factories)。
3.7 使用方如何使用
引入 Starter 依赖后,直接注入就能用:
@RestController
public class UploadController {
@Autowired
private AliyunOSSOperator aliyunOSSOperator;
@PostMapping("/upload")
public String upload(MultipartFile image) throws Exception {
String url = aliyunOSSOperator.upload(image.getBytes(), image.getOriginalFilename());
return url;
}
}不需要 new,不需要手动配置,Spring 自动帮你搞定了一切。
四、总结
| 技术点 | 核心思想 | 实际收益 |
|---|---|---|
| AOP + 自定义注解 | 把日志逻辑从业务代码中剥离 | Controller 代码干净,日志逻辑统一管理 |
@Transactional | 声明式事务,不用手动 commit/rollback | 多表操作的数据一致性得到保证 |
REQUIRES_NEW | 日志使用独立事务 | 业务回滚不影响日志记录 |
| 自定义 Starter | 把通用功能封装成可复用的组件 | 引入依赖 + 写配置即可使用,零代码集成 |
这些技术点在实际项目中非常常见,理解它们的原理和使用场景,对后续开发大型项目很有帮助。代码虽然不复杂,但设计思路值得反复琢磨。