Appearance
为什么使用Redisson而不是Jedis或Lettuce?原因是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);
}
}
}登录功能分析
- 用户登录的Session信息存放在Redis中,以Hash格式存放
- Key为appId+userSession+用户id组成,userSession就是普通字符串
- Value类似Java中的Map键值对格式,key为客户端(后期改为客户端+IMEI),value是Session信息
- 客户端的channel对象连接信息需要统一管理,这里先根据userId获取,后期会进行改造
- Redis的Key、用户的登录状态码都需要统一管理
- 用户的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处理登录业务
梳理一下登录逻辑:
- 解码器解码后的数据传递到当前Handler。
- 通过请求头判断,如果当前指令是登录指令,进行登录业务。
- 解析消息体,获取到userId等数据,封装厂LoginPack对象
- 封装用户的session信息,存入到Redis中
- 将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?
ConcurrentHashMap与HashMap相比它是线程安全的,且不允许存储null键或null值,更加符合业务场景;且HashMap在元素数量超过负载因子与容量的乘积时会进行扩容,这可能导致性能下降。ConcurrentHashMap使用了分段锁技术,可以在一定程度上减少扩容的影响。
登录的状态码为什么使用16进制表9000?
使用十六进制表示状态码可以用更少的位数来表示一个数值,因为每个十六进制数位可以表示0-15之间的数值,而每个十进制数位只能表示0-9之间的数值。这样一来,使用十六进制可以节省内存空间,尤其是在需要传输大量状态码的情况下。
此外,使用十六进制可以提高可读性。十六进制有A-F的字母表示,这些字母可以更直观地表示一些特殊的状态码,例如0xA表示成功,0xF表示失败。相比之下,使用十进制来表示状态码可能会使人难以理解。
