【Flutter】chopperを使ってみた
モチベーション
普段、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があるので、それを元に拡張していきます。
上記のExampleではレスポンスにしか対応していないため、POST時のrequestBodyをDto
からMap<String, dynamic>
にしていきます。
それぞれ、typedefでDto -> Json
、Json -> 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の基底クラスにconvertResponse
とconvertRequest
という関数がそれぞれあるので、overrideしてbodyをコンバートしていきます。
responseの場合はExampleにあるコードがそのまま利用できたので、修正はしていません。
requestはDto -> Map
に変更する必要があったので、以下の修正をしました。
** 追記(2024/1/7) **
以下のコードでjsonFactoriesというのをセットしてDto -> Map
と変換しようとしていましたが、jsonFactoriesの要素が一つの時は気付きませんでしたが、実はエラーが出ていました。(一応、変換はできていた)
convertResponse
の関数では関数にItem(クラス判定できる要素)
があるために、Map<Type, dynamic Function(Map<String, dynamic>)>
こんな感じの宣言ができましたが、convertRequest
にはないため、適切なfactory
をMap
から取り出せない状態でした。
そのため、結局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の初期化とdtoFactories
とjsonFactories
に設定する変換関数も書いていきます。
chopperの初期化
初期化はGet Startedにあるため、簡潔に書きますが以下のように設定しています。
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