一、websocket介绍
WebSocket是一种在单个TCP连接上进行全双工通信的协议。WebSocket通信协议于2011年被IETF定为标准RFC
6455,并由RFC7936补充规范。WebSocket API也被W3C定为标准。
WebSocket使得客户端和服务器之间的数据交换变得更加简单,允许服务端主动向客户端推送数据。在WebSocket
API中,浏览器和服务器只需要完成一次握手,两者之间就直接可以创建持久性的连接,并进行双向数据传输。
二、项目场景
项目场景是每个系统用户有多条流水线项目,每条流水线可以进行构建操作,后端模拟构建接口,实时修改当前构建流水线的状态,由初始化 -》运行中-》成功或失败,前端能够实时展示流水线状态。一般网站实现该功能都是采用轮询的方式,在一定的时间间隔内频繁获取流水线列表已此来达到"实时展示"的效果。
很多网站为了实现推送技术,所用的技术都是轮询。轮询是在特定的的时间间隔(如每1秒),由浏览器对服务器发出HTTP请求,然后由服务器返回最新的数据给客户端的浏览器。这种传统的模式带来很明显的缺点,即浏览器需要不断的向服务器发出请求,然而HTTP请求可能包含较长的头部,其中真正有效的数据可能只是很小的一部分,显然这样会浪费很多的带宽等资源。
三、项目结构
1、后端依赖
<!-- WebSocket -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
</dependency>
2、后端核心代码
客户端连接服务端时通过当前登录用户的ID和一个随机字符串生成唯一的窗口标识符,服务端websocket server通过该唯一标识维护一个标识-session的会话map,之后的消息分发都通过该map实现
BuildWebsocketServer.java【websocket服务端】
package com.swust.java.socket;
import org.springframework.stereotype.Component;
import javax.websocket.*;
import javax.websocket.server.PathParam;
import javax.websocket.server.ServerEndpoint;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.CopyOnWriteArraySet;
/**
* @Author hqf
* @Date Created in 2022/8/18 9:12
* @Description 模拟构建时流水线状态变化
* windowTag -> UserId + '-' + 'randomStr'
*/
@Component
@ServerEndpoint("/build/ws/{windowTag}")
public class BuildWebsocketServer {
//与某个客户端的连接会话,需要通过它来给客户端发送数据
private Session session;
//以CopyOnWrite开头的集合类采用复制底层数组的方式来实现写操作。当线程对此类集合执行读取操作时,线程将会直接读取集合本身,无须加锁与阻塞。
// 当线程对此类集合执行写入操作时,集合会在底层复制一份新的数组,接下来对新的数组执行写入操作。由于对集合的写入操作都是对数组的副本执行操作,因此它是线程安全的。
//concurrent包的线程安全Set,用来存放每个客户端对应的BuildWebsocketServer对象。
//虽然@Component默认是单例模式的,但springboot还是会为每个websocket连接初始化一个bean,所以可以用一个静态set保存起来。
// 注:底下WebSocket是当前类名
private static CopyOnWriteArraySet<BuildWebsocketServer> webSockets =new CopyOnWriteArraySet<>();
// 用来存在线连接数
private static Map<String, Session> sessionPool = new HashMap<>();
/**
* 链接成功调用的方法
*/
@OnOpen
public void onOpen(Session session, @PathParam(value="windowTag") String windowTag) {
try {
this.session = session;
webSockets.add(this);
sessionPool.put(windowTag, session);
System.out.println("【websocket消息】" + windowTag + "建立连接,总数为:" + webSockets.size());
} catch (Exception e) {
}
}
/**
* 链接关闭调用的方法
*/
@OnClose
public void onClose() {
try {
webSockets.remove(this);
System.out.println("【websocket消息】连接断开,总数为:" + webSockets.size());
} catch (Exception e) {
}
}
/**
* 收到客户端消息后调用的方法
*
* @param message
*/
@OnMessage
public void onMessage(String message) {
System.out.println("【websocket消息】收到客户端消息:"+message);
}
/** 发送错误时的处理
* @param session
* @param error
*/
@OnError
public void onError(Session session, Throwable error) {
System.out.println("【websocket消息】发送错误:"+error.getMessage());
error.printStackTrace();
}
// 广播消息
public void sendAllMessage(String message) {
System.out.println("【websocket消息】广播消息:"+message);
for(BuildWebsocketServer webSocket : webSockets) {
try {
if(webSocket.session.isOpen()) {
webSocket.session.getAsyncRemote().sendText(message);
}
} catch (Exception e) {
e.printStackTrace();
}
}
}
// 单点消息
public void sendOneMessage(String windowTag, String message) {
Session session = sessionPool.get(windowTag);
if (session != null && session.isOpen()) {
try {
System.out.println("【websocket消息】向" + windowTag + "推送消息:"+message);
session.getAsyncRemote().sendText(message);
} catch (Exception e) {
e.printStackTrace();
}
}
}
// 多点消息
public void sendMulMessage(List<String> userIds, String message) {
for (String windowTag : sessionPool.keySet()) {
String idStr = windowTag.split("-")[0];
// 通过解析windowTag 得到用户ID 然后根据用户ID判断是否需要推送当前流程的构建状态信息
if(userIds.contains(idStr)){
sendOneMessage(windowTag,message);
}
}
}
}
后端用户数据和用户流水线数据都是通过mock数据
MockCenter.java【Mock数据】
package com.swust.java.util;
import com.swust.java.entity.Pipeline;
import com.swust.java.entity.UserInfo;
import org.springframework.context.annotation.Bean;
import org.springframework.stereotype.Component;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* @Author hqf
* @Date Created in 2022/8/19 10:57
* @Description
*/
@Component
public class MockCenter {
// mock用户数据
@Bean
public Map<String, UserInfo> mockUser(){
Map<String,UserInfo> map = new HashMap<>();
map.put("zs",new UserInfo(1,"zs","123"));
map.put("ls",new UserInfo(2,"ls","123"));
map.put("ww",new UserInfo(3,"ww","123"));
return map;
}
// mock用户-流水线关系
@Bean
public Map<String, List<Pipeline>> mockPipeline(){
Pipeline p1 = new Pipeline(34,"标模测试",6);
Pipeline p2 = new Pipeline(66,"测试环境变量",7);
Pipeline p3 = new Pipeline(76,"单元测试",6);
Pipeline p4 = new Pipeline(93,"集成测试",7);
Pipeline p5 = new Pipeline(147,"界面测试",6);
Map<String, List<Pipeline>> map = new HashMap<>();
List<Pipeline> zsL = new ArrayList<>();
zsL.add(p1);
zsL.add(p2);
zsL.add(p3);
List<Pipeline> lsL = new ArrayList<>();
lsL.add(p2);
lsL.add(p3);
lsL.add(p4);
List<Pipeline> wwL = new ArrayList<>();
wwL.add(p3);
wwL.add(p4);
wwL.add(p5);
map.put("zs",zsL);
map.put("ls",lsL);
map.put("ww",wwL);
return map;
}
}
当用户对流水线开始构建时通过流水线ID查询该流水线参与的用户ID列表,然后根据该ID列表进行消息分发。之后通过模拟修改流水线状态来进行消息分发
BuildController.java【构建Controller】
package com.swust.java.controller;
import com.alibaba.fastjson.JSONObject;
import com.swust.java.entity.Pipeline;
import com.swust.java.entity.UserInfo;
import com.swust.java.socket.BuildWebsocketServer;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
/**
* @Author hqf
* @Date Created in 2022/8/18 8:48
* @Description
*/
@RestController
@RequestMapping("/build")
public class BuildController {
@Autowired
private BuildWebsocketServer websocketServer;
@Autowired
private Map<String, UserInfo> mockUser;
@Autowired
private Map<String, List<Pipeline>> mockPipeline;
/**
* 开始构建
* @param pipelineId 流水线ID
* @return
*/
@GetMapping("/{pipelineId}")
public void startBuild(@PathVariable("pipelineId") int pipelineId){
String message = String.format("流水线【%s】开始构建",pipelineId);
System.out.println(message);
// 查询该流程参与的用户ID列表 只给对应用户推送数据
List<String> ids = new ArrayList<>();
for (String key : mockPipeline.keySet()) {
List<Pipeline> pipelines = mockPipeline.get(key);
List<Integer> pipelineIds = new ArrayList<>();
pipelines.forEach(pipeline -> {
pipelineIds.add(pipeline.getId());
});
if(pipelineIds.contains(pipelineId)){
UserInfo userInfo = mockUser.get(key);
ids.add(String.valueOf(userInfo.getId()));
}
}
System.out.println(String.format("流水线【%s】参与用户:%s",pipelineId,ids));
// 初始化 -> 运行中 -> 成功或失败
try {
// 1、初始化
websocketServer.sendMulMessage(ids,objStr(pipelineId,1));
Thread.sleep(3 * 1000);
// 2、运行中
websocketServer.sendMulMessage(ids,objStr(pipelineId,2));
Thread.sleep(5 * 1000);
// 3、成功或失败
websocketServer.sendMulMessage(ids,Math.random() * 10 > 5 ? objStr(pipelineId,6) : objStr(pipelineId,7));
} catch (InterruptedException e) {
e.printStackTrace();
throw new RuntimeException("构建失败");
}
}
private String objStr(int pipelineId,int status){
JSONObject obj = new JSONObject();
obj.put("pipelineId", pipelineId); //流水线ID
obj.put("status", status);
return obj.toJSONString();
}
}
3、前端核心代码
<!DOCTYPE HTML>
<html>
<head>
<title>流水线列表 - websocket</title>
<link rel="stylesheet" href="./style.css">
</head>
<body>
<div class="login-box">
用户名:<input type="text" name="username">
密码:<input type="password" name="password">
<button onclick="login()">登录</button>
</div>
<div class="user-info"></div>
<ul class="pipeline-list"></ul>
</body>
<script src="./jquery-3.4.1.js"></script>
<script src="./index.js"></script>
<script type="text/javascript">
// 流水线状态映射map
const statusMap = {
1: "初始化",
2: "运行中",
3: "队列中",
4: "取消",
5: "停止",
6: "成功",
7: "失败",
8: "超时"
}
// 后端服务地址
const host = "localhost:8085";
// 当前登录用户
var user = {};
// websocket连接标识
var monitorOpen = false;
</script>
</html>
用户登录后清空用户登录表单并渲染用户所拥有的流水线列表
// 点击登录按钮
function login(){
let loginBoxDom = $(".login-box");
const username = loginBoxDom.find("[name='username']").val();
const password = loginBoxDom.find("[name='password']").val();
$.post({
url:`http://${host}/login`,
dataType:'json',
data: {username,password},
success: function(res) {
if(res.status == 1){ // 登录成功
loginBoxDom.remove();
loginSuccess(res);
}else{
alert(res.data);
}
}
});
}
// 登录成功
function loginSuccess(res){
user = res.data.user;
// 渲染用户信息
$(".user-info").text(`当前登录用户:${user.username}`)
// 根据结果渲染流水线列表
let pipelineList = res.data.pipelines;
// 窗口标识
const windowTag = `${user.id}-${Math.random().toString(36).substr(2)}`;
console.log("当前窗口标识",windowTag);
renderList(pipelineList);
statusMonitor(pipelineList,windowTag);
}
// 渲染页面数据
function renderList(pipelineList){
var dom = $(".pipeline-list");
dom.empty();
pipelineList.forEach(item => {
var template = $(`<li>
${item.name}
【状态:<span class="font-color-${item.status}">${statusMap[item.status]}</span>】
<button οnclick="startBuild(${item.id})">${item.status < 4 ? "构建中" : "开始构建"}</button>
</li>`);
dom.append(template);
})
}
// 建立流水线状态监听的连接
function statusMonitor(pipelineList,windowTag){
// 监听流水线状态
var websocket = null;
if ('WebSocket' in window) {
websocket = new WebSocket(`ws://${host}/build/ws/${windowTag}`);
console.log("尝试建立连接...");
} else {
alert('Not support websocket')
}
//连接发生错误的回调方法
websocket.onerror = function() {
alert("流水线状态监听器连接失败")
};
//连接成功建立的回调方法
websocket.onopen = function(event) {
console.log(`流水线状态监听器连接成功`);
monitorOpen = true;
}
//监听窗口关闭事件,当窗口关闭时,主动去关闭websocket连接,防止连接还没断开就关闭窗口,server端会抛异常。
window.onbeforeunload = function() {
websocket.close();
}
//关闭连接
function closeWebSocket() {
websocket.close();
}
//接收到消息的回调方法
websocket.onmessage = function(event) {
const data = JSON.parse(event.data);
console.log(`流水线状态监听器接收到的消息:`,data);
pipelineList.forEach(item => {
if(item.id == data.pipelineId){
item.status = data.status;
}
})
// 重新渲染页面
renderList(pipelineList);
}
}
// 点击开始构建按钮
function startBuild(pipelineId){
if(monitorOpen){
// 开始构建
$.ajax({
url:`http://${host}/build/${pipelineId}`,
dataType:'json',
type:'get'
});
}else{
console.log("流水线状态监听器连接中...");
}
}
四、项目演示
登录3个不同的用户,分别为zs、ls和ww并根据用户ID生成对应的窗口标识码
与服务端建立连接
WebsocketDemo
根据流水线参与的用户进行消息分发