知乎上一条高赞回答清晰直白的介绍了websocket和http的区别以及原理,下面提炼出重点:
- Http协议本身有1.0和1.1(1.0中HTTP的生命周期是每一个Request一个Response;1.1改进有了一个keep-alive,可以一次发多个Request,收多个Response)
- Http是被动的,不能主动发起
- Websocket是基于HTTP协议的,或者说借用了HTTP的协议来完成一部分握手
HTTP Request
GET /chat HTTP/1.1
Host: server.example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: x3JJHMbDL1EzLkh9GBhXDw==
Sec-WebSocket-Protocol: chat, superchat
Sec-WebSocket-Version: 13
Origin: http://example.com
HTTP Response
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: HSmrc0sMlYUkAGmm5OPpG2HaGWk=
Sec-WebSocket-Protocol: chat
如此连接就建立了,当服务器完成协议升级后(HTTP->Websocket),服务端就可以主动推送信息给客户端啦。
STOMP
STOMP是一个用于C/S之间进行异步消息传输的简单文本协议, 全称是Simple Text Oriented Messaging Protocol。它其实是消息队列的一种协议,因为简单恰巧可以用于定义websocket的消息体格式。
一个STOMP帧由三部分组成:命令,Header(头信息),Body(消息体):
- 命令使用UTF-8编码格式,命令有SEND、SUBSCRIBE、MESSAGE、CONNECT、CONNECTED等。
- Header也使用UTF-8编码格式,它类似HTTP的Header,有content-length,content-type等。
- Body可以是二进制也可以是文本。注意Body与Header间通过一个空行(EOL)来分隔。
Springboot 构建基于STOMP的websocket广播式通信
首先,生产者通过发送一条SEND命令消息到某个目的地址(destination),服务端request channel接受到这条SEND命令消息,如果目的地址是应用目的地址则转到相应的由应用自己写的业务方法做处理(对应图中的SimpAnnotationMethod),再转到broker(SimpleBroker)。如果目的地址是非应用目的地址则直接转到broker。broker通过SEND命令消息来构建MESSAGE命令消息, 再通过response channel推送MESSAGE命令消息到所有订阅此目的地址的消费者。
项目代码实践
现在有个需求是后端收到设备上报的数据后将变化数据推送到前端。
配置类:启动websocket服务
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Configuration;
import org.springframework.messaging.simp.config.MessageBrokerRegistry;
import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler;
import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker;
import org.springframework.web.socket.config.annotation.StompEndpointRegistry;
import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer;
import javax.annotation.Resource;
@Configuration
@EnableWebSocketMessageBroker // 使能WebSocket的broker.即使用broker来处理消息.
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
/**
* 服务端两次心跳时间间隔的最小毫秒数
*/
@Value("${websocket.heartbeat.heartbeat-interval-server}")
private long heartbeatIntervalServer;
/**
* 服务端接收客户端两次心跳的时间间隔
*/
@Value("${websocket.heartbeat.heartbeat-interval-client}")
private long heartbeatIntervalClient;
@Resource
private HandShakeInterceptor handShakeInterceptor;
@Override
public void configureMessageBroker(MessageBrokerRegistry config) {
long[] heartbeat = {heartbeatIntervalServer, heartbeatIntervalClient};
ThreadPoolTaskScheduler te = new ThreadPoolTaskScheduler();
te.setPoolSize(1);
te.setThreadNamePrefix("wss-heartbeat-thread-");
te.initialize();
config.enableSimpleBroker("/ws").setHeartbeatValue(heartbeat).setTaskScheduler(te); // 启用SimpleBroker,客户端可以订阅以"/ws"前缀的topic
config.setApplicationDestinationPrefixes("/app"); //将"app"前缀绑定到MessageMapping注解指定的方法上。如"app/hello"被指定用greeting()方法来处理.
}
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
registry.addEndpoint("/api/v1/ws") // 客户端连接的地址
.addInterceptors(handShakeInterceptor) // 在websocket连接时拦截请求,验证token
.setAllowedOrigins("*"); // 不限制同源,否则只有同域名或同ip才能连接
}
}
拦截器类:验证连接者的身份
websocket基于http进行握手,可以在握手前拦截或握手后拦截。
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.server.ServerHttpRequest;
import org.springframework.http.server.ServerHttpResponse;
import org.springframework.lang.Nullable;
import org.springframework.stereotype.Component;
import org.springframework.web.socket.WebSocketHandler;
import org.springframework.web.socket.server.HandshakeInterceptor;
import java.util.Map;
@Slf4j
@Component
public class HandShakeInterceptor implements HandshakeInterceptor {
@Override
public boolean beforeHandshake(ServerHttpRequest serverHttpRequest, ServerHttpResponse serverHttpResponse,
WebSocketHandler webSocketHandler, Map<String, Object> attributes) throws Exception {
String authorization;
authorization = serverHttpRequest.getURI().getQuery(); // token可以放cookie里,但也要注意同源问题,也可以放在自协议中,这里是放在url上。
// 进行token验证逻辑......
}
@Override
public void afterHandshake(ServerHttpRequest serverHttpRequest, ServerHttpResponse serverHttpResponse, WebSocketHandler webSocketHandler, @Nullable Exception e) {
}
}
监听器类:针对多实例,同一个topic会有多次订阅,只要有1个订阅,服务端就要往topic里推送消息。思路就是以一个ConcurrentHashMap 记录订阅数且保证线程安全,监听到订阅就+1, 取消订阅就-1。以一个map记录session和topic的对应值,因为有些取消订阅请求拿不到topic,就可以通过session去查找。
(此代码不完善,仅供参考)
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.ApplicationListener;
import org.springframework.messaging.simp.stomp.StompHeaderAccessor;
import org.springframework.stereotype.Component;
import org.springframework.web.socket.messaging.AbstractSubProtocolEvent;
@Slf4j
@Component
public class STOMPSubscribeEventListener implements ApplicationListener<AbstractSubProtocolEvent> {
public static final ConcurrentHashMap SUBSCRIBE_COUNT = new ConcurrentHashMap<>();
public static final Map SUBSCRIBE_SESSION = new HashMap<>();
@Override
public void onApplicationEvent(AbstractSubProtocolEvent abstractSubProtocolEvent) {
StompHeaderAccessor sha = StompHeaderAccessor.wrap(abstractSubProtocolEvent.getMessage());
// 判断客户端的行为
if (null != sha.getCommand()) {
switch (sha.getCommand()) {
case SUBSCRIBE:
String subscribeTopic = sha.getDestination();
// 存储session,方便取消订阅时拿到对应topic
String subscribeSessionId = sha.getSessionId();
Constant.SUBSCRIBE_SESSION.put(sessionId, topic);
int increseCount = null != Constant.ATTRIBUTE_SUBSCRIBE_COUNT.get(deviceId) ? (int) Constant.ATTRIBUTE_SUBSCRIBE_COUNT.get(deviceId) : 0;
Constant.ATTRIBUTE_SUBSCRIBE_COUNT.put(deviceId, ++increseCount);
break;
case UNSUBSCRIBE:
String unsubscribeSessionId = sha.getSessionId();
// 根据session获取对应topic
String unsubscribeTopic = devicePush.getSubscribeSession(unsubscribeSessionId);
int decreaseCount = (int) Constant.ATTRIBUTE_SUBSCRIBE_COUNT.get(deviceId);
if (--decreaseCount > 0) {
Constant.ATTRIBUTE_SUBSCRIBE_COUNT.put(deviceId, --decreaseCount);
} else {
Constant.ATTRIBUTE_SUBSCRIBE_COUNT.remove(deviceId);
}
break;
default:
break;
}
}
}
服务类:实现服务端推送数据。
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.messaging.simp.SimpMessageSendingOperations;
import org.springframework.stereotype.Component;
@Slf4j
@Component
public class WebSocketServiceImpl implements WebSocketService {
@Autowired
private SimpMessageSendingOperations simpMessageSendingOperations;
@Override
public void sendMessage(Long deviceId, String type, String payload) {
// 向topic"/ws/" + deviceId + "/" + type 发送payload
simpMessageSendingOperations.convertAndSend("/ws/" + deviceId + "/" + type, payload);
}
}
sengMessage到/ws开头的topic,客户端能直接收到。若是想再进行一些处理,则可以send到/app/deal,就会进入对应的控制器进行处理,这一部分和配置类相关。
控制器类
import org.springframework.messaging.handler.annotation.MessageMapping;
import org.springframework.messaging.handler.annotation.SendTo;
import org.springframework.stereotype.Controller;
import org.springframework.web.util.HtmlUtils;
@Controller // 标识控制器类
public class GreetingController {
@MessageMapping("/deal") // 标识所有发送到“/app/hello”这个destination的消息,都会被路由到这个方法进行处理.
@SendTo("/ws/xxxxx/xxxxx") // 标识这个方法返回的结果,都会被发送到它指定的destination,“/ws/xxxxx/xxxxx”.
// 传入的参数payload为调用者发送过来的消息,是自动绑定的。
public String greeting(String payload) throws Exception {
// 进行处理返回信息
return "";
}
}
下面写一个前端JS来测试连接
app.js
var stompClient = null;
function setConnected(connected) {
$("#connect").prop("disabled", connected);
$("#disconnect").prop("disabled", !connected);
if (connected) {
$("#conversation").show();
}
else {
$("#conversation").hide();
}
$("#greetings").html("");
}
function connect() {
try {
var token='eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJvcmdhbml6YXRpb25JZCI6MzY2OTI4MDQ3MDQxMDI0MDAwLCJ0ZW5hbnRJZCI6MzY2OTI4MDQ3MDQxMDI0MDAwLCJleHAiOjE1NzI5NDY2OTksInN1YmplY3RJZCI6MzY3MzU4MzUxMTc0MTQ0MDA0fQ.DNsI0H7Qjx1_eo8rByO7JtJcvHZysaBMLdyedxmbc5E';
var socket = new WebSocket('ws://localhost:8085/api/v1/ws', [token]); // 此方法是将token放于自协议中,与上文拦截器不匹配,可以在配置时不启用拦截器或自行修改拦截器。
stompClient = Stomp.over(socket);
stompClient.connect({}, function (frame) {
setConnected(true);
console.log('Connected: ' + frame);
stompClient.subscribe('/ws/375970829571330048/attributes');
});
} catch (e) {
console.log(e);
}
}
function disconnect() {
stompClient.unsubscribe('/ws/375970829571330048/attributes');
if (stompClient !== null) {
stompClient.disconnect();
}
setConnected(false);
console.log("Disconnected");
}
$(function () {
$("form").on('submit', function (e) {
e.preventDefault();
});
$( "#connect" ).click(function() { connect(); });
$( "#disconnect" ).click(function() { disconnect(); });
});
index.html
<!DOCTYPE html>
<html>
<head>
<title>Hello WebSocket</title>
<link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/css/bootstrap.min.css" integrity="sha384-ggOyR0iXCbMQv3Xipma34MD+dH/1fQ784/j6cY/iJTQUOhcWr7x9JvoRxT2MZw1T" crossorigin="anonymous">
<link href="main.css" rel="stylesheet">
<script src="https://cdn.bootcss.com/jquery/3.4.1/jquery.min.js"></script>
<script src="https://cdn.bootcss.com/sockjs-client/1.4.0/sockjs.min.js"></script>
<script src="https://cdn.bootcss.com/stomp.js/2.3.3/stomp.min.js"></script>
<script src="app.js"></script>
</head>
<body>
<noscript><h2 style="color: #ff0000">Seems your browser doesn't support Javascript! Websocket relies on Javascript being
enabled. Please enable
Javascript and reload this page!</h2></noscript>
<div id="main-content" class="container">
<div class="row">
<div class="col-md-6">
<form class="form-inline">
<div class="form-group">
<label for="connect">WebSocket connection:</label>
<button id="connect" class="btn btn-default" type="submit">Connect</button>
<button id="disconnect" class="btn btn-default" type="submit" disabled="disabled">Disconnect
</button>
</div>
</form>
</div>
<div class="col-md-6">
<form class="form-inline">
<div class="form-group">
<label for="name">What is your name?</label>
<input type="text" id="name" class="form-control" placeholder="Your name here...">
</div>
<button id="send" class="btn btn-default" type="submit">Send</button>
</form>
</div>
</div>
<div class="row">
<div class="col-md-12">
<table id="conversation" class="table table-striped">
<thead>
<tr>
<th>Greetings</th>
</tr>
</thead>
<tbody id="greetings">
</tbody>
</table>
</div>
</div>
</div>
</body>
</html>
main.css
body {
background-color: #f5f5f5;
}
#main-content {
max-width: 940px;
padding: 2em 3em;
margin: 0 auto 20px;
background-color: #fff;
border: 1px solid #e5e5e5;
-webkit-border-radius: 5px;
-moz-border-radius: 5px;
border-radius: 5px;
}