SpringBoot整合WebSocket实战

本文介绍了WebSocket协议,它能建立客户端与服务器的全双工通信。详细阐述了WebSocket的四种实现方式,包括原生jdk注解、SpringBoot封装、TIO和STOMP,还提供了在线接口测试工具。此外,讲解了基于HTTP协议的单工消息推送SSE,涉及请求头、数据格式、工具类等内容,以及nginx配置要点。

WebSocket是一种在TCP连接上进行全双工通信的协议,建立客户端和服务器之间的通信渠道。浏览器和服务器仅需一次握手,两者之间就直接可以创建持久性的连接,并进行双向数据传输。

 

 

WebSocket的实现方式有很多所以网上的文章都有些不一样,推荐使用第一种和第二种

方式一:原生jdk注解

 1.pom先导入依赖

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-websocket</artifactId>
        </dependency>

2.创建WebSocket配置类,通过这个配置才能去扫描WebSocket的注解

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.socket.server.standard.ServerEndpointExporter;

/**
 * @Description WebSocket配置类
 */
@Configuration
public class WebSocketConfig {
    /**
     * 	注入ServerEndpointExporter,
     * 	这个bean会自动注册使用了@ServerEndpoint注解声明的Websocket endpoint
     */
    @Bean
    public ServerEndpointExporter serverEndpointExporter() {
        return new ServerEndpointExporter();
    }
}

 

 3.创建WebSocket服务

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CopyOnWriteArraySet;
import javax.websocket.*;
import javax.websocket.server.PathParam;
import javax.websocket.server.ServerEndpoint;

import org.springframework.stereotype.Component;

/**
 * @Description WebSocket服务
 */

@Component
@ServerEndpoint("/websocket/{userId}")  // 接口路径 ws://localhost:8080/websocket/1
public class WebSocket {

    private static final Logger log = LoggerFactory.getLogger(WebSocket.class);

    //与某个客户端的连接会话,需要通过它来给客户端发送数据
    private Session session;

    /**
     * 用户ID
     */
    private String userId;

    //concurrent包的线程安全Set,用来存放每个客户端对应的MyWebSocket对象。
    //虽然@Component默认是单例模式的,但springboot还是会为每个websocket连接初始化一个bean,所以可以用一个静态set保存起来。
    //  注:底下WebSocket是当前类名
    private static final CopyOnWriteArraySet<WebSocket> webSockets = new CopyOnWriteArraySet<>();
    // 用来存在线连接用户信息
    private static final ConcurrentHashMap<String, Session> sessionPool = new ConcurrentHashMap<String, Session>();

