为 Node.js 构建二进制编译器

发布: (2026年1月6日 GMT+8 20:08)
7 min read
原文: Dev.to

Source: Dev.to

请提供您希望翻译的完整文本(除代码块和 URL 之外的内容),我将按照要求将其翻译成简体中文并保留原有的格式、Markdown 语法以及技术术语。谢谢!

概述

二进制编译器接受一种数据格式并生成纯粹、平坦的二进制数据。

data → buffer

出现这些技术(protobuf、flatbuffers,等等)的原因很简单:当前的互联网通信标准臃肿。是的,JSON 的效率极低,但这是我们为可调试性和可读性付出的代价。

这里的这个数字 → 1JSON 中比纯二进制大 200 %

'1'   = 8 bits
'"'   = 8 bits
'"'   = 8 bits
----------------
Total: 24 bits

在纯二进制中它只有 8 bits

但这还不是最离谱的地方。JSON 中的每个值还必须携带一个

{
  "key": "1"
}

想一想,这现在需要多少位?

别只听我说——下面有证据。

示例对象

const obj = {
  age: 26,
  randNum16: 256,
  randNum16: 16000,
  randNum32: 33000,
  hello: "world",
  thisisastr: "a long string lowkey",
}

大小对比

obj.json  98 kb
obj.bin   86 kb   # ← 没有协议(键 + 值序列化)
obj2.bin  41 kb   # ← 有协议(仅值,协议负责键)

即使键已编码,纯二进制仍然更为紧凑,且随着负载增大,节省的空间会迅速累积。

性能

Execution time comparison (milliseconds):

Name | Count | Min(ms) | Max(ms) | Mean(ms) | Median(ms) | StdDev(ms)
-----+-------+---------+---------+----------+------------+-----------
JSON | 100   | 0.000   | 0.000   | 0.000    | 0.000      | 0.000
TEMU | 100   | 0.000   | 1.000   | 0.010    | 0.000      | 0.099

最长的一次运行用了 1 ms,这其实有点误导——在 100 次采样中它是唯一的异常值。我的猜测是:Node 在分配初始缓冲区时产生的开销。

Source:

为什么需要二进制编译器?

我最近一直在为 Node.js 构建更多实时系统和工具,而 JSON 是带宽大户。在以 JSON 作为主要传输方式的情况下,构建低延迟系统几乎是不可想象的。

这也是 protobuf 等技术出现的原因,尤其适用于服务器间通信。它们速度极快,且体积更小。

我并不是在使用通用方案,而是尝试自己动手实现,以此为我想要带到 Node.js 的项目奠定基础:

  • tessera.js – 一个使用原始字节(SharedArrayBuffer)的 C++ N‑API 渲染器
    How I built a renderer for Node.js
  • shard – 一个亚 100 纳秒延迟的分析器(原生 C/C++ 通常在 5–40 ns 左右)
  • nexus – 一个类似 Godot 的 Node.js 游戏引擎

这个实验实际上是为这些项目准备可靠的二进制编码/解码器。

构建编译器

注意: 这属于实验性质。我真的就是打开 VS Code,直接开始写代码——没有做任何调研,也没有阅读论文。

说实话,这是学习任何东西的最佳方式:先凭直觉糟糕地实现一次,然后再看看专家是怎么做的。你会注意到这里的命名几乎没有经过思考,完全是随性而为。这是有意为之;这就是我的原型开发方式。

工具和设置

import { writeFileSync, fstatSync, openSync, closeSync } from "fs";

const obj = {
  age: 26,
  randNum16: 256,
  randNum16: 16000,
  randNum32: 33000,
  hello: "world",
  thisisastr: "a long string lowkey",
  // stack: ['c++', "js", "golang"],
  // hobbies: ["competitive gaming", "hacking node.js", "football"]
};

const TYPES = {
  numF: 1,   // float
  numI8: 2,  // int8
  numI16: 3, // int16
  numI32: 4, // int32
  string: 5,
  array: 6,
};

