简体中文
English 简体中文 繁體中文
Methodox 首页

在 Divooka 中处理二进制数据

在 Divooka 中读写二进制数据

在 Divooka 中写出二进制 PPM P6 格式图像并用 GIMP 查看

二进制格式无处不在:图像、网格、存档文件、网络数据包、自定义资源包、压缩流、硬件协议、游戏数据,以及几十年后仍然有用的旧文件格式。

然而在应用程序代码中,二进制解析往往很快就会变得杂乱。你从一个 byte[] 开始,接着是一个表示偏移量的整数,然后是几个辅助方法,再然后是嵌套的偏移量计算,很快解析器就同时承担了两项职责:理解文件格式,以及手动管理游标所在的位置。

ImmutableByteArray、ConstructiveByteArray 和 ImmutableConstructiveByteArray 的目标,就是把这种游标管理逻辑移到一个小巧的流式抽象中。

与其编写这样的代码:

int offset = 0;

string magic = Encoding.ASCII.GetString(bytes, offset, 4);
offset += 4;

int version = BinaryPrimitives.ReadInt32LittleEndian(bytes.AsSpan(offset, 4));
offset += 4;

int count = BinaryPrimitives.ReadInt32LittleEndian(bytes.AsSpan(offset, 4));
offset += 4;

我们更希望这样写:

var remaining = ImmutableByteArray.FromFile("Data.bin")
    .ReadString(4, out string magic)
    .ReadInt(out int version)
    .ReadInt(out int count);

解析器读起来就像二进制格式本身。

写入器与之相对应:

var data = new ConstructiveByteArray()
    .WriteStringFixedSize("DATA")
    .WriteInt(version)
    .WriteInt(count);

思路很简单:二进制数据应当表示为一系列显式的读写操作,而不是零散的偏移量算术。

读取二进制数据

读取器:ImmutableByteArray

ImmutableByteArray 是面向读取的二进制数据抽象。

它是不可变的。从中读取数据不会改变当前对象,而是由读取操作返回缓冲区中尚未读取的剩余部分。

主要的 API 形式如下:

public ImmutableByteArray ReadInt(out int value);
public int ReadInt();
public int ReadIntWithOffset(int byteIndex);

这三种形式覆盖了三种常见的用例。

out 形式用于流式解析:

var remaining = data
    .ReadString(4, out string magic)
    .ReadInt(out int width)
    .ReadInt(out int height);

直接返回的形式用于在不需要剩余数据时,从缓冲区开头读取一个值:

int width = data.ReadInt();

偏移量形式用于随机访问,不消耗任何数据:

int width = data.ReadIntWithOffset(4);

这为该类型确立了一条清晰的规则:

顺序读取通过返回新的剩余缓冲区来消耗数据;偏移量读取不消耗数据。

在内部,ImmutableByteArray 可以安全地使用一个私有的 byte[] 加上偏移量和长度。外部输入应在构造时复制,但内部切片无需复制,因为底层数组是私有的,从不以可变方式对外公开。

这意味着下面这类操作的开销很小:

var remaining = data.ReadBytes(32, out ImmutableByteArray header);

remaining 和 header 都可以是同一个内部字节数组上的视图。由于两个对象都无法修改底层存储,这样做仍然是安全的。

读取块

二进制格式经常包含固定大小的块。例如,一个文件可能包含一个 32 字节的文件头,然后是 32 字节的填充,再然后是记录。

字节的 API 形式如下:

public ImmutableByteArray ReadBytes(int count, out ImmutableByteArray value);
public ImmutableByteArray ReadBytes(int count);
public ImmutableByteArray ReadBytesWithOffset(int byteIndex, int count);
public ImmutableByteArray ReadBytes(int count, out ImmutableByteArray value, Action<ImmutableByteArray> continuation);

当需要单独解析某个子块时,延续(continuation)形式很有用:

int versionNumber = 0;

var remaining = ImmutableByteArray.FromFile("MyData.bin")
    .ReadString(4, out string magic)
    .ReadBytes(32, out ImmutableByteArray _, header => header.ReadUInt(out versionNumber))
    .ReadBytes(32, out _)
    .ReadInt(out int count);

主数据流前进 32 个字节,而延续则从隔离出来的文件头块中读取。

对于块大小固定、但目前只关心块内部分字段的格式,这一点尤其有用。

字符串

在二进制格式中,字符串通常以两种常见形式出现。

第一种是固定大小的:

public ImmutableByteArray ReadString(int size, out string value);
public string ReadString(int size);
public string ReadStringWithOffset(int byteIndex, int size);

