springboot websocket 实现用户在线聊天 及部分初始化接口

springboot websocket 实现用户在线聊天

  • 1. 添加pom依赖
  • 2. 配置websocket 和监听器
  • 3. BeanUtils
  • 4. WebSocketServer 主要的socket服务
  • 5. 启动类开启websocket服务
  • 6. 功能测试
    • 6.1 测试地址:
    • 6.2 测试两个用户之间的相互通信
  • 7. entity 实体
    • 7.1 sql
    • 7.2 java
  • 8. dao
  • 9. service
  • 10. controller 包含部分初始化接口

1. 添加pom依赖

		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-websocket</artifactId>
		</dependency>

2. 配置websocket 和监听器

/**
 * WebSocket的配置信息
 */
@Configuration
public class WebSocketConfig extends ServerEndpointConfig.Configurator {

    @Override
    public void modifyHandshake(ServerEndpointConfig sec, HandshakeRequest request, HandshakeResponse response) {

        //获取httpsession
        HttpSession session = (HttpSession) request.getHttpSession();
        sec.getUserProperties().put(HttpSession.class.getName(), session);
    }

    @Bean
    public ServerEndpointExporter serverEndpointExporter() {
        return new ServerEndpointExporter();
    }

}

监听器, 主要是同步httpSession, 这样就可以拿到用户信息

/**
 * 监听器类:主要任务是用ServletRequest将我们的HttpSession携带过去
 */
@Component
public class RequestListener implements ServletRequestListener {
    @Override
    public void requestInitialized(ServletRequestEvent sre)  {

        //将所有request请求都携带上httpSession
        ((HttpServletRequest) sre.getServletRequest()).getSession();
    }
    public RequestListener() {}

    @Override
    public void requestDestroyed(ServletRequestEvent arg0)  {}
}

3. BeanUtils

因为项目启动加载websocket时, spring bean容器还没就绪, 通过@Resource等自动注入是null, 所以需要在项目启动时提前实例化bean给websocketservice使用

@Service
public class BeanUtils implements BeanFactoryAware {
    // Spring的bean工厂
    private static BeanFactory beanFactory;
    @Override
    public void setBeanFactory(BeanFactory factory) throws BeansException {
        beanFactory=factory;
    }
    public static<T> T getBean(String beanName){
        return (T) beanFactory.getBean(beanName);
    }
}

4. WebSocketServer 主要的socket服务

  • @OnOpen 创建连接时执行
  • @OnClose 关闭连接时执行
  • @OnMessage 服务端和客户端通信的主要方法
  • @OnError 异常时执行

详细的注释代码里有

@Component
@Slf4j
//todo 前端的连接地址,可以自己修改 示例为: ws://ip:端口/message/userId
@ServerEndpoint("/message/{userId}")
public class WebSocketServer {

    /**
     * 静态变量,用来记录当前在线连接数。应该把它设计成线程安全的。
     */
    private static int onlineCount = 0;
    /**
     * concurrent包的线程安全Set,用来存放每个客户端对应的WebSocket对象。
     * 注意, 这种方式 只适用于单节点部署,
     * 如果是多接点部署, 各个节点之间的在线用户是不互通的, 需要改用其他方式.
     */
    private static final ConcurrentHashMap<String, WebSocketServer> webSocketMap = new ConcurrentHashMap<>();
    /**
     * 与某个客户端的连接会话,需要通过它来给客户端发送数据
     */
    private Session session;
    /**
     * 接收userId
     */
    private String userId = "";

    private final MessageService messageService = BeanUtils.getBean("messageService");
    private final ChatListService chatListService = BeanUtils.getBean("chatListService");;

