前言
之前写毕业设计的时候就想加上聊天系统,当时已经用ajax长轮询实现了一个(还不懂什么是轮询机制的,猛戳这里:),但由于种种原因没有加到毕设里面。后来回校答辩后研究了一下websocket,并参照网上资料写了一个简单的聊天,现在又重新整理并记录下来。
以下介绍来自维基百科: WebSocket是一种在单个TCP连接上进行全双工通信的协议。WebSocket通信协议于2011年被IETF定为标准RFC 6455,并由RFC7936补充规范。WebSocket API也被W3C定为标准。 WebSocket使得客户端和服务器之间的数据交换变得更加简单,允许服务端主动向客户端推送数据。在WebSocket API中,浏览器和服务器只需要完成一次握手,两者之间就直接可以创建持久性的连接,并进行双向数据传输。 这里可以看一下官网介绍: 官网里面介绍非常详细,我就不做搬运工了,要是有像我一样英语不好的同学,右键->翻译成简体中文 spring对websocket的支持: 这里有一份spring对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
org.springframework.boot spring-boot-starter-websocket org.springframework.boot spring-boot-starter-thymeleaf
配置文件
#修改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页面
聊天页面 正在与 聊天
登录用户: 请登录 在线人数: 0
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("" + userName + " [在线]"); } //在线人数 $("#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( " "); } //在线人数 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("0" + userName + " [在线]"); } } 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("0" + srcUserName + " [在线]"); } 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( " "); } 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( " "); $("#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" ? " " : ""; $("#hz-message-body").append( " "); } } 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; }0
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 publicT getEndpointInstance(Class clazz){ return context.getBean(clazz); } @Override public void setApplicationContext(ApplicationContext applicationContext) throws BeansException { MyEndpointConfigure.context = applicationContext; }}
WebSocketConfig
/** * WebSocket配置 */@Configurationpublic 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 MapsessionMap = new HashMap (); /** * 注入其他类(换成自己想注入的对象) */ @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 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 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 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 getOnlineList(String username) { List list = new ArrayList (); //遍历webSocketMap for (Entry 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代码,在子类引入父类,如果我们没有父类,只有一个子类,把两个整合一下就可以了
4.0.0 cn.huanzi.qch parent 1.0.0 pom org.springframework.boot spring-boot-starter-parent 2.1.0.RELEASE SpringBoot系列demo代码 org.springframework.boot spring-boot-starter org.springframework.boot spring-boot-starter-web org.springframework.boot spring-boot-starter-test test org.projectlombok lombok org.springframework.boot spring-boot-devtools true runtime org.springframework.boot spring-boot-maven-plugin ${project.artifactId} ../package
4.0.0 springboot-websocket 0.0.1 springboot-websocket SpringBoot系列——WebSocket cn.huanzi.qch parent 1.0.0 org.springframework.boot spring-boot-starter-websocket org.springframework.boot spring-boot-starter-thymeleaf org.springframework.boot spring-boot-maven-plugin
在后记的部分我们就提到要加上持久化存储,事实上我们已经开始慢慢在写一套简单的IM即时通讯,已经实现到第三版了,持续更新中...
代码开源
代码已经开源、托管到我的GitHub、码云:
GitHub:
码云: