SpringBoot中使用websocket.md

最近有這樣一個需求,網關廠家將物聯設備接入我司雲平臺的時候,希望能看到上報設備數據的關鍵日誌,以方便調試。
首先想到的就是使用websocket推送。瀏覽器發起websocket連接,發送訂閱消息,然後往這個連接session中推送日誌。

整個設計流程如下圖:

在這裏插入圖片描述

1.實現

我們設計兩個類,一個類命名爲WebSocketServer 用來管理websocket連接以及發送消息;另一個類命名爲WebSocketBus用來管理WebSocketServer 對象,以及接收設備日誌後匹配對應的WebSocketServer 對象,將日誌信息推送到瀏覽器。

Talk is cheap, show me the code!

@ServerEndpoint(value="/websocket/message")
@Component
public class WebSocketServer {
    
    private Logger log = Logger.getLogger("WebSocket");
    
    private WebSocketBus webSocketBus;

    
    private Session session;//與某個客戶端的連接會話,需要通過它來給客戶端發送數據
    
    private String key;//訂閱日誌的標識
    
    
    

    /**
     * 連接建立成功調用的方法*/
    @OnOpen
    public void onOpen(Session session) {
        this.session = session;  
         //在線數加1
   
        String success = "websocket連接成功!";
        try {
            sendMessage(success);
        } catch (IOException e) {
            log.error(e,e);
        }
    }

    /**
     * 連接關閉調用的方法
     */
    @OnClose
    public void onClose() {
        webSocketBus.removeServer(key,this);  //從set中刪除
        log.info("============= 有一連接關閉!key=" + key+",sessionId="+session.getId());
    }

    /**
     * 收到客戶端消息後調用的方法
     *
     * @param message 客戶端發送過來的消息*/
    @OnMessage
    public void onMessage(String message, Session session) {
        if(StringUtil.isEmpty(message)) {
            return;
        }
        HashMap<String,String> javaObject = JacksonUtil.toJavaObject(message, HashMap.class);
        if(javaObject == null){
             log.info("unknown message: " + message);
             return ;
         }
        String operation = javaObject.get("operation");
        String key = javaObject.get("key");
        String uuid=(String)javaObject.get("uuid");
        if(StringUtil.isEmpty(operation)){
            log.info("unknown operation : " + message);
            return ;
        }
        
        if("register".equals(operation)){
            this.key = key;
            getWebSocketBus().addServer(key, this);
    
            log.info("[+]     register key="+key+",account="+",uuid="+uuid);
        }else if("unRegister".equals(operation)){
            webSocketBus.removeServer(key, this); 
            log.info("[+]     unregister key="+key+",account="+",uuid="+uuid);
        }
             
        
    }

    private WebSocketBus getWebSocketBus() {
        // TODO Auto-generated method stub
        if(this.webSocketBus == null) {
            webSocketBus =  (WebSocketBus) SpringContextUtil.getBean("webSocketBus");
        }
        return webSocketBus;
        
    }

    /**
     * 發生錯誤時調用
     */
    @OnError
    public void onError(Session session, Throwable error) {
        log.error(error,error);
    }


    public void sendMessage(String message) throws IOException {
//        this.session.getBasicRemote().sendText(message);
        this.session.getAsyncRemote().sendText(message);
    }


    public String getkey() {
        return key;
    } 
}

@Component
public class WebSocketBus {
    private Logger log = Logger.getLogger(this.getClass());
    
    @Autowired
    private KafkaTemplate<String,String> kafkaTemplate;
    
        
    @Value(value="${LogRegistTopic}")
    private String logRegister;//訂閱|取消訂閱topic
    
    private static AtomicInteger onlineCount = new AtomicInteger(0);
    
    private Map<String,Set<WebSocketServer>> sessionCache = new ConcurrentHashMap<>();
    private Gson gson = new GsonBuilder().create();//線程安全的,大膽用吧
    
    
    @KafkaListener(id = "log",topics = {"${LogTopic}"})
    public void listen(ConsumerRecord<String, ?> record) {
        Optional kafkaMessage = Optional.ofNullable(record.value());
        Optional<String> kafkaKey = Optional.ofNullable(record.key());
        if (kafkaKey.isPresent()) {
            Object value = kafkaMessage.get();
            String key = kafkaKey.get();
            GatewayFormatLog gatewayLog = gson.fromJson((String)value, GatewayFormatLog.class);
            if(sessionCache.containsKey(key)) {
                Set<WebSocketServer> set = sessionCache.get(key);
                for(WebSocketServer server :set) {
                    try {
                            server.sendMessage(gatewayLog.getMessage().toString());
                    } catch (IOException e) {
                        log.error(e,e);
                    }
                }
            }
        }
    }
    
    
    
    public void addServer(String key,WebSocketServer server) {
        boolean notRegisted = true;
        if(sessionCache.containsKey(key)) {
            Set<WebSocketServer> set = sessionCache.get(key);
            if(set.contains(server)){
                notRegisted = false;
            }else {
                set.add(server);
            }
            
        }else {
            Set<WebSocketServer> set = new  CopyOnWriteArraySet<WebSocketServer>();
            set.add(server);
            sessionCache.put(key, set);
        }
        if(notRegisted) {
            kafkaTemplate.send(logRegister, gson.toJson(new DataEvent("register",key)));
        }
        
    }
    