    /**
     * 链接成功调用的方法
     */
    @OnOpen
    public void onOpen(Session session, @PathParam(value = "userId") String userId) throws InterruptedException {
        try {
            this.session = session;
            this.userId = userId;
            webSockets.add(this);
            sessionPool.put(userId, session);
            log.info(session.getId());
            log.info("【websocket消息】有新的连接,总数为:" + webSockets.size());
        } catch (Exception e) {
            e.printStackTrace();
        }
        //响应消息
        session.getAsyncRemote().sendText("连接成功");

        }

    }

    /**
     * 链接关闭调用的方法
     */
    @OnClose
    public void onClose() {
        try {
            webSockets.remove(this);
            sessionPool.remove(this.userId);
            log.info("【websocket消息】连接断开,总数为:" + webSockets.size());
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

    /**
     * 收到客户端消息后调用的方法
     *
     * @param message
     */
    @OnMessage
    public void onMessage(String message) {
        log.info("【websocket消息】收到客户端消息:" + message);
    }

    /**
     * 发送错误时的处理
     *
     * @param session
     * @param error
     */
    @OnError
    public void onError(Session session, Throwable error) {
        log.error("用户错误,原因:" + error.getMessage());
        error.printStackTrace();
    }


    // 此为广播消息
    public void sendAllMessage(String message) {
        log.info("【websocket消息】广播消息:" + message);
        for (WebSocket webSocket : webSockets) {
            try {
                if (webSocket.session.isOpen()) {
                    webSocket.session.getAsyncRemote().sendText(message);
                }
            } catch (Exception e) {
                e.printStackTrace();
            }
        }
    }

    // 此为单点消息
    public void sendOneMessage(String userId, String message) {
        Session session = sessionPool.get(userId);
        if (session != null && session.isOpen()) {
            try {
                log.info("【websocket消息】 单点消息:" + message);
                session.getAsyncRemote().sendText(message);
            } catch (Exception e) {
                e.printStackTrace();
            }
        }
    }

    // 此为单点消息(多人)
    public void sendMoreMessage(String[] userIds, String message) {
        for (String userId : userIds) {
            Session session = sessionPool.get(userId);
            if (session != null && session.isOpen()) {
                try {
                    log.info("【websocket消息】 单点消息:" + message);
                    session.getAsyncRemote().sendText(message);
                } catch (Exception e) {
                    e.printStackTrace();
                }
            }
        }

    }


    // 调用 :
    //@Resource
    //private WebSocket webSocket;
    public void sendTest() {
        //创建业务消息信息
        JSONObject obj = new JSONObject();
        obj.put("cmd", "topic");//业务类型
        obj.put("msgId", "messageid");//消息id
        obj.put("msgTxt", "内容");//消息内容
        //全体发送
        sendAllMessage(obj.toJSONString());
        //单个用户发送 (userId为用户id)
        sendOneMessage(userId, obj.toJSONString());
        //多个用户发送 (userIds为多个用户id,逗号‘,’分隔)
        sendMoreMessage(new String[]{"1"}, obj.toJSONString());

    }

}

 前端调用接口路径 ws://localhost:8080/websocket/1

 方式二:SpringBoot封装

1.在项目的pom.xml文件中,添加以下依赖:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-websocket</artifactId>
</dependency>

2.创建一个WebSocket配置类,用于配置WebSocket相关的拦截路径参数和处理器。

@Configuration
@EnableWebSocket //启用WebSocket支持
public class WebSocketConfig implements WebSocketConfigurer {
 
    @Override
    public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
        //auth用于鉴权处理 openid用户的id
registry.addHandler(webSocketHandler(),"/netgate/{auth}/{openid}") //注册Handler
           .addInterceptors(new WebSocketHandshakeInterceptor())  //握手过滤器注册Interceptor
           .setAllowedOrigins("*"); //关闭跨域校验

        //注册SockJS,提供SockJS支持(主要是兼容ie8) 
        registry.addHandler(myHandler(), "/my-websocket-url") //注册Handler
        .withSockJS(); //支持sockjs协议
    }

    @Bean
    public ServletServerContainerFactoryBean createWebSocketContainer() {
        ServletServerContainerFactoryBean container = new                     
        ServletServerContainerFactoryBean();
        container.setMaxTextMessageBufferSize(2*1024*1024);//8192*1024 1024*1024*1024
        container.setMaxBinaryMessageBufferSize(2*1024*1024);
        container.setAsyncSendTimeout(55000l);
        container.setMaxSessionIdleTimeout(55000l);//心跳
        return container;
    }

    @Bean
    public TextWebSocketHandler webSocketHandler() {
        return new NetgateHandler();
    }
 
    @Bean
    public WebSocketHandler myHandler() {
        return new MyWebSocketHandler();
    }
 
}

 3.Websocket握手过滤器

public class WebSocketHandshakeInterceptor implements HandshakeInterceptor {
    private final static Logger LOGGER = LoggerFactory.getLogger(WebSocketHandshakeInterceptor.class);
 
    /**
     * 握手前
     */
    public boolean beforeHandshake(ServerHttpRequest request, ServerHttpResponse serverHttpResponse, WebSocketHandler webSocketHandler, Map<String, Object> attributes) throws Exception {
        if (request instanceof ServletServerHttpRequest) {
        	String path = request.getURI().getPath();
        	if(requestIsValid(path)){
        		String[] params = getParams(path);
        		attributes.put("WEBSOCKET_AUTH", params[0]);
        		attributes.put("WEBSOCKET_OPENID", params[1]);
        		attributes.put("WEBSOCKET_FIRSTONE","yes");
        	}
        }
        System.out.println("================Before Handshake================");
        return true;
    }
 
    /**
     * 握手后
     */
    public void afterHandshake(ServerHttpRequest serverHttpRequest, ServerHttpResponse serverHttpResponse, WebSocketHandler webSocketHandler, Exception e) {
    	System.out.println("================After Handshake================");
    	if(e!=null) e.printStackTrace();
    	System.out.println("================After Handshake================");
    }
    
    private boolean requestIsValid(String url){
        //在这里可以写上具体的鉴权逻辑
    	boolean isvalid = false;
    	if(StringUtils.isNotEmpty(url)
    			&& url.startsWith("/netgate/")
    			&& url.split("/").length==6){
    		isvalid = true;
    	}
    	return isvalid;
    }
    
    private String[] getParams(String url){
    	url = url.replace("/netgate/","");
    	return url.split("/");
    }
    
}

 4.创建一个WebSocket处理器类,用于处理WebSocket的连接、消息和事件,有两种接口实现方式。

TextWebSocketHandler:文本内容

BinaryWebSocketHandler:二进制内容

/**
 * Websocket处理器
 */
@Component
public class NetgateHandler extends TextWebSocketHandler {

	@Autowired
	private MqttGateway mqttGateway;
	
    
    /*
	 * 网关连接集合
	 * 第一级:设备序列号 sn
	 * 第二级:用户唯一标识 openid
	 */
	private static ConcurrentHashMap<String,ConcurrentHashMap<String,WebSocketSession>> netgates = new ConcurrentHashMap<String,ConcurrentHashMap<String,WebSocketSession>>();

    
    /**
     * 处理前端发送的文本信息
     * js调用websocket.send时候,会调用该方法
     * WebSocketSession代表每个客户端会话
     */
    @Override
    protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
    	if(!session.isOpen()) {
			System.out.println("连接已关闭,不再处理该连接的消息!");
			return;
		}
    	String mes = ObjectUtils.toString(message.getPayload(),"");
    	String pid = session.getAttributes().get("WEBSOCKET_PID").toString();
    	String sn = session.getAttributes().get("WEBSOCKET_SN").toString();
		if(message==null || "".equals(mes)){
			System.out.println(getSysDate()+"============接收到空消息,不予处理。");
		}else if(mes.length()==1){
			//心跳消息过滤掉
			return;
		}else {
			//转发成mqtt消息
			String topic = "pay/"+pid+"/server/"+sn;
			System.out.println(topic);
			System.out.println(getSysDate()+"============消息处理完成:"+mes);
			mqttGateway.sendToMqtt(topic,mes);
		}
    }
 
 
    /**
     * 当新连接建立的时候,被调用
     * 连接成功时候,会触发页面上onOpen方法
     *
     * @param session
     * @throws Exception
     */
    @Override
    public void afterConnectionEstablished(WebSocketSession session) throws Exception {
    	System.out.println(getSysDate()+"============正在初始化连接:"+session.getId());
        try {
            //初始化连接,把session存储起来
			this.initUsers(session);
		} catch (Exception e) {
			System.out.println(getSysDate()+"============初始化连接异常-开始:"+e.getMessage());
			e.printStackTrace();
			System.out.println(getSysDate()+"============初始化连接异常-结束:"+e.getMessage());
		}
        System.out.println(getSysDate()+"============初始化连接完成:"+session.getId());
    }
 
    /**
     * 当连接关闭时被调用
     *
     * @param session
     * @param status
     * @throws Exception
     */
    @Override
    public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception {
    	System.out.println(getSysDate()+"============正在关闭连接:"+session.getId()+",isOpen:"+session.isOpen()+";code:"+status.getCode());
    	try {
    		System.out.println("断开连接状态值"+status.getCode());
			this.removeSession(session);
		} catch (Exception e) {
			System.out.println(getSysDate()+"============关闭连接异常-开始:"+e.getMessage());
			e.printStackTrace();
			System.out.println(getSysDate()+"============关闭连接异常-结束:"+e.getMessage());
		}
    	System.out.println(getSysDate()+"============正在关闭完成:"+session.getId()+",isOpen:"+session.isOpen()+";code:"+status.getCode());
    }
 
    /**
     * 传输错误时调用
     *
     * @param session
     * @param exception
     * @throws Exception
     */
    @Override
    public void handleTransportError(WebSocketSession session, Throwable exception) throws Exception {
    	System.out.println(getSysDate()+"============发生传输错误:"+session.getId()+";session.isOpen():"+session.isOpen()+";exception:"+exception.getMessage());
    	exception.printStackTrace();
    	if (session.isOpen()) {
        	//try { session.close(); } catch (Exception e) {e.printStackTrace();}
        }else {
        	try {
    			this.removeSession(session);
    		} catch (Exception e) {
    			System.out.println(getSysDate()+"============传输错误处理异常-开始:"+e.getMessage());
    			e.printStackTrace();
    			System.out.println(getSysDate()+"============传输错误处理异常-结束:"+e.getMessage());
    		}
        }
    	System.out.println(getSysDate()+"============错误处理结束:"+session.getId()+";session.isOpen():"+session.isOpen()+";exception:"+exception.getMessage());
    }


	public synchronized void sendMsgToNetgateSn(String sn, String msg)  {
		if(netgates.size()>0 && netgates.containsKey(sn) && !netgates.get(sn).isEmpty()){
			//获取EID对应的后台管理连接 多个
			for (WebSocketSession ws: netgates.get(sn).values()){
				System.out.println("对网关指令开始发送啦:sn="+sn+"消息内容"+msg);
				try {ws.sendMessage(new TextMessage(msg));} catch (IOException e) {System.out.println(getSysDate()+"发生了异常:"+e.getMessage());e.printStackTrace();continue;}
			}
		}
	}
    
	//连接接入的处理方法
	private synchronized void initUsers(WebSocketSession session){
		String pid = (String) session.getAttributes().get("WEBSOCKET_PID");
		String sn = (String) session.getAttributes().get("WEBSOCKET_SN");
		String openid = (String) session.getAttributes().get("WEBSOCKET_OPENID");
		if(StringUtils.isNotEmpty(pid) && StringUtils.isNotEmpty(sn) && StringUtils.isNotEmpty(openid)){
			ConcurrentHashMap<String,WebSocketSession> netgate = netgates.get(sn);
			if(netgate == null){
				netgate = new ConcurrentHashMap<String,WebSocketSession>();
			}
			WebSocketSession session_exist = netgate.get(sn);
			if(session_exist!=null) {
				System.out.println("检测到相同SN重复连接,SN:"+sn+",连接ID:"+session_exist.getId()+",准备清理失效的连接。。。");
				try {session_exist.close();} catch (IOException e) {e.printStackTrace();}
			}
			netgate.putIfAbsent(openid, session);
			netgates.put(sn,netgate);
		}
	}
    
	//连接被关闭时处理集合
	private synchronized void removeSession(WebSocketSession session){
		String sn = (String) session.getAttributes().get("WEBSOCKET_SN");
		String openid = (String) session.getAttributes().get("WEBSOCKET_OPENID");
		if(netgates.get(sn).containsKey(openid)) {
			WebSocketSession exist_session = netgates.get(sn).get(openid);
			//确保是同一个session 不是同一个session则不应该进行下一步的处理
			if(exist_session.getId()!=null && exist_session.getId().equals(session.getId())) {
				netgates.get(sn).remove(openid);
				System.out.println("有一网关连接关闭!SN:"+sn+",当前在线数量为"+netgates.get(sn).keySet().size());
			}else {
				System.out.println("检测到关闭session异常,程序中断处理,关闭sessionId:"+session.getId()+",当前实际sessionId:"+exist_session.getId());
			}
		}else {
			System.out.println("检测到关闭session异常,程序中断处理,系统中未找到对应的session,Sn="+sn+"openid="+openid);
		}
	}
	
	private String getSysDate() {
		 SimpleDateFormat df = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");//设置日期格式
        return df.format(new Date());
	}
}

 

