websocket性能低?教你使用netty整合websocket(三)——客戶端聊天消息存儲到redis和MySQL,並實現離線消息的處理

前提

瞭解如何實現點對點聊天(客戶端與客戶端通信)請參看上一篇博客:SpringBoot+Netty整合websocket(二)——實現點對點聊天(客戶端與客戶端通信)

在上一篇博客中實現了點對點聊天(客戶端與客戶端通信),但仍存在一些問題:

  1. 客戶端的聊天消息還未實現暫存redis或者存儲mysql(瀏覽器刷新後,消息就會丟失)。
  2. 客戶端如果離線則不能接受消息,即缺少對離線消息的處理。

接下來將以一個實例來說明。

注:此篇博客是在上一篇博客基礎上所寫,請同時參看上一篇博客。

需求

  1. 主要存在兩種類型的用戶,一個是提問方,可以提出問題;另一個是解答方,可以解答問題。
  2. 解答方和提問方在解答問題時,需要建立聊天室,進行聊天解答。

核心數據庫設計

關於用戶表就不展示了,主要用其user.id

問題表
在這裏插入圖片描述

問題表解讀

ask_idanswer_id都是用戶id,代表的是提問方id和解答方id
context是解決問題時的聊天內容,類型是TEXT(重點)
其餘字段和聊天存儲關係不大,略。

後端實現

關於客戶端與客戶端通信,請參看上一篇文章。客戶端聊天消息存儲到redis和MySQL,並實現離線消息的處理,主要修改的是WebSocketHandler類。

1.引入依賴
		<!-- redis -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-redis</artifactId>
        </dependency>

		<!--測試redis連接-->
        <dependency>
            <groupId>redis.clients</groupId>
            <artifactId>jedis</artifactId>
            <version>2.9.3</version>
        </dependency>

		<!--Mysql依賴包-->
        <dependency>
            <groupId>mysql</groupId>
            <artifactId>mysql-connector-java</artifactId>
            <scope>runtime</scope>
        </dependency>
        <!-- druid數據源驅動 -->
        <dependency>
            <groupId>com.alibaba</groupId>
            <artifactId>druid-spring-boot-starter</artifactId>
            <version>${druid.version}</version>
        </dependency>
		
2.配置application.yml
spring:
  datasource:
    druid:
      db-type: com.alibaba.druid.pool.DruidDataSource
      driverClassName: net.sf.log4jdbc.sql.jdbcapi.DriverSpy
      url: jdbc:log4jdbc:mysql://ip:3306/數據庫?serverTimezone=Asia/Shanghai&characterEncoding=utf8&useSSL=false
      username: 
      password: 

  redis:
    #數據庫索引
    database: 0
    host: IP地址
    port: 6379
    password: redis
    #連接超時時間
    timeout: 10000
    jedis:
      pool:
        # 連接池最大連接數(使用負值表示沒有限制)
        max-active: -1
        # 連接池最大阻塞等待時間(使用負值表示沒有限制)
        max-wait: -1
        
# netty運行端口
netty:
  port: 10101
3.question的實體類略
4.整合redis
配置RedisConfig
@Slf4j
@Configuration
@EnableCaching
@ConditionalOnClass(RedisOperations.class)
@EnableConfigurationProperties(RedisProperties.class)
public class RedisConfig extends CachingConfigurerSupport {

    /**
     *  設置 redis 數據默認過期時間,默認2小時
     *  設置@cacheable 序列化方式
     */
    @Bean
    public RedisCacheConfiguration redisCacheConfiguration(){
        FastJsonRedisSerializer<Object> fastJsonRedisSerializer = new FastJsonRedisSerializer<>(Object.class);
        RedisCacheConfiguration configuration = RedisCacheConfiguration.defaultCacheConfig();
        configuration = configuration.serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(fastJsonRedisSerializer)).entryTtl(Duration.ofHours(2));
        return configuration;
    }

    @SuppressWarnings("all")
    @Bean(name = "redisTemplate")
    @ConditionalOnMissingBean(name = "redisTemplate")
    public RedisTemplate<Object, Object> redisTemplate(RedisConnectionFactory redisConnectionFactory) {
        RedisTemplate<Object, Object> template = new RedisTemplate<>();
        //序列化
        FastJsonRedisSerializer<Object> fastJsonRedisSerializer = new FastJsonRedisSerializer<>(Object.class);
        // value值的序列化採用fastJsonRedisSerializer
        template.setValueSerializer(fastJsonRedisSerializer);
        template.setHashValueSerializer(fastJsonRedisSerializer);
        // 全局開啓AutoType,這裏方便開發,使用全局的方式
        ParserConfig.getGlobalInstance().setAutoTypeSupport(true);
        // 建議使用這種方式,小範圍指定白名單
        // ParserConfig.getGlobalInstance().addAccept("me.zhengjie.domain");
        // key的序列化採用StringRedisSerializer
        template.setKeySerializer(new StringRedisSerializer());
        template.setHashKeySerializer(new StringRedisSerializer());
        template.setConnectionFactory(redisConnectionFactory);

        return template;
    }

    /**
     * 自定義緩存key生成策略,默認將使用該策略
     */
    @Bean
    @Override
    public KeyGenerator keyGenerator() {
        return (target, method, params) -> {
            Map<String,Object> container = new HashMap<>(3);
            Class<?> targetClassClass = target.getClass();
            // 類地址
            container.put("class",targetClassClass.toGenericString());
            // 方法名稱
            container.put("methodName",method.getName());
            // 包名稱
            container.put("package",targetClassClass.getPackage());
            // 參數列表
            for (int i = 0; i < params.length; i++) {
                container.put(String.valueOf(i),params[i]);
            }
            // 轉爲JSON字符串
            String jsonString = JSON.toJSONString(container);
            // 做SHA256 Hash計算,得到一個SHA256摘要作爲Key
            return DigestUtils.sha256Hex(jsonString);
        };
    }

    @Bean
    @Override
    public CacheErrorHandler errorHandler() {
        // 異常處理,當Redis發生異常時,打印日誌,但是程序正常走
        log.info("初始化 -> [{}]", "Redis CacheErrorHandler");
        return new CacheErrorHandler() {
            @Override
            public void handleCacheGetError( RuntimeException e, Cache cache, Object key) {
                log.error("Redis occur handleCacheGetError:key -> [{}]", key, e);
            }

            @Override
            public void handleCachePutError(RuntimeException e, Cache cache, Object key, Object value) {
                log.error("Redis occur handleCachePutError:key -> [{}];value -> [{}]", key, value, e);
            }

            @Override
            public void handleCacheEvictError(RuntimeException e, Cache cache, Object key) {
                log.error("Redis occur handleCacheEvictError:key -> [{}]", key, e);
            }

            @Override
            public void handleCacheClearError(RuntimeException e, Cache cache) {
                log.error("Redis occur handleCacheClearError:", e);
            }
        };
    }


    /**
     * 重寫序列化器
     */
    static class StringRedisSerializer implements RedisSerializer<Object> {

        private final Charset charset;

        StringRedisSerializer() {
            this(StandardCharsets.UTF_8);
        }

        private StringRedisSerializer(Charset charset) {
            Assert.notNull(charset, "Charset must not be null!");
            this.charset = charset;
        }

        @Override
        public String deserialize(byte[] bytes) {
            return (bytes == null ? null : new String(bytes, charset));
        }

        @Override
        public byte[] serialize(Object object) {
            String string = JSON.toJSONString(object);
            if (StringUtils.isBlank(string)) {
                return null;
            }
            string = string.replace("\"", "");
            return string.getBytes(charset);
        }


    }

}

