見出し画像

Kotlinでコードを⾃動⽣成する

第10章では、アノテーションプロセッサーを使用して、Kotlin のクラスからJavaとKotlinのコードを⾃動⽣成する⽅法について紹介します。

全章を一括して購入されたい方はこちらの記事をご購入ください。https://note.mu/nikkei_staff/n/n44623c9b9ab4

⽇経電⼦版のアプリチームでAndroidアプリを開発しているやまんだ/@ymndです。

本稿では、Kotlin のクラスから、Java とKotlin のコードを⾃動⽣成する⽅法について紹介します。ここでいうコードの⾃動⽣成とは、対象となるクラスに⽬印を付けるだけで、コンパイルのタイミングで⾃動的にコードが⽣成される機能のことです。

まずアノテーションプロセッサーを動作させるための環境構築を⾏い、実装作業の全体像を掴みます。次にKotlin でアノテーションプロセッサーを使う際に対⾯する型の問題について説明します。そして、Java とKotin それぞれのコードを⾃動⽣成をするための実装⽅法について詳細に解説します。今回紹介するアノテーションプロセッサーの実装例は次のリポジトリに掲載しております。本稿の開発環境はAndroid Studio 3.3 Canary9、Kotlin のバージョンは1.2.61 を使⽤しています。またライブラリは2018/09/24 時点のバージョンを利⽤しています。

https://github.com/ymnder/zelkova

コード⽣成の題材として、Kotlin のData クラスからBuilder クラスを⽣成していきます。よく知られたデザインパターンであること、またコード⽣成結果が複雑ではなく、元の要素から引数名や型を読み取る必要があることからこの題材を選びました。

10.1 作業前の道具⽴て

実装作業に進む前に、元のクラスと⾃動⽣成されるクラスを考えます(リスト10.1)。

⽣成したいクラスの理想形が⾒えたのでどうやって実現するかを整理します。Java にはアノテーションプロセッサー(Pluggable Annotation Processing API)という機能があります。アノテーションプロセッサーを利⽤したライブラリとしては、Dagger 2、ButterKnife、PermissionsDispatcherなどがあります。いずれのライブラリもアノテーションをクラスやメソッドなどに付与し、ボイラープレートなコードをコンパイル時に⽣成し開発をサポートしてくれます。

コード⽣成の流れは、コンパイル時に読み込んだ情報を元に、任意のディレクトリに⽂字列情報をクラスとして書き出します*1。ところが、アノテーションプロセッサーは情報を読み取れる⼀⽅、コードを⽣成する仕組みは提供してくれません。そのため⾃分でクラスの形に⽂字列を組み⽴てながらJava 標準API のWriterクラスを⽤いてファイルとして出⼒します。クラス本体はもとよりpackage やimport を忘れずに書き出す必要があり、過酷な試練が待ち受けていそうです。

そこでJavaPoet*2、KotlinPoet*3というSquare 製のライブラリを使います。これは.javaや.ktのファイル⽣成を助けてくれるライブラリです。クラスなどのコード⽣成に必要な情報をBuilder パターンで組むことができ、import などの追加や、インデントの最適化なども⾏ってくれます。

10.2 はじめてのアノテーションプロセッサー

実装に⼊る前にアノテーションプロセッサーの作業環境を整えていきましょう。アノテーションプロセッサーで開発をするには3つのモジュール*4を⽤意する必要があります。

app
動作確認を⾏うサンプルモジュール

annotation
アノテーションを定義するモジュール

processor
アノテーションプロセッサーの本体となるモジュール

これらのモジュールの依存関係は次のようになります(図10.1)。kaptはアノテーションプロセッサーをKotlin で⾏うためのプラグインです*5。

本稿ではAndroid アプリでのライブラリの利⽤を想定し、Android Studio を使⽤して開発を⾏いますが、IntelliJ でも同様に開発を⾏うことができます。まずはAndroid Studio を起動しCreate New Project で新規アプリを作成します。このアプリは動作確認⽤として使⽤するため、設定内容は任意です。今回はKotlin で実装作業を⾏っていくため、Language はKotlin を選択します。Finish ボタンを押すことでappモジュールが作成されます。

次に、File > New > New Module を選択し、Module 選択画⾯を起動します(図10.2)。

New Module を選択するとModule が⼀覧で表⽰されます(図10.3)。

Java Library を選択後、Library の名前を⼊⼒します(図10.4)。今回はKotlin で作業を⾏いますが

Library Name にannotationを⼊⼒し、Module を作成します。processorも同様の⼿順で作成します。

