通過Spring Cloud Gateway轉發WebSocket實現消息推送

前言

最近被安排做一個消息推送的實驗性方案,在網上沒有找到比較合適業務的架構方案,最後弄出了一個勉強還能用的小示例(只適合自己實驗做着玩,完全不能上生產環境),這裏我對這個小示例做下總結,順便鞏固下最近學的一系列知識。在文章的最後我會放上github代碼倉庫鏈接。

一、總體方案流程

先上總體流程圖吧,(我沒有用visio來畫圖,而是用了網上的一個工具Process On,所以圖看上去不是那麼正規。不用visio主要還是因爲visio用的太菜了,有機會還是要多練習一下使用visio畫圖)。

主要起了四個工程,

  1. 一個是模擬用戶服務的工程user-service(模擬兩個服務就通過兩個不同的端口來模擬);
  2. 一個是Spring Cloud Gateway網關工程;
  3. 一個是dispatch,用來做消息的分發調度;
  4. 還有一個工程就是模擬消息的生產者;
  5. 服務發現使用的是Consul。

接着對照上面的圖說一下整體流程和思路。用戶通過網關登錄到服務器上,服務器A和服務器B模擬的是一個微服務的兩臺服務器,網關可能會讓用戶連到ServerA上,也可能會連到ServerB上。並且在用戶登錄的同時會連接WebSocket,WebSocket是消息推送能否實現的關鍵,爲什麼要使用WebSocket下面再說。用戶登錄並連接到服務器上以後,將用戶的登錄名和服務器信息寫入Redis中(Key:userName,Value:serverIpAdress+port)。爲什麼要記錄服務器信息呢?因爲每臺服務器都會訂閱一個以自己ip地址加端口號命名的topic,如下圖所示:

當消息的生產者有一條消息要推送時,會先發給Dispatch(這裏的流程跟上圖有些不一樣,按理說應該要先存入一個隊列中,然後讓Dispatch自己主動去取然後再推送給指定的消息隊列。爲什麼要這樣做呢?按照架構師他的話來說,就是要在Dispatch這裏做持久化等等一系列操作,真正的工程是很複雜的,消息推送不可能像我上面這個圖一樣簡單)。但是因爲一些原因我並沒有這樣做,而是直接讓生產者將消息推給了Dispatch。Dispatch收到消息後,會在Redis中去查找用戶所連接的服務器,然後將消息推送到該服務器監聽的消息隊列中,服務器最後通過WebSocket將消息推給用戶。

2、具體實現

2.1、Spring Boot集成WebSocket

2.1.1、WebSocket簡介

首先拋出幾個問題:

Q1:什麼是WebSocket?

A1:WebSocket是Html5提供的一種能在單個TCP連接上進行全雙工通訊的協議,是HTTP協議中長連接的升級版。全雙工通訊就是你能主動給我發消息,我也能主動給你發消息。

Q2:爲什麼要使用WebSocket?

A2:使用WebSocket最大的好處就是客戶端能主動向服務端發送消息,服務端也能主動向客戶端發送消息,能最大程度的保證信息交換的實時性。Web應用的信息交互過程通常是客戶端通過瀏覽器發出一個請求,服務器端接收和審覈完請求後進行處理並返回結果給客戶端,然後客戶端瀏覽器將信息呈現出來,這種機制對於信息變化不是特別頻繁的應用尚能應付,但是對於那些實時要求比較高的應用來說,比如說在線遊戲、股票查詢,在線證券、設備監控、新聞在線播報、RSS訂閱推送等等,當客戶端瀏覽器準備呈現這些信息的時候,這些信息在服務器端可能已經過時了。所以保持客戶端和服務器端的信息同步是實時Web 應用的關鍵要素。

在WebSocket出現之前,客戶端是怎麼和服務端進行信息交換的呢?最常用的方式就是輪詢,而輪詢又分爲短輪詢和長輪詢。

短輪詢就相當於客戶端每隔一定時間就主動發送一個request給服務端請求數據交換,服務端不管有沒有新的數據都要對客戶端的請求進行響應。短輪詢一般都是客戶端使用ajax來實現的,用戶一般是不會感覺到客戶端發生的變化的,但是這種短輪詢的方式對服務端來說會有不小的壓力。因爲每次客戶端和服務端之間信息的交換都對應着一次Request-Response的過程,每發生一次這個過程,雙方都要交換大量的請求頭和響應頭,這增加了每次傳輸的數據量;並且輪詢的時間間隔非常難控制,時間短了,容易造成服務器CPU資源的浪費,而時間長了又難以保證信息的實時性。

