Netty第三部
继续Netty第二部的内容
一、ChannelHandler
1、ChannelHandler接口
ChannelHandler是Netty的主要组件,处理所有的入站和出站数据的应用程序逻辑的容器,可以应用在数据的格式转换、异常处理、数据报文统计等
继承ChannelHandler的两个子接口:
ChannelInboundHandler:处理入站数据以及各种状态变化
ChannelOutboundHandler:处理出站数据并且允许拦截所有的操作
2、ChannelInboundHandler接口
- channelRegistered当Channel已经注册到它的EventLoop并且能够处理I/O时被调用
- channelUnregistered当Channel从它的EventLoop注销并且无法处理任何I/O时被调用
- channelActive当Channel处于活动状态时被调用;Channel已经连接/绑定并且已经就绪
- channelInactive当 Channel离开活动状态并且不再连接它的远程节点时被调用
- channelReadComplete当Channel上的一个读操作完成时被调用
- channelRead 当从Channel读取数据时被调用
- channelWritabilityChanged当Channel的可写状态发生改变时被调用。可以通过调用Channel的 isWritable()方法来检测Channel的可写性。与可写性相关的阈值可以通过 Channel.config().setWriteHighWaterMark()和Channel.config().setWriteLowWaterMark()方法来设置
- userEventTriggered当ChannelnboundHandler.fireUserEventTriggered()方法被调用时被调用。
3、ChannelOutboundHandler接口
出站操作和数据将由 ChannelOutboundHandler 处理。它的方法将被 Channel、ChannelPipeline 以及 ChannelHandlerContext 调用。
所有由 ChannelOutboundHandler 本身所定义的方法:
- bind(ChannelHandlerContext,SocketAddress,ChannelPromise) 当请求将 Channel 绑定到本地地址时被调用
- connect(ChannelHandlerContext,SocketAddress,SocketAddress,ChannelPromise) 当请求将 Channel 连接到远程节点时被调用
- disconnect(ChannelHandlerContext,ChannelPromise) 当请求将 Channel 从远程节点断开时被调用
- close(ChannelHandlerContext,ChannelPromise) 当请求关闭 Channel 时被调用
- deregister(ChannelHandlerContext,ChannelPromise) 当请求将 Channel 从它的 EventLoop 注销时被调用
- read(ChannelHandlerContext) 当请求从 Channel 读取更多的数据时被调用
- flush(ChannelHandlerContext) 当请求通过 Channel 将入队数据冲刷到远程节点时被调用
- write(ChannelHandlerContext,Object,ChannelPromise) 当请求通过 Channel 将数据写到 远程节点时被调用
4、ChannelHandler的适配器
ChannelInboundHandlerAdapter(处理入站)和ChannelOutboundHandlerAdapter(处理出站)是Netty提供的ChannelHandler基类,降低了ChannelHandler实现的复杂度

