基於WebSocket的實時訊息傳遞設計

2023-01-19 18:00:35

概述

web管理系統中可以對業務資料執行新增和刪除,現在需要當業務資料發生新增或刪除操作後,儘可能實時的反應到WPF使用者端上面。

web管理系統用VUE編寫,後端服務為SpringBoot,WPF使用者端基於.Netframework4.8編寫。

整體架構

sequenceDiagram title: 互動時序圖 web前臺->>+web後端服務:新增資料 Note over web前臺,web後端服務:caremaId,labelInfo,...... web後端服務->>+WebSocketServer:建立websocker訊息 Note over web後端服務,WebSocketServer:Must:cameraId=clientId WPF使用者端1-->>+WebSocketServer:建立監聽 Note over WPF使用者端1,WebSocketServer:clientId WPF使用者端2-->>+WebSocketServer:建立監聽 Note over WPF使用者端2,WebSocketServer:clientId WebSocketServer->>WPF使用者端1:分發websocker訊息 Note over WebSocketServer,WPF使用者端1:依據:cameraId=clientId WebSocketServer->>WPF使用者端2:分發websocker訊息 Note over WebSocketServer,WPF使用者端2:依據:cameraId=clientId

設計

流程設計

  • 使用者在瀏覽器介面執行新增業務資料操作,呼叫後端新增介面
  • WPF使用者端在啟動的時候初始化websocket使用者端,並建立對server的監聽
  • 後端新增介面先將資料落庫,而後呼叫websocket伺服器端產生訊息,訊息在產生後立馬被傳送到了正在監聽中的websocket-client
  • websocket-server和websocket-client是一對多的關係,如何保證業務資料被正確的分發?監聽的時候給server端傳遞一個全域性唯一的clientId,業務資料在產生的時候關聯到一個BizId上面,只要保證clientId=BizId就可以了。
  • 刪除流程和新增類似

程式設計

WebSocketServer

概述

WebSocketServer端採用SpringBoot框架實現,通過在springboot-web專案中整合 org.springframework.boot:spring-boot-starter-websocket
實現websocket的能力。

新增pom



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


新增設定類



import org.springframework.context.annotation.Bean;

import org.springframework.context.annotation.Configuration;

import org.springframework.web.socket.config.annotation.EnableWebSocket;

import org.springframework.web.socket.server.standard.ServerEndpointExporter;

@Configuration
@EnableWebSocket
public class WebSocketConfig {
    @Bean
    public ServerEndpointExporter serverEndpointExporter() {
        return new ServerEndpointExporter();
    }
}



建立websocket端點



import com.alibaba.fastjson.JSON;
import org.springframework.stereotype.Component;
import javax.websocket.*;
import javax.websocket.server.PathParam;
import javax.websocket.server.ServerEndpoint;
import java.io.IOException;
import java.util.concurrent.ConcurrentHashMap;

@ServerEndpoint("/ws/label/{clientId}")
@Component
public class LabelWebSocket {
    /**
     * session list
     */
    private static ConcurrentHashMap<String, Session> sessionList = new ConcurrentHashMap<>();
    /**
     * 當前 clientId
     */
    private String currentClientId = "";
    @OnOpen
    public void open(Session session, @PathParam("clientId") String clientId) throws IOException {
        if (sessionList.containsKey(clientId)) {
            sessionList.remove(clientId);
        }
        sessionList.put(clientId, session);
        currentClientId = clientId;
        this.sendMsg(session, "connectok");
    }
    @OnClose
    public void close(Session session) throws IOException {
        sessionList.remove(currentClientId);
        System.out.println("連線關閉,session=" + JSON.toJSONString(session.getId()));
    }
    @OnMessage
    public void receiveMsg(Session session, String msg) throws IOException {
        this.sendMsg(session, "接收到的訊息為:" + msg);
//        throw new RuntimeException("主動拋異常");
    }
    @OnError
    public void error(Session session, Throwable e) throws IOException {
        System.out.println("連線異常,session=" + JSON.toJSONString(session.getId()) + ";currentClientId=" + currentClientId);
        this.sendMsg(session, "發生異常,e=" + e.getMessage());
        e.printStackTrace();
    }
    /**
     * @param clientId
     * @param msg
     */
    public boolean sendMsg(String clientId, String msg) throws IOException {
        if (sessionList.containsKey(clientId)) {
            Session session = sessionList.get(clientId);
            this.sendMsg(session, msg);
            return true;
        } else {
            return false;
        }
    }
    private void sendMsg(Session session, String msg) throws IOException {
        session.getBasicRemote().sendText(msg);
    }
}



