hi_rtp 半年再造记 —— 一次从"肉眼+printf"到"合成流+CRC"的测试观演化
一、为什么写这篇
工程师写代码,绝大多数时候是在写公司的代码 —— 别人定的需求、别人选的框架、别人画的边界。
但每个工程师心里,总有那么一两件事,是想留给自己业余时间去做的。
我自己 2020 年底就动手了这么一件事。叫 hi_rtp。
它是一个从零自己写的流媒体服务,输入是实时 RTP 视频流,输出是连续图片。功能上跟当时我在公司用着的一个第三方组件几乎一样 —— 但每一行代码我自己敲。从 2020 年 11 月 9 日第一个 commit,到 2021 年 4 月 17 日最后一个 commit(refactoring by effective modern c++),半年时间断断续续做下来。
这篇不打算只讲"我做了什么"。我想顺着 hi_rtp 这件事,讲一个更值得讲的部分 —— 在这半年里,我对"什么是好测试"的认知,完整地演化了一次。
这次演化的起点是"肉眼 + printf",终点是"合成流 + CRC 自动断言",中间隔着我后来接触到 Netflix / YouTube / FFmpeg FATE 时的那次"豁然开朗"。这次演化我把它原原本本记录在了项目里(docs/testing-discussion.md),所以这篇不是事后追溯,是有时间地层化石的认知迭代实录。
二、起点:2020.11.09 的第一行代码
那段时间,我在公司接手运营一套外购的实时音视频平台。这套平台原厂提供了一个组件,专门把实时音视频流转成连续的图片,供下游业务方使用。我天天用它,用得很顺,但我不"懂"它。
具体讲,它内部那些东西 —— RTP 包怎么拆、H264 解码栈怎么接、PTS / DTS 怎么对齐、跨进程的图像数据怎么交给下游 —— 我都能模糊讲出大概,但都讲不到我对"懂"的最低标准。
我对"懂"的最低标准是 —— 我能从零自己写一份。
读源码不够。装一遍不够。讲架构不够。只有自己再写一份,才算懂。
2020 年 11 月 9 日,我开了 gitee 仓库 hi_rtp,打下第一行代码。
为什么是 11 月?因为前 6 个月我都在忙这套平台的运营、一键部署、监控扩展、跟下游业务方对接。这些事告一段落了,我心里那件"再写一遍"的事终于轮到了。
第一行代码不是 RTP 拆包,也不是 H264 解码 —— 是 git init。
听起来无聊,但这一步意义不小:它把"我业余想写一个流媒体服务"从念头,变成了硬盘上一个真实的、有 git 时间戳的、可以累积的项目。
念头是廉价的。第一行代码是分水岭。
三、半年时间线:7 模块,从 RTP 拆包到完整链路打通
接下来的半年,断断续续写下去。最后这个项目的结构变成了 7 个模块(architecture.md 491 行,有完整图):
- Network 层:基于 libuv 的 reactor 模式,处理 UDP socket
- RTP 层:按 RFC 3550 + RFC 6184 拆包,组帧
- Decode 层:接 H264 解码栈
- SharedMemory 层:mmap + POSIX 信号量,跨进程零拷贝输出
- 跨语言 IPC:C++ 写入端 + Python
posix_ipc读取端,跨语言协议明确定义 - Test 层:gtest 框架(具体怎么用,下一节展开)
- Docs 层:architecture.md / testing-discussion.md(这两份后来比代码本身更有价值)
7 种设计模式落地在其中:Strategy / Observer / Factory / Singleton / RAII / Reactor / Bridge —— 不是为了用模式而用模式,是这套架构本身需要它们,自然就用上了。
写到后面,那个我天天用的第三方组件,在我脑子里从"模糊的功能轮廓"变成了"清晰的实现路径"。再回去看它,我能讲出每一个数据流的去向、每一个抽象边界的取舍、每一次性能权衡的代价。
这就是"自己再写一遍"最直接的收益 —— 它把"用着"和"懂着"那条鸿沟,在我脑子里关掉了。
但这只是收益的一半。另一半收益,来自测试这件事。
四、当时的测试设计:肉眼 + printf + 主动跳过
hi_rtp 写到一半的时候,我开始搭测试。
但有意思的事情发生了 —— 我写的"测试"跟主流单元测试社区的样子,看起来完全不一样。
具体讲,如果你今天打开 hi_rtp/test/,你会看到这些东西:
HiRTPParserTest.cpp里 5 个TEST(),有 4 个第一行就是return;HiRTPDecordeTest.cpp唯一的TEST()第一行也是return;HiShareMemoryTest.cpp的TEST()里是while(true)死循环- 大量
printf/cout输出,ASSERT_*用得极少 UdpSocketWrapperTest的断言写在 libuv 异步回调里
如果你是一个"断言驱动派"的工程师,你看到这些会觉得 —— 这是没写完的、跳过了的、不合格的测试。
但这正是我当时的有意设计。我把这个设计的理由,原原本本写进了 docs/testing-discussion.md:
"这个单元测试结果是使用肉眼看,而不是通过断言等判断,这是这个项目特点决定的,也是我有意设计的。"
理由有 4 条:
- 视频解码的产物是图像,图像质量无法用数值断言衡量。解码后是否有花屏、偏色、丢帧,只能通过人眼对比判断
printf输出是有效的验证手段。运行时输出 seq(序列号)、frameTime(帧时间戳),人眼观察是否连续、是否有异常跳变,能发现很多问题- gtest 的角色是测试组织框架,不是断言驱动引擎。
TEST()提供命名、分组、顺序执行能力;ASSERT_*仅在纯逻辑边界条件(对象池计数、文件夹不存在返回值)适用 return;跳过的测试是因为这些测试依赖特定外部视频源,源不可达时主动跳过,等环境恢复后可继续使用
这是一种继承自 Live555 的测试范式。Live555 是流媒体领域的祖师级开源项目(RTSP / RTP 服务端最经典实现之一),它的测试方式就是 printf + 自己查看结果 —— 不是因为它落后,是因为这个领域的产物本身没法用数值断言。
把这一段独立讲清楚:hi_rtp 当时用肉眼 + printf,不是"实操跟不上",是按视频/流媒体领域的特点做的有意选择。
五、后来:撞见了 Netflix / YouTube / FFmpeg FATE
hi_rtp 收尾之后,我在工作中接触到的业务形态发生了变化,开始更深入地碰流媒体这条线。
也就是在这段时间,我第一次接触到一种和我 hi_rtp 时代完全不一样的"流媒体测试观":
Netflix:预编码的测试 bitstream + CRC/hash 自动比对
Netflix 的做法是,预先构造好一组测试 bitstream,每一段都已经知道正确解码出来应该是什么样(用 CRC 或 hash 记下来)。任何一个解码器对着这些 bitstream 跑一遍,输出帧的 CRC / hash 跟预期对一下,自动化判定。
YouTube/Google:VP9/AV1 解码器一致性测试
同样的思路,用已知 reference bitstream 做解码器一致性测试。任何一个 VP9 / AV1 解码器,都得能解出跟 reference 完全一致的输出 —— 逐像素一致。
FFmpeg FATE(FFmpeg Automated Testing Environment)
开源世界版本的同一种思路:用确定性测试向量,做解码回归测试。每次 commit 跑一遍,任何回归立刻报警。
这三种实践背后,是同一个核心思路:
用合成视频流(如彩条、特定颜色图案)代替真实视频流作为测试输入。
合成流的每一帧像素值是预先确定的,解码输出可以逐像素比对,从而做到自动化断言。
我第一次看到这个思路的时候,心里咯噔一下。
我之前那么笃定"视频测试只能肉眼看"的判断,被打开了一道门。
不是说我之前错了。是我之前没看到这个维度。
六、不是推翻,是补全 —— 两种方式的关系
回去看 hi_rtp 的 testing-discussion.md,我加了一节,把这件事彻底想清楚:
| 维度 | 肉眼验证 | 确定性流断言 |
|---|---|---|
| 测试输入 | 真实视频流(RTSP/RTMP) | 合成测试 bitstream |
| 验证方式 | 人眼观察 BMP 输出 | CRC/hash 自动比对 |
| 能发现的问题 | 花屏、偏色、丢帧、卡顿 | 解码回归、像素级偏差 |
| 自动化 | 否,需人工 | 是,可入 CI |
| 适用阶段 | 端到端质量评估、调试 | 解码器回归测试 |
| 依赖 | 需外部视频源可达 | 需预构造测试向量 |
两种方式不是替代关系,是互补关系:
- 确定性流断言保证解码器不退化,适合每次提交自动跑
- 肉眼验证保证真实场景的图像质量,适合发版前人工确认
如果今天让我重写 hi_rtp 的测试,我不会推翻当时那一套。我会在它的基础上,新加一组合成流 + CRC 比对的自动化用例。
旧的那一套该跑还是跑(端到端质量靠它),新的那一套补全 CI 维度(解码器回归靠它)。
两条腿走路,而不是非此即彼。
七、三条教训 —— 比代码本身更值钱的部分
这件事过去几年了,我现在能从里面提炼出来的认知,有三条:
第一条:领域特点决定测试策略,不能用断言覆盖率作为统一标尺
视频 / 图像处理的测试方法论,跟业务逻辑的测试方法论,根本不在一个评价体系里。如果你在视频领域看到大量 printf 没断言,先别急着喷,先想想为什么 —— 大概率是有领域原因的。同理,如果你在业务逻辑领域看到全是肉眼测试,那就该喷了,因为业务逻辑完全有条件做自动化断言。
第二条:测试策略有演进路径,初期肉眼 / 中期补打点 / 后期上合成流是合理节奏
不要追求"一上来就把测试做完美"。项目初期用肉眼 + printf 快速起步是合理的;随着对行业实践的了解加深,逐步引入合成流做自动化断言。一上来就追求"完美测试",大概率连主功能都跑不通就死了。
第三条:"当时没有意识到"是常态,后续认知升级不否定前序选择的合理性
我后来知道 Netflix / YouTube / FFmpeg FATE 那种打法,但我不后悔当时用肉眼 + printf。当时那个选择,是基于当时的认知和资源做的,是合理的。后续看到更广的视野,只是在已有的合理选择之上做加法,而不是推翻。
这条第三的认知,不只是测试观,是整个职业生涯的认知观。
凡是后悔"当时太蠢"的念头,大概率都是用今天的视野去评价当年的选择。不公平,也不必要。
八、testing-discussion.md —— 我现在最珍惜的那份文档
讲完这三条,我想最后讲一件事 —— 为什么我把这次认知演化写成 testing-discussion.md 而不是只在脑子里想想。
答案很简单:
写下来,是把它钉住。
如果不写下来,这些认知只在脑子里飘 —— 半年后可能就模糊了,一年后可能就忘了,五年后回头看,自己都不知道自己当年想过什么。
docs/testing-discussion.md 这份文档,它的内部结构是 3 层时间地层:
- 当时的设计(2020-2021,肉眼 + printf 是有意设计)
- 后来的认知升级(2021.10+ 接触 Netflix/YouTube/FFmpeg FATE 那一刻)
- 成熟的判断(两种方式不是替代,是互补)
我后来回头看,这种"时间地层"结构,比代码本身更有价值。
代码会过时,但"我对一件事的认知是怎么一步步变化的"这件事,只要被诚实记录下来,就会变成永恒的资产。
我以后还会写更多 testing-discussion.md。
不只是测试。也写架构决策的演化、写技术选型的演化、写"我曾经笃定的东西是怎么被新的视野打开的"那种瞬间。
因为我相信,工程师真正稀缺的不是"现在的认知",是"诚实记录认知演化"这件事。
九、最后
hi_rtp 这个项目,严格来讲已经"死了" —— 2021 年 4 月之后基本没动过,gitee 上的 star 数寥寥无几,没人 fork 我,也没人 issue 我。
按工程界的成功标准,它不能算成功的项目。
但它对我个人的价值,是任何一个"成功的项目"都给不了我的:
- 它让那个我天天用的第三方组件,在我脑子里从"模糊轮廓"变成"清晰实现"
- 它让我从零跑通了"RTP → 解码 → 共享内存 → 跨进程输出"的全链路
- 它让我有了一个 491 行的 architecture.md,半年后还能自己看明白
- 它让我经历了一次完整的"测试观演化",并把演化过程钉在了 testing-discussion.md 里
- 它让我后来再看任何一个流媒体项目、任何一种测试方法、任何一种工程选择,都有了一份"我自己写过、我自己想过、我自己迭代过"的判断底气
一个项目的真正价值,不在于它跑得多久、被多少人用、有多少 star。
在于它在你脑子里留下的清晰度,和你为了得到它所付出的诚实。
附:hi_rtp 项目仍在 gitee 公开:https://gitee.com/hi_rtp/hi_rtp —— 如果你对 RTP / H264 / libuv reactor / 跨语言 IPC 这些感兴趣,可以从 architecture.md 入手;如果你对"工程师怎么记录自己的认知演化"感兴趣,直接看 docs/testing-discussion.md。
这两份文档,是我自己最珍惜的部分。
浙公网安备 33010602011771号