在本文中,我將介紹個人日誌庫,專門用於Spring Boot RESTful Web
應用程序。關於這個庫的主要設想是:java
logstash-logback-encoder
庫和Logstash
與Elastic Stack
集成RestTemplate``和OpenFeign
,記錄全部可能發生的日誌我想在閱讀了文章的前言後,你可能會問爲何我決定構建一個Spring Boot
已有功能的庫。但問題是它真的具備這些功能?你可能會感到驚訝,由於答案是否認的。雖然可使用一些內置的Spring
組件例如CommonsRequestLoggingFilter
輕鬆地記錄HTTP
請求,可是沒有任何用於記錄響應主體(response body)的開箱即用機制。固然你能夠基於Spring HTTP攔截器(HandlerInterceptorAdapter)或過濾器(OncePerRequestFilter)實現自定義解決方案,但這並無你想的那麼簡單。第二種選擇是使用Zalando Logbook
,它是一個可擴展的Java庫,能夠爲不一樣的客戶端和服務器端技術啓用完整的請求和響應日誌記錄。這是一個很是有趣的庫,專門用於記錄HTTP請求和響應,它提供了許多自定義選項並支持不一樣的客戶端。所以,爲了更高級, 你能夠始終使用此庫。 個人目標是建立一個簡單的庫,它不只記錄請求和響應,還提供自動配置,以便將這些日誌發送到Logstash
並關聯它們。它還會自動生成一些有價值的統計信息,例如請求處理時間。全部這些值都應該發送到Logstash
。咱們繼續往下看。node
從依賴開始吧。咱們須要一些基本的Spring庫,它們包含spring-web
,spring-context
在內,並提供了一些額外的註解。爲了與Logstash
集成,咱們使用logstash-logback-encoder
庫。Slf4j
包含用於日誌記錄的抽象,而javax.servlet-api
用於HTTP通訊。Commons IO
不是必需的,但它提供了一些操做輸入和輸出流的有用方法。git
<properties> <java.version>11</java.version> <commons-io.version>2.6</commons-io.version> <javax-servlet.version>4.0.1</javax-servlet.version> <logstash-logback.version>5.3</logstash-logback.version> <spring.version>5.1.6.RELEASE</spring.version> <slf4j.version>1.7.26</slf4j.version> </properties> <dependencies> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-context</artifactId> <version>${spring.version}</version> </dependency> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-web</artifactId> <version>${spring.version}</version> </dependency> <dependency> <groupId>net.logstash.logback</groupId> <artifactId>logstash-logback-encoder</artifactId> <version>${logstash-logback.version}</version> </dependency> <dependency> <groupId>javax.servlet</groupId> <artifactId>javax.servlet-api</artifactId> <version>${javax-servlet.version}</version> <scope>provided</scope> </dependency> <dependency> <groupId>commons-io</groupId> <artifactId>commons-io</artifactId> <version>${commons-io.version}</version> </dependency> <dependency> <groupId>org.slf4j</groupId> <artifactId>slf4j-api</artifactId> <version>${slf4j.version}</version> </dependency> </dependencies>
第一步是實現HTTP請求和響應包裝器。咱們必須這樣作,由於沒法讀取HTTP流兩次。若是想記錄請求或響應正文,在處理輸入流或將其返回給客戶端以前,首先必須讀取輸入流。Spring提供了HTTP請求和響應包裝器的實現,但因爲未知緣由,它們僅支持某些特定用例,如內容類型application/x-www-form-urlencoded
。由於咱們一般在RESTful應用程序之間的通訊中使用aplication/json
內容類型,因此Spring ContentCachingRequestWrapper
和ContentCachingResponseWrapper
在這沒什麼用。 這是個人HTTP請求包裝器的實現,能夠經過各類方式完成。這只是其中之一:github
public class SpringRequestWrapper extends HttpServletRequestWrapper { private byte[] body; public SpringRequestWrapper(HttpServletRequest request) { super(request); try { body = IOUtils.toByteArray(request.getInputStream()); } catch (IOException ex) { body = new byte[0]; } } @Override public ServletInputStream getInputStream() throws IOException { return new ServletInputStream() { public boolean isFinished() { return false; } public boolean isReady() { return true; } public void setReadListener(ReadListener readListener) { } ByteArrayInputStream byteArray = new ByteArrayInputStream(body); @Override public int read() throws IOException { return byteArray.read(); } }; } }
輸出流必須作一樣的事情,這個實現有點複雜:web
public class SpringResponseWrapper extends HttpServletResponseWrapper { private ServletOutputStream outputStream; private PrintWriter writer; private ServletOutputStreamWrapper copier; public SpringResponseWrapper(HttpServletResponse response) throws IOException { super(response); } @Override public ServletOutputStream getOutputStream() throws IOException { if (writer != null) { throw new IllegalStateException("getWriter() has already been called on this response."); } if (outputStream == null) { outputStream = getResponse().getOutputStream(); copier = new ServletOutputStreamWrapper(outputStream); } return copier; } @Override public PrintWriter getWriter() throws IOException { if (outputStream != null) { throw new IllegalStateException("getOutputStream() has already been called on this response."); } if (writer == null) { copier = new ServletOutputStreamWrapper(getResponse().getOutputStream()); writer = new PrintWriter(new OutputStreamWriter(copier, getResponse().getCharacterEncoding()), true); } return writer; } @Override public void flushBuffer() throws IOException { if (writer != null) { writer.flush(); } else if (outputStream != null) { copier.flush(); } } public byte[] getContentAsByteArray() { if (copier != null) { return copier.getCopy(); } else { return new byte[0]; } } }
我將ServletOutputStream
包裝器實現放到另外一個類中:spring
public class ServletOutputStreamWrapper extends ServletOutputStream { private OutputStream outputStream; private ByteArrayOutputStream copy; public ServletOutputStreamWrapper(OutputStream outputStream) { this.outputStream = outputStream; this.copy = new ByteArrayOutputStream(); } @Override public void write(int b) throws IOException { outputStream.write(b); copy.write(b); } public byte[] getCopy() { return copy.toByteArray(); } @Override public boolean isReady() { return true; } @Override public void setWriteListener(WriteListener writeListener) { } }
由於咱們須要在處理以前包裝HTTP請求流和響應流,因此咱們應該使用HTTP過濾器。Spring提供了本身的HTTP過濾器實現。咱們的過濾器擴展了它,並使用自定義請求和響應包裝來記錄有效負載。此外,它還生成和設置X-Request-ID
,X-Correlation-ID
header和請求處理時間。docker
public class SpringLoggingFilter extends OncePerRequestFilter { private static final Logger LOGGER = LoggerFactory.getLogger(SpringLoggingFilter.class); private UniqueIDGenerator generator; public SpringLoggingFilter(UniqueIDGenerator generator) { this.generator = generator; } protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException { generator.generateAndSetMDC(request); final long startTime = System.currentTimeMillis(); final SpringRequestWrapper wrappedRequest = new SpringRequestWrapper(request); LOGGER.info("Request: method={}, uri={}, payload={}", wrappedRequest.getMethod(), wrappedRequest.getRequestURI(), IOUtils.toString(wrappedRequest.getInputStream(), wrappedRequest.getCharacterEncoding())); final SpringResponseWrapper wrappedResponse = new SpringResponseWrapper(response); wrappedResponse.setHeader("X-Request-ID", MDC.get("X-Request-ID")); wrappedResponse.setHeader("X-Correlation-ID", MDC.get("X-Correlation-ID")); chain.doFilter(wrappedRequest, wrappedResponse); final long duration = System.currentTimeMillis() - startTime; LOGGER.info("Response({} ms): status={}, payload={}", value("X-Response-Time", duration), value("X-Response-Status", wrappedResponse.getStatus()), IOUtils.toString(wrappedResponse.getContentAsByteArray(), wrappedResponse.getCharacterEncoding())); } }
完成包裝器和HTTP過濾器的實現後,咱們能夠爲庫準備自動配置。第一步是建立@Configuration
包含全部必需的bean。咱們必須註冊自定義HTTP過濾器SpringLoggingFilter
,以及用於與Logstash
和RestTemplate
HTTP客戶端攔截器集成的logger appender
:json
@Configuration public class SpringLoggingAutoConfiguration { private static final String LOGSTASH_APPENDER_NAME = "LOGSTASH"; @Value("${spring.logstash.url:localhost:8500}") String url; @Value("${spring.application.name:-}") String name; @Bean public UniqueIDGenerator generator() { return new UniqueIDGenerator(); } @Bean public SpringLoggingFilter loggingFilter() { return new SpringLoggingFilter(generator()); } @Bean public RestTemplate restTemplate() { RestTemplate restTemplate = new RestTemplate(); List<ClientHttpRequestInterceptor> interceptorList = new ArrayList<ClientHttpRequestInterceptor>(); restTemplate.setInterceptors(interceptorList); return restTemplate; } @Bean public LogstashTcpSocketAppender logstashAppender() { LoggerContext loggerContext = (LoggerContext) LoggerFactory.getILoggerFactory(); LogstashTcpSocketAppender logstashTcpSocketAppender = new LogstashTcpSocketAppender(); logstashTcpSocketAppender.setName(LOGSTASH_APPENDER_NAME); logstashTcpSocketAppender.setContext(loggerContext); logstashTcpSocketAppender.addDestination(url); LogstashEncoder encoder = new LogstashEncoder(); encoder.setContext(loggerContext); encoder.setIncludeContext(true); encoder.setCustomFields("{\"appname\":\"" + name + "\"}"); encoder.start(); logstashTcpSocketAppender.setEncoder(encoder); logstashTcpSocketAppender.start(); loggerContext.getLogger(Logger.ROOT_LOGGER_NAME).addAppender(logstashTcpSocketAppender); return logstashTcpSocketAppender; } }
庫中的配置集合由Spring Boot
加載。Spring Boot
會檢查已發佈jar
中是否存在META-INF/spring.factories
文件。該文件應列出key等於EnableAutoConfiguration
的配置類:api
org.springframework.boot.autoconfigure.EnableAutoConfiguration=\ pl.piomin.logging.config.SpringLoggingAutoConfiguration
經過自動配置的日誌記錄追加器(logging appender)實現與Logstash
集成。咱們能夠經過在application.yml
文件中設置屬性spring.logstash.url
來覆蓋Logstash
目標URL:服務器
spring: application: name: sample-app logstash: url: 192.168.99.100:5000
要在應用程序中啓用本文中描述的全部功能,只須要將個人庫包含在依賴項中:
<dependency> <groupId>pl.piomin</groupId> <artifactId>spring-boot-logging</artifactId> <version>1.0-SNAPSHOT</version> </dependency>
在運行應用程序以前,您應該在計算機上啓動Elastic Stack
。最好的方法是經過Docker
容器。但首先要建立Docker
網絡,以經過容器名稱啓用容器之間的通訊。
$ docker network create es
如今,在端口9200啓動Elasticsearch
的單個節點實例,我使用版本爲6.7.2
的Elastic Stack工具:
$ 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設置爲輸出。它還將建立一個包含應用程序名稱的索引。
input { tcp { port => 5000 codec => json } } output { elasticsearch { hosts => ["http://elasticsearch:9200"] index => "micro-%{appname}" } }
如今咱們可使用Docker
容器啓動Logstash
。它在端口5000上公開並從logstash.conf
文件中讀取配置:
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
:
$ 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
頭。
個人Spring logging library
庫能夠在GitHub的https://github.com/piomin/spring-boot-logging.git
中找到。我還在努力,因此很是歡迎任何反饋或建議。該庫專用於基於微服務的體系結構,您的應用程序能夠在容器內的許多實例中啓動。在此模型中,將日誌存儲在文件中沒有任何意義。這就是爲何與Elastic Stack
集成很是重要的緣由。 可是這個庫最重要的特性是將HTTP請求/響應與完整正文和一些附加信息記錄到此日誌中,如相關ID或請求處理時間。庫很是精簡,包含在應用程序以後,全部都是開箱即用的。
原文連接:https://piotrminkowski.wordpress.com/2019/05/07/logging-with-spring-boot-and-elastic-stack/
做者: PiotrMińkowski
譯者:Yunooa
關注公衆號:鍋外的大佬,天天分享國外最新技術文章,幫助開發者更好地成長!