Dart后端框架呈现出不同的层次结构。在最基础的层面,有Shelf这种框架,它提供原始的编程元素和完全的控制权,用户需要自行完成所有的连接工作;而在功能最为完备的层面,则有Serverpod,这是一种带有代码生成机制以及固定开发规范的完整框架,它会替用户做出大部分结构上的决策。

Dart Frog位于这两个极端之间,对于许多Flutter开发者来说,它是最适合的选择。

Dart Frog是一个快速、简洁的后端框架,它建立在Shelf的基础上。该框架最初由Very Good Ventures开发,现在由独立团队进行维护。它采用了Next.js和Remix所流行的基于文件的路由机制,并将其应用到了Dart语言中;同时,它还提供了一个简洁的命令行工具,能够轻松地处理开发服务器的配置、热重载、生产环境的构建以及Docker镜像的生成等工作。

你只需要在routes目录下编写Dart代码文件,然后导出一个onRequest函数,Dart Frog就会自动处理路由逻辑。无需进行任何路由器配置、处理器注册或其他繁琐的操作——文件系统本身就已经构成了路由系统。

在本文中,我们将使用Dart Frog构建一个用于用户和资料管理的REST API(与前面链接的文章中构建的API相同),将其连接到PostgreSQL数据库,添加JWT认证机制,并最终部署到Fly.io平台上。

通过学习本文的内容,你将深入理解Dart Frog的路由机制,并清楚地了解它与Shelf、Serverpod之间的区别。

目录

先决条件

在开始之前,您需要具备以下条件:

  • 熟练掌握Dart和Flutter开发技术

  • 了解REST API的概念、端点、HTTP方法以及状态码

  • 已安装并运行Docker Desktop

  • 拥有Fly.io账户用于部署项目

Dart Frog与其他框架的区别

了解Dart Frog与其他两种框架的差异,有助于您为每个项目做出合适的选择。

Shelf提供了一个路由器,您需要手动配置处理程序;文件夹结构与URL结构之间没有关联,您需要自行决定文件的位置。

Serverpod会根据端点类名和方法名自动生成路由;您只需定义相应的类,运行生成工具,URL就会自动被确定下来。

Dart Frog会直接将您的文件系统结构映射到URL结构中:例如,文件routes/users/index.dart对应URL路径 /users,文件routes/users/[id].dart对应URL路径 /users/:id。无需任何配置或额外的处理步骤,文件本身就代表了相应的路由。

对于曾经使用过Next.js或其他现代Web框架的Flutter开发者来说,这种模型会显得非常直观;在团队协作中,这种方式也更加便于管理——只需查看文件夹结构,就能立即知道有哪些端点存在。

另一个关键区别在于请求上下文的处理方式:Shelf会将原始请求直接传递给处理程序,而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.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`函数:

import 'package:dart_frog/dart_frog.dart';

Future 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 onRequest(RequestContext context) async {
  // 原始的HTTP请求信息
  final request = context.request;

  // HTTP请求方法
  print(request.method); // 可以是GET、POST等

  // 路径参数(例如动态路由路径如/users/[id].dart)
  final id = contextrequest.uri.pathSegments.last;

  // 查询参数
  final page = request(uri.queryParameters['page'];

  // 请求体数据
  final body = await request.json() as Map;

  // 中间件注入的值
  final db = context.read();
  final currentUser = context.read();

  return Response.json(body: {'ok': true});
}

`context.read()`实现了依赖注入的功能。中间件负责提供所需的值,而路由处理函数则直接使用这些值。这样的设计使得路由代码更加简洁,也便于进行测试:路由处理函数并不需要了解数据库连接是如何被创建的,它只需要从`RequestContext`对象中获取这些信息即可。

中间件与依赖注入

位于任何路由文件夹中的`_middleware.dart`文件都会将该文件夹及其子文件夹中的所有路由应用相应的中间件逻辑;而放在“routes/”目录根目录下的`_middleware.dart`文件则会为整个应用程序全局应用这些中间件。

Dart Frog中的中间件使用“提供者模式”来将所需值注入到`RequestContext`对象中:

import 'package:dart_frog/dart_frog.dart';

Handler middleware(Handler handler) {
  return handler.use(
    provider((
      (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': '需要授权'},
);
}

// 验证令牌并获取用户信息
final user = verifyToken(authHeader);
return handler(context.provide(() => user));
};
}

动态路由

名为 [id].dart 的文件可以匹配任何路径片段。在处理程序内部,可以从 URL 中提取相应的参数:

Future onRequest(RequestContext context, String id) async {
// 对于动态路由,id会自动作为参数传递进来
return Response.json(body: {'userId': id});
}

Dart Frog 会将动态路由参数作为额外的参数传递给 onRequest 函数。这种方式比手动从 URL 中解析参数更为简洁。

设置数据库

使用 Docker Compose 配置 PostgreSQL

在项目根目录下创建 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('数据库连接成功');
    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 idxprofiles_user_id ON profiles(user_id);
    ''');
    print('迁移操作已完成');
  }
}

迁移操作

在使用 Dart Frog 构建项目时,系统会自动生成一个 `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 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 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 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 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作为主要的查询键,因为个人资料的访问总是与特定的用户相关联。

