# 通信协议设计

在 TCP 网络编程中,发送方和接收方的数据包格式都是二进制,发送方将对象转化成二进制流发送给接收方,接收方获得二进制数据后需要知道如何解析成对象。

一个完备的网络协议需要具备以下基本要素

# 魔数

作用是防止任何人随便向服务器端口发送数据。服务端在接收到数据时会解析出前几个固定字节的魔数做对比,如果和约定的魔数不匹配,就会认为是非法数据。

魔数的思想在压缩算法,Java Class 文件就有魔数 0XCAFFBABE ,调侃为咖啡宝贝,在加载 Class 文件时就会首先验证魔数的正确性。

# 序列化算法

序列化算法表示数据发送方应该用何种方法将请求的对象转化为二进制,以及如何将二进制转化为对象等,如 JSON,Hessian,Java 自带序列化等。

可以从 Http 协议中看出,完整的网络协议还需要长度域字段,请求数据,状态,保留字段,报文类型,协议版本号等。

+---------------------------------------------------------------+
| 魔数 2byte | 协议版本号 1byte | 序列化算法 1byte | 报文类型 1byte  |
+---------------------------------------------------------------+
| 状态 1byte |        保留字段 4byte     |      数据长度 4byte     | 
+---------------------------------------------------------------+
|                   数据内容 (长度不定)                          |
+---------------------------------------------------------------+

# 实现 Http 协议通信

需要使用到我们上一篇讲到的解码器和编码器

channel.pipeline()
        .addLast(new HttpRequestDecoder())   //Http 请求解码器
        .addLast(new ChannelInboundHandlerAdapter(){
            @Override
            public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
                // 看看是个啥类型
                System.out.println("收到客户端的数据:"+msg.getClass());  
                
              	// 收到浏览器请求后,我们需要给一个响应回去
                //HTTP 版本为 1.1,状态码就 OK(200)即可
                FullHttpResponse response = new 
                    DefaultFullHttpResponse(HttpVersion.HTTP_1_1, 
                                            HttpResponseStatus.OK);  
              	// 直接向响应内容中写入数据
                response.content().writeCharSequence("Hello World!",
                                                     StandardCharsets.UTF_8);
                ctx.channel().writeAndFlush(response);   // 发送响应
                ctx.channel().close();   //HTTP 请求是一次性的,所以记得关闭
            }
        })
        .addLast(new HttpResponseEncoder());   // 响应记得也要编码后发送哦

用浏览器访问一下

控制台打印的类型为:

class.io.netty.handler.codec.http.DefaultHttpRequest
class.io.neety.handler.codec.http.LastHttpContent$1
class.io.netty.handler.codec.http.DefaultHttpRequest
class.io.neety.handler.codec.http.LastHttpContent$1

可以看到一次请求是一个 DefaultHttpRequest + LastHttpContent$1 ,这里有两组是因为浏览器请求了一个地址后紧接着又请求了网站的 favicon 图标。

如果不希望一次请求被拆分为两个,可以在 HttpRequestDecoder 后面加上聚合器

// 将内容聚合为一个 FullHttpRequest,参数是最大内容长度
.addLast(new HttpObjectAggregator(Integer.MAX_VALUE))

我们再改一下 channelRead 里的内容

@Override
public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
    FullHttpRequest request = (FullHttpRequest) msg;
    System.out.println("浏览器请求路径:"+request.uri());  // 直接获取请求相关信息
    FullHttpResponse response = new DefaultFullHttpResponse(HttpVersion.HTTP_1_1,
                                                            HttpResponseStatus.OK);
    response.content().writeCharSequence("Hello World!", StandardCharsets.UTF_8);
    ctx.channel().writeAndFlush(response);
    ctx.channel().close();
}
// 浏览器总共发送了两次请求
// 浏览器请求路径:/
// 浏览器请求路径:/favicon.ico

如果我们是请求服务器网页资源,需要自定义一个解析器(本质就是通过请求路径拿到相关资源)

