
Вы пишете приложение на Flutter. Модели уже описаны, бизнес-правила продуманы. Осталась небольшая деталь: сервер, который будет принимать запросы и работать с данными.
И здесь обычно начинается второй сезон сериала. Другой язык, другой менеджер пакетов, ещё один набор моделей. Поле переименовали на клиенте, забыли на сервере — поздравляем, у нас распределённая опечатка.
Dart позволяет написать обе стороны приложения. А Fletch предлагает довольно понятный способ сделать серверную: знакомые по Express маршруты, middleware, обработчики запросов и немного инфраструктуры, которую не хочется каждый раз собирать вручную.
Разберёмся, как это выглядит в коде, откуда берётся скорость и какие мелочи стоит заметить до первого деплоя.
В статье рассматривается Fletch 2.3.0 — последняя опубликованная версия, найденная при проверке 30 сентября 2026 года. Примеры проверены с Dart 3.13.5 на Linux x64. Там, где документация расходится с поведением, ориентиром служит код опубликованного пакета.
Знакомство: Express-подход на Dart
Fletch — HTTP-фреймворк Картикея Махавара, распространяемый под лицензией MIT. Он работает поверх dart:io и предоставляет маршрутизацию, middleware, сессии, внедрение зависимостей, контроллеры и потоковые ответы.
Если вы пользовались Express, узнавание наступит примерно здесь:
app.get('/health', (req, res) => res.json({'status': 'ok'}));
HTTP-метод, адрес, запрос и ответ. Можно открыть файл и прочитать поведение приложения сверху вниз.
При этом сходство с Express относится к организации API. JavaScript-middleware нельзя просто скопировать из npm в Dart-проект. У Fletch собственные типы Request, Response и собственный конвейер обработки.
Уровень ответственности тоже понятный. Fletch организует HTTP-часть приложения. Базу данных, миграции, очереди задач и способ авторизации выбирает разработчик.
Небольшая историческая ловушка: старые версии пакета fletch 0.x относились к другой, jQuery-подобной библиотеке. Современный серверный проект использует ветку 2.x. Десятилетняя история имени на pub.dev не означает десятилетнюю историю этого HTTP-фреймворка.
Первый сервер:
Создадим обычное консольное приложение и зафиксируем версию:
dart create -t console fletch_demo cd fletch_demo dart pub add fletch:2.3.0
Заменим содержимое bin/fletch_demo.dart:
import 'dart:io'; import 'package:fletch/fletch.dart'; Future<void> main() async { final app = Fletch( maxBodySize: 1024 * 1024, maxFileSize: 1024 * 1024, ); app.get('/health', (req, res) => res.json({'status': 'ok'})); app.get('/hello/:name?', (req, res) { final name = req.params['name'] ?? 'Хабр'; res.json({'message': 'Привет, $name!'}); }); await app.listen(8080, address: InternetAddress.loopbackIPv4); print('http://127.0.0.1:8080'); }
Запускаем:
dart run bin/fletch_demo.dart
GET /hello вернёт приветствие Хабру, а GET /hello/Dart — приветствие Dart. Знак ? делает параметр пути необязательным. Поддержка таких параметров появилась в 2.3.0.
Мы явно ограничили тело запроса и отдельный загружаемый файл одним мебибайтом. Адрес 127.0.0.1 выбран для локального знакомства: сервер доступен с этой машины. Для контейнера или внешних подключений адрес привязки нужно выбирать отдельно.
Сессии этот пример не использует. Предупреждение об отсутствующем sessionSecret при запуске ожидаемо; к настройке подписи вернёмся ниже.
Что полезно заметить:
req.paramsсодержит параметры пути,req.query— параметры строки запроса.res.json()формирует JSON-ответ с UTF-8 и соответствующимContent-Type.У
res.json()возвращаемый типvoid: обычный ответ отправляется фреймворком после завершения обработчика.Доступны отдельные методы регистрации для GET, POST, PUT, PATCH, DELETE, HEAD и OPTIONS.
С последним пунктом не стоит автоматически переносить все привычки из Express: одинаковые названия методов не гарантируют одинаковые правила для каждого пограничного случая.
JSON приехал. Теперь его нужно проверить
Строгая типизация заканчивается там, где начинается чужой HTTP-запрос. Клиент вполне способен прислать число вместо имени, массив вместо объекта или половину JSON-документа.
Добавим следующий обработчик в main(), перед app.listen():
app.post('/api/greet', (req, res) async { if (req.headers.contentType?.mimeType != 'application/json') { throw HttpError(415, 'Ожидается application/json'); } Object? body; try { body = await req.body; } on FormatException { throw ValidationError('Некорректный JSON'); } if (body is! Map<String, dynamic>) { throw ValidationError('Ожидается JSON-объект'); } final name = body['name']; if (name is! String || name.trim().isEmpty) { throw ValidationError('Поле name должно быть непустой строкой'); } res.json({'message': 'Привет, ${name.trim()}!'}); });
Проверка из терминала с POSIX-оболочкой:
curl -i http://127.0.0.1:8080/api/greet \ -H 'Content-Type: application/json' \ --data '{"name":"Dart"}'
Получим 200 и приветствие. Пустое имя или повреждённый JSON дадут 400, неподходящий тип содержимого — 415.
await req.body выбирает способ разбора по Content-Type и сохраняет результат для повторного обращения. Помимо JSON поддерживаются URL-encoded формы, текст и сырые байты; для multipart есть formData и files. Но проверку структуры данных приложение выполняет само. ValidationError в Fletch соответствует HTTP 400.
Перехват FormatException здесь имеет практический смысл: без явного преобразования ошибка декодирования попадёт в общий обработчик исключений и по умолчанию превратится в 500. Некорректный ввод клиента лучше описать клиентской ошибкой.
Middleware: что происходит по дороге к обработчику
Middleware получает (req, res, next). Вызов next() передаёт управление дальше; если middleware сформировал ответ и закончил работу без next(), следующий обработчик не вызывается. Асинхронное продолжение нужно ожидать через await или возвращать его результат.
Например, перед запуском сервера можно добавить:
app.use(app.cors( allowedOrigins: ['http://localhost:5173'], )); app.use(app.rateLimiter( maxRequests: 120, window: const Duration(minutes: 1), ));
Первое middleware настраивает CORS для указанного origin. Второе ограничивает частоту обращений: по умолчанию ключом служит IP непосредственного сетевого клиента, а превышение лимита даёт 429. Оба механизма подключаются явно.
За обратным прокси непосредственным клиентом часто окажется сам прокси. Без дополнительной настройки разные пользователи попадут в один лимит. Fletch позволяет передать keyGenerator, но доверять X-Forwarded-For следует только при корректно настроенной доверенной цепочке прокси. Иначе клиент сможет сам подсказать серверу удобный для себя IP.
Есть особенность реализации 2.3.0: middleware приложения оборачивает зарегистрированные маршруты. Оно не выполняется для отсутствующего маршрута и не наследуется автоматически подключённым IsolatedContainer. Поэтому общий счётчик запросов или защиту модулей нельзя строить на предположении, что app.use() обязательно перехватит вообще всё. Это проверено отдельными HTTP-тестами.
Когда маршрутов стало больше пяти
Свалить весь API в main() технически можно. Читать его через полгода будет уже отдельным видом спорта.
Для группировки маршрутов у Fletch есть контроллеры, а для зависимостей — интеграция с GetIt. Добавим вне main() два класса:
class UserService { Future<List<Map<String, Object>>> list() async => [ {'id': 1, 'name': 'Анна'}, ]; } class UsersController extends Controller { @override void registerRoutes(ControllerOptions options) { options.get('/list', (req, res) async { final users = await req.resolve<UserService>().list(); res.json({'users': users}); }); } }
В main(), перед app.listen():
app.registerLazySingleton<UserService>(() => UserService()); app.useController('/users', UsersController());
Теперь GET /users/list вернёт список пользователей. Пока это демонстрационные данные; на месте UserService.list() может быть обращение к выбранному драйверу базы. Фреймворк не заставляет менять HTTP-обработчик из-за того, что внутри сервиса появился SQL-запрос.
Здесь используется req.resolve<T>(). В коде 2.3.0 старое обращение через req.container уже помечено устаревшим, хотя ещё встречается в README.
Для более самостоятельных модулей предусмотрен IsolatedContainer: собственные маршруты, middleware и контейнер зависимостей под префиксом. Слово isolated означает организационную изоляцию модуля. Само по себе создание такого контейнера не запускает новый Dart isolate и не добавляет вычислительное ядро.
Сервер, который умеет говорить первым
Уведомления, прогресс обработки, обновления дашборда — не всегда удобно заставлять клиента каждые две секунды спрашивать: «Ну что, уже?»
Для передачи событий от сервера клиенту у Fletch есть SSE. Этот маршрут можно добавить в тот же main():
app.get('/events', (req, res) async { await res.sse((sink) async { for (var i = 1; i <= 3; i++) { await sink.sendEvent('$i', event: 'tick', id: '$i'); await Future<void>.delayed(const Duration(seconds: 1)); } }); });
Проверяем:
curl -N http://127.0.0.1:8080/events
Клиент получит три события типа tick, после чего соединение закроется. У sendEvent() параметр называется event: встречающееся в README имя type не соответствует API этой версии.
Для файлов и произвольных байтовых потоков есть res.stream(). Если нужно отдавать большой файл постепенно, ему можно передать File.openRead(). Метод res.file() устроен иначе: в 2.3.0 он сначала читает файл целиком в память. Для больших файлов эта разница существенна.
SSE передаёт события в направлении от сервера к клиенту. Отдельного высокоуровневого WebSocket API в проверенном пакете нет. Длительные SSE-соединения также требуют согласовать таймауты приложения и прокси: стандартный requestTimeout Fletch равен 30 секундам и охватывает выполнение потокового ответа.
Что даёт Dart на сервере
Общие модели. Независимый от Flutter пакет с DTO, перечислениями и частью правил проверки можно подключить и к клиенту, и к серверу. Но JSON-сериализацию, валидацию внешнего ввода и совместимость версий API всё равно нужно продумать. Общий язык уменьшает дублирование, но не отменяет сетевую границу.
Привычная асинхронность. Пока обработчик ожидает асинхронный запрос к базе, event loop может выполнять другие задачи. Тяжёлый синхронный расчёт занимает текущий isolate и задерживает остальную работу в нём.
Нативная сборка. Для показанного приложения со стандартным транспортом dart:io:
dart compile exe bin/fletch_demo.dart -o fletch_server ./fletch_server
На Linux получаем исполняемый файл с машинным кодом и необходимым Dart runtime. Отдельно устанавливать Dart SDK на сервер ради его запуска не требуется; совместимость ОС, архитектуры и системных библиотек остаётся условием. Сборщик мусора при AOT никуда не исчезает.
И ещё одна поправка к популярному обещанию: AOT не гарантирует, что программа окажется быстрее прогретого JIT во всех сценариях. Документация Dart прямо допускает более высокую пиковую производительность JIT-кода при подходящем профиле исполнения.
Если понадобятся несколько ядер, можно запускать несколько изолятов с отдельным экземпляром приложения в каждом и shared: true при привязке к одному адресу и порту. Пример есть в репозитории Fletch. Общие сессии, лимиты и данные при этом потребуют согласованного внешнего хранилища: обычные Dart-объекты, при этом, не становятся общей изменяемой памятью нескольких изолятов.
А теперь про скорость — с условиями рядом с цифрами
В changelog 2.3.0 автор указывает 47 104 запроса в секунду на Apple M-series и отставание от чистого dart:io в пределах 1,5%. Это результат, заявленный автором для его теста.
Есть и опубликованные результаты другого проекта — Dartmark. В наборе от 18 июня 2026 года, при concurrency = 8, получилась следующая картина:
Реализация |
Версия в тесте |
Запросов в секунду |
|---|---|---|
Чистый |
Стандартная библиотека |
17 035,82 |
Fletch |
2.3.0 |
16 590,55 |
Netto |
0.1.5 |
15 946,08 |
Serinus |
2.1.11 |
12 397,27 |
Dart Frog |
1.2.6 |
9 984,60 |
Shelf |
1.4.2 |
9 888,70 |
Relic |
1.2.0 |
9 378,0 |
В этом конкретном сравнении Fletch уступил чистому dart:io примерно 2,61% по пропускной способности. Это хороший результат для фреймворка с маршрутизацией и обработкой JSON.
Откуда небольшие накладные расходы? В коде видны вполне приземлённые решения: radix-маршрутизатор, отложенное создание части объектов, общий JsonUtf8Encoder, специальный путь для маршрутов без middleware и сокращение лишних асинхронных обёрток. Для необязательных параметров варианты маршрута формируются при регистрации.
Но аккуратность нужна и здесь. Например, «ленивые сессии» не означают, что любой запрос без обращения к req.session совсем не трогает хранилище.
Сессии и эксплуатация: несколько настроек, которые действительно важны
Fletch поддерживает подпись идентификатора сессии через HMAC-SHA256. Но Fletch() без sessionSecret создаёт приложение с неподписанными сессиями. Конструктор предупреждает об этом и продолжает работу.
Для приложения, использующего сессии, замените создание app на конфигурацию такого вида:
final secret = Platform.environment['SESSION_SECRET']; if (secret == null || secret.length < 32) { throw StateError('Задайте SESSION_SECRET длиной не менее 32 символов'); } final app = Fletch( sessionSecret: secret, secureCookies: true, debug: false, maxBodySize: 1024 * 1024, maxFileSize: 1024 * 1024, );
Длина — только техническое требование конструктора. Значение должно быть случайным секретом. Для локальной проверки cookie по HTTP используется secureCookies: false; приведённая конфигурация рассчитана на HTTPS.
Cookie сессии по умолчанию получает HttpOnly и SameSite=Lax; подпись защищает идентификатор от подделки, но не шифрует его. После успешной проверки учётных данных следует вызывать await req.session.regenerate(), чтобы сменить идентификатор сессии. Это, так скажем , дополнение к авторизации.
Стандартный MemorySessionStore хранит данные в памяти процесса. После перезапуска они исчезают, а между экземплярами приложения автоматически не разделяются. У него есть срок жизни записей и предел в 10 000 сессий по умолчанию, однако предел количества записей не равен пределу памяти в байтах. Для общего постоянного хранилища предусмотрен интерфейс SessionStore, но готового встроенного Redis-адаптера в пакете нет.
Для TLS есть listenSecure(), для подключения к заранее созданному HttpServer — serveWith(), для завершения работы с ожиданием активных запросов — app.close(). Вызов последнего нужно связать с жизненным циклом своего процесса. По умолчанию ожидание ограничено 30 секундами.
Таймаут запроса тоже имеет границу возможностей: Fletch использует Future.timeout. Клиенту можно вернуть ошибку по истечении времени, но уже запущенная асинхронная операция продолжит выполняться. Отмена обращения к базе или внешнему сервису должна поддерживаться соответствующим кодом. Такое поведение проверено отдельным тестом.
Где молодой проект ещё требует внимания
Пользоваться Fletch приятно, пока не начинаешь приписывать ему больше гарантий, чем даёт реализация.
Документация местами отстаёт. Кроме имени аргумента SSE и устаревшего доступа к DI, в README встречается утверждение о зависимостях только от GetIt. В pubspec.yaml версии 2.3.0 на самом деле перечислены 11 прямых runtime-зависимостей.
Загрузка тела запроса буферизуется. При обращении к стандартным парсерам тело сначала собирается в памяти. Значения по умолчанию — 10 MiB для всего тела и 100 MiB для отдельного файла, поэтому большой multipart-запрос раньше упрётся в общий лимит. Возможность потоковой отправки ответа эту особенность загрузки не меняет.
Пользовательский обработчик ошибок имеет ловушку. В проверенной версии простой res.json(...) внутри setErrorHandler() не помечает ответ отправленным. Фреймворк затем может заменить его стандартным ответом. Для собственного формата нужно учесть это поведение; явный await res.send(req.httpRequest.response) завершает отправку. В примерах статьи используются стандартные HttpError и ValidationError, чтобы не зависеть от этой особенности.
У разработки есть свои условия. В пакете присутствуют hotReload() и reassemble() для взаимодействия с VM service и обновления маршрутов. Но показанная команда dart run сама по себе не является файловым наблюдателем с автоматическим hot reload. Для этих возможностей нужна соответствующая настройка инструментов разработки.
Общий Dart-код нужно выбирать осмысленно. Независимые модели и утилиты подходят для совместного пакета. Серверные секреты, привилегированную логику и зависимости от dart:io переносить в браузерный клиент вместе с ними не следует.
Кому Fletch стоит попробовать
В первую очередь — разработчику, который уверенно пользуется Dart и хочет самостоятельно собрать HTTP API: для мобильного приложения, внутреннего сервиса, webhook-обработчика или панели управления.
Особенно хорошо идея выглядит для команды, которой нравится явная регистрация маршрутов и возможность выбирать собственные библиотеки для данных. Порог знакомства небольшой, а путь от запроса до обработчика легко проследить по коду.
Если же проекту необходимы готовая ORM, генерация клиентского SDK, админка и единый набор архитектурных соглашений, их наличие нужно оценивать отдельно.
Мне здесь интересна сама практическая возможность: написать клиент и HTTP-сервис на знакомом языке, разделить подходящие модели, собрать сервер в исполняемый файл и сохранить достаточно простой код приложения.
Fletch даёт для этого рабочую основу. Начать можно с одного маршрута, а решение о большом проекте принять после проверки тех сценариев, на которых этот проект будет жить.
Safkagosu
Спасибо за подробную и полезную статью!