AOP拦截自调用方法

背景

因公司一后台管理系统现可能由非技术人员进行操作,为防止错误操作或出问题时找不到背锅人的情况,现计划在原有框架下加入日志切面。

代码

CREATE TABLE `sys_log` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT,
  `username` varchar(50) DEFAULT '' COMMENT '用户名',
  `operation` varchar(50) DEFAULT '' COMMENT '用户操作',
  `method` varchar(200) DEFAULT '' COMMENT '请求方法',
  `params` varchar(2000) DEFAULT '' COMMENT '请求参数',
  `exception` varchar(1000) DEFAULT '' COMMENT '异常信息',
  `ip` varchar(64) DEFAULT '' COMMENT 'IP地址',
  `create_date` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  PRIMARY KEY (`id`),
  KEY `idx_cd_op` (`create_date`,`operation`)
) ENGINE=InnoDB AUTO_INCREMENT=34 DEFAULT CHARSET=utf8 COMMENT='系统后台操作日志'

实体

public class SysLog implements Serializable {
    private Long id;

    private String username;

    private String operation;

    private String method;

    private String params;

    private String exception;

    private String ip;

    private Date createDate;

注解

@Documented
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Log {
    LogOperationEnum value();
}

枚举

public enum LogOperationEnum {

    UPDATE_ORDER_INFO("更新订单信息"),
    ADD_SYS_CTRL("新增系统配置"),
    UPDATE_SYS_CTRL("更新系统配置"),
    UPDATE_SYS_LOAN_NUM("更新资方账期"),
    ADD_SIGH_CONF("新增签约配置"),
    UPDATE_SIGH_CONF("更新签约配置"),
    ADD_COPYWRITING_CONF("新增文案配置"),
    UPDATE_COPYWRITING_CONF("更新文案配置"),
    ADD_COMPANY_INFO("新增公司信息"),
    UPDATE_COMPANY_INFO("更新公司信息"),
    ;

    public final String desc;

    LogOperationEnum(String desc) {
        this.desc = desc;
    }

    public String getDesc(){
        return desc;
    }
}

切面

@Aspect
@Component
public class LogAspect {

    public static final int PARAMS_MAX_LENGTH = 2000;
    public static final int EXCEPTION_MAX_LENGTH = 1000;

    @Resource
    private ISysLogService sysLogService;

    @Pointcut("@annotation(com.q.pay.order.admin.annotation.Log)")
    public void logPointCut() {
    }

    @Around("logPointCut()")
    public Object doAround(ProceedingJoinPoint joinPoint) throws Throwable {

        Object result;
        SysLog sysLog = new SysLog();

        //操作名
        MethodSignature signature = (MethodSignature) joinPoint.getSignature();
        Method method = signature.getMethod();
        Log LogAnnotation = method.getAnnotation(Log.class);
        sysLog.setOperation(LogAnnotation.value().desc);

        //方法名
        String className = joinPoint.getTarget().getClass().getSimpleName();
        String methodName = signature.getName();
        sysLog.setMethod(className + "." + methodName);

        //参数
        Object[] args = joinPoint.getArgs();
        if (ObjectUtil.isNotEmpty(args[0])) {
            String params = JsonUtil.toJSONString(args[0]);
            int paramsLength = params.length();
            int maxLength = paramsLength > PARAMS_MAX_LENGTH ? PARAMS_MAX_LENGTH : paramsLength;
            sysLog.setParams(params.substring(0, maxLength));
        }

        //操作人
        if (ObjectUtil.isNotEmpty(args[1])) {
            sysLog.setUsername(args[1].toString());
        }

        //IP地址
        HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getRequest();
        sysLog.setIp(IPUtils.getIpAddr(request));

        try {
            result = joinPoint.proceed();
        } catch (Throwable e) {
            //异常信息
            String exception = e.toString();
            int exceptionLength = exception.length();
            int maxLength = exceptionLength > EXCEPTION_MAX_LENGTH ? EXCEPTION_MAX_LENGTH : exceptionLength;
            sysLog.setException(exception.substring(0, maxLength));

            //异步插入操作日志
            sysLogService.asyncInsertSelective(sysLog);
            throw e;
        }

        //异步插入操作日志
        sysLogService.asyncInsertSelective(sysLog);
        return result;
    }

}

IPUtils

public class IPUtils {
    private static Logger logger = LoggerFactory.getLogger(IPUtils.class);

    /**
     * 获取IP地址
     * 
     * 使用Nginx等反向代理软件, 则不能通过request.getRemoteAddr()获取IP地址
     * 如果使用了多级反向代理的话,X-Forwarded-For的值并不止一个,而是一串IP地址,X-Forwarded-For中第一个非unknown的有效IP字符串,则为真实IP地址
     */
    public static String getIpAddr(HttpServletRequest request) {

        String ip = request.getHeader("x-forwarded-for");
        if (ip == null || ip.length() == 0 || "unknown".equalsIgnoreCase(ip)) {
            ip = request.getHeader("Proxy-Client-IP");
        }
        if (ip == null || ip.length() == 0 || "unknown".equalsIgnoreCase(ip)) {
            ip = request.getHeader("WL-Proxy-Client-IP");
        }
        if (ip == null || ip.length() == 0 || "unknown".equalsIgnoreCase(ip)) {
            ip = request.getRemoteAddr();
        }
        return "0:0:0:0:0:0:0:1".equals(ip) ? "127.0.0.1" : ip;
    }

}

接着在对应Controller中加入响应的@Log注解即可。
但由于原有Controller框架原有导致日志拦截失效。

切面失效原因

首先有一个抽象基类BaseController

public abstract class BaseController<PO, VO> {

