前言
之前写毕业设计的时候就想加上聊天系统,当时已经用ajax长轮询实现了一个(还不懂什么是轮询机制的,猛戳这里:https://www.cnblogs.com/hoojo/p/longPolling_comet_jquery_iframe_ajax.html),但由于种种原因没有加到毕设里面。后来回校答辩后研究了一下websocket,并参照网上资料写了一个简单的聊天,现在又重新整理并记录下来。
以下介绍来自维基百科:
WebSocket是一种在单个TCP连接上进行全双工通信的协议。WebSocket通信协议于2011年被IETF定为标准RFC 6455,并由RFC7936补充规范。WebSocket API也被W3C定为标准。
WebSocket使得客户端和服务器之间的数据交换变得更加简单,允许服务端主动向客户端推送数据。在WebSocket API中,浏览器和服务器只需要完成一次握手,两者之间就直接可以创建持久性的连接,并进行双向数据传输。
这里可以看一下官网介绍:http://www.websocket.org/aboutwebsocket.html
官网里面介绍非常详细,我就不做搬运工了,要是有像我一样英语不好的同学,右键->翻译成简体中文
spring对websocket的支持:https://docs.spring.io/spring/docs/4.3.13.RELEASE/spring-framework-reference/htmlsingle/#websocket
这里有一份spring对websocket的详细介绍:https://docs.spring.io/spring/docs/5.1.8.RELEASE/spring-framework-reference/web.html#websocket
四个大章节,内容很多,就不一一展开介绍了
效果
2019/04/30补充:我们这个登录、登出非常简单,就一个请求地址,连页面都没有,所以刚开始我就没有贴出来,导致博客文章阅读起来比较吃力,现在在这里补充一下(由于项目后面有所改动,请求地址少了 springboot/,不过大家看得懂就行),这里只是一个小demo,所有就怎么简单怎么来
登录 http://localhost:10086/websocket/login/huanzi,
登出 http://localhost:10086/websocket/logout/huanzi
上下线有提示
如果这时候发送消息给离线的人,则会收到系统提示消息
群聊
本例中,点击自己是群聊窗口
huanzi一发送群聊,laowang跟xiaofang都不是在当前群聊窗口,出现小圆点+1
huanzi一发送群聊,xiaofang在当前群聊窗口,直接追加消息,老王不在对应的聊天窗口,出现小圆点+1
xiaofang回复,huanzi直接追加消息,laowang依旧小圆点+1
laowang点击群聊窗口,小圆点消失,追加群聊消息
laowang参与群聊
xiaofang切出群聊窗口,laowang在群聊发送消息,xiaofang出现小圆点+1
切回来,小圆点消失,聊天数据正常接收追加
三方正常参与聊天
私聊
huanzis私聊xiaofang,xiaofang聊天窗口在群聊,小圆点+1,而laowang不受影响
xiaofang切到私聊窗口,小圆点消失,数据正常追加;huanzi刚好处于私聊窗口,数据直接追加
效果演示到此结束,下面贴出代码
代码编写
首先先介绍一下项目结构
maven
<!-- springboot websocket --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-websocket</artifactId> </dependency> <!-- thymeleaf模板 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-thymeleaf</artifactId> </dependency>
配置文件
#修改thymeleaf访问根路径 spring.thymeleaf.prefix=classpath:/view/
socketChart.css样式
body{ background-color: #efebdc; } #hz-main{ width: 700px; height: 500px; background-color: red; margin: 0 auto; } #hz-message{ width: 500px; height: 500px; float: left; background-color: #B5B5B5; } #hz-message-body{ width: 460px; height: 340px; background-color: #E0C4DA; padding: 10px 20px; overflow:auto; } #hz-message-input{ width: 500px; height: 99px; background-color: white; overflow:auto; } #hz-group{ width: 200px; height: 500px; background-color: rosybrown; float: right; } .hz-message-list{ min-height: 30px; margin: 10px 0; } .hz-message-list-text{ padding: 7px 13px; border-radius: 15px; width: auto; max-width: 85%; display: inline-block; } .hz-message-list-username{ margin: 0; } .hz-group-body{ overflow:auto; } .hz-group-list{ padding: 10px; } .left{ float: left; color: #595a5a; background-color: #ebebeb; } .right{ float: right; color: #f7f8f8; background-color: #919292; } .hz-badge{ width: 20px; height: 20px; background-color: #FF5722; border-radius: 50%; float: right; color: white; text-align: center; line-height: 20px; font-weight: bold; opacity: 0; }
socketChart.html页面
<!DOCTYPE> <!--解决idea thymeleaf 表达式模板报红波浪线--> <!--suppress ALL --> <html xmlns:th="http://www.thymeleaf.org"> <head> <title>聊天页面</title> <!-- jquery在线版本 --> <script src="http://libs.baidu.com/jquery/2.1.4/jquery.min.js"></script> <!--引入样式--> <link th:href="@{/css/socketChart.css}" rel="stylesheet" type="text/css"/> </head> <body> <div id="hz-main"> <div id="hz-message"> <!-- 头部 --> 正在与<span id="toUserName"></span>聊天 <hr style="margin: 0px;"/> <!-- 主体 --> <div id="hz-message-body"> </div> <!-- 功能条 --> <div id=""> <button>表情</button> <button>图片</button> <button id="videoBut">视频</button> <button onclick="send()" style="float: right;">发送</button> </div> <!-- 输入框 --> <div contenteditable="true" id="hz-message-input"> </div> </div> <div id="hz-group"> 登录用户:<span id="talks" th:text="${username}">请登录</span> <br/> 在线人数:<span id="onlineCount">0</span> <!-- 主体 --> <div id="hz-group-body"> </div> </div> </div> </body> <script type="text/javascript" th:inline="javascript"> //项目根路径 var ctx = [[${#request.getContextPath()}]];//登录名 var username = /*[[${username}]]*/''; </script> <script th:src="@{/js/socketChart.js}"></script> </html>
socketChart.js 逻辑代码
//消息对象数组 var msgObjArr = new Array(); var websocket = null; //判断当前浏览器是否支持WebSocket, springboot是项目名 if ('WebSocket' in window) { websocket = new WebSocket("ws://localhost:10086/springboot/websocket/"+username); } else { console.error("不支持WebSocket"); } //连接发生错误的回调方法 websocket.onerror = function (e) { console.error("WebSocket连接发生错误"); }; //连接成功建立的回调方法 websocket.onopen = function () { //获取所有在线用户 $.ajax({ type: 'post', url: ctx + "/websocket/getOnlineList", contentType: 'application/json;charset=utf-8', dataType: 'json', data: {username:username}, success: function (data) { if (data.length) { //列表 for (var i = 0; i < data.length; i++) { var userName = data[i]; $("#hz-group-body").append("<div class="hz-group-list"><span class='hz-group-list-username'>" + userName + "</span><span id="" + userName + "-status">[在线]</span><div id="hz-badge-" + userName + "" class='hz-badge'>0</div></div>"); } //在线人数 $("#onlineCount").text(data.length); } }, error: function (xhr, status, error) { console.log("ajax错误!"); } }); } //接收到消息的回调方法 websocket.onmessage = function (event) { var messageJson = eval("(" + event.data + ")"); //普通消息(私聊) if (messageJson.type == "1") { //来源用户 var srcUser = messageJson.srcUser; //目标用户 var tarUser = messageJson.tarUser; //消息 var message = messageJson.message; //最加聊天数据 setMessageInnerHTML(srcUser.username,srcUser.username, message); } //普通消息(群聊) if (messageJson.type == "2"){ //来源用户 var srcUser = messageJson.srcUser; //目标用户 var tarUser = messageJson.tarUser; //消息 var message = messageJson.message; //最加聊天数据 setMessageInnerHTML(username,tarUser.username, message); } //对方不在线 if (messageJson.type == "0"){ //消息 var message = messageJson.message; $("#hz-message-body").append( "<div class="hz-message-list" style='text-align: center;'>" + "<div class="hz-message-list-text">" + "<span>" + message + "</span>" + "</div>" + "</div>"); } //在线人数 if (messageJson.type == "onlineCount") { //取出username var onlineCount = messageJson.onlineCount; var userName = messageJson.username; var oldOnlineCount = $("#onlineCount").text(); //新旧在线人数对比 if (oldOnlineCount < onlineCount) { if($("#" + userName + "-status").length > 0){ $("#" + userName + "-status").text("[在线]"); }else{ $("#hz-group-body").append("<div class="hz-group-list"><span class='hz-group-list-username'>" + userName + "</span><span id="" + userName + "-status">[在线]</span><div id="hz-badge-" + userName + "" class='hz-badge'>0</div></div>"); } } else { //有人下线 $("#" + userName + "-status").text("[离线]"); } $("#onlineCount").text(onlineCount); } } //连接关闭的回调方法 websocket.onclose = function () { //alert("WebSocket连接关闭"); } //将消息显示在对应聊天窗口 对于接收消息来说这里的toUserName就是来源用户,对于发送来说则相反 function setMessageInnerHTML(srcUserName,msgUserName, message) { //判断 var childrens = $("#hz-group-body").children(".hz-group-list"); var isExist = false; for (var i = 0; i < childrens.length; i++) { var text = $(childrens[i]).find(".hz-group-list-username").text(); if (text == srcUserName) { isExist = true; break; } } if (!isExist) { //追加聊天对象 msgObjArr.push({ toUserName: srcUserName, message: [{username: msgUserName, message: message, date: NowTime()}]//封装数据 }); $("#hz-group-body").append("<div class="hz-group-list"><span class='hz-group-list-username'>" + srcUserName + "</span><span id="" + srcUserName + "-status">[在线]</span><div id="hz-badge-" + srcUserName + "" class='hz-badge'>0</div></div>"); } else { //取出对象 var isExist = false; for (var i = 0; i < msgObjArr.length; i++) { var obj = msgObjArr[i]; if (obj.toUserName == srcUserName) { //保存最新数据 obj.message.push({username: msgUserName, message: message, date: NowTime()}); isExist = true; break; } } if (!isExist) { //追加聊天对象 msgObjArr.push({ toUserName: srcUserName, message: [{username: msgUserName, message: message, date: NowTime()}]//封装数据 }); } } // 对于接收消息来说这里的toUserName就是来源用户,对于发送来说则相反 var username = $("#toUserName").text(); //刚好打开的是对应的聊天页面 if (srcUserName == username) { $("#hz-message-body").append( "<div class="hz-message-list">" + "<p class='hz-message-list-username'>"+msgUserName+":</p>" + "<div class="hz-message-list-text left">" + "<span>" + message + "</span>" + "</div>" + "<div style=" clear: both; "></div>" + "</div>"); } else { //小圆点++ var conut = $("#hz-badge-" + srcUserName).text(); $("#hz-badge-" + srcUserName).text(parseInt(conut) + 1); $("#hz-badge-" + srcUserName).css("opacity", "1"); } } //发送消息 function send() { //消息 var message = $("#hz-message-input").html(); //目标用户名 var tarUserName = $("#toUserName").text(); //登录用户名 var srcUserName = $("#talks").text(); websocket.send(JSON.stringify({ "type": "1", "tarUser": {"username": tarUserName}, "srcUser": {"username": srcUserName}, "message": message })); $("#hz-message-body").append( "<div class="hz-message-list">" + "<div class="hz-message-list-text right">" + "<span>" + message + "</span>" + "</div>" + "</div>"); $("#hz-message-input").html(""); //取出对象 if (msgObjArr.length > 0) { var isExist = false; for (var i = 0; i < msgObjArr.length; i++) { var obj = msgObjArr[i]; if (obj.toUserName == tarUserName) { //保存最新数据 obj.message.push({username: srcUserName, message: message, date: NowTime()}); isExist = true; break; } } if (!isExist) { //追加聊天对象 msgObjArr.push({ toUserName: tarUserName, message: [{username: srcUserName, message: message, date: NowTime()}]//封装数据[{username:huanzi,message:"你好,我是欢子!",date:2018-04-29 22:48:00}] }); } } else { //追加聊天对象 msgObjArr.push({ toUserName: tarUserName, message: [{username: srcUserName, message: message, date: NowTime()}]//封装数据[{username:huanzi,message:"你好,我是欢子!",date:2018-04-29 22:48:00}] }); } } //监听点击用户 $("body").on("click", ".hz-group-list", function () { $(".hz-group-list").css("background-color", ""); $(this).css("background-color", "whitesmoke"); $("#toUserName").text($(this).find(".hz-group-list-username").text()); //清空旧数据,从对象中取出并追加 $("#hz-message-body").empty(); $("#hz-badge-" + $("#toUserName").text()).text("0"); $("#hz-badge-" + $("#toUserName").text()).css("opacity", "0"); if (msgObjArr.length > 0) { for (var i = 0; i < msgObjArr.length; i++) { var obj = msgObjArr[i]; if (obj.toUserName == $("#toUserName").text()) { //追加数据 var messageArr = obj.message; if (messageArr.length > 0) { for (var j = 0; j < messageArr.length; j++) { var msgObj = messageArr[j]; var leftOrRight = "right"; var message = msgObj.message; var msgUserName = msgObj.username; var toUserName = $("#toUserName").text(); //当聊天窗口与msgUserName的人相同,文字在左边(对方/其他人),否则在右边(自己) if (msgUserName == toUserName) { leftOrRight = "left"; } //但是如果点击的是自己,群聊的逻辑就不太一样了 if (username == toUserName && msgUserName != toUserName) { leftOrRight = "left"; } if (username == toUserName && msgUserName == toUserName) { leftOrRight = "right"; } var magUserName = leftOrRight == "left" ? "<p class='hz-message-list-username'>"+msgUserName+":</p>" : ""; $("#hz-message-body").append( "<div class="hz-message-list">" + magUserName+ "<div class="hz-message-list-text " + leftOrRight + "">" + "<span>" + message + "</span>" + "</div>" + "<div style=" clear: both; "></div>" + "</div>"); } } break; } } } }); //获取当前时间 function NowTime() { var time = new Date(); var year = time.getFullYear();//获取年 var month = time.getMonth() + 1;//或者月 var day = time.getDate();//或者天 var hour = time.getHours();//获取小时 var minu = time.getMinutes();//获取分钟 var second = time.getSeconds();//或者秒 var data = year + "-"; if (month < 10) { data += "0"; } data += month + "-"; if (day < 10) { data += "0" } data += day + " "; if (hour < 10) { data += "0" } data += hour + ":"; if (minu < 10) { data += "0" } data += minu + ":"; if (second < 10) { data += "0" } data += second; return data; }
java代码有三个类,MyEndpointConfigure,WebSocketConfig,WebSocketServer;
MyEndpointConfigure
/** * 解决注入其他类的问题,详情参考这篇帖子:webSocket无法注入其他类:https://blog.csdn.net/tornadojava/article/details/78781474 */ public class MyEndpointConfigure extends ServerEndpointConfig.Configurator implements ApplicationContextAware { private static volatile BeanFactory context; @Override public <T> T getEndpointInstance(Class<T> clazz){ return context.getBean(clazz); } @Override public void setApplicationContext(ApplicationContext applicationContext) throws BeansException { MyEndpointConfigure.context = applicationContext; } }
WebSocketConfig
/** * WebSocket配置 */ @Configuration public class WebSocketConfig{ /** * 用途:扫描并注册所有携带@ServerEndpoint注解的实例。 @ServerEndpoint("/websocket") * PS:如果使用外部容器 则无需提供ServerEndpointExporter。 */ @Bean public ServerEndpointExporter serverEndpointExporter() { return new ServerEndpointExporter(); } /** * 支持注入其他类 */ @Bean public MyEndpointConfigure newMyEndpointConfigure (){ return new MyEndpointConfigure (); } }
WebSocketServer
/** * WebSocket服务 */ @RestController @RequestMapping("/websocket") @ServerEndpoint(value = "/websocket/{username}", configurator = MyEndpointConfigure.class) public class WebSocketServer { /** * 在线人数 */ private static int onlineCount = 0; /** * 在线用户的Map集合,key:用户名,value:Session对象 */ private static Map<String, Session> sessionMap = new HashMap<String, Session>(); /** * 注入其他类(换成自己想注入的对象) */ @Autowired private UserService userService; /** * 连接建立成功调用的方法 */ @OnOpen public void onOpen(Session session, @PathParam("username") String username) { //在webSocketMap新增上线用户 sessionMap.put(username, session); //在线人数加加 WebSocketServer.onlineCount++; //通知除了自己之外的所有人 sendOnlineCount(session, "{'type':'onlineCount','onlineCount':" + WebSocketServer.onlineCount + ",username:'" + username + "'}"); } /** * 连接关闭调用的方法 */ @OnClose public void onClose(Session session) { //下线用户名 String logoutUserName = ""; //从webSocketMap删除下线用户 for (Entry<String, Session> entry : sessionMap.entrySet()) { if (entry.getValue() == session) { sessionMap.remove(entry.getKey()); logoutUserName = entry.getKey(); break; } } //在线人数减减 WebSocketServer.onlineCount--; //通知除了自己之外的所有人 sendOnlineCount(session, "{'type':'onlineCount','onlineCount':" + WebSocketServer.onlineCount + ",username:'" + logoutUserName + "'}"); } /** * 服务器接收到客户端消息时调用的方法 */ @OnMessage public void onMessage(String message, Session session) { try { //JSON字符串转 HashMap HashMap hashMap = new ObjectMapper().readValue(message, HashMap.class); //消息类型 String type = (String) hashMap.get("type"); //来源用户 Map srcUser = (Map) hashMap.get("srcUser"); //目标用户 Map tarUser = (Map) hashMap.get("tarUser"); //如果点击的是自己,那就是群聊 if (srcUser.get("username").equals(tarUser.get("username"))) { //群聊 groupChat(session,hashMap); } else { //私聊 privateChat(session, tarUser, hashMap); } //后期要做消息持久化 } catch (IOException e) { e.printStackTrace(); } } /** * 发生错误时调用 */ @OnError public void onError(Session session, Throwable error) { error.printStackTrace(); } /** * 通知除了自己之外的所有人 */ private void sendOnlineCount(Session session, String message) { for (Entry<String, Session> entry : sessionMap.entrySet()) { try { if (entry.getValue() != session) { entry.getValue().getBasicRemote().sendText(message); } } catch (IOException e) { e.printStackTrace(); } } } /** * 私聊 */ private void privateChat(Session session, Map tarUser, HashMap hashMap) throws IOException { //获取目标用户的session Session tarUserSession = sessionMap.get(tarUser.get("username")); //如果不在线则发送“对方不在线”回来源用户 if (tarUserSession == null) { session.getBasicRemote().sendText("{"type":"0","message":"对方不在线"}"); } else { hashMap.put("type", "1"); tarUserSession.getBasicRemote().sendText(new ObjectMapper().writeValueAsString(hashMap)); } } /** * 群聊 */ private void groupChat(Session session,HashMap hashMap) throws IOException { for (Entry<String, Session> entry : sessionMap.entrySet()) { //自己就不用再发送消息了 if (entry.getValue() != session) { hashMap.put("type", "2"); entry.getValue().getBasicRemote().sendText(new ObjectMapper().writeValueAsString(hashMap)); } } } /** * 登录 */ @RequestMapping("/login/{username}") public ModelAndView login(HttpServletRequest request, @PathVariable String username) { return new ModelAndView("socketChart.html", "username", username); } /** * 登出 */ @RequestMapping("/logout/{username}") public String loginOut(HttpServletRequest request, @PathVariable String username) { return "退出成功!"; } /** * 获取在线用户 */ @RequestMapping("/getOnlineList") private List<String> getOnlineList(String username) { List<String> list = new ArrayList<String>(); //遍历webSocketMap for (Entry<String, Session> entry : WebSocketServer.sessionMap.entrySet()) { if (!entry.getKey().equals(username)) { list.add(entry.getKey()); } } return list; } }
后记
后期把所有功能都补全就完美了,表情、图片都算比较简单,之前用轮询实现的时候写过了,但是没加到这里来;音视频聊天的话可以用WbeRTC来做,之前也研究了一下,不过还没搞完,这里贴一下维基百科对它的介绍,想了解更多的自行Google:
WebRTC,名称源自网页即时通信(英语:Web Real-Time Communication)的缩写,是一个支持网页浏览器进行实时语音对话或视频对话的API。它于2011年6月1日开源并在Google、Mozilla、Opera支持下被纳入万维网联盟的W3C推荐标准。
最后在加上持久化存储,注册后才能聊天,离线消息上线后接收,再加上用Redis或者其他的缓存技术支持,完美。不过聊天记录要做存储,表设计不知如何设计才合理,如果哪位大佬愿意分享可以留言给我,大家一起进步!
补充
2019-07-03补充:这里补充贴出pom代码,在子类引入父类,如果我们没有父类,只有一个子类,把两个整合一下就可以了
<?xml version="1.0" encoding="UTF-8"?> <project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> <modelVersion>4.0.0</modelVersion> <groupId>cn.huanzi.qch</groupId> <artifactId>parent</artifactId> <version>1.0.0</version> <packaging>pom</packaging> <parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>2.1.0.RELEASE</version> <relativePath/> </parent> <description>SpringBoot系列demo代码</description> <!-- 在父类引入一下通用的依赖 --> <dependencies> <!-- spring-boot-starter --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter</artifactId> </dependency> <!-- springboot web(MVC)--> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <!-- springboot --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> <scope>test</scope> </dependency> <!--lombok插件 --> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> </dependency> <!--热部署工具dev-tools--> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-devtools</artifactId> <optional>true</optional> <scope>runtime</scope> </dependency> </dependencies> <!--构建工具--> <build> <plugins> <plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> <configuration> <finalName>${project.artifactId}</finalName> <outputDirectory>../package</outputDirectory> </configuration> </plugin> </plugins> </build> </project>
<?xml version="1.0" encoding="UTF-8"?> <project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> <modelVersion>4.0.0</modelVersion> <artifactId>springboot-websocket</artifactId> <version>0.0.1</version> <name>springboot-websocket</name> <description>SpringBoot系列——WebSocket</description> <!--继承父类--> <parent> <groupId>cn.huanzi.qch</groupId> <artifactId>parent</artifactId> <version>1.0.0</version> </parent> <dependencies> <!-- springboot websocket --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-websocket</artifactId> </dependency> <!-- thymeleaf模板 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-thymeleaf</artifactId> </dependency> </dependencies> <build> <plugins> <plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> </plugin> </plugins> </build> </project>
在后记的部分我们就提到要加上持久化存储,事实上我们已经开始慢慢在写一套简单的IM即时通讯,已经实现到第三版了,持续更新中...
代码开源
代码已经开源、托管到我的GitHub、码云: