目录
AOP 是什么
AOP(Aspect Oriented Programming,面向切面编程)通过预编译方式或运行期动态代理,把程序中的通用功能统一维护起来,是 OOP 的补充与完善。
它的核心是「切面」——从业务逻辑中分离出来的横切逻辑。只要一个需求与核心业务无关、但需要散落在多处,例如:
- 日志打印及记录
- 权限认证
- 全局异常处理拦截
- 返回值统一处理
- 多数据源切换
- 事务处理
这些都属于 AOP 的适用范围。换句话说,AOP 的价值在于分离关注点:核心关注点写业务,横切关注点交给切面,符合这个特征就可以用 AOP。
实现 AOP 的技术路线有两类:
| 方式 | 说明 |
|---|---|
| 动态代理 | 截取消息并对消息进行装饰,取代原有对象行为的执行 |
| 静态织入 | 引入特定语法创建「方面」,编译器在编译期织入代码 |
Spring AOP 走的是动态代理路线。
AOP 的核心术语
| 术语 | 含义 |
|---|---|
| Joinpoint(连接点) | 程序执行中的一个精确执行点,例如类中的某个方法。抽象概念,实现时不需要定义 |
| Pointcut(切入点) | 捕获连接点的结构,用于定义「拦截哪些方法的调用」 |
| Advice(通知) | 切入点的执行代码,即「方面」的具体逻辑 |
| Aspect(方面) | Pointcut + Advice 的组合,类似 OOP 中的类,但表达的是对象间的横向关系 |
| Introduce(引入) | 为对象引入附加的方法或属性,改变对象结构(部分工具称 mixin) |
通知有 5 种类型:
- 前置通知(Before):目标方法调用之前执行
- 后置通知(After):目标方法完成后执行,不关心输出结果
- 返回通知(AfterReturning):目标方法成功执行后调用
- 异常通知(AfterThrowing):目标方法抛出异常后调用
- 环绕通知(Around):包裹目标方法,可在调用前后执行自定义行为
本文的日志拦截用的就是环绕通知,因为需要在方法执行前后各取一次时间戳,并拿到返回值。
引入依赖与配置
原则上只要引入 Spring 框架的 AOP 依赖即可使用,但 Spring Boot 额外提供了 spring-boot-starter-aop 自动配置模块,用起来更省事。它由两部分组成:
spring-boot-autoconfigure下的org.springframework.boot.autoconfigure.aop.AopAutoConfiguration提供@Configuration配置类与配置项spring-boot-starter-aop自身提供spring-aop、aspectjrt、aspectjweaver的依赖
加入依赖后就会自动触发 AOP 关联行为,包括构建 AutoProxyCreator、把横切关注点织入目标对象等。
<!--AOP-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
AopAutoConfiguration 只留了两个配置项,一般不需要配,但需要知道:
spring:
aop:
auto: true # 自动代理开关,false 表示关闭自动 AOP 配置
proxy-target-class: false # true 基于类代理(CGLIB),false 默认基于接口代理(JDK)
实战:用自定义注解记录接口日志
目标:记录接口调用日志,包含接口返回码、接口耗时、请求时间、请求参数、IP、异常信息等,并落库。
一、建表
CREATE TABLE `log_info` (
`id` int(11) NOT NULL AUTO_INCREMENT COMMENT '自增id',
`type` varchar(255) DEFAULT NULL COMMENT '日志类型',
`response_code` varchar(255) DEFAULT NULL COMMENT '接口返回状态码',
`operate_time` datetime DEFAULT NULL COMMENT '请求时间',
`spend_time` bigint(255) DEFAULT NULL COMMENT '消耗时间',
`url` varchar(255) DEFAULT NULL COMMENT 'url',
`body` varchar(255) DEFAULT NULL COMMENT '请求体',
`ip` varchar(255) DEFAULT NULL COMMENT 'ip',
`query` varchar(255) DEFAULT NULL COMMENT '查询参数',
`exception` varchar(255) DEFAULT NULL COMMENT '异常信息',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
二、日志实体
/**
* 系统日志实体
*/
@TableName("log_info")
@Data
@ApiModel(value = "系统日志实体", description = "系统日志实体")
public class LogInfo extends BaseEntity {
private static final long serialVersionUID = 1L;
@ApiModelProperty(value = "主键id自增列")
@TableId(value = "id", type = IdType.AUTO)
private Integer id;
@ApiModelProperty(value = "操作类型")
@TableField(value = "log_type")
private String logType;
@ApiModelProperty(value = "接口返回状态码")
@TableField(value = "response_code")
private Integer responseCode;
@ApiModelProperty(value = "请求时间")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
@TableField(value = "operate_time")
private Date operateTime;
@ApiModelProperty(value = "消耗时间")
@TableField(value = "spend_time")
private Long spendTime;
@ApiModelProperty(value = "url")
@TableField(value = "url")
private String url;
@ApiModelProperty(value = "请求体")
@TableField(value = "body")
private String body;
@ApiModelProperty(value = "ip")
@TableField(value = "ip")
private String ip;
@ApiModelProperty(value = "查询参数")
@TableField(value = "query")
private String query;
@ApiModelProperty(value = "异常信息")
@TableField(value = "exception")
private String exception;
@ApiModelProperty(value = "用户id(工号)")
@TableField(value = "user_id")
private String userId;
}
三、持久层
/**
* 系统日志持久层
*/
@Component
public interface LogInfoMapper extends BaseMapper<LogInfo> {
/**
* 清除指定日期之前的日志
*
* @param date 时间
*/
void clear(Date date);
}
四、业务层
public interface ILogInfoService extends IService<LogInfo> {
}
@Slf4j
@Service
public class LogInfoServiceImpl extends ServiceImpl<LogInfoMapper, LogInfo>
implements ILogInfoService {
}
五、自定义注解
/**
* 自定义注解类 @SysLog
*/
@Target(ElementType.METHOD) // 注解作用于方法级别
@Retention(RetentionPolicy.RUNTIME) // 运行期生效,切面才能通过反射读取
@Documented // 生成文档
public @interface SysLog {
// 声明注解成员
String operation() default "";
// 日志类型,默认查询类型
LogTypeEnum logType() default LogTypeEnum.LOG_TYPE_QUERY;
}
六、Controller
/**
* 用户管理分发器
*/
@RestController
@RequestMapping("/user")
@Api(tags = "用户管理模块", description = "用户管理模块")
public class UserController {
@Autowired
private UserService userService;
/**
* 根据用户id查询用户信息
*/
@SysLog(logType = LogTypeEnum.LOG_TYPE_QUERY)
@GetMapping("/getUser-by-id")
@ApiOperation(value = "根据用户id查询用户信息", notes = "根据用户id查询用户信息")
public ResultResponse<UserEntity> getUserById(
@RequestParam(name = "userId") @ApiParam("请输入用户id") String userId) {
return new ResultResponse<>(userService.getById(userId));
}
}
切面处理类拆解
先声明切面骨架:
/**
* 系统日志:切面处理类
*/
@Slf4j
@Aspect
@Component
public class SysLogAspect {
@Autowired
private ILogInfoService iLogInfoService;
@Around("execution(public * com.example.demo.controller.*.*(..))")
public Object postLogAspect(ProceedingJoinPoint pjp) throws Throwable {
// 核心逻辑,下面逐个拆解
return null;
}
}
需要采集的数据可以分为四类:
- 查询参数、IP、URL、请求体
- 返回值(返回 code、是否异常)
- 请求时间、目标接口耗时
- 操作类型
一、从切入点获取 request
private HttpServletRequest getRequest() {
ServletRequestAttributes servletRequestAttributes =
(ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
return servletRequestAttributes.getRequest();
}
二、获取 IP
private String getIpAddress() {
HttpServletRequest request = SpringServletContextUtils.getRequest();
if (request != null) {
try {
return IpUtils.getIpAddr(request);
} catch (Exception e) {
log.error("unable to get ip address");
}
}
return null;
}
三、获取请求体
请求体只能从 ContentCachingRequestWrapper 中读取,需要预先用过滤器或 Filter 包装 HttpServletRequest:
private void setBody(HttpServletRequest request, LogInfo log) {
if (request instanceof ContentCachingRequestWrapper) {
ContentCachingRequestWrapper wrapper = (ContentCachingRequestWrapper) request;
String body = StringUtils.toEncodedString(wrapper.getContentAsByteArray(),
Charset.forName(wrapper.getCharacterEncoding()));
log.setBody(body);
}
}
四、构建日志对象
/**
* 构建日志对象
*/
private LogInfo createOpLog() throws JsonProcessingException {
HttpServletRequest request = this.getRequest();
LogInfo log = new LogInfo();
log.setQuery(request.getQueryString());
this.setBody(request, log);
log.setOperateTime(new Date());
log.setUrl(request.getServletPath());
log.setIp(getIpAddress());
// 获取登录人 id
SysUserEntity user = SpringServletContextUtils.getUser();
if (!Objects.isNull(user)) {
log.setUserId(user.getAccountId());
}
return log;
}
五、执行目标方法并计算耗时
private Object proceedController(ProceedingJoinPoint pjp, LogInfo log) throws Throwable {
// 记录开始时间
long spendTime = System.currentTimeMillis();
// 调用目标接口并获取返回结果
Object result = pjp.proceed(pjp.getArgs());
// 计算接口耗时(单位:秒)
spendTime = (System.currentTimeMillis() - spendTime) / 1000;
log.setSpendTime(spendTime);
return result;
}
六、获取返回码
private BaseResponse setResponseCode(LogInfo log, Object result) {
// 判断返回体类型是否为 BaseResponse
if (result != null && result instanceof BaseResponse) {
return (BaseResponse) result;
}
return new BaseResponse();
}
七、从注解中取操作类型
private void getLogType(ProceedingJoinPoint pjp, LogInfo log) {
// 通过反射获取织入点的方法
MethodSignature signature = (MethodSignature) pjp.getSignature();
Method method = signature.getMethod();
// 获取方法上的注解
SysLog sysLog = method.getAnnotation(SysLog.class);
// 取注解中声明的日志类型
log.setLogType(sysLog.logType().getValue());
}
八、环绕通知总装
@Around("execution(public * com.example.demo.controller.*.*(..))")
public Object postLogAspect(ProceedingJoinPoint pjp) throws Throwable {
// 初始化 log
LogInfo log = this.createOpLog();
// 执行目标接口并记录耗时
Object result = this.proceedController(pjp, log);
// 获取操作类型
this.getLogType(pjp, log);
// 获取返回值编码 code
BaseResponse resData = this.setResponseCode(log, result);
// 赋值返回编码
log.setResponseCode(resData.getCode());
// 非成功状态记录异常信息
if (log.getResponseCode() != ResultEnum.SUCCESS.getKey()) {
log.setException(resData.getMsg());
}
// 保存日志到数据库
iLogInfoService.save(log);
return result;
}
两种切点写法
一、切整个 Controller 层
@Around("execution(public * com.example.demo.controller.*.*(..))")
表达式含义拆解:
| 片段 | 含义 |
|---|---|
第一个 * |
返回任意类型 |
com.example.demo.controller |
包路径,针对所有控制器 |
第二个 * |
任意类 |
第三个 * |
任意方法 |
(..) |
任意参数 |
好处是无需给接口加注解,整个控制层自动被拦截;缺点是日志范围不受控,不适合只想记录部分接口的场景。
二、切自定义注解
// 定义切点:在注解的位置切入代码
@Pointcut("@annotation(com.example.review.annotation.SysLog)")
public void logPointCut() {
}
@Around("logPointCut()")
public Object postLogAspect(ProceedingJoinPoint pjp) throws Throwable {
LogInfo log = this.createOpLog();
Object result = this.proceedController(pjp, log);
this.getLogType(pjp, log);
BaseResponse resData = this.setResponseCode(log, result);
log.setResponseCode(resData.getCode());
if (log.getResponseCode() != ResultEnum.SUCCESS.getKey()) {
log.setException(resData.getMsg());
}
iLogInfoService.save(log);
return result;
}
需要精细化控制时,用这种方式把 @SysLog 标注在目标方法上即可。
三、两种写法的区别
两者的区别不在于切面逻辑,而在于「怎么挑出要被拦截的方法」:
execution(...)是按代码结构匹配:包名、类名、方法名、返回值、参数列表,只要能写出表达式就能拦,不要求目标方法做任何改动。@annotation(...)是按方法上的标记匹配:谁带了@SysLog就拦谁,没带的一律放过,属于声明式切入。
| 维度 | execution 切 Controller 层 |
@annotation 切注解 |
|---|---|---|
| 匹配依据 | 包路径 + 方法签名 | 方法上是否存在指定注解 |
| 对业务代码的侵入 | 零侵入,不用改任何接口 | 需要在目标方法上加 @SysLog |
| 拦截粒度 | 粗,整个包下的方法全被拦 | 细,精确到单个方法 |
| 日志范围 | 不可控,增删接口会自动被记录 | 完全由开发者决定 |
| 新增接口的成本 | 自动生效,但也可能记录到不需要的接口 | 想记录就加注解,不加就不记录 |
| 典型场景 | 统一审计、全量调用统计 | 差异化业务日志(登录、导出、删除等) |
实际项目里两者经常组合使用:用 execution 把范围圈定在 Controller 层避免误伤 Service,再用 @annotation 在圈定的范围内做二次筛选,写出来就是一个 && 表达式:
@Around("execution(public * com.example.demo.controller.*.*(..)) " +
"&& @annotation(com.example.demo.annotation.SysLog)")
public Object postLogAspect(ProceedingJoinPoint pjp) throws Throwable {
// ...
}
四、跟自定义注解是什么关系
@SysLog 本身没有任何行为。它只是一个贴在方法上的元数据标签,JVM 不会因为看到它就去做任何事。真正干活的是 SysLogAspect,注解在这里承担三个角色:
- 开关:
@Pointcut("@annotation(...)")让切面只对带注解的方法生效,注解等同于「这条接口需要记录日志」的声明。 - 定位:切点表达式负责找到方法,而切面要区分具体是登录日志还是删除日志,就得靠注解成员。
- 传参:
logType、operation这些成员值,是切面通过反射从方法上读出来的:
Method method = ((MethodSignature) pjp.getSignature()).getMethod();
SysLog sysLog = method.getAnnotation(SysLog.class);
log.setLogType(sysLog.logType().getValue()); // 注解成员 → 日志字段
这也解释了两者配合时的一个坑:前面 getLogType() 默认目标方法上一定有 @SysLog。如果只用 execution 切整个 Controller 层,遇到没加注解的方法,method.getAnnotation(SysLog.class) 会返回 null,直接 sysLog.logType() 就是空指针。所以:
- 用
@annotation切点 → 天然保证注解存在,可以放心取值; - 用
execution切点 → 必须先判空,或给日志类型兜一个默认值。
还有两个前置条件必须满足,否则注解加了也不生效:
- 注解的保留策略必须是
@Retention(RetentionPolicy.RUNTIME),否则运行期反射读不到; - 目标类必须由 Spring 容器管理(
@Controller/@Service等),因为 Spring AOP 靠代理实现,new出来的对象和方法内部自调用都不会经过切面。
一句话概括:注解负责「声明要不要记、记成什么类型」,切点负责「找到这些声明」,切面负责「真正去记录」,三者缺一不可。
测试与小结
重启项目后通过 Swagger 调用 /user/getUser-by-id,接口本身正常返回且无报错。此时打开数据库查看 log_info 表,若切面生效,会新增一条记录,包含 URL、查询参数、IP、耗时、返回码与操作类型。
测试过程中有一个细节:本地调用时获取到的 IP 是 127.0.0.1 的 IPv6 形式,属于正常现象,可在 IP 工具方法末尾对回环地址做一次判断,再决定展示形式。
最后附上日志类型枚举,配合注解中的 logType 使用,便于日志分类统计与筛选:
/**
* 日志类型枚举
*/
public enum LogTypeEnum implements IEnum {
LOG_TYPE_LOGIN(1, "登录日志"),
LOG_TYPE_LOGOUT(2, "登出日志"),
LOG_TYPE_EXPORT(3, "导出日志"),
LOG_TYPE_QUERY(4, "查询日志"),
LOG_TYPE_EDIT(5, "修改日志"),
LOG_TYPE_ADD(6, "新增日志"),
LOG_TYPE_DELETE(7, "删除日志"),
LOG_TYPE_IMPORT(8, "导入日志"),
LOG_TYPE_LOGIN_OUT(9, "外部日志"),
LOG_TYPE_UPLOAD_FILES(10, "文件上传(批量)"),
LOG_TYPE_DELETE_FILES(11, "文件删除"),
LOG_TYPE_SCOPE_FLUSH(12, "量化分刷新操作"),
LOG_TYPE_DELETE_REDIS_BY_KEY(13, "redis指定删除"),
LOG_TYPE_IMG_UPLOAD(14, "反馈问题及图片上传操作"),
;
@EnumValue
private Integer key;
private String value;
LogTypeEnum(Integer key, String value) {
this.key = key;
this.value = value;
}
@JsonValue
@Override
public Integer getKey() {
return this.key;
}
public void setKey(Integer key) {
this.key = key;
}
@Override
public String getValue() {
return this.value;
}
public void setValue(String value) {
this.value = value;
}
/**
* 将参数反序列化为枚举
*/
@JsonCreator
public static LogTypeEnum parse(Integer param) {
return IEnum.parse(param, LogTypeEnum.class);
}
}
整套方案的要点只有三条:
- 自定义注解决定「哪些接口需要记录」,用
@Retention(RUNTIME)保证切面能反射读到; - 环绕通知负责前后夹击,
pjp.proceed()之前取时间,之后算耗时并拿返回值; - 请求体必须依赖
ContentCachingRequestWrapper,否则流被读一次就空了。
至此,接口日志的采集与落库形成闭环,后续无论是做审计、排查慢接口还是统计调用量,都可以基于 log_info 这张表继续扩展。