長輪詢則相當於客戶端給服務端打一個電話,如果服務端有數據更新,那麼它就接起這個電話(響應),將數據返回給客戶端;如果服務端沒有數據更新,那麼這個電話就一直掛在那(不響應),直到有數據更新才接起電話。長輪詢的缺點很明顯,服務器的併發連接數是有限的,如果服務端一直沒有更新數據而一直保持這個連接,那麼這個空閒的連接就是一種資源浪費。

WebSocket相對於輪詢的優勢很明顯,WebSocket只需要建立一次連接,客戶端和服務端只需要交換一次請求頭和響應頭就可以無數次交換信息。

2.1.2、相關代碼

Spring Boot集成WebSocket的maven依賴:

<dependency>
    <groupId>org.springframework.boot</groupId>
	<artifactId>spring-boot-starter-websocket</artifactId>
</dependency>

WebSocket配置文件WebSocketAutoConfig:


/**
 * websocket配置
 */
@Configuration
@EnableWebSocketMessageBroker
public class WebSocketAutoConfig implements WebSocketMessageBrokerConfigurer {
    @Override
    public void registerStompEndpoints(StompEndpointRegistry registry) {
        registry.addEndpoint("/mq")         //開啓/bullet端點
                .setAllowedOrigins("*")         //允許跨域訪問
                .withSockJS();                  //使用sockJS
    }
}

前端頁面,前端通過SockJS來進行WebSocket連接(其中比較關鍵的代碼我都寫了註釋,就不再一一解釋了):

<!DOCTYPE html>
<html xmlns:text-align="http://java.sun.com/JSP/Page">
<head>
    <meta charset="UTF-8" />
    <title>h3</title>
    <noscript>
        <h2 style="color:#ff0000">貌似你的瀏覽器不支持websocket</h2>
    </noscript>
    <script src="/user-service/static/sockjs.js"></script>
    <script src="/user-service/static/stomp.js"></script>
    <script src="/user-service/static/jquery.js"></script>
    <script src="/user-service/static/bootstrap-3.3.7-dist/js/bootstrap.min.js"></script>
    <script src="/user-service/static/bootstrap-3.3.7-dist/css/bootstrap.min.css"></script>
    <script type="text/javascript">
    function loginin() {
        $.ajax({
            type:"get",
            data:"username=" + document.getElementById("username1").value,
            url:"login"
        })
        connect();
    }

    var stompClient = null;
    //gateway網關的地址
    var host="http://localhost:4445/user-service";
    function setConnected(connected) {
        document.getElementById('disconnect').disabled = !connected;
        $('#response').html();
    }
    function connect() {
        //地址+端點路徑,構建websocket鏈接地址
        var socket = new SockJS(host+'/mq');
        var username = document.getElementById("username1").value;
        stompClient = Stomp.over(socket);
        stompClient.connect({}, function(frame) {
            setConnected(true);
            console.log('Connected:' + frame);
            //監聽一個具有自己信息的對列(/toAll/id)
            //把用戶名作爲自己監聽的消息隊列標識
            stompClient.subscribe('/toAll/' + username, function(response) {
                showResponse(response.body);
            });
        });
    }
    function disconnect() {
        if (stompClient != null) {
            stompClient.disconnect();
        }
        setConnected(false);
        console.log("Disconnected");
    }
    function send() {
        var name = $('#name').val();
        var message = $('#messgae').val();
        //發送消息的路徑
        stompClient.send("/chat", {}, JSON.stringify({username:name,message:message}));
    }
    function showResponse(message) {
        var response = $('#response');
        response.html(message);
    }
</script>
</head>
<body onload="disconnect()">

    <div class="input-group" id="login" style="margin:200px auto;width:200px">
        <span class="input-group-addon" id="basic-addon1">用戶名</span>
        <input type="text" class="form-control" placeholder="請輸入用戶名" aria-describedby="basic-addon1" id="username1"
           name="username" style="width: 200px">
    </div>
    <div style="position:absolute;left:700px;top:250px"  class="btn-group" role="group" aria-label="...">
        <button type="button" class="btn btn-default" id="loginbutton" onclick="loginin()">登錄</button>
        <button type="button" class="btn btn-default" id="disconnect" onclick="disconnect();">斷開連接</button>
    </div>
    <div style="position:absolute;left:670px;top:60px;height: 100px">
        <h1 id="response"></h1>
    </div>

