前言
在日常的项目开发过程中,后端开发人员应该主动去关心自己的接口性能。这种关心需要量化,而量化的直接方式就是对接口的响应时间进行监控,以了解系统性能,帮助判断性能瓶颈。本文基于已有的全链路日志系统进一步补充了接口耗时的方案。已有的全链路日志系统是围绕ELK+Jaeger构建起来的,在Spring Cloud微服务架构中,可以实现跨服务的请求日志追踪 ,帮助我们进行线上问题排查。
服务告警部分则是通过Frostmourne平台来实现了,该平台可以接入Elasticsearch,配置相关的项目监控与告警。当监控到接口超时以后,可以通过接口超时日志中的traceId,在Jaeger平台上查看整个请求链路的耗时分布,快速明确问题发生的位置,提升问题发现与响应的速度。
实现
基本介绍
统计接口的耗时情况属于一个可以复用的功能点,因此这里直接使用 SpringMVC的HandlerInterceptor拦截器来实现,后续抽取成一个公共组件,方便复用。
拦截器接口 HandlerInterceptor 提供了三个方法来实现对请求前、请求后,响应后进行自定义处理,并且拦截器的前置处理和后置处理是具体关联性的。
- preHandle() :在 Controller 方法执行之前执行。即在 HandlerMapping 确定适当的处理程序对象之后调用,但在HandlerAdapter 调用处理程序之前调用。
- postHandle() :在 Controller 方法执行之后执行。即在 HandlerAdapter 实际调用处理程序之后,但在DispatcherServlet 呈现视图之前调用。
- afterCompletion() :完成请求处理后(即渲染视图之后)的回调。 将在处理程序执行的任何结果上被调用,从而允许适当的资源清理。
实现思路
要统计接口处理请求的时长,可以在拦截器的 preHandle() 方法记录请求开始时间(startTime),在 afterCompletion() 方法中记录请求处理完后的结束时间(endTime),请求处理时间(响应时间) = 结束时间 - 开始时间。
实现过程
- 定义一个拦截器
import lombok.extern.slf4j.Slf4j;
import org.slf4j.MDC;
import org.springframework.core.NamedThreadLocal;
import org.springframework.lang.Nullable;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.servlet.HandlerInterceptor;
import org.springframework.web.servlet.ModelAndView;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
/**
* 拦截器,统计接口耗时
*/
@Slf4j
public class TimeConsumingInterceptor implements HandlerInterceptor {
public static final String APPLICATION_JSON = "application/json";
private NamedThreadLocal<Long> threadLocal = new NamedThreadLocal<>("StopWatch_StartTime");
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// 记录请求开始时间
threadLocal.set(System.currentTimeMillis());
return true;
}
@Override
public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, @Nullable ModelAndView modelAndView) throws Exception {
// no need to override
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, @Nullable Exception ex) throws Exception {
// 请求结束时间
Long endTime = System.currentTimeMillis();
try {
// 从HttpServletRequest获取开始时间
Long startTime = threadLocal.get();
String fullUrl = getFullUrl(request);
String paremeter = getParemeter(request);
Long cost = endTime - startTime;
MDC.put("costTime", cost.toString());
MDC.put("url", fullUrl);
// 打印请求
log.info("request parameter: {}", paremeter);
} catch (Exception e) {
log.error("fail to calculate time cost", e);
} finally {
threadLocal.remove();
MDC.remove("costTime");
MDC.remove("url");
}
}
/**
* 获取完整的URL路径
*
* @param request 请求对象
* @return 完整的URL路径
*/
private String getFullUrl(HttpServletRequest request) {
StringBuilder sb = new StringBuilder();
String method = request.getMethod();
sb.append(method).append(" ").append(request.getRequestURL().toString());
return sb.toString();
}
/**
* 获取请求参数
*
* @param request 请求对象
* @return 完整的URL路径
*/
private String getParemeter(HttpServletRequest request) {
try {
if (RequestMethod.POST.name().equals(request.getMethod())
&& APPLICATION_JSON.equals(request.getContentType())) {
RequestWrapper requestWrapper = new RequestWrapper(request);
return requestWrapper.getBodyString().replaceAll("\s+"", """);
}
} catch (Exception e) {
log.error("fail to get paramter", e);
}
return "";
}
}
- 配置拦截器使其生效
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Slf4j
@Configuration
public class WebMvcConfig implements WebMvcConfigurer {
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new TimeConsumingInterceptor())
// 需拦截的URI配置
.addPathPatterns("/**")
// 不需拦截的URI配置
.excludePathPatterns("/swagger/**", "/static/**", "/resource/**");
log.info("***************** ADD TIME CONSUMING INTERCEPTOR ******************");
}
}
- 添加logback配置,在开发和测试环境由于流量小,可以通过TCP监听的方式直接将接口的耗时日志传输至logstash,生产环境最好还是通过filebeat监听日志文件的方式去实现。
<?xml version="1.0" encoding="UTF-8"?>
<configuration>
<contextName>log</contextName>
<property name="logback.logDir" value="${LOG_PATH}"/>
<springProperty name="logback.appName" scope="context" source="spring.application.name"/>
<springProperty name="logback.elastic" scope="context" source="logback.elastic"/>
<springProperty name="env" scope="context" source="spring.profiles.active"/>
<springProperty name="serverIP" scope="context" source="spring.cloud.client.ip-address" defaultValue="0.0.0.0"/>
<property name="commonLayoutPattern"
value="[${serverIP}] %d{${LOG_DATEFORMAT_PATTERN:-yyyy-MM-dd HH:mm:ss.SSS}} [%mdc{trace_id:-N/A}] ${LOG_LEVEL_PATTERN:-%p} ${PID:- } --- [%t] %logger{39}.%method[%line] : %m%n"/>
<appender name="consoleLog" class="ch.qos.logback.core.ConsoleAppender">
<!--展示格式 layout -->
<layout class="ch.qos.logback.classic.PatternLayout">
<pattern>${commonLayoutPattern}</pattern>
</layout>
</appender>
<appender name="logStash" class="net.logstash.logback.appender.LogstashTcpSocketAppender">
<!--可以访问的logstash日志收集端口-->
<destination>192.168.xxx.xxx:4560</destination>
<encoder class="net.logstash.logback.encoder.LoggingEventCompositeJsonEncoder">
<providers>
<timestamp>
<timeZone>Asia/Shanghai</timeZone>
</timestamp>
<pattern>
<pattern>
{
"appName": "${logback.appName}-${env}",
"serverIP": "${serverIP}",
"traceId": "%mdc{trace_id:-N/A}",
"costTime": "%mdc{costTime:-0}",
"url": "%mdc{url:-N/A}",
"message": "%message",
"exception": "%ex{15}"
}
</pattern>
</pattern>
</providers>
</encoder>
</appender>
<springProfile name="dev,pre">
<logger name="com.xxx.xxx.log.autoconfigure.TimeConsumingInterceptor" additivity="false">
<appender-ref ref="logStash"/>
<appender-ref ref="consoleLog"/>
<appender-ref ref="fileRequestLog"/>
</logger>
</springProfile>
<springProfile name="prod">
<logger name="com.xxx.xxx.log.autoconfigure.TimeConsumingInterceptor" additivity="false">
<appender-ref ref="fileRequestLog"/>
</logger>
</springProfile>
<appender name="asyncRequestLog" class="ch.qos.logback.classic.AsyncAppender">
<discardingThreshold>0</discardingThreshold>
<queueSize>1024</queueSize>
<appender-ref ref="fileRequestLog"/>
</appender>
</configuration>
- 配置开发、测试环境的logstash传输耗时日志
input {
tcp {
host => "192.168.xxx.xxx"
port => 4560
codec => json_lines
}
}
filter {
mutate {
convert => {
"costTime" => "integer"
}
}
}
output {
elasticsearch {
hosts => ["http://192.168.xxx.xxx:9200"]
index => "request-%{[appName]}-%{+YYYY.MM.dd}"
}
}
- 查看耗时结果
- 配置耗时监控与结果验证
补充
在调试过程中发现无法获取接口的请求参数,原因在于请求流只能操作一次,会导致后续无法再获取流了。解决的方案是通过过滤器获取参数然后传到后面程序中。
- 定义过滤器
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import javax.servlet.*;
import javax.servlet.annotation.WebFilter;
import javax.servlet.http.HttpServletRequest;
import java.io.IOException;
@Slf4j
@Component
@WebFilter(urlPatterns = "/*", filterName = "channelFilter")
public class ChannelFilter implements Filter {
@Override
public void init(FilterConfig filterConfig) {
// no need to override
}
@Override
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
log.debug("================进入过滤器==================");
HttpServletRequest httpServletRequest = (HttpServletRequest) servletRequest;
RequestWrapper requestWrapper = new RequestWrapper(httpServletRequest);
filterChain.doFilter(requestWrapper, servletResponse);
}
@Override
public void destroy() {
// no need to override
}
}
- 添加请求参数处理逻辑
import lombok.extern.slf4j.Slf4j;
import javax.servlet.ReadListener;
import javax.servlet.ServletInputStream;
import javax.servlet.ServletRequest;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletRequestWrapper;
import java.io.*;
import java.nio.charset.StandardCharsets;
@Slf4j
public class RequestWrapper extends HttpServletRequestWrapper {
private final byte[] body;
public RequestWrapper(HttpServletRequest request) {
super(request);
String sessionStream = getBodyString(request);
body = sessionStream.getBytes(StandardCharsets.UTF_8);
}
public String getBodyString() {
return new String(body, StandardCharsets.UTF_8);
}
public String getBodyString(final ServletRequest request) {
StringBuilder sb = new StringBuilder();
BufferedReader reader = null;
try (InputStream inputStream = cloneInputStream(request.getInputStream())) {
reader = new BufferedReader(new InputStreamReader(inputStream, StandardCharsets.UTF_8));
String line = "";
while ((line = reader.readLine()) != null) {
sb.append(line);
}
} catch (IOException e) {
log.error("fail to get request body", e);
} finally {
if (reader != null) {
try {
reader.close();
} catch (IOException e) {
log.error("fail to close stream reader", e);
}
}
}
log.debug("获取body请求参数:" + sb);
return sb.toString();
}
public InputStream cloneInputStream(ServletInputStream inputStream) {
ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream();
byte[] buffer = new byte[1024];
int len;
try {
while ((len = inputStream.read(buffer)) > -1) {
byteArrayOutputStream.write(buffer, 0, len);
}
byteArrayOutputStream.flush();
} catch (IOException e) {
log.error("fail to clone input stream", e);
}
return new ByteArrayInputStream(byteArrayOutputStream.toByteArray());
}
@Override
public BufferedReader getReader() {
return new BufferedReader(new InputStreamReader(getInputStream()));
}
@Override
public ServletInputStream getInputStream() {
final ByteArrayInputStream bais = new ByteArrayInputStream(body);
return new ServletInputStream() {
@Override
public int read() {
return bais.read();
}
@Override
public boolean isFinished() {
return false;
}
@Override
public boolean isReady() {
return false;
}
@Override
public void setReadListener(ReadListener readListener) {
// no need to override
}
};
}
}