public class MyWebSocketHandler extends TextWebSocketHandler {
 
    @Override
    public void afterConnectionEstablished(WebSocketSession session) throws Exception {
        // 在WebSocket连接建立时执行的逻辑
    }
 
    @Override
    protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
        // 处理接收到的文本消息
    }
 
    @Override
    public void handleTransportError(WebSocketSession session, Throwable exception) throws Exception {
        // 处理传输错误事件
    }
 
    @Override
    public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception {
        // 在WebSocket连接关闭时执行的逻辑
    }
}

5.Controller中可以通过@Autowired注入WebSocketHandler,并在方法中调用处理器的方法来与WebSocket进行交互。

 方式三:TIO

1.引入pom依赖配置application

 <dependency>
     <groupId>org.t-io</groupId>
     <artifactId>tio-websocket-spring-boot-starter</artifactId>
     <version>3.5.5.v20191010-RELEASE</version>
</dependency>
tio:
  websocket:
    server:
      port: 8989

2.实现handler

@Component
public class MyHandler implements IWsMsgHandler {
    /**
     * 握手
     *
     * @param httpRequest
     * @param httpResponse
     * @param channelContext
     * @return
     * @throws Exception
     */
    @Override
    public HttpResponse handshake(HttpRequest httpRequest, HttpResponse httpResponse, ChannelContext channelContext) throws Exception {
        return httpResponse;
    }

