登录界面展示
登录界面模仿QQ登录操作,支持拖动、最小化和关闭
聊天界面展示
登录后的右侧显示在线用户,右下方显示在线用户的登录日志
窗口支持拖动、关闭操作
发送消息界面展示
在线用户实现及时聊天功能,可以对指定用户发起聊天(实现点对点的消息推送功能)
支持消息的本地缓存,聊天内容不会因为页面的刷新、关闭、退出登录等操作而丢失(消息只是做了简单缓存),清理浏览器缓存数据时消息数据便会丢失。
Demo的目录代码点对点消息推送,实现一对一的即时通信功能
下面是Demo的后端代码实现及说明 创建SpringBoot项目,导入依赖该Demo主要使用的是 SpringBoot + WebSocket 技术实现
项目pom依赖配置
项目配置文件说明1.8 1.8 1.8 UTF-8 UTF-8 org.springframework.boot spring-boot-starter-web org.springframework.boot spring-boot-starter-websocket org.springframework.boot spring-boot-devtools true true org.apache.commons commons-lang3 cn.hutool hutool-all 5.7.16 org.slf4j slf4j-api org.slf4j slf4j-log4j12 com.alibaba fastjson 1.2.55 org.springframework.boot spring-boot-maven-plugin
application.properties
# 项目名称 spring.application.name=websocket-chatroom # 配置资源路径 spring.resource.static-locations=classpath:/static/ # 视图前缀配置 spring.mvc.view.prefix=/chat/ # 视图后缀配置 spring.mvc.view.suffix=.html ###########################【热部署】######################### # 重启目录 spring.devtools.restart.additional-paths=src/main/java # 设置开启热部署 spring.devtools.restart.enabled=true # 设置字符集 spring.freemarker.charset=utf-8 # 页面不加载缓存,修改后立即生效 spring.freemarker.cache=false # 服务端口配置 server.port=80
application.yml
spring:
resource:
static-locations: classpath:/static/
application:
name: websocket-chatroom
mvc:
view:
suffix: .html
prefix: /chat/
devtools:
restart:
additional-paths: src/main/java
enabled: true
freemarker:
charset: utf-8
cache: false
server:
port: 80
项目的配置类说明
WebConfig.java
WebConfig 配置类
配置后项目启动时,访问默认项目路径时,会跳转到该类指定的页面中
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void addViewControllers(ViewControllerRegistry registry) {
registry.addViewController("/").setViewName("/chat/login");
}
}
WebSocketConfig.java
使用WebSocket前首先配置 @ServerEndpoint
首先需要注入 ServerEndpointExporter ,
这个bean会自动注册使用 @ServerEndpoint 注解来声明WebSocket endpoint。
注意:如果使用独立的Servlet容器,而不是直接使用SpringBoot内置容器,就不需要注入 ServerEndpointExporter,因为他将由容器自己提供和管理。
@Configuration
public class WebSocketConfig {
@Bean
public ServerEndpointExporter serverEndpointExporter() {
return new ServerEndpointExporter();
}
}
GetHttpSessionConfigurator.java
该类可以在建立连接后存放HttpSession,方便后续的使用
用户建立连接时,通过 EndpointConfig 获取存在 session 中的用户数据
public class GetHttpSessionConfigurator extends ServerEndpointConfig.Configurator {
@Override
public void modifyHandshake(ServerEndpointConfig sec,
HandshakeRequest request,
HandshakeResponse response) {
// 获取httpsession对象
HttpSession httpSession = (HttpSession) request.getHttpSession();
// 存放httpsession对象
Map userProperties = sec.getUserProperties();
userProperties.put(HttpSession.class.getName(), httpSession);
}
}
创建用户对象实体
@Data
public class User implements Serializable {
private static final long serialVersionUID = -31513108721728277L;
private String userId;
private String username;
private String phone;
private String password;
public User(String userId, String username, String password, String phone) {
this.userId = userId;
this.username = username;
this.password = password;
this.phone = phone;
}
public User() {
}
}
创建数据信息对象
使用数据信息对象来模拟数据存储数据,实现用户的登录和注册
import cn.hutool.core.lang.Validator;
import cn.molu.app.pojo.User;
import cn.molu.app.vo.R;
public class UserData {
public static Map userMap = new HashMap();
static {
//userMap.key=phone, userMap.value=user(id, name, password, phone);
userMap.put("150******67", new User("001", "jack", "123456", "150******67"));
userMap.put("135******88", new User("002", "mary", "123456", "135******88"));
userMap.put("136******66", new User("003", "tom", "123456", "136******66"));
userMap.put("159******21", new User("004", "tim", "123456", "159******21"));
userMap.put("188******88", new User("005", "jenny", "123456", "188******88"));
userMap.put("177******14", new User("006", "admin", "123456", "177******14"));
userMap.put("176******67", new User("007", "lover", "123456", "176******67"));
userMap.put("150******83", new User("008", "molu", "123456", "150******83"));
}
public static User loginByPhone(String phone) {
boolean mobile = Validator.isMobile(phone);
if (!mobile) {
return null;
}
User user = userMap.get(phone);
if (ObjectUtils.isBlank(user)) {
return null;
}
return user;
}
public static R register(HttpServletResponse res, User user) {
if (null == user) {
return R.err("请输入手机号、用户名和密码!");
}
String phone = user.getPhone();
String username = user.getUsername();
String password = user.getPassword();
ObjectUtils.checkNull(res, "手机号、用户名和密码不能为空!", phone, username, password);
user.setUserId(String.format("%03d", String.valueOf(userMap.size() + 1)));
userMap.put(phone, user);
return R.ok("注册成功!");
}
}
数据响应实体类
响应数据结果,存储用户基本数据,过滤掉用户的重要信息
@Data
public class Result {
private boolean flag;
private String message;
private String username;
private String userId;
private String dateStr;
public void setDateStr(Date date) {
this.dateStr = dateFormat(date);
}
public Result() {
super();
}
public static String dateFormat(Date date) {
return ObjectUtils.dateFormat(date);
}
}
消息接收和响应类
用于接收和返回数据前台数据
public class ResultMessage {
private Boolean systemMsgFlag;
private String fromName;
private String fromId;
private String toName;
private String toId;
private Object message;
private String dateStr;
public void setDateStr(String dateStr) {
this.dateStr = dateStr;
}
public String getDateStr(Date date) {
if (date == null) {
date = new Date();
}
return dateFormat(date);
}
public void setDateStr() {
this.dateStr = dateFormat(new Date());
}
public void setDateStr(Date date) {
if (date == null) {
date = new Date();
}
this.dateStr = dateFormat(date);
}
public ResultMessage() {
super();
}
public static String dateFormat(Object date) {
return ObjectUtils.dateFormat(date);
}
}
WS核心服务类
webSocket 的核心服务类,建立连接、发送消息、推送通知、关闭连接释放资源、消息处理等。。。
@Component
@ServerEndpoint(value = "/webSocket/{userId}", configurator = GetHttpSessionConfigurator.class)
public class ChatEndpoint {
private final static Logger LOGGER = LogManager.getLogger(ChatEndpoint.class);
private static Map onLineUsers = new ConcurrentHashMap();
public static Map cacheUser = new HashMap();
private HttpSession httpSession;
@OnOpen
public void onOpen(Session session, EndpointConfig endpointConfig) {
// 获取GetHttpSessionConfigurator中存放的HttpSession
Map userProperties = endpointConfig.getUserProperties();
HttpSession httpSession = (HttpSession) userProperties.get(HttpSession.class.getName());
this.httpSession = httpSession;
String username = ObjectUtils.getStr(httpSession.getAttribute("username")) ;
String userId = ObjectUtils.getStr(httpSession.getAttribute("userId"));
// 如果用户名和用户id为空,则直接返回
if (ObjectUtils.isEmpty(username,userId)) {
return;
}
LOGGER.info("{} 建立了连接。。。", username);
Result res = new Result();
res.setUserId(userId);
res.setUsername(username);
// 缓存数据
onLineUsers.put(userId, session);
cacheUser.put(userId, res);
// 组织消息体数据,将数据推送给所有在线用户
String message = getSysMessage(getusers());
// 调用方法进行系统消息的推送
broadcastAllUsers(message);
LOGGER.info("推送系统消息:{} ", message);
}
@OnMessage
public void onMessage(String message, Session session) throws IOException {
LOGGER.info("接收到消息:{}", message);
if (StringUtils.isBlank(message)) {
return;
}
// 心跳检测机制
if ("PING".toUpperCase().equals(message.toUpperCase())) {
heartCheck(session);
return;
}
LOGGER.info("接收到好友的消息:{}", message);
ResultMessage msgObj = JSON.parseObject(message, ResultMessage.class);
// 消息接收人id
String toId = msgObj.getToId();
// 获取消息体内容
String text = ObjectUtils.getStr(msgObj.getMessage());
// 获取推送指定用户数据
String resultMessage = getMessage(msgObj, text);
LOGGER.info("接收到好友发来的数据:{}", resultMessage);
// 点对点发送数据(给指定用户发送消息)
Session toSession = onLineUsers.get(toId);
if (toSession != null && toSession.isOpen()) {
Basic basicRemote = toSession.getBasicRemote();
basicRemote.sendText(resultMessage);
}
}
@OnClose
public void onClose(Session session) {
if (httpSession == null) {
return;
}
String username = ObjectUtils.getStr(httpSession.getAttribute("username"));
String userId = ObjectUtils.getStr(httpSession.getAttribute("userId"));
if (StringUtils.isBlank(userId)) {
return;
}
LOGGER.info("{}:关闭了连接。。。", username);
// 移除已关闭连接的用户
onLineUsers.remove(userId);
// 移除缓存的用户数据
cacheUser.remove(userId);
LOGGER.info("已从onLineUsers移除:{},从cacheUser中移除:{}", username, username);
String message = getSysMessage(getusers());
// 设置已关闭连接的用户数据在HttpSession域中有效时间
httpSession.setMaxInactiveInterval(1800);
broadcastAllUsers(message);
LOGGER.info("关闭连接,推送内容:{}", message);
}
@OnError
public void onError(Session session, Throwable error) {
LOGGER.info("连接出错了......{}", error);
}
private void broadcastAllUsers(String message) {
try {
// 将消息推送给所有的在线用户
Set ids = getIds();
LOGGER.info("onLineUsers的所有id:{}", ids);
for (String id : ids) {
Session session = onLineUsers.get(id);
Result result = cacheUser.get(id);
LOGGER.info("获取到用户信息:{}", result);
// 判断用户是否是连接状态
if (session.isOpen()) {
session.getBasicRemote().sendText(message);
}
}
LOGGER.info("给{}推送了{}", getIds(), message);
} catch (Exception e) {
LOGGER.error("广播发送系统消息失败!{}", e);
e.printStackTrace();
}
}
public static String getMessage(ResultMessage resultMessage, String message) {
resultMessage.setSystemMsgFlag(false);
resultMessage.setMessage(message);
ObjectMapper objectMapper = new ObjectMapper();
try {
return objectMapper.writeValueAsString(resultMessage);
} catch (JsonProcessingException e) {
e.printStackTrace();
}
return "";
}
public static String getSysMessage(Collection collection) {
ResultMessage resultMessage = new ResultMessage();
resultMessage.setSystemMsgFlag(true);
resultMessage.setMessage(collection);
ObjectMapper objectMapper = new ObjectMapper();
try {
return objectMapper.writeValueAsString(resultMessage);
} catch (JsonProcessingException e) {
e.printStackTrace();
}
return "";
}
public static void heartCheck(Session session) {
try {
Map params = new HashMap();
params.put("type", "PONG");
session.getAsyncRemote().sendText(JSON.toJSONString(params));
LOGGER.info("应答客户端的消息:{}", JSON.toJSONString(params));
} catch (Exception e) {
e.printStackTrace();
}
}
private Set getIds() {
return ChatEndpoint.onLineUsers.keySet();
}
private Collection getusers() {
return cacheUser.values();
}
public static String dateFormat(Date date) {
return ObjectUtils.dateFormat(date);
}
}
后台控制器接口
后端访问接口
@Controller
@RequestMapping("index")
public class LoginController {
@ResponseBody
@PostMapping("/login")
public Result login(User user, HttpServletRequest req,HttpServletResponse res) {
HttpSession session = req.getSession();
ObjectUtils.checkNull(res,"请输入手机号和密码!",user,user.getPhone(),user.getPassword());
String phone = user.getPhone();
String password = user.getPassword();
boolean mobile = Validator.isMobile(phone);
Result result = new Result();
if(!mobile){
result.setMessage("手机号格式错误!");
result.setFlag(false);
return result;
}
try {
User userData = UserData.loginByPhone(phone);
ObjectUtils.checkNull(res,userData,String.format("登录失败,未获取到%s的用户信息!", phone));
if (password.equals(userData.getPassword())) {
String username = userData.getUsername();
result.setFlag(true);
result.setMessage("登录成功!");
result.setUsername(username);
String userId = userData.getUserId();
result.setUserId(userId);
result.setDateStr(new Date());
session.setAttribute("username", username);
session.setAttribute("userId", userId);
} else {
result.setMessage("登录失败,密码输入错误!");
result.setFlag(false);
}
} catch (Exception e) {
e.printStackTrace();
result.setMessage("登录失败!",e.getMessage());
result.setFlag(false);
}
return result;
}
@GetMapping("/toChatroom")
public String toChatroom(HttpSession session) {
String username =ObjectUtils.getStr(session.getAttribute("username"));
if (StringUtils.isBlank(username)) {
return "login";
}
return "div";
}
@ResponseBody
@GetMapping("/getUsername")
public Map getUsername(HttpSession session) {
Map map = new HashMap();
String username = (String) session.getAttribute("username");
String userId = (String) session.getAttribute("userId");
map.put("flag", true);
map.put("username", username);
map.put("userId", userId);
return map;
}
@GetMapping(value = "/toIndex")
public String toIndexPage() {
return "login";
}
}
登录页面代码
登录页面JS代码登录
$(function() {
$(".close").click(function() {
$(".box").hide();
});
$("#loginFormBtn").click(function() {
if (loginCheckData()) {
login();
}
});
$(".showPwd").bind("input propertychange", function() {
if ($(this).prop("checked")) {
$(".upwd").attr("type", "text");
} else {
$(".upwd").attr("type", "password");
}
})
});
function login() {
let params = {};
params.phone = ml.empty($('.phone').val());
params.password = ml.empty($('.upwd').val());
params.flag = $("input[name='rememberMe']").prop("checked");
$.post("/index/login", params, function(res) {
if (res.flag) {
ml.msgBox(res.message);
location.href = "/index/toChatroom";
} else {
ml.msgBox(res.message, 5, 5);
}
}).error(function(err) {
ml.msgBoxBtn(err.responseJSON.status + ":" + err.responseJSON.message, "错误提示");
})
}
function loginCheckData() {
let phone = ml.empty($('.phone').val());
if (!phone || phone.length != 11) {
ml.tips("请输入正确的手机号!", "phone");
return false;
}
// 不可包含中文及中文字符
let regZh = /[u4e00-u9fa5][u3000-u301eufe10-ufe19ufe30-ufe44ufe50-ufe6buff01-uffee]/;
//1.验证手机号 规则:第一位1,第二位是358中的一个,第三到十一位必须是数字。总长度11
let reg = /^[1][358][0-9]{9}$/;
if (!reg.test(phone) || regZh.test(phone)) {
ml.tips("输入的手机号格式不正确!", "phone");
return false;
}
let password = ml.empty($('.upwd').val());
if (!password) {
ml.tips("请输入密码!", "upwd");
return false;
}
if (regZh.test(password)) {
ml.tips("密码不可包含中文字符!", "upwd");
return false;
}
if (password.length < 6) {
ml.tips("密码必须为6~16个字符之间!", "upwd");
return false;
}
let regPwd = /[A-Za-z0-9.!?]{12,30}/;
if (!regPwd.test(password)) {
ml.tips("密码仅支持数字、大小写字母和.!?符号", "upwd");
return false;
}
//ml.msgBox("校验通过!");
return true;
}
聊天界面代码
聊天界面JS代码
let webObj = null;//全局WebSocket对象
let lockReconnect = false; // 网络断开重连
let wsCreateHandler = null; // 创建连接
let username = null; // 当前登录人姓名
let userId = null; //当亲登录人id
let toName = null; //消息接收人姓名
let toId = null;//消息接收人id
$(function() {
$(".bg_change_size").remove();
// 在ajax请求开始前将请求设为同步请求:$.ajaxSettings.async = false。
// 在ajax请求结束后不要忘了将请求设为异步请求,否则其他的ajax请求也都变成了同步请求 $.ajaxSettings.async = true。
$.ajax({
async: false,
type: 'GET',
url: "/index/getUsername",
success: function(res) {
if (!res.userId || !res.username) {
location.href = '/index/toIndex';
}
username = res.username;
userId = res.userId;
}
});
// 创建webSocket对象
createWebSocket();
// 发送消息到服务器
$(".sendMsg").on("click", function() {
sendMessage();
})
});
function createWebSocket() {
try {
// 获取访问路径,带有端口号:ws://localhost/webSocket/001
let host = window.location.host;
// 创建WebSocket连接对象
webObj = new WebSocket(`ws://${host}/webSocket/${userId}`);
// 加载组件
initWsEventHandle();
} catch (e) {
ml.msgBox("连接出错,正在尝试重新连接,请稍等。。。");
// 尝试重新连接服务器
reconnect();
}
}
function initWsEventHandle() {
try {
// 建立连接
webObj.onOpen = function(evt) {
onWsOpen(evt);
// 建立连接之后,开始传输心跳包
heartCheck.start();
};
// 传送消息
webObj.onmessage = function(evt) {
// 发送消息
onWsMessage(evt);
// 接收消息后 也需要心跳包的传送
heartCheck.start();
};
// 关闭连接
webObj.onclose = function(evt) {
// 关闭连接,可能是异常关闭,需要重新连接
onWsClose(evt);
// 尝试重新连接
reconnect();
};
// 连接出错
webObj.onerror = function(evt) {
// 连接出错
onWsError(evt);
// 尝试重新连接
reconnect();
}
} catch (e) {
if (e) {
conlog("catch", e);
}
ml.msgBox("初始化组件失败,正在重试,请稍后。。。");
// 尝试重新创建连接
reconnect();
}
}
function onWsOpen(e) {
if (e.data) {
conlog("onWsOpen", e.data);
}
ml.msgBox("建立连接成功。。。");
}
function onWsMessage(e) {
if (e.data) {
conlog("onWsMessage", e.data);
}
let jsonStr = e.data;
//接收到服务器推送的消息后触发事件
message(e);
}
function onWsClose(e) {
if (e.data) {
conlog("onWsClose", e.data);
}
ml.msgBox("连接关闭,尝试重新连接服务器,请稍侯。。。");
closeFun(e);
}
function onWsError(e) {
ml.msgBox("连接出错,正在尝试重新连接服务器,请稍侯。。。" + e.data);
}
function conlog(msg) {
conlog('', msg);
}
function conlog(title, msg) {
let content = msg;
if (title) {
content = `${title}:${msg}`;
}
console.log(`${content}`);
}
function reconnect() {
if (lockReconnect) {
return;
}
conlog("reconnect");
ml.msgBox("正在尝试重新连接,请稍侯。。。");
lockReconnect = true;
// 没链接上会一直连接,设置延迟,避免过多请求
wsCreateHandler && clearTimeout(wsCreateHandler);
wsCreateHandler = setTimeout(function() {
ml.msgBox("正在重新连接。。。");
createWebSocket();
lockReconnect = false;
ml.msgBox("重连完成。。。");
}, 1000);
}
var heartCheck = {
// 在15s内若没收到服务端消息,则认为连接断开,需要重新连接
timeout: 15000, // 心跳检测触发时间
timeoutObj: null,
serverTimeoutObj: null,
// 重新连接
reset: function() {
clearTimeout(this.timeoutObj);
clearTimeout(this.serverTimeoutObj);
this.start();
},
// 开启定时器
start: function() {
let self = this;
this.timeoutObj && clearTimeout(this.timeoutObj);
this.serverTimeoutObj && clearTimeout(this.serverTimeoutObj);
this.timeoutObj = setTimeout(function() {
ml.msgBox("发送ping到后台服务。。。");
try {
webObj.send("PING");
} catch (e) {
ml.msgBox("发送ping异常。。。");
}
//内嵌定时器
self.serverTimeoutObj = setTimeout(function() {
// 若onclose方法会执行reconnect方法,我们只需执行close()就行,
// 若直接执行reconnect会触发onclose导致重连两次
ml.msgBox("没有收到后台数据,关闭连接。。。");
webObj.close();
//reconnect();
}, self.timeout);
}, this.timeout);
}
};
function message(e) {
//获取服务端推送过来的消息
let message = e.data;
// 将message转为JSON对象
let res = JSON.parse(message);
// 是否为系统消息
if (res.systemMsgFlag) {
let allNames = res.message;
//1. 好友列表展示 2. 系统推广
let userListStr = "";
let broadcastListStr = "";
let imgUrl = "..https://blog.csdn.net/imgs/chatIc.png";
for (let user of allNames) {
if (user.userId != userId) {
userListStr += `好友${user.username}上线了!
`; $(".sys-msg").html(`${tips}用户:${username}离开了`); } // 选择好友 function chatWith(id, name, obj) { toId = id; toName = name; $(".edit-msg").attr("disabled", false); $(".div-btn").show(); $(".chat-msg").show(); $(obj).addClass("selected-li").siblings().removeClass("selected-li"); let chatNow = `正在和${name}聊天`; $(".div-main-title").html(chatNow); $(".chat-msg").html(""); var chatData = sessionStorage.getItem(toId); if (chatData) { //渲染聊天数据到聊天区 $(".chat-msg").html(chatData); } $(".chat-main")[0].scrollTop = $(".chat-main")[0].scrollHeight; } function sendMessage() { // 发送消息 if (!toId || !toName) { ml.tips("sendMsg", "请选择好友..."); return; } let msg = $(".edit-msg").val(); if (!msg) { ml.tips("sendMsg", "请输入内容..."); return; } let img = `${imgUrl}' class='myself-img'/>`; let li = $(""); li.html(`${img}${msg}`); $(".chat-msg").append(li); $(".edit-msg").val(''); $(".chat-main")[0].scrollTop = $(".chat-main")[0].scrollHeight; let chatData = sessionStorage.getItem(toId); let liStr = `




