🥟

Bun.jsのconsole.logを改善する

2022/11/09に公開

はじめに

この記事は東京大学工学部電気電子工学科・電子情報工学科 3 年後期実験「大規模ソフトウェアを手探る」のレポートとして作成されました。

この実験はソースコードの全容を把握することが困難な大規模オープンソースソフトウェアに対して、限られた時間で手探りを入れながら所望の機能を追加または拡張しようという実験です。

本記事では調査対象の OSS としてBun.jsを選択し、調査した結果について記録しています。

1. 成果概要

以下のリポジトリに本家のリポジトリを fork して実装を行いました。

https://github.com/doss-eeic/2022-03-bun

Bun.js の console.log 関数を改善しました。
具体的には、改行・インデントが全く反映されなかった console.log について、それらを反映させることによって、視認性を改善しました。

改良前の console.log は、以下の通りです。
表示させるサンプルとしては、僕の VSCode の settings.json の一部を切り出し、利用しています。

  • 表示させたテキスト
{
  "files.autoSave": "afterDelay",
  "files.eol": "\n",
  "[rust]": {
    "editor.defaultFormatter": "rust-lang.rust"
  },
  "[javascript]": {
    "editor.tabSize": 2,
    "editor.maxTokenizationLineLength": 2500,
    "editor.defaultFormatter": "esbenp.prettier-vscode"
  },
  "[typescript]": {
    "editor.tabSize": 2,
    "editor.defaultFormatter": "esbenp.prettier-vscode"
  },
  "[typescriptreact]": {
    "editor.tabSize": 2,
    "editor.defaultFormatter": "esbenp.prettier-vscode"
  },
  "[tex]": {
    "editor.suggest.snippetsPreventQuickSuggestions": false,
    "editor.tabSize": 2
  },
  "[latex]": {
    "editor.suggest.snippetsPreventQuickSuggestions": false,
    "editor.tabSize": 2
  },
  "[bibtex]": {
    "editor.tabSize": 2
  },
  "latex-workshop.intellisense.package.enabled": true,
  "latex-workshop.latex.autoBuild.run": "never",
}


  • 改良前 console.log の表示

・・・見づらい!!!
改行もインデントもないので、内容がわかりにくいです。

2. Bun.js について

Bun.js は 2022 年 7 月に発表された、オールインワンの JavaScript ランタイムです。

https://twitter.com/jarredsumner/status/1544460933753229312?s=20&t=ORGN80uF0xgXlWqiczolAQ

Bun.js 以前の JavaScript ランタイムでは、npm や webpack など複数の異なるライブラリを利用し提供されていた機能をワンパッケージで提供しているというのが最大の特徴であり、npm などでは JavaScript で記述されていた処理をほとんど Zig での記述に置き換えられていることからnpm installに相当するbun installや webpack に対応するbun bunコマンドなどは Node.js のライブラリに比べて非常に高速に動作します。

これらの特徴から、Bun.js は非常に多くの注目を集めており、ほとんど個人によるプロジェクトながらベンチャーキャピタルから 10 億円を調達し、会社を設立したそうです

https://news.mynavi.jp/techplus/article/zerojavascript-19/

ちなみに、2022 年 7 月時点の日本語記事では「約 9 億円」と書かれていましたが、2022 年 9 月の記事では「約 10 億円」と書かれていました。
円安恐ろしい・・・

3. 環境構築

Bun.js への調査を開始するにあたって、まずは環境構築を行いました。

VSCode devcontainer を使用

devcontainer は Docker container に接続して開発を行うための VSCode のプラグインです。

Bun.js には公式で VSCode devcontainer を利用して環境構築を行うための Docker イメージが用意されています。
今回の開発においてはこのイメージを利用して開発を行うことにしました。

Docker を利用することで、依存関係のインストールで詰まることなくチーム内で開発環境を構築・共有できました。
(本来は llvm, clang, Zig, rust など多くの依存関係を手動でインストールする必要があります。)

Azure VM を利用

devcontainer 用のイメージ自体は公式で用意されていて簡単に利用できるのですが、Bun.js(+WebKit)のビルドが最低でも 22GB のメモリを消費するらしく、自分たちが所有するローカルの PC ではビルドがうまくいきませんでした。

Troubleshooting (general)