/**
 * Value 序列化
 * @param <T>
 */
class FastJsonRedisSerializer<T> implements RedisSerializer<T> {

    private Class<T> clazz;

    FastJsonRedisSerializer(Class<T> clazz) {
        super();
        this.clazz = clazz;
    }

    @Override
    public byte[] serialize(T t) {
        if (t == null) {
            return new byte[0];
        }
        return JSON.toJSONString(t, SerializerFeature.WriteClassName).getBytes(StandardCharsets.UTF_8);
    }

    @Override
    public T deserialize(byte[] bytes) {
        if (bytes == null || bytes.length <= 0) {
            return null;
        }
        String str = new String(bytes, StandardCharsets.UTF_8);
        return JSON.parseObject(str, clazz);
    }

}

配置RedisUtil(redis常用的相關方法)

因爲內容比較多,放在文章末尾“附”

與聊天相關的redis工具類
@Component
@Slf4j
public class ChatRedisUtil {

	@Autowired
    private RedisUtil redisUtil;
   
    /**
     * 功能描述:將JavaBean對象的信息緩存進Redis
     *
     * @param chatVO 聊天信息JavaBean
     * @return 是否保存成功
     */
    public boolean saveCacheChatMessage ( String key, ChatVO chatVO ) {
        //判斷key是否存在
        if (redisUtils.hasKey(key)) {
            //將javabean對象添加到緩存的list中
            long redisSize = redisUtils.lGetListSize(key);
            System.out.println("redis當前數據條數" + redisSize);
            Long index = redisUtils.rightPushValue(key, chatVO);
            System.out.println("redis執行rightPushList返回值:" + index);
            return redisSize < index;
        } else {
            //不存在key時,將chatVO存進緩存,並設置過期時間
            boolean isCache = redisUtils.lSet(key, chatVO);
            //保存成功,設置過期時間
            if (isCache) {
                redisUtils.expire(key, 3L, TimeUnit.DAYS);
            }
            return isCache;
        }
    }

    /**
     * 功能描述:從緩存中讀取聊天信息
     *
     * @param key 緩存聊天信息的鍵
     * @return 緩存中聊天信息list
     */
    public List<Object> getCacheChatMessage ( String key ) {
        List<Object> chatList = null;
        //判斷key是否存在
        if (redisUtils.hasKey(key)) {
            //讀取緩存中的聊天內容
            chatList = redisUtils.lGet(key, 0, redisUtils.lGetListSize(key));
        } else {
            System.out.println("此次解答無聊天信息");
            log.info("redis緩存中無此鍵值:" + key);
        }
        return chatList;
    }

    /**
     * 功能描述: 在緩存中刪除聊天信息
     *
     * @param key 緩存聊天信息的鍵
     */
    public void deleteCacheChatMessage ( String key ) {
        //判斷key是否存在
        if (redisUtils.hasKey(key)) {
            redisUtils.del(key);
        }
    }

    /**
     * 功能描述: 創建已發送消息房間號
     * 根據ChatVO中的fromUserId和toUserId生成聊天房間號:問題id-小號用戶id-大號用戶id
     * 例如“1-2”: 小號在前,大號在後;保證房間號唯一
     *
     * @param fromUserId 發送方id
     * @param toUserId   接收方id
     */
    public String createChatNumber (Integer questionId, Integer fromUserId, Integer toUserId) {
        StringBuilder key = new StringBuilder();
        key.append(questionId).append("-");
        if (fromUserId < toUserId) {
            key.append(fromUserId).append("-").append(toUserId);
        } else {
            key.append(toUserId).append("-").append(fromUserId);
        }
        return key.toString();
    }

    /**
     * 功能描述:創建離線聊天記錄的房間號(redis的鍵)
     *      拼接方式:發送方用戶id-簽證標識
     * @param toUserId 發送方用戶id
     * @return 用戶離線消息房間號
     */
    public String createOffLineNumber(Integer toUserId){
        return toUserId + "-" + MsgSignFlagEnum.unsign.type;
    }

    /**
     * 功能描述:從redis讀取緩存信息集合(List<Object>),並且存儲到新的鍵中  oldKey——>newKey
     */
    public void signQuestionMessageList(String oldKey,String newKey){
        redisUtils.rightPushList(newKey,getCacheChatMessage(oldKey));
    }

    /**
     * 功能描述:從redis讀取每一條緩存信息,並且存儲到新的鍵中  oldKey——>newKey
     */
    public void signQuestionMessage(String oldKey,String newKey){
        redisUtils.rightPushValue(newKey,getCacheChatMessage(oldKey));
    }

}

