点击左上角,关注:“锅外的大佬”
专注分享国外最新技术内容
帮助每位开发者更优秀地成长
在本文中,我将介绍我的日志库,专门用于 SpringBootRESTfulWeb应用程序。关于这个库的主要设想是:
  • 使用完整正文记录所有传入的HTTP请求和传出的HTTP响应
  • 使用 logstash-logback-encoder库和 Logstash与 ElasticStack集成
  • 对于 RestTemplateOpenFeign,记录所有可能发生的日志
  • 在单个API端点调用中跨所有通信生成和传递关联Id(correlationId)
  • 计算和存储每个请求的执行时间
  • 可自动配置的库——除了引入依赖项之外,不必执行任何操作,就能正常工作

1.简述

我想在阅读了文章的前言后,你可能会问为什么我决定构建一个 SpringBoot已有功能的库。但问题是它真的具有这些功能?你可能会感到惊讶,因为答案是否定的。虽然可以使用一些内置的 Spring组件例如 CommonsRequestLoggingFilter轻松地记录 HTTP请求,但是没有任何用于记录响应主体(response body)的开箱即用机制。
当然你可以基于Spring HTTP拦截器(HandlerInterceptorAdapter)或过滤器(OncePerRequestFilter)实现自定义解决方案,但这并没有你想的那么简单。
第二种选择是使用 ZalandoLogbook,它是一个可扩展的Java库,可以为不同的客户端和服务器端技术启用完整的请求和响应日志记录。这是一个非常有趣的库,专门用于记录HTTP请求和响应,它提供了许多自定义选项并支持不同的客户端。
因此,为了更高级,你可以始终使用此库。我的目标是创建一个简单的库,它不仅记录请求和响应,还提供自动配置,以便将这些日志发送到 Logstash并关联它们。它还会自动生成一些有价值的统计信息,例如请求处理时间。所有这些值都应该发送到 Logstash。我们继续往下看。

2.实现

从依赖开始吧。我们需要一些基本的Spring库,它们包含 spring-web, spring-context在内,并提供了一些额外的注解。为了与 Logstash集成,我们使用 logstash-logback-encoder库。 Slf4j包含用于日志记录的抽象,而 javax.servlet-api用于HTTP通信。 CommonsIO不是必需的,但它提供了一些操作输入和输出流的有用方法。
  1. <properties>
  2. <java.version>11</java.version>
  3. <commons-io.version>2.6</commons-io.version>
  4. <javax-servlet.version>4.0.1</javax-servlet.version>
  5. <logstash-logback.version>5.3</logstash-logback.version>
  6. <spring.version>5.1.6.RELEASE</spring.version>
  7. <slf4j.version>1.7.26</slf4j.version>
  8. </properties>
  9. <dependencies>
  10. <dependency>
  11. <groupId>org.springframework</groupId>
  12. <artifactId>spring-context</artifactId>
  13. <version>${spring.version}</version>
  14. </dependency>
  15. <dependency>
  16. <groupId>org.springframework</groupId>
  17. <artifactId>spring-web</artifactId>
  18. <version>${spring.version}</version>
  19. </dependency>
  20. <dependency>
  21. <groupId>net.logstash.logback</groupId>
  22. <artifactId>logstash-logback-encoder</artifactId>
  23. <version>${logstash-logback.version}</version>
  24. </dependency>
  25. <dependency>
  26. <groupId>javax.servlet</groupId>
  27. <artifactId>javax.servlet-api</artifactId>
  28. <version>${javax-servlet.version}</version>
  29. <scope>provided</scope>
  30. </dependency>
  31. <dependency>
  32. <groupId>commons-io</groupId>
  33. <artifactId>commons-io</artifactId>
  34. <version>${commons-io.version}</version>
  35. </dependency>
  36. <dependency>
  37. <groupId>org.slf4j</groupId>
  38. <artifactId>slf4j-api</artifactId>
  39. <version>${slf4j.version}</version>
  40. </dependency>
  41. </dependencies>