これでapp、annotation、processorの3つのモジュールが作成できました。

10.2.1 annotation モジュールの実装

続いて動作確認をするための最⼩限の実装を⾏っていきます。annotationのディレクトリ下にあるbuild.gradle を開き、ライブラリを追加していきます(リスト10.2)。

apply plugin: ’java-library’の代わりにKotlin のapplyとimplementationを追加しています。

次にannotationのmodule に、アノテーションとなるクラスを作成します。アノテーションクラス化したのちに、@Targetと@Retentionを付けてください(リスト10.3)。クラス名は任意ですので作りたい機能に合わせて素敵な名前をつけましょう。

Kotlin ではclassの前にannotationをつけることにより、アノテーションクラスであることを宣⾔することができます。ここで、アノテーション宣⾔時に@Targetと@Retentionという2つのアノテーションが付与してあります。

@Targetは、アノテーションを付与できる対象要素の範囲を⽰せます。アノテーションの対象をCLASSやPROPERTY、FUNCTIONなどのように明⽰的に宣⾔することにより、誤った対象に付与された際にLint による警告を出せます*6。たとえば、この例ではCLASSを指定しているので、仮にFIELD にアノテーションを付けると警告が出ます。

@Retentionは、アノテーションをコンパイル結果である.classに含めるかどうかと、実⾏時にリフレクションで参照できるかどうかを指定できます*7。この挙動は、SOURCE、BINARY、RUNTIMEの3種類から指定できます*8。

SOURCE
.class にアノテーションを含めない

BINARY
.class にアノテーションを含めるが、リフレクションでは⾒えなくする

RUNTIME
.class にアノテーションを含め、リフレクションでも⾒える(デフォルト)

Java のRetentionでは、BINARYではなく、CLASSが使われています。Kotlin ではクラス外で関数が定義されていることから、CLASS という表現が不適切であるために、独⾃のenum を定義したようです*9。本章の例ではコード⽣成を⾏うprocess中でアノテーションが参照できれば⼗分で、実⾏時にリフレクションで⾒る必要もないため、SOURCEを指定します。

10.2.2 processor モジュールの実装

アノテーションファイルができましたので次にプロセッサーを作成します。processorのディレクトリ下にあるbuild.gradle を開き、ライブラリを追加していきます。

processorは実際にアノテーションを処理するモジュールです。対象となるアノテーションを知る必要があるので、annotationモジュールを読み込みます。

auto-service*10はGoogle のライブラリで、プロセッサーを実⾏するためのエントリーポイントを⾃動⽣成してくれます。アノテーションプロセッサーは特定のクラスを継承すれば動くものではなく、コンパイル時に実⾏してもらえるように処理の登録を⾏う必要があります。そのため、META-INF/services/javax.annotation.processing.Processorに実⾏したいプロセッサーを指定します。しかしauto-service ライブラリの@AutoServiceを付与することで⾃動的に実⾏対象のプロセッサーが追加されるようになります*11。

ライブラリを追加した後は、Processorクラスを実装していきましょう(リスト10.5)。

JavaBuilderProcessorはアノテーションをつけたクラスを元に、空のBuilder クラスを⽣成するシンプルなProcessorです。getSupportedSourceVersionはサポートを⾏うJDK のバージョンを指定します。getSupportedAnnotationTypesはサポートを⾏うアノテーションを指定します、ここで追加したアノテーションが処理の対象となり、processが動作します*12。

RoundEnvironment#getElementsAnnotatedWithはアノテーションが付与された要素を取得するためのメソッドです。今回はクラスを対象にしたアノテーションですのでforEachに渡ってくる項⽬はクラス要素で、generateClassFileに渡してファイル⽣成を⾏います。このクラス要素からアノテーションが付いたクラスの名前やパッケージ名が得られます。JavaFileはその名のとおりJava のファイルを作成するクラスで、package 名と、クラスをつくるTypeSpecを引数にとります。ファイルを組み上げたら出⼒先のディレクトリを指定してwriteToで書き出します。

10.2.3 app モジュールの実装

最後にappモジュールにアノテーションとプロセッサーを紐づけるため、build.gradleに依存関係を追加します(リスト10.6)。

次にアノテーションを付与した任意のクラスを作成します(リスト10.7)。

これで準備完了です。早速ビルドをしてみましょう。

このgradlew コマンドを実⾏することapp/build/generated/source/kapt/debug/の下にTeaBuilderが⽣成されます。

10.2.4 デバッグ⽅法

