ブログ

ビルドツール「Bazel」について

ソフトウェアエンジニアのshinyaです。

2019年10月に、ソフトウェアのビルド・テストツールである「Bazel」のバージョン1.0がリリースされました(Bazel公式ブログ)。

Bazelは使いやすいのですが、現時点では情報がまとまっているサイトが少ないです。そのため、この記事では私が調べたことをまとめることにしました。

目次

適宜 NOTE:という項目で、補足的な情報を追加しています。

以下は2019年11月24日時点(Bazel 1.1.0)での情報です。コードはUbuntu 18.04とmacOSでテストしました。

Bazelとは

Bazel (/ˈbeɪzˌəl/1) はGoogleにより開発されているソフトウェアのビルド・テストツールです。

特徴

  • ビルド結果をキャッシュし、必要な箇所のみビルドすることで高速動作する。
  • 多くのプログラミング言語に対応している。
  • Starlark2というPythonライクな言語で拡張機能を追加できる。

よく比較対象として挙げられるビルドツール

Bazelが使われている有名なOSS

Bazelのインストール

Bazelのインストール手順は公式サイトに各OSごとにまとめられています。

ただ、私はBazelを直接インストールするよりもBazeliskをインストールしたほうが良いと考えています。BazeliskはBazelのラッパーで、プロジェクトごとに適切なバージョンのBazel使い分ける事ができます(Rubyのrbenvや、Pythonのpyenvに似ています)。例えば、TensorFlowはBazel 1.1.0、KubernetesはBazel 0.23.2を使用しており、各プロジェクトごとに対応するバージョンのBazelをインストールするのは面倒です。Bazeliskは設定ファイル(.bazelversion)や環境変数(USE_BAZEL_VERSION)などから、各プロジェクトごとに適切なバージョンのBazelをダウンロードし、使用してくれます。

その他のBazelのバージョン管理方法として、asdfbazelプラグインを使う方法もあります。

この記事では今後、Bazeliskを使う前提で話を進めますが、Bazelを直接インストールした場合でもほとんど違いはありません。

Bazeliskのインストール

詳細なインストール手順はBazelisk公式サイトを参照してください。

Ubuntu 18.04

A) リリースファイルをダウンロードするパターン

$ sudo apt update
$ sudo apt install -y curl build-essential
$ curl -L -o bazel https://github.com/bazelbuild/bazelisk/releases/download/v1.1.0/bazelisk-linux-amd64
$ chmod +x bazel
$ export PATH="${PATH}:${PWD}"
$ bazel version

B) Go getでインストールするパターン

Go1.11以降がインストールされているなら、go getでインストールするのが楽です。

$ go get github.com/bazelbuild/bazelisk
$ export PATH="${PATH}:$(go env GOPATH)/bin"
$ alias bazel=bazelisk
$ bazel version

macOS

Homebrewからインストールするパターン

Homebrew経由が楽です。

$ brew tap bazelbuild/tap
$ brew install bazelbuild/tap/bazelisk
$ bazel version

NOTE: コマンドラインの補完機能について

Bazelはbash用とzsh用のコマンドライン補完用スクリプトを提供しています(公式サイト)。Bazelを直接インストールした場合、コマンドライン補完スクリプトも同時にインストールされるのですが、Bazeliskではインストールされません。以下、Bazeliskでコマンドライン補完用スクリプトを取得する方法をいくつか考えましたが、どれもあまり良い方法には思えていません。

Ubuntu 18.04

A) APTのBazelからスクリプトを取得するパターン

Bazelを一度APTでインストールし、補完スクリプトをコピー後、Bazelを消します。

$ sudo apt sudo apt update
$ sudo apt install -y curl build-essential
curl https://bazel.build/bazel-release.pub.gpg | sudo apt-key add -
$ echo 'deb [arch=amd64] https://storage.googleapis.com/bazel-apt stable jdk1.8' | sudo tee /etc/apt/sources.list.d/bazel.list
$ sudo apt update
$ sudo apt install -y bazel
# bash用スクリプトは /etc/bash_completion.d/bazel に出力されます。
# 適宜必要なパスにコピーしてください。
$ cp /etc/bash_completion.d/bazel PATH_TO_BASH_COMPLETIONS
# zsh用スクリプトはGitHubからダウンロードします。
$ curl https://raw.githubusercontent.com/bazelbuild/bazel/master/scripts/zsh_completion/_bazel -o PATH_TO_ZSH_COMPLETIONS
# Bazelをアンインストールする。
$ apt --purge remove bazel