第一步是实现HTTP请求和响应包装器。我们必须这样做,因为无法读取HTTP流两次。如果想记录请求或响应正文,在处理输入流或将其返回给客户端之前,首先必须读取输入流。
Spring提供了HTTP请求和响应包装器的实现,但由于未知原因,它们仅支持某些特定用例,如内容类型 application/x-www-form-urlencoded。因为我们通常在RESTful应用程序之间的通信中使用 aplication/json内容类型,所以Spring ContentCachingRequestWrapper和 ContentCachingResponseWrapper在这没什么用。 
这是我的HTTP请求包装器的实现,可以通过各种方式完成。这只是其中之一:
  1. publicclassSpringRequestWrapperextendsHttpServletRequestWrapper{
  2. privatebyte[] body;
  3. publicSpringRequestWrapper(HttpServletRequest request){
  4. super(request);
  5. try{
  6. body =IOUtils.toByteArray(request.getInputStream());
  7. }catch(IOException ex){
  8. body =newbyte[0];
  9. }
  10. }
  11. @Override
  12. publicServletInputStream getInputStream()throwsIOException{
  13. returnnewServletInputStream(){
  14. publicboolean isFinished(){
  15. returnfalse;
  16. }
  17. publicboolean isReady(){
  18. returntrue;
  19. }
  20. publicvoid setReadListener(ReadListener readListener){
  21. }
  22. ByteArrayInputStream byteArray =newByteArrayInputStream(body);
  23. @Override
  24. publicint read()throwsIOException{
  25. return byteArray.read();
  26. }
  27. };
  28. }
  29. }
输出流必须做同样的事情,这个实现有点复杂:
  1. publicclassSpringResponseWrapperextendsHttpServletResponseWrapper{
  2. privateServletOutputStream outputStream;
  3. privatePrintWriter writer;
  4. privateServletOutputStreamWrapper copier;
  5. publicSpringResponseWrapper(HttpServletResponse response)throwsIOException{
  6. super(response);
  7. }
  8. @Override
  9. publicServletOutputStream getOutputStream()throwsIOException{
  10. if(writer !=null){
  11. thrownewIllegalStateException("getWriter() has already been called on this response.");
  12. }
  13. if(outputStream ==null){
  14. outputStream = getResponse().getOutputStream();
  15. copier =newServletOutputStreamWrapper(outputStream);
  16. }
  17. return copier;
  18. }
  19. @Override
  20. publicPrintWriter getWriter()throwsIOException{
  21. if(outputStream !=null){
  22. thrownewIllegalStateException("getOutputStream() has already been called on this response.");
  23. }
  24. if(writer ==null){
  25. copier =newServletOutputStreamWrapper(getResponse().getOutputStream());
  26. writer =newPrintWriter(newOutputStreamWriter(copier, getResponse().getCharacterEncoding()),true);
  27. }
  28. return writer;
  29. }
  30. @Override
  31. publicvoid flushBuffer()throwsIOException{
  32. if(writer !=null){
  33. writer.flush();
  34. }
  35. elseif(outputStream !=null){
  36. copier.flush();
  37. }
  38. }
  39. publicbyte[] getContentAsByteArray(){
  40. if(copier !=null){
  41. return copier.getCopy();
  42. }
  43. else{
  44. returnnewbyte[0];
  45. }
  46. }
  47. }
我将 ServletOutputStream包装器实现放到另一个类中:
  1. publicclassServletOutputStreamWrapperextendsServletOutputStream{
  2. privateOutputStream outputStream;
  3. privateByteArrayOutputStream copy;
  4. publicServletOutputStreamWrapper(OutputStream outputStream){
  5. this.outputStream = outputStream;
  6. this.copy =newByteArrayOutputStream();
  7. }
  8. @Override
  9. publicvoid write(int b)throwsIOException{
  10. outputStream.write(b);
  11. copy.write(b);
  12. }
  13. publicbyte[] getCopy(){
  14. return copy.toByteArray();
  15. }
  16. @Override
  17. publicboolean isReady(){
  18. returntrue;
  19. }
  20. @Override
  21. publicvoid setWriteListener(WriteListener writeListener){
  22. }
  23. }
因为我们需要在处理之前包装HTTP请求流和响应流,所以我们应该使用HTTP过滤器。Spring提供了自己的HTTP过滤器实现。
我们的过滤器扩展了它,并使用自定义请求和响应包装来记录有效负载。此外,它还生成和设置 X-Request-ID, X-Correlation-ID header和请求处理时间。
  1. publicclassSpringLoggingFilterextendsOncePerRequestFilter{
  2. privatestaticfinalLogger LOGGER =LoggerFactory.getLogger(SpringLoggingFilter.class);
  3. privateUniqueIDGenerator generator;
  4. publicSpringLoggingFilter(UniqueIDGenerator generator){
  5. this.generator = generator;
  6. }
  7. protectedvoid doFilterInternal(HttpServletRequest request,HttpServletResponse response,FilterChain chain)throwsServletException,IOException{
  8. generator.generateAndSetMDC(request);
  9. finallong startTime =System.currentTimeMillis();
  10. finalSpringRequestWrapper wrappedRequest =newSpringRequestWrapper(request);
  11. LOGGER.info("Request: method={}, uri={}, payload={}", wrappedRequest.getMethod(),
  12. wrappedRequest.getRequestURI(),IOUtils.toString(wrappedRequest.getInputStream(),
  13. wrappedRequest.getCharacterEncoding()));
  14. finalSpringResponseWrapper wrappedResponse =newSpringResponseWrapper(response);
  15. wrappedResponse.setHeader("X-Request-ID", MDC.get("X-Request-ID"));
  16. wrappedResponse.setHeader("X-Correlation-ID", MDC.get("X-Correlation-ID"));
  17. chain.doFilter(wrappedRequest, wrappedResponse);
  18. finallong duration =System.currentTimeMillis()- startTime;
  19. LOGGER.info("Response({} ms): status={}, payload={}", value("X-Response-Time", duration),
  20. value("X-Response-Status", wrappedResponse.getStatus()),
  21. IOUtils.toString(wrappedResponse.getContentAsByteArray(), wrappedResponse.getCharacterEncoding()));
  22. }
  23. }

3.自动配置

完成包装器和HTTP过滤器的实现后,我们可以为库准备自动配置。第一步是创建 @Configuration包含所有必需的bean。
我们必须注册自定义HTTP过滤器 SpringLoggingFilter,以及用于与 Logstash和 RestTemplateHTTP客户端拦截器集成的 logger appender
  1. @Configuration
  2. publicclassSpringLoggingAutoConfiguration{
  3. privatestaticfinalString LOGSTASH_APPENDER_NAME ="LOGSTASH";
  4. @Value("${spring.logstash.url:localhost:8500}")
  5. String url;
  6. @Value("${spring.application.name:-}")
  7. String name;
  8. @Bean
  9. publicUniqueIDGenerator generator(){
  10. returnnewUniqueIDGenerator();
  11. }
  12. @Bean
  13. publicSpringLoggingFilter loggingFilter(){
  14. returnnewSpringLoggingFilter(generator());
  15. }
  16. @Bean
  17. publicRestTemplate restTemplate(){
  18. RestTemplate restTemplate =newRestTemplate();
  19. List<ClientHttpRequestInterceptor> interceptorList =newArrayList<ClientHttpRequestInterceptor>();
  20. restTemplate.setInterceptors(interceptorList);
  21. return restTemplate;
  22. }
  23. @Bean
  24. publicLogstashTcpSocketAppender logstashAppender(){
  25. LoggerContext loggerContext =(LoggerContext)LoggerFactory.getILoggerFactory();
  26. LogstashTcpSocketAppender logstashTcpSocketAppender =newLogstashTcpSocketAppender();
  27. logstashTcpSocketAppender.setName(LOGSTASH_APPENDER_NAME);
  28. logstashTcpSocketAppender.setContext(loggerContext);
  29. logstashTcpSocketAppender.addDestination(url);
  30. LogstashEncoder encoder =newLogstashEncoder();
  31. encoder.setContext(loggerContext);
  32. encoder.setIncludeContext(true);
  33. encoder.setCustomFields("{\"appname\":\""+ name +"\"}");
  34. encoder.start();
  35. logstashTcpSocketAppender.setEncoder(encoder);
  36. logstashTcpSocketAppender.start();
  37. loggerContext.getLogger(Logger.ROOT_LOGGER_NAME).addAppender(logstashTcpSocketAppender);
  38. return logstashTcpSocketAppender;
  39. }
  40. }
