Идея создавать полный стек веб или мобильного приложения с использованием одной технологии не является новой. Этим путем уже прошел 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)
Safort
26.06.2023 12:54+4@dmitriizolotov, спасибо за статью! Известны ли вам случаи использования Дарта на сервер в продакшене?
beduin01
26.06.2023 12:54+1Я Бэк на Dart пишу. Доволен. Причина выбора: не хочу постоянно с языке на язык переключаться. И очень нравится возможность бинарик собрать без зависимостей.
Минусы: не достает некоторых библиотек. Допустим того же драйвера для Oracle
Safort
26.06.2023 12:54А можно по подробнее про проект? Мб где-то прочитать можно про него? Или, может, статью напишите?) Плюсы, минусы и т.д.
stanukih
26.06.2023 12:54-1И дополнительный вопрос. Известны ли случаи серьезного Бэка на node? Я слышал, что его применяют в разных больших компаниях, но кроме js там ворох технологий, и насколько там важной деталью является js вопрос.
Safort
26.06.2023 12:54На "серьёзном" бэкенде всегда есть ворох технологий, тут уже не важно node.js или нет.
Mitai
26.06.2023 12:54+1мне одному не нравится что для бесплатного вебинара хотят знать мой номер? а че паспорт и снилс вам для вебинара не нужен?
MountainGoat
Жду CSS для бэка.