【Dart】Dart Macrosを使ってみる
初めに
今回は Dart の Macros を使ってみたいと思います。
なお、2024年4月5日現在 Dart の Macros は Dart では実行できるものの、 Flutter では使用することができないためご注意ください。
Macros についての詳しい説明の前にXの投稿を2つ引用させて頂きます。
まずは以下の投稿をご覧ください。
この投稿では、Macros を使うとデータクラスを定義するコードを大幅に短縮できるとされています。
また、以下の投稿もご覧ください。
この投稿では、コード生成や freezed、json_serializable を使用せずにデータクラス定義が可能であるとされています。つまり、コード生成を行う build_runner の flutter pub run build_runner build --delete-conflicting-outputs
コマンドも実行不要になります。
個人的には Macros を使用するメリットとして以下の二つが大きいかと思いました。
- データクラス定義等に関して build_runner のコマンドを実行しないで済む
- データクラスの記述が短縮できる
Dart の Macros とは
次に Macros の説明に移りたいと思います。
Dart の Macros とは、dart-lang / macros の記述によると、以下のように定義されています。
マクロとは、コンパイル時にプログラムの他の部分を変更できるコードの一部である
また、Macros は Dart における Static Metaprograming をモチベーションとして開発されています。
Static Metaprograming についてはDart の Static Metaprogramming の記述 を以下に引用します。
Metaprogramming refers to code that operates on other code as if it were data. It can take code in as parameters, reflect over it, inspect it, create it, modify it, and return it. Static metaprogramming means doing that work at compile-time, and typically modifying or adding to the program based on that work.
(日本語訳)
メタプログラミングとは、他のコードをあたかもデータであるかのように操作するコードを指す。メタプログラミングは、コードをパラメータとして受け取り、それを反映し、検査し、作成し、修正し、返すことができる。静的メタプログラミングとは、コンパイル時にその作業を行い、通常はその作業に基づいてプログラムを修正したり追加したりすることを意味する。
上記にある通り、コンパイル時にコードを検査、作成、修正することで、問題があればコンパイル時にエラーを出してくれるようになります。したがって、より素早くコードの修正を行うことができるようになります。
説明を合わせると、 Macros はコンパイル時にプログラムの一部を変更できるものであり、その変更の内容に問題があればコンパイルエラーとして出力されるため、早い段階で気づくことができ効率も上がるということがわかります。
記事の対象者
- Flutter, Dart 学習者
- Dartの新しい機能を試してみたい方
目的
今回は上記の Macros を Dart で使ってみることを目的とします。なお Dart に関して理解が浅い部分も多々あるため、誤っている部分等あればご指摘いただければ幸いです。
最終的には Macros で定義されたモデルを用いて、 APIから取得した JSON 形式のデータをカスタムデータとして扱う実装を行いたいと思います。
準備
Macros を使うためには以下のステップが必要です。
- Flutter, Dart のバージョンアップ
- VSCode の拡張機能追加
- pubspec.yaml の編集
- analysis_options.yaml の編集
なお、自身の環境は以下の通りです。
- Flutter version 3.22.0-5.0.pre.4 on channel master
- Dart version 3.5.0 (build 3.5.0-18.0.dev)
- VS Code (version 1.88.0)
1. Flutter, Dart のバージョンアップ
サンプルプロジェクトの README をみると、Flutter に関しては master channel に切り替える必要があります。また、Dart のバージョンもあげておく必要があります。
したがって、 Macros を実行したいプロジェクトに移動して以下を実行します。
flutter upgrade
2. VSCode の拡張機能の変更
次に VSCode の Dart, Flutter 拡張機能を変更します。
Dart の拡張機能の「Switch to Pre-Release Version」ボタンを選択して、リリース前の機能にもアクセスできるようにします。
Flutter の拡張機能も同様に「Switch to Pre-Release Version」ボタンを選択します。
ボタンを選択した後、プロジェクトに反映させるために一度VSCodeを再起動しておきましょう。
3. pubspec.yaml の編集
次にプロジェクトの pubspec.yaml
を サンプルプロジェクトのpubspeck.yamlを参考に変更します。
dependencies:
macros: any
dev_dependencies:
_fe_analyzer_shared: any
dependency_overrides:
macros:
git:
url: https://github.com/dart-lang/sdk.git
path: pkg/macros
ref: main
_fe_analyzer_shared:
git:
url: https://github.com/dart-lang/sdk.git
path: pkg/_fe_analyzer_shared
ref: main
4. analysis_options.yaml の編集
次に analysis_options.yaml
に関しても サンプルプロジェクトのanalysis_options.yaml に従って変更します。
analyzer:
enable-experiment:
- macros
これで準備は完了です。
実装
次に Macros で定義されたモデルを用いて、 APIから取得した JSON 形式のデータをカスタムデータとして扱う実装を行います。
実装は以下の手順で行います。
- モデルを Macros で定義
- 使用するデータモデルを作成
- APIからデータを取得して表示させる処理を実装
- 実行と結果
1. モデルを Macros で定義
まずは Macros でモデルを作成していきます。
コードは以下の通りです。
import 'dart:async';
import 'package:macros/macros.dart';
macro class Model implements ClassDeclarationsMacro {
const Model();
static const _baseTypes = ['bool', 'double', 'int', 'num', 'String'];
static const _collectionTypes = ['List'];
Future<void> buildDeclarationsForClass(
ClassDeclaration classDeclaration,
MemberDeclarationBuilder builder,
) async {
final className = classDeclaration.identifier.name;
final fields = await builder.fieldsOf(classDeclaration);
final fieldNames = <String>[];
final fieldTypes = <String, String>{};
final fieldGenerics = <String, List<String>>{};
for (final field in fields) {
final fieldName = field.identifier.name;
fieldNames.add(fieldName);
final fieldType = (field.type.code as NamedTypeAnnotationCode).name.name;
fieldTypes[fieldName] = fieldType;
if (_collectionTypes.contains(fieldType)) {
final generics = (field.type.code as NamedTypeAnnotationCode)
.typeArguments
.map((e) => (e as NamedTypeAnnotationCode).name.name)
.toList();
fieldGenerics[fieldName] = generics;
}
}
final fieldTypesWithGenerics = fieldTypes.map(
(name, type) {
final generics = fieldGenerics[name];
return MapEntry(
name,
generics == null ? type : '$type<${generics.join(', ')}>',
);
},
);
_buildFromJson(builder, className, fieldNames, fieldTypes, fieldGenerics);
_buildToJson(builder, fieldNames, fieldTypes);
_buildCopyWith(builder, className, fieldNames, fieldTypesWithGenerics);
_buildToString(builder, className, fieldNames);
_buildEquals(builder, className, fieldNames);
_buildHashCode(builder, fieldNames);
}
void _buildFromJson(
MemberDeclarationBuilder builder,
String className,
List<String> fieldNames,
Map<String, String> fieldTypes,
Map<String, List<String>> fieldGenerics,
) {
final code = [
'factory $className.fromJson(Map<String, dynamic> json) {'.indent(2),
'return $className('.indent(4),
for (final fieldName in fieldNames) ...[
if (_baseTypes.contains(fieldTypes[fieldName])) ...[
"$fieldName: json['$fieldName'] as ${fieldTypes[fieldName]},"
.indent(6),
] else if (_collectionTypes.contains(fieldTypes[fieldName])) ...[
"$fieldName: (json['$fieldName'] as List<dynamic>)".indent(6),
'.whereType<Map<String, dynamic>>()'.indent(10),
'.map(${fieldGenerics[fieldName]?.first}.fromJson)'.indent(10),
'.toList(),'.indent(10),
] else ...[
'$fieldName: ${fieldTypes[fieldName]}'
".fromJson(json['$fieldName'] "
'as Map<String, dynamic>),'
.indent(6),
],
],
');'.indent(4),
'}'.indent(2),
].join('\n');
builder.declareInType(DeclarationCode.fromString(code));
}
void _buildToJson(
MemberDeclarationBuilder builder,
List<String> fieldNames,
Map<String, String> fieldTypes,
) {
final code = [
'Map<String, dynamic> toJson() {'.indent(2),
'return {'.indent(4),
for (final fieldName in fieldNames) ...[
if (_baseTypes.contains(fieldTypes[fieldName])) ...[
"'$fieldName': $fieldName,".indent(6),
] else if (_collectionTypes.contains(fieldTypes[fieldName])) ...[
"'$fieldName': $fieldName.map((e) => e.toJson()).toList(),".indent(6),
] else ...[
"'$fieldName': $fieldName.toJson(),".indent(6),
],
],
'};'.indent(4),
'}'.indent(2),
].join('\n');
builder.declareInType(DeclarationCode.fromString(code));
}
void _buildCopyWith(
MemberDeclarationBuilder builder,
String className,
List<String> fieldNames,
Map<String, String> fieldTypes,
) {
final code = [
'$className copyWith({'.indent(2),
for (final fieldName in fieldNames) ...[
'${fieldTypes[fieldName]}? $fieldName,'.indent(4),
],
'}) {'.indent(2),
'return $className('.indent(4),
for (final fieldName in fieldNames) ...[
'$fieldName: $fieldName ?? this.$fieldName,'.indent(6),
],
');'.indent(4),
'}'.indent(2),
].join('\n');
builder.declareInType(DeclarationCode.fromString(code));
}
void _buildToString(
MemberDeclarationBuilder builder,
String className,
List<String> fieldNames,
) {
final code = [
'@override'.indent(2),
'String toString() {'.indent(2),
"return '$className('".indent(4),
for (final fieldName in fieldNames) ...[
if (fieldName != fieldNames.last) ...[
"'$fieldName: \$$fieldName, '".indent(8),
] else ...[
"'$fieldName: \$$fieldName'".indent(8),
],
],
"')';".indent(8),
'}'.indent(2),
].join('\n');
builder.declareInType(DeclarationCode.fromString(code));
}
void _buildEquals(
MemberDeclarationBuilder builder,
String className,
List<String> fieldNames,
) {
final code = [
'@override'.indent(2),
'bool operator ==(Object other) {'.indent(2),
'return other is $className &&'.indent(4),
'runtimeType == other.runtimeType &&'.indent(8),
for (final fieldName in fieldNames) ...[
if (fieldName != fieldNames.last) ...[
'$fieldName == other.$fieldName &&'.indent(8),
] else ...[
'$fieldName == other.$fieldName;'.indent(8),
],
],
'}'.indent(2),
].join('\n');
builder.declareInType(DeclarationCode.fromString(code));
}
void _buildHashCode(
MemberDeclarationBuilder builder,
List<String> fieldNames,
) {
final code = [
'@override'.indent(2),
'int get hashCode {'.indent(2),
'return Object.hash('.indent(4),
'runtimeType,'.indent(6),
for (final fieldName in fieldNames) ...[
'$fieldName,'.indent(6),
],
');'.indent(4),
'}'.indent(2),
].join('\n');
builder.declareInType(DeclarationCode.fromString(code));
}
}
extension on String {
String indent(int length) {
final space = StringBuffer();
for (var i = 0; i < length; i++) {
space.write(' ');
}
return '$space$this';
}
}
こちらはサンプルプロジェクトの model.dart をもとに作成したものです。
非常に複雑に見えますが、実装しているのは以下の項目です。
- FromJson
- ToJson
- CopyWith
- ToString
- Equals
- HashCode
FromJson のコードを例にとってより詳しくみていきます。
FromJson ではデータモデルを作成したいクラスの className
, fieldNames
, fieldTypes
, fieldGenerics
を引数として受け取り、それらをもとに fromJson関数を作成しています。
void _buildFromJson(
MemberDeclarationBuilder builder,
String className,
List<String> fieldNames,
Map<String, String> fieldTypes,
Map<String, List<String>> fieldGenerics,
) {
final code = [
'factory $className.fromJson(Map<String, dynamic> json) {'.indent(2),
'return $className('.indent(4),
for (final fieldName in fieldNames) ...[
if (_baseTypes.contains(fieldTypes[fieldName])) ...[
"$fieldName: json['$fieldName'] as ${fieldTypes[fieldName]},"
.indent(6),
] else if (_collectionTypes.contains(fieldTypes[fieldName])) ...[
"$fieldName: (json['$fieldName'] as List<dynamic>)".indent(6),
'.whereType<Map<String, dynamic>>()'.indent(10),
'.map(${fieldGenerics[fieldName]?.first}.fromJson)'.indent(10),
'.toList(),'.indent(10),
] else ...[
'$fieldName: ${fieldTypes[fieldName]}'
".fromJson(json['$fieldName'] "
'as Map<String, dynamic>),'
.indent(6),
],
],
');'.indent(4),
'}'.indent(2),
].join('\n');
builder.declareInType(DeclarationCode.fromString(code));
}
ここで以下のようなデータクラスが引数として渡された場合を考えます。
class User {
final String name;
final String email;
User(this.name, this.email);
// この場合の FromJsonの引数
// className = User
// fieldNames = [ 'name', 'email' ]
// fieldTypes = { 'name': 'String', 'email': 'String' }
この時、FromJson の code
の部分は以下のように変換されます。
これは通常のデータクラスを定義したときに一緒に定義する fromJson と同じような記述になります。
factory User.fromJson(Map<String, dynamic> json) {
return User(
name: json[name] as String,
email: json[email] as String,
);
}
つまり、この _buildFromJson
メソッドで何をしているかというと、className
や fieldNames
などのそれぞれのクラス独自の値を受け取り、その値を適切に代入することで今まで記述していた fromJson メソッドと同じようなメソッドを生成しているのです。
この辺りの実装については以下の記事が非常に参考になるかと思います。
_buildFromJson
をもとに Macro の Model が何を行なっているかについてみましたが、そのほかの ToJson
や CopyWith
についても同様で、クラス独自の値を受け取り、今までの書き方のコードを生成しています。
2. 使用するデータモデルを作成
次に使用するデータモデルを作成していきます。
今回データを取得するのは JSONPlaceholder API です。
かなり単純な構造のデータを JSON 形式で取得することができます。
その中でも今回は posts
と photos
の二種類の取得を行いたいと思います。
データモデルのコードはそれぞれ以下の通りです。
()
class Post {
final int userId;
final int id;
final String title;
final String body;
Post({
required this.userId,
required this.id,
required this.title,
required this.body,
});
}
()
class Photo {
final int albumId;
final int id;
final String title;
final String url;
final String thumbnailUrl;
Photo({
required this.albumId,
required this.id,
required this.title,
required this.url,
required this.thumbnailUrl,
});
}
一番初めの章で紹介したXの投稿まではシンプルにできませんでしたが、それでも非常にシンプルにデータモデルの定義ができています。
@Moddel()
アノテーションをつけることで、先程 Macros を用いて作成した Model をクラスに付与することができます。
復習にはなりますが、 Post
の _buildFromJson
関数を例にとってみると、 _buildFromJson
には以下のようなデータが引数として入ることになるかと思います。
className = Post
fieldNames = [ 'userId', 'id', 'title', 'body' ]
fieldTypes = { 'userId': 'int', 'id': 'int', 'title': 'String', 'body': 'String' }
これでモデルの定義は完了です。
3. APIからデータを取得して表示させる処理を実装
最後にAPIからデータを取得して表示させる処理を実装していきます。
コードは以下の通りです。
Post の場合
import 'dart:convert';
import 'package:http/http.dart' as http;
// import 'post.dartのパス';
Future main() async {
final url = Uri.parse('https://jsonplaceholder.typicode.com/posts');
final response = await http.get(url);
if (response.statusCode == 200) {
final jsonList = jsonDecode(response.body) as List<dynamic>;
const int limit = 10;
for (int i = 0; i < limit; i++) {
final json = jsonList[i];
final post = Post.fromJson(json as Map<String, dynamic>);
print('${post.id} --------------------');
print('title: ${post.title}');
print('body: ${post.body}');
print('posted by id: ${post.userId}\n');
}
} else {
print('Get Error');
}
}
Photo の場合
import 'dart:convert';
import 'package:http/http.dart' as http;
// import 'photo.dartのパス';
Future main() async {
final url = Uri.parse('https://jsonplaceholder.typicode.com/photos');
final response = await http.get(url);
if (response.statusCode == 200) {
final jsonList = jsonDecode(response.body) as List<dynamic>;
const int limit = 10;
for (int i = 0; i < limit; i++) {
final json = jsonList[i];
final photo = Photo.fromJson(json as Map<String, dynamic>);
print('${photo.albumId} ---------------------');
print('title: ${photo.title}');
print('url: ${photo.url}');
print('image url: ${photo.url}\n');
}
} else {
print('Get Error');
}
}
以下では Post
を例にとって詳しくみていきます。
下記のコードではアクセスするAPIのエンドポイントの定義とデータの取得を行なっています。
final url = Uri.parse('https://jsonplaceholder.typicode.com/posts');
final response = await http.get(url);
以下では返ってきた JSONのリストに対して、取得する Post
の件数を10件に絞って Post
型に変換して表示しています。Post
に関してはIDとタイトル、本文を print文で表示させるようにしています。
final jsonList = jsonDecode(response.body) as List<dynamic>;
const int limit = 10;
for (int i = 0; i < limit; i++) {
final json = jsonList[i];
final post = Post.fromJson(json as Map<String, dynamic>);
print('${post.id} --------------------');
print('title: ${post.title}');
print('body: ${post.body}');
print('posted by id: ${post.userId}\n');
}
これでデータクラスの定義からデータ取得、表示までの一連の実装は完了です。
4. 実行と結果
最後に今までのコードを実行するのですが、記事の冒頭にも記述した通り、2024年4月5日現在 Dart の Macros は Dart では実行できるものの、 Flutter では使用することができません。したがって、 flutter run
ではなく別の方法を取る必要があります。
ターミナルを開き、以下のコマンドを実行することで Macros を含む Dart の実行を行うことができます。
dart --enable-experiment=macros [実行するファイルのパス]
// lib 直下の main.dart に main() がある場合
dart --enable-experiment=macros lib/main.dart
これで実行すると結果はそれぞれ以下のようになります。(長いので折りたたんでいます)
Post
1 --------------------
title: sunt aut facere repellat provident occaecati excepturi optio reprehenderit
body: quia et suscipit
suscipit recusandae consequuntur expedita et cum
reprehenderit molestiae ut ut quas totam
nostrum rerum est autem sunt rem eveniet architecto
posted by id: 1
2 --------------------
title: qui est esse
body: est rerum tempore vitae
sequi sint nihil reprehenderit dolor beatae ea dolores neque
fugiat blanditiis voluptate porro vel nihil molestiae ut reiciendis
qui aperiam non debitis possimus qui neque nisi nulla
posted by id: 1
3 --------------------
title: ea molestias quasi exercitationem repellat qui ipsa sit aut
body: et iusto sed quo iure
voluptatem occaecati omnis eligendi aut ad
voluptatem doloribus vel accusantium quis pariatur
molestiae porro eius odio et labore et velit aut
posted by id: 1
4 --------------------
title: eum et est occaecati
body: ullam et saepe reiciendis voluptatem adipisci
sit amet autem assumenda provident rerum culpa
quis hic commodi nesciunt rem tenetur doloremque ipsam iure
quis sunt voluptatem rerum illo velit
posted by id: 1
5 --------------------
title: nesciunt quas odio
body: repudiandae veniam quaerat sunt sed
alias aut fugiat sit autem sed est
voluptatem omnis possimus esse voluptatibus quis
est aut tenetur dolor neque
posted by id: 1
6 --------------------
title: dolorem eum magni eos aperiam quia
body: ut aspernatur corporis harum nihil quis provident sequi
mollitia nobis aliquid molestiae
perspiciatis et ea nemo ab reprehenderit accusantium quas
voluptate dolores velit et doloremque molestiae
posted by id: 1
7 --------------------
title: magnam facilis autem
body: dolore placeat quibusdam ea quo vitae
magni quis enim qui quis quo nemo aut saepe
quidem repellat excepturi ut quia
sunt ut sequi eos ea sed quas
posted by id: 1
8 --------------------
title: dolorem dolore est ipsam
body: dignissimos aperiam dolorem qui eum
facilis quibusdam animi sint suscipit qui sint possimus cum
quaerat magni maiores excepturi
ipsam ut commodi dolor voluptatum modi aut vitae
posted by id: 1
9 --------------------
title: nesciunt iure omnis dolorem tempora et accusantium
body: consectetur animi nesciunt iure dolore
enim quia ad
veniam autem ut quam aut nobis
et est aut quod aut provident voluptas autem voluptas
posted by id: 1
10 --------------------
title: optio molestias id quia eum
body: quo et expedita modi cum officia vel magni
doloribus qui repudiandae
vero nisi sit
quos veniam quod sed accusamus veritatis error
posted by id: 1
Photo
1 ---------------------
title: accusamus beatae ad facilis cum similique qui sunt
url: https://via.placeholder.com/600/92c952
image url: https://via.placeholder.com/600/92c952
1 ---------------------
title: reprehenderit est deserunt velit ipsam
url: https://via.placeholder.com/600/771796
image url: https://via.placeholder.com/600/771796
1 ---------------------
title: officia porro iure quia iusto qui ipsa ut modi
url: https://via.placeholder.com/600/24f355
image url: https://via.placeholder.com/600/24f355
1 ---------------------
title: culpa odio esse rerum omnis laboriosam voluptate repudiandae
url: https://via.placeholder.com/600/d32776
image url: https://via.placeholder.com/600/d32776
1 ---------------------
title: natus nisi omnis corporis facere molestiae rerum in
url: https://via.placeholder.com/600/f66b97
image url: https://via.placeholder.com/600/f66b97
1 ---------------------
title: accusamus ea aliquid et amet sequi nemo
url: https://via.placeholder.com/600/56a8c2
image url: https://via.placeholder.com/600/56a8c2
1 ---------------------
title: officia delectus consequatur vero aut veniam explicabo molestias
url: https://via.placeholder.com/600/b0f7cc
image url: https://via.placeholder.com/600/b0f7cc
1 ---------------------
title: aut porro officiis laborum odit ea laudantium corporis
url: https://via.placeholder.com/600/54176f
image url: https://via.placeholder.com/600/54176f
1 ---------------------
title: qui eius qui autem sed
url: https://via.placeholder.com/600/51aa97
image url: https://via.placeholder.com/600/51aa97
1 ---------------------
title: beatae et provident et ut vel
url: https://via.placeholder.com/600/810b14
image url: https://via.placeholder.com/600/810b14
結果にある通り、それぞれの独自のクラスに関して、JSON形式から正常に変換して取得、表示できていることがわかります。
今後の可能性
Macros は現在 Dart のみで実行が可能になっていますが、 Pub.dev を確認したところ以下のように Dart Team によって開発されている Macros パッケージが確認できました。
Flutter で使用できるようになる日も近いかもしれません。
まとめ
最後まで読んでいただいてありがとうございました。
Macros を触ってみて、Flutterに導入されれば開発効率が大きく上がる技術であると感じました。
今回作成したデータクラスは二つのみでしたが、作成した Macros はアノテーションとして使いまわせるた、扱うデータクラスが増えれば増えるほど、他の書き方と比較して楽に記述できると感じました。
一方で Macros を使用して手軽にかけてしまうことで、もちろん使い方次第ではありますが、Modelなどの内部で何が起こっているのかを意識しなくなり、機能追加や変更に対処できなくなる可能性もあると感じました。
例えば今回の例だと Macros を使っているデータクラスのプロパティが nullable の時の実装はどうするのかなど、すでにあるマクロだけでなく自身で変更を加えるなどしてどのような処理が行われているかは把握しておく必要があると感じました。
誤っている点やもっと良い書き方があればご指摘いただければ幸いです。
参考
引用させていただいた投稿
Discussion