概述
websocket 介绍
-
WebSocket 是一种通信协议,通过单个 TCP 连接提供全双工通信通道。它允许客户端和服务器之间进行双向通信、实时交互,比如在线聊天、实时数据展示等。
与传统的 HTTP 协议不同,WebSocket 连接是持久的,可以在服务器和客户端之间发送实时数据。
-
WebSocket的 一些关键特点:
- 全双工通信: WebSocket 支持全双工通信,允许服务器和客户端同时发送和接收数据,而无需等待对方的响应。
- 持久连接: 一旦建立 WebSocket 连接,它可以保持打开状态,允许实时数据的即时传输,而无需为每个通信步骤重新建立连接。
- 低延迟: WebSocket 连接通常比传统的 HTTP 请求-响应模型更具有低延迟。这使得它非常适合实时应用程序,如在线游戏、聊天应用和股票市场数据更新。
- 简单握手过程: WebSocket 连接的建立通过简单的握手过程进行,使用 HTTP 协议进行初始握手后,连接就升级为WebSocket 连接。
- 协议标准: WebSocket 定义了一个标准的通信协议,包括数据帧格式、握手过程和关闭连接的规范。
- 适用于各种应用: WebSocket 广泛用于各种实时应用程序,包括在线聊天、协作工具、实时通知、在线游戏和金融应用等。
-
基本工作原理:
- 握手过程: 客户端发起一个 HTTP 请求,表明希望升级为 WebSocket 连接。服务器接受请求后,进行握手过程,确认协议升级。
- 数据帧传输: 一旦握手成功,双方可以通过发送数据帧进行通信。数据帧可以包含文本、二进制数据等。
- 保持连接: WebSocket 连接是持久的,保持打开状态,直到一方发起关闭握手。
- 关闭连接: 任何一方都可以发起关闭握手,双方会交换关闭帧,最终关闭连接。
Java 实现 websocket 服务端的方式
主要有两种:
- Tomcat 7 的 @ServerEndpoint 注解方式
- springboot 集成 websocket 方式
@ServerEndpoint 注解方式
@ServerEndpoint 注解
介绍
-
@ServerEndpoint 注解是 tomcat 7 中新增加的一个注解,用于标识一个类为 WebSocket 服务端点
WebSocket 服务端点监听客户端的 WebSocket 连接,并将连接管理起来,供客户端和服务端进行实时通信
-
一个标注有 @ServerEndpoint 的类必须包含一个无参构造函数,并且可以有一个或多个注解为 @OnOpen、@OnClose、@OnMessage、@OnError 的方法。
-
@OnOpen:当 WebSocket 连接建立时,会调用标注有 @onOpen 的方法
-
@OnClose:当 WebSocket 连接关闭时,会调用标注有 @OnClose 的方法
-
@OnMessage:当收到客户端发送的消息时,会调用标注有 @OnMessage 的方法
在 @OnMessage 方法中,可以通过参数文本、二进制、PongMessage 等方式来接收客户端发送的消息。
同样可以通过 Session 给客户端发送消息,以实现双向通信。
-
@OnError:当出现错误时,会调用标注有 @OnError 的方法
-
-
注意:标注 @ServerEndpoint 注解的类对象是多例的,即每个连接都会创建一个新的对象
@ServerEndpoint 注解的参数配置
-
value 参数:必选参数,用于指定 WebSocket 服务端点的 URI 地址
-
**decoders **参数:数组类型,指定解码器
包含用于将 WebSocket 消息解码为 Java 对象的解码器(Decoder)的类
解码器帮助将原始消息转换为 Java 对象
-
encoders 参数:数组类型,指定编解码器
包含用于将 Java 对象编码为 WebSocket 消息的编码器(Encoder)的类。
编码器帮助将 Java 对象转换为可以发送到客户端的消息
-
subprotocols 参数:用于指定一个或多个 WebSocket 的子协议
-
configurator 参数:一个类,用于提供配置 WebSocket 端点的自定义配置器
这允许在 WebSocket 端点创建和配置过程中进行额外的设置
WebSocket 端点类常用对象
在一个使用@ServerEndpoint
注解定义的 WebSocket 端点类中,可以自动注入以下类型的对象:
-
javax.websocket.Session:WebSocket 的一个会话对象,代表了 WebSocket 的一个客户端和服务器的连接。
可以使用 Session 对象来获取 WebSocket 中的各种状态和信息,比如获得客户端的地址、判断客户端是否已经关闭等。
-
javax.websocket.EndpointConfig: 用于在 WebSocket 端点的生命周期内共享配置信息的对象
-
其他 Spring Bean: 如果 WebSocket 端点类是一个 Spring 管理的 Bean,可以通过使用 @Autowired 注解或构造函数注入其他 Spring Bean,以便在 WebSocket 端点中使用它们。
-
Servlet API 对象: 如果 WebSocket 应用程序与 Servlet 容器集成,可以注入 Servlet API 的相关对象,例如
HttpServletRequest
、HttpServletResponse
等,以便在 WebSocket 处理中访问Web请求和响应@Autowired private HttpServletRequest request; @OnOpen public void onOpen(Session session) { // 使用注入的HttpServletRequest对象 }
-
其他常用对象:
-
RemoteEndpoint :WebSocket 的一个远程端点对象,代表了客户端和服务器的一个连接通道。
通过 RemoteEndpoint 对象,可以向客户端发送消息、关闭连接等。
RemoteEndpoint 对象获取方法:通过 Session 对象的 basicRemote 属性获取
RemoteEndpoint.Basic remote = session.getBasicRemote();
-
使用注意事项
- 多线程:WebSocket 是一种基于事件驱动的编程模型,因此需要注意多线程问题(线程安全性和同步)
- 长连接:WebSocket 是一种长连接的通信模型,需要特别注意长时间连接的问题(资源占用和关闭连接)
- 消息大小:WebSocket 的消息大小是有限制的,需要特别注意消息大小的问题(消息的大小和格式)
基本使用
依赖
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
</dependency>
核心处理器(@ServerEndpoint)
import com.blackcrow.common.utils.UUIDUtil;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import javax.websocket.*;
import javax.websocket.server.ServerEndpoint;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
/**
* websocket 服务端点。注意:websocket对象是多例的
*/
@Slf4j
@Service
//@ServerEndpoint(value = "/chat")
@ServerEndpoint(value = "/chat", configurator = WebSocketServerConfigurator.class)
public class WebSocketServer {
// 用于存储每个用户客户端对象
public static Map<String, WebsocketServer> onlineUserMap = new ConcurrentHashMap<>();
// 用户id
private String userId;
// 会话
private Session session;
@OnOpen
public void onOpen(Session session, EndpointConfig config){
this.session = session;
this.userId = UUIDUtil.get4UUID();
log.info("收到来自窗口的连接,userId={}", this.userId);
onlineUserMap.put(this.userId, this);
Object aaaa = config.getUserProperties().get("aaaa");
log.info("aaaa={}", aaaa);
}
@OnMessage
public void onMessage(String message, Session session){
log.info("收到来自窗口[{}]的的信息: {}", this.userId, message);
}
@OnClose
public void onClose(Session session){
onlineUserMap.remove(this.userId);
log.info("有一连接[{}]关闭!当前连接数为 {}", this.userId, onlineUserMap.size());
}
@OnError
public void onError(Session session, Throwable throwable){
log.error("WebsocketServer 连接发生错误", throwable);
}
/**
* 給session连接推送消息
*/
private void sendMessage(Object message) {
try {
this.session.getBasicRemote().sendObject(message);
} catch (Exception e) {
e.printStackTrace();
log.error("向客户端推送数据发生错误", e);
}
}
/**
* 向所有连接群发消息
*/
public static void sendMessageToAll(Object message){
for (WebsocketServer item : onlineUserMap.values()) {
try {
item.sendMessage(message);
} catch (Exception e) {
log.error("向客户端推送数据发生错误", e);
}
}
}
}
配置类(ServerEndpointExporter )
注:
- 如果是在 SpringBoot 环境,则必须注册 ServerEndpointExporter 对象,用于扫描 @ServerEndpoint 注解的配置,不然在客户端连接的时候会一直连不上。若不是在 SpringBoot 下开发的可以跳过这一环节。
- ServerEndpointExporter 类是 spring-boot-starter-websocket 提供的
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.socket.server.standard.ServerEndpointExporter;
@Configuration
public class WebSocketConfig {
/**
* ServerEndpointExporter 将会扫描所有使用 @ServerEndpoint 注解标记的类,并将它们注册为 WebSocket 服务端点
*/
@Bean
public ServerEndpointExporter serverEndpointExporter() {
return new ServerEndpointExporter();
}
}
握手连接处理类
-
这个类是一个自定义的 WebSocket 配置器,它实现了 javax.websocket.server.ServerEndpointConfig.Configurator 接口。它的作用是在 WebSocket 握手期间修改握手请求和响应。
具体来说,这个类重写了 modifyHandshake 方法,在 WebSocket 握手期间被调用。
modifyHandshake
方法允许在这个握手阶段进行干预,以便:- 修改请求头:可以添加、修改或删除 HTTP 请求头中的信息。这对于传递身份验证信息、自定义参数或修改其他请求属性非常有用。
- 定制子协议:WebSocket 支持客户端和服务器协商使用哪个子协议。通过
modifyHandshake
,可以根据客户端的请求或其他条件来选择要使用的子协议。 - 处理跨域问题:在某些情况下,可能需要处理跨域 WebSocket 连接。
modifyHandshake
可以配置跨域相关的设置,如允许的来源、凭据模式等。 - 添加自定义逻辑:可以使用
modifyHandshake
来添加任何自定义逻辑,这些逻辑在握手阶段需要执行。 - 拒绝连接:可以在
modifyHandshake
方法中添加逻辑来检查客户端的请求,并据此决定是否继续握手过程。如果决定拒绝连接,可以抛出一个异常来中断握手。
-
注:在 WebSocket 的生命周期中,握手是客户端和服务器建立连接的第一步。这一步通常涉及 HTTP 请求和响应,以便双方能够就将要建立的 WebSocket 连接达成协议。
import javax.websocket.HandshakeResponse;
import javax.websocket.server.HandshakeRequest;
import javax.websocket.server.ServerEndpointConfig;
import java.util.List;
import java.util.Map;
/**
* 握手处理器
*/
public class WebSocketServerConfigurator extends ServerEndpointConfig.Configurator {
@Override
public void modifyHandshake(ServerEndpointConfig sec, HandshakeRequest request, HandshakeResponse response) {
// 获取客户端发送的HTTP请求头信息
Map<String, List<String>> headers = request.getHeaders();
// 检查某个特定的请求头是否存在或是否符合要求
List<String> customHeaderValues = headers.get("Custom-Header");
if (customHeaderValues == null || customHeaderValues.isEmpty() || !customHeaderValues.get(0).equals("ExpectedValue")) {
// 如果请求头不符合要求,则拒绝握手
throw new RuntimeException("Custom header is missing or invalid");
}
// 如果请求头符合要求,则继续握手过程
super.modifyHandshake(sec, request, response);
}
}
springboot 集成 websocket 方式
WebSocket 端点类常用对象
WebSocketMessage
public interface WebSocketMessage<T> {
/**
* 消息载荷
*/
T getPayload();
/**
* 消息字节长度
*/
int getPayloadLength();
/**
* 当org.springframework.web.socket.WebSocketHandler#supportsPartialMessages()配置允许分片消息时,
* 如果当前消息是客户端本次送达消息的最后一部分时,该方法返回true。如果分片消息不可用或是被禁用,放回false
*/
boolean isLast();
}
WebSocketSession
常用方法:
- 主动推送消息:
void sendMessage(WebSocketMessage<?> message);
- 关闭连接:
void close();
public interface WebSocketSession extends Closeable {
/**
* 会话标识
*/
String getId();
/**
* WebSocket 连接的URI
*/
@Nullable
URI getUri();
/**
* 返回握手请求中使用的Headers
*/
HttpHeaders getHandshakeHeaders();
/**
*返回WebSocke会话关联的属性。
*在服务端,可以使用org.springframework.web.socket.server.HandshakeInterceptor填充属性
*在客户端,可以使用org.springframework.web.socket.client.WebSocketClient的握手方法填充属性
*/
Map<String, Object> getAttributes();
/**
* 返回一个包含已验证的用户名称的java.security.Principal实例,如果用户没有验证成功返回null
*/
@Nullable
Principal getPrincipal();
/**
* 返回请求接收方的地址
*/
@Nullable
InetSocketAddress getLocalAddress();
/**
* 返回客户端的地址
*/
@Nullable
InetSocketAddress getRemoteAddress();
/**
*返回约定的子协议,如果没有协议或是协议失败返回null
*/
@Nullable
String getAcceptedProtocol();
/**
* 配置一次接收文本消息最大值
*/
void setTextMessageSizeLimit(int messageSizeLimit);
/**
* 获取一次接收文本消息最大值
*/
int getTextMessageSizeLimit();
/**
* 配置一次接收二进制消息最大值
*/
void setBinaryMessageSizeLimit(int messageSizeLimit);
/**
* 获取一次接收二进制消息最大值
*/
int getBinaryMessageSizeLimit();
/**
* 获取约定的扩展
*/
List<WebSocketExtension> getExtensions();
/**
* 发送消息,WebSocket会话底层协议不支持并发发送消息,因此发送必须是同步的。
* 保证信息发送同步进行,一种方法是使用org.springframework.web.socket.handler.ConcurrentWebSocketSessionDecorator
* 包装WebSocketSession
*/
void sendMessage(WebSocketMessage<?> message) throws IOException;
/**
* 底层连接是否打开
*/
boolean isOpen();
/**
* 使用状态码1000关闭WebSocket连接
*/
@Override
void close() throws IOException;
/**
* 使用指定状态码WebSocket连接
*/
void close(CloseStatus status) throws IOException;
}
基本使用
依赖
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
</dependency>
核心处理器(WebSocketHandler)
-
可以实现 WebSocketHandler 接口,也可以继承 AbstractWebSocketHandler 类来创建 WebSocketHandler 实例
-
WebSocketHandler 接口提供了五个方法:
-
afterConnectionEstablished:连接成功后调用
-
handleMessage:处理发送来的消息
handleMessage 方法中有一个
WebSocketMessage
参数,是一个接口,但一般不直接使用这个接口而是使用它的实现类,它有以下几个实现类:- BinaryMessage:二进制消息体
- TextMessage:文本消息体
- PingMessage: Ping 消息体
- PongMessage: Pong 消息体
但是由于
handleMessage
方法的参数是WebSocketMessage
接口,所以实际使用中可能需要判断一下当前来的消息具体是它的哪个子类,比如这样:public void handleMessage(WebSocketSession session, WebSocketMessage<?> message) throws Exception { if (message instanceof TextMessage) { this.handleTextMessage(session, (TextMessage)message); } else if (message instanceof BinaryMessage) { this.handleBinaryMessage(session, (BinaryMessage)message); } }
可以直接继承
AbstractWebSocketHandler
类,然后重写想要处理的消息类型,它已经封装了这些重复劳动, -
handleTransportError: WS 连接出错时调用
-
afterConnectionClosed:连接关闭后调用
-
supportsPartialMessages:是否支持分片消息。没什么用,返回 false 就完事了
-
import org.springframework.web.socket.*;
import java.io.IOException;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
/**
* websocket核心处理器
*/
public class MyWebSocketHandler implements WebSocketHandler {
private static final Map<String, WebSocketSession> SESSIONS = new ConcurrentHashMap<>();
@Override
public void afterConnectionEstablished(WebSocketSession session) throws Exception {
String userName = session.getAttributes().get("userName").toString();
SESSIONS.put(userName, session);
System.out.println(String.format("成功建立连接~ userName: %s", userName));
}
@Override
public void handleMessage(WebSocketSession session, WebSocketMessage<?> message) throws Exception {
String msg = message.getPayload().toString();
System.out.println(msg);
}
@Override
public void handleTransportError(WebSocketSession session, Throwable exception) throws Exception {
System.out.println("连接出错");
if (session.isOpen()) {
session.close();
}
}
@Override
public void afterConnectionClosed(WebSocketSession session, CloseStatus closeStatus) throws Exception {
System.out.println("连接已关闭,status:" + closeStatus);
}
@Override
public boolean supportsPartialMessages() {
return false;
}
/**
* 指定发消息
*/
public static void sendMessage(String userName, String message) {
WebSocketSession webSocketSession = SESSIONS.get(userName);
if (webSocketSession == null || !webSocketSession.isOpen()) return;
try {
webSocketSession.sendMessage(new TextMessage(message));
} catch (IOException e) {
e.printStackTrace();
}
}
/**
* 群发消息
*/
public static void fanoutMessage(String message) {
SESSIONS.keySet().forEach(us -> sendMessage(us, message));
}
}
核心配置类(WebSocketConfigurer)
-
可以配置 websocket 入口,允许访问的域、注册 Handler、定义拦截器等
-
注意 WebSocketHandlerRegistry .addHandler(WebSocketHandler webSocketHandler, String… paths) 的第二个参数是一个字符串类型的参数列表,说明可以为多个端点指定同样配置的 WebSocketHandler 处理
-
WebSocketHandlerRegistry.addHandler() 方法注册 WebSocketHandler 之后会返回 WebSocketHandlerRegistration 用于配置 WebSocketHandler
public interface WebSocketHandlerRegistration { // 继续添加消息处理器 WebSocketHandlerRegistration addHandler(WebSocketHandler handler, String... paths); // 添加握手处理器,处理握手事件 WebSocketHandlerRegistration setHandshakeHandler(HandshakeHandler handshakeHandler); // 添加握手拦截器,可以在处理握手前和握手后处理一些业务逻辑 WebSocketHandlerRegistration addInterceptors(HandshakeInterceptor... interceptors); // 配置允许的浏览器跨源请求类型 WebSocketHandlerRegistration setAllowedOrigins(String... origins); // 配置允许的浏览器跨源请求类型 WebSocketHandlerRegistration setAllowedOriginPatterns(String... originPatterns); // 允许使用SockJS应急选项 SockJsServiceRegistration withSockJS(); }
import org.springframework.context.annotation.Configuration;
import org.springframework.http.server.ServerHttpRequest;
import org.springframework.http.server.ServerHttpResponse;
import org.springframework.http.server.ServletServerHttpRequest;
import org.springframework.web.socket.WebSocketHandler;
import org.springframework.web.socket.config.annotation.EnableWebSocket;
import org.springframework.web.socket.config.annotation.WebSocketConfigurer;
import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry;
import org.springframework.web.socket.server.HandshakeInterceptor;
import java.util.Map;
/**
* websocket 核心配置类
*/
@Configuration
@EnableWebSocket // 开启注解接收和发送消息
public class WebSocketConfig implements WebSocketConfigurer {
/**
* 配置 websocket 入口,允许访问的域、注册 Handler、定义拦截器等
* 注;配置注册的处理器和拦截器是单例的,无论多少连接进来,都是用相同的对象处理。
*/
@Override
public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
registry.addHandler(new MyWebSocketHandler(), "/ws") // 设置连接路径和处理
.setAllowedOrigins("*")
.addInterceptors(new MyWebSocketInterceptor()); // 设置拦截器
}
/**
* 自定义拦截器拦截WebSocket请求
*/
class MyWebSocketInterceptor implements HandshakeInterceptor {
/**
* 握手前置拦截。一般用来注册用户信息,绑定 WebSocketSession
*/
@Override
public boolean beforeHandshake(ServerHttpRequest request, ServerHttpResponse response,
WebSocketHandler wsHandler, Map<String, Object> attributes) throws Exception {
System.out.println("握手前置拦截~~");
if (!(request instanceof ServletServerHttpRequest)) return true;
// HttpServletRequest servletRequest = ((ServletServerHttpRequest) request).getServletRequest();
// String userName = (String) servletRequest.getSession().getAttribute("userName");
String userName = "Koishipyb";
attributes.put("userName", userName);
Object userName1 = attributes.get("userName");
return true;
}
/**
* 握手后置拦截
*/
@Override
public void afterHandshake(ServerHttpRequest request, ServerHttpResponse response,
WebSocketHandler wsHandler, Exception exception) {
System.out.println("握手后置拦截~~");
}
}
}
nginx 配置 WebSocket
server{
# 监听的端口号
listen 9095;
server_name robotchat.lukeewin.top; # 这里填写的是访问的域名
location / {
proxy_pass http://127.0.0.1:9090; # 这里填写的是代理的路径和端口
proxy_set_header Host $host;
proxy_set_header X-Real_IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
# 以下配置针对websocket
location /ws { # onlineCount为websocket的访问uri
proxy_redirect off;
proxy_pass http://127.0.0.1:9090;
proxy_set_header Host $host;
proxy_set_header X-Real_IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_http_version 1.1;
proxy_read_timeout 36000s;
proxy_send_timeout 36000s;
proxy_set_header Upgrade $http_upgrade; # 升级协议头 websocket
proxy_set_header Connection "upgrade";
}
}
注:
-
添加如下三行语句,才能在后台中拿到真实的 ip 地址
proxy_set_header Host $host; proxy_set_header X-Real_IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
获取真实 ip 地址代码如下:
public String getRealIp(HttpServletRequest request) { String ip = request.getHeader("X-Forwarded-For"); if (StringUtils.isNotEmpty(ip) && !"unKnown".equalsIgnoreCase(ip)) { int index = ip.indexOf(","); if (index != -1) { return ip.substring(0, index); } else { return ip; } } ip = request.getHeader("X-Real-IP"); if (StringUtils.isNotEmpty(ip) && !"unKnown".equalsIgnoreCase(ip)) { return ip; } return request.getRemoteAddr(); }