5.自定義SpringContextHolder工具類

用於在Spring的bean容器中獲取實例

/**
 * 通過SpringContextHolder獲取bean對象的工具類
 *
 * 需要將SpringContextHolder注入到bean容器中,所以加@Configuration註解
 */
@Slf4j
@Configuration
public class SpringContextHolder implements ApplicationContextAware, DisposableBean {

    private static ApplicationContext applicationContext = null;

    /**
     * 從靜態變量applicationContext中取得Bean, 自動轉型爲所賦值對象的類型.
     */
    @SuppressWarnings("unchecked")
    public static <T> T getBean(String name) {
        assertContextInjected();
        return (T) applicationContext.getBean(name);
    }

    /**
     * 從靜態變量applicationContext中取得Bean, 自動轉型爲所賦值對象的類型.
     */
    public static <T> T getBean(Class<T> requiredType) {
        assertContextInjected();
        return applicationContext.getBean(requiredType);
    }

    /**
     * 檢查ApplicationContext不爲空.
     */
    private static void assertContextInjected() {
        if (applicationContext == null) {
            throw new IllegalStateException("applicaitonContext屬性未注入, 請在applicationContext" +
                    ".xml中定義SpringContextHolder或在SpringBoot啓動類中註冊SpringContextHolder.");
        }
    }

    /**
     * 清除SpringContextHolder中的ApplicationContext爲Null.
     */
    private static void clearHolder() {
        log.debug("清除SpringContextHolder中的ApplicationContext:"
                + applicationContext);
        applicationContext = null;
    }

    @Override
    public void destroy(){
        SpringContextHolder.clearHolder();
    }

    @Override
    public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
        if (SpringContextHolder.applicationContext != null) {
            log.warn("SpringContextHolder中的ApplicationContext被覆蓋, 原有ApplicationContext爲:" + SpringContextHolder.applicationContext);
        }
        SpringContextHolder.applicationContext = applicationContext;
    }
}

6.修改WebSocketHandler

如果在WebSocketHandler直接使用@Autowired注入ChatRedisUtil會報空指針錯誤,所以在WebSocketHandler中使用ChatRedisUtil需要用SpringContextHolder在bean容器中獲取實例。使用如下:

 //實例化redis對象,通過自定義的SpringContextHolder在bean容器中獲取chatRedisUtil對象
ChatRedisUtil chatRedisUtil = SpringContextHolder.getBean(ChatRedisUtil.class);
@Slf4j
public class WebSocketHandler extends SimpleChannelInboundHandler<Object> {

    /**
     * 客戶端組
     * 用於記錄和管理所有客戶端的channel
     */
    public static ChannelGroup channelGroup;

    static {
        channelGroup = new DefaultChannelGroup(GlobalEventExecutor.INSTANCE);
    }

    /**
     * 接收客戶端傳來的消息
     *
     * @param ctx
     * @param msg
     * @throws Exception
     */
    @Override
    protected void channelRead0 ( ChannelHandlerContext ctx, Object msg ) throws Exception {
        Channel currentChannel = ctx.channel();
        //實例化redis對象,通過自定義的SpringContextHolder在bean容器中獲取chatRedisUtil對象
        ChatRedisUtil chatRedisUtil = SpringContextHolder.getBean(ChatRedisUtil.class);
        //文本消息
        if (msg instanceof TextWebSocketFrame) {
            String message = ((TextWebSocketFrame) msg).text();
            System.out.println("收到客戶端消息:" + message);
            //json消息轉換爲Javabean對象
            ChatMsgVO chatMsgVO = null;
            try {
                chatMsgVO = JSONUtil.toBean(message, ChatMsgVO.class, true);
            } catch (JSONException e) {
                e.printStackTrace();
                System.out.println("json解析異常,發送的消息應該爲json格式");
                return;
            }
            Integer action = chatMsgVO.getAction();
            if (action.equals(MsgActionEnum.CONNECT.type)) {
                //當websocket第一次open的時候,初始化channel,把用的channel和userId關聯起來
                Integer fromUserId = chatMsgVO.getFromUserId();
                UserChannelRel.put(fromUserId, currentChannel);
                //測試
                channelGroup.forEach(channel -> log.info(channel.id().asLongText()));
                UserChannelRel.output();
                /* 第一次或者斷線重連,查詢redis此用戶的離線消息,並處理 */
                //查詢此用戶的離線消息
                String unsignKey=chatRedisUtil.createOffLineNumber(fromUserId);
                List<Object> offLineMessageList = chatRedisUtil.getCacheChatMessage(unsignKey);
                //若有離線消息
                if (offLineMessageList!=null){
                    //遍歷當前用戶的所有離線消息
                    for (int i=0;i<offLineMessageList.size();i++){
                        //離線消息json轉javabean
                        ChatVO chatVO= (ChatVO) offLineMessageList.get(i);
                        //將離線消息發送給當前用戶
                        sendMessage(fromUserId, JSONUtil.toJsonStr(chatVO));
                        //每條消息對應的已讀消息的房間號
                        String signKey = chatRedisUtil.createChatNumber(chatVO.getQuestionId(), chatVO.getFromUserId(), chatVO.getToUserId());
                        //每條消息的簽證
                        chatRedisUtil.saveCacheChatMessage(signKey,chatVO);
                    }
                    //簽證完成後,在redis中刪除離線消息
                    chatRedisUtil.deleteCacheChatMessage(unsignKey);
                }
            } else if (action.equals(MsgActionEnum.CHAT.type)) {
                //聊天類型的消息,把聊天記錄保存到redis,同時標記消息的簽收狀態[是否簽收]
                Integer toUserId = chatMsgVO.getToUserId();
                Channel receiverChannel = UserChannelRel.get(toUserId);
                //將消息轉化爲需要保存在redis中的消息
                ChatVO chatVO = JSONUtil.toBean(message, ChatVO.class, true);
                //消息保存至redis的鍵
                String key = "";
                //設置發送消息的時間
                chatVO.setDateTime(new DateTime());
                if (receiverChannel == null) {
                    //接收方離線狀態,將消息保存到redis,並設置[未簽收]狀態
                    //設置redis鍵爲未接收房間號。拼接發送方和接收方,成爲房間號;MsgSignFlagEnum.signed.type爲0,代表未簽收
                    key = chatRedisUtil.createOffLineNumber(chatVO.getToUserId());
                } else {
                    //接受方在線,服務端直接轉發聊天消息給用戶,並將消息存儲到redis,並設置[簽收]狀態
                    //設置redis鍵爲已接收房間號。拼接發送方和接收方,成爲房間號;MsgSignFlagEnum.signed.type爲1,代表已經簽收
                    key = chatRedisUtil.createChatNumber(chatVO.getQuestionId(), chatVO.getFromUserId(), chatVO.getToUserId());

                    /* 發送消息給指定用戶 */
                    //判斷消息是否符合定義的類型
                    if (ChatTypeVerificationUtil.verifyChatType(chatVO.getChatMessageType())) {
                        //發送消息給指定用戶
                        if (toUserId > 0 && UserChannelRel.isContainsKey(toUserId)) {
                            sendMessage(toUserId, JSONUtil.toJsonStr(chatVO));
                        }
                    } else {
                        //消息不符合定義的類型的處理
                    }
                }
                /* 消息保存到redis中 */
                boolean isCache = chatRedisUtil.saveCacheChatMessage(key, chatVO);
                //緩存失敗,拋出異常
                if (!isCache) {
                    throw new BadRequestException("聊天內容緩存失敗,聊天信息內容:" + message);
                }
            } else if (action.equals(MsgActionEnum.KEEPALIVE.type)) {
                //心跳類型的消息
                log.info("收到來自channel爲[" + currentChannel + "]的心跳包");
            }

        }
        //二進制消息
        if (msg instanceof BinaryWebSocketFrame) {
            System.out.println("收到二進制消息:" + ((BinaryWebSocketFrame) msg).content().readableBytes());
            BinaryWebSocketFrame binaryWebSocketFrame = new BinaryWebSocketFrame(Unpooled.buffer().writeBytes("hello".getBytes()));
            //給客戶端發送的消息
            ctx.channel().writeAndFlush(binaryWebSocketFrame);
        }
        //ping消息
        if (msg instanceof PongWebSocketFrame) {
            System.out.println("客戶端ping成功");
        }
        //關閉消息
        if (msg instanceof CloseWebSocketFrame) {
            System.out.println("客戶端關閉,通道關閉");
            Channel channel = ctx.channel();
            channel.close();
        }

    }

