1. 程式人生 > >第18章-使用WebSocket和STOMP實現消息功能

第18章-使用WebSocket和STOMP實現消息功能

control rom rup mpm 空間 except 線路 如何 cto

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

附件列表

  • code18-3.jpg
  • code18-4.jpg
  • code18-7.jpg
  • img18-1.jpg
  • img18-2.jpg
  • img18-4.jpg
  • jsSubcrbTopic.jpg
  • spittleTmplt.jpg
  • table18-1.jpg
  • xmlConfSockjs.jpg

第18章-使用WebSocket和STOMP實現消息功能