Chapter 02

Node.js/TypeScriptのビルド

Kesin11
Kesin11
2020.11.08に更新

1章はBazelの仕組みや考え方についての紹介でした。2章では日本語ではほとんど紹介されていない、nodejsとTypeScriptをBazelでビルドする方法を中心に実際のコードをお見せしながら以下の内容を紹介します。

  • Bazel自体のセットアップ
  • TypeScriptをBazelでビルドする方法
  • ESLint, jestをBazelから実行する方法
  • 独自ruleを作る方法

なお、この章以降の動作確認はGitHub Codespaces上で動いているDebian GNU/Linux 9 (stretch)にて行っています。(一部macOSでビルドしているところもあります)

Bazelのセットアップ

Bazelのインストールは公式ドキュメントにあるようにaptやhomebrewで行うのが一般的なようですが、nodejsの場合はもっと簡単です。rules_nodejsのドキュメントにあるように@bazel/createでBazelを使うプロジェクトのテンプレートからプロジェクトを始められます。

npm init @bazel {PROJECT_NAME}

package.json, WORKSPACE.bazel, BUILD.bazelの3つのファイルが用意されます。まずはpackage.jsonから見ていきましょう。

package.json
{
    "name": "nodejs",
    "version": "0.1.0",
    "private": true,
    "devDependencies": {
        "@bazel/bazelisk": "latest",
        "@bazel/ibazel": "latest",
        "@bazel/buildifier": "latest"
    },
    "scripts": {
        "build": "bazel build //...",
        "test": "bazel test //..."
    }
}

Bazel関係と思われるパッケージが3つ既に登録されていますね。それぞれ以下のような役割になっています。

  • bazelisk: .bazelversionに書かれたバージョンのBazelをダウンロードし、そのバージョンのBazelを実行するラッパー
  • ibazel: 多くのツールに存在する--watchのような機能をBazelにも提供するツール
  • buildfier: Bazel関係ファイルのLinter, Formatter

npm installを実行するとnode_modules/.bin/bazelが用意されます。実はこれがbazeliskへのシンボリックリンクとなっており、./node_modules/.bin/bazelを実行してみるとBazel本体のインストールが行われます。

$ node_modules/.bin/bazel --version
2020/10/18 00:48:39 Downloading https://releases.bazel.build/3.0.0/release/bazel-3.0.0-linux-x86_64...
bazel 3.0.0

インストールされるBazelのバージョンは.bazelversionで指定されたバージョンです。執筆時点では3.0.0でしたが、例えば試しに.bazelversionを3.3.0に変更してから再実行すると3.3.0のBazelがダウンロードされることを確認できるでしょう。

WORKSPACE

https://bazelbuild.github.io/rules_nodejs/install.html

次はWORKSPACEを見ていきましょう。@bazel/createでセットアップした場合はこのようなWORKSPACE.bazelが最初から用意されています。

WORKSPACE.bazel
# Bazel workspace created by @bazel/create 2.2.1

# Declares that this directory is the root of a Bazel workspace.
# See https://docs.bazel.build/versions/master/build-ref.html#workspace
workspace(
    # How this workspace would be referenced with absolute labels from another workspace
    name = "nodejs",
    # Map the @npm bazel workspace to the node_modules directory.
    # This lets Bazel use the same node_modules as other local tooling.
    managed_directories = {"@npm": ["node_modules"]},
)

# Install the nodejs "bootstrap" package
# This provides the basic tools for running and packaging nodejs programs in Bazel
load("@bazel_tools//tools/build_defs/repo:http.bzl", "http_archive")
http_archive(
    name = "build_bazel_rules_nodejs",
    sha256 = "64a71a64ac58b8969bb19b1c9258a973b6433913e958964da698943fb5521d98",
    urls = ["https://github.com/bazelbuild/rules_nodejs/releases/download/2.2.1/rules_nodejs-2.2.1.tar.gz"],
)