    /**
     * Handler活躍狀態,表示連接成功
     * 當客戶端連接服務端之後(打開連接)
     * 獲取客戶端的channel,並且放到ChannelGroup中去進行管理
     *
     * @param ctx
     * @throws Exception
     */
    @Override
    public void handlerAdded ( ChannelHandlerContext ctx ) throws Exception {
        System.out.println("與客戶端連接成功");
        channelGroup.add(ctx.channel());
    }

    /**
     * @param ctx
     * @throws Exception
     */
    @Override
    public void handlerRemoved ( ChannelHandlerContext ctx ) throws Exception {
        //當觸發handlerRemoved,ChannelGroup會自動移除對應的客戶端的channel
        //所以下面這條語句可不寫
//        clients.remove(ctx.channel());
        log.info("客戶端斷開,channel對應的長id爲:" + ctx.channel().id().asLongText());
        log.info("客戶端斷開,channel對應的短id爲:" + ctx.channel().id().asShortText());
    }

    /**
     * 異常處理
     *
     * @param ctx
     * @param cause
     * @throws Exception
     */
    @Override
    public void exceptionCaught ( ChannelHandlerContext ctx, Throwable cause ) throws Exception {
        System.out.println("連接異常:" + cause.getMessage());
        cause.printStackTrace();
        ctx.channel().close();
        channelGroup.remove(ctx.channel());
    }

    @Override
    public void userEventTriggered ( ChannelHandlerContext ctx, Object evt ) throws Exception {
        //IdleStateEvent是一個用戶事件,包含讀空閒/寫空閒/讀寫空閒
        if (evt instanceof IdleStateEvent) {
            IdleStateEvent event = (IdleStateEvent) evt;
            if (event.state() == IdleState.READER_IDLE) {
                log.info("進入讀空閒");
            } else if (event.state() == IdleState.WRITER_IDLE) {
                log.info("進入寫空閒");
            } else if (event.state() == IdleState.ALL_IDLE) {
                log.info("channel關閉前,用戶數量爲:" + channelGroup.size());
                //關閉無用的channel,以防資源浪費
                ctx.channel().close();
                log.info("channel關閉後,用戶數量爲:" + channelGroup.size());
            }

        }
    }

    /**
     * 給指定用戶發內容
     * 後續可以掉這個方法推送消息給客戶端
     */
    public void sendMessage ( Integer toUserId, String message ) {
        Channel channel = UserChannelRel.get(toUserId);
        channel.writeAndFlush(new TextWebSocketFrame(message));
    }

    /**
     * 羣發消息
     */
    public void sendMessageAll ( String message ) {
        channelGroup.writeAndFlush(new TextWebSocketFrame(message));
    }


}

聊天消息整合進mysql

聊天業務使用比較頻繁,爲了降低mysql的壓力,所以一般不採用,每發送一條消息,就直接存進mysql。
現在所有的消息,不管是離線還是在線都暫存在redis。

我們可以提供接口,手動將redis的消息存儲進mysql,也可以利用定時任務等,將redis的消息讀取出來然後存進mysql,這些都是可以的。

也可利用接口形式,查詢redis暫存的聊天信息。
這裏採用提供接口的方式。

Controller
@RestController
@RequestMapping("/api/question")
public class QuestionController {

	/**
	 * 修改問題狀態:聊天信息從redis保存到數據庫(當結束此次解答時調用),status改爲2
	 */
	@PutMapping("/updateChatContent")
    public ResponseEntity<Object> updateChatContent(Integer questionId,Integer askId,Integer answerId){
        questionService.updateChatContent(questionId,askId,answerId);
        return new ResponseEntity<>(HttpStatus.NO_CONTENT);
    }

