马上注册,结交更多好友,享用更多功能,让你轻松玩转社区。
您需要 登录 才可以下载或查看,没有账号?站点注册
×
PBIDEA:用 uo_compress 做内存级压缩全解 —— zlib / deflate / gzip / snappy 与 zip 条目的真实边界(PB10 基准 · PB12.5 实测)
阅读说明
1. 适用版本:PBIDEA(核验快照见下方【版本声明】),PowerBuilder 8 及以上通用。本文示例按 PB10 语法书写,并在 PB 10 与 PB 12.5 两版实机跑过,两版读数逐字相同。
2. 支持数据库:不涉及。uo_compress 是纯内存字节处理,不需要任何数据库连接。
3. 操作系统与环境要求:Windows XP 及以上;需部署 PBIDEA 运行库(PbIdea.dll,以及 Pbidea_cs.dll);uo_compress 声明在 websuite.pbl,因此 PBL 库列表里要挂上它;7z 文件级两个函数另需外部 7z 组件(本文如实标注了本机实测结果)。
4. 难度系数:★★★☆☆(会 blob 与 ToUTF8 就能读懂;难点在「压缩类型的字节差异」和「解压失败不报错」这两处反直觉行为)。
5. 其它阅读说明:文中所有读数都来自实机探针,探针源码与文章主线示例一同打包在回帖附件里,可直接导入 PB10 复跑。建议先看第二节的字节表再动手,能省掉一半调试时间。 【版本声明】
- 本文以 PBIDEA 当前最新版本为准(版本源由维护方统一核验),并对照随版发布的源码整理。
- 核验日期:2026-10-25;当次核验的 PBIDEA 核心运行库:18,132,480 字节 / 2572 个导出,md5 54e4b9244973…。当次核验的导出源码共 575 个文件、GBK 编码、无 BOM(版本源与导出目录由维护方统一核验,不在文中展开)。
- 版本识别口径:逐项解析 PE 导出表(文件体积、目录名都不能作为判据)。
- 适用平台:Windows XP 及以上(XP / 7 / 10 / 11 均可);PowerBuilder 8 及以上(PB 8/9/10/10.5/11.x/12.x 及以上通用)。本文的实机验证环境为 Windows 10 + PB 10 与 PB 12.5。
- PBIDEA 版本会持续更新,本文只对上述核验时点的最新版本负责;后续若行为有变,以新版为准。
一、先弄清 uo_compress 在体系里的位置
uo_compress 是一个 nonvisualobject,声明在 websuite.pbl,一共 15 条有效声明,按用途分成四组:
| 组 | 方法 | 说明 | | zlib 流 | zCompress(blob, int nLevel)、zCompress(blob, int nLevel, int compressType) | 压缩,返回 blob;compressType 0=deflate、1=zlib、2=gzip | | zlib 流 | zUncompress(blob)、zUncompress(blob, int compressType) | 解压,返回 blob | | gzip 便捷 | gzcompress(blob)、gzuncompress(blob) | 等价于 compressType=2 的固定档 | | snappy | snappyCompress(blob)、snappyCompress(blob, ref boolean)、snappyUncompress(blob)、snappyUncompress(blob, ref boolean) | 高速低压缩比,带 success 出参的重载才好判成败 | | zip 条目 | CompressEntry(string fileName, blob data)、DecompressEntry(blob, ref string fileName) | 造/读单个 ZIP 条目(local header + deflate 数据) | | 7z 文件 | SevenZipAddFile(文件, 7z路径)、SevenZipAExtract(7z路径, 输出目录) | 文件级压缩/解压,依赖外部 7z 组件 | | 杂项 | Printf(string, ...) | 往控制台打印,做命令行小工具时用 |
这里最容易混的是和 uo_zip / uo_7z 的分工。一句话:uo_zip 管「把很多文件打成一个 .zip 包」,uo_compress 管「把一段字节压小」。前者是容器(要写中央目录、要维护条目列表),后者是压缩流引擎;CompressEntry 正好卡在中间——它只生成一条 zip 条目,你自己拼包时才用得上。已经写过文件打包的读者,这篇补的正是底层那一层。
二、三种 compressType 的真实字节
同一段 11000 字节的 ASCII 文本,三种类型压出来的体积和头字节完全不一样:
| compressType | 名称 | 体积 | 头部字节(前 8 字节 hex) | 说明 | | 0 | deflate(裸流) | 108 | edcbc91182401000 | 没有文件头,直接从压缩数据开始 | | 1 | zlib | 114 | 789cedcbc9118240 | 2 字节 78 9C 头 + 4 字节 Adler-32 尾 | | 2 | gzip | 126 | 1f8b080000000000 | 10 字节头 + 8 字节尾(CRC32 + 原始长度) |
三条规律值得记下来:
- 裸 deflate 最小,但它也最麻烦——没有任何魔数,你无法从字节判断「这是什么格式」,解压时必须自己知道它是什么。
- zlib 和 gzip 的前 6 个数据字节一模一样(edcbc9118240),差别只在头部封装。所以「压缩结果看起来差不多」是对的,体积差 8~12 字节完全是头部开销。
- gzcompress() 与 zCompress(data, nLevel, 2) 字节完全相同(实测相等),它只是省掉一个参数的便捷重载,不是另一套算法。
gzip 头解析出来是这样的:1f 8b | 08 | 00 | 00 00 00 00 | 00 | 0a,即 ID1/ID2、CM=8(deflate)、FLG=0(无文件名、无注释)、MTIME 全 0、XFL=0、OS=0x0A。注意 MTIME 恒为 0——同一段数据每次压出来都一样,这点对「压缩结果做指纹/去重」很关键(Python 的 gzip.compress 默认会写当前时间,要加 mtime=0 才可比)。
三、解压必须配对类型,否则静默返回空
这是本篇最值钱的一条。zUncompress 有单参和双参两个重载,实测矩阵如下(源数据均 11000 字节,读数格式为「输出字节数, 是否等于原文」,空格子已逐项跑过,不是推测):
| 输入数据 | zUncompress(d) 单参 | zUncompress(d, 0) | zUncompress(d, 1) | zUncompress(d, 2) | | type 0(裸 deflate) | 0, false | 11000, true | 0, false | 0, false | | type 1(zlib) | 11000, true | 0, false | 11000, true | 11000, true | | type 2(gzip) | 11000, true | 0, false | 11000, true | 11000, true |
三个结论:
- 单参重载不是「自动识别一切」:它认得 zlib 与 gzip,但认不出裸 deflate,返回 0 字节、不抛异常、不报错。你要是把 compressType=0 的数据拿去 zUncompress(d),只会得到一个「解压成功但内容为空」的假象。
- 类型 1 与类型 2 完全互通:zlib 数据用 gzip 类型能解,gzip 数据用 zlib 类型也能解(实测两个方向都是 11000, true)。因为两者内部都是同一套 deflate 数据,只有头部封装不同,解压器对头部是宽容的。
- 类型 0 是孤岛:它只认自己,type 1 与 type 2 都解不开它,它也解不开别人。全部失败形态都是返回长度 0 的 blob,没有任何错误码可查(这两个方法都没有 ref 出参或返回值)。
所以工程上的铁规矩是:压缩时用什么类型,解压时必须用同一个类型,并把 compressType 当成协议字段随数据一起存。存文件也好、走网络也好,别只存压缩后的字节。
另外还有一条更隐蔽的:非法的 compressType 会静默回落成 zlib。实测传 3、-1、99 三种非法值,输出与 compressType=1 字节完全相同,不报错、不警告。这意味着「参数写错」和「参数写对」在你眼里没有区别,只有体积和头部字节能露馅。
四、nLevel 参数:实机证明它被忽略
几乎所有 zlib 封装都会把「压缩级别 0~9」当卖点,但 uo_compress 的这个参数在本机实测中没有任何效果,两组数据都验证过:
| 测试数据 | L0 | L1 | L3 | L6 | L9 | L-1 | L10 | | 11000 字节重复文本 | 114 | 114 | 114 | 114 | 114 | 114 | 114 | | 78207 字节混合文本 | 6058 | 6058 | 6058 | 6058 | 6058 | — | — |
九档全部同尺寸,连 -1(zlib 的默认档)和越界的 10 也是同一个结果。进一步的证据在互通测试里:PB 压出来的 zlib 字节,与 Python zlib.compress(src, 6) 逐字节相同,而与 zlib.compress(src, 9) 不同。也就是说这版内部固定按 6 档压缩,nLevel 收下了但没往下传。
对你的影响很实际:
- 别指望「把级别调到 9 再压」能省体积,调了也不会变;
- 反过来也别担心「级别 0 会不压缩」——它照样压,只是白填一个参数;
- 如果你真的需要 9 档或 1 档的速度/体积权衡,只能在 PB 里调用外部 zlib(比如自己 Declare 外部函数)或交给服务端。
五、边界数据:空、1 字节、随机、1MB
| 原始数据 | 压缩后(zlib 档) | 往返还原 | | 0 字节(空 blob) | 8 字节 | true | | 1 字节 A | 9 字节 | true | | 65536 字节随机 | 65562 字节(+26) | true | | 1048576 字节文本 | 3133 字节 | true |
几个能直接用的判断:
- 空数据也会产出 8 字节(zlib 空流 78 9C 03 00 00 00 00 01)。所以「压缩结果非空」不能证明「原始数据非空」,判断空必须看原始长度。
- 不可压缩数据会变大:65536 字节随机数压完 65562,多出 26 字节(约 0.04%)。所以「先压再说」不能无脑用在小块二进制上,尤其是已经压过的 jpg / zip / exe。
- 压缩率取决于重复度:1MB 同构文本压到 3133 字节(约 0.3%),是因为全文都是同一行模板的重复。
- 耗时(1MB 文本,Cpu() 读数,粗粒度仅供参考):压缩约 16 ms、解压低于计时分辨率(0 ms)。Cpu() 精度有限,PB12.5 上量到 16,PB10 上量到 0,这个差异是计时抖动,不是两版性能差。
六、文本压缩前务必先 ToUTF8 —— blob(字符串) 是 UTF-16LE
这一节是最容易吃暗亏的地方。在 PB 里把字符串直接塞进 blob,走的是 UTF-16LE(每字符 2 字节,ASCII 也不例外)。实测两组读数:
| 内容 | Blob(ls) 长度 | ToUTF8(ls) 长度 | | 2 个汉字(中文) | 4 | 6 | | 4 个汉字(中文测试) | 8 | 12 |
差异在 1.5 倍以内还只是体积问题,真正的问题是跨语言不通用:你在 PB 里压一段 UTF-16LE,收到的 Python / Java 服务解压出来是一堆带零字节的乱码。正确姿势固定成三步:
- 压缩前:lb = ToUTF8(ls_text);
- 解压后:ls = FromUTF8(lb);
- 校验时比较 blob 而不是比较字符串(PB 支持 blob 用 = 直接比内容,实测可用)。
顺带一个反直觉读数:12 字节的中文压缩成 21 字节——小块数据因为 zlib 头部 + Adler-32 及 Huffman 表开销,压完反而变大。中文短串(几十字节以内)不建议压缩,直接存更省。
七、snappy:快、但压缩比明显输给 zlib
snappyCompress / snappyUncompress 是另一条路子,实测对比:
| 数据 | snappy | zlib(type 1) | 谁更小 | | 11000 字节重复文本 | 573 | 114 | zlib 胜,差 5 倍 | | 65536 字节随机 | 65542 | 65562 | snappy 略胜(少 20 字节) | | 0 字节 | 1(且 success=true) | 8 | snappy 胜 |
结论很朴素:文本/结构化数据用 zlib,几乎无重复的二进制用 snappy(省 20 字节不值得为它换算法,选 snappy 的真实理由是速度,不是体积)。
snappy 侧有三个必须知道的细节:
- 带 ref boolean success 的重载才可靠。不带出参的版本在空数据上也照返回 1 字节,你没法判断它是「压了个空」还是「出错了」。
- 空数据压出来是 1 字节,解压回 0 字节且 success=true——空进空出是正常路径,别误判成失败。
- 输出是标准 raw snappy block(varint 原始长度前缀 + tag 序列),我用 Python 读第一个 varint 得到 73,正好等于原始长度。也就是说它跟 python-snappy、Go 的 snappy 包是同一格式,可以互解。
八、zip 条目:CompressEntry / DecompressEntry
CompressEntry(文件名, 数据) 造出来的是一条标准 ZIP local file header + 数据(没有中央目录)。用工具拆开一个 69 字节的条目,结构如下:
| 字段 | 读数 | 说明 | | 签名 | 50 4B 03 04(PK\x03\x04) | 标准 local header | | 版本 | 20 | 解压需 2.0 | | 通用标志 | 0x0000 | 未置 UTF-8 名标志(bit 11) | | 压缩方法 | 8 | deflate | | CRC32 / 压缩长度 / 原始长度 | 一致校验通过 | CRC 与 zlib.crc32 对得上 | | 文件名长度 | 按 UTF-8 字节计 | 英文 10 字节(report.txt) |
英文名往返完全正常:DecompressEntry 回读文件名 report.txt、数据字节与原文逐字节相同、CRC 校验通过。中文名会坏,这是本节的重点:
- 条目里存的名字字节是 E6 8A A5 E8 A1 A8 2E 74 78 74——这是 报表.txt 的 UTF-8 编码;
- 但通用标志位是 0x0000,没有置 UTF-8 标志(bit 11);
- 于是 DecompressEntry 回读时按 ANSI(GBK) 去解这串 UTF-8 字节,得到 鎶ヨ〃.txt(7 个字符),与原文「报表.txt」(6 个字符)不相等——两版 PB 读数完全一致,不是版本问题。
所以在 PB 里用 CompressEntry 造条目,文件名请一律用 ASCII(report.txt、data_20261025.csv 这种),中文名要么转拼音/序号,要么自己在条目外另记一份「原始文件名 ↔ 条目名」的映射表。真要中文名,就整包交给 uo_zip(它处理过编码问题),别手工拼。
九、7z 文件级:本机实测结果与如实说明
SevenZipAddFile / SevenZipAExtract 依赖外部 7z 组件,本机(Windows 10,未装 7-Zip 命令行)实测:
| 调用 | 结果 | | SevenZipAddFile('in.txt','out.7z') | 返回 false,且不生成 .7z 文件(FileLength 为 -1) | | SevenZipAExtract('out.7z','dir') 对不存在的包 | 直接抛运行期错误 Error calling external function SevenZipAExtract,不是返回 false |
第二条尤其要留神:它不给你「失败」这个返回值,而是把整个调用打断。所以这两个函数务必先判文件存在,再调用;不要放在批处理循环里裸跑。
如实说明:本机没有 7z 组件,因此这两个函数的成功路径未实测,只验证了失败路径。需要文件级打包的话,uo_zip(ZIP)是我实测过可用的路线,7z 请在你的机器上按装好组件的环境自测。
十、坏数据:三种形态,一种最危险
解压坏数据不会抛异常,但返回值很有欺骗性:
| 输入 | 输出 | 危险度 | | 截断一半的 zlib 数据(49 字节) | 24 字节的半截明文 | ★★★ 最危险 | | 24 字节随机垃圾 | 0 字节 | ★ 能看出来 | | 0 字节空 blob | 0 字节 | 无 |
截断的那个才是真正的坑:它返回了 24 字节「看起来像数据」的内容,Len() 不为 0,你的代码很可能就把它当成功继续往下跑了。而「随机垃圾」和「空」反而因为返回 0 字节,容易被判定成失败。
因此完整性校验不能只看「返回非空」,至少要做两层:
- 长度比对:解压结果长度必须等于压缩前记录的原始长度;
- 内容比对:关键场景再比 CRC32(uo_blob 的 crc())或 MD5,或者干脆比对完整字节。
这也解释了为什么第三节强调「把 compressType 和原始长度一起存」——它们不是元数据装饰,是解压正确性的判据。
十一、与其他语言互通(可直接照抄的换算表)
| 需求 | PB 侧写法 | 其他语言侧 | | zlib 互解 | zCompress(d, 6, 1) / zUncompress(d, 1) | Python zlib.compress(d, 6) / zlib.decompress(d);字节完全相同 | | gzip 互解 | zCompress(d, 6, 2) / gzcompress(d) | Python gzip.decompress(d) 可解 PB 的产物 | | gzip 头差异 | OS 字节为 0x0A | Python gzip.compress(mtime=0) 的 OS 是 0xFF,只有第 10 个字节不同,压缩数据体与尾部 CRC/长度逐字节相同 | | snappy 互解 | snappyCompress(d) | 标准 raw snappy block,snappy.decompress() 可直接用 | | 反向(别人压、PB 解) | zUncompress(d, 1) / zUncompress(d, 2) | Python 的 zlib.compress 与 gzip.compress(mtime=0) 产物,PB 侧均已实测还原成功 |
十二、PB10 与 PB12.5 的差异
行为一致,零差异。 本文 25 组探针在两版上逐条比对了输出文本:p00~p13b 全部读数逐字相同(含空数据、随机数据、中文名乱码形态 鎶ヨ〃.txt、7z 失败路径的异常文本、以及第三节那张 3×4 的错配矩阵)。唯一不同的一项是第五节的计时读数(PB12.5 量到 16 ms、PB10 量到 0 ms),属 Cpu() 分辨率抖动,不是版本行为差异。
运行环境差异只有一处需要留意:PB10 与 PB12.5 要挂各自的 websuite.pbl 与各自的 PbIdea.dll(PB10 用 10 目录那份、PB12.5 用 12.5 目录那份),混挂会报外部对象不可解析。
十三、坑清单(一条一条对着查)
| # | 坑 | 正确做法 | | 1 | 用单参 zUncompress 解裸 deflate,得到空 blob 却以为成功 | 类型必须配对,compressType 随数据一起存 | | 2 | 类型错配返回 0 字节、无任何错误码 | 只能靠「原始长度 + CRC」自校验 | | 3 | 非法 compressType(3 / -1 / 99)静默回落成 zlib | 参数写死常量,别算 | | 4 | 以为 nLevel 越大压得越小 | 本版固定 6 档,nLevel 无效 | | 5 | 中文名进 CompressEntry 后回读乱码 | 条目名用 ASCII,中文另行映射 | | 6 | 空数据压出来 8 字节,误判「原始非空」 | 判空看原始长度 | | 7 | 小块中文压缩后反而变大(12→21) | 小于几十字节的数据不压 | | 8 | 直接压 Blob(字符串)(UTF-16LE),跨语言乱码 | 压前 ToUTF8,解后 FromUTF8 | | 9 | 截断数据解压出半截内容被当成功 | 长度比对 + CRC 校验 | | 10 | 无参 snappyCompress 无法判成败 | 用带 ref boolean success 的重载 | | 11 | SevenZipAExtract 对不存在的包直接抛错 | 先 FileExists 再调用 | | 12 | PB10 挂 PB12.5 的 websuite.pbl / PbIdea.dll | 两版各挂各的 |
十四、完整可运行示例
下面这个对象把「压缩 → 校验 → 解压 → 互通验证」串成一条线。演示步骤:
- 先准备一段文本(示例用循环拼出约 11 KB,也可以换成你自己的字符串);
- 调 of_demo(),它会依次做三种类型的压缩、比对体积与头部、按类型解压并校验、再验证 snappy;
- 最后用 MessageBox 弹出汇总,任何一个 RT= 为 false 就说明该类型链路有问题。
注意几个书写要点:blob 变量先声明后赋值;uo_compress 是非可视对象,必须 create,用完 destroy;比较压缩结果用 blob 的 =(比内容);非法参数不要靠异常捕获,因为它不抛异常。另外说明一句:实机验证时我把最后那行 MessageBox 换成了「把同一串结果写进文本文件」,因为在无窗口的测试宿主里弹不出消息框——除了输出方式,逻辑与被验证的代码完全一致,文中保留 MessageBox 是为了你贴进窗口事件里能直接看到结果。
- // 演示:uo_compress 三种类型压缩 + 按类型解压 + 完整性校验
- // 输入:一段可压缩文本(此处用循环拼出约 11KB),无需外部文件与数据库
- public function string of_demo ();
- string ls_text, ls_out, ls_prefix
- long ll_i
- blob lb_src, lb_c0, lb_c1, lb_c2, lb_u0
- uo_compress lc
- uo_blob lh
- boolean lb_ok
- lc = create uo_compress
- // 1) 造原始数据:先把字符串拼好,再统一转 UTF-8(不要直接 Blob(字符串))
- ls_text = ''
- for ll_i = 1 to 200
- ls_text = ls_text + 'PowerBuilder compress demo line 0123456789 abcdefghij. '
- next
- lb_src = ToUTF8(ls_text)
- ls_out = 'SRC=' + String(Len(lb_src))
- // 2) 三种类型各压一次(compressType:0=裸deflate 1=zlib 2=gzip)
- lb_c0 = lc.zCompress(lb_src, 6, 0)
- lb_c1 = lc.zCompress(lb_src, 6, 1)
- lb_c2 = lc.zCompress(lb_src, 6, 2)
- ls_out = ls_out + ' | C0=' + String(Len(lb_c0))
- ls_out = ls_out + ' C1=' + String(Len(lb_c1))
- ls_out = ls_out + ' C2=' + String(Len(lb_c2))
- // 3) 取头部字节,确认「裸 deflate 无头、zlib 78 9C、gzip 1F 8B」
- lh = create uo_blob
- lh.reset()
- lh.set(lb_c1)
- ls_prefix = Left(lh.hex(), 4)
- ls_out = ls_out + ' | ZLIB_HEAD=' + ls_prefix
- lh.reset()
- lh.set(lb_c2)
- ls_out = ls_out + ' GZIP_HEAD=' + Left(lh.hex(), 4)
- // 4) 按类型解压并校验:长度还原 + 字节相等,两项都过才算成功
- lb_u0 = lc.zUncompress(lb_c0, 0)
- ls_out = ls_out + ' | RT0=' + String(lb_u0 = lb_src)
- lb_u0 = lc.zUncompress(lb_c1, 1)
- ls_out = ls_out + ' RT1=' + String(lb_u0 = lb_src)
- lb_u0 = lc.zUncompress(lb_c2, 2)
- ls_out = ls_out + ' RT2=' + String(lb_u0 = lb_src)
- // 5) 反面教材:用单参重载去解裸 deflate,会静默得到 0 字节
- lb_u0 = lc.zUncompress(lb_c0)
- ls_out = ls_out + ' | DEF_BY_1ARG=' + String(Len(lb_u0))
- // 6) snappy 用带 success 出参的重载,空数据也可判成败
- lb_c0 = lc.snappyCompress(lb_src, lb_ok)
- lb_u0 = lc.snappyUncompress(lb_c0, lb_ok)
- ls_out = ls_out + ' | SNP=' + String(Len(lb_c0)) + ' SNP_RT=' + String(lb_u0 = lb_src)
- destroy lh
- destroy lc
- MessageBox('uo_compress 实测汇总', ls_out)
- return ls_out
- end function
复制代码
把这套读数抄下来对照,正常结果应当是:SRC=11000、C0=107 / C1=113 / C2=125、ZLIB_HEAD=789C、GZIP_HEAD=1F8B、RT0/RT1/RT2 全为 true、DEF_BY_1ARG=0、SNP=573 且 SNP_RT=true。(这组示例的样例行与第二节探针的样例行差一个字节,所以体积比第二节表里的 108/114/126 各小 1,属正常。)DEF_BY_1ARG=0 不是 bug,是本文第三节那条坑的现场复现,可以拿它当团队的警示案例。以上读数在 PB 10 与 PB 12.5 上逐字相同。
十五、选型建议
一句话版本:文本与结构化数据走 zlib(compressType=1)并配长度/CRC 校验;追求速度的二进制走 snappy;要打包成文件就换 uo_zip;压缩类型与原始长度永远跟数据一起存。 至于 nLevel,当它不存在就好。 |