Dart コード生成 — build_runner とシリアライゼーション
コード生成はボイラープレート撲滅の究極の武器 — 機械にコードを書かせ、人間はロジックを書く。
1. 学べること
- build_runner の仕組み:Builder / Generator / AssetReader
- 一般的なジェネレータ:json_serializable / freezed / dart_mappable
- パートファイルメカニズム:.g.dart / .freezed.dart
- カスタム Builder 開発入門
- Bob のシナリオ:DataPipeline が json_serializable を使ってシリアライゼーションコードを自動生成
2. 開発者のリアルな物語
(1) 課題:20 のモデルクラスの手動シリアライゼーションに 3 日
Bob の DataPipeline には 20 のデータモデルクラスがあり、それぞれに fromJson/toJson メソッドが必要だった。20 クラスのシリアライゼーションコードを手動で書くのに 3 日かかり、その間に 4 つのタイポと 2 つの型変換エラーが発生した。さらに悪いことに、新しいフィールドを追加するたびに、コードの 3 か所(フィールド宣言、fromJson、toJson)を手動で更新する必要があった。1 つの更新漏れで 10 万件のパースが失敗した。
(2) json_serializable の解決策
モデルクラスに @JsonSerializable アノテーションを付けると、build_runner が自動的に fromJson/toJson を生成する。新しいフィールドを追加するには、宣言 + ビルドの再実行だけで、見落としのリスクはない。
import 'package:json_annotation/json_annotation.dart';
part 'order.g.dart';
@JsonSerializable()
class Order {
final String id;
final double amount;
Order({required this.id, required this.amount});
factory Order.fromJson(Map<String, dynamic> json) => _$OrderFromJson(json);
Map<String, dynamic> toJson() => _$OrderToJson(this);
}
> 出力: ローカルの DartPad または `dart run` で実行してください。この Dart コースの全例は Dart 3.x / Flutter 3.x ベースです。SDK バージョンにより結果が多少異なる場合があります。
(3) 効果
- 20 クラスのシリアライゼーションコードが 3 日から 30 分に短縮
- 新しいフィールドの追加は 1 か所だけ変更すればよく、ジェネレータが自動更新
- 型変換エラーはコンパイル時に検出可能
3. build_runner の仕組み
(1) 生成パイプライン
flowchart LR
A["model.dart<br/>@JsonSerializable"] --> B[build_runner]
B --> C["model.g.dart<br/>fromJson/toJson"]
B --> D["model.freezed.dart<br/>copyWith/equals"]
A --> E[".part ディレクティブ"]
E --> C
subgraph 生成パイプライン
B
C
D
end
> 出力: ローカルの DartPad または `dart run` で実行してください。この Dart コースの全例は Dart 3.x / Flutter 3.x ベースです。SDK バージョンにより結果が多少異なる場合があります。
| コンポーネント | 責任 |
|---|---|
| Builder | ソースファイルを読み、何を生成するかを決定 |
| Generator | 具体的なコード生成ロジックを含む |
| AssetReader | ソースコードファイルを読み取る |
| AssetWriter | 生成ファイルを書き込む |
| パートファイル | .g.dart / .freezed.dart |
4. json_serializable
(1) 設定と使用
▶ サンプル:pubspec.yaml 設定
dependencies:
json_annotation: ^4.8.0
dev_dependencies:
build_runner: ^2.4.0
json_serializable: ^6.7.0
▶ サンプル:基本モデルクラス
// lib/src/models/order.dart
import 'package:json_annotation/json_annotation.dart';
part 'order.g.dart';
@JsonSerializable()
class Order {
final String id;
final double amount;
final String status;
final String? category;
Order({
required this.id,
required this.amount,
required this.status,
this.category,
});
factory Order.fromJson(Map<String, dynamic> json) => _$OrderFromJson(json);
Map<String, dynamic> toJson() => _$OrderToJson(this);
}
// 実行: dart run build_runner build
// _$OrderFromJson と _$OrderToJson を含む order.g.dart が生成される
▶ サンプル:カスタムフィールドマッピング
import 'package:json_annotation/json_annotation.dart';
part 'product.g.dart';
@JsonSerializable()
class Product {
@JsonKey(name: 'product_id')
final String id;
@JsonKey(name: 'product_name')
final String name;
@JsonKey(name: 'unit_price')
final double price;
@JsonKey(defaultValue: 'General')
final String category;
@JsonKey(fromJson: _dateTimeFromEpoch, toJson: _dateTimeToEpoch)
final DateTime createdAt;
@JsonKey(ignore: true)
final String? cachedData;
Product({
required this.id,
required this.name,
required this.price,
this.category = 'General',
required this.createdAt,
this.cachedData,
});
factory Product.fromJson(Map<String, dynamic> json) => _$ProductFromJson(json);
Map<String, dynamic> toJson() => _$ProductToJson(this);
}
DateTime _dateTimeFromEpoch(int epoch) => DateTime.fromMillisecondsSinceEpoch(epoch);
int _dateTimeToEpoch(DateTime dt) => dt.millisecondsSinceEpoch;
▶ サンプル:ネストオブジェクトのシリアライゼーション
import 'package:json_annotation/json_annotation.dart';
part 'customer.g.dart';
@JsonSerializable()
class Address {
final String city;
final String? state;
final String country;
Address({required this.city, this.state, required this.country});
factory Address.fromJson(Map<String, dynamic> json) => _$AddressFromJson(json);
Map<String, dynamic> toJson() => _$AddressToJson(this);
}
@JsonSerializable()
class Customer {
final String name;
final String email;
final Address? address;
Customer({required this.name, required this.email, this.address});
factory Customer.fromJson(Map<String, dynamic> json) => _$CustomerFromJson(json);
Map<String, dynamic> toJson() => _$CustomerToJson(this);
}
@JsonKey パラメータ |
意味 | 例 |
|---|---|---|
name |
JSON のキー名 | @JsonKey(name: 'product_id') |
defaultValue |
欠落時のデフォルト値 | @JsonKey(defaultValue: 'N/A') |
fromJson |
カスタムデシリアライゼーション関数 | @JsonKey(fromJson: _parse) |
toJson |
カスタムシリアライゼーション関数 | @JsonKey(toJson: _format) |
ignore |
このフィールドを無視 | @JsonKey(ignore: true) |
5. freezed
(1) イミュータブルデータクラスの生成
▶ サンプル:基本 freezed 使用法
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:json_annotation/json_annotation.dart';
part 'order_event.freezed.dart';
part 'order_event.g.dart';
@freezed
class OrderEvent with _$OrderEvent {
const factory OrderEvent.created({
required String orderId,
required double amount,
required DateTime timestamp,
}) = OrderCreated;
const factory OrderEvent.statusChanged({
required String orderId,
required String from,
required String to,
}) = OrderStatusChanged;
const factory OrderEvent.cancelled({
required String orderId,
required String reason,
}) = OrderCancelled;
factory OrderEvent.fromJson(Map<String, dynamic> json) =>
_$OrderEventFromJson(json);
}
// 実行: dart run build_runner build
// 生成される:
// - order_event.freezed.dart: copyWith、==、hashCode、toString、パターンマッチング
// - order_event.g.dart: fromJson、toJson
| freezed が生成するもの | 機能 |
|---|---|
copyWith() |
イミュータブルコピー |
== / hashCode |
値の等価性 |
toString() |
フォーマット済み出力 |
when() |
パターンマッチングコールバック |
maybeWhen() |
オプショナルパターンマッチング |
fromJson/toJson |
JSON シリアライゼーション |
6. build_runner コマンド
▶ サンプル:一般的なコマンド
# ワンタイムビルド
dart run build_runner build
# 監視モード - ファイル変更時に再ビルド
dart run build_runner watch
# 生成ファイルのクリーンアップ
dart run build_runner clean
# 古いファイルを削除してビルド
dart run build_runner build --delete-conflicting-outputs
# 削除付きで監視
dart run build_runner watch --delete-conflicting-outputs
| コマンド | 目的 | 開発段階 |
|---|---|---|
build |
ワンタイム生成 | リリース |
watch |
変更時に自動生成 | 開発 |
clean |
生成ファイルの削除 | リセット |
--delete-conflicting-outputs |
競合を自動上書き | デバッグ |
7. パートファイルメカニズム
(1) part と part of
▶ サンプル:パートファイル関係
// lib/models/order.dart (ソースファイル)
import 'package:json_annotation/json_annotation.dart';
// パートファイルを宣言
part 'order.g.dart'; // json_serializable によって生成
part 'order.freezed.dart'; // freezed によって生成(使用する場合)
@JsonSerializable()
class Order {
final String id;
final double amount;
Order({required this.id, required this.amount});
factory Order.fromJson(Map<String, dynamic> json) => _$OrderFromJson(json);
Map<String, dynamic> toJson() => _$OrderToJson(this);
}
// 生成: order.g.dart
// part of 'order.dart';
// Order _$OrderFromJson(Map<String, dynamic> json) => Order(...)
// Map<String, dynamic> _$OrderToJson(Order instance) => {...}
| ディレクティブ | 場所 | 意味 |
|---|---|---|
part 'file.dart' |
ソースファイル | パートファイルを宣言 |
part of 'file.dart' |
生成ファイル | 属するソースファイルを示す |
.g.dart |
生成ファイル | json_serializable から |
.freezed.dart |
生成ファイル | freezed から |
8. カスタム Builder 入門
▶ サンプル:シンプルな Builder コンセプト
// カスタム Builder コンセプト(簡略化)
// 実際のプロジェクトでは別パッケージに配置
// カスタムアノテーション
class CsvMapping {
final String columnName;
final bool required;
const CsvMapping({required this.columnName, this.required = true});
}
// アノテーションを使用するモデル
class Order {
@CsvMapping(columnName: 'order_id')
final String id;
@CsvMapping(columnName: 'total_amount', required: false)
final double amount;
Order({required this.id, required this.amount});
}
// カスタム Builder が生成するもの:
// order.mapper.dart
// part of 'order.dart';
//
// Order OrderFromCsv(Map<String, String> row) => Order(
// id: row['order_id'] ?? '',
// amount: double.tryParse(row['total_amount'] ?? '0') ?? 0,
// );
9. Bob のシナリオ:DataPipeline シリアライゼーションコード生成
▶ サンプル:完全なモデル定義
// lib/src/models/order.dart
import 'package:json_annotation/json_annotation.dart';
part 'order.g.dart';
@JsonSerializable(createToJson: true, createFactory: true)
class Order {
@JsonKey(name: 'order_id')
final String id;
@JsonKey(name: 'total_amount')
final double amount;
@JsonKey(name: 'order_status', defaultValue: 'pending')
final String status;
@JsonKey(name: 'product_category', required: false)
final String? category;
@JsonKey(name: 'discount_rate', defaultValue: 0)
final double discountRate;
@JsonKey(name: 'created_at')
final DateTime createdAt;
Order({
required this.id,
required this.amount,
this.status = 'pending',
this.category,
this.discountRate = 0,
DateTime? createdAt,
}) : createdAt = createdAt ?? DateTime.now();
// build_runner によって生成
factory Order.fromJson(Map<String, dynamic> json) => _$OrderFromJson(json);
Map<String, dynamic> toJson() => _$OrderToJson(this);
// 計算プロパティ(JSON に含まれない)
double get tax => amount * 0.08;
double get total => amount * (1 - 0.08) * (1 - discountRate);
String get formatAmount => '\$${amount.toStringAsFixed(2)} USD';
}
// 実行後: dart run build_runner build
// order.g.dart ファイルが以下に生成される:
// - _$OrderFromJson: JSON を Order にパース
// - _$OrderToJson: Order を JSON にシリアライズ
10. 完全なサンプル:DataPipeline モデルシリアライゼーション
// ============================================
// DataPipeline モデルシリアライゼーション
// json_serializable による完全なモデル
// ============================================
import 'package:json_annotation/json_annotation.dart';
part 'models.g.dart';
// Order モデル
@JsonSerializable()
class Order {
@JsonKey(name: 'order_id')
final String id;
@JsonKey(name: 'total_amount')
final double amount;
@JsonKey(defaultValue: 'pending')
final String status;
@JsonKey(name: 'category')
final String? category;
@JsonKey(name: 'discount_rate', defaultValue: 0.0)
final double discountRate;
@JsonKey(name: 'region', defaultValue: 'US')
final String region;
Order({
required this.id,
required this.amount,
this.status = 'pending',
this.category,
this.discountRate = 0.0,
this.region = 'US',
});
factory Order.fromJson(Map<String, dynamic> json) => _$OrderFromJson(json);
Map<String, dynamic> toJson() => _$OrderToJson(this);
double get effectiveAmount => amount * (1 - discountRate);
double get tax => effectiveAmount * 0.08;
double get total => effectiveAmount + tax;
String get formatTotal => '\$${total.toStringAsFixed(2)} USD';
}
// Report モデル
@JsonSerializable()
class Report {
final String title;
final int totalOrders;
final int completedOrders;
final double totalRevenue;
final double totalTax;
final Map<String, double> revenueByCategory;
final DateTime generatedAt;
Report({
required this.title,
required this.totalOrders,
required this.completedOrders,
required this.totalRevenue,
required this.totalTax,
required this.revenueByCategory,
DateTime? generatedAt,
}) : generatedAt = generatedAt ?? DateTime.now();
factory Report.fromJson(Map<String, dynamic> json) => _$ReportFromJson(json);
Map<String, dynamic> toJson() => _$ReportToJson(this);
String get formatRevenue => '\$${totalRevenue.toStringAsFixed(2)} USD';
double get averageOrderValue => completedOrders > 0 ? totalRevenue / completedOrders : 0;
}
// シミュレーション使用法(本番では .g.dart が生成される)
void main() {
// 生成コードがすることのシミュレーション
final json = {
'order_id': 'ORD-001',
'total_amount': 1500.0,
'status': 'completed',
'category': 'Electronics',
'discount_rate': 0.1,
'region': 'US',
};
// 手動パース(_$OrderFromJson をシミュレート)
final order = Order(
id: json['order_id'] as String,
amount: (json['total_amount'] as num).toDouble(),
status: json['status'] as String? ?? 'pending',
category: json['category'] as String?,
discountRate: (json['discount_rate'] as num?)?.toDouble() ?? 0.0,
region: json['region'] as String? ?? 'US',
);
print('=== DataPipeline Order ===');
print('ID: ${order.id}');
print('Amount: \$${order.amount.toStringAsFixed(2)} USD');
print('Discount: ${(order.discountRate * 100).toStringAsFixed(0)}%');
print('Tax: \$${order.tax.toStringAsFixed(2)} USD');
print('Total: ${order.formatTotal}');
print('Category: ${order.category ?? "N/A"}');
print('Region: ${order.region}');
// レポート生成をシミュレート
final report = Report(
title: 'Daily Analytics Report',
totalOrders: 1000,
completedOrders: 850,
totalRevenue: 525000.0,
totalTax: 42000.0,
revenueByCategory: {
'Electronics': 360000.0,
'Clothing': 89000.0,
'Books': 76000.0,
},
);
print('\n=== Report ===');
print('Title: ${report.title}');
print('Orders: ${report.completedOrders}/${report.totalOrders}');
print('Revenue: ${report.formatRevenue}');
print('Average: \$${report.averageOrderValue.toStringAsFixed(2)} USD');
}
> 出力: ローカルの DartPad または `dart run` で実行してください。この Dart コースの全例は Dart 3.x / Flutter 3.x ベースです。SDK バージョンにより結果が多少異なる場合があります。
出力:
=== DataPipeline Order ===
ID: ORD-001
Amount: $1500.00 USD
Discount: 10%
Tax: $108.00 USD
Total: $1458.00 USD
Category: Electronics
Region: US
=== Report ===
Title: Daily Analytics Report
Orders: 850/1000
Revenue: $525000.00 USD
Average: $617.65 USD
❓ よくある質問
Q:
build_runner watchモードは遅いですか? A: 初回ビルドはすべてのファイルをスキャンする必要があり遅くなります(10-30 秒)。以降のインクリメンタルビルドは変更されたファイルのみを処理し、通常 1-3 秒ですかります。大規模プロジェクトでは--build-filterを使って指定ファイルのみを生成してください。
Q:
json_serializableと手書きのfromJsonのパフォーマンスの違いはありますか? A: 実際的にはありません。生成コードは手書きコードと同等の品質を持ち、一部のシナリオではより精密な型チェックを使うためむしろ優れています。
Q:
freezedとjson_serializableは必ず一緒に使う必要がありますか? A: いいえ。json_serializableはシリアライゼーションを扱い、freezedはイミュータブルクラス生成を扱います。json_serializableだけを使うことも、両方を組み合わせることもできます。
Q: 生成ファイルはバージョン管理にコミットすべきですか? A: アプリケーションプロジェクトでは推奨されます(CI が
build_runnerを要求しないように)。ライブラリプロジェクトでも推奨されます(pub.dev 公開で必要になるため)。一部のチームは生成ファイルを.gitignoreに追加します。
Q: 生成コードをデバッグするには? A:
.g.dartファイルを直接開いて読んでください。生成コードは標準の Dart コードなので、ブレークポイントを設定したり print 文を追加できます。問題は通常アノテーション設定にあり、生成ロジックにはありません。
Q:
build_runnerとsource_genの関係は何ですか? A:source_genはbuild_runnerの高レベル抽象化で、カスタム Builder を書くためのよりシンプルな API を提供します。json_serializableとfreezedは両方ともsource_gen上に構築されています。
Q:
@JsonKeyのfromJson/toJsonの型をカスタマイズできますか? A: はい。トップレベル関数または static メソッドを定義してください。シグネチャはT fromJson(Object? json)とObject toJson(T value)にマッチします。それを@JsonKeyで参照します。
📖 まとめ
build_runnerは Dart コード生成の実行エンジン:アノテーションを読み → コードを生成json_serializableはfromJson/toJsonを自動生成;@JsonKeyがマッピングをカスタマイズfreezedはイミュータブルデータクラスを生成:copyWith、==、hashCode、when- パートファイルメカニズムが生成コードとソースコードをリンクする
- DataPipeline は
json_serializableを使って 20 モデルクラスの手書きシリアライゼーションを撲滅する
📝 練習問題
- 基礎(難易度 ⭐):Dart プロジェクトを作成し、
json_serializableとbuild_runner依存関係を追加してください。シンプルなProductクラス(3 フィールド)に@JsonSerializableアノテーションを付け、dart run build_runner buildを実行して生成された.g.dartファイルを調べてください。 - 中級(難易度 ⭐⭐):ネストモデル(
List<Product>を含むOrder、ProductはCategory列挙型 を含む)のjson_serializableを設定してください。カスタムフィールド名とデフォルト値を処理します。fromJson/toJsonのラウンドトリップの正確性を検証してください。 - 挑戦(難易度 ⭐⭐⭐):
freezedとjson_serializableを組み合わせて、DataPipeline の Sealed Class スタイルのOrderEventモデル(Created/StatusChanged/Cancelled)を作成してください。イミュータブルクラス + JSON シリアライゼーション +copyWithを生成します。生成コードを検証するテストを書いてください。