likes
comments
collection
share

从字节数组说开去...

作者站长头像
站长
· 阅读数 9

二进制和字节数组

在Web应用的开发工作中,无论是前端还是后端,经常会需要需要处理基础的二进制(binary)数据的情况。就会遇到诸如ArrayBuffer,Uint8Array和Buffer等各种概念和技术。笔者觉得有必要对这些内容进行整理和总结,从而能够更好的支撑开发和应用。

我们现在这个世界主流的计算机系统技术,都是以二进制和冯诺依曼架构为基础的。所以在这些系统中,任何的信息和技术,在系统核心和底层,都是以二进制数据的形式进行操作和处理的。但纯粹的二进制数据(完全由0和1组成)的表示和调试对于开发者而言又过于繁复,因此,在一般的开发环境中,技术性的引入了一种介于高级(自然语言和文字)和底层二进制数组形式中间的表示形式,就是使用字节来表示。一个字节是8位二进制,也可以表示为一个256以内的无符号整数(Uint8);很多个字节用数组来表示。这一,任意类型的信息,都可以在二进制表示的基础上,转换为字节数组的形式来进行表示和操作。

信息的表示

很多初学者可能会有一些疑惑,基本数据类型如布尔、整数和字符串很好理解,那么其他复杂的信息如多语言文本、图片、音频和视频又是怎么处理的呢。

这里简单的分析和整理一下。

  • 文本

文本的问题看起来简单,但其实很麻烦,因为涉及到不同语言和字符集标准兼容的问题。经过很长时间的发展,互联网应用才逐渐统一到基本上使用UTF8字符集标准。所以我们现在基本上所有语言文本的编码方式,都可以统一使用UTF8字符集,并使用统一的编解码方式来进行处理。它的基本概念是为字符集中的每个字符都赋予一个代码,并使用一个或者多个字节来记录。如UTF8就使用可变长度编码,可以使用1个到3个字节来表达某个字符,可以提供非常大的信息空间来容纳世界上所有语言的文字。所有语言的每一个字符,包括很多自定义信息(比如很多图标和表情符号)都可以找到对应的UTF8编码,使用字节的数组来进行表示。

从字节数组说开去...
  • 图片

图片信息编码的最基本概念是将图片先转换成一个像素组成的矩阵;每个像素的颜色,都可以使用三种颜色(RGB,红绿蓝)的组合来表示,如果每个颜色的深度使用256来表示,那么一个像素的颜色就可以使用3个HEX数值来表示。比如“普鲁士蓝”这种颜色就可以使用这个编码#003153来表示。这样,任何一张图片都可以转换成位置+颜色组成的数组,也就可以用字节数组来进行表示了。这是基本原理和方法,实际的图片可能会加入信息压缩的算法,也可能使用不同的色彩或者深度定义,但基本原理和模式都是一样的。

从字节数组说开去...

  • 音频

我们知道,声音的本质是空气的震动;而我们听到的各种不同的声音,是因为同时又很多不同频率的震动。所以,任何声音都可以表示称为在某一个时间点具有不同频率的震动,连续起来就是声音的波纹。因此,音频的编码方式大致是这样的。先将声波在时间轴上划分为小的片段,称为“采样”;然后记录在任何一个时间点上,对应的频率,并将其记录和换算成为一个值,称为“量化”;然后就可以生成量化后的结果,就是一个数字的数组,通过定义采样的频率和量化的精细化,就可以尽量的贴近声波原始的信息(当然也会造成记录数值增多变大)。这样就可以将声音使用字节数组来表示了。

从字节数组说开去...

  • 视频

视频的本质其实就是动态的图片+音频,当然由于应用和场景的丰富,还需要同步的处理多个通道的媒体信息(如多语言音频、字幕等等),它们都有相关的标准和组织规范。但这些不影响它们都可以使用基础的二进制或者字节来进行存储和表达的基本模式。

至此,我们就可以了解到,世界上任何形式和类型的信息,都可以被用适当的方式来进行量化,然后使用数字(二进制或者字节数组)来进行表达和处理。

在不同开发运行环境中的差异

我们在开发中经常提到的如binary(二进制数据),字节数组(Byte Array),数据缓冲(ArrayBuffer和Buffer)其实在技术上是一回事。只是在不同的编程系统中的名称、叫法和处理特性直接略有差异而已。但就是这点差异,也会在各个系统之间造成一些兼容性和互操作的麻烦。所以需要开发人员概念清晰,理解和处理正确。