B) Bazelのソースコードからスクリプトを作成する

$ sudo apt install -y build-essential openjdk-11-jdk
$ curl -L -o 1.1.0.tar.gz https://github.com/bazelbuild/bazel/archive/1.1.0.tar.gz
$ tar xf 1.1.0.tar.gz
$ cd bazel-1.1.0/
# bash用スクリプトは bazel-bin/scripts/bazel-complete.bash に出力されます。
# 適宜必要なパスにコピーしてください。
$ bazel build //scripts:bash_completion
$ cp bazel-bin/scripts/bazel-complete.bash PATH_TO_BASH_COMPLETIONS
# zsh用スクリプトはビルド不要で、 scripts/zsh_completion/_bazel にあります。
$ cp scripts/zsh_completion/_bazel PATH_TO_ZSH_COMPLETIONS
# ソースコードが不要なら、消します。
$ cd ..
$ rm -rf 1.1.0.tar.gz bazel-1.1.0/

macOS

HomebrewのBazelからスクリプトを取得するパターン

Bazelを一度Homebrewでインストールし、補完スクリプトをコピー後、Bazelを消します。

$ brew tap bazelbuild/tap
# Bazeliskが入っていれば一度削除する。
$ brew uninstall bazelbuild/tap/bazelisk
# Bazelをインストールする。
$ brew install bazelbuild/tap/bazel
# bash用スクリプトを適宜必要なパスにコピーする。
$ cp $(brew --prefix)/etc/bash_completion.d/bazel-complete.bash  PATH_TO_BASH_COMPLETIONS
# zsh用スクリプトを適宜必要なパスにコピーする。
$ cp $(brew --prefix)/share/zsh-completions/_bazel PATH_TO_ZSH_COMPLETIONS
# Bazelをアンインストールする。
$ brew uninstall bazelbuild/tap/bazel
# Bazeliskを再度インストールする。
$ brew install bazelbuild/tap/bazelisk


NOTE: 統合開発環境(IDE)のサポートについて

プラグインを追加することで、IDEでBazelに対応できます。


Bazelのチュートリアル

空のBazelワークスペース

最終的なコードはこちらに置いてあります。

とりあえず空のBazelワークスペース ~/first_bazel を作ってみます。

$ mkdir ~/first_bazel
$ cd ~/first_bazel/
$ echo '1.1.0' > .bazelversion
$ touch WORKSPACE

これで何もビルド対象が無い、空のBazelワークスペースが完成しました。

~/first_bazel
├── .bazelversion  # Bazelのバージョンを指定
└── WORKSPACE  # 外部依存などを追加する際にはこのファイルを変更する。

Bazelがちゃんとインストールされていれば、bazel info workspaceでワークスペースの情報が表示されます。

$ bazel info workspace
~/first_bazel/

簡単なJavaパッケージ

最終的なコードはこちら

JDKが必要なので、あらかじめインストールしておきます。

Javaライブラリ

ワークスペースにsrc/main/java/jp/flywheel/ディレクトリを作り、例としてMyLib.javaファイルを作ります。

$ mkdir -p src/main/java/jp/flywheel/
// src/main/java/jp/flywheel/MyLib.java
package jp.flywheel;
public class MyLib {
  public static int fibonacci(int n) {
    if (n < 0) {
      throw new IllegalArgumentException();
    } else if (n == 0 || n == 1) {
      return n;
    }
    return fibonacci(n - 1) + fibonacci(n - 2);
  }
}

src/main/java/jp/flywheel/に以下のBUILD.bazelというファイルを作成します。

# src/main/java/jp/flywheel/BUILD.bazel
load("@rules_java//java:defs.bzl", "java_library")
java_library(
    name = "mylib",
    srcs = ["MyLib.java"],
)

