6.12 讀取嵌套和可變長二進制數(shù)據(jù)

2018-02-24 15:26 更新

問題

你需要讀取包含嵌套或者可變長記錄集合的復雜二進制格式的數(shù)據(jù)。這些數(shù)據(jù)可能包含圖片、視頻、電子地圖文件等。

解決方案

struct 模塊可被用來編碼/解碼幾乎所有類型的二進制的數(shù)據(jù)結構。為了解釋清楚這種數(shù)據(jù),假設你用下面的Python數(shù)據(jù)結構來表示一個組成一系列多邊形的點的集合:

現(xiàn)在假設這個數(shù)據(jù)被編碼到一個以下列頭部開始的二進制文件中去了:

+------+--------+------------------------------------+
|Byte  | Type   |  Description                       |
+======+========+====================================+
|0     | int    |  File code (0x1234, little endian) |
+------+--------+------------------------------------+
|4     | double |  Minimum x (little endian)         |
+------+--------+------------------------------------+
|12    | double |  Minimum y (little endian)         |
+------+--------+------------------------------------+
|20    | double |  Maximum x (little endian)         |
+------+--------+------------------------------------+
|28    | double |  Maximum y (little endian)         |
+------+--------+------------------------------------+
|36    | int    |  Number of polygons (little endian)|
+------+--------+------------------------------------+

緊跟著頭部是一系列的多邊形記錄,編碼格式如下:

+------+--------+-------------------------------------------+
|Byte  | Type   |  Description                              |
+======+========+===========================================+
|0     | int    |  Record length including length (N bytes) |
+------+--------+-------------------------------------------+
|4-N   | Points |  Pairs of (X,Y) coords as doubles         |
+------+--------+-------------------------------------------+

為了寫這樣的文件,你可以使用如下的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))

將數(shù)據(jù)讀取回來的時候,可以利用函數(shù) struct.unpack() ,代碼很相似,基本就是上面寫操作的逆序。如下:

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

盡管這個代碼可以工作,但是里面混雜了很多讀取、解包數(shù)據(jù)結構和其他細節(jié)的代碼。如果用這樣的代碼來處理真實的數(shù)據(jù)文件,那未免也太繁雜了點。因此很顯然應該有另一種解決方法可以簡化這些步驟,讓程序員只關注自最重要的事情。

在本小節(jié)接下來的部分,我會逐步演示一個更加優(yōu)秀的解析字節(jié)數(shù)據(jù)的方案。目標是可以給程序員提供一個高級的文件格式化方法,并簡化讀取和解包數(shù)據(jù)的細節(jié)。但是我要先提醒習啊你,本小節(jié)接下來的部分代碼應該是整本書中最復雜最高級的例子,使用了大量的面向對象編程和元編程技術。一定要仔細的閱讀我們的討論部分,另外也要參考下其他章節(jié)內容。

首先,當讀取字節(jié)數(shù)據(jù)的時候,通常在文件開始部分會包含文件頭和其他的數(shù)據(jù)結構。盡管struct模塊可以解包這些數(shù)據(jù)到一個元組中去,另外一種表示這種信息的方式就是使用一個類。就像下面這樣:

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)

這里我們使用了一個描述器來表示每個結構字段,每個描述器包含一個結構兼容格式的代碼以及一個字節(jié)偏移量,存儲在內部的內存緩沖中。在 __get__() 方法中,struct.unpack_from()函數(shù)被用來從緩沖中解包一個值,省去了額外的分片或復制操作步驟。

Structure 類就是一個基礎類,接受字節(jié)數(shù)據(jù)并存儲在內部的內存緩沖中,并被 StructField 描述器使用。這里使用了 memoryview() ,我們會在后面詳細講解它是用來干嘛的。

使用這個代碼,你現(xiàn)在就能定義一個高層次的結構對象來表示上面表格信息所期望的文件格式。例如:

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)

下面的例子利用這個類來讀取之前我們寫入的多邊形數(shù)據(jù)的頭部數(shù)據(jù):

>>> 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
>>>

這個很有趣,不過這種方式還是有一些煩人的地方。首先,盡管你獲得了一個類接口的便利,但是這個代碼還是有點臃腫,還需要使用者指定很多底層的細節(jié)(比如重復使用 StructField ,指定偏移量等)。另外,返回的結果類同樣確實一些便利的方法來計算結構的總數(shù)。

任何時候只要你遇到了像這樣冗余的類定義,你應該考慮下使用類裝飾器或元類。元類有一個特性就是它能夠被用來填充許多低層的實現(xiàn)細節(jié),從而釋放使用者的負擔。下面我來舉個例子,使用元類稍微改造下我們的 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()讓我們在不需要知道任何數(shù)據(jù)的大小和結構的情況下就能輕松的從文件中讀取數(shù)據(jù)。比如:

