Toyokumo Tech Blog

トヨクモ株式会社の開発本部のブログです。

モノレポなClojureプロジェクトをClojure LSPで開発するための設定方法

こんにちは。開発本部の木下です。

Visual Studio Codeの普及と同じくしてLSP(Language Server Protocol)を開発で使用することは普通のこととして浸透したように思います。

LSP があれば、Intellij IDEAに代表される IDE が提供してきたような開発体験を VSCode や Vim や Emacs といった普段から自分が使用しているエディタで得られることができるようになります。

しかしながら弊社の Clojure アプリケーションで LSP を使った開発を生産的に行うには 1 つ問題がありました。 この記事ではどういった問題なのかということと、その解決策について記載します。

これまでの構成

弊社のプロジェクトは基本的に次のように 1 つのリポジトリ(プロジェクト)に複数のモジュールが存在する、いわゆるモノレポな構成になっていました。

.
├── .clj-kondo
├── .cljstyle
├── Makefile
├── api1
│   ├── deps.edn
│   ├── src
│   └── target
│       └── api1.jar # api1 としてデプロイ
├── api2
│   ├── deps.edn
│   ├── src
│   └── target
│       └── api2.jar # api2 としてデプロイ
└── common
    ├── deps.edn
    └── src

上記の例では、common, api1, api2 の 3 つのモジュールがあります。 共有したいコードは common に配置し、api1 と api2 は独立したアプリケーションとしてデプロイします。

api1 と api2 からは:local/rootを利用して common を参照します。

;; api1/deps.edn or api2/deps.edn
{:deps {myproject/common {:local/root "../common"}}}

この構成において注意したいのは common モジュールのコードを変更する時です。

通常の開発時は api1 や api2 の REPL を起動して開発することになりますが、 common にある関数を変更する際は、それを呼び出している箇所を api1 と api2 を横断して確認する必要があります。

LSP で開発するときの問題点

このようなプロジェクトをClojure LSPを使って開発してみるとします。

ここで考えなければいけないのは、どこを LSP にとってのプロジェクトルートとするかです。 以下の例で考えてみます。

.
├── .clj-kondo
├── .cljstyle
├── .lsp --------------------------(1)
├── Makefile
├── api1
│   ├── .lsp ----------------------(2)
│   ├── deps.edn
│   ├── src
│   └── target
│       └── api1.jar # api1 としてデプロイ
├── api2
│   ├── .lsp ----------------------(3)
│   ├── deps.edn
│   ├── src
│   └── target
│       └── api2.jar # api2 としてデプロイ
└── common
    ├── deps.edn
    └── src

(1)はプロジェクトルートを開いて開発するやり方です。 この場合、Clojure LSP が common, api1, api2 を横断して認識してくれることを期待しますが、そうはいかず、開いたファイルしか認識してくれないということが起こります。 これではまともに開発ができません。

(2)と(3)はモジュールごとに開いて(プロジェクトルートとして)開発するやり方です。 例えば api1 を開いた場合、Clojure LSP が:local/root に対応してくれているので common と api1 はうまく認識してくれますが、 common を参照している api2 のコードは認識できません。 この状況のまま開発するとうっかり api2 のことを忘れて common を変更してしまったということが起こりやすくなります。 もちろん注意して検索して探せば参照している箇所を見つけることはできるでしょうが、それは生産的な開発環境とは言えないでしょう。

なぜこのような問題が発生してしまうのかというと、Clojure LSP はクラスパスから情報を得ているためです。 そこで全てのモジュールがクラスパスに入るような解決策を考えます。

解決策 1: LSP のための deps.edn を用意する

解決策の 1 つ目はプロジェクトのルートディレクトリに LSP のための deps.edn を用意するというものです。

.
├── .clj-kondo
├── .cljstyle
├── .lsp
├── deps.edn # 追加
├── Makefile
├── api1
├── api2
└── common
;; deps.edn
{:deps {myproject/common {:local/root "common"}
        myproject/api1 {:local/root "api1"}
        myproject/api2 {:local/root "api2"}}}

