programing

Spring 5 WebClient 통화 기록 방법

batch 2023. 4. 1. 08:36
반응형

Spring 5 WebClient 통화 기록 방법

Spring 5 Web Client를 사용하여 요청을 기록하려고 합니다.내가 그걸 어떻게 할 수 있는지 알기나 해?

(스프링 5와 스프링 부츠 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;
}

Exchange Filter Function을 사용하면 쉽게 할 수 있습니다.

관습만 돼요.logRequest를 작성할 의 필터WebClient를 사용합니다.WebClient.Builder.

과 같습니다.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-call을 반환한다.Mono다른 사람이 구독할 때까지 스택 위로 이동합니다.은 '아예'입니다.logRequest()필터링을 실시합니다.해도 send()이 방법은 솔루션의 일부가 아니라 필터가 작동한다는 것을 보여줄 뿐입니다.

을 사용하다 또 다른 글을 .ExchangeFilterFunction 해서 it에추 and에추 and and and and 。WebClient이 목적으로 도우미를 사용할 수 있습니다.ExchangeFilterFunction.ofRequestProcessor사용됩니다.메서드를 사용하여 헤더/쿠키 등을 가져올 수 있습니다.

    // 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);
        });
    }

꼭 의 지 your에 해 주세요WebClient:

.filter(logResponse())

단, 필터 내의 응답 본문을 읽지 않도록 주의해 주십시오.본체는 스트림 특성상 완충포장 없이 한 번만 섭취할 수 있습니다.따라서 필터로 읽을 경우 서브스크라이버에서는 읽을 수 없습니다.

본문을 로그에 기록해야 하는 경우 기본 계층(Netty)에서 이 작업을 수행할 수 있습니다.아이디어를 얻으려면 매튜 버켓의 답변을 참조하십시오.

요청/응답의 netty do logging은 도청 처리를 의뢰함으로써 할 수 있습니다.Spring Web Client를 이렇게 작성하면 도청 옵션이 활성화됩니다.

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

로그 설정을 실시합니다.

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

요청/응답(본문 포함)을 모두 기록하지만 형식이 HTTP에 고유하지 않기 때문에 읽을 수 없습니다.

Spring Boot 2.4.0에서는 HttpClient의 wiretap() 메서드에는 완전한 요청/응답 헤더 및 본문을 일반 사람이 읽을 수 있는 형식으로 표시하기 위해 전달할 수 있는 추가 파라미터가 있습니다.형식(AdvancedByteBufFormat)을 사용합니다.텍스트)

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":[.....

로거를 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에 패키지 Netty의 하게 합니다.reactor.ipc.netty이 답변과 함께 작동해야 합니다.

시체를 기록하고 싶지 않다면, 이건 정말 쉬워요.

스프링 부트 > = 2.1.0

application.properties에 다음 항목을 추가합니다.

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

두 번째 행은 헤더가 로그에 포함되도록 합니다.

스프링 부트 < 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의 답변에 따라

@Matew Buckett answer Netty 。그러나 형식은 그다지 화려하지 않습니다(16진수 덤프 포함). 그것은 할 수 .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에 근거하고 있습니다.

Spring Boot 2.2.4 및 Spring 5.2.3에 대한 2020년 2월 업데이트:

에 넣을 수 있었다spring.http.log-request-details=true현재 Spring WebFlux 참조에서는 코드 예제에서는 사용되지 않는 메서드를 사용하지만 헤더를 기록하기 위해 몇 가지 코딩이 필요하다고 합니다.

권장되지 않는 메서드를 대체할 수 있는 방법이 아직 있으므로 WebClient 수준에서 헤더를 기록하기 위한 콤팩트한 코드 조각은 다음과 같습니다.

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

더 나아가서

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

