(翻譯)使用Spring,AngularJS和SockJS搭建Websocket服務

在搭建Websocket服務時,Spring的官方文檔讓人感受凌亂,Google了一把,找到了一篇不錯的教程,翻譯一下供你們參考。javascript

原文地址:http://g00glen00b.be/spring-angular-sockjs/css

------------------------------------------------原文------------------------------------------------html

剛纔我寫了一篇關於如何使用Spring、AngularJS和Websockets搭建一個Web應用的教程。然而,那篇教程僅僅使用了Websockets可以作的一小部分,所以在這篇教程裏我將解釋怎樣使用相同的框架:Spring,AngularJS,Stomp.js以及SockJS來編寫一個聊天應用。整個應用將會使用JavaConfig編寫,甚至web.xml(我仍將在先前的教程中保留)也會被WebAppInitializer替代。前端

咱們將要編寫的應用看上去會像這樣:java

爲什麼使用Websocket

曾經,某人決定寫一個郵件列表應用。一開始,他編寫了一個每分鐘檢查是否有新郵件的客戶端。大多數狀況下是沒有新郵件的,但客戶端還老是發送新請求,致使服務器巨大的負擔。這個技術很流行,被稱爲輪詢(polling)。git

過了一下子,他們使用了一項新技術,客戶端檢查是否有新郵件,服務器一有新郵件就返回響應。這項技術比輪詢好一點,但你仍然須要發送請求,致使許多沒必要要的(阻塞)傳輸,咱們稱之爲長輪詢(long polling)。github

當你開始想,你能得出的惟一結論就是服務器應該一有郵件就向客戶端發送消息。客戶端不該當初始化請求,但服務器須要作。好久以來不可能這麼作,但Websockets引入以後成爲了可能。web

Websocket是一個協議以及Javascript API,該協議是很底層的、全雙工協議,意味着消息可以同時雙向發送。這使得服務器發送數據至客戶端,而不是反過來,成爲可能。輪詢和長輪詢不再須要了,它們在之前快樂地生活着。spring

由於Websockets提供了雙向通訊的方式,它一般用於實時應用。好比,某人打開了你的應用並修改了一些數據,你可以使用Websocket直接更新可視化的數據來通知全部用戶。json

創建工程

這裏你將須要幾個庫,主要是用於創建Web應用的Spring Web MVC框架以及用於創建Websocket部分應用的Spring messaging + Websocket。咱們也須要一個像Jackson同樣的JSON序列化器,由於Stomp須要JSON序列化/反序列化,所以我也將會把那些加入到咱們的應用中。

<dependency>
    <groupId>org.springframework</groupId>
    <artifactId>spring-webmvc</artifactId>
    <version>4.1.1.RELEASE</version>
</dependency>
<dependency>
    <groupId>org.springframework</groupId>
    <artifactId>spring-websocket</artifactId>
    <version>4.1.1.RELEASE</version>
</dependency>
<dependency>
    <groupId>org.springframework</groupId>
    <artifactId>spring-messaging</artifactId>
    <version>4.1.1.RELEASE</version>
</dependency>
<dependency>
    <groupId>javax.websocket</groupId>
    <artifactId>javax.websocket-api</artifactId>
    <version>1.0</version>
    <scope>provided</scope>
</dependency>
<dependency>
    <groupId>javax.servlet</groupId>
    <artifactId>javax.servlet-api</artifactId>
    <version>3.1.0</version>
    <scope>provided</scope>
</dependency>
<dependency>
    <groupId>javax.servlet</groupId>
    <artifactId>jstl</artifactId>
    <version>1.2</version>
</dependency>
<dependency>
    <groupId>com.fasterxml.jackson.core</groupId>
    <artifactId>jackson-core</artifactId>
    <version>2.3.3</version>
</dependency>
<dependency>
    <groupId>com.fasterxml.jackson.core</groupId>
    <artifactId>jackson-databind</artifactId>
    <version>2.3.3</version>
</dependency>
<dependency>
    <groupId>com.fasterxml.jackson.jaxrs</groupId>
    <artifactId>jackson-jaxrs-json-provider</artifactId>
    <version>2.3.3</version>
