一、需求
1、需求概述
内部管理系统,用于统计用户的使用情况,使用习惯。
2、分析
由于是内部系统,用商业级埋点有点浪费。可以借助ELK日志分析系统,为HTTP API接口增加统一请求日志。
3、统一请求日志要记录以下信息:
- 请求信息:请求路径、请求参数、请求时间、响应状态
- 用户信息:用户id、操作系统、浏览器版本
- 应用信息:接口耗时、响应结果(API统一格式的返回结果)
二、选用AOP or Filter
1、AOP拦截所有方法,可以拦截指定Controller;
2、Filter拦截所有请求,拦截位置比Handler靠前,比较适合用于行为日志记录
以终为始,不管黑猫白猫,能抓住老鼠就是好猫。这两种方式均能满足需求。本章选用Filter方式实现。
- OncePerRequestFilter(SpringMVC)
抽象类OncePerRequestFilter继承自GenericFilterBean,它保留了GenericFilterBean中的所有方法并对之进行了扩展,在oncePerRequestFilter中的主要方法是doFilter。
- AbstractRequestLoggingFilter(SpringMVC)
AbstractRequestLoggingFilter是对OncePerRequestFilter的扩展,它除了遗传了其父类及祖先类的所有功能外,还在doFilterInternal中决定了在过滤之前和之后执行的事件,它的子类关注的是beforeRequest和afterRequest。
因为埋点需求需要记录response信息,选用基于OncePerRequestFilter实现,并参考HttpTraceLogFilter一些思路和写法
三、实现代码
1、日志配置
logback-spring.xml
<!-- 行为埋点 -->
<appender name="EVENT_LOG" class="ch.qos.logback.core.rolling.RollingFileAppender">
<File>${LOG_HOME}/event.log</File>
<append>true</append>
<!-- ThresholdFilter:临界值过滤器,过滤掉 TRACE 和 DEBUG 级别的日志 -->
<filter class="ch.qos.logback.classic.filter.ThresholdFilter">
<level>INFO</level>
</filter>
<encoder>
<Pattern>%msg%n</Pattern>
<charset>UTF-8</charset>
</encoder>
<rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
<!-- 每天生成一个日志文件,保存30天的日志文件
- 如果隔一段时间没有输出日志,前面过期的日志不会被删除,只有再重新打印日志的时候,会触发删除过期日志的操作。
-->
<fileNamePattern>${LOG_HOME}/event.%d{yyyy-MM-dd}.%i.log</fileNamePattern>
<maxHistory>30</maxHistory>
<maxFileSize>100MB</maxFileSize>
<totalSizeCap>3GB</totalSizeCap>
</rollingPolicy>
</appender>
<!-- 省略其他 -->
<logger name="event_log" level="info" additivity="false">
<appender-ref ref="EVENT_LOG"/>
</logger>
pom
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
<version>5.3.2</version>
</dependency>
2、日志格式封装(model)
import com.fasterxml.jackson.annotation.JsonFormat;
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateTimeSerializer;
import lombok.Data;
import org.springframework.format.annotation.DateTimeFormat;
import java.time.LocalDateTime;
/**
* 行为埋点
* @author Ice sun
* @date 2020/10/29 18:01
*/
@Data
public class EventLog {
/**
* 用户id
*/
private String userId;
/**
* 用户邮箱
*/
private String email;
/**
* 机构名称
*/
private String orgName;
/**
* 部门名称
*/
private String deptName;
/**
* 请求路径
*/
private String url;
/**
* 请求 IP
*/
private String ip;
/**
* 请求参数
*/
private String params;
/**
* 接口用时(毫秒)
*/
private Long useTime;
/**
* 浏览器类型
*/
private String browser;
/**
* 操作系统类型
*/
private String operatingSystem;
/**
* 请求响应状态,示例:200 302等
*/
private Integer status;
/**
* 统一接口响应状态代码 1 成功 其他见码表
*/
private String code;
/**
* 统一接口响应状态描述
*/
private String msg;
/**
* 统一接口响应状态结果
*/
private String data;
/**
* 记录时间
*/
@JsonSerialize(using = LocalDateTimeSerializer.class)
@DateTimeFormat(iso = DateTimeFormat.ISO.TIME)
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
private LocalDateTime createTime;
}
3、过滤器(Filter)
/**
* 埋点Filter
* @author Ice sun
* @date 2020/11/2 19:27
*/
public class HttpEventLogFilter extends OncePerRequestFilter {
private final Logger log = LoggerFactory.getLogger("event_log");
/**
* 预留日志总开关,通过配置开启或关闭
*/
private boolean logEnabed = true;
/**
* 排除上传文件类请求
*/
private static final String IGNORE_CONTENT_TYPE = "multipart/form-data";
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
if (!(request instanceof ContentCachingRequestWrapper)) {
request = new ContentCachingRequestWrapper(request);
}
if (!(response instanceof ContentCachingResponseWrapper)) {
response = new ContentCachingResponseWrapper(response);
}
int status = HttpStatus.INTERNAL_SERVER_ERROR.value();
LocalDateTime start = LocalDateTime.now();
try {
filterChain.doFilter(request, response);
status = response.getStatus();
}catch (Exception e){
log.error("TraceLogFilter",e);
} finally {
if(logEnabed&&!Objects.equals(IGNORE_CONTENT_TYPE, request.getContentType())) {
ObjectMapper mapper = new ObjectMapper();
LocalDateTime end = LocalDateTime.now();
Duration duration = Duration.between(start, end);
Long useTime = duration.toMillis();
HttpSession session = request.getSession(false);
EventLog eventLog = new EventLog();
String userId = (String) session.getAttribute("userId");
String email = (String) session.getAttribute("email");
//请求参数
StringBuffer requestParams = new StringBuffer();
if("POST".equals(request.getMethod())){
String params = getRequestBody(request);
requestParams.append(params);
}else {
String queryString = request.getQueryString();
requestParams.append(queryString);
}
//响应结果,如果没有统一返回格式,此处需要改写
String result = getResponseBody(response);
ResultVO resultVO = new ResultVO();//API统一响应封装类
resultVO = mapper.readValue(result,ResultVO.class);
String ip = getRealIP(request);
String header = request.getHeader("User-Agent");
//hutools工具类
UserAgent ua = UserAgentUtil.parse(header);
eventLog.setUserId(userId);
eventLog.setEmail(email);
eventLog.setParams(requestParams.toString());
eventLog.setUrl(request.getRequestURI());
eventLog.setIp(ip);
eventLog.setBrowser(ua == null ? "" : ua.getBrowser() == null ? "" : ua.getBrowser().toString());
eventLog.setOperatingSystem(ua == null ? "" : ua.getPlatform() == null ? "" : ua.getPlatform().toString());
eventLog.setStatus(status);
eventLog.setUseTime(useTime);
eventLog.setCode(resultVO.getCode());
eventLog.setData(resultVO.getDate());
eventLog.setMsg(resultVO.getMsg());
eventLog.setCreateTime(start);
log.info(mapper.writeValueAsString(eventLog));
}
//非常关键,一定要加
updateResponse(response);
}
}
/**
* 获取请求的真实IP
* @param request
* @return
*/
public static String getRealIP(HttpServletRequest request) {
String ip = request.getHeader("X-Forwarded-For");
if (!StringUtils.isEmpty(ip) && !"unKnown".equalsIgnoreCase(ip)) {
//多次反向代理后会有多个ip值,第一个ip才是真实ip
int index = ip.indexOf(",");
if (index != -1) {
return ip.substring(0, index);
} else {
return ip;
}
}
ip = request.getHeader("X-Real-IP");
if (!StringUtils.isEmpty(ip) && !"unKnown".equalsIgnoreCase(ip)) {
return ip;
}
return request.getRemoteAddr();
}
private String getRequestBody(HttpServletRequest request) {
String requestBody = "";
ContentCachingRequestWrapper wrapper = WebUtils.getNativeRequest(request, ContentCachingRequestWrapper.class);
if (wrapper != null) {
try {
requestBody = IOUtils.toString(wrapper.getContentAsByteArray(), wrapper.getCharacterEncoding());
} catch (IOException e) {
// NOOP
}
}
return requestBody;
}
private String getResponseBody(HttpServletResponse response) {
String responseBody = "";
ContentCachingResponseWrapper wrapper = WebUtils.getNativeResponse(response, ContentCachingResponseWrapper.class);
if (wrapper != null) {
try {
responseBody = IOUtils.toString(wrapper.getContentAsByteArray(), wrapper.getCharacterEncoding());
} catch (IOException e) {
// NOOP
}
}
return responseBody;
}
/**
* 解决字节流在读取了一次之后清空的问题
*/
private void updateResponse(HttpServletResponse response) throws IOException {
ContentCachingResponseWrapper responseWrapper = WebUtils
.getNativeResponse(response, ContentCachingResponseWrapper.class);
Objects.requireNonNull(responseWrapper).copyBodyToResponse();
}
}
4、SpringBoot,Application类注册Filter
/**
* 记录http请求日志
* @return
*/
@Bean
public FilterRegistrationBean httpEventFilter() {
FilterRegistrationBean registration = new FilterRegistrationBean(new HttpEventLogFilter());
registration.addUrlPatterns("/api/*");
return registration;
}
四、总结
实际需求拆解出来的思考过程和关键代码实现。上述代码未考虑是用户否登录情况,即用户信息日志,加上session值的判断更加完整。
AOP实现地址:https://blog.csdn.net/qq_16465949/article/details/109490660
参考链接:https://www.sofastack.tech/projects/
SOFATracer 是蚂蚁金服开发的基于 OpenTracing 规范 的分布式链路跟踪系统,其核心理念就是通过一个全局的 TraceId 将分布在各个服务节点上的同一次请求串联起来。通过统一的 TraceId 将调用链路中的各种网络调用情况以日志的方式记录下来同时也提供远程汇报到 Zipkin 进行展示的能力,以此达到透视化网络调用的目的。
参考文章:https://blog.csdn.net/weixin_33941350/article/details/88033066