    /**
     * 查询列表
     */
    @RequestMapping("/query")
    @ResponseBody
    public Object list(@Validated VO vo,
                       @RequestParam(value = "pageIndex", required = false) Integer pageIndex,
                       @RequestParam(value = "pageSize", required = false) Integer pageSize, BindingResult bindingResult) throws Exception {
        BindingResultUtil.judgeParam(bindingResult);
        PO config = buildQuery(vo);
        Page page = query(config, pageIndex, pageSize);
        return ResponseUtil.buildSuccess(page);
    }

    /**
     * 更新
     */
    @RequestMapping(value = "/update", method = RequestMethod.POST)
    @ResponseBody
    public Object updateInfo(String loginUserName, @Validated(value = {Update.class}) VO vo, BindingResult bindingResult) throws Exception {
        BindingResultUtil.judgeParam(bindingResult);
        PO po = buildQuery(vo);
        return update(po, loginUserName) > 0 ? ResponseUtil.buildSuccess() : ResponseUtil.buildError();
    }

    /**
     * 新增
     */
    @RequestMapping(value = "/add", method = RequestMethod.POST)
    @ResponseBody
    public Object addInfo(String loginUserName, @Validated(value = {Save.class}) VO vo, BindingResult bindingResult) throws Exception {
        BindingResultUtil.judgeParam(bindingResult);
        PO po = buildQuery(vo);
        return add(po, loginUserName) > 0 ? ResponseUtil.buildSuccess() : ResponseUtil.buildError();
    }


    protected abstract PO buildQuery(VO vo);

    protected abstract Page query(PO po, Integer pageIndex, Integer pageSize);

    public abstract Integer update(PO po, String loginUserName) throws Exception;

    public abstract Integer add(PO po, String loginUserName) throws Exception;
}

而子类Controller大致如下:

@Controller
@RequestMapping(path = "/companyInfo")
public class CompanyInfoController extends BaseController<CompanyInfo, CompanyInfoVO> {

    @Resource
    private ICompanyInfoService companyInfoService;

    @Log(LogOperationEnum.ADD_COMPANY_INFO)
    @Override
    public Integer add(CompanyInfo po, String loginUserName) {
        companyInfoService.insert(po);
        return 1;
    }

    @Log(LogOperationEnum.UPDATE_COMPANY_INFO)
    @Override
    public Integer update(CompanyInfo po, String loginUserName) {
        companyInfoService.update(po);
        return 1;
    }

    @Override
    protected Page query(CompanyInfo po, Integer pageIndex, Integer pageSize) {
        return companyInfoService.query(po, pageIndex, pageSize);
    }

    @Override
    protected CompanyInfo buildQuery(CompanyInfoVO vo) {
        return new CompanyInfo()
                .setId(vo.getId())
                .setCompanyId(vo.getCompanyId())
                .setCompanyName(vo.getCompanyName())
                .setAdvanceRepayment(vo.getAdvanceRepayment())
                .setCompanyStatus(vo.getCompanyStatus())
                .setCreateDate(vo.getCreateDate())
                .setUpdateDate(vo.getUpdateDate())
                .setExt1(vo.getExt1())
                .setExt2(vo.getExt2())
                .setExt3(vo.getExt3());
    }
}

一开始不知道拦截为啥失效,做了非常多尝试,本来已经打算把注解打在Service层了,后来发现是方法自调用导致的失效。
因为子类加了日志注解并重写的update和add方法是在BaseController中的updateInfo和addInfo中调用,这样事实上就出现了同一个类中方法的自我调用,这跟Spring事务、缓存、异步注解是同样道理,AOP默认同一个类中方法的自我调用是不会生效的,因为此时调用的是原目标对象方法而不是代理对象方法。

这是由于 Spring AOP (包括动态代理和 CGLIB 的 AOP) 的限制导致的. Spring AOP 并不是扩展了一个类(目标对象), 而是使用了一个代理对象来包装目标对象, 并拦截目标对象的方法调用. 这样的实现带来的影响是: 在目标对象中调用自己类内部实现的方法时, 这些调用并不会转发到代理对象中, 甚至代理对象都不知道有此调用的存在.

image.png

因此此处的add和update代表的是this对象,而不是代理对象
可以参考以下生成的反编译代理类,代理方法中的自调用方法是不会再次被代理的
https://blog.csdn.net/luzhensmart/article/details/84866599

解决方案

1、开启expose-proxy
spring中expose-proxy的作用与原理
作用:将代理对象暴露出来,可以使用使用AopContext.currentProxy()获取当前代理

<aop:aspectj-autoproxy proxy-target-class="true" expose-proxy="true"/>

2、修改自调用方法为代理方法

((BaseController)AopContext.currentProxy()).update
image.png