本格的な実装に⼊る前にアノテーションプロセッサーのデバッグ実⾏について説明します。まず、processingEnv.messagerを利⽤することでプリントデバッグする⽅法があります。しかしプリントデバッグではそのときの変数の状態を確認するのが困難であり、デバッグの試⾏回数が増えます。

そこで、Android Studio のリモートデバッグを利⽤しBreakPoint でデバッグ作業を⾏っていきましょう。アノテーションプロセッサーはアプリ開発と異なりコンパイル時の処理を扱うために、普通にデバッグボタンを押すだけではブレークポイントで⽌まりません。そのためIDE 上でのデバッグ実⾏を⾏うために⼀⼯夫します。

Android Studio のRun > Edit Configurations を選択し設定ダイアログを表⽰します。次に左上の+ を押し、Add New Configuration からRemote を追加します。最後にName を⼊⼒して完了します(図10.5)。

これでAndroid Studio 側の設定が終わりました。次にgradle 側でデバッグ状態でコンパイルを⾏うコマンドを⼊⼒します。

これを実⾏することでコンパイルを開始時点で待機状態にさせられます*13。ここで、>Starting Daemonの状態で停⽌しない場合はコマンドが間違っている可能性があります。

次にAndroid Studio で先ほど作成したRemote 設定を選択し、Debug ボタンを押します(図10.6)。

デバッガーを起動するとgradle の処理が再開され、コンパイルが⾏われます。少しすると、ブレークポイントで⽌まるので、デバッグを始められます(図10.7)。

ブレークポイントで⽌められることにより、変数名の状態を把握したり、⼀⾏ずつ実⾏したり、式を評価したりできます。これでアノテーションプロセッサーを⾼速にデバッグできるようになりました。

10.3 Kotlin の型はどこへ消えた?

Builder クラスの実装の説明に⼊る前に、Kotlin で書かれたコードをアノテーションの対象とした際に起きる問題を説明します(図10.8)。

Kotlin のType をアノテーション対象のクラスで使⽤していた場合、アノテーションプロセッサーの中ではすべてJava のクラスとして変換されます。つまり、プロパティの型情報をそのままKotlin ファイルに書き出すと、Kotlin のファイルにJava の型が混⼊してエラーになります。これはKotlin がJava のコードとしてコンパイルされた後*14でアノテーションプロセッサーが実⾏されることによります*15。取得した型を、Java ファイルに書き出す場合は問題ありませんが、Kotlin ファイルに書き出す場合はもう1度変換しなければなりません。

これを解決するには次のような⽅法が考えられます。

• 1対1で型をマッピングして変換する
• Kotlin Metadata を使う

使いたい型をひととおり定義してマッピングするのはひとつの解決策です。ごく⼀部の型を対象とした簡単な変換でしたらこれで⼗分ですが、すべての対応関係を網羅するのは現実的ではありません。

もうひとつの解決策はKotlin Metadata*16を使⽤する⽅法です。これは@Metadata*17に含まれているデータの操作をハンドリングしやすくするライブラリです。Kotlin がJava にコンパイルされるときにKotlin の情報はクラスから失われますが@Metadataの中に⼀部が残されます。この情報を利⽤することでKotlin 由来の情報を復元することができます。

本稿では実験的に、このKotlin Metadata とKotlinPoet を組み合わせてコード⽣成を⾏っていきます。Kotlin Metadata で取得した型の⽂字列をそのままKotlinPoet で使える形に変換できない場合があります。すなわち、Kotlin Metadata を使⽤してKotlinの型を復元すると、取得できる型はElementでなくStringです。それゆえにStringをKotlinPoet で扱える形にしなければなりません。このときClassName#bestGuessが使⽤できます。しかし、このメソッドではMap<String, List<Any>やList<MyHoge>のような形には変換できません。クラス名のとおり、ClassName#bestGuessがParameterizedTypeなどのケースを想定したつくりでないからです。現時点では、複雑な型を使⽤したクラスを作成したい場合は、KotlinPoet を使⽤せず、String をファイルに書き出す伝統的な⼿法を使⽤した⽅が良さそうです*18。

次の節では、JavaPoet を使⽤してJava のクラスを作成する⽅法を解説していきます。アノテーションプロセッサーの過程でJava に変換されてしまった型をそのまま扱えるのでシンプルにコード⽣成を⾏えます。そして、その次の節では、KotlinPoet を使⽤してKotlin のクラスを出⼒する⽅法を紹介します。Java に変換された型をKotlin の型に再変換する処理が含まれているため、コード⽣成過程の差異をご覧ください。

10.4 ⾃動⽣成の実装:JavaPoet

