netty 网络聊天室2

转自: https://blog.csdn.net/littleschemer/article/details/50676798

最近在学习Netty框架,使用的学习教材是李林锋著的《Netty权威指南》。国内关于netty的书籍几乎没有,这本书算是比较好的入门资源了。

我始终觉得,学习一个新的框架,除了研究框架的源代码之外,还应该使用该框架开发一个实际的小应用。为此,我选择Netty作为通信框架,开发一个模仿QQ的聊天室。

基本框架是这样设计的,使用Netty作为通信网关,使用JavaFX开发客户端界面,使用Spring作为IOC容器,使用MyBatics支持持久化。本文将着重介绍Netty网关的私有协议栈开发。

Netty服务端程序示例

启动Reactor线程组监听客户端链路的连接与IO网络读写。

 
  1. public class ChatServer {

  2.  
  3. private Logger logger = LoggerFactory.getLogger(ChatServer.class);

  4.  
  5. //避免使用默认线程数参数

  6. private EventLoopGroup bossGroup = new NioEventLoopGroup(1);

  7. private EventLoopGroup workerGroup = new NioEventLoopGroup(Runtime.getRuntime().availableProcessors());

  8.  
  9. public void bind(int port) throws Exception {

  10. logger.info("服务端已启动,正在监听用户的请求......");

  11. try{

  12. ServerBootstrap b = new ServerBootstrap();

  13. b.group(bossGroup,workerGroup)

  14. .channel(NioServerSocketChannel.class)

  15. .option(ChannelOption.SO_BACKLOG, 1024)

  16. .childHandler(new ChildChannelHandler());

  17.  
  18. ChannelFuture f = b.bind(new InetSocketAddress(port))

  19. .sync();

  20. f.channel().closeFuture().sync();

  21. }catch(Exception e){

  22. logger.error("", e);

  23. throw e;

  24. }finally{

  25. bossGroup.shutdownGracefully();

  26. workerGroup.shutdownGracefully();

  27. }

  28. }

  29.  
  30. public void close() {

  31. try{

  32. if (bossGroup != null) {

  33. bossGroup.shutdownGracefully();

  34. }

  35. if (workerGroup != null) {

  36. workerGroup.shutdownGracefully();

  37. }

  38. }catch(Exception e){

  39. logger.error("", e);

  40. }

  41. }

  42.  
  43. private class ChildChannelHandler extends ChannelInitializer<SocketChannel>{

  44. @Override

  45. protected void initChannel(SocketChannel arg0) throws Exception {

  46. ChannelPipeline pipeline = arg0.pipeline();

  47. pipeline.addLast(new PacketDecoder(1024*4,0,4,0,4));

  48. pipeline.addLast(new LengthFieldPrepender(4));

  49. pipeline.addLast(new PacketEncoder());

  50. //客户端300秒没收发包,便会触发UserEventTriggered事件到MessageTransportHandler

  51. pipeline.addLast("idleStateHandler", new IdleStateHandler(0, 0, 300));

  52. pipeline.addLast(new IoHandler());

  53. }

  54. }

  55.  
  56. }

通信私有协议栈的设计

私有协议栈主要用于跨进程的数据通信,只能用于企业内部,协议设计比较灵巧方便。

在这里,消息定义将消息头和消息体融为一体。将消息的第一个short数据视为消息的类型,服务端将根据消息类型处理不同的业务逻辑。定义Packet抽象类,抽象方法

 readFromBuff(ByteBuf buf) 和  writePacketMsg(ByteBuf buf) 作为读写数据的抽象行为,而具体的读写方式由相应的子类去实现。代码如下:

 
  1. package com.kingston.net;

  2. import io.netty.buffer.ByteBuf;

  3.  
  4. import java.io.UnsupportedEncodingException;

  5. public abstract class Packet {

  6.  
  7. // protected String userId;

  8.  
  9. public void writeToBuff(ByteBuf buf){

  10. buf.writeShort(getPacketType().getType());

  11. writePacketMsg(buf);

  12. }

  13.  
  14. abstract public void writePacketMsg(ByteBuf buf);

  15.  
  16. abstract public void readFromBuff(ByteBuf buf);

  17.  
  18. abstract public PacketType getPacketType();

  19.  
  20. abstract public void execPacket();

  21.  
  22. protected String readUTF8(ByteBuf buf){

  23. int strSize = buf.readInt();

  24. byte[] content = new byte[strSize];

  25. buf.readBytes(content);

  26. try {

  27. return new String(content,"UTF-8");

  28. } catch (UnsupportedEncodingException e) {

  29. e.printStackTrace();

  30. return "";

  31. }

  32.  
  33. }

  34.  
  35. protected void writeUTF8(ByteBuf buf,String msg){

  36. byte[] content ;

  37. try {

  38. content = msg.getBytes("UTF-8");

  39. buf.writeInt(content.length);

  40. buf.writeBytes(content);

  41. } catch (UnsupportedEncodingException e) {

  42. e.printStackTrace();

  43. }

  44. }

  45.  
  46. }

