1
Help us understand the problem. What are the problem?

More than 1 year has passed since last update.

posted at

Bazelのマクロでコード生成を自動化(+ツールの管理を任せる)

この記事はOpenSaaS Studio Advent Calendar 2019、12日目の記事です。前回は依存ライブラリの管理について触れましたが、今回は手動で実施していたビルド処理をBazelの管理スコープに入れよう、というお話。

はじめに

Bazelでは公式や有志によって様々なルールが提供されており、それらを利用することでプログラミング言語やDockerイメージのビルドなど色々な処理を定義することができます。また、既存のルールでは要件を満たせない場合に新しくルールやマクロを定義することで処理の自動化やビルドに必要なツールの管理をBazelに移譲できます。
この記事では、私が実際に作ったマクロを元に新しい処理を追加する方法とその際に気をつけたポイントを紹介します。

マクロ化する処理

PEGで定義された規則からGolang用パーサのソースコードを生成する処理。PEGを使うとsample.pegのように独自の記法で構文を定義できます。詳細は今回の記事に関係ないので省略。
ソースコードの生成にはpointlander/pegというツールを使っていて、構文を変更すると(ツールが入っていなければ)ツールのインストール、コードの生成、コミットしてリポジトリにpush、という作業が必要でした。

sample.peg
package example

type Opt Peg {
    Params map[string][]string
}

Expression  <- ( KEY PATH )

KEY     <- < KEY_SYNTAX > &DOMAIN                       { p.addParam("prefix", text) }
PATH    <- DOMAIN < PATH_SYNTAX > ( QUESTION / EOF )    { p.addParam("path", text) }