    /**
     * 握手成功
     *
     * @param httpRequest
     * @param httpResponse
     * @param channelContext
     * @throws Exception
     */
    @Override
    public void onAfterHandshaked(HttpRequest httpRequest, HttpResponse httpResponse, ChannelContext channelContext) throws Exception {
        System.out.println("握手成功");
    }

    /**
     * 接收二进制文件
     *
     * @param wsRequest
     * @param bytes
     * @param channelContext
     * @return
     * @throws Exception
     */
    @Override
    public Object onBytes(WsRequest wsRequest, byte[] bytes, ChannelContext channelContext) throws Exception {
        return null;
    }

    /**
     * 断开连接
     *
     * @param wsRequest
     * @param bytes
     * @param channelContext
     * @return
     * @throws Exception
     */
    @Override
    public Object onClose(WsRequest wsRequest, byte[] bytes, ChannelContext channelContext) throws Exception {
        System.out.println("关闭连接");
        return null;
    }

    /**
     * 接收消息
     *
     * @param wsRequest
     * @param s
     * @param channelContext
     * @return
     * @throws Exception
     */
    @Override
    public Object onText(WsRequest wsRequest, String s, ChannelContext channelContext) throws Exception {
        System.out.println("接收文本消息:" + s);
        return "success";
    }
}

3.主启动类加上开启注解

@EnableTioWebSocketServer

方式四:STOMP 

1.引入maven坐标

<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-websocket</artifactId>
</dependency>

 2.加入配置类

@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {

    @Override
    public void registerStompEndpoints(StompEndpointRegistry registry) {
        // 配置客户端尝试连接地址
        registry.addEndpoint("/ws").setAllowedOrigins("*").withSockJS();
    }

    @Override
    public void configureMessageBroker(MessageBrokerRegistry registry) {
        // 设置广播节点
        registry.enableSimpleBroker("/topic", "/user");
        // 客户端向服务端发送消息需有/app 前缀
        registry.setApplicationDestinationPrefixes("/app");
        // 指定用户发送(一对一)的前缀 /user/
        registry.setUserDestinationPrefix("/user/");
    }
}

3.添加处理器

@Controller
public class WSController {

    @Autowired
    private SimpMessagingTemplate simpMessagingTemplate;

    @MessageMapping("/hello")
    @SendTo("/topic/hello")
    public ResponseMessage hello(RequestMessage requestMessage) {
        System.out.println("接收消息:" + requestMessage);
        return new ResponseMessage("服务端接收到你发的:" + requestMessage);
    }

    @GetMapping("/sendMsgByUser")
    public @ResponseBody
    Object sendMsgByUser(String token, String msg) {
        simpMessagingTemplate.convertAndSendToUser(token, "/msg", msg);
        return "success";
    }

    @GetMapping("/sendMsgByAll")
    public @ResponseBody
    Object sendMsgByAll(String msg) {
        simpMessagingTemplate.convertAndSend("/topic", msg);
        return "success";
    }

    @GetMapping("/test")
    public String test() {
        return "test-stomp.html";
    }
}

在线接口测试:

websocket在线测试 (websocket-test.com)

在线websocket测试-在线工具-postjson (coolaf.com)

下面是两个前端的示例 

