在搭建Websocket服务时,Spring的官方文档让人感觉凌乱,Google了一把,找到了一篇不错的教程,翻译一下供大家参考。
原文地址:http://g00glen00b.be/spring-angular-sockjs/
------------------------------------------------原文------------------------------------------------
刚才我写了一篇关于如何使用Spring、AngularJS和Websockets搭建一个Web应用的教程。然而,那篇教程仅仅使用了Websockets能够做的一小部分,因此在这篇教程里我将解释怎样使用相同的框架:Spring,AngularJS,Stomp.js以及SockJS来编写一个聊天应用。整个应用将会使用JavaConfig编写,甚至web.xml(我仍将在先前的教程中保留)也会被WebAppInitializer替代。
我们将要编写的应用看上去会像这样:
曾经,某人决定写一个邮件列表应用。一开始,他编写了一个每分钟检查是否有新邮件的客户端。大多数情况下是没有新邮件的,但客户端还总是发送新请求,导致服务器巨大的负担。这个技术很流行,被称为轮询(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添加了另外的可能性,比如对于主题的发布和订阅。
与使用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; } }
我们应用的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/)
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控制器也很简单,因为它将一切都传递给我们在教程后面要编写的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。该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上下载档案包。