• 加筆
  • 2018.06.06
  • 修正
三 月
17
土曜日
2018

Clojure Deps and CLIを利用する

Leiningenbootといった既存のビルドシステムを利用せずに、Deps and CLIと必要な処理を自分で記述するスクリプティング型のビルド方式を試してみた。

2018.06.06 『続・Clojure Deps and CLIを利用する』を追加。


概要

Clojure(Script)なライブラリ nijohando/failable へ本ビルド方式を適用。

ディレクトリ構成

dev/prj にビルドで必要になる各種処理(REPL、テスト、デプロイ)のスクリプトを配置。
dev/prj/taskclj コマンドから呼び出すタスクグループ毎のエントリーポイントを配置。

├── 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.forSignaturetrue にしておかないとリリースバージョンのデプロイが失敗するので注意。
詳細は『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のようにタスクやパイプラインのモデル化をやり始めると本来やりたい事に対して余分な手続きや制約が発生してしまう。

ビルドのモデル化は考えずにやりたき事をビルドモデルに非依存な関数として用意しそれを使うといった素朴で単純な構成が実は良いのではないかと思った。