EmacsでJavaを書くという話

  • 47
    いいね
  • 0
    コメント

EmacsでJavaを書くという話

こんにちは、ピーター・ルッソです。
知事選がんばります!!
この記事は Emacs Advent Calandar 2016 の 21 日目の記事です。
ところで最近、 Emacs で Java を書けるようにしてるのですが、今回はその話をしたいと思います。

EmacsとJava開発環境

Java の開発環境といえばもはや IDE を使うのが普通でしょう。
現状であれば Eclipse か IntelliJ のどちらか?といった感じでしょうか。

IDE は確かに便利ではありますが、やはり重量級のイメージがあります。
基本メインで Emacs を使ってる場合、どうしても重そうというイメージを払拭できませんし、
Java も同じく Emacs で開発でしたいという思いが強い人も多いことでしょう。

Emacs で Java を書く場合どのような方法があるのでしょうか?

挙げるとすれば以下のパッケージを使うことでしょう。

どれも一昔前では導入するだけでも一苦労しましたが、今ではどれも MELPA 経由でインストールできるように非常に試しやすくなりました。

では早速一つずつどんなパッケージなのか見ていきたいと思います。

JDEE

古くから Emacs には JDEE という CEDET ベースの巨大なパッケージがあります。
機能はかなり多いです。MELPAに登録されたので導入は以前に比べかなりしきいが下がったといえます。

最近 GitHub にリポジトリを移し、開発を再開させようとはしていますが、ジェネリクス、拡張 for 文など Java 5 以上の構文などにまだ未対応な部分が多いです。
またCEDET ベースなのでもはやメンテしていくのは厳しいでしょう。
ビルドツールも元々 Ant のみのサポート、そして根本的にサポートしている機能も古いです。
(有志が一部様々な機能を追加している場合がありますが、すべて本体にマージされてなかったりします。)

JDEE は機能は古いですが数は多いため、一部のユーザーは未だに JDEE を個別でカスタマイズして使用しているようです。

Malabar-mode

Malabar-modeは Maven 3をサポートし、Java 6をサポートしています。
Maven をサポートした部分は大きく、一昔前であればこれが一番まともに機能していたパッケージです。

これも一時期死んでいて開発を再開させようとメンテナが交代しました。その結果、1.x系は MELPA に登録され、1.x系に関しては導入はとてもしきいが下がりました

次バージョン、 2.0 の開発も進められていましたが、 Malabar-mode 自体、 CEDET に依存しており、2.0 では CEDET の最新を要求するので導入はなかなかしきいが高くなりました。そのせいか安定して動作しないことが多く、現在では開発はほぼ停止しています。

CEDET のパーサーを使うものの多くは機能しないケースがことがあります。これは CEDET が Idle 時間を利用し、そこで解析、キャッシュを行うようになっている仕様のためです。なのでファイルをオープンした直後では正しく補完が表示されなかったり、補完にもの凄く時間がかかったりします。
(解析結果がない場合はその場で解析しはじめるがやたらと遅い)

Eclim

元々は Vim で Java を書くためのものを Emacs に移植したものです。
こちらも MELPA に登録されたので導入はかなりしきいが下がりました。

本家の Eclim (Vim) の機能に比べて実装されていなかったものもだいぶ実装されてきています。
補完はもちろん、 Import の最適化や、リファクタリング、 Ant, Maven タスクの実行など、 Eclipse 同様の機能をシームレスに使うことができます。
Eclipse の機能を使うのでジェネリクスはもちろん Java 8 も対応しています。
但し、やはり Eclim は Eclipse 自体の機能を Emacs で使うものであるため、まず Eclipse についての知識がないとよくわからない箇所がでてくるかも知れません。

余談ですが、Visual Studio Code の Java Integration も Eclipse(JDT) ベースです。

ENSIME (ENJIME)

こちらも現状での本命の一つです。
ENSIME は Scala の開発環境であるイメージが強いですが、既に Java 対応が済んでおり、Emacs に関してはその恩恵を受けることができます。
また対応しているビルドツールが多いのも強みでしょう。ですが、初回セットアップには時間がかかります。
コレは手間がかかるという話ではありません。コマンドを実行してから、たまに行きたくなるような遠いお店に昼飯を食べにいけという意味です。

