最近公司的项目,客户临时追加一个需求,要看到使用用户的操作日志。类似于下方那样。此项目是网上的一个叫做xboot的项目,功能挺齐全的,可以参考。
回到此功能,这个功能并不复杂,主要就是记录并显示用户请求了哪些业务方法,ip,请求时间,请求参数等信息。
我这里想到了三种实现方式,这里分别说说。
这种实现方式,其实就是定义一个切面,去横切指定的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。这时候需要再全局异常拦截器里手动调用记录日志方法。
这种大致说一下吧,不太推荐。因为现在的项目都是微服务,然后统一入口由网关去分发请求到各个服务。所有可以从网关那从Request中获取相关信息,Gateway也提供了相关的过滤器实现为我们去额外处理请求参数,不过这种弊端就是没法像上面那样通过@SysLog注解去获取业务类型和方法说明。还是利用上面两个方法去实现吧。
暂时我能想到的就这三种实现方式,从开发角度,还是推荐第一种AOP代理方式,获取参数很方便,这也是很多博客以及面试中能问到的,毕竟AOP的实际使用并不多。第二种拦截器需要额外处理请求参数,一些特殊的请求参数不太好获取。
最后额外的ip地址所属城市,这个网上有提供免费的API接口去获取。请求路径,这个从request中获取即可,其它的信息用到再说吧。