在 Divooka 中处理二进制数据


二进制格式无处不在:图像、网格、存档文件、网络数据包、自定义资源包、压缩流、硬件协议、游戏数据,以及几十年后仍然有用的旧文件格式。
然而在应用程序代码中,二进制解析往往很快就会变得杂乱。你从一个 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");
不可变写入器并不是要在高吞吐量输出场景中取代可变写入器。它的存在是为了安全性、实验,以及可分叉的构建。