springboot、feign接入logbook

82 阅读3分钟
<dependency>
    <groupId>org.zalando</groupId>
    <artifactId>logbook-spring-boot-starter</artifactId>
    <version>2.16.0</version>
</dependency>
server:
  env: ${APP_ENV}
  servlet:
    encoding:
      #中文乱码
      force-response: true
import com.fasterxml.jackson.databind.ObjectMapper;
import com.google.common.collect.Lists;
import feign.Request;
import feign.Response;
import lombok.*;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.cloud.context.config.annotation.RefreshScope;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.lang.NonNull;
import org.springframework.stereotype.Component;
import org.zalando.logbook.*;
import org.zalando.logbook.json.JsonHttpLogFormatter;

import javax.annotation.Nullable;
import java.io.*;
import java.net.URI;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import java.util.*;

/**
 * 请求记录
 */
@Configuration(proxyBeanMethods = false)
public class LogbookAutoExtConfiguration {

    private final LogbookAutoExtProperties logbookAutoExtProperties;

    public LogbookAutoExtConfiguration(LogbookAutoExtProperties logbookAutoExtProperties) {
        this.logbookAutoExtProperties = logbookAutoExtProperties;
    }

    @Bean
    @RefreshScope
    @ConditionalOnMissingBean(HttpLogFormatter.class)
    public HttpLogFormatter jsonFormatter(final ObjectMapper mapper) {
        return new CustomsFormatter(mapper,
                logbookAutoExtProperties.getRequests(),
                logbookAutoExtProperties.getResponses(),
                logbookAutoExtProperties.getSkipPaths());
    }

    @Bean
    @RefreshScope
    @ConditionalOnMissingBean(HttpLogWriter.class)
    public HttpLogWriter writer() {
        return new CustomsHttpLogWriter();
    }

    @Bean
    @ConditionalOnMissingBean(feign.Logger.class)
    public feign.Logger feignLogbookLogger(Logbook logbook) {
        return new FeignLogbookLogger(logbook);
    }

    @Setter
    @Getter
    @ToString
    @RefreshScope
    @Component
    @ConfigurationProperties("logbook.ext")
    public static class LogbookAutoExtProperties {

        /**
         * 打印哪些请求信息,不配打印所有
         *
         * @see StructuredHttpLogFormatter#prepare(Precorrelation, HttpRequest)
         */
        private List<String> requests = Lists.newArrayList("type", "correlation", "method", "uri", "body");
        /**
         * 打印哪些返回信息,不配打印所有
         *
         * @see StructuredHttpLogFormatter#prepare(Correlation, HttpResponse)
         */
        private List<String> responses = Lists.newArrayList("type", "correlation", "duration", "status", "body");
        private List<String> skipPaths = Lists.newArrayList("/swagger-ui.html");

    }

    public static class CustomsFormatter implements HttpLogFormatter {

        private final JsonHttpLogFormatter delegate;
        private final List<String> requests;
        private final List<String> responses;
        private final List<String> skipPaths;

        private final ThreadLocal<String> correlations = new ThreadLocal<>();


        public CustomsFormatter(ObjectMapper mapper,
                                List<String> requests,
                                List<String> responses,
                                List<String> skipPaths) {
            this.delegate = new JsonHttpLogFormatter(mapper);
            this.requests = requests == null ? new ArrayList<>() : requests;
            this.responses = responses == null ? new ArrayList<>() : responses;
            this.skipPaths = skipPaths == null ? new ArrayList<>() : skipPaths;
        }

        @Override
        public String format(@NonNull Precorrelation precorrelation,
                             @NonNull HttpRequest request) throws IOException {
            String path = request.getPath();
            if (skipPaths.contains(path)) {
                correlations.set(precorrelation.getId());
                return delegate.format(new HashMap<>());
            }
            Map<String, Object> content = delegate.prepare(precorrelation, request);
            return delegate.format(filter(content, requests));
        }

        @Override
        public String format(@NonNull Correlation correlation,
                             @NonNull HttpResponse response) throws IOException {
            if (Objects.equals(correlations.get(), correlation.getId())) {
                correlations.remove();
                return delegate.format(new HashMap<>());
            }
            Map<String, Object> content = delegate.prepare(correlation, response);
            return delegate.format(filter(content, responses));
        }

        private Map<String, Object> filter(Map<String, Object> content, List<String> filters) {
            if (filters.isEmpty()) {
                return content;
            }
            Map<String, Object> result = new LinkedHashMap<>();
            filters.forEach(k -> {
                Object value = content.get(k);
                if (value != null) {
                    result.put(k, value);
                }
            });
            return result.isEmpty() ? content : result;
        }

    }

    public static class CustomsHttpLogWriter implements HttpLogWriter {

        private final Logger log = LoggerFactory.getLogger(Logbook.class);

        @Override
        public boolean isActive() {
            return log.isInfoEnabled();
        }

        @Override
        public void write(@NonNull final Precorrelation precorrelation, @NonNull final String request) {
            log.info(request);
        }

        @Override
        public void write(@NonNull final Correlation correlation, @NonNull final String response) {
            log.info(response);
        }

    }

    /**
     * copy
     * org.zalando.logbook.openfeign.FeignLogbookLogger
     */
    @AllArgsConstructor
    public static class FeignLogbookLogger extends feign.Logger {

        private final Logbook logbook;

        private final ThreadLocal<Logbook.ResponseProcessingStage> stage = new ThreadLocal<>();

        @Override
        @Generated
        protected void log(String configKey, String format, Object... args) {
        }

