Spring 利用 AOP + 线程池 + 异步任务完成接口调用日志的记录

964 阅读7分钟

Spring 利用 AOP + 线程池 + 异步任务完成接口调用日志的记录

在公司级项目中,为了安全性,规范性考虑,我们常常会对接口调用做一些记录。

主要记录的是以下几个问题

  1. 谁调用了接口?
  2. 调用了什么接口?
  3. 接口调用的方法是什么?
  4. 接口返回的信息是什么?

我们利用mysql对每一条接口调用,进行信息的记录。

1.创建sys_log 表

-- ----------------------------
-- Table structure for sys_oper_log
-- ----------------------------
CREATE TABLE `sys_oper_log`
(
    `oper_id`        bigint(20)                                               NOT NULL AUTO_INCREMENT COMMENT '日志主键',
    `title`          varchar(50) CHARACTER SET utf8 COLLATE utf8_general_ci   NULL DEFAULT '' COMMENT '模块标题',
    `business_type`  int(11)                                                  NULL DEFAULT 0 COMMENT '业务类型(0其它 1新增 2修改 3删除)',
    `method`         varchar(100) CHARACTER SET utf8 COLLATE utf8_general_ci  NULL DEFAULT '' COMMENT '方法名称',
    `request_method` varchar(10) CHARACTER SET utf8 COLLATE utf8_general_ci   NULL DEFAULT '' COMMENT '请求方式',
    `operator_type`  int(11)                                                  NULL DEFAULT 0 COMMENT '操作类别(0其它 1后台用户 2手机端用户)',
    `oper_name`      varchar(50) CHARACTER SET utf8 COLLATE utf8_general_ci   NULL DEFAULT '' COMMENT '操作人员',
    `dept_name`      varchar(50) CHARACTER SET utf8 COLLATE utf8_general_ci   NULL DEFAULT '' COMMENT '部门名称',
    `oper_url`       varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci  NULL DEFAULT '' COMMENT '请求URL',
    `oper_ip`        varchar(50) CHARACTER SET utf8 COLLATE utf8_general_ci   NULL DEFAULT '' COMMENT '主机地址',
    `oper_location`  varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci  NULL DEFAULT '' COMMENT '操作地点',
    `oper_param`     varchar(2000) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT '' COMMENT '请求参数',
    `json_result`    varchar(2000) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT '' COMMENT '返回参数',
    `status`         int(11)                                                  NULL DEFAULT 0 COMMENT '操作状态(0正常 1异常)',
    `error_msg`      varchar(2000) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT '' COMMENT '错误消息',
    `oper_time`      datetime(0)                                              NULL DEFAULT NULL COMMENT '操作时间',
    `work_num`       varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci  NULL DEFAULT NULL,
    PRIMARY KEY (`oper_id`) USING BTREE
) ENGINE = InnoDB
  AUTO_INCREMENT = 633
  CHARACTER SET = utf8
  COLLATE = utf8_general_ci COMMENT = '操作日志记录'
  ROW_FORMAT = Dynamic;

表中记录了三方面

  • 方法:模块标题,业务类型,方法名称
  • 操作人:请求方式,操作类别,部门名称,请求URL,主机地址,操作地点
  • 请求和返回:请求参数,返回参数,操作状态,操作时间

2.利用Spring AOP 切面进行处理

因为对于以上这些数据的记录是重复的,对此我们需要进行集中的处理。

定义注解

我们定义一个log注解,希望所有加上该注解的方法,都具有将操作记录的能力。

