如何记录 Spring 5 WebClient 调用
how to log Spring 5 WebClient call
我正在尝试使用 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;
}
您可以使用 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.ofResponseProcessor
helper for this purpose just the same way as ExchangeFilterFunction.ofRequestProcessor
is used. You can use methods of ClientResponse
得到 headers/cookies 等
// 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())
但要小心,不要尝试在过滤器中读取响应正文。由于它的流性质,在没有某种缓冲包装器的情况下,主体只能被消耗一次。因此,如果您将在过滤器中阅读它,您将无法在订阅者中阅读它。
如果确实需要记录body,可以让底层(Netty)来做。请参阅 了解思路。
您不一定需要滚动自己的记录器,reactor.ipc.netty.channel.ChannelOperationsHandler
会为您完成。只需为 class 配置日志系统以在 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
要记录headers或形成body,请将以上设置为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 月:
针对评论中询问如何记录请求和响应的问题 body,我不知道 Spring 是否有这样的记录器,但 WebClient
已构建在 Netty 上,因此为包 reactor.ipc.netty
启用调试日志记录应该可以工作,以及 this 答案。
如果你不想记录body,那么这真的很简单。
Spring 开机 >= 2.1.0
将以下内容添加到 application.properties:
logging.level.org.springframework.web.reactive.function.client.ExchangeFunctions=TRACE
spring.http.log-request-details=true
第二行导致 headers 被包含在日志中。
Spring 启动 < 2.1.0
将以下内容添加到 application.properties:
logging.level.org.springframework.web.reactive.function.client.ExchangeFunctions=TRACE
而不是上面的第二行,你需要像这样声明一个class:
@Configuration
static class LoggingCodecConfig {
@Bean
@Order(0)
public CodecCustomizer loggingCodecCustomizer() {
return (configurer) -> configurer.defaultCodecs()
.enableLoggingRequestDetails(true);
}
}
你可以让 netty 记录 request/responses 通过要求它做窃听,如果你像这样创建你的 Spring WebClient 然后它启用窃听选项。
WebClient webClient = WebClient.builder()
.clientConnector(new ReactorClientHttpConnector(
HttpClient.create().wiretap(true)
))
.build()
然后进行日志记录设置:
logging.level.reactor.netty.http.client.HttpClient: DEBUG
这将记录 request/response 的所有内容(包括正文),但格式并非特定于 HTTP,因此可读性不强。
@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();
}
Spring Boot 2.2.4 和 Spring 5.2.3 的 2020 年 2 月更新:
我没有设法让 spring.http.log-request-details=true
完成它的工作,以及当前的 Spring WebFlux reference suggests that some coding needs be done to have headers logged, though the code example uses deprecated exchangeStrategies()
方法。
仍然有一个已弃用方法的替代方法,因此用于在 WebClient 级别记录 headers 的一段紧凑代码可能如下所示:
WebClient webClient = WebClient.builder()
.codecs(configurer -> configurer.defaultCodecs().enableLoggingRequestDetails(true))
.build();
进一步
logging.level.org.springframework.web.reactive.function.client.ExchangeFunctions=TRACE
需要注意的是,并非所有 headers 在 WebFlux ExchangeFunctions
级别都可用(确实存在),因此在 Netty HttpClient
级别进行更多日志记录可能是必不可少的同样,根据 @Matthew's suggestion:
WebClient webClient = WebClient.builder()
.clientConnector(new ReactorClientHttpConnector(
HttpClient.create()
.wiretap(true)))
.build()
进一步
logging.level.reactor.netty.http.client.HttpClient: DEBUG
这也会记录尸体。
如果您希望在请求或响应中记录 JSON 的序列化版本,您可以创建自己的 Json Encoder/Decoder 类 来包装默认值并记录 JSON。具体来说,您将子类化 Jackson2JsonEncoder
和 Jackson2JsonDecoder
类 并覆盖公开串行数据的方法。
这里有解释:https://andrew-flower.com/blog/webclient-body-logging
上面显示的方法主要针对非流式数据。对流数据执行此操作可能更具挑战性。
由于需要额外的内存/处理,显然不建议在 Prod 环境中执行此操作,但为开发环境配置它很有用。
@StasKolodyuk's answer elaborates on the solution from baeldung 用于记录响应式 WebClient 的响应主体。注意
tc.bootstrap(...)
在
中已弃用
HttpClient httpClient = HttpClient
.create()
.tcpConfiguration(
tc -> tc.bootstrap(
b -> BootstrapHandlers.updateLogSupport(b, new CustomLogger(HttpClient.class))))
.build()
添加自定义 LoggingHandler 的另一种未弃用的方法是 (Kotlin)
val httpClient: HttpClient = HttpClient.create().mapConnect { conn, b ->
BootstrapHandlers.updateLogSupport(b, CustomLogger(HttpClient::class.java))
conn
}
在 Spring Boot 2.4.0 中,HttpClient 的 wiretap() 方法有额外的参数,您可以传递这些参数以在 [= 中显示完整的 request/response headers 和 body 16=]正常 人类可读格式。使用格式 (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":[.....
剧透: 到目前为止,ExchangeFilterFunction
的自定义日志记录不支持记录正文。
在我的例子中,最好的日志记录是使用 Bealdung 的解决方案实现的(参见 this)。
因此我设置了一个默认生成器,以便不同的 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>
这就是 2021 年对我有用的方法:)
HttpClient httpClient = HttpClient
.create()
.wiretap(this.getClass().getCanonicalName(),
LogLevel.INFO, AdvancedByteBufFormat.TEXTUAL);
WebClient client = WebClient.builder()
.baseUrl("https://example.com")
.clientConnector(new ReactorClientHttpConnector(httpClient))
.build();
如果要实现CustomLoggerHandler,不要忘记实现equals()
和hashCode()
,否则会内存泄漏https://github.com/reactor/reactor-netty/issues/988#issuecomment-582489035
有一种方法可以仅使用 ExchangeFilterFunction 来记录请求和响应正文。它独立于底层ClientHttpConnector
,支持定制输出。实际输出不包含在实现中。相反,可以访问请求和响应主体的行包含解释性注释。将以下 class 实例添加到 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()
);
}
}
根据 Stanislav Burov 的回答,我制作了这个记录器,它记录了所有 request/response headers、方法、url 和 body。
public class WebClientLogger implements ExchangeFilterFunction {
@Override
public Mono<ClientResponse> filter(ClientRequest request, ExchangeFunction next) {
// Log url using 'request.url()'
// Log method using 'request.method()'
// Log request headers using 'request.headers().entrySet().stream().map(Object::toString).collect(joining(","))'
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);
@Override
public Mono<Void> writeWith(Publisher<? extends DataBuffer> body) {
boolean needToLog = alreadyLogged.compareAndSet(false, true);
if (needToLog) {
body = DataBufferUtils.join(body)
.doOnNext(content -> {
// Log request body using 'content.toString(StandardCharsets.UTF_8)'
});
}
return super.writeWith(body);
}
@Override
public Mono<Void> setComplete() { // This is for requests with no body (e.g. GET).
boolean needToLog = alreadyLogged.compareAndSet(false, true);
if (needToLog) {
}
return super.setComplete();
}
};
return originalBodyInserter.insert(loggingOutputMessage, context);
})
.build();
return next.exchange(loggingClientRequest)
.map(clientResponse -> {
// Log response status using 'clientResponse.statusCode().value())'
// Log response headers using 'clientResponse.headers().asHttpHeaders().entrySet().stream().map(Object::toString).collect(joining(","))'
return clientResponse.mutate()
.body(f -> f.map(dataBuffer -> {
// Log response body using 'dataBuffer.toString(StandardCharsets.UTF_8)'
return dataBuffer;
}))
.build();
}
);
}
}
这是我根据 Stanislav Burov 的出色回答摘录的片段。我将一些 lambda 提取到独立 类 以使代码对我来说更具可读性,并且我还实现了一个有限的 UTF-8 感知解码器。我正在使用一些 Guava 和 Java 17 功能,但此代码可以轻松移植到早期版本。我不缓冲整个 request/response 主体,而是在单独调用时记录缓冲区,因此它不会使用过多的 RAM 或写入极长的行。
package kz.doubleservices.healthbus.util;
import org.reactivestreams.Publisher;
import org.slf4j.Logger;
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.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.HexFormat;
import java.util.List;
import java.util.Locale;
import java.util.concurrent.ThreadLocalRandom;
import java.util.function.Function;
import static com.google.common.io.BaseEncoding.base32;
public class LoggingExchangeFilterFunction implements ExchangeFilterFunction {
private final Logger logger;
public LoggingExchangeFilterFunction(Logger logger) {
this.logger = logger;
}
@Override
@NonNull
public Mono<ClientResponse> filter(@NonNull ClientRequest request, @NonNull ExchangeFunction next) {
if (!logger.isDebugEnabled()) {
return next.exchange(request);
}
String requestId = generateRequestId();
if (logger.isTraceEnabled()) {
var message = new StringBuilder();
message.append("HTTP request start; request-id=").append(requestId).append('\n')
.append(request.method()).append(' ').append(request.url());
request.headers().forEach((String name, List<String> values) -> {
for (String value : values) {
message.append('\n').append(name).append(": ").append(value);
}
});
logger.trace(message.toString());
} else {
logger.debug("HTTP request; request-id=" + requestId + '\n' +
request.method() + ' ' + request.url());
}
if (logger.isTraceEnabled()) {
var bodyInserter = new LoggingBodyInserter(logger, requestId, request.body());
request = ClientRequest.from(request).body(bodyInserter).build();
}
return next.exchange(request).map(new LoggingClientResponseTransformer(logger, requestId));
}
private static String generateRequestId() {
var bytes = new byte[5];
ThreadLocalRandom.current().nextBytes(bytes);
return base32().encode(bytes).toLowerCase(Locale.ROOT);
}
private static class LoggingBodyInserter implements BodyInserter<Object, ClientHttpRequest> {
private final Logger logger;
private final String requestId;
private final BodyInserter<?, ? super ClientHttpRequest> originalBodyInserter;
private LoggingBodyInserter(Logger logger, String requestId,
BodyInserter<?, ? super ClientHttpRequest> originalBodyInserter) {
this.logger = logger;
this.requestId = requestId;
this.originalBodyInserter = originalBodyInserter;
}
@Override
@NonNull
public Mono<Void> insert(@NonNull ClientHttpRequest outputMessage, @NonNull Context context) {
var loggingOutputMessage = new LoggingClientHttpRequestDecorator(outputMessage, logger, requestId);
return originalBodyInserter.insert(loggingOutputMessage, context);
}
}
private static class LoggingClientHttpRequestDecorator extends ClientHttpRequestDecorator {
private final Logger logger;
private final String requestId;
public LoggingClientHttpRequestDecorator(ClientHttpRequest delegate, Logger logger, String requestId) {
super(delegate);
this.logger = logger;
this.requestId = requestId;
}
@Override
@NonNull
public Mono<Void> writeWith(@NonNull Publisher<? extends DataBuffer> body) {
Flux<? extends DataBuffer> loggingBody = Flux.from(body)
.doOnNext(this::logDataBuffer)
.doOnComplete(this::logComplete)
.doOnError(this::logError);
return super.writeWith(loggingBody);
}
@Override
@NonNull
public Mono<Void> setComplete() {
logger.trace("HTTP request end; request-id=" + requestId);
return super.setComplete();
}
private void logDataBuffer(DataBuffer dataBuffer) {
int readPosition = dataBuffer.readPosition();
byte[] data = new byte[dataBuffer.readableByteCount()];
dataBuffer.read(data);
dataBuffer.readPosition(readPosition);
logger.trace("HTTP request data; request-id=" + requestId + '\n' + bytesToString(data));
}
private void logComplete() {
logger.trace("HTTP request end; request-id=" + requestId);
}
private void logError(Throwable exception) {
logger.trace("HTTP request error; request-id=" + requestId, exception);
}
}
private static class LoggingClientResponseTransformer implements Function<ClientResponse, ClientResponse> {
private final Logger logger;
private final String requestId;
private LoggingClientResponseTransformer(Logger logger, String requestId) {
this.logger = logger;
this.requestId = requestId;
}
@Override
public ClientResponse apply(ClientResponse clientResponse) {
if (logger.isTraceEnabled()) {
var message = new StringBuilder();
message.append("HTTP response start; request-id=").append(requestId).append('\n')
.append("HTTP ").append(clientResponse.statusCode());
clientResponse.headers().asHttpHeaders().forEach((String name, List<String> values) -> {
for (String value : values) {
message.append('\n').append(name).append(": ").append(value);
}
});
logger.trace(message.toString());
} else {
logger.debug("HTTP response; request-id=" + requestId + '\n' +
"HTTP " + clientResponse.statusCode());
}
return clientResponse.mutate()
.body(new ClientResponseBodyTransformer(logger, requestId))
.build();
}
}
private static class ClientResponseBodyTransformer implements Function<Flux<DataBuffer>, Flux<DataBuffer>> {
private final Logger logger;
private final String requestId;
private boolean completed = false;
private ClientResponseBodyTransformer(Logger logger, String requestId) {
this.logger = logger;
this.requestId = requestId;
}
@Override
public Flux<DataBuffer> apply(Flux<DataBuffer> body) {
return body
.doOnNext(this::logDataBuffer)
.doOnComplete(this::logComplete)
.doOnError(this::logError);
}
private void logDataBuffer(DataBuffer dataBuffer) {
int readPosition = dataBuffer.readPosition();
byte[] data = new byte[dataBuffer.readableByteCount()];
dataBuffer.read(data);
dataBuffer.readPosition(readPosition);
logger.trace("HTTP response data; request-id=" + requestId + '\n' + bytesToString(data));
}
private void logComplete() {
if (!completed) {
logger.trace("HTTP response end; request-id=" + requestId);
completed = true;
}
}
private void logError(Throwable exception) {
logger.trace("HTTP response error; request-id=" + requestId, exception);
}
}
private static String bytesToString(byte[] bytes) {
var string = new StringBuilder(bytes.length);
for (int i = 0; i < bytes.length; i++) {
byte b1 = bytes[i];
if (b1 >= 0) {
if (32 <= b1 && b1 < 127) { // ordinary ASCII characters
string.append((char) b1);
} else { // control characters
switch (b1) {
case '\t' -> string.append("\t");
case '\n' -> string.append("\n");
case '\r' -> string.append("\r");
default -> {
string.append("\x");
HexFormat.of().toHexDigits(string, b1);
}
}
}
continue;
}
if ((b1 & 0xe0) == 0xc0) { // UTF-8 first byte of 2-bytes sequence
i++;
if (i < bytes.length) {
byte b2 = bytes[i];
if ((b2 & 0xc0) == 0x80) { // UTF-8 second byte of 2-bytes sequence
char c = (char) ((b1 & 0x1f) << 6 | b2 & 0x3f);
if (Character.isLetter(c)) {
string.append(c);
continue;
}
}
string.append("\x");
HexFormat.of().toHexDigits(string, b1);
string.append("\x");
HexFormat.of().toHexDigits(string, b2);
continue;
}
}
string.append("\x");
HexFormat.of().toHexDigits(string, b1);
}
return string.toString();
}
}
当涉及到 Spring 的响应式 WebClient 时,获得 request/response 正确的日志记录在某种程度上真的很困难。
我有以下要求:
- 在一个日志语句中记录请求和响应包括正文(如果您将所有内容放在一个语句中会更方便滚动浏览 AWS cloudwatch 中的数百个日志)
- 从日志中过滤个人数据或财务数据等敏感数据以符合 GDPR 和 PCI
or using 因此不是一个选项。
这是我对这个问题的看法(再次基于 Stanislav 的出色 )。
(以下代码使用了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"}
当然,错误情况也得到了妥善处理。
您可以使用过滤器功能通过一些操作来跟踪 webclient 日志,包括请求和响应正文有效负载:
public class TracingExchangeFilterFunction implements ExchangeFilterFunction {
return next.exchange(buildTraceableRequest(request))
.flatMap(response ->
response.body(BodyExtractors.toDataBuffers())
.next()
.doOnNext(dataBuffer -> traceResponse(response, dataBuffer))
.thenReturn(response)) ;
}
private ClientRequest buildTraceableRequest(
final ClientRequest clientRequest) {
return ClientRequest.from(clientRequest).body(
new BodyInserter<>() {
@Override
public Mono<Void> insert(
final ClientHttpRequest outputMessage,
final Context context) {
return clientRequest.body().insert(
new ClientHttpRequestDecorator(outputMessage) {
@Override
public Mono<Void> writeWith(final Publisher<? extends DataBuffer> body) {
return super.writeWith(
from(body).doOnNext(buffer ->
traceRequest(clientRequest, buffer)));
}
}, context);
}
}).build();
}
private void traceRequest(ClientRequest clientRequest, DataBuffer buffer) {
final ByteBuf byteBuf = NettyDataBufferFactory.toByteBuf(buffer);
final byte[] bytes = ByteBufUtil.getBytes(byteBuf);
// do some tracing
}
private void traceResponse(ClientResponse response, DataBuffer dataBuffer) {
final byte[] bytes = new byte[dataBuffer.readableByteCount()];
dataBuffer.read(bytes);
// do some tracing
}
}
我正在尝试使用 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;
}
您可以使用 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.ofResponseProcessor
helper for this purpose just the same way as ExchangeFilterFunction.ofRequestProcessor
is used. You can use methods of ClientResponse
得到 headers/cookies 等
// 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())
但要小心,不要尝试在过滤器中读取响应正文。由于它的流性质,在没有某种缓冲包装器的情况下,主体只能被消耗一次。因此,如果您将在过滤器中阅读它,您将无法在订阅者中阅读它。
如果确实需要记录body,可以让底层(Netty)来做。请参阅
您不一定需要滚动自己的记录器,reactor.ipc.netty.channel.ChannelOperationsHandler
会为您完成。只需为 class 配置日志系统以在 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
要记录headers或形成body,请将以上设置为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 月:
针对评论中询问如何记录请求和响应的问题 body,我不知道 Spring 是否有这样的记录器,但 WebClient
已构建在 Netty 上,因此为包 reactor.ipc.netty
启用调试日志记录应该可以工作,以及 this 答案。
如果你不想记录body,那么这真的很简单。
Spring 开机 >= 2.1.0
将以下内容添加到 application.properties:
logging.level.org.springframework.web.reactive.function.client.ExchangeFunctions=TRACE
spring.http.log-request-details=true
第二行导致 headers 被包含在日志中。
Spring 启动 < 2.1.0
将以下内容添加到 application.properties:
logging.level.org.springframework.web.reactive.function.client.ExchangeFunctions=TRACE
而不是上面的第二行,你需要像这样声明一个class:
@Configuration
static class LoggingCodecConfig {
@Bean
@Order(0)
public CodecCustomizer loggingCodecCustomizer() {
return (configurer) -> configurer.defaultCodecs()
.enableLoggingRequestDetails(true);
}
}
你可以让 netty 记录 request/responses 通过要求它做窃听,如果你像这样创建你的 Spring WebClient 然后它启用窃听选项。
WebClient webClient = WebClient.builder()
.clientConnector(new ReactorClientHttpConnector(
HttpClient.create().wiretap(true)
))
.build()
然后进行日志记录设置:
logging.level.reactor.netty.http.client.HttpClient: DEBUG
这将记录 request/response 的所有内容(包括正文),但格式并非特定于 HTTP,因此可读性不强。
@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();
}
Spring Boot 2.2.4 和 Spring 5.2.3 的 2020 年 2 月更新:
我没有设法让 spring.http.log-request-details=true
完成它的工作,以及当前的 Spring WebFlux reference suggests that some coding needs be done to have headers logged, though the code example uses deprecated exchangeStrategies()
方法。
仍然有一个已弃用方法的替代方法,因此用于在 WebClient 级别记录 headers 的一段紧凑代码可能如下所示:
WebClient webClient = WebClient.builder()
.codecs(configurer -> configurer.defaultCodecs().enableLoggingRequestDetails(true))
.build();
进一步
logging.level.org.springframework.web.reactive.function.client.ExchangeFunctions=TRACE
需要注意的是,并非所有 headers 在 WebFlux ExchangeFunctions
级别都可用(确实存在),因此在 Netty HttpClient
级别进行更多日志记录可能是必不可少的同样,根据 @Matthew's suggestion:
WebClient webClient = WebClient.builder()
.clientConnector(new ReactorClientHttpConnector(
HttpClient.create()
.wiretap(true)))
.build()
进一步
logging.level.reactor.netty.http.client.HttpClient: DEBUG
这也会记录尸体。
如果您希望在请求或响应中记录 JSON 的序列化版本,您可以创建自己的 Json Encoder/Decoder 类 来包装默认值并记录 JSON。具体来说,您将子类化 Jackson2JsonEncoder
和 Jackson2JsonDecoder
类 并覆盖公开串行数据的方法。
这里有解释:https://andrew-flower.com/blog/webclient-body-logging
上面显示的方法主要针对非流式数据。对流数据执行此操作可能更具挑战性。
由于需要额外的内存/处理,显然不建议在 Prod 环境中执行此操作,但为开发环境配置它很有用。
@StasKolodyuk's answer elaborates on the solution from baeldung 用于记录响应式 WebClient 的响应主体。注意
tc.bootstrap(...)
在
中已弃用 HttpClient httpClient = HttpClient
.create()
.tcpConfiguration(
tc -> tc.bootstrap(
b -> BootstrapHandlers.updateLogSupport(b, new CustomLogger(HttpClient.class))))
.build()
添加自定义 LoggingHandler 的另一种未弃用的方法是 (Kotlin)
val httpClient: HttpClient = HttpClient.create().mapConnect { conn, b ->
BootstrapHandlers.updateLogSupport(b, CustomLogger(HttpClient::class.java))
conn
}
在 Spring Boot 2.4.0 中,HttpClient 的 wiretap() 方法有额外的参数,您可以传递这些参数以在 [= 中显示完整的 request/response headers 和 body 16=]正常 人类可读格式。使用格式 (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":[.....
剧透: 到目前为止,ExchangeFilterFunction
的自定义日志记录不支持记录正文。
在我的例子中,最好的日志记录是使用 Bealdung 的解决方案实现的(参见 this)。
因此我设置了一个默认生成器,以便不同的 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>
这就是 2021 年对我有用的方法:)
HttpClient httpClient = HttpClient
.create()
.wiretap(this.getClass().getCanonicalName(),
LogLevel.INFO, AdvancedByteBufFormat.TEXTUAL);
WebClient client = WebClient.builder()
.baseUrl("https://example.com")
.clientConnector(new ReactorClientHttpConnector(httpClient))
.build();
如果要实现CustomLoggerHandler,不要忘记实现equals()
和hashCode()
,否则会内存泄漏https://github.com/reactor/reactor-netty/issues/988#issuecomment-582489035
有一种方法可以仅使用 ExchangeFilterFunction 来记录请求和响应正文。它独立于底层ClientHttpConnector
,支持定制输出。实际输出不包含在实现中。相反,可以访问请求和响应主体的行包含解释性注释。将以下 class 实例添加到 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()
);
}
}
根据 Stanislav Burov 的回答,我制作了这个记录器,它记录了所有 request/response headers、方法、url 和 body。
public class WebClientLogger implements ExchangeFilterFunction {
@Override
public Mono<ClientResponse> filter(ClientRequest request, ExchangeFunction next) {
// Log url using 'request.url()'
// Log method using 'request.method()'
// Log request headers using 'request.headers().entrySet().stream().map(Object::toString).collect(joining(","))'
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);
@Override
public Mono<Void> writeWith(Publisher<? extends DataBuffer> body) {
boolean needToLog = alreadyLogged.compareAndSet(false, true);
if (needToLog) {
body = DataBufferUtils.join(body)
.doOnNext(content -> {
// Log request body using 'content.toString(StandardCharsets.UTF_8)'
});
}
return super.writeWith(body);
}
@Override
public Mono<Void> setComplete() { // This is for requests with no body (e.g. GET).
boolean needToLog = alreadyLogged.compareAndSet(false, true);
if (needToLog) {
}
return super.setComplete();
}
};
return originalBodyInserter.insert(loggingOutputMessage, context);
})
.build();
return next.exchange(loggingClientRequest)
.map(clientResponse -> {
// Log response status using 'clientResponse.statusCode().value())'
// Log response headers using 'clientResponse.headers().asHttpHeaders().entrySet().stream().map(Object::toString).collect(joining(","))'
return clientResponse.mutate()
.body(f -> f.map(dataBuffer -> {
// Log response body using 'dataBuffer.toString(StandardCharsets.UTF_8)'
return dataBuffer;
}))
.build();
}
);
}
}
这是我根据 Stanislav Burov 的出色回答摘录的片段。我将一些 lambda 提取到独立 类 以使代码对我来说更具可读性,并且我还实现了一个有限的 UTF-8 感知解码器。我正在使用一些 Guava 和 Java 17 功能,但此代码可以轻松移植到早期版本。我不缓冲整个 request/response 主体,而是在单独调用时记录缓冲区,因此它不会使用过多的 RAM 或写入极长的行。
package kz.doubleservices.healthbus.util;
import org.reactivestreams.Publisher;
import org.slf4j.Logger;
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.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.HexFormat;
import java.util.List;
import java.util.Locale;
import java.util.concurrent.ThreadLocalRandom;
import java.util.function.Function;
import static com.google.common.io.BaseEncoding.base32;
public class LoggingExchangeFilterFunction implements ExchangeFilterFunction {
private final Logger logger;
public LoggingExchangeFilterFunction(Logger logger) {
this.logger = logger;
}
@Override
@NonNull
public Mono<ClientResponse> filter(@NonNull ClientRequest request, @NonNull ExchangeFunction next) {
if (!logger.isDebugEnabled()) {
return next.exchange(request);
}
String requestId = generateRequestId();
if (logger.isTraceEnabled()) {
var message = new StringBuilder();
message.append("HTTP request start; request-id=").append(requestId).append('\n')
.append(request.method()).append(' ').append(request.url());
request.headers().forEach((String name, List<String> values) -> {
for (String value : values) {
message.append('\n').append(name).append(": ").append(value);
}
});
logger.trace(message.toString());
} else {
logger.debug("HTTP request; request-id=" + requestId + '\n' +
request.method() + ' ' + request.url());
}
if (logger.isTraceEnabled()) {
var bodyInserter = new LoggingBodyInserter(logger, requestId, request.body());
request = ClientRequest.from(request).body(bodyInserter).build();
}
return next.exchange(request).map(new LoggingClientResponseTransformer(logger, requestId));
}
private static String generateRequestId() {
var bytes = new byte[5];
ThreadLocalRandom.current().nextBytes(bytes);
return base32().encode(bytes).toLowerCase(Locale.ROOT);
}
private static class LoggingBodyInserter implements BodyInserter<Object, ClientHttpRequest> {
private final Logger logger;
private final String requestId;
private final BodyInserter<?, ? super ClientHttpRequest> originalBodyInserter;
private LoggingBodyInserter(Logger logger, String requestId,
BodyInserter<?, ? super ClientHttpRequest> originalBodyInserter) {
this.logger = logger;
this.requestId = requestId;
this.originalBodyInserter = originalBodyInserter;
}
@Override
@NonNull
public Mono<Void> insert(@NonNull ClientHttpRequest outputMessage, @NonNull Context context) {
var loggingOutputMessage = new LoggingClientHttpRequestDecorator(outputMessage, logger, requestId);
return originalBodyInserter.insert(loggingOutputMessage, context);
}
}
private static class LoggingClientHttpRequestDecorator extends ClientHttpRequestDecorator {
private final Logger logger;
private final String requestId;
public LoggingClientHttpRequestDecorator(ClientHttpRequest delegate, Logger logger, String requestId) {
super(delegate);
this.logger = logger;
this.requestId = requestId;
}
@Override
@NonNull
public Mono<Void> writeWith(@NonNull Publisher<? extends DataBuffer> body) {
Flux<? extends DataBuffer> loggingBody = Flux.from(body)
.doOnNext(this::logDataBuffer)
.doOnComplete(this::logComplete)
.doOnError(this::logError);
return super.writeWith(loggingBody);
}
@Override
@NonNull
public Mono<Void> setComplete() {
logger.trace("HTTP request end; request-id=" + requestId);
return super.setComplete();
}
private void logDataBuffer(DataBuffer dataBuffer) {
int readPosition = dataBuffer.readPosition();
byte[] data = new byte[dataBuffer.readableByteCount()];
dataBuffer.read(data);
dataBuffer.readPosition(readPosition);
logger.trace("HTTP request data; request-id=" + requestId + '\n' + bytesToString(data));
}
private void logComplete() {
logger.trace("HTTP request end; request-id=" + requestId);
}
private void logError(Throwable exception) {
logger.trace("HTTP request error; request-id=" + requestId, exception);
}
}
private static class LoggingClientResponseTransformer implements Function<ClientResponse, ClientResponse> {
private final Logger logger;
private final String requestId;
private LoggingClientResponseTransformer(Logger logger, String requestId) {
this.logger = logger;
this.requestId = requestId;
}
@Override
public ClientResponse apply(ClientResponse clientResponse) {
if (logger.isTraceEnabled()) {
var message = new StringBuilder();
message.append("HTTP response start; request-id=").append(requestId).append('\n')
.append("HTTP ").append(clientResponse.statusCode());
clientResponse.headers().asHttpHeaders().forEach((String name, List<String> values) -> {
for (String value : values) {
message.append('\n').append(name).append(": ").append(value);
}
});
logger.trace(message.toString());
} else {
logger.debug("HTTP response; request-id=" + requestId + '\n' +
"HTTP " + clientResponse.statusCode());
}
return clientResponse.mutate()
.body(new ClientResponseBodyTransformer(logger, requestId))
.build();
}
}
private static class ClientResponseBodyTransformer implements Function<Flux<DataBuffer>, Flux<DataBuffer>> {
private final Logger logger;
private final String requestId;
private boolean completed = false;
private ClientResponseBodyTransformer(Logger logger, String requestId) {
this.logger = logger;
this.requestId = requestId;
}
@Override
public Flux<DataBuffer> apply(Flux<DataBuffer> body) {
return body
.doOnNext(this::logDataBuffer)
.doOnComplete(this::logComplete)
.doOnError(this::logError);
}
private void logDataBuffer(DataBuffer dataBuffer) {
int readPosition = dataBuffer.readPosition();
byte[] data = new byte[dataBuffer.readableByteCount()];
dataBuffer.read(data);
dataBuffer.readPosition(readPosition);
logger.trace("HTTP response data; request-id=" + requestId + '\n' + bytesToString(data));
}
private void logComplete() {
if (!completed) {
logger.trace("HTTP response end; request-id=" + requestId);
completed = true;
}
}
private void logError(Throwable exception) {
logger.trace("HTTP response error; request-id=" + requestId, exception);
}
}
private static String bytesToString(byte[] bytes) {
var string = new StringBuilder(bytes.length);
for (int i = 0; i < bytes.length; i++) {
byte b1 = bytes[i];
if (b1 >= 0) {
if (32 <= b1 && b1 < 127) { // ordinary ASCII characters
string.append((char) b1);
} else { // control characters
switch (b1) {
case '\t' -> string.append("\t");
case '\n' -> string.append("\n");
case '\r' -> string.append("\r");
default -> {
string.append("\x");
HexFormat.of().toHexDigits(string, b1);
}
}
}
continue;
}
if ((b1 & 0xe0) == 0xc0) { // UTF-8 first byte of 2-bytes sequence
i++;
if (i < bytes.length) {
byte b2 = bytes[i];
if ((b2 & 0xc0) == 0x80) { // UTF-8 second byte of 2-bytes sequence
char c = (char) ((b1 & 0x1f) << 6 | b2 & 0x3f);
if (Character.isLetter(c)) {
string.append(c);
continue;
}
}
string.append("\x");
HexFormat.of().toHexDigits(string, b1);
string.append("\x");
HexFormat.of().toHexDigits(string, b2);
continue;
}
}
string.append("\x");
HexFormat.of().toHexDigits(string, b1);
}
return string.toString();
}
}
当涉及到 Spring 的响应式 WebClient 时,获得 request/response 正确的日志记录在某种程度上真的很困难。
我有以下要求:
- 在一个日志语句中记录请求和响应包括正文(如果您将所有内容放在一个语句中会更方便滚动浏览 AWS cloudwatch 中的数百个日志)
- 从日志中过滤个人数据或财务数据等敏感数据以符合 GDPR 和 PCI
这是我对这个问题的看法(再次基于 Stanislav 的出色
(以下代码使用了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"}
当然,错误情况也得到了妥善处理。
您可以使用过滤器功能通过一些操作来跟踪 webclient 日志,包括请求和响应正文有效负载:
public class TracingExchangeFilterFunction implements ExchangeFilterFunction {
return next.exchange(buildTraceableRequest(request))
.flatMap(response ->
response.body(BodyExtractors.toDataBuffers())
.next()
.doOnNext(dataBuffer -> traceResponse(response, dataBuffer))
.thenReturn(response)) ;
}
private ClientRequest buildTraceableRequest(
final ClientRequest clientRequest) {
return ClientRequest.from(clientRequest).body(
new BodyInserter<>() {
@Override
public Mono<Void> insert(
final ClientHttpRequest outputMessage,
final Context context) {
return clientRequest.body().insert(
new ClientHttpRequestDecorator(outputMessage) {
@Override
public Mono<Void> writeWith(final Publisher<? extends DataBuffer> body) {
return super.writeWith(
from(body).doOnNext(buffer ->
traceRequest(clientRequest, buffer)));
}
}, context);
}
}).build();
}
private void traceRequest(ClientRequest clientRequest, DataBuffer buffer) {
final ByteBuf byteBuf = NettyDataBufferFactory.toByteBuf(buffer);
final byte[] bytes = ByteBufUtil.getBytes(byteBuf);
// do some tracing
}
private void traceResponse(ClientResponse response, DataBuffer dataBuffer) {
final byte[] bytes = new byte[dataBuffer.readableByteCount()];
dataBuffer.read(bytes);
// do some tracing
}
}