From Flutter to Backend: How to Build Production-Grade REST APIs with Dart and Dart Frog
TL;DR · AI 摘要
Dart Frog 是一个基于 Shelf 的轻量级后端框架,采用文件系统路由模型,适合 Flutter 工程师构建 REST API。
核心要点
- Dart Frog 采用文件系统路由模型,无需手动配置路由器。
- Dart Frog 支持热重载、生产构建和 Docker 生成。
- 文章演示了如何使用 Dart Frog 构建用户和资料管理的 REST API,并连接 PostgreSQL 数据库。
结构提纲
按章节快速跳转。
思维导图
用一张图看清主题之间的关系。
查看大纲文本(无障碍 / 无 JS 友好)
- Dart Frog 框架
- 核心特性
- 文件系统路由模型
- 支持热重载和 Docker 生成
- 与 Shelf 和 Serverpod 的对比
- 使用场景
- 构建 REST API
- 连接 PostgreSQL
- 部署到 Fly.io
金句 / Highlights
值得收藏与分享的关键句。
Dart Frog 采用文件系统路由模型,无需手动配置路由器。
Dart Frog 支持热重载、生产构建和 Docker 生成。
文章演示了如何使用 Dart Frog 构建用户和资料管理的 REST API,并连接 PostgreSQL 数据库。
从 Flutter 到后端:如何使用 Dart 和 Dart Frog 构建生产级 REST API
2026 年 6 月 12 日
/
#dart_frog
Oluwaseyi Fatunmole
Dart 后端框架存在于一个光谱中。在最基础的一端是 Shelf,它提供原始的原语和完全的控制权。你必须自己连接所有内容。在最复杂的一端是 Serverpod。它是一个完整的框架,带有代码生成和有偏见的约定。框架为你做出大多数结构上的决策。
Dart Frog 位于中间,对许多 Flutter 工程师来说,它是最自然的选择。
Dart Frog 是一个基于 Shelf 构建的快速、极简主义的后端框架,最初由 Very Good Ventures 创建,现在由独立团队维护。它采用了 Next.js 和 Remix 流行的基于文件的路由模型,将其应用于 Dart,并用一个干净的 CLI 进行封装,该 CLI 可以处理开发服务器、热重载、生产构建和 Docker 生成,开箱即用。
你只需在 routes/ 目录中编写一个 Dart 文件,导出一个 onRequest 函数,Dart Frog 就会自动处理路由。不需要路由器配置,不需要处理程序注册,也不需要挂载。文件系统就是路由器。
在本文中,我们将使用 Dart Frog 构建一个用户和资料管理的 REST API(与上面链接文章中构建的相同),将其连接到 PostgreSQL,添加 JWT 认证,并部署到 Fly.io。
到文章结束时,你将深入了解 Dart Frog 的路由模型,并清楚地了解它与 Shelf 和 Serverpod 相比所处的位置。
目录
- 前提条件
- Dart Frog 与 Shelf 和 Serverpod 的区别
- 安装 Dart Frog
- 创建项目
- 理解项目结构
- Dart Frog 核心概念 文件基于路由 RequestContext 中间件和依赖注入 动态路由
- 设置数据库 使用 Docker Compose 配置 PostgreSQL 环境配置 数据库连接管理 迁移
- 定义模型
- 构建仓库 用户仓库 资料仓库
- 认证服务
- 中间件 数据库中间件 认证中间件 错误中间件
- 构建路由 认证路由 用户路由 资料路由
- 连接中间件管道
- 测试 API
- 部署 生产构建 部署到 Fly.io
- 结论
前提条件
在开始之前,你应该具备以下条件:
- 熟悉 Dart 和 Flutter 开发
- 了解 REST API 的概念、端点、HTTP 方法和状态码
- 安装并运行 Docker Desktop
- 用于部署的 Fly.io 账户
Dart Frog 与 Shelf 和 Serverpod 的区别
了解 Dart Frog 在与其他两个框架的关系中所处的位置,有助于你为每个项目做出正确的选择。
Shelf 给你一个 Router,你需要手动挂载处理程序。你的文件夹结构与 URL 结构没有关系。你决定什么放在哪里。
Serverpod 从端点类名和方法名生成你的路由。你定义一个类,运行一个生成器,URL 就会自动生成。
Dart Frog 将你的文件系统直接映射到你的 URL 结构。routes/users/index.dart 中的文件成为 /users 端点。routes/users/[id].dart 中的文件成为 /users/:id。不需要配置,不需要注册,也不需要生成步骤。文件就是路由。
该模型对那些使用过 Next.js 或任何现代 Web 框架的 Flutter 工程师来说会立即显得直观。在团队协作中,它也更容易导航。你只需看一下文件夹结构,就能立刻知道有哪些端点。
另一个关键区别是 RequestContext。在 Shelf 中,它会将原始的 Request 直接传递给处理程序,而 Dart Frog 则将其封装在 RequestContext 中,该对象不仅包含请求本身,还包含中间件注入的任何值。这是 Dart Frog 的依赖注入机制,而且非常优雅。
安装 Dart Frog
安装 Dart Frog CLI:
dart pub global activate dart_frog_cli验证安装:
dart_frog --version创建项目
dart_frog create user_profile_api
cd user_profile_api使用热重载启动开发服务器:
dart_frog dev访问 http://localhost:8080,你会看到默认的欢迎响应。开发服务器会监视文件变化并自动重新加载。在构建过程中无需重启。
理解项目结构
user_profile_api/
routes/
index.dart ← GET /
pubspec.yaml
analysis_options.yaml这就是初始的完整结构。简洁而精炼。我们添加的所有内容都将从这里扩展。
构建完 API 后,完整的结构将如下所示:
user_profile_api/
routes/
_middleware.dart ← 全局中间件管道
index.dart ← GET /
auth/
login.dart ← POST /auth/login
register.dart ← POST /auth/register
users/
index.dart ← GET /users
[id].dart ← GET, PUT, DELETE /users/:id
[id]/
profile.dart ← GET, POST, PUT /users/:id/profile
lib/
config/
database.dart
env.dart
models/
user.dart
profile.dart
repositories/
user_repository.dart
profile_repository.dart
services/
auth_service.dart
middleware/
auth_middleware.dart
error_middleware.dart
pubspec.yamlroutes/ 文件夹是 Dart Frog 项目的核心。lib/ 文件夹包含所有路由导入的共享逻辑。这种分离是清晰且有意为之的:路由相关的内容放在 routes/ 中,而业务逻辑则放在 lib/ 中。
Dart Frog 核心概念
基于文件的路由
routes/ 目录中的每个 .dart 文件都是一个路由。文件路径决定了 URL 路径:
文件路径
URL
routes/index.dart
/
routes/users/index.dart
/users
routes/users/[id].dart
/users/:id
routes/auth/login.dart
/auth/login
routes/users/[id]/profile.dart
/users/:id/profile
每个路由文件必须导出一个 onRequest 函数:
import 'package:dart_frog/dart_frog.dart';
Future<Response> onRequest(RequestContext context) async {
return Response.json(body: {'message': 'Hello from Dart Frog'});
}这就是全部的约定。一个函数,一个文件,一个路由。当你运行 dart_frog dev 或 dart_frog build 时,Dart Frog 会自动生成内部的路由粘合代码。
RequestContext
RequestContext 是传递给每个路由处理程序和中间件的对象。它不仅仅是 HTTP 请求:它是一个容器,包含了请求本身以及中间件注入的任何值:
Future<Response> onRequest(RequestContext context) async {
// 原始的 HTTP 请求
final request = context.request;
// HTTP 方法
print(request.method); // GET, POST 等// 路径参数(用于动态路由,如 [id].dart)
final id = context.request.uri.pathSegments.last;
// 查询参数
final page = request.uri.queryParameters['page'];
// 请求体
final body = await request.json() as Map<String, dynamic>;
// 由中间件注入的值
final db = context.read<DatabaseConnection>();
final currentUser = context.read<AuthenticatedUser>();
return Response.json(body: {'ok': true});
}context.read() 是依赖注入机制。中间件提供值,而路由使用这些值。这使路由保持整洁且易于测试:路由处理程序不需要知道数据库连接是如何创建的,它只需要从上下文中读取即可。
中间件和依赖注入
任何路由文件夹中的 _middleware.dart 文件会将中间件应用于该文件夹及其子文件夹中的所有路由。位于 routes/ 根目录的 _middleware.dart 文件会全局应用。
Dart Frog 中的中间件使用提供者模式将值注入到上下文中:
import 'package:dart_frog/dart_frog.dart';
Handler middleware(Handler handler) {
return handler.use(
provider<DatabaseConnection>(
(context) => DatabaseConnection.instance,
),
);
}同一文件夹或任何子文件夹中的任何路由都可以通过 context.read() 获取连接。不需要全局单例,也不需要手动传递。上下文会携带它。
中间件函数还可以在请求到达路由处理程序之前拦截请求,这使它们非常适合用于身份验证:
Handler middleware(Handler handler) {
return (context) async {
final authHeader = context.request.headers['authorization'];
if (authHeader == null) {
return Response.json(
statusCode: 401,
body: {'error': 'Authorization required'},
);
}
// 验证令牌并注入用户
final user = verifyToken(authHeader);
return handler(context.provide<AuthenticatedUser>(() => user));
};
}动态路由
名为 [id].dart 的文件匹配任何单个路径段。在处理程序中,从 URL 提取参数:
Future<Response> onRequest(RequestContext context, String id) async {
// id 会自动作为参数传递给动态路由
return Response.json(body: {'userId': id});
}Dart Frog 会将动态路由参数作为额外参数传递给 onRequest。这比手动从 URL 中解析它们更整洁。
设置数据库
用于 PostgreSQL 的 Docker Compose
在项目根目录中创建 docker-compose.yml:
version: '3.8'
services:
postgres:
image: postgres:16-alpine
container_name: user_profile_db
environment:
POSTGRES_DB: user_profile_api
POSTGRES_USER: dart_user
POSTGRES_PASSWORD: dart_password
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U dart_user -d user_profile_api"]
interval: 5s
timeout: 5s
retries: 5
volumes:
postgres_data:启动数据库:
docker compose up -d环境配置
将依赖项添加到 pubspec.yaml:
dependencies:
dart_frog: ^1.4.0
dart_frog_auth: ^0.1.0
postgres: ^3.3.0
dart_jsonwebtoken: ^2.12.0
bcrypt: ^1.1.3
dotenv: ^4.1.0
dev_dependencies:
dart_frog_cli: ^1.2.0
test: ^1.24.0
dart_frog_test: ^0.1.0运行 dart pub get。
创建 .env:
DB_HOST=localhost
DB_PORT=5432
DB_NAME=user_profile_api
DB_USER=dart_user
DB_PASSWORD=dart_password
JWT_SECRET=your_super_secret_key_change_this_in_production
JWT_EXPIRY_HOURS=24
PORT=8080创建 lib/config/env.dart:
import 'package:dotenv/dotenv.dart';
class Env {
static late final DotEnv _env;
static void load() {
_env = DotEnv(includePlatformEnvironment: true)..load();
}
static String get dbHost => _env['DB_HOST'] ?? 'localhost';
static int get dbPort => int.parse(_env['DB_PORT'] ?? '5432');
static String get dbName => _env['DB_NAME'] ?? 'user_profile_api';
static String get dbUser => _env['DB_USER'] ?? 'dart_user';
static String get dbPassword => _env['DB_PASSWORD'] ?? '';
static String get jwtSecret => _env['JWT_SECRET'] ?? '';
static int get jwtExpiryHours =>
int.parse(_env['JWT_EXPIRY_HOURS'] ?? '24');
}数据库连接管理器
创建 lib/config/database.dart:
import 'package:postgres/postgres.dart';
import 'env.dart';
class Database {
static Connection? _connection;
static Future<Connection> get connection async {
if (_connection != null) return _connection!;
_connection = await Connection.open(
Endpoint(
host: Env.dbHost,
port: Env.dbPort,
database: Env.dbName,
username: Env.dbUser,
password: Env.dbPassword,
),
settings: const ConnectionSettings(sslMode: SslMode.disable),
);
print('Database connected');
return _connection!;
}
static Future<void> runMigrations() async {
final conn = await connection;
await conn.execute('''
CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email VARCHAR(255) UNIQUE NOT NULL,
password_hash VARCHAR(255) NOT NULL,
first_name VARCHAR(100) NOT NULL,
last_name VARCHAR(100) NOT NULL,
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
CREATE TABLE IF NOT EXISTS profiles (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
bio TEXT,
avatar_url VARCHAR(500),
phone VARCHAR(20),
location VARCHAR(255),
website VARCHAR(500),
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
UNIQUE(user_id)
);
CREATE INDEX IF NOT EXISTS idx_profiles_user_id ON profiles(user_id);
''');
print('Migrations applied');
}
}数据库迁移
Dart Frog 项目在运行 dart_frog build 时会生成一个 main.dart 入口文件。对于开发服务器,最好从项目入口点运行迁移。在项目根目录中创建 main.dart:
import 'dart:io';
import 'package:dart_frog/dart_frog.dart';
import 'lib/config/database.dart';
import 'lib/config/env.dart';
Future<HttpServer> run(Handler handler, InternetAddress ip, int port) async {
Env.load();
await Database.runMigrations();
return serve(handler, ip, port);
}这个 run 函数是 Dart Frog 的服务器生命周期钩子。它在服务器开始接受请求之前运行,为我们提供了加载环境变量和运行迁移的正确位置。
定义模型
在数据库层就绪后,我们需要 Dart 类来表示进出数据库的数据。
User 模型映射到 users 表,并处理数据库行与 Dart 对象之间的转换。Profile 模型对 profiles 表执行相同的操作。这两个模型遵循相同的模式:一个用于从数据库中读取的工厂构造函数,以及一个用于将数据发送回客户端的 toJson 方法。
请注意,User 模型中的 toJson 方法故意排除了密码哈希。在 API 响应中,绝不要返回凭证数据。
创建 lib/models/user.dart:
class User {
const User({
required this.id,
required this.email,
required this.passwordHash,
required this.firstName,
required this.lastName,
required this.isActive,
required this.createdAt,
required this.updatedAt,
});
final String id;
final String email;
final String passwordHash;
final String firstName;
final String lastName;
final bool isActive;
final DateTime createdAt;
final DateTime updatedAt;
factory User.fromRow(Map<String, dynamic> row) => User(
id: row['id'] as String,
email: row['email'] as String,
passwordHash: row['password_hash'] as String,
firstName: row['first_name'] as String,
lastName: row['last_name'] as String,
isActive: row['is_active'] as bool,
createdAt: row['created_at'] as DateTime,
updatedAt: row['updated_at'] as DateTime,
);
Map<String, dynamic> toJson() => {
'id': id,
'email': email,
'firstName': firstName,
'lastName': lastName,
'isActive': isActive,
'createdAt': createdAt.toIso8601String(),
'updatedAt': updatedAt.toIso8601String(),
};
}创建 lib/models/profile.dart:
class Profile {
const Profile({
required this.id,
required this.userId,
this.bio,
this.avatarUrl,
this.phone,
this.location,
this.website,
required this.createdAt,
required this.updatedAt,
});
final String id;
final String userId;
final String? bio;
final String? avatarUrl;
final String? phone;
final String? location;
final String? website;
final DateTime createdAt;
final DateTime updatedAt;
factory Profile.fromRow(Map<String, dynamic> row) => Profile(
id: row['id'] as String,
userId: row['user_id'] as String,
bio: row['bio'] as String?,
avatarUrl: row['avatar_url'] as String?,
phone: row['phone'] as String?,
location: row['location'] as String?,
website: row['website'] as String?,
createdAt: row['created_at'] as DateTime,
updatedAt: row['updated_at'] as DateTime,
);
Map<String, dynamic> toJson() => {
'id': id,
'userId': userId,
'bio': bio,
'avatarUrl': avatarUrl,
'phone': phone,
'location': location,
'website': website,
'createdAt': createdAt.toIso8601String(),
'updatedAt': updatedAt.toIso8601String(),
};
}构建仓库
仓库是应用程序与数据库之间的唯一接触点。我们不会直接在路由处理程序中编写 SQL,而是在此处集中所有数据库操作。这样可以保持处理程序的整洁,并使数据访问逻辑易于查找、维护和独立测试。
用户仓库(UserRepository)处理所有对用户表的操作。档案仓库(ProfileRepository)则对档案执行相同的操作,使用 userId 作为其主要查找键,因为档案总是以特定用户为上下文进行访问。
用户仓库
创建 lib/repositories/user_repository.dart:
import 'package:postgres/postgres.dart';
import '../config/database.dart';
import '../models/user.dart';
class UserRepository {
Future<Connection> get _conn => Database.connection;
Future<List<User>> findAll() async {
final conn = await _conn;
final results = await conn.execute(
'SELECT * FROM users WHERE is_active = TRUE ORDER BY created_at DESC',
);
return results.map((r) => User.fromRow(r.toColumnMap())).toList();
}
Future<User?> findById(String id) async {
final conn = await _conn;
final results = await conn.execute(
Sql.named('SELECT * FROM users WHERE id = @id AND is_active = TRUE'),
parameters: {'id': id},
);
if (results.isEmpty) return null;
return User.fromRow(results.first.toColumnMap());
}
Future<User?> findByEmail(String email) async {
final conn = await _conn;
final results = await conn.execute(
Sql.named('SELECT * FROM users WHERE email = @email'),
parameters: {'email': email},
);
if (results.isEmpty) return null;
return User.fromRow(results.first.toColumnMap());
}
Future<User> create({
required String email,
required String passwordHash,
required String firstName,
required String lastName,
}) async {
final conn = await _conn;
final results = await conn.execute(
Sql.named('''
INSERT INTO users (email, password_hash, first_name, last_name)
VALUES (@email, @passwordHash, @firstName, @lastName)
RETURNING *
'''),
parameters: {
'email': email,
'passwordHash': passwordHash,
'firstName': firstName,
'lastName': lastName,
},
);
return User.fromRow(results.first.toColumnMap());
}
Future<User?> update({
required String id,
String? firstName,
String? lastName,
}) async {
final conn = await _conn;
final results = await conn.execute(
Sql.named('''
UPDATE users
SET
first_name = COALESCE(@firstName, first_name),
last_name = COALESCE(@lastName, last_name),
updated_at = NOW()
WHERE id = @id AND is_active = TRUE
RETURNING *
'''),
parameters: {'id': id, 'firstName': firstName, 'lastName': lastName},
);
if (results.isEmpty) return null;
return User.fromRow(results.first.toColumnMap());
}
Future<bool> delete(String id) async {
final conn = await _conn;
final results = await conn.execute(
Sql.named('''
UPDATE users SET is_active = FALSE, updated_at = NOW()
WHERE id = @id AND is_active = TRUE
RETURNING id
'''),
parameters: {'id': id},
);
return results.isNotEmpty;
}
}档案仓库
创建 lib/repositories/profile_repository.dart:
import 'package:postgres/postgres.dart';
import '../config/database.dart';
import '../models/profile.dart';
class ProfileRepository {
Future<Connection> get _conn => Database.connection;Future<Profile?> findByUserId(String userId) async {
final conn = await _conn;
final results = await conn.execute(
Sql.named('SELECT * FROM profiles WHERE user_id = @userId'),
parameters: {'userId': userId},
);
if (results.isEmpty) return null;
return Profile.fromRow(results.first.toColumnMap());
}
Future<Profile> create({
required String userId,
String? bio,
String? avatarUrl,
String? phone,
String? location,
String? website,
}) async {
final conn = await _conn;
final results = await conn.execute(
Sql.named('''
INSERT INTO profiles (user_id, bio, avatar_url, phone, location, website)
VALUES (@userId, @bio, @avatarUrl, @phone, @location, @website)
RETURNING *
'''),
parameters: {
'userId': userId,
'bio': bio,
'avatarUrl': avatarUrl,
'phone': phone,
'location': location,
'website': website,
},
);
return Profile.fromRow(results.first.toColumnMap());
}
Future<Profile?> update({
required String userId,
String? bio,
String? avatarUrl,
String? phone,
String? location,
String? website,
}) async {
final conn = await _conn;
final results = await conn.execute(
Sql.named('''
UPDATE profiles
SET
bio = COALESCE(@bio, bio),
avatar_url = COALESCE(@avatarUrl, avatar_url),
phone = COALESCE(@phone, phone),
location = COALESCE(@location, location),
website = COALESCE(@website, website),
updated_at = NOW()
WHERE user_id = @userId
RETURNING *
'''),
parameters: {
'userId': userId,
'bio': bio,
'avatarUrl': avatarUrl,
'phone': phone,
'location': location,
'website': website,
},
);
if (results.isEmpty) return null;
return Profile.fromRow(results.first.toColumnMap());
}认证服务
该项目中的认证由一个专门的 AuthService 处理,该服务位于 lib/services/ 中。它有一个明确的责任:处理认证所需的加密操作,包括在存储之前对密码进行哈希处理,在登录时验证密码,成功时生成签名的 JWT 令牌,并在受保护的请求中验证这些令牌。
将这种逻辑保留在服务中,而不是分散在路由处理程序中,意味着它可以通过中间件注入,并在应用程序的任何地方清晰地使用。
创建 lib/services/auth_service.dart:
import 'package:bcrypt/bcrypt.dart';
import 'package:dart_jsonwebtoken/dart_jsonwebtoken.dart';
import '../config/env.dart';
import '../models/user.dart';
class AuthService {
String hashPassword(String password) =>
BCrypt.hashpw(password, BCrypt.gensalt());
bool verifyPassword(String password, String hash) =>
BCrypt.checkpw(password, hash);
String generateToken(User user) {
final jwt = JWT({
'sub': user.id,
'email': user.email,
'iat': DateTime.now().millisecondsSinceEpoch ~/ 1000,
});
return jwt.sign(
SecretKey(Env.jwtSecret),
expiresIn: Duration(hours: Env.jwtExpiryHours),
);
}
JWT? verifyToken(String token) {
try {
return JWT.verify(token, SecretKey(Env.jwtSecret));
} catch (_) {
return null;
}
}
}中间件
中间件是 Dart Frog 依赖注入模型发挥关键作用的地方。我们不再在每个路由处理程序中实例化仓库和服务,而是在中间件中创建它们一次,并通过 RequestContext 使它们对下游的所有处理程序可用。
本节定义了三个中间件:注入仓库和认证服务的数据库中间件、验证 JWT 令牌并保护路由的认证中间件,以及捕获未处理异常并在整个 API 中返回一致错误响应的错误中间件。
数据库中间件
创建 lib/middleware/database_middleware.dart:
import 'package:dart_frog/dart_frog.dart';
import '../repositories/user_repository.dart';
import '../repositories/profile_repository.dart';
import '../services/auth_service.dart';
Middleware databaseMiddleware() {
return (handler) {
return handler
.use(provider<UserRepository>((_) => UserRepository()))
.use(provider<ProfileRepository>((_) => ProfileRepository()))
.use(provider<AuthService>((_) => AuthService()));
};
}此中间件将仓库和认证服务注入到每个请求上下文中。路由通过 context.read() 读取它们,而无需关心它们是如何创建的。
认证中间件
创建 lib/middleware/auth_middleware.dart:
import 'dart:convert';
import 'package:dart_frog/dart_frog.dart';
import '../services/auth_service.dart';
Middleware authMiddleware() {
return (handler) {
return (context) async {
final authHeader = context.request.headers['authorization'];
if (authHeader == null || !authHeader.startsWith('Bearer ')) {
return Response.json(
statusCode: 401,
body: {'error': 'Authorization header missing or malformed'},
);
}
final token = authHeader.substring(7);
final authService = context.read<AuthService>();
final jwt = authService.verifyToken(token);
if (jwt == null) {
return Response.json(
statusCode: 401,
body: {'error': 'Invalid or expired token'},
);
}
final userId = jwt.payload['sub'] as String;
final userEmail = jwt.payload['email'] as String;
return handler(
context.provide<Map<String, String>>(
() => {'userId': userId, 'userEmail': userEmail},
),
);
};
};
}错误中间件
创建 lib/middleware/error_middleware.dart:
import 'package:dart_frog/dart_frog.dart';
Middleware errorMiddleware() {
return (handler) {
return (context) async {
try {
return await handler(context);
} on FormatException catch (e) {
return Response.json(
statusCode: 400,
body: {'error': 'Invalid request body: ${e.message}'},
);
} catch (e, stackTrace) {
print('Unhandled error: $e\n$stackTrace');
return Response.json(
statusCode: 500,
body: {'error': 'An internal server error occurred'},
);
}
};
};
}构建路由
当模型、仓库、认证服务和中间件都已就绪后,我们现在可以构建路由处理程序。
在 Dart Frog 中,routes/ 文件夹中的每个文件都是一个独立的端点。路由不会直接管理依赖项。相反,它们读取中间件已注入到上下文中的内容,并调用适当的仓库或服务方法。
本节涵盖三组路由:用于注册和登录的认证路由、用于创建、读取、更新和删除操作的用户路由,以及嵌套在用户 ID 下的个人资料路由。
认证路由
创建 routes/auth/register.dart:
import 'package:dart_frog/dart_frog.dart';
import '../../lib/repositories/user_repository.dart';
import '../../lib/services/auth_service.dart';
Future<Response> onRequest(RequestContext context) async {
if (context.request.method != HttpMethod.post) {
return Response.json(statusCode: 405, body: {'error': 'Method not allowed'});
}
final body = await context.request.json() as Map<String, dynamic>;
final email = body['email'] as String?;
final password = body['password'] as String?;
final firstName = body['firstName'] as String?;
final lastName = body['lastName'] as String?;
if (email == null || password == null ||
firstName == null || lastName == null) {
return Response.json(
statusCode: 400,
body: {'error': 'email, password, firstName, and lastName are required'},
);
}
if (password.length < 8) {
return Response.json(
statusCode: 400,
body: {'error': 'Password must be at least 8 characters'},
);
}
final userRepo = context.read<UserRepository>();
final authService = context.read<AuthService>();
final existing = await userRepo.findByEmail(email);
if (existing != null) {
return Response.json(
statusCode: 409,
body: {'error': 'An account with this email already exists'},
);
}
final user = await userRepo.create(
email: email,
passwordHash: authService.hashPassword(password),
firstName: firstName,
lastName: lastName,
);
return Response.json(
statusCode: 201,
body: {
'user': user.toJson(),
'token': authService.generateToken(user),
},
);
}创建 routes/auth/login.dart:
import 'package:dart_frog/dart_frog.dart';
import '../../lib/repositories/user_repository.dart';
import '../../lib/services/auth_service.dart';
Future<Response> onRequest(RequestContext context) async {
if (context.request.method != HttpMethod.post) {
return Response.json(statusCode: 405, body: {'error': 'Method not allowed'});
}
final body = await context.request.json() as Map<String, dynamic>;
final email = body['email'] as String?;
final password = body['password'] as String?;
if (email == null || password == null) {
return Response.json(
statusCode: 400,
body: {'error': 'email and password are required'},
);
}
final userRepo = context.read<UserRepository>();
final authService = context.read<AuthService>();
final user = await userRepo.findByEmail(email);
if (user == null || !authService.verifyPassword(password, user.passwordHash)) {
return Response.json(
statusCode: 401,
body: {'error': 'Invalid email or password'},
);
}
return Response.json(
body: {
'user': user.toJson(),
'token': authService.generateToken(user),
},
);
}用户路由
创建 routes/users/index.dart:
import 'package:dart_frog/dart_frog.dart';
import '../../lib/repositories/user_repository.dart';
Future<Response> onRequest(RequestContext context) async {
if (context.request.method != HttpMethod.get) {
return Response.json(statusCode: 405, body: {'error': 'Method not allowed'});
}
final userRepo = context.read<UserRepository>();
final users = await userRepo.findAll();
Future<Response> _createProfile(
RequestContext context,
ProfileRepository repo,
String userId,
) async {
final body = await context.request.json() as Map<String, dynamic>;
final profile = await repo.create(
userId: userId,
bio: body['bio'] as String?,
website: body['website'] as String?,
);
return Response.json(body: profile.toJson());
}
Future<Response> _updateProfile(
RequestContext context,
ProfileRepository repo,
String userId,
) async {
final body = await context.request.json() as Map<String, dynamic>;
final profile = await repo.update(
id: body['id'] as String?,
userId: userId,
bio: body['bio'] as String?,
website: body['website'] as String?,
);
if (profile == null) {
return Response.json(statusCode: 404, body: {'error': 'Profile not found'});
}
return Response.json(body: profile.toJson());
}请注意,onRequest 接收 String id 作为第二个参数,Dart Frog 会自动将动态路径段传递给处理程序。对 context.request.method 的 switch 语句在单个文件中处理所有 HTTP 方法,这是 Dart Frog 对 CRUD 端点的标准做法。
个人资料路由
创建 routes/users/[id]/profile.dart:
import 'package:dart_frog/dart_frog.dart';
import '../../../lib/repositories/user_repository.dart';
import '../../../lib/repositories/profile_repository.dart';
Future<Response> onRequest(RequestContext context, String id) async {
final userRepo = context.read<UserRepository>();
final profileRepo = context.read<ProfileRepository>();
final user = await userRepo.findById(id);
if (user == null) {
return Response.json(statusCode: 404, body: {'error': 'User not found'});
}
switch (context.request.method) {
case HttpMethod.get:
return _getProfile(profileRepo, id);
case HttpMethod.post:
return _createProfile(context, profileRepo, id);
case HttpMethod.put:
return _updateProfile(context, profileRepo, id);
default:
return Response.json(
statusCode: 405,
body: {'error': 'Method not allowed'},
);
}
}
Future<Response> _getProfile(ProfileRepository repo, String userId) async {
final profile = await repo.findByUserId(userId);
if (profile == null) {
return Response.json(statusCode: 404, body: {'error': 'Profile not found'});
}
return Response.json(body: profile.toJson());
}Future<Response> _createProfile(
RequestContext context,
ProfileRepository repo,
String userId,
) async {
final existing = await repo.findByUserId(userId);
if (existing != null) {
return Response.json(
statusCode: 409,
body: {'error': 'Profile already exists for this user'},
);
}
final body = await context.request.json() as Map<String, dynamic>;
final profile = await repo.create(
userId: userId,
bio: body['bio'] as String?,
avatarUrl: body['avatarUrl'] as String?,
phone: body['phone'] as String?,
location: body['location'] as String?,
website: body['website'] as String?,
);
return Response.json(statusCode: 201, body: profile.toJson());
}
Future<Response> _updateProfile(
RequestContext context,
ProfileRepository repo,
String userId,
) async {
final body = await context.request.json() as Map<String, dynamic>;
final profile = await repo.update(
userId: userId,
bio: body['bio'] as String?,
avatarUrl: body['avatarUrl'] as String?,
phone: body['phone'] as String?,
location: body['location'] as String?,
website: body['website'] as String?,
);
if (profile == null) {
return Response.json(statusCode: 404, body: {'error': 'Profile not found'});
}
return Response.json(body: profile.toJson());
}连接中间件管道
路由和中间件已经编写完成,但它们尚未连接。在 Dart Frog 中,连接通过在 routes/ 文件夹中战略性地放置 _middleware.dart 文件来实现。
回顾一下,根目录下的 _middleware.dart 文件适用于项目中的每个路由。子文件夹中的 _middleware.dart 文件仅适用于该文件夹及其子文件夹中的路由。这使我们能够精确地控制中间件在哪些文件夹中运行,而无需任何手动注册或挂载。
创建 routes/_middleware.dart 以应用于每个路由的全局中间件:
import 'package:dart_frog/dart_frog.dart';
import '../lib/middleware/database_middleware.dart';
import '../lib/middleware/error_middleware.dart';
Handler middleware(Handler handler) {
return handler
.use(databaseMiddleware())
.use(errorMiddleware());
}创建 routes/users/_middleware.dart 以使用认证保护所有用户路由:
import 'package:dart_frog/dart_frog.dart';
import '../../lib/middleware/auth_middleware.dart';
Handler middleware(Handler handler) {
return handler.use(authMiddleware());
}这是 Dart Frog 模型中最优雅的部分之一。routes/users/_middleware.dart 文件会自动将认证应用于 routes/users/ 下的所有路由,包括 routes/users/index.dart、routes/users/[id].dart 和 routes/users/[id]/profile.dart。routes/auth/ 下的认证路由不受影响,因为它们位于 users/ 文件夹之外。
没有手动的中间件挂载,没有受保护路由的数组,也没有路由组的配置。文件夹结构完成了所有工作。
测试 API
随着服务器运行并连接所有路由,我们可以从端到端验证整个流程。启动开发服务器并按顺序运行每个端点:首先注册用户以获取令牌,然后在受保护的路由上使用该令牌。将下面命令中的 {userId} 替换为注册响应中返回的实际 ID。
启动开发服务器:
dart_frog dev
# 服务器现在正在运行于:http://localhost:8080注册用户:
curl http://localhost:8080/auth/register \
-X POST \
-H "Content-Type: application/json" \
-d '{
"email": "seyi@example.com",
"password": "securepassword",
"firstName": "Seyi",
"lastName": "Dev"
}'响应:
{
"user": {
"id": "uuid-here",
"email": "seyi@example.com",
"firstName": "Seyi",
"lastName": "Dev",
"isActive": true,
"createdAt": "2025-01-01T00:00:00.000Z",
"updatedAt": "2025-01-01T00:00:00.000Z"
},
"token": "eyJhbGci..."
}登录:
curl http://localhost:8080/auth/login \
-X POST \
-H "Content-Type: application/json" \
-d '{"email": "seyi@example.com", "password": "securepassword"}'获取所有用户:
curl http://localhost:8080/users \
-H "Authorization: Bearer eyJhbGci..."获取特定用户:
curl http://localhost:8080/users/{userId} \
-H "Authorization: Bearer eyJhbGci..."创建个人资料:
curl http://localhost:8080/users/{userId}/profile \
-X POST \
-H "Authorization: Bearer eyJhbGci..." \
-H "Content-Type: application/json" \
-d '{
"bio": "Flutter engineer turned backend developer",
"location": "Lagos, Nigeria",
"website": "https://example.com"
}'更新用户:
curl http://localhost:8080/users/{userId} \
-X PUT \
-H "Authorization: Bearer eyJhbGci..." \
-H "Content-Type: application/json" \
-d '{"firstName": "Oluwaseyi"}'删除用户:
curl http://localhost:8080/users/{userId} \
-X DELETE \
-H "Authorization: Bearer eyJhbGci..."部署
在本地完成所有测试后,最后一步是将 API 部署上线。Dart Frog 让这个过程变得简单:只需一条 CLI 命令即可生成一个适用于生产环境的 Dockerfile,之后我们将其部署到 Fly.io,应用程序将在容器化服务中运行,并与一个托管的 PostgreSQL 数据库一起运行。
生产构建
Dart Frog 通过以下命令生成一个适用于生产环境的 Docker 配置:
dart_frog build这将创建一个 build/ 目录,其中包含以下内容:
build/
bin/
server.dart ← 编译后的入口点
Dockerfile ← 适用于生产环境的 Dockerfile
pubspec.yaml
pubspec.lock生成的 Dockerfile 是一个多阶段构建,第一阶段将编译为原生二进制文件,第二阶段则在最小的 Debian 镜像中运行。你不需要自己编写这个文件。
部署到 Fly.io
步骤 1 — 认证:
fly auth login步骤 2 — 从 build 目录启动:
cd build
fly launchFly 会检测到 Dockerfile 并提示进行配置。在被询问时创建一个 PostgreSQL 数据库。
步骤 3 — 设置密钥:
fly secrets set JWT_SECRET="your_production_jwt_secret"
fly secrets set JWT_EXPIRY_HOURS="24"步骤 4 — 部署:
fly deploy步骤 5 — 验证:
curl https://your-app-name.fly.dev/auth/register \
-X POST \
-H "Content-Type: application/json" \
-d '{"email":"test@example.com","password":"password123","firstName":"Seyi","lastName":"Dev"}'结论
Dart Frog 正确地定位了自己:介于 Shelf 提供的原始控制和 Serverpod 提供的全面意见之间。它将 JavaScript 生态系统中已被证明的基于文件的路由模型干净地引入 Dart,而不会牺牲语言的优势。
路由模型是其最强大的功能。查看 routes/ 文件夹可以让你全面了解你的 API:存在哪些端点、它们是如何分组的,以及哪些中间件适用于哪些部分。这种透明性使代码库更易于导航、更容易上手,并且随着代码库的增长,也更容易理解。
RequestContext 和用于依赖注入的提供者模式设计得非常周到。中间件注入,路由消费,两者之间没有任何干扰。文件夹作用域的中间件尤其整洁,只需在正确的文件夹中放置一个 _middleware.dart 文件,就可以保护整个 API 的一部分。
对于正在构建需要服务于多种客户端类型的 API 的 Flutter 工程师来说,Dart Frog 在实践中找到了一个理想的平衡点,这一点 Shelf 和 Serverpod 都无法如此自然地实现。它能够符合标准的 REST 约定,并与现有的前端基础设施无缝集成。
如今,Dart 真正意义上成为了一种全栈语言。相同的团队、相同的语言、相同的规范——从 Flutter 应用到驱动它的服务器。
快乐编码!
我是移动工程负责人和高级软件工程师,拥有 7 年以上构建和领导可扩展、企业级跨平台移动应用程序交付的经验。我专注于端到端系统设计,从架构到部署,特别关注性能、可扩展性和可维护的代码。我曾领导工程团队,提升交付效率、代码质量和协作,同时指导开发人员在技术和职业上成长。我的核心技术栈包括 Flutter、Dart、.NET 和 .NET Core,有将移动系统与强大后端架构集成的经验。除了交付工作,我还构建并发布了 Flutter 插件,为开源项目做出贡献,并撰写技术内容,简化复杂的工程概念。我处于实际工程与技术领导的交汇点,专注于构建具有影响力的产品,并赋能高效团队。
如果你读到这里,请感谢作者,以表达你对他们的关心。说声谢谢
免费学习编程。freeCodeCamp 的开源课程已帮助超过 40,000 人成为开发人员。立即开始
ADVERTISEMENT