例如,文件的魔数值:

data.ReadString(4, out string magic);

第二种是以零结尾的:

public ImmutableByteArray ReadString(out string value);
public string ReadString();
public string ReadStringWithOffset(int byteIndex);

它会一直读取到第一个 0x00 字节为止。

data.ReadString(out string name);

固定大小的字符串常见于文件头中。以零结尾的字符串常见于较旧的二进制格式、网格文件、嵌入式元数据和 C 风格的结构中。

写入二进制数据

写入器:ConstructiveByteArray

ConstructiveByteArray 是可变的写入器。

它是一个流式的、仅追加的字节构建器:

var current = new ConstructiveByteArray()
    .WriteStringFixedSize("CBA1")
    .WriteInt(1)
    .WriteDouble(3.14159);

它是就地可变的。每次调用都会把字节追加到同一个底层缓冲区,并返回 this。

这使它成为生成二进制文件时实用的默认选择。

该 API 有意使用显式的方法名:

WriteInt(...)
WriteUInt(...)
WriteDouble(...)
WriteStringFixedSize(...)
WriteStringZeroTerminated(...)
WriteBytes(...)

这避免了单个重载的 Write(...) 方法带来的歧义。在二进制代码中,显式通常值得多打几个字符。

不可变写入器:ImmutableConstructiveByteArray

ImmutableConstructiveByteArray 与写入器的 API 相对应,但每次写入都会返回一个新对象。

var baseFile = new ImmutableConstructiveByteArray()
    .WriteStringFixedSize("TEST")
    .WriteInt(1);

var pathA = baseFile.WriteStringZeroTerminated("Variant A");
var pathB = baseFile.WriteStringZeroTerminated("Variant B");

当你要尝试不同的后续写入路径时,这会很有用。

例如,你可能有一个公共的文件头,然后有几种可能的负载布局:

var common = new ImmutableConstructiveByteArray()
    .WriteStringFixedSize("MESH")
    .WriteInt(version);

var triangleMesh = common
    .WriteStringZeroTerminated("triangles")
    .WriteInt(triangleCount);

var pointCloud = common
    .WriteStringZeroTerminated("points")
    .WriteInt(pointCount);

可变写入器更快,内存效率也更高。不可变写入器则更适合分支、测试和函数式构建模式。

写入固定大小的块

写入器同样支持固定大小的子块:

public ConstructiveByteArray WriteBytes(int count);
public ConstructiveByteArray WriteBytes(int count, Action<ConstructiveByteArray> subblock);
public ConstructiveByteArray WriteBytes(ConstructiveByteArray source);

对于可变版本:

int versionNumber = 1;
int count = 15;

ConstructiveByteArray current = new ConstructiveByteArray()
    .WriteStringFixedSize("CBA1")
    .WriteBytes(32, con => con.WriteUInt((uint)versionNumber))
    .WriteBytes(32)
    .WriteInt(count);

for (int i = 0; i < count; i++)
    current = current.WriteBytes(128);

current.Save("Copy1.bin");
current.Save("Copy2.bin");
current.Dispose();

块延续会写入一个临时的子缓冲区。如果写入的字节少于 32 个,结果会自动用零填充;如果写入的字节多于 32 个,写入器应当抛出异常。

这让固定大小的二进制结构表达起来容易得多。

不可变版本则改用函数式延续:

ImmutableConstructiveByteArray current = new ImmutableConstructiveByteArray()
    .WriteStringFixedSize("CBA1")
    .WriteBytes(32, con => con.WriteUInt((uint)versionNumber))
    .WriteBytes(32)
    .WriteInt(count);

在内部,该延续的形式应当是:

Func<ImmutableConstructiveByteArray, ImmutableConstructiveByteArray>

因为不可变写入会返回新对象。

示例

示例:读取 PPM 图像

PPM 是 Netpbm 家族中一种简单的图像格式。由于其结构小巧直接,它很适合用作教学格式。

二进制 PPM 文件通常以一个 ASCII 文件头开头:

P6
width height
maxValue

其后是原始的 RGB 像素数据。

例如:

P6
256 256
255
<binary RGB bytes>

文件头是文本,而像素负载是二进制的。这使 PPM 成为文本与二进制混合解析的一个好例子。

一个简单的 PPM 读取器可能是这样的:

public sealed class PpmImage
{
    public int Width { get; }
    public int Height { get; }
    public byte[] Pixels { get; }