WebSocketClient

概述

WebSocketClient端整合在WPF應用使用者端中,通過前期調研,選中 WebSocketSharp 作為websocketclient工具,WebSocketSharp 是託管在Github的開源專案,MITLicense,目前4.9K的star。

安裝WebSocketSharp


//nuget

Install-Package WebSocketSharp -Pre

初始化client


WebSocket ws = new WebSocket("ws://127.0.0.1:8083/ws/xx/clientId");


建立連線



private void InitWebSocket()
{
    ws.OnOpen += (sender, e) =>
    {
        Console.WriteLine("onOpen");
    };
    //允許ping
    ws.EmitOnPing = true;
    //接收到xiaoxi
    ws.OnMessage += (sender, e) =>
    {
        ReceiveMessage(sender, e);
    };
    ws.Connect();
    //傳送訊息
    //ws.Send("BALUS")
    ;
}
private void ReceiveMessage(object sender, MessageEventArgs e)
{
    if (e.IsText)
    {
        // Do something with e.Data.like jsonstring
        Console.WriteLine(e.Data);
        return;
    }
    if (e.IsBinary)
    {
        // Do something with e.RawData. like  byte[]
        return;
    }
    if (e.IsPing)
    {
        // Do something to notify that a ping has been received.
        return;
    }
}



跨執行緒更新UI

由於 WebSocketSharp 會建立執行緒來處理 ReceiveMessage ,而WPF中子執行緒是無法更新UI的,所以需要引入 Dispatcher 來實現跨執行緒更新UI。

獲取當前執行緒名字

 //當前執行緒

string name = Thread.CurrentThread.ManagedThreadId.ToString();


範例程式碼


private void ReceiveMessage(object sender, MessageEventArgs e)
{
    if (e.IsText)
    {
        // Do something with e.Data.like jsonstring
        Console.WriteLine(e.Data);
        //當前執行緒
        string name = Thread.CurrentThread.ManagedThreadId.ToString();
        App.Current.Dispatcher.Invoke((Action)(() =>
        {
            Image lab = new Image();
            lab.Uid = "123456";
            lab.Name = "labName";
            lab.Width = 50; lab.Height = 50;
            string url = "http://xxx:xxx/img/louyu.png";
            BitmapImage bitmapImage = HttpUtil.getImage(url);
            lab.Source = bitmapImage;
            lab.AddHandler(MouseLeftButtonDownEvent, new MouseButtonEventHandler(LabelClick));
            Canvas.SetTop(lab, 800);
            Canvas.SetLeft(lab, 600);
            this.cav.Children.Add(lab);
        }));
        return;
    }
}



介面設計

新增介面

概述

目前WebSocketServer和web後端服務是在同一個SpringBoot的工程中,所以只要將WebSocketServer託管到SpringContainer中,web後端服務可以通過 DI 的方式直接存取 WebSocketEndPoint。

如果考慮程式的低耦合,可以在WebSocketServer和web後端服務之間架設一個MQ。

核心程式碼


    @Autowired
    private LabelWebSocket ws;
    @GetMapping("/create")
    public boolean createLabel() throws IOException {
        String cameraId = "cml";
        //todo
        boolean result = ws.sendMsg(cameraId, "新增標籤");
        return result;
    }


風險

分散式風險

當前在 WebSocketServer 中,已經連線的client資訊是記錄在當前程序的cache中,如果服務做橫向擴容,cache資訊無法在多範例程序中傳遞,將導致無法正確的處理業務資料,並可能會發生意想不到的異常和bug,此問題在並行越高的情況下造成的影響越大

資源風險

web後端服務為基於java語言的springboot程式,這種型別程式的特點是記憶體消耗特別嚴重。WebSocketServer服務在本專案中僅用作訊息中介軟體,連通web後端服務和WPF使用者端。

首先WebSocketServer沒有太多的計算能力的消耗,記憶體消耗會隨著連線使用者端數量的增長而增長,網路將是最大的開銷,一方面需要轉發來自web後端服務的業務資料,並和WPF使用者端保持長連線;另一方面WebSocketServer和WPF使用者端的互動可能會走公網,而其和web後端服務必然是在區域網環境。

綜上,將web後端服務和WebSocketServer分開部署對於硬體資源成本和利用率來說是最好的選擇。

高可用風險

未引入重試機制,當某一個環節失敗之後,將導致異常情況發生。