浏览器和标准环境

在浏览器和标准的Javascript环境中,字节数组通常使用ArrayBuffer作为底层的实现方式。但为了处理和操作方便,采用Uint8Array作为其的TypedArray视图进行应用层面的实现。我们在开发和应用时接触到二进制数据的操作一般都和Uint8Array相关。所以在前端我们的讨论以Uint8Array为主。

Uint8Array这个名字很容易理解,它是一类数组,数组元素都是8位的无符号整数,即一个字节。在程序环境中,Uint8Array的组织方式的特点是:

  • 长度固定,创建后不能改变大小
  • 每个元素占据1个字节,存储8位无符号整数(0-255)
  • 内存连续,性能好,适用于对性能敏感的操作如文件、网络等等
  • 可以按偏移量和长度将ArrayBuffer“切片”为视图
  • 提供map、filter、reduce、join等数组方法
  • 可以访问 underlying ArrayBuffer 的单个字节
  • 与DataView一起使用可以访问 ArrayBuffer 中的任意位置和任意格式的字节
  • 支持所有JS环境,并在Nodejs中,可以很方便的与Buffer互相转换

当然,在实际前端编程工作中,直接遇到需要对二进制数据进行操作的机会其实并不多,一般都是相关的框架、模块和第三方库在内部都进行了处理。所以下面的讨论和代码只是用于了解和熟悉相关概念,具体的操作,反而在后端Nodejs环境中体现的更清晰明确一点。下面是几个常见的操作和场景:

  • 数组的创建:和一般JS对象相同,使用 new 方法,并指定数组大小:
  • 从文本信息中创建,后续可能用于如摘要或者加密等密码学操作

在较新的浏览器环境中提供了TextEncoder和TextDecoder来实现文本信息和字节数组的相互转换,称为编码和解码。

  • 二进制信息

如图片内容可以在canvas加载图片后,使用toDataURL转成Base64,然后在转为Uint8Array。对于输入文件,可以借助FileReader,readAsArrayBuffer方法,读取文件内容为ArrayBuffer,并转成Uint8Array。

  • 密码学操作

新版浏览器的密码学套件,和老旧浏览器的密码学算法库,也会大量使用Uint8Array作为其基本数据形式。但一般都是在内部处理,我们知晓即可。

总之,Uint8Array是JavaScript中处理二进制数据的重要接口之一,可以高效存储和操作ArrayBuffer中的原始字节信息。与TypedArray、DataView结合使用,可以实现复杂的二进制数据处理。

上面提到的相关操作的示例代码如下:


// 创建新字节数组对象
let a = new Uint8Array(20);

// 文本的编码和解码
let a1 =  new TextEncoder().encode("China中国");
let t1 =  new TextDecoder().decode(a1);

// 加载图片数据
const loadImage = ( imgID)=>{
    let dimage= document.getElementById(imgID); 

    // 创建canvas
    let canvas = document.createElement('canvas');
    let ctx = canvas.getContext('2d');

    // 设置canvas宽高为图片宽高 绘制图片到canvas
    canvas.width  = dimage.width;
    canvas.height = dimage.height;
    ctx.drawImage(dimage, 0, 0); 

    // 转换为base64
    let base64 = canvas.toDataURL('image/png').replace(/^data:image\/png;base64,/, '');

    // 处理base64字符串得到字节数组
    let base64Data = base64;  
    let bytes = atob(base64Data);

    let array = new Uint8Array(bytes.length).map((v,i)=> bytes.charCodeAt(i));

    return array;
};

setTimeout(()=>{
    let data = loadImage('im_load'); 
    console.log(data.length, data);
}, 2000);

// 获取文件内容

const input = document.getElementById('fileInput');

input.addEventListener('change', (e) => {
  const file = input.files[0];

  const reader = new FileReader();

  // 读取为 ArrayBuffer
  reader.readAsArrayBuffer(file);

  reader.onload = (e) => {
    const arrayBuffer = e.target.result;

    // 转换为 Uint8Array
    const uint8Array = new Uint8Array(arrayBuffer);

    // 使用 Uint8Array 数据
  };
});


Nodejs