<!DOCTYPE html>
<html lang="en">
	<head>
		<meta charset="utf-8" />
		<meta http-equiv="X-UA-Compatible" content="IE=edge">
		<meta name="viewport" content="width=device-width, initial-scale=1">
		<title>本地websocket测试</title>
		<meta name="robots" content="all" />
		<meta name="keywords" content="本地,websocket,测试工具" />
		<meta name="description" content="本地,websocket,测试工具" />
		<style>
			.btn-group{
				display: inline-block;
			}
		</style>
	</head>
	<body>
		<input type='text' value='通信地址, ws://开头..' class="form-control" style='width:390px;display:inline'
		 id='wsaddr' />
		<div class="btn-group" >
			<button type="button" class="btn btn-default" onclick='addsocket();'>连接</button>
			<button type="button" class="btn btn-default" onclick='closesocket();'>断开</button>
			<button type="button" class="btn btn-default" onclick='$("#wsaddr").val("")'>清空</button>
		</div>
		<div class="row">
			<div id="output" style="border:1px solid #ccc;height:365px;overflow: auto;margin: 20px 0;"></div>
			<input type="text" id='message' class="form-control" style='width:810px' placeholder="待发信息" onkeydown="en(event);">
			<span class="input-group-btn">
				<button class="btn btn-default" type="button" onclick="doSend();">发送</button>
			</span>
			</div>
		</div>
	</body>		
		
		<script src="https://code.jquery.com/jquery-3.1.1.min.js"></script>
		<script language="javascript" type="text/javascript">
			function formatDate(now) {
				var year = now.getFullYear();
				var month = now.getMonth() + 1;
				var date = now.getDate();
				var hour = now.getHours();
				var minute = now.getMinutes();
				var second = now.getSeconds();
				return year + "-" + (month = month < 10 ? ("0" + month) : month) + "-" + (date = date < 10 ? ("0" + date) : date) +
					" " + (hour = hour < 10 ? ("0" + hour) : hour) + ":" + (minute = minute < 10 ? ("0" + minute) : minute) + ":" + (
						second = second < 10 ? ("0" + second) : second);
			}
			var output;
			var websocket;
 
			function init() {
				output = document.getElementById("output");
				testWebSocket();
			}
 
			function addsocket() {
				var wsaddr = $("#wsaddr").val();
				if (wsaddr == '') {
					alert("请填写websocket的地址");
					return false;
				}
				StartWebSocket(wsaddr);
			}
 
			function closesocket() {
				websocket.close();
			}
 
			function StartWebSocket(wsUri) {
				websocket = new WebSocket(wsUri);
                //监听是否连接成功
				websocket.onopen = function(evt) {
					onOpen(evt)
				};
                // 监听连接关闭事件
				websocket.onclose = function(evt) {
					onClose(evt)
				};
                // 接听服务器发回的信息并处理展示
				websocket.onmessage = function(evt) {
					onMessage(evt)
				};
                // 监听并处理error事件
				websocket.onerror = function(evt) {
					onError(evt)
				};
			}
 
			function onOpen(evt) {
				writeToScreen("<span style='color:red'>连接成功,现在你可以发送信息啦!!!</span>");
			}
 
			function onClose(evt) {
				writeToScreen("<span style='color:red'>websocket连接已断开!!!</span>");
				websocket.close();
			}
 
			function onMessage(evt) {
				writeToScreen('<span style="color:blue">服务端回应&nbsp;' + formatDate(new Date()) + '</span><br/><span class="bubble">' +
					evt.data + '</span>');
			}
 
			function onError(evt) {
				writeToScreen('<span style="color: red;">发生错误:</span> ' + evt.data);
			}
 
			function doSend() {
				var message = $("#message").val();
				if (message == '') {
					alert("请先填写发送信息");
					$("#message").focus();
					return false;
				}
				if (typeof websocket === "undefined") {
					alert("websocket还没有连接,或者连接失败,请检测");
					return false;
				}
				if (websocket.readyState == 3) {
					alert("websocket已经关闭,请重新连接");
					return false;
				}
				console.log(websocket);
				$("#message").val('');
				writeToScreen('<span style="color:green">你发送的信息&nbsp;' + formatDate(new Date()) + '</span><br/>' + message);
				websocket.send(message);
			}
 
			function writeToScreen(message) {
				var div = "<div class='newmessage'>" + message + "</div>";
				var d = $("#output");
				var d = d[0];
				var doScroll = d.scrollTop == d.scrollHeight - d.clientHeight;
				$("#output").append(div);
				if (doScroll) {
					d.scrollTop = d.scrollHeight - d.clientHeight;
				}
			}
 
 
			function en(event) {
				var evt = evt ? evt : (window.event ? window.event : null);
				if (evt.keyCode == 13) {
					doSend()
				}
			}
		</script>
 
