
Идея создавать полный стек веб или мобильного приложения с использованием одной технологии не является новой. Этим путем уже прошел 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/
Добавить комментарий