美文网首页
使用 Spring Cloud Gateway 替换 Zuul

使用 Spring Cloud Gateway 替换 Zuul

作者: 嗷嗷待哺丶 | 来源:发表于2020-01-13 16:43 被阅读0次

    前言

    之前的项目使用的是Zuul网关,有个需求需要用到WebSocket,所以一直在查Spring Cloud Zuul 转发 WebSocket请求的教程和文章,查来查去,发现不行,Zuul对WebSocket的支持不是很友好。

    总结下来就是以下几点:

    • 高版本的websocket在第一次http请求后,使用的是更快速的tcp连接,zuul网关只能管理http请求,并且不支持tcp以及udp请求
    • zuul转发websocket时,会将websocket降级为http请求转发掉(轮询的方式,效率不是很理想),换句话说就是不支持转发长连接,zuul2好像可以。

    这让我很发愁,毕竟我是一个强迫症,做自己的项目,当然不能凑活,于是我就找其他办法,在查的偶然间,我发现他们说Spring Cloud Gateway 支持转发WebSocket,我眼睛一亮,但是换网关,也不能说换就换,就搜了搜能不能在Zuul的基础上想想办法,网上的办法有很多,但可惜我觉得都不理想,没有办法,换Gateway吧,话不多说,上教程。

    第一步,引入Gateway的 maven依赖

        <dependency>
                <groupId>org.springframework.cloud</groupId>
                <artifactId>spring-cloud-starter-gateway</artifactId>
            </dependency>
    
            <dependency>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-starter-websocket</artifactId>
                <exclusions>
                    <exclusion>
                        <groupId>org.springframework.boot</groupId>
                        <artifactId>spring-boot-starter-web</artifactId>
                    </exclusion>
                </exclusions>
            </dependency>
    

    在这里要说个坑(敲黑板,划重点了)由于Gateway自带Netty,和tomcat冲突,所以看上面,第二个依赖,spring-boot-starter-websocket 这个里面自带spring-boot-starter-web jar包,而spring-boot-starter-web jar包里面包含tomcat一系列jar包,所以这里要给排除掉,不然启动会报错,如果依然报错,看看报错提示,是不是提示netty和tomcat冲突,在找找其他jar包里有没有包含tomcat的,排除掉。

    第二步,依赖引入完毕,那么开始写配置文件

    Gateway 有两种方式,一种是配置文件,一种是拦截器
    下面是yml文件的配置

    spring:
      cloud:
        #spring-cloud-gateway
        gateway:
          routes:
          - id: xxx              #路由的id,参数配置不要重复,如不配置,Gateway会使用生成一个uuid代替。
            uri: lb://xxx        #lb:// 表示从注册中心获取路径进行转发,xxx是注册在注册中心的微服务的名称
            predicates:
            - Path=/xxx/**       #符合该路径后,转发
          #WebSocket转发配置
          - id: xxx     
            uri: lb:ws://xxx     #lb:ws://xxx 表示从注册中心获取路径转发,并且请求协议换成ws  
            predicates:
            - Path=/xxx/**
    
    

    好了,配置文件写好了,- Path=/xxx/** 设置好断言,如果你的请求路径符合这个规则,Gateway就会进行相应的转发,是不是很简单,但是这里有个问题,普通的转发无所谓,到这里就结束了,可是WebSocket的转发,会有点问题,我使用的是SockJS + Stomp + WebSocket,在这里就要简单介绍一下 SockJS、Stomp、WebSocket了。

    WebSocket

    • WebSocke是 HTML5 提供的一种在单个 TCP 连接上进行全双工通讯的协议。
    • WebSocket协议是基于TCP的一种新的网络协议,是一个应用层协议,是TCP/IP协议的子集。
    • 它实现了浏览器与服务器全双工(full-duplex)通信,客户端和服务器都可以向对方主动发送和接收数据。在 WebSocket API 中,浏览器和服务器只需要完成一次握手,两者之间就直接可以创建持久性的连接,并进行双向数据传输。
    • 在JS中创建WebSocket后,会有一个HTTP请求从浏览器发向服务器。在取得服务器响应后,建立的连接会使用HTTP升级将HTTP协议转换为WebSocket协议。也就是说,使用标准的HTTP协议无法实现WebSocket,只有支持那些协议的专门浏览器才能正常工作。由于WebScoket使用了自定义协议,所以URL与HTTP协议略有不同。未加密的连接为ws://,而不是http://。加密的连接为wss://,而不是https://,所以如果你的项目使用了网关,又想使用WebSocket,在网关转发这方面,就会遇到问题。

    SockJS

    • SockJS是一个浏览器JavaScript库,它提供了一个连贯的、跨浏览器的JavaScript API,在浏览器和web服务器之间建立一个低延迟、全双工、跨域通信通道。SockJs的一大好处是提供了浏览器兼容性,优先使用原生的WebSocket,在不支持websocket的浏览器中,会自动降为轮询的方式。

    Stomp

    • STOMP(Simple Text-Orientated Messaging Protocol),中文为:面向消息的简单文本协议
    • websocket定义了两种传输信息类型:文本信息和二进制信息。类型虽然被确定,但是他们的传输体是没有规定的。所以,需要用一种简单的文本传输类型来规定传输内容,它可以作为通讯中的文本传输协议。
    • STOMP是基于帧的协议,客户端和服务器使用STOMP帧流通讯
    • 一个STOMP客户端是一个可以以两种模式运行的用户代理,可能是同时运行两种模式。
    • 作为生产者,通过SEND框架将消息发送给服务器的某个服务
    • 作为消费者,通过SUBSCRIBE制定一个目标服务,通过MESSAGE框架,从服务器接收消息。

    总结

    • SockJS 提供了浏览器兼容性,在不支持WebSocket的浏览器中,会自动降为轮询的方式。
    • Stomp 简单理解就是规定了传输内容,作为通讯中的文本传输协议;这个我也是看的一知半解,但是我觉得如果你对WebSocket了解足够多的话,你就清楚为什么要用它。
    • WebSocket 实现了浏览器与服务器全双工通信,客户端和服务器都可以向对方主动发送和接收数据,而且第一次建立WebSocket的连接的协议是HTTP或HTTPS协议,url使用的是http://或https://,建立成功之后,url使用的是ws://或wss://。

    看到这里,你大概就明白了,我说的问题在哪里了(划重点)
    由于WebSocket第一次连接使用的是http协议或者是https协议,并且咱们的配置文件还是这样配置的:

          - id: xxx     
            uri: lb:ws://xxx     #lb:ws://xxx 表示从注册中心获取路径转发,并且请求协议换成ws  
            predicates:
            - Path=/xxx/**
    
    

    这样配置的意思就是,如果符合 - Path=/xxx/** 这个地址路径,那么就会转换成WebSocket协议来进行转发请求。

    如果你的项目没有网关,前端的WebSocket建立请求会直接请求到你的WebSocket服务上,成功建立连接。

    如果你的项目有网关,而且你还这样配置了转发规则,那么前端的每一次WebSocket请求都会以url是ws://或wss://这个路径进行转发请求,连接就不会建立成功。

    那么这个时候就要做一下特殊处理,话不多说,上代码。

    第三步,拦截第一次WebSocket请求,做特殊处理。

    import lombok.extern.slf4j.Slf4j;
    import org.springframework.cloud.gateway.filter.GatewayFilterChain;
    import org.springframework.cloud.gateway.filter.GlobalFilter;
    import org.springframework.core.Ordered;
    import org.springframework.http.HttpHeaders;
    import org.springframework.stereotype.Component;
    import org.springframework.web.server.ServerWebExchange;
    import org.springframework.web.util.UriComponentsBuilder;
    import reactor.core.publisher.Mono;
    
    import java.net.URI;
    import java.util.ArrayList;
    
    import static org.springframework.cloud.gateway.support.ServerWebExchangeUtils.GATEWAY_REQUEST_URL_ATTR;
    
    @Component
    @Slf4j
    public class WebSocketFilter implements GlobalFilter, Ordered {
    
        private final static String DEFAULT_FILTER_PATH = "/ws/info";
    
        @Override
        public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
            URI requestUrl = exchange.getRequiredAttribute(GATEWAY_REQUEST_URL_ATTR);
            String scheme = requestUrl.getScheme();
            if (!"ws".equals(scheme) && !"wss".equals(scheme)) {
                return chain.filter(exchange);
            } else if (DEFAULT_FILTER_PATH.equals(requestUrl.getPath())) {
                String wsScheme = convertWsToHttp(scheme);
                URI wsRequestUrl = UriComponentsBuilder.fromUri(requestUrl).scheme(wsScheme).build().toUri();
                exchange.getAttributes().put(GATEWAY_REQUEST_URL_ATTR, wsRequestUrl);
            }
            return chain.filter(exchange);
        }
    
        @Override
        public int getOrder() {
            return Ordered.LOWEST_PRECEDENCE - 2;
        }
    
        private static String convertWsToHttp(String scheme) {
            scheme = scheme.toLowerCase();
            return "ws".equals(scheme) ? "http" : "wss".equals(scheme) ? "https" : scheme;
        }
    }
    
    

    由于种种尝试,我已经知道前端进行第一次WebSocket请求的时候,路径是"/ws/info",这段代码做的就是针对这个路径在转发过程中进行拦截,把ws或者wss替换成http或者https进行转发请求,来一招狸猫换太子,神不知鬼不觉。

    这个方法是我在网上找到的,也不知道是哪位大佬写的,办法真多。

    那么现在网关转发的问题解决了,但是由于请求会存在跨域的问题,要在WebSocket的服务上进行一下配置,还有如果你需要鉴权的话,下面的代码也有

    import com.alibaba.fastjson.JSONObject;
    import io.jsonwebtoken.Claims;
    import org.springframework.context.annotation.Configuration;
    import org.springframework.http.server.ServerHttpRequest;
    import org.springframework.http.server.ServerHttpResponse;
    import org.springframework.http.server.ServletServerHttpRequest;
    import org.springframework.messaging.simp.config.MessageBrokerRegistry;
    import org.springframework.web.socket.WebSocketHandler;
    import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker;
    import org.springframework.web.socket.config.annotation.StompEndpointRegistry;
    import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer;
    import org.springframework.web.socket.config.annotation.WebSocketTransportRegistration;
    import org.springframework.web.socket.server.HandshakeInterceptor;
    import org.springframework.web.socket.server.support.DefaultHandshakeHandler;
    
    import java.security.Principal;
    import java.util.Map;
    
    /**
     * 开启webSocket支持
     */
    @Configuration
    @EnableWebSocketMessageBroker
    public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
    
        @Override
        public void registerStompEndpoints(StompEndpointRegistry registry) {
            registry.addEndpoint("/ws") //符合这个路径的请求
                    .addInterceptors(new HandshakeInterceptor() {
                        /**
                         * websocket握手之前
                         */
                        @Override
                        public boolean beforeHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler, Map<String, Object> attributes) throws Exception {
                            ServletServerHttpRequest req = (ServletServerHttpRequest) request;
                            //获取token认证
                            String token = req.getServletRequest().getParameter("token");
                            //解析token获取用户信息
                            Principal user = "";  //鉴权,我的方法是,前端把token传过来,解析token,判断正确与否,return true表示通过,false请求不通过。
                            if (user == null) {   //如果token认证失败user为null,返回false拒绝握手
                                return false;
                            }
                            //保存认证用户
                            attributes.put("user", user);
                            return true;
                        }
    
                        @Override
                        public void afterHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler, Exception exception) {
    
                        }
                    })
            //握手之后
                    .setHandshakeHandler(new DefaultHandshakeHandler() {
                        @Override
                        protected Principal determineUser(ServerHttpRequest request, WebSocketHandler wsHandler, Map<String, Object> attributes) {
                            //设置认证用户
                            return (Principal) attributes.get("user");
                        }
                    })
                    .setAllowedOrigins("xxxx")          //这里设置跨域,允许哪个地址访问,*号是所有
                    .withSockJS();                                  //使用sockJS
        }
    
        @Override
        public void configureMessageBroker(MessageBrokerRegistry registry) {
            //topic用来广播,user单独发送
            registry.enableSimpleBroker("/topic", "/user");
        }
    
        /**
         * 消息传输参数配置
         */
        @Override
        public void configureWebSocketTransport(WebSocketTransportRegistration registry) {
            registry.setMessageSizeLimit(8192) //设置消息字节数大小
                    .setSendBufferSizeLimit(8192)//设置消息缓存大小
                    .setSendTimeLimit(10000); //设置消息发送时间限制毫秒
        }
    }
    
    

    WebSocket服务配置现在写完了,下面就是发送数据了。
    由于我现在只需要点对点的发送数据,也就是给某一个用户发送数据,所以,在前端要订阅某一个用户,而后端根据这个用户id进行推送

    前端代码

        let socket = new SockJS("");    //这里就是你要建立连接的路径
        this.stompClient = Stomp.over(socket);
        this.stompClient.heartbeat.outgoing = 10000; //前端对后端进行心跳检测的时长 ms
        this.stompClient.heartbeat.incoming = 0; //后端对前端就行心跳检测的时长 ms
    
        //去掉debug打印
        this.stompClient.debug = null;
    
        //这里是订阅路径,如果你要点对点的推送消息,就是这种格式”/user“是前缀,
        //“/123”是你要订阅的用户的id,当然了我这个需求是这样的,你想换成什么都可以,
        //“/single”就是个标识,加不加无所谓,加上可能比较容易理解吧,这些都要跟你的后端设置对应上
        this.subscribeUrl = '/user/123/single';
    
        //开始连接
        this.stompClient.connect({}, () => {
          console.info("[WebSocket] 连接成功!");
    
          //进行订阅服务
          this.stompClient.subscribe(this.subscribeUrl, message => {
              console.log(message); //这里就是后端推送过来的数据
          });
    
        }, err => {
          //断开连接
          this.error("[WebSocket] "+err);
        })
    
    

    后端代码

    import org.springframework.beans.factory.annotation.Autowired;
    import org.springframework.messaging.simp.SimpMessagingTemplate;
    import org.springframework.stereotype.Service;
    
    @Service
    public class WebSocketServer {
    
        @Autowired
        private SimpMessagingTemplate template;
    
        public void sendGroupMessage(String content) throws Exception {
        //广播就很简单了
            template.convertAndSend("/topic/group", content);
        }
    
        public void sendSingleMessage(String userId, String content) throws Exception {
        //点对点的这种,后端想推送到前端数据,使用convertAndSendToUser
        //三个参数分别是 userId,标识,想要推送的数据内容
            template.convertAndSendToUser(userId, "/single", content);
        }
    }
    
    

    以上写完之后,就结束了,可能小伙伴们满心欢喜的去测试了,但是!!!
    还有最后一个问题没有解决,这个问题也是在测试过程中发现的。

    我在前后端联调过程中,发现前端请求后总是提示跨域问题,我明明已经设置跨域了呀,为什么还报错?百思不得其解,英文不好的我复制了前端的报错提示,翻译了一下,他说响应头里有多个Origin,之前也没接触过这种情况,不知道只能有一个,只能去某度去搜搜,果不其然,很快就搜到了《解决Spring Cloud Gateway 2.x跨域时出现重复Origin的BUG》,这篇文章说这是Spring Cloud Gateway 2.x 的BUG,大佬给出了解决的代码,还贴了源码,一言不合就上源码,真是让我压力很大。

    总结来说这个BUG就是会造成重复的Origin,那么大佬给的代码就是解决重复,下面贴一个完整的配置

    import lombok.extern.slf4j.Slf4j;
    import org.springframework.cloud.gateway.filter.GatewayFilterChain;
    import org.springframework.cloud.gateway.filter.GlobalFilter;
    import org.springframework.core.Ordered;
    import org.springframework.http.HttpHeaders;
    import org.springframework.stereotype.Component;
    import org.springframework.web.server.ServerWebExchange;
    import org.springframework.web.util.UriComponentsBuilder;
    import reactor.core.publisher.Mono;
    
    import java.net.URI;
    import java.util.ArrayList;
    
    import static org.springframework.cloud.gateway.support.ServerWebExchangeUtils.GATEWAY_REQUEST_URL_ATTR;
    
    @Component
    @Slf4j
    public class WebSocketFilter implements GlobalFilter, Ordered {
    
        private final static String DEFAULT_FILTER_PATH = "/ws/info";
    
        @Override
        public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
            URI requestUrl = exchange.getRequiredAttribute(GATEWAY_REQUEST_URL_ATTR);
            String scheme = requestUrl.getScheme();
            if (!"ws".equals(scheme) && !"wss".equals(scheme)) {
                return chain.filter(exchange);
            } else if (DEFAULT_FILTER_PATH.equals(requestUrl.getPath())) {
                String wsScheme = convertWsToHttp(scheme);
                URI wsRequestUrl = UriComponentsBuilder.fromUri(requestUrl).scheme(wsScheme).build().toUri();
                exchange.getAttributes().put(GATEWAY_REQUEST_URL_ATTR, wsRequestUrl);
            }
    
            //解决返回多个origin信息
            return chain.filter(exchange).then(Mono.defer(() -> {
                exchange.getResponse().getHeaders().entrySet().stream()
                        .filter(kv -> (kv.getValue() != null && kv.getValue().size() > 1))
                        .filter(kv -> (kv.getKey().equals(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN)
                                || kv.getKey().equals(HttpHeaders.ACCESS_CONTROL_ALLOW_CREDENTIALS)))
                        .forEach(kv ->
                        {
                            kv.setValue(new ArrayList<String>() {{
                                add(kv.getValue().get(0));
                            }});
                        });
    
                return chain.filter(exchange);
            }));
        }
    
        @Override
        public int getOrder() {
            return Ordered.LOWEST_PRECEDENCE - 2;
        }
    
        private static String convertWsToHttp(String scheme) {
            scheme = scheme.toLowerCase();
            return "ws".equals(scheme) ? "http" : "wss".equals(scheme) ? "https" : scheme;
        }
    }
    
    

    这次再去测试测试,已经没问题了把。
    文章到这里就结束了,如果有不懂的小伙伴可以在底下评论问询,文章有写的不对的地方,还请各位大佬多多指正。

    如需转载,请注明出处,谢谢!

    相关文章

      网友评论

          本文标题:使用 Spring Cloud Gateway 替换 Zuul

          本文链接:https://www.haomeiwen.com/subject/ajhnactx.html