    /**
     * 连接关闭
     * 调用的方法
     */
    @OnClose
    public void onClose() {
        if (webSocketMap.containsKey(userId)) {
            webSocketMap.remove(userId);
            //从set中删除
            subOnlineCount();
        }
        //todo 修改数据库, 用户的状态为下线
        log.info("用户退出:" + userId + ",当前在线人数为:" + getOnlineCount());
        //todo 测试回应,可以和前端约定返回值
        //todo 如果有订阅下线提醒的逻辑 可以加在这里
        sendMessage("用户下线");
    }

    /**
     * 连接建立成
     * 功调用的方法
     */
    @OnOpen
    public void onOpen(Session session, @PathParam("userId") String userId, EndpointConfig config) {

        //todo 用户体系接入后, 可以拿session里的user而不是用明文路径的形式传递userid, 安全性更高
//        HttpSession httpSession= (HttpSession) config.getUserProperties().get(HttpSession.class.getName());
//        User user = (User) httpSession.getAttribute("user");

        this.session = session;
        this.userId = userId;
        if (webSocketMap.containsKey(userId)) {
            webSocketMap.remove(userId);
            //加入set中
            webSocketMap.put(userId, this);
        } else {
            //加入set中
            webSocketMap.put(userId, this);
            //在线数加1
            addOnlineCount();
        }
        //todo 修改数据库, 用户的状态为在线
        log.info("用户连接:" + userId + ",当前在线人数为:" + getOnlineCount());
        //todo 测试回应,可以和前端约定返回具体什么代表上线
        //todo 如果有订阅上线提醒的逻辑 可以加在这里
        sendMessage("连接成功");
    }

    /**
     * 收到客户端消息后调用的方法
     *
     * @param message 客户端发送过来的消息
     *                目前暂定的前端带过来的结构为:  {"to":"xxx","msg":"xxx"}
     *                               todo message结构可以按具体情况自定义约定修改
     **/
    @OnMessage
    public void onMessage(String message, Session session) {
        log.info("用户消息:" + userId + ",报文:" + message);
        //todo 群发消息(群聊消息)的话, message 的结构改动判断即可
        //消息保存到数据库、redis
        if (StringUtils.isNotBlank(message)) {
            try {
                //解析发送的报文
                JSONObject jsonObject = JSON.parseObject(message);
                //追加发送人(防止篡改)
                jsonObject.put("from", this.userId);
                String toUserId = jsonObject.getString("to");
                //todo 这里可以效验目标id是否是正确的id
                if (StringUtils.isNotBlank(toUserId)) {
                    Message messageEntity;
                    //todo 判断目标用户是否是自己的好友,
                    if (true) {

                        //消息记录保存到数据库
                        messageEntity = Message.builder()
                                .fromId(jsonObject.getLong("from"))
                                .toId(jsonObject.getLong("to"))
                                .content(jsonObject.getString("msg"))
                                //单聊消息固定: 1, 后续拓展群里等消息对应定义值
                                .type(1)
                                .time(new Date())
                                .isTransport(1)
                                .build();
                        messageService.save(messageEntity);
                        // 重置好友聊天列表的内容和最后时间, 展示在最上面
                        chatListService.updateChatList(messageEntity);

                        //判断用户是否在线 在线实时推送, 不在线不推送
                        if (webSocketMap.containsKey(toUserId)) {
                            //回推数据结构: {"from":"xxx", "to":"xxx","msg":"xxx"}
                            //todo 可以按具体情况自定义约定修改
                            webSocketMap.get(toUserId).sendMessage(jsonObject.toJSONString());
                        }

                    } else {
                        //todo 不为对方好友, 或者被拉黑等, isTransport 是否送达, 设置为0
                        //或者不需要保存这些记录的话, 这段删了也行
                        messageEntity = Message.builder()
                                .fromId(jsonObject.getLong("from"))
                                .toId(jsonObject.getLong("to"))
                                .content(jsonObject.getString("msg"))
                                //单聊消息固定: 1, 后续拓展群里等消息对应定义值
                                .type(1)
                                .time(new Date())
                                .isTransport(0)
                                .build();
                        messageService.save(messageEntity);
                        //todo 可以发送消息提醒, 请先添加对方为好友
//                        sendMessage("xxx");
                    }

                } else {
                    log.error("请求的userId为空");
                }
            } catch (Exception e) {
                e.printStackTrace();
            }
        }
    }


