譯:Spring Boot & Elastic Stack 記錄日誌

在本文中,我將介紹個人日誌庫,專門用於Spring Boot RESTful Web應用程序。關於這個庫的主要設想是:java

  • 使用完整正文記錄全部傳入的HTTP請求和傳出的HTTP響應
  • 使用logstash-logback-encoder庫和LogstashElastic Stack集成
  • 對於RestTemplate``和OpenFeign,記錄全部可能發生的日誌
  • 在單個API端點調用中跨全部通訊生成和傳遞關聯Id(correlationId)
  • 計算和存儲每一個請求的執行時間
  • 可自動配置的庫——除了引入依賴項以外,沒必要執行任何操做,就能正常工做

1.簡述

我想在閱讀了文章的前言後,你可能會問爲何我決定構建一個Spring Boot已有功能的庫。但問題是它真的具備這些功能?你可能會感到驚訝,由於答案是否認的。雖然可使用一些內置的Spring組件例如CommonsRequestLoggingFilter輕鬆地記錄HTTP請求,可是沒有任何用於記錄響應主體(response body)的開箱即用機制。固然你能夠基於Spring HTTP攔截器(HandlerInterceptorAdapter)或過濾器(OncePerRequestFilter)實現自定義解決方案,但這並無你想的那麼簡單。第二種選擇是使用Zalando Logbook,它是一個可擴展的Java庫,能夠爲不一樣的客戶端和服務器端技術啓用完整的請求和響應日誌記錄。這是一個很是有趣的庫,專門用於記錄HTTP請求和響應,它提供了許多自定義選項並支持不一樣的客戶端。所以,爲了更高級, 你能夠始終使用此庫。 個人目標是建立一個簡單的庫,它不只記錄請求和響應,還提供自動配置,以便將這些日誌發送到Logstash並關聯它們。它還會自動生成一些有價值的統計信息,例如請求處理時間。全部這些值都應該發送到Logstash。咱們繼續往下看。node

2.實現

從依賴開始吧。咱們須要一些基本的Spring庫,它們包含spring-webspring-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 ContentCachingRequestWrapperContentCachingResponseWrapper在這沒什麼用。 這是個人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-IDX-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()));
    }
}

3.自動配置

完成包裝器和HTTP過濾器的實現後,咱們能夠爲庫準備自動配置。第一步是建立@Configuration包含全部必需的bean。咱們必須註冊自定義HTTP過濾器SpringLoggingFilter,以及用於與LogstashRestTemplateHTTP客戶端攔截器集成的logger appenderjson

@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

4.與Logstash集成

經過自動配置的日誌記錄追加器(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-IDX-Request-IDX-Response-TimeX-Response-Status頭。

5.摘要

個人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

關注公衆號:鍋外的大佬,天天分享國外最新技術文章,幫助開發者更好地成長!

相關文章
相關標籤/搜索