@Target({ElementType.PARAMETER, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface Log {
    /**
     * 模块
     */
    public String title() default "";
​
    /**
     * 功能
     */
    public BusinessType businessType() default BusinessType.OTHER;
​
    /**
     * 操作人类别
     */
    public OperatorType operatorType() default OperatorType.MANAGE;
​
    /**
     * 是否保存请求的参数
     */
    public boolean isSaveRequestData() default true;
}

定义切面

我们写一个LogAspect类来处理加上该注解以后记录日志的能力。

@Aspect
@Component
public class LogAspect {
    private static final Logger log = LoggerFactory.getLogger(LogAspect.class);
    // 配置织入点
    @Pointcut("@annotation(com.maiqu.mb.annotation.Log)")
    public void logPointCut() {
    }
}
​

然后在该类中添加业务逻辑取记录日志,该方法是,等方法调用结束后,任何加上Log注解的方法都会执行

  /**
     * 处理完请求后执行
     *
     * @param joinPoint 切点
     */
    @AfterReturning(pointcut = "logPointCut()", returning = "jsonResult")
    public void doAfterReturning(JoinPoint joinPoint, Object jsonResult) {
        handleLog(joinPoint, null, jsonResult);
    }

我们来实现上图中的handleLog方法。这个方法的目的主要是为了获取以上数据库表定义的各个参数

​
    /**
     * 处理完请求后执行
     *
     * @param joinPoint 切点
     */
    @AfterReturning(pointcut = "logPointCut()", returning = "jsonResult")
    public void doAfterReturning(JoinPoint joinPoint, Object jsonResult) {
        handleLog(joinPoint, null, jsonResult);
    }
​
    /**
     * 拦截异常操作
     *
     * @param joinPoint 切点
     * @param e         异常
     */
    @AfterThrowing(value = "logPointCut()", throwing = "e")
    public void doAfterThrowing(JoinPoint joinPoint, Exception e) {
        handleLog(joinPoint, e, null);
    }
​
    protected void handleLog(final JoinPoint joinPoint, final Exception e, Object jsonResult) {
        try {
            // 获得注解
            Log controllerLog = getAnnotationLog(joinPoint);
            if (controllerLog == null) {
                return;
            }
​
            // 获取当前的用户
            String loginUserPhone = (String) StpUtil.getExtra(JwtConstants.JWT_PHONE);
            String loginUserRealName = (String) StpUtil.getExtra(JwtConstants.JWT_REAL_NAME);
​
            // *========数据库日志=========*//
            SysOperLog operLog = new SysOperLog();
            operLog.setStatus(BusinessStatus.SUCCESS.ordinal());
​
            // 请求的地址
            String ip = IpUtils.getIpAddr(ServletUtils.getRequest());
            operLog.setOperIp(ip);
            // 返回参数
            operLog.setJsonResult(JSON.toJSONString(jsonResult));
            operLog.setOperUrl(ServletUtils.getRequest().getRequestURI());
​
            if (StringUtils.isNotEmpty(loginUserRealName)) {
                operLog.setOperName(loginUserRealName);
            }else {
                operLog.setOperName(loginUserPhone);
            }
​
            if (e != null) {
                operLog.setStatus(BusinessStatus.FAIL.ordinal());
                operLog.setErrorMsg(StringUtils.substring(e.getMessage(), 0, 2000));
            }
            // 设置方法名称
            String className = joinPoint.getTarget().getClass().getName();
            String methodName = joinPoint.getSignature().getName();
            operLog.setMethod(className + "." + methodName + "()");
            // 设置请求方式
            operLog.setRequestMethod(ServletUtils.getRequest().getMethod());
            // 处理设置注解上的参数
            getControllerMethodDescription(joinPoint, controllerLog, operLog);
            // 保存数据库
            AsyncManager.me().execute(AsyncFactory.recordOper(operLog));
        } catch (Exception exp) {
            // 记录本地异常日志
            log.error("==前置通知异常==");
            log.error("异常信息:{}", exp.getMessage());
            exp.printStackTrace();
        }
    }
​
    /**
     * 获取注解中对方法的描述信息 用于Controller层注解
     *
     * @param log     日志
     * @param operLog 操作日志
     * @throws Exception
     */
    public void getControllerMethodDescription(JoinPoint joinPoint, Log log, SysOperLog operLog) throws Exception {
        // 设置action动作
        operLog.setBusinessType(log.businessType().ordinal());
        // 设置标题
        operLog.setTitle(log.title());
        // 设置操作人类别
        operLog.setOperatorType(log.operatorType().ordinal());
        // 是否需要保存request,参数和值
        if (log.isSaveRequestData()) {
            // 获取参数的信息,传入到数据库中。
            setRequestValue(joinPoint, operLog);
        }
    }
​
    /**
     * 获取请求的参数,放到log中
     *
     * @param operLog 操作日志
     * @throws Exception 异常
     */
    private void setRequestValue(JoinPoint joinPoint, SysOperLog operLog) throws Exception {
        String requestMethod = operLog.getRequestMethod();
        if (HttpMethod.PUT.name().equals(requestMethod) || HttpMethod.POST.name().equals(requestMethod)) {
            String params = argsArrayToString(joinPoint.getArgs());
            operLog.setOperParam(StringUtils.substring(params, 0, 2000));
        } else {
            Map<String, Object> paramsMap = (Map<String, Object>) ServletUtils.getRequest().getAttribute(HandlerMapping.URI_TEMPLATE_VARIABLES_ATTRIBUTE);
            String queryString = ServletUtils.getRequest().getQueryString();
            if (StringUtils.isNotEmpty(queryString)) {
                String[] split = queryString.split("&");
                for (int i = 0; i < split.length; i++) {
                    paramsMap.put(split[i].split("=")[0],split[i].split("=")[1]);
                }
            }
            operLog.setOperParam(StringUtils.substring(paramsMap.toString(), 0, 2000));
        }
    }
​
    /**
     * 是否存在注解,如果存在就获取
     */
    private Log getAnnotationLog(JoinPoint joinPoint) throws Exception {
        Signature signature = joinPoint.getSignature();
        MethodSignature methodSignature = (MethodSignature) signature;
        Method method = methodSignature.getMethod();
​
        if (method != null) {
            return method.getAnnotation(Log.class);
        }
        return null;
    }
​
    /**
     * 参数拼装
     */
    private String argsArrayToString(Object[] paramsArray) {
        String params = "";
        if (paramsArray != null && paramsArray.length > 0) {
            for (int i = 0; i < paramsArray.length; i++) {
                if (!isFilterObject(paramsArray[i])) {
                    Object jsonObj = JSON.toJSON(paramsArray[i]);
                    params += jsonObj.toString() + " ";
                }
            }
        }
        return params.trim();
    }
​
    /**
     * 判断是否需要过滤的对象。
     *
     * @param o 对象信息。
     * @return 如果是需要过滤的对象,则返回true;否则返回false。
     */
    public boolean isFilterObject(final Object o) {
        return o instanceof MultipartFile || o instanceof HttpServletRequest || o instanceof HttpServletResponse;
    }

针对上述代码,我们写了很多工具类去获取,下面让我们来一一解释

3.如何获取请求的ip?

1.获取当前的请求对象。

首先,我们要获取当前的request.

首先我们利用spring的 org.springframework.web.context.request;包下面的 请求上下文对象 RequestContextHolder 去获取它的一些属性。

然后将获取到的属性 attributes 转化为它的一个实现类 ServletRequestAttributes.

之后根据ServletRequestAttributes的一个getRequest方法去获取当前的请求对象

我们利用ServletUtils 去包装一下

public class ServletUtils {
​
    /**
     * 获取request
     */
    public static HttpServletRequest getRequest() {
        return getRequestAttributes().getRequest();
    }
​
    /**
     * 获取response
     */
    public static HttpServletResponse getResponse() {
        return getRequestAttributes().getResponse();
    }
​
    public static ServletRequestAttributes getRequestAttributes() {
        RequestAttributes attributes = RequestContextHolder.getRequestAttributes();
        return (ServletRequestAttributes) attributes;
    }
​
}
​

2.获取当前的ip

我们同样写了一个IpUtil 工具类来封装.这个主要是对请求头进行解析,获取ip,有兴趣的同学可以深入研究,但如果是搞开发的同学,建议直接粘贴即可。

public class IpUtils {
    public static String getIpAddr(HttpServletRequest request) {
        if (request == null) {
            return "unknown";
        }
        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("X-Forwarded-For");
        }
        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.getHeader("X-Real-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;
    }
    
    private static boolean internalIp(byte[] addr) {
        if (StringUtils.isEmpty(Arrays.toString(addr)) || addr.length < 2) {
            return true;
        }
        final byte b0 = addr[0];
        final byte b1 = addr[1];
        // 10.x.x.x/8
        final byte SECTION_1 = 0x0A;
        // 172.16.x.x/12
        final byte SECTION_2 = (byte) 0xAC;
        final byte SECTION_3 = (byte) 0x10;
        final byte SECTION_4 = (byte) 0x1F;
        // 192.168.x.x/16
        final byte SECTION_5 = (byte) 0xC0;
        final byte SECTION_6 = (byte) 0xA8;
        switch (b0) {
            case SECTION_1:
                return true;
            case SECTION_2:
                if (b1 >= SECTION_3 && b1 <= SECTION_4) {
                    return true;
                }
            case SECTION_5:
                switch (b1) {
                    case SECTION_6:
                        return true;
                }
            default:
                return false;
        }
    }
​
    /**
     * 将IPv4地址转换成字节
     *
     * @param text IPv4地址
     * @return byte 字节
     */
    public static byte[] textToNumericFormatV4(String text) {
        if (text.length() == 0) {
            return null;
        }
​
        byte[] bytes = new byte[4];
        String[] elements = text.split("\.", -1);
        try {
            long l;
            int i;
            switch (elements.length) {
                case 1:
                    l = Long.parseLong(elements[0]);
                    if ((l < 0L) || (l > 4294967295L)) {
                        return null;
                    }
                    bytes[0] = (byte) (int) (l >> 24 & 0xFF);
                    bytes[1] = (byte) (int) ((l & 0xFFFFFF) >> 16 & 0xFF);
                    bytes[2] = (byte) (int) ((l & 0xFFFF) >> 8 & 0xFF);
                    bytes[3] = (byte) (int) (l & 0xFF);
                    break;
                case 2:
                    l = Integer.parseInt(elements[0]);
                    if ((l < 0L) || (l > 255L)) {
                        return null;
                    }
                    bytes[0] = (byte) (int) (l & 0xFF);
                    l = Integer.parseInt(elements[1]);
                    if ((l < 0L) || (l > 16777215L)) {
                        return null;
                    }
                    bytes[1] = (byte) (int) (l >> 16 & 0xFF);
                    bytes[2] = (byte) (int) ((l & 0xFFFF) >> 8 & 0xFF);
                    bytes[3] = (byte) (int) (l & 0xFF);
                    break;
                case 3:
                    for (i = 0; i < 2; ++i) {
                        l = Integer.parseInt(elements[i]);
                        if ((l < 0L) || (l > 255L)) {
                            return null;
                        }
                        bytes[i] = (byte) (int) (l & 0xFF);
                    }
                    l = Integer.parseInt(elements[2]);
                    if ((l < 0L) || (l > 65535L)) {
                        return null;
                    }
                    bytes[2] = (byte) (int) (l >> 8 & 0xFF);
                    bytes[3] = (byte) (int) (l & 0xFF);
                    break;
                case 4:
                    for (i = 0; i < 4; ++i) {
                        l = Integer.parseInt(elements[i]);
                        if ((l < 0L) || (l > 255L)) {
                            return null;
                        }
                        bytes[i] = (byte) (int) (l & 0xFF);
                    }
                    break;
                default:
                    return null;
            }
        } catch (NumberFormatException e) {
            return null;
        }
        return bytes;
    }
}

4.如何获取方法名称,方法类型和类名称?

获取类名称

String className = joinPoint.getTarget().getClass().getName();

他获取到被请求的类中的方法,然后利用反射获取它所在的目录

最后的结果可能是这样,包名 + 类名

com.maiqu.mb.controller.MbConsultantController

获取方法名

String methodName = joinPoint.getSignature().getName();

获取方法类型

这个util在上文写过,主要是返回request对象用的

String method = ServletUtils.getRequest().getMethod()

5.如何利用异步的方式保存数据库?

为什么要异步?为了不影响正常业务逻辑,且它对响应没有要求。

  // 保存数据库
  AsyncManager.me().execute(AsyncFactory.recordOper(operLog));

这里我们封装了一个AcyncManager类

public class AsyncManager {
    /**
     * 操作延迟10毫秒
     */
    private final int OPERATE_DELAY_TIME = 10;
​
    /**
     * 异步操作任务调度线程池
     */
    private ScheduledExecutorService executor = SpringUtils.getBean("scheduledExecutorService");
​
    /**
     * 单例模式
     */
    private AsyncManager() {
    }
​
    private static AsyncManager me = new AsyncManager();
​
    public static AsyncManager me() {
        return me;
    }
​
    /**
     * 执行任务
     *
     * @param task 任务
     */
    public void execute(TimerTask task) {
        executor.schedule(task, OPERATE_DELAY_TIME, TimeUnit.MILLISECONDS);
    }
}
​

我们只需要一个AsyncManager的实例去管理这个任务的执行。

那么这个executor又是怎么获取的呢?

我们自己定义一个线程池配置类,并在其中定义一个scheduledExecutorService的bean去执行周期性的任务

@Configuration
public class ThreadPoolConfig {
    // 核心线程池大小
    private int corePoolSize = 50;
​
    /**
     * 执行周期性或定时任务
     */
    @Bean(name = "scheduledExecutorService")
    protected ScheduledExecutorService scheduledExecutorService() {
        return new ScheduledThreadPoolExecutor(corePoolSize,
                new BasicThreadFactory.Builder().namingPattern("schedule-pool-%d").daemon(true).build()) {
            @Override
            protected void afterExecute(Runnable r, Throwable t) {
                super.afterExecute(r, t);
                Threads.printException(r, t);
            }
        };
    }
}

我们可以根据name来获取该定义的实例bean

我们为啥用ScheduledExecutorService呢?我们知道,Java的定时调度可以用Timer或者TimerTask来实现,但它的实现是单线程,我们需要一个你多线程的执行方案,它解决了任务之间会影响,且性能较低的问题。

只要线程数足够,那么任务就很快执行,但如果不够,多任务会复用一个线程。它本身内部用的延迟队列实现,基于等待/唤醒机制实现,因此CPU 不会一直繁忙,从而也提升了性能

回到正题,这个executor又是怎么获取的呢?

我们利用封装了一个SpringUtil去获取

@Component
public final class SpringUtils implements BeanFactoryPostProcessor, ApplicationContextAware {
    /**
     * Spring应用上下文环境
     */
    private static ConfigurableListableBeanFactory beanFactory;
​
​
    @Override
    public void postProcessBeanFactory(ConfigurableListableBeanFactory beanFactory) throws BeansException {
        SpringUtils.beanFactory = beanFactory;
    }
​
    /**
     * 获取对象
     *
     * @param name
     * @return Object 一个以所给名字注册的bean的实例
     * @throws BeansException
     */
    @SuppressWarnings("unchecked")
    public static <T> T getBean(String name) throws BeansException {
        return (T) beanFactory.getBean(name);
    }
​
}
​

这样,我们就可以利用自定义的getName()方法来获取自定义的bean(ScheduledExecutorService)了

这样我们就定义好了多线程,执行任务的定时执行器服务,之后我们要往里面传我们需要执行的任务

AsyncManager.me().execute()
AsyncManager.me().execute(AsyncFactory.recordOper(operLog));

我们定义一个异步工厂,进行返回异步任务

@Slf4j
public class AsyncFactory {
​
​
    /**
     * 操作日志记录
     *
     * @param operLog 操作日志信息
     * @return 任务task
     */
    public static TimerTask recordOper(final SysOperLog operLog) {
        return new TimerTask() {
            @Override
            public void run() {
                // 远程查询操作地点
//                operLog.setOperLocation(AddressUtils.getRealAddressByIP(operLog.getOperIp()));
                SpringUtils.getBean(SysOperLogService.class).insertOperlog(operLog);
            }
        };
    }
}

执行异步多线程任务小结:

  1. 定义一个bean,去定义线程池,定义名字
  2. 用spring根据它的名字获取它的实例
  3. 利用该实例去执行任务
  4. 同样根据spring找到定义好的方法,传入要执行的方法(任务)

想了解该方面的同学建议去看这篇文章哟~

www.jianshu.com/p/aeb391e4e…