• Spring Cloud Gateway实战WebFlux解析请求体及抛出指定错误代码和信息


    概述

    基于Spring Cloud开发微服务时,使用Spring Cloud原生自带的Gateway作为网关,所有请求都需要经过网关服务转发。

    为了防止恶意请求刷取数据,对于业务请求需要进行拦截,故而可在网关服务增加拦截过滤器。基于此,有如下源码:

    
    @Slf4j
    @Component
    public class BlockListFilter extends AbstractGatewayFilterFactory {
    	private static final String DIALOG_URI = "/dialog/nextQuestion";
        @Resource
        private AssessmentBlockListService assessmentBlockListService;
        @Lazy
        @Resource
        private RemoteUserService remoteUserService;
        @Lazy
        @Resource
        private RemoteRcService remoteRcService;
        @Lazy
        @Autowired
        private RemoteOAuthService remoteOAuthService;
        @Value("${blockListSwitch:true}")
        private Boolean blockListSwitch;
    
        @Override
        public GatewayFilter apply(Object config) {
            return (exchange, chain) -> {
                ServerHttpResponse response = exchange.getResponse();
                response.getHeaders().setContentType(MediaType.APPLICATION_JSON_UTF8);
                ServerHttpRequest serverHttpRequest = exchange.getRequest();
                String uri = serverHttpRequest.getURI().getPath();
                HttpHeaders httpHeaders = serverHttpRequest.getHeaders();
                // 只处理相关url
                if (blockListSwitch && StringUtils.equalsIgnoreCase(uri, DIALOG_URI)) {
                    String token = httpHeaders.getFirst("Authorization");
                    String pureToken = StringUtils.replaceIgnoreCase(token, "Bearer ", "");
                    BaseUserInfo baseUserInfo = UserUtil.getBaseUserInfo(pureToken);
                    if (baseUserInfo == null) {
                        log.warn("传入的token非法:{}", token);
                        return chain.filter(exchange);
                    }
                    // 从JWT token中解析用户信息(不含mobile等敏感信息)
                    String channel = baseUserInfo.getChannel();
                    String userKey = baseUserInfo.getUserkey();
                    UserParam userParam = new UserParam();
                    userParam.setKey(userKey);
                    userParam.setChannel(channel);
                    // Feign远程请求user服务获取mobile信息
                    Response<UserAccountVO> userAccountResponse = remoteUserService.baseQuery(userParam);
                    if (userAccountResponse.getCode() != 0) {
                        log.warn("未获取到userKey={}的用户信息!", userKey);
                        return chain.filter(exchange);
                    }
                    UserAccountVO userAccountVO = userAccountResponse.getData();
                    log.info("blocklist filter user={}", JsonUtil.beanToJson(userAccountVO));
                    String mobile = userAccountVO.getMobile();
                    if (StringUtils.isNotBlank(userKey)) {
                    	// 具体的拦截业务逻辑
                        this.process(uri, userKey, mobile, channel, pureToken);
                    }
                    return chain.filter(exchange);
                }
                return chain.filter(exchange);
            };
        }
    
    	private String process(String uri, String userKey, String mobile, String channel, String token) {
            DetectUserDTO detectUserDTO = new DetectUserDTO();
            detectUserDTO.setChannel(channel);
            detectUserDTO.setMobile(mobile);
            detectUserDTO.setUserKey(userKey);
            detectUserDTO.setUri(uri);
            Response<DetectUserVO> detectUserVOResponse = remoteRcService.detectUser(detectUserDTO);
            if (detectUserVOResponse.getCode() != 0) {
                log.warn("mobile={} 风控接口返回异常:{}", mobile, JsonUtil.beanToJson(detectUserVOResponse));
                return null;
            }
            DetectUserVO detectUserVO = detectUserVOResponse.getData();
            if (detectUserVO.getIsInAllowList()) {
                log.info("在白名单中,放行");
            } else if (detectUserVO.getIsInBlockList()) {
                log.info("在黑名单中,拦截处理...");
                this.logout(token);
                return "当前手机号问诊次数已达今日上限!";
            }
            return null;
        }
        
        private void logout(String token) {
            // 强制下线,踢出登录态
            LogoutDto logoutDto = new LogoutDto();
            logoutDto.setToken(pureToken);
            remoteOAuthService.logout(logoutDto);
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50
    • 51
    • 52
    • 53
    • 54
    • 55
    • 56
    • 57
    • 58
    • 59
    • 60
    • 61
    • 62
    • 63
    • 64
    • 65
    • 66
    • 67
    • 68
    • 69
    • 70
    • 71
    • 72
    • 73
    • 74
    • 75
    • 76
    • 77
    • 78
    • 79
    • 80
    • 81
    • 82
    • 83
    • 84
    • 85
    • 86
    • 87
    • 88
    • 89
    • 90

    上面的代码只是实现:判断流量请求URL,进而判断用户是否触发风控黑名单机制,如果触发黑名单,则踢出登录态强制用户下线,即用户不能使用App。

    需求

    异常捕获

    上面的踢出登录态做法过于简单粗暴,App有若干个功能模块,某些请求URL触发黑名单机制,就强制用户下线,不能使用App其他功能。应该允许用户使用除了触发黑名单模块的其他模块功能。当然,对于是否强制下线的交互设计,每个人有每个人的看法。

    就我遇到的情况来细说,痛点在于,前端(所谓大前端概念,含iOS和安卓App)同学针对此种情况直接返回统一的错误页:【抱歉,请返回再试一次!】,并且这个页面没有任何UI设计,仅仅是前面提到的一句提示文案。用户看到这个文案,会重新登录(因为后端做了踢出登录态逻辑),App成功后经过网关校验,发现用户依旧触发黑名单,然后再次踢出登录态,陷入死循环。

    基于此,做如下改造:不踢出登录态,可以继续使用其他模块功能,在使用某个触发黑名单机制的模块时后端返回错误码,前端弹窗提示【无法使用此功能】。

    故而对上面的代码进行调整:

    String msg = this.process(uri, userKey, mobile, channel, pureToken,);
    if (StringUtils.isNotBlank(msg)) {
        return Mono.error(new CustomException(BlockTypeEnum.getCodeByMsg(msg), msg));
    }
    
    • 1
    • 2
    • 3
    • 4

    本地启动Gateway网关服务,postman模拟请求,得到接口返回数据:
    在这里插入图片描述
    咦?怎么Code不是枚举类里定义的错误文案msg对应的错误码,而是500呢?

    看代码,发现有个继承DefaultErrorWebExceptionHandler的JsonErrorWebExceptionHandler类,那这个类也需要调整:

    
    @Slf4j
    public class JsonErrorWebExceptionHandler extends DefaultErrorWebExceptionHandler {
    
        public JsonErrorWebExceptionHandler(ErrorAttributes errorAttributes,
                                            ResourceProperties resourceProperties,
                                            ErrorProperties errorProperties,
                                            ApplicationContext applicationContext) {
            super(errorAttributes, resourceProperties, errorProperties, applicationContext);
        }
    
        @Override
        protected Map<String, Object> getErrorAttributes(ServerRequest request, boolean includeStackTrace) {
            Map<String, Object> errorAttributes = new HashMap<>(8);
            Throwable error = super.getError(request);
            errorAttributes.put("message", error.getMessage());
            if (error instanceof CustomException) {
                errorAttributes.put("code", ((CustomException) error).getCode());
            } else {
                errorAttributes.put("code", HttpStatus.INTERNAL_SERVER_ERROR.value());
            }
            errorAttributes.put("method", request.methodName());
            errorAttributes.put("path", request.path());
            log.warn("网关异常,path:{},method:{},message:{}", request.path(), request.methodName(), error.getMessage());
            return errorAttributes;
        }
    
        @Override
        protected RouterFunction<ServerResponse> getRoutingFunction(ErrorAttributes errorAttributes) {
            return RouterFunctions.route(RequestPredicates.all(), this::renderErrorResponse);
        }
    
        @Override
        protected HttpStatus getHttpStatus(Map<String, Object> errorAttributes) {
            // 这里其实可以根据errorAttributes里面的属性定制HTTP响应码
            return HttpStatus.INTERNAL_SERVER_ERROR;
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38

    经过调整后,断点调试,万事大吉:
    在这里插入图片描述
    但是!!!

    后来在和前端联调时才发现有点不对劲:
    在这里插入图片描述
    如上图,枚举类里有几个code,每个code都有对应的系统功能模块黑名单msg。之前没有注意到右上角的接口状态码是500,这个500表示请求未成功。也就是说,接口未成功,接口响应码是自定义的code也没啥用。status必须得是200、204、202这种才行。

    总结一下:虽然使用Mono.error()返回业务自定义错误信息,但错误代码9996被转为500。

    继续排查。找到下面参考2里的文章,做如下调整:

    String msg = this.process(uri, userKey, mobile, channel, pureToken,);
    if (StringUtils.isNotBlank(msg)) {
    	// 对应200,表明接口请求是成功的,但是触发业务异常错误码
    	exchange.getResponse().setStatusCode(HttpStatus.OK);
        return exchange.getResponse()
                            .writeWith(Flux.just(exchange.getResponse()
                                    .bufferFactory()
                                    .wrap(JSON.toJSONString(msg).getBytes())));
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9

    调试结果,右上角的Status变成200:
    在这里插入图片描述
    注意看,Postman可以返回中文msg。

    发布测试环境后,在Chrome浏览器里却发现返回数据乱码!?
    在这里插入图片描述
    IDEA调整截图如上没有乱码问题,console控制台打印也是正常:
    在这里插入图片描述
    乱码不是什么大问题,研发这么多年遇到无数次。继续排查,做如下调整解决问题:

    return exchange.getResponse()
                   .writeWith(Flux.just(exchange.getResponse()
                   .bufferFactory().wrap(JSON.toJSONString(msg).getBytes(StandardCharsets.UTF_8))));
    
    • 1
    • 2
    • 3

    获取请求体

    前面提到,我们的App有几个功能模块,其中一个模块的请求全部dialog/nextQuestion接口。用户恶意刷数据,多次请求此接口就会触发黑名单。但是在我们的交互设计上,又希望用户从其他功能模块切换到此功能时,可以请求一次此接口。那怎么判断是第一次呢?那就需要解析requestBody。

    根据下面的参考1文章,增加如下代码:

    public String resolveBodyForDialog(ServerHttpRequest serverHttpRequest) {
        String uri = serverHttpRequest.getURI().getPath();
        // 只有某些请求才解析
        if (!StringUtils.equalsAnyIgnoreCase(uri, "dialog/nextQuestion")) {
            return "";
        }
        StringBuilder sb = new StringBuilder();
        Flux<DataBuffer> body = serverHttpRequest.getBody();
        body.subscribe(buffer -> {
            byte[] bytes = new byte[buffer.readableByteCount()];
            buffer.read(bytes);
            DataBufferUtils.release(buffer);
            String bodyString = new String(bytes, StandardCharsets.UTF_8);
            sb.append(bodyString);
        });
        return sb.toString();
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17

    本地调试时没有问题,可以获取到requestBody:
    在这里插入图片描述
    但是测试环境却又问题:
    在这里插入图片描述
    详细的错误日志:

    500 Server Error for HTTP POST "/api/open/dialog/nextQuestion"
    io.netty.handler.codec.EncoderException: io.netty.util.IllegalReferenceCountException: refCnt: 0, decrement: 1
    at io.netty.handler.codec.MessageToMessageEncoder.write(MessageToMessageEncoder.java:107)
    at io.netty.channel.CombinedChannelDuplexHandler.write(CombinedChannelDuplexHandler.java:348)
    at io.netty.channel.AbstractChannelHandlerContext.invokeWrite0(AbstractChannelHandlerContext.java:716)
    at io.netty.channel.AbstractChannelHandlerContext.invokeWrite(AbstractChannelHandlerContext.java:708)
    at io.netty.channel.AbstractChannelHandlerContext.write(AbstractChannelHandlerContext.java:791)
    at io.netty.channel.AbstractChannelHandlerContext.write(AbstractChannelHandlerContext.java:701)
    at reactor.netty.channel.MonoSendMany$SendManyInner.run(MonoSendMany.java:286)
    at reactor.netty.channel.MonoSendMany$SendManyInner.trySchedule(MonoSendMany.java:368)
    at reactor.netty.channel.MonoSendMany$SendManyInner.onSubscribe(MonoSendMany.java:221)
    at reactor.core.publisher.FluxMapFuseable$MapFuseableSubscriber.onSubscribe(FluxMapFuseable.java:90)
    at reactor.core.publisher.FluxContextStart$ContextStartSubscriber.onSubscribe(FluxContextStart.java:97)
    at reactor.core.publisher.MonoJust.subscribe(MonoJust.java:54)
    at reactor.core.publisher.MonoSubscriberContext.subscribe(MonoSubscriberContext.java:47)
    at reactor.core.publisher.FluxSourceMonoFuseable.subscribe(FluxSourceMonoFuseable.java:38)
    at reactor.core.publisher.FluxMapFuseable.subscribe(FluxMapFuseable.java:63)
    at reactor.core.publisher.Flux.subscribe(Flux.java:7921)
    at reactor.netty.channel.MonoSendMany.subscribe(MonoSendMany.java:81)
    at reactor.core.publisher.MonoIgnoreThen$ThenIgnoreMain.drain(MonoIgnoreThen.java:153)
    at reactor.core.publisher.MonoIgnoreThen.subscribe(MonoIgnoreThen.java:56)
    at reactor.core.publisher.Mono.subscribe(Mono.java:3848)
    at reactor.netty.NettyOutbound.subscribe(NettyOutbound.java:305)
    at reactor.core.publisher.MonoSource.subscribe(MonoSource.java:51)
    at reactor.core.publisher.MonoDefer.subscribe(MonoDefer.java:52)
    at reactor.netty.http.client.HttpClientConnect$HttpIOHandlerObserver.onStateChange(HttpClientConnect.java:441)
    at reactor.netty.ReactorNetty$CompositeConnectionObserver.onStateChange(ReactorNetty.java:470)
    at reactor.netty.resources.PooledConnectionProvider$DisposableAcquire.onStateChange(PooledConnectionProvider.java:512)
    at reactor.netty.resources.PooledConnectionProvider$PooledConnection.onStateChange(PooledConnectionProvider.java:451)
    at reactor.netty.channel.ChannelOperationsHandler.channelActive(ChannelOperationsHandler.java:62)
    at io.netty.channel.AbstractChannelHandlerContext.invokeChannelActive(AbstractChannelHandlerContext.java:225)
    at io.netty.channel.AbstractChannelHandlerContext.invokeChannelActive(AbstractChannelHandlerContext.java:211)
    at io.netty.channel.AbstractChannelHandlerContext.fireChannelActive(AbstractChannelHandlerContext.java:204)
    at io.netty.channel.CombinedChannelDuplexHandler$DelegatingChannelHandlerContext.fireChannelActive(CombinedChannelDuplexHandler.java:414)
    at io.netty.channel.ChannelInboundHandlerAdapter.channelActive(ChannelInboundHandlerAdapter.java:69)
    at io.netty.channel.CombinedChannelDuplexHandler.channelActive(CombinedChannelDuplexHandler.java:213)
    at io.netty.channel.AbstractChannelHandlerContext.invokeChannelActive(AbstractChannelHandlerContext.java:225)
    at io.netty.channel.AbstractChannelHandlerContext.invokeChannelActive(AbstractChannelHandlerContext.java:211)
    at io.netty.channel.AbstractChannelHandlerContext.fireChannelActive(AbstractChannelHandlerContext.java:204)
    at io.netty.channel.DefaultChannelPipeline$HeadContext.channelActive(DefaultChannelPipeline.java:1396)
    at io.netty.channel.AbstractChannelHandlerContext.invokeChannelActive(AbstractChannelHandlerContext.java:225)
    at io.netty.channel.AbstractChannelHandlerContext.invokeChannelActive(AbstractChannelHandlerContext.java:211)
    at io.netty.channel.DefaultChannelPipeline.fireChannelActive(DefaultChannelPipeline.java:906)
    at io.netty.channel.epoll.AbstractEpollChannel$AbstractEpollUnsafe.fulfillConnectPromise(AbstractEpollChannel.java:618)
    at io.netty.channel.epoll.AbstractEpollChannel$AbstractEpollUnsafe.finishConnect(AbstractEpollChannel.java:651)
    at io.netty.channel.epoll.AbstractEpollChannel$AbstractEpollUnsafe.epollOutReady(AbstractEpollChannel.java:527)
    at io.netty.channel.epoll.EpollEventLoop.processReady(EpollEventLoop.java:422)
    at io.netty.channel.epoll.EpollEventLoop.run(EpollEventLoop.java:333)
    at io.netty.util.concurrent.SingleThreadEventExecutor$5.run(SingleThreadEventExecutor.java:906)
    at io.netty.util.internal.ThreadExecutorMap$2.run(ThreadExecutorMap.java:74)
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50

    具体研究这篇文章,做如下调整:

    String uri = serverHttpRequest.getURI().getPath();
    // 只有某些请求才解析
    if (!StringUtils.equalsAnyIgnoreCase(uri, DIALOG_URI)) {
        return "";
    }
    Flux<DataBuffer> body = serverHttpRequest.getBody();
    AtomicReference<String> bodyRef = new AtomicReference<>();
    body.subscribe(buffer -> {
        CharBuffer charBuffer = StandardCharsets.UTF_8.decode(buffer.asByteBuffer());
        bodyRef.set(charBuffer.toString());
    });
    return bodyRef.get();
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12

    上面的报错消失。问题虽然是解决,但是不明就里。

    后面又发现一个乱码问题,针对如下requestBody:

    {
      "stateId": "DASHBOARD",
      "answer": {
        "transitionId": "GET_HEALTH_ADVICE",
        "label": "开始评估症状"
      }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7

    bodyRef.get()获取到的中文数据乱码。参考3,解决方法:

    String encoding = System.getProperty("file.encoding");
    CharBuffer charBuffer = Charset.forName(encoding).decode(buffer.asByteBuffer());
    bodyRef.set(charBuffer.toString());
    
    • 1
    • 2
    • 3

    参考

  • 相关阅读:
    使用 setoolkit 伪造站点窃取用户信息
    Socks5代理与代理IP技术:网络工程师的全能武器
    【linux/shell案例实战】shell界面命令快捷键
    Linux mkdir -p 以及如何创建带有空格的文件夹
    day25-登录和代理IP
    使用mitmproxy抓包详细记录(一)
    【云原生 | 18】Docker数据卷及卷的持久化问题
    投资失败+转岗降薪,2年逆袭月薪30K,我的船终于靠了岸
    Python高校学生档案管理系统毕业设计源码071528
    电网数字孪生解决方案助力智慧电网体系建设
  • 原文地址:https://blog.csdn.net/lonelymanontheway/article/details/102291722