	/**
	 * 查詢解決問題時的聊天記錄(從redis緩存中查詢)
	 */
    @GetMapping("/getChatContent")
    public ResponseEntity<Object> getChatContent(Integer questionId,Integer askId,Integer answerId){
        return new ResponseEntity<>(questionService.getChatContent(questionId,askId,answerId),HttpStatus.OK);
    }

}
Service
public interface QuestionService {

    /**
     * 功能描述:解答完畢時,將聊天內容存進mysql,清空redis緩存
     * @param questionId 問題id
     * @param askId 提問方id
     * @param answerId 解答方id
     */
    void updateChatContent ( Integer questionId, Integer askId, Integer answerId );
    
    /**
     * 功能描述:查詢歷史聊天記錄(從redis緩存中查詢)
     * @param questionId 問題id
     * @param askId 提問方id
     * @param answerId 解答方id
     * @return 聊天信息的json字符串
     */
    String getChatContent ( Integer questionId, Integer askId, Integer answerId);

}
ServiceImpl
@Service
public class QuestionServiceImpl implements QuestionService {

	@Override
    @Transactional(rollbackFor = Exception.class)
    public void updateChatContent ( Integer questionId, Integer askId, Integer answerId ) {
        //實例化對象,從bean中取出  (還不理解)
        ChatRedisUtil chatRedisUtil = SpringContextHolder.getBean(ChatRedisUtil.class);
        //將用戶未收到的消息,進行簽證

        //將askId和answerId拼接成key,即房間號
        String key = chatRedisUtil.createChatNumber(questionId, askId, answerId);
        //得到緩存中的聊天信息的json字符串
        String chatContent = getChatContent(questionId, askId, answerId);
        //將數據存進數據庫
        Question oldQuestion = questionRepository.findById(questionId).orElseGet(Question::new);
        ValidationUtil.isNull(oldQuestion.getId(), "Question", "id", questionId);
        oldQuestion.setContext(chatContent);
        //設置問題狀態爲已解答
        oldQuestion.setStatus(2);
        oldQuestion.setSolveTime(new Timestamp(System.currentTimeMillis()));
        Question newQuestion = questionRepository.save(oldQuestion);
        if (newQuestion.getId() > 0) {
            //清除redis中緩存的數據
            chatRedisUtil.deleteCacheChatMessage(key);
        }
    }

    @Override
    public String getChatContent ( Integer questionId, Integer askId, Integer answerId) {
        //實例化對象,從bean中取出  (還不理解)
        ChatRedisUtil chatRedisUtil = SpringContextHolder.getBean(ChatRedisUtil.class);
        //將askId和answerId拼接成key,即房間號
        String key = chatRedisUtil.createChatNumber(questionId, askId, answerId);
        List<Object> chatList = chatRedisUtil.getCacheChatMessage(key);
        //將List數據轉爲json字符串,返回
        return JSONUtil.toJsonStr(chatList);
    }
}

question的mapper省略

原理解析

聊天消息存儲到redis的原理

1.引入房間號的概念,作爲redis的鍵值

創建房間號的方法詳情見ChatRedisUtil

創建已發送消息房間號(客戶端websocket在線,可直接發送消息)

根據ChatVO中的questionId、fromUserId和toUserId生成聊天房間號:問題id-小號用戶id-大號用戶id
例如房間號“11-1-2”——問題id11,用戶1,用戶2(用戶id小號在前,大號在後)。即用戶1、用戶2關於問題1所產生的聊天記錄。如果不需要和問題等實例關聯,則可以忽略問題id,直接拼接用戶id作爲房間號。

創建離線聊天房間號(客戶端websocket不在線,不能直接發送消息)

根據發送方用戶id和簽證標識拼接房間號:發送方用戶id-簽證標識(簽證標識爲0,說明是離線消息,可自行定義)

根據已發送消息房間號和離線聊天房間號就可以實現聊天記錄存儲在redis。

2.聊天記錄在redis的存儲方式

因爲我們定義的有ChatMsgVO和ChatVO,說明一條消息是一個實體類,一個房間號裏面可以存很多條消息,可以使用List也可以使用Json存儲,這裏我們採用在redis中以List方式存取聊天信息。

關於redis對List的使用詳情請參看附錄中的RedisUtil

3.如何區分接收方是在線狀態還是離線狀態?

通過UserChannelRelget方法,如果得到的Channel爲空說明是離線狀態,反之則是在線狀態。

	/** 根據用戶id查詢 */
    public static Channel get(Integer senderId) {
        return manager.get(senderId);
    }

具體使用:

	Channel receiverChannel = UserChannelRel.get(toUserId);
	if (receiverChannel == null) {
		//接收方離線,直接緩存到離線房間號的redis
    } else {
		//在線,直接發送消息,並緩存redis
	}

