Привет! Начну с главного - я лентяй. Я очень-очень ленивый разработчик. Мне приходится писать много кода - как для бэка, так и для фронта. И моя лень постоянно терзает меня, говоря: Ты мог бы не писать этот код, а ты пишешь... Так и живем.
Но что делать? Как можно избавиться от необходимости писать хотя бы часть кода?
Есть много подходов к решению этой проблемы. Давайте посмотрим на некоторые из них.
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>', ], [ /(@nullable\n)(?<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 уже сейчас можно найти тут.