需要注意的是,由于Netty通信本质上传送的是byte数据,无法直接传送String字段串,需要先经过简单的编解码成字节数组才能传送。

POJO对象的编码与解码

数据发送方发送载体为ByteBuf,因此在发包时,需要将POJO对象进行编码。本项目使用Netty自带的编码器MessageToByteEncoder,实现自定义的编码方式。代码如下:

 
  1. package com.kingston.net;

  2.  
  3. import io.netty.buffer.ByteBuf;

  4. import io.netty.channel.ChannelHandlerContext;

  5. import io.netty.handler.codec.MessageToByteEncoder;

  6.  
  7. public class PacketEncoder extends MessageToByteEncoder<Packet> {

  8.  
  9. @Override

  10. protected void encode(ChannelHandlerContext ctx, Packet msg, ByteBuf out)

  11. throws Exception {

  12. msg.writeToBuff(out);

  13. }

  14.  
  15. }

接收方实际接收ByteBuf数据,需要将其解码成对应的POJO对象,才能处理对应的逻辑。本项目使用Netty自带的解码器ByteToMessageDecoder(LengthFieldBasedFrameDecoder继承自ByteToMessageDecoder,其作用见下文),实现自定义的解码方式。代码如下:

 
  1. package com.kingston.net.codec;

  2.  
  3. import io.netty.buffer.ByteBuf;

  4. import io.netty.channel.ChannelHandlerContext;

  5. import io.netty.handler.codec.LengthFieldBasedFrameDecoder;

  6.  
  7. import com.kingston.net.Packet;

  8. import com.kingston.net.PacketManager;

  9.  
  10. public class PacketDecoder extends LengthFieldBasedFrameDecoder{

  11.  
  12. public PacketDecoder(int maxFrameLength,

  13. int lengthFieldOffset, int lengthFieldLength,

  14. int lengthAdjustment, int initialBytesToStrip

  15. ) {

  16. super(maxFrameLength, lengthFieldOffset, lengthFieldLength,

  17. lengthAdjustment, initialBytesToStrip);

  18. }

  19.  
  20. @Override

  21. public Object decode(ChannelHandlerContext ctx, ByteBuf in) throws Exception {

  22. ByteBuf frame = (ByteBuf)(super.decode(ctx, in));

  23. if(frame.readableBytes() <= 0) return null ;

  24. short packetType = frame.readShort();

  25. Packet packet = PacketManager.createNewPacket(packetType);

  26. packet.readFromBuff(frame);

  27.  
  28. return packet;

  29. }

  30.  
  31. }

通信协议将包头的第一个short数据视为包类型,根据包类型反射拿到对应的包class定义,调用抽象读取方法完成消息体的读取。

消息协议的解析与执行