これでsrc/main/java/jp/flywheel/パッケージが出来ました。パッケージは、BUILDもしくはBUILD.bazelファイルを含むディレクトリです。
上記のBUILD.bazelでは、java_libraryというビルドルールで、MyLib.javaというソースファイルから、mylibというJavaライブラリのターゲットを作成しています。

パッケージが出来上がったので、ビルドしてみます。

$ bazel build //src/main/java/jp/flywheel:mylib

//src/main/java/jp/flywheel:mylibの部分は//パッケージ名:ターゲット名を表していて、ラベルと呼ばれます。

ビルドの生成物は bazel-bin/src/main/java/jp/flywheel/ に出力されます。

Javaバイナリ

続いて、このMyLibを使用する、MyBin.javaというファイルを作成し、java_binaryルールをBUILD.bazelに追加します。

// src/main/java/jp/flywheel/MyBin.java
package jp.flywheel;
public class MyBin {
  public static void main(String[] args) {
    int n = Integer.parseInt(args[0]);
    System.out.println("Hello Bazel!");
    System.out.println("Fib(" + n + ") = " + MyLib.fibonacci(n));
  }
}
# src/main/java/jp/flywheel/BUILD.bazel
load("@rules_java//java:defs.bzl", "java_binary", "java_library")
java_library(
    name = "mylib",
    srcs = ["MyLib.java"],
)
java_binary(
    name = "mybin",
    srcs = ["MyBin.java"],
    main_class = "jp.flywheel.MyBin",
    deps = [
        ":mylib",  # or "//src/main/java/jp/flywheel:mylib",
    ],
)

java_binaryルールは、Javaの実行ファイルを作成します。

$ bazel build //src/main/java/jp/flywheel:mybin

ビルドの生成物は bazel-bin/src/main/java/jp/flywheel/ に出力されます。

java_binaryルールでの生成物はbazel runで直接実行できます。

$ bazel run //src/main/java/jp/flywheel:mybin
Exception in thread "main" java.lang.ArrayIndexOutOfBoundsException: Index 0 out of bounds for length 0
        at jp.flywheel.MyBin.main(MyBin.java:6)

bazel runからmain関数に引数を渡すには、-- のあとに追加します。

$ bazel run //src/main/java/jp/flywheel:mybin -- 10
Hello Bazel!
Fib(10) = 55

java_binaryルールは、暗黙的(implicit)にいくつかのターゲットを追加します。例えば、ターゲット名に_deploy.jarを追加すると、デプロイ用のuber JAR (fat JARやall-in-one JARとも呼ばれます)を作る事ができます。

$ bazel build //src/main/java/jp/flywheel:mybin_deploy.jar
# 実際にデプロイするなら、"-c opt"などの最適化オプションの追加を検討します。
# $ bazel build -c opt //src/main/java/jp/flywheel:mybin_deploy.jar

bazel-bin/src/main/java/jp/flywheel/mybin_deploy.jarというuber JARが作成され、java -jarなどで実行できます。

$ java -jar bazel-bin/src/main/java/jp/flywheel/mybin_deploy.jar 11
Hello Bazel!
Fib(11) = 89

Javaテスト

MyLib.javaのユニットテストを作るため、ワークスペースにsrc/test/java/jp/flywheel/ディレクトリを作り、MyLibTest.javaファイルを作ります。

$ mkdir -p src/test/java/jp/flywheel/
// src/test/java/jp/flywheel/MyLibTest.java
package jp.flywheel;
import static org.junit.Assert.assertEquals;
import org.junit.Test;
public class MyLibTest {
  @Test
  public void testFibonacci() {
    assertEquals(144, MyLib.fibonacci(12));
  }
}

src/test/java/jp/flywheel/に以下のBUILD.bazelというファイルを作成します。

# src/test/java/jp/flywheel/BUILD.bazel
load("@rules_java//java:defs.bzl", "java_test")
java_test(
    name = "mylib_test",
    srcs = [
        "MyLibTest.java",
    ],
    test_class = "jp.flywheel.MyLibTest",
    deps = [
        "//src/main/java/jp/flywheel:mylib",
    ],
)

