はじめに
Kotlinでコマンドラインツール作ってみました。
jarファイル置場
https://github.com/youmitsu/KotlinReplaceSupporter/releases
リポジトリ
https://github.com/youmitsu/KotlinReplaceSupporter
こちらにスライドもあるので、よければご覧ください。
https://speakerdeck.com/youmitsu/java-nil-kotlinfalseyi-xing-lu-wosuan-chu-surukomantorainturuwokotlinteshi-zhuang-sitemitahua
何を作ったか
Androidプロジェクトのルートディレクトリで、krs
コマンド(KotlinReplaceSupporterの略です)を叩くと、そのAndroidプロジェクトのKotlinへの移行率を算出してくれるというツールです。
Macでは動作確認済み。
Windowsでは使えるかわからないですが、おそらく使えます。(ちゃんと確認します)
また、Kotlin->Javaにするものであれば基本的に使えるので、Android以外のプロジェクトにおいても使用可能です。
作り方
- 環境準備
- 実装
- バイナリ(jar)にする
- ツールとして配布する
1. 環境準備
開発環境
- MacBookPro Sierra
- IntelliJ IDEA Community 2017.3
- Kotlin 1.2.50
プロジェクト作成
1 intelliJ IDEAを立ち上げて、File > New > Projectを押下。以下の画面が表示されるので、Gradleを選択し、Kotlinにチェックを入れてプロジェクトを作成する。
2 以下のようなプロジェクト階層が出来上がります。準備完了です。
2. 実装
実装1: 言語情報を保持するLanguageクラスの作成
言語情報をもつクラスを作成します。
プロパティには、言語名、拡張子の情報を持たせています。
ここでtoStringをoverrideしているのは、System.out.println()で出力した時に便利だからです。
/**
* 言語の情報を保持するクラス
*
*/
data class Language(
val name: String, //言語名
val extension: String //拡張子
){
override fun toString() = name
}
実装2: 言語と移行元or先、ファイルのリストを保持するSourceListクラスの作成
ここで、実際にファイル群を格納するためのクラスを作成します。その時にこのファイル群は移行元なのか、移行先なのかをBooleanで持たせています。
/**
* 言語のlistを保持するクラス
*/
class SourceList(
val language: Language,
val isReplaceTarget: Boolean,
val list: MutableList<File> = mutableListOf()) {
fun add(file: File){
list.add(file)
}
}
今回は、Java->Kotlinという風に決まっているので、mainメソッドで、以下のように定義してあげます。
fun main(args: Array<String>) {
val javaList = SourceList(Language("Java", "java"), false)
val kotlinList = SourceList(Language("Kotlin", "kt"), true)
//以下省略
}
実装3: 計算対象に入れたくないファイル群をignoreする
Androidプロジェクトにおいて、app/build配下のものはソースではなく、自動生成されたファイルなので、計算対象に含めたくないということがあると思います。(R.javaとかDataBinding系のファイルとか)
そこで、このツールでは.krsignore
というファイルをホームディレクトリに作成し、プロジェクトからの相対パスを記述しておくことで計算対象から除外することができます。
.krsignoreがない場合は、実行したディレクトリ配下全てのjava,kotlinファイルが計算対象となります。
以下のようにIgnoreFileReaderというクラスを定義します。
class IgnoreFileReader(ignoreFilePath: String, private val cd: String) {
private var fileReader: FileReader? = null
var files: List<File> = mutableListOf()
init {
try {
fileReader = FileReader(ignoreFilePath)
} catch(e: FileNotFoundException) {
//TODO: エラーハンドリング
}
}
fun read() {
fileReader?.let {
val filesStr = it.readLines()
files = filesStr.map {
File("$cd/$it")
}
}
}
}
初期化時には、.krsignoreのパス、カレントディレクトリを渡します。
その後、渡された.krsignoreのパスにより、FileReaderを初期化します。ここで、見つからなかった場合、例外を吐いてしまうので、try,catchで囲んでいます。(エラーハンドリングはサボっています。。)
最終的にはread()を呼ぶことで、.krsignoreファイルの中身をreadLineで読み、それらの絶対パスをfilesに保存しています。
mainメソッドでは、以下のように使用します。
fun main(args: Array<String>) {
//省略
val homeDir = System.getProperty("user.home") ?: ""
val cd = File(".").absoluteFile.parent
val ignoreFileReader = IgnoreFileReader("$homeDir/.krsignore", cd)
ignoreFileReader.read()
//以下省略
}
.krsignoreの記法
プロジェクト配下の相対パスを記述してあげます。以下の例は、app/build配下のファイルを全て計算対象から外す場合の書き方です。
app/build
実装4: プロジェクト内のディレクトリを探索する。
ここで、実際にプロジェクト内のjava,kotlinファイルを探索していきます。探索には、以下のSearchHandlerを作成します。
プライマリコンストラクタには、以下を渡します。
- プロジェクトルートのパス
- 対象となるSourceListの配列(Pairとかの方が良いかも)
-
.krsignore
によって得られたファイルのリスト
executeメソッドを呼び出すことによって、プロジェクトルートのパスからwalkTopDown()というFileの拡張関数によって探索を開始する。探索する中で、ignoreファイルの中にあるパスであれば除外し、kotlin or javaのファイルであればそれぞれのSourceListの配列にaddしていきます。配列はResultクラスに持たせています。全体のファイル数がないと、率が出せないので、全ファイルの合計値も持たせています。(あまりよくない)
class SearchHandler(
private val path: String,
private val langList: Array<SourceList>,
private val ignoreDirList: List<File>) {
lateinit var result: Result
fun execute() {
var sizeAll = 0
val rootDir = File(path)
rootDir.walkTopDown()
.filter {
isNotIgnoreFile(it)
}.forEach { file ->
langList.map { langList ->
if(isTargetFile(file, langList.language.extension)){
langList.add(file)
println(file)
sizeAll++
}
}
}
result = Result(langList, sizeAll)
}
private fun isTargetFile(file: File, extension: String): Boolean = file.extension == extension
/**
* 対象外ファイルを無視する
*/
private fun isNotIgnoreFile(file: File): Boolean = ignoreDirList.none {
file.startsWith(it)
}
}
class Result(val arr: Array<SourceList>,
val sizeAll: Int)
main.ktは以下になります。
SearchHandler#execute()によって探索が行われ、javaと、kotlinのファイルのリストがSearchHandler保持されている状況です。
fun main(args: Array<String>) {
val javaList = SourceList(Language("Java", "java"), false)
val kotlinList = SourceList(Language("Kotlin", "kt"), true)
val homeDir = System.getProperty("user.home") ?: ""
val cd = File(".").absoluteFile.parent
val ignoreFileReader = IgnoreFileReader("$homeDir/.krsignore", cd)
ignoreFileReader.read()
val handler = SearchHandler(cd, arrayOf(javaList, kotlinList), ignoreFileReader.files)
handler.execute()
}
File.walkTopDown()の紹介
Fileクラスに拡張関数として定義されています。
これをFileクラスのインスタンスに対して呼び出すと、FileTreeWalkというクラスが返却されます。
これがSequenceインターフェースを実装しているクラスのため、Sequence#forEach()メソッドが使え、ディレクトリ配下の全ファイルを探索して、出力することが簡単にできるようになっています。
実装5: 最後に結果を出力する
結果の出力には、ResultLoggerクラスを作成します。
SearchHandler#execute()によって得られたResultクラスのインスタンスをコンストラクタに渡します。
log()メソッドを呼び出すことで、それぞれの言語のファイル数と最終的なリプレース率を出力します。
class ResultLogger(val result: Result) {
fun log() {
logCount()
logReplaceRate()
}
private fun logCount() = result.arr.forEach { it ->
println("---------${it.language}---------")
println("ファイル数:${it.list.size}")
}
private fun logReplaceRate() = println("リプレース率:${calcReplaceRate()} %")
private fun calcReplaceRate(): Double {
val value: Double = result.arr.filter{sl -> sl.isReplaceTarget}.map{ it.list.size / result.sizeAll.toDouble() * 100.0 }.first()
return BigDecimal(value).setScale(1, RoundingMode.HALF_UP).toDouble()
}
}
mainでは、以下のように呼ぶだけです。
fun main(args: Array<String>) {
val homeDir = System.getProperty("user.home") ?: ""
val cd = File(".").absoluteFile.parent
val javaList = SourceList(Language("Java", "java"), false)
val kotlinList = SourceList(Language("Kotlin", "kt"), true)
val ignoreFileReader = IgnoreFileReader("$homeDir/.krsignore", cd)
ignoreFileReader.read()
val handler = SearchHandler(cd, arrayOf(javaList, kotlinList), ignoreFileReader.files)
handler.execute()
val logger = ResultLogger(handler.result)
logger.log()
}
これで実装は完了です。
3. バイナリ(jar)にして、エクスポートする
実装が完了したらバイナリ(jar)にして、ツールとしてエクスポートしなければなりません。
エクスポートはGradleで行います。
今回はbuild.gradle.ktsで書いています。
以下のようなタスクを書いてあげます。build.gradle.ktsファイルの全体像は、githubを参照ください。
build.gradle->build.gradle.ktsへの移行は以下のサイトが参考になったので、気になる方は参考にしてみてください。
https://guides.gradle.org/migrating-build-logic-from-groovy-to-kotlin/
task("packJar", type = Jar::class) {
archiveName = "krs.jar"
manifest {
attributes["Main-Class"] = "jp.co.youmeee.app.MainKt"
}
from(configurations.runtime.map({ if (it.isDirectory) it else zipTree(it) }))
with(tasks["jar"] as CopySpec)
}
gradleラッパーで以下のコマンドを叩いてあげるもしくは、intelliJIDEAでGUIからタスクを実行すると、build/libsフォルダにjarファイルとして出力されます。
$ ./gradlew packJar
4.ツールを配布する
jarファイルのままでも使うことはできるのですが、このままだと以下の感じで打たないと行けないので、ツールとして不便です。
$ java -jar ~/krs.jar
そこで、今回はhomebrewを使って、krsというコマンドで叩けるようにしてみました。
Macを使っているデベロッパーであれば、みんな入っていると思われるパッケージ管理ツールですね。
homebrewへの公開の仕方は、以下のサイトを参考にさせていただきました。
少し説明すると、
1.$ brew create [Github上のバイナリのURL]
を叩く。
2. 以下のようなFormulaと呼ばれるDSL(Rubyで書かれている)のファイルが生成されるので、編集します。
3. brew install kotlinreplacesupporter
でインストール。
4. krs
コマンドが叩けるようになる
class Kotlinreplacesupporter < Formula
desc "This is a tool of replacing"
homepage "https://github.com/youmitsu/KotlinReplaceSupporter"
url "https://github.com/youmitsu/KotlinReplaceSupporter/releases/download/v1.4/krs.jar"
sha256
depends_on :java => "1.6+"
def install
if build.head?
system "./gradlew", "packJar"
libexec.install "krs.jar"
else
libexec.install "krs.jar"
end
bin.write_jar_script libexec/"krs.jar", "krs"
end
test do
system "#{bin}/krs"
end
end
ただ、実際にhomebrewへ公開するには、リポジトリのスター数とかコントリビュータの数とか色々条件があるので、個人的に作成したものを公開するのはなかなか厳しいのかなと思います。(詳しい方教えてください。。)
ですが、ローカルで使う分には同様に使えるので、一旦僕はローカルだけで公開して、使用しています。
結果的には、以下のようにkrsと打つだけで、ツールが実行できます。一気にツール感が増してきましたね。
$ ~/AndroidStudio/HogeApp> krs
まとめ
- Kotlinでもコマンドラインツールは作れる!
- File系のクラスはAndroid書いてるとあまり触らないし、便利な拡張関数もあって、勉強になりました。
- エラーハンドリングとか甘い部分あるので、その辺りfixしていきたい。
- もう少し汎用的にして、外からオプションで、言語の識別子を与えるようにすれば、Java→Kotlinのようなこともできる(需要はない)
Kotlinをこれからも愛ででいきたいと思います。最後まで読んでいただきありがとうございました!