WebSockect 实现聊天室
说明:最近二手交易课设有一个需求是实现 购买者和商品发布者有一个聊天对话的功能,类似于咸鱼的聊天对话功能吧。想到的就是 WebSocket 协议来实现,问了一个前端小伙伴,他一般使用 socketio(一个 websocket 框架),我看了下也有 java 版的,但思考了下决定还是使用原生 websocket 来写前后端。
什么是 websocket?
这里放一个知乎的高赞回答,看完很清楚。WebSocket 是什么原理?为什么可以实现持久连接? - Ovear 的回答 - 知乎
首先我们先说说大家都很了解的 Http 协议,在 B/S 开发中,我们常用这种协议来进行各种响应和处理。
他的特点就是一个 Request 和 一个 Response 而且是无状态的(想要保持状态需要间接通过 session 和 cookie)。虽然在一些不那么复杂的需求下,这样的机制已经足够了,但是一些复杂的应用场景如需要一直监听某个数据的变化就显得力不从心了。当然我们也可以使用 ajax 来轮询,但这样其实是非常低效率的,你把自己想成服务器,假设有个人(客户端)一直在你耳边叨叨(数据更新了没。。。)我想你也一定要疯掉了吧。
其次 Http 协议的另一个特点,浏览器只能主动发送请求接收信息,不能被动接收服务器信息。这一点确实蛋疼,使得一旦数据有了变化我需要自己去请求,但是我又怎么知道数据什么时候更新了呢?
然鹅,websocket 的出现就可以巧妙的解决这些问题。
websocket 协由握手和数据传输构成
握手基于 HTTP 协议,然后客户端和服务端实现长连接,所以说 websocket 和 http 是有交集的。
那么数据如何传输呢?只需要在服务端设立转发的服务,那么数据就可以实现从 A 客户端到 B 客户端的发送,拿聊天举例,正是这种长连接机制以及允许客户端主动接收服务端消息的机制使得聊天消息能够看上去好像在两个客户端建立了连接。其实就是服务器做了一次转发。
Java 怎么写服务端的 WebSocket(SpringBoot)
非常类似 Servlet,这里我们需要写ServerEndPoint
这里我们主要重写三个方法
- onOpen(建立连接时自动调用)
- onMessage(接收消息时自动调用)
- onClose(关闭连接时自动调用)
当然还有 onError 等方法
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43
| @ServerEndpoint(value = "/chat",configurator = GetHttpSessionConfigurator.class)
public class ChatEndPoint {
@OnOpen public void onOpen(Session session, EndpointConfig config){
}
@OnMessage public void onMessage(String message,Session session){
}
@OnClose public void onClose(Session session){
}
}
|
这里需要注意,上面 session 指的是 websocket 的 session,不是 http 的 session,也是用来标识每一个长连接的对象,看到这里聪明的小伙伴应该能想到实现消息转发可以用 session 来标识每一个用户。
所以我们想要实现聊天消息转发可以使用 map 来存储 websocket 的 session。这里我存储 EndPoint 实例类似,因为我们可以使用 endpoint 实例来获取 session 对象,记住每一个用户进行一次 websocket 长连接,就会创建一个 endpoint 对象。
1 2 3 4
|
private static Map<String,ChatEndPoint> users = new ConcurrentHashMap<>();
|
说到这里有小伙伴想问了,项目的一些数据存储在 HttpSession 中既然 websocket 是基于 http 的,那么我能不能取出 Httpsession 在 Endpoint 里使用啊?答案是可以的,只需要在 springboot 配置中在注入对象前 ServerEndpointConfig 放入这个 httpsession 就可以了
配置:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24
| @Configuration
public class WebSocketConfig { @Bean public ServerEndpointExporter serverEndpointExporter(){ return new ServerEndpointExporter(); } }
public class GetHttpSessionConfigurator extends ServerEndpointConfig.Configurator { @Override public void modifyHandshake(ServerEndpointConfig sec, HandshakeRequest request, HandshakeResponse response) { HttpSession httpSession = (HttpSession)request.getHttpSession();
sec.getUserProperties().put(HttpSession.class.getName(),httpSession); } }
@ServerEndpoint(value = "/chat",configurator = GetHttpSessionConfigurator.class)
|
获取:
1 2 3 4 5 6 7 8 9
| @OnOpen public void onOpen(Session session, EndpointConfig config){
this.session = session;
HttpSession httpSession = (HttpSession) config.getUserProperties().get(HttpSession.class.getName()); }
|
前端怎么写 WebSocket
前端其实也类似,写起来更简单,只需要 new 一个 websocket 对象就能够实现连接。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| let ws: any = null;
export default { connect() { ws = new WebSocket('ws://localhost:8081/api/chat'); }, getWs() { return ws; },
removeWs() { ws.close(); }, };
|
1 2 3 4 5 6 7 8 9 10 11 12
| ws.connect();
ws.getWs().onopen = function () {
};
ws.getWs().onmessage = function (evt) { } ws.getWs().onclose = function () {
};
|
聊天怎么实现
思路
- 每一个客户端与服务端建立连接就将 EndPoint 实例存入 userHashMap(这里使用静态的)中。
- 客户端断开连接,就将此用户从 userHashMap 去除,所以 userHashMap 始终存储在线用户
- 客户端发消息,消息内容需要有发消息人,收消息人,内容,时间,封装成一个对象。
- 服务端 onmessage 接收到就检查用户里 userHashMap 有没有此人(有表示在线),有就找到 session 直接转发给他,没有需要暂时存储到 chatsHashMap,存消息列表 。
- 那么我们还要修改 1 步骤,这里连接上就要检查 chatsHashMap 有没有自己的消息,有就转发给自己并去除 chatsHashMap 的消息,这样一来就实现了离线和在线用户的聊天功能。
后端全部代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226
| @Component @ServerEndpoint(value = "/chat",configurator = GetHttpSessionConfigurator.class)
public class ChatEndPoint {
private static Map<String,ChatEndPoint> users = new ConcurrentHashMap<>();
private static Map<String, ArrayList<String>> chats = new ConcurrentHashMap<>();
private Session session;
private HttpSession httpSession;
@OnOpen public void onOpen(Session session, EndpointConfig config){
this.session = session;
HttpSession httpSession = (HttpSession) config.getUserProperties().get(HttpSession.class.getName());
this.httpSession = httpSession;
User user = (User)httpSession.getAttribute(USER_LOGIN_STATE);
String uid = String.valueOf(user.getUid());
users.put(uid,this);
if(!chats.containsKey(uid)) {
ArrayList<String> arr = new ArrayList<>(); System.out.println(uid+"调用了一次"); chats.put(uid,arr);
} String message = MessageUtils.getMessage(true, null,null,null, getUsers());
System.out.println(message); broadcastAllUsers(MessageUtils.getMessage(true, null, null,null,"当前在线用户人数:"+users.size()+"人")); broadcastAllUsers(message);
ArrayList<String> chatCache = chats.get(uid);
for(String chat:chatCache){
try { this.session.getBasicRemote().sendText(chat); } catch (IOException e) { e.printStackTrace(); } } chats.remove(uid);
}
private Set<String> getUsers(){
return ChatEndPoint.users.keySet();
}
private void broadcastAllUsers(String message){
Set<String> usersSet = users.keySet(); try {
for (String user : usersSet) { ChatEndPoint chatEndPoint = users.get(user); chatEndPoint.session.getBasicRemote().sendText(message); }} catch(IOException e){ e.printStackTrace(); }
}
@OnMessage public void onMessage(String message,Session session){
ObjectMapper mapper = new ObjectMapper(); try { Message mess = mapper.readValue(message, Message.class);
String toUid = mess.getToUid();
ChatEndPoint chatEndPoint = users.get(toUid);
User user = (User)httpSession.getAttribute(USER_LOGIN_STATE);
if(chatEndPoint!=null){
if(user==null){
throw new BusinessException(ErrorCode.LOGIN_ERROR); }
String message1 = MessageUtils.getMessage(false, user.getUserName(), user.getUid(),user.getPhone(), mess);
chatEndPoint.session.getBasicRemote().sendText(message1);
}else{
ArrayList<String> messages = chats.get(toUid);
if(messages == null){ messages = new ArrayList<>(); chats.put(toUid,messages);
}
messages.add(MessageUtils.getMessage(false, user.getUserName(), user.getUid(),user.getPhone(), mess));
}
} catch (Exception e) { e.printStackTrace(); }
}
@OnClose public void onClose(Session session){
User user = (User)httpSession.getAttribute(USER_LOGIN_STATE);
users.remove(user.getUid().toString());
System.out.println(user.getUserName()+"下线了,当前在线人数:"+users.size()+"人");
}
private String getUnix(){
long time = System.currentTimeMillis();
time = time / 1000;
return String.valueOf(time);
}
}
|
前端接收怎么存储呢?我暂时只想到存储在 sessionStorage 或者 localStorage 中,但这部分数据存储需要考虑去重和数据对应每个用户,不要疏忽了让别的用户看到了不属于自己的对话内容,那就出大问题了。整体思路挺简单的就是要细心。
实现展示
组件采用的是 react-jwchat 感觉挺好看的,各项功能都正常,就是有时候会 websocket 连接了就断开了,猜测是没写 error 处理吧。
写在最后
最后看我的二手交易系统的前后端源码(websocket 源码也在里面)
react-jwchat 聊天组件
二手交易系统前端
二手交易系统后端