使用 Spring Cloud Gateway 替換 Zuul 實現(xiàn)接入 WebSocket 教程

前言

之前的項目使用的是Zuul網(wǎng)關(guān),有個需求需要用到WebSocket,所以一直在查Spring Cloud Zuul 轉(zhuǎn)發(fā) WebSocket請求的教程和文章,查來查去,發(fā)現(xiàn)不行,Zuul對WebSocket的支持不是很友好。

總結(jié)下來就是以下幾點:

  • 高版本的websocket在第一次http請求后,使用的是更快速的tcp連接,zuul網(wǎng)關(guān)只能管理http請求,并且不支持tcp以及udp請求
  • zuul轉(zhuǎn)發(fā)websocket時,會將websocket降級為http請求轉(zhuǎn)發(fā)掉(輪詢的方式,效率不是很理想),換句話說就是不支持轉(zhuǎn)發(fā)長連接,zuul2好像可以。

這讓我很發(fā)愁,畢竟我是一個強迫癥,做自己的項目,當(dāng)然不能湊活,于是我就找其他辦法,在查的偶然間,我發(fā)現(xiàn)他們說Spring Cloud Gateway 支持轉(zhuǎn)發(fā)WebSocket,我眼睛一亮,但是換網(wǎng)關(guān),也不能說換就換,就搜了搜能不能在Zuul的基礎(chǔ)上想想辦法,網(wǎng)上的辦法有很多,但可惜我覺得都不理想,沒有辦法,換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,參數(shù)配置不要重復(fù),如不配置,Gateway會使用生成一個uuid代替。
        uri: lb://xxx        #lb:// 表示從注冊中心獲取路徑進(jìn)行轉(zhuǎn)發(fā),xxx是注冊在注冊中心的微服務(wù)的名稱
        predicates:
        - Path=/xxx/**       #符合該路徑后,轉(zhuǎn)發(fā)
      #WebSocket轉(zhuǎn)發(fā)配置
      - id: xxx     
        uri: lb:ws://xxx     #lb:ws://xxx 表示從注冊中心獲取路徑轉(zhuǎn)發(fā),并且請求協(xié)議換成ws  
        predicates:
        - Path=/xxx/**

好了,配置文件寫好了,- Path=/xxx/** 設(shè)置好斷言,如果你的請求路徑符合這個規(guī)則,Gateway就會進(jìn)行相應(yīng)的轉(zhuǎn)發(fā),是不是很簡單,但是這里有個問題,普通的轉(zhuǎn)發(fā)無所謂,到這里就結(jié)束了,可是WebSocket的轉(zhuǎn)發(fā),會有點問題,我使用的是SockJS + Stomp + WebSocket,在這里就要簡單介紹一下 SockJS、Stomp、WebSocket了。

WebSocket

  • WebSocke是 HTML5 提供的一種在單個 TCP 連接上進(jìn)行全雙工通訊的協(xié)議。
  • WebSocket協(xié)議是基于TCP的一種新的網(wǎng)絡(luò)協(xié)議,是一個應(yīng)用層協(xié)議,是TCP/IP協(xié)議的子集。
  • 它實現(xiàn)了瀏覽器與服務(wù)器全雙工(full-duplex)通信,客戶端和服務(wù)器都可以向?qū)Ψ街鲃影l(fā)送和接收數(shù)據(jù)。在 WebSocket API 中,瀏覽器和服務(wù)器只需要完成一次握手,兩者之間就直接可以創(chuàng)建持久性的連接,并進(jìn)行雙向數(shù)據(jù)傳輸。
  • 在JS中創(chuàng)建WebSocket后,會有一個HTTP請求從瀏覽器發(fā)向服務(wù)器。在取得服務(wù)器響應(yīng)后,建立的連接會使用HTTP升級將HTTP協(xié)議轉(zhuǎn)換為WebSocket協(xié)議。也就是說,使用標(biāo)準(zhǔn)的HTTP協(xié)議無法實現(xiàn)WebSocket,只有支持那些協(xié)議的專門瀏覽器才能正常工作。由于WebScoket使用了自定義協(xié)議,所以URL與HTTP協(xié)議略有不同。未加密的連接為ws://,而不是http://。加密的連接為wss://,而不是https://,所以如果你的項目使用了網(wǎng)關(guān),又想使用WebSocket,在網(wǎng)關(guān)轉(zhuǎn)發(fā)這方面,就會遇到問題。

SockJS

  • SockJS是一個瀏覽器JavaScript庫,它提供了一個連貫的、跨瀏覽器的JavaScript API,在瀏覽器和web服務(wù)器之間建立一個低延遲、全雙工、跨域通信通道。SockJs的一大好處是提供了瀏覽器兼容性,優(yōu)先使用原生的WebSocket,在不支持websocket的瀏覽器中,會自動降為輪詢的方式。

Stomp

  • STOMP(Simple Text-Orientated Messaging Protocol),中文為:面向消息的簡單文本協(xié)議
  • websocket定義了兩種傳輸信息類型:文本信息和二進(jìn)制信息。類型雖然被確定,但是他們的傳輸體是沒有規(guī)定的。所以,需要用一種簡單的文本傳輸類型來規(guī)定傳輸內(nèi)容,它可以作為通訊中的文本傳輸協(xié)議。
  • STOMP是基于幀的協(xié)議,客戶端和服務(wù)器使用STOMP幀流通訊
  • 一個STOMP客戶端是一個可以以兩種模式運行的用戶代理,可能是同時運行兩種模式。
  • 作為生產(chǎn)者,通過SEND框架將消息發(fā)送給服務(wù)器的某個服務(wù)
  • 作為消費者,通過SUBSCRIBE制定一個目標(biāo)服務(wù),通過MESSAGE框架,從服務(wù)器接收消息。

總結(jié)

  • SockJS 提供了瀏覽器兼容性,在不支持WebSocket的瀏覽器中,會自動降為輪詢的方式。
  • Stomp 簡單理解就是規(guī)定了傳輸內(nèi)容,作為通訊中的文本傳輸協(xié)議;這個我也是看的一知半解,但是我覺得如果你對WebSocket了解足夠多的話,你就清楚為什么要用它。
  • WebSocket 實現(xiàn)了瀏覽器與服務(wù)器全雙工通信,客戶端和服務(wù)器都可以向?qū)Ψ街鲃影l(fā)送和接收數(shù)據(jù),而且第一次建立WebSocket的連接的協(xié)議是HTTP或HTTPS協(xié)議,url使用的是http://或https://,建立成功之后,url使用的是ws://或wss://。

看到這里,你大概就明白了,我說的問題在哪里了(劃重點)
由于WebSocket第一次連接使用的是http協(xié)議或者是https協(xié)議,并且咱們的配置文件還是這樣配置的:

      - id: xxx     
        uri: lb:ws://xxx     #lb:ws://xxx 表示從注冊中心獲取路徑轉(zhuǎn)發(fā),并且請求協(xié)議換成ws  
        predicates:
        - Path=/xxx/**

這樣配置的意思就是,如果符合 - Path=/xxx/** 這個地址路徑,那么就會轉(zhuǎn)換成WebSocket協(xié)議來進(jìn)行轉(zhuǎn)發(fā)請求。

如果你的項目沒有網(wǎng)關(guān),前端的WebSocket建立請求會直接請求到你的WebSocket服務(wù)上,成功建立連接。

如果你的項目有網(wǎng)關(guān),而且你還這樣配置了轉(zhuǎn)發(fā)規(guī)則,那么前端的每一次WebSocket請求都會以url是ws://或wss://這個路徑進(jìn)行轉(zhuǎn)發(fā)請求,連接就不會建立成功。

那么這個時候就要做一下特殊處理,話不多說,上代碼。

第三步,攔截第一次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;
    }
}

由于種種嘗試,我已經(jīng)知道前端進(jìn)行第一次WebSocket請求的時候,路徑是"/ws/info",這段代碼做的就是針對這個路徑在轉(zhuǎn)發(fā)過程中進(jìn)行攔截,把ws或者wss替換成http或者h(yuǎn)ttps進(jìn)行轉(zhuǎn)發(fā)請求,來一招貍貓換太子,神不知鬼不覺。

這個方法是我在網(wǎng)上找到的,也不知道是哪位大佬寫的,辦法真多。

那么現(xiàn)在網(wǎng)關(guān)轉(zhuǎn)發(fā)的問題解決了,但是由于請求會存在跨域的問題,要在WebSocket的服務(wù)上進(jìn)行一下配置,還有如果你需要鑒權(quán)的話,下面的代碼也有

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認(rèn)證
                        String token = req.getServletRequest().getParameter("token");
                        //解析token獲取用戶信息
                        Principal user = "";  //鑒權(quán),我的方法是,前端把token傳過來,解析token,判斷正確與否,return true表示通過,false請求不通過。
                        if (user == null) {   //如果token認(rèn)證失敗user為null,返回false拒絕握手
                            return false;
                        }
                        //保存認(rèn)證用戶
                        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) {
                        //設(shè)置認(rèn)證用戶
                        return (Principal) attributes.get("user");
                    }
                })
                .setAllowedOrigins("xxxx")          //這里設(shè)置跨域,允許哪個地址訪問,*號是所有
                .withSockJS();                                  //使用sockJS
    }

    @Override
    public void configureMessageBroker(MessageBrokerRegistry registry) {
        //topic用來廣播,user單獨發(fā)送
        registry.enableSimpleBroker("/topic", "/user");
    }

    /**
     * 消息傳輸參數(shù)配置
     */
    @Override
    public void configureWebSocketTransport(WebSocketTransportRegistration registry) {
        registry.setMessageSizeLimit(8192) //設(shè)置消息字節(jié)數(shù)大小
                .setSendBufferSizeLimit(8192)//設(shè)置消息緩存大小
                .setSendTimeLimit(10000); //設(shè)置消息發(fā)送時間限制毫秒
    }
}

