简介
在实际应用中我们的tcp服务端经常会使用集群方式运行,这样增大了系统的性能和容灾,本文讲述简单的netty tcp服务端集群应用原理的部分代码,文章源码地址:https://github.com/itwwj/netty-learn.git中的netty-day08-cluster项目。
一、集群原理
- 服务端启动会向redis注册自己,以ip为key,以连接信息为value
- 客户端连接服务端时,服务端会向redis注册客户端信息,以channelId为key以客户端连接信息为value,同时在连接的服务端节点向map集合保存节点信息,以channelId为key以channel对象为value。
- 不同节点客户端互发消息时,client会把消息发送给连接的server节点,server节点会先在本节点查找是否有发送目标的channelId,如果没有就把消息转发给redis,由redis广播给对应的目标server节点,再由该节点发送给对象的client。
二、项目依赖
<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
<java.version>1.8</java.version>
<protostuff.version>1.0.10</protostuff.version>
<objenesis.version>2.4</objenesis.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
<version>2.0.4.RELEASE</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
<version>2.0.4.RELEASE</version>
</dependency>
<!-- test -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<version>2.0.4.RELEASE</version>
<scope>test</scope>
</dependency>
<!-- 添加redis依赖模块 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
<version>2.0.6.RELEASE</version>
</dependency>
<!-- fastjson -->
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>fastjson</artifactId>
<version>1.2.58</version>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<version>2.0.4.RELEASE</version>
<executions>
<execution>
<phase>package</phase>
<goals>
<goal>repackage</goal>
</goals>
</execution>
</executions>
<configuration>
<includeSystemScope>true</includeSystemScope>
<mainClass>com.gitee.netty.cluster.ClusterApplication</mainClass>
</configuration>
</plugin>
</plugins>
三、部分代码
redis配置类:
/**
* redisTemplate配置类
* @author jie
*/
@Configuration
public class RedisConfig {
@Bean
public RedisTemplate<String, Object> redisMessageTemplate(RedisConnectionFactory connectionFactory) {
RedisTemplate<String, Object> template = new RedisTemplate<>();
template.setConnectionFactory(connectionFactory);
template.setDefaultSerializer(new FastJsonRedisSerializer<>(Object.class));
return template;
}
}
/**
* 用户管道信息;记录某个用户分配到某个服务端
* @author jie
*/
@Configuration
public class SubConfig {
@Value("${netty.port}")
private int port;
/**
* 接受广播消息配置 接受主题格式为:message_pub+本机ip+本程序端口
* @param connectionFactory
* @param msgAgreementListenerAdapter
* @return
* @throws UnknownHostException
*/
@Bean
public RedisMessageListenerContainer container(RedisConnectionFactory connectionFactory, MessageListenerAdapter msgAgreementListenerAdapter) throws UnknownHostException {
RedisMessageListenerContainer container = new RedisMessageListenerContainer();
container.setConnectionFactory(connectionFactory);
container.addMessageListener(msgAgreementListenerAdapter, new PatternTopic("message_pub"+ NetWorkUtils.getHost()+port));
return container;
}
}
redis工具类:
/**
* @author jie
*/
@Service
public class RedisUtil {
@Autowired
private StringRedisTemplate redisTemplate;
/**
* 向redis存入设备连接信息
*
* @param deviceChannelInfo
*/
public void pushObj(DeviceChannelInfo deviceChannelInfo) {
redisTemplate.opsForHash().put("deviceIds", deviceChannelInfo.getChannelId(), JSON.toJSONString(deviceChannelInfo));
}
/**
* 查询redis中的设备连接信息
*
* @return
*/
public List<DeviceChannelInfo> popList() {
List<Object> values = redisTemplate.opsForHash().values("deviceIds");
if (null == values) {
return new ArrayList<>();
}
List<DeviceChannelInfo> deviceChannelInfoList = new ArrayList<>();
for (Object strJson : values) {
deviceChannelInfoList.add(JSON.parseObject(strJson.toString(), DeviceChannelInfo.class));
}
return deviceChannelInfoList;
}
/**
* 根据channelId查询连接信息
*
* @param channelId
* @return
*/
public DeviceChannelInfo selectByChannel(String channelId) {
Object deviceIds = redisTemplate.opsForHash().get("deviceIds", channelId);
if (deviceIds == null) {
return null;
}
return JSON.parseObject(deviceIds.toString(), DeviceChannelInfo.class);
}
/**
* 移除某个设备信息
*
* @param channelId
*/
public void remove(String channelId) {
redisTemplate.opsForHash().delete("deviceIds", channelId);
}
/**
* 清空设备信息
*/
public void clear() {
redisTemplate.delete("deviceIds");
}
}
发布消息到redis:
/**
* 发布redis消息
*
* @author jie
*/
@Slf4j
@Component
public class MsgPub {
@Autowired
private StringRedisTemplate redisTemplate;
public void pushMessage(String topic, MsgAgreement message) {
log.info("向 "+topic+"发送消息:"+message);
redisTemplate.convertAndSend(topic, MsgUtil.obj2Json(message));
}
}
接受redis广播来的消息:
/**
* redis订阅消息处理实现类
*
* @author jie
*/
@Slf4j
@Component
public class MsgReceiver extends MessageListenerAdapter {
@Autowired
private StringRedisTemplate redisTemplate;
/**
* 接收redis推送的消息如果当前服务连接的有此设备就推送消息
*
* @param message 消息
*/
@Override
public void onMessage(Message message, byte[] pattern) {
String msg = redisTemplate.getStringSerializer().deserialize(message.getBody());
String topic = redisTemplate.getStringSerializer().deserialize(message.getChannel());
log.info("来自" + topic + "的消息:" + msg);
MsgAgreement msgAgreement = JSON.parseObject(msg, MsgAgreement.class);
String toChannelId = msgAgreement.getToChannelId();
Channel channel = CacheUtil.cacheChannel.get(toChannelId);
if (null == channel) {
return;
}
channel.writeAndFlush(MsgUtil.obj2Json(msgAgreement));
}
}
netty事件触发类:
/**
* 操作类
*
* @author jie
*/
@Slf4j
public class MyServerHandler extends ChannelInboundHandlerAdapter {
private CacheService cacheService;
public MyServerHandler(CacheService cacheService) {
this.cacheService = cacheService;
}
@Override
public void userEventTriggered(ChannelHandlerContext ctx, Object evt) throws Exception {
if (evt instanceof IdleStateEvent) {
IdleState state = ((IdleStateEvent) evt).state();
if (state == IdleState.READER_IDLE) {
log.info("客户端" + ctx.channel().id() + "长时间未通讯,即将剔除。");
// 在规定时间内没有收到客户端的上行数据, 主动断开连接
ctx.disconnect();
}
} else {
super.userEventTriggered(ctx, evt);
}
}
/**
* 当客户端主动链接服务端的链接后,这个通道就是活跃的了。也就是客户端与服务端建立了通信通道并且可以传输数据
*/
@Override
public void channelActive(ChannelHandlerContext ctx) throws Exception {
SocketChannel channel = (SocketChannel) ctx.channel();
log.info("有一客户端链接到本服务端 channelId:" + channel.id());
//保存设备信息
DeviceChannelInfo deviceChannelInfo = DeviceChannelInfo.builder()
.channelId(channel.id().toString())
.ip(NetWorkUtils.getHost())
.port(channel.localAddress().getPort())
.linkDate(new Date())
.build();
cacheService.getRedisUtil().pushObj(deviceChannelInfo);
CacheUtil.cacheChannel.put(channel.id().toString(), channel);
ctx.writeAndFlush("ok \r\n");
}
/**
* 当客户端主动断开服务端的链接后,这个通道就是不活跃的。也就是说客户端与服务端的关闭了通信通道并且不可以传输数据
*/
@Override
public void channelInactive(ChannelHandlerContext ctx) throws Exception {
log.info("客户端断开链接" + ctx.channel().id());
cacheService.getRedisUtil().remove(ctx.channel().id().toString());
CacheUtil.cacheChannel.remove(ctx.channel().id().toString(), ctx.channel());
}
/**
* 处理通道内的数据
*
* @param ctx
* @param objMsgJsonStr
* @throws Exception
*/
@Override
public void channelRead(ChannelHandlerContext ctx, Object objMsgJsonStr) throws Exception {
String msg = objMsgJsonStr.toString();
if (msg.length() < 5) {
log.info("心跳消息:" + msg);
return;
}
MsgAgreement msgAgreement = MsgUtil.json2Obj(msg);
String toChannelId = msgAgreement.getToChannelId();
//判断接收消息用户是否在本服务端
Channel channel = CacheUtil.cacheChannel.get(toChannelId);
if (null != channel) {
channel.writeAndFlush(MsgUtil.obj2Json(msgAgreement));
return;
}
//如果为NULL则接收消息的用户不在本服务端,需要push消息给全局
cacheService.push(msgAgreement);
}
/**
* 发现异常关闭连接打印日志
*/
@Override
public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception {
ctx.close();
cacheService.getRedisUtil().remove(ctx.channel().id().toString());
CacheUtil.cacheChannel.remove(ctx.channel().id().toString(), ctx.channel());
log.error("异常信息:\r\n" + cause.getMessage());
}
}
netty出入栈:
/**
*
* MyChannelInitializer的主要目的是为程序员提供了一个简单的工具,用于在某个Channel注册到EventLoop后,对这个Channel执行一些初始
* 化操作。ChannelInitializer虽然会在一开始会被注册到Channel相关的pipeline里,但是在初始化完成之后,ChannelInitializer会将自己
* 从pipeline中移除,不会影响后续的操作。
* @author jie
*/
public class MyChannelInitializer extends ChannelInitializer<SocketChannel> {
private CacheService cacheService;
public MyChannelInitializer(CacheService cacheService) {
this.cacheService = cacheService;
}
@Override
protected void initChannel(SocketChannel channel) {
/**
* 心跳监测
* 1、readerIdleTimeSeconds 读超时时间
* 2、writerIdleTimeSeconds 写超时时间
* 3、allIdleTimeSeconds 读写超时时间
* 4、TimeUnit.SECONDS 秒[默认为秒,可以指定]
*/
channel.pipeline().addLast(new IdleStateHandler(60, 0, 0));
// 基于换行符号
channel.pipeline().addLast(new LineBasedFrameDecoder(1024));
// 解码转String,注意调整自己的编码格式GBK、UTF-8
channel.pipeline().addLast(new StringDecoder(CharsetUtil.UTF_8));
// 解码转String,注意调整自己的编码格式GBK、UTF-8
channel.pipeline().addLast(new StringEncoder(CharsetUtil.UTF_8));
// 在管道中添加我们自己的接收数据实现方法
channel.pipeline().addLast(new MyServerHandler(cacheService));
}
}
缓存服务:
/**
* @author jie
*/
@Service
public class CacheService {
@Autowired
private MsgPub msgPub;
@Autowired
private RedisUtil redisUtil;
/**
* 将通道数据广播给指定的主题 主题格式为:message_pub+节点ip+节点端口
* @param msgAgreement
*/
public void push(MsgAgreement msgAgreement) {
DeviceChannelInfo deviceChannelInfo = redisUtil.selectByChannel(msgAgreement.getToChannelId());
if (deviceChannelInfo == null) {
return;
}
msgPub.pushMessage("message_pub"+deviceChannelInfo.getIp()+deviceChannelInfo.getPort(), msgAgreement);
}
public RedisUtil getRedisUtil() {
return redisUtil;
}
}
缓存工具类:
/**
* @author jie
*/
public class CacheUtil {
/**
* 线程池
*/
public static ExecutorService executorService = Executors.newFixedThreadPool(3);
/**
* 缓存channel
*/
public static Map<String, Channel> cacheChannel = new ConcurrentHashMap(new HashMap<>());
}
构建消息实体:
/**
* 构建消息
*
* @author jie
*/
public class MsgUtil {
public static MsgAgreement buildMsg(String channelId, String content) {
return new MsgAgreement(channelId, content);
}
public static MsgAgreement json2Obj(String objJsonStr) {
objJsonStr = objJsonStr.replace("\r\n", "");
return JSON.parseObject(objJsonStr, MsgAgreement.class);
}
public static String obj2Json(MsgAgreement msgAgreement) {
return JSON.toJSONString(msgAgreement) + "\r\n";
}
}