</dependency>

在前端,我也將須要一些庫,我將使用Bower創建。若是你不打算使用Bower,你總能夠本身下載下來。

{
  "name": "spring-ng-chat",
  "version": "0.0.1-SNAPSHOT",
  "dependencies": {
    "sockjs": "0.3.4",
    "stomp-websocket": "2.3.4",
    "angular": "1.3.8",
    "lodash": "2.4.1"
  }
}

我將使用的庫是:SockJS+Stomp.js用於經過Websocket通訊,AngularJS將用於創建客戶端的應用,Lo-Dash是將要使用的工具庫(Underscore.js的一個分支)

什麼是STOMP?就像我以前所說的,Websocket協議是個漂亮的底層協議,然而,一些高層協議能夠在Websocket的上層,如MQTT和STOMP。好比STOMP爲Websocket添加了另外的可能性,好比對於主題的發佈和訂閱。

Java配置

與使用XML配置咱們的應用相反,我將向你展現如何配置相同的應用而不須要任何XML。咱們須要的第一個類是web.xml的替代品,用於啓動咱們的web應用。在這個類中咱們能夠定義咱們的應用上下文,咱們的web應用上下文和一些與servlet相關的配置。

public class WebAppInitializer extends AbstractAnnotationConfigDispatcherServletInitializer {
  @Override
  protected void customizeRegistration(ServletRegistration.Dynamic registration) {
    registration.setInitParameter("dispatchOptionsRequest", "true");
    registration.setAsyncSupported(true);
  }
  @Override
  protected Class< ?>[] getRootConfigClasses() {
    return new Class< ?>[] { AppConfig.class, WebSocketConfig.class };
  }
  @Override
  protected Class< ?>[] getServletConfigClasses() {
    return new Class< ?>[] { WebConfig.class };
  }
  @Override
  protected String[] getServletMappings() {
    return new String[] { "/" };
  }
  @Override
  protected Filter[] getServletFilters() {
    CharacterEncodingFilter characterEncodingFilter = new CharacterEncodingFilter();
    characterEncodingFilter.setEncoding(StandardCharsets.UTF_8.name());
    return new Filter[] { characterEncodingFilter };
  }
}

這個類的大部分都很清楚。首先咱們用getRootConfigClasses和getServletConfigClasses()來定義咱們的bean配置類。getServletMappings()和getServletFilters()跟servlet配置相關。在這裏我將應用映射到上下文root而且添加了一個Filter來確保全部數據都是UTF-8的。

而後來到這裏最後的方法customizeRegistrion。若是你在Tomcat容器中運行應用的話,這可能會很重要。它表示容許異步通訊來防止鏈接不用被直接關閉。

就像你可能注意到的,獲得三個類沒法找到的編譯錯誤。我就如今定義那些類,所以讓咱們從AppConfig開始:

@Configuration
@ComponentScan(basePackages = "be.g00glen00b", excludeFilters = {
    @ComponentScan.Filter(value = Controller.class, type = FilterType.ANNOTATION),
    @ComponentScan.Filter(value = Configuration.class, type = FilterType.ANNOTATION)
})
public class AppConfig {
}

這裏很空也很沒用,它表示掃描哪些包,但排除全部的配置和控制器類(配置類被咱們的WebAppInitializer啓動,而控制器類綁定在咱們的WebConfig類上)。既然咱們只須要一個控制器,這個類將不作特別的事情,但若是你有特殊的服務,若是它們正確註解的話將成爲Spring的bean。

下一個類是WebConfig:

@Configuration
@EnableWebMvc
@ComponentScan(basePackages = "be.g00glen00b.controller")
public class WebConfig extends WebMvcConfigurerAdapter {
  @Bean
  public InternalResourceViewResolver getInternalResourceViewResolver() {
    InternalResourceViewResolver resolver = new InternalResourceViewResolver();
    resolver.setPrefix("/WEB-INF/views/");
    resolver.setSuffix(".jsp");
    return resolver;
  }
  @Override
  public void configureDefaultServletHandling(DefaultServletHandlerConfigurer configurer) {
    configurer.enable();
  }
  @Bean
  public WebContentInterceptor webContentInterceptor() {
    WebContentInterceptor interceptor = new WebContentInterceptor();
    interceptor.setCacheSeconds(0);
    interceptor.setUseExpiresHeader(true);
    interceptor.setUseCacheControlHeader(true);
    interceptor.setUseCacheControlNoStore(true);
    return interceptor;
  }
  @Override
  public void addResourceHandlers(ResourceHandlerRegistry registry) {
    registry.addResourceHandler("/libs/**").addResourceLocations("/libs/");
    registry.addResourceHandler("/app/**").addResourceLocations("/app/");
    registry.addResourceHandler("/assets/**").addResourceLocations("/assets/");
  }
  @Override
  public void addInterceptors(InterceptorRegistry registry) {
    registry.addInterceptor(webContentInterceptor());
  }
}

這個配置類啓動咱們的web上下文。它告訴咱們哪些靜態資源能被服務(使用addResourceHandlers)。它添加無緩衝的攔截器(webContentInterceptor()和addInterceptors())並經過getInternalResourceViewResolver() bean告訴咱們動態資源的路徑(JSP文件)。

最後是Websocket的配置:

@Configuration
@EnableWebSocketMessageBroker
@ComponentScan(basePackages = "be.g00glen00b.controller")
public class WebSocketConfig extends AbstractWebSocketMessageBrokerConfigurer {
  @Override
  public void configureMessageBroker(MessageBrokerRegistry config) {
    config.enableSimpleBroker("/topic");
    config.setApplicationDestinationPrefixes("/app");
  }
  @Override
  public void registerStompEndpoints(StompEndpointRegistry registry) {
    registry.addEndpoint("/chat").withSockJS();
  }
}

就像WebConfig同樣,它也會掃描控制器包,由於咱們將咱們的Websocket通訊映射到咱們的控制器。而後咱們將使用configureMessageBroker來配置消息經紀人(通訊進入和離開的地方),而且使用registerStompEndpoints來配置咱們的節點。

WebSocket尚未在全部的瀏覽器上都能工做起來。許多WebSocket庫(好比SockJS和Socket.io)提供了使用長輪詢和輪詢等的回退選項。Spring也容許這些回退,而且與SockJS兼容。這也是爲何選擇SockJS做爲客戶端是一個好主意的緣由。

數據傳輸對象

咱們主要的通訊會經過WebSocket。爲了通訊,咱們會發送一個特定的載荷並相應到一個指定的Stomp.js主體。咱們須要兩個類,Message和OutputMessage。

首先,Message會包含聊天消息自身以及一個產生的ID,好比:

public class Message {
  private String message;
  private int id;
  
  public Message() {
    
  }
  
  public Message(int id, String message) {
    this.id = id;
    this.message = message;
  }
  public String getMessage() {
    return message;
  }
  public void setMessage(String message) {
    this.message = message;
  }
  public int getId() {
    return id;
  }
  public void setId(int id) {
    this.id = id;
  }
}
OutputMessage將擴展Message,但也會加入一個時戳(當前日期):
public class OutputMessage extends Message {
    private Date time;
    
    public OutputMessage(Message original, Date time) {
        super(original.getId(), original.getMessage());
        this.time = time;
    }
    
    public Date getTime() {
        return time;
    }
    
    public void setTime(Date time) {
        this.time = time;
    }
}

Spring控制器

咱們應用的Java部分的最後一步是帶兩個映射的控制器本身;一個用於包含咱們應用的HTML/JSP頁面,另外一個用於WebSocket傳輸:

@Controller
@RequestMapping("/")
public class ChatController {
  @RequestMapping(method = RequestMethod.GET)
  public String viewApplication() {
    return "index";
  }
    
  @MessageMapping("/chat")
  @SendTo("/topic/message")
  public OutputMessage sendMessage(Message message) {
    return new OutputMessage(message, new Date());
  }
}