        @Override
        protected void logRetry(String configKey, Level logLevel) {
        }

        @Override
        protected IOException logIOException(String configKey, Level logLevel, IOException ioe, long elapsedTime) {
            return ioe;
        }

        @Override
        protected void logRequest(String configKey, Level logLevel, Request request) {
            final HttpRequest httpRequest = LocalRequest.create(request);
            try {
                Logbook.ResponseProcessingStage processingStage = logbook.process(httpRequest).write();
                stage.set(processingStage);
            } catch (IOException e) {
                throw new UncheckedIOException(e);
            }
        }

        @Override
        protected Response logAndRebufferResponse(String configKey, Level logLevel, Response response, long elapsedTime) {
            try {
                byte[] body = response.body() != null ? toByteArray(response.body().asInputStream()) : null;
                final HttpResponse httpResponse = RemoteResponse.create(response, body);
                stage.get().process(httpResponse).write();
                return Response.builder()
                        .status(response.status())
                        .request(response.request())
                        .reason(response.reason())
                        .headers(response.headers())
                        .body(body)
                        .build();
            } catch (IOException e) {
                throw new UncheckedIOException(e);
            }
        }

        static HttpHeaders toLogbookHeaders(Map<String, Collection<String>> feignHeaders) {
            Map<String, List<String>> convertedHeaders = new HashMap<>();
            for (Map.Entry<String, Collection<String>> header : feignHeaders.entrySet()) {
                convertedHeaders.put(header.getKey(), new ArrayList<>(header.getValue()));
            }
            return HttpHeaders.of(convertedHeaders);
        }

        static byte[] toByteArray(final InputStream in) throws IOException {
            final ByteArrayOutputStream out = new ByteArrayOutputStream();
            copy(in, out);
            return out.toByteArray();
        }

        static void copy(final InputStream from, final OutputStream to) throws IOException {
            final byte[] buf = new byte[4096];
            while (true) {
                final int r = from.read(buf);
                if (r == -1) {
                    break;
                }
                to.write(buf, 0, r);
            }
        }

    }

    @RequiredArgsConstructor
    static class LocalRequest implements HttpRequest {
        private final URI uri;
        private final Request.HttpMethod httpMethod;
        private final HttpHeaders headers;
        private final byte[] body;
        private final Charset charset;
        private boolean withBody = false;

        public static LocalRequest create(Request request) {
            return new LocalRequest(
                    URI.create(request.url()),
                    request.httpMethod(),
                    FeignLogbookLogger.toLogbookHeaders(request.headers()),
                    request.body(),
                    request.charset()
            );
        }

        @Override
        public String getRemote() {
            return "localhost";
        }

        @Override
        public String getMethod() {
            return httpMethod.toString();
        }

        @Override
        public String getScheme() {
            return uri.getScheme() == null ? "" : uri.getScheme();
        }

        @Override
        public String getHost() {
            return uri.getHost() == null ? "" : uri.getHost();
        }

        @Override
        public Optional<Integer> getPort() {
            return Optional.of(uri).map(URI::getPort).filter(p -> p != -1);
        }

        @Override
        public String getPath() {
            return uri.getPath() == null ? "" : uri.getPath();
        }

        @Override
        public String getQuery() {
            return uri.getQuery() == null ? "" : uri.getQuery();
        }

        @Override
        public HttpRequest withBody() {
            withBody = true;
            return this;
        }

        @Override
        public HttpRequest withoutBody() {
            withBody = false;
            return this;
        }

        @Override
        public String getProtocolVersion() {
            return "HTTP/1.1";
        }

        @Override
        public Origin getOrigin() {
            return Origin.LOCAL;
        }

        @Override
        public HttpHeaders getHeaders() {
            return headers;
        }

        @Nullable
        @Override
        public String getContentType() {
            return Optional.ofNullable(headers.get("Content-Type"))
                    .flatMap(ct -> ct.stream().findFirst())
                    .orElse(null);
        }

        @Override
        public Charset getCharset() {
            return charset == null ? StandardCharsets.UTF_8 : charset;
        }

        @Override
        public byte[] getBody() {
            return withBody && body != null ? body : new byte[0];
        }

    }

    @RequiredArgsConstructor
    static class RemoteResponse implements HttpResponse {
        private final int status;
        private final HttpHeaders headers;
        private final byte[] body;
        private final Charset charset;
        private boolean withBody = false;

        public static RemoteResponse create(Response response, byte[] body) {
            return new RemoteResponse(
                    response.status(),
                    FeignLogbookLogger.toLogbookHeaders(response.headers()),
                    body,
                    StandardCharsets.UTF_8
            );
        }

        @Override
        public int getStatus() {
            return status;
        }

        @Override
        public String getProtocolVersion() {
            return "HTTP/1.1";
        }

        @Override
        public Origin getOrigin() {
            return Origin.REMOTE;
        }

        @Override
        public HttpHeaders getHeaders() {
            return headers;
        }

        @Nullable
        @Override
        public String getContentType() {
            return Optional.ofNullable(headers.get("Content-Type"))
                    .flatMap(ct -> ct.stream().findFirst())
                    .orElse(null);
        }

        @Override
        public Charset getCharset() {
            return charset;
        }

        @Override
        public HttpResponse withBody() {
            withBody = true;
            return this;
        }

        @Override
        public RemoteResponse withoutBody() {
            withBody = false;
            return this;
        }

        @Override
        public byte[] getBody() {
            return withBody && body != null ? body : new byte[0];
        }
    }

}