Как перестать писать код для взаимодействия с бэкендом

в 14:29, , рубрики: api, dart, flutter, open source, openapi, swagger, кодогенерация

Привет! Начну с главного - я лентяй. Я очень-очень ленивый разработчик. Мне приходится писать много кода - как для бэка, так и для фронта. И моя лень постоянно терзает меня, говоря: Ты мог бы не писать этот код, а ты пишешь... Так и живем.

Но что делать? Как можно избавиться от необходимости писать хотя бы часть кода?

Есть много подходов к решению этой проблемы. Давайте посмотрим на некоторые из них.

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, используется Dart 2.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 уже сейчас можно найти тут.

Автор: Михаил Альфа

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js