WebSocket服務(wù)配置現(xiàn)在寫完了,下面就是發(fā)送數(shù)據(jù)了。
由于我現(xiàn)在只需要點對點的發(fā)送數(shù)據(jù),也就是給某一個用戶發(fā)送數(shù)據(jù),所以,在前端要訂閱某一個用戶,而后端根據(jù)這個用戶id進(jìn)行推送

前端代碼

    let socket = new SockJS("");    //這里就是你要建立連接的路徑
    this.stompClient = Stomp.over(socket);
    this.stompClient.heartbeat.outgoing = 10000; //前端對后端進(jìn)行心跳檢測的時長 ms
    this.stompClient.heartbeat.incoming = 0; //后端對前端就行心跳檢測的時長 ms

    //去掉debug打印
    this.stompClient.debug = null;

    //這里是訂閱路徑,如果你要點對點的推送消息,就是這種格式”/user“是前綴,
    //“/123”是你要訂閱的用戶的id,當(dāng)然了我這個需求是這樣的,你想換成什么都可以,
    //“/single”就是個標(biāo)識,加不加無所謂,加上可能比較容易理解吧,這些都要跟你的后端設(shè)置對應(yīng)上
    this.subscribeUrl = '/user/123/single';

    //開始連接
    this.stompClient.connect({}, () => {
      console.info("[WebSocket] 連接成功!");

      //進(jìn)行訂閱服務(wù)
      this.stompClient.subscribe(this.subscribeUrl, message => {
          console.log(message); //這里就是后端推送過來的數(shù)據(jù)
      });

    }, 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 {
    //點對點的這種,后端想推送到前端數(shù)據(jù),使用convertAndSendToUser
    //三個參數(shù)分別是 userId,標(biāo)識,想要推送的數(shù)據(jù)內(nèi)容
        template.convertAndSendToUser(userId, "/single", content);
    }
}