User Repository

创建文件“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(
      Sqlnamed('''
        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(
      Sqlnamed('''
        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(
      Sqlnamed('''
        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(
      Sqlnamed('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(
      Sqlnamed('''
        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(
      Sqlnamed('''
        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());
  }
}

认证服务

在这个项目中,认证功能由位于 `lib/services/` 目录下的专用 `AuthService` 来处理。该服务的职责非常明确:负责执行各种加密操作,包括在存储用户密码之前对其进行哈希处理、在用户登录时验证密码、在认证成功后生成经过签名的 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(EnvjwtSecret),
      Expires: 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()))
        .use-provider((_ => ProfileRepository()))
        .use-provider((_ => 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': '授权头部缺失或格式不正确'},
        );
      }

      final token = authHeader.substring(7);
      final authService = context.read();
      final jwt = authService.verifyToken(token);

      if (jwt == null) {
        return Response.json(
          statusCode: 401,
          body: {'error': '令牌无效或已过期'},
        );
      }

      final userId = jwt.payload['sub'] as String;
      final userEmail = jwtpayload['email'] as String;

      return handler(
        context.provide>((
          () => {'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': '请求体无效:${e.message}'},
        );
      } catch (e, stackTrace) {
        print('未处理的错误:\(e\n\)堆栈跟踪信息:${stackTrace}`);
        return Response.json(
          statusCode: 500,
          body: {'error': '发生了内部服务器错误'},
        );
      }
    };
  };
}

构建路由

既然模型、仓库、认证服务以及中间件都已经准备好了,我们现在就可以开始构建路由处理程序了。

本节将介绍三类路由:用于注册和登录的认证路由、用于执行CRUD操作的用户相关路由,以及嵌套在用户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 onRequest(RequestContext context) async {
  if (context.request.method != HttpMethod.post) {
    return Response.json(statusCode: 405, body: {'error': '不允许使用此方法'});
  }

  final body = await context.request.json() as Map;
  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和lastName'},
    );
  }

  if (password.length < 8) {
    return Response.json(
      statusCode: 400,
      body: {'error': '密码长度必须至少为8个字符'},
    );
  }

  final userRepo = context.read();
  final authService = context.read();

  final existing = await userRepo.findByEmail(email);
  if (existing != null) {
    return Response.json(
      statusCode: 409,
      body: {'error': '已经存在具有此email地址的账户'},
    );
  }

  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 onRequest(RequestContext context) async {
  if (context.request.method != HttpMethod.post) {
    return Response.json(statusCode: 405, body: {'error': '不允许使用此方法'});
  }

  final body = await context.request.json() as Map;
  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和password'},
    );
  }

  final userRepo = context.read();
  final authService = context.read();
  final user = await userRepo.findByEmail(email);

  if (user == null || !authService.verifyPassword(password, user.passwordHash)) {
    return Response.json(
      statusCode: 401,
      body: {'error': '电子邮件或密码无效'},
    );
  }

  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 onRequest(RequestContext context) async {
  if (context.request.method != HttpMethod.get) {
    return Response.json(statusCode: 405, body: {'error': '不允许使用此方法'));
  }

  final userRepo = context.read();
  final users = await userRepo.findAll();

  return Response.json(
    body: users.map((u) => u.toJson()).ToList(),
  );
}

创建文件 routes/users/[id].dart:

import 'package:dart_frog/dart_frog.dart';
import '../../lib/repositories/user_repository.dart';

Future onRequest(RequestContext context, String id) async {
  final userRepo = context.read();

  switch (context.request.method) {
    case HttpMethod.get:
      return _getUser(userRepo, id);
    case HttpMethod.put:
      return _updateUser(context, userRepo, id);
    case HttpMethod.delete:
      return _deleteUser(userRepo, id);
    default:
      return Response.json(
        statusCode: 405,
        body: {'error': '不允许使用此方法'},
      );
  }
}

Future _getUser(UserRepository repo, String id) async {
  final user = await repo.findById(id);
  if (user == null) {
    return Response.json(statusCode: 404, body: {'error': '未找到该用户'));
  }
  return Response.json(body: user.toJson());
}

Future _updateUser(
  RequestContext context,
  UserRepository repo,
  String id,
) async {
  final body = await context.request.json() as Map;
  final user = await repo.update(
    id: id,
    firstName: body['firstName'] as String?,
    lastName: body['lastName'] as String?,
  );
  if (user == null) {
    return Response.json(statusCode: 404, body: {'error': '未找到该用户'));
  }
  return Response.json(body: user.toJson());
}

Future _deleteUser(UserRepository repo, String id) async {
  final deleted = await repo.delete(id);
  if (!deleted) {
    return Response.json(statusCode: 404, body: {'error': '未找到该用户'));
  }
  return Response.json(statusCode: 204, body: null);
}

请注意,onRequest方法接收一个字符串类型的id作为第二个参数——Dart Frog会自动将这个动态路径段传递给相应的处理函数。通过使用switch语句根据context.request.method的值来处理各种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 onRequest(RequestContext context, String id) async {
  final userRepo = context.read();
  final profileRepo = context.read();

  final user = await userRepo.findById(id);
  if (user == null) {
    return Response.json(statusCode: 404, body: {'error': '未找到该用户'));
  }

  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': '不允许使用此方法'},
      );
  }
}

Future _getProfile(ProfileRepository repo, String userId) async {
  final profile = await repo.findByUserId(userId);
  if (profile == null) {
    return Response.json(statusCode: 404, body: {'error': '未找到该用户的个人资料'));
  }
  return Response.json(body: profile.toJson());
}

Future _createProfile(
  RequestContext context,
  ProfileRepository repo,
  String userId,
) async {
  final existing = await repo.findByUserId(userId);
  if (existing != null) {
    return Response.json(
      statusCode: 409,
      body: {'error': '该用户的个人资料已存在'},
    );
  }

  final body = await context.request.json() as Map;
  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 _updateProfile(
  RequestContext context,
  ProfileRepository repo,
  String userId,
) async {
  final body = await context.request.json() as Map;
  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': '未找到该用户的个人资料'));
  }
  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工程师转型为后端开发人员",
    "location": "尼日利亚拉各斯",
    "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平台上,在那里应用程序会作为容器化服务运行,并且会使用由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 — 从构建目录启动服务:

cd build
fly launch

Fly.io会自动识别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语言中,而且丝毫没有削弱这种语言自身的优势。

路由机制是Dart Frog最突出的特点。只要查看“routes/”这个文件夹,你就能了解关于你的API的所有信息:有哪些接口存在,它们是如何分类的,以及哪些中间件适用于哪些部分。这种透明性使得代码库更易于理解、更便于维护,而且随着代码规模的扩大,也更容易进行管理。

对于依赖注入机制,Dart Frog采用了非常成熟的设计模式。中间件负责注入相应的功能,接口则负责使用这些功能,两者之间没有任何混淆或冲突。特别是针对文件夹级别的中间件管理,设计得异常简洁:只需将一个 `_middleware.dart` 文件放在正确的文件夹中,就能为你的API的某个部分添加相应的功能。

对于那些需要为多种类型的客户端提供服务、需要遵循标准的REST协议、或者需要与现有的前端基础设施顺利集成的Flutter工程师来说,Dart Frog确实提供了一个非常理想的选择。Shelf和Serverpod虽然也具备这些功能,但相比之下,Dart Frog在实现这些目标时更加自然、更加高效。

如今,Dart已经真正成为了一种全栈开发语言。无论是Flutter应用程序,还是为其提供支持的服务器端代码,都使用同一种语言、遵循相同的开发规范。

祝编程愉快!

Comments are closed.