SpringCloud Gateway 收集輸入輸出日誌

語言: CN / TW / HK

請求響應日誌是日常開發調試定位問題的重要手段,在微服務中引入SpringCloud Gateway後我們希望在網關層統一進行日誌的收集。

本節內容將實現以下兩個功能:

  1. 獲取請求的輸入輸出參數,封裝成自定義日誌
  2. 將日誌發送到MongoDB進行存儲

獲取輸入輸出參數

  • 首先我們先定義一個日誌體

java @Data public class GatewayLog { /**訪問實例*/ private String targetServer; /**請求路徑*/ private String requestPath; /**請求方法*/ private String requestMethod; /**協議 */ private String schema; /**請求體*/ private String requestBody; /**響應體*/ private String responseData; /**請求ip*/ private String ip; /**請求時間*/ private Date requestTime; /**響應時間*/ private Date responseTime; /**執行時間*/ private long executeTime; }

  • 關鍵】在網關定義日誌過濾器,獲取輸入輸出參數

```java /* * 日誌過濾器,用於記錄日誌 * @author jianzh5 * @date 2020/3/24 17:17 / @Slf4j @Component public class AccessLogFilter implements GlobalFilter, Ordered { @Autowired private AccessLogService accessLogService;

private final List<HttpMessageReader<?>> messageReaders = HandlerStrategies.withDefaults().messageReaders();


/**
 * 順序必須是<-1,否則標準的NettyWriteResponseFilter將在您的過濾器得到一個被調用的機會之前發送響應
 * 也就是説如果不小於 -1 ,將不會執行獲取後端響應的邏輯
 * @return
 */
@Override
public int getOrder() {
    return -100;
}

@Override
@SuppressWarnings("unchecked")
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {

    ServerHttpRequest request = exchange.getRequest();

    // 請求路徑
    String requestPath = request.getPath().pathWithinApplication().value();

    Route route = getGatewayRoute(exchange);


    String ipAddress = WebUtils.getServerHttpRequestIpAddress(request);

    GatewayLog gatewayLog = new GatewayLog();
    gatewayLog.setSchema(request.getURI().getScheme());
    gatewayLog.setRequestMethod(request.getMethodValue());
    gatewayLog.setRequestPath(requestPath);
    gatewayLog.setTargetServer(route.getId());
    gatewayLog.setRequestTime(new Date());
    gatewayLog.setIp(ipAddress);

    MediaType mediaType = request.getHeaders().getContentType();

    if(MediaType.APPLICATION_FORM_URLENCODED.isCompatibleWith(mediaType) || MediaType.APPLICATION_JSON.isCompatibleWith(mediaType)){
        return writeBodyLog(exchange, chain, gatewayLog);
    }else{
        return writeBasicLog(exchange, chain, gatewayLog);
    }
}

private Mono<Void> writeBasicLog(ServerWebExchange exchange, GatewayFilterChain chain, GatewayLog accessLog) {
    StringBuilder builder = new StringBuilder();
    MultiValueMap<String, String> queryParams = exchange.getRequest().getQueryParams();
    for (Map.Entry<String, List<String>> entry : queryParams.entrySet()) {
        builder.append(entry.getKey()).append("=").append(StringUtils.join(entry.getValue(), ","));
    }
    accessLog.setRequestBody(builder.toString());

    //獲取響應體
    ServerHttpResponseDecorator decoratedResponse = recordResponseLog(exchange, accessLog);

    return chain.filter(exchange.mutate().response(decoratedResponse).build())
            .then(Mono.fromRunnable(() -> {
                // 打印日誌
                writeAccessLog(accessLog);
            }));
}


/**
 * 解決 request body 只能讀取一次問題,
 * 參考: org.springframework.cloud.gateway.filter.factory.rewrite.ModifyRequestBodyGatewayFilterFactory
 * @param exchange
 * @param chain
 * @param gatewayLog
 * @return
 */
@SuppressWarnings("unchecked")
private Mono writeBodyLog(ServerWebExchange exchange, GatewayFilterChain chain, GatewayLog gatewayLog) {
    ServerRequest serverRequest = ServerRequest.create(exchange,messageReaders);

    Mono<String> modifiedBody = serverRequest.bodyToMono(String.class)
            .flatMap(body ->{
                gatewayLog.setRequestBody(body);
                return Mono.just(body);
            });

    // 通過 BodyInserter 插入 body(支持修改body), 避免 request body 只能獲取一次
    BodyInserter bodyInserter = BodyInserters.fromPublisher(modifiedBody, String.class);
    HttpHeaders headers = new HttpHeaders();
    headers.putAll(exchange.getRequest().getHeaders());
    // the new content type will be computed by bodyInserter
    // and then set in the request decorator
    headers.remove(HttpHeaders.CONTENT_LENGTH);

    CachedBodyOutputMessage outputMessage = new CachedBodyOutputMessage(exchange, headers);

    return bodyInserter.insert(outputMessage,new BodyInserterContext())
            .then(Mono.defer(() -> {
                // 重新封裝請求
                ServerHttpRequest decoratedRequest = requestDecorate(exchange, headers, outputMessage);

                // 記錄響應日誌
                ServerHttpResponseDecorator decoratedResponse = recordResponseLog(exchange, gatewayLog);

                // 記錄普通的
                return chain.filter(exchange.mutate().request(decoratedRequest).response(decoratedResponse).build())
                        .then(Mono.fromRunnable(() -> {
                            // 打印日誌
                            writeAccessLog(gatewayLog);
                        }));
            }));
}

/**
 * 打印日誌
 * @author javadaily
 * @date 2021/3/24 14:53
 * @param gatewayLog 網關日誌
 */
private void writeAccessLog(GatewayLog gatewayLog) {
    log.info(gatewayLog.toString());      
}



private Route getGatewayRoute(ServerWebExchange exchange) {
    return exchange.getAttribute(ServerWebExchangeUtils.GATEWAY_ROUTE_ATTR);
}


/**
 * 請求裝飾器,重新計算 headers
 * @param exchange
 * @param headers
 * @param outputMessage
 * @return
 */
private ServerHttpRequestDecorator requestDecorate(ServerWebExchange exchange, HttpHeaders headers,
                                                   CachedBodyOutputMessage outputMessage) {
    return new ServerHttpRequestDecorator(exchange.getRequest()) {
        @Override
        public HttpHeaders getHeaders() {
            long contentLength = headers.getContentLength();
            HttpHeaders httpHeaders = new HttpHeaders();
            httpHeaders.putAll(super.getHeaders());
            if (contentLength > 0) {
                httpHeaders.setContentLength(contentLength);
            } else {
                // TODO: this causes a 'HTTP/1.1 411 Length Required' // on
                // httpbin.org
                httpHeaders.set(HttpHeaders.TRANSFER_ENCODING, "chunked");
            }
            return httpHeaders;
        }

        @Override
        public Flux<DataBuffer> getBody() {
            return outputMessage.getBody();
        }
    };
}


/**
 * 記錄響應日誌
 * 通過 DataBufferFactory 解決響應體分段傳輸問題。
 */
private ServerHttpResponseDecorator recordResponseLog(ServerWebExchange exchange, GatewayLog gatewayLog) {
    ServerHttpResponse response = exchange.getResponse();
    DataBufferFactory bufferFactory = response.bufferFactory();

    return new ServerHttpResponseDecorator(response) {
        @Override
        public Mono<Void> writeWith(Publisher<? extends DataBuffer> body) {
            if (body instanceof Flux) {
                Date responseTime = new Date();
                gatewayLog.setResponseTime(responseTime);
                // 計算執行時間
                long executeTime = (responseTime.getTime() - gatewayLog.getRequestTime().getTime());

                gatewayLog.setExecuteTime(executeTime);

                // 獲取響應類型,如果是 json 就打印
                String originalResponseContentType = exchange.getAttribute(ServerWebExchangeUtils.ORIGINAL_RESPONSE_CONTENT_TYPE_ATTR);


                if (ObjectUtil.equal(this.getStatusCode(), HttpStatus.OK)
                        && StringUtil.isNotBlank(originalResponseContentType)
                        && originalResponseContentType.contains("application/json")) {

                    Flux<? extends DataBuffer> fluxBody = Flux.from(body);
                    return super.writeWith(fluxBody.buffer().map(dataBuffers -> {

                        // 合併多個流集合,解決返回體分段傳輸
                        DataBufferFactory dataBufferFactory = new DefaultDataBufferFactory();
                        DataBuffer join = dataBufferFactory.join(dataBuffers);
                        byte[] content = new byte[join.readableByteCount()];
                        join.read(content);

                        // 釋放掉內存
                        DataBufferUtils.release(join);
                        String responseResult = new String(content, StandardCharsets.UTF_8);



                        gatewayLog.setResponseData(responseResult);

                        return bufferFactory.wrap(content);
                    }));
                }
            }
            // if body is not a flux. never got there.
            return super.writeWith(body);
        }
    };
}

} ```

