Dart コード生成 — build_runner とシリアライゼーション

コード生成はボイラープレート撲滅の究極の武器 — 機械にコードを書かせ、人間はロジックを書く。

1. 学べること


2. 開発者のリアルな物語

(1) 課題:20 のモデルクラスの手動シリアライゼーションに 3 日

Bob の DataPipeline には 20 のデータモデルクラスがあり、それぞれに fromJson/toJson メソッドが必要だった。20 クラスのシリアライゼーションコードを手動で書くのに 3 日かかり、その間に 4 つのタイポと 2 つの型変換エラーが発生した。さらに悪いことに、新しいフィールドを追加するたびに、コードの 3 か所(フィールド宣言、fromJsontoJson)を手動で更新する必要があった。1 つの更新漏れで 10 万件のパースが失敗した。

(2) json_serializable の解決策

モデルクラスに @JsonSerializable アノテーションを付けると、build_runner が自動的に fromJson/toJson を生成する。新しいフィールドを追加するには、宣言 + ビルドの再実行だけで、見落としのリスクはない。

DART
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);
}
TEXT
> 出力: ローカルの DartPad または `dart run` で実行してください。この Dart コースの全例は Dart 3.x / Flutter 3.x ベースです。SDK バージョンにより結果が多少異なる場合があります。

(3) 効果


3. build_runner の仕組み

(1) 生成パイプライン

100%
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
TEXT
> 出力: ローカルの 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 設定

YAML
dependencies:
  json_annotation: ^4.8.0

dev_dependencies:
  build_runner: ^2.4.0
  json_serializable: ^6.7.0

▶ サンプル:基本モデルクラス

DART
// 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 が生成される

▶ サンプル:カスタムフィールドマッピング

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;

▶ サンプル:ネストオブジェクトのシリアライゼーション

DART
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 使用法

DART
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 コマンド

▶ サンプル:一般的なコマンド

BASH
# ワンタイムビルド
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) partpart of

▶ サンプル:パートファイル関係

DART
// 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 コンセプト

DART
// カスタム 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 シリアライゼーションコード生成

▶ サンプル:完全なモデル定義

DART
// 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 モデルシリアライゼーション

DART
// ============================================
// 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');
}
TEXT
> 出力: ローカルの DartPad または `dart run` で実行してください。この Dart コースの全例は Dart 3.x / Flutter 3.x ベースです。SDK バージョンにより結果が多少異なる場合があります。

出力:

TEXT
=== 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: freezedjson_serializable は必ず一緒に使う必要がありますか? A: いいえ。json_serializable はシリアライゼーションを扱い、freezed はイミュータブルクラス生成を扱います。json_serializable だけを使うことも、両方を組み合わせることもできます。

Q: 生成ファイルはバージョン管理にコミットすべきですか? A: アプリケーションプロジェクトでは推奨されます(CI が build_runner を要求しないように)。ライブラリプロジェクトでも推奨されます(pub.dev 公開で必要になるため)。一部のチームは生成ファイルを .gitignore に追加します。

Q: 生成コードをデバッグするには? A: .g.dart ファイルを直接開いて読んでください。生成コードは標準の Dart コードなので、ブレークポイントを設定したり print 文を追加できます。問題は通常アノテーション設定にあり、生成ロジックにはありません。

Q: build_runnersource_gen の関係は何ですか? A: source_genbuild_runner の高レベル抽象化で、カスタム Builder を書くためのよりシンプルな API を提供します。json_serializablefreezed は両方とも source_gen 上に構築されています。

Q: @JsonKeyfromJson/toJson の型をカスタマイズできますか? A: はい。トップレベル関数または static メソッドを定義してください。シグネチャは T fromJson(Object? json)Object toJson(T value) にマッチします。それを @JsonKey で参照します。


📖 まとめ


📝 練習問題

  1. 基礎(難易度 ⭐):Dart プロジェクトを作成し、json_serializablebuild_runner 依存関係を追加してください。シンプルな Product クラス(3 フィールド)に @JsonSerializable アノテーションを付け、dart run build_runner build を実行して生成された .g.dart ファイルを調べてください。
  2. 中級(難易度 ⭐⭐):ネストモデル(List<Product> を含む OrderProductCategory 列挙型 を含む)の json_serializable を設定してください。カスタムフィールド名とデフォルト値を処理します。fromJson/toJson のラウンドトリップの正確性を検証してください。
  3. 挑戦(難易度 ⭐⭐⭐)freezedjson_serializable を組み合わせて、DataPipeline の Sealed Class スタイルの OrderEvent モデル(Created/StatusChanged/Cancelled)を作成してください。イミュータブルクラス + JSON シリアライゼーション + copyWith を生成します。生成コードを検証するテストを書いてください。

← 前のレッスン | 次のレッスン →

Web-Tutorial.com

Web-Tutorial 技術チーム

複数の開発者によって共同維持されているプログラミングチュートリアルプラットフォーム。各チュートリアルは専門分野の開発者が執筆・レビューしています。正確で信頼性の高いコンテンツを目指しています — 問題を見つけた場合はお知らせください。

100%