消息使用第一个short数据作为消息的类型。为了区分每一个消息协议包,需要有一个数据结构缓存各种协议的类型与对应的消息包定义。为此,使用枚举类定义所有的协议包。代码如下:

 
  1. public enum PacketType {

  2.  
  3. //业务上行数据包

  4.  
  5.  
  6. //链接心跳包

  7. ReqHeartBeat((short)0x0001, ReqHeartBeatPacket.class),

  8. //新用户注册

  9. ReqUserRegister((short)0x0100, ReqUserRegisterPacket.class),

  10. //用户登陆

  11. ReqUserLogin((short)0x0101, ReqUserLoginPacket.class),

  12. //聊天

  13. ReqChat((short)0x0102, ReqChatPacket.class),

  14.  
  15.  
  16. //业务下行数据包

  17.  
  18.  
  19. RespHeartBeat((short)0x2001, RespHeartBeatPacket.class),

  20.  
  21. //新用户注册

  22. ResUserRegister((short)0x2100, ResUserRegisterPacket.class),

  23.  
  24. RespLogin((short)0x2102, RespUserLoginPacket.class),

  25.  
  26. RespChat((short)0x2103, RespChatPacket.class),

  27.  
  28. ;

  29.  
  30. private short type;

  31. private Class<? extends AbstractPacket> packetClass;

  32. private static Map<Short,Class<? extends AbstractPacket>> PACKET_CLASS_MAP = new HashMap<Short,Class<? extends AbstractPacket>>();

  33.  
  34. public static void initPackets() {

  35. Set<Short> typeSet = new HashSet<Short>();

  36. Set<Class<?>> packets = new HashSet<>();

  37. for(PacketType p:PacketType.values()){

  38. Short type = p.getType();

  39. if(typeSet.contains(type)){

  40. throw new IllegalStateException("packet type 协议类型重复"+type);

  41. }

  42. Class<?> packet = p.getPacketClass();

  43. if (packets.contains(packet)) {

  44. throw new IllegalStateException("packet定义重复"+p);

  45. }

  46. PACKET_CLASS_MAP.put(type,p.getPacketClass());

  47. typeSet.add(type);

  48. packets.add(packet);

  49. }

  50. }

  51.  
  52. PacketType(short type,Class<? extends AbstractPacket> packetClass){

  53. this.setType(type);

  54. this.packetClass = packetClass;

  55. }

  56.  
  57. public short getType() {

  58. return type;

  59. }

  60.  
  61. public void setType(short type) {

  62. this.type = type;

  63. }

  64.  
  65. public Class<? extends AbstractPacket> getPacketClass() {

  66. return packetClass;

  67. }

  68.  
  69. public void setPacketClass(Class<? extends AbstractPacket> packetClass) {

  70. this.packetClass = packetClass;

  71. }

  72.  
  73.  
  74. public static Class<? extends AbstractPacket> getPacketClassBy(short packetType){

  75. return PACKET_CLASS_MAP.get(packetType);

  76. }

  77.  
  78. }

PacketType枚举类中有一个初始化方法initPackets(),用于缓存所有包类型与对应的实体类的映射关系。这样,就可以根据包类型,直接拿到对应的Packet子类。

经过解码反射得到完整的消息包定义后,就可以通过反射机制,调用相应的业务方法。该步骤由包执行器完成,代码如下:

 
  1. package com.kingston.net;

  2.  
  3. import java.lang.reflect.InvocationTargetException;

  4. import java.lang.reflect.Method;

  5.  
  6. public class PacketExecutor {

  7.  
  8. public static void execPacket(Packet pact){

  9. if(pact == null) return;

  10.  
  11. try {

  12. Method m = pact.getClass().getMethod("execPacket");

  13. m.invoke(pact, null);

  14. } catch (NoSuchMethodException | SecurityException e) {

  15. e.printStackTrace();

  16. } catch (IllegalAccessException e) {

  17. e.printStackTrace();

  18. } catch (IllegalArgumentException e) {

  19. e.printStackTrace();

  20. } catch (InvocationTargetException e) {

  21. e.printStackTrace();

  22. }

  23. }

  24.  
  25. }

包执行器其实是根据反射,调用对应子类消息包的业务处理方法。

到这里,读者应该可以感受抽象包Packet的定义是该通信机制的精华部分。正是有了abstract public void  readFromBuff(ByteBuf buf); abstract public void writePacketMsg(ByteBuf buf); abstract public void execPacket()三个抽象方法,才能将各种消息包的读写、业务逻辑相互隔离。

写到这里,我不禁回想起大学期间做过的一个聊天室课程设计。当初,我采用Java作为服务器,flash作为客户端,基于socket进行通信。通信消息体只有一个长字符串,通信双方根据不同消息类型将字符串作多次分隔。如果当初协议类型再多几个的话,估计想死的心都有了。

Netty的半包读写解决之道

