Elm の Bazel ルール
本記事は「Elmアドベントカレンダー2021」の2日目の記事です。
Bazelというビルドツールがあります。Bazelでは、いわゆるライブラリのようなものを使うことで、簡単にさまざまなプログラムのビルドやテストの実行を同じインターフェースで行うことができます。例えば:
- bazelbuild/rules_go : Goプログラムのビルドやテストなど
- bazelbuild/rules_docker : Dockerイメージのビルドやプッシュなど
- bazelbuild/rules_nodejs : Nodeプログラムのビルドやテストなど
- tweag/rules_haskell : Haskellプログラムのビルドやテストなど
などなど、サードパーティ製も含めて様々なライブラリ(ルール)があります。
しかし、Elmのためのライブラリ(正確にはバージョン0.19.1が動作するライブラリ)が無かったので自作しました:
という話です。そのため、Elmプログラムなどは一切出てきません。ごめんなさい。
rules_elm
v1.0.0 では以下のルールを用意してあります:
-
elm_make
:elm make
を実行するルール -
elm_dependencies
:elm_make
ルールで依存パッケージのキャッシュをするためのルール -
elm_test
:elm-test-rs
を利用してテストを実行するルール
使い方
Bazelで外部のライブラリを利用するには、プロジェクトに対し一つずつ存在するWORKSPACE
ファイルに設定を書き足します:
http_archive(
name = "rules_elm",
sha256 = "a9db7f55e3693ab94a60cbf602221095514aec6541253b21cc89f0ba1365d87c",
urls = ["https://github.com/matsubara0507/rules_elm/releases/download/v1.0.0/rules_elm-v1.0.0.zip"],
)
load("@rules_elm//elm:repositories.bzl", rules_elm_repositories = "repositories")
rules_elm_repositories()
load("@rules_elm//elm:toolchain.bzl", rules_elm_toolchains = "toolchains")
rules_elm_toolchains(version = "0.19.1")
前述した各種ルールを利用するにはBUILD
ファイルに記述します:
load("@rules_elm//elm:def.bzl", "elm_dependencies", "elm_make")
elm_dependencies(
name = "deps",
elm_json = "elm.json",
)
elm_make(
name = "index",
srcs = glob(["**"]),
elm_home = ":deps",
elm_json = "elm.json",
main = "src/Main.elm",
optimize = True,
output = "index.html",
)
また、elm_test
を利用する場合はNode.jsが必要なため、rules_nodejs-coreをWORKSPACE
ファイルに追記する必要があります:
load("@bazel_tools//tools/build_defs/repo:http.bzl", "http_archive")
http_archive(
name = "rules_nodejs",
sha256 = "995eb2fbcd6c0d27faea1f8b362a3a448d98d42b6c0fddc2943b72fe866a9d8e",
urls = ["https://github.com/bazelbuild/rules_nodejs/releases/download/4.4.4/rules_nodejs-core-4.4.4.tar.gz"],
)
load("@rules_nodejs//nodejs:repositories.bzl", "rules_nodejs_dependencies", "nodejs_register_toolchains")
rules_nodejs_dependencies()
nodejs_register_toolchains(
name = "node16",
node_version = "16.13.0",
)
rules_nodejs-core というのは、rules_nodejs とは異なりnode実行環境だけを提供します(npm
コマンドなどを実行するためのルールなどは提供していない)。
例:Go+Elm
とても簡易的なサンプルプロジェクトを作りました。Elmで生成したHTMLファイルをGoのEmbed機能を利用して埋め込んで返すだけの簡単なアプリケーションです:
ちなみに、OpenAPIを利用してサーバーとクライアント間で多少やりとりする例はコチラです:
Bazel ルールを作る
ここからは rules_elm を自作する部分の話を書きます。
elm make
するルール
まず最初に、elm make
するためのルールを作りました。elm make
をするためにまずは、Elmコンパイラを取得し、ツールチェイン化する必要があります。また、Windowsでも動作するように少し乱暴な工夫を施しました。
ツールチェイン
Bazelにはツールチェインと呼ばれる機能があります。ツールチェインは、なんらかの実行ファイルをBazelサンドボックスへインストールさせる機能です。その際に、実行したいプラットフォームに対してインストールすべき対象を簡単に切り替える仕組みが提供されています。
プラットフォームに対する分岐パターンを設定するためにまず、toolchain
関数を使います。これには exec_compatible_with
と target_compatible_with
という引数があり、前者がツールを実行するプラットフォームを指定し、後者がツールからの生成物を利用するプラットフォームを指定できます。その指定によって、インストールすべき対象を選択するわけです(より詳細にはコチラ)。ここで、exec_compatible_with
と target_compatible_with
があるのは、Go言語やスマホアプリのようなクロスコンパイルできるツールを想定しているわけです。
Elmの場合は、target_compatible_with
は関係ない(生成物がJavaScriptやHTMLなので)はずなので特に指定する必要がありません:
toolchain(
name = "toolchain",
toolchain_type = "@rules_elm//elm:toolchain",
toolchain = "@rules_elm_compiler_mac//:mac_info",
exec_compatible_with = ["@platforms//os:osx"],
)
toolchain_type
引数はrules_elm/elm/BUILD.bazel
で定義してあり、以降で作成するルールでElm用のツールチェインを使いたい場合にも指定します。toolchain
引数は実際に紐付けるツールチェイン本体、つまりElmコンパイラを指しています。なので、次にElmコンパイラを取得する必要がありますね。
Elmコンパイラの取得
実は、これが少々面倒でした。というのも、GitHubリリースなどにある zip
や tar.gz
ファイルをダウンロードして展開する場合は repository_ctx.download_and_extract
をよく使うのですが、Elmコンパイラは gz
だけで、なんとこれは download_and_extract
で展開できないからです。
しょうがないので、repository_ctx.download
でダウンロードしたのちに、自分で gunzip
する方法を取ることにしました:
def _elm_compiler_impl(ctx):
os = ctx.attr.os
version = ctx.attr.version
file_name = "elm"
if os == "windows":
file_name += ".exe"
ctx.download(
url = "https://github.com/elm/compiler/releases/download/{}/binary-for-{}-64-bit.gz".format(version, os),
sha256 = ctx.attr.checksum,
output = file_name + ".gz",
)
ctx.file(
"BUILD",
executable = False,
content = """
load("@rules_elm//elm:toolchain.bzl", "elm_toolchain", "extract_gzip")
exports_files(["{elm}.gz"])
extract_gzip(name = "{elm}", archive = "{elm}.gz")
elm_toolchain(name = "{os}_info", elm = ":{elm}")
""".format(os = os, elm = file_name),
)
extract_gzip
は自前で用意したもので gunzip
するだけのルールです。余談ですが、元々は雑に ctx.execute([ctx.which("gzip"),...)
としていましたが、これだと repository_cache でうまくキャッシュされないことが分かり修正しました。
Windows対応
それなりに面倒なのが、Windowsでも動作するようにすることです。シェルスクリプトを使うことで、いろんなルールを簡単に作ることができるのですが、Windowsの場合はこれが動かない場合が多いのです。
rules_haskellを参考にしたところ、Pythonスクリプトを間に噛ませる方法を取っていたので、少々乱暴ですが真似することにしました。
まず、ElmコマンドをラップしたようなPythonスクリプト(のテンプレート)を作成します:
#!/usr/bin/env python3
# elm_wrapper.py ELM_PROJECT_ROOT [ARGS_FOR_ELM...]
# 1引数目の ELM_PROJECT_ROOT だけ Elm プロジェクトへの相対パスで残りは elm コマンドへの引数
import os
import os.path
import subprocess
import sys
def run(cmd, *args, **kwargs):
try:
subprocess.run(cmd, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, *args, **kwargs)
except subprocess.CalledProcessError as err:
sys.stdout.buffer.write(err.stdout)
sys.stderr.buffer.write(err.stderr)
raise
elm_runtime_path = os.path.abspath("path/to/elm") # ここはテンプレート
elm_project_root = sys.argv.pop(1)
for i, arg in enumerate(sys.argv):
if arg == "--output":
sys.argv[i+1] = os.path.abspath(sys.argv[i+1])
# HOME: getAppUserDataDirectory:getEnv: does not exist (no environment variable)
# というエラーが出るので適当に定義しておく
os.putenv("HOME", os.getcwd())
os.chdir(elm_project_root)
run([elm_runtime_path] + sys.argv[1:])
そして、このテンプレートを展開してBazelの生成物とし、それをrules_pythonのpy_binary
でBazelルールから実行可能にすることで、Windowsでも同じように動作するようになりました。
elm_make
の定義
そして最後に、PythonでラップしたElmコマンドを呼び出す elm_make
を定義します:
def _elm_make_impl(ctx):
elm_compiler = ctx.toolchains["@rules_elm//elm:toolchain"].elm
output_file = ctx.actions.declare_file(ctx.attr.output)
inputs = [elm_compiler, ctx.file.elm_json] + ctx.files.srcs
arguments = [
ctx.file.elm_json.dirname,
"make", ctx.attr.main,
"--output", output_file.path,
]
ctx.actions.run(
executable = ctx.executable._elm_wrapper,
arguments = arguments,
inputs = inputs,
outputs = [output_file],
)
return [DefaultInfo(files = depset([output_file]))]
elm_make = rule(
_elm_make_impl,
attrs = {
"srcs": attr.label_list(allow_files = True),
"elm_json": attr.label(
mandatory = True,
allow_single_file = True,
),
"main": attr.string(default = "src/Main.elm"),
"output": attr.string(default = "index.html"),
"_elm_wrapper": attr.label(
executable = True,
cfg = "host",
default = Label("@rules_elm//elm/private:elm_wrapper"),
),
},
toolchains = ["@rules_elm//elm:toolchain"],
)
Bazelルールでは、コマンドの実行に必要なファイルを全て与える必要があります。それが actions.run
のinputs
引数です。これで、まず一つ目のルールができました。
しかし、このelm_make
ではbazel build
を実行するたびにElmの依存パッケージをインストールし直してしまいます。とても非効率ですね。次はこれを何とかするルールを作ります。
依存パッケージをキャッシュする
Bazelの特徴の一つとして、生成物の依存関係を明示的に管理することで、キャッシュを効率よく行うというのがあります。キャッシュをうまくやるには、ライブラリ側でそういう設計をする必要があります。前述した通り、このままだとelm make
をするたびに依存パッケージのインストールを行うので、うまくキャッシュする仕組みを考えました。それが elm_dependencies
です。
Elmのインストール済み依存パッケージはどこか
そもそも、Elmではインストールした依存パッケージをどのように管理しているのでしょうか?elm make
などをするとElmプロジェクトの配下に elm-stuff
という(基本的には git 管理しない)ディレクトリができますが、中身を見てみるとここにはありません。
Elmコンパイラ(バージョンは 0.19.1)のソースコードを直接呼んだところ、ELM_HOME
環境変数に設定したパスのディレクトリに保存されているようでした。ELM_HOME
環境変数が設定されてない場合は$HOME/.elm
が使われているようです。
$ ls ~/.elm/0.19.1/packages/
bartavelle elm elm-community elm-explorations justinmimbs lock registry.dat rtfeldman
registry.dat
ファイルには、このディレクトリ配下で既に管理しているパッケージ群が書かれています。lock
ファイルは、このディレクトリへの書き込みを排他制御するためのもので、filelockパッケージを利用して行っています(ソースコードから抜粋):
compile :: FilePath -> IO (Either Exit.Reactor B.Builder)
compile path =
do maybeRoot <- Stuff.findRoot
case maybeRoot of
Nothing ->
return $ Left $ Exit.ReactorNoOutline
Just root ->
BW.withScope $ \scope -> Stuff.withRootLock root $ Task.run $ ...
withRootLock :: FilePath -> IO a -> IO a
withRootLock root work =
do let dir = stuff root
Dir.createDirectoryIfMissing True dir
Lock.withFileLock (dir </> "lock") Lock.Exclusive (\_ -> work)
elm install
や elm make
を実行するとlock
ファイルによってロックをとり、registry.dat
ファイルを見て対象のパッケージがダウンロード済みかを確認し、なければダウンロードしてくるといった感じです。
registry.dat
ファイルがあるため、依存パッケージ別に保存し再利用することはけっこう難しいです。なので,Bazelのサンドボックス内に保存したELM_HOME
の中身をまるまるドカッと Bazelの生成物として再利用することにしました、この生成物はelm.json
に依存することにすれば、elm.json
が変更されない限りは再ダウンロードされません。もちろん、elm.json
が少しでも変更されると全て再ダウンロードされますが、そこまで時間かからないので取り敢えず目を瞑ることにします。
インストール済み依存パッケージの生成するルール
elm_make
のときと同様に、Windowsでも動作させるためにPythonスクリプトで実装をします:
#!/usr/bin/env python3
# elm_dependencies.py ELM_PROJECT_ROOT
import json
import os
import os.path
import shutil
import subprocess
import sys
def run(cmd, *args, **kwargs):
try:
subprocess.run(cmd, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, *args, **kwargs)
except subprocess.CalledProcessError as err:
sys.stdout.buffer.write(err.stdout)
sys.stderr.buffer.write(err.stderr)
raise
elm_runtime_path = os.path.abspath("@@ELM_RUNTIME@@") # Bazelのテンプレート機能で後から指定
elm_project_root = sys.argv.pop(1) # トップレベル以外で Elm プロジェクトを指定した場合を考慮
os.environ["ELM_HOME"] = os.path.abspath(os.getenv("ELM_HOME")) # 念のため絶対パスにする
os.chdir(elm_project_root)
# elm.json しか無い前提なので `source-directories` で指定してあるパスを生成しておく
elm_json = json.load(open("elm.json"))
if elm_json["type"] == "application":
for srcdir in elm_json["source-directories"]:
os.makedirs(srcdir, exist_ok = True)
# Main.elm はなんでも良いのでコンパクトなのを生成
with open("Main.elm", mode = "w") as f:
f.write("import Browser\nimport Debug\nmain = Browser.sandbox (Debug.todo \"temp\")")
run([elm_runtime_path, "make", "Main.elm"])
# zip で固める
elm_home = os.getenv("ELM_HOME")
shutil.make_archive(elm_home, "zip", root_dir = elm_home)
指定した elm.json
を読んで、必要最小限なElmプロジェクトを作成し、それをとりあえずビルドして作られた ELM_HOME
を zip
に固めてBazelの生成物としています。かなり無理矢理ですね笑
あとは、elm_make
のときと同じように py_binary
を利用して、elm_dependencies
を作成します(割愛)。
インストール済み依存パッケージを再利用する
そして、elm_make
側を拡張して、前述したBazelの生成物から ELM_HOME
を再利用します:
#!/usr/bin/env python3
# elm_wrapper.py ELM_PROJECT_ROOT [ARGS_FOR_ELM...]
...
if os.getenv("ELM_HOME_ZIP") == None:
os.putenv("HOME", os.getcwd())
else:
elm_home = os.getcwd() + "/.elm"
elm_home_zip = os.getenv("ELM_HOME_ZIP")
with zipfile.ZipFile(elm_home_zip) as elm_zip:
elm_zip.extractall(elm_home)
os.environ["ELM_HOME"] = elm_home
os.chdir(elm_project_root)
...
ELM_HOME_ZIP
にelm_dependencies
のBazel生成物のパスを設定し、それを unzip
して、そのパスを ELM_HOME
にしているだけです。zip
を挟むことで、パーミッションの問題も解決しています。
テスト用ルールを作る
最後にテストを実行するためのルールを作ります。
ご存知の通り、Elm本体はテストするためのナニガシを提供していません。テスト用のパッケージは提供していますが、今のところ実行方法はサードパーティに委ねています。デファクトスタンダードなのは rtfeldman/node-test-runner を利用して実行する方法だと思います。
しかし、rtfeldman/node-test-runner のコマンドは npm
などでインストールし実行するコマンドです。npm
を利用する場合、rules_nodejsを利用して面倒な設定をする必要があります。なので、できればシングルバイナリで提供されてるものがあると嬉しいのです。
調べたところなんとありました:
まさかの Rust 製です。これを使うことにします。
Build系ルールとの違い
前述した2つのルールは bazel build
で実行することを想定しています。対して、テストは bazel test
で実行し、振る舞い的には bazel run
と同じです。build
は生成までをBazelのサンドボックで行い、そこで必要なファイル群をツールチェインや引数などを利用して用意します。対して、run
の場合は最終的な成果物そのものが実行用のサンドボックスだと考えてください。つまり、実行するのに必要なもの(例えば今回の場合はNodeとか)をすべてまとめて生成物にする必要があります。そこが run
系のルールを作る上での注意点です:
def _elm_test_wrapper_impl(ctx):
elm_compiler = ctx.toolchains["@rules_elm//elm:toolchain"].elm
elm_test_bin = ctx.toolchains["@rules_elm//elm:toolchain"].elm_test
nodeinfo = ctx.toolchains["@rules_nodejs//nodejs:toolchain_type"].nodeinfo
inputs = [
elm_compiler,
elm_test_bin,
ctx.file.elm_json,
] + ctx.files.srcs + ctx.files.tests + nodeinfo.tool_files
test_filepaths = []
for file in ctx.files.tests:
test_filepaths.append(file.short_path)
substitutions = { ... } // 割愛
if ctx.file.elm_home != None:
substitutions["@@ELM_HOME_ZIP@@"] = ctx.file.elm_home.short_path
inputs.append(ctx.file.elm_home)
elm_wrapper = ctx.actions.declare_file(ctx.attr.src_name + ".py")
ctx.actions.expand_template(
template = ctx.file.elm_wrapper_tpl,
output = elm_wrapper,
is_executable = True,
substitutions = substitutions,
)
return [DefaultInfo(files = depset([elm_wrapper]), runfiles = ctx.runfiles(files = inputs))]
_elm_test_wrapper = rule(
_elm_test_wrapper_impl,
attrs = {
"src_name": attr.string(),
"elm_wrapper_tpl": attr.label(allow_single_file = True),
"tests": attr.label_list(allow_files = True),
"srcs": attr.label_list(allow_files = True),
"elm_json": attr.label(
mandatory = True,
allow_single_file = True,
),
"elm_home": attr.label(allow_single_file = True),
},
toolchains = [
"@rules_elm//elm:toolchain",
"@rules_nodejs//nodejs:toolchain_type",
]
)
最後の runfiles
が前述した「実行するのに必要なものをすべてまとめて生成物にする」をしているところです。この _elm_test_wrapper
で作ったBazel生成物を py_test
に渡しています(Windows対応)。
symlink問題
かなり試行錯誤したのですが、その中でも厄介だった問題の一つが symlink の問題です。いつものようにelm-test-rs
をツールチェイン化して、いざ実行してみたところ次のようなエラーが出ました:
elm-test-rs 1.1.0 for elm 0.19.1
--------------------------------
Generating the elm.json for the Runner.elm
The dependencies picked to run the tests are:
{
"direct": {
"elm/browser": "1.0.2",
"elm/core": "1.0.5",
"elm/html": "1.0.0",
"elm/json": "1.1.3",
"elm-explorations/test": "1.2.2",
"mpizenberg/elm-test-runner": "4.0.5"
},
"indirect": {
"elm/random": "1.0.0",
"elm/time": "1.0.0",
"elm/url": "1.0.0",
"elm/virtual-dom": "1.0.2"
}
}
get_module_name of: /path/to/elm-test-rs/tests/example-projects/passing/app/tests/Tests.elm
Error: This file "/path/to/elm-test-rs/tests/example-projects/passing/app/tests/Tests.elm" matches no source directory! Imports wont work then.
elm-test-rs
の実装を追ってみたところ、elm-test-rs
内でテスト用のElmファイルが、設定されてるディレクトリ内に存在するかどうかをチェックするところで落ちているようでした。 もちろん、ファイルはBazelサンドボックス(runfiles
)に渡しています。色々調査した結果、どうやらファイルがsymlinkされたものの場合、このようにエラーとなってしまうらしいです。ファイルがsymlinkになってしまうのはBazelの性質上どうしようもないことです。なので、elm-test-rs
側に修正PRを投げて対応してもらいました。
正直、普通に使っている限りsymlinkされたファイルを利用することはないはずですが。。。無事マージしてもらえたので気にしないことにします。
おしまい
ElmをBazelでビルドしよう!という人は、まず居ないと思います。しかし、「Bazel使ってフロントエンドしたい」という方がいたら、試しにElmなんてどうでしょうか?
ちなみに、私は会社のBazelを利用しているプロジェクトにElmを導入したくて丸っと作ることになりました笑
Discussion