這裏很容易,當咱們運行到root上下文時,viewApplication被映射到那裏,從而index.jsp做爲視圖。另外一個方法,sendMessage容許咱們在一個消息進入消息經紀人 /app/chat時廣播一個消息到/topic/message(不要忘記咱們在WebSocketConfig定義了前綴/app)。

視圖

如今整個Java代碼已經編寫完畢,讓咱們經過定義JSP頁面開始。這個頁面將包含兩個主要的組件;添加新消息的表單,以及消息列表自身。

<!DOCTYPE HTML>
<html>
  <head>
    <link href="http://fonts.googleapis.com/css?family=Open+Sans:400,300,600,700" rel="stylesheet" type="text/css" />
    <link href="assets/style.css" rel="stylesheet" type="text/css" />
  </head>
  <body ng-app="chatApp">
    <div ng-controller="ChatCtrl">
      <form ng-submit="addMessage()" name="messageForm">
        <input type="text" placeholder="Compose a new message..." ng-model="message" />
        <div>
          <span ng-bind="max - message.length" ng-class="{danger: message.length > max}">140</span>
          <button ng-disabled="message.length > max || message.length === 0">Send</button>
        </div>
      </form>
      <hr />
      <p ng-repeat="message in messages | orderBy:'time':true">
        <time>{{message.time | date:'HH:mm'}}</time>
        <span ng-class="{self: message.self}">{{message.message}}</span>
      </p>
    </div>
    
    <script src="libs/sockjs/sockjs.min.js" type="text/javascript"></script>
    <script src="libs/stomp-websocket/lib/stomp.min.js" type="text/javascript"></script>
    <script src="libs/angular/angular.min.js"></script>
    <script src="libs/lodash/dist/lodash.min.js"></script>
    <script src="app/app.js" type="text/javascript"></script>
    <script src="app/controllers.js" type="text/javascript"></script>
    <script src="app/services.js" type="text/javascript"></script>
  </body>
</html>

首先咱們添加了Open Sans字體以及咱們本身樣式(咱們將在這個教程後面定義)。而後咱們開始body和啓動咱們稱爲chatApp的AngularJS應用。在這個應用裏將有一個AngularJS控制器,ChatCtrl。不要將這個與咱們的Spring控制器混淆!

咱們要作的第一件事是建立一個包含文本域的表單。咱們將這個文本域綁定在一個稱爲message的model上。當咱們的表單提交時,咱們控制器的addMessage()函數將被調用,用於經過WebSocket發送消息。

爲了將表單變得奇特一點,咱們也添加了跟Twitter運行相似的計數器。當你鍵入太多字符(超過最大值)時,因爲ng-deisabled指令它將變紅而且你不能提交表單。

在表單之下,咱們依次處理消息,而且對於每一個消息打印時間和消息內容。若是消息是經過用戶本身發送的,經過ng-class指令,它將會有一個本身特別的分類。消息經過日期排序,最近的排在列表的最前面。

