Spring项目打印请求日志,记录用户操作日志相关实现

最近公司的项目,客户临时追加一个需求,要看到使用用户的操作日志。类似于下方那样。此项目是网上的一个叫做xboot的项目,功能挺齐全的,可以参考。
Spring项目打印请求日志,记录用户操作日志相关实现_第1张图片
回到此功能,这个功能并不复杂,主要就是记录并显示用户请求了哪些业务方法,ip,请求时间,请求参数等信息。
我这里想到了三种实现方式,这里分别说说。

AOP代理实现方式

这种实现方式,其实就是定义一个切面,去横切指定的Controller方法,然后用环绕通知这种advice,在调用原目标方法前记录一些信息,在执行目标方法后再记录一些信息,最终打印或存储日志。

这种方式的好处就是获取请求参数非常方便,毕竟参数已经由Spring MVC映射到方法参数上。否则获取post请求的参数非常麻烦。
不多说,具体看下面的demo吧。

自定义需要记录日志的注解

/**
 * @Description: 记录请求方法执行
 *
 * @author binghua.zheng
 * @Date: 2021/11/12 21:42
 */
@Target({ElementType.METHOD,ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface SysLog {

    /**
     *  业务类型
     *
     * @return string
     */
    String businessType() default "";

    /**
     * 方法说明
     *
     * @return string
     */
    String method() default "";

    /**
     * 日志级别 1-重要; 2-普通
     *
     * @return string
     */
    String level() default "2";
}

此注解用于标注在controller的方法上,这样将来的日志就知道所执行的方法是什么业务和重要度。

日志内容

/**
 * @Description: 日志信息
 *
 * @author: binghua.zheng
 * @Date: 2021/11/12 22:13
 */
@Setter
@Getter
@ToString
public class SysLogMetadata {

    /**
     * 日志ID
     */
    private String id;

    /**
     * 操作人
     */
    private String operateUser;

    /**
     * 业务类型
     */
    private String businessType;

    /**
     * 方法描述
     */
    private String method;

    /**
     * 日志级别
     */
    private String level;

    /**
     * 客户端IP
     */
    private String clientIp;

    /**
     * 操作时间
     */
    private String operateTime;

    /**
     * 执行时间
     */
    private String executeTime;

    /**
     * 请求参数
     */
    private String parameter;

    /**
     * 操作结果
     */
    private String operateResult;

    /**
     * 额外信息
     */
    private String msg;
}

将来准备入库或者打印的内容。

日志切面

/**
 * @Description: 记录日志切面
 *
 * @author: binghua.zheng
 * @Date: 2021/11/12 21:50
 */
@Aspect
public class SysLogAspect {

    private static final Logger LOGGER = LoggerFactory.getLogger(SysLogAspect.class);

    @Autowired
    private HttpServletRequest request;

    @Pointcut("@annotation(com.binghuazheng.tools.enhance.log.SysLog)")
    public void pointcut() {}

    /**
     * 拦截Controller的 @SysLog 注解,记录相关请求日志信息
     */
    @Around("pointcut()")
    public Object logRequest(ProceedingJoinPoint pjp) throws Throwable {
        Object result;
        // syslog
        SysLogMetadata logMetadata = this.initSysLog(pjp);
        long beginTime = System.nanoTime();
        try {
            result = pjp.proceed();
        } catch (Throwable throwable) {
            // 记录异常信息
            logMetadata.setExecuteTime(String.valueOf(this.doGetExecuteTime(beginTime)));
            logMetadata.setOperateResult("2");
            // 从异常message或堆栈里获取异常信息
            logMetadata.setMsg(throwable.getMessage() == null ? this.doGetStackTrace(throwable) : throwable.getMessage());
            // 入库
            LOGGER.info(JsonUtils.toJson(logMetadata));
            throw throwable;
        }
        logMetadata.setExecuteTime(String.valueOf(this.doGetExecuteTime(beginTime)));
        logMetadata.setOperateResult("1");
        logMetadata.setMsg("执行成功");
        // 入库
        LOGGER.info(JsonUtils.toJson(logMetadata));
        return result;
    }

    /**
     * 初始化SysLogMetadata
     */
    private SysLogMetadata initSysLog(ProceedingJoinPoint pjp) {
        // parse method
        MethodSignature signature = (MethodSignature)pjp.getSignature();
        Method method = signature.getMethod();
        SysLog sysLog = method.getAnnotation(SysLog.class);

        // syslog
        SysLogMetadata logMetadata = new SysLogMetadata();
        logMetadata.setId(UUID.randomUUID().toString());
        logMetadata.setBusinessType(sysLog.businessType());
        logMetadata.setMethod(sysLog.method());
        logMetadata.setLevel(sysLog.level());
        logMetadata.setParameter(JsonUtils.toJson(pjp.getArgs()));
        logMetadata.setClientIp(this.doGetLocalhost());
        logMetadata.setOperateUser("1001");
        logMetadata.setOperateTime(LocalDateTime.now().toString());
        return logMetadata;
    }

    private String doGetStackTrace(Throwable throwable) {
        StringWriter sw = new StringWriter();
        try (PrintWriter pw = new PrintWriter(sw)) {
            throwable.printStackTrace(pw);
            return sw.toString();
        }
    }

    private String doGetLocalhost() {
        InetAddress address = null;
        try {
            address = InetAddress.getLocalHost();
        } catch (UnknownHostException e) {
            e.printStackTrace();
        }
        assert address != null;
        return address.getHostAddress();
    }

    private long doGetExecuteTime(long beginTime) {
        return (System.nanoTime() - beginTime) / 1000000;
    }


}

此方法就是核心的切面类了,定义切点去拦截@SysLog注解,定义@Around通知增强原Controller方法。内部创建SysLogMetadata对象,分别在目标Controller方法执行前后收集相关信息。

配置类

/**
 * @Description: 记录日志 AOP实现 配置类
 *
 * @author : binghua.zheng
 * @Date: 2021/11/12 21:36
 */
@EnableAspectJAutoProxy
public class LogAopConfig {

    /**
     * 日志切面
     */
    @Bean
    public SysLogAspect sysLogAspect() {
        return new SysLogAspect();
    }
}

用于创建切面类的bean以及引入AOP的框架后置处理器。

/**
 * @Description: 启用系统日志
 *
 * @author: binghua.zheng
 * @Date: 2021/11/12 23:01
 */
@Target({ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Import(LogAopConfig.class)
public @interface EnableSysLog {
}

再定义 一个开启注解,当作日志功能的开关吧,通过@Import注解导入上边的配置类。

测试

启动类加上@EnableSysLog注解,然后在controller方法上标注@SysLog注解就可以了。

/**
 * @Description: 启动类
 *
 * @Auther: binghua.zheng
 * @Date: 2021/11/5 21:39
 */
@EnableSysLog
@SpringBootApplication
public class ToolInitailizer {

    public static void main(String[] args) {
        SpringApplication.run(ToolInitailizer.class, args);
    }
}
/**
 * @Description:
 * @author: binghua.zheng
 * @Date: 2021/11/12 22:55
 */
@RestController
@RequestMapping("/user")
public class UserController {

    private static final Logger LOGGER = LoggerFactory.getLogger(UserController.class);

    @SysLog(businessType = "用户管理", method = "添加用户", level = "1")
    @PostMapping
    public void userInfo(@RequestBody UserInfo userInfo) {
        LOGGER.info(JsonUtils.toJson(userInfo));
    }

}    

效果如下。

2021-11-14 23:04:49.830  INFO 15676 --- [nio-8080-exec-1] o.a.c.c.C.[Tomcat].[localhost].[/]       : Initializing Spring DispatcherServlet 'dispatcherServlet'
2021-11-14 23:04:49.831  INFO 15676 --- [nio-8080-exec-1] o.s.web.servlet.DispatcherServlet        : Initializing Servlet 'dispatcherServlet'
2021-11-14 23:04:49.835  INFO 15676 --- [nio-8080-exec-1] o.s.web.servlet.DispatcherServlet        : Completed initialization in 4 ms
2021-11-14 23:04:49.960  INFO 15676 --- [nio-8080-exec-1] c.b.tools.controller.UserController      : {"userId":"1001","username":"张三","password":"123456"}
2021-11-14 23:04:49.962  INFO 15676 --- [nio-8080-exec-1] c.b.t.e.log.aop.config.SysLogAspect      : {"id":"512afd17-683f-4483-ae1d-f49e3d9204e4","operateUser":"1001","businessType":"用户管理","method":"添加用户","level":"1","clientIp":"192.168.40.1","operateTime":"2021-11-14T23:04:49.951","executeTime":"8","parameter":"[{\"userId\":\"1001\",\"username\":\"张三\",\"password\":\"123456\"}]","operateResult":"1","msg":"执行成功"}

拦截器实现方式

此实现方式就是利用Spring MVC提供的拦截器机制,因为拦截器会在Controller方法调用前,调用后,以及渲染动态页面后分别被调用,这样我们就可以以此来记录请求的执行信息。
但是这种方式相比较AOP那种,有个缺陷就是不好获取参数信息,Request的请求参数在SpringMVC的表现形式有很多,比如常用的@RequestBody这种,@ReqeustParame这种以及@PathVariable这种。尤其是ReqeustBody,你从Reqeust中以流的方式获取后,此Request中就直接清空了,后期执行Controller方法就有问题了。而拦截器本身不提供参数给你,需要额外的手段去获取这些参数。

日志信息缓存

/**
 * @Description: 一次request下的日志相关数据缓存
 *
 * @author: binghua.zheng
 * @Date: 2021/11/12 21:57
 */
public class LogThreadLocal {

    private static final ThreadLocal<Map<String, Object>> LOG_CACHE = new ThreadLocal<>();


    public static void clear() {
        LOG_CACHE.remove();
    }

    public static void setAttribute(String key, Object value) {
        Map<String, Object> cache = getCache();
        cache.put(key, value);
    }

    public static Object getAttribute(String key) {
        Map<String, Object> cache = getCache();
        return cache.get(key);
    }


    public static String getAttributeStr(String key) {
        Map<String, Object> cache = getCache();
        Object value = cache.get(key);
        return value == null ? null : String.valueOf(value);
    }

    public static Map<String, Object> getCache() {
        Map<String, Object> cache = LOG_CACHE.get();
        if (cache == null) {
            cache = new HashMap<>();
            LOG_CACHE.set(cache);
        }
        return cache;
    }

}

因为拦截器方法调用是不同阶段调用,并不像AOP在一个方法内,所有这里定义一个缓存去记录相关日志属性。

日志拦截器

/**
 * @Description: @SysLog注解拦截器
 *
 * @author: binghua.zheng
 * @Date: 2021/11/13 23:17
 */
public class SysLogInterceptor implements HandlerInterceptor {

    private static final Logger LOGGER = LoggerFactory.getLogger(SysLogInterceptor.class);

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        if (handler instanceof HandlerMethod) {
            SysLog sysLogAnnotation = ((HandlerMethod) handler).getMethodAnnotation(SysLog.class);
            // 处理@SysLog注解标注的Controller方法
            if (sysLogAnnotation != null) {
                LogThreadLocal.setAttribute("logFlag", "1");
                LogThreadLocal.setAttribute("businessType", sysLogAnnotation.businessType());
                LogThreadLocal.setAttribute("method", sysLogAnnotation.method());
                LogThreadLocal.setAttribute("level", sysLogAnnotation.level());
                // 默认根据token等从其它拦截器或认证服务获取此人身份,如果是拦截器,要保证日志拦截器在token拦截器之后执行
                LogThreadLocal.setAttribute("operateUser", "1001");
                LogThreadLocal.setAttribute("clientIp", this.doGetLocalhost());
                LogThreadLocal.setAttribute("operateTime", LocalDateTime.now().toString());
                LogThreadLocal.setAttribute("beginTime", System.nanoTime());
            }
        }
        return true;
    }

    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
        if (!"1".equals(LogThreadLocal.getAttribute("logFlag"))) {
            LogThreadLocal.clear();
            return ;
        }
        // 处理请求日志
        try {
            SysLogMetadata sysLogMetadata = new SysLogMetadata();
            sysLogMetadata.setId(UUID.randomUUID().toString());
            sysLogMetadata.setBusinessType(LogThreadLocal.getAttributeStr("businessType"));
            sysLogMetadata.setMethod(LogThreadLocal.getAttributeStr("method"));
            sysLogMetadata.setLevel(LogThreadLocal.getAttributeStr("level"));
            sysLogMetadata.setOperateUser(LogThreadLocal.getAttributeStr("operateUser"));
            sysLogMetadata.setClientIp(LogThreadLocal.getAttributeStr("clientIp"));
            sysLogMetadata.setOperateTime(LogThreadLocal.getAttributeStr("operateTime"));
            sysLogMetadata.setExecuteTime(this.doGetExecuteTime(Long.parseLong(LogThreadLocal.getAttributeStr("beginTime"))));
            sysLogMetadata.setOperateResult("1");
            sysLogMetadata.setMsg("执行成功");
            // 是否执行成功,这里需要注意,如果有全局异常拦截器,那么即使有异常,ex也为null,需要在全局异常拦截器中调用此afterCompletion方法提前记录日志。
            if (ex != null) {
                sysLogMetadata.setOperateResult("2");
                // 一般的异常,message的值都是null.异常信息在堆栈里,我们自己定义的异常,才有可能往ex里放入message和code
                sysLogMetadata.setMsg(ex.getMessage() == null ? this.doGetStackTrace(ex) : ex.getMessage());
            }
            // 获取请求参数,默认常用的@ReqeustBody/@PathVariable/@RequestParam这三种
            sysLogMetadata.setParameter(this.doGetParameter(request));

            // 日志入库操作
            LOGGER.info(JsonUtils.toJson(sysLogMetadata));
        } finally {
            LogThreadLocal.clear();
        }
    }

    private String doGetParameter(HttpServletRequest request) {
        // 收集三种类型的参数
        Map<String, Object> parameter = new HashMap<>();
        // @PathVariable这种
        Object pathVariable = request.getAttribute(View.PATH_VARIABLES);
        if (pathVariable != null) {
            parameter.putAll(((Map<String, Object>) pathVariable));
        }
        // @RequestParam这种
        Map<String, String[]> parameterMap = request.getParameterMap();
        if (parameterMap != null) {
            parameter.putAll(parameterMap);
        }
        // 从SysLogRequestBodyAdvice中获取的@RequestBoedy这种
        Object requestBody = LogThreadLocal.getAttribute("requestBody");
        if (requestBody != null) {
            parameter.putAll(JsonUtils.parseJson(requestBody.toString(), Map.class));
        }

        LOGGER.info("@PathVariable : {} , @RequestParam : {}, @RequestBody : {}", pathVariable, parameterMap, requestBody);
        return JsonUtils.toJson(parameter);
    }

    private String doGetStackTrace(Throwable throwable) {
        StringWriter sw = new StringWriter();
        try (PrintWriter pw = new PrintWriter(sw)) {
            throwable.printStackTrace(pw);
            return sw.toString();
        }
    }

    private String doGetExecuteTime(long beginTime) {
        return String.valueOf((System.nanoTime() - beginTime) / 1000000);
    }

    private String doGetLocalhost() {
        InetAddress address = null;
        try {
            address = InetAddress.getLocalHost();
        } catch (UnknownHostException e) {
            e.printStackTrace();
        }
        assert address != null;
        return address.getHostAddress();
    }
}

这个就是核心的拦截器,内部会分别记录相关日志信息。特别说明的就是获取请求参数的方法doGetParameter,这里我只处理了最常用的@PathVariable,@RequestParame,@RequestBody这三种注解对应的参数。Spring MVC处理参数是用不同的HandlerMethodArgumentResolver参数处理器来操作的。如果需要处理其它类型的注解,将来再细看。Post请求的请求体对应@RequestBody注解,需要额外定义类来接收。

接收请求体的参数

/**
 * @Description: 获取RequestBody中的数据
 *
 * @author: binghua.zheng
 * @Date: 2021/11/13 23:20
 */
@ControllerAdvice
public class SysLogRequestBodyAdvice extends RequestBodyAdviceAdapter {

    @Override
    public boolean supports(MethodParameter methodParameter, Type targetType, Class<? extends HttpMessageConverter<?>> converterType) {
        return methodParameter.hasMethodAnnotation(SysLog.class);
    }


    @Override
    public Object afterBodyRead(Object body, HttpInputMessage inputMessage, MethodParameter parameter, Type targetType, Class<? extends HttpMessageConverter<?>> converterType) {
        LogThreadLocal.setAttribute("requestBody", JsonUtils.toJson(body));
        return body;
    }
}

这里我用LogThreadLocal去缓存请求体。

配置类

/**
 * @Description: mvc config
 *
 * @author: binghua.zheng
 * @Date: 2021/11/13 23:15
 */
@EnableWebMvc
public class AppConfig implements WebMvcConfigurer {

    @Autowired
    private SysLogInterceptor sysLogInterceptor;

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(sysLogInterceptor);
    }

}
/**
 * @Description:
 * @author: binghua.zheng
 * @Date: 2021/11/14 00:16
 */
@Target({ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Import({SysLogInterceptor.class, AppConfig.class})
public @interface EnableSysLogInterceptor {
}

配置类需要把拦截器加入到Spring MVC中,然后像上面AOP一样,定义一个开关注解,用于启动日志拦截器。

测试

/**
 * @Description: 启动类
 *
 * @Auther: binghua.zheng
 * @Date: 2021/11/5 21:39
 */
@EnableSysLogInterceptor
@SpringBootApplication
public class ToolInitailizer {

    public static void main(String[] args) {
        SpringApplication.run(ToolInitailizer.class, args);
    }
}

启动类添加@EnableSysLogInterceptor注解开启日志拦截,然后执行controller方法,这里挑个特殊的,执行post请求,并且同时带有@RequestBody,@RequestParam,以及@PathVariable三种注解的Controller方法。

/**
 * @Description:
 * @author: binghua.zheng
 * @Date: 2021/11/12 22:55
 */
@RestController
@RequestMapping("/user")
public class UserController {

    private static final Logger LOGGER = LoggerFactory.getLogger(UserController.class);

    @SysLog(businessType = "用户管理", method = "添加用户", level = "1")
    @PostMapping("/{age}")
    public void insertInfo(@RequestBody UserInfo userInfo, @RequestParam("userId") String id, @PathVariable("age") String age) {
        LOGGER.info(JsonUtils.toJson(userInfo) + id + age);
    }
}
###
POST http://localhost:8080/user/30?userId=66
Content-Type: application/json

{   "userId":"1001",   "username":"张三",   "password":"123456" }

效果如下

2021-11-14 23:38:00.853  INFO 18452 --- [nio-8080-exec-1] o.a.c.c.C.[Tomcat].[localhost].[/]       : Initializing Spring DispatcherServlet 'dispatcherServlet'
2021-11-14 23:38:00.853  INFO 18452 --- [nio-8080-exec-1] o.s.web.servlet.DispatcherServlet        : Initializing Servlet 'dispatcherServlet'
2021-11-14 23:38:00.856  INFO 18452 --- [nio-8080-exec-1] o.s.web.servlet.DispatcherServlet        : Completed initialization in 3 ms
2021-11-14 23:38:00.930  INFO 18452 --- [nio-8080-exec-1] c.b.tools.controller.UserController      : {"userId":"1001","username":"张三","password":"123456"}6630
2021-11-14 23:38:00.945  INFO 18452 --- [nio-8080-exec-1] c.b.t.e.l.interceptor.SysLogInterceptor  : @PathVariable : {age=30} , @RequestParam : org.apache.catalina.util.ParameterMap@4b2adea8, @RequestBody : {"userId":"1001","username":"张三","password":"123456"}
2021-11-14 23:38:00.949  INFO 18452 --- [nio-8080-exec-1] c.b.t.e.l.interceptor.SysLogInterceptor  : {"id":"b6e52990-2c14-458f-8323-036e6f2327a9","operateUser":"1001","businessType":"用户管理","method":"添加用户","level":"1","clientIp":"192.168.40.1","operateTime":"2021-11-14T23:38:00.869","executeTime":"66","parameter":"{\"password\":\"123456\",\"userId\":\"1001\",\"age\":\"30\",\"username\":\"张三\"}","operateResult":"1","msg":"执行成功"}

这里由于定义了同名key的userId参数,所以HashMap只显示了一个,其它的都显示出来了,实际请求中一般不会这么玩。
另外需要注意的就是,此拦截器方法执行时机对异常信息的处理问题,实际项目中都有全局异常处理,一旦我们的业务代码报错后,都会进入全局异常处理器中,然后包装为相关的Response,异常信息就没有了,可能只有{code:500,message:业务错误}这种响应,再执行拦截器方法就会发现ex永远为null。这时候需要再全局异常拦截器里手动调用记录日志方法。

Gateway网关方式

这种大致说一下吧,不太推荐。因为现在的项目都是微服务,然后统一入口由网关去分发请求到各个服务。所有可以从网关那从Request中获取相关信息,Gateway也提供了相关的过滤器实现为我们去额外处理请求参数,不过这种弊端就是没法像上面那样通过@SysLog注解去获取业务类型和方法说明。还是利用上面两个方法去实现吧。

总结

暂时我能想到的就这三种实现方式,从开发角度,还是推荐第一种AOP代理方式,获取参数很方便,这也是很多博客以及面试中能问到的,毕竟AOP的实际使用并不多。第二种拦截器需要额外处理请求参数,一些特殊的请求参数不太好获取。

最后额外的ip地址所属城市,这个网上有提供免费的API接口去获取。请求路径,这个从request中获取即可,其它的信息用到再说吧。

你可能感兴趣的:(解决方案,其它,spring,java,后端)