最近在学习Netty框架,使用的学习教材是李林锋著的《Netty权威指南》。国内关于netty的书籍几乎没有,这本书算是比较好的入门资源了。
我始终觉得,学习一个新的框架,除了研究框架的源代码之外,还应该使用该框架开发一个实际的小应用。为此,我选择Netty作为通信框架,开发一个模仿QQ的聊天室。
基本框架是这样设计的,使用Netty作为通信网关,使用JavaFX开发客户端界面,使用Spring作为IOC容器,使用MyBatics支持持久化。本文将着重介绍Netty的私有协议栈开发,使用的Netty版本是最新的5.0.0.Alpha2版本。
服务端程序代码:
流程步骤:
1.启动Reactor线程组监听客户端链路的连接与IO网络读写。
package com.kingston.netty; import io.netty.bootstrap.ServerBootstrap; import io.netty.channel.ChannelFuture; import io.netty.channel.ChannelInitializer; import io.netty.channel.ChannelOption; import io.netty.channel.EventLoopGroup; import io.netty.channel.nio.NioEventLoopGroup; import io.netty.channel.socket.SocketChannel; import io.netty.channel.socket.nio.NioServerSocketChannel; import io.netty.handler.codec.LengthFieldBasedFrameDecoder; import io.netty.handler.codec.LengthFieldPrepender; import java.io.IOException; import com.kingston.net.PacketDecoder; import com.kingston.net.PacketEncoder; public class NettyChatServer { public void bind(int port) throws IOException{ EventLoopGroup bossGroup = new NioEventLoopGroup(); EventLoopGroup workerGroup = new NioEventLoopGroup(); System.err.println("服务端已启动,正在监听用户的请求......"); try{ ServerBootstrap b = new ServerBootstrap(); b.group(bossGroup,workerGroup) .channel(NioServerSocketChannel.class) .option(ChannelOption.SO_BACKLOG, 1024) .childHandler(new ChildChannelHandler()); ChannelFuture f = b.bind(port).sync(); f.channel().closeFuture().sync(); }catch(Exception e){ e.printStackTrace(); }finally{ bossGroup.shutdownGracefully(); workerGroup.shutdownGracefully(); } } private class ChildChannelHandler extends ChannelInitializer<SocketChannel>{ @Override protected void initChannel(SocketChannel arg0) throws Exception { arg0.pipeline().addLast(new LengthFieldPrepender(2)); arg0.pipeline().addLast(new PacketEncoder()); arg0.pipeline().addLast(new LengthFieldBasedFrameDecoder(1024*1, 0,2,0,2)); arg0.pipeline().addLast(new PacketDecoder()); arg0.pipeline().addLast(new ChatServerHandler()); } } }2.私有协议栈的设计。私有协议栈主要用于跨进程的数据通信,只能用于企业内部,协议设计比较灵巧方便。
在这里,消息定义将消息头和消息体融为一体。将消息的第一个short数据视为消息的类型,服务端将根据消息类型处理不同的业务逻辑。定义Packet抽象类,抽象方法
readFromBuff(ByteBuf buf) 和 writePacketMsg(ByteBuf buf) 作为读写数据的抽象行为,而具体的读写方式由相应的子类去实现。代码如下:
package com.kingston.net; import io.netty.buffer.ByteBuf; import java.io.UnsupportedEncodingException; public abstract class Packet { // protected String userId; public void writeToBuff(ByteBuf buf){ buf.writeShort(getPacketType().getType()); writePacketMsg(buf); } abstract public void writePacketMsg(ByteBuf buf); abstract public void readFromBuff(ByteBuf buf); abstract public PacketType getPacketType(); abstract public void execPacket(); protected String readUTF8(ByteBuf buf){ int strSize = buf.readInt(); byte[] content = new byte[strSize]; buf.readBytes(content); try { return new String(content,"UTF-8"); } catch (UnsupportedEncodingException e) { e.printStackTrace(); return ""; } } protected void writeUTF8(ByteBuf buf,String msg){ byte[] content ; try { content = msg.getBytes("UTF-8"); buf.writeInt(content.length); buf.writeBytes(content); } catch (UnsupportedEncodingException e) { e.printStackTrace(); } } }在这里需要注意的是,由于Netty通信本质上传送的是byte数据,无法直接传送String字段串,需要先经过简单的编解码成字节数组才能传送。
3.POJO对象的编码与解码
数据发送方发送载体为ByteBuf,因此在发包时,需要将POJO对象进行编码。本项目使用Netty自带的编码器MessageToByteEncoder,实现自定义的编码方式。代码如下:
package com.kingston.net; import io.netty.buffer.ByteBuf; import io.netty.channel.ChannelHandlerContext; import io.netty.handler.codec.MessageToByteEncoder; public class PacketEncoder extends MessageToByteEncoder<Packet> { @Override protected void encode(ChannelHandlerContext ctx, Packet msg, ByteBuf out) throws Exception { msg.writeToBuff(out); } }接收方实际接收ByteBuf数据,需要将其解码成对应的POJO对象,才能处理对应的逻辑。本项目使用Netty自带的解码器ByteToMessageDecoder,实现自定义的解码方式。代码如下:
package com.kingston.net; import io.netty.buffer.ByteBuf; import io.netty.channel.ChannelHandlerContext; import io.netty.handler.codec.ByteToMessageDecoder; import java.util.List; public class PacketDecoder extends ByteToMessageDecoder{ @Override protected void decode(ChannelHandlerContext ctx, ByteBuf in, List<Object> out) throws Exception { if(in.readableBytes() <= 0) return ; short packetType = in.readShort(); Class<? extends Packet> packetClass = PacketType.getPacketClassBy(packetType); if(packetClass == null){ throw new IllegalPacketException("类型为"+packetType+"的包定义不存在"); } Packet packet = (Packet)packetClass.newInstance(); packet.readFromBuff(in); out.add(packet); } }通信协议将包头的第一个short数据视为包类型,根据包类型反射拿到对应的包class定义,调用抽象读取方法完成消息体的读取。
4.消息协议的解析与执行
消息使用第一个short数据作为消息的类型。为了区分每一个消息协议包,需要有一个数据结构缓存各种协议的类型与对应的消息包定义。为此,使用枚举类定义所有的协议包。代码如下:
package com.kingston.net; import java.util.HashMap; import java.util.HashSet; import java.util.Map; import java.util.Set; import com.kingston.service.login.ClientLogin; import com.kingston.service.login.ServerHearBeat; import com.kingston.service.login.ServerLogin; public enum PacketType { //业务上行数据包 ServerLogin((short)0x0001,ServerLogin.class), ServerHearBeat((short)0x0002,ServerHearBeat.class), //业务下行数据包 ClientLogin((short)0x2000,ClientLogin.class), ; private short type; private Class<? extends Packet> packetClass; private static Map<Short,Class<? extends Packet>> PACKET_CLASS_MAP = new HashMap<Short,Class<? extends Packet>>(); static{ //使用Map数据结构,缓存包类型与对应的实体类的映射关系 Set<Short> typeSet = new HashSet<Short>(); for(PacketType p:PacketType.values()){ Short type = p.getType(); if(typeSet.contains(type)){ throw new IllegalStateException("packet type 协议类型重复"+type); } PACKET_CLASS_MAP.put(type,p.getPacketClass()); typeSet.add(type); } } PacketType(short type,Class<? extends Packet> packetClass){ this.setType(type); this.packetClass = packetClass; } public short getType() { return type; } public void setType(short type) { this.type = type; } public Class<? extends Packet> getPacketClass() { return packetClass; } public void setPacketClass(Class<? extends Packet> packetClass) { this.packetClass = packetClass; } public static Class<? extends Packet> getPacketClassBy(short packetType){ return PACKET_CLASS_MAP.get(packetType); } // public static void main(String[] args) { // for(PacketType p:PacketType.values()){ // System.err.println(p.getPacketClass().getSimpleName()); // } // } }PacketType枚举类中有一段静态代码块,在初始化时缓存所有包类型与对应的实体类的映射关系。这样,就可以根据包类型,直接拿到对应的Packet子类。
经过解码反射得到完整的消息包定义后,就可以通过反射机制,调用相应的业务方法。该步骤由包执行器完成,代码如下:
package com.kingston.net; import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; public class PacketExecutor { public static void execPacket(Packet pact){ if(pact == null) return; try { Method m = pact.getClass().getMethod("execPacket"); m.invoke(pact, null); } catch (NoSuchMethodException | SecurityException e) { e.printStackTrace(); } catch (IllegalAccessException e) { e.printStackTrace(); } catch (IllegalArgumentException e) { e.printStackTrace(); } catch (InvocationTargetException e) { e.printStackTrace(); } } }
包执行器其实是根据反射,调用对应子类消息包的业务处理方法。
到这里,读者应该可以感受抽象包Packet的定义是该通信机制的精华部分。正是有了abstract public void readFromBuff(ByteBuf buf);abstract public void writePacketMsg(ByteBuf buf);abstract public void execPacket()三个抽象方法,才能将各种消息包的读写、业务逻辑相互隔离。
写到这里,我不禁回想起大学期间做过的一个聊天室课程设计。当初,我采用Java作为服务器,flash作为客户端,基于socket进行通信。通信消息体只有一个长字符串,通信双方根据不同消息类型将字符串作多次分隔。如果当初协议类型再多几个的话,估计想死的心都有了。
5.半包读写解决之道
MessageToByteEncoder 和 ByteToMessageDecoder两个类只是解决POJO的编解码,并没有处理粘包,拆包的异常情况。在本例中,使用LengthFieldBasedFrameDecoder和LengthFieldPrepender两个工具类,就可以轻松解决半包读写异常。
6.服务端与客户端数据通信方式
客户端tcp链路建立后,服务端必须缓存对应的ChannelHandlerContext对象。这样,服务端就可以向所有连接的用户发送数据了。发送数据基础服务类代码如下:
package com.kingston.base; import io.netty.channel.ChannelHandlerContext; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; import com.kingston.net.Packet; import com.kingston.util.StringUtil; public class ServerManager { //缓存所有登录用户的通信上下文环境 private static Map<Integer,ChannelHandlerContext> SESSION_CHANNEL_MAP = new ConcurrentHashMap<Integer,ChannelHandlerContext>(); public static void sendPacketTo(Packet pact,String userId){ if(pact == null || StringUtil.isEmpty(userId)) return; Map<Integer,ChannelHandlerContext> contextMap = SESSION_CHANNEL_MAP; if(StringUtil.isEmpty(contextMap)) return; ChannelHandlerContext targetContext = contextMap.get(userId); if(targetContext == null) return; targetContext.writeAndFlush(pact); } /** * 向所有在线用户发送数据包 */ public static void sendPacketToAllUsers(Packet pact){ if(pact == null ) return; Map<Integer,ChannelHandlerContext> contextMap = SESSION_CHANNEL_MAP; if(StringUtil.isEmpty(contextMap)) return; contextMap.values().forEach( (ctx) -> ctx.writeAndFlush(pact)); } /** * 向单一在线用户发送数据包 */ public static void sendPacketTo(Packet pact,ChannelHandlerContext targetContext ){ if(pact == null || targetContext == null) return; targetContext.writeAndFlush(pact); } public static ChannelHandlerContext getOnlineContextBy(String userId){ return SESSION_CHANNEL_MAP.get(userId); } public static void addOnlineContext(Integer userId,ChannelHandlerContext context){ if(context == null){ throw new NullPointerException(); } SESSION_CHANNEL_MAP.put(userId,context); } }7.服务端验证用户登录的简单demo
demo流程为客户端发送一个以Server开头命名的上行包到服务端,服务端接受数据后,直接发送一个以Client开头命名的响应包到客户端。
上行包ServerLogin代码如下:
package com.kingston.service.login; import io.netty.channel.ChannelHandlerContext; import com.kingston.base.ServerManager; public class LoginManagerImpl implements LoginManager{ // @Autowired // private UserDao userDao; @Override public void validateLogin(ChannelHandlerContext context,Integer userId, String password) { boolean isValid = validate(userId, password); ClientLogin resp = new ClientLogin(); resp.setAlertMsg("成功登录"); if(isValid){ resp.setIsValid((byte)1); ServerManager.addOnlineContext(userId, context); } ServerManager.sendPacketTo(resp, context); } /** * 验证帐号密码是否一致 */ private boolean validate(Integer userId, String password){ // userDao = (UserDao) ServerDataPool.SPRING_BEAN_FACTORY .getBean(User.class); // User user = userDao.findById(userId); // if(user == null) return false; // // return user.getPassword().equals(password); return true; } }下行包ClientLogin代码如下:
package com.kingston.service.login; import io.netty.buffer.ByteBuf; import com.kingston.net.Packet; import com.kingston.net.PacketType; public class ClientLogin extends Packet{ private String alertMsg; private byte isValid; @Override public void writePacketMsg(ByteBuf buf) { writeUTF8(buf, alertMsg); buf.writeByte(isValid); } @Override public void readFromBuff(ByteBuf buf) { this.alertMsg = readUTF8(buf); this.isValid = buf.readByte(); } @Override public PacketType getPacketType() { return PacketType.ClientLogin; } @Override public void execPacket() { System.err.println("收到服务端的验证消息,"+alertMsg); } public String getAlertMsg() { return alertMsg; } public void setAlertMsg(String alertMsg) { this.alertMsg = alertMsg; } public byte getIsValid() { return isValid; } public void setIsValid(byte isValid) { this.isValid = isValid; } }处理登录逻辑的管理类代码如下:
package com.kingston.service.login; import io.netty.channel.ChannelHandlerContext; import com.kingston.base.ServerManager; public class LoginManagerImpl implements LoginManager{ // @Autowired // private UserDao userDao; @Override public void validateLogin(ChannelHandlerContext context,Integer userId, String password) { boolean isValid = validate(userId, password); ClientLogin resp = new ClientLogin(); resp.setAlertMsg("成功登录"); if(isValid){ resp.setIsValid((byte)1); ServerManager.addOnlineContext(userId, context); } ServerManager.sendPacketTo(resp, context); } /** * 验证帐号密码是否一致 */ private boolean validate(Integer userId, String password){ // userDao = (UserDao) ServerDataPool.SPRING_BEAN_FACTORY .getBean(User.class); // User user = userDao.findById(userId); // if(user == null) return false; // // return user.getPassword().equals(password); return true; } }至此,服务端主要通信逻辑基本完成。
客户端程序代码:
客户端私有协议跟编解码方式跟服务端完全一致。客户端主要关注数据界面的展示。下面只给出启动应用程序的代码,以及测试通信的示例代码。
1.启动Reactor线程组建立与服务端的的连接,以及处理IO网络读写。
程序启动方式跟服务端类似,具体代码如下:
package com.kingston.netty; import com.kingston.net.PacketDecoder; import com.kingston.net.PacketEncoder; import io.netty.bootstrap.Bootstrap; import io.netty.channel.ChannelFuture; import io.netty.channel.ChannelInitializer; import io.netty.channel.EventLoopGroup; import io.netty.channel.nio.NioEventLoopGroup; import io.netty.channel.socket.SocketChannel; import io.netty.channel.socket.nio.NioSocketChannel; import io.netty.handler.codec.LengthFieldBasedFrameDecoder; import io.netty.handler.codec.LengthFieldPrepender; public class NettyChatClient { public void connect(int port,String host) throws Exception{ EventLoopGroup group = new NioEventLoopGroup(); try{ Bootstrap b = new Bootstrap(); b.group(group).channel(NioSocketChannel.class) .handler(new ChannelInitializer<SocketChannel>(){ @Override protected void initChannel(SocketChannel arg0) throws Exception { arg0.pipeline().addLast(new LengthFieldBasedFrameDecoder(1024*1, 0,2,0,2)); arg0.pipeline().addLast(new PacketDecoder()); arg0.pipeline().addLast(new LengthFieldPrepender(2)); arg0.pipeline().addLast(new PacketEncoder()); arg0.pipeline().addLast(new NettyClientHandler()); } }); ChannelFuture f = b.connect(host,port).sync(); f.channel().closeFuture().sync(); }catch(Exception e){ e.printStackTrace(); }finally{ group.shutdownGracefully(); } } }处理业务逻辑的ChannelHandler代码如下:
package com.kingston.netty; import io.netty.channel.Channel; import io.netty.channel.ChannelHandlerAdapter; import io.netty.channel.ChannelHandlerContext; import io.netty.channel.ChannelPromise; import com.kingston.net.Packet; import com.kingston.net.PacketExecutor; import com.kingston.service.login.ServerLogin; public class NettyClientHandler extends ChannelHandlerAdapter{ public NettyClientHandler(){ } @Override public void channelActive(ChannelHandlerContext ctx){ ServerLogin loginPact = new ServerLogin(); loginPact.setUserName("Netty爱好者"); loginPact.setUserPwd("world"); ctx.writeAndFlush(loginPact); System.err.println("向服务端发送登录请求"); // StartApp.channelContext = ctx; } @Override public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception{ Packet packet = (Packet)msg; PacketExecutor.execPacket(packet); } @Override public void close(ChannelHandlerContext ctx,ChannelPromise promise){ System.err.println("TCP closed..."); ctx.close(promise); } @Override public void channelInactive(ChannelHandlerContext ctx) throws Exception { System.err.println("客户端关闭1"); } @Override public void disconnect(ChannelHandlerContext ctx, ChannelPromise promise) throws Exception { ctx.disconnect(promise); System.err.println("客户端关闭2"); } @Override public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception { System.err.println("客户端关闭3"); // ctx.fireExceptionCaught(cause); Channel channel = ctx.channel(); cause.printStackTrace(); if(channel.isActive()){ System.err.println("simpleclient"+channel.remoteAddress()+"异常"); // ctx.close(); } } }
至此,聊天室的登录流程基本完成。限于篇幅,此demo例子并没有出现spring,mybatic,javafx相关代码,但是私有协议通信方式代码已全部给出。有了一个用户登录的例子,相信构建其他得业务逻辑也不会太困难。
最后,说下写代码的历程。这个demo是我春节宅家期间,利用零碎时间做的,平均一天一个小时。很多开发人员应该有这样的经历,看书的时候往往觉得都能理解,但实际上自己动手就会遇到各种卡思路。在做这个demo时,我更多时间是花在查资料上。
我也会继续往这个项目添加功能,让它看起来越来越“炫”。(^-^)如果开发过程中,有比较好的设计思想,我会再写点文字,跟各地的高手取下经。