在咱們頁面的最後咱們載入全部的須要的庫,以及咱們應用的Javascript文件。(譯者注:就像教程以前所說的,做者使用bower來管理工程裏用到的Javascript庫文件,若是讀者若是不使用bower,能夠本身下載,或者使用免費的CDN,好比https://cdnjs.com/)

引導AngularJS應用

Our first JavaScript file is app.js. This file will define all module packages, in this case:

咱們的第一個Javascritp文件是app.js。這個文件將定義全部模塊包,以下:

angular.module("chatApp", [
  "chatApp.controllers",
  "chatApp.services"
]);
angular.module("chatApp.controllers", []);
angular.module("chatApp.services", []);

AngularJS控制器

AngularJS控制器也很簡單,由於它將一切都傳遞給咱們在教程後面要編寫的service裏。控制器包含三個跟model關聯的域——包含文本框內鍵入信息的message,包含全部接收到消息的messages數組以及用於跟Twitter外觀相似計數器的最大容許字符數量max。

angular.module("chatApp.controllers").controller("ChatCtrl", function($scope, ChatService) {
  $scope.messages = [];
  $scope.message = "";
  $scope.max = 140;
  $scope.addMessage = function() {
    ChatService.send($scope.message);
    $scope.message = "";
  };
  ChatService.receive().then(null, null, function(message) {
    $scope.messages.push(message);
  });
});

咱們已經說明了當表單提交時,addMessage被調用,將消息傳遞給service,而後經過重置message model爲空字符串來清空文本域。

咱們也調用service來接收消息。這部分的服務將返回一個每次收到消息時更新進展部分指令的deferred。控制器會將其加入messages數組做爲迴應。

AngularJS service

咱們基於AngularJS的客戶端應用的最後一部分是service。該service更復雜一點,由於他包含了全部的WebSocket傳輸處理代碼。service的代碼以下:

angular.module("chatApp.services").service("ChatService", function($q, $timeout) {
    
    var service = {}, listener = $q.defer(), socket = {
      client: null,
      stomp: null
    }, messageIds = [];
    
    service.RECONNECT_TIMEOUT = 30000;
    service.SOCKET_URL = "/spring-ng-chat/chat";
    service.CHAT_TOPIC = "/topic/message";
    service.CHAT_BROKER = "/app/chat";
    
    service.receive = function() {
      return listener.promise;
    };
    
    service.send = function(message) {
      var id = Math.floor(Math.random() * 1000000);
      socket.stomp.send(service.CHAT_BROKER, {
        priority: 9
      }, JSON.stringify({
        message: message,
        id: id
      }));
      messageIds.push(id);
    };
    
    var reconnect = function() {
      $timeout(function() {
        initialize();
      }, this.RECONNECT_TIMEOUT);
    };
    
    var getMessage = function(data) {
      var message = JSON.parse(data), out = {};
      out.message = message.message;
      out.time = new Date(message.time);
      if (_.contains(messageIds, message.id)) {
        out.self = true;
        messageIds = _.remove(messageIds, message.id);
      }
      return out;
    };
    
    var startListener = function() {
      socket.stomp.subscribe(service.CHAT_TOPIC, function(data) {
        listener.notify(getMessage(data.body));
      });
    };
    
    var initialize = function() {
      socket.client = new SockJS(service.SOCKET_URL);
      socket.stomp = Stomp.over(socket.client);
      socket.stomp.connect({}, startListener);
      socket.stomp.onclose = reconnect;
    };
    
    initialize();
    return service;
  });

讓咱們從最底部開始。在代碼的底部你能夠看到咱們執行了initialize函數用於創建service。這隻會執行一次,由於AngularJS服務是單例的,意味着每次都會返回同一個實例。

initialize()函數會創建SockJS Websocket客戶端而且將其用於Stomp.js的websocket客戶端。Stomp.js是Websocket協議的附件,用於容許對於主題的訂閱和通知以及JSON載荷。

當客戶端鏈接到WebSocket服務器時,startListener()函數被調用,它將監聽到全部/topic/message主題的消息接收。隨後它將數據發送到會被控制器使用的deferred。

startListener函數調用getMessage函數來將Websocket數據體(=載荷)翻譯成控制器須要的model。在這裏它將JSON字符串解析爲一個對象,並將時間設置爲一個Date對象。

若是消息ID在messageIds數組內列出,這意味着這個消息源自這個客戶端,所以它講self屬性設置爲true。

隨後它將消息ID從列表中移除使ID在消息ID池中可用。

當與服務器的Websocket斷開時,它將在30秒後調用reconnect()函數來嘗試從新初始化鏈接。

最後,咱們有兩個公共的service函數,receive()和send()。然咱們開始編寫receive()函數由於這是兩個中最簡單的。這個函數作的惟一一件事是返回用於發送消息的deferred。

另外一方面send()函數將消息做爲JSON對象發送(字符串化的)而且使用一個新產生的ID。這個ID被加入messageIds數組以使其可以被getMessage()函數使用來檢查該消息是被這個客戶端仍是另外一個添加的。

樣式

以上是咱們須要全部的Java和Javascript代碼,讓咱們用一些酷酷的樣式來結束咱們的應用吧!我使用以下的CSS代碼:

body, * {
  font-family: 'Open Sans', sans-serif;
  box-sizing: border-box;
}
.container {
  max-width: 1000px;
  margin: 0 auto;
  width: 80%;
}
input[type=text] {
  width: 100%;
  border: solid 1px #D4D4D1;
  transition: .7s;
  font-size: 1.1em;
  padding: 0.3em;
  margin: 0.2em 0;
}
input[type=text]:focus {
  -webkit-box-shadow: 0 0 5px 0 rgba(69, 155, 231, .75);
  -moz-box-shadow: 0 0 5px 0 rgba(69, 155, 231, .75);
  box-shadow: 0 0 5px 0 rgba(69, 155, 231, .75);
  border-color: #459be7;
  outline: none;
}
.info {
  float: right;
}
form:after {
  display: block;
  content: '';
  clear: both;
}
button {
  background: #459be7;
  color: #FFF;
  font-weight: 600;
  padding: .3em 1.9em;
  border: none;
  font-size: 1.2em;
  margin: 0;
  text-shadow: 0 0 5px rgba(0, 0, 0, .3);
  cursor: pointer;
  transition: .7s;
}
button:focus {
  outline: none;
}
button:hover {
  background: #1c82dd;
}
button:disabled {
  background-color: #90BFE8;
  cursor: not-allowed;
}
.count {
  font-weight: 300;
  font-size: 1.35em;
  color: #CCC;
  transition: .7s;
}
.count.danger {
  color: #a94442;
  font-weight: 600;
}
.message time {
  width: 80px;
  color: #999;
  display: block;
  float: left;
}
.message {
  margin: 0;
}
.message .self {
  font-weight: 600;
}
.message span {
  width: calc(100% - 80px);
  display: block;
  float: left;
  padding-left: 20px;
  border-left: solid 1px #F1F1F1;
  padding-bottom: .5em;
}
hr {
  display: block;
  height: 1px;
  border: 0;
  border-top: solid 1px #F1F1F1;
  margin: 1em 0;
  padding: 0;
}

演示

在web服務器上運行咱們的應用以前,先檢查一些東西。首先,確保你設置你的根上下文到/spring-ng-chat/。若是你沒有設置,你的AngularJS服務在鏈接到Websocket服務器會遇到麻煩,鏈接到/spring-ng-chat/chat也同樣。若是你不想這樣,你須要在AngularJS service內修改SOCKET_URL屬性。

第二,若是你在使用Eclipse內嵌的Tomcat運行這個應用,你須要將Maven依賴添加到你的部署集成中去。你能夠經過到你的project properties內點擊Deployment assembly並添加庫來完成。(譯者注:譯者本身是直接使用tomcat7-maven-plugin熱部署到Tomcat上的,具體方法請谷歌)

最後,確認你使用的web容器支持WebSocket Java API。若是不是這樣,你可能須要升級你的web容器。

若是以上都準備好了,你能夠啓動你的應用,看上去應該像這樣:

若是你開始寫消息,你會看到按鈕如今是可用的,而且計數器在運行:

若是你鍵入太多,你會看到如今又不可用了,而且計數器如今用紅色顯示了一個負值:

當你輸入了一條信息併發送後,你會看到它以黑體顯示在消息列表上(由於是你發送的)。你你也會看到你的當前消息在文本框內被重置爲空字符串。

若是你在新的窗口中打開應用,你應該看到它如今是空的。WebSocket是實時的,所以在給定時間收到的消息纔會被列出來,沒有歷史。

若是你在其餘窗口發消息,你會看到消息在全部屏幕中顯示。一個使用黑體,另外一個是普通字體。

能夠看到,WebSocket在正常工做,你會看到消息實時顯示,由於客戶端發送消息到服務器,而後服務器將消息發送到全部客戶端。

感謝WebSocket,使得這個服務器-客戶端消息模型成爲可能。

成就:使用Spring,AngularJS和SockJS編寫了一個聊天應用。

看到這個意味着你完成這個使用Spring,AngularJS和SockJS編寫的簡單WebSocket聊天應用。若是你對於完整代碼示例感興趣,你能夠在Github上週到。若是你想本身嘗試代碼,你能夠從Github上下載檔案包。

相關文章
相關標籤/搜索