阅读完需:约 15 分钟
1.http与websocket
http超文本传输协议,大家都非常熟悉,http有1.0、1.1、 2.0几个版本,从http1.1起,默认都开启了Keep-Alive,保持连接持续性,简单地说,当一个网页打开完成后,客户端和服务器之间用于传输http数据的TCP连接不会关闭,如果客户端再次访问这个服务器上的网页,会继续使用这一条已经建立的连接,这样就降低了资源的消耗优化性能,但是Keep-Alive也是有时间限制的,还有一个客户端只能主动发起请求才能获取返回数据,并不能主动接收后台推送的数据,websocket便应运而生。
websocket 是 html5
新增加特性之一,目的是浏览器与服务端建立全双工的通信方式,解决 http 请求-响应带来过多的资源消耗,同时对特殊场景应用提供了全新的实现方式,比如聊天、股票交易、游戏等对对实时性要求较高的行业领域。
http与websocket都是基于TCP(传输控制协议)的,websocket可以看做是对http协议的一个补充
2.SockJs
SockJS是一个JavaScript库,为了应对许多浏览器不支持WebSocket协议的问题,设计了备选SockJs。SockJS 是 WebSocket 技术的一种模拟。SockJS会尽可能对应 WebSocket API,但如果WebSocket 技术不可用的话,会自动降为轮询的方式。
3.Stompjs
STOMP—— Simple Text Oriented Message Protocol——面向消息的简单文本协议。
SockJS 为 WebSocket 提供了 备选方案。但无论哪种场景,对于实际应用来说,这种通信形式层级过低。 STOMP协议,来为浏览器 和 server 间的 通信增加适当的消息语义。
4.WebSocket、SockJs、STOMP三者关系
简而言之,WebSocket 是底层协议,SockJS 是WebSocket 的备选方案,也是底层协议,而 STOMP 是基于 WebSocket(SockJS)的上层协议。
1、HTTP协议解决了 web 浏览器发起请求以及 web 服务器响应请求的细节,假设 HTTP 协议 并不存在,只能使用 TCP 套接字来 编写 web 应用。
2、直接使用 WebSocket(SockJS) 就很类似于 使用 TCP 套接字来编写 web 应用,因为没有高层协议,就需要我们定义应用间所发送消息的语义,还需要确保连接的两端都能遵循这些语义;
3、同HTTP在TCP 套接字上添加请求-响应模型层一样,STOMP在WebSocket 之上提供了一个基于帧的线路格式层,用来定义消息语义;
<head>
<meta charset="UTF-8">
<title>Title</title>
<script src="/webjars/jquery/jquery.min.js"></script>
<script src="/webjars/sockjs-client/sockjs.min.js"></script>
<script src="/webjars/stomp-websocket/stomp.min.js"></script>
</head>
STOMP帧
STOMP帧由命令,一个或多个头信息、一个空行及负载(文本或字节)所组成;
其中可用的 command 包括:
connect,send,subscribe,unsubscribe,begin,commit,abort,ack,nack,disconnect
发起连接
客户端可以通过使用Stomp.js和sockjs-client连接
// 建立连接对象(还未发起连接)
var socket=new SockJS("/spring-websocket-portfolio/portfolio");
// 获取 STOMP 子协议的客户端对象
var stompClient = Stomp.over(socket);
// 向服务器发起websocket连接并发送CONNECT帧
stompClient.connect(
{},
function connectCallback (frame) {
// 连接成功时(服务器响应 CONNECTED 帧)的回调方法
document.getElementById("state-info").innerHTML = "连接成功";
console.log('已连接【' + frame + '】');
stompClient.subscribe('/topic/getResponse', function (response) {
showResponse(response.body);
});
},
function errorCallBack (error) {
// 连接失败时(服务器响应 ERROR 帧)的回调方法
document.getElementById("state-info").innerHTML = "连接失败";
console.log('连接失败【' + error + '】');
}
);
说明:
1. socket连接对象也可通过WebSocket(不通过SockJS)连接
var socket=new WebSocket("/spring-websocket-portfolio/portfolio");
2. stompClient.connect()方法签名:
client.connect(headers, connectCallback, errorCallback);
其中 headers
表示客户端的认证信息,如:
var headers = {
login: 'mylogin',
passcode: 'mypasscode',
// additional header
'client-id': 'my-client-id'
};
若无需认证,直接使用空对象 “{}” 即可;
connectCallback
表示连接成功时(服务器响应 CONNECTED 帧)的回调方法;errorCallback
表示连接失败时(服务器响应 ERROR 帧)的回调方法,非必须;
断开连接
若要从客户端主动断开连接,可调用 disconnect()
方法
client.disconnect(function () {
alert("See you next time!");
};
该方法为异步进行,因此包含了回调参数,操作完成时自动回调;
心跳机制
若使用STOMP 1.1 版本,默认开启了心跳检测机制,可通过client对象的heartbeat field进行配置(默认值都是10000 ms):
client.heartbeat.outgoing = 20000; // client will send heartbeats every 20000ms
client.heartbeat.incoming = 0; // client does not want to receive heartbeats from the server
// The heart-beating is using window.setInterval() to regularly send heart-beats and/or check server heart-beats
发送信息
连接成功后,客户端可使用 send() 方法向服务器发送信息:
client.send(destination url[, headers[, body]]);
其中destination url 为服务器 controller中 @MessageMapping 中匹配的URL,字符串,必须参数;
headers 为发送信息的header,JavaScript 对象,可选参数;
body 为发送信息的 body,字符串,可选参数;
例:
client.send("/queue/test", {priority: 9}, "Hello, STOMP");client.send("/queue/test", {}, "Hello, STOMP");
订阅、接收信息
STOMP 客户端要想接收来自服务器推送的消息,必须先订阅相应的URL,即发送一个 SUBSCRIBE 帧,然后才能不断接收来自服务器的推送消息;
订阅和接收消息通过 subscribe()
方法实现:
subscribe(destination url, callback[, headers])
其中 destination url 为服务器 @SendTo 匹配的 URL,字符串;
callback 为每次收到服务器推送的消息时的回调方法,该方法包含参数 message;
headers 为附加的headers,JavaScript 对象;什么作用?
该方法返回一个包含了id属性的 JavaScript 对象,可作为 unsubscribe() 方法的参数;
例:
var headers = {ack: 'client', 'selector': "location = 'Europe'"};
var callback = function(message) {
if (message.body) {
alert("got message with body " + message.body)
} else {
alert("got empty message");
}
});
var subscription = client.subscribe("/queue/test", callback, headers);
取消订阅
var subscription = client.subscribe(...);
subscription.unsubscribe();
JSON 支持
STOMP 帧的 body 必须是 string 类型,若希望接收/发送 json 对象,可通过 JSON.stringify() and JSON.parse() 实现;
例:
var quote = {symbol: 'APPL', value: 195.46};
client.send("/topic/stocks", {}, JSON.stringify(quote));
client.subcribe("/topic/stocks", function(message) {
var quote = JSON.parse(message.body);
alert(quote.symbol + " is at " + quote.value);
});
事务支持
STOMP 客户端支持在发送消息时用事务进行处理:
举例说明:
// start the transaction
// 该方法返回一个包含了事务 id、commit()、abort() 的JavaScript 对象
var tx = client.begin();
// send the message in a transaction
// 最关键的在于要在 headers 对象中加入事务 id,若没有添加,则会直接发送消息,不会以事务进行处理
client.send("/queue/test", {transaction: tx.id}, "message in a transaction");
// commit the transaction to effectively send the message
tx.commit();
// tx.abort();
Debug 信息
STOMP 客户端默认将传输过程中的所有 debug 信息以 console.log()
形式输出到客户端浏览器中,也可通过以下方式输出到 DOM 中:
client.debug = function(str) {
// str 参数即为 debug 信息
// append the debug log to a #debug div somewhere in the page using JQuery:
$("#debug").append(str + "\n");
};
认证
默认情况下,STOMP消息将在消息传递到客户端之前由服务器自动确认。
客户端可以选择通过订阅目的地来处理消息确认,并将ack头设置为client或client-individual。
在这种情况下,客户端必须使用message.ack()方法通知服务器它已经确认了消息。
var subscription = client.subscribe("/queue/test",
function(message) {
// do something with the message
...
// and acknowledge it
message.ack();
},
{ack: 'client'}
);
ack()方法接受一个headers参数,用于附加的headers来确认消息。例如,当代理已有效地处理ACK STOMP帧时,可以将消息确认为事务的一部分并要求收据:
var tx = client.begin();
message.ack({ transaction: tx.id, receipt: ‘my-receipt’ });
tx.commit();
nack()方法还可以用于通知STOMP 1.1代理客户端没有使用消息。它采用与ack()方法相同的参数。
STOMP 服务端整理
一、STOMP 简介
直接使用WebSocket(或SockJS)就很类似于使用TCP套接字来编写Web应用。因为没有高层级的线路协议(wire protocol),因此就需要我们定义应用之间所发送消息的语义,还需要确保连接的两端都能遵循这些语义。
就像HTTP在TCP套接字之上添加了请求-响应模型层一样,STOMP在WebSocket之上提供了一个基于帧的线路格式(frame-based wire format)层,用来定义消息的语义。
与HTTP请求和响应类似,STOMP帧由命令、一个或多个头信息以及负载所组成。例如,如下就是发送数据的一个STOMP帧:
>>> SEND transaction:tx-0 destination:/app/marco content-length:20 {"message":"Marco!"}
在这个例子中,STOMP命令是send,表明会发送一些内容。紧接着是三个头信息:一个表示消息的的事务机制,一个用来表示消息要发送到哪里的目的地,另外一个则包含了负载的大小。然后,紧接着是一个空行,STOMP帧的最后是负载内容。
1、启用STOMP功能
STOMP 的消息根据前缀的不同分为三种。如下,以 /app 开头的消息都会被路由到带有@MessageMapping 或 @SubscribeMapping 注解的方法中;以/topic 或 /queue 开头的消息都会发送到STOMP代理中,根据你所选择的STOMP代理不同,目的地的可选前缀也会有所限制;以/user开头的消息会将消息重路由到某个用户独有的目的地上。
2、处理来自客户端的STOMP消息
服务端处理客户端发来的STOMP消息,主要用的是 @MessageMapping 注解。如下:
@MessageMapping("/marco")
@SendTo("/topic/marco")
public Shout stompHandle(Shout shout){
LOGGER.info("接收到消息:" + shout.getMessage());
Shout s = new Shout();
s.setMessage("Polo!");
return s;
}
2.1、@MessageMapping 指定目的地是“/app/marco”(“/app”前缀是隐含的,因为我们将其配置为应用的目的地前缀)。
2.2、方法接收一个Shout参数,因为Spring的某一个消息转换器会将STOMP消息的负载转换为Shout对象。Spring 4.0提供了几个消息转换器,作为其消息API的一部分
2.3、尤其注意,这个处理器方法有一个返回值,这个返回值并不是返回给客户端的,而是转发给消息代理的,如果客户端想要这个返回值的话,只能从消息代理订阅。@SendTo 注解重写了消息代理的目的地,如果不指定@SendTo,帧所发往的目的地会与触发处理器方法的目的地相同,只不过会添加上“/topic”前缀。
2.4、如果客户端就是想要服务端直接返回消息呢?听起来不就是HTTP做的事情!即使这样,STOMP 仍然为这种一次性的响应提供了支持,用的是@SubscribeMapping注解,与HTTP不同的是,这种请求-响应模式是异步的…
@SubscribeMapping("/getShout")
public Shout getShout(){
Shout shout = new Shout();
shout.setMessage("Hello STOMP");
return shout;
}
3、发送消息到客户端
3.1 在处理消息之后发送消息
正如前面看到的那样,使用 @MessageMapping 或者 @SubscribeMapping 注解可以处理客户端发送过来的消息,并选择方法是否有返回值。
如果 @MessageMapping 注解的控制器方法有返回值的话,返回值会被发送到消息代理,只不过会添加上”/topic”前缀。可以使用@SendTo 重写消息目的地;
如果 @SubscribeMapping 注解的控制器方法有返回值的话,返回值会直接发送到客户端,不经过代理。如果加上@SendTo 注解的话,则要经过消息代理。
3.2 在应用的任意地方发送消息
spring-websocket 定义了一个 SimpMessageSendingOperations
接口(或者使用SimpMessagingTemplate
),可以实现自由的向任意目的地发送消息,并且订阅此目的地的所有用户都能收到消息。
@Autowired
private SimpMessageSendingOperations simpMessageSendingOperations;
/**
* 广播消息,不指定用户,所有订阅此的用户都能收到消息
* @param shout
*/
@MessageMapping("/broadcastShout")
public void broadcast(Shout shout) {
simpMessageSendingOperations.convertAndSend("/topic/shouts", shout);
}
3.3 为指定用户发送消息
3.2介绍了如何广播消息,订阅目的地的所有用户都能收到消息。如果消息只想发送给特定的用户呢?spring-websocket 介绍了两种方式来实现这种功能,一种是 基于@SendToUser注解和Principal参数,一种是SimpMessageSendingOperations 接口的convertAndSendToUser方法。
- 基于@SendToUser注解和Principal参数
@SendToUser 表示要将消息发送给指定的用户,会自动在消息目的地前补上”/user”前缀。如下,最后消息会被发布在 /user/queue/notifications-username。但是问题来了,这个username是怎么来的呢?就是通过 principal 参数来获得的。那么,principal 参数又是怎么来的呢?需要在spring-websocket 的配置类中重写 configureClientInboundChannel 方法,添加上用户的认证。
@MessageMapping("/shout")
@SendToUser("/queue/notifications")
public Shout userStomp(Principal principal, Shout shout) {
String name = principal.getName();
String message = shout.getMessage();
LOGGER.info("认证的名字是:{},收到的消息是:{}", name, message);
return shout;
}
- convertAndSendToUser方法
除了convertAndSend()
以外,SimpMessageSendingOperations
还提供了convertAndSendToUser()
方法。按照名字就可以判断出来,convertAndSendToUser()
方法能够让我们给特定用户发送消息。
@MessageMapping("/singleShout")
public void singleUser(Shout shout, StompHeaderAccessor stompHeaderAccessor) {
String message = shout.getMessage();
LOGGER.info("接收到消息:" + message);
Principal user = stompHeaderAccessor.getUser();
simpMessageSendingOperations.convertAndSendToUser(user.getName(), "/queue/shouts", shout);
}
如上,这里虽然我还是用了认证的信息得到用户名。但是,其实大可不必这样,因为 convertAndSendToUser 方法可以指定要发送给哪个用户。也就是说,完全可以把用户名的当作一个参数传递给控制器方法,从而绕过身份认证!convertAndSendToUser 方法最终会把消息发送到 /user/sername/queue/shouts 目的地上。
4、处理消息异常
在处理消息的时候,有可能会出错并抛出异常。因为STOMP消息异步的特点,发送者可能永远也不会知道出现了错误。@MessageExceptionHandler标注的方法能够处理消息方法中所抛出的异常。我们可以把错误发送给用户特定的目的地上,然后用户从该目的地上订阅消息,从而用户就能知道自己出现了什么错误啦…
@MessageExceptionHandler(Exception.class)
@SendToUser("/queue/errors")
public Exception handleExceptions(Exception t){
t.printStackTrace();
return t;
}