写在最前面,原理部分。
什么是webScoket?
websocket 是一种网络通信协议,一般用来进行实时通信会使用到,webscoket是html5开始提供的一种在TCP连接上的全双工通讯协议。
什么是全双工协议,和单工通信有什么区别?
全双工协议是指在通信过程中,双方可以同时发送和接收数据的协议。在全双工通信中,发送方和接收方可以同时进行数据传输,不需要等待对方的响应。
单工通信是指通信双方只能在一个方向上进行数据传输的协议。在单工通信中,通信双方只能在一个特定的时间窗口内进行数据传输,无法同时发送和接收数据。
说人话就是,单工通信就是只能由一方发消息,另一方接受。双全工就是双方可以没有限制的自由发送消息,建立两个通道,ab都可以使用这两个通道,两个通道,一个用于发送消息,另一个用于接收消息。这样,双方既可以发送消息给对方,也可以接收对方发送的消息。
那么http协议是如何升级为webScoket协议的呢?
客户端发送一个HTTP请求到服务器,请求升级到WebSocket协议。请求头中包含一个特殊的字段Upgrade: websocket,以及其他必要的字段如Connection: Upgrade等。
服务器接收到该请求后,进行验证和处理。如果服务器支持WebSocket协议,会返回一个HTTP 101 Switching Protocols的响应,表示协议切换成功。响应头中包含Upgrade: websocket和Connection: Upgrade字段。
客户端收到服务器的响应后,即可确认协议升级成功。此时,客户端和服务器之间的连接从HTTP协议切换到了WebSocket协议。
那么我们知道http协议要经过三次握手。所以一个webScoket请求也是三次握手成功之后,再进行请求升级的。
WebSocket和HTTP请求在数据传输上有以下不同之处?
数据格式:HTTP请求和响应的数据格式通常是文本形式,使用HTTP头和HTTP体来传输数据。WebSocket使用帧(frame)来传输数据,帧可以是文本、二进制数据或其他自定义格式。
请求方式:HTTP请求有多种请求方法,如GET、POST、PUT、DELETE等,用于不同的操作和数据传输需求。而WebSocket没有特定的请求方法,只有建立连接的握手过程,之后通过发送和接收帧来进行数据传输。
连接维持:HTTP请求每次都需要重新建立连接,每次请求和响应都是独立的。而WebSocket连接是持久的,可以保持长时间的连接,不需要重复建立。
所以webScoket是一套跟http完全不一样的通讯方式。那么实现也不一致,java已经对于webScoket进行了实现,参考下面代码实现即可。
首先对于webScoket的理解和代码,其他文章都大差不差,我也就不过多赘述。
接下来是代码的简单实现
package com.pix.webScoket.Client;
import com.alibaba.fastjson.JSONObject;
import com.pix.webScoket.webScoketEnum.MessageTypeEnum;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.stereotype.Service;
import org.springframework.util.ObjectUtils;
import javax.websocket.*;
import javax.websocket.server.PathParam;
import javax.websocket.server.ServerEndpoint;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
@ServerEndpoint(value = "/api/v2/ws/{info}")
@Slf4j
@Component
@Service
public class WebSocketServer {
// 在线连接
private static Map<String, List<Session>> onlineUsers = new ConcurrentHashMap<>();
//当前用户信息
private String info;
//缓存分组,根据业务id
private static Map<String,String> cacheGroupByPatientId = new ConcurrentHashMap<>();
//业务信息分组,根据业务id
private static Map<String,List<String>> userInfoGroupByPatientId = new ConcurrentHashMap<>();
//创建连接时初始化信息
@OnOpen
public void onOpen(@PathParam("info") String info, Session session ,EndpointConfig config) {
this.info = info;
saveSessionAndInfo(info, session);
//广播消息
this.broadcastAllUser(this.getOnleMessage());
}
//断开连接时调用
@OnClose
public void onClose(@PathParam("info") String info,Session session, CloseReason closeReason) {
removeSessionAndInfo(info, session);
//通知其他用户已下线
//广播消息
this.broadcastAllUser(this.getClosMessage());
}
//收到客户端发送的消息,这里直接转发给所有人了
@OnMessage
public void onMessage(@PathParam("info") String info, String messageString, Session session) {
if ("test".equals(messageString)){
return;
}
Map<String,Object> message = new HashMap<>();
message.put("code", MessageTypeEnum.TASK_LIST.getCode());
message.put("receiver",messageString);
String patienId = getPatienId(info);
WebSocketServer.cacheGroupByPatientId.put(patienId,messageString);
message.put("userSet",onlineUsers.keySet());
this.broadcastAllUser(JSONObject.toJSONString(message));
}
//广播消息
public void broadcastAllUser(String message){
String patientId = getPatienId(info);
try {
List<Session> sessions = onlineUsers.get(patientId);
for (Session session : sessions) {
session.getBasicRemote().sendText(message);
}
}catch (Exception e){
// TODO: 2023/7/26 记录日志
}
}
private String getOnleMessage(){
Map<String,Object> message = new HashMap<>();
message.put("code",MessageTypeEnum.ITEM_WARN_LIST.getCode());
message.put("receiver", this.info +"加入文档编辑");
String patienId = getPatienId(this.info);
Map<String, String> cacheGroupByPatientIdR = WebSocketServer.cacheGroupByPatientId;
message.put("onOpen", ObjectUtils.isEmpty(cacheGroupByPatientIdR)?null:cacheGroupByPatientIdR.get(patienId));
Map<String, List<String>> userInfoR = WebSocketServer.userInfoGroupByPatientId;
message.put("userSet", ObjectUtils.isEmpty(userInfoR)?null:userInfoR.get(patienId));
return JSONObject.toJSONString(message);
}
private String getClosMessage(){
Map<String,Object> message = new HashMap<>();
message.put("code",MessageTypeEnum.ITEM_WARN_LIST.getCode());
message.put("receiver",this.info +"退出文档编辑");
String patienId = getPatienId(this.info);
message.put("userSet",WebSocketServer.userInfoGroupByPatientId.get(patienId));
return JSONObject.toJSONString(message);
}
private static void saveSessionAndInfo(String info, Session session) {
//session进行保存
String patientId = getPatienId(info);
List<Session> sessions = WebSocketServer.onlineUsers.get(patientId);
if (ObjectUtils.isEmpty(sessions)){
sessions = new ArrayList<>();
}
sessions.add(session);
WebSocketServer.onlineUsers.put(patientId,sessions);
//info保存
List<String> infoList = WebSocketServer.userInfoGroupByPatientId.get(patientId);
if (ObjectUtils.isEmpty(infoList)){
infoList = new ArrayList<>();
}
infoList.add(info);
WebSocketServer.userInfoGroupByPatientId.put(patientId,infoList);
}
private static void removeSessionAndInfo(String info, Session session) {
///session进行移除
String patientId = getPatienId(info);
List<Session> sessions = onlineUsers.get(patientId);
if (ObjectUtils.isEmpty(sessions)){
sessions = new ArrayList<>();
}
sessions.remove(session);
onlineUsers.put(patientId,sessions);
//info移除
List<String> infoList = userInfoGroupByPatientId.get(patientId);
if (ObjectUtils.isEmpty(infoList)){
infoList = new ArrayList<>();
}
infoList.remove(info);
WebSocketServer.userInfoGroupByPatientId.put(patientId,infoList);
}
private static String getPatienId(String info) {
String[] split = info.split("\\|");
String patientId = split[2];
return patientId;
}
}
配置类的简单实现
package com.pix.webScoket.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.socket.config.annotation.EnableWebSocket;
import org.springframework.web.socket.server.standard.ServerEndpointExporter;
@Configuration
@EnableWebSocket
public class WebSocketConfig {
@Bean
public ServerEndpointExporter serverEndpointExporter() {
return new ServerEndpointExporter();
}
}
Scoket枚举
package com.pix.webScoket.webScoketEnum;
import lombok.AllArgsConstructor;
import lombok.Getter;
/**
* 腕表WebSocket
* 客户端和服务端交互的消息类型
* @author: liyadong
*/
@Getter
@AllArgsConstructor
public enum MessageTypeEnum {
TASK_LIST(0, "数据转发"),
ITEM_WARN_LIST(1, "上下线通知"),
TASK_WARN_LIST(2, "心跳"),
;
private int code;
private String des;
/**
* 由String 转换为枚举值
*
* @param des
* @return
*/
public static MessageTypeEnum getFrom(String des) {
for (MessageTypeEnum tmp : MessageTypeEnum.values()) {
if (des.equals(tmp.getDes())) {
return tmp;
}
}
return null;
}
}
踩坑1:
webScoket中只能更改一个请求头,Sec-WebSocket-Protocol。如果这个作为token携带的访视,那么就一定要再次放到这个请求头中返回去,每一次都需要。
我的实现,因为我使用的是spring securty来进行解决的,所以在过滤器进行了token的处理,改为首先从cookie中获取,下面是我的过滤器实现。那么就绕过了Sec-WebSocket-Protocol这个请求头,前端不要往这个请求头塞任何值,塞了值但是么有原封不动的返回去,那么就会报错。
package com.yaoteng.paltform.system.security;
import com.yaoteng.paltform.enums.ErrorCode;
import com.yaoteng.paltform.system.config.SecurityIgnoreUrl;
import com.yaoteng.paltform.system.exception.ServiceException;
import com.yaoteng.paltform.system.utils.JwtUtil;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
import org.springframework.security.web.util.matcher.RequestMatcher;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.filter.OncePerRequestFilter;
import lombok.extern.slf4j.Slf4j;
import javax.annotation.Resource;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.Cookie;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.Arrays;
import java.util.stream.Stream;
/**
* 定义请求过滤器,在请求来到的时候完成一次过滤,但是这里并不能被全局异常处理捕获到,并且返回给前端,所以处理的实现是再包一层,详情 ExceptionHandlerFilter
*/
@Component
@Slf4j
public class JwtAuthenticationTokenFilter extends OncePerRequestFilter {
public static final String AUTHORIZATION = "Authorization";
public static final String SecWebSocketProtocol = "Sec-WebSocket-Protocol";
@Resource
private UserDetailsService userDetailsService;
@Resource
private SecurityIgnoreUrl securityIgnoreUrl;
@Override
@Transactional(rollbackFor = Exception.class)
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
String token = request.getHeader(AUTHORIZATION);
String secWebSocketProtocol = request.getHeader(SecWebSocketProtocol);
log.info(request.getRequestURL().toString());
Stream<RequestMatcher> matchers = Arrays.stream(securityIgnoreUrl.getUrls()).map(AntPathRequestMatcher::new);
if (matchers.anyMatch(matcher -> matcher.matches(request))) {
filterChain.doFilter(request, response);
return;
}
if (secWebSocketProtocol!=null){
token = secWebSocketProtocol;
}
if (token == null) {
Cookie[] cookies = request.getCookies();
Cookie cookie = null;
for (int i = 0; i < cookies.length; i++) {
Cookie cookie1 = cookies[i];
if ("Admin-Token".equals(cookie1.getName())){
cookie = cookie1;
}
}
token = cookie.getValue();
}
if (token == null) {
throw new ServiceException(ErrorCode.TOKEN_IS_EMPTY);
}
if (JwtUtil.verify(token) && JwtUtil.isExpired(token)) {
throw new ServiceException(ErrorCode.TOKEN_IS_EXPIRED);
}
if (!JwtUtil.verify(token)) {
throw new ServiceException(ErrorCode.TOKEN_IS_ILLEGAL);
}
String username = JwtUtil.getUsername(token);
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
SecurityContextHolder.getContext().setAuthentication(authenticationToken);
filterChain.doFilter(request, response);
}
}
踩坑二:
确保你的Nginx版本支持WebSocket。WebSocket在Nginx 1.3版本及以上才被支持。
在Nginx的配置文件中,添加以下代码块来启用WebSocket代理:
location /websocket {
proxy_pass http://your_backend_server;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
在上面的代码中,将your_backend_server替换为实际的后端服务器地址和端口。
保存并重新加载Nginx配置。
确保后端服务器支持WebSocket协议,并且在代码中正确处理WebSocket连接。
为了方便大家,我在这里展示我的配置文件地址
其中代码配置文件:
server:
servlet:
context-path: /prod-api/service
port: 8979
因为上面有,我就展示一部分了
其中webScoket客户端
@ServerEndpoint(value = "/api/v2/ws/{info}")
@Slf4j
@Component
@Service
public class WebSocketServer {
// 在线连接
private static Map<String, List<Session>> onlineUsers = new ConcurrentHashMap<>();
//当前用户信息
private String info;
那么我的配置文件,由上面的应该改成:
location /prod-api/service/api/v2/ {
proxy_pass http://localhost:8979;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
好了,我下午要去出摊了
更新:因为有兄弟想要后面工具类的代码,在此更新
package com.yaoteng.paltform.enums;
package com.yaoteng.paltform.enums;
/**
* 错误代码枚举类
*/
public enum ErrorCode {
/**
* 40101: token不能为空
*/
TOKEN_IS_EMPTY(40101, "token不能为空"),
/**
* 40102: token已过期
*/
TOKEN_IS_EXPIRED(40102, "token已过期"),
/**
* 40103: token不合法
*/
TOKEN_IS_ILLEGAL(40103, "token不合法"),
;
private final int code;
private final String message;
ErrorCode(int code, String message) {
this.code = code;
this.message = message;
}
public int getCode() {
return code;
}
public String getMessage() {
return message;
}
}
此类主要解析忽略路径,需要在配置文件中进行维护
package com.yaoteng.paltform.system.config;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
@ConfigurationProperties(prefix = "security.ignore")
@Component
public class SecurityIgnoreUrl {
/**
* url集合
*/
private String[] urls;
public String[] getUrls() {
return urls;
}
public void setUrls(String[] urls) {
this.urls = urls;
}
}
配置文件如下:
# 放行的url
security:
ignore:
urls:
- /api/v1/user/login
- /api/v1/user/**
- /swagger-ui.html
- /swagger-resources/**
- /doc.html
- /v2/api-docs
- /webjars/**
- /druid/**
- /favicon.ico
package com.yaoteng.paltform.system.utils;
import com.auth0.jwt.JWT;
import com.auth0.jwt.algorithms.Algorithm;
import com.auth0.jwt.exceptions.JWTVerificationException;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.Date;
/**
* Jwt工具类(认证加密解密工具类,谨慎修改)
* 这个也是随便拷贝的,用生成和解密就完事了
*/
public final class JwtUtil {
public static final String SECRET = "ADF!@#DF#3";
private JwtUtil() {
}
public static String generateToken(Long userId, String username) {
return JWT.create()
.withClaim("userId", userId)
.withClaim("username", username)
.withExpiresAt(Instant.now().plus(1, ChronoUnit.DAYS))
.sign(Algorithm.HMAC256(SECRET));
}
public static String getUsername(String token) {
return JWT.decode(token).getClaim("username").asString();
}
public static Long getUserId(String token) {
return JWT.decode(token).getClaim("userId").asLong();
}
public static boolean verify(String token) {
try {
JWT.require(Algorithm.HMAC256(SECRET)).build().verify(token);
return true;
} catch (JWTVerificationException | IllegalArgumentException e) {
return false;
}
}
public static boolean isExpired(String token) {
return JWT.decode(token).getExpiresAt().before(new Date());
}
}
package com.yaoteng.paltform.system.exception;
import com.yaoteng.paltform.enums.ErrorCode;
public class ServiceException extends RuntimeException {
private int code = 500;
public ServiceException(ErrorCode errorCode) {
super(errorCode.getMessage());
this.code = errorCode.getCode();
}
public ServiceException() {
}
public ServiceException(String message) {
super(message);
}
public ServiceException(String message, Throwable cause) {
super(message, cause);
}
public ServiceException(Throwable cause) {
super(cause);
}
public ServiceException(String message, Throwable cause, boolean enableSuppression, boolean writableStackTrace) {
super(message, cause, enableSuppression, writableStackTrace);
}
public int getCode() {
return code;
}
}