Bun技术评估 - 21 二进制(Binary Data)

概述

本文是笔者的系列博文 《Bun技术评估》 中的第二十一篇。

在本文的内容中,笔者主要想要来探讨一下JavaScript和Bun中如何对二进制形式(Binary Data)的数据进行处理的。

之所以有这个想法,是笔者在研究和评估Bun技术体系的时候,发现其在API板块中,有这么一个章节:

bun.sh/docs/api/bi...

这个文档中的内容,厘清了在以前开发过程中的很多疑问和误解,让笔者很受启发,这本身也是一个非常基础,但也非常重要的问题,可以帮助理解很多JS语言中数据处理的底层逻辑和程序设计方面的考虑。所以觉得有必要总结和分享出来。

Bun支持的二进制数据对象

作为一个基础特性,Bun 实现了许多用于处理二进制数据的数据类型和实用方法,其中大部分是Web或者JS标准,也有少数独有的特性。下面先从整体的视角列举如下,然后分别结合示例和代码展开说明:

  • ArrayBuffer 数组缓冲

数据缓冲是在执行环境中的原始的内存数据块,就是数据的最原始形态,它的长度固定,也不可以直接读写,只能通过对应的视图来访问和操作。

  • DataView 数据视图

数据视图是一个类,它提供了一套 get/set API,可以基于偏移量,来读写ArrayBuffer中的一部分字节。它经常用于二进制信息的实际和基本操作。

  • TypedArray 类型化数组

类型化数组,指的的是在数据视图基础上,提供了一系列类似数组一样的接口的对象类,可以用于处理二进制数据(如ArrayBuffer)。具体包括Uint8Array(无符号8位整数数组)、Uint16Array,Int8Array等等。

  • Buffer 缓冲

Buffer是一个Nodejs提供的Uint8Array的子类,在其基础上增加了增加方便性的方法。但它并不是标准的JS特性,而是nodejs API,所以浏览器不支持。但是bun实现了对Buffer的实现和支持。

  • Blob

Blob即二进制大对象(Binary Large Object) ,是浏览器环境中支持的一种数据类型。可以简单的理解Blob是一个二进制数据的容器,但并不是普通文件,而是类似"内存中的文件"。Blob是只读的,它还带有MIME类型,大小等属性。Blog不能直接操作,但提供了一系列转换方法,可以方便的转换成ArrayBuffer、ReadableStream和字符串。

  • File 文件

File是Blob的子类,用于表达一个文件。增加的属性包括文件名和最后修改时间戳。在Node.js v20中有实验性的支持。

  • BunFile

BunFile是Bun提供的一个数据对象。它是Blob的子类,但可以表示一个(延迟加载的)磁盘文件。它可以通过Bun.file(path)方法创建。

下面分别结合一些代码示例深入展开说明。

ArrayBuffer(AB)

AB是JS语言中的一个最基础的二进制数据类型。其实,在2009年之前,还没有语言原生的方法来存储和操纵JS二进制数据。ECMAScript v5 为此引入了一系列新机制。最基本的构建块就是ArrayBuffer,这是一个简单的数据结构,代表内存中的字节序列。

但是要注意和理解的是,尽管名字中有Array,但实际上它并不是一个数组,所以不会支持可能期望的数组方法和运算符。事实上, 在创建之后,我们是无法直接从ArrayBuffer读取或写入值的。它基本上只提供了两个操作,检查大小和创建切片。这些基本操作和示例代码如下:

js 复制代码
// 创建一个固定长度的ab
const abuf = new ArrayBuffer(8);

abuf.byteLength; // => 8

// slice方法,会产生一个新的 ArrayBuffer
const slice = buf.slice(0, 4); 
slice.byteLength; // => 4

除了基本操作之外,AB可以转换(或者创建)成为各种其他类型的数据:

js 复制代码
// TypedArray
new Uint8Array(buf);

// DataView
new DataView(buf);

// Buffer 
Buffer.from(buf); /
Buffer.from(buf, 0, 10); // AB片段