以上寫完之后,就結(jié)束了,可能小伙伴們滿心歡喜的去測試了,但是?。?!
還有最后一個問題沒有解決,這個問題也是在測試過程中發(fā)現(xiàn)的。

我在前后端聯(lián)調(diào)過程中,發(fā)現(xiàn)前端請求后總是提示跨域問題,我明明已經(jīng)設(shè)置跨域了呀,為什么還報錯?百思不得其解,英文不好的我復(fù)制了前端的報錯提示,翻譯了一下,他說響應(yīng)頭里有多個Origin,之前也沒接觸過這種情況,不知道只能有一個,只能去某度去搜搜,果不其然,很快就搜到了《解決Spring Cloud Gateway 2.x跨域時出現(xiàn)重復(fù)Origin的BUG》,這篇文章說這是Spring Cloud Gateway 2.x 的BUG,大佬給出了解決的代碼,還貼了源碼,一言不合就上源碼,真是讓我壓力很大。

總結(jié)來說這個BUG就是會造成重復(fù)的Origin,那么大佬給的代碼就是解決重復(fù),下面貼一個完整的配置

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

這次再去測試測試,已經(jīng)沒問題了把。
文章到這里就結(jié)束了,如果有不懂的小伙伴可以在底下評論問詢,文章有寫的不對的地方,還請各位大佬多多指正。

如需轉(zhuǎn)載,請注明出處,謝謝!

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容