MessageToByteEncoder 和 ByteToMessageDecoder两个类只是解决POJO的编解码,并没有处理粘包,拆包的异常情况。在本例中,使用LengthFieldBasedFrameDecoder和LengthFieldPrepender两个工具类,就可以轻松解决半包读写异常。

服务端与客户端数据通信方式

客户端tcp链路建立后,服务端必须缓存对应的ChannelHandlerContext对象。这样,服务端就可以向所有连接的用户发送数据了。发送数据基础服务类代码如下:

 
  1. package com.kingston.base;

  2.  
  3. import io.netty.channel.ChannelHandlerContext;

  4.  
  5. import java.util.Map;

  6. import java.util.concurrent.ConcurrentHashMap;

  7.  
  8. import com.kingston.net.Packet;

  9. import com.kingston.util.StringUtil;

  10.  
  11. public class ServerManager {

  12.  
  13. //缓存所有登录用户对应的通信上下文环境(主要用于业务数据处理)

  14. private static Map<Integer,ChannelHandlerContext> USER_CHANNEL_MAP = new ConcurrentHashMap<>();

  15. //缓存通信上下文环境对应的登录用户(主要用于服务)

  16. private static Map<ChannelHandlerContext,Integer> CHANNEL_USER_MAP = new ConcurrentHashMap<>();

  17.  
  18. public static void sendPacketTo(Packet pact,String userId){

  19. if(pact == null || StringUtil.isEmpty(userId)) return;

  20.  
  21. Map<Integer,ChannelHandlerContext> contextMap = USER_CHANNEL_MAP;

  22. if(StringUtil.isEmpty(contextMap)) return;

  23.  
  24. ChannelHandlerContext targetContext = contextMap.get(userId);

  25. if(targetContext == null) return;

  26.  
  27. targetContext.writeAndFlush(pact);

  28. }

  29.  
  30. /**

  31. * 向所有在线用户发送数据包

  32. */

  33. public static void sendPacketToAllUsers(Packet pact){

  34. if(pact == null ) return;

  35. Map<Integer,ChannelHandlerContext> contextMap = USER_CHANNEL_MAP;

  36. if(StringUtil.isEmpty(contextMap)) return;

  37.  
  38. contextMap.values().forEach( (ctx) -> ctx.writeAndFlush(pact));

  39.  
  40. }

  41.  
  42. /**

  43. * 向单一在线用户发送数据包

  44. */

  45. public static void sendPacketTo(Packet pact,ChannelHandlerContext targetContext ){

  46. if(pact == null || targetContext == null) return;

  47. targetContext.writeAndFlush(pact);

  48. }

  49.  
  50. public static ChannelHandlerContext getOnlineContextBy(String userId){

  51. return USER_CHANNEL_MAP.get(userId);

  52. }

  53.  
  54. public static void addOnlineContext(Integer userId,ChannelHandlerContext context){

  55. if(context == null){

  56. throw new NullPointerException();

  57. }

  58. USER_CHANNEL_MAP.put(userId,context);

  59. CHANNEL_USER_MAP.put(context, userId);

  60. }

  61.  
  62. /**

  63. * 注销用户通信渠道

  64. */

  65. public static void ungisterUserContext(ChannelHandlerContext context ){

  66. if(context != null){

  67. int userId = CHANNEL_USER_MAP.getOrDefault(context,0);

  68. CHANNEL_USER_MAP.remove(context);

  69. USER_CHANNEL_MAP.remove(userId);

  70. context.close();

  71. }

  72. }

  73.  
  74. }

模拟用户登录的服务端demo

1. demo流程为客户端发送一个以Req开头命名的上行包到服务端,服务端接受数据后,直接发送一个以Resp开头命名的响应包到客户端。

