Привет! Начну с главного - я лентяй. Я очень-очень ленивый разработчик. Мне приходится писать много кода - как для бэка, так и для фронта. И моя лень постоянно терзает меня, говоря: Ты мог бы не писать этот код, а ты пишешь... Так и живем.
Но что делать? Как можно избавиться от необходимости писать хотя бы часть кода?
Есть много подходов к решению этой проблемы. Давайте посмотрим на некоторые из них.
OpenAPI
Предположим, ваш backend - это набор REST-сервисов. Первое, с чего стоило бы начать - изучить документацию вашего бэка в надежде наткнуться на спецификацию OpenAPI. Идеальной будет ситуация, когда ваш бэк предоставляет максимально полную спеку, в которой будут описаны все методы, которые используются клиентами, а также все передаваемые и получаемые данные и возможные ошибки. На самом деле, я пишу эти строки и думаю, что это само собой разумеющееся: кажется очевидным, что если ты разрабатываешь API, то должна быть и спецификация, причем не в виде простого перечисления методов, а максимально полная, и, самое главное - генерируемая из кода, а не написанная руками, но так дело обстоит далеко не везде, поэтому надо стремиться к лучшему.
Ну ок, вот мы нашли нашу спеку, она полноценная, без темных пятен. Отлично - дело почти сделано. Теперь осталось использовать ее для достижения наших коварных целей. Так уж вышло, что я пишу еще и приложения на Flutter и в качестве клиента буду рассматривать именно его, но подход, применяемый тут - подойдет и для web-клиентов (да и для любых других тоже найдется что заюзать).
Генерация по инициативе клиента
Думаю, не будет откровением, что магии то и нет. Чтобы фича появилась - все равно должен появиться некий код. И да, мы не будем его писать, но будет кодогенератор. И вот тут-то и начинается самое интересное. Есть библиотеки для Flutter (да и не только для него), которые сгенерируют код для работы с бэком исходя из аннотаций, которые вы можете накидать на псевдо-сервисы (которые вам все еще придется написать).
Выглядит это примерно так:
import 'package:dio/dio.dart';
import 'package:json_annotation/json_annotation.dart';
import 'package:retrofit/retrofit.dart';
part 'example.g.dart';
@RestApi(baseUrl: "https://5d42a6e2bc64f90014a56ca0.mockapi.io/api/v1/")
abstract class RestClient {
factory RestClient(Dio dio, {String baseUrl}) = _RestClient;
@GET("/tasks/{id}")
Future<Task> getTask(@Path("id") String id);
@GET('/demo')
Future<String> queries(@Queries() Map<String, dynamic> queries);
@GET("https://httpbin.org/get")
Future<String> namedExample(@Query("apikey") String apiKey, @Query("scope") String scope, @Query("type") String type, @Query("from") int from);
@PATCH("/tasks/{id}")
Future<Task> updateTaskPart(@Path() String id, @Body() Map<String, dynamic> map);
@PUT("/tasks/{id}")
Future<Task> updateTask(@Path() String id, @Body() Task task);
@DELETE("/tasks/{id}")
Future<void> deleteTask(@Path() String id);
@POST("/tasks")
Future<Task> createTask(@Body() Task task);
@POST("http://httpbin.org/post")
Future<void> createNewTaskFromFile(@Part() File file);
@POST("http://httpbin.org/post")
@FormUrlEncoded()
Future<String> postUrlEncodedFormData(@Field() String hello);
}
@JsonSerializable()
class Task {
String id;
String name;
String avatar;
String createdAt;
Task({this.id, this.name, this.avatar, this.createdAt});
factory Task.fromJson(Map<String, dynamic> json) => _$TaskFromJson(json);
Map<String, dynamic> toJson() => _$TaskToJson(this);
}
После запуска генератора мы получим рабочий сервис, готовый к использованию:
import 'package:logger/logger.dart';
import 'package:retrofit_example/example.dart';
import 'package:dio/dio.dart';
final logger = Logger();
void main(List<String> args) {
final dio = Dio(); // Provide a dio instance
dio.options.headers["Demo-Header"] = "demo header";
final client = RestClient(dio);
client.getTasks().then((it) => logger.i(it));
}
Данный способ (применимый на любых типах клиентов) может сэкономить вам немало времени и в случае, если ваш бэк не имеет нормальной OpenAPI схемы - то у вас то и не особо большой выбор, однако, если качественная схема есть, то по сравнению с тем способом генерации кода, о котором мы поговорим дальше у текущего варианта есть несколько недостатков:
-
Вам все еще нужно писать код, меньше, чем раньше, но немало
-
Вы должны самостоятельно отслеживать изменения в бэкенде и вслед за ними менять написанный вами код
На последнем пункте стоит остановиться немного подробнее - если (когда) произойдут изменения на бэке в методах, которые уже используются в вашем приложении - то вам нужно самостоятельно отслеживать эти изменения, дорабатывать модели DTO, и, возможно, endpoint'а. Также, если по какой-то невероятнейшей причине произойдут обратно-несовместимые изменения метода, то узнаете вы об этом только в рантайме (в момент вызова данного метода) - чего может не произойти во время разработки (особенно, если у вас нет или недостаточно тестов) и тогда у вас будет крайне неприятный баг в проде.
Генерация без "тумана войны"
Вы же еще не забыли, что у нас есть качественная OpenAPI-схема? Отлично! Все поле боя вам открыто и нет смысла идти наощупь (я добавил эту фразу, чтобы хоть как-то оправдать заголовок этого блока, придумывать которые, со скрипом, приходится самому; генерация тут не поможет). Тогда стоит обратить внимание на те инструменты, которые предлагает вся экосистема OpenAPI в принципе!
Из всего многообразия молотков и микроскопов сейчас нас интересует всего один. И имя ему - OpenAPI Generator. Данный напильник позволяет генерировать код для любого языка (ну почти), а также - как для клиентов, так и для сервера (чтобы сделать mock-сервер, к примеру).
Давайте уже перейдем к коду:
В качестве схемы мы возьмем то, что предлагает демка Swagger. Затем, нам надо установить сам генератор. Вот прекрасное пособие для этого. Если вы читаете эту статью, то с высокой долей вероятности у вас уже установлена Node.js, а значит, одним из самых простых способов установки будет использование npm-версии.
Следующий шаг - сама генерация. Есть парочка способов сделать это:
-
Использование сугубо консольной команды
-
Использование команды в сочетании с файлом конфигурации
На нашем примере 1й способ будет выглядить следующим образом:
openapi-generator-cli generate -i https://petstore.swagger.io/v2/swagger.json -g dart-dio -o .pet_api --additional-properties pubName=pet_api
Альтернативный способ - описание параметров в файле openapitools.json
, например так:
{
"$schema": "node_modules/@openapitools/openapi-generator-cli/config.schema.json",
"spaces": 2,
"generator-cli": {
"version": "5.1.1",
"generators": {
"pet": {
"input-spec": "https://petstore.swagger.io/v2/swagger.json",
"generator-name": "dart-dio",
"output": ".pet_api",
"additionalProperties": {
"pubName": "pet_api"
}
}
}
}
}
И последующий запуск команды:
openapi-generator-cli generate
Полный перечень доступных параметров для Dart представлен здесь. А для любого другого генератора список этих параметров можно узнать, выполнив следующую консольную команду:
# <generator-name>, dart-dio - for example
openapi-generator-cli config-help -g dart-dio
Даже если вы выберите полностью консольный вариант, после первого запуска генератора, у вас появится файл конфигурации с прописанной в нем версией используемого генератора, как в данном примере - 5.1.1
. В случае с Dart / Flutter эта версия имеет весьма важное значение, так как каждая из них может нести определенные изменения, в том числе, с обратной несовместимостью или интересными эффектами.
Так, начиная с версии 5.1.0
генератор использует null-safety, но реализует это посредством явных проверок, а не возможностей самого языка Dart (пока это так, к сожалению). К примеру - если в вашей схеме некоторые из полей модели размечены как обязательные, то если ваш бэкенд вернет модель без этого поля - то случится ошибка в рантайме.
flutter: Deserializing '[id, 9, category, {id: 0, name: cats}, photoUrls, [string], tags, [{id: 0, na...' to 'Pet' failed due to: Tried to construct class "Pet" with null field "name". This is forbidden; to allow it, mark "name" with @nullable.
А все из-за того, что поле name
модели Pet
явным образом указано, как обязательное, но отсутствует в ответе запроса:
{
"Pet": {
"type": "object",
"required": [
"name", // <- required field
"photoUrls" // <- and this too
],
"properties": {
"id": {
"type": "integer",
"format": "int64"
},
"category": {
"$ref": "#/definitions/Category"
},
"name": {
"type": "string",
"example": "doggie"
},
"photoUrls": {
"type": "array",
"xml": {
"wrapped": true
},
"items": {
"type": "string",
"xml": {
"name": "photoUrl"
}
}
},
"tags": {
"type": "array",
"xml": {
"wrapped": true
},
"items": {
"xml": {
"name": "tag"
},
"$ref": "#/definitions/Tag"
}
},
"status": {
"type": "string",
"description": "pet status in the store",
"enum": [
"available",
"pending",
"sold"
]
}
},
"xml": {
"name": "Pet"
}
},
"Category": {
"type": "object",
"properties": {
"id": {
"type": "integer",
"format": "int64"
},
"name": {
"type": "string"
}
},
"xml": {
"name": "Category"
}
},
"Tag": {
"type": "object",
"properties": {
"id": {
"type": "integer",
"format": "int64"
},
"name": {
"type": "string"
}
},
"xml": {
"name": "Tag"
}
}
}
Что же, генератор запущен - дело сделано и осталось несколько несложных шагов, в которых нам почти не придется писать код (ради этого же все затевалось!). Стандартный openapi-generator
сгенерирует только базовый код, в котором используются библиотеки, полагающиеся уже на кодогенерацию средствами самого Dart. Поэтому, после завершения базовой генерации необходимо запустить и Dart генератор:
cd .pet_api
flutter pub get
flutter pub run build_runner build --delete-conflicting-outputs
На выходе мы получаем готовый пакет, который будет располагаться там, где вы указали в файле конфигурации или консольной команде. Осталось включить его в pubspec.yaml
:
name: openapi_sample
description: Sample for OpenAPI
version: 1.0.0
publish_to: none
environment:
flutter: ">=2.0.0"
sdk: ">=2.12.0 <3.0.0"
dependencies:
flutter:
sdk: flutter
pet_api: # <- our generated library
path: .pet_api
И использовать данную библиотеку следующим образом:
import 'package:dio/dio.dart';
import 'package:pet_api/api/pet_api.dart';
import 'package:pet_api/model/pet.dart';
import 'package:pet_api/serializers.dart'; // <- we must use [standartSerializers] from this package module
Future<Pet> loadPet() async {
final Dio dio = Dio(BaseOptions(baseUrl: 'https://petstore.swagger.io/v2'));
final PetApi petApi = PetApi(dio, standardSerializers);
const petId = 9;
final Response<Pet> response = await petApi.getPetById(petId, headers: <String, String>{'Authorization': 'Bearer special-key'});
return response.data;
}
Из важного в ней - необходимость прописать, какие мы будем использовать сериализаторы (standartSerializers
) для того, чтобы JSON'ы превращались в нормальные модели. А также прокидывать инстансы Dio
в сгенерированные ...Api
, указывая в них базовые урлы серверов.
Нюансы Dart
Вроде бы и все, что можно сказать по этой теме, но Dart не так давно получил крупное обновление, в нем появилась null-safety. И все пакеты активно обновляются, а проекты мигрируют на новую версию языка, более устойчивую к ошибкам нашим. Однако, на данный момент, генератор не поддерживает эту новую версию, причем - сразу по нескольким направлениям:
-
Версия языка в пакете (в последней версии генератора -
5.1.1
, используется Dart2.7.0
) -
Устаревшие пакеты
-
Обратная несовместимость некоторых из используемых пакетов (в актуальной версии
Dio
некоторые методы имеют другие названия и много чего еще)
name: pet_api
version: 1.0.0
description: OpenAPI API client
environment:
sdk: '>=2.7.0 <3.0.0' # -> '>=2.12.0 <3.0.0'
dependencies:
dio: '^3.0.9' # Actual -> 4.0.0
built_value: '>=7.1.0 <8.0.0' # -> 8.1.0
built_collection: '>=4.3.2 <5.0.0' # -> 5.1.0
dev_dependencies:
built_value_generator: '>=7.1.0 <8.0.0' # -> 8.1.0
build_runner: any # -> 2.0.5
test: '>=1.3.0 <1.16.0' # -> 1.17.9
И это может доставлять сразу несколько проблем - если вы уже перешли на Flutter 2.0+ и Dart 2.12+, то, чтобы запустить кодогенерацию второго этапа (которая на Dart) - вам придется переключать язык на старую версию, FVM позволяет это сделать довольно быстро, но это все равно неудобство.
Второй минус заключается в том, что данный сгенированный api-пакет теперь является legacy-зависимостью, что не позволит запустить ваш новый проект с sound-null-safety
. Вы сможете использовать преимущества null-safety
при написании кода, но рантайм проверки и оптимизации вам будут недоступны, а проект будет работоспособен только при использовании дополнительного параметра Flutter: --no-sound-null-safety
.
Варианта исправления этой ситуации три:
-
Сделать pull-request, с обновлением openapi-generator
-
Дождаться, пока это сделает кто-то другой, через пол года это, скорее всего, случится
-
Исправить сгенерированный код, чтобы он уже сейчас стал
sound-null-safety
Третий пункт звучит так, будто нам придется писать код... Немного придется, но не тот.
До начала наших манипуляций покажу вам bash-скрипт, который получился на данный момент и который запускает всю нашу логику генерации кода:
openapi-generator-cli generate
cd .pet_api || exit
flutter pub get
flutter pub run build_runner build --delete-conflicting-outputs
Данный скрипт полагается и на файл конфигурации, который мы обсуждали выше. Давайте дополним этот скрипт, чтобы он сразу же и обновлял все зависимости нашего сгенированного пакета:
openapi-generator-cli generate
cd .pet_api || exit
echo "name: pet_api
version: 1.0.0
description: OpenAPI API client
environment:
sdk: '>=2.12.0 <3.0.0'
dependencies:
dio: ^4.0.0 built_value: ^8.1.0 built_collection: ^5.1.0
dev_dependencies:
built_value_generator: ^8.1.0 build_runner: ^2.0.5 test: ^1.17.9" > pubspec.yaml
flutter pub get
flutter pub run build_runner build --delete-conflicting-outputs
Теперь - наш генератор корректно запустится и с новой версией Dart (>2.12.0
) в системе. Все бы ничего, но использовать наш api-пакет по прежнему не получится! Во первых - сгенерированный код изобилует аннотациями, привязывающими его к старой версии языка:
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.7 <--
// ignore_for_file: unused_import
А во вторых - есть обратная несовместимость в логике Dio и пакетов, которые используются для сериализации / десериализации моделей. Давайте исправим это! Для исправления нам потребуется написать совсем немного утилитарного кода, который будет исправлять несовместимости, которые появятся в нашем сгенерированном коде. Я упоминал выше, что советовал бы ставить генератор посредством npm, как самого простого способа, если у вас есть Node.js, соответственно, по инерции - и утилитарный код будет написан на JS. При желании его несложно переписать на Dart, если у вас нет Node.js и нет желания с ней связываться.
Давайте взглянем на эти нехитрые манипуляции:
const fs = require('fs');
const p = require('path');
const dartFiles = [];
function main() {
const openapiDirPath = p.resolve(__dirname, '.pet_api');
searchDartFiles(openapiDirPath);
for (const filePath of dartFiles) {
fixFile(filePath);
console.log('Fixed file:', filePath);
}
}
function searchDartFiles(path) {
const isDir = fs.lstatSync(path).isDirectory();
if (isDir) {
const dirContent = fs.readdirSync(path);
for (const dirContentPath of dirContent) {
const fullPath = p.resolve(path, dirContentPath);
searchDartFiles(fullPath);
}
} else {
if (path.includes('.dart')) {
dartFiles.push(path);
}
}
}
function fixFile(path) {
const fileContent = fs.readFileSync(path).toString();
const fixedContent = fixOthers(fileContent);
fs.writeFileSync(path, fixedContent);
}
const fixOthers = fileContent => {
let content = fileContent;
for (const entry of otherFixers.entries()) {
content = content.replace(entry[0], entry[1]);
}
return content;
};
const otherFixers = new Map([
// ? Base fixers for Dio and standard params
[
'// @dart=2.7',
'// ',
],
[
/response.request/gm,
'response.requestOptions',
],
[
/request: /gm,
'requestOptions: ',
],
[
/Iterable<Object> serialized/gm,
'Iterable<Object?> serialized',
],
[
/(?<type>^ +Uint8List)(?<value> file,)/gm,
'$<type>?$<value>',
],
[
/(?<type>^ +String)(?<value> additionalMetadata,)/gm,
'$<type>?$<value>',
],
[
/(?<type>^ +ProgressCallback)(?<value> onReceiveProgress,)/gm,
'$<type>?$<value>',
],
[
/(?<type>^ +ProgressCallback)(?<value> onSendProgress,)/gm,
'$<type>?$<value>',
],
[
/(?<type>^ +ValidateStatus)(?<value> validateStatus,)/gm,
'$<type>?$<value>',
],
[
/(?<type>^ +Map<String, dynamic>)(?<value> extra,)/gm,
'$<type>?$<value>',
],
[
/(?<type>^ +Map<String, dynamic>)(?<value> headers,)/gm,
'$<type>?$<value>',
],
[
/(?<type>^ +CancelToken)(?<value> cancelToken,)/gm,
'$<type>?$<value>',
],
[
/(@nullablen)(?<annotation>^ +@.*n)(?<type>.*)(?<getter> get )(?<variable>.*n)/gm,
'$<annotation>$<spaces>$<type>?$<getter>$<variable>',
],
[
'final result = <Object>[];',
'final result = <Object?>[];',
],
[
'Iterable<Object> serialize',
'Iterable<Object?> serialize',
],
[
/^ *final _response = await _dio.request<dynamic>(n +_request.path,n +data: _bodyData,n +options: _request,n +);/gm,
`_request.data = _bodyData;
final _response = await _dio.fetch<dynamic>(_request);
`,
],
// ? Special, custom params for concrete API
[
/(?<type>^ +String)(?<value> apiKey,)/gm,
'$<type>?$<value>',
],
[
/(?<type>^ +String)(?<value> name,)/gm,
'$<type>?$<value>',
],
[
/(?<type>^ +String)(?<value> status,)/gm,
'$<type>?$<value>',
],
]);
main();
Включим использование данного скрипта после openapi-генератора и до Dart-генератора:
rm -rf ".pet_api" || echo ".pet_api folder not found"
openapi-generator-cli generate
cd .pet_api || exit
echo "name: pet_api
version: 1.0.0
description: OpenAPI API client
environment:
sdk: '>=2.12.0 <3.0.0'
dependencies:
dio: ^4.0.0
built_value: ^8.1.0
built_collection: ^5.1.0
dev_dependencies:
built_value_generator: ^8.1.0
build_runner: ^2.0.5
test: ^1.17.9
" > pubspec.yaml
node ../openapi_updater.js # <--
flutter pub get
flutter pub run build_runner build --delete-conflicting-outputs
Теперь все готово! Большая часть всех этих регулярок исправляют базовую логику сгенерированного кода, однако есть и тройка кастомных, которые нужны для конкретного API. В каждом конкретном будут свои кастомные регулярки, но очень вероятно, что добавить их не составит большого труда, а все базовые будут работать на любом API.
Выводы
Подход к генерации клиентского кода, при наличии качественной OpenAPI схемы является крайне простой задачей, вне зависимости от языка клиента. В случае Dart - все еще есть определенные неудобства, вызванные, сугубо, переходным периодом на null-safety. Но в рамках данной заметки мы успешно преодолели все неурядицы и получили полностью работоспособную библиотеку для работы с бэкендом, зависимости которой (и сама она) обновлены до самой новой версии и могут быть использованными в проекте на Flutter с sound-null-safety без каких-либо ограничений.
Дополнительный плюс подхода, когда источником истины является именно схема - в случае ее изменения с потерей обратной совместимости наш сгенерированный код тут же отреагирует на это и покажет все ошибки на этапе статического анализа что убережет ваши нервишки от отлова багов в рантайме.
Также, есть и другие способы, которые позволят не писать то, что можно не писать. А тем временем, весь код из статьи с рабочими заплатками, позволяющими использовать генератор с null-safety уже сейчас можно найти тут.
Автор: Михаил Альфа