Недавно столкнулся с необходимостью написать REST API сервер на Dart. Оставим за рамками этой статьи почему и зачем это было надо, но первое с чем я столкнулся - выбор библиотек. Так уж сложилось, что я привык писать на NodeJS используя KoaJS в качестве веб сервера. Простая и удобная библиотека с кучей расширений для любой необходимости. А вот Dart в этом плане несколько подкачал. На момент поисков из "живых" пакетов на pub.dev был только shelf. Что-то отдаленно похожее, но по факту жутко неудобное. Неделю промучившись с оным, понял, надо писать свое, с блэкджеком... что-нибудь в стиле того же KoaJS
Знакомство
Знакомьтесь - Dia - легковесный и простой http сервер на Dart. Основная идея проекта: контекст http-запроса, который проходит очередь middleware которые его читают и меняют при необходимости.
Второй основополагающий принцип - минимализм. Только самое необходимое. Это не фреймворк, а именно пакет. Для любого расширяющего функционала - отдельный пакет. Это позволяет сократить размер кодовой базы проекта подключив только необходимые пакеты.
Собственно поэтому сам Dia почти ничего не умеет. Только создавать и прокидывать по очереди middleware контекст. Весь остальной функционал который необходим (например мне в моих проектах), реализован в отдельных пакетах которые мы рассмотрим чуть позже.
Практик
А сейчас приступим к практике. Устанавливается все стандартно и просто, добавлением в pubspec.yaml соответствующих строк:
dependencies:
dia: ^0.0.7
Используется тоже просто. Вот минимальный пример:
import 'package:dia/dia.dart';
main() {
/// Create instance of Dia
final app = App();
/// Add middleware
app.use((ctx, next) async {
/// write response on all query
ctx.body = 'Hello world!';
});
/// Listen localhost:8080
app
.listen('localhost', 8080)
.then((info) => print('Server started on http://localhost:8080'));
}
Контекст
Кто знаком с KoaJS поймет практически с лету. Для остальных поясню основные моменты. app.use - добавляет в очередь middleware. Это по сути, асинхронная функция, принимающая в качестве аргументов контекст и ссылку на следующую middleware. Контекст представляет собой класс предоставляющий быстрые методы доступа к полям ответа (код ответа, тело, заголовки) и дополнительные методы типа throwError - который позволяет сразу отправить HTTP ошибку в качестве ответа.
Контекст можно расширить своими полями и методами. Например добавить в него поле содержащее данные об авторизованном пользователе:
class MyContext extends Context{
User? user;
MyContext(HttpRequest request): super(request);
}
main() {
/// Create instance of Dia
final app = App<MyContext>();
app.use((ctx, next) async {
ctx.user = new User('test');
await next();
});
app.use((ctx, next) async {
if(ctx.user==null){
ctx.trowError(401);
}
});
/// Add middleware
app.use((ctx, next) async {
/// write response on all query
ctx.body = 'Hello world!';
});
/// Listen localhost:8080
app
.listen('localhost', 8080)
.then((info) => print('Server started on http://localhost:8080'));
}
Next
next - ссылка на следующее middleware в очереди. Когда middleware не возвращает окончательный результат, а только изменяет контекст, ему часто требуется дождаться завершения следующего middleware. Это необходимо, например, чтобы добавить логирование или обработку ошибок:
app.use((ctx,next) async {
final start = DateTime.now();
await next();
final diff = DateTime.now().difference(start).inMicroseconds;
print('${ctx.request.method} ${ctx.request.uri.path} $diff ms')
});
Готовые middleware
Как я уже говорил, весь дополнительный функционал должен быть реализован в отдельных пакетах. Некоторые из них уже опубликованы:
dia_cors - middleware для добавления CORS заголовков
dia_static - отдает файлы на скачивание из заданной папки. Может использоваться как сервер статики
dia_router - позволяет задать middleware для определенных url и http методов. Самое то для реализации REST API
dia_body - разбирает http запрос и возвращает из него переданные параметры и загруженные файлы.
Рассмотрим два последних пакета более подробно, ибо с ними не все так просто.
Роутер
Первый из них - dia_router. Для его применения необходимо использовать контекст с миксином Routing.
class ContextWithRouting extends Context with Routing {
ContextWithRouting(HttpRequest request) : super(request);
}
void main() {
/// create Dia app with Routing mixin on Context
final app = App<ContextWithRouting>();
/// create router
final router = Router<ContextWithRouting>('/route');
/// add handler to GET request
router.get('/data/:id', (ctx, next) async {
ctx.body = '${ctx.params}';
});
app.use(router.middleware);
/// start server
app
.listen('localhost', 8080)
.then((_) => print('Started on http://localhost:8080'));
}
Если запустить этот код и открыть в браузере ссылку http://localhost:8080/route/data/12 то мы увидим{id: 12}.
Т.е мы не только задали специальный обработчик для фиксированного URL но и выдернули из него параметр с помощью регулярки. Кто знаком с npm пакетом koa-router оценит это удобство!
Парсер
Следующий пакет - dia_body. В запросе данные передают не только в пути но и еще кучей извращенных методов. Например пихают в body голый json или шлют form-data, а некоторые, так вообще передают данные в x-www-form-urlencoded. Мало того, есть еще и те кто шлет файлы как multipart/form-data! Вот, чтобы это все обработать нам и понадобится этот пакет.
Как вы наверное уже догадались, тут нам тоже необходим расширенный контекст с миксином ParsedBody:
class ContextWithBody extends Context with ParsedBody {
ContextWithBody(HttpRequest request) : super(request);
}
void main() {
final app = App<ContextWithBody>();
app.use(body());
app.use((ctx, next) async {
ctx.body = '''
query=${ctx.query}
parsed=${ctx.parsed}
files=${ctx.files}
''';
});
/// Start server listen on localhost:8080
app
.listen('localhost', 8080)
.then((info) => print('Server started on http://localhost:8080'));
}
В результате мы увидим:
ctx.query - параметры из URL вида ?param=value в Map<String,String>
ctx.parsed - параметры из тела запроса, будь то json, form-data или x-www-form-urlencoded в Map<String,dynamic>
ctx.files - загруженные файлы в Map<String,List<UploadedFile>> где String - имя параметра, UploadedFile - класс содержащий filename и File с загруженным файлом.
По дефолту, файлы загружаются во временную системную директорию, но это можно изменить используя необязательный именованный параметр uploadDirectory
QA
А как быть когда надо использовать оба эти пакета вместе? Да еще свои параметры в контекст добавить? Нет ничего проще! Именно для этого в dart и существуют миксины:
class CustomContext extends Context with Routing, ParsedBody {
User? user;
ContextWithBody(HttpRequest request) : super(request);
}
Ну а если я хочу запустить сервер в режиме SSL? Тоже все просто! Дело в том, что "под капотом" Dia использует обычный HttpServer из dart:io, так что Dia автоматически поддерживает все что поддерживает он. Например ctx.request из контекста - HttpRequest из dart:io. Так что можете использовать это при написании своих middleware. А вот так запускается сервер в режиме https:
const serverKey = 'cert/key.pem';
const certificateChain = 'cert/chain.pem';
final serverContext = SecurityContext();
serverContext
.useCertificateChainBytes(await File(certificateChain).readAsBytes());
serverContext.usePrivateKey(serverKey, password: 'password');
final server = await app.listen(
'localhost', 8444,
securityContext: serverContext);
Итоги
Dia пакет новый и обкатан пока только на одном "боевом" проекте. Вероятно в нем есть баги и недоработки. Например, в коде надо навести порядок с документированием API, да еще много чего надо бы сделать. Поэтому и версия у проекта пока еще не релизная. Буду рад любой обратной связи и помощи.
Проект опубликован на GitHub под MIT лицензией. Жду ваших ишью и пуллреквестов!
usetester
На первый взгляд весьма похоже на github.com/rknell/alfred
Есть какие-то принципиальные отличия?
Cobalt Автор
Есть и довольно существенные. Alfred позиционирует себя как фремверк пропогандирующий позицию: чем меньше зависимостей, тем лучше. Dia же занимает кординально противоположенную позицию: не замусориваем проект лишним кодом. Поэтому Alfred включает в себя все возможные middleware а Dia выносит их в пакеты
Скажу больше, в Dart уже было несколько фремверков типа Angel — почему-то все они со временем умирают. Может из-за сложности поддержки? Гораздо проще обновить либу содержащую меньше кода, или миддлвару перевести на нулсейфети самому чем весь фремверк переписывать.