本文介绍如何利用WebSocket和STOMP实现消息功能
WebSocket是发送和接收消息的底层API,而SockJS是在WebSocket 之上的API,最后 STOMP(面向消息的简单文本协议)是基于SockJS 的高级API(简而言之,WebSocket 是底层协议,SockJS 是WebSocket 的备选方案,也是底层协议,而STOMP是基于WebSocket(SockJS) 的上层协议)
WebSocket
WebSocket协议提供了通过一个套接字实现全双工通信的功能。也能够实现web浏览器和server间的异步通信,全双工意味着server与浏览器间可以发送和接收消息。
使用spring来实现WebSocket有几种方式
方式一、使用spring的低层级WebSocket API
方式二、应对不支持WebSocket的场景(引入SockJS)
方式三、使用Spring的STOMP
1、如何理解 STOMP 与 WebSocket 的关系:
- 假设HTTP协议并不存在,只能使用TCP套接字来编写web应用,你可能认为这是一件疯狂的事情;TCP套接也是一种类似于Http的请求协议。
- 不过幸好,我们有HTTP协议,它解决了web 浏览器发起请求以及 web 服务器响应请求的细节;
- 直接使用 WebSocket(SockJS)就很类似于使用TCP套接字来编写web应用;因为没有高层协议,因此就需要我们定义应用间所发送消息的语义,还需要确保
连接的两端都能遵循这些语义;- 同HTTP在TCP套接字上添加请求-响应模型层一样,STOMP在WebSocket之上提供了一个基于帧的线路格式层,用来定义消息语义;(干货——STOMP
在 WebSocket 之上提供了一个基于 帧的线路格式层,用来定义消息语义)
2、STOMP帧的定义
该帧由命令,一个或多个头信息以及负载所组成。如下就是发送 数据的一个 STOMP帧:(引入了 STOMP帧格式)
SEND
destination:/app/marco
content-length:20
{"message":"Marco!"}
对以上代码的分析
- SEND:STOMP命令,表明会发送一些内容;
- destination:头信息,用来表示消息发送到哪里;
- content-length:头信息,用来表示负载内容的 大小;
- 空行:
- 帧内容(负载)内容:
3、启用STOMP消息功能
spring 的消息功能是基于消息代理构建的,因此我们必须要配置一个消息代理和一个消息目的地(spring 的消息功能是基于消息代理构建的)
如下代码展现了如何通过java配置启用基于代理的的web 消息功能(@EnableWebSocketMessageBroker 注解的作用:能够在 WebSocket 上启用 STOMP)
@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig extends AbstractWebSocketMessageBrokerConfigurer {
@Override
public void configureMessageBroker(MessageBrokerRegistry config) {
config.enableSimpleBroker("/topic", "/queue");
config.setApplicationDestinationPrefixes("/app");
//应用程序以/app为前缀,而代理目的地以/topic为前缀
}
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
registry.addEndpoint("/hello").withSockJS();
//在网页上我们就可以通过这个链接/server/hello来和服务器的WebSocket连接,比如http://ip:8080/hello注意,不是http://ip:8080/app/hello
}
}
对以上代码的分析
- EnableWebSocketMessageBroker注解表明:这个配置类不仅配置了 WebSocket,还配置了基于代理的STOMP 消息;
- 它重载了 registerStompEndpoints() 方法:将 “/hello” 路径注册为STOMP端点。这个路径与之前发送和接收消息的目的路径有所不同,这是一个端点,客户端在订阅或发布消息到目的地址前,要连接该端点,即用户发送请求 url=’/server/hello’ 与 STOMP server进行连接,之后再转发到订阅url;(端点的作用:客户端在订阅或发布消息到目的地址前,要连接该端点)
- 它重载了configureMessageBroker() 方法:配置了一个简单的消息代理。如果不重载,默认case下,会自动配置一个简单的内存消息代理,用来处理 "/topic"为前缀的消息。但经过重载后,消息代理将会处理前缀为 “/topic” and “/queue” 消息。
- 之外:发送应用程序的消息将会带有 “/app” 前缀,下图展现了 这个配置中的 消息流;
对上述处理step的分析
- 应用程序 目的地以 “/app” 为前缀,而代理的目的地以 “/topic” 和 “/queue” 作为前缀;
- 以应用程序为目的地的消息将会直接路由到带有 @MessageMapping 注解的控制器方法中;(@MessageMapping的作用)
- 而发送到代理上的消息,包括@MessageMapping注解方法的返回值所形成的消息,将会路由到代理上,并最终发送到订阅这些目的地客户端; (client 连接地址和发送地址是不同的,以本例为例,前者是/server/hello,后者是/server/app/XX,先连接后发送)
4、启用 STOMP 代理中继
- 在生成环境下,可能会希望使用真正支持STOMP的代理来支持WebSocket消息,如RabbitMQ或ActiveMQ。这样的代理提供了可扩展性和健壮性更好的消息功能,当然,他们也支持STOMP 命令;
5、处理来自客户端的 STOMP 消息
处理来自客户端的 STOMP 消息
- 借助@MessageMapping 注解能够在控制器中处理STOMP消息
@Controller
public class GreetingController {
@MessageMapping("/hello")
@SendTo("/topic/greetings")
public Greeting greeting(HelloMessage message) throws Exception {
System.out.println("receiving " + message.getName());
System.out.println("connecting successfully.");
return new Greeting("Hello, " + message.getName() + "!");
}
}
对以上代码的分析
- @MessageMapping注解:表示 handleShout()方法能够处理指定目的地上到达的消息
- 这个目的地(消息发送目的地url)就是 “/server/app/hello”,其中 “/app” 是 隐含的,"/server" 是springmvc项目名称,类似http://localhost:8080
6、处理来自客户端的订阅(使用@SubscribeMapping注解)
- @SubscribeMapping注解的方法:当收到 STOMP 订阅消息的时候,带有@SubscribeMapping 注解的方法将会触发;其也是通过AnnotationMethodMessageHandler来接收消息的;
- @SubscribeMapping注解的应用场景:实现请求-回应模式。在请求-回应模式中,客户端订阅一个目的地,然后预期在这个目的地上获得一个一次性的响应(@SubsribeMapping注解实现请求-回应模式,客户端订阅之后一定会收到一个服务端的响应)
@SubscribeMapping({"/marco"})
public Shout handleSubscription() {
Shout outgoing = new Shout();
outgoing.setMessage("Polo!");
return outgoing;
}
对以上代码的分析
- @SubscribeMapping注解的方法来处理 对 “/app/macro"目的地订阅(与@MessageMapping类似,”/app" 是隐含的 );
- 请求-回应模式与HTTP-GET的全球-响应模式差不多:关键区别在于,HTTP-GET请求是同步的,而订阅的全球-回应模式是异步的,这样客户端能够在回应可用时再去处理,而不必等待;(HTTP-GET请求是同步的,而订阅的请求-回应模式是异步的)
7、编写 JavaScript 客户端
借助STOMP 库,通过 JavaScript发送消息
function connect() {
//连接端点
var socket = new SockJS("<c:url value='/hello'/>");
stompClient = Stomp.over(socket);
stompClient.connect({}, function(frame) {
//订阅/topic/greetings
stompClient.subscribe('/topic/greetings', function(greeting){
showGreeting(JSON.parse(greeting.body).content);
});
});
}
function sendName() {
var name = document.getElementById('name').value;
//发送消息/app/hello
stompClient.send("/app/hello", {}, JSON.stringify({ 'name': name }));
}
对以上代码的 分析
- 以上代码连接“/hello” 端点并发送 ”name“;
- stompClient.send("/app/hello", {}, JSON.stringify({‘name’:name}))。
第一个参数:json 负载消息发送的目的地。
第二个参数:是一个头信息的Map,它会包含在 STOMP 帧中。
第三个参数:负载消息;(stomp client连接地址和发送地址不一样的。
连接地址为/hello 类似于localhost:8080/springmvc_project_name/hello , 而发送地址为’/app/hello’,这里要当心)
8、发送消息到客户端
spring提供了两种发送数据到客户端的方法:
- 作为处理消息 或处理订阅的附带结果;
- 使用消息模板,主动给用户发送消息;
8.1、第一种收到订阅,做出响应。
在收到客户端发过来的消息,并处理消息后,给客户端一个响应
@MessageMapping("/hello")
@SendTo("/topic/greetings")
public Greeting greeting(HelloMessage message) throws Exception {
return new Greeting("Hello, " + message.getName() + "!");
}
对以上代码的分析:
- 返回的对象将会进行转换(通过消息转换器)并放到STOMP帧的负载中(上面介绍了帧的负载中存的是要发送的内容),然后发送给消息代理(消息代理分为STOMP代理中继和内存消息代理)
- 默认情况下:帧所发往的目的地会与触发处理器方法的目的地相同。所以返回的对象会写入到STOMP帧的负载中,并发布到
"/topic/stomp"目的地。不过可以通过@SendTo注解,重载目的地(@SendTo注解的作用)- 消息将会发到/topic/greetings,所有订阅这个主题的应用都会收到这条消息;
- 如果为方法添加@SendTo注解的话,那么消息将会发送到指定的目的地,这样就会经过代理;(SubscribeMapping注解返回的消息直接发送到client,不经过代理,而@SendTo注解的路径,就会经过代理,然后再发送到目的地)
8.2、在应用的任意地方发送消息
spring的SimpMessagingTemplate能够在应用的任何地方发送消息,不必以接收一条消息为前提来发送,当前前提是要订阅。
看下面这个案例:让首页订阅一个STOMP主题,在Spittle创建的时候,该主题能够收到 Spittle更新时的feed
<script>
var sock = new SockJS('spittr');
var stomp = Stomp.over(sock);
stomp.connect('guest', 'guest', function(frame) {
stomp.subscribe("/topic/spittlefeed", handleSpittle);
});
function handleSpittle(incoming) {
var spittle = JSON.parse(incoming.body);
var source = $("#spittle-template").html();
var template = Handlebars.compile(source);
var spittleHtml = template(spittle);
$('.spittleList').prepend(spittleHtml);
}
</script>
对以上代码的分析
- 在连接到STMOP代理后,我们订阅了"/topic/spittlefeed"主题,并指定当消息到达后,由handleSpittle()函数来处理Spittle更新。
server端代码
使用SimpMessagingTemplate将所有新创建的Spittle以消息的形式发布到"/topic/feed"主题上
@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); //发送消息.
}
}
对以上代码的分析:
- 配置spring支持stomp的一个附带功能是在spring应用上下文中已经包含了Simple
- 在发布消息给STOMP主题的时候,所有订阅该主题的客户端都会收到消息。但有的时候,我们希望将消息发送给指定用户;
8.2.1、为目标用户发送消息
1、在使用srping和STOMP消息功能的时候,有三种方式来认证用户:
- @MessageMapping和@SubscribeMapping注解标注的方法能够使用Principal来获取认证用户
- @MessageMapping和@SubscribeMapping和@MessageException方法返回的值能够以消息的形式发送给认证用户
- SimpMessagingTemplate能够发送消息给特定用户;
2、在控制器中处理用户的消息
例子:编写一个控制器方法,根据传入的消息创建新的Spittle对象,并发送一个回应,表明对象创建成功;(这种REST也可以实现,不过它是同步的,而这里是异步的)
代码如下:它会处理传入的消息并将其存储到Spittle
@MessageMapping("/spittle")
@SendToUser("/queue/notifications")
public Notification handleSpittle(Principal principal, SpittleForm form) {
Spittle spittle = new Spittle(rincipal.getName(), form.getText(), new Date());
spittleRepo.save(spittle);
return new Notification("Saved Spittle");
}
上面代码分析
- 该方法最后返回一个新的Notificatino,表明对象保存成功;
- 该方法使用了@MessageMapping("/spittle")
注解:所以当有发往"/app/spittle"目的地的消息到达时,该方法就会触发;如果用户已经认证的话,将会根据STOMP帧上的头信息得到Principal对象;- @SendToUser注解:指定了Notification要发送的目的地"/queue/notifications";
- 表面上"/queue/notifications"并不会与特定用户相关联,但因为这里使用的是@SendToUser注解,而不是@SendTo,所以就会发生更多的事情了
看一下针对控制器方法发布的Notificatino对象的目的地,客户端该如何进行订阅。
看个例子:看如下的JavaScript代码,它订阅了一个用户特定的目的地
stomp.subscribe("/user/queue/notifications", handleNotifications);
对以上代码的分析
这个目的地使用了"/user"作为前缀,在内部以"/user"为前缀的消息将会通过UserDestinationMessageHandler进行处理,而不是AnnotationMethodMessageHandler、 SimpleBrokerMessageHandler、StompBrokerRelayMessageHandler
如下图所示:
注意:UserDestinationMessageHandler的主要任务:是将用户消息重新路由到某个用户独有的目的地上。在处理订阅的时候,它会将目标地址中的"/user"前缀去掉,并基于用户的会话添加一个后缀。如"/user/queue/notifications" 的订阅最后可能路由到 名为 "/queue/notifacations-user65a4sdfa"目的地上。
3、为指定用户发送消息
- SimpMessagingTemplate还提供了convertAndSendToUser()方法,该方法能够让我们给特定用户发送消息;
- 我们在web应用上添加一个特性:当其他用户提交的Spittle提到某个用户时,将会提醒该用户(类似于微博的@功能)
看个例子
如果Spittle文本中包含"@tangrong",那么我们就应该发送一条消息给使用tangrong用户名登录的client,代码实例如下
@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!"));
}
}
}
本文转自;https://blog.csdn.net/pacosonswjtu/article/details/51914567