在网络软件开发中, 不得不做的一件事是解析和处理从网络上接收到的报文. 一些标准的应用层协议比如 HTTP 等已经有各种开源的可复用的解析器, 但更多的是各种自定义的内部协议, 又或者你本身就要实现某种标准的或行业的协议, 两种情况下你都需要自己写代码来解析和处理报文.
这类软件历史悠久, 按理应该有比较成熟的设计模式. 但在最近几年接触到的几个项目中, 发现实现这些功能的代码依然缺乏必要的设计. 这些代码都是国内著名电信供应商的项目, 因此觉得有必要开始这方面的讨论. 下面两个模式是相对基础和容易想到的两个模式, 算是一个开始.
Navigator Pattern, 导航者模式
模式名称
- Navigator/ 导航者
意图
- 封装报文数据复杂的内部结构, 通过提供有业务含义的寻址操作来避免危险的指针运算, 以减少重复和出错的可能, 并提供更清晰的业务意义
动机
在网络通信软件的开发中, 为了传输效率或完整性的考虑, 通常在应用层协议的定义中, 一次可以发送多个单位的净荷数据, 其具体数量可用报文头中的某个字段来描述. 另外一些时候报文体的长度是不定的, 通常也用报文头中的某个字段来表示实际的报文多长.
而此类软件通常以 C 语言开发完成. 经典的实现方案是为报文定义如下的数据结构, 并以指针运算来寻址特定的数据. 而当数据结构有嵌套时, 其指针运算将变的异常复杂和易错:
typedef struct SecondLevelPayload { int field_1; char field_2; float field_3; } SecondLevelPayload; typedef struct TopLevelPayload { int some_top_level_field; int second_level_payload_count; SecondLevelPayload* payload //payload 之间有嵌套 } TopLevelPayload; typedef struct Message { int count; TopLevelPayload* payload; } Message; void print_datagram(Message* message) { TopLevelPayload* current = message->payload; for(int i = 0; i < message->count; i++) { PrintTopLevelPayload(current++); //or PrintTopLevelPayload(message->payload[i]); } }
上面最后的函数试图循环打印所有 TopLevelPayload, 但寻址方式却是错的, 因为第二个 TopLevelPayload 的地址并不是第一个的地址加上其自身结构体的长度. 这里的症结在于 C 语言缺乏描述动态集合的设施, 只有指向首地址的指针, 而指针的大小和其指向的数据的大小是不同的. 又因为数据流是连续的, 因此据此定义的结构体的大小和实际数据的大小是不一致的. 初步的改正如下:
void print_datagram(Message* message) { TopLevelPayload* current = message->payload; for(int i = 0; i < message->count; i++) { PrintTopLevelPayload(current); current = (TopLevelPayload*)((char*)current + sizeof(TopLevelPayload) + current->second_level_payload_count * sizeof(SecondLevelPayload)); } }
这样代码就变得晦涩, 看不出意图. 而指针运算容易出错, 且当其它代码需要在报文内部寻址的时候需要重复一遍代码来再算一次, 当报文协议 / 结构体定义变化的时候, 需要检查所有现存的指针运算看是否还合适. 我们需要更好的设计.
方案
这里的问题是寻址. 而现实生活中, 当我们需要去某个地址的时候, 我们借助导航. 它可以是一部仪器, 也可以是熟悉当地环境的路人. 但接口是一致的: 我们只需要告诉他我们要去哪, 不需要提前了解地形. 在 C 语言中, 它可以是围绕着报文首地址指针提供的一组有业务含义的接口函数:
TopLevelPayload* goto_nth_toplevel_payload(Message* message, int nth_toplevel_payload) { TopLevelPayload* addr = message->payload; for(int i = 0; i < nth_toplevel_payload; i++) { addr = (TopLevelPayload*)((char*)addr + sizeof_toplevel_payload(addr)); } return addr; } SecondLevelPayload* goto_nth_secondlevel_payload(TopLevelPayload* top, int nth_secondlevel_payload) { return top->payload + nth_secondlevel_payload; } static int sizeof_toplevel_payload(TopLevelPayload* payload) { return sizeof(TopLevelPayload) + payload->second_level_payload_count * sizeof(SecondLevelPayload); }
这样, 通过报文首地址和 goto_nth_toplevel_payload(), goto_nth_secondlevel_payload() 两个函数, 客户代码就可以在报文体中任意巡航, 而无需理会其内部表示, 无需涉及易错和晦涩的指针运算. 当报文协议变化时, 我们也只需要修改 navigator, 无需修改客户代码.
相关模式
Page Object 模式描述了在 web 应用测试领域针对易变的 web 页面进行封装的方法, 其中也涉及对页面不同元素的导航. 其解决的主要问题是减少相对频繁的页面变化对测试代码的稳定性造成的冲击, 并更清晰的描述测试意图.
SAD Pattern: Simple API for Datagram
模式名称
- SAD, Simple API for Datagram
意图
- 分离网络报文的解析和处理, 使解析代码和处理代码不再耦合在一起, 便于扩展. 类似 SAX(Simple API for XML) 将 XML 文档的解析和处理分离到不同的单元中
动机
在网络通信软件的开发中, 经常要处理网络上接收到的各种数据报文. 而收到某种报文后, 需要进行的处理逻辑上可能不止一件事情. 处理过程中会用到报文中的数据, 因此需要对报文进行解析. 而报文的结构通常存在动态部分, 而在 C 语言中, 无法定义一个数据结构可以直接将报文映射到该结构. 一个例子参见前面的 Navigator 模式中定义的报文结构.
缺乏考虑的做法通常会把解析和处理放在一起, 一个大函数, 用局部变量甚至全局变量来保存解析出来的数据, 并对其进行各种处理. 这样做的问题是:
- 难以扩展: 当需要增加新的处理时, 需要在解析过程中多个地方插入处理代码
- 难以理解: 不同的处理代码混在一起, 和报文解析的逻辑也混在一起, 难以看清楚真正做了什么事
- 容易出错: 不同的处理共享解析出来的数据, 容易互相影响, 引入错误
另外一种常见的做法是每种不同的处理单独去解析自己需要的内容. 这种方式相对内聚, 但需要解析多遍报文结构, 解析代码也有重复
我们需要更好的设计.
方案
SAX 以事件驱动的方式分离了 XML 文档的解析和处理. 我们可以借鉴. 报文有内部结构, 我们可以使用 Navigator 模式遍历其内部结构, 并在每一个独立的净荷开始和结束时触发回调, 而对报文内容的各种处理可以以回调函数的形式注册到解析过程中, 为每种处理编写单独的回调函数.
例如, 对于 Navigator 模式中定义的报文结构, 可以定义如下的 API:
typedef void (*MessageHandler)(Message*); typedef void (*TopLevelPayloadHandler)(TopLevelPayload*); typedef void (*SecondLevelPayloadHandler)(SecondLevelPayload*); typedef struct Handler { MessageHandler start_handle_message; MessageHandler end_handle_message; TopLevelPayloadHandler start_handle_toplevel_payload; TopLevelPayloadHandler end_handle_toplevel_payload; SecondLevelPayloadHandler start_handle_secondlevel_payload; SecondLevelPayloadHandler end_handle_secondlevel_payload; } Handler; void parse(Message* message, Handler* handlers, int handler_count) { for(int i=0; i < handler_count; i++) { handlers[i]->start_handle_message(message); } // 遍历 Message 内部嵌套的 payload, 并调用对应的 handler, 比如: //handlers[i]->start_handle_toplevel_payload(toplevel_payload_pointer); //handlers[i]->end_handle_toplevel_payload(toplevel_payload_pointer); for(int i=0; i < handler_count; i++) { handlers[i]->end_handle_message(message); } }
而每种不同的处理, 只需提供自己的 handler 即可. 比如可以有打印报文内容的 handler, 有根据报文操作硬件的 handler, 有持久化报文数据的 handler 等:
Handler handlers[3] = { DataPrinter, HardwareManipulator, DataPersister}; parse(message, handlers, sizeof(handlers)/sizeof(handlers[0]));
效果
- 报文解析和报文处理的代码彻底分开, 不再纠缠在一起
- 可以很容易的扩展新的报文处理逻辑
- 报文只需解析一遍
- 其约束在于不同的 handler 之间不应该有依赖
相关模式
- SAX 是处理 XML 的一种类似的模式, 但其最初的出发点是源于 DOM 的性能太差, 不过它也有分离解析和处理的效果
- Visitor 模式用于在不改变层次结构的情况下增加对这个层次结构的处理, 并且自动分发正确的处理到正确的节点. 它客观上也分离了数据的解析和对数据的处理.
感谢张凯峰对本文的审校。
给InfoQ 中文站投稿或者参与内容翻译工作,请邮件至 editors@cn.infoq.com 。也欢迎大家通过新浪微博( @InfoQ )或者腾讯微博( @InfoQ )关注我们,并与我们的编辑和其他读者朋友交流。
评论