Spring Cloud Gateway 代理日志记录 Filter

有时候客户端会有莫名其妙的问题需要服务端辅助定位,这时候有一份完全的请求的信息的日志会非常有帮助,这里提供一种基于过滤器的实现。代码见:https://github.com/giafei/gateway-request-recorder-starter


过滤器

package net.giafei.gateway.filter.logger;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.cloud.gateway.filter.GatewayFilterChain;
import org.springframework.cloud.gateway.filter.GlobalFilter;
import org.springframework.core.Ordered;
import org.springframework.core.io.buffer.*;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.server.reactive.ServerHttpRequest;
import org.springframework.lang.Nullable;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;

import java.net.URI;
import java.nio.CharBuffer;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;

import static org.springframework.cloud.gateway.support.ServerWebExchangeUtils.GATEWAY_REQUEST_URL_ATTR;

@Component
public class RequestRecorderGlobalFilter implements GlobalFilter, Ordered {
    private Logger logger = LoggerFactory.getLogger("requestRecorder");
    private final static String REQUEST_RECORDER_LOG_BUFFER = "RequestRecorderGlobalFilter.request_recorder_log_buffer";

    @Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
        ServerHttpRequest originalRequest = exchange.getRequest();
        URI originalRequestUrl = originalRequest.getURI();

        //只记录http的请求
        String scheme = originalRequestUrl.getScheme();
        if ((!"http".equals(scheme) && !"https".equals(scheme))) {
            return chain.filter(exchange);
        }

        RecorderServerHttpResponseDecorator response = new RecorderServerHttpResponseDecorator(exchange.getResponse());

        ServerWebExchange ex = exchange.mutate()
                .request(new RecorderServerHttpRequestDecorator(exchange.getRequest()))
                .response(response)
                .build();

        response.subscribe(
                Mono.defer(() -> recorderRouteRequest(ex)).then(
                        Mono.defer(() -> recorderResponse(ex))
                )
        );
        return recorderOriginalRequest(ex)
                .then(chain.filter(ex))
                .then();
    }

    private Mono<Void> writeLog(ServerWebExchange exchange) {
        StringBuffer logBuffer = exchange.getAttribute(REQUEST_RECORDER_LOG_BUFFER);
        logBuffer.append("\n------------ end at ")
                .append(System.currentTimeMillis())
                .append("------------\n\n");

        logger.info(logBuffer.toString());
        return Mono.empty();
    }

    private Mono<Void> recorderOriginalRequest(ServerWebExchange exchange) {
        StringBuffer logBuffer = new StringBuffer("\n------------开始时间 ")
                .append(System.currentTimeMillis())
                .append("------------");
        exchange.getAttributes().put(REQUEST_RECORDER_LOG_BUFFER, logBuffer);

        ServerHttpRequest request = exchange.getRequest();
        return recorderRequest(request, request.getURI(), logBuffer.append("\n原始请求:\n"));
    }

    private Mono<Void> recorderRouteRequest(ServerWebExchange exchange) {
        URI requestUrl = exchange.getRequiredAttribute(GATEWAY_REQUEST_URL_ATTR);
        StringBuffer logBuffer = exchange.getAttribute(REQUEST_RECORDER_LOG_BUFFER);

        return recorderRequest(exchange.getRequest(), requestUrl, logBuffer.append("代理请求:\n"));
    }

    private Mono<Void> recorderRequest(ServerHttpRequest request, URI uri, StringBuffer logBuffer) {
        if (uri == null) {
            uri = request.getURI();
        }

        HttpMethod method = request.getMethod();
        HttpHeaders headers = request.getHeaders();

        logBuffer
                .append(method.toString()).append(' ')
                .append(uri.toString()).append('\n');

        logBuffer.append("------------请求头------------\n");
        headers.forEach((name, values) -> {
            values.forEach(value -> {
                logBuffer.append(name).append(":").append(value).append('\n');
            });
        });

        Charset bodyCharset = null;
        if (hasBody(method)) {
            long length = headers.getContentLength();
            if (length <= 0) {
                logBuffer.append("------------无body------------\n");
            } else {
                logBuffer.append("------------body 长度:").append(length).append(" contentType:");
                MediaType contentType = headers.getContentType();
                if (contentType == null) {
                    logBuffer.append("null,不记录body------------\n");
                } else if (!shouldRecordBody(contentType)) {
                    logBuffer.append(contentType.toString()).append(",不记录body------------\n");
                } else {
                    bodyCharset = getMediaTypeCharset(contentType);
                    logBuffer.append(contentType.toString()).append("------------\n");
                }
            }
        }


        if (bodyCharset != null) {
            return doRecordBody(logBuffer, request.getBody(), bodyCharset)
                    .then(Mono.defer(() -> {
                        logBuffer.append("\n------------ end ------------\n\n");
                        return Mono.empty();
                    }));
        } else {
            logBuffer.append("------------ end ------------\n\n");
            return Mono.empty();
        }
    }

    private Mono<Void> recorderResponse(ServerWebExchange exchange) {
        RecorderServerHttpResponseDecorator response = (RecorderServerHttpResponseDecorator)exchange.getResponse();
        StringBuffer logBuffer = exchange.getAttribute(REQUEST_RECORDER_LOG_BUFFER);

        HttpStatus code = response.getStatusCode();
        logBuffer.append("响应:").append(code.value()).append(" ").append(code.getReasonPhrase()).append('\n');

        HttpHeaders headers = response.getHeaders();
        logBuffer.append("------------响应头------------\n");
        headers.forEach((name, values) -> {
            values.forEach(value -> {
                logBuffer.append(name).append(":").append(value).append('\n');
            });
        });

        Charset bodyCharset = null;
        MediaType contentType = headers.getContentType();
        if (contentType == null) {
            logBuffer.append("------------ contentType = null,不记录body------------\n");
        } else if (!shouldRecordBody(contentType)) {
            logBuffer.append("------------不记录body------------\n");
        } else {
            bodyCharset = getMediaTypeCharset(contentType);
            logBuffer.append("------------body------------\n");
        }

        if (bodyCharset != null) {
            return doRecordBody(logBuffer, response.copy(), bodyCharset)
                    .then(Mono.defer(() -> writeLog(exchange)));
        } else {
            return writeLog(exchange);
        }
    }

    @Override
    public int getOrder() {
        //在GatewayFilter之前执行
        return - 1;
    }

    private boolean hasBody(HttpMethod method) {
        //只记录这3种谓词的body
        if (method == HttpMethod.POST || method == HttpMethod.PUT || method == HttpMethod.PATCH)
            return true;

        return false;
    }

    //记录简单的常见的文本类型的request的body和response的body
    private boolean shouldRecordBody(MediaType contentType) {
        String type = contentType.getType();
        String subType = contentType.getSubtype();

        if ("application".equals(type)) {
            return "json".equals(subType) || "x-www-form-urlencoded".equals(subType) || "xml".equals(subType) || "atom+xml".equals(subType) || "rss+xml".equals(subType);
        } else if ("text".equals(type)) {
            return true;
        }

        //暂时不记录form
        return false;
    }

    private Mono<Void> doRecordBody(StringBuffer logBuffer, Flux<DataBuffer> body, Charset charset) {
        return DataBufferUtils.join(body).doOnNext(buffer -> {
                CharBuffer charBuffer = charset.decode(buffer.asByteBuffer());
                logBuffer.append(charBuffer.toString());
                DataBufferUtils.release(buffer);
        }).then();
    }

    private Charset getMediaTypeCharset(@Nullable MediaType mediaType) {
        if (mediaType != null && mediaType.getCharset() != null) {
            return mediaType.getCharset();
        }
        else {
            return StandardCharsets.UTF_8;
        }
    }
}