// UTF8 字符串
new TextDecoder().decode(buf);

// Array数组 
Array.from(new Uint8Array(buf));

// Blob
new Blob([buf], { type: "text/plain" });

// 读取流
new ReadableStream({
  start(controller) {
    controller.enqueue(buf);
    controller.close();
  },
});

// 读取流, 使用chunk 
const view = new Uint8Array(buf);
const chunkSize = 1024;

new ReadableStream({
  start(controller) {
    for (let i = 0; i < view.length; i += chunkSize) {
      controller.enqueue(view.slice(i, i + chunkSize));
    }
    controller.close();
  },
});

在真实的程序当中,对于AB的操作,基本上都是通过"视图"来进行的。这就是后续要讨论的DataView和TypedArray。

DataView(DV)

基于ArrayBuffer,我们可以创建DataView,就可以进行进一步的操作,比如读写内容等等:

js 复制代码
// 创建ArrayBuffer
const buf = new ArrayBuffer(4);
// [0b00000000, 0b00000000, 0b00000000, 0b00000000]

// 基于AB创建DV
const dv = new DataView(buf);

// 写入值,读取值
dv.setUint8(0, 3); // write value 3 at byte offset 0
dv.getUint8(0); // => 3
// [0b00000011, 0b00000000, 0b00000000, 0b00000000]

// 写入更大的值
dv.setUint16(1, 513);
// [0b00000011, 0b00000010, 0b00000001, 0b00000000]

// 按照位置和类型取值
console.log(dv.getUint16(1)); // => 513
console.log(dv.getUint8(1)); // => 2
console.log(dv.getUint8(2)); // => 1

// 写值溢出,因为float64需要8字节
dv.setFloat64(0, 3.1415);
// ^ RangeError: Out of bounds access

可以看到,这里基于呈现的方便,AB是一个二进制数值的数组(实际上,只是一个内存区域)。而基于AB对象创建的DV,才提供了数据的访问和操作的能力。

同样,DataVew可以转换成为其他类型或者形式的数据,其核心就是Dataview的buffer属性:

js 复制代码
// ArrayBuffer
view.buffer;

// TypedArray
new Uint8Array(view.buffer, view.byteOffset, view.byteLength);
new Uint16Array(view.buffer, view.byteOffset, view.byteLength / 2);
new Uint32Array(view.buffer, view.byteOffset, view.byteLength / 4);
// etc...

// Buffer
Buffer.from(view.buffer, view.byteOffset, view.byteLength);

// utf8 string
new TextDecoder().decode(view);

// number[]
Array.from(view);

// Blob
// only if arr is a view of its entire backing TypedArray
new Blob([view.buffer], { type: "text/plain" });

// readable stream
new ReadableStream({
  start(controller) {
    controller.enqueue(view.buffer);
    controller.close();
  },
});

// readable stream chunk
new ReadableStream({
  start(controller) {
    for (let i = 0; i < view.buffer.byteLength; i += chunkSize) {
      controller.enqueue(view.buffer.slice(i, i + chunkSize));
    }
    controller.close();
  },
});

DV提供的其他主要数据操作方法如下包括:

get/setBigInt64(),get/setBigUint64(),get/setFloat32(), get/setFloat64(),get/setInt16() , get/setInt32(), get/setInt8(), get/setUint16(),get/setUint32(), get/setUint8() 等。

显然,虽然有这么多类型方法,但使用起来是非常不方便的,所以需要引入类似于数组的操作方法,来提高二进制数据操作的方便和效率,这些我们后面会在TypedArray章节中探讨。

TypedArray(TA)

这部分内容,其实才是我们在日常开发中接触和使用最多的。

基本概念

TA是一个家族和统称,其实并没有一个类叫做TypedArray,我们实际使用的一般就是一个确定类型的成员,如Uint8Array(就是一般所谓的字节数组,因为每个数组成员,刚好是一个字节)。要更好的理解的话,TA的基础应该是基于DV,但它提供了一系列操作接口,可以像操作数组一样操作DV。这时候的二进制数据,才真正表现的像一个数组。

