WebSocket并搭建简易聊天室
WebSocketWebSocketWebSocket特点代码基本框架客户端服务端代码示例发送与接收客户端实现服务端实现长连接在线聊天室设计思路客户端服务端代码存在的问题用户身份解决方法用户姓名的显示代码并发优化解决方法WebSocketWebSocket,是一个基于TCP的通讯协议,适用于网站应用。HTTP:短连接的TCP协议WebSocket: 长连接的TCP协议常见的应用场景:基于Web的聊
·
WebSocket
WebSocket
- WebSocket,是一个基于TCP的通讯协议,适用于网站应用。
- HTTP:短连接的TCP协议
- WebSocket: 长连接的TCP协议
- 常见的应用场景:基于Web的聊天室、在线客服
WebSocket特点
- 单一TCP长连接,采用全双工通信模式(HTTP是半双工,只能一请求,一答案,而不能同时进行)
- 对代理、防火墙透明
- 无头部信息、消息更精简
- 通过ping/pong 来保活
- 服务器可以主动推送消息给客户端,不在需要客户轮询
代码基本框架
WebSocket的代码框架:

- 客户端:用JS创建一个 WebSocket
- 服务端:用 @ServerEndPoint 创建一个服务
客户端
- 创建 WebSocket对象
- 指定服务地址
ws://127.0.0.1:8080/demo/websocket/chat - 指定事件处理的回调方法
onopen(), onclose(), onerror(), onmessage() - 发送消息 sock.send(msg)
服务端
- @ServerEndpoint(“/websocket/chat”)
指定服务地址 - @OnOpen @OnClose @OnError @OnMessage
分别指定4个事件的回调;
EndPoint: 一个服务;
Session: 代表了一路客户端连接;
注意:
服务地址
ws://127.0.0.1:8080/demo/websocket/chat,ws:// 表示协议为websocket,与 http:// 区分javax.websocket.Session代表了一个客户端连接
代码示例
客户端:chat.html
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title></title>
<script src="js/jquery.min.js" ></script>
<script src="js/afquery.js" ></script>
<link rel="stylesheet" href="css/common.css" />
</head>
<body>
<div>
</div>
</body>
<script>
// 打开WebSocket
// ws://127.0.0.1:8080/demo/websocket/chat
var sock = new WebSocket("ws://" + location.host + "/demo/websocket/chat");
// 指定回调方法
//连接完成触发的回调方法
sock.onopen = function() {
console.log('Client Socket Open');
};
//连接关闭触发的回调方法
sock.onclose = function(event) {
console.log('Client Socket Closed');
};
//连接通讯发生异常触发的回调方法
sock.onerror = function(event) {
console.log('Client Socket Error');
};
//通讯过程中受到信息触发的回调方法
sock.onmessage = function(event) {
console.log('** Client Receive:' + event.data);
};
</script>
</html>
服务端:
package my;
import java.io.IOException;
import javax.websocket.EndpointConfig;
import javax.websocket.OnClose;
import javax.websocket.OnError;
import javax.websocket.OnMessage;
import javax.websocket.OnOpen;
import javax.websocket.Session;
import javax.websocket.server.ServerEndpoint;
@ServerEndpoint("/websocket/chat")
public class ChatEndpoint
{
Session session; // 一个WebSocket连接,不是 HttpSession
public ChatEndpoint()
{
System.out.println("** 创建一个Endpoint...");
}
//连接完成触发的回调方法
@OnOpen
public void onOpen(Session session, EndpointConfig config)
{
this.session = session;
System.out.println("** 新的用户连接: ");
}
//通讯连接关闭触发的回调方法
@OnClose
public void onClose()
{
System.out.println("** 用户连接已关闭");
}
//通讯发生异常触发的回调方法
@OnError
public void onError(Session session, Throwable error)
{
System.out.println("** 用户连接出错");
error.printStackTrace();
}
//通讯过程中受到信息触发的回调方法
@OnMessage
public void onMessage(String message, Session session)
{
System.out.println("用户消息:" + message);
try
{
session.getBasicRemote().sendText("OK, welcome");
} catch (IOException e)
{
e.printStackTrace();
}
}
}
不需要记住这些代码,但是要记住发生的四个回调事件。
发送与接收
- 接收:由onmessage自动处理
- 发送: 等下看;
客户端实现
- sock.onmessage( event ):自动接收服务器发来的消息,event.data即为消息内容
- sock.send ( message ):调用send()方法即可以向服务器发送消息
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title></title>
<script src="js/jquery.min.js" ></script>
<script src="js/afquery.js" ></script>
<link rel="stylesheet" href="css/common.css" />
<style>
.container{
margin: 30px auto auto auto;
width: 800px;
}
.line{
margin: 10px 0;
}
.message{
width: calc(100% - 80px);
padding: 6px;
}
.chatroot{
width: 100%;
height: 300px;
font-size: 15px;
line-height: 130%;
}
</style>
</head>
<body>
<div class='container'>
<div class='line'>
<input type='text' class='message'>
<button onclick='my.doSend()'> 发送 </button>
</div>
<div class='line'>
<textarea class='chatroot'></textarea>
</div>
</div>
</body>
<script>
// 打开WebSocket
// ws://127.0.0.1:8080/demo/websocket/chat
var sock = new WebSocket("ws://" + location.host + "/demo/websocket/chat");
// 指定回调方法
sock.onopen = function() {
console.log('Client Socket Open');
};
sock.onclose = function(event) {
console.log('Client Socket Closed');
};
sock.onerror = function(event) {
console.log('Client Socket Error');
};
sock.onmessage = function(event) {
console.log('** Client Receive:' + event.data);
my.showMessage('<< ' + event.data);
};
var my = {};
// 发送
my.doSend = function(){
var message = $('.message').val();
my.showMessage('>> ' + message );
// 发送消息
sock.send( message );
}
// 显示消息
my.showMessage = function(message){
$('.chatroot').append(message + '\n');
}
</script>
</html>
服务端实现
- @OnMessage:自动接收客户端发来的消息
- session.getBasicRemote().sendText():向客户端发送消息
- 在ChatEndpoint.onOpen()里,记录当前Session
- 在服务端,由注解来指定回调。回调方法的名称是可以任意的,参数形式是确定的
- 服务端的EndPoint是多例:每一个客户端连接上来,服务器就创建一个ChatEndPoint对象与用户连接关联。
- 注意:因为EndPoint是多例的,在Spring IOC注入过程,EndPoint类用@Component注解修饰注入容器,由于IOC只会在应用启动过程中进行属性填充,如果我们再EndPoint里注入了一些属性(RestTemplate等),那么第一个连接属性才有填充,第二及以后的属性会报空指针异常。
```java
package my;
import java.io.IOException;
import javax.websocket.EndpointConfig;
import javax.websocket.OnClose;
import javax.websocket.OnError;
import javax.websocket.OnMessage;
import javax.websocket.OnOpen;
import javax.websocket.Session;
import javax.websocket.server.ServerEndpoint;
@ServerEndpoint("/websocket/chat")
public class ChatEndpoint
{
Session session; // 一个WebSocket连接,不是 HttpSession
public ChatEndpoint()
{
System.out.println("** 创建一个Endpoint...");
}
@OnOpen
public void onOpen(Session session, EndpointConfig config)
{
this.session = session;
System.out.println("** 新的用户连接: ");
}
@OnClose
public void onClose()
{
System.out.println("** 用户连接已关闭");
}
@OnError
public void onError(Session session, Throwable error)
{
System.out.println("** 用户连接出错");
error.printStackTrace();
}
@OnMessage
public void onMessage(String message, Session session)
{
System.out.println("用户消息:" + message);
try
{
session.getBasicRemote().sendText("OK, welcome");
} catch (IOException e)
{
e.printStackTrace();
}
}
}
长连接
- WebSocket是长连接模式:一次连接,多次消息交互
- WebSocket协议与 HTTP 协议是不同的
- 首次交互相似
- 后续的交互完全不同
在线聊天室
- 在线聊天室:使用WebSocket,实现多人在线聊天
- 每个人都可以发送消息,显示到聊天室里
设计思路
思路如下:
- 点‘发送’后,将消息发送到服务器
- 服务器把此消息转发给每一个用户
这意味着,服务器端要记下每一个用户client1, client2, …, clientN.
添加一个管理器 ChatRoom ,由它维护所有的客户端列表
- addClient( ) : 新客户连接时,添加一条记录
- removeClient() : 客户断开时,移除一条记录
- sendAll() : 给所有客户端发送一条消息
客户端
chat.html
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title></title>
<script src="js/jquery.min.js" ></script>
<script src="js/afquery.js" ></script>
<link rel="stylesheet" href="css/common.css" />
<style>
.container{
margin: 30px auto auto auto;
width: 800px;
}
.line{
margin: 10px 0;
}
.message{
width: calc(100% - 80px);
padding: 6px;
}
.chatroot{
width: 100%;
height: 300px;
font-size: 15px;
line-height: 130%;
}
</style>
</head>
<body>
<div class='container'>
<div class='line'>
<input type='text' class='message'>
<button onclick='my.doSend()'> 发送 </button>
</div>
<div class='line'>
<textarea class='chatroot'></textarea>
</div>
</div>
</body>
<script>
// 打开WebSocket
// ws://127.0.0.1:8080/demo/websocket/chat
var sock = new WebSocket("ws://" + location.host + "/demo/websocket/chat");
// 指定回调方法
sock.onopen = function() {
console.log('Client Socket Open');
};
sock.onclose = function(event) {
console.log('Client Socket Closed');
};
sock.onerror = function(event) {
console.log('Client Socket Error');
};
sock.onmessage = function(event) {
console.log('** Client Receive:' + event.data);
my.showMessage(': ' + event.data);
};
var my = {};
// 发送
my.doSend = function(){
var message = $('.message').val();
// my.showMessage('>> ' + message );
$('.message').val(''); // 清空输入
// 发送消息
sock.send( message );
}
// 显示消息
my.showMessage = function(message){
$('.chatroot').append(message + '\n');
}
</script>
</html>
服务端代码
聊天室ChatRoom.java
package my;
import java.util.Iterator;
import java.util.LinkedList;
import java.util.List;
public class ChatRoom
{
// 创建全局实例 ChatRoom.i
public static ChatRoom i = new ChatRoom();
// 客户端的列表
private List<ChatEndpoint> clientList = new LinkedList<>();
// 当有客户端连接时,将此客户端添加到 clientList
public void addClient(ChatEndpoint client)
{
synchronized (clientList)
{
clientList.add(client);
}
}
// 当有客户端断开时,将此客户端从 clientList 移除
public void removeClient(ChatEndpoint client)
{
synchronized (clientList)
{
clientList.remove(client);
}
}
// 发给所有人
public void sendAll(String message)
{
// 1 并发问题
// 2 效率问题
synchronized (clientList)
{
Iterator iter = clientList.iterator();
while(iter.hasNext())
{
ChatEndpoint client = (ChatEndpoint)iter.next();
client.sendMessage( message );
}
}
}
}
长连接类ChatEndpoint.java
package my;
import java.io.IOException;
import javax.websocket.EndpointConfig;
import javax.websocket.OnClose;
import javax.websocket.OnError;
import javax.websocket.OnMessage;
import javax.websocket.OnOpen;
import javax.websocket.Session;
import javax.websocket.server.ServerEndpoint;
@ServerEndpoint("/websocket/chat")
public class ChatEndpoint
{
Session session; // 一个WebSocket连接,不是 HttpSession
public ChatEndpoint()
{
System.out.println("** 创建一个Endpoint...");
}
@OnOpen
public void onOpen(Session session, EndpointConfig config)
{
this.session = session;
ChatRoom.i.addClient(this);
System.out.println("** 新的用户连接: ");
}
@OnClose
public void onClose()
{
ChatRoom.i.removeClient(this);
System.out.println("** 用户连接已关闭");
}
@OnError
public void onError(Session session, Throwable error)
{
ChatRoom.i.removeClient(this);
System.out.println("** 用户连接出错");
error.printStackTrace();
}
@OnMessage
public void onMessage(String message, Session session)
{
System.out.println("用户消息:" + message);
ChatRoom.i.sendAll(message);
}
public void sendMessage(String message)
{
try
{
session.getBasicRemote().sendText(message);
} catch (IOException e)
{
e.printStackTrace();
}
}
}
存在的问题
- 并发问题
每个ChatEndpoint是放在一个线程里运行的,每个线程要并发地访问 clientList 对象 - 效率问题
当用户多、消息多的时候,如何既能保证线程安全、又能及时将消息发送出去。 - 用户身份无法确定、发出的消息没法确认是谁发的。
- 我们使用Synchronized来保证线程安全解决并发问题、但是没有解决效率问题,两者必须先保证线程安全问题。
- 原因:多个用户同时竞争一个Synchronized锁,直接升级为轻量级锁或者重量级锁,导致效率问题。
用户身份
- 多人聊天时,每个用户应该有一个名字。
- 在项目中增加Spring支持;http://127.0.0.1:8080/demo/chatroom
后台:ChatController.chatroom()
前端:/template/chatroom.html - 在网站应用里,一般是从当前会话HttpSession中获取。所以 需要在Endpoint里,能够获取当前HttpSession
解决方法
- 添加 ChatEndpointConfig 类
public class ChatEndpointConfig extends Configurator
{
@Override
public void modifyHandshake(ServerEndpointConfig sec
, HandshakeRequest request
, HandshakeResponse response)
{
// 把 HttpSession 放到 ServerEndpointConfig 中
HttpSession httpSession = (HttpSession) request.getHttpSession();
sec.getUserProperties().put("httpSession", httpSession);
}
}
- 指定 Configurator
@ServerEndpoint(value="/websocket/chat", configurator=ChatEndpointConfig.class)
public class ChatEndpoint
{
}
- 在 OnOpen 中取得 HttpSession 。即当用户登录成功,进入聊天室建立WebSocket完成后,记录用户的信息;
@OnOpen
public void onOpen(Session session, EndpointConfig config)
{
// 从 EndpointConfig 获取当前 HttpSession对象
HttpSession httpSession =(HttpSession)config.getUserProperties().get("httpSession");
}
用户姓名的显示代码
客户端登录页面login.html,直接复制即可;
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>在线聊天室</title>
<script th:src="@{/js/jquery.min.js}" ></script>
<script th:src="@{/js/afquery.js}" ></script>
<link rel="stylesheet" th:href="@{/css/common.css}" />
<style>
.container{
margin: 30px auto auto auto;
width: 500px;
}
.form{
background:#fcfcfc;
padding: 10px;
}
.line{
margin: 10px 0;
}
.label{
display:inline-block;
width: 80px;
}
.line input{
width: 300px;
}
</style>
</head>
<body>
<div class='container form' >
<div class='line'>
登录聊天室
</div>
<div class='line'>
<span class='label'>用户名</span>
<input type='text' class='user' >
</div>
<div class='line'>
<span class='label'>密码</span>
<input type='password' class='password' >
</div>
<div class='line' style='margin-top: 40px'>
<button onclick='my.login()'> 登录 </button>
</div>
</div>
</body>
<script>
var my = {};
// 用户登录
my.login = function(){
var req = {};
req.user = $('.form .user').val().trim();
req.password = $('.form .password').val().trim();
Af.rest('[[@{/login.do}]]' , req, function(data){
location.href = '[[@{/chatroom}]]' ;
})
}
</script>
</html>
客户端聊天室页面chatroom.html,直接复制即可;
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>在线聊天室</title>
<script th:src="@{/js/jquery.min.js}" ></script>
<script th:src="@{/js/afquery.js}" ></script>
<link rel="stylesheet" th:href="@{/css/common.css}" />
<style>
.container{
margin: 30px auto auto auto;
width: 800px;
}
.line{
margin: 10px 0;
}
.message{
width: calc(100% - 80px);
padding: 6px;
}
.chatroot{
width: 100%;
height: 300px;
font-size: 15px;
line-height: 130%;
}
</style>
</head>
<body>
<div class='container'>
<!-- 已登录时显示 -->
<div class='line'>
当前用户: <span th:text='${session.user}' style='color:blue'></span>
</div>
<div class='line'>
<input type='text' class='message'>
<button onclick='my.doSend()'> 发送 </button>
</div>
<div class='line'>
<textarea class='chatroot'></textarea>
</div>
</div>
</body>
<script>
// 打开WebSocket
// ws://127.0.0.1:8080/demo/websocket/chat
var sock = new WebSocket("ws://" + location.host + "/demo/websocket/chat");
// 指定回调方法
sock.onopen = function() {
console.log('Client Socket Open');
};
sock.onclose = function(event) {
console.log('Client Socket Closed');
};
sock.onerror = function(event) {
console.log('Client Socket Error');
};
sock.onmessage = function(event) {
console.log('** Client Receive:' + event.data);
my.showMessage(event.data);
};
var my = {};
// 发送
my.doSend = function(){
var message = $('.message').val();
// my.showMessage('>> ' + message );
$('.message').val(''); // 清空输入
// 发送消息
sock.send( message );
}
// 显示消息
my.showMessage = function(message){
$('.chatroot').append(message + '\n');
}
</script>
</html>
登录验证类LoginController
package my;
import javax.servlet.http.HttpSession;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import com.alibaba.fastjson.JSONObject;
import af.spring.AfRestData;
@Controller
public class LoginController
{
@GetMapping("/login")
public String login()
{
return "login";
}
@PostMapping("/login.do")
public Object login(@RequestBody JSONObject jreq
, HttpSession session) throws Exception
{
// 登录
String user = jreq.getString("user");
session.setAttribute("user", user);
return new AfRestData("");
}
}
服务类EndPoint
package my;
import java.io.IOException;
import javax.servlet.http.HttpSession;
import javax.websocket.EndpointConfig;
import javax.websocket.OnClose;
import javax.websocket.OnError;
import javax.websocket.OnMessage;
import javax.websocket.OnOpen;
import javax.websocket.Session;
import javax.websocket.server.ServerEndpoint;
@ServerEndpoint(value="/websocket/chat", configurator=ChatEndpointConfig.class)
public class ChatEndpoint
{
Session session; // 一个WebSocket连接,不是 HttpSession
String user; // 当前用户身份,考虑从 HttpSession中获取
public ChatEndpoint()
{
System.out.println("** 创建一个Endpoint...");
}
@OnOpen
public void onOpen(Session session, EndpointConfig config)
{
this.session = session;
ChatRoom.i.addClient(this);
System.out.println("** 新的用户连接: ");
// 从 EndpointConfig 获取当前 HttpSession对象
HttpSession httpSession = (HttpSession)config.getUserProperties().get("httpSession");
this.user = (String)httpSession.getAttribute("user");
if(user == null) user = "未登录";
}
@OnClose
public void onClose()
{
ChatRoom.i.removeClient(this);
System.out.println("** 用户连接已关闭");
}
@OnError
public void onError(Session session, Throwable error)
{
ChatRoom.i.removeClient(this);
System.out.println("** 用户连接出错");
error.printStackTrace();
}
@OnMessage
public void onMessage(String message, Session session)
{
System.out.println("用户消息:" + message);
ChatRoom.i.sendAll(user + ": " + message);
}
public void sendMessage(String message)
{
try
{
session.getBasicRemote().sendText(message);
} catch (IOException e)
{
e.printStackTrace();
}
}
}
配置类Configurator:
public class ChatEndpointConfig extends Configurator
{
@Override
public void modifyHandshake(ServerEndpointConfig sec
, HandshakeRequest request
, HandshakeResponse response)
{
// 把 HttpSession 放到 ServerEndpointConfig 中
HttpSession httpSession = (HttpSession) request.getHttpSession();
sec.getUserProperties().put("httpSession", httpSession);
}
}
聊天室类ChatRoom.java
package my;
import java.util.Iterator;
import java.util.LinkedList;
import java.util.List;
public class ChatRoom
{
// 创建全局实例 ChatRoom.i
public static ChatRoom i = new ChatRoom();
// 客户端的列表
private List<ChatEndpoint> clientList = new LinkedList<>();
// 当有客户端连接时,将此客户端添加到 clientList
public void addClient(ChatEndpoint client)
{
synchronized (clientList)
{
clientList.add(client);
}
}
// 当有客户端断开时,将此客户端从 clientList 移除
public void removeClient(ChatEndpoint client)
{
synchronized (clientList)
{
clientList.remove(client);
}
}
// 发给所有人
public void sendAll(String message)
{
// 1 并发问题
// 2 效率问题
synchronized (clientList)
{
Iterator iter = clientList.iterator();
while(iter.hasNext())
{
ChatEndpoint client = (ChatEndpoint)iter.next();
client.sendMessage( message );
}
}
}
}
至此聊天室代码基本完工了。剩下效率问题没搞定;
并发优化
- 并发问题:假设1000人在访问这个聊天室
- 有的人正在进入 clientList.add()
- 有的人正在断开 clientList.remove()
- 有的人正在发送消息 clientList.iterator()
- 多个线程,最终都要并发地访问同一个对象
ChatRoom.i.clientList
分析用户的操作:
- 用户的加入与断开,是个低频操作
- 消息的群发,是个高频、高耗时的操作
这种场景就是典型的读多写少,且数据一致性不会那么严苛。
解决方法
- 针对读夺写少场景,可以使用CopyOnWriteArrayList线程安全类;
- 参照读写分离思想,让写操作对应一个对象,让读操作对应一个拷贝对象;
package my;
import java.util.Iterator;
import java.util.LinkedList;
import java.util.List;
public class ChatRoom
{
// 创建全局实例 ChatRoom.i
public static ChatRoom i = new ChatRoom();
// 客户端的列表
private List<ChatEndpoint> clientList = new LinkedList<>();
private List<ChatEndpoint> clientListCopy = new LinkedList<>();;
// 当有客户端连接时,将此客户端添加到 clientList
public void addClient(ChatEndpoint client)
{
synchronized (clientList)
{
clientList.add(client);
// 生成一份新的拷贝
clientListCopy = new LinkedList<>( clientList);
}
}
// 当有客户端断开时,将此客户端从 clientList 移除
public void removeClient(ChatEndpoint client)
{
synchronized (clientList)
{
clientList.remove(client);
// 生成一份新的拷贝
clientListCopy = new LinkedList<>( clientList);
}
}
// 发给所有人
public void sendAll(String message)
{
// 1 并发问题
// 2 效率问题
Iterator iter = clientListCopy.iterator();
while(iter.hasNext())
{
ChatEndpoint client = (ChatEndpoint)iter.next();
client.sendMessage( message );
}
}
}
聊天室代码已经上传到资源里,想要的就去下载。
更多推荐



所有评论(0)