辅助类 RecorderServerHttpRequestDecorator

package net.giafei.gateway.filter.logger;

import org.springframework.core.io.buffer.DataBuffer;
import org.springframework.http.server.reactive.ServerHttpRequest;
import org.springframework.http.server.reactive.ServerHttpRequestDecorator;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;

import java.util.LinkedList;
import java.util.List;

//解决response的body只能读一次的问题
public class RecorderServerHttpRequestDecorator extends ServerHttpRequestDecorator {
    private final List<DataBuffer> dataBuffers = new LinkedList<>();
    private boolean bufferCached = false;
    private Mono<Void> progress = null;

    public RecorderServerHttpRequestDecorator(ServerHttpRequest delegate) {
        super(delegate);
    }

    @Override
    public Flux<DataBuffer> getBody() {
        synchronized (dataBuffers) {
            if (bufferCached)
                return copy();

            if (progress == null) {
                progress = cache();
            }

            return progress.thenMany(Flux.defer(this::copy));
        }
    }

    private Flux<DataBuffer> copy() {
        return Flux.fromIterable(dataBuffers)
                .map(buf -> buf.factory().wrap(buf.asByteBuffer()));
    }

    private Mono<Void> cache() {
        return super.getBody()
                .map(dataBuffers::add)
                .then(Mono.defer(()-> {
                    bufferCached = true;
                    progress = null;

                    return Mono.empty();
                }));
    }
}
import net.giafei.tools.filter.util.DataBufferUtilFix;
import net.giafei.tools.filter.util.DataBufferWrapper;
import org.reactivestreams.Publisher;
import org.springframework.core.io.buffer.DataBuffer;
import org.springframework.http.server.reactive.ServerHttpResponse;
import org.springframework.http.server.reactive.ServerHttpResponseDecorator;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;

public class RecorderServerHttpResponseDecorator extends ServerHttpResponseDecorator {
    private DataBufferWrapper data = null;

    public RecorderServerHttpResponseDecorator(ServerHttpResponse delegate) {
        super(delegate);
    }

    @Override
    public Mono<Void> writeWith(Publisher<? extends DataBuffer> body) {
        return DataBufferUtilFix.join(Flux.from(body))
                .doOnNext(d -> this.data = d)
                .flatMap(d -> super.writeWith(copy()));
    }