上行包ReqUserLogin代码如下:

 
  1. public class ReqUserLoginPacket extends Packet{

  2.  
  3. private long userId;

  4. private String userPwd;

  5.  
  6. @Override

  7. public void writePacketBody(ByteBuf buf) {

  8. buf.writeLong(userId);

  9. writeUTF8(buf, userPwd);

  10. }

  11.  
  12. @Override

  13. public void readPacketBody(ByteBuf buf) {

  14. this.userId = buf.readLong();

  15. this.userPwd =readUTF8(buf);

  16.  
  17. System.err.println("id="+userId+",pwd="+userPwd);

  18. }

  19.  
  20. @Override

  21. public PacketType getPacketType() {

  22. return PacketType.ReqUserLogin;

  23. }

  24.  
  25. @Override

  26. public void execPacket() {

  27.  
  28.  
  29. }

  30.  
  31. public String getUserPwd() {

  32. return userPwd;

  33. }

  34.  
  35. public void setUserPwd(String userPwd) {

  36. this.userPwd = userPwd;

  37. }

  38.  
  39. public long getUserId() {

  40. return userId;

  41. }

  42.  
  43. public void setUserId(long userId) {

  44. this.userId = userId;

  45. }

  46.  
  47. }

2. 业务逻辑服务,收到登录包后,调用对应的业务处理方法进行处理

 
  1. @Component

  2. public class LoginService {

  3.  
  4. @Autowired

  5. private UserDao userDao;

  6.  
  7. public void validateLogin(Channel channel, long userId, String password) {

  8. User user = validate(userId, password);

  9. IoSession session = ChannelUtils.getSessionBy(channel);

  10. RespUserLoginPacket resp = new RespUserLoginPacket();

  11. if(user != null) {

  12. resp.setIsValid((byte)1);

  13. resp.setAlertMsg("登录成功");

  14. ServerManager.INSTANCE.registerSession(user, session);

  15. }else{

  16. resp.setAlertMsg("帐号或密码错误");

  17. }

  18.  
  19. ServerManager.INSTANCE.sendPacketTo(session, resp);

  20. }

  21.  
  22. /**

  23. * 验证帐号密码是否一致

  24. */

  25. private User validate(long userId, String password){

  26. if (userId <= 0 || StringUtils.isEmpty(password)) {

  27. return null;

  28. }

  29. User user = userDao.findById(userId);

  30. if (user != null &&

  31. user.getPassword().equals(password)) {

  32. return user;

  33. }

  34.  
  35. return null;

  36. }

  37.  
  38. }

3. 业务处理后,下发一个响应包。下行包RespUserLogin代码如下:

 
  1. public class RespUserLoginPacket extends AbstractPacket{

  2.  
  3. private String alertMsg;

  4. private byte isValid;

  5.  
  6. @Override

  7. public void writePacketBody(ByteBuf buf) {

  8. writeUTF8(buf, alertMsg);

  9. buf.writeByte(isValid);

  10. }

  11.  
  12. @Override

  13. public void readPacketBody(ByteBuf buf) {

  14. this.alertMsg = readUTF8(buf);

  15. this.isValid = buf.readByte();

  16. }

  17.  
  18. @Override

  19. public PacketType getPacketType() {

  20. return PacketType.RespUserLogin;

  21. }

  22.  
  23. @Override

  24. public void execPacket() {

  25. System.err.println("receive login "+ alertMsg);

  26. LoginManager.getInstance().handleLoginResponse(this);

  27. }

  28.  
  29. public String getAlertMsg() {

  30. return alertMsg;

  31. }

  32.  
  33. public void setAlertMsg(String alertMsg) {

  34. this.alertMsg = alertMsg;

  35. }

  36.  
  37. public byte getIsValid() {

  38. return isValid;

  39. }

  40.  
  41. public void setIsValid(byte isValid) {

  42. this.isValid = isValid;

  43. }

  44.  
  45. }

至此,服务端主要通信逻辑基本完成。

模拟用户登录的客户端demo

