Skip to content

为什么使用Redisson而不是JedisLettuce?原因是Redisson有对锁(红锁、可重入锁等)、分布式等功能的支持,且在底层与Redis进行了优化,更适合作为本项目连接Redis的客户端工具。

使用Redisson整合redis

用户登录的信息使用Redis存储,包括应用ID、端标识、SDK版本号、连接状态等等,首先引入依赖。

xml
<dependency>
    <groupId>org.redisson</groupId>
    <artifactId>redisson</artifactId>
    <version>3.23.2</version>
</dependency>

修改Config.yml文件

yaml
xim:
  tcpPort: 9000
  webSocketPort: 19000
  bossThreadSize: 1
  workThreadSize: 4
  redis:
    mode: single # 单机模式:single 哨兵模式:Sentinel 集群模式:cluster
    database: 5
    password:
    timeout: 3000 # 超时时间
    poolMinIdle: 8 #最小空闲数
    poolConnTimeout: 3000 # 连接超时时间(毫秒)
    poolSize: 10 # 连接池大小
    single: #redis单机配置
      address: 127.0.0.1:6379

修改BootstrapConfig.java文件

java
@Data
public class BootstrapConfig {

    private TcpConfig xim;

    @Data
    public static class TcpConfig {
        private Integer tcpPort;
        private Integer webSocketPort;
        private Integer bossThreadSize;
        private Integer workThreadSize;

        private RedisConfig redis;
    }

    @Data
    @Builder
    @NoArgsConstructor
    @AllArgsConstructor
    public static class RedisConfig {

        /** 单机模式:single 哨兵模式:sentinel 集群模式:cluster */
        private String mode;

        /** 数据库 */
        private Integer database;

        /** 密码 */
        private String password;

        /** 超时时间 */
        private Integer timeout;

        /** 最小空闲数 */
        private Integer poolMinIdle;

        /** 连接超时时间(毫秒) */
        private Integer poolConnTimeout;

        /** 连接池大小 */
        private Integer poolSize;

        /** redis单机配置 */
        private RedisSingle single;

    }

    /**
     * redis单机配置
     */
    @Data
    @Builder
    @NoArgsConstructor
    @AllArgsConstructor
    public static class RedisSingle {
        private String address;
    }

创建RedisManager管理Client

java
public class RedisManager {

    private static RedissonClient redissonClient;

    public static void init(BootstrapConfig config) {
        SingleClientStrategy singleClientStrategy = new SingleClientStrategy();
        redissonClient = singleClientStrategy.getRedissonClient(config.getXim().getRedis());
    }

    public static RedissonClient getRedissonClient() {
        return redissonClient;
    }
}

启动类初始化Redis客户端

java
public class Starter {
    public static void main(String[] args) throws FileNotFoundException {
        if (args.length > 0) {
            start(args[0]);
        }
    }

    private static void start(String path) {
        try {
            Yaml yaml = new Yaml();
            FileInputStream inputStream = new FileInputStream(path);
            BootstrapConfig bootstrapConfig = yaml.loadAs(inputStream, BootstrapConfig.class);
            System.out.println(bootstrapConfig);
            new XimServer(bootstrapConfig.getXim()).start();
            new XimWebSocketServer(bootstrapConfig.getXim()).start();
			// 初始化RedisClient
            RedisManager.init(bootstrapConfig );
        } catch (Exception e) {
            e.printStackTrace();
            // 如果有错误,直接退出整个程序
            System.exit(500);
        }
    }
}

登录功能分析

  1. 用户登录的Session信息存放在Redis中,以Hash格式存放
    • Key为appId+userSession+用户id组成,userSession就是普通字符串
    • Value类似Java中的Map键值对格式,key为客户端(后期改为客户端+IMEI),value是Session信息
  2. 客户端的channel对象连接信息需要统一管理,这里先根据userId获取,后期会进行改造
  3. Redis的Key、用户的登录状态码都需要统一管理
  4. 用户的Session信息,需要创建实体对象。

创建实体类对象

用户的Session信息存放到Redis使用JSON格式的字符串存入,但在Java中需映射成对象使用;

用户Session对象

java
/**
 * @author cv大魔王
 * @version 1.0
 * @description 即时通讯用户session包含的数据
 * @date 2023/8/21
 */
@Data
public class UserSession {

    private String userId;

    /** 应用id */
    private Integer appId;

    /** 端标识(mac,win,web等) */
    private Integer clientType;

    /** sdk版本号 */
    private Integer version;

    /** 连接状态 1在线 2离线 */
    private Integer connectState;
}

创建登录信息管理类

登录时除了请求头中包含的数据(指令、版本、客户端类型、消息解析类型、imei长度、appId和消息体长度),请求体的JSON数据应该也包含登录时的数据,目前只包含userId,后期可根据业务情况进行填写。

就和正常的登录请求类似,发送POST请求时,携带用户的账号密码,只不过在IM即时通讯系统中登录时直接传递userId,登录功能由SpringBoot模块负责,后期集成时会详细说明,这里只需要知道用户id是从请求头解析出来的,请求体是JOSN格式,如果是其他格式则特殊处理,在请求头中有标注消息解析类型。

java
@Data
public class LoginPack {

    private String userId;
}

指令状态码枚举类

java
public interface Command  {
}

先创建接口然后创建枚举继承自接口,这个枚举类包含系统指令,暂时只有退出和登录,未来还会有心跳、下线通知等;而所有指令的枚举类都需要继承自Command接口,除了系统指令外还有业务指令,例如上线通知、消息收发等。

java
@Getter
public enum SystemCommand implements Command {

    /** 登录 9000 */
    LOGIN(0x2328),

