本章参考内容《spring实战》集成篇之使用WebSocket和STOMP实现消息功能
spring为WebSocket通信提供了支持,包括:
我们来一一介绍其中的内容板块,当然,如果你对原生socket编程感兴趣也可以去了解一下,再次强调一下,本文是spirng的WebSocket编程模块。
在介绍WebSocket 之前,有必要了解一下WebSocket到底是什么玩意,。
按照其最简单的形式,WebSocket只是两个应用之间通信的通道。位于WebSocket一端的应用发送消息,另外一端处理消息。因为它是全都可以发送和处理消息。如图所示。
你需要知道WebSocket 和 Http协议的区别:
由于本模块主讲WebSocket,如果你对WebSocket还没有比较清晰的了解,
去了解下:
关键词----理解WebSocket与轮询、质询(很多其他站的dl有详细的解析,多看几篇,也有利于理解)
好了回到正题
WebSocket通信可以应用于任何类型的应用中,但是WebSocket最常见的应用场景是实现服务器和基于浏览器的应用之间的通信。
为了在Spring使用较低层级的API来处理消息,我们必须编写一个实现WebSocketHandler的类. **WebSocketHandler需要我们实现五个方法。
public interface WebSocketHandler {
void afterConnectionEstablished(WebSocketSession session) throw Exception;
void handleMessage(WebSocketSession session, WebSocketMessage<?> message) throws Exception;
void handleTransportError(WebSocketSession session, Throwable exception) throws Exception;
void afterConnectionClosed(WebSocketSession session, CloseStatus closeStatus) throws Exception;
boolean supportsPartialMessages();
}
字面意思看方法名,定义了涉及到WebSocket通信过程会使用到的内容。
包括消息处理,异常处理,连接断开处理,连接建立处理。
相比直接实现WebSocketHandler,更为简单的方法是扩展AbstractWebSocketHandler,这是WebSocketHandler的一个抽象实现。
注意在springboot2.x很多对抽象实现,都通过default关键字,改成了接口的空实现,
有效规范了编程思想。但是这里并没有使用空实现,
应该抽象类扩张了handleMessage(其实现调用了其他三个消息处理类型,原理很简单,就是if 使用instanceof 判断是否可以强转),如下是扩展的三个抽象方法
这三个方法只是handleMessage()方法的具体化,每个方法对应于某一种特定类型的消息。
下面是实现的一个案列
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!")); //发送文本消息
}
}
当然你觉得想处理特定文本类型的数据单独分离开的话,
spring还提供了扩展TextWebSocketHandler或BinaryWebSocketHandle的方法
小结:
幸好,提到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)还是简单地使用标签加载JavaScript库。加载SockJS客户端库的最简单办法是使用
标签从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”的连接。
SocketJS并不是具体的实现层,它是对WebSocket技术的模拟,目的是为了兼用不同客户端,
表面上它尽可能对应WebSocket Api,但是它底层非常智能,如果WebSocket技术不可用,就会选择另外的通信方案,它会从以下方案中选一个最优可行方案:
- XHR流 或 XHR轮询
- XDR流 或 XDR轮询
- iFrame事件端
- iFrame HTML文件
- iFrame XHR 轮询
- JSONP 轮询
小结: 不能用WebSocket就用SockJS来代替吧。
直接使用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 使用STOMP消息
直接使用WebSocket(或SockJS)就很类似于使用TCP套接字来编写Web应用。因为没有高层级的线路协议(wire protocol),因此就需要我们定义应用之间所发送消息的语义,还需要确保连接的两端都能遵循这些语义。
不过,好消息是我们并非必须要使用原生的WebSocket连接。就像HTTP在TCP套接字之上添加了请求-响应模型层一样,STOMP在WebSocket之上提供了一个基于帧的线路格式(frame-based wire format)层,用来定义消息的语义。
乍看上去,STOMP的消息格式非常类似于HTTP请求的结构。与HTTP请求和响应类似,STOMP帧由命令、一个或多个头信息以及负载所组成。例如,如下就是发送数据的一个STOMP帧:
```scheme
SEND
destination:/app/marco
content-length:20
{\"message\":\"Marco!\"}
在Spring MVC中为控制器方法添加**@MessageMapping注解,使其处理STOMP消息,它与带有@RequestMapping**注解的方法处理HTTP请求的方式非常类似。但是与@RequestMapping不同的是
@Configuration
@EnableWebSocketMessageBroker //启用STOMP消息--同时还配置了WebSocket
public class WebSocketStompConfig extends AbstractWebSocketMessageBrokerConfigurer {
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
registry.addEndpoint("/marcopolo").withSockJS();//为“/marcopolo”启用SockJS功能
}
@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命令的自己。同时因为这种代理是基于内存的,所以并不使用于集群。如果是集群,每个节点也只能管理自己的代理了和自己的那部分消息。
对于生产环境下的应用来说,你可能会希望使用真正支持STOMP的代理来支撑WebSocket消息,如RabbitMQ或ActiveMQ。这样的代理提供了可扩展性和健壮性更好的消息功能,当然它们也会完整支持STOMP命令。我们需要根据相关的文档来为STOMP搭建代理。搭建就绪之后,就可以使用STOMP代理来替换内存代理了,只需按照如下方式重载configureMessageBroker()方法即可:
@Override
public void configureMessageBroker(MessageBrokerRegistry registry) {
registry.enableStompBrokerRelay("/queue", "/topic");
registry.setApplicationDestinationPrefixes("/app");
}
默认情况下,STOMP代理中继会假设代理监听localhost的61613端口,并且客户端的username和password均为“guest”。如果你的STOMP代理位于其他的服务器上,或者配置成了不同的客户端凭证,那么我们可以在启用STOMP代理中继的时候,需要配置这些细节信息:
@Override
public void configureMessageBroker(MessageBrokerRegistry registry) {
registry.enableStompBrokerRelay("/queue", "/topic")
.setRelayHost("rabbit.someotherserver") //域名或Ip地址
.setRelayPort(62623) //端口号
.setClientLogin("marcopolo") // 账号
.setClientPasscode("letmein01") // 密码
registry.setApplicationDestinationPrefixes("/app");
}
类似HTTP请求的方式,使用STOMP和WEBSOCKET也提供了响应的编程模型,非常类似
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消息。
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模板定义在一个单独的标签中,如下所示:
在服务器端,我们可以使用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,这种做法是很好的。但有的时候,我们希望发送消息给指定的用户,而不是所有的客户端.
但是,如果你知道用户是谁的话,那么就能处理与某个用户相关的消息,而不仅仅是与所有客户端相关联。好消息是我们已经了解了如何识别用户。通过使用与第9章相同的认证机制,我们可以使用Spring Security来认证用户,并为目标用户处理消息。
在使用Spring和STOMP消息功能的时候,我们有三种方式利用认证用户:
控制器的@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所示。 [外链图片转存失败(img-5HkxmThv-1564800346082)(C:\Users\Administrator\AppData\Roaming\Typora\typora-user-images\1564576742253.png)]
UserDestinationMessageHandler会将用户消息重新路由到某个用户独有的目的地上。在处理订阅的时候会会将/user前缀去掉,同时基于用户的绘画添加一个后缀 /queue/notifications-user3j1j2k。
除了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”目的地上。
使用@MessageExceptionHandler处理消息异常,