##########################
#### Syntax
##########################
KEY_SYNTAX          <- [a-zA-Z0-9_*{}(),:;%#=\-+]+   # key can not contain Slash
PATH_SYNTAX         <- [a-zA-Z0-9_*{}(),.:;%#=\-+/]+  # key can contain Slash


DOMAIN              <- ('.localhost' PORT '/' )
PORT                <- ':' [0-9]+

##########################
#### Elements
##########################
QUESTION            <- '?'
EOF                 <- !.

必要なライブラリを定義

それではマクロを作っていきましょう。
これまでと同じくコード生成にはpointlander/pegを使います。ツールのダウンロードもBazelにやってもらいたいので、まずはツール(と依存ライブラリ)を定義します。

WORKSPACE
### Peg to go
# keep
go_repository(
    name = "com_github_pointlander_peg",
    importpath = "github.com/pointlander/peg",
    commit = "169894c6af14e914b8ee20035b2fe22f222d77af",
)

# keep
go_repository(
    name = "com_github_pointlander_jetset",
    importpath = "github.com/pointlander/jetset",
    commit = "eee7eff80bd4f18ff56a0eef8d5b005c8d592e7a",
)

# keep
go_repository(
    name = "com_github_pointlander_compress",
    importpath = "github.com/pointlander/compress",
    commit = "ff44bd196cc34cff192fd5c586f74b9ca7fc9a11",
)

ここで定義したライブラリはgo.modでは定義されていないので、Gazelleで削除されないようにkeepコメントを付けています。(前回の記事を参照)

マクロを作成

spec

  • PEGファイルと同じパッケージにGolangのソースコードを生成
  • 生成するファイル名はPEGファイルの拡張子をgoにしたもの(例えば、sample.peg->sample.go
  • 入力は1ファイルのみ

実装

Bazelには指定した入力を元にbashコマンドを実行してファイルを生成するgenruleというルールがあるのでこれを使います。

peg_go.bzl
def peg_go(name, src):
    gosrc = src + ".go"
    native.genrule(
        name = name,
        srcs = [src],
        outs = [gosrc],
        cmd = "cp $(<) $(@D) && $(location @com_github_pointlander_peg//:peg) $(@D)/%s && rm $(@D)/%s" % (src, src),
        tools = ["@com_github_pointlander_peg//:peg"],
        visibility = ["//visibility:public"],
    )

peg_gosrcに指定したPEGファイルを元にGolangのソースコードが生成され、生成されたコードはnameで指定したラベルでアクセスします。
雰囲気でなんとなく分かると思うのでgenruleの詳細は公式のドキュメントにお任せしますが、分かりづらいところがあるので二点解説します。

  • cmdで使用している$(<)$(@D)といった記述は、srcsoutsで指定した内容に基づいた記法で、入力(のうち1ファイルだけの場合)や出力先ディレクトリを意味しています。詳細を知りたい場合は上で挙げているこのルールの公式ドキュメントや変数に関するドキュメントを参照してください。
  • bashコマンドの実行後、outsで指定したファイルが存在しないとビルドエラーになります。pointlander/pegで生成したコードは入力ファイルと同じディレクトリに出力されるので、ツールを実行する前にPEGファイルを出力先ディレクトリに移動(cp $(<) $(@D))し、実行した後にそのファイルを削除(rm $(@D)/%s")しています。(出力先に関するノウハウをどこかで見た気がしますが、探しきれませんでした。。。)

入力/出力ディレクトリの補足

Bazelは独自のサンドボックス環境を使ってビルドをする仕組みになっており、ビルド成果物を入力ファイルと異なるディレクトリに出力する必要があります。公式に詳細のドキュメントがあるので興味があれば確認してみてください。

マクロの使用

定義したマクロを呼び出してソースコードを生成しましょう。

Build.bazel
load("@io_bazel_rules_go//go:def.bzl", "go_library")
load("//:peg_go.bzl", "peg_go")

peg_go(
    name = "peg_go",
    src = "example.peg",
)

go_library(
    name = "go_default_library",
    srcs = [
        "example.go",
        ":peg_go",  # keep
    ],
    importpath = "github.com/mmmknt/rules_peg/tests/example",
    visibility = ["//visibility:public"],
)

peg_gonameで指定したラベルをgo_librarysrcsで指定しています。また、ここで生成されるコードはリポジトリに含めていないため、GazelleでBazelのビルドファイルを生成する際に削除されないようkeepコメントを付けています。

リポジトリの中に生成したコードへのシンボリックリンクを作る

急にイケてない作業が出てきましたが、Bazelの仕様に対応するためのワークアラウンドです。
入力/出力ディレクトリの補足で取り上げましたが、Bazelの成果物はリポジトリ配下には生成されません。(出力ディレクトリへのシンボリックリンクができるので、そこから辿ることはできる)
このままではIDEが認識してくれないorパッケージを間違って認識してしまうため、リポジトリの中の想定しているディレクトリの中にシンボリックリンクを追加できるようにします。

hack/expose-generated-go.sh
#!/usr/bin/env bash

set -o errexit
set -o nounset
set -o pipefail

OS="$(go env GOHOSTOS)"
ARCH="$(go env GOARCH)"
ROOT=$(dirname ${BASH_SOURCE})/..

expose_package () {
    local out_path=$1
    local package=$2
    local old_links=$(eval echo \$$3)
    local generated_files=$(eval echo \$$4)

    # Compute the relative_path from this package to the bazel-bin
    local count_paths="$(echo -n "${package}" | tr '/' '\n' | wc -l)"
    local relative_path="../../"

    # Delete all old links
    for f in ${old_links}; do
        if [[ -f "${f}" ]]; then
            echo "Deleting old link: ${f}"
            rm ${f}
        fi
    done

    # Link to the generated files
    local found=0
    for f in ${generated_files}; do
        if [[ -f "${f}" ]]; then
            found=1
            local base=${f##*/}
            echo "Adding a new link: ${package}/${base}"
            ln -nsf "${relative_path}${f}" "${package}/"
        fi
    done
    if [[ "${found}" == "0" ]]; then
        echo "Error: No generated file was found inside ${out_path} for the package ${package}"
        exit 1
    fi
}

##################
# For peg go files
##################

# Build peg go files
for label in $(bazel query 'attr(generator_function, peg_go, //...)'); do
    bazel build "${label}"
done

# Link to the generated files and add them to excluding list in the root BUILD file
for package in $(bazel query 'attr(generator_function, peg_go, //...)' --output package); do
    # Compute the path where Bazel puts the files
    out_path="bazel-bin/${package}"

    old_links=${package}/*.peg.go
    generated_files=${out_path}/*.peg.go
    expose_package ${out_path} ${package} old_links generated_files
done

Golangのコード生成した後にBazelのqueryで出力先を取得、その結果を使ってリポジトリの中にシンボリックリンクを作っています。
作ったシンボリックリンクは消したいですよね、ということでこんなスクリプトもあります。

hack/unlink-go.sh
#!/bin/sh

for f in $(find . -name \*\.peg\.go -not -path "./vendor/*"); do
    echo ${f}
    unlink ${f}
done;

まとめ

  • マクロ化することでBazelのビルドプロセスに組み込める
  • Bazelに組み込むことで、ツールの管理をBazelに任せられる
  • 生成したコードをリポジトリに追加する手間が増えた

Bazelのスコープに組み込むことで楽になるところもありつつ、Bazelのコンセプトを考えるとどうしてもワークアラウンドが必要になるところがある、という結果になりました。
Bazelが特定の言語に寄っていなかったり、ベストプラクティスが定まっていなかったり、ということもありどうしても新しいワークアラウンドを自分たちで考える必要があると思うので、チームの方向性やビルドプロセス全体のバランスを考えつついい感じに導入していきたいですね!(cloneしたらすぐ開発できるように生成したコードもコミットしとく、とか)

おまけ

  • Bazeliskを使えばBazel自体のバージョンも管理できます。
  • 今回の内容はリポジトリにまとめてあるので、もう少し整理したらリンク貼ります。
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Sign upLogin
1
Help us understand the problem. What are the problem?