一、需求
1、需求概述
内部管理系统,用于统计用户的使用情况,使用习惯。
2、分析
由于是内部系统,用商业级埋点有点浪费。可以借助ELK日志分析系统,为HTTP API接口增加统一请求日志。
3、统一请求日志要记录以下信息:
- 请求信息:请求路径、请求参数、请求时间、响应状态
- 用户信息:用户id、操作系统、浏览器版本
- 应用信息:接口耗时、响应结果(API统一格式的返回结果)
二、AOP方式
1、AOP拦截所有方法,可以拦截指定Controller;
面向切面编程通常用在实现日志记录、性能统计、事务处理、异常处理等场景。通过AOP可以降低模块间的耦合度,不改变业务模块代码的情况下实现功能。
2、AOP 的核心概念
- 切面(Aspect) :通常是一个类,在里面可以定义切入点和通知。
- 连接点(Joint Point) :被拦截到的点,因为 Spring 只支持方法类型的连接点,所以在 Spring 中连接点指的就是被拦截的到的方法,实际上连接点还可以是字段或者构造器。
- 切入点(Pointcut) :对连接点进行拦截的定义。
- 通知(Advice) :拦截到连接点之后所要执行的代码,通知分为前置、后置、异常、最终、环绕通知五类。
- AOP 代理 :AOP 框架创建的对象,代理就是目标对象的加强。Spring 中的 AOP 代理可以使 JDK 动态代理,也可以是 CGLIB 代理,前者基于接口,后者基于子类。
3、Spring AOP 相关注解
- @Aspect : 将一个 java 类定义为切面类。
- @Pointcut :定义一个切入点,可以是一个规则表达式,比如下例中某个 package 下的所有函数,也可以是一个注解等。
- @Before :在切入点开始处切入内容。
- @After :在切入点结尾处切入内容。
- @AfterReturning :在切入点 return 内容之后切入内容(可以用来对处理返回值做一些加工处理)。
- @Around :在切入点前后切入内容,并自己控制何时执行切入点自身的内容。
- @AfterThrowing :用来处理当切入内容部分抛出异常之后的处理逻辑。
其中 @Before 、 @After 、 @AfterReturning 、 @Around 、 @AfterThrowing 都属于通知。
三、实现代码
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
<!-- Spring AOP -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</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、拦截器(AOP)
/**
*
* @author Ice sun
* @date 2020/11/5 19:48
*/
@Aspect
@Component
@Order(100)
public class HttpEventLogAspect {
private final Logger logger = LoggerFactory.getLogger("event_log");
/**
* 获取请求的真实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();
}
@Pointcut("execution(* cn.ice.demo.modules.*.web.*Controller.*(..))")
public void doEventLog() {
}
@Around("doEventLog()")
public Object doAround(ProceedingJoinPoint point) throws Throwable {
ObjectMapper mapper = new ObjectMapper();
ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
if (attributes == null) {
Object result = point.proceed();
return result;
}
HttpServletRequest request = attributes.getRequest();
HttpServletResponse response = attributes.getResponse();
HttpSession session = request.getSession(false);
//请求参数
StringBuffer requestParams = new StringBuffer();
if ("POST".equals(request.getMethod())) {
String params = getRequestBody(request);
requestParams.append(params);
} else {
String queryString = request.getQueryString();
requestParams.append(queryString);
}
LocalDateTime start = LocalDateTime.now();
Object result = point.proceed();
int status = response.getStatus();
//响应结果,如果没有统一返回格式,此处需要改写
String resp = getResponseBody(response);
ResultVO resultVO = new ResultVO();
resultVO = mapper.readValue(resp, ResultVO.class);
EventLog eventLog = new EventLog();
LocalDateTime end = LocalDateTime.now();
Duration duration = Duration.between(start, end);
Long useTime = duration.toMillis();
String userId = (String) session.getAttribute("userId");
String email = (String) session.getAttribute("email");
String ip = getRealIP(request);
String header = request.getHeader("User-Agent");
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));
return result;
}
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;
}
}
四、总结
实际需求拆解出来的思考过程和关键代码实现。上述代码未考虑是用户否登录情况,即用户信息日志,加上session值的判断更加完整。