</body>
</html>

使用WebSocket給單個用戶發送信息:

 /**
  * 用戶在前端訂閱的消息隊列名稱爲"/toAll/username"
  * 通過teleWebSocketManager發送信息給指定用戶
  */
    @ResponseBody
    @RequestMapping(value="/sendToOne", produces = {"application/json; charset=utf-8"},method= RequestMethod.GET)
    public String sendToOne(String username, String message){
        teleWebSocketManager.send("/toAll/" + username, message);
        return "成功";
    }

2.2、Spring Boot集成Spring Cloud Gateway

2.2.1、Spring Cloud Gateway簡介

Spring Cloud Gateway是Spring Cloud下的一個全新項目,它創建了一個在Spring控制下的API網關,它的目的是提供一個簡單、高效的api路由方式。

Spring Cloud Gateway的特點:

  • 使用gateway時,Spring版本應該不低於5.0,Spring boot版本不低於2.0;
  • 能夠在任何請求屬性中匹配路由;
  • 能夠爲每個路由單獨制定斷言和過濾器;
  • 能夠很容易的配置斷言和過濾器;
  • 支持路徑重寫;

以上是從Spring官網翻譯過來的,英語水平有限,大家可以自己去官網看一下官方文檔

2.2.2、Spring Cloud Gateway配置相關代碼

首先是Spring Cloud Gateway的相關依賴:

<dependencyManagement>
    <dependencies>
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-dependencies</artifactId>
            <version>Finchley.RELEASE</version>
            <type>pom</type>
            <scope>import</scope>
        </dependency>
    </dependencies>
</dependencyManagement>

<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-gateway</artifactId>
    <version>2.0.1.RELEASE</version>
    <exclusions>
        <exclusion>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-gateway-core</artifactId>
        </exclusion>
    </exclusions>
</dependency>

Spring Cloud Gateway配置路由有兩種方式,一種是在代碼中進行配置,還有一種是在配置文件中進行配置。在代碼中進行配置擴展性較差,一般都不推薦;使用配置文件進行配置是比較好的一種方式,路由配置文件:

server:
  port: 4445

spring:
  application:
    name: gateway-service
  profiles:
    active: dev
  cloud:
    consul:
      host: localhost
      port: 8500
      # 服務發現配置
      discovery:
        serviceName: gateway-service
    #路由配置,user-service是我的服務名
    gateway:
      routes:
        #表示websocket的轉發
        - id: user-service-websocket
          uri: lb:ws://user-service
          predicates:
          - Path=/user-service/mq/**
        - id: user-service
          uri: lb://user-service
          predicates:
          - Path=/user-service/**
      discovery:
        locator:
          enabled: true
          lower-case-service-id: true

上面是路由的配置,下面說一個很關鍵的問題,這個問題關係到websocket能否連接成功。我們的前端是通過SockJS來建立WebSocket連接的,什麼是SockJS?(這個鏈接指向的是Spring的官方文檔,關於SockJS的這一段在20.3.1節中,大家就不用自己去找了,直接到20.3.1節中去看就好了)。

官方文檔中20.3.1節裏第一句話就是:"The goal of SockJS is to let applications use a WebSocket API but fall back to non-WebSocket alternatives when necessary at runtime, i.e. without the need to change application code." 翻譯過來,大致意思就是說SockJS的作用是讓WEB應用使用WebSocket API,但是在必要的運行時能改變成非WebSocket(比如HTTP的長連接)而無需改動代碼。SockJS被設計成在瀏覽器中使用,它也竭盡全力的使用各種技術去支持所有的瀏覽器版本。有關SockJS傳輸類型和瀏覽器的完整列表,可以在文檔中查看。SockJS可用的傳輸類型有三種:WebSocket、HTTP Streaming、HTTP長輪詢。

下面這段比較重要:"The SockJS client begins by sending "GET /info" to obtain basic information from the server. After that it must decide what transport to use. If possible WebSocket is used. If not, in most browsers there is at least one HTTP streaming option and if not then HTTP (long) polling is used.",大致意思就是說SockJS客戶端開始時會發送一個GET類型的"/info"請求從服務器去獲取基本信息,這個請求之後SockJS必須決定使用哪種傳輸,可能是WebSocket,如果不是的話,在大部分瀏覽器中會使用HTTP Streaming或者HTTP長輪詢。那麼我們應該怎麼去處理這個/info請求呢?那麼我們可以在網關中配置一個攔截器,當攔截到/info請求時就對它進行處理。

攔截器代碼:


import org.springframework.cloud.gateway.filter.GatewayFilterChain;
import org.springframework.cloud.gateway.filter.GlobalFilter;
import org.springframework.core.Ordered;
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 static org.springframework.cloud.gateway.support.ServerWebExchangeUtils.GATEWAY_REQUEST_URL_ATTR;

/**
 * @author: wang.mh
 * 2019/6/19 17:05
 */