これでテストパッケージ(//src/test/java/jp/flywheel/)の準備は整いました。

しかしこのままでは、テストパッケージ(//src/test/java/jp/flywheel/)に、ライブラリのパッケージ(//src/main/java/jp/flywheel/)の参照権限(visibility)が無いため、ビルドが失敗します。

$ bazel build src/test/java/jp/flywheel:mylib_test
target '//src/main/java/jp/flywheel:mylib' is not visible from target '//src/test/java/jp/flywheel:mylib_test'.
Check the visibility declaration of the former target if you think the dependency is legitimate

デフォルトでは、各ターゲットは同じパッケージ内のターゲットからしか参照できません。

テストパッケージ(//src/test/java/jp/flywheel/)が、ライブラリのパッケージ(//src/main/java/jp/flywheel/)のターゲットを参照できるように、src/main/java/jp/flywheel/BUILD.bazeldefault_visibilityを追加します。

# src/main/java/jp/flywheel/BUILD.bazel
load("@rules_java//java:defs.bzl", "java_binary", "java_library")
package(default_visibility = ["//src/test/java/jp/flywheel:__subpackages__"])
java_library(
    name = "mylib",
    srcs = ["MyLib.java"],
)
java_binary(
    name = "mybin",
    srcs = ["MyBin.java"],
    main_class = "jp.flywheel.MyBin",
    deps = [
        ":mylib",  # or "//src/main/java/jp/flywheel:mylib",
    ],
)

//src/test/java/jp/flywheel:__subpackages__は、テストパッケージ(//src/test/java/jp/flywheel)と、それ以下のサブパッケージを意味します。

Visibilityの詳しい設定方法は、common attributesのvisibilityの項目を見てください。

これでテストパッケージ(//src/test/java/jp/flywheel)からライブラリのパッケージが見られるようになったので、bazel testでユニットテストを実行してみます。

$ bazel test //src/test/java/jp/flywheel:mylib_test
//src/test/java/jp/flywheel:mylib_test               PASSED in 0.5s

これで無事にJavaのテストを書くことができました。


NOTE: BUILDBUILD.bazel

ビルドターゲットは BUILDBUILD.bazel のどちらかに記入します。公式サイトBazel本体BUILDを、rules_gobuildtoolsではBUILD.bazelを使用しています。

BUILDBUILD.bazelのどちらを使用しても良いのですが、私は以下の理由から、今後新しくプロジェクトを作る場合はBUILD.bazelを使用したほうが良いと考えています。

  • Bazel本体や、(ちゃんと更新されている)周辺ツールはBUILDBUILD.bazelのどちらにも対応しているため。(BUILDBUILD.bazelが同じディレクトリにある場合、BUILD.bazelが優先される。)
  • 大文字小文字を区別しない(case-insensitive)ファイルシステムの場合、buildBUILDで名前の衝突が起きるため。

WORKSPACEも同様にWORKSPACE.bazelを代わりに使用することができます。しかし、BUILDと比較して名前の衝突が起きにくいためか、対応しているツールが少ない印象です。例えば、GitHubのシンタックスハイライトにはBUILD.bazelは登録されていますが、WORKSPACE.bazelは登録されていません。

参考



NOTE: Bazelプロジェクト用の参考になる.gitignore



NOTE: ErrorProne

BazelではErrorProneがデフォルトで有効3になっています。

そのため、以下のようなIllegalFormatConversion("%d"の型と"hello"という文字列の型が合わない)はエラーとなりビルドに失敗します。

コードはこちら

package jp.flywheel;
public class MyBin {
  public static void main(String[] args) {
    System.out.println(String.format("%d", "hello"));
  }
}
$ bazel build //src/main/java/jp/flywheel:mybin
src/main/java/jp/flywheel/MyBin.java:5:
error: [FormatString] illegal format conversion: 'java.lang.String' cannot be formatted using '%d'
    System.out.println(String.format("%d", "hello"));
                                    ^
    (see https://errorprone.info/bugpattern/FormatString)
Target //src/main/java/jp/flywheel:mybin failed to build

ErrorProneを無効にするメリットは特に思いつきませんが、--javacopt='-XepDisableAllChecks'というフラグを追加するとErrorProneを無効にできます。

$ bazel build --javacopt='-XepDisableAllChecks' //src/main/java/jp/flywheel:mybin

.bazelrcファイルに書いておくと、フラグを常に適用できます。

$ cat .bazelrc
build --javacopt='-XepDisableAllChecks'

いずれにしても、ErrorProneを無効にするメリットは無いように思います。



NOTE: Java 11

BazelのJavaコードはデフォルトでJava8でビルドされます。--java_toolchain(と場合によっては--javabase)をJava11にするとJava11でビルドされます。

コードはこちら

$ bazel build --java_toolchain=@bazel_tools//tools/jdk:toolchain_java11 \
    --javabase=@bazel_tools//tools/jdk:remote_jdk11 \
    //src/main/java/jp/flywheel:mybin

常にJava11でビルドするなら、.bazelrcファイルに書いておくと楽だと思います。

$ cat .bazelrc
build --java_toolchain=@bazel_tools//tools/jdk:toolchain_java11
build --javabase=@bazel_tools//tools/jdk:remote_jdk11

参考



NOTE: testonly属性

先述の通り、Bazelではパッケージやターゲットごとにvisibilityを設定でき、これによりターゲットの影響範囲を制御することができます。

ターゲットの影響範囲を制限する別の方法として、testonly属性が便利です。testonlyTrueに設定されているターゲットは、testonlyのターゲットもしくはテストターゲット(*_testルール)からしか参照できません。testonlyを使用することで、テスト用のライブラリ・コードをテストでしか使用できないように制限できます。



NOTE: パッケージレイアウト

前述の例では、パッケージのレイアウトはMavenのディレクトリレイアウトと同様に、src/{main, test}/java/jp/flywheel/...というレイアウトでした。

コードはこちら

simple_java
├── .bazelversion
├── WORKSPACE
└── src
    ├── main
    │   └── java
    │       └── jp
    │           └── flywheel
    │               ├── BUILD.bazel
    │               ├── MyBin.java
    │               └── MyLib.java
    └── test
        └── java
            └── jp
                └── flywheel
                    ├── BUILD.bazel
                    └── MyLibTest.java

しかし、BazelではMavenのレイアウトに揃える必要はありません。以下のように、ライブラリ、バイナリ、テストのコードを1つのパッケージにすることもできます。さらに言えば、C++のソースコードやCSVなどのデータも同じパッケージに入れる事もできます。

コードはこちら

simple_java_flat
├── .bazelversion
├── WORKSPACE
└── src
    ├── BUILD.bazel
    ├── MyBin.java
    ├── MyLib.java
    └── MyLibTest.java

ただ、以下の理由から、私はMavenのレイアウトを使用したほうが良いと考えています。

  • Visibilityの管理や、テストコードの分離が容易であるため。
  • 統合開発環境(IDE)や周辺ツールがMavenのレイアウトを仮定している可能性があるため。
  • Bazel本体のGitHubリポジトリの構造がsrc/{main, test}/java/com/google/...となっているため。
  • MavenやGradleなどから移行したユーザが容易に学習できるため。

より実践的な例

Apache Spark Scalaパイプライン

Scala 2.12.8 で書かれたApache Spark 2.4.4のパイプラインをBazel上で作ります。

最終的なコードはこちらに置いてあります。

WORKSPACEに外部依存を追加する

ScalaをBazelで使うにはrules_scalaからScala用のルールをインポートする必要があります。

また、SparkのartifactをMavenリポジトリから取得するため、rules_jvm_externalもインポートします。

rules_scalarules_jvm_externalにかかれている内容を参考に、WORKSPACEを更新します。バージョンやSHA256の値は適宜変更します。SkylibProtocol Buffersはrules_scalaのために必要なので、これらも追加します。

WORKSPACEの中身が大きくなっていますが、やっていることは単純です。http_archiveルールを使用して、Protocol BuffersなどのソースコードをGitHubからダウンロードし、それらの中に定義されているルールを読み込み、実行しているだけです。

workspace(name = "simple_spark")
load("@bazel_tools//tools/build_defs/repo:http.bzl", "http_archive")
# Skylib
SKYLIB_TAG = "0.9.0"
SKYLIB_SHA = "9245b0549e88e356cd6a25bf79f97aa19332083890b7ac6481a2affb6ada9752"
http_archive(
    name = "bazel_skylib",
    sha256 = SKYLIB_SHA,
    strip_prefix = "bazel-skylib-%s" % SKYLIB_TAG,
    url = "https://github.com/bazelbuild/bazel-skylib/archive/%s.tar.gz" % SKYLIB_TAG,
)
# Protocol Buffers
PROTOBUF_TAG = "3.10.1"
PROTOBUF_SHA = "6adf73fd7f90409e479d6ac86529ade2d45f50494c5c10f539226693cb8fe4f7"
http_archive(
    name = "com_google_protobuf",
    sha256 = PROTOBUF_SHA,
    strip_prefix = "protobuf-%s" % PROTOBUF_TAG,
    url = "https://github.com/protocolbuffers/protobuf/archive/v%s.tar.gz" % PROTOBUF_TAG,
)
load("@com_google_protobuf//:protobuf_deps.bzl", "protobuf_deps")
protobuf_deps()
# Scala
RULES_SCALA_VERSION = "ff57530cc6796cdcd4ab0405c5404fad2d2e8923"  # Latest commit as of 2019-11-27.
RULES_SCALA_SHA = "3712768d345917b9a94557a4ab008a89a9488031662ec5ab3d8fb2efa0ed5ec6"
http_archive(
    name = "io_bazel_rules_scala",
    sha256 = RULES_SCALA_SHA,
    strip_prefix = "rules_scala-%s" % RULES_SCALA_VERSION,
    url = "https://github.com/bazelbuild/rules_scala/archive/%s.zip" % RULES_SCALA_VERSION,
)
load("@io_bazel_rules_scala//scala:toolchains.bzl", "scala_register_toolchains")
scala_register_toolchains()
load("@io_bazel_rules_scala//scala:scala.bzl", "scala_repositories")
scala_repositories(
    scala_version_shas = (
        "2.12.8",
        {
            "scala_compiler": "f34e9119f45abd41e85b9e121ba19dd9288b3b4af7f7047e86dc70236708d170",
            "scala_library": "321fb55685635c931eba4bc0d7668349da3f2c09aee2de93a70566066ff25c28",
            "scala_reflect": "4d6405395c4599ce04cea08ba082339e3e42135de9aae2923c9f5367e957315a",
        },
    ),
)
# Maven
RULES_JVM_EXTERNAL_TAG = "2.10"
RULES_JVM_EXTERNAL_SHA = "1bbf2e48d07686707dd85357e9a94da775e1dbd7c464272b3664283c9c716d26"
http_archive(
    name = "rules_jvm_external",
    sha256 = RULES_JVM_EXTERNAL_SHA,
    strip_prefix = "rules_jvm_external-%s" % RULES_JVM_EXTERNAL_TAG,
    url = "https://github.com/bazelbuild/rules_jvm_external/archive/%s.zip" % RULES_JVM_EXTERNAL_TAG,
)
load("@rules_jvm_external//:defs.bzl", "maven_install")
maven_install(
    artifacts = [
        "org.apache.spark:spark-catalyst_2.12:2.4.4",
        "org.apache.spark:spark-core_2.12:2.4.4",
        "org.apache.spark:spark-sql_2.12:2.4.4",
        # For testing.
        "org.apache.spark:spark-catalyst_2.12:jar:tests:2.4.4",
        "org.apache.spark:spark-core_2.12:jar:tests:2.4.4",
        "org.apache.spark:spark-sql_2.12:jar:tests:2.4.4",
    ],
    repositories = [
        "https://jcenter.bintray.com/",
        "https://repo1.maven.org/maven2",
    ],
)

NOTE: maven_installで取得するartifactを固定する

rules_jvm_externalmaven_installルールは、推移的な依存関係を解決し、必要なJARファイルをダウンロードします。

例えば、org.apache.spark:spark-sql_2.12:2.4.4org.apache.parquet:parquet-hadoop:1.10.1に依存しています。maven_installartifactsorg.apache.spark:spark-sql_2.12:2.4.4を追加すると、org.apache.parquet:parquet-hadoop:1.10.1も自動的にダウンロードされ、@maven//:org_apache_parquet_parquet_hadoopラベルとして参照できるようになります。この依存関係の解決に時間がかかります。rules_jvm_externalでは解決済みの依存関係をファイルに出力し、2回目以降はその解決済みの依存関係を利用することで、処理の高速化ができます4。処理の高速化だけでなく、Yarnyarn.lockPipenvのPipfile.lockのように、推移的な依存関係を固定することで再現性を上げる効果もありそうです。

Bazelのquery機能を使うと、各ターゲットの依存関係などを調べることができます。Graphviz (dot)がインストールされていれば、依存関係グラフを画像として出力できます。

例えば、@maven//:org_apache_spark_spark_sql_2_12の依存関係は、以下のコマンドでPNG画像に出力できます。(出力PNG画像: けっこう大きいので注意)。

$ bazel query --notool_deps --noimplicit_deps \
    'deps(@maven//:org_apache_spark_spark_sql_2_12)' --output graph \
    | dot -Tpng -o spark_sql_deps.png

パイプラインのコードを追加する

テキストファイル中の単語をカウントする単純なSparkパイプラインを
src/main/scala/jp/flywheel/WordCount.scalaに作成します。

// src/main/scala/jp/flywheel/WordCount.scala
package jp.flywheel
import org.apache.spark.sql.{Dataset, SparkSession, Row}
import org.apache.spark.sql.functions.{col, count, explode, split}
object WordCount {
  def main(args: Array[String]): Unit = {
    val inputTextFilePattern = args(0)
    val outputPath = args(1)
    val spark = SparkSession.builder.appName("WordCount").getOrCreate
    try {
      val textDF = spark.read.text(inputTextFilePattern)
      val wordCountDF = countWords(textDF)
      wordCountDF
        .repartition(1)
        .sortWithinPartitions("word")
        .write
        .csv(outputPath)
    } finally {
      spark.close
    }
  }
  def countWords(textDF: Dataset[Row]): Dataset[Row] =
    textDF
      .select(explode(split(col("value"), " ")).alias("word"))
      .filter(col("word").isNotNull)
      .filter(col("word").notEqual(""))
      .groupBy(col("word"))
      .agg(count(col("word")).alias("count"))
}

BUILD.bazelも追加します。

# src/main/scala/jp/flywheel/BUILD.bazel
load("@io_bazel_rules_scala//scala:scala.bzl", "scala_binary", "scala_library")
package(default_visibility = ["//src/test/scala/jp/flywheel:__subpackages__"])
scala_library(
    name = "spark_deps",
    exports = [
        "@maven//:org_apache_spark_spark_catalyst_2_12",
        "@maven//:org_apache_spark_spark_core_2_12",
        "@maven//:org_apache_spark_spark_sql_2_12",
    ],
)
scala_library(
    name = "word_count_lib",
    srcs = ["WordCount.scala"],
    deps = [
        ":spark_deps",
    ],
)
scala_binary(
    name = "word_count_bin",
    main_class = "jp.flywheel.WordCount",
    runtime_deps = [":word_count_lib"],
)

java_binaryルールと同様に、scala_binaryルールでもターゲット名に_deploy.jarを追加することで、uber JARを作成できます。

$ bazel build //src/main/scala/jp/flywheel:word_count_bin_deploy.jar
# 生成物は bazel-bin/src/main/scala/jp/flywheel/word_count_bin_deploy.jar

生成したuber JARはMavenやGradleで作成した場合と同様に、spark-submitなどで実行できます。

$ spark-submit \
    --master 'local[*]' \
    bazel-bin/src/main/scala/jp/flywheel/word_count_bin_deploy.jar \
    INPUT_FILE_PATH \
    OUTPUT_FILE_PATH

テストコードを追加する

WordCount.scalaをテストするためのコードを、src/test/scala/jp/flywheel/WordCountTest.scalaに作成します。

// src/test/scala/jp/flywheel/WordCountTest.scala
package jp.flywheel
import org.apache.spark.sql.Row
import org.apache.spark.sql.test.SharedSparkSession
import org.apache.spark.sql.types.{DataTypes, StructField, StructType}
import org.scalatest.{FunSuite, Matchers}
class WordCountTest extends FunSuite with Matchers with SharedSparkSession {
  test("countWords") {
    val rows = Seq(
      Row("word1 word2 word3"),
      Row("word1 word2"),
      Row("word2 word3"),
      Row("   word3   word4   ")
    )
    val schema = StructType(Seq(StructField("value", DataTypes.StringType)))
    val textDF =
      spark.createDataFrame(spark.sparkContext.parallelize(rows), schema)
    WordCount.countWords(textDF).collect should contain theSameElementsAs Seq(
      Row("word1", 2L),
      Row("word2", 3L),
      Row("word3", 3L),
      Row("word4", 1L)
    )
  }
}

SharedSparkSessionを使いたいため、Sparkのテスト用のartifact (@maven//:org_apache_spark_spark_sql_2_12_tests)をtestonly属性付きで追加しています。

# src/test/scala/jp/flywheel/BUILD.bazel
load("@io_bazel_rules_scala//scala:scala.bzl", "scala_library", "scala_test")
scala_library(
    name = "spark_test_deps",
    testonly = True,
    exports = [
        "//src/main/scala/jp/flywheel:spark_deps",
        "@maven//:org_apache_spark_spark_catalyst_2_12_tests",
        "@maven//:org_apache_spark_spark_core_2_12_tests",
        "@maven//:org_apache_spark_spark_sql_2_12_tests",
    ],
)
scala_test(
    name = "word_count_test",
    srcs = [
        "WordCountTest.scala",
    ],
    deps = [
        ":spark_test_deps",
        "//src/main/scala/jp/flywheel:word_count_lib",
    ],
)
$ bazel test //src/test/scala/jp/flywheel:word_count_test
//src/test/scala/jp/flywheel:word_count_test         PASSED in 9.2s

以上で簡単なSparkパイプラインが出来上がりました。

参考サイトなど

公式サイト

https://bazel.build/

Bazelのビジョンなどがまとまっています。

複数のバージョンについての情報が書かれているため、今見ているページがどのバージョンのことについて書かれているかをしっかり確認した方が良いと思います。サイト内検索を使用する際は、過去のバージョンのページに移動してしまう場合があるため特に注意。

リリースについてのblog記事(例えばBazel 1.1)のCommunityの項目には、Bazelのチュートリアル記事などのリンクがあります。

Awesome Bazel

https://awesomebazel.com/

Bazelの拡張ルールや、周辺ツール、ブログ記事などのリソースへのリンクがまとめられています。

Buildifier

BuildifierはBazel関連ファイル(WORKSPACE, BUILD, BUILD.bazel, *.bzlなど)のフォーマッタです。Buildifierに--lint=warnフラグを追加することで、Bazel関連ファイルをチェックすることができます(Warning一覧)。Bazelの情報はサイトやソースコードが書かれた時期によりバラバラで、廃止予定(deprecated)の機能が書かれたサイトやソースコードがあります。現時点で何が廃止予定の機能なのかはBuildifierでチェックするのが確実な上、楽です。

bazelbuild内のソースコード

https://github.com/bazelbuild

結局のところ、Bazel本体や拡張ルールのソースコードやissueを読むのが手っ取り早くて正確だと思います。

まとめ・感想

Bazelは使いやすいのですが、まとまっている情報が少ないため、本記事では調べた事や実際に使ってみた感想などをまとめました。

Bazelや周辺ツールの進化が速いため、本記事の有効期間は短いかもしれませんが、誰かのお役に立てば幸いです。

Notes


  1. Bazel FAQ: How do you pronounce “Bazel"?
    カタカナ表記だと、ベイゼルでしょうか?
    Javaビルドツール入門 Maven/Gradle/SBT/Bazel対応」では、バゼルと表記されています。 

  2. Starlarkは以前はSkylarkという名前であったため、今でもSkylarkという表現をときどき見かけます。
    https://blog.bazel.build/2018/08/17/starlark.html 

  3. https://blog.bazel.build/2015/06/25/ErrorProne.html 

  4. https://github.com/bazelbuild/rules_jvm_external#pinning-artifacts-and-integration-with-bazels-downloader