简单的代理非常适合入门但仅支持STOMP命令的子集(例如,不支持acks, receipts等),依赖于简单的消息发送循环,并且不适合于群集。作为替代方案,应用程序可以升级到使用功能齐全的消息代理。本文将以rabbitmq作为外部消息代理实现,首先安装RabbitMQ并启动rabbitmq_web_stomp插件。
上图与简单消息代理结构中的主要区别是使用“代理中继-StompBrokerRelay"通过TCP将消息传递到外部STOMP代理,以及将消息从代理传递到订阅的客户端。此外,应用程序组件(例如,HTTP请求处理方法,业务服务等)也可以向代理中继或者外部消息代理发送消息,以便向订阅的WebSocket客户端广播消息。
io.projectreactor
reactor-net
2.0.8.RELEASE
io.projectreactor.ipc
reactor-netty
io.netty
netty-all
@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig4Stomp implements WebSocketMessageBrokerConfigurer {
/**
* @param registry
*/
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
registry.addEndpoint("/stomp") // 设置websocket端点
.setAllowedOrigins("*") // 允许跨域请求
.setHandshakeHandler(clientHandshakeHandler())
.addInterceptors(clientHandshakeInterceptor())
.withSockJS();// 指定使用SockJS协议
}
/**
* 配置外部消息代理
*
* @param registry
*/
@Override
public void configureMessageBroker(MessageBrokerRegistry registry) {
// rabbitmq合法的目的前缀:/temp-queue, /exchange, /topic, /queue, /amq/queue, /reply-queue/
registry.enableStompBrokerRelay("/topic", "/queue", "/amq/queue", "/exchange") // 配置消息代理前缀
.setRelayHost(host) // 配置消息代理服务器.默认:127.0.0.1
.setRelayPort(port) // 配置代理服务器端口.默认:61613
.setClientLogin(username).setClientPasscode(password) // 配置每个客户端的连接认证信息.默认:guest/guest
.setSystemLogin(username).setSystemPasscode(password) // 源自服务端的连接的认证信息.默认:guest/guest
.setUserRegistryBroadcast("/topic/simp-user-registry")// 当有用户注册时将其广播到其他服务器
.setUserDestinationBroadcast("/topic/unresolved-user-destination")// 将当前服务端点无法发送到user dest的消息广播到其他服务端点处理
.setSystemHeartbeatReceiveInterval(15000) // 配置服务端session接收stomp消息代理心跳时间间隔(0代表不接收)
.setSystemHeartbeatSendInterval(15000); // 配置服务端session向stomp消息代理发送心跳时间间隔(0代表不接收)
// 配置服务端接收消息的地址前缀与@MessageMapping路径组合使用
registry.setApplicationDestinationPrefixes("/app");
// 配置点对点使用的订阅前缀,默认是"/user" 例如:(@link org.springframework.messaging.simp.user.DefaultUserDestinationResolver)
// 客户端订阅:/user/queue/message
// 服务器推送指定用户:/user/{userId}/queue/message
registry.setUserDestinationPrefix("/user");
}
}
上述配置中的“STOMP代理中继”是StompBrokerRelayMessageHandler,它通过将消息转发到外部消息代理来处理消息。为此,它建立到外部消息代理的TCP连接,将所有消息转发给它,然后通过其WebSocket会话将从代理接收的所有消息转发给客户端。从本质上讲,它充当“转发”,可以在两个方向上转发消息。
对于每个新的客户端的CONNECT消息,STOMP代理中继-StompBrokerRelayMessageHandler将打开与外部消息代理的独立TCP连接,并专门用于处理来自发起CONNECT消息的客户端的所有消息。来自同一客户端的消息通过会话ID消息头标识。 相反,当STOMP代理在TCP连接上发回消息时,这些消息将会带有客户端的会话ID,并通过提供给构造函数的MessageChannel向下游发送。
StompBrokerRelayMessageHandler还会自动打开与外部消息代理的默认“system”TCP连接,用于发送源自服务器应用程序的消息(而不是来自客户端)。此类消息不与任何客户端关联,因此没有会话ID标头。 “system”TCP连接实际上是共享的,不能用于接收消息。spring提供了几个属性来配置“system”连接,包括:setSystemLogin、setSystemPasscode、setSystemHeartbeatSendInterval、setSystemHeartbeatReceiveInterval。
配置属性解读
1. /topic/[routing_key]
通过amq.topic交换机订阅/发布消息,订阅时默认创建一个临时队列,通过routing_key与topic进行绑定
a. /topic:固定前缀
b. routing_key:路由键
对于接收者端,订阅该destination会创建出自动删除的、非持久的队列并根据routing_key路由键绑定到amq.topic交换机 上,同时实现对该队列的订阅。
对于发送者端,消息会被发送到amq.topic交换机中,并指定了routing_key。
代码示例
客户端订阅:
stompClient.subscribe(
'/topic/notice',
function(respnose){
showResponse(JSON.parse(respnose.body).responseMessage);
});
服务端发送:
@SendTo("/topic/notice")
public ResponseMessage broadcast(ClientMessage clientMessage){
...
// 如果不使用@SendTo注解可使用SimpMessagingTemplate
// simpMessagingTemplate.convertAndSend("topic/notice", serverMessage);
}
public ResponseMessage broadcast(ClientMessage clientMessage){
...
// 使用rabbitmq API直接向消息代理发送消息
rabbitTemplate.convertAndSend("amq.topic", "notice", serverMessage);
}
2. /exchange/[exchangename]/[routing_key]
通过交换机订阅/发布消息,交换机需要手动创建,也可以使用rabbitmq默认的几个交换机,参数说明:
a. /exchange:固定值
b. exchangename:交换机名称
c. routing_key:路由键[可选],可以是"/exchange/[exchangename]/"不能省略末尾"/",这样路由键为空串
对于接收者端,订阅该destination会创建一个唯一的、自动删除的随机queue,并根据routing_key将该 queue 绑定到所给的 exchangename,实现对该队列的消息订阅。
对于发送者端,消息就会被发送到定义的 exchangename中,并且指定了 routing_key。
代码示例
客户端订阅:
stompClient.subscribe(
'/topic/notice',
function(respnose){
showResponse(JSON.parse(respnose.body).responseMessage);
});
stompClient.subscribe(
'/exchange/custom/notice',
function(respnose){
showResponse(JSON.parse(respnose.body).responseMessage);
});
服务端发送:
@SendTo("/exchange/amq.topic/notice")
public ResponseMessage broadcast(RequestMessage requestMessage){
...
// 如果不使用@SendTo注解可使用SimpMessagingTemplate
// simpMessagingTemplate.convertAndSend("/exchange/amq.topic/notice", serverMessage);
}
@SendTo("/exchange/custom/notice")
public ResponseMessage broadcast(RequestMessage requestMessage){
...
// 如果不使用@SendTo注解可使用SimpMessagingTemplate
// simpMessagingTemplate.convertAndSend("/exchange/custom/notice", serverMessage);
}
public ResponseMessage broadcast(ClientMessage clientMessage){
...
// 使用rabbitmq API直接向消息代理发送消息
rabbitTemplate.convertAndSend("amq.topic", "notice", serverMessage);
}
代码解读
客户端订阅了主题:"/exchange/custom/notice",路径中的custom是自定义的交换机,notice是routing_key,服务端通过exchange前缀组合交换机名称以及路由键可向该目的地发送消息。这里客户端同时又订阅了"/topic/notice"主题,根据上述分析,topic前缀使用rabbitmq提供的amq.topic交换机发布/订阅消息,所以这里除了可以使用topic前缀规则来向客户端发送消息还可以使用exchange前缀指定交换机为amq.topic来向客户端推送消息,参考以上服务端代码。使用exchange前缀比较灵活,建议在服务端使用exchange前缀推送消息,客户端订阅可视情况而定。
3. /queue/[queuename]
使用rabbitmq的默认交换机订阅/发布消息,默认由stomp自动创建一个持久化队列,参数说明:
a. /queue:固定值
b. queuename:自动创建的持久化队列名称
对于接收者端,订阅队列queuename的消息
对于接收者端,向queuename发送消息
代码示例
客户端订阅:
stompClient.subscribe(
'/queue/msg',
function(respnose){
showResponse(JSON.parse(respnose.body).responseMessage);
});
服务端发送:
@SendTo("/queue/msg")
public ResponseMessage broadcast(RequestMessage requestMessage){
...
// 如果不使用@SendTo注解可使用SimpMessagingTemplate
// simpMessagingTemplate.convertAndSend("/queue/msg", serverMessage);
}
public ResponseMessage broadcast(RequestMessage requestMessage){
...
// 向rabbitmq默认交换机发送消息需要使用RabbitMQ提供的API
rabbitTemplate.convertAndSend("msg", serverMessage);
}
代码解读
客户端使用"/queue/[queuename]"前缀订阅时,rabbitmq代理刽自动创建一个[queuename]名称的持久化队列,并且绑定到默认交换机,路由键即队列名称。
Rabbitmq文档:默认交换机隐式绑定到每个队列,路由密钥等于队列名称。 无法显式绑定到默认交换或从默认交换中取消绑定。 它也无法删除。
所以服务端在向这种订阅前缀目的地推送消息时可直接向消息目的地发送,亦可使用rabbitmqTemplate API直接向消息代理推送消息,spring提供的SimpMessagingTemplate貌似没有向rabbitmq默认交换机发送消息的接口。
4. /amq/queue/[queuename]
与”/queue/queuename”相似,两者的区别是
a. 队列不由stomp自动进行创建,队列需要自定义且队列不存在失败
这种情况下无论是发送者还是接收者都不会产生队列。 但如果该队列不存在,接收者会报错。
代码示例
首先手动创建一个队列名为"msg",通过路由键"notice"绑定到amq.topic交换机。
客户端订阅:
stompClient.subscribe(
'/amq/queue/msg',
function(respnose){
showResponse(JSON.parse(respnose.body).responseMessage);
});
服务端发送:
@SendTo("/exchange/amq.topic/notice")
public ResponseMessage broadcast(RequestMessage requestMessage){
...
// 如果不使用@SendTo注解可使用SimpMessagingTemplate
// simpMessagingTemplate.convertAndSend("/exchange/amq.topic/notice", serverMessage);
}
@SendTo("/amq/queue/msg")
public ResponseMessage broadcast(RequestMessage requestMessage){
...
// 如果不使用@SendTo注解可使用SimpMessagingTemplate
// simpMessagingTemplate.convertAndSend("/amq/queue/msg", serverMessage);
}
public ResponseMessage broadcast(RequestMessage requestMessage){
...
// 使用RabbitMQ提供的API直接向消息代理发送消息
rabbitTemplate.convertAndSend("amq.topic", "notice", serverMessage);
}
代码解读
客户端通过"/amq/queue"前缀订阅了名称为"msg"的队列的消息,"msg"队列是手动创建的并且通过路由键"notice"绑定到了topic交换机(可绑定到其他交换机),服务端可以有3种方式向该目的地推送消息,其一是直接向"/amq/queue/msg"目的地推送,其二则是通过"/exchange"前缀推送,最后可通过rabbitTemplate发送,可参考上述说明。
1. 使用"/user/"前缀
UserDestinationMessageHandler侦听处理具有"/user/"目的地前缀的消息,将其目标转换为用户的活动会话唯一的实际目的地,然后将解析的消息发送到要传递的代理通道。例如:"/user/queue/msg"的订阅将会转化为"/queue/msg-user{sessionid}",消息发送目的地"/user/{username}/queue/msg"将会转化为"/queue/msg-user{sessionid}"。由上例可知,实际的发布/订阅代理前缀最终是"/queue/",因此需要选用一个"/user/"前缀被转化后实际的代理前缀,根据rabbitmq代理支持的几种前缀进行分析:
(1) 使用“/exchange/”前缀,客户端订阅:"/user/exchange/{exchangename}/[routingkey]"将转化为"/exchange/{exchangename}/[routingkey]-user{sessionid}",rabbitmq代理将自动生成一个自动删除的随机queue与指定的交换机通过实际的路由键"[routingkey]-user{sessionid}进行绑定,用户取消订阅或断开连接后队列自动删除。服务端消息推送:
@SendToUser("/exchange/{exchangename}/[routingkey]")
public ResponseMessage broadcast(RequestMessage requestMessage){
...
// 如果不使用@SendToUser注解可使用SimpMessagingTemplate
// simpMessagingTemplate.convertAndSendToUser({username},"/exchange/{exchangename}/[routingkey]", serverMessage);
}
(2) 使用"/topic/"前缀,与"/exchange"前缀类似,只不过交换机固定为amq.topic。客户端订阅"/user/topic/[routingkey]"转化为:"/queue/[routingkey]-user{sessionid}",rabbitmq代理将自动生成一个自动删除的随机queue与指定的交换机通过实际的路由键"[routingkey]-user{sessionid}进行绑定,用户取消订阅或断开连接后队列自动删除。服务端消息推送:
@SendToUser("/topic/[routingkey]")
public ResponseMessage broadcast(RequestMessage requestMessage){
...
// 如果不使用@SendToUser注解可使用SimpMessagingTemplate
// simpMessagingTemplate.convertAndSendToUser({username},"/topic/[routingkey]", serverMessage);
}
(3) 使用"/queue/"前缀,客户端订阅"/user/queue/[queuename]"转化为:"/queue/[queuename]-user{sessionid}",rabbitmq代理将根据实际的队列名称:[queuename]-user{sessionid} 创建一个持久化队列并且通过路由键:[queuename]-user{sessionid} 与rabbitmq的默认交换机进行绑定,默认交换机是direct类型的。使用这种前缀客户端每一次会话都会根据sessionid生成的不同的持久化队列,客户端断开连接或取消订阅或者rabbitmq宕机均不会删除queue,用户量较大时会消耗大量rabbitmq内存。
@SendToUser("/queue/[queuename]")
public ResponseMessage broadcast(RequestMessage requestMessage){
...
// 如果不使用@SendToUser注解可使用SimpMessagingTemplate
// simpMessagingTemplate.convertAndSendToUser({username},"/queue/[queuename]", serverMessage);
}
(4) 使用"/amq/queue/"前缀,客户端订阅"/user/amq/queue/[queuename]" 转化为:"/amq/queue/[queuename]-user{sessionid}",使用这种前缀指定的queuename必须存在,而这里实际的queuename为:[queuename]-user{sessionid},所以需要在客户端订阅时进行拦截,然后手动创建名称为[queuename]-user{sessionid}的队列使客户端可以成功订阅。
@SendToUser("/amq/queue/[queuename]")
public ResponseMessage broadcast(RequestMessage requestMessage){
...
// 如果不使用@SendToUser注解可使用SimpMessagingTemplate
// simpMessagingTemplate.convertAndSendToUser({username},"/amq/queue/[queuename]", serverMessage);
}
2. 不使用"/user/"前缀
自定义点对点消息前缀,例如"/p2p/",客户端订阅时进行拦截,然后将订阅目的地修改为rabbitmq支持的代理前缀,比如"/amq/queue/",需要手动创建队列通过路由键绑定到指定交换机,因为是点对点消息所以路由键可根据每个客户端的标识id生成,保证客户端点对点队列的安全稳定,创建的队列可以根据需求设置为持久化/非持久化等属性。服务端推送:根据绑定的交换机以及路由键的生成规则向指定客户端推送消息。拦截修改时也可使用其他前缀,例如"/topic/","/queue/","/exchange/",除了路由键可以根据客户端标识生成,队列都是由stomp创建的,不太灵活。
使用rabbitmq做为外部消息代理时将支持消息持久化,需要在消息发布时设置消息头的持久化属性:
客户端
function sendNotice() {
var notice = $("#notice").val();
var clientMessage = {
"fromUserId" : currentUser,
"toUserId" : "all",
"message" : notice
};
// 自定义消息头
var headers = {
priority:0,// 消息优先级
persistent:true, // 消息是否持久化
other:'custom' // 自定义消息标识,可用于订阅端消息过滤
}
// 第一个参数:json负载消息发送的 目的地; 第二个参数:是一个头信息的Map,它会包含在 STOMP帧中;第三个参数:负载消息
stompClient.send("/app/send-notice", headers, JSON.stringify(clientMessage));
$("#notice").val('');
}
注意:在客户端设置的消息头属性,必须直接发送到消息代理目的地才有效,如果发送至@MessageMapping等注解方法则需要在后台程序中设置。
服务端
/** 使用spring stomp websocket提供的API */
@MessageMapping("send_oms")
public ResponseMessage broadcast(RequestMessage requestMessage){
...
Map headers = new HashMap<>();
headers.put("persistent", true);// 设置消息持久化
headers.put("priority", 4);// 设置消息优先级
simpMessagingTemplate.convertAndSend(destination, userMessage, headers);
}
/** 使用Rabbitmq提供的API */
@PostMapping
public void pushMsg(@RequestBody OperMessage operMessage){
// 消息属性设置
MessagePostProcessor postProcessor = new MessagePostProcessor() {
@Override
public Message postProcessMessage(Message message) throws AmqpException {
message.getMessageProperties().setPriority(4);// rabbitmq中消息优先级默认值为0,范围0~255
message.getMessageProperties().setDeliveryMode(MessageDeliveryMode.PERSISTENT);
// ...
return message;
}
};
// rabbitTemplate发送消息默认持久化
rabbitTemplate.convertAndSend(operExchange, operMessage.getRoutingKey(), operMessage, postProcessor);
}
解读
若要实现消息持久化必须保证消息所依赖的交换机、队列是持久化的,因为如果消息的媒介和载体被销毁,那么消息也会不复存在。消息持久化的意义在于保证消息传达的可靠性,可配合客户端使用ACK机制进行消息的确认与删除。
默认情况,在消息发送给客户端之前,服务端会自动确认(acknowledged)。客户端可以选择通过订阅一个目的地时设置一个ack header为client或client-individual来处理消息确认。订阅ack请求头说明:
1.{ack: 'client'}
ack具有累积效应,譬如接收了10条消息,如果你ack了第8条消息,那么1-7条消息都会被ack,只有9-10两条消息还保持未ack状态
2.{ack: 'client-individual'}
ack为独立确认模式,只确认当前调用ack的消息不会影响其他消息,在订阅回调接口中大量接收单条消息使用
3.auto(默认) 自动确认
在下面这个例子,客户端必须调用message.ack()来通知客户端它已经接收了消息:
var subscription = client.subscribe("/topic/notice",
function(message) {
// do something with the message
...
// and acknowledge it
message.ack();
// or no ack
// message.nack();
},
{ack: 'client-individual'}
);
解读
如果客户端指定ack订阅头,则必须在消费消息后进行消息确认,否则服务端队列将不会删除该消息,即可重复消费。
当消息没有被其他消费者消费且在以下情况下,rabbitmq 会对消息进行重新投递:
1.client 未响应ACK, 主动关闭 Channel;
2.client 未响应ACk, 网络异常断开;
当客户端重新连接后便可再次消费没有ack的消息。
message.nack()表示客户端不能消费这个消息,消息将重新投递到消息队列中等待其他消费者消费,如果当前只有一个消费者则会导致消息循环阻塞直到服务端收到ack。另外需要注意,如果在ack方法之后使用nack则使ack无效,客户端如果多次ack同一个或未知的传递标签,RabbitMQ将报PRECONDITION_FAILED - unknown delivery tag 1 此类的错误信息。
<<< PONG <<< ERROR message:Invalid destination content-type:text/plain version:1.0,1.1,1.2 spanTraceId:3041a6ec0537cdf5 spanId:3041a6ec0537cdf5 spanSampled:0 content-length:48 '/notice/case' is not a valid topic destination Whoops! Lost connection to http://127.0.0.1:9005/msgpush/stomp
原因:rabbitmq不支持订阅后缀带"/"
参考:https://github.com/spring-guides/gs-messaging-stomp-websocket/issues/35