下面的示例代码,可以帮助我们理解这个概念:

js 复制代码
// 创建 ArrayBuffer
const buffer = new ArrayBuffer(3);

// 基于AB创建Uint8Array 
const arr = new Uint8Array(buffer);

// 初始内容都是0
// contents are initialized to zero
console.log(arr); // Uint8Array(3) [0, 0, 0]

// 数组元素赋值
arr[0] = 0;
arr[1] = 10;
arr[2] = 255;

// 越界操作
arr[3] = 255; // no-op, out of bounds

多种类型数组

类型数组支持的数组成员类型很多,它们包括:

  • Uint(8/16/32): 无符号整数,8位/16位/32位,分别占用1/2/4个字节
  • Int(8/16/32): 有符号整数,8位/16位/32位,分别占用1/2/4个字节
  • Float(16/32/64): 16位/32位/64位的浮点数,分别占用2/4/8个字节
  • BigUint: 64位无符号大整数,占用8个字节
  • BigInt64: 64位有符号大整数,占用8个字节
  • Uint8Clamped: 与Uint8Array相同,但在为元素分配值时自动归一范围0-255

这样,即使表示同一个信息,其实是可以使用不同类型的类型数组的,例如:

为什么要这么做? 笔者觉得,在某些场景下,选择合适的数据类型,可以简化编程,或者提高信息处理的效率。比如在密码学中,AES算法和很多步骤操作数据都是以4个字节为单位的,这时直接使用Uint32,就可能简化计算和表示。

但使用类型数组进行转换的时候,还需要注意一个问题就是转换时的长度是需要匹配的,比如下面这个示例:

js 复制代码
const buf = new ArrayBuffer(10);
const arr = new Uint32Array(buf);
// ^  RangeError: ArrayBuffer length minus the byteOffset
//     is not a multiple of the element size

// 修正方式
const arr = new Uint32Array(buf, 0, 2);
/*
  buf    _ _ _ _ _ _ _ _ _ _    10 bytes
  arr   [_______,_______]       2 4-byte elements
*/

arr.byteOffset; // 0
arr.length; // 2

示例中,是无法将长度为10的AB直接转换为Uint32Array的,但构造方法提供了可选参数,可以强行指定将AB切片后满足类型数组的要求。

Uint8Array

在各种类型数组中,最常用的和最基础的,无疑就是Uint8Array了。它的每一个成员都是一个无符号整数(0~255,8位),就是一个"字节",所以也常被称为"字节数组,bytes"。逻辑上,在现在当前这个以二进制为基础的计算体系中,任何信息,都可以简单的使用这种字节数组的形式来表示。所以它基本上是事实的二进制数据的表示方式,在各种编程语言中,都以不同的形式得到了支持。

基本操作

以Uint8Array为例,关于一个类型数组的常见操作方式如下:

js 复制代码
// 来自 ArrayBuffer
const buf = new ArrayBuffer(10);
const arr = new Uint8Array(buf);

// 成员赋值
arr[0] = 30;
arr[1] = 60;

// 简单构造
// from an array of numbers
const arr1 = new Uint8Array([0, 1, 2, 3, 4, 5, 6, 7]);
arr1[0]; // => 0;
arr1[7]; // => 7;

// 来自另一个类型数组
const arr2 = new Uint8Array(arr);

// 通用数组方法
const arr = new Uint8Array([0, 1, 2, 3, 4, 5, 6, 7]);

arr.filter(n => n > 128); // Uint8Array(1) [255], 过滤
arr.map(n => n * 2); // Uint8Array(8) [0, 2, 4, 6, 8, 10, 12, 14],映射
arr.reduce((acc, n) => acc + n, 0); // 28,削减
arr.forEach(n => console.log(n)); // 0 1 2 3 4 5 6 7 , 遍历
arr.every(n => n < 10); // true, 判全
arr.find(n => n > 5); // 6, 查找
arr.includes(5); // true,包含
arr.indexOf(5); // 5, 索引