在Nodejs中,为了更好的处理字节数组,在Uint8Array的基础上,引入和实现Buffer类型,并作为其数据处理的主要技术方案,在nodejs程序和库中随处可见。这也是很奇怪的事情,它们都是Javascrip语言体系,都使用V8处理引擎,为什么Buffer这么好的东西,在Browser环境中不能直接使用呢。

在使用Buffer的时候,我们需要先了解一些需要注意的地方。 Buffer的长度是固定的,无法进行动态调整,需要调整的话可能要创建新的Buffer对象;Buffer在Nodejs中是全局对象,不需要引用;从技术上讲,Buffer在底层是对Unit8Array的扩展,所以很容易兼容Uint8Array。由于长度固定,所以nodejs为buffer操作很容易进行优化,所以buffer操作的效率和性能是很高的,只需要注意不要滥用就可以了。

关于Buffer应用和操作等方面的内容,可以大体总结为三个方面:

  • 创建和生成

最常用的就是from方法,可以从对象、字符串、数组或者其他Buffer等多种方式创建。这是一个类的静态方法直接调用。原来的new Buffer()方式,已经不建议使用。最常见的是从一个字符串或者编码字符串中创建,默认编码是UTF8,也可以指定为"base64"或者"hex"编码来解码信息,非常方便。Buffer.from编码方式包括utf8、larin1、base64、base64url、hex、ascii、binary、ucs2、utf16le等等

alloc用于创建一个空Buffer,如果预先知道结果的大小(比如需要将内容合并),就可以使用这个方法。如Buffer.alloc(8,0)就可以创建8比特的空Buffer。

concat也是一种常见的创建方式,它可以连接多个Buffer,在密码学计算操作中经常用到。

在nodejs中,很多操作都会buffer作为结果,比如 readFile,httpRequest,密码学方法、stream等,所以需要开发人员对Buffer和相关特性比较熟悉。下面是一些例子:

// 密码学套件大量使用buffer作为标准数据信息
buf = crypto.createHmac("SHA1",Buffer.from("key")).update("").digest();

// 文件内容获取
buf = fs.readFileSync("1.png");
    

  • 操作和处理

Buffer作为一个“数组”,支持很多数组的操作,最常见就是裁切(slice)和合并(concat)。获取buffer的长度可以直接使用length属性或者byteLength方法; 还有判断一个对象是否是buffer,可以用isBuffer方法;要判断两个Buffer是否内容相同,可以使用compare或者equals方法。要在两个Buffer直接复制数据,可以使用copy或者set方法;要设置一些内容,可以使用fill方法和write方法;在Buffer中,还可以直接判断内容的存在性,使用inludes方法; 也可以使用indexOf和lastIndexOf方法来查找内容的位置。可以使用位置索引像数组一样存取数据。在一定的条件下,也可以像数组一样,使用map和reduce等方法

// buffer的裁切:
let buf = Buffer.from("Whats up");
let buf1 = buf.slice(0,4);
let buf2 = buf.slice(-4);

// buffer的合并
buf3 = Buffer.concat([buf1,buf2]);

// buffer的比较
let e = buf1.compare(buf2); // 0 is equal
// 也可用
e = buf1.equals(buf2);

// buffer内容复制, 意思是将buf1的1~3内容,复制到buf2的第4字节开始之后 
buf1.copy(buf2,4,1,3);
// 等同于
buf2.set(buf1.slice(1,3),4);

buf.fill(0,1,4);

// 是否包括内容
buf.includes("s");

// 内容位置
buf.indexOf("s");

// 数组操作
buf1 = Buffer.from("hello").sort().toString();
buf2 = Buffer.from("hello").map(v=>0|v/2);
buf2 = Buffer.from("hello").reduce((c,v,i)=>(c[i%2] ^=v,c),[]);

// 

补充一些信息: 根据Nodejs的文档,slice方法是不推荐使用的,应当使用subarray,也是可以使用负数来表示位置的。另外,由于通常在前端浏览器环境中是不支持Buffer的,所以如果你要编写前后端兼容的代码,可能就只有使用Uint8Array,很多Buffer的方法就无法直接使用了。

  • Buffer转换和输出

Buffer通常不会作为业务处理的最后结果,所以一般都有输出或者转换的环节。最常用的操作是将Buffer输出成字符串或者编码字符串,使用toString方法就可用。toString方法可以指定编码方式,获取想要的编码结果。常用的编码方式包括utf8(默认)、hex、base64等等。

下面是一些典型的应用方式:

// 编码和解码操作
let str64 = Buffer.from("whats up").toString("base64");
let otext = Buffer.from(str64,"base64").toString();

// 写入文件
fs.write("1.txt", Buffer.from("what's up"));

// Http 响应
response
.type
.send(bufFile)

Uint8Array、ArreyBuffer和Buffer的转换

在实际开发的各种不同的场景和环境中,可能会经常遇到某些方法和对象只支持某种类型的输入,这样需要在不同的字节数组类型之间进行转换以实现兼容。这里专门总结一下,为了方便讨论和理解,笔者画了一张图:

从字节数组说开去...

笔者觉得,这几个概念,从下到上的层次关系是ArrayBuffer-Uint8Array-Buffer。ArrayBuffer其实是基本形式,和真实的数据载体,和在内存中的存储结构;Uint8Array是一种视图和呈现方式,其表现形式就是字节数组,就是无符号的2的8次方整数数组;Buffer是在Uint8Array之上封装的更方便处理的工具类(提供了更丰富方便的功能)。所以其实Buffer和ArrayBuffer之间的转换,是通过Uint8Array完成的。

  • 转为Buffer

通常Nodejs环境中的处理的兼容性是最好的,但有时也会遇到需要转为Buffer的情况。无论是Uint8Array还是ArrayBuffer,都使用Buffer.from方法转化为Buffer对象。

  • 从Buffer转出

ArrayBuffer其实是Buffer的buffer属性,但要注意不能直接引用,真正的内容是一个片段,需要进行offset和slice的处理:

aryBuf = buf.buffer.slice(buf.offset, buf.offset+buf.length)

uintary = new Uint8Array(aryBuf)

  • ArrayBuffer和Uint8Array

转为Uint8Array可以使用new Uint8Array()构造方法;而ArrayBuffer就是Uint8Arrry对象的buffer属性,但可能要进行slice操作。

在Web应用开发中,如果不使用字符串,直接传递二进制数据时,在前端提交的是ArrayBuffer(Uint8Array),而在后端接收到的是Buffer;反之,响应的数据在后端是Buffer,前端接收到的是ArrayBuffer,这些信息的内容是一样的类型和形式不同而已。

当然以上的关系和理解也可能不完全正确,如果读者觉得有问题或不妥的地方,诚望不吝赐教。

Buffer应用的最佳实践和性能优化

显而易见,在后端和Nodejs环境中,Buffer是非常方便和好用的。但功能越强大,也越容易被不当使用,所以自己使用时要特别小心。在技术上,Buffer对象存储在堆内存(Heap)中,而不是栈内存,所以它不会随着函数结束自动释放。 而V8垃圾回收机制是基于引用计数的。如果Buffer对象被引用,即使不再使用也不会被回收。

下面是一些比较好的考虑和做法:

  • 构造时指定大小,避免过度占用内容
  • 在可控的情况下,应该合理规划数据操作,如使用set复制数据,并尽量复用buffer,而非直接复制和构造新对象
  • 及时清理,可以在不使用时调用buf.free()释放内存
  • 避免频繁的进行字符串-Buffer之间的转换,因为这需要内存复制
  • 尽量少用全局变量持有的Buffer对象,因为会导致长时间不释放
  • 检查内存泄漏情况,监控Buffer使用

ArrayBuffer和视图

前面已经提到Uint8Array是ArrayBuffer的视图,它是一种类型的TypedArray。实际上,还有其他的视图对象,如其他类型的TypedArray(Uint16Array等)和DataView等。它们都是用于操作ArrayBuffer的一类特殊对象,其主要特征是:

  • 视图对象通过指定方式解析ArrayBuffer的内存,并以这种解析后的视图进行操作
  • 主要的视图对象有TypedArray(Uint8Array等)和DataView两种
  • TypedArray提供基于类型的视图,如Uint8Array视为8位无符号整数数组
  • DataView允许自定义数据格式、偏移量和大小,提供更灵活的视图
  • 视图对象本身不存储数据,都是对同一ArrayBuffer对象的不同视角解析
  • 视图对象可以高效操作ArrayBuffer中的数据,而无需复制转换
  • 多个视图对象可以同时解析同一ArrayBuffer,协同操作

所以,视图对象实现了面向不同数据类型及格式的ArrayBuffer解析机制。涉及这个机制,是为了更好更高效的对 ArrayBuffer这个通用的二进制数据容器进行操作,扩展其应用范围。