# Annotated Controllers

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

# @MessageMapping

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

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

# 支持的方法参数

下表描述了方法参数。

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用来指导输出消息只给与输入消息相关的用户。参见 用户目的地 (opens new window)

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

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

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

# @SubscribeMapping

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

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

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

@Autowired
private TaskScheduler messageBrokerTaskScheduler;

// 在初始化过程中
stompClient.setTaskScheduler(this.messageBrokerTaskScheduler);

// 当订阅...
StompHeaders headers = new StompHeaders();
headers.setDestination("/topic/...");
headers.setReceipt("r1");
FrameHandler handler = ...;
stompSession.subscribe(headers, handler).addReceiptTask(() -> {
    // 订阅就绪...
});
1
2
3
4
5
6
7
8
9
10
11
12
13
14

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

:::tips 需要认真阅读:

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

# 一个例子

在前面的 例子中改造 (opens new window),后端控制器里面

package cn.mrcode.study.springdocsread.websocket;

import org.springframework.messaging.handler.annotation.MessageMapping;
import org.springframework.messaging.simp.annotation.SubscribeMapping;
import org.springframework.stereotype.Controller;

/**
 * @author mrcode
 */
@Controller
public class StompController {
    /**
     * @param greeting
     * @return 返回值是广播给所有人
     */
    // 需要注意的是:客户端需要发送消息到 /app/greeting
    // 响应的消息,会默认广播到  /topic/greeting  上,只要订阅了 /topic/greeting 的订阅者都能收到
    @MessageMapping("/greeting")
    public String handle(String greeting) {
        return "[" + getTimestamp() + ": " + greeting;
    }

    private String getTimestamp() {
        return System.currentTimeMillis() + "";
    }

    /**
     * @return 返回值只返回给订阅的人;
     */
    // 需要注意的是:前端需要订阅  /app/greeting2
    // 也就是说,只要有订阅 /app/greeting2,订阅成功后,该订阅者就会收到这里返回的消息
    @SubscribeMapping("/greeting2")
    public String handle2() {
        return "[ 单个消息" + getTimestamp() + ": ";
    }
}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37

前端页面

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>STOMP</title>
</head>
<body>

</body>
<script type="text/javascript" src="/node_modules/webstomp-client/dist/webstomp.min.js"></script>
<script type="text/javascript"
        src="https://cdnjs.loli.net/ajax/libs/sockjs-client/1.6.0/sockjs.js"></script>
<script>
    // 这里使用 sockJs 库链接
    var socket = new SockJS("http://localhost:8080/portfolio");
    // 文章上说可以使用 WebSocket 链接。 实际上我这里测试不可以,会报错
    // var socket = new WebSocket("ws://localhost:8080/portfolio");
    var stompClient = webstomp.over(socket);

    // 链接上 服务器时
    stompClient.connect({}, function (frame) {
        console.log(frame)
        // 订阅消息
        stompClient.subscribe("/topic/greeting", msg => {
            console.log("收到订阅的消息广播:" + msg.body)
        })

        // 订阅消息
        stompClient.subscribe("/app/greeting2", msg => {
            console.log("收到初始化的订阅消息:" + msg.body)
        })

        // 链接上服务器时,像服务器发送一个消息
        stompClient.send("/app/greeting", "我的第一个消息")
    })
</script>
</html>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37

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

# @MessageExceptionHandler

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

@Controller
public class MyController {

    // ...

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

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

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