代码仓库地址
WebSocket是一种在Web应用程序中用于实现双向通信的协议。WebSocket协议有着以下的一些特性:
双向通信:WebSocket允许客户端和服务器之间建立持久性的双向通信通道。这意味着客户端和服务器可以同时发送和接收数据,而不需要等待请求-响应周期。
实时性: WebSocket非常适合需要实时数据传输的应用程序,如在线聊天、在线游戏、实时协作工具和股票市场数据更新。它允许信息在服务器和客户端之间实时交换,而无需轮询或刷新页面。
低延迟:由于WebSocket连接是持久的,因此它可以降低通信的延迟。与传统的HTTP请求-响应模式相比,WebSocket减少了每次请求的开销。
轻量级: WebSocket协议相对较轻量,通信头部较小,因此它在传输数据时效率很高。
跨域通信: WebSocket允许跨域通信,这意味着一个网站上的WebSocket客户端可以与另一个域上的WebSocket服务器进行通信,从而支持更多的互联网应用场景。
简而言之,WebSocket协议是一种在单个TCP连接上进行全双工通信的协议,它允许服务器和客户端之间建立持久性连接,以便它们可以实时地交换数据,而不需要像HTTP那样的请求-响应模式。在海量并发及客户端与服务器交互负载流量大的情况下,WebSocket极大地节省了网络带宽资源的消耗,有明显的性能优势,且客户端发送和接受消息是在同一个持久连接上发起,实时性优势明显。以下使用Springboot框架,基于WebSocket搭建简易聊天室,更直观地体现WebSocket协议的特性。
首先为Springboot导入依赖
<!-- websocket --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-websocket</artifactId> </dependency>
然后通过WebSocketConfig.java配置类注入一个ServerEndpointExporter,该Bean会自动注册使用@ServerEndpoint注解标注的websocket endpoint,它是一个用于WebSocket端点的导出器,可以实现以下功能:
启用WebSocket支持: ServerEndpointExporter负责扫描并注册带有@ServerEndpoint注解的WebSocket端点类。这些端点类定义了WebSocket端点的行为,包括消息处理、连接管理等。通过注入ServerEndpointExporter,你启用了Spring对WebSocket的支持,使得你可以使用WebSocket端点来处理WebSocket连接和消息。
自动注册WebSocket端点:ServerEndpointExporter会自动扫描@ServerEndpoint注解标记的类,并将它们注册为WebSocket端点,这样它们就可以处理客户端的WebSocket连接。不需要手动注册每个WebSocket端点,Spring会自动处理。
WebSocket会话管理:ServerEndpointExporter还负责管理WebSocket会话的生命周期。它会处理WebSocket连接的打开、关闭等事件,并确保WebSocket会话的管理和维护。
WebSocket端点的部署和配置:通过ServerEndpointExporter,可以将WebSocket端点部署到Spring应用程序中,而无需手动配置WebSocket容器或处理WebSocket连接的细节。这简化了WebSocket应用程序的开发和配置。
配置类代码如下:
package com.websocket.config; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.web.socket.server.standard.ServerEndpointExporter; @Configuration public class WebSocketConfig { @Bean public ServerEndpointExporter serverEndpointExporter() { return new ServerEndpointExporter(); } }
配置完毕后,即可开始编写服务端程序。在WebSocketServer.java类中,我们用SESSIONS记录当前在线连接,用SESSION_MAP记录用户与连接的对应关系。
private static final CopyOnWriteArraySet<Session> SESSIONS = new CopyOnWriteArraySet<>(); private static final Map<String, Session> SESSION_MAP = new HashMap<>(); private static final Logger log = LoggerFactory.getLogger(WebSocketServer.class);
使用 websocket 依赖提供的 @ServerEndpoint、 @OnOpen、@OnClose 、@OnMessage 等注解,结合 WebSocket 生命周期进行业务方法编写。当客户端与WebSocket端点建立连接、关闭连接或发送消息时,相应的注解方法会被自动触发。
@OnOpen:用于标记一个方法,该方法会在客户端与WebSocket端点成功建立连接时被调用。通常可以在这个方法中执行初始化工作,例如添加连接到管理器中。该方法参数可以包含Session对象,用于表示与客户端的WebSocket会话。
@OnClose:用于标记一个方法,该方法会在客户端与WebSocket端点的连接关闭时被调用。通常可以在这个方法中执行清理工作,例如从管理器中移除连接。方法参数同样可以包含Session对象。
@OnMessage:用于标记一个方法,该方法会在客户端发送消息到WebSocket端点时被调用。你可以在这个方法中处理接收到的消息,并向客户端发送响应。方法参数包括Session对象和表示接收消息内容的参数。
/** * 连接建立成功时调用的方法 * * @param session * @param username */ @OnOpen public void onOpen(Session session, @PathParam("username") String username) { if (username == null || "".equals(username) || "所有人".equals(username)) { log.info("用户名非法"); try { JSONObject jsonObject = new JSONObject(); jsonObject.set("msg", "用户名非法"); sendMessage(jsonObject.toString(), session); session.close(); } catch (IOException e) { log.error("关闭失败", e); } } else if (SESSION_MAP.containsKey(username)) { log.info("不能重复加入!该用户已加入 "); try { JSONObject jsonObject = new JSONObject(); jsonObject.set("msg", "该用户已加入,不得重复加入"); sendMessage(jsonObject.toString(), session); session.close(); } catch (IOException e) { log.error("关闭失败", e); } } else { JSONObject jsonObject = new JSONObject(); jsonObject.set("msg", "欢迎加入聊天室!"); sendMessage(jsonObject.toString(), session); SESSIONS.add(session); SESSION_MAP.put(username, session); log.info("新增会话,用户[{}], 当前在线人数为:{}", username, SESSION_MAP.size()); } // 刷新用户列表:将当前所有用户信息发送给客户端 JSONObject result = new JSONObject(); JSONArray array = new JSONArray(); for (Object key : SESSION_MAP.keySet()) { JSONObject jsonObject = new JSONObject(); jsonObject.set("username", key); // {"username", "zhang", "username": "admin"} array.add(jsonObject); } result.set("users", array); // {"users": [{"username": "zhang"}, {"username": "admin"}]} sendMsg2All(JSONUtil.toJsonStr(result)); }
/** * 连接关闭调用的方法 * * @param session * @param username */ @OnClose public void onClose(Session session, @PathParam("username") String username) { SESSIONS.remove(session); /**如果是主动关闭连接,那么将SESSION_MAP中用户名对应的Session置为空值; 如果是因为登录不合法关闭连接,则不影响此用户名对应的Session状态*/ if(SESSION_MAP.get(username) == session) { SESSION_MAP.remove(username); } log.info("连接关闭,移除[{}]的用户会话, 当前在线人数为={}", username, SESSIONS.size()); }
/** * 收到客户端消息后调用的方法 * 后台收到客户端发送过来的消息 * onMessage 是一个消息的中转站 * 接收浏览器端 socket.send 发送过来的 json数据 * * @param message 客户端发送过来的消息 */ @OnMessage public void onMessage(String message, Session session, @PathParam("username") String username) { log.info("收到消息,来自[{}]的消息[{}]", username, message); JSONObject obj = JSONUtil.parseObj(message); // to表示发送给哪个用户,比如 admin String toUsername = obj.getStr("to"); // 发送的消息文本 hello String text = obj.getStr("text"); // {"to": "admin", "text": "聊天文本"} /** * 群发消息 */ if(toUsername.equals("所有人")){ Set<String> set = SESSION_MAP.keySet(); Iterator<String> it = set.iterator(); while (it.hasNext()){ Session toSession = SESSION_MAP.get(it.next()); if(toSession == session) continue; JSONObject jsonObject = new JSONObject(); jsonObject.set("from", username); jsonObject.set("text", text); this.sendMessage(jsonObject.toString(), toSession); } log.info("[{}]群发消息成功:发送消息[{}]", username, text); return; } /** * 根据 to用户名来获取 session,再通过session发送消息文本 */ Session toSession = SESSION_MAP.get(toUsername); if (toSession != null) { // 服务器端 再把消息组装一下,组装后的消息包含发送人和发送的文本内容 // {"from": "zhang", "text": "hello"} JSONObject jsonObject = new JSONObject(); jsonObject.set("from", username); jsonObject.set("text", text); this.sendMessage(jsonObject.toString(), toSession); log.info("发送消息成功:[{}]成功给[{}]发送消息[{}]", username, toUsername, jsonObject); } else { log.info("发送失败,未找到用户[{}]的会话", toUsername); } }
另外,还需要编写服务端给客户端发送消息的方法,为了体现更多的应用场景,本聊天室同时满足私聊和群聊功能,则需要编写向指定客户端发送消息以及向所有建立连接的客户端发送消息的方法
/** * 服务端发送消息给客户端 * * @param message * @param toSession */ private void sendMessage(String message, Session toSession) { try { toSession.getBasicRemote().sendText(message); log.info("成功发送[{}]消息给客户端(sessionId={})", message, toSession.getId()); } catch (Exception e) { log.error("发送消息失败", e); } } /** * 服务端发送消息给所有客户端 * * @param message */ private void sendMsg2All(String message) { try { for (Session session : SESSION_MAP.values()) { try { session.getBasicRemote().sendText(message); log.info("服务端成功发送消息[{}]给客户端[{}]", message, session.getId()); } catch (Exception e) { log.error("发送消息失败", e); } } } catch (Exception e) { log.error("发送消息失败", e); } }
前端的实现非常简单,由于本文侧重于展现WebSocket的生命周期,仅使用必要的组件
为了达到识别用户的效果,这里使用localStorage存储连接用户信息,在该信息设置的过期时限前,用户再次连接可以触发提示并显示上次连接的时间
/** *检查localStorage中是否存储此用户名,是否已过期,若用户名存在且未过期,则可识别此用户(设置过期时长为24h) */ var expiration = new Date().getTime() + (24 * 60 * 60 * 1000); var storedData = localStorage.getItem(this.username); if (storedData) { storedData = JSON.parse(storedData); var currentTime = new Date().getTime(); if (currentTime > storedData.expiration) { // 数据已过期,清除 localStorage.removeItem(this.username) storedData = null } } //数据有效,则识别用户 if (storedData) { alert('欢迎回来,' + this.username + '\n你上次登录的时间为: ' + storedData.storageTime) } localStorage.setItem(this.username, JSON.stringify({ value: this.username, expiration: expiration, storageTime: this.thisTime()}))
总体的vue代码index.html如下:
<body> <style> * { margin: 0; padding: 0; } .box { width: 50%; margin: 20px auto 0; } .box > div { margin-top: 20px; } .box > div:nth-child(odd) { background-color: #fafafa; } .box .talk-panel p:nth-child(odd) { background-color: #fafafa; } </style> <div id="app"> <div class="box" v-if="able"> <h1> 聊 天 室 </h1> <div> 输入想要使用的用户名: <input v-model.trim="username"> </div> <div class="status"> <div> <button :disabled="connected" @click="connect">连接</button> <button :disabled="!connected" @click="disconnect">断开连接</button> </div> 您的状态:<em :style="'color:' + (connected ? 'green':'red') "> {{ connected ? '已连接':'未连接' }} </em> </div> <div> <h2>在线用户列表</h2> <ul> <li v-for="(u,index) in online_users" :key="index" @click="talk_to = u.username"> <a href="javascript:;">{{u.username}}</a> </li> </ul> </div> <div> <h2>发送消息</h2> 对象: <select v-model="talk_to"> <option v-for="(u,index) in online_users" :key="index"> {{ u.username }} </option> <option value="所有人">所有人</option> </select> <br/> 消息:<input v-model="talk_content" placeholder="请输入想发送的内容"> <br/> <button @click="sendMessage">发送消息</button> </div> <div class="talk-panel"> <h2>聊天消息</h2> <p v-for="(item,index) in talk_history" :key="index"> [{{item.time || timeFormatter}}], [{{item.from}}] 发送给 [{{item.to}}] >>> [{{item.message}}] </p> </div> </div> </div> </body> <script class="lazyload" src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsQAAA7EAZUrDhsAAAANSURBVBhXYzh8+PB/AAffA0nNPuCLAAAAAElFTkSuQmCC" data-original="js/vue.js"></script> <script> new Vue({ el: '#app', data() { return { username: 'User', online_users: [], message_list: [], able: true, // 浏览器是否支持 ws talk_to: undefined, talk_content: undefined, // 聊天内容 connected: false,//是否已连接 ws_url: "ws://localhost:8888/ws/", // ws暴露地址 socket: undefined, talk_history: [] //格式 [{time: this.thisTime(), from: 'Other', to: 'Me', message: 'Hello'}] } }, create() { if (typeof (WebSocket) === 'undefined') { this.message('您的浏览器不支持 websocket !') this.able = false } }, mounted() { if (!this.able) alert('您的浏览器不支持 WebSocket') }, methods: { connect() { if (!this.username || this.username === '') { alert('请输入名字') return } if (this.connected) { alert('已连接') return } // 如果开启则关闭 if (this.socket) { this.socket.close() this.socket = null } // 初始化 this.talk_to = undefined this.talk_content = undefined this.talk_history = [] this.online_users = [] // 建立连接,编写回调 let ws_url = this.ws_url + this.username let that = this this.socket = new WebSocket(ws_url) /** * 执行@OnOpen注解函数,判断用户是否可以连接,通过即建立WebSocket连接 */ this.socket.onopen = function () { that.connected = true } /** *检查localStorage中是否存储此用户名,是否已过期 *若用户名存在且未过期,则可识别此用户(设置过期时长为24h) */ var expiration = new Date().getTime() + (24 * 60 * 60 * 1000); var storedData = localStorage.getItem(this.username); if (storedData) { storedData = JSON.parse(storedData); var currentTime = new Date().getTime(); if (currentTime > storedData.expiration) { // 数据已过期,清除 localStorage.removeItem(this.username) storedData = null } } //数据有效,则识别用户 if (storedData) { alert('欢迎回来,' + this.username + '\n你上次登录的时间为: ' + storedData.storageTime) } localStorage.setItem(this.username, JSON.stringify({ value: this.username, expiration: expiration, storageTime: this.thisTime()})) /** *当发送消息时,执行函数 */ this.socket.onmessage = function (msg) { console.log('收到消息,', msg) let data = JSON.parse(msg.data) // 判断拿到的消息类型。更新UI if (!data) return // 从server拿到的消息,拆包,共三种消息类型。 if (data.users ) { // 用户列表 that.online_users = data.users.filter(u => u.username !== that.username) } else if (data.from) { // 聊天信息 //console.log('聊天信息') that.talk_history.push({time: that.thisTime(), from: data.from, to: '我', message: data.text}) } else if (data.msg) { // 连接失败消息 alert(data.msg) } } this.socket.onerror = function () { alert('onerror, ws 发生了错误') } this.socket.onclose = function () { console.log('连接已关闭') that.connected = false that.online_users = [] that.talk_history = [] that.talk_to = undefined that.talk_content = undefined } }, disconnect() { console.log('disconnect') if (!this.connected) { alert('已关闭连接') } if (this.socket) { this.socket.close() } this.socket = null }, thisTime() { const now = new Date() return now.getFullYear() + "/" + (now.getMonth() + 1) + "/" + now.getDate() + " " + now.getHours() + ":" + now.getMinutes() + ":" + now.getSeconds() }, sendMessage() { if (!this.connected || !this.socket) { alert('请先登录') return } if (!this.talk_to) { alert('请选择聊天对象') return } if (!this.talk_content || this.talk_content === '') { alert('请输入聊天内容') return } console.log('talk_to = ', this.talk_to, ', msg = ', this.talk_content) this.socket.send(JSON.stringify({from: this.username, to: this.talk_to, text: this.talk_content})) this.talk_history.push({time: this.thisTime(), from: this.username, to: this.talk_to, message: this.talk_content}) } } }) </script>
最后聊天界面如图:
首先当然是向为本实验做出贡献的所有人特别是孟宁老师做出感谢。这个博客作为这个学期网络程序设计课程的结课报告,理应以对这门课程的评价作为结尾。尽管网络程序设计这门课程从开始到结课不过一个多月的时间,但这段短暂的课程时光给我带来的收获不亚于一些持续一个学期的课程。作为一名跨考生,在这门课程之前我对于网络体系这方面的知识几乎是一片空白,但是网络程序设计课程短短几周的时间内,我对Socket API及Socket编程,网络协议设计及RPC,TCP/IP协议栈等网络体系中的重点有了一定的了解,这显然得益于孟宁老师别开生面的授课方式,和一系列循序渐进的实验。孟宁老师上课生动有趣,善于把握重点,庖丁解牛般为我们梳理了当下较为前沿的网络体系结构以及互联网架构设计背后的渊源,设置的一系列课程实验也比较基础,让身为初学者的我也能充分理解并独立完成,这对于我掌握网络通信协议有很大的帮助。自由轻松的课程氛围,详实的课程资料和恰到好处的实验设置,这毫无疑问是我理想中的研究生课程。尽管课程已经结束,但今后身为软工人,我不会停下对网络程序设计的学习。坚持不懈,终有收获!