至此,开发中对于二进制数据的操作方式和能力,才算达到一个比较丰富和高效的程度。所以前面那么多种数据类型,本质上都是一样的,但在数据操控层面,却有着发展和演进的层次和过程。

字符串

对文本和字符串进行编码和解码,是业务应用开发中非常常用的操作。所谓编码,就是将文本或者字符串转换成为二进制方式(如字节数组);而解码就是反向操作。为此JS提供了很多种方式,来方便这个处理。

开发中应该了解,这里笔者使用的"文本"和"字符串"是不同的概念。实际上确实有两种看起来都是字符串形式但实际含义不同的信息。文本是指人类可以阅读和理解的信息,通常就是自然语言的文字,它实际上是使用某种编码方式(如UTF8)的原始信息;而另一种字符串它使用一种约定的表示方式(限制在某些字符范围,如base64和hex),它实际上就是二进制数据,只不过使用字符方式呈现而已。

下面的示例可以帮助读者理解这两类信息的差异:

js 复制代码
// 简单二进制信息
new Uint8Array([1, 2, 3, 4, 5]).toBase64(); // "AQIDBA=="
Uint8Array.fromBase64("AQIDBA=="); // Uint8Array(4) [1, 2, 3, 4, 5]

new Uint8Array([255, 254, 253, 252, 251]).toHex(); // "fffefdfcfb=="
Uint8Array.fromHex("fffefdfcfb"); // Uint8Array(5) [255, 254, 253, 252, 251]

// 文本信息
const encoder = new TextEncoder();
const bytes = encoder.encode("hello world");
// => Uint8Array(11) [ 104, 101, 108, 108, 111, 32, 119, 111, 114, 108, 100 ]

const decoder = new TextDecoder();
const text = decoder.decode(bytes);
// => hello world

转换

将一个类型数组(通常是Uint8Array),转换成为其他结构和类型的操作如下:

js 复制代码
// ArrayBuffer
arr.buffer

// dataview
new DataView(arr.buffer, arr.byteOffset, arr.byteLength);

// Buffer
Buffer.from(arr);

// UTF8 string
new TextDecoder().decode(arr);

// number[]
Array.from(arr);

// Blob
// only if arr is a view of its entire backing TypedArray
new Blob([arr.buffer], { type: "text/plain" });

// readable stream
new ReadableStream({
  start(controller) {
    controller.enqueue(arr);
    controller.close();
  },
});

// readable stream chunk
new ReadableStream({
  start(controller) {
    for (let i = 0; i < arr.length; i += chunkSize) {
      controller.enqueue(arr.slice(i, i + chunkSize));
    }
    controller.close();
  },
});

Buffer

在对TypedArray有了一定程度的了解之后,我们应该就能够更好的理解这个Buffer了。

简单而言,Buffer就是nodejs的开发者,在TypedArray(具体而言应该就是Uint8Array)的基础上,又进行了封装和改进,基于场景和需求,增加实现了很多实用的方法和特性,这样可以大大简化后续的开发工作。这些改进,开发者在日常工作中应该也可以明显体会的到,比如从各种格式或者编码创建Buffer,转换类型,定位和切片,复制和写入,密码学操作等等。作为继承者和同行者,Bun也实现的Buffer,而且用法和nodejs无基本异。

Buffer的常见操作包括从各种原型数据中创建,转换,切片,合并,写值,输出等等,下面是一些简单的示例代码:

js 复制代码
// 从文本创建
const buf = Buffer.from("hello world");
// => Buffer(11) [ 104, 101, 108, 108, 111, 32, 119, 111, 114, 108, 100 ]

// Buffer属性和内容
buf.length; // => 11
buf[0]; // => 104, ascii for 'h'

// 写入buffer
buf.writeUInt8(72, 0); // => ascii for 'H'

// 输出字符串,默认是utf8编码
console.log(buf.toString());
// => Hello world

将Buffer转换成为其他类型的数据,可以参考以下的操作(其实和TypedArray基本无异):