Java 対応部は ENJIME と呼ばれ、 Java ファイルでも ensime-mode を有効にするだけで機能します。
Lambda などでも補完が効き、動作も軽快です。
ただし、基本は ENSIME と同じなのですが、現状ではドキュメントが全く無いので Java のみで使おうとすると少々わからない部分がでてくるかも知れません。

ちなみに私自身、ENSIMEコミッタから協力要請されています。

現実的な選択肢

現状、選ぶとすれば Eclim or ENSIME の2つからといった感じになりそうです。
ただ Eclim は Eclipse が必要でセットアップするのが面倒だし、実質 Eclipse をバックグラウンドで起動するような形になります。
なのでそのまま Eclipse 使えばいいのでは?という気がしてきます。

となると ENJIME なのですが、ENJIME に関してもビルドツール毎の初期設定が若干面倒ではあります。
またドキュメントがなさすぎてカスタマイズができるのかどうか不明すぎるという点もマイナス印象です。
セットアップにものの凄い時間がかかるというのもマイナス点かも知れません。

新しい開発環境の開発

というわけで結局、自分(ルッソ議員)に合った環境がない以上は開発するしかないないという話になります。
この辺はイチから開発したことがないのでやってみるのもよいかも?と思い、重い腰をあげて開発を開始しました。
ちなみに私はまともな elisp を書くのが初めてです。

設計方針について

まず自分(ルッソ議員)が欲しいものはどんなものなのか、整理してみます。

  • Build tool との連携(ソースディレクトリ、クラスパスなどの設定をとりこむ)
  • Plugin を使わないで Build tool と連携する(設定を少なくしたい)
  • Class, Method などの補完
  • Generics に対応した補完
  • 補完の際に、引数、返り値が確認できる
  • 補完機能呼び出しをシームレスに(IntelliJのようにわざわざキーを押下しないでも補完候補が自動ででる)
  • コンパイルができる
  • コンパイル結果に問題があった場合に通知できる
  • ユニットテストを実行できる

と上記に書いたようなものがあれば実用できそうです。では実現するにあたりどんな設計にすればいいでしょうか?

クライアント・サーバーモデル方式

まず大きな設計方針としてクライアント・サーバーモデルとし、クライアント(すなわち今回は Emacs)での処理を減らすという方式を採用します。
この方式は先人たちの失敗から学んだ結果であり、 ENJIME などもそのような方式となっています。
極力、elisp は表示部のみに特化し、巨大な elisp を書かない方針で開発します。

elisp を極力書かない理由は以下です。

  1. elisp の貧弱さの問題
  2. elisp の処理速度の問題
  3. 並列処理の問題

一部の狂人が elisp だけで実装しようとしますが、これは間違いです。
elisp 単体で見ると処理速度はそこまで悪くありません。但し、それは単純な処理の場合の話です。
elisp の言語仕様、標準関数を見る限りやはりリッチとは言い難く、実現したい処理を記述するには
それ相応のコード量を書く必要があり、それだけ多くのコードを実行しなければなりません。
ライブラリとしてのパッケージを使用をしてもコード量は減るけど処理自体がなくなるわけでもないので
そこそこの実行速度程度ではやはり遅く感じてくるでしょう。
また elisp は並列処理がまともにできません。マルチコアが主流になった今これは痛手です。
そのため、elisp ではあまりロジック的な部分を書かず、サーバー側で主機能を実装するようにします。