@Component
public class WebsocketHandler implements GlobalFilter, Ordered {
    private final static String DEFAULT_FILTER_PATH = "/user-service/info";

    /**
     *
     * @param exchange ServerWebExchange是一個HTTP請求-響應交互的契約。提供對HTTP請求和響應的訪問,
     *                 並公開額外的 服務器 端處理相關屬性和特性,如請求屬性
     * @param chain
     * @return
     */
    @Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
        String upgrade = exchange.getRequest().getHeaders().getUpgrade();
      
        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;
    }

    static String convertWsToHttp(String scheme) {
        scheme = scheme.toLowerCase();
        return "ws".equals(scheme) ? "http" : "wss".equals(scheme) ? "https" : scheme;
    }
}

2.3、Spring Boot集成消息中間件RocketMQ

2.3.1、消息中間件的作用

更加確切的來說應該是消息隊列的作用,我認爲消息中間件是對消息隊列的一種包裝,讓消息隊列具有更多功能,使其成爲一種可配置可擴展的一種工具。一般提起消息隊列,都會說它有以下三種作用:

  1. 異步處理:舉個例子,我給你打電話,告訴你我要出門了,讓你做好準備來接我。但是呢你一直不接電話,而我一直在等你接電話,必須要等你接了電話,我纔出門,這種處理方式叫同步處理;如果採用異步處理方式的話,那麼即使你不接電話也沒關係,我可以在電話中給你留言,在留言中告訴你“我出門了,準備來接我”,然後我就出門,不必等你接了電話我纔出門,兩件事分開來幹,可以大大的減少過程中花費的時間。對應用來說,就是提高響應速度,減少不必要的時間損耗。
  2. 應用解耦:最典型的例子就是訂單系統和庫存系統,假設完成一個訂單必須依賴這兩個系統的協作,首先在訂單系統中下單,然後在庫存系統中進行庫存數量加減操作。如果某天這個庫存系統掛了呢?那下單的整個流程就沒法走完,訂單就不可能生效,這時候老闆肯定不幹呀,你這一個應用掛了害的我這個系統都不能用,這得損失多少錢!!!那麼這時候就可以使用消息隊列來進行應用間的解耦,下單以後,訂單系統先對這張訂單進行持久化操作,然後發一條消息到消息隊列中,告訴庫存系統進行相應的操作,那麼這時候庫存系統即使掛掉了也沒關係,因爲只要它一旦恢復了,那麼就能主動從消息隊列中拉取未消費的信息,從而對庫存進行操作。這樣一來兩個應用之間沒有直接的聯繫了,一個訂單不再依賴兩個應用連續的處理流程,這就是應用解耦。
  3. 流量削峯:最典型的應用就是秒殺搶購,假設這個產品只有100件,這時候有10000個人去搶,那麼這10000人同時訪問很有可能會瞬間擊垮整個系統,那麼這時使用消息隊列,就可以進行限制,只有前100個人才能進入這個隊列中,後面的9900個人的請求全部都扔掉,那麼下單系統就可以慢慢悠悠的從這個消息隊列中去取這100個排隊的下單請求。還有一種情況,可能你的網站業務很特殊,白天一般沒人訪問,最高峯也就每分鐘100個人的訪問量,而到了夜裏,可能每分鐘的平均訪問量就會超過5000,那麼這時候怎麼辦呢?加大服務器的投入成本嗎?只要你有錢就可以這樣做,但是白天你這麼高的服務器配置不是都浪費了嗎?這時候就可以加入消息隊列,比如說原來你的應用只能支持平均每分鐘500的訪問量,那麼在晚上的時候,將所有的請求全部壓入消息隊列中,應用慢慢的從消息隊列中去取請求來進行處理,每次只處理500個,雖然說總的處理時間會比你加大服務器投入成本要慢,但是在網絡世界中,這種慢用戶或許幾乎感覺不到。

未完待續

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章