better-files で JAR のリソースが読めないので頑張った

新参ですが、ここから先は「だ・である調」で行くことにする。Scala の better-files で JAR ファイルに突っ込んだリソース(テキスト、画像など)が読めないという問題にぶち当たった。

better-files とは?

Scala については多少なりとも耳にしたことはあると期待するが、いろいろ怒られそうな言い方をすれば Java を静的型付けのまま型推論やら何やらがあって楽に書ける言語、という程度の認識で結構だろう。

その何やらというのは、Scala にある多くのシュガーシンタックスや implicits といったもので、コイツらを使うことで Groovy などと比べてもかなり柔軟なコーディングができる。利点は上手く使えば直感的で読み書きしやすいコードを書くことができること、欠点は下手に使いまくると非常にデバッグがしづらいコードが書けてしまうことで、諸刃の剣とも言える。

ここで、better-files というライブラリがありる。これは、Scala の柔軟性を活用して、ファイルに関する操作を直感的に書くことができるというものだ。およそ Java NIO 2 に相当し、大抵のファイル操作から読み書きまで扱える。具体的な書き方についてはリポジトリの README に数多くの例が載っているので参照していただきたい。ただし、一部の記法が未実装だったり、Scala の更新によって通らなくなったコードもあるので注意。

GitHub: pathikrit/better-files
Scalaの新しいI/Oライブラリbetter-filesを使ってみる - たけぞう瀕死ブログ

お試しプロジェクト

ここでは例として次のような sbt プロジェクトを作る。まず、ディレクトリ(名前は何でもいい)を作ってビルド設定を書いたファイル build.sbt を置く。内容は最低限の設定に留め、better-files への依存だけを足しておく。

build.sbt
name := "better-files_JAR"
version := "0.0.1"
scalaVersion := "2.12.4"
libraryDependencies += "com.github.pathikrit" %% "better-files" % "3.4.0"

もうひとつ、sbt のバージョンを project/build.properties に宣言する。私がこの記事を書いている時点での最新バージョンは 1.0.4 なので、次のコマンドでサクッと済ませておく。

$ mkdir project
$ echo "sbt.version=1.0.4" > project/build.properties

あとは sbt: Directory structure に従って、ファイルを次のように配置する。

src/
  main/
    resources/
      hello.txt
    scala/
      main.scala

hello.txt はテキストファイルで、中身は何でもいいのだが、さすがに1行では物足りないので、私は

hanako
is
always
happy

の5行(最後の空行は出力を見やすくするため)を書いておこう。

本体の main.scalahello.txt の内容を表示するだけのプログラムで、better-files を使って次のように書ける。

Main.scala
import better.files._

object Main {
    def main(args: Array[String]): Unit = {
        println(File("src/main/resources/hello.txt").contentAsString)
    }
}

ファイルオブジェクトから直接テキストの読み込みができるので楽だということがお分かりいただけるであろう。java.nio.Files.readAllLines でファイルのすべての行を一括で読めることを考慮しても、それなりにアドバンテージはあると思われる。もっとも、これは better-files の機能のほんの一部に過ぎないことを断っておく。

さて、コードを書いたならそれを実行しなければ意味がない。まずは sbt を起動しよう。

$ sbt

最初はここから sbt の更新などを行うために結構時間がかかることがあるが、プロンプトが表示されるまでコーヒーでも飲んで待つ。プロンプトが表示されたら

> compile

でコンパイル、あるいは

> run

でコンパイル、パッケージ化、実行までを一通りやってくれる。

これで期待通りに world と表示されるかというと、一応表示はされるのだが、メッセージに埋もれてしまって見づらいかもしれない。余計なものを消すには

> error
> set showSuccess := false

を続けて打つ。(error コマンドはエラー以外のメッセージを表示しないというもの。なんかややこしい。)ここでもう一度 run すればお望み通りの結果になるだろう。なお、sbt のバージョン 1.0.1 および 1.0.2 ではデバッグメッセージが消えないというバグがあるので、上で書いたように sbt.version=1.0.4 とすることを忘れずに。

JAR の実行

JAR ファイルは target/scala-2.12/ の下にあるので、それをプロジェクトルートから実行する。例えば、

$ scala target/scala-2.12/better-files_jar_2.12-0.0.1.jar

を実行すると、多分いろいろ怒られるだろう。better-files ライブラリへのパスが解決されていないためだ。

ライブラリの JAR ファイルは Maven Repository から落とすことができるので、これを例えば lib/jar といったディレクトリに置いて環境変数 CLASSPATH に設定するなどすれば実行できるようになる。

sbt-assembly を使ってひとつに

先の方法はライブラリを共用して本体の JAR ファイルを小さくできるという利点はあるが、毎度それをやるのは面倒くさい。スクリプトや sbt プラグインを書いて JAR を集めることも可能だが少し手間がかかる。何より JAR の移植可能性を下げてしまう。

依存関係を全部取り込んでひとつの実行可能な JAR にする方法があれば、というと実はある。sbt-assembly というものだ。

やり方は至極簡単で、公式リポジトリの指示に従って project の下に assembly.sbt というプラグイン宣言ファイルを置き、sbt シェル上で

> assembly

というコマンドを打つだけ。お手軽。

いろいろ設定できることはあるが、とりあえずこれで単独で実行可能な JAR ファイルができるだろう。実行は普通に java コマンドで JAR のアプリケーションを実行するのと同じだ。

$ java -jar target/scala-2.12/better-files_JAR-assembly-0.0.1.jar
hanako
is
always
happy