# The npm_install rule runs yarn anytime the package.json or package-lock.json file changes.
# It also extracts any Bazel rules distributed in an npm package.
load("@build_bazel_rules_nodejs//:index.bzl", "npm_install")
npm_install(
    # Name this npm so that Bazel Label references look like @npm//package
    name = "npm",
    package_json = "//:package.json",
    package_lock_json = "//:package-lock.json",
)

workspaceは名前の設定と、Bazelの中でnode_modulesにインストールされるパッケージを参照するための@npmというprefixを設定しています。これは後ほどBUILDの説明で登場します。

http_archiveはBazelでnodejsを扱うためのrules_nodejsをダウンロードします。1章で紹介したように、Bazelではhttp_archiveのような本当に基本的なrule以外はこのようにバージョンとsha256まで指定して厳格に同一のものをダウンロードして使用します。

npm_installはrules_nodejsに含まれており、package.jsonから依存パッケージをダウンロードしています。

node_modulesについて

https://bazelbuild.github.io/rules_nodejs/#hermeticity-and-reproducibility
https://bazelbuild.github.io/rules_nodejs/dependencies.html

1章でも説明したように、Bazelではsandboxの中でビルドするのでホストマシンの作業ディレクトリに影響を与えないのが基本ですが、node_modulesについては例外的にホストマシンと共有されます。Bazelでビルドするときにはnpm_installによって必ずnpm installが実行されますが、そのときにホストマシン側のnode_modulesも更新されます。


この挙動についてドキュメントによるとBazelはパッケージマネージャではないことと、npmのパッケージは全てpackage-lock.json(もしくはyarn.lock)によってバージョンが厳格に管理されるため、node_modules自体はホストマシンと共有してlockファイルの内容をビルドにおけるinputとして扱う方針にしているようです。

BUILD

@bazel/createによって作成されるBUILD.bazelは空ですので、ここからは自分が勉強のために作成したリポジトリを使いながら解説していきます。ぜひお手元にgit cloneして試してみてください。

https://github.com/Kesin11/bazel-playground

nodejs_test

BUILD.bazel

まずは基本中の基本として、単にindex.jsを実行するだけのコードをBUILD.bazelに書いてみましょう。

BUILD.bazel
load("@build_bazel_rules_nodejs//:index.bzl", "nodejs_test")

# index.jsを実行してステータスコードが0かどうかチェックするだけ
nodejs_test(
  name = "index_test",
  entry_point = "src/index.js",
  data = [
    "@npm//lodash",
    "src/printer.js",
    "src/int_list.js",
  ],
)

nodejs_testは、entry_pointのjsを実行してその戻り値が0の場合は成功、それ以外は失敗とするruleです。実行するindex.jsでは自分が作成した関数をexportしているprinter.js, int_list.jsに加えてlodashをrequireしているので、それらを依存として指定するためのdataに明示しています。

このnodejs_testは、本来bazel testによって実行されますが、package.jsonには既にこれを実行するためのnpm run testが用意されているのでそちらで実行します。

nodejs_testの使い方はこれだけです。これでBazelからjsを実行できました。ですが、ビルドやテストにおいてindex.jsだけを実行するというケースはほぼないでしょうから、次はより実践的にESLintを実行できるようにしてみましょう。

eslint_test(binから自動生成されたrule)

BUILD.bazel
load("@npm//eslint:index.bzl", "eslint_test")

eslint_test(
  name = "lint",
  args = ["src", "__tests__"],
  data = [".eslintrc.js"] + glob(["src/**/*.js", "__tests__/**/*.js"]) + [
    "@npm//eslint-plugin-jest",
    "@npm//jest" # for eslint auto detect jest version
  ],
)

eslint_testはESLintを実行するruleです。nodejs_testと同様に、戻り値が0なら成功、それ以外は失敗となります。引数はnodejs_testとほぼ一緒なので想像しやすいと思いますが、eslintに渡すオプションをargsに、eslintを実行するのに必要な依存ファイルをdataに渡す必要があります。