, WebFlux , 、 WebFlux 、 web((((((( ( ()(((((((((((((((((((존존존존존존존존존존존존존존존 it it존존존존존존 it it it it it it it it )에서 모든 헤더를 사용할 수 .ExchangeFunctions몇 개 더합니다.HttpClient@Matthew의 제안대로 레벨도 필수일 수 있습니다.

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

더 나아가서

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

이렇게 하면 시체도 기록될 거야

이것이 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();

Exchange Filter Function만을 사용하여 요청 및 응답 본문을 기록하는 방법이 있습니다.그것은 기초와는 무관하다.ClientHttpConnector및 맞춤형 출력을 지원합니다.이치노대신 요청 및 응답 본문에 액세스할 수 있는 행에는 설명 설명이 포함됩니다. add add다 、 음 add to to to to add to to to 。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()
                );
    }

}

스포일러: 지금까지의 커스텀 로깅은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>

Stanislav Burov의 답변을 바탕으로 모든 요청/응답 헤더, 메서드, URL 및 본문을 기록하는 로거를 만들었습니다.

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();
                    }
            );

}

}

여기 스타니슬라브 부로프의 훌륭한 답변을 바탕으로 한 제 토막이 있습니다.코드를 읽기 쉽게 하기 위해 스탠드아론 클래스에 람다를 추출하고 제한된 UTF-8 인식 디코더도 구현했습니다.Guava와 Java 17의 기능을 사용하고 있습니다만, 이 코드는 이전 버전으로 쉽게 이식할 수 있습니다.요구/응답 본문 전체를 버퍼링하는 것이 아니라 버퍼가 다른 콜로 착신할 때 버퍼를 기록하기 때문에 과도한 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에서는 요청/응답 로깅을 올바르게 수행하는 것이 매우 어렵습니다.

다음과 같은 요건이 있었습니다.

  • 하나의 로그 스테이트먼트에 본문을 포함한 로그 요구 및 응답(AWS Cloudwatch에서 수백 개의 로그를 스크롤하면 모든 것을 하나의 스테이트먼트에 저장하는 것이 훨씬 편리함)
  • 로그에서 개인 데이터나 재무 데이터 등의 기밀 데이터를 필터링하여 GPR 및 PCI에 준거

따라서 Netty를 도청하거나 맞춤형 Jackson 디코더를 사용하는 것은 선택사항이 아니었다.

스타니슬라프의 훌륭한 답변에 기초한 문제에 대한 제 견해입니다.

(아래 코드는 롬복 주석 처리를 사용하고 있으며, 아직 사용하지 않으신다면 이 주석 처리도 사용하고 싶을 것입니다.그렇지 않으면 쉽게 디롬복(delombok)을 해제할 수 있습니다.)

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"}

물론 오류 조건도 적절하게 처리됩니다.

WebTestClient답은 매우 유사합니다.

final HttpClient httpClient = HttpClient
  .create()
  .wiretap(
    this.getClass().getCanonicalName(), 
    LogLevel.INFO, 
    AdvancedByteBufFormat.TEXTUAL);

final WebTestClient webTestClient = WebTestClient
  .bindToServer(new ReactorClientHttpConnector(httpClient))
  .build();

요청 또는 응답에 JSON의 시리얼 버전을 기록하는 경우 기본값을 랩하는 자체 Json Encoder/Decoder 클래스를 만들고 JSON을 기록할 수 있습니다.으로는 '아주 좋다'라는 하위 를 합니다.Jackson2JsonEncoder ★★★★★★★★★★★★★★★★★」Jackson2JsonDecoder클래스 및 시리얼 데이터를 노출하는 메서드를 덮어씁니다.

자세한 것은, https://andrew-flower.com/blog/webclient-body-logging 를 참조해 주세요.

위의 접근방식은 주로 비스트리밍 데이터에 초점을 맞추고 있습니다.데이터 스트리밍을 위해 이 작업을 수행하는 것이 더 어려울 수 있습니다.

추가 메모리/프로세싱이 필요하기 때문에 Prod 환경에서는 권장하지 않지만 개발 환경에 맞게 구성하는 것이 유용합니다.

@StasKolodyuk의 답변은 반응하는 WebClient의 응답 본문을 기록하기 위한 beldung의 솔루션에 대해 자세히 설명하고 있습니다.주의:

tc.bootstrap(...)

에서 권장되지 않습니다.

    HttpClient httpClient = HttpClient
      .create()
      .tcpConfiguration(
        tc -> tc.bootstrap(
          b -> BootstrapHandlers.updateLogSupport(b, new CustomLogger(HttpClient.class))))
      .build()

커스텀 Logging Handler를 추가하는 또 하나의 권장되지 않는 방법은 (Kotlin)입니다.

val httpClient: HttpClient = HttpClient.create().mapConnect { conn, b ->
    BootstrapHandlers.updateLogSupport(b, CustomLogger(HttpClient::class.java))
    conn
}

Handler 를 실장하는 Custom Logger Handler 를 실장하는 말아 .equals() ★★★★★★★★★★★★★★★★★」hashCode()그렇지 않으면 메모리 누수가 발생합니다.https://github.com/reactor/reactor-netty/issues/988#issuecomment-582489035

필터 기능을 사용하여 요청 및 응답 본문 페이로드 등의 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
}

}

언급URL : https://stackoverflow.com/questions/46154994/how-to-log-spring-5-webclient-call

반응형