If you encounter error: the build command failed with exit code 9 during the build process, this means you ran out of memory or swap. Bun currently needs about 22 GB of RAM to compile.

oven-sh/bun/README.md - Troubleshooting (general)

そこで、Azure 上で VM インスタンスを作成し、そこで開発環境を構築し直しました。

ところで、クラウドを利用するとなると金銭面が心配になるかと思いますが、幸い今回はAzure for studentsというプログラムにより$100 分の無料枠を利用することができたため、追加の費用はかかりませんでした。

余談ですが、この Azure for Students というプログラムは Azure で自由に利用できる$100 分のクレジットだけではなく、Windows のライセンスなども取得することができるため、おすすめです


4. 調査

実装に取り掛かる前に、Bun.js や他の JavaScript ランタイムについて調査を行いました

4.1 Bun.js の大まかな構造

以下の図は Bun.js のアーキテクチャを簡略化して示した図です。

bun.js's architecture

図を見るとわかる通り、Bun.js のランタイムは外部ライブラリに依存する部分・自前で実装されている部分との組み合わせにより実装されています。

ここでは図中の各部分について簡単な説明をしようと思います。

JS parser

  • JavaScript を AST(抽象構文木)にパースし、JavaScript エンジンが解釈できる形に変換する部分です
  • Bun.js は TypeScript をそのまま実行できるので、素の JavaScript だけではなく、TypeScript のパースも行うことができます

JavaScript エンジン

  • パーサによって変換された抽象構文木を実際に解釈・実行します
  • Bun.js においては、WebKit(Safari に利用されるレンダリングエンジン)に含まれる JavaScript エンジンである JavaScriptCore が利用されています
  • 他の有名な JavaScript ランタイムである Node.js や Deno については Chrome の JavaScript エンジンである V8 を利用しているので、JavaScriptCore を利用している点は Bun.js の特徴の一つです

Bindings

  • HTTP Server (Bun.serve())など本来の JavaScript には存在しない機能や、各種組み込み関数を実装しています
  • 今回の目標である console.log 関数についてもこの bindings の中で Zig により実装されています(該当部分)

イベントループ、非同期 I/O、thread プール

  • JavaScript について調べるとよく非同期ノンブロッキングという言葉に出会うことがあると思いますが、それを実現するための非同期 I/O が epoll または kqueue というシステムコールによって提供されています。(epoll は linux、kqueue は MacOS のシステムコールです)

その他の外部依存関係

  • WebKit の他にも Bun.js では様々な外部ライブラリが使われています。
  • 例えば、boring SSL (Google による OpenSSl fork)などが使われています

JavaScript ランタイムのアーキテクチャについては、以下の記事が参考になったので、興味がある人は是非読んでみてください(Node.js についての記事)

4.2 現在の Bun.js の Console 実装

Bun.js の console.log の処理はsrc/bun.js/bindings/exports.zig内にZigConsoleClientとして実装されています。

ZigConsoleClient 構造体の内部には console.log や console.warn のときに呼び出されるmessageWithTypeAndLevel()関数の他にも、console オブジェクトから呼び出される以下の関数が定義されています
(いくつかの関数は関数定義だけされていて内部は未実装のようでした)

  • messageWithTypeAndLevel
  • count
  • countReset
  • time
  • timeLog
  • timeEnd
  • profile
  • profileEnd
  • takeHeapSnapshot
  • timeStamp
  • record
  • recordEnd
  • screeenshot

また、これらそれぞれの関数について comptime の処理(Zig 特有の機能でコンパイル時に型情報を取得・編集したり、演算を行うことができる)を行い、C/C++から呼び出せる形式でエクスポートされています

以下はその実装の該当部分です

  pub const Export = shim.exportFunctions(.{
      .@"messageWithTypeAndLevel" = messageWithTypeAndLevel,
      .@"count" = count,
      // (...中略)
      .@"record" = record,
      .@"recordEnd" = recordEnd,
      .@"screenshot" = screenshot,
  });

  comptime {
      @export(messageWithTypeAndLevel, .{
          .name = Export[0].symbol_name,
      });
      @export(count, .{
          .name = Export[1].symbol_name,
      });
      // (...中略)
      @export(record, .{
          .name = Export[10].symbol_name,
      });
      @export(recordEnd, .{
          .name = Export[11].symbol_name,
      });
      @export(screenshot, .{
          .name = Export[12].symbol_name,
      });
  }

