如何记录Spring 5 WebClient调用

97

我正在尝试使用Spring 5 WebClient记录请求。您知道我该如何实现吗?

(我正在使用Spring 5和Spring Boot 2)

目前代码如下:

try {
    return webClient.get().uri(url, urlParams).exchange().flatMap(response -> response.bodyToMono(Test.class))
            .map(test -> xxx.set(test));
} catch (RestClientException e) {
    log.error("Cannot get counter from opus", e);
    throw e;
}
19个回答

66
在Spring Boot 2.4.0中,HttpClient的wiretap()方法有额外的参数可以传递,以便以正常易读的格式显示完整的请求/响应标头和主体。请使用格式(AdvancedByteBufFormat.TEXTUAL)。
结果:

在Spring Boot 2.4.0中,HttpClient的wiretap()方法有额外的参数可以传递,以便以正常易读的格式显示完整的请求/响应标头和主体。请使用格式(AdvancedByteBufFormat.TEXTUAL)。

HttpClient httpClient = HttpClient.create()
      .wiretap(this.getClass().getCanonicalName(), LogLevel.DEBUG, AdvancedByteBufFormat.TEXTUAL);
ClientHttpConnector conn = new ReactorClientHttpConnector(httpClient);   

WebClient client =  WebClient.builder()
            .clientConnector(conn)
            .build();

结果:

POST /score HTTP/1.1
Host: localhost:8080
User-Agent: insomnia/2020.5.2
Content-Type: application/json
access_: 
Authorization: Bearer eyJ0e....
Accept: application/json
content-length: 4506