为什么ChannelOutboundHandlerAdapter会有read()方法
ChannelOutboundHandler.read()是主动触发读事件的,或者是处理读事件的前置处理器;调用ChannelOutboundHandler.read()方法时Channel会向Selector读取数据,读取数据之后会交给ChannelPipeline,这时候ChannelPipeline就会触发ChannelInboundHandler.channelRead()读事件
如何在一个ChannelHandler实现同时实现入栈出战
继承ChannelDuplexHandler,也可以同时实现ChannelOutboundHandler, ChannelInboundHandler这两个接口
5、共享Handler
ChannelHandlerAdapter提供了isSharable()方法,如果实现ChannelHandler的类上指定了@Sharable注解,isSharable()方法就会返回true,反之则否;
每个SocketChannel都有自己的ChannelPipeline,同时SocketChannel会对应一个EventLoop,也就是说只会一个线程来处理,ChannelHandler实例之间是完全独立,只要不是共享了全局变量,ChannelHandler是线程安全的
如何通过共享Handler实现包统计
继承ChannelDuplexHandler类,同时类上加上@Sharable注解注解,指定该实例为static,保证只有一个,添加到ChannelPipeline中,重写read()和flush()方法,就可以具体发包收包记录了
6、资源管理和SimpleChannelInboundHandler
在NIO实现中需要依靠创建buffer来进行Channel之间的数据交换;
Netty也是同样的设计,在read网络数据时由Netty创建Buffer,write时会拿到这个Buffer写到网络中(outBoundHandler处理了write()操作并丢弃了数据,而read()需要继续往下一个Handler传递,所以没有相关处理)
可能产生内存泄露的情况:
- 没有调用相关的fireChannelRead方法,也不释放Buffer
- 执行channelRead方法抛出异常导致fireChannelRead方法未执行,同时也不释放Buffer
正常执行fireChannelRead方法入站往后传递Buffer,Netty都会释放Buffer,Netty提供了SimpleChannelInboundHandler类来支持这种情况,继承SimpleChannelInboundHandler类,重写channelRead0方法,即使不抛出异常,也不调用fireChannelRead方法,最终Netty也会帮我们做Buffer的释放
@Override
public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {boolean release = true;try {if (acceptInboundMessage(msg)) {@SuppressWarnings("unchecked")I imsg = (I) msg;channelRead0(ctx, imsg);} else {release = false;ctx.fireChannelRead(msg);}} finally {if (autoRelease && release) {ReferenceCountUtil.release(msg);}}
}/*** Is called for each message of type {@link I}.** @param ctx the {@link ChannelHandlerContext} which this {@link SimpleChannelInboundHandler}* belongs to* @param msg the message to handle* @throws Exception is thrown if an error occurred*/
protected abstract void channelRead0(ChannelHandlerContext ctx, I msg) throws Exception;
二、Netty内置通信传输模式
- NIO:io.netty.channel.socket.nio 使用java.nio.channels包作为基础——基于选择器的方式
- Epoll:io.netty.channel.epoll 由JNI驱动的epoll()和非阻塞 IO。这个传输支持只有在Linux上可用的多种特性,如SO_REUSEPORT,比NIO传输更快,而且是完全非阻塞的。将NioEventLoopGroup替换为EpollEventLoopGroup,并且将NioServerSocketChannel.class替换为EpollServerSocketChannel.class即可。
- OIO:io.netty.channel.socket.oio 使用java.net包作为基础——使用阻塞流
- Local:io.netty.channel.local 可以在VM内部通过管道进行通信的本地传输
- Embedded:io.netty.channel.embedded Embedded 传输,允许使用ChannelHandler而又不需要一个真正的基于网络的传输。在测试ChannelHandler实现时非常有用
三、引导Bootstrap
ServerBootstrap将绑定到一个端口,因为服务器必须要监听连接,而Bootstrap则是由想要连接到远程节点的客户端应用程序所使用的。
引导一个客户端只需要一个EventLoopGroup,但是一个ServerBootstrap则需要两个(也可以是同一个实例)
@Override
public ServerBootstrap group(EventLoopGroup group) {return group(group, group);
}/*** Set the {@link EventLoopGroup} for the parent (acceptor) and the child (client). These* {@link EventLoopGroup}'s are used to handle all the events and IO for {@link ServerChannel} and* {@link Channel}'s.*/
public ServerBootstrap group(EventLoopGroup parentGroup, EventLoopGroup childGroup) {super.group(parentGroup);if (this.childGroup != null) {throw new IllegalStateException("childGroup set already");}this.childGroup = ObjectUtil.checkNotNull(childGroup, "childGroup");return this;
}
与ServerChannel相关联的EventLoopGroup将分配一个负责为传入连接请求创建Channel的EventLoop。一旦连接被接受,第二个EventLoopGroup就会给它的Channel分配 一个EventLoop。
四、ChannelInitializer
ChannelInitializer是ChannelInboundHandlerAdapter的子类,当中的initChannel()方法提供了一种将多个ChannelHandler添加到一个ChannelPipeline中的简便方法。你只需要简单地向Bootstrap或ServerBootstrap的实例提供你的ChannelInitializer实现即可,并且一旦Channel被注册到了它的EventLoop之后,就会调用你的initChannel()版本。在该方法返回之后,ChannelInitializer的实例将会从ChannelPipeline中移除它自己。
/*** This method will be called once the {@link Channel} was registered. After the method returns this instance* will be removed from the {@link ChannelPipeline} of the {@link Channel}.** @param ch the {@link Channel} which was registered.* @throws Exception is thrown if an error occurs. In that case it will be handled by* {@link #exceptionCaught(ChannelHandlerContext, Throwable)} which will by default close* the {@link Channel}.*/
protected abstract void initChannel(C ch) throws Exception;@SuppressWarnings("unchecked")
private boolean initChannel(ChannelHandlerContext ctx) throws Exception {if (initMap.add(ctx)) { // Guard against re-entrance.try {initChannel((C) ctx.channel());} catch (Throwable cause) {// Explicitly call exceptionCaught(...) as we removed the handler before calling initChannel(...).// We do so to prevent multiple calls to initChannel(...).exceptionCaught(ctx, cause);} finally {ChannelPipeline pipeline = ctx.pipeline();if (pipeline.context(this) != null) {pipeline.remove(this);}}return true;}return false;
}
五、ChannelOption
- ChannelOption.SO_BACKLOG:对应的是 tcp/ip 协议 listen 函数中的 backlog 参数,从Linux2.2开始,backlog的参数行为在Linux2.2中发生了变化,现在它指定等待接受的完全建立的套接字的队列长度,而不是不完整的连接请求的数量
- ChannelOption.SO_REUSEADDR:对应于套接字选项中的SO_REUSEADDR,这个参数表示允许重复使用本地地址和端口
- ChannelOption.SO_KEEPALIVE:对应于套接字选项中的 SO_KEEPALIVE,如果在两小时内没有数据的通信时,TCP会自动发送一 个活动探测数据报文,确定连接状态
- ChannelOption.SO_SNDBUF和ChannelOption.SO_RCVBUF:对应于套接字选项中的 SO_SNDBUF和 SO_RCVBUF,这两个参数用于操作接 收缓冲区和发送缓冲区的大小
- ChannelOption.SO_LINGER: 对应于套接字选项中的 SO_LINGER,保证TCP四次回收最后一次发送close()时数据传输完毕
- ChannelOption.TCP_NODELAY:对应于套接字选项中的TCP_NODELAY,该参数的使用与Nagle算法有关,Nagle算法是将小的数据包组装为更大的帧然后进行发送,而不是输入一次发送一次,因此在数据包不足的时候会等待其他数据的到了,组装成大的数据包进行发送,虽然该方式有效提高网络的有效负载,但是却造成了延时,而该参数的作用就是禁止使用Nagle算法,使用于小数据即时传输,于TCP_NODELAY相对应的是TCP_CORK,该选项是需要等到发送的数据量最大的时候,一次性发送数据,适用于文件传输。
六、ByteBuf
ByteBuf 维护了两个不同的索引,名称以 read 或者 write 开头的 ByteBuf 方法,将会 推进其对应的索引,而名称以 set 或者 get 开头的操作则不会。 如果打算读取字节直到 readerIndex 达到和 writerIndex 同样的值时会发生什么。在那 时,你将会到达“可以读取的”数据的末尾。就如同试图读取超出数组末尾的数据一样,试 图读取超出该点的数据将会触发一个 IndexOutOf-BoundsException。 可以指定 ByteBuf 的最大容量。试图移动写索引(即 writerIndex)超过这个值将会触发 一个异常。(默认的限制是 Integer.MAX_VALUE。)
七、粘包/半包问题
1、TCP粘包/半包发生的原因
分包产生的原因就简单的多:就是一个数据包被分成了多次接收。 更具体的原因至少包括:
- 应用程序写入数据的字节大小大于套接字发送缓冲区的大小
- 进行 MSS大小的TCP分段。MSS是最大报文段长度的缩写。MSS是TCP报文段中的 数据字段的最大长度。数据字段加上TCP首部才等于整个的TCP报文段。所以MSS并不是TCP报文段的最大长度,而是:MSS=TCP报文段长度-TCP首部长度。

假设客户端分别发送了两个数据包D1和D2给服务端,由于服务端一次读取到的字节 数是不确定的,故可能存在以下 4 种情况。
- 服务端分两次读取到了两个独立的数据包,分别是D1和D2,没有粘包和拆包;
- 服务端一次接收到了两个数据包,D1和D2粘合在一起,被称为TCP粘包;
- 服务端分两次读取到了两个数据包,第一次读取到了完整的D1包和D2包的部分内容,第二次读取到了D2包的剩余内容,这被称为TCP拆包;
- 服务端分两次读取到了两个数据包,第一次读取到了D1包的部分内容D1_1,第 二次读取到了D1包的剩余内容D1_2和D2包的整包。
如果此时服务端TCP接收滑窗非常小,而数据包D1和D2比较大,很有可能会发生第五种可能,即服务端分多次才能将D1和D2包接收完全,期间发生多次拆包。
2、解决TCP粘包/半包问题
由于底层的 TCP 无法理解上层的业务数据,所以在底层是无法保证数据包不被拆分和重 组的,这个问题只能通过上层的应用协议栈设计来解决,根据业界的主流协议的解决方案, 可以归纳如下。
- 在包尾增加分割符,比如回车换行符进行分割,例如 FTP 协议; 参见 cn.tuling.nettybasic.splicing.linebase(回车换行符进行分割)和 cn.tuling.nettybasic.splicing.delimiter(自定义分割符)下的代码
- 消息定长,例如每个报文的大小为固定长度 200 字节,如果不够,空位补空格; 参见 cn.tuling.nettybasic.splicing.fixed 下的代码
- 将消息分为消息头和消息体,消息头中包含表示消息总长度(或者消息体长度) 的字段,通常设计思路为消息头的第一个字段使用 int32 来表示消息的总长度,使用 LengthFieldBasedFrameDecoder
4、分析channelRead和channelReadComplete
- Netty是在读到完整的业务请求报文后才调用一次业务ChannelHandler的channelRead方法
- 如果一个业务消息被TCP协议栈发送了N次,则服务端的channelReadComplete方法就会被调用N次。
九、编码器和解码器
- 将字节解码为消息——ByteToMessageDecoder
- 将一种消息类型解码为另一种——MessageToMessageDecoder。
十、序列化问题
1、Java序列化的缺点
- 无法跨语言
- 序列化后的码流太大
- 序列化性能太低
2、序列化框架
具体可以参考:几种Java常用序列化框架的选型与对比-阿里云开发者社区
相关文章:
Netty第三部
继续Netty第二部的内容 一、ChannelHandler 1、ChannelHandler接口 ChannelHandler是Netty的主要组件,处理所有的入站和出站数据的应用程序逻辑的容器,可以应用在数据的格式转换、异常处理、数据报文统计等 继承ChannelHandler的两个子接口ÿ…...
【C++入门篇】保姆级教程篇【下】
目录 一、运算符重载 1)比较、赋值运算符重载 2) 流插入留提取运算符重载 二、剩下的默认成员函数 1)赋值运算符重载 2)const成员函数 3)取地址及const取地址操作符重载 三、再谈构造函数 1)初始化列表 …...
CCLink转Modbus TCP网关_CCLINK参数配置
CCLink转Modbus TCP网关(XD-ETHCL20),具有CCLINK主从站功能。主要用途是将各种MODBUS-TCP设备接入到CCLINK总线中。它可以作为从站连接到CCLINK总线上,也可以作为主站或从站连接到MODBUS-MTP总线上。 1、 配置网关的CCLINK参数&am…...
一文2000字从0到1使用压测神器JMeter进行压力测试!
概 述 Apache JMeter 是 Apache组织开发的基于 Java的压力测试工具。用于对软件做压力测试,它最初被设计用于 Web应用测试但后来扩展到其他测试领域。它可以用于测试静态和动态资源例如静态文件、Java 小服务程序、CGI 脚本、Java 对象、数据库, FTP 服…...
极狐GitLab CI 助力 .Net 项目研发效率和质量双提升
目录 .NET nuget 自动生成测试包(prerelease)版本号 .NET 版本号规范 持续集成自动打包 持续集成自动修改版本号 .NET 行级增量代码规范——拯救老项目 本地全量代码规范 行级增量代码规范 很多团队或开发者都会使用 C#、VB 等语言开发 .Net 应用…...
[协程]生成器协程调度器的实现-未完
本章内容的三个层次...
Git之分支与版本->课程目标及知识点的应用场景,分支的场景应用,标签的场景应用
1.课程目标及知识点的应用场景 Git分支和标签的命名规范 分支 dev/test/pre/pro(即master) dev:开发环境--windows (自己的电脑) test:测试环境--windows/linux (公司专门的测试电脑 pre:灰度环境(非常大的公司非常重要的项目) pro:正式环境 灰度环境与正式环境的服务器配置…...
PHP正则提取或替换img标记属性
<?php/*PHP正则提取图片img标记中的任意属性*/ $str <center><img src"/uploads/images/20100516000.jpg" height"120" width"120"><br />PHP正则提取或更改图片img标记中的任意属性</center>;//1、取整个图片代码…...
Git 命令行使用指南
Git 命令行使用指南 第一部分:配置 Git 1.1 设置用户信息1.2 配置换行符处理 第二部分:创建和配置仓库 2.1 初始化仓库2.2 克隆仓库2.3 递归克隆2.4 深度克隆 第三部分:基本操作 3.1 添加文件3.2 提交更改3.3 查看状态和提交历史3.4 创建和切…...
Spring 常见面试题
1、Spring概述 1.1、Spring是什么? Spring是一个轻量级Java开发框架,目的是为了解决企业级应用开发的业务逻辑层和其他各层的耦合问题Spring最根本的使命是解决企业级应用开发的复杂性,即简化Java开发。这些功能的底层都依赖于它的两个核心特性,也就是…...
caffe搭建squeezenet网络的整套工程
之前用pytorch构建了squeezenet,个人觉得pytorch是最好用的,但是有的工程就是需要caffe结构的,所以本篇也用caffe构建一个squeezenet网络。 数据处理 首先要对数据进行处理,跟pytorch不同,pytorch读取数据只需要给数据…...
【OWT】梳理构建的webrtc和owt mfc工程
梳理构建的webrtc和owt mfc工程M98 + owtp2p : 发现最终基于m98的owt也可以直接跑通 【owt】p2p client mfc 工程梳理 服务端使用github版本。 本地运行调试即可。 M98 VS2017 构建 :只构建了m98的webrtc.lib 【webrtc】vs2017 重新构建m98 G:\webrtc_m98_yjf\src webrtc本身…...
02 powershell服务器远程执行命令
一、获取服务器登录凭证 $Username myft\xngrq $PWD 123!# #将密码加密成特殊的字符串对象 $pass ConvertTo-SecureString -AsPlainText $PWD -Force #创建一个登录凭证对象 $Cred New-Object System.Management.Automation.PSCredential -ArgumentList $Username,$pass …...
LeetCode257. Binary Tree Paths
文章目录 一、题目二、题解 一、题目 Given the root of a binary tree, return all root-to-leaf paths in any order. A leaf is a node with no children. Example 1: Input: root [1,2,3,null,5] Output: [“1->2->5”,“1->3”] Example 2: Input: root […...
Linux下MSSQL (SQL Server)数据库无法启动故障处理
有同事反馈一套CentOS7下的mssql server2017无法启动需要我帮忙看看,启动报错情况如下 检查日志并没有更新日志信息 乍一看mssql-server服务有问题,检查mssql也确实没有进程 既然服务有问题,那么我们用一种方式直接手工后台启动mssql引擎来…...
2311极语言高亮说明书
入门 安装目录下Sec.exe为ide.Sc为编译器. .sec为单文件二进制源码结构,.SEC和.极为多文件文本结构,命令行:cmd Sc.exe 源码路径. 基础 整数变量也可以是万能指针,传送参数,参数只有整数和小数两种. 可在名称前面加或&符号取变量或函数名指针地址,文本变量只取地址不用加…...
金蝶云星空与金蝶云星空对接集成盘亏单查询打通盘亏单新增
金蝶云星空与金蝶云星空对接集成盘亏单查询打通盘亏单新增 接通系统:金蝶云星空 金蝶K/3Cloud(金蝶云星空)是移动互联网时代的新型ERP,是基于WEB2.0与云技术的新时代企业管理服务平台。金蝶K/3Cloud围绕着“生态、人人、体验”&am…...
深入理解 Django 信号机制
Django 信号(signals)是一种实现解耦的有力工具,它允许某些发生的事件通知其他部分的代码。信号主要用于在 Django 应用中的不同部分之间传递信息,尤其是在模型操作发生时。本文将深入探讨 Django 信号的工作原理、如何定义和接收…...
uniapp开发app应用从创建到上架
目录 前言 一、项目初始化 1.初始化方式 2.账号注册 3.插件安装 二、项目结构及重点文件介绍 1.项目基本结构 2.项目文件介绍 三、应用打包 1. 安卓打包 2.苹果打包 四、应用发布 1. 安卓市场发布 用户权限和隐私政策 注销 软著和App备案证书 2. 苹果市场发布 …...
为什么使用Golang而非Rust开发桌面应用?
MoonGuard 团队选择 Golang 而不是 Rust 作为他们的 Krater 桌面应用程序,因为 Golang 中更容易进行内存管理、类型安全和 ORM 支持。 使用 Rust 和 Tauri 时面临的一些挑战包括: 难以理解 Rust 的所有权和借用规则、其严格的类型安全有时会限制开发速…...
变量 varablie 声明- Rust 变量 let mut 声明与 C/C++ 变量声明对比分析
一、变量声明设计:let 与 mut 的哲学解析 Rust 采用 let 声明变量并通过 mut 显式标记可变性,这种设计体现了语言的核心哲学。以下是深度解析: 1.1 设计理念剖析 安全优先原则:默认不可变强制开发者明确声明意图 let x 5; …...
简易版抽奖活动的设计技术方案
1.前言 本技术方案旨在设计一套完整且可靠的抽奖活动逻辑,确保抽奖活动能够公平、公正、公开地进行,同时满足高并发访问、数据安全存储与高效处理等需求,为用户提供流畅的抽奖体验,助力业务顺利开展。本方案将涵盖抽奖活动的整体架构设计、核心流程逻辑、关键功能实现以及…...
Golang 面试经典题:map 的 key 可以是什么类型?哪些不可以?
Golang 面试经典题:map 的 key 可以是什么类型?哪些不可以? 在 Golang 的面试中,map 类型的使用是一个常见的考点,其中对 key 类型的合法性 是一道常被提及的基础却很容易被忽视的问题。本文将带你深入理解 Golang 中…...
前端倒计时误差!
提示:记录工作中遇到的需求及解决办法 文章目录 前言一、误差从何而来?二、五大解决方案1. 动态校准法(基础版)2. Web Worker 计时3. 服务器时间同步4. Performance API 高精度计时5. 页面可见性API优化三、生产环境最佳实践四、终极解决方案架构前言 前几天听说公司某个项…...
相机Camera日志分析之三十一:高通Camx HAL十种流程基础分析关键字汇总(后续持续更新中)
【关注我,后续持续新增专题博文,谢谢!!!】 上一篇我们讲了:有对最普通的场景进行各个日志注释讲解,但相机场景太多,日志差异也巨大。后面将展示各种场景下的日志。 通过notepad++打开场景下的日志,通过下列分类关键字搜索,即可清晰的分析不同场景的相机运行流程差异…...
Java入门学习详细版(一)
大家好,Java 学习是一个系统学习的过程,核心原则就是“理论 实践 坚持”,并且需循序渐进,不可过于着急,本篇文章推出的这份详细入门学习资料将带大家从零基础开始,逐步掌握 Java 的核心概念和编程技能。 …...
laravel8+vue3.0+element-plus搭建方法
创建 laravel8 项目 composer create-project --prefer-dist laravel/laravel laravel8 8.* 安装 laravel/ui composer require laravel/ui 修改 package.json 文件 "devDependencies": {"vue/compiler-sfc": "^3.0.7","axios": …...
Python ROS2【机器人中间件框架】 简介
销量过万TEEIS德国护膝夏天用薄款 优惠券冠生园 百花蜂蜜428g 挤压瓶纯蜂蜜巨奇严选 鞋子除臭剂360ml 多芬身体磨砂膏280g健70%-75%酒精消毒棉片湿巾1418cm 80片/袋3袋大包清洁食品用消毒 优惠券AIMORNY52朵红玫瑰永生香皂花同城配送非鲜花七夕情人节生日礼物送女友 热卖妙洁棉…...
Linux 内存管理实战精讲:核心原理与面试常考点全解析
Linux 内存管理实战精讲:核心原理与面试常考点全解析 Linux 内核内存管理是系统设计中最复杂但也最核心的模块之一。它不仅支撑着虚拟内存机制、物理内存分配、进程隔离与资源复用,还直接决定系统运行的性能与稳定性。无论你是嵌入式开发者、内核调试工…...
代码规范和架构【立芯理论一】(2025.06.08)
1、代码规范的目标 代码简洁精炼、美观,可持续性好高效率高复用,可移植性好高内聚,低耦合没有冗余规范性,代码有规可循,可以看出自己当时的思考过程特殊排版,特殊语法,特殊指令,必须…...