shim.exportFunctions()の内部ではそれぞれの ZigConsoleClient で定義されている名前空間と構造体名、関数名を決まったフォーマットに従って連結して出力しています

例えば、messageWithTypeAndLevel()の場合は次のように変換されています

namespace     = "Zig"
struct_name   = "ConsoleClient"
function_name = "messageWithTypeAndLevel"

export_name = namespace + "__" + struct_name + "__" + function_name
            = "Zig__ConsoleClient__messageWithTypeAndLevel"

このようにそれぞれ定義された関数名について@export()関数を用いて C 言語から呼び出せる形式に変換しています

@export(messageWithTypeAndLevel, .{
    .name = Export[0].symbol_name, // Zig__ConsoleClient__messageWithTypeAndLevel
});

このような関数のエクスポートは console 以外の JavaScript の組み込みオブジェクト(processなど)についても同様に行われています

これらの各関数が JavaScriptCore 側から呼び出されることができるようにするためにはもう少し手順を踏む必要があります。console 向けに定義された各種関数は exports.zig と同じディレクトリにあるZigConsoleClient.cppで C++のラッパーを用意されています。この各関数は JavaScriptCore 内の console 実装部分(runtime/ConsoleClient.cpp)に対応しています

このようにして bindings として JavaScript エンジンと各種組み込み関数のつなぎこみが行われています。

4.3 他実装の調査

実装の参考にするために、他の JavaScript ランタイム(Node.js, Deno)における console.log の実装を調査しました。
Node や Deno における console.log 関数は Bun.js のものと比べると非常に高機能であり、インデントありの改行付加処理だけではなく、変数の長さに応じて成形方法を変化させています。
以下は Deno による console.log の表示の一例です

console_log_deno

この画像からも分かる通り配列を単純に走査するだけではなく、配列の後半にある要素の長さも計算して配列の表示方法を変化させていることがわかります

Deno の実装

deno/ext/console内にて定義されています。
Bun.js の実装と比較して特筆するべき点は出力する文字列の生成処理がネイティブの言語(Rust)ではなく、JavaScript として定義されており、この JavaScript ファイルが起動時にブートローダによって読み出されるようになっていることです。

まず、console.log に渡された引数はinspectArgs()に引き渡され、その内部で解析されます

log = (...args) => {
  this.#printFunc(
    inspectArgs(args, {
      ...getConsoleInspectOptions(),
      indentLevel: this.indentLevel,
    }) + "\n",
    1,
  );
};

inspectArgs に渡された引数は、その内部にて更に変数の型ごとに異なる関数に渡され、処理されています。
特に表示時に要素の走査が必要となるもの(Array, Object, Set, Map, etc...)については各種の型に対応した関数(inspectArray など)での前処理を経て、最終的に
inspectIterable関数に引き渡され、共通の処理が行われます。

5. 実装

調査の結果、Deno や Node.js の console.log の実装を Bun.js にそのまま移植するというのは Deno が JavaScript で実装されているのに対し、Bun.js が Zig での実装となっているため簡単ではないと判断し、ひとまずは最低限の改行とインデントを反映することにしました。

5.1 実装方法の概要

次のような JavaScript オブジェクトを出力することを考えます。

obj_example

このような JavaScript オブジェクトは次のような木構造になっています

js-tree

このオブジェクトの全要素は、木構造の根から再帰的に探索を行うことで操作することができ、実際に Bun.js や Deno の実装においてもそのようにして全要素の表示を行っています。

また、木構造のグラフともとのオブジェクトを見比べてみると、木構造の根からの深さとインデントを対応させるといい感じに出力できるのでは無いかということに気づけると思います。
今回の実装においてはそのような方針で実装を行っていこうと思います。

5.2 実際に実装する

JavaScript の console オブジェクトには indentLevel というプロパティが存在しており、Node や Deno の実装ではオブジェクトの再帰的な探索においてその値を変化させながら要素を表示しています。

// JavaScriptのconsoleのプロパティ
console {
  log: [Function: log],
  debug: [Function: debug],
  info: [Function: info],
  dir: [Function: dir],
  dirxml: [Function: dir],
  warn: [Function: warn],
  error: [Function: error],
  assert: [Function: assert],
  count: [Function: count],
  countReset: [Function: countReset],
  table: [Function: table],
  time: [Function: time],
  timeLog: [Function: timeLog],
  timeEnd: [Function: timeEnd],
  group: [Function: group],
  groupCollapsed: [Function: group],
  groupEnd: [Function: groupEnd],
  clear: [Function: clear],
  trace: [Function: trace],
  indentLevel: 0,
  [Symbol(isConsoleInstance)]: true
}

