Spring 4.0為WebSocket通信提供了支持,包括:
- 發送和接收消息的低層級API;
- 發送和接收消息的高級API;
- 用來發送消息的模板;
- 支持SockJS,用來解決瀏覽器端、服務器以及代理不支持WebSocket的問題。
1 使用Spring的低層級WebSocket API
按照其最簡單的形式,WebSocket只是兩個應用之間通信的通道。位於WebSocket一端的應用發送消息,另外一端處理消息。因為它是全雙工的,所以每一端都可以發送和處理消息。如圖18.1所示。
WebSocket通信可以應用於任何類型的應用中,但是WebSocket最常見的應用場景是實現服務器和基於瀏覽器的應用之間的通信。
為了在Spring使用較低層級的API來處理消息,我們必須編寫一個實現WebSocketHandler的類.WebSocketHandler需要我們實現五個方法。相比直接實現WebSocketHandler,更為簡單的方法是擴展AbstractWebSocketHandler,這是WebSocketHandler的一個抽象實現。
public class MarcoHandler extends AbstractWebSocketHandler {
private static final Logger logger = LoggerFactory.getLogger(MarcoHandler.class);
@Override
protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
logger.info("Received message: " + message.getPayload());
Thread.sleep(2000);
session.sendMessage(new TextMessage("Polo!"));
}
}
除了重載WebSocketHandler中所定義的五個方法以外,我們還可以重載AbstractWebSocketHandler中所定義的三個方法:
- handleBinaryMessage()
- handlePongMessage()
- handleTextMessage()
這三個方法只是handleMessage()方法的具體化,每個方法對應於某一種特定類型的消息。
另外一種方案,我們可以擴展TextWebSocketHandler或BinaryWebSocketHandler。TextWebSocketHandler是AbstractWebSocketHandler的子類,它會拒絕處理二進制消息。它重載了handleBinaryMessage()方法,如果收到二進制消息的時候,將會關閉WebSocket連接。與之類似,BinaryWebSocketHandler也是AbstractWeb-SocketHandler的子類,它重載了handleTextMessage()方法,如果接收到文本消息的話,將會關閉連接。
現在,已經有了消息處理器類,我們必須要對其進行配置,這樣Spring才能將消息轉發給它。在Spring的Java配置中,這需要在一個配置類上使用@EnableWebSocket,並實現WebSocketConfigurer接口,如下面的程序清單所示。
程序清單18.2 在Java配置中,啟用WebSocket並映射消息處理器
@Configuration
@EnableWebSocket
public class WebSocketConfig implements WebSocketConfigurer {
@Override
public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
// registry.addHandler(marcoHandler(), "/marco").withSockJS();
registry.addHandler(marcoHandler(), "/marco");
}
@Bean
public MarcoHandler marcoHandler() {
return new MarcoHandler();
}
}
或XML配置:
程序清單18.3 借助websocket命名空間以XML的方式配置WebSocket
不管使用Java還是使用XML,這就是所需的配置。
現在,我們可以把注意力轉向客戶端,它會發送“Marco!”文本消息到服務器,並監聽來自服務器的文本消息。如下程序清單所展示的JavaScript代碼開啟了一個原始的WebSocket並使用它來發送消息給服務器。
程序清單18.4 連接到“marco” WebSocket的JavaScript客戶端
通過發送“Marco!”,這個無休止的Marco Polo游戲就開始了,因為服務器端的MarcoHandler作為響應會將“Polo!”發送回來,當客戶端收到來自服務器的消息后,onmessage事件會發送另外一個“Marco!”給服務器。這個過程會一直持續下去,直到連接關閉。
2 應對不支持WebSocket的場景
WebSocket是一個相對比較新的規范。雖然它早在2011年底就實現了規范化,但即便如此,在Web瀏覽器和應用服務器上依然沒有得到一致的支持。Firefox和Chrome早就已經完整支持WebSocket了,但是其他的一些瀏覽器剛剛開始支持WebSocket。如下列出了幾個流行的瀏覽器支持WebSocket功能的最低版本:
- Internet Explorer:10.0
- Firefox: 4.0(部分支持),6.0(完整支持)。
- Chrome: 4.0(部分支持),13.0(完整支持)。
- Safari: 5.0(部分支持),6.0(完整支持)。
- Opera: 11.0(部分支持),12.10(完整支持)。
- iOS Safari: 4.2(部分支持),6.0(完整支持)。
- Android Browser: 4.4。
服務器端對WebSocket的支持也好不到哪里去。GlassFish在幾年前就開始支持一定形式的WebSocket,但是很多其他的應用服務器在最近的版本中剛剛開始支持WebSocket。例如,我在測試上述例子的時候,所使用的就是Tomcat 8的發布候選構建版本。
即便瀏覽器和應用服務器的版本都符合要求,兩端都支持WebSocket,在這兩者之間還有可能出現問題。防火牆代理通常會限制所有除HTTP以外的流量。它們有可能不支持或者(還)沒有配置允許進行WebSocket通信。
幸好,提到WebSocket的備用方案,這恰是SockJS所擅長的。SockJS讓我們能夠使用統一的編程模型,就好像在各個層面都完整支持WebSocket一樣,SockJS在底層會提供備用方案。
例如,為了在服務端啟用SockJS通信,我們在Spring配置中可以很簡單地要求添加該功能。重新回顧一下程序清單18.2中的registerWebSocketHandlers()方法,稍微加一點內容就能啟用SockJS:
@Override
public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
registry.addHandler(marcoHandler(), "/marco").withSockJS();
}
- XML完成相同的配置效果:
要在客戶端使用SockJS,需要確保加載了SockJS客戶端庫。具體的做法在很大程度上依賴於使用JavaScript模塊加載器(如require.js或curl.js)還是簡單地使用<script>
標簽加載JavaScript庫。加載SockJS客戶端庫的最簡單辦法是使用<script>
標簽從SockJS CDN中進行加載,如下所示:
<script src="http://cdn.sockjs.org/sockjs-0.3.min.js"></script>
除了加載SockJS客戶端庫以外,在程序清單18.4中,要使用SockJS只需修改兩行代碼:
var url = 'marco';
var sock = new SocktJS(url);
所做的第一個修改就是URL。SockJS所處理的URL是“http://”或“https://”模式,而不是“ws://”和“wss://”。即便如此,我們還是可以使用相對URL,避免書寫完整的全限定URL。在本例中,如果包含JavaScript的頁面位於“http://localhost:8080/websocket”路徑下,那么給定的“marco”路徑將會形成到“http://localhost:8080/websocket/marco”的連接。
3 使用STOMP消息
直接使用WebSocket(或SockJS)就很類似於使用TCP套接字來編寫Web應用。因為沒有高層級的線路協議(wire protocol),因此就需要我們定義應用之間所發送消息的語義,還需要確保連接的兩端都能遵循這些語義。
不過,好消息是我們並非必須要使用原生的WebSocket連接。就像HTTP在TCP套接字之上添加了請求-響應模型層一樣,STOMP在WebSocket之上提供了一個基於幀的線路格式(frame-based wire format)層,用來定義消息的語義。
乍看上去,STOMP的消息格式非常類似於HTTP請求的結構。與HTTP請求和響應類似,STOMP幀由命令、一個或多個頭信息以及負載所組成。例如,如下就是發送數據的一個STOMP幀:
SEND
destination:/app/marco
content-length:20
{\"message\":\"Marco!\"}
3.1 啟用STOMP消息功能
在Spring MVC中為控制器方法添加@MessageMapping注解,使其處理STOMP消息,它與帶有@RequestMapping注解的方法處理HTTP請求的方式非常類似。但是與@RequestMapping不同的是
- @MessageMapping的功能無法通過@EnableWebMvc啟用,而是@EnableWebSocketMessageBroker。
- Spring的Web消息功能基於消息代理(message broker)構建,因此除了告訴Spring我們想要處理消息以外,還有其他的內容需要配置。
@Configuration
@EnableWebSocketMessageBroker
public class WebSocketStompConfig extends AbstractWebSocketMessageBrokerConfigurer {
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
registry.addEndpoint("/marcopolo").withSockJS();
}
@Override
public void configureMessageBroker(MessageBrokerRegistry registry) {
// registry.enableStompBrokerRelay("/queue", "/topic");
registry.enableSimpleBroker("/queue", "/topic");
registry.setApplicationDestinationPrefixes("/app");
}
}
上述配置,它重載了registerStompEndpoints()方法,將“/marcopolo”注冊為STOMP端點。這個路徑與之前發送和接收消息的目的地路徑有所不同。這是一個端點,客戶端在訂閱或發布消息到目的地路徑前,要連接該端點。
WebSocketStompConfig還通過重載configureMessageBroker()方法配置了一個簡單的消息代理。消息代理將會處理前綴為“/topic”和“/queue”的消息。除此之外,發往應用程序的消息將會帶有“/app”前綴。圖18.2展現了這個配置中的消息流。
啟用STOMP代理中繼
對於生產環境下的應用來說,你可能會希望使用真正支持STOMP的代理來支撐WebSocket消息,如RabbitMQ或ActiveMQ。這樣的代理提供了可擴展性和健壯性更好的消息功能,當然它們也會完整支持STOMP命令。我們需要根據相關的文檔來為STOMP搭建代理。搭建就緒之后,就可以使用STOMP代理來替換內存代理了,只需按照如下方式重載configureMessageBroker()方法即可:
@Override
public void configureMessageBroker(MessageBrokerRegistry registry) {
registry.enableStompBrokerRelay("/queue", "/topic");
registry.setApplicationDestinationPrefixes("/app");
}
上述configureMessageBroker()方法的第一行代碼啟用了STOMP代理中繼(broker relay)功能,並將其目的地前綴設置為“/topic”和“/queue”。這樣的話,Spring就能知道所有目的地前綴為“/topic”或“/queue”的消息都會發送到STOMP代理中。
在第二行的configureMessageBroker()方法中將應用的前綴設置為“/app”。所有目的地以“/app”打頭的消息都將會路由到帶有@MessageMapping注解的方法中,而不會發布到代理隊列或主題中。
默認情況下,STOMP代理中繼會假設代理監聽localhost的61613端口,並且客戶端的username和password均為“guest”。如果你的STOMP代理位於其他的服務器上,或者配置成了不同的客戶端憑證,那么我們可以在啟用STOMP代理中繼的時候,需要配置這些細節信息:
@Override
public void configureMessageBroker(MessageBrokerRegistry registry) {
registry.enableStompBrokerRelay("/queue", "/topic")
.setRelayHost("rabbit.someotherserver")
.setRelayPort(62623)
.setClientLogin("marcopolo")
.setClientPasscode("letmein01")
registry.setApplicationDestinationPrefixes("/app");
}
3.2 處理來自客戶端的STOMP消息
Spring 4.0引入了@MessageMapping注解,它用於STOMP消息的處理,類似於Spring MVC的@RequestMapping注解。當消息抵達某個特定的目的地時,帶有@MessageMapping注解的方法能夠處理這些消息。
@Controller
public class MarcoController {
private static final Logger logger = LoggerFactory
.getLogger(MarcoController.class);
@MessageMapping("/marco")
public Shout handleShout(Shout incoming) {
logger.info("Received message: " + incoming.getMessage());
try { Thread.sleep(2000); } catch (InterruptedException e) {}
Shout outgoing = new Shout();
outgoing.setMessage("Polo!");
return outgoing;
}
}
示handleShout()方法能夠處理指定目的地上到達的消息。在本例中,這個目的地也就是“/app/marco”(“/app”前綴是隱含的,因為我們將其配置為應用的目的地前綴)。
- Shout類是個簡單的JavaBean
public class Shout {
private String message;
public String getMessage() {
return message;
}
public void setMessage(String message) {
this.message = message;
}
}
因為我們現在處理的不是HTTP,所以無法使用Spring的HttpMessageConverter實現將負載轉換為Shout對象。Spring 4.0提供了幾個消息轉換器,作為其消息API的一部分。表18.1描述了這些消息轉換器,在處理STOMP消息的時候可能會用到它們。
表18.1 Spring能夠使用某一個消息轉換器將消息負載轉換為Java類型
處理訂閱
@SubscribeMapping的主要應用場景是實現請求-回應模式。在請求-回應模式中,客戶端訂閱某一個目的地,然后預期在這個目的地上獲得一個一次性的響應。
例如,考慮如下@SubscribeMapping注解標注的方法:
@SubscribeMapping({"/marco"})
public Shout handleSubscription(){
Shout outgoing = new Shout();
outgoing.setMessage("Polo!");
return outgoing;
}
可以看到,handleSubscription()方法使用了@SubscribeMapping注解,用這個方法來處理對“/app/marco”目的地的訂閱(與@MessageMapping類似,“/app”是隱含的)。當處理這個訂閱時,handleSubscription()方法會產生一個輸出的Shout對象並將其返回。然后,Shout對象會轉換成一條消息,並且會按照客戶端訂閱時相同的目的地發送回客戶端。
如果你覺得這種請求-回應模式與HTTP GET的請求-響應模式並沒有太大差別的話,那么你基本上是正確的。但是,這里的關鍵區別在於HTTPGET請求是同步的,而訂閱的請求-回應模式則是異步的,這樣客戶端能夠在回應可用時再去處理,而不必等待。
編寫JavaScript客戶端
程序清單18.7 借助STOMP庫,通過JavaScript發送消息
在本例中,URL引用的是程序清單18.5中所配置的STOMP端點(不包括應用的上下文路徑“/stomp”)。
但是,這里的區別在於,我們不再直接使用SockJS,而是通過調用Stomp.over(sock)創建了一個STOMP客戶端實例。這實際上封裝了SockJS,這樣就能在WebSocket連接上發送STOMP消息。
3.3 發送消息到客戶端
WebSocket通常視為服務器發送數據給瀏覽器的一種方式,采用這種方式所發送的數據不必位於HTTP請求的響應中。使用Spring和WebSocket/STOMP的話,該如何與基於瀏覽器的客戶端通信呢?
Spring提供了兩種發送數據給客戶端的方法:
- 作為處理消息或處理訂閱的附帶結果;
- 使用消息模板。
在處理消息之后,發送消息
@MessageMapping("/marco")
public Shout handleShout(Shout incoming) {
logger.info("Received message: " + incoming.getMessage());
Shout outgoing = new Shout();
outgoing.setMessage("Polo!");
return outgoing;
}
當@MessageMapping注解標示的方法有返回值的時候,返回的對象將會進行轉換(通過消息轉換器)並放到STOMP幀的負載中,然后發送給消息代理。
默認情況下,幀所發往的目的地會與觸發處理器方法的目的地相同,只不過會添加上“/topic”前綴。就本例而言,這意味着handleShout()方法所返回的Shout對象會寫入到STOMP幀的負載中,並發布到“/topic/marco”目的地。不過,我們可以通過為方法添加@SendTo注解,重載目的地:
@MessageMapping("/marco")
@SendTo("/topic/shout")
public Shout handleShout(Shout incoming) {
logger.info("Received message: " + incoming.getMessage());
Shout outgoing = new Shout();
outgoing.setMessage("Polo!");
return outgoing;
}
按照這個@SendTo注解,消息將會發布到“/topic/shout”。所有訂閱這個主題的應用(如客戶端)都會收到這條消息。
按照類似的方式,@SubscribeMapping注解標注的方式也能發送一條消息,作為訂閱的回應。
@SubscribeMapping("/marco")
public Shout handleSubscription(){
Shout outgoing = new Shout();
outgoing.setMessage("Polo!");
return outgoing;
}
@SubscribeMapping的區別在於這里的Shout消息將會直接發送給客戶端,而不必經過消息代理。如果你為方法添加@SendTo注解的話,那么消息將會發送到指定的目的地,這樣會經過代理。
在應用的任意地方發送消息
@MessageMapping和@SubscribeMapping提供了一種很簡單的方式來發送消息,這是接收消息或處理訂閱的附帶結果。不過,Spring的SimpMessagingTemplate能夠在應用的任何地方發送消息,甚至不必以首先接收一條消息作為前提。
我們不必要求用戶刷新頁面,而是讓首頁訂閱一個STOMP主題,在Spittle創建的時候,該主題能夠收到Spittle更新的實時feed。在首頁中,我們需要添加如下的JavaScript代碼塊:
Handlebars庫將Spittle數據渲染為HTML並插入到列表中。Handlebars模板定義在一個單獨的<script>
標簽中,如下所示:
在服務器端,我們可以使用SimpMessagingTemplate將所有新創建的Spittle以消息的形式發布到“/topic/spittlefeed”主題上。如下程序清單展現的SpittleFeedServiceImpl就是實現該功能的簡單服務:
程序清單18.8 SimpMessagingTemplate能夠在應用的任何地方發布消息
@Service
public class SpittleFeedServiceImpl implements SpittleFeedService {
private SimpMessageSendingOperations messaging;
@Autowired
public SpittleFeedServiceImpl(SimpMessageSendingOperations messaging) {
this.messaging = messaging;
}
public void broadcastSpittle(Spittle spittle) {
messaging.convertAndSend("/topic/spittlefeed", spittle);
}
}
在這個場景下,我們希望所有的客戶端都能及時看到實時的Spittle feed,這種做法是很好的。但有的時候,我們希望發送消息給指定的用戶,而不是所有的客戶端。
4 為目標用戶發送消息
但是,如果你知道用戶是誰的話,那么就能處理與某個用戶相關的消息,而不僅僅是與所有客戶端相關聯。好消息是我們已經了解了如何識別用戶。通過使用與第9章相同的認證機制,我們可以使用Spring Security來認證用戶,並為目標用戶處理消息。
在使用Spring和STOMP消息功能的時候,我們有三種方式利用認證用戶:
- @MessageMapping和@SubscribeMapping標注的方法能夠使用Principal來獲取認證用戶;
- @MessageMapping、@SubscribeMapping和@MessageException方法返回的值能夠以消息的形式發送給認證用戶;
- SimpMessagingTemplate能夠發送消息給特定用戶。
4.1 在控制器中處理用戶的消息
在控制器的@MessageMapping或@SubscribeMapping方法中,處理消息時有兩種方式了解用戶信息。在處理器方法中,通過簡單地添加一個Principal參數,這個方法就能知道用戶是誰並利用該信息關注此用戶相關的數據。除此之外,處理器方法還可以使用@SendToUser注解,表明它的返回值要以消息的形式發送給某個認證用戶的客戶端(只發送給該客戶端)。
@MessageMapping("/spittle")
@SendToUser("/queue/notifications")
public Notification handleSpittle(Principal principal, SpittleForm form) {
Spittle spittle = new Spittle(principal.getName(), form.getText(), new Date());
spittleRepo.save(spittle);
feedService.broadcastSpittle(spittle);
return new Notification("Saved Spittle for user: " + principal.getName());
}
JavaScript客戶端代碼:
stomp.subscribe("/user/queue/notifications", handleNotification);
在內部,以“/user”作為前綴的目的地將會以特殊的方式進行處理。這種消息不會通過AnnotationMethodMessageHandler(像應用消息那樣)來處理,也不會通過SimpleBrokerMessageHandler或StompBrokerRelayMessageHandler(像代理消息那樣)來處理,以“/user”為前綴的消息將會通過UserDestinationMessageHandler進行處理,如圖18.4所示。
!!!
4.2 為指定用戶發送消息
除了convertAndSend()以外,SimpMessagingTemplate還提供了convertAndSendToUser()方法。按照名字就可以判斷出來,convertAndSendToUser()方法能夠讓我們給特定用戶發送消息。
為了闡述該功能,我們要在Spittr應用中添加一項特性,當其他用戶提交的Spittle提到某個用戶時,將會提醒該用戶。例如,如果Spittle文本中包含“@jbauer”,那么我們就應該發送一條消息給使用“jbauer”用戶名登錄的客戶端。如下程序清單中的broadcastSpittle()方法使用了convertAndSendToUser(),從而能夠提醒所談論到的用戶。
@Service
public class SpittleFeedServiceImpl implements SpittleFeedService {
private SimpMessagingTemplate messaging;
private Pattern pattern = Pattern.compile("\\@(\\S+)");
@Autowired
public SpittleFeedServiceImpl(SimpMessagingTemplate messaging) {
this.messaging = messaging;
}
public void broadcastSpittle(Spittle spittle) {
messaging.convertAndSend("/topic/spittlefeed", spittle);
Matcher matcher = pattern.matcher(spittle.getMessage());
if (matcher.find()) {
String username = matcher.group(1);
messaging.convertAndSendToUser(username, "/queue/notifications",
new Notification("You just got mentioned!"));
}
}
}
在broadcastSpittle()中,如果給定Spittle對象的消息中包含了類似於用戶名的內容(也就是以“@”開頭的文本),那么一個新的Notification將會發送到名為“/queue/notifications”的目的地上。因此,如果Spittle中包含“@jbauer”的話,Notification將會發送到“/user/jbauer/queue/notifications”目的地上。
5 處理消息異常
源碼
https://github.com/myitroad/spring-in-action-4/tree/master/Chapter_18
附件列表