このような deps.edn を用意することで、ルートから見ると全てのモジュールがクラスパスに含まれているため、モジュールを横断した参照が可能になります。

解決策 2: 1 つのモジュールにまとめる

解決策の 2 つ目はプロジェクトの構成を変更して 1 つのモジュールにまとめるやり方です。

2.1 ソースコードを src にまとめる

まずはソースコードを全て src 下にまとめるシンプルなやり方です。

次のように配置し、deps.edn にビルドするための設定を追加します。今回はubedrdepsを使っています。

.
├── .clj-kondo
├── .cljstyle
├── .lsp
├── deps.edn
└── src
    └── myproject
        ├── api1
        │   └── core.clj # api1 のメインクラス
        ├── api2
        │   └── core.clj # api2 のメインクラス
        └── common
        │   └── util.clj
{:paths ["src" "classes"]
 :deps {org.clojure/clojure {:mvn/version "1.11.1"}}
 :aliases {:dev {:extra-paths ["test"]}
           :uberjar {:replace-deps {uberdeps/uberdeps {:mvn/version "1.1.1"}}
                     :replace-paths []
                     :main-opts ["-m" "uberdeps.uberjar"]}}}

次のようにビルドします。

mkdir classes
clojure -M -e "(compile 'myproject.api1.core)"
clojure -M:uberjar --main-class myproject.api1.core --target target/api1.jar
clojure -M -e "(compile 'myproject.api2.core)"
clojure -M:uberjar --main-class myproject.api2.core --target target/api2.jar

2.2 モジュールごとにソースを分ける

依存するライブラリを api1 と api2 とで分けたいとなってきたら、alias を使って分離します。

.
├── Makefile
├── deps.edn
├── src
│   └── myproject
│       └── common
│           └── util.clj
├── src-api1
│   └── myproject
│       └── api1
│           └── core.clj
└── src-api2
    └── myproject
        └── api2
            └── core.clj
;; deps.edn
{:paths ["src" "classes"]
 :deps {org.clojure/clojure {:mvn/version "1.11.1"}}
 :aliases {:dev {:extra-paths ["test"]}
           :api1 {:extra-paths ["src-api1"]
                  :extra-deps {}}
           :api2 {:extra-paths ["src-api2"]
                  :extra-deps {}}
           :uberjar {:replace-deps {uberdeps/uberdeps {:mvn/version "1.1.1"}}
                     :replace-paths []
                     :main-opts ["-m" "uberdeps.uberjar"]}}}

この場合は次のようにビルドします。

mkdir classes
clojure -M:api1 -e "(compile 'myproject.api1.core)"
clojure -M:uberjar --main-class myproject.api1.core --target target/api1.jar
clojure -M:api2 -e "(compile 'myproject.api2.core)"
clojure -M:uberjar --main-class myproject.api2.core --target target/api2.jar

ただしこのようにした際は、.lsp/config.edn を用意して alias を LSP に認識させる必要があります。

;; .lsp/config.edn
{:source-aliases #{:dev :test :api1 :api2}}

まとめ

弊社ではこれまで Clojure 開発にCursiveや、弊社の@liquidz作のvim-icedを使うことがほとんどでそれらに最適化されたプロジェクト構成をしていました。

Cursive はプロジェクト全体を静的解析した上で参照関係を把握してくれるので特段問題なく開発できていました。 vim-iced は Clojure LSP を使用せず、clj-kondoを直接利用してプロジェクト全体を静的解析するので問題なく開発できていました。

今後は VS Code や Emacs といった開発環境でも生産的に開発できるように構成を考慮し、 それぞれの開発者の好みと生産性を両立できるようにしていければと思っています。


トヨクモでは好きなエディタを使って一緒に働いてくれる技術が好きなエンジニアを募集しております。

採用に関する情報を公開しております。 気になった方はこちらからご応募ください。