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

Комментарии (10)


  1. MountainGoat
    26.06.2023 12:54
    -1

    Жду CSS для бэка.


  1. Safort
    26.06.2023 12:54
    +4

    @dmitriizolotov, спасибо за статью! Известны ли вам случаи использования Дарта на сервер в продакшене?


    1. crackedmind
      26.06.2023 12:54

      Ну тот же pub.dev или вам другие варианты нужны?


      1. Safort
        26.06.2023 12:54

        Не совсем. Я скорее про частные компании, какие-нибудь стартапы, например.


    1. beduin01
      26.06.2023 12:54
      +1

      Я Бэк на Dart пишу. Доволен. Причина выбора: не хочу постоянно с языке на язык переключаться. И очень нравится возможность бинарик собрать без зависимостей.

      Минусы: не достает некоторых библиотек. Допустим того же драйвера для Oracle


      1. Safort
        26.06.2023 12:54

        А можно по подробнее про проект? Мб где-то прочитать можно про него? Или, может, статью напишите?) Плюсы, минусы и т.д.


  1. PackRuble
    26.06.2023 12:54

    Спасибо! Очень интересный материал ????


  1. stanukih
    26.06.2023 12:54
    -1

    И дополнительный вопрос. Известны ли случаи серьезного Бэка на node? Я слышал, что его применяют в разных больших компаниях, но кроме js там ворох технологий, и насколько там важной деталью является js вопрос.


    1. Safort
      26.06.2023 12:54

      На "серьёзном" бэкенде всегда есть ворох технологий, тут уже не важно node.js или нет.


  1. Mitai
    26.06.2023 12:54
    +1

    мне одному не нравится что для бесплатного вебинара хотят знать мой номер? а че паспорт и снилс вам для вебинара не нужен?