4.處理聊天消息(redis)

	if (action.equals(MsgActionEnum.CHAT.type)) {
	   //聊天類型的消息,把聊天記錄保存到redis,同時標記消息的簽收狀態[是否簽收]
	   Integer toUserId = chatMsgVO.getToUserId();
	   Channel receiverChannel = UserChannelRel.get(toUserId);
	   //將消息轉化爲需要保存在redis中的消息
	   ChatVO chatVO = JSONUtil.toBean(message, ChatVO.class, true);
	   //消息保存至redis的鍵
	   String key = "";
	   //設置發送消息的時間
	   chatVO.setDateTime(new DateTime());
	   if (receiverChannel == null) {
	       //設置redis鍵爲未接收房間號。拼接發送方和接收方,成爲房間號;MsgSignFlagEnum.signed.type爲0,代表未簽收
	       key = chatRedisUtil.createOffLineNumber(chatVO.getToUserId());
	   } else {
	      //設置redis鍵爲已接收房間號。拼接發送方和接收方,成爲房間號;MsgSignFlagEnum.signed.type爲1,代表已經簽收
	      key = chatRedisUtil.createChatNumber(chatVO.getQuestionId(), chatVO.getFromUserId(), chatVO.getToUserId());
	      
	      /* 發送消息給指定用戶 */
	      //判斷消息是否符合定義的類型
	      if (ChatTypeVerificationUtil.verifyChatType(chatVO.getChatMessageType())) {
	          //發送消息給指定用戶
	          if (toUserId > 0 && UserChannelRel.isContainsKey(toUserId)) {
	              sendMessage(toUserId, JSONUtil.toJsonStr(chatVO));
	          }
	      } else {
	          //消息不符合定義的類型的處理
	   
	      }
	      /* 消息保存到redis中 */
	      boolean isCache = chatRedisUtil.saveCacheChatMessage(key, chatVO);
	      //緩存失敗,拋出異常
	      if (!isCache) {
	          hrow new BaseException("聊天內容緩存失敗,聊天信息內容:" + message);
	      }
	} 

5.用戶第一次登陸或者重連時,處理離線消息

用戶第一次登陸或者重連時,根據用戶id,查看redis是否有離線消息(根據離線房間號是否存在判斷)。如果有,遍歷房間號內所有的離線消息,將其每條消息存儲進對應的在線消息房間號的redis中,然後將消息重新發送給用戶,執行完後刪除離線房間號的所有記錄。

	/* 第一次或者斷線重連,查詢redis此用戶的離線消息,並處理 */
    //查詢此用戶的離線消息
    String unsignKey=chatRedisUtil.createOffLineNumber(fromUserId);
    List<Object> offLineMessageList = chatRedisUtil.getCacheChatMessage(unsignKey);
    //若有離線消息
    if (offLineMessageList!=null){
       //遍歷當前用戶的所有離線消息
       for (int i=0;i<offLineMessageList.size();i++){
           //離線消息json轉javabean
           ChatVO chatVO= (ChatVO) offLineMessageList.get(i);
           //將離線消息發送給當前用戶
           sendMessage(fromUserId, JSONUtil.toJsonStr(chatVO));
           //每條消息對應的已讀消息的房間號
           String signKey = chatRedisUtil.createChatNumber(chatVO.getQuestionId(), chatVO.getFromUserId(), chatVO.getToUserId());
           //每條消息的簽證
           chatRedisUtil.saveCacheChatMessage(signKey,chatVO);
       }
       //簽證完成後,在redis中刪除離線消息
       chatRedisUtil.deleteCacheChatMessage(unsignKey);
   }

5.消息存儲進mysql

我的思路是每當問題解決後,調用接口,手動將redis的消息存儲進mysql。這塊可以根據業務邏輯具體實現。

RedisUtil
@Component
@SuppressWarnings({"unchecked", "all"})
public class RedisUtil {

    @Autowired
    private RedisTemplate<Object, Object> redisTemplate;

    // =============================common============================

    /**
     * 指定緩存失效時間
     *
     * @param key  鍵
     * @param time 時間(秒)
     */
    public boolean expire ( String key, long time ) {
        try {
            if (time > 0) {
                redisTemplate.expire(key, time, TimeUnit.SECONDS);
            }
        } catch (Exception e) {
            e.printStackTrace();
            return false;
        }
        return true;
    }

    /**
     * 根據 key 獲取過期時間
     *
     * @param key 鍵 不能爲null
     * @return 時間(秒) 返回0代表爲永久有效
     */
    public long getExpire ( Object key ) {
        return redisTemplate.getExpire(key, TimeUnit.SECONDS);
    }

    /**
     * 查找匹配key
     *
     * @param pattern key
     * @return /
     */
    public List<String> scan ( String pattern ) {
        ScanOptions options = ScanOptions.scanOptions().match(pattern).build();
        RedisConnectionFactory factory = redisTemplate.getConnectionFactory();
        RedisConnection rc = Objects.requireNonNull(factory).getConnection();
        Cursor<byte[]> cursor = rc.scan(options);
        List<String> result = new ArrayList<>();
        while (cursor.hasNext()) {
            result.add(new String(cursor.next()));
        }
        try {
            RedisConnectionUtils.releaseConnection(rc, factory);
        } catch (Exception e) {
            e.printStackTrace();
        }
        return result;
    }

    /**
     * 分頁查詢 key
     *
     * @param patternKey key
     * @param page       頁碼
     * @param size       每頁數目
     * @return /
     */
    public List<String> findKeysForPage ( String patternKey, int page, int size ) {
        ScanOptions options = ScanOptions.scanOptions().match(patternKey).build();
        RedisConnectionFactory factory = redisTemplate.getConnectionFactory();
        RedisConnection rc = Objects.requireNonNull(factory).getConnection();
        Cursor<byte[]> cursor = rc.scan(options);
        List<String> result = new ArrayList<>(size);
        int tmpIndex = 0;
        int fromIndex = page * size;
        int toIndex = page * size + size;
        while (cursor.hasNext()) {
            if (tmpIndex >= fromIndex && tmpIndex < toIndex) {
                result.add(new String(cursor.next()));
                tmpIndex++;
                continue;
            }
            // 獲取到滿足條件的數據後,就可以退出了
            if (tmpIndex >= toIndex) {
                break;
            }
            tmpIndex++;
            cursor.next();
        }
        try {
            RedisConnectionUtils.releaseConnection(rc, factory);
        } catch (Exception e) {
            e.printStackTrace();
        }
        return result;
    }

    /**
     * 判斷key是否存在
     *
     * @param key 鍵
     * @return true 存在 false不存在
     */
    public boolean hasKey ( String key ) {
        try {
            return redisTemplate.hasKey(key);
        } catch (Exception e) {
            e.printStackTrace();
            return false;
        }
    }

    /**
     * 刪除緩存
     *
     * @param key 可以傳一個值 或多個
     */
    public void del ( String... key ) {
        if (key != null && key.length > 0) {
            if (key.length == 1) {
                redisTemplate.delete(key[0]);
            } else {
                redisTemplate.delete(CollectionUtils.arrayToList(key));
            }
        }
    }

    // ============================String=============================

    /**
     * 普通緩存獲取
     *
     * @param key 鍵
     * @return 值
     */
    public Object get ( String key ) {
        return key == null ? null : redisTemplate.opsForValue().get(key);
    }

    /**
     * 批量獲取
     *
     * @param keys
     * @return
     */
    public List<Object> multiGet ( List<String> keys ) {
        Object obj = redisTemplate.opsForValue().multiGet(Collections.singleton(keys));
        return null;
    }

    /**
     * 普通緩存放入
     *
     * @param key   鍵
     * @param value 值
     * @return true成功 false失敗
     */
    public boolean set ( String key, Object value ) {
        try {
            redisTemplate.opsForValue().set(key, value);
            return true;
        } catch (Exception e) {
            e.printStackTrace();
            return false;
        }
    }

    /**
     * 普通緩存放入並設置時間
     *
     * @param key   鍵
     * @param value 值
     * @param time  時間(秒) time要大於0 如果time小於等於0 將設置無限期
     * @return true成功 false 失敗
     */
    public boolean set ( String key, Object value, long time ) {
        try {
            if (time > 0) {
                redisTemplate.opsForValue().set(key, value, time, TimeUnit.SECONDS);
            } else {
                set(key, value);
            }
            return true;
        } catch (Exception e) {
            e.printStackTrace();
            return false;
        }
    }

    /**
     * 普通緩存放入並設置時間
     *
     * @param key      鍵
     * @param value    值
     * @param time     時間
     * @param timeUnit 類型
     * @return true成功 false 失敗
     */
    public boolean set ( String key, Object value, long time, TimeUnit timeUnit ) {
        try {
            if (time > 0) {
                redisTemplate.opsForValue().set(key, value, time, timeUnit);
            } else {
                set(key, value);
            }
            return true;
        } catch (Exception e) {
            e.printStackTrace();
            return false;
        }
    }

    // ================================Map=================================

    /**
     * HashGet
     *
     * @param key  鍵 不能爲null
     * @param item 項 不能爲null
     * @return 值
     */
    public Object hget ( String key, String item ) {
        return redisTemplate.opsForHash().get(key, item);
    }

    /**
     * 獲取hashKey對應的所有鍵值
     *
     * @param key 鍵
     * @return 對應的多個鍵值
     */
    public Map<Object, Object> hmget ( String key ) {
        return redisTemplate.opsForHash().entries(key);

    }

    /**
     * HashSet
     *
     * @param key 鍵
     * @param map 對應多個鍵值
     * @return true 成功 false 失敗
     */
    public boolean hmset ( String key, Map<String, Object> map ) {
        try {
            redisTemplate.opsForHash().putAll(key, map);
            return true;
        } catch (Exception e) {
            e.printStackTrace();
            return false;
        }
    }

    /**
     * HashSet 並設置時間
     *
     * @param key  鍵
     * @param map  對應多個鍵值
     * @param time 時間(秒)
     * @return true成功 false失敗
     */
    public boolean hmset ( String key, Map<String, Object> map, long time ) {
        try {
            redisTemplate.opsForHash().putAll(key, map);
            if (time > 0) {
                expire(key, time);
            }
            return true;
        } catch (Exception e) {
            e.printStackTrace();
            return false;
        }
    }

    /**
     * 向一張hash表中放入數據,如果不存在將創建
     *
     * @param key   鍵
     * @param item  項
     * @param value 值
     * @return true 成功 false失敗
     */
    public boolean hset ( String key, String item, Object value ) {
        try {
            redisTemplate.opsForHash().put(key, item, value);
            return true;
        } catch (Exception e) {
            e.printStackTrace();
            return false;
        }
    }

    /**
     * 向一張hash表中放入數據,如果不存在將創建
     *
     * @param key   鍵
     * @param item  項
     * @param value 值
     * @param time  時間(秒) 注意:如果已存在的hash表有時間,這裏將會替換原有的時間
     * @return true 成功 false失敗
     */
    public boolean hset ( String key, String item, Object value, long time ) {
        try {
            redisTemplate.opsForHash().put(key, item, value);
            if (time > 0) {
                expire(key, time);
            }
            return true;
        } catch (Exception e) {
            e.printStackTrace();
            return false;
        }
    }

    /**
     * 刪除hash表中的值
     *
     * @param key  鍵 不能爲null
     * @param item 項 可以使多個 不能爲null
     */
    public void hdel ( String key, Object... item ) {
        redisTemplate.opsForHash().delete(key, item);
    }

    /**
     * 判斷hash表中是否有該項的值
     *
     * @param key  鍵 不能爲null
     * @param item 項 不能爲null
     * @return true 存在 false不存在
     */
    public boolean hHasKey ( String key, String item ) {
        return redisTemplate.opsForHash().hasKey(key, item);
    }

    /**
     * hash遞增 如果不存在,就會創建一個 並把新增後的值返回
     *
     * @param key  鍵
     * @param item 項
     * @param by   要增加幾(大於0)
     * @return
     */
    public double hincr ( String key, String item, double by ) {
        return redisTemplate.opsForHash().increment(key, item, by);
    }

    /**
     * hash遞減
     *
     * @param key  鍵
     * @param item 項
     * @param by   要減少記(小於0)
     * @return
     */
    public double hdecr ( String key, String item, double by ) {
        return redisTemplate.opsForHash().increment(key, item, -by);
    }

    // ============================set=============================

    /**
     * 根據key獲取Set中的所有值
     *
     * @param key 鍵
     * @return
     */
    public Set<Object> sGet ( String key ) {
        try {
            return redisTemplate.opsForSet().members(key);
        } catch (Exception e) {
            e.printStackTrace();
            return null;
        }
    }

    /**
     * 根據value從一個set中查詢,是否存在
     *
     * @param key   鍵
     * @param value 值
     * @return true 存在 false不存在
     */
    public boolean sHasKey ( String key, Object value ) {
        try {
            return redisTemplate.opsForSet().isMember(key, value);
        } catch (Exception e) {
            e.printStackTrace();
            return false;
        }
    }

    /**
     * 將數據放入set緩存
     *
     * @param key    鍵
     * @param values 值 可以是多個
     * @return 成功個數
     */
    public long sSet ( String key, Object... values ) {
        try {
            return redisTemplate.opsForSet().add(key, values);
        } catch (Exception e) {
            e.printStackTrace();
            return 0;
        }
    }

    /**
     * 將set數據放入緩存
     *
     * @param key    鍵
     * @param time   時間(秒)
     * @param values 值 可以是多個
     * @return 成功個數
     */
    public long sSetAndTime ( String key, long time, Object... values ) {
        try {
            Long count = redisTemplate.opsForSet().add(key, values);
            if (time > 0) {
                expire(key, time);
            }
            return count;
        } catch (Exception e) {
            e.printStackTrace();
            return 0;
        }
    }

    /**
     * 獲取set緩存的長度
     *
     * @param key 鍵
     * @return
     */
    public long sGetSetSize ( String key ) {
        try {
            return redisTemplate.opsForSet().size(key);
        } catch (Exception e) {
            e.printStackTrace();
            return 0;
        }
    }

    /**
     * 移除值爲value的
     *
     * @param key    鍵
     * @param values 值 可以是多個
     * @return 移除的個數
     */
    public long setRemove ( String key, Object... values ) {
        try {
            Long count = redisTemplate.opsForSet().remove(key, values);
            return count;
        } catch (Exception e) {
            e.printStackTrace();
            return 0;
        }
    }

    // ===============================list=================================

    /**
     * 獲取list緩存的內容
     *
     * @param key   鍵
     * @param start 開始
     * @param end   結束 0 到 -1代表所有值
     * @return
     */
    public List<Object> lGet ( String key, long start, long end ) {
        try {
            return redisTemplate.opsForList().range(key, start, end);
        } catch (Exception e) {
            e.printStackTrace();
            return null;
        }
    }

    /**
     * 獲取list緩存的長度
     *
     * @param key 鍵
     * @return
     */
    public long lGetListSize ( String key ) {
        try {
            return redisTemplate.opsForList().size(key);
        } catch (Exception e) {
            e.printStackTrace();
            return 0;
        }
    }

    /**
     * 通過索引 獲取list中的值
     *
     * @param key   鍵
     * @param index 索引 index>=0時, 0 表頭,1 第二個元素,依次類推;index<0時,-1,表尾,-2倒數第二個元素,依次類推
     * @return
     */
    public Object lGetIndex ( String key, long index ) {
        try {
            return redisTemplate.opsForList().index(key, index);
        } catch (Exception e) {
            e.printStackTrace();
            return null;
        }
    }

    /**
     * 將list放入緩存
     *
     * @param key   鍵
     * @param value 值
     * @return
     */
    public boolean lSet ( String key, Object value ) {
        try {
            redisTemplate.opsForList().rightPush(key, value);
            return true;
        } catch (Exception e) {
            e.printStackTrace();
            return false;
        }
    }

    /**
     * 將list放入緩存
     *
     * @param key   鍵
     * @param value 值
     * @param time  時間(秒)
     * @return
     */
    public boolean lSet ( String key, Object value, long time ) {
        try {
            redisTemplate.opsForList().rightPush(key, value);
            if (time > 0) {
                expire(key, time);
            }
            return true;
        } catch (Exception e) {
            e.printStackTrace();
            return false;
        }
    }

    /**
     * 將list放入緩存
     *
     * @param key   鍵
     * @param value 值
     * @return
     */
    public boolean lSet ( String key, List<Object> value ) {
        try {
            redisTemplate.opsForList().rightPushAll(key, value);
            return true;
        } catch (Exception e) {
            e.printStackTrace();
            return false;
        }
    }

    /**
     * 將list放入緩存
     *
     * @param key   鍵
     * @param value 值
     * @param time  時間(秒)
     * @return
     */
    public boolean lSet ( String key, List<Object> value, long time ) {
        try {
            redisTemplate.opsForList().rightPushAll(key, value);
            if (time > 0) {
                expire(key, time);
            }
            return true;
        } catch (Exception e) {
            e.printStackTrace();
            return false;
        }
    }

    /**
     * 根據索引修改list中的某條數據
     *
     * @param key   鍵
     * @param index 索引
     * @param value 值
     * @return /
     */
    public boolean lUpdateIndex ( String key, long index, Object value ) {
        try {
            redisTemplate.opsForList().set(key, index, value);
            return true;
        } catch (Exception e) {
            e.printStackTrace();
            return false;
        }
    }

    /**
     * 移除N個值爲value
     *
     * @param key   鍵
     * @param count 移除多少個
     * @param value 值
     * @return 移除的個數
     */
    public long lRemove ( String key, long count, Object value ) {
        try {
            return redisTemplate.opsForList().remove(key, count, value);
        } catch (Exception e) {
            e.printStackTrace();
            return 0;
        }
    }

    //-----------------------自定義工具擴展----------------------

    /**
     * 功能描述:在list的右邊添加元素
     * 如果鍵不存在,則在執行推送操作之前將其創建爲空列表
     *
     * @param key 鍵
     * @return value 值
     * @author RenShiWei
     * Date: 2020/2/6 23:22
     */
    public Long rightPushValue ( String key, Object value ) {
        return redisTemplate.opsForList().rightPush(key, value);
    }

    /**
     * 功能描述:在list的右邊添加集合元素
     * 如果鍵不存在,則在執行推送操作之前將其創建爲空列表
     *
     * @param key 鍵
     * @return value 值
     * @author RenShiWei
     * Date: 2020/2/6 23:22
     */
    public Long rightPushList ( String key, List<Object> values ) {
        return redisTemplate.opsForList().rightPushAll(key, values);
    }

    /**
     * 指定緩存失效時間,攜帶失效時間的類型
     *
     * @param key  鍵
     * @param time 時間(秒)
     * @param unit 時間的類型  TimeUnit枚舉
     */
    public boolean expire ( String key, long time ,TimeUnit unit) {
        try {
            if (time > 0) {
                redisTemplate.expire(key, time, unit);
            }
        } catch (Exception e) {
            e.printStackTrace();
            return false;
        }
        return true;
    }

}

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章