>>> 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
>>>

一旦你開始使用了元類,你就可以讓它變得更加智能。例如,假設你還想支持嵌套的字節(jié)結構,下面是對前面元類的一個小的改進,提供了一個新的輔助描述器來達到想要的效果:

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 描述器被用來疊加另外一個定義在某個內存區(qū)域上的結構。它通過將原始內存緩沖進行切片操作后實例化給定的結構類型。由于底層的內存緩沖區(qū)是通過一個內存視圖初始化的,所以這種切片操作不會引發(fā)任何的額外的內存復制。相反,它僅僅就是之前的內存的一個疊加而已。另外,為了防止重復實例化,通過使用和8.10小節(jié)同樣的技術,描述器保存了該實例中的內部結構對象。

使用這個新的修正版,你就可以像下面這樣編寫:

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
>>>

到目前為止,一個處理定長記錄的框架已經(jīng)寫好了。但是如果組件記錄是變長的呢?比如,多邊形文件包含變長的部分。

一種方案是寫一個類來表示字節(jié)數(shù)據(jù),同時寫一個工具函數(shù)來通過多少方式解析內容。跟6.11小節(jié)的代碼很類似:

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() 是一個工具,用來從一個文件中讀取帶大小前綴的數(shù)據(jù)塊,這也是很多文件格式常用的方式。作為輸入,它接受一個包含大小編碼的結構格式編碼,并且也是自己形式??蛇x的 includes_size 參數(shù)指定了字節(jié)數(shù)是否包含頭部大小。下面是一個例子教你怎樣使用從多邊形文件中讀取單獨的多邊形數(shù)據(jù):

>>> 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 類作為輸入。這樣子可以很靈活的去解析數(shù)據(jù),例如:

>>> 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() 函數(shù)的另外一個修正版:

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

討論

這一節(jié)向你展示了許多高級的編程技術,包括描述器,延遲計算,元類,類變量和內存視圖。然而,它們都為了同一個特定的目標服務。

上面的實現(xiàn)的一個主要特征是它是基于懶解包的思想。當一個 Structure 實例被創(chuàng)建時,__init__() 僅僅只是創(chuàng)建一個字節(jié)數(shù)據(jù)的內存視圖,沒有做其他任何事。特別的,這時候并沒有任何的解包或者其他與結構相關的操作發(fā)生。這樣做的一個動機是你可能僅僅只對一個字節(jié)記錄的某一小部分感興趣。我們只需要解包你需要訪問的部分,而不是整個文件。

為了實現(xiàn)懶解包和打包,需要使用 StructField 描述器類。用戶在 _fields_ 中列出來的每個屬性都會被轉化成一個 StructField 描述器,它將相關結構格式碼和偏移值保存到存儲緩存中。元類 StructureMeta 在多個結構類被定義時自動創(chuàng)建了這些描述器。我們使用元類的一個主要原因是它使得用戶非常方便的通過一個高層描述就能指定結構格式,而無需考慮低層的細節(jié)問題。

StructureMeta 的一個很微妙的地方就是它會固定字節(jié)數(shù)據(jù)順序。也就是說,如果任意的屬性指定了一個字節(jié)順序(<表示低位優(yōu)先 或者 >表示高位優(yōu)先),那后面所有字段的順序都以這個順序為準。這么做可以幫助避免額外輸入,但是在定義的中間我們仍然可能切換順序的。比如,你可能有一些比較復雜的結構,就像下面這樣:

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() 的使用可以幫助我們避免內存的復制。當結構存在嵌套的時候,memoryviews 可以疊加同一內存區(qū)域上定義的機構的不同部分。這個特性比較微妙,但是它關注的是內存視圖與普通字節(jié)數(shù)組的切片操作行為。如果你在一個字節(jié)字符串或字節(jié)數(shù)組上執(zhí)行切片操作,你通常會得到一個數(shù)據(jù)的拷貝。而內存視圖切片不是這樣的,它僅僅是在已存在的內存上面疊加而已。因此,這種方式更加高效。

還有很多相關的章節(jié)可以幫助我們擴展這里討論的方案。參考8.13小節(jié)使用描述器構建一個類型系統(tǒng)。8.10小節(jié)有更多關于延遲計算屬性值的討論,并且跟NestedStruct描述器的實現(xiàn)也有關。9.19小節(jié)有一個使用元類來初始化類成員的例子,和 StructureMeta 類非常相似。Python的 ctypes 源碼同樣也很有趣,它提供了對定義數(shù)據(jù)結構、數(shù)據(jù)結構嵌套這些相似功能的支持。

以上內容是否對您有幫助:
在線筆記
App下載
App下載

掃描二維碼

下載編程獅App

公眾號
微信公眾號

編程獅公眾號