AOP 操作日志笔记
一、AOP 基础概念
AOP(Aspect Oriented Programming,面向切面编程)把”日志记录、权限校验、事务管理”这类横跨多个业务方法、但又和业务本身无关的通用逻辑,从业务代码中抽取出来,集中写在一个”切面”里,由 Spring 动态代理在方法执行的前后自动织入,业务代码零侵入。
核心术语:
| 术语 | 英文 | 在本项目中的对应 |
|---|---|---|
| 切面 | Aspect(@Aspect) |
OperLogAspect 类,日志逻辑全部集中在这里 |
| 切点 | Pointcut(@Pointcut) |
@annotation(com.situ.mall.annotation.MyLog)——拦截所有标注了 @MyLog 的方法 |
| 通知 | Advice(@Before/@AfterReturning/@AfterThrowing 等) |
doBefore、doAfterReturning、doAfterThrowable 三个方法 |
| 连接点 | JoinPoint | 每一个被拦截到的 Controller 方法 |
常用通知类型:
@Before:方法执行前@AfterReturning:方法正常返回后@AfterThrowing:方法抛出异常后@After:方法执行后(无论成功失败,相当于 finally)@Around:环绕通知,功能最强,可完全控制是否执行目标方法
二、整体流程
用户请求(带JWT)
│
▼
网关 AuthGlobalFilter:校验token,把用户id/name塞进请求头 X-Login-Id / X-Login-Name
│
▼
微服务 LoginInterceptor:读取请求头,写入 ThreadLocal(LoginContext)
│
▼
Controller 方法(@MyLog标注) ◄────── AOP 代理拦截 ──────┐
│ │
│ ┌───────────────────────────────────────────┐ │
│ │ OperLogAspect │ │
├──►│ @Before 记录开始时间/IP/参数/UA │ │
│ │ │ │ │
│ │ ▼ 执行目标方法 │ │
│ │ @AfterReturning 记录响应+耗时,存库 │ │
│ │ @AfterThrowing 记录异常+耗时,存库 │ │
│ └───────────────────────────────────────────┘ │
▼
日志最终写入 MySQL 的 oper_log 表
依赖链:自定义注解 @MyLog(标记谁要记日志)→ 切面 OperLogAspect(怎么记)→ 实体 OperLog + MyBatis-Plus(记到哪)。
三、引入依赖(根 pom.xml)
<!--AOP-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
<!--工具库:取IP、解析User-Agent、格式化参数等-->
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
<version>5.8.16</version>
</dependency>
spring-boot-starter-aop 引入了 AspectJ,是 @Aspect 生效的前提;hutool 用来获取客户端真实 IP(JakartaServletUtil.getClientIP,能穿透 Nginx 等代理头)和解析 User-Agent。
四、自定义注解 @MyLog(mall-common/.../annotation/MyLog.java)
@Target({ElementType.METHOD}) // 只能标注在方法上
@Retention(RetentionPolicy.RUNTIME) // 运行时保留,反射才能读到
@Documented
public @interface MyLog {
/**
* 模块
*/
public String module() default "";
}
说明:
@Target(METHOD)——注解只能贴在方法上;@Retention(RUNTIME)——关键。注解默认在编译后就被丢弃,只有声明为 RUNTIME,切面里才能通过反射method.getAnnotation(MyLog.class)读到它;module属性用来描述这个方法是干什么的(”查询”/”添加”/”删除”……),最终写进日志表的module字段,查询日志时一眼能看出是什么操作。
为什么用注解而不是切所有 Controller?切点写成
execution(public com.situ.mall.controller..*(..))也可以(代码里保留了这行被注释的写法),但那样粒度太粗——查询、登录等无需审计的请求也会记日志。注解方式的好处是”想记谁就贴谁”,由业务开发者自己决定。
五、日志实体 OperLog(mall-common/.../pojo/OperLog.java)
对应数据库 oper_log 表,MyBatis-Plus 注解映射:
@Data
@EqualsAndHashCode(callSuper = false)
public class OperLog implements Serializable {
@TableId(value = "id", type = IdType.AUTO)
private Long id;
/** 模块标题 */
private String module;
/** 日志类型 INFO/ERROR */
@TableField("log_type")
private String logType;
/** 操作人员id */
@TableField("admin_id")
private Long adminId;
/** 操作人员 */
@TableField("admin_name")
private String adminName;
/** 请求方式GET/POST */
@TableField("request_method")
private String requestMethod;
/** 请求URI */
@TableField("request_uri")
private String requestUri;
/** 请求参数 */
@TableField("request_params")
private String requestParams;
/** 返回参数 */
@TableField("response_params")
private String responseParams;
/** 客户端IP */
@TableField("request_ip")
private String requestIp;
/** 请求服务器地址 */
@TableField("server_address")
private String serverAddress;
/** 是否异常 1是异常,0不是 */
private Integer exception;
/** 异常信息 */
@TableField("exception_msg")
private String exceptionMsg;
/** 开始/结束/执行耗时 */
@TableField("start_time")
private Date startTime;
@TableField("end_time")
private Date endTime;
@TableField("execute_time")
private Integer executeTime;
/** 用户代理原始字符串 */
@TableField("user_agent")
private String userAgent;
/** 操作系统 */
@TableField("device_name")
private String deviceName;
/** 浏览器名称 */
@TableField("browser_name")
private String browserName;
/** 逻辑删除 1表示删除,0表示未删除 */
@TableLogic
private Integer deleted;
/** 创建/更新时间,MyBatis-Plus自动填充 */
@TableField(value = "create_time", fill = FieldFill.INSERT)
private Date createTime;
@TableField(value = "update_time", fill = FieldFill.INSERT_UPDATE)
private Date updateTime;
}
建表 SQL(对应字段):
CREATE TABLE oper_log (
id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键',
module VARCHAR(64) COMMENT '模块标题(来自@MyLog的module)',
log_type VARCHAR(16) COMMENT '日志类型 INFO/ERROR',
admin_id BIGINT COMMENT '操作人id(网关透传)',
admin_name VARCHAR(64) COMMENT '操作人姓名',
request_method VARCHAR(16) COMMENT 'GET/POST/PUT/DELETE',
request_uri VARCHAR(255) COMMENT '请求URI',
request_params TEXT COMMENT '请求参数',
response_params TEXT COMMENT '响应结果(JSON)',
request_ip VARCHAR(64) COMMENT '客户端IP',
server_address VARCHAR(128) COMMENT '服务器地址',
exception TINYINT COMMENT '是否异常 1是 0否',
exception_msg TEXT COMMENT '异常信息',
start_time DATETIME COMMENT '开始时间',
end_time DATETIME COMMENT '结束时间',
execute_time INT COMMENT '执行耗时(毫秒)',
user_agent VARCHAR(512) COMMENT 'User-Agent原文',
device_name VARCHAR(64) COMMENT '操作系统',
browser_name VARCHAR(64) COMMENT '浏览器',
deleted TINYINT DEFAULT 0 COMMENT '逻辑删除',
create_time DATETIME COMMENT '创建时间',
update_time DATETIME COMMENT '更新时间'
) COMMENT '操作日志记录';
六、核心:切面类 OperLogAspect(mall-common/.../aspect/OperLogAspect.java)
@Log4j2
@Aspect // 声明这是一个切面
@Component // 交给Spring管理,这样才能注入Service
public class OperLogAspect {
String LOG_INFO = "INFO";
String LOG_ERROR = "ERROR";
//ThreadLocal是线程隔离的
private ThreadLocal<OperLog> operLogThreadLocal = new ThreadLocal<>();
@Autowired
private IOperLogService operLogService;
/**
* 日志切点:拦截所有标注了 @MyLog 注解的方法
*/
//@Pointcut("execution(public * com.situ.mall.controller.*.*(..))") // 按包拦截的备选写法
@Pointcut("@annotation(com.situ.mall.annotation.MyLog))")
public void operLogAspect() {
}
/**
* 前置通知:目标方法执行前,收集本次请求的全部信息
*/
@Before(value = "operLogAspect()")
public void doBefore(JoinPoint joinPoint) throws Throwable {
// 拿到当前HTTP请求对象(因为切面不是Controller,拿不到request,必须从RequestContextHolder取)
HttpServletRequest request = ((ServletRequestAttributes) Objects
.requireNonNull(RequestContextHolder.getRequestAttributes())).getRequest();
OperLog operLog = new OperLog();
// 1. 操作人:从LoginContext(ThreadLocal)取,网关鉴权后透传过来的
Map<String, Object> map = LoginContext.getLoginInfo();
if (map != null) {
Long id = (Long) map.get("id");
String name = (String) map.get("name");
operLog.setAdminId(id);
operLog.setAdminName(name);
}
// 2. 通过反射读取方法上的 @MyLog 注解,拿到 module 描述
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
Method method = signature.getMethod();
MyLog myLog = method.getAnnotation(MyLog.class);
if (myLog != null && myLog.module() != null) {
operLog.setModule(myLog.module());
}
// 3. 记录请求各项信息
operLog.setStartTime(new Date());
operLog.setRequestUri(URLUtil.getPath(request.getRequestURI()));
operLog.setRequestParams(formatParams(request.getParameterMap()));
operLog.setRequestMethod(request.getMethod());
operLog.setRequestIp(JakartaServletUtil.getClientIP(request)); // 能穿透代理,取真实IP
operLog.setServerAddress(request.getScheme() + "://" + request.getServerName() + ":" + request.getServerPort());
// 4. 解析User-Agent,得到操作系统和浏览器
String userAgentStr = request.getHeader("User-Agent");
operLog.setUserAgent(userAgentStr);
UserAgent userAgent = UserAgentUtil.parse(userAgentStr);
operLog.setDeviceName(userAgent.getOs().getName());
operLog.setBrowserName(userAgent.getBrowser().getName());
// 5. 存入ThreadLocal,留给"返回通知/异常通知"续用
operLogThreadLocal.set(operLog);
log.info("开始计时: {} URI: {} IP: {}", operLog.getStartTime(), operLog.getRequestUri(), operLog.getRequestIp());
}
/**
* 正常返回通知:方法成功返回后,补齐响应和耗时,写库
*/
@AfterReturning(pointcut = "operLogAspect()", returning = "ret")
public void doAfterReturning(Object ret) {
OperLog operLog = operLogThreadLocal.get();
operLog.setLogType(LOG_INFO);
operLog.setEndTime(new Date());
operLog.setExecuteTime(Math.toIntExact(Long.valueOf(ChronoUnit.MILLIS.between(
LocalDateTime.ofInstant(operLog.getStartTime().toInstant(), ZoneId.systemDefault()),
LocalDateTime.ofInstant(operLog.getEndTime().toInstant(), ZoneId.systemDefault())))));
operLog.setException(0);
// 响应结果转JSON存字符串
if (ret instanceof Result) {
Result r = Convert.convert(Result.class, ret);
try {
operLog.setResponseParams(new ObjectMapper().writeValueAsString(r));
} catch (JsonProcessingException e) {
throw new RuntimeException(e);
}
} else if (ret instanceof String) {//转发界面
operLog.setResponseParams((String) ret);
}
operLogService.save(operLog);
operLogThreadLocal.remove(); // 用完必须清,防止内存泄漏和脏数据
log.info("计时结束: {} 用时: {}ms URI: {} ", operLog.getEndTime(), operLog.getExecuteTime(),
operLog.getRequestUri());
}
/**
* 异常通知:方法抛异常后,记录异常信息,写库
*/
@AfterThrowing(pointcut = "operLogAspect()", throwing = "e")
public void doAfterThrowable(Throwable e) {
OperLog operLog = operLogThreadLocal.get();
operLog.setLogType(LOG_ERROR);
operLog.setEndTime(new Date());
operLog.setExecuteTime(Math.toIntExact(Long.valueOf(ChronoUnit.MILLIS.between(
LocalDateTime.ofInstant(operLog.getStartTime().toInstant(), ZoneId.systemDefault()),
LocalDateTime.ofInstant(operLog.getEndTime().toInstant(), ZoneId.systemDefault())))));
operLog.setException(1);
operLog.setExceptionMsg(e.getMessage());
operLogService.save(operLog);
operLogThreadLocal.remove();
log.info("计时结束: {} 用时: {}ms URI: {} ", operLog.getEndTime(), operLog.getExecuteTime(),
operLog.getRequestUri());
}
/**
* 格式化请求参数,密码脱敏
*/
private String formatParams(Map<String, String[]> parameterMap) {
if (parameterMap == null) {
return null;
}
StringBuilder params = new StringBuilder();
for (Map.Entry<String, String[]> param : (parameterMap).entrySet()) {
if (params.length() != 0) {
params.append("&");
}
params.append(param.getKey() + "=");
if (StrUtil.endWithIgnoreCase(param.getKey(), "password")) {
params.append("*"); // 参数名以password结尾的,值打码
} else if (param.getValue() != null) {
params.append(ArrayUtil.join(param.getValue(), ","));
}
}
return params.toString();
}
}
逐点解读
① 切点 @Pointcut @annotation(com.situ.mall.annotation.MyLog) 意思是:拦截所有标注了 @MyLog 注解的方法。下面的空方法 operLogAspect() 只是给切点起个名字,其他通知用 "operLogAspect()" 引用它,避免重复写表达式。
② ThreadLocal 存 OperLog(贯穿全程的”接力棒”) 一次请求要被三个通知接力处理:@Before 收集信息 → @AfterReturning(或 @AfterThrowing)补齐结果和耗时。这三个方法之间没有参数传递的途径,所以用 ThreadLocal<OperLog> 把半成品日志对象暂存起来。ThreadLocal 是线程隔离的——Tomcat 每个请求一个线程,各请求的日志对象互不串扰。用完必须 remove(),否则线程池复用线程时会内存泄漏、拿到上一个请求的脏数据。
③ 操作人信息从哪来(微服务下的链路) 切面运行在微服务里,request 里没有登录态。完整链路是:前端带 JWT → 网关 AuthGlobalFilter 解析 token,把用户 id/name 写进请求头 X-Login-Id/X-Login-Name → LoginInterceptor 读请求头存入 LoginContext(也是 ThreadLocal)→ 切面里 LoginContext.getLoginInfo() 取出。三个 ThreadLocal 环环相扣。
④ RequestContextHolder 获取 request 切面类不是 Controller,方法参数里没有 HttpServletRequest。Spring 把当前请求绑存在 RequestContextHolder 里,任何代码都能通过 ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getRequest() 拿到——这是在非 Web 层代码里获取 request 的标准姿势。
⑤ 耗时计算 @Before 记 startTime,返回/异常通知记 endTime,用 ChronoUnit.MILLIS.between(...) 算毫秒差存入 executeTime,日志表就是一份现成的”接口性能报表”。
⑥ 正常与异常两条分支 @AfterReturning 标 logType=INFO、exception=0,把返回的 Result 序列化成 JSON 存 response_params;@AfterThrowing 标 logType=ERROR、exception=1,存 exception_msg。两个通知不会同时触发,正好覆盖成功/失败两种结局。
⑦ 密码脱敏 formatParams 里对参数名以 password 结尾(不区分大小写)的值替换成 *,避免明文密码进日志库——日志往往权限控制比业务库宽松,这是安全底线。
七、使用方式:在 Controller 方法上贴注解
@RestController
@RequestMapping("/admin")
public class AdminController {
@MyLog(module = "查询")
@GetMapping
public Result select(...)
@MyLog(module = "添加")
@PostMapping
public Result add(...)
@MyLog(module = "修改")
@PutMapping("/{id}")
public Result update(...)
@MyLog(module = "删除")
@DeleteMapping("/{id}")
public Result delete(...)
@MyLog(module = "批量删除")
@DeleteMapping
public Result deleteBatch(...)
}
业务方法里一行日志代码都不用写,只要贴上 @MyLog(module = "xxx"),AOP 自动在方法前后完成收集、计时、入库。
目前
mall-admin的AdminController(查询/添加/修改/删除/批量删除)和mall-user的UserController都在用这个注解。
八、存储层(MyBatis-Plus 三件套)
// Mapper
public interface OperLogMapper extends BaseMapper<OperLog> {
}
// Service接口
public interface IOperLogService extends IService<OperLog> {
}
// Service实现:save()方法直接继承自MyBatis-Plus,一行不写
@Service
public class OperLogServiceImpl extends ServiceImpl<OperLogMapper, OperLog> implements IOperLogService {
}
切面里 operLogService.save(operLog) 一个方法完成入库——这就是用 MyBatis-Plus 的好处,单表操作零 SQL。
九、日志查询接口(待完成)
@RestController
@RequestMapping("/oper-log")
public class OperLogController {
// 目前是空壳,可补充分页查询
}
可以补一个分页查询(配合 MyBatis-Plus 的 Page):
@Autowired
private IOperLogService operLogService;
@GetMapping
public Result page(@RequestParam(defaultValue = "1") Integer pageNum,
@RequestParam(defaultValue = "10") Integer pageSize) {
Page<OperLog> page = operLogService.page(new Page<>(pageNum, pageSize),
new QueryWrapper<OperLog>().orderByDesc("id"));
return Result.ok(page);
}
⚠️ 注意:网关
application.yml里目前没有/oper-log/的路由**,前端经网关访问日志接口会 404,需要补一条路由;同时别忘了AuthGlobalFilter的鉴权(日志查询应要求登录,无需加白名单)。
十、可改进的点
@RequestBody的 JSON 参数没被记录:formatParams(request.getParameterMap())只能拿到 URL 参数和表单参数,本项目登录等接口都是 JSON body,request_params会是空的。可在doBefore里追加joinPoint.getArgs()序列化:
if (StrUtil.isBlank(operLog.getRequestParams()) && joinPoint.getArgs().length > 0) {
operLog.setRequestParams(new ObjectMapper().writeValueAsString(joinPoint.getArgs()));
}
- 日志入库是同步的:
save()卡在请求主链路上,接口慢一点、日志库挂了还会影响业务。改进:@Async异步保存,或塞进消息队列。 Convert.convert(Result.class, ret)多余:ret本来就是Result,强转即可Result r = (Result) ret;,不必走 hutool 的反射转换。new ObjectMapper()每次新建:ObjectMapper 创建开销大且线程安全,应注入单例复用。- 异常通知里
e.getMessage()可能为 null(如 NPE),可改存e.toString()或堆栈前几百字符。 @AfterReturning里假设 ThreadLocal 一定有值:极端情况下(如异步场景)operLogThreadLocal.get()可能返回 null 导致 NPE,建议加判空。