Leiningenやbootといった既存のビルドシステムを利用せずに、Deps and CLIと必要な処理を自分で記述するスクリプティング型のビルド方式を試してみた。
2018.06.06 『続・Clojure Deps and CLIを利用する』を追加。
概要
Clojure(Script)なライブラリ nijohando/failable へ本ビルド方式を適用。
- tools.deps.alpha でクラスパス、依存ライブラリを管理
- clj コマンドでスクリプトの実行
- make で各タスクに応じた複数の
cli
コマンドやその他外部コマンドを束ねる - clojure.main/replでREPL(clj)
- figwheel on Node.jsでREPL(cljs)
- clojarへのデプロイは cemerick/pomegranate
- テストは clojure.test / cljs.test on Node.js
ディレクトリ構成
dev/prj
にビルドで必要になる各種処理(REPL、テスト、デプロイ)のスクリプトを配置。
dev/prj/task
は clj
コマンドから呼び出すタスクグループ毎のエントリーポイントを配置。
├── Makefile
├── deps.edn
├── dev
│ ├─ prj.clj // prj配下で利用する共通処理
│ └─ prj
│ ├── cljs.clj // ClojureScript関連
│ ├── package.clj // パッケージ作成、デプロイ関連
│ ├── pom.clj // pom.xmlの操作用
│ ├── repl.clj // repl関連
│ ├── test.clj // テスト実行関連
│ ├── user.clj // REPL(clj)の起点ネームスペース
│ ├── user.cljs // REPL(cljs)の起点ネームスペース
│ └── task
│ ├── cljs.clj // cljs固有タスクグループの起動エントリーポイント
│ ├── package.clj // パッケージ固有タスクグループの起動エントリーポイント
│ ├── repl.clj // REPL固有タスクグループの起動エントリーポイント
│ └── test.clj // テスト固有タスクグループの起動エントリーポイント
├── src
└── test
Makefile
外部コマンドで済むタスクは外部コマンドで完結させ、スクリプトで処理させたいものは clj
コマンド経由で該当タスクを実行している。
起動エントリーポイントとなるタスクグループはそれぞれmain関数を持ちcljコマンドで起動可能。起動時には実行するタスク名をキーワードで指定する。
Github上のClojure(Script)のプロジェクトではタスク毎にシェルスクリプトを定義するケースが良く見られるが、今回は make
を使用して全てのタスクとその起動方法をMakefileに集約した。
# 省略 ...
TASK_CLJS=-C:dev -R:dev -m prj.task.cljs
TASK_TEST=-C:dev:test -R:dev:test -m prj.task.test
TASK_PACKAGE=-C:dev -R:dev:package -m prj.task.package
TASK_REPL=-C:dev:test -R:dev:repl:test:package -m prj.task.repl
# 省略 ...
npm-install:
$(CLJCMD) $(TASK_CLJS) :npm-install
repl-clj:
$(CLJCMD) $(TASK_REPL) :repl-clj
repl-cljs:
$(CLJCMD) $(TASK_REPL) :repl-cljs
test-clj:
$(CLJCMD) $(TASK_TEST) :test-clj
test-cljs:
$(CLJCMD) $(TASK_TEST) :test-cljs
pom.xml:
$(CLJCMD) -Spom
$(CLJCMD) $(TASK_PACKAGE) :update-pom $(GROUP_ID) $(ARTIFACT_ID) $(VERSION)
ifdef IS_RELEASE_VERSION
$(GPG_SIGN_CMD) pom.xml
endif
$(JAR_FILE):
mkdir -p $(WORK_DIR)
jar cf $(JAR_FILE) -C src jp
ifdef IS_RELEASE_VERSION
$(GPG_SIGN_CMD) $(JAR_FILE)
endif
package: pom.xml $(JAR_FILE)
deploy: package
$(CLJCMD) $(TASK_PACKAGE) :deploy pom.xml $(JAR_FILE) $(DEPLOY_REPO_URL)
# 省略 ...
タスク毎にバラバラとシェルスクリプトを用意するよりこちらの方が見通しが良くて良い気がする。
が、欠点としては make
コマンドを事前にOSへインストールしておく一手間が必要な点。
ローカル開発環境は良いがCI環境で利用するCircleCIコンテナにこのためだけに make
を含んだ大きめのパッケージ(build-essential
)をインストールしなければならない点がデメリット。
今回は以下の make
コマンド付きのCircleCI用Clojureコンテナを用意した。
https://github.com/nijohando/dockerfiles/tree/master/circleci-clj
deps.edn
deps.edn
は細かく aliases
を分けた。
理由としてはCI環境で動かす際にCI環境で動かす必要が無いタスクが依存する依存関係のダウンロードを抑制したかったため。
{:deps
{org.clojure/clojure {:mvn/version "1.9.0"}
org.clojure/clojurescript {:mvn/version "1.9.946"}}
:aliases
{:dev {:extra-paths ["dev"]
:extra-deps {environ {:mvn/version "1.1.0"}
meta-merge {:mvn/version "1.0.0"}}}
:test {:extra-paths ["test"]
:extra-deps {org.clojure/core.async {:mvn/version "0.4.474"}}}
:repl {:extra-deps {org.clojure/tools.nrepl {:mvn/version "0.2.12"}
cider/cider-nrepl {:mvn/version "0.17.0-SNAPSHOT"}
figwheel-sidecar {:mvn/version "0.5.14"}}}
:package {:extra-deps {com.cemerick/pomegranate {:mvn/version "1.0.0"}
org.clojure/data.xml {:mvn/version "0.2.0-alpha5"}}}}}
基本的に依存ライブラリは最初の一度しかダウンロードされないので気にならない場合が殆どが、ローカル環境でcircleciのジョブの動作確認を行うために circleci コマンドを使う場合、
ダウンロードした依存ライブラリは都度コンテナとともに破棄される。
このためダウンロードする依存関係を最小化しておかないとローカル環境におけるジョブ作成&デバッグが辛い事になる。
REPL(clj)
REPL関連は dev/prj/repl.clj
に処理を定義しており、
$ make repl-clj
で以下の repl-clj
関数が実行されREPLが立ち上がる。
(defn- find-available-port
[]
(let [s (java.net.ServerSocket. 0)]
(.close s)
(.getLocalPort s)))
(defn repl-clj
[]
(let [port (find-available-port)
server (clojure.tools.nrepl.server/start-server :port port :handler cider.nrepl/cider-nrepl-handler)]
(spit ".nrepl-port" port)
(clojure.main/repl :init #(do (in-ns 'prj.user)
(clojure.core/use 'clojure.core)
(use 'prj.user)))
(clojure.tools.nrepl.server/stop-server server)))
REPL起動時の起点ネームスペースには prj.user
を指定。
prj.user
には make
経由で起動するclojure系タスクの関数を require
しておき、 REPL経由でもタスクを実行できるようにしている。
(ns prj.user
(:require [prj.test :refer [test-clj test-cljs]]
[prj.repl :refer [repl-cljs]]
[prj.package :refer [update-pom deploy]]
[prj.cljs :refer [npm-install build-cljs]]))
REPLの起点ネームスペースを user
ではなく prj.user
としたのはCojure / ClojureScript 間で以下のような差異があるため両REPLともに prj.user
に統一した。
- Clojre / ClojureScrip で REPLのデフォルトの初期ネームスペースが異なる (
user
/cljs.user
) - ClojureScriptはシングルセグメントのネームスペースを推奨していない
cljs.user
には任意の関数を事前定義できない
REPL(cljs)
同じくcljs用のREPLも dev/prj/repl.clj
に処理を定義しており、
$ make repl-cljs
で以下の repl-cljs
関数が実行されcljs用REPLが立ち上がる。
(defn- run-figwheel
[conf]
(ra/start-figwheel!
{:figwheel-options {:server-logfile (str prj/work-dir "/figwheel_server.log")}
:build-ids [(:id conf)]
:all-builds [(merge conf {:figwheel true})]}))
(defn repl-cljs
([]
(repl-cljs nil))
([config-or-id]
(let [conf (prj.cljs/config (or config-or-id :dev))
jsfile-path (get-in conf [:compiler :output-to])]
(run-figwheel conf)
(let [p (prj.cljs/run-node jsfile-path)]
(ra/cljs-repl)
(ra/stop-figwheel!)
@(:stop p)))))
cljsはREPL起動時の初期ネームスペースを指定することができないためネームスペース cljs.user
で立ち上がる。
このためREPL起動後には同ネームスペースのロードと移動をする必要がある。
dev:cljs.user=> (require 'prj.user)
dev:cljs.user=> (in-ns 'prj.user)
dev:prj.user=>
パッケージの作成とデプロイ
Clojarsへのデプロイには以下5つの作業が必要となる。
- pom.xmlの生成・更新
- jarの作成
- 署名ファイルの作成
- リポジトリへの送信
これらの作業もスクリプトと外部コマンドを組み合わせて make
経由で行えるようにする。
pom.xmlの生成・更新
clj
コマンドの -Spom
オプションは deps.edn
の情報を元に pom.xml
を生成する。
また pom.xml
が既に存在すればDependencies部分のみ更新を行う。
deps.ednには依存関係とクラスパスの情報しかないためそこから生成されたpom.xmlは必要十分な情報が記載されていない。 このためpom.xmlの管理方法としては以下のいずれかをとる必要がある。
- pom.xmlを初期生成後に手動で編集しgit管理の対象とする
- pom.xmlをgit管理の対象外としデプロイ時の一時ファイルとして都度生成、不足している情報は生成時に都度補完する。
今回は後者の方法を採用。deps.edn
では足りない情報をMakefileが側に変数に定義し、以下のような make
ルールで都度、アーティファクトID、グループID、バージョンを任意の値で書き換えている。
pom.xml:
$(CLJCMD) -Spom
$(CLJCMD) $(TASK_PACKAGE) :update-pom $(GROUP_ID) $(ARTIFACT_ID) $(VERSION)
jarの作成
単なるライブラリ系プロジェクトということもあり素朴にjarコマンドでアーカイブを作成した。
$(JAR_FILE):
mkdir -p $(WORK_DIR)
jar cf $(JAR_FILE) -C src jp
署名ファイルの作成
Clojarsはリリースバージョンのデプロイにはpom.xml
とjarファイルの署名が必須となっている。
make
ルールの中でリリースバージョンの場合それぞれの署名ファイルを作成するようにしている。
pom.xml:
$(CLJCMD) -Spom
$(CLJCMD) $(TASK_PACKAGE) :update-pom $(GROUP_ID) $(ARTIFACT_ID) $(VERSION)
ifdef IS_RELEASE_VERSION
$(GPG_SIGN_CMD) pom.xml
endif
$(JAR_FILE):
mkdir -p $(WORK_DIR)
jar cf $(JAR_FILE) -C src jp
ifdef IS_RELEASE_VERSION
$(GPG_SIGN_CMD) $(JAR_FILE)
endif
デプロイ
パッケージ関連は dev/prj/package.clj
に処理を定義しており、
$ make deploy
で deploy
の事前条件のターゲットである pom.xml
と $(JAR_FILE)
が処理された後 以下のdeploy
関数が実行されリポジトリへデプロイが行われる。
(defn deploy
[[pom-file jar-file deploy-repo-url]]
(java.lang.System/setProperty "aether.checksums.forSignature" "true")
(aether/register-wagon-factory!
"http" #(org.apache.maven.wagon.providers.http.HttpWagon.))
(let [{:keys [group-id artifact-id version]} (pom/select (io/file pom-file)
{:group-id [pom/group-id]
:artifact-id [pom/artifact-id]
:version [pom/version]})
coordinates [(symbol group-id artifact-id) version]
artifact-map (merge {}
(when-not (snapshot? version)
{[:extension "pom.asc"] (io/file (str pom-file ".asc"))
[:extension "jar.asc"] (io/file (str jar-file ".asc"))}))
repository {:default {:url deploy-repo-url
:username (prj/env :deploy-repo-user)
:password (prj/env :deploy-repo-pass)}}]
(aether/deploy :coordinates coordinates
:artifact-map artifact-map
:jar-file jar-file
:pom-file pom-file
:repository repository)))
cemerick/pomegranateを使いデプロイを行う。
システムプロパティ aether.checksums.forSignature
を true
にしておかないとリリースバージョンのデプロイが失敗するので注意。
詳細は『Clojarsへのデプロイがno checksums providedで失敗』 を参照。
Clojarsへデプロイしたアーティファクトは基本的に変更不可で誤ったデプロイについても取り消すことはできないため、 デプロイ処理については入念にローカル環境でデバッグをしておく必要がある。
Clojarsへのデプロイは https
のみであるが、ローカル環境でsonatype/nexusコンテナを使って動作確認をするためには http
でのデプロイが必要になるため
以下のコードで http
用の wagon factoryを追加する必要がある。
(aether/register-wagon-factory!
"http" #(org.apache.maven.wagon.providers.http.HttpWagon.))
テスト
テストは clj / cljsによって
- テストの前準備作業
- テストの起動方法
- テストの内容
が異なるため管理が煩雑になりやすい。
テストに関連するコードは役割に応じて以下3つのディレクトリに分けて管理する。
- dev/prj
- clj / cljsテスト実行のためのタスク。 テストが起動するまでに必要な処理を担当する。
- test/prj
- テスト実行時で必要となるヘルパー
- test/jp
- このライブラリのテストケース本体
├── dev
│ └─ prj
│ └── test.clj // clj・cljs毎のテストを起動するためのタスク
└── test
├── jp
│ └── nijohando
│ ├── failable_test.cljc // clj・cljs 共通テスト
│ └── failable_test_cljs.cljs // cljs専用テスト
└── prj
└── test
├── cases.cljc // テストスイート
└── runner.cljs // cljs専用テストランナー
テストの実行はCI環境からは make
経由で、開発時は主にREPL上から起動する。
make
のターゲット名、REPLから実行する関数名ともにtest-clj
(cljテスト用) test-cljs
(cljsテスト用) で各テストが実行されるようにしている。
最後に
ビルドタスクが煩雑になりやすいClojure(Script)なライブラリ系やアプリケーション系プロジェクトはLiningenのような宣言型ビルドツールよりもこのようなスクリプティング型のビルド方式の方が塩梅が良い気がした。
また同じようなスクリプティング型のビルドツールにbootがあるが、bootのようにタスクやパイプラインのモデル化をやり始めると本来やりたい事に対して余分な手続きや制約が発生してしまう。
ビルドのモデル化は考えずにやりたき事をビルドモデルに非依存な関数として用意しそれを使うといった素朴で単純な構成が実は良いのではないかと思った。