从 0 开始实现一个网页聊天室 (小型项目)(上):https://developer.aliyun.com/article/1520798
UserMapper
用户的相关操作
@Mapper public interface UserMapper { // 把用户插入到数据库中 -> 注册 int insert(@Param("user") User user); // 根据用户名查询用户信息 -> 登录 @Select("select * from user where username = #{username}") User selectByName(@Param("username") String username); }
<?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd"> <mapper namespace="com.example.java_chatroom.model.UserMapper"> <insert id="insert" useGeneratedKeys="true" keyProperty="userId"> insert into user values(null, #{user.username}, #{user.password}) </insert> </mapper>
WebSocket 通讯模块
前端
主要是 JS 中的代码
先放一个 demo
// 编写 js 使用 websocket 的代码. // 创建一个 websocket 实例 let websocket = new WebSocket("ws://127.0.0.1:8080/test"); // 给这个 websocket 注册上一些回调函数. websocket.onopen = function() { // 连接建立完成后, 就会自动执行到. console.log("websocket 连接成功!"); } websocket.onclose = function() { // 连接断开后, 自动执行到. console.log("websocket 连接断开!"); } websocket.onerror = function() { // 连接异常时, 自动执行到 console.log("websocket 连接异常!"); } websocket.onmessage = function(e) { // 收到消息时, 自动执行到 console.log("websocket 收到消息! " + e.data); } // 发送消息 (点击发送按钮之后触发的事件) let messageInput = document.querySelector('#message'); let sendButton = document.querySelector('#send-button'); sendButton.onclick = function() { console.log("websocket 发送消息: " + messageInput.value); websocket.send(messageInput.value); }
这里就是本项目前端使用 WebSocket 进行网络通信的逻辑
/ // 操作 websocket / // 创建 websocket 实例 // let websocket = new WebSocket("ws://127.0.0.1:8080/WebSocketMessage"); // let websocket = new WebSocket("ws://152.136.56.110:9090/WebSocketMessage"); let websocket = new WebSocket("ws://" + location.host + "/WebSocketMessage"); websocket.onopen = function() { console.log("websocket 连接成功!"); } websocket.onmessage = function(e) { console.log("websocket 收到消息! " + e.data); // 此时收到的 e.data 是个 json 字符串, 需要转成 js 对象 let resp = JSON.parse(e.data); if (resp.type == 'message') { // 处理消息响应 handleMessage(resp); } else { // resp 的 type 出错! console.log("resp.type 不符合要求!"); } } websocket.onclose = function() { console.log("websocket 连接关闭!"); } websocket.onerror = function() { console.log("websocket 连接异常!"); } function handleMessage(resp) { // 把客户端收到的消息, 给展示出来. // 展示到对应的会话预览区域, 以及右侧消息列表中. // 1. 根据响应中的 sessionId 获取到当前会话对应的 li 标签. // 如果 li 标签不存在, 则创建一个新的 let curSessionLi = findSessionLi(resp.sessionId); if (curSessionLi == null) { // 就需要创建出一个新的 li 标签, 表示新会话. curSessionLi = document.createElement('li'); curSessionLi.setAttribute('message-session-id', resp.sessionId); // 此处 p 标签内部应该放消息的预览内容. 一会后面统一完成, 这里先置空 curSessionLi.innerHTML = '<h3>' + resp.fromName + '</h3>' + '<p></p>'; // 给这个 li 标签也加上点击事件的处理 curSessionLi.onclick = function() { clickSession(curSessionLi); } } // 2. 把新的消息, 显示到会话的预览区域 (li 标签里的 p 标签中) // 如果消息太长, 就需要进行截断. let p = curSessionLi.querySelector('p'); p.innerHTML = resp.content; if (p.innerHTML.length > 10) { p.innerHTML = p.innerHTML.substring(0, 10) + '...'; } // 3. 把收到消息的会话, 给放到会话列表最上面. let sessionListUL = document.querySelector('#session-list'); sessionListUL.insertBefore(curSessionLi, sessionListUL.children[0]); // 4. 如果当前收到消息的会话处于被选中状态, 则把当前的消息给放到右侧消息列表中. // 新增消息的同时, 注意调整滚动条的位置, 保证新消息虽然在底部, 但是能够被用户直接看到. if (curSessionLi.className == 'selected') { // 把消息列表添加一个新消息. let messageShowDiv = document.querySelector('.right .message-show'); addMessage(messageShowDiv, resp); scrollBottom(messageShowDiv); } // 其他操作, 还可以在会话窗口上给个提示 (红色的数字, 有几条消息未读), 还可以播放个提示音. // 这些操作都是纯前端的. 实现也不难, 不是咱们的重点工作. 暂时不做了. } function findSessionLi(targetSessionId) { // 获取到所有的会话列表中的 li 标签 let sessionLis = document.querySelectorAll('#session-list li'); for (let li of sessionLis) { let sessionId = li.getAttribute('message-session-id'); if (sessionId == targetSessionId) { return li; } } // 啥时候会触发这个操作, 就比如如果当前新的用户直接给当前用户发送消息, 此时没存在现成的 li 标签 return null; }
后端
同样先上 Demo
@Component public class TestWebSocketAPI extends TextWebSocketHandler { @Override public void afterConnectionEstablished(WebSocketSession session) throws Exception { // 该方法会在 websocket 连接建立之后, 被自动调用 System.out.println("Test 连接成功!"); } @Override protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception { // 该方法会在 websocket 收到消息的时候, 被自动调用 System.out.println("Test 收到消息!" + message.toString()); // session 是个会话, 里面记录通信双方的信息 (session 中持有 websocket 的通信连接) session.sendMessage(message); } @Override public void handleTransportError(WebSocketSession session, Throwable exception) throws Exception { // 这个方法实在 连接出现异常的时候, 被自动调用 System.out.println("Test 连接异常!"); } @Override public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception { // 这个方法是在连接正常关闭后, 会被自动调用 System.out.println("Test 连接关闭!"); } }
下面是本项目中后端使用 WebSocket 实现网络通信
创建 Handler 对象
@Slf4j @Component public class WebSocketAPI extends TextWebSocketHandler { @Autowired private OnlineUserMapper onlineUserMapper; @Autowired private MessageSessionMapper messageSessionMapper; @Autowired private MessageMapper messageMapper; // 自己创建对象也行, 使用 @Autowired 注入也行, spring 本身就有内置对象 ObjectMapper private ObjectMapper objectMapper = new ObjectMapper(); @Override public void afterConnectionEstablished(WebSocketSession session) throws Exception { log.info("[WebSocketAPI] 连接成功!"); User user = (User) session.getAttributes().get("user"); if(user == null) { return; } log.info("获取到的 userId: {}, username: {}",user.getUserId(), user.getUsername()); // 连接建立成功之后, 将 上线用户 和 session 进行绑定 onlineUserMapper.online(user.getUserId(), session); } /** * 数据处理 * @param session * @param message * @throws Exception */ @Override protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception { log.info("[WebSocketAPI] 收到消息! " + message.toString()); // 先获取到当前用户的信息, 后续要转发的消息等 User user = (User) session.getAttributes().get("user"); if(user == null){ log.info("[WebSocketAPI] user == null, 未登录用户, 无法进行消息转发"); return; } // 针对请求进行解析, 把 json 格式字符串转换成 Java 对象 MessageRequest req = objectMapper.readValue(message.getPayload(), MessageRequest.class); if("message".equals(req.getType())) { // 进行消息转发 transferMessage(user, req); }else { log.info("[WebSocketAPI] req.type 有误! {}", message.getPayload()); } } /** * 通过该方法来完成消息的实际转发过程 * @param user 发送消息的对象 * @param req 内含 sessionId, content */ private void transferMessage(User user, MessageRequest req) throws IOException { // 先构造一个待转发的响应对象. MessageResponse MessageResponse resp = new MessageResponse(user.getUserId(), user.getUsername(), req.getSessionId(), req.getContent()); // 把这个响应对象转换成 JSON 格式字符串,以待备用 String respJson = objectMapper.writeValueAsString(resp); log.info("[transferMessage] respJson: {}", respJson); // 根据请求中的 sessionId, 获取到 MessageSession 里有哪些用户 (查询数据库) List<Friend> friends = messageSessionMapper.getFriendsBySessionId(req.getSessionId(), user.getUserId()); // 此处响应返回的对象中, 应该包含发送方 Friend myself = new Friend(user.getUserId(), user.getUsername()); friends.add(myself); // 循环遍历 friends, 给其中每一个对象都发送一份响应 // 这里是为了满足群聊的设定(即使前端还未实现,但是后端接口和数据库都是支持群聊的) for(Friend friend : friends) { // 已知 userId, 进一步查询 OnlineUserMapper, 获取对应的 WebSocketSession, 从而进行消息转发 WebSocketSession webSocketSession = onlineUserMapper.getSession(friend.getFriendId()); if(webSocketSession != null) { webSocketSession.sendMessage(new TextMessage(respJson)); } } // 转发的消息还要在数据库备份 Message message = new Message(user.getUserId(), user.getUsername(), req.getSessionId(), resp.getContent()); // 自增主键为 null或为空, 数据库会自动生成 messageMapper.add(message); } @Override public void handleTransportError(WebSocketSession session, Throwable exception) throws Exception { log.info("[WebSocketAPI] 连接异常! " + exception.toString()); User user = (User) session.getAttributes().get("user"); if(user != null) { onlineUserMapper.offline(user.getUserId(), session); } } @Override public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception { log.info("[WebSocketAPI] 连接关闭! " + status.toString()); User user = (User) session.getAttributes().get("user"); if(user != null) { onlineUserMapper.offline(user.getUserId(), session); } } }
将 Handler 注册到 Config 里面
@Configuration @EnableWebSocket public class WebSocketConfig implements WebSocketConfigurer { @Autowired private TestWebSocketAPI testWebSocketAPI; @Autowired private WebSocketAPI webSocketAPI; @Override public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) { // 通过本方法, 将创建好的 Handler 类给注册到具体路径上. // 此时浏览器可通过 请求路径, 调用到绑定的 Handler 类. registry.addHandler(testWebSocketAPI, "/test"); registry.addHandler(webSocketAPI, "/WebSocketMessage") // 通过注册这个特定的 HttpSession 拦截器, 可以把用户在 // HttpSession 中添加的 Attribute 键值对 // 往 WebSocketSession 中添加一份 .addInterceptors(new HttpSessionHandshakeInterceptor()); } }
OnlineUserMapper
本类用来记录当前用户在线的状态. (维护 userId 和 WebSocketSession 之间的映射)
// 本类用来记录当前用户在线的状态. (维护 userId 和 WebSocketSession 之间的映射) @Slf4j @Component public class OnlineUserMapper { // 此处这个哈希表要考虑 线程安全 问题 private ConcurrentHashMap<Integer, WebSocketSession> sessions = new ConcurrentHashMap<>(); /** * 用户上线, 给哈希表里插入键值对 * @param userId * @param webSocketSession */ public void online(int userId, WebSocketSession webSocketSession) { if(sessions.get(userId) != null) { // 针对用户多开, 这里的处理是不记录后面登录用户的 session, 即后续登录用户做不到消息的收发 // (毕竟这里是根据映射关系来实现消息转发的) log.info("[{}] 已登录, 登录失败",userId); return; } sessions.put(userId, webSocketSession); log.info("[{}] 上线!", userId); } /** * 用户下线, 根据 userId 删除键值对 * @param userId * @param webSocketSession */ public void offline(int userId, WebSocketSession webSocketSession) { if(sessions.get(userId) == webSocketSession) { // 如果键值对中 session和调用该方法的 session 相同, 才允许删除键值对 sessions.remove(userId); log.info("[{}] 下线!", userId); } } /** * 根据 userId 获取键值对 * * @param userId * @return */ public WebSocketSession getSession(int userId) { return sessions.get(userId); } }
功能处理
用户注册
调用接口: register
@Slf4j @RestController @Controller @ResponseBody public class UserAPI { @Resource private UserMapper userMapper; /** * 用户注册 * 返回 User 对象 * 注册成功, 返回的 User 对象包含用户信息 * 注册失败, 返回的 User 对象无内容 */ @RequestMapping("/register") public Object register(String username, String password) { User user = new User(); // 判空 if(!StringUtils.hasLength(username) || !StringUtils.hasLength(password)) { return user; } try { user = new User(username, password); int ret = userMapper.insert(user); log.info("注册 ret :{}", ret); user.setPassword(""); } catch (DuplicateKeyException e) { // 抛出该异常说明用户名重复, 注册失败 user = new User(); log.error("用户名重复, 注册失败"); } return user; } }
用户登录
调用接口: login
@Slf4j @RestController @Controller @ResponseBody public class UserAPI { @Resource private UserMapper userMapper; /** * 用户登录 * 返回 User 对象 * 登录成功, 返回的 User 对象包含用户信息, 并且将 User 对象存储在 session 中 * 登录失败, 返回的 User 对象无内容 */ @RequestMapping("/login") public Object login(String username, String password, HttpServletRequest request) { // 判空 if(!StringUtils.hasLength(username) || !StringUtils.hasLength(password)) { return new User(); } // 校验用户名密码 User user = userMapper.selectByName(username); if(user == null || !password.equals(user.getPassword())) { return new User(); } // 校验成功, 则登陆成功, 创建会话 // true 表示会话不存在则创建会话, false 表示会话不存在就返回空 HttpSession session = request.getSession(true); session.setAttribute("user",user); user.setPassword(""); return user; } }
用户登录后, 聊天界面会自动获取登录用户的好友列并展示
调用接口: friendList
// 处理好友信息 @Slf4j @RestController public class FriendAPI { @Resource private FriendMapper friendMapper; @RequestMapping("/friendList") public Object getFriendList(HttpServletRequest req) { // 1. 先从会话中, 获取到 userId HttpSession session = req.getSession(false); if(session == null) { log.info("[getFriendList] session 不存在"); return new ArrayList<Friend>(); } User user = (User) session.getAttribute("user"); if(user == null) { log.info("[getFriendList] user 不存在"); return new ArrayList<Friend>(); } // 根据 userId 查询数据库 List<Friend> list = friendMapper.selectFriendList(user.getUserId()); return list; } }
用户登录后, 聊天界面会自动获取登录用户的会话列并展示
调用接口: sessionList
@Slf4j @RestController public class MessageSessionAPI { @Resource private MessageSessionMapper messageSessionMapper; @Resource private MessageMapper messageMapper; /** * 获取登录用户 的 所有会话信息 (会话id, 最后一条信息) * @param req * @return */ @RequestMapping("/sessionList") public Object getMessageSessionList(HttpServletRequest req) { List<MessageSession> messageSessionList = new ArrayList<>(); // 1. 获取当前用户的 userId (从 Spring 的 session 中获取) HttpSession session = req.getSession(false); if(session == null) { log.info("[getMessageSessionList] session == null"); return messageSessionList; } User user = (User) session.getAttribute("user"); if(user == null) { log.info("[getMessageSessionList] user == null"); return messageSessionList; } int userId = user.getUserId(); // 2. 根据 userId 查询数据库, 查出包含该用户的 会话 id List<Integer> sessionIdList = messageSessionMapper.getSessionIdsByUserId(user.getUserId()); //3. 遍历会话id, 查询出每个会话里涉及的好友有谁 for(int sessionId : sessionIdList) { MessageSession messageSession = new MessageSession(); messageSession.setSessionId(sessionId); // 查询每个会话涉及的好友有谁 List<Friend> friends = messageSessionMapper.getFriendsBySessionId(sessionId, user.getUserId()); messageSession.setFriends(friends); // 查询出每个会话的最后一条消息 String lastMessage = messageMapper.getLastMessageBySessionId(sessionId); if (lastMessage == null) { lastMessage = ""; } messageSession.setLastMessage(lastMessage); messageSessionList.add(messageSession); } // 最终目标是构造出一个 MessageSession 对象数组 return messageSessionList; } }
好友列表中, 点击某一个好友之后, 会在会话列创建出一个新会话
调用接口: session
@Slf4j @RestController public class MessageSessionAPI { @Resource private MessageSessionMapper messageSessionMapper; @Resource private MessageMapper messageMapper; /** * 创建会话, 并给会话表中插入两条信息 -- 我和好友绑定的会话信息 * @param toUserId 好友id * @param user 登录用户信息 * @return */ @Transactional @RequestMapping("/session") public Object addMessageSession(int toUserId, @SessionAttribute("user") User user) { Map<String, Integer> resp = new HashMap<>(); // 先给 message_session 表插入数据, 获取 messageId , messageId 放在 MessionSession 对象里 MessageSession messageSession = new MessageSession(); messageSessionMapper.addMessageSession(messageSession); //通过先插入一个空的 messageSession, 可以获取自增主键 messionId // 往 message_session_user 表里插入数据 -- 自己 MessageSessionUserItem item1 = new MessageSessionUserItem(messageSession.getSessionId(), user.getUserId()); messageSessionMapper.addMessageSessionUser(item1); // 往 message_session_user 表里插入数据 -- 好友 MessageSessionUserItem item2 = new MessageSessionUserItem(messageSession.getSessionId(), toUserId); messageSessionMapper.addMessageSessionUser(item2); resp.put("sessionId", messageSession.getSessionId()); // JSON 对于普通对象和 Map 都能处理 // return messageSession; return resp; } }
会话列表中, 点击某一个会话之后, 右侧消息栏会显示出该会话的最近100条消息
调用接口: message
@RestController public class MessageAPI { @Resource private MessageMapper messageMapper; @RequestMapping("/message") public Object getMessage(int sessionId) { List<Message> messages = messageMapper.getMessagesBySessionId(sessionId); // 针对查询结果, 进行逆置操作 Collections.reverse(messages); return messages; } }
编辑消息后, 点击发送按钮会发送消息到对应会话, 该会话的所有用户的消息列表中都会出现新的消息
这里应用的 WebSocket 技术, handleTextMessage 方法能够感知到消息发送, 并获取消息信息进行处理
@Slf4j @Component public class WebSocketAPI extends TextWebSocketHandler { @Autowired private OnlineUserMapper onlineUserMapper; @Autowired private MessageSessionMapper messageSessionMapper; @Autowired private MessageMapper messageMapper; // 自己创建对象也行, 使用 @Autowired 注入也行, spring 本身就有内置对象 ObjectMapper private ObjectMapper objectMapper = new ObjectMapper(); /** * 数据处理 * @param session * @param message * @throws Exception */ @Override protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception { log.info("[WebSocketAPI] 收到消息! " + message.toString()); // 先获取到当前用户的信息, 后续要转发的消息等 User user = (User) session.getAttributes().get("user"); if(user == null){ log.info("[WebSocketAPI] user == null, 未登录用户, 无法进行消息转发"); return; } // 针对请求进行解析, 把 json 格式字符串转换成 Java 对象 MessageRequest req = objectMapper.readValue(message.getPayload(), MessageRequest.class); if("message".equals(req.getType())) { // 进行消息转发 transferMessage(user, req); }else { log.info("[WebSocketAPI] req.type 有误! {}", message.getPayload()); } } /** * 通过该方法来完成消息的实际转发过程 * @param user 发送消息的对象 * @param req 内含 sessionId, content */ private void transferMessage(User user, MessageRequest req) throws IOException { // 先构造一个待转发的响应对象. MessageResponse MessageResponse resp = new MessageResponse(user.getUserId(), user.getUsername(), req.getSessionId(), req.getContent()); // 把这个响应对象转换成 JSON 格式字符串,以待备用 String respJson = objectMapper.writeValueAsString(resp); log.info("[transferMessage] respJson: {}", respJson); // 根据请求中的 sessionId, 获取到 MessageSession 里有哪些用户 (查询数据库) List<Friend> friends = messageSessionMapper.getFriendsBySessionId(req.getSessionId(), user.getUserId()); // 此处响应返回的对象中, 应该包含发送方 Friend myself = new Friend(user.getUserId(), user.getUsername()); friends.add(myself); // 循环遍历 friends, 给其中每一个对象都发送一份响应 // 这里是为了满足群聊的设定(即使前端还未实现,但是后端接口和数据库都是支持群聊的) for(Friend friend : friends) { // 已知 userId, 进一步查询 OnlineUserMapper, 获取对应的 WebSocketSession, 从而进行消息转发 WebSocketSession webSocketSession = onlineUserMapper.getSession(friend.getFriendId()); if(webSocketSession != null) { webSocketSession.sendMessage(new TextMessage(respJson)); } } // 转发的消息还要在数据库备份 Message message = new Message(user.getUserId(), user.getUsername(), req.getSessionId(), resp.getContent()); // 自增主键为 null或为空, 数据库会自动生成 messageMapper.add(message); } }