Annotated Controllers

应用程序可以使用注解的 @Controller类来处理来自客户端的消息。这样的类可以声明 @MessageMapping@SubscribeMapping@ExceptionHandler方法,如以下主题所述。

@MessageMapping

你可以使用 @MessageMapping 来注解那些根据 目的地路由消息的方法。它在方法层面和类型层面都被支持。在类型层面上,@MessageMapping被用来表达控制器中所有方法的共享映射。

默认情况下,映射值是 Ant 风格的路径模式(例如 /thing*/thing/**),包括对模板变量的支持(例如 /thing/{id})。这些值可以通过 @DestinationVariable方法参数进行引用。应用程序也可以切换到点状分隔的映射目的地约定,如 点状分隔符中所解释

支持的方法参数

下表描述了方法参数。

Method argument Description
Message 为了获得完整的信息。
MessageHeaders 用于访问信息中的 header
MessageHeaderAccessor, SimpMessageHeaderAccessor, and StompHeaderAccessor 用于通过类型化的访问器方法访问 header。
@Payload 用于访问消息的有效载荷,由配置的 MessageConverter 转换(例如,从 JSON)。
这个注解的存在不是必须的,因为默认情况下,如果没有其他参数被匹配,它就会被假定。
你可以用 @javax.validation.Valid或 Spring 的 @Validated来注解有效载荷参数,以使有效载荷参数被自动验证。
@Header 用于访问一个特定的 header — 如果有必要,同时使用org.springframework.core.convert.Converter.Converter 进行类型转换。
@Headers 用于访问消息中的所有 header。这个参数必须是可分配给 java.util.Map.Message的。
@DestinationVariable 用于访问从消息目的地提取的模板变量。必要时,数值会被转换为声明的方法参数类型。
java.security.Principal 反映在 WebSocket HTTP 握手时登录的用户。

返回值

默认情况下,@MessageMapping 方法的返回值通过匹配的 MessageConverter 被序列化为一个有效载荷,并作为一个消息发送到brokerChannel,从那里被广播给订阅者。外发消息的目的地与内发消息的目的地相同,但前缀为 /topic

你可以使用 @SendTo@SendToUser注解来定制输出消息的目的地。@SendTo是用来定制目标目的地或指定多个目的地的。@SendToUser用来指导输出消息只给与输入消息相关的用户。参见 用户目的地

你可以在同一个方法上同时使用 @SendTo@SendToUser,而且在类的层面上也支持这两种注解,在这种情况下,它们作为类中方法的默认值。然而,请记住,任何方法级的 @SendTo@SendToUser注解都会覆盖类级的任何此类注解。

消息可以被异步处理,@MessageMapping方法可以返回 ListenableFutureCompleteableFutureCompletionStage

请注意,@SendTo@SendToUser只是一种便利,相当于使用 SimpMessagingTemplate 来发送消息。如果有必要,对于更高级的场景,@MessageMapping方法可以直接使用 SimpMessagingTemplate。这可以代替返回一个值,也可能是除了返回一个值之外。参见 发送消息

@SubscribeMapping

@SubscribeMapping@MessageMapping类似,但只缩小了对订阅信息的映射。它支持与 @MessageMapping相同的方法参数。然而对于返回值,默认情况下,消息被直接发送到客户端(通过 clientOutboundChannel,对订阅的响应),而不是发送到经纪人(通过 brokerChannel,作为 广播 给匹配的订阅)。添加 @SendTo@SendToUser 会重写这一行为,并代替发送至经纪人。(简单说:@SubscribeMapping 的返回值是指针对订阅者,而 @MessageMapping 是针对所有订阅者)

这在什么时候是有用的?假设经纪人被映射到 /topic/queue,而应用控制器被映射到 /app。在这种设置中,经纪人存储了所有对 /topic/queue的订阅,这些订阅是为了重复广播,而应用程序不需要参与。客户端也可以订阅一些 /app的目的地,控制器可以在不涉及代理的情况下返回一个值,而不需要再次存储或使用该订阅(实际上是一个一次性的请求 - 回复交换)。这方面的一个用例是在启动时用初始数据填充一个用户界面。

这在什么时候没有用?不要试图将经纪人和控制器映射到同一个目标前缀,除非你想让两者独立处理消息,包括订阅,因为某些原因。入站消息是平行(并行)处理的。不能保证一个经纪人或一个控制器先处理一个给定的消息。如果目标是在订阅被存储并准备好进行广播时得到通知,如果服务器支持的话,客户端应该要求得到一个收据(简单的经纪人不支持)。例如,使用 Java STOMP 客户端,你可以做以下事情来添加一个收据:

  1. @Autowired
  2. private TaskScheduler messageBrokerTaskScheduler;
  3. // 在初始化过程中
  4. stompClient.setTaskScheduler(this.messageBrokerTaskScheduler);
  5. // 当订阅...
  6. StompHeaders headers = new StompHeaders();
  7. headers.setDestination("/topic/...");
  8. headers.setReceipt("r1");
  9. FrameHandler handler = ...;
  10. stompSession.subscribe(headers, handler).addReceiptTask(() -> {
  11. // 订阅就绪...
  12. });

一个服务器端的选择是在 brokerChannel 上 注册 一个 ExecutorChannelInterceptor,并实现 afterMessageHandled 方法,该方法在消息(包括订阅)被处理后被调用。

:::tips 需要认真阅读:

  • @SubscribeMapping:客户端是需要 订阅 /app/路径,而不是 发送 消息到该路径;订阅后就会收到响应的消息,可以用于做数据的初始化
  • @MessageMapping:客户端是 发送 /app/路径,方法响应的消息会被默认发送到 /topic/路径 上,简单说如果没有 @MessageMapping(“/task”) 注解的方法,只要客户端订阅了 /topic/task ,后端也可以通过 其他方式发送消息/topic/task 上 :::

一个例子

在前面的 例子中改造,后端控制器里面

  1. package cn.mrcode.study.springdocsread.websocket;
  2. import org.springframework.messaging.handler.annotation.MessageMapping;
  3. import org.springframework.messaging.simp.annotation.SubscribeMapping;
  4. import org.springframework.stereotype.Controller;
  5. /**
  6. * @author mrcode
  7. */
  8. @Controller
  9. public class StompController {
  10. /**
  11. * @param greeting
  12. * @return 返回值是广播给所有人
  13. */
  14. // 需要注意的是:客户端需要发送消息到 /app/greeting
  15. // 响应的消息,会默认广播到 /topic/greeting 上,只要订阅了 /topic/greeting 的订阅者都能收到
  16. @MessageMapping("/greeting")
  17. public String handle(String greeting) {
  18. return "[" + getTimestamp() + ": " + greeting;
  19. }
  20. private String getTimestamp() {
  21. return System.currentTimeMillis() + "";
  22. }
  23. /**
  24. * @return 返回值只返回给订阅的人;
  25. */
  26. // 需要注意的是:前端需要订阅 /app/greeting2
  27. // 也就是说,只要有订阅 /app/greeting2,订阅成功后,该订阅者就会收到这里返回的消息
  28. @SubscribeMapping("/greeting2")
  29. public String handle2() {
  30. return "[ 单个消息" + getTimestamp() + ": ";
  31. }
  32. }

前端页面

  1. <!DOCTYPE html>
  2. <html lang="en">
  3. <head>
  4. <meta charset="UTF-8">
  5. <title>STOMP</title>
  6. </head>
  7. <body>
  8. </body>
  9. <script type="text/javascript" src="/node_modules/webstomp-client/dist/webstomp.min.js"></script>
  10. <script type="text/javascript"
  11. src="https://cdnjs.loli.net/ajax/libs/sockjs-client/1.6.0/sockjs.js"></script>
  12. <script>
  13. // 这里使用 sockJs 库链接
  14. var socket = new SockJS("http://localhost:8080/portfolio");
  15. // 文章上说可以使用 WebSocket 链接。 实际上我这里测试不可以,会报错
  16. // var socket = new WebSocket("ws://localhost:8080/portfolio");
  17. var stompClient = webstomp.over(socket);
  18. // 链接上 服务器时
  19. stompClient.connect({}, function (frame) {
  20. console.log(frame)
  21. // 订阅消息
  22. stompClient.subscribe("/topic/greeting", msg => {
  23. console.log("收到订阅的消息广播:" + msg.body)
  24. })
  25. // 订阅消息
  26. stompClient.subscribe("/app/greeting2", msg => {
  27. console.log("收到初始化的订阅消息:" + msg.body)
  28. })
  29. // 链接上服务器时,像服务器发送一个消息
  30. stompClient.send("/app/greeting", "我的第一个消息")
  31. })
  32. </script>
  33. </html>

控制台显示如下
image.png
可以看到,只在订阅成功后,就能收到消息,而不是需要通过发送后触发后端的消息广播。

@MessageExceptionHandler

一个应用程序可以使用 @MessageExceptionHandler方法来处理来自 @MessageMapping方法的异常。你可以在注解本身中声明异常,如果你想获得对异常实例的访问,也可以通过方法参数来声明。下面的例子通过一个方法参数声明了一个异常:

  1. @Controller
  2. public class MyController {
  3. // ...
  4. @MessageExceptionHandler
  5. public ApplicationError handleException(MyException exception) {
  6. // ...
  7. return appError;
  8. }
  9. }

@MessageExceptionHandler 方法支持灵活的方法签名,支持与 [@MessageMapping](#uCbzp)方法相同的方法参数类型和返回值。

通常情况下,@MessageExceptionHandler 方法适用于它们所声明的 @Controller类(或类层次结构)。如果你想让这些方法在更大范围内应用(跨控制器),你可以在一个标有 @ControllerAdvice的类中声明它们。这与 Spring MVC 中的 类似支持 相当。