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から見ていきましょう。
{
"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
次はWORKSPACEを見ていきましょう。@bazel/create
でセットアップした場合はこのような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について
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して試してみてください。
nodejs_test
まずは基本中の基本として、単にindex.jsを実行するだけのコードを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)
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")
がそうです。
binを提供しているnpmパッケージについては、Bazelが自動的に{パッケージ名}_testというrulesを生成してくれるため、ツールごとに自分でruleを自作する必要はありません。今回のeslint_testはこの仕組みを使用したものです。
jest_test(独自作成rule)
load(":jest.bzl", "jest_test")
jest_test(
name = "jest_test",
srcs = glob(["__tests__/*.js"]),
jest_config = "jest.config.js",
deps = glob(["src/*.js"])
)
""" 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_library
とts_project
の2つが存在します。
ts_library
ドキュメントにも書いてあるのですが、ts_library
はGoogleが社内でTypeScriptをコンパイルする方法のOSS版ということで、かなりBazel用にカスタマイズされたもののようです。
一応自分も少し試してみたのですが、挙動がよく分からなかったり後述のts_project
の方がわかりやすかったので今回はあまり深く調べませんでした。TypeScriptを.jsに変換してjestを実行するところまでのBUILD.bazelは試してみたので、もし興味があれば見てみてください。
ts_project
ts_project
はts_library
よりも素直な挙動に見えたのでこちらは詳しく解説していきます。
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_project
はtsc —-project
を実行するruleです。ここまでの解説でおそらく何となく使い方は想像できるかと思いますが、srcsには変換したい.tsを指定し、depsにはコンパイル時に必要な依存物、つまり型情報が記載されている@types
を使用するのであればそれも指定します。
注意点としては、.d.tsや.mapの生成に関わるdeclaration
とsource_map
のオプションはtsconfig.jsonと一致させる必要があります。これは自分の推測ですが、Bazelが生成されるファイルを追跡するためにBUILDの中でも明示的に書く必要があるのではないかと思います。
次のnodejs_testではコンパイルして生成されたindex.jsを実行しているのですが、いくつかポイントがあります。まず依存物を指定するdata
ですが、:lib
はts_project
によって生成されたファイル全てを意味しています。ts_project
でname = "lib"
としたので、その生成物全てを参照するときは:lib
となるためです。
次にentry_pointに指定している:src/index.ts
は、ts_projectによってindex.tsから生成されるindex.jsを意味しています。これは非常に分かりにくいのですが、色々試してみた結果このように指定できるようです。
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",
],
)
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イメージをビルドする方法について解説します。