[読書会]JSON とシリアル化

(当頁はdocs.flutter.dev(https://docs.flutter.dev/data-and-backend/serialization/json)のリファレンスブログです。)

目次 index【閲覧時間3分】 click !!>>

1.概要

 現在のモバイルアプリ開発では、ウェブサーバとの通信や構造化データの保存が必要になることがほとんどです。
ネットワーク接続されたアプリを作る場合、いずれは JSON(JavaScript Object Notation)データを扱う必要が出てくる可能性が高いです。

 ここでは、Flutter を使った JSON の活用方法について説明しています。
また、異なるシナリオでどの JSON ソリューションを使用すべきか、そしてその理由についても解説しています。

データ構造  ===(エンコーディング、 シリアライズ (JSON形式等の)文字列

データ構造  デコーディング、 デシリアライズ=== (JSON形式等の)文字列

2.適切な JSON シリアル化について

 ここでは、JSONを操作する為の2つの一般的な戦略について触れています。

  • 手動シリアル化
  • コード生成を使用した自動シリアル化

(1)小規模プロジェクトでは、手動シリアル化を使用する

 Dartの標準ライブラリ dart:convert を利用して、手動でJSONデコードを行う方法を指します。
jsonDecode() 関数を使用し、JSON文字列を Map 型に変換します
その後、このマップから必要な値を直接参照します。

(1)小規模プロジェクトでは、手動シリアル化を使用する:
import 'dart:convert';

void main() {
  String jsonString = '{"name": "John", "age": 30}';
  Map<String, dynamic> jsonData = jsonDecode(jsonString);

  print(jsonData['name']); // John
  print(jsonData['age']);  // 30
}

(参考)

Dart のパターンとレコードを使ってみる

(2)中~大規模プロジェクトでは、コード生成を使用する

 コード生成を利用することで、JSONシリアライズに必要なボイラープレートコード(定型的なコード)を外部ライブラリが自動的に生成します。開発者はモデルクラスを定義し、ツールがそれに基づいてシリアライズ/デシリアライズ処理を生成します。

3.FlutterにはGSON/Jackson/Moshiに相当するライブラリはありません

4.dart:convert 使用による手動のJSONシリアライズ

 Flutter では、dart:convert ライブラリを使用して、シンプルに JSON データのシリアライズ(データを文字列に変換)やデシリアライズ(文字列をデータに変換)ができます。

 以下の例では、簡単な JSON モデル(ユーザーモデル)を使用して、手動で JSON を扱う方法について説明しています。

シンプルな JSON モデル(例):
{
  "name": "John Smith",
  "email": "john@example.com"
}

4.1 インラインでの JSONシリアライズ( JsonDecode() 関数 )

 dart:convert jsonDecode() 関数を使用して、(上記の様な)JSON 文字列をデコードします。

dart:convert の jsonDecode() 関数を使用して、(上記の様な)JSON 文字列をデコードします。
final user = jsonDecode(jsonString) as Map<String, dynamic>;

print('Howdy, ${user['name']}!');
print('We sent the verification link to ${user['email']}.');

(課題)

  • 動的型(dynamic): jsonDecode() は dynamic 型を返します。
  • データの型が実行時までわからない。
  • 型安全性、オートコンプリート、コンパイル時のエラー検出ができない。

4.2 モデルクラスを使用した JSONシリアライズ(例:User クラス)

 この問題を解決するために、まず JSON データを扱うモデルクラスを作成します。
(以下は、User クラスの例です。)

(1)JSON データを扱うモデルクラスの作成

JSON データを扱うモデルクラス(例):
class User {
  final String name;
  final String email;

  User(this.name, this.email);

  //JSON を User オブジェクトにデコードするコンストラクタ
  User.fromJson(Map<String, dynamic> json)
      : name = json['name'] as String,
        email = json['email'] as String;

  //User オブジェクトを JSON にエンコードするメソッド
  Map<String, dynamic> toJson() => {
        'name': name,
        'email': email,
      };
}

(2)上記モデルクラスの使用例

上記のモデルクラスの使用例:
//JSON をデコードして User オブジェクトを作成
final userMap = jsonDecode(jsonString) as Map<String, dynamic>;
final user = User.fromJson(userMap);

print('Howdy, ${user.name}!');
print('We sent the verification link to ${user.email}.');

//User オブジェクトを JSON にエンコード
String json = jsonEncode(user);
print(json); // {"name":"John Smith","email":"john@example.com"}

(注意点)

  1. モデルクラスへの責任
    • デコード (fromJson) やエンコード (toJson) のロジックはモデルクラス内に実装します。
    • 呼び出し側のコードは、JSON 処理を意識する必要がなくなります。
  2. ユニットテストの重要性
    • 実運用のアプリでは、User.fromJson() や User.toJson() の正確な動作を保証するために、ユニットテストを作成する必要があります。

(まとめ)

 Flutter では、JSON の内容を想定して事前にモデルクラスを定義する必要があります。
このアプローチにより、型安全性が向上し、コンパイル時のエラー検出が可能となりますが、開発初期の作業量が増えるというトレードオフがあります。

 これに対し、GSON/Jackson/Moshi のような動的なライブラリは開発の迅速化に寄与しますが、型安全性が低く、ランタイムエラーの可能性があるため、アプリの規模や性質によって適切な方法を選ぶ必要があります。

5.コード生成ライブラリを使用した JSON のシリアライズ

 JSON のシリアライズ処理を自動化するために、コード生成ライブラリを使用できます。
ここでは、json_serializable というライブラリを使用しています。
このライブラリは、JSON シリアライズのボイラープレート繰り返し書く必要のあるコード)を自動生成します。

(Flutter で JSON シリアライズのコードを生成するために使用される主な2つのライブラリ

  1. json_serializable
    • 通常の Dart クラスアノテーション(@ を使ったメタ情報)を付与するだけでシリアライズ可能にします。
    • シンプルで学習コストが低い。
    • 必要な設定だけ行えば、既存のコードに最小限の変更で適用可能です。
  2. built_value
    • 不変(Immutable)な値クラスを定義するための、より高機能な方法を提供します。
    • クラスが不変であるため、コードの堅牢性が向上します。
    • JSON のシリアライズ/デシリアライズにも対応していますが、セットアップがやや複雑です。

コード生成ライブラリ json_serializable によるシリアライズ手順:

5.1 json_serializable の設定

json_serializable の設定:
flutter pub add json_annotation
flutter pub add dev:build_runner
flutter pub add dev:json_serializable

5.2 json_serializable を使用したモデルクラスの作成

 json_serializable を使ってモデルクラスを作成する方法を示します。

(1)モデルクラス作成

 この例では、シンプルな JSON モデル(User クラス)を使用しています。

(user.dart)
import 'package:json_annotation/json_annotation.dart';

//`part` ディレクティブ:
//( 生成されたコードファイルをリンク。
//  ファイル名は元の Dart ファイル名に対応し、`.g.dart` が付与されます。)
part 'user.g.dart';

//`@JsonSerializable()`:
//( クラスが JSON シリアライズに対応していることを示します。
//  このアノテーションを付けることで、build_runner によって必要な
//  シリアライズ/デシリアライズコードが自動生成されます。)
@JsonSerializable()
class User {
  User(this.name, this.email);

  String name;
  String email;

  //User.fromJson() ファクトリコンストラクタ:
  //( JSON マップ(Map<String, dynamic>)から User インスタンスを作成します。
  //  生成された _UserFromJson() メソッドを呼び出します。)
  factory User.fromJson(Map<String, dynamic> json) => _$UserFromJson(json);

  //`toJson()` メソッド:
  //( User インスタンスを JSON に変換します。
  //  生成された _UserToJson() メソッドを呼び出します。)
  Map<String, dynamic> toJson() => _$UserToJson(this);
}

(2)カスタマイズ例

(ⅰ)API の JSON フィールド名と Dart モデルのフィールド名が異なる場合

 以下のように @JsonKey を使用して、JSON フィールドとモデルフィールドをマッピングします。

@JsonKey を使用して、JSON フィールドとモデルフィールドをマッピングする(例):
//API のフィールド "registration_date_millis" を
//Dart の "registrationDateMillis" にマッピング。
@JsonKey(name: 'registration_date_millis')
final int registrationDateMillis;

(ⅱ)クラス全体のフィールド変換を一括でカスタマイズ

 例えば、全ての Dart フィールドをスネークケース(snake_case)に変換する場合:

全ての Dart フィールドをスネークケース(snake_case)に変換する場合(例):
@JsonSerializable(fieldRename: FieldRename.snake)
class User {
  final String userName;
  final int registrationDateMillis;
}

 上記設定により、userName は JSON の “user_name” に、registrationDateMillis は “registration_date_millis” に変換されます。

(3)よく使われる @JsonKey アノテーション

(ⅰ)デフォルト値の設定

 JSON にキーが存在しない場合や値が null の場合、デフォルト値として false を使用。

JSON にキーが存在しない場合や値が null の場合、デフォルト値として false を使用:
@JsonKey(defaultValue: false)
final bool isAdult;

(ⅱ)必須フィールドの指定

 JSON に指定されたキーが存在しない場合、例外をスローする。

JSON に指定されたキーが存在しない場合、例外をスローする(例):
@JsonKey(required: true)
final String id;

(ⅲ)フィールドを無視

 生成されるシリアライズ/デシリアライズコードでこのフィールドを完全に無視。

生成されるシリアライズ/デシリアライズコードでこのフィールドを完全に無視(例):
@JsonKey(ignore: true)
final String verificationCode;

5.3 コード生成ユーティリティの実行

 (上記の)part ‘user.g.dart’ の部分で、
” Target of URI hasn’t been generated: ‘user.g.dart’. “
というエラーが発生することがありますが、これは、以下のコード生成ユーティリティを動作させることで解消します。

(1)1度だけコードを生成する

  • これにより、コード生成ユーティリティが user.g.dart のような必要なファイルを生成します。
  • コマンドは一度だけ実行され、すべてのモデルクラスのボイラープレートコードが生成されます。
(1)1度だけコードを生成する(例):
flutter pub run build_runner build

(2)継続的にコードを生成する

  • watch モードでは、ソースコードに変更が加えられるたびに自動的にコード生成が行われます。
  • 開発中に便利なモードで、モデルクラスの更新に応じて即座に生成されたコードを更新します。
(2)継続的にコードを生成する(例):
flutter pub run build_runner watch

5.4 json_serializable モデルの使用

(1)JSONデコード(変換)

  • json_serializable を使った JSON デコードは、これまで手動で行ったコードと同じように動作します。
  • モデルクラスに手動でシリアライズ/デシリアライズのロジックを記述する必要がなくなり、json_serializable が自動生成したコードを利用することでデコードが簡単に行えます。
JSONデコード(変換):
//jsonDecode:
//( JSON文字列をMapに変換 )
final userMap = jsonDecode(jsonString) as Map<String, dynamic>;

//User.fromJson(userMap):
//( MapからUserオブジェクトを作成 )
final user = User.fromJson(userMap);

(2)JSONエンコード(変換)

  • JSON エンコードのコードも手動で行う場合と同じように利用可能。
  • json_serializable を使うことで、エンコードのロジックは .g.dart ファイルに自動生成され、開発者が記述する必要はありません。
Dart
//jsonEncode:
//( UserオブジェクトをJSON文字列に変換。
//  json_serializable が生成した _$UserToJson メソッドが内部的に利用されます。)
String json = jsonEncode(user);

6.入れ子クラスのコード生成

 クラスの中に別のクラス(入れ子クラス)が含まれている場合、JSON形式でそのクラスを渡そうとするとエラーが発生することがあります。(例として、Address クラスを User クラスに含めた場合を考えます。)

(例1)Addressクラスの定義

(Address クラスの定義)
import 'package:json_annotation/json_annotation.dart';

part 'address.g.dart';

@JsonSerializable()
class Address {
  String street; //通りの名称
  String city; //都市の名称

  Address(this.street, this.city);

  //JSONから生成
  factory Address.fromJson(Map<String, dynamic> json) =>
      _$AddressFromJson(json);

  //JSONへ変換
  Map<String, dynamic> toJson() => _$AddressToJson(this);
}

(例2)User クラスに入れ子の形で Address クラスを使用

(User クラスに入れ子で実装した Address クラス)
import 'package:json_annotation/json_annotation.dart';
import 'address.dart';

part 'user.g.dart';

@JsonSerializable()
class User {
  User(this.name, this.address);

  String name; //ユーザー名
  Address address; //アドレス(入れ子クラス)

  //JSONから生成
  factory User.fromJson(Map<String, dynamic> json) => _$UserFromJson(json);
  
  //JSONへ変換
  Map<String, dynamic> toJson() => _$UserToJson(this);
}

(問題の発生)

 上記コードで 、” dart run build_runner build –delete-conflicting-outputs ” を実行すると、
.g.dart ” ファイルが生成されます。

 しかし、自動生成された _$UserToJson メソッドを見ると以下の様になります。

(自動生成された _$UserToJson メソッド)
Map<String, dynamic> _$UserToJson(User instance) => <String, dynamic>{
  'name': instance.name,
  'address': instance.address, //Addressクラスがそのまま出力される
};

 この状態で、以下のように toJson を呼び出すと・・・

Dart
Address address = Address('My st.', 'New York');
User user = User('John', address);
print(user.toJson());

 出力結果は以下の様になり、不完全です。

(不完全な結果)
{name: John, address: Instance of 'Address'}

 期待する出力形式は以下のような、ネストされたJSONデータです。

(期待する出力形式)
{name: John, address: {street: My st., city: New York}}

 これを実現するには、@JsonSerializable() アノテーションに explicitToJson: true追加します

(修正後)@JsonSerializable() アノテーションに explicitToJson: true を追加します:
import 'package:json_annotation/json_annotation.dart';
import 'address.dart';

part 'user.g.dart';

@JsonSerializable(explicitToJson: true)
class User {
  User(this.name, this.address);

  String name; //ユーザー名
  Address address; //アドレス(入れ子クラス)

  //JSONから生成
  factory User.fromJson(Map<String, dynamic> json) => _$UserFromJson(json);
  
  //JSONへ変換
  Map<String, dynamic> toJson() => _$UserToJson(this);

(explicitToJson の効果)

 explicitToJson: true 指定すると、_$UserToJson メソッドが以下のように修正されます

(修正後)_$UserToJson メソッド
Map<String, dynamic> _$UserToJson(User instance) => <String, dynamic>{
  'name': instance.name,
  //AddressクラスのtoJsonを呼び出す
  'address': instance.address.toJson(), 
};

 これにより、Address クラスの toJson メソッドが自動的に呼び出され、期待するネストされたJSON形式が得られます。

(最終的な結果)

 修正版コードを使うと以下のように正しく出力されます。

(期待通りに出力された内容)
{name: John, address: {street: My st., city: New York}}

(補足)

  • explicitToJson を使う理由
    • 入れ子構造を持つクラスで、子クラスの toJson メソッドを明示的に呼び出すため。
    • デフォルトでは、入れ子クラスが直接シリアライズされず、Instance of ‘ClassName’ のように表示される。
  • さらに詳しい情報:

 これで、入れ子構造を持つクラスのJSONシリアライズに対応できます。

コメントを残す