js 复制代码
// ArrayBuffer
buf.buffer

// dataview
new DataView(buf.buffer, buf.byteOffset, buf.byteLength);

// TypedArray
new Uint8Array(buf);

// string
buf.toString(); // utf8 string
buf.toString("base64"); // to base64 string
buf.toString("hex"); // to hex string

// number[]
Array.from(buf);

// Blob
new Blob([buf], { type: "text/plain" });

// readable stream
new ReadableStream({
  start(controller) {
    controller.enqueue(buf);
    controller.close();
  },
});

// readable stream chunk
new ReadableStream({
  start(controller) {
    for (let i = 0; i < buf.length; i += chunkSize) {
      controller.enqueue(buf.slice(i, i + chunkSize));
    }
    controller.close();
  },
});

关于Buffer相关的技术内容很多,相关扩展信息可以参考nodejs技术文档中的专题章节:

nodejs.org/api/buffer....

Blob

和前面讨论的ArrayBuffer、TypedArray、Dataview和Buffer等基于JavaScript语言的二进制数据类型不同,Blob最初在浏览器中实现,现在是一个标准的Web API,当然在nodejs和bun中也实现了对其的支持。

Blob确实通常在浏览器环境中使用,用于承载和表示二进制的信息,比如图片、文件等等,当然也可以用于文本信息。下面是一些操作Blob的示例代码:

js 复制代码
// 创建blob对象
const blob = new Blob(["<html>Hello</html>"], {
  type: "text/html",
});

// blob属性
blob.type; // => text/html
blob.size; // => 19

// 多种内容组合
const blob = new Blob([
  "<html>",
  new Blob(["<body>"]),
  new Uint8Array([104, 101, 108, 108, 111]), // "hello" in binary
  "</body></html>",
]);

// 内容访问和转换
await blob.text(); // => <html><body>hello</body></html>
await blob.bytes(); // => Uint8Array (copies contents)
await blob.arrayBuffer(); // => ArrayBuffer (copies contents)
await blob.stream(); // => ReadableStream

从上面的示例我们可以了解到,blob对象最大的特点是对于二进制数据,增加了MIME类型的描述,这样我们就可以简单的将Blob理解成为"带信息类型"的二进制信息。此外,Blob还提供了一些方便性的操作方法,来帮助访问和转换内容。

下面是将Blob对象转换成为其他类型的一般操作方法,注意这些转换方法的同步操作:

js 复制代码
// ArrayBuffer
await blob.arrayBuffer();

// dataview
new DataView(await blob.arrayBuffer());

// TypedArray
await blob.bytes();

// Buffer
Buffer.from(await blob.arrayBuffer());

// utf8 string
await blob.text();

// number[]
Array.from(buf);

// Blob
Array.from(await blob.bytes());

// readable stream
blob.stream();

File

File也是一个Web API。它是Blob的子类,增加了name和lastModified的属性,用于更好的表示文件对象。虽然它的使用场景一般还是浏览器,最常见的就是结合浏览器的<input type="file"> 元素使用。例如下面的参考代码:

js 复制代码
// 浏览器on browser!
<input type="file" id="file" />

// 文件列表
const files = document.getElementById("file").files;
// => File[]

// 也可以从内容创建
const file = new File(["<html>Hello</html>"], "index.html", {
  type: "text/html",
});

Nodejs和Bun同样支持File。

BunFile

BunFile是Bun实现和提供的一个对象。它本质上是一个Blob的子类,和File类似。但是,BunFile的最大特点是它实现了文件对象的"延迟加载表示",也就是说,BunFile实例化的时候,不需要实际读取并加载文件内容,可以只进行逻辑操作,这样可以大大减少在文件操作和管理时的资源占用。

下面是一个简单的代码示例:

js 复制代码
// 访问并创建BunFile实例
const file = Bun.file("index.txt");
// => BunFile

// 读取内容
await file.bytes();

