且构网

分享程序员开发的那些事...
且构网 - 分享程序员编程开发的那些事

《Python Cookbook(第3版)中文版》——6.12 读取嵌套型和大小可变的二进制结构

更新时间:2022-09-19 22:47:25

本节书摘来自异步社区《Python Cookbook(第3版)中文版》一书中的第6章,第6.12节,作者[美]David Beazley , Brian K.Jones,陈舸 译,更多章节内容可以访问云栖社区“异步社区”公众号查看。

6.12 读取嵌套型和大小可变的二进制结构

6.12.1 问题

我们需要读取复杂的二进制编码数据,这些数据中包含有一系列嵌套的或者大小可变的记录。这种数据包括图片、视频、shapefile(zh.wikipedia.org/zh-cn/Shapefile)等。
6.12.2 解决方案

struct模块可用来编码和解码几乎任何类型的二进制数据结构。为了说明本节中提到的这种数据,假设我们有一个用Python数据结构表示的点的集合,这些点可用来组成一系列的三角形:

polys = [
          [ (1.0, 2.5), (3.5, 4.0), (2.5, 1.5) ],
          [ (7.0, 1.2), (5.1, 3.0), (0.5, 7.5), (0.8, 9.0) ],
          [ (3.4, 6.3), (1.2, 0.5), (4.6, 9.2) ],
        ]

现在假设要将这份数据编码为一个二进制文件,这个文件的文件头可以表示为如下的形式:


《Python Cookbook(第3版)中文版》——6.12 读取嵌套型和大小可变的二进制结构

紧跟在这个文件头之后的是一系列的三角形记录,每条记录编码为如下的形式:


《Python Cookbook(第3版)中文版》——6.12 读取嵌套型和大小可变的二进制结构

要写入这个文件,可以使用如下的Python代码:

import struct
import itertools
def write_polys(filename, polys):
    # Determine bounding box
    flattened = list(itertools.chain(*polys))
    min_x = min(x for x, y in flattened)
    max_x = max(x for x, y in flattened)
    min_y = min(y for x, y in flattened)
    max_y = max(y for x, y in flattened)
with open(filename, 'wb') as f:
    f.write(struct.pack('<iddddi',
                        0x1234,
                        min_x, min_y,
                        max_x, max_y,
                    len(polys)))
    for poly in polys:
        size = len(poly) * struct.calcsize('<dd')
        f.write(struct.pack('<i', size+4))
        for pt in poly:
            f.write(struct.pack('<dd', *pt))
# Call it with our polygon data
write_polys('polys.bin', polys)

要将结果数据回读的话,可以利用struct.unpack()函数写出相似的代码,只是在编写的时候将所执行的操作反转即可(即,用unpack()取代之前的pack())。示例如下:

import struct
def read_polys(filename):
    with open(filename, 'rb') as f:
        # Read the header
        header = f.read(40)
        file_code, min_x, min_y, max_x, max_y, num_polys = \
            struct.unpack('<iddddi', header)
        polys = []
        for n in range(num_polys):
            pbytes, = struct.unpack('<i', f.read(4))
            poly = []
            for m in range(pbytes // 16):
                pt = struct.unpack('<dd', f.read(16))
                poly.append(pt)
            polys.append(poly)
    return polys

尽管这份代码能够工作,但是其中混杂了一些read调用、对结构的解包以及其他一些细节,因此代码比较杂乱。如果用这样的代码去处理一个真正的数据文件,很快就会变的更加混乱。因此,很明显需要寻求其他的解决方案来简化其中的一些步骤,将程序员解放出来,把精力集中在更加重要的问题上。

在本节剩余的部分中,我们将逐步构建出一个用来解释二进制数据的高级解决方案,目的是让程序员提供文件格式的高层规范,而将读取文件以及解包所有数据的细节部分都隐藏起来。先提前给读者预警,本节后面的代码可能是本书中最为高级的示例,运用了多种面向对象编程和元编程的技术。请确保仔细阅读本节的讨论部分,并且需要来回翻阅其他章节,交叉参考。

首先,当我们读取二进制数据时,文件中包含有文件头和其他的数据结构是非常常见的。尽管struct模块能够将数据解包为元组,但另一种表示这种信息的方式是通过类。下面的代码正是这么做的:

import struct
class StructField:
    '''
    Descriptor representing a simple structure field
    '''
    def __init__(self, format, offset):
        self.format = format
        self.offset = offset
    def __get__(self, instance, cls):
        if instance is None:
            return self
        else:
            r = struct.unpack_from(self.format,
                                   instance._buffer, self.offset)
            return r[0] if len(r) == 1 else r

class Structure:
    def __init__(self, bytedata):
        self._buffer = memoryview(bytedata)

代码中使用了描述符(descriptor)来代表每一个结构字段。每个描述符中都包含了一个struct模块可识别的格式代码(format)以及相对于底层内存缓冲区的字节偏移(offset)。在__get__()方法中,通过struct.unpack_from()函数从缓冲区中解包出对应的值,这样就不用创建额外的切片对象或者执行拷贝动作了。

Structure类只是用作基类,它接受一些字节数据并保存在由StructField描述符所使用的底层内存缓冲区中。这样一来,在Structure类中用到的memoryview(),意图就非常清楚了。

使用这份代码,现在就可以将结构定义为高层次的类,将前面表格中用来描述文件格式的信息都映射到类的定义中。示例如下:

class PolyHeader(Structure):
    file_code = StructField('<i', 0)
    min_x = StructField('<d', 4)
    min_y = StructField('<d', 12)
    max_x = StructField('<d', 20)
    max_y = StructField('<d', 28)
    num_polys = StructField('<i', 36)

下面的示例使用这个类来读取之前写入的三角形数据的文件头:

>>> f = open('polys.bin', 'rb')
>>> phead = PolyHeader(f.read(40))
>>> phead.file_code == 0x1234
True
>>> phead.min_x
0.5
>>> phead.min_y
0.5
>>> phead.max_x
7.0
>>> phead.max_y
9.2
>>> phead.num_polys
3
>>>

这么做挺有趣的,但是这种方法还存在许多问题。第一,尽管得到了便利的类接口,但代码比较冗长,需要用户指定许多底层的细节(比如,重复使用StructField、指定偏移量等)。得到的结果中,这个类也缺少一些常用的便捷方法,比如提供一种方式来计算结构的总大小。

任何时候当面对这种过于冗长的类定义时,都应该考虑使用类装饰器(class decorator)或者元类(metaclass)。元类的功能之一是它可用来填充许多底层的实现细节,把这份负担从用户身上拿走。举个例子,考虑下面这个元类和稍微修改过的Structure类:

class StructureMeta(type):
    '''
    Metaclass that automatically creates StructField descriptors
    '''
    def __init__(self, clsname, bases, clsdict):
        fields = getattr(self, '_fields_', [])
        byte_order = ''
        offset = 0
        for format, fieldname in fields:
            if format.startswith(('<','>','!','@')):
                byte_order = format[0]
                format = format[1:]
            format = byte_order + format
            setattr(self, fieldname, StructField(format, offset))
            offset += struct.calcsize(format)
        setattr(self, 'struct_size', offset)
class Structure(metaclass=StructureMeta):
    def __init__(self, bytedata):
        self._buffer = bytedata

    @classmethod
    def from_file(cls, f):
        return cls(f.read(cls.struct_size))

使用这个新的Structure类,现在就可以像这样编写结构的定义了:

class PolyHeader(Structure):
    _fields_ = [
       ('<i', 'file_code'),
       ('d', 'min_x'),
       ('d', 'min_y'),
       ('d', 'max_x'),
       ('d', 'max_y'),
       ('i', 'num_polys')
    ]

可以看到,现在的定义要简化得多。新增的类方法from_file()也使得从文件中读取数据变得更加简单,因为现在不需要了解数据的结构大小等细节问题了。比如,现在可以这么做:

>>> f = open('polys.bin', 'rb')
>>> phead = PolyHeader.from_file(f)
>>> phead.file_code == 0x1234
True
>>> phead.min_x
0.5
>>> phead.min_y
0.5
>>> phead.max_x
7.0
>>> phead.max_y
9.2
>>> phead.num_polys
3
>>>

一旦引入了元类,就可以为其构建更多智能化的操作。比如说,假设想对嵌套型的二进制结构提供支持。下面是对这个元类的修改,以及对新功能提供支持的描述符定义:

class NestedStruct:
    '''
    Descriptor representing a nested structure
    '''
    def __init__(self, name, struct_type, offset):
        self.name = name
        self.struct_type = struct_type
        self.offset = offset
    def __get__(self, instance, cls):
        if instance is None:
            return self
        else:
            data = instance._buffer[self.offset:
                               self.offset+self.struct_type.struct_size]
            result = self.struct_type(data)
            # Save resulting structure back on instance to avoid
            # further recomputation of this step
            setattr(instance, self.name, result)
            return result
class StructureMeta(type):
    '''
    Metaclass that automatically creates StructField descriptors
    '''
    def __init__(self, clsname, bases, clsdict):
        fields = getattr(self, '_fields_', [])
        byte_order = ''
        offset = 0
        for format, fieldname in fields:
            if isinstance(format, StructureMeta):
                setattr(self, fieldname,
                        NestedStruct(fieldname, format, offset))
                offset += format.struct_size
            else:
                if format.startswith(('<','>','!','@')):
                    byte_order = format[0]
                    format = format[1:]
                format = byte_order + format
                setattr(self, fieldname, StructField(format, offset))
                offset += struct.calcsize(format)
        setattr(self, 'struct_size', offset)

在这份代码中,NestedStruct描述符的作用是在一段内存区域上定义另一个结构。这是通过在原内存缓冲区中取一个切片,然后在这个切片上实例化给定的结构类型来实现的。由于底层的内存缓冲区是由memoryview来初始化的,因此这个切片操作不会涉及任何额外的内存拷贝动作。相反,它只是在原来的内存中“覆盖”上新的结构实例。此外,要避免重复的实例化动作,这个描述符会利用8.10节中提到的技术将内层结构对象保存在该实例上。

使用这种新的技术,现在就可以像这样编写代码了:

class Point(Structure):
    _fields_ = [
          ('<d', 'x'),
          ('d', 'y')
    ]
class PolyHeader(Structure):
    _fields_ = [
          ('<i', 'file_code'),
          (Point, 'min'), # nested struct
          (Point, 'max'), # nested struct
          ('i', 'num_polys')
    ]

太神奇了,一切都还是按照所期望的方式正常运转。示例如下:

>>> f = open('polys.bin', 'rb')
>>> phead = PolyHeader.from_file(f)
>>> phead.file_code == 0x1234
True
>>> phead.min # Nested structure
<__main__.Point object at 0x1006a48d0>
>>> phead.min.x
0.5
>>> phead.min.y
0.5
>>> phead.max.x
7.0
>>> phead.max.y
9.2
>>> phead.num_polys
3
>>>

到目前为止,我们已经成功开发了一个用来处理固定大小记录的框架。但是对于大小可变的组件又该如何处理呢?比如说,这份三角形数据文件的剩余部分中包含有大小可变的区域。

一种处理方法是编写一个类来简单代表一块二进制数据,并附带一个通用函数来负责以不同的方式来解释数据的内容。这和6.11节中的代码关系紧密:

class SizedRecord:
    def __init__(self, bytedata):
        self._buffer = memoryview(bytedata)
    @classmethod
    def from_file(cls, f, size_fmt, includes_size=True):
        sz_nbytes = struct.calcsize(size_fmt)
        sz_bytes = f.read(sz_nbytes)
        sz, = struct.unpack(size_fmt, sz_bytes)
        buf = f.read(sz - includes_size * sz_nbytes)
        return cls(buf)

    def iter_as(self, code):
        if isinstance(code, str):
            s = struct.Struct(code)
            for off in range(0, len(self._buffer), s.size):
                yield s.unpack_from(self._buffer, off)
        elif isinstance(code, StructureMeta):
            size = code.struct_size
            for off in range(0, len(self._buffer), size):
                data = self._buffer[off:off+size]
                yield code(data)

这里的类方法SizedRecord.from_file()是一个通用的函数,用来从文件中读取大小预定好的数据块,这在许多文件格式中都是很常见的。对于输入参数,它可接受结构的格式代码,其中包含有编码的大小(以字节数表示)。可选参数includes_size用来指定字节数中是否要包含进文件头的大小。下面的示例展示如何使用这份代码来读取三角形数据文件中那些单独的三角形:

>>> f = open('polys.bin', 'rb')
>>> phead = PolyHeader.from_file(f)
>>> phead.num_polys
3
>>> polydata = [ SizedRecord.from_file(f, '<i')
...               for n in range(phead.num_polys) ]
>>> polydata
[<__main__.SizedRecord object at 0x1006a4d50>,
 <__main__.SizedRecord object at 0x1006a4f50>,
 <__main__.SizedRecord object at 0x10070da90>]
>>>

可以看到,SizedRecord实例的内容还没有经过解释。要做到这一点,可以使用iter_as()方法。该方法可接受一个结构格式代码或者Structure类作为输入。这给了我们极大的***来选择如何解释数据。比如:

>>> for n, poly in enumerate(polydata):
...     print('Polygon', n)
...     for p in poly.iter_as('<dd'):
...           print(p)
...
Polygon 0
(1.0, 2.5)
(3.5, 4.0)
(2.5, 1.5)
Polygon 1
(7.0, 1.2)
(5.1, 3.0)
(0.5, 7.5)
(0.8, 9.0)
Polygon 2
(3.4, 6.3)
(1.2, 0.5)
(4.6, 9.2)
>>>
>>> for n, poly in enumerate(polydata):
...     print('Polygon', n)
...     for p in poly.iter_as(Point):
...                print(p.x, p.y)
...
Polygon 0
1.0 2.5
3.5 4.0
2.5 1.5
Polygon 1
7.0 1.2
5.1 3.0
0.5 7.5
0.8 9.0
Polygon 2
3.4 6.3
1.2 0.5
4.6 9.2
>>>

现在我们把所有的东西结合起来。下面是read_polys()函数的另一种实现:

class Point(Structure):
    _fields_ = [
        ('<d', 'x'),
        ('d', 'y')
    ]
class PolyHeader(Structure):
    _fields_ = [
        ('<i', 'file_code'),
        (Point, 'min'),
        (Point, 'max'),
        ('i', 'num_polys')
    ]
def read_polys(filename):
    polys = []
    with open(filename, 'rb') as f:
        phead = PolyHeader.from_file(f)
        for n in range(phead.num_polys):
            rec = SizedRecord.from_file(f, '<i')
            poly = [ (p.x, p.y)
                      for p in rec.iter_as(Point) ]
            polys.append(poly)
    return polys

6.12.3 讨论

本节展示了多种高级编程技术的实际应用,这些技术包括描述符、惰性求值、元类、类变量以及memoryview。只是它们都用于一个非常具体的目的而已。

本节给出的实现中,一个非常重要的特性就是强烈基于惰性展开(lazy-unpacking)的思想。每当创建出一个Structure实例时,__init__()方法只是根据提供的字节数据创建出一个memoryview,除此之外别的什么都不做。具体而言就是这个时候不会进行任何的解包或其他与结构相关的操作。采用这种方法的一个动机是我们可能只对二进制记录中的某几个特定部分感兴趣。与其将整个文件解包展开,不如只对实际要访问到的那几个部分解包即可。

要实现惰性展开和对值进行打包,StructField描述符就派上用场了。用户在_fields_中列出的每个属性都会转换为一个StructField描述符,它保存着相关属性的结构化代码和相对于底层内存缓冲区的字节偏移量。当我们定义各种各样的结构化类型时,元类StructureMeta用来自动创建出这些描述符。使用元类的主要原因在于这么做能以高层次的描述来指定结构的格式,完全不用操心底层的细节问题,因而能极大地简化用户的操作。

元类StructureMeta中有一个微妙的方面需要注意,那就是它将字节序给规定死了。也就说,如果有任何属性指定了字节序(<指代小端序,而>指代大端序),那么这个字节序就适用于该属性之后的所有字段。这种行为可避免我们产生额外的键盘输入,同时也使得在定义字段时可以切换字节序。比如说,我们可能会碰到下面这样更加复杂的数据:

class ShapeFile(Structure):
    _fields_ = [ ('>i', 'file_code'), # Big endian 这里是大端,后两个属性都是大端
                 ('20s', 'unused'),
                 ('i', 'file_length'),
                 ('<i', 'version'), # Little endian 切换为小端,后续所有的属性都是小端
                 ('i', 'shape_type'),
                 ('d', 'min_x'),
                 ('d', 'min_y'),
                 ('d', 'max_x'),
                 ('d', 'max_y'),
                 ('d', 'min_z'),
                 ('d', 'max_z'),
                 ('d', 'min_m'),
                 ('d', 'max_m') ]

前文中提到,解决方案中对memoryview()的使用起到了避免内存拷贝的作用。当结构数据开始出现嵌套时,memoryview可用来在相同的内存区域中覆盖上不同的结构定义。这种行为十分微妙,它考虑到了切片操作在memoryview和普通的字节数组上的不同行为。如果对字节串或字节数组执行切片操作的话,通常都会得到一份数据的拷贝,但memoryview就不会这样——切片只是简单地覆盖在已有的内存之上。因此,这种方法更加高效。

还有一些相关的章节会帮助我们对解决方案中用到的技术进行扩展。8.13节中采用描述符构建了一个类型系统。8.10节中介绍了有关惰性计算的性质,这个和NestedStruct描述符的实现有一定的相关性。9.19节中有一个例子采用元类来初始化类的成员,这个和StructureMeta类采用的方式非常相似。我们可能也会对Python标准库中ctypes模块的源代码产生兴趣,因为它对定义数据结构、对数据结构的嵌套以及类似功能的支持和我们的解决方案比较相似。