    @Override
    public Mono<Void> writeAndFlushWith(Publisher<? extends Publisher<? extends DataBuffer>> body) {
        return writeWith(Flux.from(body)
                .flatMapSequential(p -> p));
    }

    public Flux<DataBuffer> copy() {
        //如果data为null 就出错了 正好可以调试
        DataBuffer buffer = this.data.newDataBuffer();
        if (buffer == null)
            return Flux.empty();

        return Flux.just(buffer);
    }
}

路由配置

spring:
  cloud:
    gateway:
      routes:
      - id: gloabl_filter
        uri: http://localhost:4101
        predicates:
        - Path=/filter/**
        filters:
        - StripPrefix=1

      - id: no_filter
        uri: http://localhost:4101
        predicates:
        - Path=/no-filter/{test}
        filters:
        - SetPath=/{test}
        - IgnoreTestGlobalFilter

      - id: img
        uri: http://httpbin.org:80
        predicates:
        - Path=/image/*
        filters:
        - IgnoreTestGlobalFilter

效果

------------开始时间 1533963520775------------
原始请求:
GET http://localhost:8080/filter/echo?a=1&b=2
------------请求头------------
cache-control:no-cache
Postman-Token:3ceae0d1-9f3f-42bc-85c1-ebea10950c46
User-Agent:PostmanRuntime/7.2.0
Accept:*/*
Host:localhost:8080
accept-encoding:gzip, deflate
Connection:keep-alive
------------ end ------------

代理请求:
GET http://localhost:4101/echo?a=1&b=2&throwFilter=true
------------请求头------------
cache-control:no-cache
Postman-Token:3ceae0d1-9f3f-42bc-85c1-ebea10950c46
User-Agent:PostmanRuntime/7.2.0
Accept:*/*
Host:localhost:8080
accept-encoding:gzip, deflate
Connection:keep-alive
------------ end ------------

响应:200 OK
------------响应头------------
Content-Type:application/json;charset=UTF-8
Date:Sat, 11 Aug 2018 04:58:40 GMT
------------body------------
{"a":["1"],"b":["2"],"throwFilter":["true"]}
------------ end at 1533963520873------------
------------开始时间 1533963577778------------
原始请求:
POST http://localhost:8080/filter/echo?a=1&b=2
------------请求头------------
Content-Type:application/json
cache-control:no-cache
Postman-Token:69498eea-4270-4ed7-b374-5e15e760cd10
User-Agent:PostmanRuntime/7.2.0
Accept:*/*
Host:localhost:8080
accept-encoding:gzip, deflate
content-length:14
Connection:keep-alive
------------body 长度:14 contentType:application/json------------
{"a":1, "b":2}
------------ end ------------

代理请求:
POST http://localhost:4101/echo?a=1&b=2&throwFilter=true
------------请求头------------
Content-Type:application/json
cache-control:no-cache
Postman-Token:69498eea-4270-4ed7-b374-5e15e760cd10
User-Agent:PostmanRuntime/7.2.0
Accept:*/*
Host:localhost:8080
accept-encoding:gzip, deflate
content-length:14
Connection:keep-alive
------------body 长度:14 contentType:application/json------------
{"a":1, "b":2}
------------ end ------------

响应:200 OK
------------响应头------------
Content-Type:text/plain;charset=UTF-8
Content-Length:14
Date:Sat, 11 Aug 2018 04:59:37 GMT
------------body------------
}2:"b" ,1:"a"{
------------ end at 1533963577796------------
------------开始时间 1533963706176------------
原始请求:
GET http://localhost:8080/image/webp
------------请求头------------
cache-control:no-cache
Postman-Token:01562f0b-9f28-4eda-8095-398991f7d537
User-Agent:PostmanRuntime/7.2.0
Accept:*/*
Host:localhost:8080
accept-encoding:gzip, deflate
Connection:keep-alive
------------ end ------------

代理请求:
GET http://httpbin.org:80/image/webp
------------请求头------------
cache-control:no-cache
Postman-Token:01562f0b-9f28-4eda-8095-398991f7d537
User-Agent:PostmanRuntime/7.2.0
Accept:*/*
Host:localhost:8080
accept-encoding:gzip, deflate
Connection:keep-alive
------------ end ------------

响应:200 OK
------------响应头------------
Server:gunicorn/19.9.0
Date:Sat, 11 Aug 2018 05:01:44 GMT
Content-Type:image/webp
Content-Length:10568
Access-Control-Allow-Origin:*
Access-Control-Allow-Credentials:true
Via:1.1 vegur
------------不记录body------------

------------ end at 1533963706808------------
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,294评论 6 493
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,493评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,790评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,595评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,718评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,906评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,053评论 3 410
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,797评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,250评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,570评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,711评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,388评论 4 332
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,018评论 3 316
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,796评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,023评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,461评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,595评论 2 350

推荐阅读更多精彩内容