歡迎光臨
每天分享高質量文章

再談 websocket 論架構設計

(點擊上方公眾號,可快速關註)


來源:Lrwin ,

lrwinx.github.io/2017/07/09/再談websocket-論架構設計/

導語

本篇文章以websocket的原理和落地為核心,來敘述websocket的使用,以及相關應用場景。

websocket概述

http與websocket

如我們所瞭解,http連接為一次請求一次響應(request->response),必須為同步呼叫方式。

而websocket為一次連接以後,會建立tcp連接,後續客戶端與服務器交互為全雙工方式的交互方式,客戶端可以發送訊息到服務端,服務端也可將訊息發送給客戶端。

此圖來源於Websocket協議的學習、調研和實現,如有侵權問題,告知後,刪除。

根據上圖,我們大致可以瞭解到http與websocket之間的區別和不同。

為什麼要使用websocket

那麼瞭解http與websocket之間的不同以後,我們為什麼要使用websocket呢? 他的應用場景是什麼呢?

我找到了一個比較符合websocket使用場景的描述

“The best fit for WebSocket is in web applications where the client and server need to exchange events at high frequency and with low latency.”

翻譯: 在客戶端與服務器端交互的web應用中,websocket最適合在高頻率低延遲的場景下,進行事件的交換和處理

此段來源於spring websocket的官方文件

http://docs.spring.io/spring/docs/5.0.0.M5/spring-framework-reference/html/websocket.html

瞭解以上知識後,我舉出幾個比較常見的場景:

  1. 游戲中的資料傳輸

  2. 股票K線圖資料

  3. 客服系統

根據如上所述,各個系統都來使用websocket不是更好嗎?

其實並不是,websocket建立連接之後,後邊交互都由tcp協議進行交互,故開發的複雜度會較高。當然websocket通訊,本身要考慮的事情要比HTTP協議的通訊考慮的更多.

所以如果不是有特殊要求(即 應用不是”高頻率低延遲”的要求),需要優先考慮HTTP協議是否可以滿足。

比如新聞系統,新聞的資料晚上10分鐘-30分鐘,是可以接受的,那麼就可以採用HTTP的方式進行輪詢(polling)操作呼叫REST接口。

當然有時我們建立了websocket通訊,並且希望通過HTTP提供的REST接口推送給某客戶端,此時需要考慮REST接口接受資料傳送給websocket中,進行廣播式的通訊方式。

至此,我已經講述了三種交互方式的使用場景:

  1. websocket獨立使用場景

  2. HTTP獨立使用場景

  3. HTTP中轉websocket使用場景

相關技術概念

websocket

websocket為一次HTTP握手後,後續通訊為tcp協議的通訊方式。

當然,和HTTP一樣,websocket也有一些約定的通訊方式,http通訊方式為http開頭的方式,e.g. http://xxx.com/path ,websocket通訊方式則為ws開頭的方式,e.g. ws://xxx.com/path

SSL:

  1. HTTP: https://xxx.com/path

  2. WEBSOCKET: wss://xxx.com/path

此圖來源於WebSocket 教程,如有侵權問題,告知後,刪除。

SockJS

正如我們所知,websocket協議雖然已經被制定,當時還有很多版本的瀏覽器或瀏覽器廠商還沒有支持的很好。

所以,SockJS,可以理解為是websocket的一個備選方案。

那它如何規定備選方案的呢?

它大概支持這樣幾個方案:

  1. Websockets

  2. Streaming

  3. Polling

當然,開啟並使用SockJS後,它會優先選用websocket協議作為傳輸協議,如果瀏覽器不支持websocket協議,則會在其他方案中,選擇一個較好的協議進行通訊。

看一下目前瀏覽器的支持情況:

此圖來源於github: sockjs-client

所以,如果使用SockJS進行通訊,它將在使用上保持一致,底層由它自己去選擇相應的協議。

可以認為SockJS是websocket通訊層上的上層協議。

底層對於開發者來說是透明的。

STOMP

STOMP 中文為: 面向訊息的簡單文本協議

websocket定義了兩種傳輸信息型別: 文本信息 和 二進制信息 ( text and binary ).

型別雖然被確定,但是他們的傳輸體是沒有規定的。

當然你可以自己來寫傳輸體,來規定傳輸內容。(當然,這樣的複雜度是很高的)

