T
traeai
登录
返回首页
freeCodeCamp.org

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

8.5Score

TL;DR · AI 摘要

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

核心要点

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

结构提纲

按章节快速跳转。

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

  2. 对比 ShelfServerpod,说明 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.methodswitch 语句在单个文件中处理所有 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": "seyi@example.com",
    "password": "securepassword",
    "firstName": "Seyi",
    "lastName": "Dev"
  }'

响应:

code
{
  "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..."
}

登录:

code
curl http://localhost:8080/auth/login \
  -X POST \
  -H "Content-Type: application/json" \
  -d '{"email": "seyi@example.com", "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":"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

AI 可能会生成不准确的信息,请核实重要内容

From Flutter to Backend: How to Build Production-Grade REST APIs with Dart and Dart Frog | freeCodeCamp.org | traeai