function getObjectKeysAndValues(obj) {
  // JS preserves property order per spec
  const keys = Object.keys(obj);
  const values = Object.values(obj);
  return [keys, values];
}

function isFloat(num) {
  return !Number.isInteger(num);
}

序列化键

简单协议:

[allKeysLen | keyLen | key] → buffer
function serKeys(keys) {
  let len = 0;

  for (let i = 0; i  255)
      throw new Error(`Key too long: "${k}" (${k.length} bytes)`);

    buf.writeUInt8(k.length, writer++);
    const written = buf.write(k, writer, "utf8");
    writer += written;
  }

  return buf;
}

反序列化就是逆过程:读取长度 → 读取键 → 移动指针。

function deserKeys(buf) {
  let reader = 2;
  const keys = [];

  while (reader = -128 && num = -32768 && num = -128 && num = -32768 && num  8
i16 -> 16
i32 -> 32

编译器

序列化

function gen(obj, protocol = false) {
  if (typeof obj !== "object")
    throw new Error("Must be Object");

  let cache = new Map();
  const [keys] = getObjectKeysAndValues(obj);

  let serk;
  if (!protocol) {
    serk = serKeys(keys);
  }

  let length = 0;

  for (const key of keys) {
    let buf;

    switch (typeof obj[key]) {
      case "number":
        buf = seNumber(obj[key]);
        break;
      case "string":
        buf = seString(obj[key]);
        break;
      default:
        continue;
    }

    length += buf.length;
    cache.set(key, buf);
  }

  const dataBuf = Buffer.allocUnsafe(length);
  let writer = 0;

  for (const key of keys) {
    const b = cache.get(key);
    if (b) {
      b.copy(dataBuf, writer);
      writer += b.length;
    }
  }

  return protocol ? dataBuf : Buffer.concat([serk, dataBuf]);
}

反序列化

function unserData(buf) {
  let reader = 0;
  let data = [];

  while (reader < buf.length) {
    const t = buf.readInt8(reader++);
    switch (t) {
      case 1:
        data.push(buf.readFloatBE(reader));
        reader += 4;
        break;
      case 2:
        data.push(buf.readInt8(reader++));
        break;
      case 3:
        data.push(buf.readInt16BE(reader));
        reader += 2;
        break;
      case 4:
        data.push(buf.readInt32BE(reader));
        reader += 4;
        break;
      case 5:
        const len = buf.readInt16BE(reader);
        reader += 2;
        data.push(buf.subarray(reader, reader + len).toString("utf8"));
        reader += len;
        break;
    }
  }

  return data;
}

统一解析器

function ungen(buf, protocol = false) {
  if (!protocol) {
    const keysLen = buf.readInt16BE(0);
    const keysBuf = buf.subarray(0, keysLen);
    deserKeys(keysBuf);
    return unserData(buf.subarray(keysLen));
  }
  return unserData(buf);
}

运行它

健全性检查

let samples = { JSON: [], TEMU: [] };

function J() {
  const start = process.hrtime.bigint();
  JSON.parse(JSON.stringify(obj));
  const end = process.hrtime.bigint();
  samples.JSON.push((end - start) / 1_000_000n);
}

function T() {
  const start = process.hrtime.bigint();
  const b = gen(obj, true);
  ungen(b);
  const end = process.hrtime.bigint();
  samples.TEMU.push((end - start) / 1_000_000n);
}

采样

const WARMUP = 100_000;
const SAMPLE = 100;

for (let i = 0; i < WARMUP; i++) {}
for (let i = 0; i < SAMPLE; i++) J();

for (let i = 0; i < WARMUP; i++) {}
for (let i = 0; i < SAMPLE; i++) T();

console.dir(samples.TEMU);

它可以工作。

真正的问题是: 在进行适当的研究后,这还能提升多少?

Back to Blog

相关文章

阅读更多 »

Socket.IO 服务器基准测试

Socket.IO 服务器基准测试 !Sahaj Bhat https://media2.dev.to/dynamic/image/width=50,height=50,fit=cover,gravity=auto,format=auto/https%3A%2F%2Fdev-to-uploads....