Dart для бэкэндеров. Часть 1

от автора

Идея создавать полный стек веб или мобильного приложения с использованием одной технологии не является новой. Этим путем уже прошел Javascript (JS + React/Native + Node.JS), Python (cowasm + kivy) и даже Go (go/wasm, gomobile) и Dart тоже не исключение (web для него естественная среда обитания, поскольку язык создавался для замены JavaScript, также поддерживается компиляция в Wasm с включенным экспериментом wasm gc, для мобильной разработки существует фреймворк Flutter). Кроме того, приложение на Dart может компилироваться в исполняемый файл и это может дать прирост производительности для высоконагруженных систем. В этой статье мы рассмотрим несколько решений для создания бэкэнда на Dart, в первой части обсудим общие вопросы архитектуры и создадим простой сервер без фреймворка и с использованием Shelf, а во второй части статьи речь пойдет о Frog и Conduit.

Прежде всего нужно отметить, что самостоятельные приложения на Dart (не собранные с использованием Flutter) могут использовать все языковые возможности, такие как рефлексия (пакет dart:mirrors, общие идеи рассмотрены в этой статье) и маркировка с использованием символов (специальный тип объекта, который в коде начинается с префикса #), что используется в некоторых backend-фреймворках для описания схемы данных или связи обработчиков с URL без использования кодогенерации.

Библиотека или фреймворк для backend кроме непосредственно решения задачи маршрутизации веб-запросов к соответствующим методам также может решать другие задачи:

  • управление схемой базы данных (миграция схемы, генерация документации);

  • создание OpenAPI-документации на основе описаний маршрутизации (http-методы, путь к сервису, аргументы и возможные результаты);

  • управление доступом к сервисам (JWT-токен, OAuth или любой другой способ аутентификации);

  • реализовывать DI для создания слабосвязанной архитектуры;

  • добавлять промежуточные обработчики (middleware) для манипуляции заголовками или содержанием запроса/ответа;

  • поддерживать генерацию страниц на основе шаблонов;

  • предоставлять удобные средства перехвата и обработки ошибок;

  • обеспечивать возможности для тестирования разработанной функциональности с замыканием запросов из теста внутрь фреймворка.

В качестве примера мы будем создавать простой REST API для CRUD-операций над товарами в каталоге. Описание товара включает в себя название (title), описание (description), цену (price) и необязательную фотографию (photo). Мы начнем с решения без использования фреймворков, а затем рассмотрим, какие будут отличия при их применении.

Вначале создадим пустой dart-проект:

dart create -t console sampleapi 

Добавим необходимые зависимости в pubspec.yaml:

dependencies:   hive: ^2.2.3   json_annotation: ^4.8.1  dev_dependencies:    lints: ^2.0.0   test: ^1.21.0   build_runner: ^2.4.0   json_serializable: ^6.7.0   hive_generator: ^2.0.0 

Определим модель данных для Hive и DTO-объект с поддержкой JSON (в DTO дополнительно будет передаваться id записи):

part 'sampleapi.g.dart';  @HiveType(typeId: 0) class ProductHiveObject {   @HiveField(0)   String title;   @HiveField(1)   String description;   @HiveField(2)   double price;   @HiveField(3)   String? photo;    ProductHiveObject({     required this.title,     required this.description,     required this.price,     required this.photo,   }); }  @JsonSerializable() class ProductDTO {   int? id;   String title;   String description;   double price;   String? photo;    ProductDTO({     this.id,     required this.title,     required this.description,     required this.price,     this.photo,   });    factory ProductDTO.fromJson(Map<String, dynamic> json) =>       _$ProductDTOFromJson(json);    Map<String, dynamic> toJson() => _$ProductDTOToJson(this);    ProductHiveObject toHive() => ProductHiveObject(         title: title,         description: description,         price: price,         photo: photo,       );    factory ProductDTO.fromHive(int id, ProductHiveObject hive) => ProductDTO(         id: id,         title: hive.title,         description: hive.description,         price: hive.price,         photo: hive.photo,       );    @override   String toString() =>       'Product(id=$id, title=$title, description=$description, price=$price, photo=$photo)'; } 

Для хранения данных в этом варианте будем использовать Hive (однако также можно использовать и sqlite3, postgres или postgrest для ORM, mongo_dart или любой другой драйвер). Запустим кодогенерацию для создания методов fromJson / toJson и адаптера для Hive:

dart run build_runner build 

Начнем с самого простого решения и создадим сервер на основе класса HttpServer из dart.io

import 'dart:io'; import 'dart:developer' as developer; import 'models.dart';  class ProductsApi {   ProductsRepository repository;    ProductsApi(this.repository);    Future<void> run() async {     final server = await HttpServer.bind('0.0.0.0', 8080);     await repository.init();     await for (final request in server) {       developer.log('Request uri: ${request.requestedUri.path}');       request.response.writeln('Hello');       await request.response.close();     }   } }  void main(List<String> arguments) async => ProductsApi().run(); 

Добавим логику обработки REST-запросов (часть из них должна быть с авторизацией):

  • GET /product — список всех товаров (без авторизации)

  • GET /product/:id — информация о товаре (без авторизации)

  • POST /product — создание нового товара (с авторизацией)

  • PUT /product/:id — изменение товара (с авторизацией)

  • DELETE /product/:id — удаление товара (с авторизацией)

Для авторизации будем использовать токен, переданный через заголовок Authorization (тип bearer), пока будет достаточно факта наличия заголовка. Добавим логику для разбора запроса и обработки соответствующих методов:

Future<void> run() async {     final server = await HttpServer.bind('0.0.0.0', 8080);     await repository.init();     await for (final request in server) {       final uri = request.requestedUri;       final segments = uri.pathSegments;       if (segments[0] != 'product') {         request.response.statusCode = HttpStatus.badRequest;       } else {         int? id = segments.length > 1 ? int.tryParse(segments[1]) : null;         String method = request.method;         developer.log('Request: $method[$id]');         try {           final response = await _handle(method, id);           developer.log('Response for $method[$id] : $response');           request.response.write(response);         } on ProductsHTTPException catch (e) {           developer               .log('HTTP Exception, status: ${e.status} on URI: ${e.uri}: $e');           request.response.statusCode = e.status;           request.response.writeln(e.message);           request.response.writeln('URI: ${e.uri.toString()}');         }       }       await request.response.close();     }   } 

Класс репозитория определяется контрактом по доступу к данным:

abstract interface class ProductsRepository {   Future<void> init();   Future<void> dispose();   FutureOr<List<ProductDTO>> getProducts();   FutureOr<ProductDTO?> getProduct(int id);   FutureOr<int> addProduct(ProductDTO product);   FutureOr<void> deleteProduct(int id);   FutureOr<void> updateProduct(int id, ProductDTO product); } 

В нашем случае реализация с Hive может быть такой:

class ProductsRepositoryImpl implements ProductsRepository {   late Box<ProductHiveObject> box;    @override   Future<void> init() async {     Hive.init('.');     Hive.registerAdapter(ProductHiveObjectAdapter());     box = await Hive.openBox('products');     box.put(       1,       ProductHiveObject(         title: 'Pen',         description: 'Beatiful pens',         price: 34.0,         photo: 'pens.jpg',       ),     );   }    @override   Future<void> dispose() => Hive.close();    @override   FutureOr<List<ProductDTO>> getProducts() => box       .toMap()       .entries       .map((e) => ProductDTO.fromHive(e.key, e.value))       .toList();    @override   FutureOr<ProductDTO?> getProduct(int id) {     final value = box.get(id);     if (value == null) return null;     return ProductDTO.fromHive(id, value);   }    @override   FutureOr<int> addProduct(ProductDTO product) => box.add(product.toHive());    @override   FutureOr<void> deleteProduct(int id) => box.delete(id);    @override   FutureOr<void> updateProduct(int id, ProductDTO product) =>       box.put(id, product.toHive()); } 

Для уведомления об ошибке при обработке (например, если товар не найден) добавим собственный класс, унаследованный от Exception:

class ProductsHTTPException implements HttpException {   Uri _uri;   int _status;   String _message;    ProductsHTTPException(this._status, this._uri, this._message);    int get status => _status;    @override   String get message => _message;    @override   Uri? get uri => _uri; } 

При обработке методов HTTP-запроса нужно будет дополнительно проверять наличие идентификатора (обязательно для PUT/DELETE), наличие тела запроса (обязательно для POST/PUT) и авторизации (методы PUT/POST/DELETE). В идеальном мире здесь можно было бы использовать middleware, который получает объект запроса и его анализирует, сейчас мы сделаем просто вспомогательные методы:

void _checkAuthorization(bool authorized, String path) {     if (!authorized) {       throw ProductsHTTPException(           HttpStatus.unauthorized, Uri.parse(path), 'Authorization required');     }   }    void _idNeeded(int? id, String path) {     if (id == null) {       throw ProductsHTTPException(           HttpStatus.badRequest, Uri.parse(path), 'Product id is required');     }   }    void _bodyNeeded(ProductDTO? body, String path) {     if (body == null) {       throw ProductsHTTPException(           HttpStatus.badRequest, Uri.parse(path), 'Product data is required');     }   } 

Тогда реализация метода handle может выглядеть следующим образом:

Future<String?> _handle(       String method, int? id, bool authorized, ProductDTO? body) async {     switch (method) {       case 'GET':         if (id == null) {           final products = await repository.getProducts();           return jsonEncode(products);         } else {           final product = await repository.getProduct(id);           if (product == null) {             throw ProductsHTTPException(                 404, Uri.parse('/product/$id'), 'Product isn\'t found');           }           return jsonEncode(product);         }       case 'DELETE':         _checkAuthorization(authorized, '/product/$id');         _idNeeded(id, '/product');         repository.deleteProduct(id!);       case 'PUT':         _checkAuthorization(authorized, '/product/$id');         _idNeeded(id, '/product');         _bodyNeeded(body, '/products/$id');         repository.updateProduct(id!, body!);       case 'POST':         _checkAuthorization(authorized, '/product');         _bodyNeeded(body, '/product/$id');         repository.addProduct(body!);       default:         return null;     }   } 

А в цикле обработки входящих запросов дополнительно будет извлекаться значение body для POST/PUT-запросов и проверяться авторизация:

ProductDTO? body;         try {           body = ProductDTO.fromJson(               jsonDecode(await utf8.decoder.bind(request).join()));           developer.log('Request body: $body');         } catch (e) {           body = null;         }         try {           final authorized = request.headers['Authorization'] != null;           final response = await _handle(method, id, authorized, body);           developer.log('Response $method[$id] : $response');           if (response != null) {             request.response.write(response);           }         } on ProductsHTTPException catch (e) {           developer               .log('HTTP Exception, status: ${e.status} on URI: ${e.uri}: $e');           request.response.statusCode = e.status;           request.response.writeln(e.message);           request.response.writeln('URI: ${e.uri.toString()}');         } 

Реализация завершена и можно запустить сервер и убедиться что все REST-запросы будут выполняться корректно. Исходный текст этой реализации сервера можно найти в ветке httpserver в репозитории https://github.com/dzolotov/dart-backend.

У этого простого решения есть несколько важных недостатков:

  • все запросы выполняются в основном изоляте и, если встретится длительная операция, она приведет к невозможности обработки других запросов (например, здесь это взаимодействие с Hive);

  • очень много пришлось делать вручную (собственный разбор запроса по сегментам, проверки авторизации смешаны с основным кодом);

  • код получился громоздким и плохо поддерживаемым, несмотря на то, что архитектурно ответственность разделена между слоями;

  • логирование запросов и ответов выполняется непосредственно в коде;

  • документация по API должна быть создана вручную;

  • тестирование сделать сложно (необходимо запускать экземпляр сервера на порте и потом подключаться к нему через любой http-клиент), хотя разумеется для подмены репозитория на тестовую реализацию мы можем использовать любой service locator для Dart (например, getit).

Давайте теперь перейдем к использованию специализированных библиотек и начнем с shelf. Shelf является модульной библиотекой, основанной на идее использования промежуточных обработчиков (middleware). Для Shelf существует большое количество расширений — для обработки статики, поддержки CORS, WebSockets, Multipart-запросов, ограничению скорости запросов, поддержки сессий, применению шаблонов для создания страниц (например, shelf_mustache), генерации документации в формате OpenAPI, даже есть кодогенерация из аннотаций (для упрощения привязки методов), автоматическое обновление сертификатов LetsEncrypt или отправка метрик в Prometheus. Список доступных пакетов можно посмотреть по этой ссылке. Для нас наибольший интерес представляют следующие пакеты:

  • shelf_plus — позволяет привязать обработчики к URI и извлечь из них параметры (также может использоваться непосредственно shelf-router, поверх которого построен shelf-plus);

  • shelf_swagger_ui — запуск Swagger (интерфейса для просмотра файлов OpenAPI с документацией по поддерживаемым запросам);

  • shelf_test_handler — библиотека для создания тестов разработанного API;

  • shelf_serve_isolates — запуск обработки запросов в нескольких изолятах для исключения потенциальной блокировки очереди длительной обработкой (но в нашем случае это решение работать не будет, поскольку из изолята нельзя передавать Future, возвращаемое из асинхронной функции);

  • shelf_router_generator — создает набор привязок по аннотациям в контроллере;

  • shelf_open_api + shelf_open_api_generator — генератор документации в формате OpenAPI по обрабатываемым запросам и возможным ответам.

Добавим необходимые зависимости в pubspec.yaml:

dependencies:   hive: ^2.2.3   json_annotation: ^4.8.1   shelf: ^1.4.1   shelf_plus: ^1.7.0   shelf_swagger_ui: ^1.0.0   shelf_test_handler: ^2.0.0   shelf_serve_isolates: ^1.1.0   shelf_open_api: ^1.0.0  dev_dependencies:    lints: ^2.0.0   test: ^1.21.0   build_runner: ^2.4.0   json_serializable: ^6.7.0   hive_generator: ^2.0.0   shelf_open_api_generator: ^1.0.0 

Логика shelf организуется вокруг обработчиков запросов (Handler) и промежуточных обработчиков (middleware), которые используются для перехвата запроса и его изменения при необходимости. Центральная концепция обработки в Shelf — Pipeline, на который добавляются дополнительные перехватывающие функции (addMiddleware) и обработчики запроса (addHandler). Простейшая реализация обработчика запросов может выглядеть так:

void main() {     final handler = Pipeline().addHandler(_process);     final server = await serve(handler, '0.0.0.0', 8080); }  Response _process(Request request) { //код обработки     return Response(HttpStatus.ok,         body: 'Hello Shelf', headers: {'Content-Type': 'text/plain'}); } 

Для middleware используется builder-функция, которая создает Middleware(это псевдоним для типа функции, которая принимает и возвращает handler, к которому может присоединить свои обработчики). Builder здесь необходим для определения дополнительной конфигурации middleware (например, можно определить уровень логирования). Для создания middleware полезно использовать функцию createMiddleware, который позволяет привязать middleware к обработке запроса или ответа, например:

Middleware get logger => createMiddleware(         requestHandler: (request) {           developer.log('Request ${request.method} ${request.url.path}');           return null; //здесь может быть Response         },         responseHandler: (response) async {           if (['text/plain', 'application/json'].contains(response.mimeType)) {             final content = await response.readAsString();             developer.log('Response $content');             return response.change(body: content);           } else {             return response;           }         },       );  void main() {     final handler = Pipeline().addMiddleware(logger).addHandler(_process);     final server = await serve(handler, '0.0.0.0', 8080); } 

Обратите внимание, что после извлечения ответа (через read или readAsString), произойдет ошибка при отправке результата, поскольку метод может быть вызван только один раз. Обходное решение здесь — создание копии ответа, для которого создается новый экземпляр Body на основе строке.

Точно также может быть реализована проверка аутентификации, для этого в requestHandler может быть сразу возвращен результат (ошибка 401), без передачи сообщения основному обработчику:

Middleware get auth => createMiddleware(requestHandler: (request) {         if (request.method == 'GET') return null;         if (!request.headers.containsKey('Authorization')) {           return Response.unauthorized('You need to be authorized user');         }         return null;       });  void main() {     final handler = Pipeline()         .addMiddleware(logger)         .addMiddleware(auth)         .addHandler(_process);     final server = await serve(handler, '0.0.0.0', 8080); } 

Порядок добавления middleware имеет значение, в этом случае запрос сначала отобразится на экране, а уже затем будет отклонен с ошибкой Unauthorized.

Обработчики запроса и middleware могут быть асинхронными. При этом если используются только синхронные методы, можно использовать изоляты для исключения ситуации блокировки:

final server = await ServeWithMultiIsolates(             address: '0.0.0.0', port: 8080, handler: handler)         .serve(); 

Теперь займемся маршрутизацией запросов. Один из возможных вариантов определения обработчиков маршрутов — использование Router (входит в shelf_plus или shelf_router):

Handler _getRoutes() {     final app = RouterPlus();     app.use(logger);     app.use(auth);     app.get('/product',         () async => (await repository.getProducts()).map((e) => e.toJson()));     app.get('/product/<id>', (Request req, String id) {       final result = repository.getProduct(int.tryParse(id) ?? 0);       if (result == null) return Response.notFound('Product isn\'t found');       return result;     });     app.post('/product', (Request request) async {       repository.addProduct(           ProductDTO.fromJson(jsonDecode(await request.readAsString())));       return Response.ok('');     });     app.put('/product/<id>', (Request request, String id) async {       repository.updateProduct(int.tryParse(id) ?? 0,           ProductDTO.fromJson(jsonDecode(await request.readAsString())));     });     app.delete('/product/<id>', (Request request, String id) async {       repository.deleteProduct(int.tryParse(id) ?? 0);     });     return app;   }    Future<void> run() async {     await repository.init();     await serve(_getRoutes(), '0.0.0.0', 8080);   } 

Обратите внимание, что в ответе может возвращаться не только строки, но и список байтов (для отправки двоичных файлов), а также произвольные структуры, которые автоматически конвертируются в строку через jsonEncode (и устанавливается Content-Type: application/json).

Но такой способ определения не позволит сгенерировать документацию автоматически. Кроме того, мы смешиваем логику обработки в определение маршрутов. Перейдем на использование кодогенерации и аннотации над классом контроллера и сразу будем добавлять аннотации для создания документации.

class ProductsController {   ProductsRepository repository;   ProductsController(this.repository);    Response toJson(dynamic data) => Response.ok(jsonEncode(data));    //Get products list   //   //Get all the products   //You can write the long description here   @Route('GET', '/product')   @OpenApiRoute()   Future<Response> getProducts(Request request) async =>       toJson((await repository.getProducts()).map((e) => e.toJson()).toList());    //Get product with given id   @Route('GET', '/product/<id>')   @OpenApiRoute()   Future<Response> getProduct(Request request, String id) async =>       toJson((await repository.getProduct(int.tryParse(id) ?? 0)));    //Create new product   @Route('POST', '/product')   @OpenApiRoute(requestBody: ProductDTO)   Future<Response> createProduct(Request request) async {     final data = jsonDecode(await request.readAsString());     repository.addProduct(ProductDTO.fromJson(data));     return Response.ok('');   }    //Delete product   @Route('DELETE', '/product/<id>')   @OpenApiRoute()   Future<Response> deleteProduct(Request request, String id) async {     repository.deleteProduct(int.tryParse(id) ?? 0);     return Response.ok('');   }    //Update product   @Route('PUT', '/product/<id>')   @OpenApiRoute(requestBody: ProductDTO)   Future<Response> updateProduct(Request request, String id) async {     final data = jsonDecode(await request.readAsString());     repository.updateProduct(int.tryParse(id) ?? 0, data);     return Response.ok('');   }    RouterPlus get router => _$ProductsControllerRouter(this).plus..use(logger)..use(auth); } 

Для запуска сервера будем использовать свойство router из контроллера:

await serve(ProductsController(repository).router, '0.0.0.0', 8080); 

Создание документации требует добавление конфигурации в build.yaml:

targets:   $default:     builders:       shelf_open_api_generator:         options:           include_routes_in: 'bin/sampleapi.dart'           info_title: 'Api' builders:   shelf_open_api_generator:     import: package:shelf_open_api_generator/shelf_open_api_generator.dart     builder_factories: [ 'buildOpenApi' ]     build_extensions: { 'bin/{{}}.open_api.dart': [ 'public/{{}}.open_api.yaml' ] }     auto_apply: root_package     build_to: source 

Также необходимо создать файл-заглушку (lib/sample.open_api.dart), из которого будет получено название для сгенерированного файла в каталоге public:

final openApi = 'place holder for shelf_open_api_generator package'; 

И теперь можно добавить запуск Swagger и связывание с public-каталогом для извлечения из приложения Swagger файла openapi:

RouterPlus get router => _$ProductsControllerRouter(this).plus     ..use(logger)     ..use(auth)     ..mount('/swagger',         SwaggerUI('public/sample.open_api.yaml', title: 'Swagger API'))     ..mount('/', createStaticHandler('/')); 

Swagger будет доступен по адресу http://localhost:8080/swagger.

Выполним компиляцию приложения в исполняемый файл:

dart compile exe -o sampleapi bin/main.dart 

Теперь сервер может быть запущен через исполняемый файл sampleapi или добавлен в контейнер (в этом случае также нужно добавить пакет shelf_docker_shutdown):

FROM dart AS build COPY . /opt WORKDIR /opt RUN dart compile exe -o sampleapi bin/main.dart  FROM scratch COPY --from=build /opt/sampleapi / ENTRYPOINT /sampleapi 

При тестировании можно использовать пакет shelf_test_handler, который подменяет ответы по заданным правилам и представляет url для использования в HTTP-клиентах для обращения к тестовому серверу.

Код проекта на shelf доступен в репозитории https://github.com/dzolotov/dart-backend (ветка shelf). Во второй части статьи мы рассмотрим использование библиотеки Frog (от Very Good Ventures) и фреймворка Conduit (который ранее назывался Aqueduct) и научимся создавать полную swagger-документацию по API и добавлять описание к полям модели данных. А сейчас хочу пригласить вас на бесплатный урок, где мы разберем новые возможности Flutter 3.10 и Dart 3 и используем их для создания простой интерактивной трехмерной игры с фоновой музыкой и звуковыми эффектами, а также попробуем подключиться к внешним устройствам через механизмы вызова нативного кода.


ссылка на оригинал статьи https://habr.com/ru/companies/otus/articles/743804/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *