🕌

【Flutter】chopperを使ってみた

2023/12/26に公開

モチベーション

普段、http通信のクライアントライブラリはdioを利用しているが、Flutter Favoriteを眺めていたらchopperなるものがあることを知りました。
dioはいいなーと思いつついつも触っているので、Flutter Favoriteにあるchopperがどんなもんかと触ってみました。

Getting started

Install

chopperは通信を関数で表現し、アノテーションをつけて宣言することでコードを自動生成して利用できるようになります。
そのため、chopperというプラグインの他にbuild_runner及びchopper_generatorが必要なのでインストールします。

flutter pub add chopper
flutter pub add dev:build_runner
flutter pub add dev:chopper_generator

利用するAPI情報を関数として宣言

前述したようにchopperは通信を関数で表現します。実際の通信については自動生成されたコード内で行われます。
と、いうことでまずは定義をしています。

例えば以下のようなWebAPIがあり、そこに対し通信をしていきます。

基本情報
domain: http://localhost:3000

各APIの情報
- 新規作成
path: /diary
method: POST
requestBody:
{
  'id': (String),
  'text': (String)
}
responseBody:
{
  'id': (String),
  'text': (String),
  'created': (DateTime),
  'updated': (DateTime)
}

- リスト取得
path: /diary
method: GET
responseBody:
[
  {
    'id': (String),
    'text': (String),
    'created': (DateTime),
    'updated': (DateTime)
  },
  ...
]

上記のAPIを利用するための宣言をしていきます。
まずは、Dtoを宣言していきます。

いつもDtoやEntity系はFreezedを利用して作っているので、今回もFreezedを使っていきます。

import 'package:freezed_annotation/freezed_annotation.dart';

part 'diary_dto.freezed.dart';
part 'diary_dto.g.dart';

@freezed
class DiaryDto with _$DiaryDto {
  const factory DiaryDto({
    required String id,
    required String text,
    DateTime? created,
    DateTime? updated,
  }) = _DiaryDto;

  factory DiaryDto.fromJson(Map<String, dynamic> json) =>
      _$DiaryDtoFromJson(json);
}

POST時のrequestBodyにも使い回したいので、created``updatedはOptionalで宣言しました。

Dtoを宣言したので、連携するAPIの情報を関数で表現していきます。

クラスがAPIの宣言ですよと表現するアノテーション。
baseUrlはドメインを除いたPathが入ります。

@ChopperApi(baseUrl: '/diary')

create関数についてはstaticで宣言するようです。(chopperのGet Startedから引用しています)
また、abstract classで宣言して、ChopperServiceを継承するのもそういうルールのようです。

abstract class DiaryService extends ChopperService {
  static DiaryService create([ChopperClient? client]) => _$DiaryService(client);

次に各エンドポイントの宣言をしていきます。
今回はURIはPOSTの時も、GETと時も同じですが場合時よっては/diary/createのようなパスが追加される場合もあると思います。
その場合は
@Post(path: '/create')のようにアノテーション内にパスを宣言します。

  @Post()
  Future<Response<DiaryDto>> newDiary(@Body() DiaryDto body);

  @Get()
  Future<Response<List<DiaryDto>>> getDiaries();

また、GETの場合は/diary/:idの単体取得や、/diary?offset=x&limit=yなどのIDがパスに入る場合や、クエリがつく場合もあるかと思います。

IDがパスに入る場合は以下のように@Pathをつけて宣言します。

@Get(path: "/{id}")
Future<Response<DiaryDto>> getDiary({
  @Path('id') String id,
})

クエリの場合は@Queryをつけて宣言します。

@Get()
Future<Response<List<DiaryDto>>> getDiaries({
  @Query('offset') int offset,
  @Query('limit') int limit,
});

POST時のrequestBodyについても@Bodyをつけて宣言するのですが、@Bodyを複数引数内に宣言すると@Bodyの宣言が多いとコード生成時に怒られます。
例えば、以下はNGでException: Too many Body annotation for 'newDiary'というエラーが出ます。

  @Post(path: '/create')
  Future<Response<DiaryDto>> newDiary(
    @Body() String id,
    @Body() String text,
  );

そのためPOSTの宣言をする場合は@Bodyの引数を一つにする必要があり、複数のデータを送信する場合はクラスを作ることが必須になります。
場合によっては、以下のようにMapを引数にしてもいいかもしれません。

  @Post()
  Future<Response<DiaryDto>> newDiary(@Body() Map<String, dynamic> body);

最終的には以下のコードになります。

@ChopperApi(baseUrl: '/diary')
abstract class DiaryService extends ChopperService {
  static DiaryService create([ChopperClient? client]) => _$DiaryService(client);

  @Post()
  Future<Response<DiaryDto>> newDiary(@Body() DiaryDto body);

  @Get()
  Future<Response<List<DiaryDto>>> getDiaries();
}

Converterの設定

chopperではレスポンス及びリクエストについてはMapで送信、返却されるため独自のDtoクラスを作成した場合はConverterを設定する必要があります。

もともとJsonConverterというのを用意してくれており、Exampleに以下のConverterがあるので、それを元に拡張していきます。
https://github.com/lejard-h/chopper/blob/develop/example/bin/main_json_serializable.dart#L66-L120

上記のExampleではレスポンスにしか対応していないため、POST時のrequestBodyをDtoからMap<String, dynamic>にしていきます。

それぞれ、typedefでDto -> JsonJson -> Dtoのファクトリーを定義。

typedef DtoFactory<T> = T Function(Map<String, dynamic> json);
typedef JsonFactory<T> = Map<String, dynamic> Function(T dto);

JsonSerializableConverterにそれぞれのファクトリーを追加します。
(JsonConvertはfreeezedにも存在するため、より明確にするためにimport 'package:chopper/chopper.dart' as chopper;と宣言し、chopperのJsonConverterを指定しています。)

class JsonSerializableConverter extends chopper.JsonConverter {
  final Map<Type, DtoFactory> dtoFactories;
  final Map<Type, JsonFactory> jsonFactories;

JsonConvertの基底クラスにconvertResponseconvertRequestという関数がそれぞれあるので、overrideしてbodyをコンバートしていきます。
responseの場合はExampleにあるコードがそのまま利用できたので、修正はしていません。
requestはDto -> Mapに変更する必要があったので、以下の修正をしました。

** 追記(2024/1/7) **
以下のコードでjsonFactoriesというのをセットしてDto -> Mapと変換しようとしていましたが、jsonFactoriesの要素が一つの時は気付きませんでしたが、実はエラーが出ていました。(一応、変換はできていた)
convertResponseの関数では関数にItem(クラス判定できる要素)があるために、Map<Type, dynamic Function(Map<String, dynamic>)>こんな感じの宣言ができましたが、convertRequestにはないため、適切なfactoryMapから取り出せない状態でした。
そのため、結局Jsonにする用の基底クラスを用意してそれをDtoに継承させることで対処しました。

  Map<String, dynamic>? _encode<T>(T dto) {
    final jsonFactory = jsonFactories[T];
    if (jsonFactory == null) {
      return null;
    }
    return jsonFactory(dto);
  }

  @override
  chopper.Request convertRequest(chopper.Request request) {
    return super.convertRequest(request.copyWith(body: _encode(request.body)));
  }

引数で受け取ったRequestのbodyを取り出して変換。copyWithで変換後のbodyを再セットしています。

これで、変換する処理の実装は完了です。
次にChopperの初期化とdtoFactoriesjsonFactoriesに設定する変換関数も書いていきます。

chopperの初期化

初期化はGet Startedにあるため、簡潔に書きますが以下のように設定しています。
https://github.com/lejard-h/chopper/blob/develop/getting-started.md#defining-a-chopperclient

final chopper = chopper.ChopperClient(
  baseUrl: Uri.http('localhost:3000'),
  services: [DiaryService.create()],
  converter: JsonSerializableConverter(
    dtoFactories: const {
      DiaryDto: DiaryDto.fromJson,
    },
    jsonFactories: {
      DiaryDto: (dto) => dto.toJson(),
    },
  ),
);

追加部分として、converterに先ほど作成したJsonSerializableConverterをセットします。
JsonからDtoに変換するためにfromJson、DtoからJsonに変換するためにtoJsonをそれぞれのFactoriesにセットしています。

使ってみる

実際に通信をしてみます。といっても、関数を呼ぶだけでした。

先ほどChopperClientを初期化した際にservicesにDiaryService.create()でインスタンスを作ってセットしているので、それを呼びだし通信に対応した関数を呼び出します。

final service = chopper.getService<DiaryService>();
final response = await service.newDiary(
  DiaryDto(id: 'id', text: 'text'),
);

response内にStatusCodeやReponseBody等基本的な情報が入っています。
通信が成功したか、失敗したかについてはresponse.isSuccessfulというのが用意されており、200 <= statusCode < 300を通信成功として扱っています。

ログを出してみる

通信内容(Request情報やResponse情報など)をログに出したい場合、pretty_chopper_loggerというプラグインが用意されており、それをchopper初期化時にセットしておけばログ出力が可能になります。

final chopper = chopper.ChopperClient(
  baseUrl: Uri.http('localhost:3000'),
  services: [DiaryService.create()],
  converter: JsonSerializableConverter(
    dtoFactories: const {
      DiaryDto: DiaryDto.fromJson,
    },
    jsonFactories: {
      DiaryDto: (dto) => dto.toJson(),
    },
  ),
  interceptors: [PrettyChopperLogger()], // <-これ
);

interceptorsにLoggerを設定することで、以下のようにログが出力されるようになりました。

flutter: ╔╣ Request ║ GET
flutter: ║  http://localhost:3000/diary
flutter: ╚═════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════
The Flutter DevTools debugger and profiler on iPhone 14 Pro Max is available at:
http://127.0.0.1:9101?uri=http://127.0.0.1:60674/Afn8U1CSnFw=/
flutter: ╔╣ Response ║ GET ║ Status: 200 OK
flutter: ║  http://localhost:3000/diary
flutter: ╚═════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════
flutter: ╔╣ Headers
flutter: ║  {
flutter: ║    "x-powered-by": "Express",
flutter: ║    "connection": "keep-alive",
flutter: ║    "keep-alive": "timeout=5",
flutter: ║    "date": "Tue, 26 Dec 2023 13:39:29 GMT",
flutter: ║    "content-length": "758",
flutter: ║    "etag": "W/\"2f6-ZdPsT1MVJvXvXiQKd+YEvwxWOys\"",
flutter: ║    "content-type": "application/json; charset=utf-8"
flutter: ║  }
flutter: ╚═════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════
flutter: ╔╣ Body
flutter: ║
flutter: ║    {
flutter: ║      "seq": 1,
flutter: ║      "id": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
flutter: ║      "created": "2023-12-24T13:13:49.592Z",
flutter: ║      "updated": "2023-12-24T13:13:49.592Z",
flutter: ║      "text": "bbbbbbbbbbbbbbbbbbbbbbbbbbbb"
flutter: ║    },
flutter: ║    {
flutter: ║      "seq": 2,
flutter: ║      "id": "3eed828c-cd4c-4661-9535-5c17c024b5a2",
flutter: ║      "created": "2023-12-25T14:35:29.249Z",
flutter: ║      "updated": "2023-12-25T14:35:29.249Z",
flutter: ║      "text": "createatecreate"
flutter: ║    },
flutter: ║    {
flutter: ║      "seq": 3,
flutter: ║      "id": "b2e53805-538f-4d78-bc35-a76abd85ba3b",
flutter: ║      "created": "2023-12-25T14:38:14.926Z",
flutter: ║      "updated": "2023-12-25T14:38:14.926Z",
flutter: ║      "text": "testtesttest"
flutter: ║    },
flutter: ║    {
flutter: ║      "seq": 4,
flutter: ║      "id": "106fb378-d750-4747-9e01-8dcab7b517e3",
flutter: ║      "created": "2023-12-25T14:38:19.361Z",
flutter: ║      "updated": "2023-12-25T14:38:19.361Z",
flutter: ║      "text": "aaaaaaaaa"
flutter: ║    },
flutter: ║    {
flutter: ║      "seq": 5,
flutter: ║      "id": "b09637d2-9dae-46cf-b020-b2702a2aec89",
flutter: ║      "created": "2023-12-25T14:38:23.001Z",
flutter: ║      "updated": "2023-12-25T14:38:23.001Z",
flutter: ║      "text": "bbbbb"
flutter: ║    }
flutter: ║
flutter: ╚═════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════

実装においてちょっと詰まったところ

今回はEntity系にFreezedを使いたく、chopperのConverterでfromJsonやtoJsonをしたのですが、その場合サーバー側で配列で返却されるなどする場合はエラーになってしまいました。

エラーになること自体は問題はないのですが、Converter内で発生しており、全部の通信が共通の部分を通るため若干どのAPIのどのパラメータでExceptionやらassertやらが上がっているのかが分かりずらい感はありました。

たまたまFreezedでDtoを定義して、それをConverterでMap<String, dynamic>からDtoに変換させていたのですが、Service層(上記のDiaryService)まではMapを利用してService(Map<String, dynamic>) -> RemoteDataSource(Dto) -> Repository(Entity)みたいな使い分けの方が、レスポンスやリクエストのボディが変わった時に柔軟に対応でき、安全そうと思ったりしました。

Discussion