使用变长序列化格式解包未知数据格式

3

我正在使用Perl(版本为5.8.8,请不要问我为什么),并查看一个序列化的二进制文件,我想从中解析信息。

格式如下:

  • 7个字节,意义未知 (DB DB 00 00 7A 03 00)
  • 空值(0x00)
  • 7个字节的用户ID字符串
  • 空值(0x00)
  • 12个字节的字符串将被丢弃
  • 空值(0x00)
  • 3个字节的数字指定接下来的项目数
  • 空值(0x00)
  • 第一项变长字符串
  • 换行符(0x0a)
  • 第二项变长字符串
  • 换行符(0x0a)
  • 等等 ...
  • 空值(0x00)
  • 7个字节的用户ID字符串
  • 等等 ...

我的当前代码有些天真,它跳过前8个字节,然后逐字节读取,直到遇到空值,然后进行非常具体的解析。

sub readGroupsFile {
    my %index;

    open (my $fh, "<:raw", "groupsfile");
    seek($fh, 8, 0);
    while (read($fh, my $userID, 7)) {
        $index{$userID} = ();
        seek($fh, 18, 1);
        my $groups = "";
        while (read($fh, my $byte, 1)) {
            last if (ord($byte) == 0);
            $groups .= $byte;
        }
        my @grouplist = split("\n", $groups);
        $index{$userID} = \@grouplist;
    }
    close($fh);

    return \%index;
}

好消息?它可行。

然而,我认为这不太优美,并且想知道我是否可以利用指定后面跟随的项数的2字节数字来加速解析。我不知道它还有其他作用。

我认为unpack()及其模板可能提供答案,但我不能弄清楚它如何处理长度可变的字符串数组。


1
为什么你在寻找18呢?根据你提供的数据,它应该是20。0x0 + 15字节字符串 + 0x0 + 2字节 + 0x0 = 20。 - Håkon Hægland
我认为问题在于 unpack 需要一个字符串来解包,它不能直接从文件中读取。因此,你需要先将文件读入一个字符串中,但这可能与你所拥有的不太高效。 - Håkon Hægland
1
抱歉,实际上是一个12字节的字符串,数字可能是3字节。+3个空值为18。我数错了。但真正的问题在其后面的数组中。我已经调整了帖子。 - bluppfisk
一旦你到达“_可变长度字符串_”,可以切换到readline(又称<>),因为它们都以换行符结尾。我认为没有理由不能混合使用read<>(只是不能使用未缓冲的sysread)。 - zdim
我不明白 "可变长度数组" 的问题在哪里...你不知道有多少 "可变长度字符串" 吗?一旦你切换到 readline,你可以测试每行读取的内容是否为一个以 null 结尾的 7 字节字符串;根据描述,似乎没有任何 "项" 可以这样。 - zdim
或者:将 local $/ 设置为 nul,然后读取四个这样的“行”(丢弃--用户ID--丢弃--要跟随的项目数);然后将 $/ 改回换行符,并使用刚刚读取的最后一个(项目数)读取相应数量的行(项目)。重复? - zdim
2个回答

3
这里有两种基于数据描述来减少硬编码特定内容的方法:一种是通过读取那些空字节(然后再转换为换行符),另一种是通过使用nuls解压缩行。

$/变量设置为空字节,然后读取前4(四)个这样的“行”。你可以在这里获取用户ID,然后最后读取的这样一个“行”是其后接的项数。恢复$/为换行符并使用正常的readline(也称为<>)读取该列表。如果此模式确实重复,则重复上述步骤。
use warnings;
use strict;
use feature 'say';

my $file = shift or die "Usage: $0 file\n";  # a_file_with_nuls.txt    
open my $fh, '<', $file or die "Can't open $file: $!"; 

my ($user_id, $num_items);
while (not eof $fh) {    
    READ_BY_NUL: { 
        my $num_of_nul_lines = 4;
        local $/ = "\x00"; 
        my $line;
        for my $i (1..$num_of_nul_lines) { 
            $line = readline $fh;
            chop $line;
            if ($i == 2) {
                $user_id = $line;
            }
        }   
        $num_items = $line;  # last nul-terminated "line"
    }        
    say "Got: user-id = |$user_id|, and number-of-items = |$num_items|";    

    my @items;
    for (1..$num_items) {
        my $line = readline $fh;
        chomp $line;
        push @items, $line;
    }    
    say for @items;
};

由于在READ_BY_NUL块中使用local设置了$/,因此它的先前值将在该块之外恢复。

输出结果如预期,但请添加检查。此外,人们可以想象出现一些错误是有道理的(例如:实际项目数低于给定数量)。

整个过程都在一个while循环中,并使用eof进行手动检查(和终止),假设模式four-nuls + number-of-lines确实重复(从问题中有点不清楚)。

我使用一个文件进行测试

