(翻译)使用Spring,AngularJS和SockJS搭建Websocket服务

在搭建Websocket服务时,Spring的官方文档让人感觉凌乱,Google了一把,找到了一篇不错的教程,翻译一下供大家参考。

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

------------------------------------------------原文------------------------------------------------

刚才我写了一篇关于如何使用Spring、AngularJS和Websockets搭建一个Web应用的教程。然而,那篇教程仅仅使用了Websockets能够做的一小部分,因此在这篇教程里我将解释怎样使用相同的框架:Spring,AngularJS,Stomp.js以及SockJS来编写一个聊天应用。整个应用将会使用JavaConfig编写,甚至web.xml(我仍将在先前的教程中保留)也会被WebAppInitializer替代。

我们将要编写的应用看上去会像这样:

(翻译)使用Spring,AngularJS和SockJS搭建Websocket服务

为何使用Websocket

曾经,某人决定写一个邮件列表应用。一开始,他编写了一个每分钟检查是否有新邮件的客户端。大多数情况下是没有新邮件的,但客户端还总是发送新请求,导致服务器巨大的负担。这个技术很流行,被称为轮询(polling)。

过了一会儿,他们使用了一项新技术,客户端检查是否有新邮件,服务器一有新邮件就返回响应。这项技术比轮询好一点,但你仍然需要发送请求,导致许多不必要的(阻塞)传输,我们称之为长轮询(long polling)。

当你开始想,你能得出的唯一结论就是服务器应该一有邮件就向客户端发送消息。客户端不应当初始化请求,但服务器需要做。很久以来不可能这么做,但Websockets引入之后成为了可能。

Websocket是一个协议以及Javascript API,该协议是很底层的、全双工协议,意味着消息能够同时双向发送。这使得服务器发送数据至客户端,而不是反过来,成为可能。轮询和长轮询再也不需要了,它们在以前快乐地生活着。

因为Websockets提供了双向通信的方式,它通常用于实时应用。比如,某人打开了你的应用并修改了一些数据,你能够使用Websocket直接更新可视化的数据来通知所有用户。

建立工程

这里你将需要几个库,主要是用于建立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上的,具体方法请谷歌)

(翻译)使用Spring,AngularJS和SockJS搭建Websocket服务

最后,确认你使用的web容器支持WebSocket Java API。如果不是这样,你可能需要升级你的web容器。

如果以上都准备好了,你可以启动你的应用,看上去应该像这样:

如果你开始写消息,你会看到按钮现在是可用的,并且计数器在运行:

如果你键入太多,你会看到现在又不可用了,并且计数器现在用红色显示了一个负值:

当你输入了一条信息并发送后,你会看到它以黑体显示在消息列表上(因为是你发送的)。你你也会看到你的当前消息在文本框内被重置为空字符串。

如果你在新的窗口中打开应用,你应该看到它现在是空的。WebSocket是实时的,因此在给定时间收到的消息才会被列出来,没有历史。

如果你在其他窗口发消息,你会看到消息在所有屏幕中显示。一个使用黑体,另一个是普通字体。

可以看到,WebSocket在正常工作,你会看到消息实时显示,因为客户端发送消息到服务器,然后服务器将消息发送到所有客户端。

感谢WebSocket,使得这个服务器-客户端消息模型成为可能。

成就:使用Spring,AngularJS和SockJS编写了一个聊天应用。

看到这个意味着你完成这个使用Spring,AngularJS和SockJS编写的简单WebSocket聊天应用。如果你对于完整代码示例感兴趣,你可以在Github上周到。如果你想自己尝试代码,你可以从Github上下载档案包。


你可能感兴趣的:(spring,websocket)