WRITE: 4506B {"body":{"invocations":[{"id":....


READ: 2048B HTTP/1.0 200 OK
Content-Type: application/json
Content-Length: 2271
Server: Werkzeug/1.0.1 Python/3.7.7
Date: Fri, 29 Jan 2021 18:49:53 GMT

{"body":{"results":[.....

6
我们有一个新的获胜者!这个简单易行,符合预期,谢谢! - Michael R
@PatPatPat 这个值将被传递给 SLF4J,并在生成带有您的跟踪的日志行时用作包名称。因此,从技术上讲,任何字符串都可以放在那里,但最好还是放 this.getClass().getCanonicalName()。 - Vladimir
2
这就是这个问题的答案。 - Moritz Kampfinger
1
在HTTP客户端中,我只能看到一个名为wiretap(boolean)的选项来进行窃听方法。无法看到三个参数的方法。有任何想法吗? - Satish Patro
1
这个答案为我生成了窃听日志。然而,所有的值都输出为<filtered>。例如,“Host:<filtered>”。如何显示除<filtered>之外的值? - Jeff Miller
显示剩余5条评论

63

您可以使用ExchangeFilterFunction轻松完成此操作。

只需在使用WebClient.Builder创建WebClient时添加自定义的logRequest过滤器即可。

以下是这种过滤器的示例以及如何将其添加到WebClient中。

@Slf4j
@Component
public class MyClient {

    private final WebClient webClient;

    // Create WebClient instance using builder.
    // If you use spring-boot 2.0, the builder will be autoconfigured for you
    // with the "prototype" scope, meaning each injection point will receive
    // a newly cloned instance of the builder.
    public MyClient(WebClient.Builder webClientBuilder) {
        webClient = webClientBuilder // you can also just use WebClient.builder()
                .baseUrl("https://httpbin.org")
                .filter(logRequest()) // here is the magic
                .build();
    }

    // Just example of sending request. This method is NOT part of the answer
    public void send(String path) {
        ClientResponse clientResponse = webClient
                .get().uri(uriBuilder -> uriBuilder.path(path)
                        .queryParam("param", "value")
                        .build())
                .exchange()
                .block();
        log.info("Response: {}", clientResponse.toEntity(String.class).block());
    }

    // This method returns filter function which will log request data
    private static ExchangeFilterFunction logRequest() {
        return ExchangeFilterFunction.ofRequestProcessor(clientRequest -> {
            log.info("Request: {} {}", clientRequest.method(), clientRequest.url());
            clientRequest.headers().forEach((name, values) -> values.forEach(value -> log.info("{}={}", name, value)));
            return Mono.just(clientRequest);
        });
    }

}

然后只需调用myClient.send("get");,日志消息就会出现。

输出示例:

Request: GET https://httpbin.org/get?param=value
header1=value1
header2=value2

编辑

有些人在评论中指出,block() 是不好的实践等。我想澄清一下:这里调用 block() 只是为了演示目的。请求日志过滤器仍将工作。您不需要添加 block() 到您的代码中来使 ExchangeFilterFunction 工作。您可以使用 WebClient 以通常的方式执行 http 调用,链接方法并将 Mono 返回到堆栈,直到有人订阅它。答案中唯一相关的部分是 logRequest() 过滤器。您可以完全忽略 send() 方法-它不是解决方案的一部分-它只是证明过滤器有效。

有些人还问如何记录响应。要记录响应,您可以编写另一个 ExchangeFilterFunction 并将其添加到 WebClient。你可以像使用 ExchangeFilterFunction.ofRequestProcessor 一样使用 ExchangeFilterFunction.ofResponseProcessor 辅助程序来实现这个目的。您可以使用 ClientResponse 的方法来获取标题/cookie 等信息。

    // This method returns filter function which will log response data
    private static ExchangeFilterFunction logResponse() {
        return ExchangeFilterFunction.ofResponseProcessor(clientResponse -> {
            log.info("Response status: {}", clientResponse.statusCode());
            clientResponse.headers().asHttpHeaders().forEach((name, values) -> values.forEach(value -> log.info("{}={}", name, value)));
            return Mono.just(clientResponse);
        });
    }

别忘了把它添加到你的 WebClient 中:

.filter(logResponse())

但是要小心,不要在过滤器中尝试读取响应正文。由于其流的性质,如果没有某种缓冲包装器,正文只能被消耗一次。因此,如果您在过滤器中读取它,您将无法在订阅者中读取它。

如果您确实需要记录正文,可以让底层层(Netty)来处理。请参见Matthew Buckett的答案以获取思路。


3
在这里,block()调用只是为了演示目的。请求日志记录过滤器仍将正常工作。要记录响应,您可以编写另一个ExchangeFilterFunction并记录响应。但是请注意,在记录响应正文时需要小心 - 由于其流的性质,只能在不包装的情况下消耗一次。 - Ruslan Stelmachenko
57
如何从 clientRequest 中获取请求体? - Gaurav Singh
5
简短回答:你无法做到。一旦你读取正文(以记录日志),消费者就无法再访问它了。我认为可以将正文流包装成一些缓冲流来实现,但是老实说我从未尝试过。而且这会占用内存,使得响应式编程有点毫无意义。如果你真的需要记录正文,可以让底层层(Netty)来实现。参见Matthew Buckett的回答以获取思路。 - Ruslan Stelmachenko
1
我认为这个解决方案不切实际,因为会阻塞。 - al gh
1
我们如何记录响应以及请求? - Onur Baskirt
显示剩余4条评论

62

您可以要求Netty进行窃听,以记录请求/响应的日志。如果您像这样创建Spring WebClient,则会启用窃听选项。

        WebClient webClient = WebClient.builder()
            .clientConnector(new ReactorClientHttpConnector(
                HttpClient.create().wiretap(true)
            ))
            .build()

然后设置日志记录:

logging.level.reactor.netty.http.client.HttpClient: DEBUG

这将记录请求/响应的所有内容(包括正文),但格式不特定于HTTP,因此不太易读。


3
非常感谢!虽然格式不太易读,但这是我目前发现的唯一能看到实际请求和响应正文的方法。 - BetaRide
9
你仍然可以使用wiretap(HTTP_CLIENT, LogLevel.DEBUG, AdvancedByteBufFormat.TEXTUAL),而不是使用wiretap(true)。 - Piotr Supel

31

您并不一定需要自己编写记录器,reactor.ipc.netty.channel.ChannelOperationsHandler已经为您完成此任务。只需将该类的日志配置为DEBUG级别即可:

2017-11-23 12:52:04.562 DEBUG 41449 --- [ctor-http-nio-5] r.i.n.channel.ChannelOperationsHandler   : [id: 0x9183d6da, L:/127.0.0.1:57681 - R:localhost/127.0.0.1:8000] Writing object DefaultFullHttpRequest(decodeResult: success, version: HTTP/1.1, content: UnpooledByteBufAllocator$InstrumentedUnpooledUnsafeHeapByteBuf(ridx: 0, widx: 0, cap: 0))
GET /api/v1/watch/namespaces/default/events HTTP/1.1
user-agent: ReactorNetty/0.7.1.RELEASE
host: localhost:8000
accept-encoding: gzip
Accept: application/json
content-length: 0

有更少漏洞的一种方法是尽可能减少编写代码的次数。

2018年11月:

使用 spring-webflux:5.1.2.RELEASE ,上述方法不再适用。请改用以下方法:


logging.level.org.springframework.web.reactive.function.client.ExchangeFunctions=DEBUG
...
2018-11-06 20:58:58.181 DEBUG 20300 --- [           main] o.s.w.r.f.client.ExchangeFunctions       : [2026fbff] HTTP GET http://localhost:8080/stocks/search?symbol=AAPL
2018-11-06 20:58:58.451 DEBUG 20300 --- [ctor-http-nio-4] o.s.w.r.f.client.ExchangeFunctions       : [2026fbff] Response 400 BAD_REQUEST
为了记录标头或表单数据,请将上述内容设置为TRACE级别;但是,仅如此还不够:
ExchangeStrategies exchangeStrategies = ExchangeStrategies.withDefaults();
exchangeStrategies
    .messageWriters().stream()
    .filter(LoggingCodecSupport.class::isInstance)
    .forEach(writer -> ((LoggingCodecSupport)writer).setEnableLoggingRequestDetails(true));

client = WebClient.builder()
    .exchangeStrategies(exchangeStrategies)

2019年3月:

回复评论中关于如何记录请求和响应主体的问题,我不知道Spring是否有这样的日志记录器,但WebClient是建立在Netty之上的,因此启用reactor.ipc.netty包的调试日志记录应该可以解决问题,同时查看此答案


1
是否可以记录响应呢? - Michael Piefel
@AbhijitSarkar LoggingHandler应该如何添加到管道中?对于Netty/Reactor新手来说,参考答案并不足够。 - Andras Hatvani
你应该能够通过将“spring.codec.log-request-details”设置为true来避免修改ExchangeStrategies。 - Michael Böckling
我目前正在使用Spring Boot 2.5.2。 - Michael Böckling
我需要记录标题(用于开发目的),这个答案有效! - denu
显示剩余5条评论

30

如果您不想记录请求体,则这很容易。

Spring Boot >= 2.1.0

在application.properties中添加以下内容:

logging.level.org.springframework.web.reactive.function.client.ExchangeFunctions=TRACE
spring.http.log-request-details=true

第二行会导致日志中包含头信息。

Spring Boot < 2.1.0

在 application.properties 文件中添加以下内容:

logging.level.org.springframework.web.reactive.function.client.ExchangeFunctions=TRACE

你需要声明一个类,而不是使用上面的第二行代码:

@Configuration
static class LoggingCodecConfig {

    @Bean
    @Order(0)
    public CodecCustomizer loggingCodecCustomizer() {
        return (configurer) -> configurer.defaultCodecs()
                .enableLoggingRequestDetails(true);
    }

}

感谢 Brian Clozel的回答


1
我喜欢Spring Boot >= 2.1.0的版本,但在我的情况下它只记录“headers={masked}”。有什么想法为什么会这样? - Tobske
4
好的,有时候我应该更长时间地搜索解决方案:https://docs.spring.io/spring/docs/current/spring-framework-reference/web-reactive.html#webflux-logging-sensitive-data。这解释了为什么它会打印掩码信息以及如何禁用它。 - Tobske
@Tobske,来自>=2.1.0部分的第二个链接会处理掩码标头,你不需要进行任何进一步的更改。至少这是我的经验。 - Fletch
8
spring.http.log-request-details=true已经被弃用,建议使用spring.mvc.log-request-details=true。意思是两个配置都可以记录HTTP请求的详细信息,但后者更符合当前的做法。 - Abdelghani Roussi
我无法看到我的请求正文被记录。我漏掉了什么吗? - mehdi mohammadi

17

@Matthew Buckett的回答向你展示了如何获取Netty的线路日志。然而,格式并不是非常华丽(它包括十六进制转储)。但是,可以通过扩展io.netty.handler.logging.LoggingHandler来轻松自定义它。

public class HttpLoggingHandler extends LoggingHandler {

    @Override
    protected String format(ChannelHandlerContext ctx, String event, Object arg) {
        if (arg instanceof ByteBuf) {
            ByteBuf msg = (ByteBuf) arg;
            return msg.toString(StandardCharsets.UTF_8);
        }
        return super.format(ctx, event, arg);
    }
}

然后将其包含在您的WebClient配置中:

HttpClient httpClient = HttpClient.create()
    .tcpConfiguration(tcpClient ->
        tcpClient.bootstrap(bootstrap ->
            BootstrapHandlers.updateLogSupport(bootstrap, new HttpLoggingHandler())));

WebClient
    .builder()
    .clientConnector(new ReactorClientHttpConnector(httpClient))
    .build()

例子:

webClient.post()
    .uri("https://postman-echo.com/post")
    .syncBody("{\"foo\" : \"bar\"}")
    .accept(MediaType.APPLICATION_JSON)
    .exchange()
    .block();
2019-09-22 18:09:21.477 DEBUG   --- [ctor-http-nio-4] c.e.w.c.e.logging.HttpLoggingHandler     : [id: 0x505be2bb] REGISTERED
2019-09-22 18:09:21.489 DEBUG   --- [ctor-http-nio-4] c.e.w.c.e.logging.HttpLoggingHandler     : [id: 0x505be2bb] CONNECT: postman-echo.com/35.170.134.160:443
2019-09-22 18:09:21.701 DEBUG   --- [ctor-http-nio-4] c.e.w.c.e.logging.HttpLoggingHandler     : [id: 0x505be2bb, L:/192.168.100.35:55356 - R:postman-echo.com/35.170.134.160:443] ACTIVE
2019-09-22 18:09:21.836 DEBUG   --- [ctor-http-nio-4] c.e.w.c.e.logging.HttpLoggingHandler     : [id: 0x505be2bb, L:/192.168.100.35:55356 - R:postman-echo.com/35.170.134.160:443] READ COMPLETE
2019-09-22 18:09:21.905 DEBUG   --- [ctor-http-nio-4] c.e.w.c.e.logging.HttpLoggingHandler     : [id: 0x505be2bb, L:/192.168.100.35:55356 - R:postman-echo.com/35.170.134.160:443] READ COMPLETE
2019-09-22 18:09:22.036 DEBUG   --- [ctor-http-nio-4] c.e.w.c.e.logging.HttpLoggingHandler     : [id: 0x505be2bb, L:/192.168.100.35:55356 - R:postman-echo.com/35.170.134.160:443] USER_EVENT: SslHandshakeCompletionEvent(SUCCESS)
2019-09-22 18:09:22.082 DEBUG   --- [ctor-http-nio-4] c.e.w.c.e.logging.HttpLoggingHandler     : POST /post HTTP/1.1
user-agent: ReactorNetty/0.8.11.RELEASE
host: postman-echo.com
Accept: application/json
Content-Type: text/plain;charset=UTF-8
content-length: 15

{"foo" : "bar"}
2019-09-22 18:09:22.083 DEBUG   --- [ctor-http-nio-4] c.e.w.c.e.logging.HttpLoggingHandler     : [id: 0x505be2bb, L:/192.168.100.35:55356 - R:postman-echo.com/35.170.134.160:443] FLUSH
2019-09-22 18:09:22.086 DEBUG   --- [ctor-http-nio-4] c.e.w.c.e.logging.HttpLoggingHandler     : [id: 0x505be2bb, L:/192.168.100.35:55356 - R:postman-echo.com/35.170.134.160:443] READ COMPLETE
2019-09-22 18:09:22.217 DEBUG   --- [ctor-http-nio-4] c.e.w.c.e.logging.HttpLoggingHandler     : HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Date: Sun, 22 Sep 2019 15:09:22 GMT
ETag: W/"151-Llbe8OYGC3GeZCxttuAH3BOYBKA"
Server: nginx
set-cookie: sails.sid=s%3APe39li6V8TL8FOJOzSINZRkQlZ7HFAYi.UkLZjfajJqkq9fUfF2Y8N4JOInHNW5t1XACu3fhQYSc; Path=/; HttpOnly
Vary: Accept-Encoding
Content-Length: 337
Connection: keep-alive

{"args":{},"data":"{\"foo\" : \"bar\"}","files":{},"form":{},"headers":{"x-forwarded-proto":"https","host":"postman-echo.com","content-length":"15","accept":"application/json","content-type":"text/plain;charset=UTF-8","user-agent":"ReactorNetty/0.8.11.RELEASE","x-forwarded-port":"443"},"json":null,"url":"https://postman-echo.com/post"}
2019-09-22 18:09:22.243 DEBUG   --- [ctor-http-nio-4] c.e.w.c.e.logging.HttpLoggingHandler     : [id: 0x505be2bb, L:/192.168.100.35:55356 - R:postman-echo.com/35.170.134.160:443] READ COMPLETE

如果你想抑制无用(对你来说)的日志条目,例如(注意结尾处的ACTIVE):

2019-09-22 18:09:21.701 DEBUG   --- [ctor-http-nio-4] c.e.w.c.e.logging.HttpLoggingHandler     : [id: 0x505be2bb, L:/192.168.100.35:55356 - R:postman-echo.com/35.170.134.160:443] ACTIVE

你可以这样重写channelActive和其他方法:

@Override
public void channelActive(ChannelHandlerContext ctx) {
    ctx.fireChannelActive();
}
答案基于 https://www.baeldung.com/spring-log-webclient-calls

1
我正在使用Netty 4.1.38,但是这段代码无法正确打印响应内容(����$N;��z�e��>$[�)。 - Niranjan
3
在链接 https://www.baeldung.com/spring-log-webclient-calls 中提到的构造函数添加后,它可以正常工作。 - user2761431
4
tcpClient.bootstrap已被弃用,而且不太明显应该使用哪个方法来替代它。 - Puce
1
是的,这是记录正文的好方法。缺点是响应分散在多个日志条目中。 - RichArt

14

2020年二月份更新的Spring Boot 2.2.4和Spring 5.2.3 :

我无法让 spring.http.log-request-details=true 正常工作,当前的 Spring WebFlux参考资料建议 需要编写一些代码来记录头部信息,尽管示例代码使用了已弃用的exchangeStrategies() 方法。

仍然有一个取代被弃用方法的方法,因此在WebClient级别获取标头日志的紧凑代码可能如下所示:

WebClient webClient = WebClient.builder()
    .codecs(configurer -> configurer.defaultCodecs().enableLoggingRequestDetails(true))
    .build();

进一步地

logging.level.org.springframework.web.reactive.function.client.ExchangeFunctions=TRACE

需要注意的是,并非所有的标题都可以在WebFlux ExchangeFunctions级别下获得(存在),因此根据@Matthew的建议,在Netty HttpClient级别上进行更多日志记录可能也是必要的:

WebClient webClient = WebClient.builder()
    .clientConnector(new ReactorClientHttpConnector(
        HttpClient.create()
            .wiretap(true)))
    .build()

进一步地

logging.level.reactor.netty.http.client.HttpClient: DEBUG

这也将记录主体内容。


8
使用仅ExchangeFilterFunction可以记录请求和响应正文的方法,它与底层ClientHttpConnector无关,并支持定制输出。实际输出未包含在实现中。相反,可以访问请求和响应正文的行包含说明性注释。将以下类实例添加到WebClient过滤器列表中:
import org.reactivestreams.Publisher;
import org.springframework.core.io.buffer.DataBuffer;
import org.springframework.http.client.reactive.ClientHttpRequest;
import org.springframework.http.client.reactive.ClientHttpRequestDecorator;
import org.springframework.web.reactive.function.BodyInserter;
import org.springframework.web.reactive.function.client.ClientRequest;
import org.springframework.web.reactive.function.client.ClientResponse;
import org.springframework.web.reactive.function.client.ExchangeFilterFunction;
import org.springframework.web.reactive.function.client.ExchangeFunction;
import reactor.core.publisher.BaseSubscriber;
import reactor.core.publisher.Mono;

import java.util.concurrent.atomic.AtomicBoolean;

public class LoggingExchangeFilterFunction implements ExchangeFilterFunction {

    @Override
    public Mono<ClientResponse> filter(ClientRequest request, ExchangeFunction next) {
        BodyInserter<?, ? super ClientHttpRequest> originalBodyInserter = request.body();
        ClientRequest loggingClientRequest = ClientRequest.from(request)
                .body((outputMessage, context) -> {
                    ClientHttpRequestDecorator loggingOutputMessage = new ClientHttpRequestDecorator(outputMessage) {

                        private final AtomicBoolean alreadyLogged = new AtomicBoolean(false); // Not sure if thread-safe is needed...

                        @Override
                        public Mono<Void> writeWith(Publisher<? extends DataBuffer> body) {
                            boolean needToLog = alreadyLogged.compareAndSet(false, true);
                            if (needToLog) {
                                // use `body.toString(Charset.defaultCharset())` to obtain request body
                            }
                            return super.writeWith(body);
                        }

                        @Override
                        public Mono<Void> writeAndFlushWith(Publisher<? extends Publisher<? extends DataBuffer>> body) {
                            boolean needToLog = alreadyLogged.compareAndSet(false, true);
                            if (needToLog) {
                                BaseSubscriber<Publisher<? extends DataBuffer>> bodySubscriber = new BaseSubscriber<Publisher<? extends DataBuffer>>() {
                                    @Override
                                    protected void hookOnNext(Publisher<? extends DataBuffer> next) {
                                        // use `next.toString(Charset.defaultCharset())` to obtain request body element
                                    }
                                };
                                body.subscribe(bodySubscriber);
                                bodySubscriber.request(Long.MAX_VALUE);
                            }
                            return super.writeAndFlushWith(body);
                        }

                        @Override
                        public Mono<Void> setComplete() { // This is for requests with no body (e.g. GET).
                            boolean needToLog = alreadyLogged.compareAndSet(false, true);
                            if (needToLog) {
                                // A request with no body, could log `request.method()` and `request.url()`.
                            }
                            return super.setComplete();
                        }
                    };
                    return originalBodyInserter.insert(loggingOutputMessage, context);
                })
                .build();
        return next.exchange(loggingClientRequest)
                .map(
                        clientResponse -> clientResponse.mutate()
                                .body(f -> f.map(dataBuffer -> {
                                    // Use `dataBuffer.toString(Charset.defaultCharset())` to obtain response body.
                                    return dataBuffer;
                                }))
                                .build()
                );
    }

}

请求没有被编写,响应已经编写了。需要编写请求来订阅(subscribe())。 - igr

8

提示:目前使用ExchangeFilterFunction进行自定义记录不能记录请求体。

在我的情况下,最好的记录方式是通过Bealdung的解决方案实现的(请参见此处)。

因此,我设置了一个默认构建器,以便不同的API共享此功能。

@Bean
public WebClient.Builder defaultWebClient() {
    final var builder = WebClient.builder();
    if (LOG.isDebugEnabled()) {
        builder.clientConnector(new ReactorClientHttpConnector(
                HttpClient.create().wiretap("reactor.netty.http.client.HttpClient",
                        LogLevel.DEBUG, AdvancedByteBufFormat.TEXTUAL)
        ));
    }
    return builder;
}

在具体的API配置中,我可以配置特定的内容:

@Bean
public SpecificApi bspApi(@Value("${specific.api.url}") final String baseUrl,
                     final WebClient.Builder builder) {
    final var webClient = builder.baseUrl(baseUrl).build();
    return new SpecificApi(webClient);
}

接下来我需要设置以下属性:

logging.level.reactor.netty.http.client: DEBUG

然后请求日志看起来像:

021-03-03 12:56:34.589 DEBUG 20464 --- [ctor-http-nio-2] reactor.netty.http.client.HttpClient     : [id: 0xe75a7fb8] REGISTERED
2021-03-03 12:56:34.590 DEBUG 20464 --- [ctor-http-nio-2] reactor.netty.http.client.HttpClient     : [id: 0xe75a7fb8] CONNECT: /192.168.01:80
2021-03-03 12:56:34.591 DEBUG 20464 --- [ctor-http-nio-2] reactor.netty.http.client.HttpClient     : [id: 0xe75a7fb8, L:/192.168.04:56774 - R:/192.168.01:80] ACTIVE
2021-03-03 12:56:34.591 DEBUG 20464 --- [ctor-http-nio-2] r.netty.http.client.HttpClientConnect    : [id: 0xe75a7fb8, L:/192.168.04:56774 - R:/192.168.01:80] Handler is being applied: {uri=http://192.168.01/user, method=GET}
2021-03-03 12:56:34.592 DEBUG 20464 --- [ctor-http-nio-2] reactor.netty.http.client.HttpClient     : [id: 0xe75a7fb8, L:/192.168.04:56774 - R:/192.168.01:80] WRITE: 102B GET /user HTTP/1.1
user-agent: ReactorNetty/1.0.3
host: 192.168.01
accept: */*

<REQUEST_BODY>

2021-03-03 12:56:34.592 DEBUG 20464 --- [ctor-http-nio-2] reactor.netty.http.client.HttpClient     : [id: 0xe75a7fb8, L:/192.168.04:56774 - R:/192.168.01:80] FLUSH
2021-03-03 12:56:34.592 DEBUG 20464 --- [ctor-http-nio-2] reactor.netty.http.client.HttpClient     : [id: 0xe75a7fb8, L:/192.168.04:56774 - R:/192.168.01:80] WRITE: 0B 
2021-03-03 12:56:34.592 DEBUG 20464 --- [ctor-http-nio-2] reactor.netty.http.client.HttpClient     : [id: 0xe75a7fb8, L:/192.168.04:56774 - R:/192.168.01:80] FLUSH
2021-03-03 12:56:34.594 DEBUG 20464 --- [ctor-http-nio-2] reactor.netty.http.client.HttpClient     : [id: 0xe75a7fb8, L:/192.168.04:56774 - R:/192.168.01:80] READ: 2048B HTTP/1.1 200 
Server: nginx/1.16.1
Date: Wed, 03 Mar 2021 11:56:31 GMT
Content-Type: application/json
Content-Length: 4883
Connection: keep-alive
Access-Control-Allow-Origin: *
Content-Range: items 0-4/4

<RESPONSE_BODY>

这对我来说非常完美,而且日志细节非常有用。 - mehdi mohammadi
这个对我有用,而不是使用this.javaClass.canonicalName。当我使用canonicalName时,我遇到了macOS异常。 - icgoogo

8
将以下文本翻译成中文:

在使用Spring的响应式WebClient时,正确地记录请求/响应日志似乎非常困难。

我有以下要求:

  • 一个日志语句中记录请求和响应,包括正文(如果您在AWS CloudWatch中滚动查看数百个日志,则所有内容都在一个语句中更方便)
  • 从日志中过滤掉个人数据或财务数据等敏感数据,以符合GDPR和PCI的规定

因此,窃听Netty或使用自定义Jackson编码器/解码器不是选项。

以下是我的解决方法(再次基于Stanislav的出色答案)。

(以下代码使用Lombok注释处理,如果尚未使用它,则可能也需要使用它。否则,去除Lombok应该很容易)

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.reactivestreams.Publisher;
import org.springframework.core.io.buffer.DataBuffer;
import org.springframework.http.client.reactive.ClientHttpRequest;
import org.springframework.http.client.reactive.ClientHttpRequestDecorator;
import org.springframework.lang.NonNull;
import org.springframework.util.StopWatch;
import org.springframework.web.reactive.function.BodyInserter;
import org.springframework.web.reactive.function.client.ClientRequest;
import org.springframework.web.reactive.function.client.ClientResponse;
import org.springframework.web.reactive.function.client.ExchangeFilterFunction;
import org.springframework.web.reactive.function.client.ExchangeFunction;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;

import java.util.concurrent.atomic.AtomicBoolean;

import static java.lang.Math.min;
import static java.util.UUID.randomUUID;
import static net.logstash.logback.argument.StructuredArguments.v;

@Slf4j
@RequiredArgsConstructor
public class RequestLoggingFilterFunction implements ExchangeFilterFunction {

  private static final int MAX_BYTES_LOGGED = 4_096;

  private final String externalSystem;

  @Override
  @NonNull
  public Mono<ClientResponse> filter(@NonNull ClientRequest request, @NonNull ExchangeFunction next) {
    if (!log.isDebugEnabled()) {
      return next.exchange(request);
    }

    var clientRequestId = randomUUID().toString();

    var requestLogged = new AtomicBoolean(false);
    var responseLogged = new AtomicBoolean(false);

    var capturedRequestBody = new StringBuilder();
    var capturedResponseBody = new StringBuilder();

    var stopWatch = new StopWatch();
    stopWatch.start();

    return next
      .exchange(ClientRequest.from(request).body(new BodyInserter<>() {

        @Override
        @NonNull
        public Mono<Void> insert(@NonNull ClientHttpRequest req, @NonNull Context context) {
          return request.body().insert(new ClientHttpRequestDecorator(req) {

            @Override
            @NonNull
            public Mono<Void> writeWith(@NonNull Publisher<? extends DataBuffer> body) {
              return super.writeWith(Flux.from(body).doOnNext(data -> capturedRequestBody.append(extractBytes(data)))); // number of bytes appended is maxed in real code
            }

          }, context);
        }
      }).build())
      .doOnNext(response -> {
          if (!requestLogged.getAndSet(true)) {
            log.debug("| >>---> Outgoing {} request [{}]\n{} {}\n{}\n\n{}\n",
              v("externalSystem", externalSystem),
              v("clientRequestId", clientRequestId),
              v("clientRequestMethod", request.method()),
              v("clientRequestUrl", request.url()),
              v("clientRequestHeaders", request.headers()), // filtered in real code
              v("clientRequestBody", capturedRequestBody.toString()) // filtered in real code
            );
          }
        }
      )
      .doOnError(error -> {
        if (!requestLogged.getAndSet(true)) {
          log.debug("| >>---> Outgoing {} request [{}]\n{} {}\n{}\n\nError: {}\n",
            v("externalSystem", externalSystem),
            v("clientRequestId", clientRequestId),
            v("clientRequestMethod", request.method()),
            v("clientRequestUrl", request.url()),
            v("clientRequestHeaders", request.headers()), // filtered in real code
            error.getMessage()
          );
        }
      })
      .map(response -> response.mutate().body(transformer -> transformer
          .doOnNext(body -> capturedResponseBody.append(extractBytes(body))) // number of bytes appended is maxed in real code
          .doOnTerminate(() -> {
            if (stopWatch.isRunning()) {
              stopWatch.stop();
            }
          })
          .doOnComplete(() -> {
            if (!responseLogged.getAndSet(true)) {
              log.debug("| <---<< Response for outgoing {} request [{}] after {}ms\n{} {}\n{}\n\n{}\n",
                v("externalSystem", externalSystem),
                v("clientRequestId", clientRequestId),
                v("clientRequestExecutionTimeInMillis", stopWatch.getTotalTimeMillis()),
                v("clientResponseStatusCode", response.statusCode().value()),
                v("clientResponseStatusPhrase", response.statusCode().getReasonPhrase()),
                v("clientResponseHeaders", response.headers()), // filtered in real code
                v("clientResponseBody", capturedResponseBody.toString()) // filtered in real code
              );
            }
          })
          .doOnError(error -> {
              if (!responseLogged.getAndSet(true)) {
                log.debug("| <---<< Error parsing response for outgoing {} request [{}] after {}ms\n{}",
                  v("externalSystem", externalSystem),
                  v("clientRequestId", clientRequestId),
                  v("clientRequestExecutionTimeInMillis", stopWatch.getTotalTimeMillis()),
                  v("clientErrorMessage", error.getMessage())
                );
              }
            }
          )
        ).build()
      );
  }

  private static String extractBytes(DataBuffer data) {
    int currentReadPosition = data.readPosition();
    var numberOfBytesLogged = min(data.readableByteCount(), MAX_BYTES_LOGGED);
    var bytes = new byte[numberOfBytesLogged];
    data.read(bytes, 0, numberOfBytesLogged);
    data.readPosition(currentReadPosition);
    return new String(bytes);
  }

}

成功交换的日志条目看起来像这样:
2021-12-07 17:14:04.029 DEBUG --- [ctor-http-nio-3] RequestLoggingFilterFunction        : | >>---> Outgoing SnakeOil request [6abd0170-d682-4ca6-806c-bbb3559998e8]
POST https://localhost:8101/snake-oil/oauth/token
Content-Type: application/x-www-form-urlencoded

grant_type=client_credentials&client_id=*****&client_secret=*****

2021-12-07 17:14:04.037 DEBUG --- [ctor-http-nio-3] RequestLoggingFilterFunction        : | <---<< Response for outgoing SnakeOil request [6abd0170-d682-4ca6-806c-bbb3559998e8] after 126ms
200 OK
Content-Type: application/json
Vary: [Accept-Encoding, User-Agent]
Transfer-Encoding: chunked

{"access_token":"*****","expires_in":"3600","token_type":"BearerToken"}

当然,错误条件也会得到优雅地处理。

你是如何对令牌进行掩码处理的?能否提供一个示例? - Harikesh

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接