所以,需要用一種簡單的文本傳輸型別來規定傳輸內容,它可以作為通訊中的文本傳輸協議,即交互中的高級協議來定義交互信息。

STOMP本身可以支持流型別的網絡傳輸協議: websocket協議和tcp協議

它的格式為:

COMMAND

essay-header1:value1

essay-header2:value2

 

Body^@

  

 

SUBSCRIBE

id:sub-1

destination:/topic/price.stock.*

 

^@

 

 

 

SEND

destination:/queue/trade

content-type:application/json

content-length:44

 

{“action”:”BUY”,”ticker”:”MMM”,”shares”,44}^@

當然STOMP已經應用於很多訊息代理中,作為一個傳輸協議的規定,如:RabbitMQ, ActiveMQ

我們皆可以用STOMP和這類MQ進行訊息交互.

除了STOMP相關的代理外,實際上還提供了一個stomp.js,用於瀏覽器客戶端使用STOMP訊息協議傳輸的js庫。

讓我們很方便的使用stomp.js進行與STOMP協議相關的代理進行交互.

正如我們所知,如果websocket內容傳輸信息使用STOMP來進行交互,websocket也很好的於訊息代理器進行交互(如:RabbitMQ, ActiveMQ)

這樣就很好的提供了訊息代理的集成方案。

總結,使用STOMP的優點如下:

  1. 不需要自建一套自定義的訊息格式

  2. 現有stomp.js客戶端(瀏覽器中使用)可以直接使用

  3. 能路由信息到指定訊息地點

  4. 可以直接使用成熟的STOMP代理進行廣播 如:RabbitMQ, ActiveMQ

技術落地

後端技術方案選型

websocket服務端選型:spring websocket

支持SockJS,開啟SockJS後,可應對不同瀏覽器的通訊支持

支持STOMP傳輸協議,可無縫對接STOMP協議下的訊息代理器(如:RabbitMQ, ActiveMQ)

前端技術方案選型

前端選型: stomp.js,sockjs.js

後端開啟SOMP和SockJS支持後,前對應有對應的js庫進行支持.

所以選用此兩個庫.

總結

上述所用技術,是這樣的邏輯:

開啟socktJS:

如果有瀏覽器不支持websocket協議,可以在其他兩種協議中進行選擇,但是對於應用層來講,使用起來是一樣的。

這是為了支持瀏覽器不支持websocket協議的一種備選方案

使用STOMP:

使用STOMP進行交互,前端可以使用stomp.js類庫進行交互,訊息一STOMP協議格式進行傳輸,這樣就規定了訊息傳輸格式。

訊息進入後端以後,可以將訊息與實現STOMP格式的代理器進行整合。

這是為了訊息統一管理,進行機器擴容時,可進行負載均衡部署

使用spring websocket:

使用spring websocket,是因為他提供了STOMP的傳輸自協議的同時,還提供了StockJS的支持。

當然,除此之外,spring websocket還提供了權限整合的功能,還有自帶天生與spring家族等相關框架進行無縫整合。

應用場景

應用背景

2016年,在公司與同事一起討論和開發了公司內部的客服系統,由於前端技能的不足,很多通訊方面的問題,無法親自除錯前端來解決問題。

因為公司技術架構體系以前後端分離為主,故前端無法協助後端除錯,後端無法協助前端除錯

在加上websocket為公司剛啟用的協議,瞭解的人不多,導致前後端除錯問題重重。

一年後的今天,我打算將前端重溫,自己來除錯一下前後端,來發掘一下之前聯調的問題.

當然,前端,我只是考慮stomp.js和sockt.js的使用。

代碼階段設計

角色

客服

客戶

登錄用戶狀態

上線

下線

分配策略

用戶登陸後,應該根據用戶角色進行分配

關係儲存策略

應該提供關係型儲存策略: 考慮記憶體式策略(可用於測試),redis式策略

備註:優先應該考慮實現Inmemory策略,用於測試,讓關係儲存策略與儲存平臺無關

通訊層設計

歸類topic的廣播設計(通訊方式:1-n)

歸類queue的單點設計(通訊方式:1-1)

代碼實現

角色

import org.springframework.security.core.GrantedAuthority;

import org.springframework.security.core.authority.SimpleGrantedAuthority;

import org.springframework.security.core.userdetails.User;