このeslint_testはnodejs_testとは異なり、WORKSPACEでダウンロードしたrules_nodejsには含まれていません。ではどこから読み込んでいるかというと、node_modules/.bin/から読み込んでおり、load("@npm//eslint:index.bzl", "eslint_test")がそうです。

https://bazelbuild.github.io/rules_nodejs/repositories.html#generated-macros-for-npm-packages-with-bin-entries

binを提供しているnpmパッケージについては、Bazelが自動的に{パッケージ名}_testというrulesを生成してくれるため、ツールごとに自分でruleを自作する必要はありません。今回のeslint_testはこの仕組みを使用したものです。

jest_test(独自作成rule)

BUILD.bazel
load(":jest.bzl", "jest_test")

jest_test(
  name = "jest_test",
  srcs = glob(["__tests__/*.js"]),
  jest_config = "jest.config.js",
  deps = glob(["src/*.js"])
)
jest.bzl
""" My jest_test wrapper
ref: https://github.com/bazelbuild/rules_nodejs/blob/stable/examples/jest/jest.bzl
"""

# eslint_test同様にbinから自動生成されるruleを使うが、_jest_testという別名で読み込む
load("@npm//jest:index.bzl", _jest_test = "jest_test")

def jest_test(name, srcs, deps, jest_config, **kwargs):
    # jest_testを生成するためのマクロ
    args = [
        "--no-cache",
        "--no-watchman",
        "--ci",
    ]
    args.extend(["--config", "$(location %s)" % jest_config])
    # 理由が分からなかったがjestはsandbox環境では__tests__の中にマッチするテストファイルが存在しないと判定してしまう
    # --rootDirや--testMatchも試したが効果はなかった
    # --runTestsByPathで1ファイルずつパスを渡す場合は正しく見つけることができる

    # 配列のsrcsでループを回す必要があるのでdefで自前のjest_testを実装する必要があった
    for src in srcs:
        args.extend(["--runTestsByPath", "$(location %s)" % src])

    _jest_test(
        name = name,
        data = [jest_config] + srcs + deps,
        args = args,
        **kwargs
    )

お次はjestですが、こちらはeslint_testよりも複雑になり独自でruleを作成しています。自作ruleはPythonと似たStarlarkという言語で作ることが可能で、自作することで引数を自由に設定できます。srcs, deps, jest_configという名前で引数を定義することで、argsとdataしか存在しなかったeslint_testに比べて分かりやすくできましたが、わざわざjest_testを自作したのはそれが直接的な理由ではありません。

コード中のコメントに書いてある通りなのですが、テストコードのパスをディレクトリやglobで指定したとしてもなぜかsandboxの中ではパスを参照できないというエラーになってしまうため、for文でループを回して1ファイルずつ—-runTestsByPathで指定する必要がありました。

自分もnodejs + Bazelのリポジトリをいくつか探して他に方法がないか探したのですが、多少の差異はありつつもほぼ似たようなコードで独自ruleを作成していました。原因が分からないのでモヤモヤするのですが、このコードで問題なく動くには動くので一旦は良しとしましょう。

testの実行

npm run testを実行すると、eslint_testやjest_testなど、ずべてのxxx_testをまとめて実行できます。

$ npm run test

> javascript@0.1.0 test /home/codespace/workspace/bazel-playground/javascript
> bazel test //...

Starting local Bazel server and connecting to it...
INFO: Invocation ID: db750dcf-b4a8-46ad-b7ff-3976d41c4f98
INFO: Analyzed 3 targets (508 packages loaded, 10069 targets configured).
INFO: Found 3 test targets...
INFO: Elapsed time: 88.613s, Critical Path: 9.24s
INFO: 6 processes: 1 remote cache hit, 5 processwrapper-sandbox.
INFO: Build completed successfully, 22 total actions
//:index_test                                                            PASSED in 1.0s
//:jest_test                                                             PASSED in 2.4s
//:lint                                                                  PASSED in 0.7s