</html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <meta content="text/html;charset=UTF-8"/>
    <meta http-equiv="Content-Type" content="text/html; charset=utf-8"/>
    <meta http-equiv="X-UA-Compatible" content="IE=edge"/>
    <meta name="viewport" content="width=device-width, initial-scale=1"/>
    <title>WebSocket Examples: Reverse</title>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.3.1/jquery.min.js"></script>
    <script th:src="@{/layui/layui.js}"></script>
    <link th:href="@{/layui/css/layui.css}" rel="stylesheet">
    <style type="text/css">
        #connect-container {
            margin: 0 auto;
            width: 400px;
        }

        #connect-container div {
            padding: 5px;
            margin: 0 7px 10px 0;
        }

        .layui-btn {
            display: inline-block;
        }
    </style>
    <script type="text/javascript">
        var ws = null;

        $(function () {
            var target = $("#target");
            if (window.location.protocol === 'http:') {
                target.val('ws://' + window.location.host + target.val());
            } else {
                target.val('wss://' + window.location.host + target.val());
            }
        });

        function setConnected(connected) {
            var connect = $("#connect");
            var disconnect = $("#disconnect");
            var echo = $("#echo");

            if (connected) {
                connect.addClass("layui-btn-disabled");
                disconnect.removeClass("layui-btn-disabled");
                echo.removeClass("layui-btn-disabled");
            } else {
                connect.removeClass("layui-btn-disabled");
                disconnect.addClass("layui-btn-disabled");
                echo.addClass("layui-btn-disabled");
            }

            connect.attr("disabled", connected);
            disconnect.attr("disabled", !connected);
            echo.attr("disabled", !connected);
        }

        //连接
        function connect() {
            var target = $("#target").val();

            ws = new WebSocket(target);
            //监听是否连接成功
            ws.onopen = function () {
                setConnected(true);
                log('Info: WebSocket connection opened.');
            };
            // 接听服务器发回的信息并处理展示
            ws.onmessage = function (event) {
                log('Received: ' + event.data);
            };
            // 监听连接关闭事件
            ws.onclose = function () {
                setConnected(false);
                log('Info: WebSocket connection closed.');
            };
        }

        //断开连接
        function disconnect() {
            if (ws != null) {
                ws.close();
                ws = null;
            }
            setConnected(false);
        }

        //Echo
        function echo() {
            if (ws != null) {
                var message = $("#message").val();
                log('Sent: ' + message);
                ws.send(message);
            } else {
                alert('WebSocket connection not established, please connect.');
            }
        }

        //日志输出
        function log(message) {
            console.debug(message);
        }
    </script>
</head>
<body>
    <noscript><h2 style="color: #ff0000">Seems your browser doesn't support Javascript! Websockets rely on Javascript being
        enabled. Please enable
        Javascript and reload this page!</h2></noscript>
    <div>
        <div id="connect-container" class="layui-elem-field">
            <legend>Echo</legend>
            <div>
                <input id="target" type="text" class="layui-input" size="40" style="width: 350px" value="/echoMessage"/>
            </div>
            <div>
                <button id="connect" class="layui-btn layui-btn-normal" onclick="connect();">Connect</button>
                <button id="disconnect" class="layui-btn layui-btn-normal layui-btn-disabled" disabled="disabled"
                        onclick="disconnect();">Disconnect
                </button>

            </div>
            <div>
                <textarea id="message" class="layui-textarea" placeholder="请输入请求的内容" style="width: 350px"></textarea>
            </div>
            <div>
                <button id="echo" class="layui-btn layui-btn-normal layui-btn-disabled" disabled="disabled"
                        onclick="echo();">Echo message
                </button>
            </div>
        </div>
    </div>
</body>
</html>

单工消息推送 SSE

SSE 是基于 HTTP 协议的;单向通信,只能由服务端向客户端单向通信;默认支持断线重连;只能传送文本消息;不支持IE;

 

sse 规范
在 html5 的定义中,服务端 sse,一般需要遵循以下要求

请求头
开启长连接 + 流方式传递
Content-Type: text/event-stream;charset=UTF-8
Cache-Control: no-cache
Connection: keep-alive

数据格式
服务端发送的消息,由 message 组成,其格式: field:value\n\n

其中 field 有五种可能

空: 即以:开头,表示注释,可以理解为服务端向客户端发送的心跳,确保连接不中断
data:数据。订阅后,服务端在消息可用时立即发送给客户端。事件是采用 UTF-8 编码的文本消息。事件之间由两个换行符分隔\n\n。每个事件由一个或多个名称:值字段组成,由单个换行符\n 分隔。 ​
event: 事件,默认值
id: 数据标识符 id 字段表示,相当于每一条数据的编号 。服务器可以发送唯一的事件标识符(id字段)。如果连接中断,客户端会自动重新连接并发送最后接收到的带有header的 Last-Event-ID 的事件 ID。 ​
retry: 重连时间 ,在重试字段中,服务器可以发送超时(以毫秒为单位),之后客户端应在连接中断时自动重新连接。如果未指定此字段,则标准应为 3000 毫秒。
 

 

 SSE工具类

import lombok.extern.slf4j.Slf4j;
import org.springframework.http.MediaType;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.Consumer;

@Slf4j
public class SSEServer {

    /**
     * 当前连接数
     */
    private static AtomicInteger count = new AtomicInteger(0);