代碼較長建議直接拷貝到編輯器,注意下面一個關鍵點:

getOrder()方法返回的值必須要<-1,否則標準的NettyWriteResponseFilter將在您的過濾器被調用的機會之前發送響應,即不會執行獲取後端響應參數的方法

通過上面的兩步我們已經可以獲取到請求的輸入輸出參數了,在writeAccessLog()中將其輸出到了日誌文件,大家可以在Postman發送請求觀察日誌。

存儲日誌

如果需要將日誌持久化方便後期檢索的話可以考慮將日誌存儲在MongoDB中,實現過程很簡單。(安裝MongoDB可以參考這篇文章:實戰|MongoDB的安裝配置

  • 引入MongoDB

xml <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-mongodb-reactive</artifactId> </dependency>

由於gateway是基於webflux,所以我們需要選擇reactive版本。

  • 在GatewayLog上添加對應的註解

java @Data @Document public class GatewayLog { @Id private String id; ... }

  • 建立AccessLogRepository

```java @Repository public interface AccessLogRepository extends ReactiveMongoRepository {

} ```

  • 建立Service

```java public interface AccessLogService {

/**
 * 保存AccessLog
 * @param gatewayLog 請求響應日誌
 * @return 響應日誌
 */
Mono<GatewayLog> saveAccessLog(GatewayLog gatewayLog);

} ```

  • 建立實現類

```java @Service public class AccessLogServiceImpl implements AccessLogService { @Autowired private AccessLogRepository accessLogRepository;

@Override
public Mono<GatewayLog> saveAccessLog(GatewayLog gatewayLog) {
    return accessLogRepository.insert(gatewayLog);
}

} ```

  • 在Nacos配置中心添加MongoDB對應配置

yml spring: data: mongodb: host: xxx.xx.x.xx port: 27017 database: accesslog username: accesslog password: xxxxxx

  • 執行請求,打開MongoDB客户端,查看日誌結果

image.png

tips : SpringCloud alibaba 微服務實戰系列源碼已經上傳至GitHub,需要的關注本公眾號 JAVA日知錄 並回復關鍵字 2214 獲取源碼地址。