全てのテストが実行されて成功しました。1章で紹介したように、Bazelではテストの結果もキャッシュされるのでinputに変化がなければテストは実際には実行されずにスキップされます。実際にコードを何も変えずに再び実行してみましょう。

$ npm run test
> javascript@0.1.0 test /home/codespace/workspace/bazel-playground/javascript
> bazel test //...

INFO: Invocation ID: 1dd03128-6c94-40ce-aa45-7d08e64cb4fa
INFO: Analyzed 3 targets (0 packages loaded, 0 targets configured).
INFO: Found 3 test targets...
INFO: Elapsed time: 0.902s, Critical Path: 0.01s
INFO: 0 processes.
INFO: Build completed successfully, 1 total action
//:index_test                                                   (cached) PASSED in 1.0s
//:jest_test                                                    (cached) PASSED in 2.4s
//:lint                                                         (cached) PASSED in 0.7s

全てのタスクがcachedになっており、初回のElapsed timeは88秒ほどでしたが2回目は全てがキャッシュで完了しているのでわずか1秒で終了していることが分かります。これこそBazelがビルドやテストを高速に行える秘訣です。

TypeScript

BazelでTypeScriptを扱う方法はts_libraryts_projectの2つが存在します。

ts_library

https://bazelbuild.github.io/rules_nodejs/TypeScript.html#ts_library

ドキュメントにも書いてあるのですが、ts_libraryはGoogleが社内でTypeScriptをコンパイルする方法のOSS版ということで、かなりBazel用にカスタマイズされたもののようです。

一応自分も少し試してみたのですが、挙動がよく分からなかったり後述のts_projectの方がわかりやすかったので今回はあまり深く調べませんでした。TypeScriptを.jsに変換してjestを実行するところまでのBUILD.bazelは試してみたので、もし興味があれば見てみてください。

BUILD.bazel

ts_project

https://github.com/Kesin11/bazel-playground/tree/master/ts_project

ts_projectts_libraryよりも素直な挙動に見えたのでこちらは詳しく解説していきます。

BUILD.bazel
load("@npm//@bazel/typescript:index.bzl", "ts_project")
load("@build_bazel_rules_nodejs//:index.bzl", "nodejs_test")

ts_project(
  name = "lib",
  srcs = glob(["src/**/*.ts"]),
  tsconfig = "tsconfig.json",
  deps = [
    "@npm//@types/lodash"
  ],
  # declarationやsouce_mapなどはtsconfig.jsonと一致しない場合はエラーになるので必ず合わせる
  declaration = True,
  source_map = True,
)

# index.jsを実行してステータスコードが0かどうかチェックするだけ
nodejs_test(
  name = "index_test",
  # ts_projectはsrc/index.tsからsrc/index.jsが生成されることが分かっている
  # ':'を省略したとしてもsrc/index.ts -> src/index.jsと解釈されてjsが実行される
  entry_point = ":src/index.ts",
  data = [
    "@npm//lodash",
    ":lib"
  ],
)

ts_projecttsc —-projectを実行するruleです。ここまでの解説でおそらく何となく使い方は想像できるかと思いますが、srcsには変換したい.tsを指定し、depsにはコンパイル時に必要な依存物、つまり型情報が記載されている@typesを使用するのであればそれも指定します。

注意点としては、.d.tsや.mapの生成に関わるdeclarationsource_mapのオプションはtsconfig.jsonと一致させる必要があります。これは自分の推測ですが、Bazelが生成されるファイルを追跡するためにBUILDの中でも明示的に書く必要があるのではないかと思います。

