一、 pom.xml 所需依賴
MessagePack是編解碼工具,稍後介紹
<!-- https://mvnrepository.com/artifact/io.netty/netty-all -->
<dependency>
<groupId>io.netty</groupId>
<artifactId>netty-all</artifactId>
</dependency>
<!-- https://mvnrepository.com/artifact/org.msgpack/msgpack -->
<dependency>
<groupId>org.msgpack</groupId>
<artifactId>msgpack</artifactId>
<version>0.6.12</version>
</dependency>
<!-- Redis -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
二、 netty配置類
2.1 啓動服務類
創建兩個EventLoopGroup
實例,實際是兩個Reactor線程組,一個用於服務端接收客戶端的連接,一個用於進行socketChannel的網絡讀寫
ServerBootstrap
對象是netty用於啓動NIO服務端的輔助啓動類,目的是降低服務端的開發複雜度
group
方法將兩個NIO線程組當做入參傳遞到ServerBootstrap
中
接着設置創建的Channel爲NioServerSocketChannel
然後配置TCP參數,此處將他的backlog設置爲128
然後綁定I/O事件的處理類ServerChannelInitializer
,這個稍後看實現,主要用於處理網絡I/O事件,例如對消息進行編解碼、記錄日誌、處理業務等
可以通過childOption
針對客戶端進行一些配置,例如檢測心跳狀態、設置是否一次發送等
private final EventLoopGroup bossGroup = new NioEventLoopGroup();
private final EventLoopGroup workerGroup = new NioEventLoopGroup();
private Channel channel;
@Autowired
private ChannelCache channelCachel;
public ChannelFuture run(InetSocketAddress address) {
ChannelFuture f = null;
try {
ServerBootstrap b = new ServerBootstrap();
b.group(bossGroup, workerGroup).channel(NioServerSocketChannel.class).option(ChannelOption.SO_BACKLOG, 128)
.childHandler(new ServerChannelInitializer()).childOption(ChannelOption.SO_KEEPALIVE, true)
.childOption(ChannelOption.TCP_NODELAY, true);
f = b.bind(address).sync();
channel = f.channel();
} catch (Exception e) {
log.error("Netty start error:", e);
} finally {
if (f != null && f.isSuccess()) {
log.info("Netty server listening " + address.getHostName() + " on port " + address.getPort()
+ " and ready for connections...");
} else {
log.error("Netty server start up Error!");
}
}
return f;
}
2.2 netty退出
public void destroy() {
log.info("Shutdown Netty Server...");
channelCachel.flushDb();
if (channel != null) {
channel.close();
}
workerGroup.shutdownGracefully();
bossGroup.shutdownGracefully();
log.info("Shutdown Netty Server Success!");
}
其中channelCachel
是自定義的一個保存通道信息的工具類,稍後介紹
2.3 I/O事件處理類
主要包括對消息的編解碼、設置心跳超時以及設置業務處理類
本例中,服務端只檢測讀空閒時間
編解碼器和業務處理類稍後展示
public class ServerChannelInitializer extends ChannelInitializer<SocketChannel> {
@Override
protected void initChannel(SocketChannel socketChannel) throws Exception {
// 解碼編碼
// socketChannel.pipeline().addLast(new
// LengthFieldBasedFrameDecoder(1024, 0, 2, 0, 2));
socketChannel.pipeline().addLast(new MsgDecoder());
// socketChannel.pipeline().addLast(new LengthFieldPrepender(2));
socketChannel.pipeline().addLast(new MsgEncoder());
socketChannel.pipeline().addLast(new IdleStateHandler(Const.READER_IDLE_TIME_SECONDS, 0, 0));
socketChannel.pipeline().addLast(new ServerHandler());
}
}
特別要注意,編解碼器的順序不要寫錯,不然會造成無法解碼的情況,導致業務處理類無法處理,之前就因爲這個問題折騰了挺久,這個以後單獨測試
2.4 通道管理類
自己實現的一個管理channel信息的工具類,同時將channel的信息存入redis中
因爲channel.id()
得到的channel標識無法被序列化,因此無法存入redis中,這也是實際出現過的問題,以後可以單獨測試,因此採用channelId.asLongText()
當做redis的value,類型是String,這是channelId的一個全局唯一標識
目前由於擔心出現一個設備通過多個channel連接過來的情況,所以在redis裏,採用set來保存用戶和channel的關係,key是用戶的id,value可以是多個不同的channel標識
@Configuration
public class ChannelCache {
@Autowired
private MyRedisService myRedisService;
// 存儲所有Channel
private ChannelGroup channelGroup = new DefaultChannelGroup("channelGroups", GlobalEventExecutor.INSTANCE);
// 存儲Channel.id().asLongText()和用戶id對應關係
private ConcurrentHashMap<String, Integer> channelIdUid = new ConcurrentHashMap<String, Integer>();
public ChannelGroup getChannelGroup() {
return channelGroup;
}
public ConcurrentHashMap<String, Integer> getChannelIdUid() {
return channelIdUid;
}
/**
* 退出時刪除redis數據庫中緩存
*
*/
public void flushDb() {
myRedisService.flushDb();
}
/**
* 獲取Channel
* @return
*/
public Channel getChannel(Channel channel) {
Channel channel_ = channelGroup.find(channel.id());
if (channel_ != null) {
return channel_;
}
return null;
}
/**
* 添加Channel到ChannelGroup
* @param uid
* @param channel
*/
public void addChannel(Channel channel, int uid) {
Channel channel_ = channelGroup.find(channel.id());
if (channel_ == null) {
channelGroup.add(channel);
}
// redis添加對應用戶和channelId之前的關係
Integer userId = channelIdUid.get(channel.id().asLongText());
if (userId != null && userId.intValue() != uid) {
// 和本次用戶數據對不上,直接刪除對應channel的老數據
redisDelete(userId, channel);
}
channelIdUid.put(channel.id().asLongText(), userId);
// redis添加對應channelId
redisAdd(uid, channel);
}
/**
* 刪除Channel
* @param channel
*/
public void removeChannel(Channel channel) {
Channel channel_ = channelGroup.find(channel.id());
if (channel_ != null) {
channelGroup.remove(channel_);
}
Integer userId = channelIdUid.get(channel.id().asLongText());
if (userId != null) {
channelIdUid.remove(channel.id().asLongText());
redisDelete(userId, channel);
}
}
private void redisDelete(int uid, Channel channel) {
redisDelete(uid, channel.id());
}
private void redisDelete(int uid, ChannelId channelId) {
myRedisService.setRemove(myRedisService.getUserKeyPrefix() + uid, channelId.asLongText());
}
private void redisAdd(int uid, Channel channel) {
redisAdd(uid, channel.id());
}
private void redisAdd(int uid, ChannelId channelId) {
myRedisService.sSetAndTime(myRedisService.getUserKeyPrefix() + uid, myRedisService.getExpireSeconds(),
channelId.asLongText());
}
}
2.5 redis配置
客戶端連接服務端時,需要通過一個標識來驗證連接用的賬號是否在系統內,以APP連接netty爲例
一般APP登錄,都會生成一個token,以便每次訪問後臺接口進行驗證,也方便有其他設備登錄賬號後,主動使上一個登錄設備的token失效。一般情況下,token都會保存在redis當中。本例採用的例子是,APP連接netty服務時,會帶上token參數,此時需要將此token與保存在redis數據庫中的數據進行比對。
由於保存用戶和通道之間也需要一個redis數據庫,所以需要配置兩個RedisTemplate
對象
@Configuration
public class RedisConfig {
/**
* 配置自定義redisTemplate. 方法名一定要叫redisTemplate 因爲@Bean註解是根據方法名配置這個bean的name
*
* @return
*/
@Bean
public RedisTemplate<String, Object> myRedisTemplate(
@Value("${redis.my.host}") String host,
@Value("${redis.my.port}") int port,
@Value("${redis.my.password}") String password,
@Value("${redis.my.database}") int database) {
RedisStandaloneConfiguration config=new RedisStandaloneConfiguration();
config.setHostName(host);
config.setDatabase(database);
config.setPassword(RedisPassword.of(password));
config.setPort(port);
LettuceConnectionFactory factory=new LettuceConnectionFactory(config);
factory.afterPropertiesSet();
RedisTemplate<String, Object> template = new RedisTemplate<>();
template.setConnectionFactory(factory);
template.setValueSerializer(new StringRedisSerializer());
// 使用StringRedisSerializer來序列化和反序列化redis的key值
template.setKeySerializer(new StringRedisSerializer());
template.setHashKeySerializer(new StringRedisSerializer());
template.setHashValueSerializer(new StringRedisSerializer());
template.afterPropertiesSet();
return template;
}
@Bean
public RedisTemplate<String,Object> loginRedisTemplate(
@Value("${redis.login.host}") String host,
@Value("${redis.login.port}") int port,
@Value("${redis.login.password}") String password,
@Value("${redis.login.database}") int database){
RedisStandaloneConfiguration config=new RedisStandaloneConfiguration();
config.setHostName(host);
config.setDatabase(database);
config.setPassword(RedisPassword.of(password));
config.setPort(port);
LettuceConnectionFactory factory=new LettuceConnectionFactory(config);
factory.afterPropertiesSet();
RedisTemplate<String, Object> template = new RedisTemplate<>();
template.setConnectionFactory(factory);
template.setValueSerializer(new StringRedisSerializer());
// 使用StringRedisSerializer來序列化和反序列化redis的key值
template.setKeySerializer(new StringRedisSerializer());
template.afterPropertiesSet();
return template;
}
}
三、 自定義協議以及編解碼器
底層的傳輸與交互都是採用二進制的方式。
如何判斷髮送的消息已經結束,就需要通過協議來規定,比如收到換行符等標識時,判斷爲結束等。
根據協議,把二進制數據轉換成Java對象稱爲解碼(也叫做拆包),把Java對象轉換爲二進制數據稱爲編碼(也叫做打包)。
常用的協議制定方法:
定長消息法:這種方式是使用長度固定的數據發送,一般適用於指令發送。譬如:數據發送端規定發送的數據都是雙字節,AA 表示啓動、BB 表示關閉等等
字符定界法:這種方式是使用特殊字符作爲數據的結束符,一般適用於簡單數據的發送。譬如:在消息的結尾自動加上文本換行符(Windows使用\r\n,Linux使用\n),接收方見到文本換行符就認爲是一個完整的消息,結束接收數據開始解析。注意:這個標識結束的特殊字符一定要簡單,常常使用ASCII碼中的特殊字符來標識(會出現粘包、半包情況)。
定長報文頭法:使用定長報文頭,在報文頭的某個域指明報文長度。該方法最靈活,使用最廣。譬如:協議爲– 協議編號(1字節)+數據長度(4個字節)+真實數據。請求到達後,解析協議編號和數據長度,根據數據長度來判斷後面的真實數據是否接收完整。HTTP 協議的消息報頭中的Content-Length 也是表示消息正文的長度,這樣數據的接收端就知道到底讀到多長的字節數就不用再讀取數據了。
實際應用中,採用最多的還是定長報文頭法。
本例採用的是定長報文頭法,協議組成: 數據長度(4個字節) + 數據。
3.1 MessagePack編解碼
MessagePack是一個高效的二進制序列化框架。
特點:
- 編解碼高效,性能高
- 序列化之後的碼流小
支持多種語言,java爲例,使用很簡單,如果是自定義的類,需要加上@Messgae註解
序列化只需兩行:
MessagePack messagePack = new MessagePack();
byte[] write = messagePack.write(msg);
反序列化:
// 自定義的類
Message message = new Message();
MessagePack msgpack = new MessagePack();
message = msgpack.read(b, Message.class);
3.2 自定義協議包
服務端接收類
@org.msgpack.annotation.Message
public class Message {
// 用戶id
private int uid;
// 模塊id: 0-心跳包
private int module;
// json格式數據
private String data;
public Message() {
super();
}
public Message(int uid, int module, String data) {
this.uid = uid;
this.module = module;
this.data = data;
}
public int getUid() {
return uid;
}
public void setUid(int uid) {
this.uid = uid;
}
public int getModule() {
return module;
}
public void setModule(int module) {
this.module = module;
}
public String getData() {
return data;
}
public void setData(String data) {
this.data = data;
}
@Override
public String toString() {
return "uid:" + uid + " module:" + module + " data:" + data;
}
}
客戶端接收類
@Message
public class Result {
private int resultCode;
private String resultMsg;
private String data;
public Result() {
this(1, "success");
}
public Result(int resultCode, String resultMsg) {
this(resultCode, resultMsg, null);
}
public Result(int resultCode, String resultMsg, String data) {
this.resultCode = resultCode;
this.resultMsg = resultMsg;
this.data = data;
}
public int getResultCode() {
return resultCode;
}
public void setResultCode(int resultCode) {
this.resultCode = resultCode;
}
public String getResultMsg() {
return resultMsg;
}
public void setResultMsg(String resultMsg) {
this.resultMsg = resultMsg;
}
public String getData() {
return data;
}
public void setData(String data) {
this.data = data;
}
@Override
public String toString() {
return "code:" + resultCode + " msg:" + resultMsg + " data:" + data;
}
}
3.3 自定義編碼器
public class MsgEncoder extends MessageToByteEncoder<Result> {
@Override
protected void encode(ChannelHandlerContext ctx, Result msg, ByteBuf out) throws Exception {
MessagePack messagePack = new MessagePack();
byte[] write = messagePack.write(msg);
out.writeInt(write.length);
out.writeBytes(write);
}
}
3.4 自定義解碼器
類似mina中的CumulativeProtocolDecoder
類,ByteToMessageDecoder
同樣可以將未處理的ByteBuf
保存起來,下次一起處理,具體的原理以後再單獨研究。
public class MsgDecoder extends ByteToMessageDecoder {
private static final Logger log = LoggerFactory.getLogger(MsgDecoder.class);
@Override
protected void decode(ChannelHandlerContext ctx, ByteBuf in, List<Object> out) throws Exception {
// log.info("thread name: " + Thread.currentThread().getName());
long start = System.currentTimeMillis();
if (in.readableBytes() < 4) {
return;
}
in.markReaderIndex();
int length = in.readInt();
if (length <= 0) {
log.info("length: " + length);
ctx.close();
return;
}
if (in.readableBytes() < length) {
log.info("return");
in.resetReaderIndex();
return;
}
byte[] b = new byte[length];
in.readBytes(b);
Message message = new Message();
MessagePack msgpack = new MessagePack();
try {
message = msgpack.read(b, Message.class);
out.add(message);
} catch (Exception e) {
log.error("MessagePack read error");
ctx.close();
}
log.info(" ====== decode succeed: " + message.toString());
long time = System.currentTimeMillis() - start;
log.info("decode time: " + time + " ms");
}
}