库中的配置集合由 SpringBoot加载。 SpringBoot会检查已发布 jar中是否存在 META-INF/spring.factories文件。
该文件应列出key等于 EnableAutoConfiguration的配置类:
  1. org.springframework.boot.autoconfigure.EnableAutoConfiguration=\
  2. pl.piomin.logging.config.SpringLoggingAutoConfiguration

4.与Logstash集成

通过自动配置的日志记录追加器(logging appender)实现与 Logstash集成。
我们可以通过在 application.yml文件中设置属性 spring.logstash.url来覆盖 Logstash目标URL:
  1. spring:
  2. application:
  3. name: sample-app
  4. logstash:
  5. url:192.168.99.100:5000
要在应用程序中启用本文中描述的所有功能,只需要将我的库包含在依赖项中:
  1. <dependency>
  2. <groupId>pl.piomin</groupId>
  3. <artifactId>spring-boot-logging</artifactId>
  4. <version>1.0-SNAPSHOT</version>
  5. </dependency>
在运行应用程序之前,您应该在计算机上启动 ElasticStack。最好的方法是通过 Docker容器。但首先要创建 Docker网络,以通过容器名称启用容器之间的通信。
  1. $ docker network create es
现在,在端口9200启动 Elasticsearch的单个节点实例,我使用版本为 6.7.2的Elastic Stack工具:
  1. $ docker run -d --name elasticsearch --net es -p 9200:9200-p 9300:9300-e "discovery.type=single-node" elasticsearch:6.7.2
运行 Logstash时,需要提供包含输入和输出定义的其他配置。我将使用JSON编解码器启动TCP输入,默认情况下不启用。 Elasticsearch URL设置为输出。它还将创建一个包含应用程序名称的索引。
  1. input {
  2. tcp {
  3. port =>5000
  4. codec => json
  5. }
  6. }
  7. output {
  8. elasticsearch {
  9. hosts =>["http://elasticsearch:9200"]
  10. index =>"micro-%{appname}"
  11. }
  12. }
现在我们可以使用 Docker容器启动 Logstash。它在端口5000上公开并从 logstash.conf文件中读取配置:
  1. docker run -d --name logstash --net es -p 5000:5000-v ~/logstash.conf:/usr/share/logstash/pipeline/logstash.conf docker.elastic.co/logstash/logstash:6.7.2
最后,我们可以运行仅用于显示日志的 Kibana:
  1. $ docker run -d --name kibana --net es -e "ELASTICSEARCH_URL=http://elasticsearch:9200"-p 5601:5601 docker.elastic.co/kibana/kibana:6.7.2
启动使用 spring-boot-logging库的示例应用程序后, POST请求中的日志将显示在 Kibana中,如下所示: 
响应日志每个条目都包含 X-Correlation-ID, X-Request-ID, X-Response-Time和 X-Response-Status头。
5.摘要
Springlogging library库可以在我的GitHub地址:https://github.com/piomin/spring-boot-logging.git中找到。我还在努力,所以非常欢迎任何反馈或建议。该库专用于基于微服务的体系结构,您的应用程序可以在容器内的许多实例中启动。
在此模型中,将日志存储在文件中没有任何意义。这就是为什么与 ElasticStack集成非常重要的原因。 但是这个库最重要的特性是将HTTP请求/响应与完整正文和一些附加信息记录到此日志中,如相关ID或请求处理时间。库非常精简,包含在应用程序之后,所有都是开箱即用的。
原文链接:https://piotrminkowski.wordpress.com/2019/05/07/logging-with-spring-boot-and-elastic-stack/
作者: PiotrMińkowski
译者:Yunooa




点击在看,和我一起帮助更多开发者!
继续阅读
阅读原文