freeCodeCamp.org

From Flutter to Backend: How to Build Production-Grade REST APIs with Dart and Dart Frog

8.5内容质量

TL;DR · AI 摘要

Dart Frog 是一个基于 Shelf 的轻量级后端框架,采用文件系统路由模型,适合 Flutter 工程师构建 REST API。

核心要点

  • Dart Frog 采用文件系统路由模型,无需手动配置路由器。
  • Dart Frog 支持热重载、生产构建和 Docker 生成。
  • 文章演示了如何使用 Dart Frog 构建用户和资料管理的 REST API,并连接 PostgreSQL 数据库。

结构提纲

按章节快速跳转。

  1. 介绍 Dart Frog 框架及其在 Flutter 开发者中的适用性。

  2. 对比 Shelf 和 Serverpod,说明 Dart Frog 的独特之处。

  3. 指导如何安装 Dart Frog 并创建项目。

  4. 解释 Dart Frog 的文件系统路由模型、中间件和依赖注入等核心概念。

  5. 介绍如何使用 Docker Compose 设置 PostgreSQL 数据库并进行环境配置。

  6. 演示如何构建 API 并部署到 Fly.io。

思维导图

用一张图看清主题之间的关系。

查看大纲文本(无障碍 / 无 JS 友好)
  • Dart Frog 框架
    • 核心特性
      • 文件系统路由模型
      • 支持热重载和 Docker 生成
      • 与 Shelf 和 Serverpod 的对比
    • 使用场景
      • 构建 REST API
      • 连接 PostgreSQL
      • 部署到 Fly.io

金句 / Highlights

值得收藏与分享的关键句。

#Dart#REST API#Dart Frog#Flutter#后端开发
打开原文

从 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:

code
dart pub global activate dart_frog_cli

验证安装:

code
dart_frog --version

创建项目

code
dart_frog create user_profile_api
cd user_profile_api

使用热重载启动开发服务器:

code
dart_frog dev

访问 http://localhost:8080,你会看到默认的欢迎响应。开发服务器会监视文件变化并自动重新加载。在构建过程中无需重启。

理解项目结构

code
user_profile_api/
  routes/
    index.dart              ← GET /
  pubspec.yaml
  analysis_options.yaml

这就是初始的完整结构。简洁而精炼。我们添加的所有内容都将从这里扩展。

构建完 API 后,完整的结构将如下所示:

code
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.yaml

routes/ 文件夹是 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 函数:

code
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 请求:它是一个容器,包含了请求本身以及中间件注入的任何值:

code
Future<Response> onRequest(RequestContext context) async {
  // 原始的 HTTP 请求
  final request = context.request;

  // HTTP 方法
  print(request.method); // GET, POST 等
code
// 路径参数(用于动态路由,如 [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 中的中间件使用提供者模式将值注入到上下文中:

code
import 'package:dart_frog/dart_frog.dart';

Handler middleware(Handler handler) {
  return handler.use(
    provider<DatabaseConnection>(
      (context) => DatabaseConnection.instance,
    ),
  );
}

同一文件夹或任何子文件夹中的任何路由都可以通过 context.read() 获取连接。不需要全局单例,也不需要手动传递。上下文会携带它。

中间件函数还可以在请求到达路由处理程序之前拦截请求,这使它们非常适合用于身份验证:

code
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 提取参数:

code
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:

code
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:

启动数据库:

code
docker compose up -d

环境配置

将依赖项添加到 pubspec.yaml:

code
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:

code
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:

code
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:

code
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:

code
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:

code
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:

code
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:

code
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:

code
import 'package:postgres/postgres.dart';
import '../config/database.dart';
import '../models/profile.dart';

class ProfileRepository {
  Future<Connection> get _conn => Database.connection;
dart
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:

code
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:

code
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:

code
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:

code
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:

code
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:

code
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:

code
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();
code

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:

code
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());
}
dart
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 以应用于每个路由的全局中间件:

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 以使用认证保护所有用户路由:

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。

启动开发服务器:

code
dart_frog dev
# 服务器现在正在运行于:http://localhost:8080

注册用户:

code
curl http://localhost:8080/auth/register \
  -X POST \
  -H "Content-Type: application/json" \
  -d '{
    "email": "[email protected]",
    "password": "securepassword",
    "firstName": "Seyi",
    "lastName": "Dev"
  }'

响应:

code
{
  "user": {
    "id": "uuid-here",
    "email": "[email protected]",
    "firstName": "Seyi",
    "lastName": "Dev",
    "isActive": true,
    "createdAt": "2025-01-01T00:00:00.000Z",
    "updatedAt": "2025-01-01T00:00:00.000Z"
  },
  "token": "eyJhbGci..."
}

登录:

code
curl http://localhost:8080/auth/login \
  -X POST \
  -H "Content-Type: application/json" \
  -d '{"email": "[email protected]", "password": "securepassword"}'

获取所有用户:

code
curl http://localhost:8080/users \
  -H "Authorization: Bearer eyJhbGci..."

获取特定用户:

code
curl http://localhost:8080/users/{userId} \
  -H "Authorization: Bearer eyJhbGci..."

创建个人资料:

code
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"
  }'

更新用户:

code
curl http://localhost:8080/users/{userId} \
  -X PUT \
  -H "Authorization: Bearer eyJhbGci..." \
  -H "Content-Type: application/json" \
  -d '{"firstName": "Oluwaseyi"}'

删除用户:

code
curl http://localhost:8080/users/{userId} \
  -X DELETE \
  -H "Authorization: Bearer eyJhbGci..."

部署

在本地完成所有测试后,最后一步是将 API 部署上线。Dart Frog 让这个过程变得简单:只需一条 CLI 命令即可生成一个适用于生产环境的 Dockerfile,之后我们将其部署到 Fly.io,应用程序将在容器化服务中运行,并与一个托管的 PostgreSQL 数据库一起运行。

生产构建

Dart Frog 通过以下命令生成一个适用于生产环境的 Docker 配置:

code
dart_frog build

这将创建一个 build/ 目录,其中包含以下内容:

code
build/
  bin/
    server.dart         ← 编译后的入口点
  Dockerfile            ← 适用于生产环境的 Dockerfile
  pubspec.yaml
  pubspec.lock

生成的 Dockerfile 是一个多阶段构建,第一阶段将编译为原生二进制文件,第二阶段则在最小的 Debian 镜像中运行。你不需要自己编写这个文件。

部署到 Fly.io

步骤 1 — 认证:

code
fly auth login

步骤 2 — 从 build 目录启动:

code
cd build
fly launch

Fly 会检测到 Dockerfile 并提示进行配置。在被询问时创建一个 PostgreSQL 数据库。

步骤 3 — 设置密钥:

code
fly secrets set JWT_SECRET="your_production_jwt_secret"
fly secrets set JWT_EXPIRY_HOURS="24"

步骤 4 — 部署:

code
fly deploy

步骤 5 — 验证:

code
curl https://your-app-name.fly.dev/auth/register \
  -X POST \
  -H "Content-Type: application/json" \
  -d '{"email":"[email protected]","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