阅读本文前,如果小伙伴对WebSocket API还不熟悉,建议先阅读以下文章:
WebSocket API简介和服务器端推送原理
本文介绍通过WebSocket API来创建一个聊天应用。如图1所示,客户1首先发送一条内容为“Hello”的消息,服务器会把这条消息推送到所有的客户端。
图1 服务器向所有的客户推送聊天消息
在图1中,客户1主动向服务器发送消息,然后收到了服务器返回的消息。而对于客户2和客户3,它们并没有主动向服务器发出请求,也会接收到服务器主动推送过来的消息,这体现了WebSocket的双向通信的功能。
用WebSocket创建聊天应用包含以下步骤:
(1)在Maven的pom.xml文件中加入WebSocket依赖
(2)在HelloappApplication启动类中注册ServerEndpointExporter Bean组件。
(3)创建服务器端点ChatServerEndpoint类。
(4)创建负责登录聊天室的控制器类:ChatController类。
(5)创建客户端的HTML文件:login.html和chat.html。login.html负责生成登录页面,chat.html负责客户端的WebSocket通信。
- 在Maven的pom.xml文件中加入WebSocket依赖
在Spring Boot框架中整合WebSocket,需要在Maven的pom.xml文件中加入以下依赖:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>
spring-boot-starter-websocket
</artifactId>
</dependency>
2. 注册ServerEndpointExporter Bean组件
Spring框架提供了一个ServerEndpointExporter类,用于扫描ServerEndpointConfig类和@ServerEndpoint注解。例程1的HelloappApplication类用@Configuration注解标识,表明它是Spring框架中的配置类。serverEndpointExporter()方法用@Bean注解标识,用于向Spring框架注册ServerEndpointExporter Bean组件。
例程1 HelloappApplication.java
@SpringBootApplication
@Configuration
public class HelloappApplication {
@Bean
public ServerEndpointExporter serverEndpointExporter(){
return new ServerEndpointExporter();
}
public static void main(String[] args) {
SpringApplication.run(HelloappApplication.class,args);
}
}
HelloappApplication类运行后,ServerEndpointExporter Bean组件就会投入工作,清点自己的人马,组建服务器端点团队,轰轰烈烈地与客户端开展WebSocket通信。
3. 创建服务器端点ChatServerEndpoint类
ChatServerEndpoint类作为服务器端点,用@ServerEndpoint注解标识,负责与客户端进行WebSocket通信。例程2是ChatServerEndpoint类的源代码,它会在WebSocket连接成功、接收到客户端的消息、WebSocket连接关闭,以及通信出现异常时执行特定的操作。
例程2 ChatServerEndpoint.java
@ServerEndpoint(value = "/dialog",
configurator = WebSocketConfigurator.class)
@Component
public class ChatServerEndpoint {
// 线程安全的集合,用来存放与每个客户端对应的Session对象
private static CopyOnWriteArraySet<Session> sessions =
new CopyOnWriteArraySet<Session>();
// 连接建立成功时触发的方法
@OnOpen
public void onOpen(Session session) {
sessions.add(session); // 加入sessions集合中
System.out.println(getUsername(session)
+"加入!当前在线人数为" + getOnlineCount());
}
// 连接关闭时触发的方法
@OnClose
public void onClose(Session session) {
sessions.remove(session); // 从sessions集合中删除
System.out.println(getUsername(session)
+"退出!当前在线人数为" + getOnlineCount());
}
// 收到客户端发送的消息时触发的方法
@OnMessage
public void onMessage(String message, Session session) {
System.out.println("收到消息:" + message);
// 群发消息
for (Session ses : sessions) {
try {
doSend(message,session);
} catch (IOException e) {
e.printStackTrace();
}
}
}
// 自定义的方法,发送消息
public void doSend(String message, Session session) {
session.getBasicRemote().sendText(
getUsername(session)+"发送的消息:"+message);
}
// 通信发生错误时触发的方法
@OnError
public void onError(Session session, Throwable error) {
System.out.println("发生错误");
error.printStackTrace();
}
// 自定义的方法,返回在线人数
public int getOnlineCount() {
return sessions.size();
}
// 自定义的方法,返回会话中的用户名
public String getUsername(Session session) {
return (String) session.getUserProperties()
.get("username");
}
}
ChatServerEndpoint类主要包含以下方法:
- 用@OnOpen注解标识的onOpen()方法: WebSocket连接建立后,会触发此方法。该方法把表示当前会话的Session对象保存到sessions集合中。
- 用@OnClose注解标识的onClose()方法:WebSocket连接关闭后,会触发此方法。该方法从sessions集合中删除表示当前会话的Session对象。
- 用@OnMessage注解标识的onMessage ()方法:当接收到客户端发送的消息时,会触发此方法。该方法再向所有的WebSocket会话中的客户端群发消息。
- 用@OnError注解标识的onError()方法:通信发生错误时会触发此方法。该方法会打印异常信息。
ChatServerEndpoint类有一个静态的sessions集合属性,用于存放所有的Session对象。每个Session对象表示客户与服务器的一个WebSocket会话。
Session接口的以下两个方法返回表示远程客户端点的RemoteEndpoint对象:
- getBasicRemote():返回类型为RemoteEndpoint.Basic。返回支持同步通信的RemoteEndpoint对象。
- getAsyncRemote():返回类型为RemoteEndpoint.Async。返回支持异步通信的RemoteEndpoint对象。
RemoteEndpoint.Basic和RemoteEndpoint.Async接口都有一个sendText()方法,用于向客户端发送文本消息。例如在ChatServerEndpoint类的doSend()方法中,以下代码向一个WebSocket会话中的客户端发送消息:
// 向客户端发送消息
session.getBasicRemote().sendText( getUsername(session)+"发送的消息:"+message);
4. 创建负责登录聊天室的控制器类
ChatController控制器类与客户端仍然按照传统的HTTP协议进行通信,它包含两个请求处理方法:
- login():返回login.html登录页面。
- chat():当客户提交登录表单时,执行该方法。该方法把表示用户名的username请求参数保存到HTTP会话中,再返回chat.html页面。
例程3是ChatController类的源代码。
例程3 ChatController.java
@RestController
public class ChatController {
@RequestMapping(value={"/","/login"})
public ModelAndView login() throws Exception {
return new ModelAndView("login"); // 转到login.html
}
@RequestMapping("/chat")
public ModelAndView chat(String username,
HttpSession session) throws Exception {
// 把用户名保存到HTTP会话中
session.setAttribute("username", username);
return new ModelAndView("chat"); // 转到chat.html
}
}
在Spring Boot框架的application.properties配置文件中,指定视图文件的扩展名为.html:
spring.mvc.view.suffix=.html
因此ChatController类的login()方法返回的ModelAndView对象就表示login.html,chat()方法返回的ModelAndView对象就表示chat.html。
5. 在WebSocket会话中访问共享数据
大力:“在进行传统的HTTP通信时,可以把客户端的用户名存放在HTTP会话中。进行WebSocket通信时,可否把HTTP会话中的用户名导入到WebSocket会话中。”
卫琴姐:“可以的。可以在WebSocket握手阶段进行共享数据的复制。”
当客户提交登录表单时,ChatController类的chat()方法把表示用户名的username请求参数保存到HTTP会话中:
// 此处sesson表示HTTP会话,username表示共享数据
session.setAttribute("username", username);
在建立WebSocket会话前,会先进行WebSocket握手,握手过程使用的是传统的HTTP协议。因此可以在握手过程中读取HTTP会话中的用户名,再把它存放到服务器端点配置对象ServerEndpointConfig中。接下来WebSocket会话开始时,服务器端的WebSocket底层实现会把ServerEndpointConfig对象中的用户名再复制到WebSocket会话中,参见图2。
图2 HTTP会话中的用户名导入到WebSocket会话中的过程
大力:“在WebSocket握手阶段,为什么不把HTTP会话中的用户名直接复制到WebSocket会话中呢?”
卫琴:“因为在握手阶段,WebSocket会话还没生成呀。”
例程4的WebSocketConfigurator类是自定义的WebSocket配置器,它通过modifyHandshake()方法修改了默认的握手行为。在握手时,modifyHandshake()方法从HTTP会话中读取用户名,再把它保存到ServerEndpointConfig对象中。
例程4 WebSocketConfigurator.java
@Component
public class WebSocketConfigurator
extends ServerEndpointConfig.Configurator {
// 修改握手行为
@Override
public void modifyHandshake(ServerEndpointConfig sec,
HandshakeRequest request,
HandshakeResponse response){
ServletRequestAttributes attributes =
(ServletRequestAttributes) RequestContextHolder
.getRequestAttributes();
HttpSession session=attributes.getRequest()
.getSession(true);
// 把HTTP会话中的用户名保存到ServerEndpointConfig中
sec.getUserProperties()
.put("username",session.getAttribute("username") );
super.modifyHandshake(sec, request, response);
}
}
在WebSocket API中,ServerEndpointConfig实现类和表示WebSocket会话的Session实现类都有一个Map类型的userProperties属性,用来存放共享数据,用户名就存放在userProperties属性中。等到握手成功,WebSocket会话建立,WebSocket的底层实现会自动把ServerEndpointConfig对象的userProperties属性中的数据复制到Session对象的userProperties属性中。
ChatServerEndpoint类用@ServerEndpoint注解标识时,如果未设置configurator属性,就会采用默认的握手行为。以下代码设置了configurator属性,就会采用WebSocketConfigurator类指定的握手行为:
@ServerEndpoint(value = "/dialog",
configurator = WebSocketConfigurator.class)
@Component
public class ChatServerEndpoint{……}
ChatServerEndpoint的getUsername()方法会读取存放在WebSocket会话中的用户名:
public String getUsername(Session session) {
return (String) session.getUserProperties()
.get("username");
}
6. 创建客户端的HTML文件
本范例的客户端包括两个HTML文件:
- login.html:登录页面,允许用户输入用户名,再提交表单。
- chat.html:和服务器建立WebSocket连接,然后接收和发送消息。
以下例程5和例程6分别是login.html和chat.html的源代码。
例程5 login.html
<html>
<head>
<meta charset="UTF-8">
<title>登录页面</title>
</head>
<body>
<form action="chat">
登录名:<input type="text" name="username"/>
<input type="submit" value="登录聊天室"/>
</form>
</body>
</html>
例程6 chat.html
<html>
<head>
<meta charset="UTF-8">
<title>聊天页面</title>
<script type="text/javaScript" src="jquery.min.js">
</script>
</head>
<body>
<form action="" >
请输入:<br><textarea rows="5" cols="10" id="inputMsg"
name="inputMsg"></textarea>
<p>
<input type="button" value="群发" onclick="doSend()"/>
<input type="button" value="结束聊天"
onclick="closeWebSocket()" />
</form>
</body>
<script type="text/javaScript">
var websocket =
new WebSocket("ws://localhost:8080/helloapp/dialog");
websocket.onopen = onOpen;
websocket.onmessage = onMessage;
websocket.onerror = onError;
websocket.onclose = onClose;
// 建立WebSocket连接时触发此方法
function onOpen() {
console.log("建立WebSocket连接");
}
// 接收到消息时触发此方法
function onMessage(evt) {
alert(evt.data);
}
// WebSocket通信出现错误时触发此方法
function onError() {
console.log("出现错误");
}
// WebSocket连接关闭时触发此方法
function onClose() {
console.log("关闭WebSocket连接");
}
// 向服务器发送消息
function doSend() {
if (websocket.readyState == websocket.OPEN) {
var msg = document.getElementById("inputMsg").value;
websocket.send(msg);
} else {
alert("连接失败!");
}
}
window.close=function() {
closeWebSocket();
}
function closeWebSocket() {
websocket.close();
alert("连接关闭");
}
</script>
</html>
在chat.html的脚本中,创建了一个客户端的WebSocket对象。当该对象创建成功,就意味着建立了与服务器的WebSocket连接:
var websocket = new WebSocket("ws://localhost:8080/helloapp/dialog");
以上WebSocket构造方法中的URL与服务器端的ChatServerEndpoint类的映射路径对应:
@ServerEndpoint(value = "/dialog",configurator = WebSocketConfigurator.class)
WebSocket对象的onopen、onmessage等属性用来设置在各种条件下触发的方法:
websocket.onopen = onOpen;
websocket.onmessage = onMessage;
websocket.onerror = onError;
websocket.onclose = onClose;
以上代码表明,当客户端与服务器进行WebSocket连接以及通信时,会触发客户端的以下方法:
- WebSocket连接建立成功后,触发onOpen()方法。
- WebSocket连接关闭后,触发onClose()方法。
- 收到服务器发送的消息时,触发onMessage()方法。
- WebSocket通信出现错误时,触发onError()方法。
当客户端与服务器进行WebSocket连接以及通信时,也会触发服务器端的相关方法。图3展示了客户端与服务器互相触发对方的方法的过程。
图3 客户端与服务器互相触发对方的方法
7. 运行范例程序
通过浏览器访问:
http://localhost:8080/helloapp/login
该请求由ChatController类的login()方法处理,它返回login.html,参见图4。
图4 login.html生成的网页
在图4的login.html网页上选择“登录聊天室”按钮,接下来由ChatController类的chat()方法处理。该方法把username请求参数保存到HTTP会话中,再把请求转发给chat.html,参见图5。
图5 chat.html生成的网页
当浏览器第一次访问chat.html时,会执行chat.html中的new WebSocket()语句,建立与服务器的WebSocket连接。接下来,用户在chat.html的网页上输入字符串“Hello”,然后选择“群发”按钮,客户端的doSend()方法会向服务器发送消息“Hello”,服务器再向客户端返回消息,客户端的onMessage()方法会显式接收到的消息,参见图6。
图6 客户端的onMessage()方法显示接收到的消息
分别打开两个浏览器,用不同的用户名登录,接着在一个浏览器的chat.html网页上输入字符串“Hello”,然后选择“群发”按钮,这时,服务器会把接收到的消息群发给所有的在线用户。因此在两个浏览器的chat.html网页上,都会显示接收到的消息。
上文参考孙卫琴的经典Java系列书籍。