まずは、JavaPoet を使⽤してKotlin クラスからJava クラスを⽣成する実装を⾏いましょう。先ほどのgenerateClassFileのメソッドの中⾝を書き換えていきます。まず、クラスをつくる上で必要となる基礎的な情報を定義します(リスト10.8)。

JavaPoet のClassName#getにより、クラスの情報をもつインスタンスを作成できます。まだ未定義のクラスであっても、このインスタンスにパッケージ名とクラス名を渡すことで扱えます。あとでセッターを作成するときの返り値の型として使⽤するため、builderNameではこれから⽣成するクラスであるTeaBuilderを定義しています。

次にフィールドとセッターを作成する実装を追加します(リスト10.9)。

enclosedElementsはアノテーションをつけたクラスに紐づく各種要素を取得できます。Data クラスではプロパティの他にもゲッターやセッターなど、さまざまなメソッドも追加されています。そこで①ではElementKind.FIELDでプロパティだけを取得できるようにフィルターしています。

次に②の拡張関数でKotlin の型がNull 許容であるかを判定しています(リスト10.10)。

Java の世界ではNull 許容かどうかを型から判断できません。その代わりに、Java に変換される際にElementにNullable のアノテーションが付与されます。これがある場合はNullable で、ない場合はNotNull とみなします。

③はFieldSpecで、型と変数名と修飾⼦を付与してフィード変数を定義できます。今回はフィード変数は外から触らせないためにModifier.PRIVATEを指定します。また②で判定したannotationの情報を追加しています。

④と⑤では、ParameterSpecでメソッドの引数となるパラメーターを作成し、メソッドを定義するMethodSpecに渡しています。先ほど定義したbuilderNameを返り値の型をとするために、returnsにセットします。addStatementは⽂を作成するメソッドで、これに⽂字列を渡すと⾃動的にJava の⽂を作成します。\$Nという記法を使⽤でき、引数にとった変数を埋め込めます。

最後に、build メソッドを作成し、クラスを書き出します(リスト10.11)。

addStatementで使⽤されているnew \$T()はクラスを指定するための記法で、buildメソッドを呼んだ際にData クラスのインスタンスを返すために使⽤しています。このaddStatementは、第2引数がObject の可変⻑引数をとり、$記法で指定した分の引数を渡す必要があります。Data クラスに必要な引数は任意の個数存在するため、Collections.nCopies(fieldNames.size, "\$N").joinToString()をして\$Nを繰り返して連結しています。さらに、*fieldNames.toTypedArray()で指定した個数分の引数を可変⻑引数として展開しています。spread operatorを使⽤することで、任意の個数の引数の配列を展開して可変⻑引数に渡せます。

それではビルドしてみましょう(リスト10.12)。

これでKotlin のクラスからJava のBuilder クラスを作成できました。

10.5 ⾃動⽣成の実装:KotlinPoet

KotlinPoet もJavaPoet と要領は同じですが、少しメソッドの使い⽅が異なります。まず、processorモジュールのbuild.gradle にライブラリを追加します(リスト10.13)。

KotlinPoet を使⽤してKotlin クラスからKotlin クラスを⽣成する実装を⾏いましょう。Kotlin はレシーバ関数など便利な機能がたくさんありますが、KotlinPoet でどのように定義すればよいかはドキュメントから読み取りにくい場合もあります。そのような場合はKotlinPoet のテスト*19を参照してください。コード出⼒が正しく⾏われていることがテストされているため、メソッドと出⼒結果の対応関係が⼀⽬瞭然です。

JavaPoet のときと同様にgenerateClassFileの中⾝を書き換えていきます。はじめにクラスをつくる上で必要となる基礎的な情報を定義します(リスト10.14)。

kotlinMetadataはKotlin Metadata の拡張関数で、Elementから@Metadataの情報を引き出します。今回はプロパティだけを使⽤したいので、propertiesにプロパティのリストを渡しています。なお、propertiesのリストは、プロパティの順序が保証されていません。このList<ProtoBuf.Property>を扱いやすくするため独⾃に定義したPropertyTypeにマッピングしています(リスト10.15)。

returnTypeでは、返り値の型の⽂字列を粗い形で取得しています。extractFullNameはKotlin Metadata に定義されている拡張関数で、Metadata から型の⽂字列を組み⽴てます。しかし、取得できる⽂字列は要素がバッククオートで区切られているためにそのままでは扱えません。そこで、replaceで、バッククオートを取り除いています。

またreturnTypeで得た⽂字列には、Kotlin の型であるIntやListなどにパッケージのフルパスが付与されています。このままではKotlinPoet で扱える型に直すときに不要なimport が⾏われてしまうので、強引に省いています*20。