    /**
     * @param session Session
     * @param error   Throwable
     */
    @OnError
    public void onError(Session session, Throwable error) {
        //todo 服务器关闭或者其他不可预料情况, 会走这里,  可以在这里对用户做强制下线处理
        log.error("用户错误:" + this.userId + ",原因:" + error.getMessage());
        error.printStackTrace();
    }

    /**
     * 服务器主动推送
     * todo  需求: 新用户注册, 用户开通vip等, 用户消费,礼物赠送等 场景, 调用此函数即可 发送消息
     * todo 消息内容结构同上, 和前端约定即可
     */
    public void sendMessage(String message) {
        try {
            this.session.getBasicRemote().sendText(message);
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

    /**
     * 发送自定义消息
     * 同上 "服务器主动推送" 接口
     **/
    public static void sendInfo(String userId, String message) {
        log.info("发送消息到:" + userId + ",报文:" + message);
        if (StringUtils.isNotBlank(userId) && webSocketMap.containsKey(userId)) {
            webSocketMap.get(userId).sendMessage(message);
        } else {
            log.warn("用户" + userId + ",不在线!");
        }
    }
    public static void sendInfo(Long userId, String message) {
        sendInfo(String.valueOf(userId), message);
    }

    /**
     * 获得此时的
     * 在线人数
     *
     * @return int
     */
    public static synchronized int getOnlineCount() {
        return onlineCount;
    }

    /**
     * 在线人
     * 数加1
     */
    public static synchronized void addOnlineCount() {
        WebSocketServer.onlineCount++;
    }

    /**
     * 在线人
     * 数减1
     */
    public static synchronized void subOnlineCount() {
        WebSocketServer.onlineCount--;
    }
}

5. 启动类开启websocket服务

@EnableWebSocket

@SpringBootApplication
@EnableWebSocket
@MapperScan("com.service.chat.dao")
public class ChatServiceApplication {

	public static void main(String[] args) {
		SpringApplication.run(ChatServiceApplication.class, args);
	}

}

6. 功能测试

以上为websocket功能, 以下为部分初始化接口及逻辑

只是需要试试websocket功能, 不需要试入库这些的, 到这里即可, 把上面wocketserver里的业务代码删除即可

6.1 测试地址:

可以用这个网站进行后端自测

http://coolaf.com/tool/chattest

6.2 测试两个用户之间的相互通信

  1. 建立两个连接

ws://localhost:8080/message/111
ws://localhost:8080/message/222
springboot websocket 实现用户在线聊天 及部分初始化接口_第1张图片

  1. 按定义的格式发消息

{“to”:“xxx”,“msg”:“xxx”}
springboot websocket 实现用户在线聊天 及部分初始化接口_第2张图片

功能ok


7. entity 实体

包含, 聊天记录 和 好友聊天列表

7.1 sql


SET NAMES utf8mb4;
SET FOREIGN_KEY_CHECKS = 0;

-- ----------------------------
-- Table structure for chat_list
-- ----------------------------
DROP TABLE IF EXISTS `chat_list`;
CREATE TABLE `chat_list`  (
  `id` bigint(20) NOT NULL,
  `user_id` bigint(20) NOT NULL,
  `friend_id` bigint(20) NOT NULL,
  `un_read_num` int(11) NOT NULL,
  `last_message_id` bigint(20) NULL DEFAULT NULL,
  `last_message_content` varchar(5000) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL,
  `send_time` datetime(0) NOT NULL,
  PRIMARY KEY (`id`) USING BTREE,
  UNIQUE INDEX `user_id`(`user_id`, `friend_id`) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_general_ci ROW_FORMAT = Dynamic;

-- ----------------------------
-- Table structure for message
-- ----------------------------
DROP TABLE IF EXISTS `message`;
CREATE TABLE `message`  (
  `id` bigint(20) NOT NULL,
  `from_id` bigint(20) NOT NULL,
  `to_id` bigint(20) NOT NULL,
  `content` varchar(5000) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL,
  `type` int(11) NOT NULL,
  `time` datetime(0) NOT NULL,
  `is_transport` int(11) NOT NULL,
  PRIMARY KEY (`id`) USING BTREE,
  INDEX `from_id`(`from_id`) USING BTREE,
  INDEX `to_id`(`to_id`) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_general_ci ROW_FORMAT = Dynamic;

SET FOREIGN_KEY_CHECKS = 1;

7.2 java

聊天列表

@Data
@ApiModel(value = "聊天列表", description = "聊天列表")
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ChatList {
	@ApiModelProperty(value = "主键id")
	@TableId
	private Long id;
	//用户id
	private Long userId;
	//好友id
	private Long friendId;
	//消息未读数量
	private int unReadNum;
	//最后一条消息id
	private Long lastMessageId;
	//最后一条消息内容
	private String lastMessageContent;
	//时间(排序用)
	private Date sendTime;

}

聊天记录:

@Data
@ApiModel(value = "消息", description = "消息记录表")
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class Message {
	@ApiModelProperty(value = "主键id")
	@TableId
	private Long id;
	//发送方
	private Long fromId;
	//接收方
	private Long toId;
	//内容
	private String content;
	/**
	 * 类型字段, 目前定义单聊消息 = 1
	 * todo 群聊消息, 添加好友消息, 好友请求通过, 好友请求不通过, 上线提醒, 等等等等各种类型, 可以自定义, 和前端联动即可
	 */
	private int type;
	//时间
	private Date time;
	//是否送达
	private int isTransport;
}

8. dao

持久层采用mybatis plus 实现

@Component
public interface MessageDao extends BaseMapper<Message> {

    /**
     * 倒序查询两人的所有聊天记录
     * todo 后续可以考虑分页
     * @param userIdA
     * @param userIdB
     * @return
     */
    @Select("SELECT * FROM message WHERE (from_id = '${userIdA}' AND to_id = '${userIdB}') OR (from_id = '${userIdB}' AND to_id = '${userIdA}') ORDER BY time DESC;")
    List<Message> getMessageRecordBetweenUsers(@Param("userIdA") Long userIdA, @Param("userIdB") Long userIdB);

    /**
     * 查询两人最后一条聊天记录
     * @param userIdA
     * @param userIdB
     * @return
     */
    @Select("SELECT * FROM message WHERE (from_id = '${userIdA}' AND to_id = '${userIdB}') OR (from_id = '${userIdB}' AND to_id = '${userIdA}') ORDER BY time DESC LIMIT 0, 1;")
    Message getLastMessage(@Param("userIdA") Long userIdA, @Param("userIdB") Long userIdB);
}
@Component
public interface ChatListDao extends BaseMapper<ChatList> {

}

9. service

包含部分逻辑:


@Component
public class ChatListService {

    @Resource
    private ChatListDao chatListDao;

    @Resource
    private MessageDao messageDao;


    /**
     * 新增自己的一条聊天列表
     * @param currUserId
     * @param friendId
     */
    public void add(Long currUserId, Long friendId) {
        ChatList one = getOne(currUserId, friendId);
        if (Objects.isNull(one)){
            //没有才新增, 有的话不增
            ChatList chatList = ChatList.builder()
                    .userId(currUserId)
                    .friendId(friendId)
                    .unReadNum(0)
                    .sendTime(new Date())
                    .build();
            //查询之前最后的一条聊天记录, 有的话填充内容
            Message lastMessage = messageDao.getLastMessage(currUserId, friendId);
            if (Objects.nonNull(lastMessage)){
                chatList.setLastMessageId(lastMessage.getId());
                chatList.setLastMessageContent(lastMessage.getContent());
                chatList.setSendTime(lastMessage.getTime());
            }
            chatListDao.insert(chatList);
            //todo 聊天列表变动 发送消息给前端
//            WebSocketServer.sendInfo(currUserId, "xxx");
        }

    }

    /**
     * 删除自己的一条聊天列表
     * @param currUserId
     * @param friendId
     */
    public void delete(Long currUserId, Long friendId) {
        LambdaQueryWrapper<ChatList> queryWrapper = new QueryWrapper<ChatList>().lambda()
                .eq(ChatList::getUserId, currUserId)
                .eq(ChatList::getFriendId,friendId);
        chatListDao.delete(queryWrapper);
        //todo 聊天列表变动 发送消息给前端
    }

    /**
     * 查询自己的一条聊天列表
     * @param userId
     * @param friendId
     * @return
     */
    public ChatList getOne(Long userId, Long friendId) {
        LambdaQueryWrapper<ChatList> queryWrapper = new QueryWrapper<ChatList>().lambda()
                .eq(ChatList::getUserId, userId)
                .eq(ChatList::getFriendId,friendId);
        List<ChatList> chatLists = chatListDao.selectList(queryWrapper);
        return CollectionUtils.isEmpty(chatLists) ? null: chatLists.get(0);
    }

    /**
     * 查询自己的所有聊天列表
     * @param userId
     * @return
     */
    public List<ChatList> getChatList(Long userId) {
        LambdaQueryWrapper<ChatList> queryWrapper = new QueryWrapper<ChatList>().lambda()
                .eq(ChatList::getUserId, userId)
                .orderByDesc(ChatList::getSendTime);
        return chatListDao.selectList(queryWrapper);
    }

    /**
     * 消息已读, 未读数清0
     * @param userId
     * @return
     */
    public void read(Long userId, Long friendId) {
        LambdaQueryWrapper<ChatList> queryWrapper = new QueryWrapper<ChatList>().lambda()
                .eq(ChatList::getUserId, userId)
                .eq(ChatList::getFriendId, friendId);
        ChatList chatList = chatListDao.selectOne(queryWrapper);

        if (Objects.nonNull(chatList) && chatList.getUnReadNum() != 0){
            chatList.setUnReadNum(0);
            chatListDao.updateById(chatList);
            //todo 聊天列表变动 发送消息给前端
        }
    }


    /**
     * 更新聊天列表
     * @param message 消息
     */
    public void updateChatList(Message message) {
        //发送者的聊天列表更新
        LambdaQueryWrapper<ChatList> queryWrapper = new QueryWrapper<ChatList>().lambda()
                .eq(ChatList::getUserId, message.getFromId())
                .eq(ChatList::getFriendId, message.getToId());
        ChatList chatList = chatListDao.selectOne(queryWrapper);
        if (Objects.isNull(chatList)){
            //列表没有, 新增
            chatListDao.insert(ChatList.builder()
                            .userId(message.getFromId())
                            .friendId(message.getToId())
                            .lastMessageId(message.getId())
                            .lastMessageContent(message.getContent())
                            .sendTime(message.getTime())
                            //这里, 接收者是未读的, 而发送者是已读的
                            .unReadNum(0)
                            .build());
            //todo 聊天列表变动 发送消息给前端
        }else {
            //列表有, 修改
            chatList.setLastMessageId(message.getId());
            chatList.setLastMessageContent(message.getContent());
            chatList.setSendTime(message.getTime());
            chatListDao.updateById(chatList);
            //todo 聊天列表变动 发送消息给前端
        }


        //接收者的聊天列表更新
        LambdaQueryWrapper<ChatList> queryWrapper2 = new QueryWrapper<ChatList>().lambda()
                .eq(ChatList::getUserId, message.getToId())
                .eq(ChatList::getFriendId, message.getFromId());
        ChatList chatList2 = chatListDao.selectOne(queryWrapper2);
        if (Objects.isNull(chatList2)){
            //列表没有, 新增
            chatListDao.insert(ChatList.builder()
                    //注意这里发送者和接收者的顺序
                    .userId(message.getToId())
                    .friendId(message.getFromId())
                    .lastMessageId(message.getId())
                    .lastMessageContent(message.getContent())
                    .sendTime(message.getTime())
                    //这里, 接收者是未读的, 而发送者是已读的
                    .unReadNum(1)
                    .build());
            //todo 聊天列表变动 发送消息给前端 发给好友的, 要填好友的id
        }else {
            //列表有, 修改
            chatList2.setLastMessageId(message.getId());
            chatList2.setLastMessageContent(message.getContent());
            chatList2.setSendTime(message.getTime());
            //未读数量+1
            chatList2.setUnReadNum(chatList2.getUnReadNum() + 1);
            chatListDao.updateById(chatList2);
            //todo 聊天列表变动 发送消息给前端 发给好友的, 要填好友的id
        }

    }


}


@Component
public class MessageService {

    @Resource
    private MessageDao messageDao;
    @Resource
    private ChatListService chatListService;

    /**
     * 1.查询聊天记录
     * 2.未读数量清0
     * 3.如果没有聊天列表的话, 要新增一条空的聊天列表
     * @param currUserId
     * @param friendId
     * @return
     */
    public List<Message> getMessageRecordBetweenUsers(Long currUserId, Long friendId){
        List<Message> messageRecordBetweenUsers = messageDao.getMessageRecordBetweenUsers(currUserId, friendId);
        chatListService.read(currUserId,friendId);
        chatListService.add(currUserId,friendId);

        return messageRecordBetweenUsers;
    }

    public Integer save(Message message) {
        return messageDao.insert(message);
    }

}

10. controller 包含部分初始化接口

部分初始化接口:


@Controller
@RequestMapping("/chatList")
public class ChatListController {

    @Resource
    private ChatListService chatListService;

    /**
     查询我的聊天列表, 及最后一条消息记录, 及未读消息数量
     */
    @GetMapping("/detail/{currUserId}")
    public ResponseEntity detail(@PathVariable("currUserId") Long currUserId){
        return ResponseEntity.ok().body(chatListService.getChatList(currUserId));
    }

    /**
     未读数量清0,
     前端点开好友聊天框时 默认清0, 不需要额外触发,
     其他情况清0时 调用;
     */
    @GetMapping("/read/{currUserId}/{friendId}")
    public ResponseEntity read(@PathVariable("currUserId") Long currUserId,
                               @PathVariable("friendId") Long friendId){
        chatListService.read(currUserId,friendId);
        return ResponseEntity.ok().build();
    }

    /**
     * 删除自己的一条聊天列表
     * @param currUserId
     * @param friendId
     * @return
     */
    @GetMapping("/delete/{currUserId}/{friendId}")
    public ResponseEntity delete(@PathVariable("currUserId") Long currUserId,
                               @PathVariable("friendId") Long friendId){
        chatListService.delete(currUserId,friendId);
        return ResponseEntity.ok().build();
    }

}

@Controller
@RequestMapping("/messageList")
public class MessageController {

    @Resource
    private MessageService messageService;

    /**
     * 点开聊天框时触发, 查询与好友的聊天记录, 并将未读数量清0
     * @param currUserId
     * @param friendId
     * @return
     */
    @GetMapping(value="/getMessageRecordBetweenUsers/{currUserId}/{friendId}")
    public ResponseEntity getMessageRecordBetweenUsers(@PathVariable("currUserId") Long currUserId,
                                                       @PathVariable("friendId") Long friendId){
        return ResponseEntity.ok().body(messageService.getMessageRecordBetweenUsers(currUserId, friendId));
    }

}

你可能感兴趣的:(websocket,spring,boot,java)