    public PpmImage(int width, int height, byte[] pixels)
    {
        Width = width;
        Height = height;
        Pixels = pixels;
    }
}

使用 ImmutableByteArray,可以这样编写一个基本的解析器:

public static PpmImage ReadPpm(string path)
{
    ImmutableByteArray data = ImmutableByteArray.FromFile(path);

    data = ReadAsciiToken(data, out string magic);

    if (magic != "P6")
        throw new InvalidDataException("Only binary P6 PPM files are supported.");

    data = ReadAsciiToken(data, out string widthText);
    data = ReadAsciiToken(data, out string heightText);
    data = ReadAsciiToken(data, out string maxValueText);

    int width = int.Parse(widthText);
    int height = int.Parse(heightText);
    int maxValue = int.Parse(maxValueText);

    if (maxValue != 255)
        throw new InvalidDataException("Only 8-bit PPM files with max value 255 are supported.");

    int pixelByteCount = width * height * 3;

    data.ReadBytes(pixelByteCount, out ImmutableByteArray pixelData);

    return new PpmImage(width, height, pixelData.ToArray());
}

辅助的标记读取器可以实现为作用于不可变缓冲区的一个小例程:

private static ImmutableByteArray ReadAsciiToken(ImmutableByteArray data, out string token)
{
    data = SkipAsciiWhitespace(data);

    int count = 0;

    while (count < data.Length && !IsAsciiWhitespace(data[count]))
        count++;

    token = Encoding.ASCII.GetString(data.AsSpan(0, count));
    return data.Skip(count);
}

private static ImmutableByteArray SkipAsciiWhitespace(ImmutableByteArray data)
{
    int count = 0;

    while (count < data.Length && IsAsciiWhitespace(data[count]))
        count++;

    return data.Skip(count);
}

private static bool IsAsciiWhitespace(byte value)
{
    return value == (byte)' ' || value == (byte)'\t' || value == (byte)'\r' || value == (byte)'\n';
}

解析器无需与偏移量缠斗。每个辅助方法接收一个缓冲区,并返回剩余的缓冲区。

这就是核心模式。

示例:写入 PPM 图像

使用 ConstructiveByteArray 写入二进制 PPM 文件非常简单。

public static void WritePpm(string path, int width, int height, ReadOnlySpan<byte> rgbPixels)
{
    int expectedLength = width * height * 3;

    if (rgbPixels.Length != expectedLength)
        throw new ArgumentException($"Expected {expectedLength} RGB byte(s).", nameof(rgbPixels));

    new ConstructiveByteArray()
        .WriteStringFixedSize("P6\n")
        .WriteStringFixedSize(width.ToString())
        .WriteByte((byte)' ')
        .WriteStringFixedSize(height.ToString())
        .WriteByte((byte)'\n')
        .WriteStringFixedSize("255\n")
        .WriteBytes(rgbPixels)
        .Save(path)
        .Dispose();
}

它会写入:

P6
width height
255

然后追加原始的 RGB 字节。

字符串方法依然是显式的。我们并没有假装整个文件都是文本,而是在构建一个包含若干 ASCII 部分的二进制字节序列。

示例:读取 PLY 多边形文件

PLY 是一种常见的多边形网格格式。它可以是 ASCII 的,也可以是二进制的。二进制小端序 PLY 文件通常以一个 ASCII 文件头开头,其后是二进制的顶点和面数据。

一个简化的二进制 PLY 文件头可能是这样的:

ply
format binary_little_endian 1.0
element vertex 3
property float x
property float y
property float z
element face 1
property list uchar int vertex_indices
end_header

然后是二进制主体。

对于这个文件头,主体包含:

三个顶点,每个顶点有三个 32 位浮点数:

x y z
x y z
x y z

然后是一个面:

uchar count
int index0
int index1
int index2

一个小型的模型类型:

public readonly record struct Vertex(float X, float Y, float Z);

public sealed class PlyMesh
{
    public List<Vertex> Vertices { get; } = new List<Vertex>();
    public List<int[]> Faces { get; } = new List<int[]>();
}

一个简化的二进制 PLY 读取器:

public static PlyMesh ReadBinaryLittleEndianPly(string path)
{
    ImmutableByteArray data = ImmutableByteArray.FromFile(path, ByteArrayEndianness.LittleEndian, Encoding.ASCII);

    data = ReadPlyHeader(data, out int vertexCount, out int faceCount);

    var mesh = new PlyMesh();

    for (int i = 0; i < vertexCount; i++)
    {
        data = data
            .ReadFloat(out float x)
            .ReadFloat(out float y)
            .ReadFloat(out float z);

        mesh.Vertices.Add(new Vertex(x, y, z));
    }

    for (int i = 0; i < faceCount; i++)
    {
        data = data.ReadByte(out byte indexCount);

        int[] indices = new int[indexCount];

        for (int j = 0; j < indexCount; j++)
            data = data.ReadInt(out indices[j]);

        mesh.Faces.Add(indices);
    }

    return mesh;
}

文件头读取器可以保持面向文本的方式:

private static ImmutableByteArray ReadPlyHeader(ImmutableByteArray data, out int vertexCount, out int faceCount)
{
    vertexCount = 0;
    faceCount = 0;

    var lines = new List<string>();

    while (true)
    {
        data = ReadAsciiLine(data, out string line);
        lines.Add(line);

        if (line == "end_header")
            break;
    }

    foreach (string line in lines)
    {
        string[] parts = line.Split(' ', StringSplitOptions.RemoveEmptyEntries);

        if (parts.Length == 3 && parts[0] == "element" && parts[1] == "vertex")
            vertexCount = int.Parse(parts[2]);

        if (parts.Length == 3 && parts[0] == "element" && parts[1] == "face")
            faceCount = int.Parse(parts[2]);
    }

    return data;
}

private static ImmutableByteArray ReadAsciiLine(ImmutableByteArray data, out string line)
{
    int count = 0;

    while (count < data.Length && data[count] != (byte)'\n')
        count++;

    line = Encoding.ASCII.GetString(data.AsSpan(0, count)).TrimEnd('\r');

    if (count < data.Length)
        count++;

    return data.Skip(count);
}

同样,二进制主体的解析器之所以简洁,是因为游标隐含在返回的 ImmutableByteArray 中。

示例:写入二进制 PLY 文件

将一个简单的三角形网格写入二进制小端序 PLY:

public static void WriteBinaryLittleEndianPly(string path, IReadOnlyList<Vertex> vertices, IReadOnlyList<int[]> faces)
{
    var current = new ConstructiveByteArray(ByteArrayEndianness.LittleEndian, Encoding.ASCII)
        .WriteStringFixedSize("ply\n")
        .WriteStringFixedSize("format binary_little_endian 1.0\n")
        .WriteStringFixedSize($"element vertex {vertices.Count}\n")
        .WriteStringFixedSize("property float x\n")
        .WriteStringFixedSize("property float y\n")
        .WriteStringFixedSize("property float z\n")
        .WriteStringFixedSize($"element face {faces.Count}\n")
        .WriteStringFixedSize("property list uchar int vertex_indices\n")
        .WriteStringFixedSize("end_header\n");

    foreach (Vertex vertex in vertices)
    {
        current = current
            .WriteFloat(vertex.X)
            .WriteFloat(vertex.Y)
            .WriteFloat(vertex.Z);
    }

    foreach (int[] face in faces)
    {
        if (face.Length > byte.MaxValue)
            throw new InvalidDataException("PLY face has too many indices for uchar list count.");

        current = current.WriteByte((byte)face.Length);

        for (int i = 0; i < face.Length; i++)
            current = current.WriteInt(face[i]);
    }

    current.Save(path);
    current.Dispose();
}

然后可以这样写入一个三角形:

var vertices = new[]
{
    new Vertex(0, 0, 0),
    new Vertex(1, 0, 0),
    new Vertex(0, 1, 0),
};

var faces = new[]
{
    new[] { 0, 1, 2 },
};

WriteBinaryLittleEndianPly("Triangle.ply", vertices, faces);

这段代码读起来是一系列格式字段,而不是一组数组修改操作。

过程式构建与数据流构建

之所以有两种写入器类型,是因为它们解决的是不同的问题。

ConstructiveByteArray 用于普通的构建。它是可变的、仅追加的、高效的,并且可以多次保存。

var data = new ConstructiveByteArray()
    .WriteStringFixedSize("FILE")
    .WriteInt(1);

data.Save("A.bin");
data.Save("B.bin");
data.Dispose();

ImmutableConstructiveByteArray 用于分支式构建。

var common = new ImmutableConstructiveByteArray()
    .WriteStringFixedSize("FILE")
    .WriteInt(1);

var a = common.WriteStringZeroTerminated("A");
var b = common.WriteStringZeroTerminated("B");

不可变写入器并不是要在高吞吐量输出场景中取代可变写入器。它的存在是为了安全性、实验,以及可分叉的构建。

另请参阅