はじめに
この記事はFlutter Advent Calendar 2019 6日目の記事です。
前日は @mkosuke さんのEffective Dartまとめでした。
Effective DartにはDartらしいコードを書くためのエッセンスが詰まっていますので、これが日本語で読めるのはとても素晴らしいことだと思います。
この記事ではFlutter開発でも使えるDartの自動コード生成のお話をします。
自動コード生成とは
プログラムにソースコードを生成させる仕組みがあります。
Android開発をしている方なら、JavaではJSR-269またはapt、Kotlinではkaptが広く知られていますし、
iOS開発されている方の中でもSourceryやSwiftGenを使ったことがある方も多いかと思います。
また、gRPCやSwagger(OpenAPI)で生成されたコードを使用する機会もあるかと思います。
こうしたものも自動コード生成を活用していると言えるでしょう。
どうして必要なのか
ボイラープレートコードを人間が書かなくて済むようにするためです。
ボイラープレートコードとは、ある処理をするために書く必要がある決まりきったコードを指します。
例えば、あるenumから、その定数を表現する文字列を取り出す必要があるとします。
// 都道府県を表現する
enum Prefecture {
hokkaido,
aomori,
...
}
extension PrefectureLabel on Prefecture {
// 都道府県名を取得する
String get label {
switch (this) {
case Prefecture.hokkaido:
return "北海道";
case Prefecture.aomori:
return "青森";
... // 長くてやってらんない 😡
}
}
}
// 世界の国と地域
enum CountriesAndRegions {
iceland,
ireland,
...
}
extension CountriesAndRegionsLabel on CountriesAndRegions {
String get label {
switch (this) {
case CountriesAndRegions.iceland:
return "アイスランド";
... // まだあるの!?!? 💢
}
}
}
上記の場合、extensionとその中身がボイラープレートコードになります。
enum定数に対して、返却する値以外は書くべきコードが同じであるためです。
enumの定義だけならまだしも、そのextensionのswitch-caseまでいちいち書きたくありませんね!
頑張って全部手書きしますか? したくないですね!
せめてenum定義と一緒に表現を持たせられれば、まだ楽になるのですが。
// Swiftみたいにこう書けたらいいな(Dart 2.6現在、enumに値をもたせることは出来ません)
enum Prefecture {
hokkaido = "北海道",
aomori = "青森",
...
}
Dartのenumは値や実装を持てない制約があるため、こればかりはどうしようもありません。
都道府県は47個で収まりますが(それでも十分きついのですが)、世界の国と地域のenumだったら何度もswitch-caseを書くのは地獄を見そうですね…
コード生成以外の手段
リフレクションでも同様の問題を解決することができます。
// 情報を付与するアノテーション
class Value {
final String value;
const Value(this.value);
}
// アノテーション(メタデータ)で情報を持たせてみる
enum Prefecture {
@Value("北海道")
hokkaido,
@Value("青森")
aomori,
...
}
extension PrefectureLabel on Prefecture {
// 都道府県名を取得する
String get label {
final instanceMirror = reflect(this);
final classMirror = instanceMirror.type;
final thisSymbol = Symbol(toString().split(".")[1]);
return classMirror
.declarations[thisSymbol]
.metadata
.first
.getField(#value)
.reflectee
}
}
dart:mirrors
を使用できる環境であれば、上記で各メタデータで指定された値を取り出すことが出来ます。
しかしFlutterではdart:mirrors
を使用することができません。
どうやら最適化のためのようです。
AndroidでもP8(Proguard)による最適化や難読化の問題、コンパイル時に方の不整合などを検出出来ない問題などがあり、リフレクションを使用したライブラリは減ってきているように思います。
それに上記の方法では、「各enum全てに対してextensionを書かなければいけない」という問題も解決できていません。
コード生成はこれらの問題も解決できる場合があります。
利用例
built_valueというツールがあります。
built_valueはいわゆるimmutableな値オブジェクトの生成を簡単にしてくれます。
part "user.g.dart"
abstract class User implements Built<User, UserBuilder> {
static Serializer<User> get serializer => _$userSerializer;
String get name;
User._();
factory User([void Function(UserBuilder) updates]) = _$User;
}
こうしたコードを書いておくと、
- メンバの内容を
==
で比較するoperator ==
-
operator ==
を オーバーライドするなら、こちらもオーバーライドする必要があるhashCode
- 内容がわかる
toString
- ビルダーバターンで必要なメンバだけを更新して新しい値を作る
rebuild
- ビルダーバターンで新しい値を作るコンストラクタ
- 上記ビルダーバターンを実現するための
UserBuilder
クラスとその実装 -
[(メンバ名), (メンバの値), ...]
という形でリストにシリアライズしてくれるシリアライザと、そのデシリアライザ
を生成してくれます。
自分で書いた分量に対して結構な量のコードを吐いてくれるので、それだけ実装を楽できたことを実感できます。
実演:アノテーションつきenumの拡張関数を自動生成する
どうして必要なのかに書いた、enumに対して特定のStringが欲しい場合もswitch-caseを含んだextensionを書かないといけない問題を解決してみましょう。
理想形を決める
まずはどんな形にできたら理想的なのか、ビフォー・アフターを考えます。
今回はアノテーションを用いてenum定数に値を保持させ、アノテーションがついているメンバを持つenumについてはextensionを生成する、という形にしたいと思います。
プロジェクトを分ける
自動生成を行う前に考える必要があるのが、プロジェクトの分割です。
生成には以下の要素が必要になります。
- アプリケーションに含めるライブラリ
- アノテーションクラス
- 生成したコードから使用するユーティリティなど
- コードの解析と生成を行うジェネレーター
- この後詳しく解説する部分
アプリケーションにジェネレーターのコードを含めてしまうと、アプリケーションからは使用しないライブラリなどが含まれることになってしまい、特に良いことがありません。
一つのリポジトリの中に、2つのpubパッケージを作るのが良いと思います。
アノテーションクラスを作る
Dartでは、デフォルトコンストラクタがconst
コンストラクタになっている(そのためには、すべてのメンバがfinal
になっている必要がある)クラスがアノテーションとして使用できます。
class Value {
final String value;
const Value(this.value): assert(value != null);
}
これはライブラリのパッケージに含めておきます。
ジェネレーターを作る
以下の説明はジェネレーターのパッケージについてのものです。
関連ライブラリ
名称 | 役割 |
---|---|
(上記で作成した、アプリケーションに含めるライブラリ) | アノテーションクラスを参照するために必要です |
build |
build_runner が実行するステップを記述するためのものです |
analyzer |
既存のDartソースコードを解析して、構文要素をプログラムから操作できるようにしてくれます |
source_gen |
build とanalyzer を組み合わせて、ソースコードの生成手段を提供します |
code_builder |
型安全にDartのソースコードを構成します。KotlinPoetみたいなもの |
dart_style |
生成したDartのソースコードを綺麗に整形してくれるもの |
build_runner |
(これは、自動生成を行いたいパッケージに取り込ませます。)パッケージに対して、定義したビルド操作を行います。Makeみたいなもの |
Dartのこの辺のツールみんなGooglabilityが低くて困る
必要に応じてプロジェクトに依存関係を追加してください。
ジェネレータクラスを作る
source_gen
を使います。
とりあえずプロジェクトに取り込んで、生成処理を行うクラスを作ってみましょう。
Generator
クラスを継承して generate()
を実装します。
class EnumHasValueGenerator extends Generator {
final TypeChecker hasValue = TypeChecker.fromRuntime(Value);
@override
FutureOr<String> generate(LibraryReader library, BuildStep buildStep) {
final lib = Library((b) => b
..body.addAll(library.enums.map((e) => Code(_codeForEnum(e))))
);
final emitter = DartEmitter();
return lib.accept(emitter).toString();
}
String _codeForEnum(ClassElement enum$) {
return """
extension ${enum$.name}ValueExtension on ${enum$.name} {
String get value {
switch (this) {
${enum$.annotatedWith(hasValue).map((f) => """
case ${enum$.name}.${f.element.name}:
return \"${f.annotation.read("value").stringValue}\";
""").join()}
default:
return this.toString();
}
}
}
""";
}
}
// ヘルパーメソッド類は省略
generate()
でreturnしたソースコードを、自動的にファイルに落としてくれます。
ソースコードの記述には code_builder
を使用します。
これを使うと型の恩恵を受けつつDartのソースコードを組むことが出来ます1。
ジェネレーターの設定をする
Builder enumHasValueBuilder(BuilderOptions options) =>
// ファイル名の接尾辞が .value.g.dart になるようにしました
PartBuilder([EnumHasValueGenerator()], ".value.g.dart");
こうした関数を宣言しておいた後、パッケージトップに build.yaml
というファイルを用意しておきます。
これで、このパッケージが build_runner
から使用できるようになります。
builders:
enum_string_value:
import: "package:enum_string_value_generator/enum_string_value_generator.dart"
builder_factories: ["enumHasValueBuilder"]
build_extensions: {".dart": [".value.g.dart"]}
auto_apply: dependents
build_to: source
applies_builders: ["source_gen|combining_builder"]
導入して使う
作成したジェネレーターを使いたいパッケージにて、build_runner
と先程作成した2つのプロジェクトを依存関係に追加し、トップに build.yaml
を作成します。
targets:
$default:
builders:
enum_string_value_generator|enumHasValueBuilder:
generate_for:
- lib/*.dart
あとは以下のコマンドを叩くことで、ソースファイルが生成されます!!
$ pub run build_runner build
できあがり
こちらに全部のソースコードを載せています。
まだFlutterパッケージで使用できるか試していませんが、Dart 2.6.0以降が使用できるプロジェクトならば使用できるかもしれません。
おわりに
自動コード生成でどんどん人間の仕事を減らして、楽して生きていきましょう!!!
-
code_builder 3.2.0時点ではまだextension記法に対応していないため、extension部分を手書きで書いています。 ↩