(點選上方公眾號,可快速關註)
來源: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
瞭解以上知識後,我舉出幾個比較常見的場景:
-
遊戲中的資料傳輸
-
股票K線圖資料
-
客服系統
根據如上所述,各個系統都來使用websocket不是更好嗎?
其實並不是,websocket建立連線之後,後邊互動都由tcp協議進行互動,故開發的複雜度會較高。當然websocket通訊,本身要考慮的事情要比HTTP協議的通訊考慮的更多.
所以如果不是有特殊要求(即 應用不是”高頻率低延遲”的要求),需要優先考慮HTTP協議是否可以滿足。
比如新聞系統,新聞的資料晚上10分鐘-30分鐘,是可以接受的,那麼就可以採用HTTP的方式進行輪詢(polling)操作呼叫REST介面。
當然有時我們建立了websocket通訊,並且希望透過HTTP提供的REST介面推送給某客戶端,此時需要考慮REST介面接受資料傳送給websocket中,進行廣播式的通訊方式。
至此,我已經講述了三種互動方式的使用場景:
-
websocket獨立使用場景
-
HTTP獨立使用場景
-
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:
-
HTTP: https://xxx.com/path
-
WEBSOCKET: wss://xxx.com/path
此圖來源於WebSocket 教程,如有侵權問題,告知後,刪除。
SockJS
正如我們所知,websocket協議雖然已經被制定,當時還有很多版本的瀏覽器或瀏覽器廠商還沒有支援的很好。
所以,SockJS,可以理解為是websocket的一個備選方案。
那它如何規定備選方案的呢?
它大概支援這樣幾個方案:
-
Websockets
-
Streaming
-
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的優點如下:
-
不需要自建一套自定義的訊息格式
-
現有stomp.js客戶端(瀏覽器中使用)可以直接使用
-
能路由資訊到指定訊息地點
-
可以直接使用成熟的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;
可提供:
-
登入(#login)
-
登出(#logout)
-
獲取對應客服(#getCustomerService)
-
獲取對應使用者串列(#listCustomers)
-
當前使用者登入狀態(#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,它可用於連線,看一下具體實現:
var socket = new SockJS("/portfolio");
stompClient = Stomp.over(socket);
stompClient.connect({}, function(frame) {
showGreeting("登入使用者: " + frame.essay-headers["user-name"]);
});
這樣便建立了連線。 後續的其他操作就可以透過stompClient控制代碼進行使用了。
通訊層設計 – spring websocket訊息模型
見模型圖:
此圖來源spring-websocket官方檔案
可以看出對於同一定於標的都為:/topic/broadcast,它的傳送渠道為兩種:/app/broadcast和/topic/broadcast
如果為/topic/broadcast,直接可將訊息體傳送給定於標的(/topic/broadcast)。
如果是/app/broadcast,它將訊息對應在MessageHandler方法中進行處理,處理後的結果發放到broker channel中,最後再講訊息體傳送給標的(/topic/broadcast)
當然,這裡邊所說的app字首就是剛才我們在websocket配置中的字首.
看一個例子:
前端訂閱:
stompClient.subscribe('/topic/broadcast', function(greeting){
showGreeting(greeting.body);
});
後端服務:
@Controller
public class ChatWebSocket extends AbstractWebSocket{
@MessageMapping("broadcast")
public String broadcast(@Payload @Validated Message message, Principal principal) {
return "傳送人: " + principal.getName() + " 內容: " + message.toString();
}
}
@Data
public class Message {
@NotNull(message = "標題不能為空")
private String title;
private String content;
}
前端傳送:
function sendBroadcast() {
stompClient.send("/app/broadcast",{},JSON.stringify({'content':'message content'}));
}
這種傳送將訊息傳送給後端帶有@MessageMapping註解的方法,然後組合完資料以後,在推送給訂閱/topic/broadcast的前端
function sendBroadcast() {
stompClient.send("/topic/broadcast",{},JSON.stringify({'content':'message content'}));
}
這種傳送直接將訊息傳送給訂閱/topic/broadcast的前端,並不透過註解方法進行流轉。
我相信上述這個理解已經解釋清楚了spring websocket的訊息模型圖
通訊層設計 – @MessageMapping
帶有這個註解的@Controller下的方法,正是對應websocket中的中轉資料的處理方法。
那麼這個註解下的方法究竟可以獲取哪些資料,其中有什麼原理呢?
我大概說一下:
Message,@Payload,@Header,@Headers,MessageHeaders,MessageHeaderAccessor, SimpMessageHeaderAccessor,StompHeaderAccessor
以上這些都是獲取訊息頭,訊息體,或整個訊息的基本物件模型。
@DestinationVariable
這個註解用於動態監聽路徑,很想rest中的@PathVariable:
e.g.:
@MessageMapping("/queue/chat/{uid}")
public void chat(@Payload @Validated Message message, @DestinationVariable("uid") String uid, Principal principal) {
String msg = "傳送人: " + principal.getName() + " chat ";
simpMessagingTemplate.convertAndSendToUser(uid,"/queue/chat",msg);
}
java.security.Principal
這個物件我需要重點說一下。
他則是spring security認證之後,產生的Token物件,即本例中的UsernamePasswordAuthenticationToken.
UsernamePasswordAuthenticationToken類圖
不難發現UsernamePasswordAuthenticationToken是Principal的一個實現.
可以將Principal直接轉成授權後的token,進行操作:
UsernamePasswordAuthenticationToken user = (UsernamePasswordAuthenticationToken) principal;
正如前邊設計章節所說,整個使用者設計都是對org.springframework.security.core.userdetails.User進行操作,那如何拿到User物件呢。
很簡單,如下:
UsernamePasswordAuthenticationToken user = (UsernamePasswordAuthenticationToken) principal;
User user = (User) user.getPrincipal()
通訊層設計 – 1-1 && 1-n
1-n topic:
此方式,上述訊息模型章節已經講過,此處不再贅述
1-1 queue:
客服-使用者溝通為1-1使用者互動的案例
前端:
stompClient.subscribe('/user/queue/chat',function(greeting){
showGreeting(greeting.body);
});
後端:
@MessageMapping("/queue/chat/{uid}")
public void chat(@Payload @Validated Message message, @DestinationVariable("uid") String uid, Principal principal) {
String msg = "傳送人: " + principal.getName() + " chat ";
simpMessagingTemplate.convertAndSendToUser(uid,"/queue/chat",msg);
}
傳送端:
function chat(uid) {
stompClient.send("/app/queue/chat/"+uid,{},JSON.stringify({'title':'hello','content':'message content'}));
}
上述的轉化,看上去沒有topic那樣1-n的廣播要流暢,因為程式碼中採用約定的方式進行開發,當然這是由spring約定的。
約定轉化的處理器為UserDestinationMessageHandler。
大概的語意邏輯如下:
“An application can send messages targeting a specific user, and Spring’s STOMP support recognizes destinations prefixed with “/user/“ for this purpose. For example, a client might subscribe to the destination “/user/queue/position-updates”. This destination will be handled by the UserDestinationMessageHandler and transformed into a destination unique to the user session, e.g. “/queue/position-updates-user123”. This provides the convenience of subscribing to a generically named destination while at the same time ensuring no collisions with other users subscribing to the same destination so that each user can receive unique stock position updates.”
大致的意思是說:如果是客戶端訂閱了/user/queue/position-updates,將由UserDestinationMessageHandler轉化為一個基於使用者會話的訂閱地址,比如/queue/position-updates-user123,然後可以進行通訊。
例子中,我們可以把uid當成使用者的會話,因為使用者1-1通訊是透過spring security授權的,所以我們可以把會話當做授權後的token.
如登入使用者token為: UsernamePasswordAuthenticationToken newToken = new UsernamePasswordAuthenticationToken(“admin”,”user”);
且這個token是合法的,那麼/user/queue/chat訂閱則為/queue/chat-admin
傳送時,如果透過/user/admin/queue/chat,則不透過@MessageMapping直接進行推送。
如果透過/app/queue/chat/admin,則將訊息由@MessageMapping註解處理,最終傳送給/user/admin/queue/chat終點
追蹤程式碼simpMessagingTemplate.convertAndSendToUser:
@Override
public void convertAndSendToUser(String user, String destination, Object payload, Map
essay-headers, MessagePostProcessor postProcessor) throws MessagingException {
Assert.notNull(user, "User must not be null");
user = StringUtils.replace(user, "/", "%2F");
super.convertAndSend(this.destinationPrefix + user + destination, payload, essay-headers, postProcessor);
}
說明最後的路徑依然是/user/admin/queue/chat終點.
通訊層設計 – @SubscribeMapping
@SubscribeMapping註解可以完成訂閱即傳回的功能。
這個很像HTTP的request-response,但不同的是HTTP的請求和響應是同步的,每次請求必須得到響應。
而@SubscribeMapping則是非同步的。意思是說:當訂閱時,直到回應可響應時在進行處理。
通訊層設計 – 異常處理
@MessageMapping是支援jsr 303校驗的,它支援@Validated註解,可丟擲錯誤異常,如下:
@MessageMapping("broadcast")
public String broadcast(@Payload @Validated Message message, Principal principal) {
return "傳送人: " + principal.getName() + " 內容: " + message.toString();
}
那異常如何處理呢
@MessageExceptionHandler,它可以進行訊息層的異常處理
@MessageExceptionHandler
@SendToUser(value = "/queue/error",broadcast = false)
public String handleException(MethodArgumentNotValidException methodArgumentNotValidException) {
BindingResult bindingResult = methodArgumentNotValidException.getBindingResult();
if (!bindingResult.hasErrors()) {
return "未知錯誤";
}
List
allErrors = bindingResult.getFieldErrors(); return "jsr 303 錯誤: " + allErrors.iterator().next().getDefaultMessage();
}
其中@SendToUser,是指只將訊息傳送給當前使用者,當然,當前使用者需要訂閱/user/queue/error地址。
註解中broadcast,則表明訊息不進行多會話的傳播(有可能一個使用者登入3個瀏覽器,有三個會話),如果此broadcast=false,則只傳給當前會話,不進行其他會話傳播
總結
本文從websocket的原理和協議,以及內容相關協議等不同維度進行了詳細介紹。
最終以一個應用場景為例,從專案的結構設計,以及程式碼策略設計,設計樣式等不同方面展示了websocket的通訊功能在專案中的使用。
如何實現某一功能其實並不重要,重要的是得瞭解理論,深入理論之後,再進行開發。
看完本文有收穫?請轉發分享給更多人
關註「ImportNew」,提升Java技能