もちろん、この方式でもデメリットはあります。Emacs 上の現在書きかけの buffer の状態は Emacs のメモリ上に存在しており、
サーバー側には見えないという点です。そのため、リアルタイムで構文解析などができません。
一度、保存し、ファイルに反映してからサーバーが解析し、結果を返す。といったような手順を踏むことになります。
また保存したソースファイルが構文的に正しいソースファイルかどうかも不明です。場合によっては中途半端な状態でソースファイルに
反映されるかも知れません。この部分は割り切る部分ではあると思います。
今回はファイルを保存時にサーバーでソースファイルを解析し、ソースファイルの状態をサーバーで管理する方式を採用しますが、
構文的に正しくないソースファイルの場合にはその場で解析を止め、解析結果は反映しないという方向にします。
逆に言えば新しい変数を追加した場合には保存しない限り解析されず、補完対象にならないということです。
この方式は ENJIME なども同じです。
とはいえ辛い部分もあります。メソッドチェーンなどの補完でイチイチ補完をしていたらとても書きづらくなってしまいます。
そのため、これらの問題を緩和するための処理を追加していきます。

またサーバーモデルを採用をするとクライアントを自由に選ぶことができるようになります。
これはすなわち Vim, Atom, VSCode にも移植することが少ない工数でできるということです。
ENSIMEEclim がまさしくこの方式であり、いろいろなエディタに対応するのがとても楽になります。

開発言語

ではなんの言語でサーバーを開発するべきか?という話をしたいと思います。
元々、これを開発する動機の一つになったのは Gradle Tooling API の公開です。
これはその名の通り Gradle を直接 API 経由で操作するといった事ができます。
ENSIME などは Gradle Plugin を適用しないと初期セットアップができませんでしたがそういったモノが必要なくなります。
となるともう答えは一つで開発言語は Java になります。

Java 8 VS Clojure

さて、Java で開発するとして JVM 上で動く言語はたくさんあるので、それらで書いても良いのかも知れません。
まずプロトタイプで感触をつかむため最初は Clojure で書き始めました。
ですが、やはり起動速度が遅すぎのため、Clojureのコード部をどんどん薄くし始め結局 Java が 9 割超え始めたのでもうすべて Java で書き直しました。

起動オプションも起動速度、消メモリ重視で起動します。

-XX:+UseConcMarkSweepGC -XX:SoftRefLRUPolicyMSPerMB=50 -XX:+TieredCompilation -Xverify:none -Xms256m -Xmx2G

Build Tool との連携

Java での開発はクラスパスの設定を行う必要があります。多くのプロジェクトは、なんらかの既存の Build Tool を使っています。
それらの設定を読み込んでシームレスにクラスパスの設定、出力先などを連携するととても親切です。

Gradle Tooling API

現状、Gradle に対応しないツールは見向きもされないでしょう。
Gradle には API があり、外部から Gradle Daemon に接続、コマンドを送りタスクを実行することができます。
今回はサーバー側で Gradle に接続し、依存解決、クラスパスなどの設定をとりこみます。
このAPIの良いところは Gradle Wrapper さえあれば Gradle 本体のダウンロードも行ってくれる点です。
プロジェクトにプロジェクトで使用する Gradle のバージョン Wrapper をコミットしておけばあとは自動で本体のダウンロード、依存解決
などを自動で行うことができます。

Maven 難しい問題

Maven も未だに根強い人気があります。
Maven も Maven 本体自体をサーバーに埋め込むという方法も考えられます。
実はこの方法を試したのですが、一部の機能がうまくインジェクトされなかったため、断念しました。
(内部はDIになっておりどのクラスがどのJarにあるのかよくわからないかつ初期化手順もよくわからない)
また場合よっては Maven のバージョンによってプラグインが動かない事もありえます。
そのため、 POM のパースと mvn コマンドの実行によるハイブリッドな方式を内部で実行し、連携する方法を採用します。

クラスローダー問題

さて、実際プロジェクトと連携し、補完などを行うわけですが対象クラスの情報をどう取得するかを考えなければいけません。
Java にはリフレクションのAPIがありますがそれでよいでしょうか?

Reflection 遅い問題

Reflection でのフィールドアクセス、メソッド呼び出しが遅いという話はありますが、列挙する場合はどうでしょうか?
例えば補完対象のクラスの一覧を表示するためクラスの一覧を作成しようと思います。
Guava にはクラスパスの情報にアクセスする ClassPathというクラスがありますが、これでgetAllClassesを呼び出すととてつもなく遅いです。
クラスローダーにのせる事自体遅くなる要因なのです。
これではスムーズな補完は厳しいです。
またクラスローダーにはサーバー側のクラスがロードされています。
そのため、プロジェクトにはない関係ないクラスが補完候補に出てしまうかも知れません。