import java.util.Collection;

public enum Role {

  CUSTOMER_SERVICE,

  CUSTOMER;

 

 

  public static boolean isCustomer(User user) {

      Collection authorities = user.getAuthorities();

      SimpleGrantedAuthority customerGrantedAuthority = new SimpleGrantedAuthority(“ROLE_” + Role.CUSTOMER.name());

      return authorities.contains(customerGrantedAuthority);

  }

 

  public static boolean isCustomerService(User user) {

      Collection authorities = user.getAuthorities();

      SimpleGrantedAuthority customerServiceGrantedAuthority = new SimpleGrantedAuthority(“ROLE_” + Role.CUSTOMER_SERVICE.name());

      return authorities.contains(customerServiceGrantedAuthority);

  }

}

代碼中User物件,為安全物件,即 spring中org.springframework.security.core.userdetails.User,為UserDetails的實現類。

User物件中,儲存了用戶授權後的很多基礎權限信息,和用戶信息。

如下:

public interface UserDetails extends Serializable {

  Collection extends GrantedAuthority> getAuthorities();

 

  String getPassword();

 

  String getUsername();

 

  boolean isAccountNonExpired();

 

  boolean isAccountNonLocked();

 

  boolean isCredentialsNonExpired();

 

  boolean isEnabled();

}

方法 #isCustomer 和 #isCustomerService 用來判斷用戶當前是否是顧客或者是客服。

登錄用戶狀態

public interface StatesManager {

 

    enum StatesManagerEnum{

        ON_LINE,

        OFF_LINE

    }

 

    void changeState(User user , StatesManagerEnum statesManagerEnum);

 

    StatesManagerEnum currentState(User user);

}

設計登錄狀態時,應存在登錄狀態管理相關的狀態管理器,此管理器只負責更改用戶狀態和獲取用戶狀態相關操作。

並不涉及其他關聯邏輯,這樣的代碼劃分,更有助於面向接口編程的擴展性

分配策略

public interface DistributionUsers {

  void distribution(User user);

}

分配角色接口設計,只關註傳入的用戶,並不關註此用戶是客服或者用戶,具體需要如何去做,由具體的分配策略來決定。

關係儲存策略

public interface RelationHandler {

 

  void saveRelation(User customerService,User customer);

 

  List listCustomers(User customerService);

 

  void deleteRelation(User customerService,User customer);

 

  void saveCustomerService(User customerService);

 

  List listCustomerService();

 

  User getCustomerService(User customer);

 

  boolean exist(User user);

 

  User availableNextCustomerService();

 

}

關係儲存策略,亦是只關註關係儲存相關,並不在乎於儲存到哪個儲存介質中。

實現類由Inmemory還是redis還是mysql,它並不專註。

但是,此處需要註意,對於這種關係儲存策略,開發測試時,並不涉及高可用,可將Inmemory先做出來用於測試。

開發功能同時,相關同事再來開發其他介質儲存的策略,性能測試以及UAT相關測試時,應切換為此介質儲存的策略再進行測試。

用戶綜合管理

對於不同功能的實現策略,由各個功能自己來實現,在使用上,我們僅僅根據接口編程即可。

所以,要將上述所有功能封裝成一個工具類進行使用,這就是所謂的 設計樣式: 門面樣式

@Component

public class UserManagerFacade {

    @Autowired

    private DistributionUsers distributionUsers;

    @Autowired

    private StatesManager statesManager;

    @Autowired

    private RelationHandler relationHandler;

 

 

    public void login(User user) {

        if (roleSemanticsMistiness(user)) {

            throw new SessionAuthenticationException(“角色語意不清晰”);

        }

 

        distributionUsers.distribution(user);

        statesManager.changeState(user, StatesManager.StatesManagerEnum.ON_LINE);

    }

    private boolean roleSemanticsMistiness(User user) {

        Collection authorities = user.getAuthorities();

 

        SimpleGrantedAuthority customerGrantedAuthority = new SimpleGrantedAuthority(“ROLE_”+Role.CUSTOMER.name());

        SimpleGrantedAuthority customerServiceGrantedAuthority = new SimpleGrantedAuthority(“ROLE_”+Role.CUSTOMER_SERVICE.name());

 

        if (authorities.contains(customerGrantedAuthority)

                && authorities.contains(customerServiceGrantedAuthority)) {

            return true;

        }

 

        return false;

    }

 