次のnodejs_testではコンパイルして生成されたindex.jsを実行しているのですが、いくつかポイントがあります。まず依存物を指定するdataですが、:libts_projectによって生成されたファイル全てを意味しています。ts_projectname = "lib"としたので、その生成物全てを参照するときは:libとなるためです。

次にentry_pointに指定している:src/index.tsは、ts_projectによってindex.tsから生成されるindex.jsを意味しています。これは非常に分かりにくいのですが、色々試してみた結果このように指定できるようです。

BUILD.bazel
load(":jest.bzl", "jest_test")

jest_test(
  name = "jest_test",
  srcs = glob(["__tests__/**/*.test.ts"]),
  jest_config = "jest.config.js",
  deps = [
    "src",
    "tsconfig.json",
    "@npm//@types/lodash",
    "@npm//lodash",
    "@npm//ts-jest",
  ],
)
jest.config.js
module.exports = {
  preset: 'ts-jest',
  testEnvironment: 'node',
};

次はjestによるテストです。jest_testの出自であるjest.bzlについては先述の自作ruleとコードが全く同一のため説明は省略します。

実装コード側はts_projectによってコンパイル済みなのでテストコードもts_projectによってコンパイルし、それらを使用してjestを実行するのが正攻法かもしれませんが自分はいつもts-jestを使用しているのでBazelでもts-jestを使うようにしてみました。

nodejsのときとほぼ変わるところはなく、jest_testのdepsにts-jestとその他必要な依存物を追加し、srcsにはテストコードの.tsを指定するだけです。こうすることで、ts-jest自体もsandboxに送られるため、ts-jestが変換したコードをjestが使用してテストを行うという通常通りの流れがsandbox内でも可能となります。

実際に実行してみるとこのようなログになります。

$ npm run build

> @ build /home/codespace/workspace/bazel-playground/ts_project
> bazel build //...

INFO: Invocation ID: e57444a9-7922-49f8-9585-6908b433e817
INFO: Analyzed 4 targets (480 packages loaded, 10333 targets configured).
INFO: Found 4 targets...
INFO: Elapsed time: 11.514s, Critical Path: 2.55s
INFO: 2 processes: 2 remote cache hit.
INFO: Build completed successfully, 29 total actions

$ npm run test

> @ test /home/codespace/workspace/bazel-playground/ts_project
> bazel test //...

Starting local Bazel server and connecting to it...
INFO: Invocation ID: 23c1142f-5ea8-4e98-b712-a69d16d95a12
INFO: Analyzed 4 targets (480 packages loaded, 10333 targets configured).
INFO: Found 2 targets and 2 test targets...
INFO: Elapsed time: 75.066s, Critical Path: 2.61s
INFO: 6 processes: 6 remote cache hit.
INFO: Build completed successfully, 31 total actions
//:index_test                                                   (cached) PASSED in 0.1s
//:jest_test                                                    (cached) PASSED in 0.1s

Executed 0 out of 2 tests: 2 tests pass.
INFO: Build completed successfully, 31 total actions

依存関係のグラフも見てみましょう。今回はjest_testを実行するのにTypeScriptからコンパイルした.jsは使わない構成にしたので、ts_projectとjest_testが依存関係にないことが確認できるかと思います。

bazel query \
  --notool_deps \
  --noimplicit_deps \
  "deps(//...) except @npm//...:*" \
  --output graph


graphbiz形式で出力された依存グラフ

2章のまとめ

以上でめでたくBazelを使ってTypeScriptのコードをビルド、テストできるようになりました 🎉

今回用意したサンプルコードは、Bazelの説明に集中するためTypeScriptのプロジェクトとしては非常にシンプルな構成なのですが、それでもBazel独自の世界観やルールを把握するのは大変だったと思います。実際はmonorepo構成だったり、フロントエンドならwebpackを使用したりするのでBazelでビルドするのはさらに複雑な構成になると思われます。

次の3章ではここまでビルドしてきたTypeScriptのプロジェクトを使ってDockerイメージをビルドする方法について解説します。