    private static Map<String, SseEmitter> sseEmitterMap = new ConcurrentHashMap<>();

    public static SseEmitter connect(String userId){
        //设置超时时间,0表示不过期,默认是30秒,超过时间未完成会抛出异常
        SseEmitter sseEmitter = new SseEmitter(0L);
        //注册回调
        sseEmitter.onCompletion(completionCallBack(userId));
        sseEmitter.onError(errorCallBack(userId));
        sseEmitter.onTimeout(timeOutCallBack(userId));
        sseEmitterMap.put(userId,sseEmitter);
        //数量+1
        count.getAndIncrement();
        log.info("create new sse connect ,current user:{}",userId);
        return sseEmitter;
    }
    /**
     * 给指定用户发消息
     */
    public static void sendMessage(String userId, String message){
        if(sseEmitterMap.containsKey(userId)){
            try{
                sseEmitterMap.get(userId).send(message);
            }catch (IOException e){
                log.error("user id:{}, send message error:{}",userId,e.getMessage());
                e.printStackTrace();
            }
        }
    }

    /**
     * 想多人发送消息,组播
     */
    public static void groupSendMessage(String groupId, String message){
        if(sseEmitterMap!=null&&!sseEmitterMap.isEmpty()){
            sseEmitterMap.forEach((k,v) -> {
                try{
                    if(k.startsWith(groupId)){
                        v.send(message, MediaType.APPLICATION_JSON);
                    }
                }catch (IOException e){
                    log.error("user id:{}, send message error:{}",groupId,message);
                    removeUser(k);
                }
            });
        }
    }
    public static void batchSendMessage(String message) {
        sseEmitterMap.forEach((k,v)->{
            try{
                v.send(message,MediaType.APPLICATION_JSON);
            }catch (IOException e){
                log.error("user id:{}, send message error:{}",k,e.getMessage());
                removeUser(k);
            }
        });
    }
    /**
     * 群发消息
     */
    public static void batchSendMessage(String message, Set<String> userIds){
        userIds.forEach(userId->sendMessage(userId,message));
    }
    public static void removeUser(String userId){
        sseEmitterMap.remove(userId);
        //数量-1
        count.getAndDecrement();
        log.info("remove user id:{}",userId);
    }
    public static List<String> getIds(){
        return new ArrayList<>(sseEmitterMap.keySet());
    }
    public static int getUserCount(){
        return count.intValue();
    }
    private static Runnable completionCallBack(String userId) {
        return () -> {
            log.info("结束连接,{}",userId);
            removeUser(userId);
        };
    }
    private static Runnable timeOutCallBack(String userId){
        return ()->{
            log.info("连接超时,{}",userId);
            removeUser(userId);
        };
    }
    private static Consumer<Throwable> errorCallBack(String userId){
        return throwable -> {
            log.error("连接异常,{}",userId);
            removeUser(userId);
        };
    }
}

Controller层

import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import vip.huhailong.catchat.sse.SSEServer;

@Slf4j
@RestController
@CrossOrigin
@RequestMapping("/sse")
public class SSEController {

    @GetMapping(value = "/connect/{userId}", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
    public SseEmitter connect(@PathVariable String userId){
        return SSEServer.connect(userId);
    }

    @GetMapping("/process")
    public void sendMessage() throws InterruptedException {
        for(int i=0; i<=100; i++){
            if(i>50&&i<70){
                Thread.sleep(500L);
            }else{
                Thread.sleep(100L);
            }
            SSEServer.batchSendMessage(String.valueOf(i));
        }
    }
}

前端

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Home</title>
    <script>
    if (window.EventSource) {
        let data = new EventSource("/cat-chat/sse/connect/huhailong")
        data.onmessage = function(event){
            console.log("test=>",event)
            document.getElementById("result").innerText = event.data+'%';
            document.getElementById("my-progress").value = event.data;
        }
    } else {
        console.log("你的浏览器不支持SSE");
    }
    </script>
</head>
<body>
    <div id="result"></div>
    <progress style="width: 300px" id="my-progress" value="0" max="100"></progress>
</body>
</html>

nginx 配置 proxy_buffering off
不配置proxy_buffering off的话,会出现请求发出后,接口收到直接返回,无法保持长连接。
参考网上说明:proxy_buffering这个参数用来控制是否打开后端响应内容的缓冲区,如果这个设置为off,那么proxy_buffers和proxy_busy_buffers_size这两个指令将会失效。
 

 

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

今晚哒老虎

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值