前に Elm のコードを Bazel でビルドするためのルールを作りました. この生成物を Bazel でビルドしてる Haskell アプリケーションから参照する方法のメモ書きです.

Bazel生成物を参照する方法

思い付いた方法は2つ:

  1. Bazel で Docker イメージを作るときに含めて動的に参照する
  2. Bazel で Haskell をビルドするときに埋め込む(Template Haskell)

前者は Docker で固めるだけなので,Haskell 側で特別なことをする必要がなく簡単. 後者は rules_haskell と Haskell 側で試行錯誤する必要があるが,Haskell アプリケーション単体で完結するので便利だ.

Docker イメージに含める

Bazel を利用して Docker イメージを作る場合は次のように書く:

pkg_tar(
    name = "bin",
    srcs = [":app"],
    mode = "0755",
    package_dir = "/usr/local/bin",
)

container_image(
    name = "image",
    base = "@haskell_base//image",
    tars = [":bin"],
    entrypoint = ["/usr/local/bin/app"],
)

:app は,例えば rules_haskell の haskell_binary などで生成した実行ファイル(の Bazel 生成物)だ. container_image にファイルを直接渡す場合は files 属性を利用する:

container_image(
    name = "image",
    base = "@haskell_base//image",
    tars = [":bin"],
    files = [":mainjs"], # ココ
    entrypoint = ["/usr/local/bin/app"],
)

:mainjs は,例えば rules_elm の elm_make などで生成したファイルだ. この場合,ルートディレクトリ直下に生成物が置かれる. 任意のパスにしたい場合は directory 属性を使えば良い:

container_image(
    name = "image",
    base = "@haskell_base//image",
    tars = [":bin"],
    files = [":mainjs"],
    directory = "/work/static", # ココ
    workdir = "/work",
    entrypoint = ["/usr/local/bin/app"],
)

しかし,この場合 :bin のパスも変わってしまう(今回の場合は /work/static/usr/local/bin/app になってしまう). そこで,:mainjs:bin のように pkg_tar を介すようにすれば良い:

pkg_tar(
    name = "static",
    srcs = [":mainjs"],
    mode = "0444",
    package_dir = "/work/static"
)

container_image(
    name = "image",
    base = "@haskell_base//image",
    tars = [":bin", ":static"],
    workdir = "/work",
    entrypoint = ["/usr/local/bin/app"],
)

これで実行ファイルは /usr/local/bin にあって,Haskell アプリケーション側で読み込む静的ファイルは /work/static にあるようにできた. ただし,こっちの方法の問題として Docker イメージまで作らないと手元で動作確認ができない点がある. それでは不便な場合は,次の実行ファイル自体に静的ファイルを埋め込んでしまう方法をとると良い.

Haskell に埋め込む

Haskell でコードを埋め込むには Template Haskell を利用する. ちなみに,実際の作業PRはこちら

今回は file-embed パッケージを利用する:

import Data.FileEmbed (embedDir)
import Servant

type API = "static" :> Raw

server :: ServerT API (RIO Env)
server = serveDirectoryEmbedded $(embedDir "./static")

rules_haskell で Template Haskell などのために Haskell のソースコード以外を渡す場合には extra_srcs 属性を利用する(参照):

haskell_library(
    name = "homelyapp-library",
    src_strip_prefix = "src",
    srcs = glob(["src/**/*.hs"]),
    deps = [
        "@stackage//:base",
        ... 
    ],
    extra_srcs = [":mainjs"], # ココ
    compiler_flags = GHC_FLAGS,
)

extra_srcs に渡しているのが Bazel の生成物でない場合はこれでうまくいくが,Bazel の生成物を渡した場合はこれだけではうまくいかない. というのも,Bazel の生成物のパスが "./static" ではないからだ. :mainjs 自体は ./static/main.js という設定で生成したが,Bazel サンドボックスにおいてはカレントパスが変わってしまう(Bazelサンドボックスと各種ディレクトリ構造について).

ではどうすれば良いか. 一応,パスの類推は可能だが,Haskellコード側に Bazel 専用のパスをハードコードするのはいやだ. で,いろいろと Issue を漁っていたらドンピシャなものを見つけた. Issue のコメント曰く,次のようにしてあげれば良い:

haskell_library(
    name = "homelyapp-library",
    src_strip_prefix = "src",
    srcs = glob(["src/**/*.hs"]),
    deps = [
        "@stackage//:base",
        ... 
    ],
    extra_srcs = [":mainjs"], 
    compiler_flags = [
        "-DMAINJS_FILE=\"$(execpath :mainjs)\"",  # ココ
    ] + GHC_FLAGS,
)

-D オプションはCプリプロセッサによる変数を外部から与えるやつで,MAINJS_FILE 変数に execpath :mainjs の結果を与えている. execpath は Bazel 専用の特殊な変数展開らしく,サンドボックスのルートから与えた Bazel 生成物への相対パスを返す. つまり,これを embedDir に渡してやれば良い:

{-# LANGUAGE CPP #-}

server :: ServerT API (RIO Env)
server = serveDirectoryEmbedded $(embedDir (takeDirectory MAINJS_FILE))

おまけ:Hazell の修正

compiler_flags をいじった結果,Hazell が動作しなくなったので直した. 問題箇所は2つ:

  1. リストの結合(+)は未対応
  2. 文字列の " のエスケープが未対応

両方対応した:

リスト結合はだいぶ雑に実装しており,もし別の式をパースしたくなった場合はまるっと書き直す必要がある(演算子の優先順位などを考慮していないため).

おしまい

ちなみに,僕は後者の埋め込みを利用することにしました. こっちだと,bazel run のワンコマンドでアプリケーションをローカルで起動できるからです.