さて、取得した情報をもとにプロパティを追加していきます(リスト10.16)。

PropertySpec.varBuilderを⽤いて、出⼒したいプロパティの情報を組み⽴てます。

①ではNull 許容型であるかを判定し、適切な初期値をセットしています。Null 許容でない場合は、lateinitを使⽤して初期化を遅延させられますが、primitive には指定できないなど、実装の説明が複雑になるために今回は⼀律Delegates.notNull()を指定しています。②は%Nの書式でJavaPoet のときと同様に埋め込みたい変数名をセットしています。JavaPoet の場合と異なり$が%になったことでバックスラッシュでエスケープを⾏う煩わしさがなくなりました。

次にbuild メソッドを⽣成していきます(リスト10.17)。

先述のとおりpropertiesのリストは順序が保証されていないため、addStatementにそのまま引数名をセットすると引数の順序とあわない可能性があります。そこで名前付き引数として定義することで、順序が保証されていなくても正しく引数の受け渡しができるようにしています。

ここまでで終わりにもできますが、DSL を定義することでBuilder クラスをより便利に扱えるようにします(リスト10.18)。

LambdaTypeName#getでは、レシーバ関数の対象とする型と、返り値の型を指定しています。そしてParameterSpec#builderで関数のパラメーターとしてセットしています。これらの情報をもとに、TypeSpecではBuilder クラスを継承するようにしてDSL クラスを定義し、FunSpecで、inline 関数を作成しています。

最後に今まで組み⽴ててきた要素を出⼒します(リスト10.19)。

outputDirectoryの部分では、出⼒先のディレクトリを変更しています。これはIntellij やAndroid Studio がkaptKotlinでは、出⼒後のクラスを認識してくれないことによります*21。

最後に出⼒結果を⾒てみましょう(リスト10.20)。

これでBuilder をKotlin フレンドリーな形で利⽤できます。

10.6 おわりに

本稿ではBuilder パターンを例にKotlin でのアノテーションプロセッサーでの利⽤⽅法を説明しました。アノテーションプロセッサーによるコードの⾃動⽣成はボイラープレートの苦役から解放し、作業の効率化を果たします。しかし、Kotlin でのアノテーションプロセッサーの利⽤には多くの落とし⽳があります。特に型の変換は未だ未解決な問題があり、完全なKotlin ファイルの出⼒が⾏えていません。本章で書ききれなかった部分は、アノテーションプロセッサーのテスト、Metadata の詳細な説明、AndroidX 対応の⽅法などがありますので、また次の機会にご紹介できればと思います。

しかしながら、アノテーションプロセッサーでKotlin コードの⽣成をサポートするべきなのかというと、いささかの疑問が残ります。この章を書くにあたり、アノテーション対象の型情報を再利⽤するような作業には不向きであることを感じました。Java の型をKotlin の型として再変換するアプローチは、複雑な変換プロセスを踏んでおりバグの温床になりかねません。アノテーションプロセッサーというJava の仕組みを利⽤していることからも、Java に⼀度変換されたものをJava でファイルに書き出す⽅が素直です。Kodein*22やKotter Knife*23というアノテーションによらないライブラリが出てきていることもその表れであると思います。

Kotlin で書いたものをアノテーションプロセッサーを通じてKotlin で出⼒したいという気持ちは分かります。しかし、⾃分が解決したい課題は、Java では実現できないのか?もしかしたらKotlin の⾔語機能で達成できるのではないか? 何がそれは本当にKotlinとしてコードを⽣成すべき課題なのか? ということを整理してから実装にあたるべきではないかと考えます。難解な⼿法を駆使して実装作業にあたるより、もっと⼿軽でシンプルな解決策があるかもしれません。

最後になりますが、⼊稿スケジュールの差し迫った中で貴重なコードレビューやフィードバックをくださったしらじさんと@hotchemi さんにこの場を借りて深く御礼を申し上げます。この記事がKotlin でコードを⾃動⽣成する⼀助となれば幸いです。

著者:やまんだ/@ymnd
スーパードンキーコング2を買いました。Android いっぱい好き。

140年の歴史ある会社が、AIやデータを駆使した開発を現場で実践しています。是非疑問や感想を #nikkei_dev_book をつけてツイートしてください!

全章を一括して購入されたい方はこちらの記事をご購入ください。
https://note.mu/nikkei_staff/n/n44623c9b9ab4

この記事を購入すると、この下に第10章だけのダウンロードリンクが表示されます。

ここから先は

119字

¥ 100