    public void removeServer(String key,WebSocketServer server) {
        if(sessionCache.containsKey(key)) {
            Set<WebSocketServer> set = sessionCache.get(key);
            set.remove(server);
            kafkaTemplate.send(logRegister, gson.toJson(new DataEvent("unregister",key)));
        }
    }
    
    public static int getOnlineCount() {
        return onlineCount.get();
    }

    public static void addOnlineCount() {
        onlineCount.incrementAndGet();
    }

    public static synchronized void subOnlineCount() {
        onlineCount.decrementAndGet();
    }

}

WebSocketServer負責建立連接,以及收發websocket消息。瀏覽器每發起一個連接,都對應一個WebSocketServer對象。
WebSocketBus管理多個WebSocketServer對象。執行過程如下:
1.WebSocketServer的onMessage接收訂閱消息,並將訂閱消息交給WebSocketBus處理
2.1 如果是訂閱消息,執行WebSocketBus.addServer,同一個key對應一個或多個websocket連接(就是多個客戶端訂閱了同一個key)。我們用CopyOnWriteArraySet存放WebSocketServer,如果是同一個對象,則不會重複添加。如果這個key沒有被訂閱過,就往kafka中發一條訂閱消息。設備服務消費
2.2 如果是取消訂閱服務,過程類似,往kafka中發一條消息訂閱消息。

3.設備服務會將訂閱的日誌發送到Kafka中。WebSocketBus的listen來消費,根據key匹配到多個WebSocketServer,向每一個WebSocketServer推送消息。

下面是前端的測試代碼:


<!DOCTYPE html>
<html>
<head>
    <meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
    <title>WebSocket/SockJS)</title>

    <script src="http://cdn.sockjs.org/sockjs-0.3.min.js"></script>

    <script type="text/javascript">
        var websocket = null;

        //判斷當前瀏覽器是否支持WebSocket
        if('WebSocket' in window){
            websocket = new WebSocket("ws://localhost:9100/websocket/message");
        }
        else{
            alert('Not support websocket')
        }

        //連接發生錯誤的回調方法
        websocket.onerror = function(){
            setMessageInnerHTML("error");
        };

        //連接成功建立的回調方法
        websocket.onopen = function(event){
            setMessageInnerHTML("open");
        }

        //接收到消息的回調方法
        websocket.onmessage = function(){
            setMessageInnerHTML(event.data);
        }

        //連接關閉的回調方法
        websocket.onclose = function(){
            setMessageInnerHTML("close");
        }

        //監聽窗口關閉事件,當窗口關閉時,主動去關閉websocket連接,防止連接還沒斷開就關閉窗口,server端會拋異常。
        window.onbeforeunload = function(){
            websocket.close();
        }

        //將消息顯示在網頁上
        function setMessageInnerHTML(innerHTML){
            document.getElementById('message').innerHTML += innerHTML + '<br/>';
        }

        //關閉連接
        function closeWebSocket(){
            websocket.close();
        }

        //發送消息
        function send(){
            var message = document.getElementById('text').value;
            websocket.send(message);
        }

    </script>
</head>
<body>
Welcome<br/>
<input id="text" type="text" />
<button onclick="send()">Send</button>
<button onclick="closeWebSocket()">Close</button>
<div id="message"></div>
</body>
</html>

2. 使用@ServerEndpoint無法注入Bean

你可能注意到了,WebSocketServer中使用WebSocketBus時,並沒有使用@Autowired,爲什麼呢?實際上使用@Autowired注入之後,沒有注入成功,使用時webSocketBus還是爲null。
我們在WebSocketServer類上使用了@Component註解。雖然@Component默認是單例模式的,但springboot還是會爲每個websocket連接初始化一個bean。
查了一下源碼:

public class DefaultServerEndpointConfigurator
        extends ServerEndpointConfig.Configurator {

    @Override
    public <T> T getEndpointInstance(Class<T> clazz)
            throws InstantiationException {
        try {
            return clazz.getConstructor().newInstance();
        } catch (InstantiationException e) {
            throw e;
        } catch (ReflectiveOperationException e) {
            InstantiationException ie = new InstantiationException();
            ie.initCause(e);
            throw ie;
        }
    }
}

使用@ServerEndpoint註解之後,無法自動注入Bean。每次創建一個新的連接之後,都是用反射創建一個對象,中間沒有從Sprin容器中找相應的Bean。
所以我們要麼自己獲取Bean,要麼將注入的Bean設置爲static,讓其注入到類上。
工具類獲取Spring Bean,只需實現ApplicationContextAware 接口即可。

@Component
public class SpringContextUtil implements ApplicationContextAware {
     private static ApplicationContext applicationContext;     //Spring應用上下文環境
     private static Properties properties=new Properties();
      /**
      * 實現ApplicationContextAware接口的回調方法,設置上下文環境   
      * @param applicationContext
      * @throws BeansException
      */
      public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
          SpringContextUtil.applicationContext = applicationContext;
      }
     
      /**
      * @return ApplicationContext
      */
      public static ApplicationContext getApplicationContext() {
        return applicationContext;
      }
      
      * 獲取對象   
      * @param name
      * @return Object 一個以所給名字註冊的bean的實例
      * @throws BeansException
      */
      public static Object getBean(String name) throws BeansException {
          if(applicationContext==null) {
              return null;
          }
        return applicationContext.getBean(name);
      }
}

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