其他更多详细的信息,笔者已经在系列文章中相关章节进行了探讨,读者也可以参考Bun官方技术文档的相关章节。

Stream

流是处理二进制数据的一种重要抽象,它在网络系统应用编程中的应用广泛而常见,比如用于读取和写入文件,发送和接收网络请求以及处理大量数据。

信息流技术应用的底层逻辑是,如果一个数据太大,以至于超出硬件的容量(比如内存大小),就需要有一个机制,可以将大数据分割成很多小的数据块,然后依次进行处理,一次处理一小块,这样就不会对系统的容量和处理能力造成很大的压力和挑战了。当然,这种机制的代价就是需要有一个可靠强大的处理过程的组织和管理能力,这就是stream技术实现的核心。

关于Stream的更详细的内容,笔者在以前讨论nodejs技术的系列文件中,已经有所提及。同时笔者也打算在本系列文章中再结合Bun的技术和实现进行再次探讨,所以这里就不再展开讨论。

在Stream和其他二进制表示类型的相互转换方面,Bun选择使用Response作为方便的中间表示,可以更轻松地将ReadableStream转换为其他格式。逻辑上可以将Readable Stream转换为arrayBuffer后,使用arrayBuffer的转换方式。但Bun和Reponse也提供了一些方法来直接操作。

js 复制代码
// arrayBuffer
const buffer = new Response(stream).arrayBuffer();
Bun.readableStreamToArrayBuffer(stream);

// Uint8Array 和 TypedArray
new Response(stream).bytes();
Bun.readableStreamToBytes(stream);

const buf = await new Response(stream).arrayBuffer();
new Int8Array(buf);
new Int8Array(Bun.readableStreamToArrayBuffer(stream));


// Dataview
const buf = await new Response(stream).arrayBuffer();
new DataView(buf);

new DataView(Bun.readableStreamToArrayBuffer(stream));

// Buffer
const buf = await new Response(stream).arrayBuffer();
Buffer.from(buf);
Buffer.from(Bun.readableStreamToArrayBuffer(stream));

// Utf8 string
await new Response(stream).text();
await Bun.readableStreamToText(stream);

// number[]
const arr = await new Response(stream).bytes();
Array.from(arr);
Array.from(new Uint8Array(Bun.readableStreamToArrayBuffer(stream)));

// Blob
new Response(stream).blob();

// readableStream 分离成两个流
const [a, b] = stream.tee();

至此,Bun中对于二进制数据支持的所有内容,基本上都涉及到了。读者也应当能够理解这些技术的来源、特性和应用的场景,从而对应用的开发提供更好的支持。

小结

本文探讨了在Bun中支持和实现的各种二进制数据的概念和技术,这些内容包括ArrayBuffer、DataView、TypedArray、Buffer、Blob、File/BunFile、Stream等等。并且进一步整理和明晰了它们的基本概念、关联关系、应用场景、基本操作和相互转换等方面的内容。

相关推荐
高松燈2 分钟前
开发中常见的String的判空场景总结
后端
Point12 分钟前
[LeetCode] 最长连续序列
前端·javascript·算法
程序员NEO15 分钟前
我只说需求,AI 全程托管,代码自己长出来了!
人工智能·后端
白露与泡影22 分钟前
Spring Boot 优雅实现多租户架构!
spring boot·后端·架构
趣多多代言人27 分钟前
20分钟学会TypeScript
前端·javascript·typescript
编写美好前程1 小时前
springboot项目如何写出优雅的service?
java·spring boot·后端
雲墨款哥1 小时前
一个前端开发者的救赎之路——JS基础回顾(二)
前端·javascript
Aurora_NeAr1 小时前
大数据之路:阿里巴巴大数据实践——实时技术与数据服务
大数据·后端
过客随尘1 小时前
Mysql RR事务隔离级别引发的生产Bug,你中招了吗?
后端·mysql
知其然亦知其所以然1 小时前
社招 MySQL 面试官问我:InnoDB 的 4 大特性?我靠这 4 个故事一战封神!
后端·mysql·面试