将锁有得外部访问都记录在日志文件里面,设计这个功能是为了(为什么):
1. 在不引入Promentheus进行接口监控时,基于日志文件就可以实现整个项目得监控。
2. 当出现问题时,可以基于此进行流量重放。
效果如下(预期):
来看一下请求日志记录得实现。
技术方案(选型)
如果单纯的将这个记录接口的请求信息,当作一个普通的需求来设计,我们可以怎么来实现呢?
基于过滤器Filter,来拦截web请求,记录请求相关信息。
基于AOP来实现方法拦截,借助@Around来实现请求方法执行前后增强,记录请求相关的信息
方案的选择?
Filter过滤器方案
关于过滤器的知识点,可以参考之前文章。若使用过滤器,则主要就是拦截web请求,具体的实现流程如下:
在过滤器的doFilter方法中,划分为三块:
- doBefore:表示将请求转发到Controller执行之前
- 记录开始执行时间
- 记录请求相关信息
- doFilter: 即将请求转发到Controller去执行
- doAfter: Controller方法执行完
- 记录结束时间,计算执行耗时
- 日志输出
使用这种方式的优缺点比较突出,优点是适用性强,实现简单,缺点是只能记录Controller的请求相关信息,如果我们想统计某个Service方法、Mapper方方法,那么这种方法不太合适
AOP切面方案
若使用AOP来实现,则关键点在于我需要拦截那些方法,即定义切点
基本策略与前面差不多,不过有几个关键点
定义切点:可以是直接拦截包路径方式,也可以是配合自定义注解,拦截某些特定注解的方式
- 使用Around环绕方式
- 使用AOP来实现的优缺点也比较明显
- 优点:
- 灵活性高,可以拦截任何共有方法
- 缺点:
- 需要自定义切点,通常不太容易一次编写,所有项目适用。
实现实例(Filter方案)
实现类
包路径:com/github/paicoding/forum/web/hook/filter/ReqRecordFilter.java
package com.github.paicoding.forum.web.hook.filter;
import com.github.paicoding.forum.api.model.context.ReqInfoContext;
import com.github.paicoding.forum.core.util.CrossUtil;
import com.github.paicoding.forum.core.util.IpUtil;
import com.github.paicoding.forum.service.statistics.service.StatisticsSettingService;
import com.github.paicoding.forum.web.global.GlobalInitService;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpMethod;
import javax.servlet.*;
import javax.servlet.annotation.WebFilter;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.net.URLDecoder;
/**
* 1. 请求参数日志输出过滤器
* 2. 判断用户是否登录
*
* @date 2022/7/6
*/
@Slf4j
@WebFilter(urlPatterns = "/*", filterName = "reqRecordFilter", asyncSupported = true)
public class ReqRecordFilter implements Filter {
private static Logger REQ_LOG = LoggerFactory.getLogger("req");
@Override
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
long start = System.currentTimeMillis();
HttpServletRequest request = null;
try {
//构建请求上下文
request = this.initReqInfo((HttpServletRequest) servletRequest);
CrossUtil.buildCors(request, (HttpServletResponse) servletResponse);
filterChain.doFilter(request, servletResponse);
} finally {
//根据请求上下文,输出请求日志
buildRequestLog(ReqInfoContext.getReqInfo(), request, System.currentTimeMillis() - start);
ReqInfoContext.clear();
}
}
private HttpServletRequest initReqInfo(HttpServletRequest request) {
String uri = request.getRequestURI();
if (uri.startsWith("/js/") || uri.startsWith("/css/") || uri.endsWith(".js") || uri.endsWith(".css")) {
// 静态资源直接放行
return request;
}
try {
ReqInfoContext.ReqInfo reqInfo = new ReqInfoContext.ReqInfo();
reqInfo.setHost(request.getHeader("host"));
reqInfo.setPath(request.getPathInfo());
reqInfo.setReferer(request.getHeader("referer"));
reqInfo.setClientIp(IpUtil.getClientIp(request));
reqInfo.setUserAgent(request.getHeader("User-Agent"));
request = this.wrapperRequest(request, reqInfo);
// 初始化登录信息
globalInitService.initLoginUser(reqInfo);
ReqInfoContext.addReqInfo(reqInfo);
} catch (Exception e) {
log.error("init reqInfo error!", e);
}
return request;
}
private void buildRequestLog(ReqInfoContext.ReqInfo req, HttpServletRequest request, long costTime) {
// fixme 过滤不需要记录请求日志的场景
if (request == null
|| req == null
|| request.getRequestURI().endsWith("css")
|| request.getRequestURI().endsWith("js")
|| request.getRequestURI().endsWith("png")
|| request.getRequestURI().endsWith("ico")
|| request.getRequestURI().endsWith("svg")) {
return;
}
StringBuilder msg = new StringBuilder();
msg.append("method=").append(request.getMethod()).append("; ");
if (StringUtils.isNotBlank(req.getReferer())) {
msg.append("referer=").append(URLDecoder.decode(req.getReferer())).append("; ");
}
msg.append("remoteIp=").append(req.getClientIp());
msg.append("; agent=").append(req.getUserAgent());
if (req.getUserId() != null) {
// 打印用户信息
msg.append("; user=").append(req.getUserId());
}
msg.append("; uri=").append(request.getRequestURI());
if (StringUtils.isNotBlank(request.getQueryString())) {
msg.append('?').append(URLDecoder.decode(request.getQueryString()));
}
msg.append("; payload=").append(req.getPayload());
msg.append("; cost=").append(costTime);
REQ_LOG.info("{}", msg);
// 保存请求计数
statisticsSettingService.saveRequestCount(req.getClientIp());
}
private HttpServletRequest wrapperRequest(HttpServletRequest request, ReqInfoContext.ReqInfo reqInfo) {
if (!HttpMethod.POST.name().equalsIgnoreCase(request.getMethod())) {
return request;
}
//封装请求参数
BodyReaderHttpServletRequestWrapper requestWrapper = new BodyReaderHttpServletRequestWrapper(request);
reqInfo.setPayload(requestWrapper.getBodyString());
return requestWrapper;
}
}
排除静态资源
因为pc前台的网页也是集成在项目中的,因此在我们实际的日志输出时,需要将一些静态资源访问给排除掉,主要是基request.getRequestURI后缀来进行过滤的。
private boolean isStaticURI(HttpServletRequest request) {
return request == null
|| request.getRequestURI().endsWith("css")
|| request.getRequestURI().endsWith("js")
|| request.getRequestURI().endsWith("png")
|| request.getRequestURI().endsWith("ico")
|| request.getRequestURI().endsWith("svg")
|| request.getRequestURI().endsWith("min.js.map")
|| request.getRequestURI().endsWith("min.css.map");
}
上面这种方式虽然实现简单,但是也有缺陷:
- 如静态资源请求带url参数
- 除上述这几种静态资源资源之外还有(XML、MP3等)
请求上下文
来看一下,请求上下文的构建,主要是基于HttpServletRequest来获取相关参数
private HttpServletRequest initReqInfo(HttpServletRequest request) {
String uri = request.getRequestURI();
if (uri.startsWith("/js/") || uri.startsWith("/css/") || uri.endsWith(".js") || uri.endsWith(".css")) {
// 静态资源直接放行
return request;
}
try {
ReqInfoContext.ReqInfo reqInfo = new ReqInfoContext.ReqInfo();
reqInfo.setHost(request.getHeader("host"));
reqInfo.setPath(request.getPathInfo());
reqInfo.setReferer(request.getHeader("referer"));
reqInfo.setClientIp(IpUtil.getClientIp(request));
reqInfo.setUserAgent(request.getHeader("User-Agent"));
//传参的封装的处理: 主要是为了避免post的输入流,读取一次后无法在获取的问题
request = this.wrapperRequest(request, reqInfo);
// 初始化登录信息
globalInitService.initLoginUser(reqInfo);
ReqInfoContext.addReqInfo(reqInfo);
} catch (Exception e) {
log.error("init reqInfo error!", e);
}
return request;
}
重点关注两个:
1.请求者的ip获取
实现类的核心如下(通用的工具类,需要注意的是若使用nginx做反向代理的话,那么请不要把用户的信息吃掉了,否则下面这个方法拿不到)
/**
* 获取请求来源的ip地址
*
* @param request
* @return
*/
public static String getClientIp(HttpServletRequest request) {
try {
String xIp = request.getHeader("X-Real-IP");
String xFor = request.getHeader("X-Forwarded-For");
if (StringUtils.isNotEmpty(xFor) && !UNKNOWN.equalsIgnoreCase(xFor)) {
//多次反向代理后会有多个ip值,第一个ip才是真实ip
int index = xFor.indexOf(",");
if (index != -1) {
return xFor.substring(0, index);
} else {
return xFor;
}
}
xFor = xIp;
if (StringUtils.isNotEmpty(xFor) && !UNKNOWN.equalsIgnoreCase(xFor)) {
return xFor;
}
if (StringUtils.isBlank(xFor) || UNKNOWN.equalsIgnoreCase(xFor)) {
xFor = request.getHeader("Proxy-Client-IP");
}
if (StringUtils.isBlank(xFor) || UNKNOWN.equalsIgnoreCase(xFor)) {
xFor = request.getHeader("WL-Proxy-Client-IP");
}
if (StringUtils.isBlank(xFor) || UNKNOWN.equalsIgnoreCase(xFor)) {
xFor = request.getHeader("HTTP_CLIENT_IP");
}
if (StringUtils.isBlank(xFor) || UNKNOWN.equalsIgnoreCase(xFor)) {
xFor = request.getHeader("HTTP_X_FORWARDED_FOR");
}
if (StringUtils.isBlank(xFor) || UNKNOWN.equalsIgnoreCase(xFor)) {
xFor = request.getRemoteAddr();
}
if ("localhost".equalsIgnoreCase(xFor) || "127.0.0.1".equalsIgnoreCase(xFor) || "0:0:0:0:0:0:0:1".equalsIgnoreCase(xFor)) {
return getLocalIp4Address();
}
return xFor;
} catch (Exception e) {
log.error("get remote ip error!", e);
return "x.0.0.1";
}
}
2.请求参数封装
首先需要理解一下为啥需要封装请求参数?
对于post之类的请求,若是传参json,那么需要从HttpServletRequest的请求流中读取,但是这个流是一次性的,如果打印日志的时候把这个参数读取出来了,那么在实际业务中,就拿不到对应的参数了,为了解决这个问题,我们需要将这个InputStream进行封装一下,所以技术派封装了一个BodyReaderHttpServletRequestWrapper类,来封装一下请求。
核心实现如下
- 只拿post,put请求,非二进制、非文件上传、非表单数据上传的场景
- 将请求参数读取到 byte[] body
- 基于body封装 ServletInputStream,用于后续的传参获取。
package com.github.paicoding.forum.web.hook.filter;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import javax.servlet.ReadListener;
import javax.servlet.ServletInputStream;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletRequestWrapper;
import java.io.BufferedReader;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.List;
/**
* post 流数据封装,避免因为打印日志导致请求参数被提前消费
*
* todo 知识点: 请求参数的封装,避免输入流读取一次就消耗了
*
* @author YiHui
* @date 2022/7/6
*/
public class BodyReaderHttpServletRequestWrapper extends HttpServletRequestWrapper {
private static final List<String> POST_METHOD = Arrays.asList("POST", "PUT");
private final Logger logger = LoggerFactory.getLogger(this.getClass());
private final byte[] body;
private final String bodyString;
public BodyReaderHttpServletRequestWrapper(HttpServletRequest request) {
super(request);
if (POST_METHOD.contains(request.getMethod()) && !isMultipart(request) && !isBinaryContent(request) && !isFormPost(request)) {
bodyString = getBodyString(request);
body = bodyString.getBytes(StandardCharsets.UTF_8);
} else {
bodyString = null;
body = null;
}
}
@Override
public BufferedReader getReader() throws IOException {
return new BufferedReader(new InputStreamReader(getInputStream()));
}
@Override
public ServletInputStream getInputStream() throws IOException {
if (body == null) {
return super.getInputStream();
}
final ByteArrayInputStream bais = new ByteArrayInputStream(body);
return new ServletInputStream() {
@Override
public int read() throws IOException {
return bais.read();
}
@Override
public boolean isFinished() {
return false;
}
@Override
public boolean isReady() {
return false;
}
@Override
public void setReadListener(ReadListener readListener) {
}
};
}
public boolean hasPayload() {
return bodyString != null;
}
public String getBodyString() {
return bodyString;
}
private String getBodyString(HttpServletRequest request) {
BufferedReader br;
try {
br = request.getReader();
} catch (IOException e) {
logger.warn("Failed to get reader", e);
return "";
}
String str;
StringBuilder body = new StringBuilder();
try {
while ((str = br.readLine()) != null) {
body.append(str);
}
} catch (IOException e) {
logger.warn("Failed to read line", e);
}
try {
br.close();
} catch (IOException e) {
logger.warn("Failed to close reader", e);
}
return body.toString();
}
/**
* is binary content
*
* @param request http request
* @return ret
*/
private boolean isBinaryContent(final HttpServletRequest request) {
return request.getContentType() != null &&
(request.getContentType().startsWith("image") || request.getContentType().startsWith("video") ||
request.getContentType().startsWith("audio"));
}
/**
* is multipart content
*
* @param request http request
* @return ret
*/
private boolean isMultipart(final HttpServletRequest request) {
return request.getContentType() != null && request.getContentType().startsWith("multipart/form-data");
}
private boolean isFormPost(final HttpServletRequest request) {
return request.getContentType() != null && request.getContentType().startsWith("application/x-www-form-urlencoded");
}
}
日志输出
最后再看一下日志输出,我们直接将上面封装的请求相关信息,按照具体的日志格式进行打印
private void buildRequestLog(ReqInfoContext.ReqInfo req, HttpServletRequest request, long costTime) {
if (req == null || isStaticURI(request)) {
return;
}
StringBuilder msg = new StringBuilder();
msg.append("method=").append(request.getMethod()).append("; ");
if (StringUtils.isNotBlank(req.getReferer())) {
msg.append("referer=").append(URLDecoder.decode(req.getReferer())).append("; ");
}
msg.append("remoteIp=").append(req.getClientIp());
msg.append("; agent=").append(req.getUserAgent());
if (req.getUserId() != null) {
// 打印用户信息
msg.append("; user=").append(req.getUserId());
}
msg.append("; uri=").append(request.getRequestURI());
if (StringUtils.isNotBlank(request.getQueryString())) {
msg.append('?').append(URLDecoder.decode(request.getQueryString()));
}
msg.append("; payload=").append(req.getPayload());
msg.append("; cost=").append(costTime);
REQ_LOG.info("{}", msg);
// 保存请求计数
statisticsSettingService.saveRequestCount(req.getClientIp());
}
小结
上面介绍了技术派中基于Filter实现的请求日志记录,将所有外部请求,都统一写道req日志文件中,可以基于此,查看一下当前项目的请求情况,接口耗时等。
其中涉及到的知识点如下:
- Filter基本使用
- Filter/AOP实现请求参数记录的方案
- 如何从HttpServletRequest中获取你需要的请求参数
- 请求参数的封装,允许请求参数InputStream的重复读取
- 如何获取请求者ip
- 日志输出