public class PageResolver {
		// 直接单例模式
    private static final PageResolver INSTANCE = new PageResolver();
    private PageResolver(){}
    public static PageResolver getInstance(){
        return INSTANCE;
    }
  	// 请求路径给进来,接着我们需要将页面拿到,然后转换成响应数据包发回去
    public FullHttpResponse resolveResource(String path){
        if(path.startsWith("/"))  {  // 判断一下是不是正常的路径请求
            
             // 如果是直接请求根路径,那就默认返回 index 页面,否则就该返回什么路径的文件就返回什么
            path = path.equals("/") ? "index.html" : path.substring(1);
            try(InputStream stream = this.getClass().getClassLoader()
                .getResourceAsStream(path)) {
                if(stream != null) {   // 拿到文件输入流之后,才可以返回页面
                    byte[] bytes = new byte[stream.available()];
                    stream.read(bytes);
                    return this.packet(HttpResponseStatus.OK, bytes);  
                    // 数据先读出来,然后交给下面的方法打包
                }
            } catch (IOException e){
                e.printStackTrace();
            }
        }
      	// 其他情况一律返回 404
        return this.packet(HttpResponseStatus.NOT_FOUND,
                           "404 Not Found!".getBytes());
    }
  	// 包装成 FullHttpResponse,把状态码和数据写进去
    private FullHttpResponse packet(HttpResponseStatus status, byte[] data){
        FullHttpResponse response = new 
            DefaultFullHttpResponse(HttpVersion.HTTP_1_1, status);
        response.content().writeBytes(data);
        return response;
    }
}

channelRead 方法中使用该类

@Override
public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
    FullHttpRequest request = (FullHttpRequest) msg;
    // 请求进来了直接走解析
    PageResolver resolver = PageResolver.getInstance();
    ctx.channel().writeAndFlush(resolver.resolveResource(request.uri()));
    ctx.channel().close();
}

# 其他内置 Handler

Netty 内置了其他很有用的 Handler,比如日志打印

channel.pipeline()
        .addLast(new HttpRequestDecoder())
        .addLast(new HttpObjectAggregator(Integer.MAX_VALUE))
        .addLast(new LoggingHandler(LogLevel.INFO))  
    	// 添加一个日志 Handler,在请求到来时会自动打印相关日志
        ...

每次请求的内容和详细信息都会在日志中出现,包括详细的数据包解析过程,请求头信息都是完整地打印在控制台上的。

还可以使用 Handler 对 IP 地址进行过滤,比如我们不希望某些 IP 地址连接我们的服务器:

channel.pipeline()
        .addLast(new HttpRequestDecoder())
        .addLast(new HttpObjectAggregator(Integer.MAX_VALUE))
        .addLast(new RuleBasedIpFilter(new IpFilterRule() {
            @Override
            public boolean matches(InetSocketAddress inetSocketAddress) {
                return !inetSocketAddress.getHostName().equals("127.0.0.1");  
              	// 进行匹配,返回 false 表示匹配失败
              	// 如果匹配失败,那么会根据下面的类型决定该干什么,
                // 比如这里判断是不是本地访问的,如果是那就拒绝
            }
            @Override
            public IpFilterRuleType ruleType() {
                return IpFilterRuleType.REJECT;   
                // 类型,REJECT 表示拒绝连接,ACCEPT 表示允许连接
            }
        }))

IdleStateHandler 可以对空闲的连接进行处理(idle-- 闲置的)

channel.pipeline()
        .addLast(new StringDecoder())
        .addLast(new IdleStateHandler(10, 10, 0))  // 侦测连接空闲状态
        // 第一个参数表示连接多少秒没有读操作时触发事件,第二个是写操作,
    	// 第三个是读写操作都算,0 表示禁用
    
        // 事件需要在 ChannelInboundHandlerAdapter 中进行监听处理
        .addLast(new ChannelInboundHandlerAdapter(){
            @Override
            public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
                System.out.println("收到客户端数据:"+msg);
                ctx.channel().writeAndFlush("已收到!");
            }
            @Override
            public void userEventTriggered(ChannelHandlerContext ctx, Object evt) throws Exception {
                // 这个方法原来是在这个时候用的
                if(evt instanceof IdleStateEvent) {
                    IdleStateEvent event = (IdleStateEvent) evt;
                    if(event.state() == IdleState.WRITER_IDLE) {
                        System.out.println("长时间没有写操作");
                    } else if(event.state() == IdleState.READER_IDLE) {
                        System.out.println("长时间没有读操作");
                    }
                }
            }
        })
        .addLast(new StringEncoder());

# 参考

利用 Netty 实现自定义协议通信

语雀・青空の霞光:https://www.yuque.com/qingkongxiaguang/javase/ibx6ug#37653588