    public void logout(User user){

        statesManager.changeState(user, StatesManager.StatesManagerEnum.OFF_LINE);

    }

 

 

    public User getCustomerService(User user){

        return relationHandler.getCustomerService(user);

    }

 

    public List listCustomers(User user){

        return relationHandler.listCustomers(user);

    }

 

    public StatesManager.StatesManagerEnum getStates(User user){

        return statesManager.currentState(user);

    }

 

}

UserManagerFacade 中註入三個相關的功能接口:

@Autowired

private DistributionUsers distributionUsers;

@Autowired

private StatesManager statesManager;

@Autowired

private RelationHandler relationHandler;

可提供:

  1. 登錄(#login)

  2. 登出(#logout)

  3. 獲取對應客服(#getCustomerService)

  4. 獲取對應用戶串列(#listCustomers)

  5. 當前用戶登錄狀態(#getStates)

這樣的設計,可保證對於用戶關係的管理都由UserManagerFacade來決定

其他內部的操作類,對於使用者來說,並不關心,對開發來講,不同功能的策略都是透明的。

通訊層設計 – 登錄,授權

spring websocket雖然並沒有要求connect時,必須授權,因為連接以後,會分發給客戶端websocket的session id,來區分客戶端的不同。

但是對於大多數應用來講,登錄授權以後,進行websocket連接是最合理的,我們可以進行權限的分配,和權限相關的管理。

我模擬例子中,使用的是spring security的Inmemory的相關配置:

public class WebSecurityConfig extends WebSecurityConfigurerAdapter {

 

  @Override

  protected void configure(AuthenticationManagerBuilder auth) throws Exception {

      auth.inMemoryAuthentication().withUser(“admin”).password(“admin”).roles(Role.CUSTOMER_SERVICE.name());

      auth.inMemoryAuthentication().withUser(“admin1”).password(“admin”).roles(Role.CUSTOMER_SERVICE.name());

 

 

      auth.inMemoryAuthentication().withUser(“user”).password(“user”).roles(Role.CUSTOMER.name());

      auth.inMemoryAuthentication().withUser(“user1”).password(“user”).roles(Role.CUSTOMER.name());

      auth.inMemoryAuthentication().withUser(“user2”).password(“user”).roles(Role.CUSTOMER.name());

      auth.inMemoryAuthentication().withUser(“user3”).password(“user”).roles(Role.CUSTOMER.name());

  }

 

  @Override

  protected void configure(HttpSecurity http) throws Exception {

      http.csrf().disable()

              .formLogin()

              .and()

              .authorizeRequests()

              .anyRequest()

              .authenticated();

  }

}

相對較為簡單,創建2個客戶,4個普通用戶。

當認證管理器認證後,會將認證後的合法認證安全物件user(即 認證後的token)放入STOMP的essay-header中.

此例中,認證管理認證之後,認證的token為org.springframework.security.authentication.UsernamePasswordAuthenticationToken,

此token認證後,將放入websocket的essay-header中。(即 後邊會談到的安全物件 java.security.Principal)

通訊層設計 – websocket配置

@Order(Ordered.HIGHEST_PRECEDENCE + 99)

@Configuration

@EnableWebSocketMessageBroker

public class WebSocketConfig extends AbstractWebSocketMessageBrokerConfigurer {

 

    @Override

    public void registerStompEndpoints(StompEndpointRegistry registry) {

        registry.addEndpoint(“/portfolio”).withSockJS();

    }

 

    @Override

    public void configureMessageBroker(MessageBrokerRegistry config) {

        config.setApplicationDestinationPrefixes(“/app”);

        config.enableSimpleBroker(“/topic”, “/queue”);

 

    }

}

此配置中,有幾點需進行講解:

其中端點”portfolio”,用於socktJs進行websocket連接時使用,只用於建立連接。

“/topic”, “/queue”,則為STOMP的語意約束,topic語意為1-n(廣播機制),queue語意為1-1(單點機制)

“app”,此為應用級別的映射終點前綴,這樣說有些晦澀,一會看一下示例將會清晰很多。

通訊層設計 – 創建連接

用於連接spring websocket的端點為portfolio,它可用於連接,看一下具體實現:

分享創造快樂