ASM Reflector

上記問題を解決するために Reflector を自作しました。
クラスローダーに載せないため純粋にプロジェクト内の補完候補を出せます。
また速度問題を解決するため、以下のアプローチを採用しています。

  1. プロジェクト検出及び、プロジェクトのコンパイル
  2. クラスパスよりjarファイルのパスを特定し、並列で jar ファイルのクラスファイルを走査、クラス一覧を作成する
  3. クラス一覧を作成する際にはどのクラスがどの jar にあるのか記録しておく
  4. 補完候補を出す際に指定したクラスのjarを特定し jar 内を検索、ASM でメンバーなどを読み取る
  5. 読み取った結果はメモリキャッシュとファイルキャッシュに載せる。プロジェクト内のクラスであれば対象ソースのchecksumも記録する
  6. 次回からは補完候補はキャッシュからの読み出しとし、checksum が変更されている場合には再度 ASM で読み込み直す

膨大なクラス数

とはいえ補完候補というのはどれぐらいあるものなのでしょうか?
JDK によりますが Java の標準だけでも 9000 クラス程度はあります。
そしてこれにプロジェクトの依存を加えると大体数万クラスぐらいをインデックスすることになります。
これらの膨大な数を素早く処理するためにはできるだけ並列化を行う必要があります。

Meghanada

と長くなりましたが上記を実装したのが Meghanada になります。
多くは Java で書かれているサーバーで実装されていますが、フロントである elisp もそれなりに量があります。
このクライアント、サーバーモデルに関しては irony-mode を参考に作られています。
Emacs で meghanada-mode で開くと同時にサーバーを起動、接続、あとはコマンドをサーバーと Emacs 側でやり取りします。

補完は company のバックエンドとして実装されています。
またコンパイルエラーなどのチェック系は flycheck の checker として実装されています。
Meghanada は Emacs の部分はなるべく独自な処理を行わないように設計されています。
そのため、他の言語の設定と同様な操作で使うことができます。

補完のトリガーは IntelliJ を参考にしています。基本的にキーワードなどを拾い補完を起動するため
わざわざ補完のためにキーを押す必要はありません。

導入のしやすさ

Meghanada はインストールのしやすさも考慮しています。
サーバーの jar ファイルは fat jar にして bintray にアップロードしてあります。
Elisp 側でローカルにサーバーのjarがない場合には bintray から自動でダウンロードしてきてくれます。
あとはEmacs を再起動すれば使えるように鳴ります。
irony のようにわざわざコンパイルするようなことはありません。

非同期処理

場合によっては重い処理もあるかも知れません。それを見越して多くは非同期で通信するように設計されています。
エディターでフリーズしてしまうのは致命的な問題です。
補完処理は company を使っています。
company には非同期対応の API があり、これを使うことでフリーズしないように工夫しています。

2つのコネクション

Meghanada はサーバーと2つコネクションを貼ります。
一つはコマンド送受信、一つはストリーム用です。
ストリームはアウトプットをリアルタイムで受信する機能です。
これは特に JUnit の実行、ビルドなどのコンソールログを確認するのに使います。

正直ストリームがないと話にならないと思うのですが、VSCode で採用されている LSP にはストリームがありません。

最後に

とざっくりと Meghanada 紹介をしました。
このプロジェクトは今年から書き始め、年内にリリースできればいいなあという感じでした。
リリースするまでは何回か書き直したのですがそれでも 10 月ぐらいに初版がリリースできました。

現状はソース解析部を独自で実装していたのですが、この部分を ENSIME などと同様、インターナルなコンパイラーAPIで書き直しています。
これによりフルでラムダ、メソッドリファレンスをサポート出来るようになると思います。

この投稿は Emacs Advent Calendar 201621日目の記事です。