客户端私有协议跟编解码方式跟服务端完全一致。客户端主要关注数据界面的展示。下面只给出启动应用程序的代码,以及测试通信的示例代码。
1.启动Reactor线程组建立与服务端的的连接,以及处理IO网络读写。

 
  1. public class SocketClient {

  2.  
  3. /** 当前重接次数*/

  4. private int reconnectTimes = 0;

  5.  
  6. public void start() {

  7. try{

  8. connect(ClientConfigs.REMOTE_SERVER_IP,

  9. ClientConfigs.REMOTE_SERVER_PORT);

  10. }catch(Exception e){

  11.  
  12. }

  13. }

  14.  
  15. public void connect(String host,int port) throws Exception {

  16. EventLoopGroup group = new NioEventLoopGroup(1);

  17. try{

  18. Bootstrap b = new Bootstrap();

  19. b.group(group).channel(NioSocketChannel.class)

  20. .handler(new ChannelInitializer<SocketChannel>(){

  21.  
  22. @Override

  23. protected void initChannel(SocketChannel arg0)

  24. throws Exception {

  25. ChannelPipeline pipeline = arg0.pipeline();

  26. pipeline.addLast(new PacketDecoder(1024*1, 0,4,0,4));

  27. pipeline.addLast(new LengthFieldPrepender(4));

  28. pipeline.addLast(new PacketEncoder());

  29. pipeline.addLast(new ClientTransportHandler());

  30. }

  31.  
  32. });

  33.  
  34. ChannelFuture f = b.connect(new InetSocketAddress(host, port),

  35. new InetSocketAddress(ClientConfigs.LOCAL_SERVER_IP, ClientConfigs.LOCAL_SERVER_PORT))

  36. .sync();

  37. f.channel().closeFuture().sync();

  38. }catch(Exception e){

  39. e.printStackTrace();

  40. }finally{

  41. // group.shutdownGracefully(); //这里不再是优雅关闭了

  42. //设置最大重连次数,防止服务端正常关闭导致的空循环

  43. if (reconnectTimes < ClientConfigs.MAX_RECONNECT_TIMES) {

  44. reConnectServer();

  45. }

  46. }

  47. }

  48. }

2.处理业务逻辑的ClientTransportHandler代码如下:

 
  1. public class ClientTransportHandler extends ChannelHandlerAdapter{

  2.  
  3.  
  4. public ClientTransportHandler(){

  5.  
  6. }

  7.  
  8. @Override

  9. public void channelActive(ChannelHandlerContext ctx){

  10. //注册session

  11. ClientBaseService.INSTANCE.registerSession(ctx.channel());

  12.  
  13. }

  14.  
  15. @Override

  16. public void channelRead(ChannelHandlerContext ctx, Object msg)

  17. throws Exception{

  18. AbstractPacket packet = (AbstractPacket)msg;

  19. PacketManager.INSTANCE.execPacket(packet);

  20. }

  21.  
  22. @Override

  23. public void close(ChannelHandlerContext ctx,ChannelPromise promise){

  24. System.err.println("TCP closed...");

  25. ctx.close(promise);

  26. }

  27.  
  28. @Override

  29. public void channelInactive(ChannelHandlerContext ctx) throws Exception {

  30. System.err.println("客户端关闭1");

  31. }

  32.  
  33. @Override

  34. public void disconnect(ChannelHandlerContext ctx, ChannelPromise promise) throws Exception {

  35. ctx.disconnect(promise);

  36. System.err.println("客户端关闭2");

  37. }

  38.  
  39. @Override

  40. public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception {

  41. System.err.println("客户端关闭3");

  42. Channel channel = ctx.channel();

  43. cause.printStackTrace();

  44. if(channel.isActive()){

  45. System.err.println("simpleclient"+channel.remoteAddress()+"异常");

  46. }

  47. }

  48. }

3. 先启动服务器,再启动JavaFX客户端(ClientStartup),即可看到登录界面


至此,聊天室的登录流程基本完成。限于篇幅,此demo例子并没有出现spring,mybatic相关代码,但是私有协议通信方式代码已全部给出。有了一个用户登录的例子,相信构建其他得业务逻辑也不会太困难。

最后,说下写代码的历程。这个demo是我春节宅家期间,利用零碎时间做的,平均一天一个小时。很多开发人员应该有这样的经历,看书的时候往往觉得都能理解,但实际上自己动手就会遇到各种卡思路。在做这个demo时,我更多时间是花在查资料上。

我也会继续往这个项目添加功能,让它看起来越来越“炫”。(^-^)

全部代码已在github上托管(代码经过多次重构,与博客上的代码略有不同)

完整服务端代码请移步 --> netty聊天室服务器

完整客户端代码请移步 --> netty聊天室客户端

--------------------- 本文来自 littleschemer 的CSDN 博客 ,全文地址请点击:https://blog.csdn.net/littleschemer/article/details/50676798?utm_source=copy

猜你喜欢

转载自blog.csdn.net/hemeinvyiqiluoben/article/details/82944701