perl -wE'say "toss\x00user-id\x00this-too\x003\x00item-1\nitem2\nitem 3"' 
    > a_file_with_nuls.txt

接着将其多次添加以生成 while 循环所需的内容。

最后,在需要的系统上使其读取为 <:raw ,并根据需要进行 unpack 。请参见下文。


如问题所述,(某些?)数据是二进制格式,因此上面所读取的内容需要进行 upack 处理。这也意味着读取可能会遇到空字节的问题——那个数据最初是如何写入的?可以将这些定长字段的未填充部分正好用 nul 填充。

另一种选择是简单地读取行,并解包第一行(然后在读取指定数量的行后每次 unpack 一行,指定为“items”)。

open my $fh, '<:raw', $file or die "Can't open $file: $!"; 

my @items;
my $block_lines = 1;

while (my $line = <$fh>) { 
    chomp $line;
    if ( $. % $block_lines == 0 ) {
        my ($uid, $num_items) = unpack "x8 A7x x13 i3x", $line;
        say "User-id: $uid, read $num_items lines for items";
        $block_lines += 1 + $num_items;
    }   
    else {
        push @items, $line;
    }
}
say for @items;

这里要跳过的字节数(x8x13)包括零。

假设每个“块”中读取的“项”(行)数可能不同,并且在读取时将它们加起来(加上空行,以获取总运行$block_lines),因此可以检查何时再次到达具有空值的行($. % $block_lines == 0)。

对于未指定的事物,它做出了一些其他(合理的)假设。 这仅经过轻微检查,使用了一些虚构数据。


我将在周一回到办公室后进行测试,但是需要注意的是,项目数量实际上是二进制的。只需读取行并解包即可吗? - bluppfisk
1
@bluppfisk 啊,没错 —— 应该没问题。(而且你想要在 <raw 中打开,就像你现在所做的一样。) - zdim
@bluppfisk 如果您的真实数据布局有显著差异,请发布一些,以便我进行测试。 - zdim
@bluppfisk 已更新,但可能需要微调 - 让我知道进展如何。 - zdim
我也用了一些小改动就让它工作了(需要在继续 while 循环前读取另一个 NULL),但 ikegami 的解决方案更快一些。谢谢你向我展示这些选项,我总是乐于学习更多。 - bluppfisk

2

你不知道需要读取多少内容,因此一次性读取整个文件将获得最佳速度结果。

Original Answer翻译成"最初的回答"

{
   my $file = do { local $/; <> };

   $file =~ s/^.{8}//s
      or die("Bad data");

   while (length($file)) {
      $file =~ s/^([^\0]*)\0[^\0]*\0[^\0]*\0([^\0]*)\0//
         or die("Bad data");

      my $user_id = $1;
      my @items = split(/\n/, $2, -1);
      ...
   }
}

通过使用缓冲区,你可以获得读取整个文件的大部分优点,而不必一次性读取整个文件,但这会使代码变得更加复杂。"Original Answer"的翻译是"最初的回答"。
{
   my $buf = '';
   my $not_eof = 1;

   my $reader = sub {
      $not_eof &&= read(\*ARGV, $buf, 1024*1024, length($buf));
      die($!) if !defined($not_eof);
      return $not_eof;
   };

   while ($buf !~ s/^.{8}//s) {
      $reader->()
         or die("Bad data");
   }      

   while (length($buf) || $reader->()) {
      my $user_id;
      my @items;
      while (1) {
         if ($buf =~ s/^([^\0]*)\0[^\0]*\0[^\0]*\0([^\0]*)\0//) {
            $user_id = $1;
            @items = split(/\n/, $2, -1);
            last;
         }

         $reader->()
            or die("Bad data");
      }

      ...
   }
}

1
有趣的方法。如果文件很大,第二种方法不会像第一种方法那样消耗太多内存,但速度大概是相同的吧? - Håkon Hægland
@Håkon Hægland,如果记录的大小通常小于缓冲区大小,则速度应该是可比较的(因为循环有效地折叠成“if”语句)。您可以通过一次提取一个字段而不是一次提取一个记录来减少其使用的内存,但这可能不是必要的。///请注意,坏文件(例如没有任何NUL的大文件)可能会完全加载到内存中,但通过在&$reader中抛出错误来避免缓冲区过大即可轻松避免。 - ikegami
是的,那看起来很合理。但还有一件事:在第二个片段中,我应该用 $buf 替换 $file 吗? - Håkon Hægland
谢谢!我仍然无法看到它在块之间的边界上如何工作。假设一条记录并没有恰好在缓冲区边界结束。那么内部的 while 将会失败,接下来就会调用 reader->(),它会覆盖整个缓冲区。那么这条记录不就丢失了吗? - Håkon Hægland
@Håkon Hægland,&$reader不会覆盖,而是追加(注意read的第四个参数)。 - ikegami
显示剩余4条评论

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接