Bun.js の ZigConsoleClient 構造体においてもその内部に保持されている Formatter 構造体に indent の値が保持されているのですが、console.log を行う際には利用されていないようでした。
(将来的に利用することを目的に用意されているが、実装が後回しになっている?)

pub const Formatter = struct {
    remaining_values: []const JSValue = &[_]JSValue{},
    map: Visited.Map = undefined,
    map_node: ?*Visited.Pool.Node = null,
    hide_native: bool = false,
    globalThis: *JSGlobalObject,
    indent: u32 = 0,
    quote_strings: bool = false,
    // (中略)
}

そのため、再帰的な探索のときにこの値を増やしながら探索し、適宜改行を加えていくという変更を行いました。

関数の再帰呼び出しが起こった場合にインデントを増加させる処理を行い、再帰関数が return するときにインデントを減少させるという処理が行えればいいのですが、その処理を記述するのには Zig の defer 構文が役に立ちました。

defer に渡した処理はプログラムを記述した時点では実行されず、defer 文を記述したスコープが終了するまでに実行が遅延されます。
多くの場合は、allocator によって確保したメモリをスコープを抜けるときに開放を忘れることを防ぐために利用されるのですが、今回は再帰関数の呼び出し前にインデント増加を呼び出し、再帰関数の呼び出しが終わったあとにインデントを減少させるのに利用しました

{
    this.indent += 1;
    defer this.indent -|= 1; // 遅延評価構文

    // 実際は複雑な分岐などがあり、途中でスコープを抜けることもある

    someFunction(); // 再帰関数呼び出し

    // defer構文がここで実行
}

上記の例は非常に簡単なものですが、実際にはスコープの途中の分岐の中でスコープを抜けることもあるため、defer を利用しない場合にはその全てに対してthis.indent -|= 1を書かなければいけません。Zig ではそのような煩雑な記述をdeferを利用することにより避けることができます。

6.結果

暴投と同じテキストを console.log で表示させた結果として、改良前・改良後の画像を掲載しています。

  • 改良前

  • 改良後

視認性が格段に向上しましたね!

7. 最後に

今回の実験では Bun.js の console.log の改善について一定の成果を得ることができたと思います。
しかしながら、今回の console.log にはもとの実装から引き継がれる多くの問題点も残しています。
例えば、以下のような問題点があります。

  • BigInt型やArrayBuffer型の表示に全く対応しておらず、それらの方を表示しようとした場合、何も表示されない
  • Deno や Node に比べて単純な処理しか行っていないので、表示する場合の行数が多くなってしまう

また、実装に際して最も大変だったのは、(日本語・英語を通じて)ドキュメントの数が非常に少ないことでした。特に、公式で提供されているドキュメントは git リポジトリの README.md だけであり、多くの情報をソースコードを読むことだけで得る必要がありました。
図らずとも本実験の主題である「手探る」という行為に通じており、その意味では良かったのかなとも思います笑

調査を通じて、そもそもとして Bun.js は Node などに比べてかなり後発のランタイムであり、まだまだ活発に開発が続けられている状況なので荒削りな部分が多いと感じました。
一方で、JavaScript エンジンとして Node などとは異なる JavaScriptCore を利用したり、開発言語として Zig を利用したりするなどかなり攻めた構成となっていますが、それをすることによりほかの JavaScript ランタイムとの差別化が行われていると思いました。

このような特徴から、Bun.js は以前の JavaScript ランタイムとは全く異なる物となっており、自分個人としても今後の動向に注目していきたいと思います。

また、本実験を通しての感想ですが、このような大規模な OSS を手探る体験はあまり経験はなく、最初はどこから手をつければいいか全くわからない状況でしたが、デバッガを利用したり、ソースコードと格闘したりする事により大規模なソフトウェアであっても自分の手で改造を加えることができるという自信につながりました。
今後もこのような大規模なソフトウェアに携わることがあると思いますが、今回の経験を活かせればいいなと思います。

Discussion