博主由于工作需要,写了一个http请求记录日志的功能,就是简单记录一下请求时间,请求路径,请求方法,携带的参数信息等等,下面是效果图:
目录结构:
内容信息:
2019-09-30 10:49:02:086 method:POST path:/jmt/cgi-bin/lottery/init/10201/205927 pathParam:(userId:13095) bodyParam:{"shopId": "3213"}
2019-09-30 10:49:26:034 method:PUT path:/jmt/cgi-bin/lottery/init/10201/205927 pathParam:(userId:13095) bodyParam:{"shopId": "3213"}
2019-09-30 10:49:34:676 method:GET path:/jmt/cgi-bin/lottery/draw/10201/205927 pathParam:(userId:13095)
2019-09-30 10:49:39:442 method:DELETE path:/jmt/cgi-bin/lottery/draw/10201/205927 pathParam:(userId:13095)
博主这里使用了springBoot2.0.3。
其实从实现思路上没什么难,无非就是写一个拦截器或者过滤器,启动时,创建日志文件,通过ServletRequest抓取信息打到文件就可以了。
但这里面有两个坑点:
1.建议别用拦截器,使用拦截器时如果使用错误的路径或错误的动作(get.post等等)访问系统,造成404或405错误,会被spring转发到一个/error的路径,而且正确的访问路径不会经过我们定义的拦截器,反而拦截到了/error请求,过滤器就不会存在这个问题。
2.关于获取请求消息体参数的,首先请求消息体的内容是存在流里面的,通过request.getInputStream()方法获取这个流,而流的特性是只能读取一次的,那么如果我们在过滤器里面读过了这个流抓取了消息体内容,那么在我们的控制层,springmvc也要去读取这个流,帮我们加载控制层方法参数时就会出问题了。
第二个坑点的解决方法就是重写request.getInputStream()方法,通过一定手段让这个方法返回的流一直可以读到消息体内容就可以了,这里博主先上代码;
主要有两个类,一个过滤器,一个ServletRequest的装饰类;
/**
* 添加http请求记录日志的过滤器
* 开启定时任务,定时打印日志内容,定时生成日志文件
*
* @author zeng wenbin
* @date Created in 2019/9/29
*/
@WebFilter(urlPatterns = "/*", filterName = "requestLogFilter")
@EnableScheduling
public class RequestLogFilter implements Filter {
/**
* 线程同步List
* 用于做http请求记录缓存,定时将内容往日志中记录一次并清空
*/
private List<String> logs_1 = Collections.synchronizedList(new LinkedList<>());
/**
* 线程同步List
* 用于做http请求记录缓存,定时将内容往日志中记录一次并清空
*/
private List<String> logs_2 = Collections.synchronizedList(new LinkedList<>());
/**
* 用于判断存储数据到集合或写入数据到日志时用logs_1还是logs_2
* 存储数据到list使用logs_1时,写入数据到日志使用logs_2
* 这样设计能提升性能
*/
private Boolean flag = true;
/**
* 日志内容日期格式
*/
private DateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss:SSS");
/**
* 日志文件位置
*/
@Value("${jmtg.data.logPosition:logs/lottery/}")
private String logPosition;
/**
* 打印流,用于输出数据到日志文件
*/
private PrintWriter writer;
/**
* 生产环境暂不使用此功能
*/
@Value("${jmtg.data.isProd:true}")
private Boolean isProd;
private final static Logger log = LoggerFactory.getLogger(RequestLogFilter.class);
/**
* 项目初始化时解析http请求日志位置
* 生成日志文件,默认项目目录下logs/lottery/requestLog.日期.log
* 初始化PrintWriter
*
* @throws IOException
*/
@Override
public void init(FilterConfig filterConfig) {
try {
//存一份日志目录,在最后还原logPosition
String position = logPosition;
//使用java的文件分隔符File.separator来代替分隔符“/”,保证代码在多系统下的兼容性
String[] split = logPosition.split("/");
//生成文件目录,文件名
StringBuilder builder = new StringBuilder();
for (String aSplit : split) {
builder.append(aSplit).append(File.separator);
}
DateFormat format = new SimpleDateFormat("yyyy-MM-dd");
String fileName = "requestLog." + format.format(new Date()) + ".log";
logPosition = builder.append(fileName).toString();
File logFile = new File(logPosition);
if (!logFile.exists()) {
logFile.getParentFile().mkdirs();
logFile.createNewFile();
}
//生成流,内容采用追加的形式
OutputStream outputStream = new FileOutputStream(logFile, true);
writer = new PrintWriter(outputStream);
//还原logPosition
logPosition = position;
} catch (Exception e) {
log.error("获取http请求日志打印流失败!", e);
throw new RuntimeException("获取http请求日志打印流失败!");
}
}
/**
* 过滤器核心方法,请求到来时触发
* 记录请求信息到List集合
* @param servletRequest 请求对象
* @param servletResponse 响应对象
* @param filterChain 过滤器链
* @throws IOException IO异常
* @throws ServletException Servlet异常
*/
@Override
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
HttpServletRequest request = (HttpServletRequest) servletRequest;
StringBuilder builder = new StringBuilder();
builder.append(dateFormat.format(new Date())).append("\t");
//请求方式
builder.append("method:").append(request.getMethod()).append("\t");
//请求路径
builder.append("path:").append(request.getRequestURI()).append("\t");
//获取请求参数
builder.append("pathParam:");
Enumeration<String> parameterNames = request.getParameterNames();
int i = 0;
//路径参数
while (parameterNames.hasMoreElements()) {
if (i == 0) {
builder.append("(");
}
String paramName = parameterNames.nextElement();
builder.append(paramName).append(":").append(request.getParameter(paramName)).append(",");
i++;
}
if (i > 0) {
builder.deleteCharAt(builder.length() - 1);
builder.append(")").append("\t");
}
//生产环境暂不处理消息体参数
if (!isProd) {
//消息体参数
if ("POST".equals(request.getMethod()) || "PUT".equals(request.getMethod())) {
//将servletRequest转换为我们编写的request装饰类 RequestWrapper
RequestWrapper wrapper = new RequestWrapper((HttpServletRequest) servletRequest);
request = wrapper;
builder.append("bodyParam:").append(wrapper.getBody());
}
}
//一个小优化,由于logs_1和2都是线程同步的
//当logs_1的内容正被输出到文件时,采用logs_2来存储请求信息,反之亦然,可以提升效率
if (flag) {
logs_1.add(builder.toString());
} else {
logs_2.add(builder.toString());
}
filterChain.doFilter(request, servletResponse);
}
/**
* 过滤器销毁时关闭打印流
*/
@Override
public void destroy() {
writer.close();
}
/**
* 定时写入http请求日志,默认10秒每次
*
* @throws IOException
*/
@Scheduled(cron = "${jmtg.data.recordTime:*/10 * * * * ?}")
public void record() {
flag = !flag;
if (flag) {
for (String data : logs_2) {
writer.println(data);
}
logs_2.clear();
writer.flush();
} else {
for (String data : logs_1) {
writer.println(data);
}
logs_1.clear();
writer.flush();
}
}
/**
* 默认每3天生成一个新的日志文件
* 向新的文件输入日志记录
*
* @throws IOException IO异常
*/
@Scheduled(cron = "${jmtg.data.updateLogTime:0 0 0 */3 * ?}")
public void updateLog() {
writer.close();
init(null);
}
}
/**
* HttpServletRequest的装饰类
* 编写目的:重写getInputStream方法,使得重复调用getInputStream获取到的输入流都是可以读的
* 编写原因:由于流的特性,流只能被读取一次,
* 而在此项目中,我们可能需要在RequestLogFilter中读取一次流获取消息体的参数,输出到请求日志中
* 而在controller层,springMvc会进行读取第二次,将消息体数据解析为参数。
*
* @author zeng wenbin
* @date Created in 2019/9/29
*/
public class RequestWrapper extends HttpServletRequestWrapper {
/**
* 消息体数据,此项目都是json字符串
*/
private final String body;
/**
* 传入request,获取消息体流,获取流数据转换为字符串赋值给body
*
* @param request HttpServletRequest
*/
public RequestWrapper(HttpServletRequest request) {
super(request);
StringBuilder stringBuilder = new StringBuilder();
BufferedReader bufferedReader = null;
InputStream inputStream = null;
try {
inputStream = request.getInputStream();
if (inputStream != null) {
bufferedReader = new BufferedReader(new InputStreamReader(inputStream));
char[] charBuffer = new char[128];
int bytesRead;
while ((bytesRead = bufferedReader.read(charBuffer)) > 0) {
stringBuilder.append(charBuffer, 0, bytesRead);
}
}
} catch (IOException ex) {
throw new RuntimeException("ServletRequest装饰类初始化失败!");
} finally {
if (inputStream != null) {
try {
inputStream.close();
}
catch (IOException e) {
e.printStackTrace();
}
}
if (bufferedReader != null) {
try {
bufferedReader.close();
}
catch (IOException e) {
e.printStackTrace();
}
}
}
body = stringBuilder.toString();
}
/**
* 重写getInputStream方法
* 将body数据转回字节并生成流返回
*
* @return 数据输入流
*/
@Override
public ServletInputStream getInputStream() {
final ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream(body.getBytes());
ServletInputStream servletInputStream = new ServletInputStream() {
@Override
public boolean isFinished() {
return false;
}
@Override
public boolean isReady() {
return false;
}
@Override
public void setReadListener(ReadListener readListener) {
}
@Override
public int read() throws IOException {
return byteArrayInputStream.read();
}
};
return servletInputStream;
}
/**
* 获取数据输入缓存流
* @return
* @throws IOException
*/
@Override
public BufferedReader getReader() throws IOException {
return new BufferedReader(new InputStreamReader(this.getInputStream()));
}
/**
* 获取流数据
* @return 字符串流行
*/
public String getBody() {
return this.body;
}
}
如上实现的功能就是默认每天在logs/lottery/目录下生成一个requestLog.yyyy-MM-dd.log的文件,文件生成频率每天一次,文件内容写入频率,每10秒一次,对于文件位置,生成频率和写入频率都是可配置的,功能还是比较简单,如有需要,大家可借鉴扩展。
对于上述的第二个坑点,代码里写的很明白了,写一个ServletRequest的装饰类,在构造里传入我们在过滤器里拿到的request,然后取出其输入流,将消息体内容存下来,然后重写getIInputStream()方法,方法里将存下来的消息体内容存到新建的流里面再把这个流return回去,最后在过滤器中,调用filterChain.doFilter方法的时候,request传我们的装饰器类,这样springmvc取到的输入流就可用了。
这里也顺便扩展一下,对于我们传进去自定义的request装饰类不用有任何担心,调用这个对象getInputStream()以外的所有方法最后走的都是我们在doFilter上接收到的request对象的方法,其实我们接收到的request也是被重写过的,也是一个装饰器类,而只要我们的装饰器类的构造里调用了父类构造,即那一句super(request),这就够了,其他的事情,方法怎么调用已经被安排的妥妥当当。
最后由于我们使用到了过滤器,所以在启动类上需要加上@ServletComponentScan注解,否则我们的过滤器是不会被扫描到的。