Spring 的 @SubscribeMapping 真的会让客户端订阅某个主题吗?

49

我正在使用Spring Websocket和STOMP,以及Simple Message Broker。在我的@Controller中,我使用了基于方法级别的@SubscribeMapping注解,它应该订阅客户端到一个主题,这样客户端就可以接收到该主题的消息。假设客户端订阅了主题"chat"

stompClient.subscribe('/app/chat', ...);

由于客户端订阅的是"/app/chat"而不是"/topic/chat",因此该订阅将转到使用@SubscribeMapping映射的方法:

@SubscribeMapping("/chat")
public List getChatInit() {
    return Chat.getUsers();
}

这里是Spring参考文档内容:

 

默认情况下,@SubscribeMapping方法的返回值会直接作为消息发送回连接的客户端,而不经过代理。这对于实现请求-响应消息交互非常有用;例如,在初始化应用程序UI时获取应用程序数据。

好的,这正是我想要的,但只是部分地!在订阅后发送一些初始数据,很好。但是关于订阅呢?在我看来,这里发生的事情就像一个请求-响应,类似于服务。订阅只是被消费了。如果是这种情况,请帮我澄清一下。

  • 如果代理没有参与,客户端是否已经订阅到某个位置了?
  • 如果我以后想向“聊天”订阅者发送一些消息,客户端会收到吗?看起来并不是这样。
  • 谁真正实现了订阅?代理还是其他人?

如果客户端没有被订阅到任何位置,我想知道为什么我们称之为“订阅”;因为客户端只接收一条消息,而不是未来的消息。

编辑:

为了确保订阅已经实现,我尝试了以下操作:

服务器端:

配置:

@Configuration
@EnableWebSocketMessageBroker
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("/hello").withSockJS();
    }
}

控制器:

@Controller
public class GreetingController {

    @MessageMapping("/hello")
    @SendTo("/topic/greetings")
    public Greeting greeting(HelloMessage message) throws Exception {
        System.out.println("inside greeting");
        return new Greeting("Hello, " + message.getName() + "!");
    }

    @SubscribeMapping("/topic/greetings")
    public Greeting try1() {
        System.out.println("inside TRY 1");
        return new Greeting("Hello, " + "TRY 1" + "!");
    }
}

客户端:

...
    stompClient.subscribe('/topic/greetings', function(greeting){
                        console.log('RECEIVED !!!');
                    });
    stompClient.send("/app/hello", {}, JSON.stringify({ 'name': name }));
...

我希望发生的事情:

  1. 当客户端订阅'/topic/greetings'时,执行try1方法。
  2. 当客户端向'/app/hello'发送消息时,应该收到问候消息,即@SendTo '/topic/greetings'。

结果:

  1. 如果客户端订阅了/topic/greetings,则无法捕获try1方法。

  2. 当客户端向'/app/hello'发送消息时,执行了greeting方法,客户端收到了问候消息。因此我们知道它已正确订阅'/topic/greetings'。

  3. 但是请记住问题1失败了。尝试了一些方法后,当客户端订阅了'/app/topic/greetings'时,即使用/app前缀(这可以通过配置理解)。

  4. 现在问题1已经解决,但是问题2失败了:当客户端向'/app/hello'发送消息时,确实执行了greeting方法,但是客户端没有收到问候消息。(因为现在客户端可能订阅了以'/app'为前缀的主题,这是不希望的。)

所以,我得到的是我想要的1或2个,但不是这两个一起。

  • 如何使用此结构(正确配置映射路径)实现这一点?

答案不在提供的文本中。

5个回答

26
默认情况下,@SubscribeMapping方法的返回值会直接发送给连接的客户端作为消息,不经过代理服务器

(强调是我自己加的)

在这里,Spring框架文档描述了响应消息的处理,而不是传入的SUBSCRIBE消息。

因此,回答您的问题:

  • 是的,客户端已订阅该主题
  • 是的,如果使用该主题发送消息,则已订阅该主题的客户端将收到消息
  • 消息代理负责管理订阅

更多关于订阅管理的信息

使用 SimpleMessageBroker,消息代理实现位于您的应用程序实例中。订阅注册由 DefaultSubscriptionRegistry 管理。 在接收消息时,SimpleBrokerMessageHandler 处理 SUBSCRIPTION 消息并注册订阅(请参见此处的实现)。
对于像 RabbitMQ 这样的“真正”的消息代理,您已配置了一个 Stomp 代理中继,该中继将消息转发到代理。在这种情况下,SUBSCRIBE 消息被转发到负责管理订阅的代理(请参见此处的实现)。
更新 - 更多关于 STOMP 消息流的内容
如果您查看STOMP消息流的参考文档, 您会发现:
- 路径为“/topic/greeting”的订阅通过“clientInboundChannel”传递并转发到代理 - 发送到“/app/greeting”的问候经过“clientInboundChannel”传递并转发到GreetingController。控制器添加当前时间,返回值作为消息通过“brokerChannel”传递给“/topic/greeting”(目标根据约定选择,但可以通过@SendTo覆盖)。
因此,在这里,/topic/hello是代理目的地;发送到那里的消息将直接转发到代理。而/app/hello是应用程序目标,应该产生一条消息发送到/topic/hello,除非@SendTo另有规定。
现在您更新的问题与之前不同,没有更精确的用例,很难确定哪种模式最适合解决这个问题。以下是几个可能的模式:
  • 当有异步事件发生时,您希望客户端能够意识到:订阅特定主题/topic/hello
  • 您想要广播消息:向特定主题发送消息/topic/hello
  • 您想要立即获得某些反馈,例如初始化应用程序状态:订阅一个应用程序目标/app/hello,使用控制器立即响应消息
  • 您想要向任何应用程序目标/app/hello发送一条或多条消息:使用@MessageMapping@SendTo或消息模板的组合。

如果您需要一个很好的示例,请查看此聊天应用程序展示了Spring Websocket功能的日志和实际用例


1
在我看来,如果你使用'/app'连接,它会连接到应用程序而不是代理;而使用'/topic'连接,则会连接到代理而不是应用程序。 - Mert Mertce
1
我发现当客户端订阅 '/app/topic' 时,它确切地订阅了 '/app/topic',而没有删除 '/app' 前缀,这使得这个订阅注册变得无用,因为 SimpleBrokerMessageHandler 从不向 '/app' 前缀的订阅发送消息。它只是在 SimpleBrokerMessageHandler 中使用 "checkDestinationPrefix" 进行过滤。 - Mert Mertce
1
修改后:我想要的是使用@SubscribeMapping捕获订阅,并且同时使客户端订阅主题而不带有“/app”前缀,这就像在MessageMapping中一样。但是在SubscribeMapping中并非如此。 订阅消息会带有“/app”前缀发送到Broker,因此客户端未能成功订阅主题。在这种情况下,Subscribtion变成了请求-响应模式,仅此而已。 这并不是真正意义上的订阅。 - Mert Mertce
1
是的,就像文档中所述,但我在这里质疑的是意图。客户端可以只订阅'/topic',但他订阅了'/app/topic'。这里的意图是什么?将订阅映射到控制器,并且不要忘记,还要订阅'/topic'。但结果是客户端被订阅到了'/app/topic',这不是我们想要做的。Brian,你没有看到这里的扭曲吗?我错过了什么吗? - Mert Mertce
4
直观来看,除了返回控制器操作的值外,人们可能会期望自动创建一个订阅/topic/chat.participants的订阅,类似于消息映射转发到相应的代理目标。但是我没有看到这种情况发生,据我所知,客户端必须单独订阅/topic/chat.participants - rainerfrey
显示剩余3条评论

19

因此,同时使用以下两种方式:

  • 使用主题处理订阅
  • 在该主题上使用@SubscribeMapping来提供连接响应

不会像你(以及我)经历的那样起作用。

解决您的情况的方法(就像我解决自己的情况一样)是:

  1. 删除@SubscribeMapping-它仅适用于/app前缀
  2. 订阅/topic,就像您自然地订阅一样(没有/app前缀)
  3. 实现ApplicationListener

    1. 如果要直接回复单个客户端,请使用用户目标(请参见websocket-stomp-user-destination),或者您也可以订阅子路径,例如/topic/my-id-42,然后您可以将消息发送到此子主题(我不知道您确切的用例,我的用例是我有专用订阅,如果我想进行广播,我会对它们进行迭代)。

    2. 在ApplicationListener的onApplicationEvent方法中收到StompCommand.SUBSCRIBE后立即发送消息

订阅事件处理程序:

@Override
  public void onApplicationEvent(SessionSubscribeEvent event) {
      Message<byte[]> message = event.getMessage();
      StompHeaderAccessor accessor = StompHeaderAccessor.wrap(message);
      StompCommand command = accessor.getCommand();
      if (command.equals(StompCommand.SUBSCRIBE)) {
          String sessionId = accessor.getSessionId();
          String stompSubscriptionId = accessor.getSubscriptionId();
          String destination = accessor.getDestination();
          // Handle subscription event here
          // e.g. send welcome message to *destination*
       }
  }

3
感谢您的帮助意愿,尤其是在经过一段时间后。您的回答对于以后阅读的人很有用,所以加上一分。然而,在我的情况下,据我记得,onApplicationEvent 在实际成功订阅之前就已经运行了,并且在那里向订阅者返回答案会误导订阅者,即假装他已经成功订阅,但这并不是真的。我发现默认的Spring Stomp Broker有重要的限制,因此我决定在项目开发足够成熟时使用另一个“真正的”代理。 - Mert Mertce

9

嗨,Mert。虽然你的问题是在四年前提出的,但由于我最近遇到了同样的问题并终于解决了它,所以我仍然会尝试回答它。

关键部分在于@SubscribeMapping是一次性的请求-响应交换,因此你控制器中的try1()方法将在客户端代码运行后仅被触发一次。

stompClient.subscribe('/topic/greetings', callback)

在此之后,无法通过 stompClient.send(...) 触发 try1()

另一个问题是控制器是应用程序消息处理程序的一部分,它接收带有前缀 /app 的目标,因此为了达到 @SubscribeMapping("/topic/greetings"),您实际上需要编写如下的客户端代码

stompClient.subscribe('/app/topic/greetings', callback)

由于传统上将主题映射到代理程序以避免歧义,因此建议修改您的代码为

@SubscribeMapping("/greetings")

stompClient.subscribe('/app/greetings', callback)

现在,console.log('RECEIVED !!!') 应该可以工作了。

官方文档 还推荐在初始UI渲染时使用 @SubscribeMapping 的用例场景。

什么时候会有用呢?假设代理被映射到 /topic 和 /queue,而应用程序控制器被映射到 /app。在此设置中,代理存储所有订阅 /topic 和 /queue 的重复广播,应用程序无需介入。客户端还可以订阅一些 /app 目标,控制器可以响应该订阅并返回一个值,而无需涉及代理或再次存储或使用该订阅(实际上是一次性请求-响应交换)。一个使用情况是在启动时使用数据填充UI。


8
我遇到了同样的问题,最终采用了以下解决方案:在客户端订阅/topic/app后,将所有收到的信息缓存于/topic处理程序中,直到/app绑定程序下载完整个聊天历史记录,这就是@SubscribeMapping返回的内容。然后我将所有最近的聊天记录与在/topic中接收到的记录合并——在我的情况下可能会有重复。

另一种有效的方法是声明:

registry.enableSimpleBroker("/app", "/topic");
registry.setApplicationDestinationPrefixes("/app", "/topic");

显然,不是完美的。但是起作用了 :)

这个运作得很好,但是有什么缺点吗?这会影响/topic通知的配置吗? - mpromonet
我认为在 enableSimpleBroker() 中不需要 "/app"。我和 OP 一样遇到了同样的问题,通过将 "/topic" 添加到应用程序目标中解决了它。 - Joffrey
1
尽管这个解决方案并不完美,但它帮助我理解了方法“setApplicationDestinationPrefixes”标记了将通过应用程序控制器(使用MessageMapping和SubscribeMapping标记)的路由。否则,它将尝试通过简单代理进行,而不调用您的方法。 - Viktor Molokanov

2
也许这不是完全相关的,但当我订阅'app/test'时,无法接收发送到'app/test'的消息。 因此,我发现添加代理是问题所在(顺便说一句,我不知道为什么)。 这是我的代码:
    @Override
    public void configureMessageBroker(MessageBrokerRegistry config) {
        config.setApplicationDestinationPrefixes("/app");
        config.enableSimpleBroker("/topic");
    }

After :

    @Override
    public void configureMessageBroker(MessageBrokerRegistry config) {
        config.setApplicationDestinationPrefixes("/app");
        // problem line deleted
    }

现在当我订阅'app/test'时,这是有效的:

最初的回答

    template.convertAndSend("/app/test", stringSample);

在我的情况下,我不需要更多。最初的回答。

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接