Skip to content

JavaWeb-AI-Tlias 技术亮点 ​

这篇文章记录了在开发 Tlias 教学管理系统过程中,几个值得深入理解的技术点。包括 AOP 操作日志、声明式事务管理、以及自定义 SpringBoot Starter 的完整实现。

一、AOP 面向切面编程 -- 操作日志记录 ​

1.1 为什么需要 AOP? ​

在 Web 项目中,很多需求是"横切"多个模块的,比如操作日志记录、权限校验、性能监控。如果在每个 Controller 方法里都手动写日志记录逻辑,代码会变得又臭又长。

AOP 的思想就是把这些与业务无关但又必须做的逻辑抽离出来,做成一个"切面",在运行时自动织入到目标方法上。

1.2 自定义注解 @Log ​

首先定义一个标记注解,用来标注哪些方法需要记录操作日志:

java
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Log {
}

这个注解本身没有任何逻辑,它只是一个"标记"。我们把它加到 Controller 的写操作方法上:

java
@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 中:

java
@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 操作日志实体类 ​

java
@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 获取当前用户 ​

java
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 的使用 ​

java
@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 传播行为:

java
@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 中记录日志的智慧 ​

java
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:

xml
<dependencies>
    <dependency>
        <groupId>com.carrot</groupId>
        <artifactId>aliyun-oss-spring-boot-autoconfigure</artifactId>
        <version>0.0.1-SNAPSHOT</version>
    </dependency>
</dependencies>

3.3 配置属性类 ​

java
@Data
@ConfigurationProperties(prefix = "aliyun.oss")
public class AliyunOSSProperties {
    private String endpoint;
    private String bucketName;
    private String region;
}

@ConfigurationProperties 让 Spring 自动把 application.yml 中 aliyun.oss 下的配置绑定到这个类的字段上:

yaml
aliyun:
  oss:
    endpoint: https://oss-cn-beijing.aliyuncs.com
    bucketName: javaweb-ai-tlias-carrot
    region: cn-beijing

3.4 OSS 操作类 ​

java
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 自动配置类 ​

java
@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.AliyunOSSAutoConfiguration

SpringBoot 启动时会扫描这个文件,自动加载里面声明的配置类。这是 SpringBoot 3.x 的做法(2.x 用的是 spring.factories)。

3.7 使用方如何使用 ​

引入 Starter 依赖后,直接注入就能用:

java
@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把通用功能封装成可复用的组件引入依赖 + 写配置即可使用,零代码集成

这些技术点在实际项目中非常常见,理解它们的原理和使用场景,对后续开发大型项目很有帮助。代码虽然不复杂,但设计思路值得反复琢磨。