    /** 退出 9003 */
    LOGOUT(0x232b);

    private int command;

    SystemCommand(int command) {
        this.command = command;
    }
}

统一管理Socket连接工具类

channel使用ConcurrentHashMap存放,它的key使用一个对象表示,根据UserClientDto对象可在集合找到对应的channel连接对象;本质上就是根据AppId、客户端类型、用户id可以唯一的定位到一台设备(后期会加上IMEI值),找到其channel连接对象,用来发送或处理消息。

java
@Data
@AllArgsConstructor
@NoArgsConstructor
public class UserClientDto {

    private Integer appId;
    
    private String userId;

    private Integer clientType;
}

统一管理Socket连接工具类如下所示,以ConcurrentHashMap存储,UserClientDto对象作为Key;工具类包含一个新增put方法、一个获取get方法。

java
public class SessionSocketHolder {

    private static final Map<UserClientDto, NioSocketChannel> CHANNELS = new ConcurrentHashMap<>();

    public static void put(Integer appId, String userId, Integer clientType, NioSocketChannel channel) {
        UserClientDto dto = new UserClientDto(appId, userId, clientType);
        CHANNELS.put(dto, channel);
    }

    public static NioSocketChannel get(Integer appId, String userId, Integer clientType) {
        UserClientDto dto = new UserClientDto(appId, userId, clientType);
        return CHANNELS.get(dto);
    }
}

全局变量

Redis的可以通过全局变量Constants类统一管理,通过String.format使用,暂时只定义了一个存放用户session的key;而全局统一管理Key,不单单是Redis的Key,channel中的变量也需要统一管理。

java
public class Constants {

    /** channel绑定的userId Key */
    public static final String UserId = "userId";

    /** channel绑定的appId Key */
    public static final String AppId = "appId";

    /** channel绑定的ClientType Key */
    public static final String ClientType = "clientType";

    public static class RedisConstants {
        /** 用户SessionKey:appId+userSession+用户id */
        public static final String UserSessionConstants = "%s:userSession:%s";
    }
}

创建Handler处理登录业务

梳理一下登录逻辑:

  1. 解码器解码后的数据传递到当前Handler。
  2. 通过请求头判断,如果当前指令是登录指令,进行登录业务。
  3. 解析消息体,获取到userId等数据,封装厂LoginPack对象
  4. 封装用户的session信息,存入到Redis中
  5. 将channel连接信息put到同一连接管理Map中
java
public class NettyServerHandler extends SimpleChannelInboundHandler<Message> {

    private static final Log log = LogFactory.get();

    @Override
    protected void channelRead0(ChannelHandlerContext ctx, Message msg) throws Exception {
        System.out.println(msg);

        Integer command = msg.getMessageHeader().getCommand();

        if (command == SystemCommand.LOGIN.getCommand()) {
            login(ctx, msg);
        }
    }

    /**
     * 用户登录,将session存入redis,保存channel并存放userId
     */
    private void login(ChannelHandlerContext ctx, Message msg) {
        // 1.先将消息体转换成字符串,再转换成LoginPack对象
        LoginPack loginPack = JSONUtil.toBean(JSONUtil.toJsonStr(msg.getMessagePack()), LoginPack.class);

        // 2.封装用户session信息
        UserSession userSession = new UserSession();
        userSession.setUserId(loginPack.getUserId());
        userSession.setAppId(msg.getMessageHeader().getAppId());
        userSession.setClientType(msg.getMessageHeader().getClientType());
        userSession.setVersion(msg.getMessageHeader().getVersion());
        userSession.setConnectState(ImConnectStatusEnum.ONLINE_STATUS.getCode());

        // 3.将session信息存储到redis中
        RedissonClient redissonClient = RedisManager.getRedissonClient();
        RMap<String, String> map = redissonClient.getMap(String.format(Constants.RedisConstants.UserSessionConstants, msg.getMessageHeader().getAppId(), loginPack.getUserId()));
        map.put(msg.getMessageHeader().getClientType() + "", JSONUtil.toJsonStr(userSession));

        // 4.存放数据到channel对象中,类似servlet的Session,后续可根据channel连接对象获取到userId
        ctx.channel().attr(AttributeKey.valueOf(Constants.UserId)).set(loginPack.getUserId());
        ctx.channel().attr(AttributeKey.valueOf(Constants.AppId)).set(msg.getMessageHeader().getAppId());
        ctx.channel().attr(AttributeKey.valueOf(Constants.ClientType)).set(msg.getMessageHeader().getClientType());
        // 5.通过统一连接管理类将channel存起来
        SessionSocketHolder.put(msg.getMessageHeader().getAppId(), loginPack.getUserId(), msg.getMessageHeader().getClientType(), (NioSocketChannel) ctx.channel());
    }
}

文中问题解析

为什么使用ConcurrentHashMap?

ConcurrentHashMapHashMap相比它是线程安全的,且不允许存储null键或null值,更加符合业务场景;且HashMap在元素数量超过负载因子与容量的乘积时会进行扩容,这可能导致性能下降。ConcurrentHashMap使用了分段锁技术,可以在一定程度上减少扩容的影响。

登录的状态码为什么使用16进制表9000?

使用十六进制表示状态码可以用更少的位数来表示一个数值,因为每个十六进制数位可以表示0-15之间的数值,而每个十进制数位只能表示0-9之间的数值。这样一来,使用十六进制可以节省内存空间,尤其是在需要传输大量状态码的情况下。

此外,使用十六进制可以提高可读性。十六进制有A-F的字母表示,这些字母可以更直观地表示一些特殊的状态码,例如0xA表示成功,0xF表示失败。相比之下,使用十进制来表示状态码可能会使人难以理解。