ファイルはどこだ?

今、「プロジェクトルートから」と言ったことは今回の主旨である。では、他のディレクトリから実行するとどうだろうか?例えば

$ cd target/scala-2.12
$ scala better-files_JAR-assembly-0.0.1.jar

として実行すると?

java.nio.file.NoSuchFileException に続いてエラーメッセージが箕面の滝のように流れ落ちることだろう。赤いエラー標識を紅葉に喩えるのは風流ではない。

理由は読んで字のごとく、「ファイルがない」のだ。エラーメッセージを読むと、$PROJECT_ROOT/target/scala-2.12/src/main/resources/hello.txt ファイルを探していることがわかる。当然そんなファイルはない。では、resources からコピーすればいいとかいうと、何のために resources に置いたのかという話になる。

JAR ファイルの中身を見てみると

$ jar tf better-files_JAR-assembly-0.0.1.jar hello        
hello.txt

のようにちゃんと hello.txt ファイルが「ルート階層に」見つかる。この場所を指定するのに src/main/resources/ をつけてはいけないことは明らかだろう。

ではどうするか?まず安直に src/main/resourcesMain.scala の中から消してみる。

Main.scala
import better.files._

object Main {
    def main(args: Array[String]): Unit = {
        println(File("hello.txt").contentAsString)
    }
}

こうすると、sbt 上で run しようが JAR を直接実行しようが hello.txt が見つからないと言ってくるはずだ。余計酷くなっとるやないけ。

リソースの取得

オラクルの公式ドキュメントでファイルの検索パスについて確認すると、およそ次のようなパスにあるファイル(リソース)が検索されることが判る。

  • システムリソース
    • Java 本体のクラス
    • CLASSPATH などのパラメータで指定されたパスにあるリソース
    • その他、特定のパスにあるリソース
    • その他、実装依存のリソース
  • 個別の ClassLoader によって取得されるリソース

ここでいうパスは JAR ファイルおよび ZIP ファイル内部のパスを含んだり含まなかったする。どっちやねん!

今回は特別なことは何もしていないので、Class オブジェクトから直接リソースを取得することにする。この場合はシステムリソースを検索することになる。先のリストでは「特定のパスにあるリソース」に該当すると考えられる。

Main.scala を次のように書き換えてみる。

Main.scala
import better.files._

object Main {
    def main(args: Array[String]): Unit = {
        val hello = getClass.getResource("hello.txt")
        println(hello)
    }
}

ここで、値 hello は URL 型である。println の出力は

jar:file:/.../better-files_jar_2.12-0.0.1.jar!/hello.txt

などとなるだろう。リソースが存在しない場合は null が返されるので、少なくともリソースを見つけることはできたようだ。

それでは、改めて中身を表示したいと思い、次の文を加える。

println(File(hello).contentAsString)

今度は java.nio.file.FileSystemNotFoundException に続けて養老の滝のようにエラーが流れ落ちてくる。これでは残念ながら名水百選には選ばれないだろう。

今度は何かというと、JAR ファイルに ZIP ファイルシステム(ディレクトリ構造のフォーマット)がないというエラーである。面倒なことに、Java から JAR ファイルの内部にアクセスするためには、ファイルシステムを自分で構築しなければならないのだ。なんでやねん!

InputStream の取得

滝を落ち川を流れ流れてたどり着いたのは、ClassLoader.getResourceAsStream を使ってファイルの InputStream を取得するという方法だった。

ここまで来ても better-files を使うと次のようにかなり簡潔に書くことができる。

Main.scala
import better.files._

object Main {
    def main(args: Array[String]): Unit = {
        getClass.getResourceAsStream("hello.txt").pipeTo(System.out)
    }
}

hello のクラスは InputStream であり、pipeTo なるメソッドは存在しないのだが、implicits によってあたかもメソッドを追加したかのように見せている。

最初に言った通りこれは諸刃の剣である。implicits の名の通り暗黙のうちに変換が行われるため、ドキュメントから検索しづらいという問題もある。自分で使うときは explicit に書くべきところとの境界を明確にすべきだろう。

細かいことだが、println ではないので最後の空行が落ちていることに注意。

行ごとに何らかの操作をするには、lines メソッドを使うという手がある。それ自体の戻り値は各行の文字列のイテレータである。例えば、main の中身を

println(getClass.getResourceAsStream("hello.txt")
            .lines.map(_.toUpperCase).mkString(" "))

に置き換えれば、

HANAKO IS ALWAYS HAPPY

が出力される。

この辺りが私が思う限りの妥協点だろう。better-files の README に書いてある方法の中で最も簡単に見えた asString はコンパイラが怒ってきたので使わなかった。ドキュメントにはできるように書いてあるが、自分でやってみると上手く行かないというのはよくあることだ。エラーメッセージとソースコード以外には疑ってかかるべきであろう。

今度こそ JAR にして実行

ここまでできたら、

> assembly

で JAR を作って実行するだけだ。好きな場所に JAR を置いて好きな場所から実行できる。

$ java -jar better-files_JAR-assembly-0.0.1.jar 
hanako
is
always
happy

Happy Assembly!

まとめ

  • JAR のリソースにアクセスするには Class.getResourceAsStream を使うのが多分一番簡単
  • better-files を使うとテキストを簡単に読める
  • better-files を使うと Java でファイル操作する気にならなくなる
  • 意外と better-files の話してなかった
  • はなこはいつでもハッピーね
Sign up for free and join this conversation.
Sign Up
If you already have a Qiita account log in.