T
traeai
登录
返回首页
freeCodeCamp.org

从 Flutter 到后端:如何使用 Dart 和 Shelf 构建并发布生产级 REST API

8.5Score
从 Flutter 到后端:如何使用 Dart 和 Shelf 构建并发布生产级 REST API

TL;DR · AI 摘要

使用 Dart 和 Shelf 可以构建生产级 REST API,无需学习新语言或框架。文章通过完整项目演示了从零搭建用户与资料管理后端、连接 PostgreSQL、实现 JWT 认证并部署到 Fly.io 的全过程。

核心要点

  • Dart 的 dart:io 可直接构建 HTTP 服务器,但复杂场景需 Shelf 提供路由、中间件和错误处理能力。
  • Shelf 采用 Handler + Middleware + Pipeline + Router 模型,支持灵活组合,适合构建可维护的后端服务。
  • 项目通过 Docker 运行 PostgreSQL,使用 JWT 和 bcrypt 实现安全认证,并成功部署至 Fly.io 生产环境。

结构提纲

按章节快速跳转。

  1. Flutter 开发者可利用已掌握的 Dart 技能构建生产级后端服务,关键在于理解 Dart 在无 UI 环境下的运行机制。

  2. Dart 在服务端仅运行进程处理 HTTP 请求,依赖 dart:io 库实现基础网络功能,不涉及 Flutter 框架组件。

  3. ·Shelf 框架核心概念

    Shelf 是 Dart 官方维护的可组合中间件库,基于 Handler、Middleware、Pipeline 和 Router 四大概念构建服务。

  4. 使用 dart create -t server-shelf 初始化项目,并在 pubspec.yaml 中添加 shelf、postgres、dart_jsonwebtoken 等依赖。

  5. 通过 Docker 部署 PostgreSQL 数据库,结合 JWTbcrypt 实现用户身份验证与密码加密存储。

  6. 使用 Fly CLI 将应用部署到 Fly.io,实现生产环境托管,支持自动扩展和 HTTPS。

思维导图

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

查看大纲文本(无障碍 / 无 JS 友好)
  • 用 Dart 和 Shelf 构建生产级 REST API
    • Dart 服务端机制
      • dart:io 基础网络支持
      • 无 Flutter 框架依赖
    • Shelf 核心架构
      • Handler 处理请求响应
      • Middleware 添加行为
      • Pipeline 组合链式处理
      • Router 映射 URL 路由
    • 项目实践
      • Docker + PostgreSQL 数据库
      • JWT + bcrypt 安全认证
      • Fly.io 生产部署

金句 / Highlights

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

  • Dart 的 dart:io 库足以构建基础 HTTP 服务器,但缺乏路由和中间件支持,导致复杂系统难以维护。

    第 3 段

    ⬇︎ 下载 PNG𝕏 分享到 X
  • Shelf 不是一个完整框架,而是提供构建块,允许开发者按需组装 Handler、Middleware 和 Router,实现高度定制化。

    第 4 段

    ⬇︎ 下载 PNG𝕏 分享到 X
  • 项目通过 Docker 容器化 PostgreSQL 数据库,确保开发与生产环境一致性,提升部署效率。

    第 6 段

    ⬇︎ 下载 PNG𝕏 分享到 X
  • JWT 用于状态无感知的身份认证,bcrypt 用于安全哈希密码,两者结合保障用户数据安全。

    第 7 段

    ⬇︎ 下载 PNG𝕏 分享到 X
  • Fly.io 提供免费层级的云部署服务,支持快速部署 Dart 后端应用,具备自动负载均衡和 HTTPS 支持。

    第 8 段

    ⬇︎ 下载 PNG𝕏 分享到 X
#Dart#Shelf#REST API#后端#Fly.io
打开原文
Image 1: From Flutter to Backend: How to Build and Ship Production REST APIs with Dart and Shelf

作为一名 Flutter 工程师,你已经熟悉 Dart。你了解 async/await,会使用模型和仓库,遵循整洁架构,并且已经发布过真实应用。

你当前所处的位置与能够构建并部署生产级后端之间的差距,其实比你想象的要小得多。

缺失的部分并不是一门新语言,也不是一种新范式。而是理解当没有 widget 树、没有 BuildContext、没有 Flutter 框架时,Dart 是如何运行的——仅仅是一个处理 HTTP 请求、连接数据库并返回响应给客户端的运行进程。

这正是本文所涵盖的内容。

我们将从零开始,使用 Dart 和 Shelf 构建一个完整的用户和资料管理 REST API,将其连接到运行在 Docker 中的 PostgreSQL 数据库,通过 JWT 认证进行安全保护,并部署到 Fly.io。

最终,你将拥有一个完全用 Dart 编写的、具备生产级别的后端,而 Dart 正是你已经熟悉的语言。

本文是系列文章(独立成篇)的一部分,我们将使用三种不同的框架来构建同一个项目。本文使用 Shelf,下一篇文章使用 Serverpod,再下一篇使用 Dart Frog。这样你可以直接比较每个框架在解决相同问题时的不同方式。

目录

前提条件

开始之前,你应该具备以下条件:

  • 对 Dart 和 Flutter 开发有良好的熟悉度
  • 理解 REST API 的概念、端点、HTTP 方法和状态码
  • 安装并运行 Docker Desktop
  • 拥有一个 Fly.io 账户(免费层级即可,fly.io)
  • 安装 Fly CLI(macOS 上使用 brew install flyctl,Windows/Linux 使用官方安装程序)
  • 一个用于检查数据库的 PostgreSQL 客户端,如 TablePlus 或 DBeaver —— 两者都很好用

Dart 在服务器端的工作原理

当你运行 Flutter 应用时,Flutter 框架做了大量工作:管理 widget 树、处理渲染管道、协调状态、响应平台事件。你的 Dart 代码建立在所有这些之上。

而在服务器端,这一切都不存在。没有 widget 树,没有管理 UI 生命周期的框架,只有一个运行中的 Dart 进程,监听某个端口,接收 HTTP 请求,执行操作,并向客户端发送响应。

Dart 的标准库 dart:io 提供了实现这一功能所需的一切底层能力:

dart
import 'dart:io';

void main() async {
  final server = await HttpServer.bind('0.0.0.0', 8080);
  print('Server running on port 8080');

  await for (final request in server) {
    request.response
      ..statusCode = 200
      ..write('Hello from Dart')
      ..close();
  }
}

这是一个纯 Dart 实现的 HTTP 服务器。无需任何包或框架。每个请求都通过 HttpServer 流进入,你可以直接写入响应。

这种方式可行,但扩展性差。一旦你需要路由、中间件、认证和结构化的错误处理,原始的 dart:io 就变得难以管理。这正是 Shelf 所解决的问题。

什么是 Shelf?

Shelf 是由 Dart 团队维护的一个可组合的 Web 服务器中间件库。它并不试图成为一个完整的框架,而是为你提供构建框架或组装所需组件的基本原语。

Shelf 的思维模型基于四个核心概念:

  • Handler(处理器):一个接收 Request 并返回 Response 的函数。Shelf 中的一切最终都是处理器。
  • Middleware(中间件):一个包装处理器的函数,在处理器执行前后添加行为。日志记录、认证和错误处理都是中间件。
  • Pipeline(管道):一系列中间件链,末端是一个处理器。请求会依次流经中间件链,最后到达处理器。
  • Router(路由器):将 URL 路径模式和 HTTP 方法映射到特定处理器。

如果你使用过 Flutter 的 Navigator 或 provider 中间件概念,这种组合模型会感到非常熟悉——将小型、单一职责的组件组装成一个完整系统。

项目搭建

创建项目

Dart 提供了一个服务端项目模板,为我们提供了一个干净的起点:

bash
dart create -t server-shelf user_profile_api
cd user_profile_api

pubspec.yaml 中添加所需的依赖项:

yaml
name: user_profile_api
description: User and Profile Management REST API built with Dart and Shelf
version: 1.0.0

environment:
  sdk: '>=3.0.0 <4.0.0'

dependencies:
  shelf: ^1.4.1
  shelf_router: ^1.1.4
  postgres: ^3.3.0
  dart_jsonwebtoken: ^2.12.0
  bcrypt: ^1.1.3
  dotenv: ^4.1.0
  crypto: ^3.0.3

dev_dependencies:
  lints: ^3.0.0
  test: ^1.24.0

运行:

bash
dart pub get

项目结构

接下来,我们将构建一个对 Flutter 工程师来说直观、易于导航且符合后端开发规范的项目结构:

code
user_profile_api/
  bin/
    server.dart              ← 入口文件
  lib/
    config/
      database.dart          ← 数据库连接管理器
      env.dart               ← 环境配置
    handlers/
      auth_handler.dart      ← 认证接口
      user_handler.dart      ← 用户接口
      profile_handler.dart   ← 个人资料接口
    middleware/
      auth_middleware.dart   ← JWT 验证中间件
      error_middleware.dart  ← 全局错误处理中间件
      logger_middleware.dart ← 请求日志记录中间件
    models/
      user.dart
      profile.dart
    repositories/
      user_repository.dart
      profile_repository.dart
    services/
      auth_service.dart      ← JWT + 密码逻辑服务
    router.dart              ← 路由定义
  migrations/
    001_create_users.sql
    002_create_profiles.sql
  docker-compose.yml
  Dockerfile
  .env
  .env.example

这种职责分离与 Flutter 工程师所熟悉的结构直接对应:models、repositories 和 services 是相同的概念。Handlers 替代了 ViewModel 或 Controller,而 Middleware 则替代了拦截器。

使用 Docker 配置数据库

在项目根目录创建 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

volumes:
  postgres_data:

启动数据库:

code
docker compose up -d

验证是否运行正常:

code
docker compose ps
# user_profile_db   running   0.0.0.0:5432->5432/tcp

环境配置

在项目根目录创建 .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

创建 .env.example 文件,包含相同的键但无值,用于提交到 Git:

code
DB_HOST=
DB_PORT=
DB_NAME=
DB_USER=
DB_PASSWORD=
JWT_SECRET=
JWT_EXPIRY_HOURS=
PORT=

.env 添加到 .gitignore

code
.env

创建 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');
  static int get port => int.parse(_env['PORT'] ?? '8080');
}

includePlatformEnvironment: true 表示 Env 类会同时读取 .env 文件和系统环境变量,因此同一段代码既可以在本地使用 .env 文件运行,也可以在生产环境中通过注入的环境变量运行。

Shelf 核心概念

在构建 API 之前,理解每个 Shelf 概念非常重要——不仅要了解它做什么,还要理解为什么这样设计。

Handlers

Handler 是 Shelf 中最基本的单元,它只是一个函数:

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

Response helloHandler(Request request) {
  return Response.ok('Hello, Dart backend!');
}

输入 Request,输出 Response。这就是完整的契约。你编写的每一个端点都是一个 handler,每一个中间件也是一个接收 handler 并返回 handler 的函数。

Handler 可以是异步的:

code
Future<Response> getUserHandler(Request request) async {
  final users = await userRepository.findAll();
  return Response.ok(jsonEncode(users));
}

Request 和 Response

Request 提供了关于传入 HTTP 请求的所有信息:

code
Future<Response> handler(Request request) async {
  // URL 和路径
  print(request.url);           // 完整 URL
  print(request.url.path);      // 仅路径部分

  // 路径参数(当使用 shelf_router 时)
  final id = request.params['id'];

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

  // 请求头
  final auth = request.headers['authorization'];

  // 请求体
  final body = await request.readAsString();
  final json = jsonDecode(body) as Map<String, dynamic>;

  return Response.ok('handled');
}

Response 提供了常见状态码的命名构造函数:

code
Response.ok(body)           // 200
Response.notFound(body)     // 404
Response(201, body: body)   // 任意状态码
Response(400, body: body)   // 错误请求
Response(401, body: body)   // 未授权
Response(500, body: body)   // 服务器错误

返回 JSON 时始终设置 Content-Type 头:

code
Response.ok(
  jsonEncode({'message': 'success'}),
  headers: {'Content-Type': 'application/json'},
)

Router

shelf_router 将 URL 模式和 HTTP 方法映射到 handler:

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

final router = Router();

router.get('/users', getAllUsersHandler);
router.get('/users/<id>', getUserHandler);
router.post('/users', createUserHandler);
router.put('/users/<id>', updateUserHandler);
router.delete('/users/<id>', deleteUserHandler);

语法中定义了路径参数,在 handler 内部通过 request.params['id'] 访问。

Pipeline 和 Middleware

Pipeline 将多个中间件串联起来,并在最后添加一个 handler:

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

final handler = Pipeline()
    .addMiddleware(loggerMiddleware())
    .addMiddleware(errorMiddleware())
    .addMiddleware(authMiddleware())
    .addHandler(router.call);

中间件是一个具有以下签名的函数:

code

Middleware myMiddleware() { return (Handler innerHandler) { return (Request request) async { // 在处理器运行之前 print('收到请求: \({request.method} \){request.url}');

final response = await innerHandler(request);

// 在处理器运行之后 print('发送响应: ${response.statusCode}');

return response; }; }; }

code

外部函数返回一个 Middleware。该 Middleware 是一个函数,它接收链中的下一个 Handler 并返回一个新的 Handler。这种嵌套结构使得中间件能够在内部处理器执行前后都运行代码。

## 连接到 PostgreSQL

### 数据库连接管理器

创建 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 _connect(); return _connection!; }

static Future<Connection> _connect() async { final conn = 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('✅ 数据库已连接: \({Env.dbHost}:\){Env.dbPort}/${Env.dbName}'); return conn; }

static Future<void> close() async { await _connection?.close(); _connection = null; } }

code

这是一个单例连接管理器——Flutter 工程师用于共享服务的相同模式。连接在首次访问时创建,并在后续所有数据库调用中重复使用。

### 执行迁移

创建 migrations 文件夹和 SQL 文件:

migrations/001_create_users.sql:

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

code

migrations/002_create_profiles.sql:

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

code

在 lib/config/database.dart 中创建迁移执行器:

static Future<void> runMigrations() async { final conn = await connection; final migrationsDir = Directory('migrations');

final files = migrationsDir .listSync() .whereType<File>() .where((f) => f.path.endsWith('.sql')) .toList() ..sort((a, b) => a.path.compareTo(b.path));

for (final file in files) { final sql = await file.readAsString(); await conn.execute(sql); print('✅ 已应用迁移: ${file.path}'); } }

code

## 构建 API

在数据库连接成功并完成迁移后,我们现在可以构建实际的 API 层。

本节涵盖用户和资料的模型、仓库和处理器。模型定义数据结构,仓库处理所有数据库交互,处理器将 HTTP 请求转换为仓库调用,并向客户端发送响应。我们将首先构建用户层,然后在其基础上构建资料层。

### 用户模型

User 模型表示数据库中的单个用户记录。它直接映射到迁移中创建的 users 表,并处理数据库行与 Dart 对象之间的双向转换。

创建 lib/models/user.dart:

class User { final String id; final String email; final String passwordHash; final String firstName; final String lastName; final bool isActive; final DateTime createdAt; final DateTime updatedAt;

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, });

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, );

// 在 JSON 响应中永远不要包含 passwordHash Map<String, dynamic> toJson() => { 'id': id, 'email': email, 'firstName': firstName, 'lastName': lastName, 'isActive': isActive, 'createdAt': createdAt.toIso8601String(), 'updatedAt': updatedAt.toIso8601String(), }; }

code

fromRow 将 PostgreSQL 查询结果行映射为 User 实例。toJson 方法故意排除了 passwordHash —— 你永远不应该在 API 响应中返回密码数据。

### 用户仓库

UserRepository 是应用程序与 users 表之间的唯一接触点。所有用户的数据库操作都通过这里进行,从而保持 SQL 逻辑集中且处理器代码清晰。

创建 lib/repositories/user_repository.dart:

import 'dart:async'; import 'package:postgres/postgres.dart'; import '../config/database.dart'; import '../models/user.dart';

class UserRepository { Future<Connection> get _conn => Database.connection;

code

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((row) => User.fromRow(row.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; }

code

这里有几个值得注意的地方。`Sql.named` 使用命名参数(@paramName)而不是位置参数,这可以防止 SQL 注入并使查询更易读。

此外,删除操作是软删除。它将 `is_active` 设置为 `FALSE` 而不是直接删除行。这是标准的生产环境做法:数据永远不会被真正删除,只是被停用。

在更新操作中使用 `COALESCE(@firstName, first_name)` 表示:如果提供了新值则使用新值,否则保留现有值。这种方式可以干净地处理部分更新,无需每次都提供所有字段。

### 用户处理器

`UserHandler` 类将仓库操作暴露为 HTTP 接口。它内部拥有一个 `Router` 实例,并将每个路由映射到私有方法,从而将路由逻辑和处理器逻辑集中在一个地方。

创建 `lib/handlers/user_handler.dart`:

import 'dart:convert'; import 'package:shelf/shelf.dart'; import 'package:shelf_router/shelf_router.dart'; import '../repositories/user_repository.dart';

class UserHandler { final UserRepository _repository;

UserHandler(this._repository);

Router get router { final router = Router(); router.get('/', _getAll); router.get('/<id>', _getOne); router.put('/<id>', _update); router.delete('/<id>', _delete); return router; }

Future<Response> _getAll(Request request) async { final users = await _repository.findAll(); return Response.ok( jsonEncode(users.map((u) => u.toJson()).toList()), headers: {'Content-Type': 'application/json'}, ); }

Future<Response> _getOne(Request request, String id) async { final user = await _repository.findById(id);

if (user == null) { return Response.notFound( jsonEncode({'error': 'User not found'}), headers: {'Content-Type': 'application/json'}, ); }

return Response.ok( jsonEncode(user.toJson()), headers: {'Content-Type': 'application/json'}, ); }

Future<Response> _update(Request request, String id) async { final body = jsonDecode(await request.readAsString()) as Map<String, dynamic>;

final user = await _repository.update( id: id, firstName: body['firstName'] as String?, lastName: body['lastName'] as String?, );

if (user == null) { return Response.notFound( jsonEncode({'error': 'User not found'}), headers: {'Content-Type': 'application/json'}, ); }

return Response.ok( jsonEncode(user.toJson()), headers: {'Content-Type': 'application/json'}, ); }

Future<Response> _delete(Request request, String id) async { final deleted = await _repository.delete(id);

if (!deleted) { return Response.notFound( jsonEncode({'error': 'User not found'}), headers: {'Content-Type': 'application/json'}, ); }

return Response( 204, headers: {'Content-Type': 'application/json'}, ); } }

code

### 个人资料模型

`Profile` 模型表示用户的扩展信息,与核心用户记录分开存储。通过在 `profiles` 表中对 `user_id` 设置唯一索引,强制实现一对一关系。除了 `userId` 外,所有字段都是可空的,因为个人资料可以先以部分信息创建,然后逐步补充完整。

创建 `lib/models/profile.dart`:

// 此处省略了 profile.dart 的内容,根据上下文应在此处继续定义 Profile 类。

code

class Profile { 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;

const Profile({ required this.id, required this.userId, this.bio, this.avatarUrl, this.phone, this.location, this.website, required this.createdAt, required this.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(), }; }

code

### 用户资料仓库

ProfileRepository 负责处理用户资料表的所有数据库操作。与用户仓库通过 ID 查询不同,大多数用户资料操作使用 userId 作为查询键,因为客户端通常通过所属用户来引用资料,而不是通过其内部 ID。

创建 lib/repositories/profile_repository.dart:

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

class ProfileRepository { Future<Connection> get _conn => Database.connection;

Future<Profile?> findByUserId(String userId) async { final conn = await _conn; final results = await conn.execute( Sql.named('SELECT * FROM profiles WHERE user_id = @userId'), parameters: {'userId': userId}, );

if (results.isEmpty) return null; return Profile.fromRow(results.first.toColumnMap()); }

Future<Profile> create({ required String userId, String? bio, String? avatarUrl, String? phone, String? location, String? website, }) async { final conn = await _conn; final results = await conn.execute( Sql.named(''' INSERT INTO profiles (user_id, bio, avatar_url, phone, location, website) VALUES (@userId, @bio, @avatarUrl, @phone, @location, @website) RETURNING * '''), parameters: { 'userId': userId, 'bio': bio, 'avatarUrl': avatarUrl, 'phone': phone, 'location': location, 'website': website, }, );

return Profile.fromRow(results.first.toColumnMap()); }

Future<Profile?> update({ required String userId, String? bio, String? avatarUrl, String? phone, String? location, String? website, }) async { final conn = await _conn; final results = await conn.execute( Sql.named(''' UPDATE profiles SET bio = COALESCE(@bio, bio), avatar_url = COALESCE(@avatarUrl, avatar_url), phone = COALESCE(@phone, phone), location = COALESCE(@location, location), website = COALESCE(@website, website), updated_at = NOW() WHERE user_id = @userId RETURNING * '''), parameters: { 'userId': userId, 'bio': bio, 'avatarUrl': avatarUrl, 'phone': phone, 'location': location, 'website': website, }, );

if (results.isEmpty) return null; return Profile.fromRow(results.first.toColumnMap()); } }

code

### 用户资料处理器

ProfileHandler 管理嵌套在用户 ID 下的用户资料端点。在每次操作之前,它会验证父级用户是否存在——无法为不存在的用户创建、获取或更新资料。它还通过在允许创建前检查是否存在记录来防止重复资料。

创建 lib/handlers/profile_handler.dart:

import 'dart:convert'; import 'package:shelf/shelf.dart'; import 'package:shelf_router/shelf_router.dart'; import '../repositories/profile_repository.dart'; import '../repositories/user_repository.dart';

class ProfileHandler { final ProfileRepository _profileRepository; final UserRepository _userRepository;

ProfileHandler(this._profileRepository, this._userRepository);

Router get router { final router = Router(); router.get('/<userId>/profile', _getProfile); router.post('/<userId>/profile', _createProfile); router.put('/<userId>/profile', _updateProfile); return router; }

Future<Response> _getProfile(Request request, String userId) async { final user = await _userRepository.findById(userId); if (user == null) { return Response.notFound( jsonEncode({'error': 'User not found'}), headers: {'Content-Type': 'application/json'}, ); }

final profile = await _profileRepository.findByUserId(userId); if (profile == null) { return Response.notFound( jsonEncode({'error': 'Profile not found'}), headers: {'Content-Type': 'application/json'}, ); }

return Response.ok( jsonEncode(profile.toJson()), headers: {'Content-Type': 'application/json'}, ); }

Future<Response> _createProfile(Request request, String userId) async { final user = await _userRepository.findById(userId); if (user == null) { return Response.notFound( jsonEncode({'error': 'User not found'}), headers: {'Content-Type': 'application/json'}, ); }

code

final existing = await _profileRepository.findByUserId(userId); if (existing != null) { return Response( 409, body: jsonEncode({'error': 'Profile already exists for this user'}), headers: {'Content-Type': 'application/json'}, ); }

final body = jsonDecode(await request.readAsString()) as Map<String, dynamic>;

final profile = await _profileRepository.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( 201, body: jsonEncode(profile.toJson()), headers: {'Content-Type': 'application/json'}, ); }

Future<Response> _updateProfile(Request request, String userId) async { final body = jsonDecode(await request.readAsString()) as Map<String, dynamic>;

final profile = await _profileRepository.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.notFound( jsonEncode({'error': 'Profile not found'}), headers: {'Content-Type': 'application/json'}, ); }

return Response.ok( jsonEncode(profile.toJson()), headers: {'Content-Type': 'application/json'}, ); } }

code

## 认证

在核心用户和资料的 CRUD 功能就绪后,下一步是保护 API。

本项目中的认证分为两部分:AuthService 负责加密操作 —— 密码哈希和 JWT 的生成与验证 —— 而 AuthHandler 则暴露注册和登录端点,客户端调用这些端点以获取令牌。一旦令牌被颁发,AuthMiddleware 会在每个受保护的请求到达处理器之前对其进行验证。

### 密码哈希

创建 `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) { return BCrypt.hashpw(password, BCrypt.gensalt()); }

bool verifyPassword(String password, String hash) { return 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; } } }

code

BCrypt.hashpw 生成带盐的哈希值。BCrypt.checkpw 将明文密码与存储的哈希值进行比对。盐值内嵌在哈希值中 —— 无需单独存储。

verifyToken 在任何失败、令牌过期、签名无效或格式错误时返回 null,而不是抛出异常。这使得认证中间件保持简洁。

### 认证处理器

创建 `lib/handlers/auth_handler.dart`:

import 'dart:convert'; import 'package:shelf/shelf.dart'; import 'package:shelf_router/shelf_router.dart'; import '../repositories/user_repository.dart'; import '../services/auth_service.dart';

class AuthHandler { final UserRepository _userRepository; final AuthService _authService;

AuthHandler(this._userRepository, this._authService);

Router get router { final router = Router(); router.post('/register', _register); router.post('/login', _login); return router; }

Future<Response> _register(Request request) async { final body = jsonDecode(await request.readAsString()) 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( 400, body: jsonEncode({'error': 'email, password, firstName, and lastName are required'}), headers: {'Content-Type': 'application/json'}, ); }

if (password.length < 8) { return Response( 400, body: jsonEncode({'error': 'Password must be at least 8 characters'}), headers: {'Content-Type': 'application/json'}, ); }

final existing = await _userRepository.findByEmail(email); if (existing != null) { return Response( 409, body: jsonEncode({'error': 'An account with this email already exists'}), headers: {'Content-Type': 'application/json'}, ); }

final passwordHash = _authService.hashPassword(password);

final user = await _userRepository.create( email: email, passwordHash: passwordHash, firstName: firstName, lastName: lastName, );

final token = _authService.generateToken(user);

return Response( 201, body: jsonEncode({ 'user': user.toJson(), 'token': token, }), headers: {'Content-Type': 'application/json'}, ); }

Future<Response> _login(Request request) async { final body = jsonDecode(await request.readAsString()) as Map<String, dynamic>;

final email = body['email'] as String?; final password = body['password'] as String?;

if (email == null || password == null) { return Response( 400, body: jsonEncode({'error': 'email and password are required'}), headers: {'Content-Type': 'application/json'}, ); }

final user = await _userRepository.findByEmail(email);

code

// 故意模糊的错误信息,绝不确认邮箱是否存在 if (user == null || !_authService.verifyPassword(password, user.passwordHash)) { return Response( 401, body: jsonEncode({'error': 'Invalid email or password'}), headers: {'Content-Type': 'application/json'}, ); }

final token = _authService.generateToken(user);

return Response.ok( jsonEncode({ 'user': user.toJson(), 'token': token, }), headers: {'Content-Type': 'application/json'}, ); }

code

登录错误信息故意模糊:“Invalid email or password” 而不是 “Email not found” 或 “Wrong password”。确认哪一部分出错会帮助攻击者枚举有效账户。

### 认证中间件

创建 lib/middleware/auth_middleware.dart:

import 'dart:convert'; import 'package:shelf/shelf.dart'; import '../services/auth_service.dart';

Middleware authMiddleware(AuthService authService) { return (Handler innerHandler) { return (Request request) async { final authHeader = request.headers['authorization'];

if (authHeader == null || !authHeader.startsWith('Bearer ')) { return Response( 401, body: jsonEncode({'error': 'Authorization header missing or malformed'}), headers: {'Content-Type': 'application/json'}, ); }

final token = authHeader.substring(7); // 去掉 'Bearer ' final jwt = authService.verifyToken(token);

if (jwt == null) { return Response( 401, body: jsonEncode({'error': 'Invalid or expired token'}), headers: {'Content-Type': 'application/json'}, ); }

// 将用户 ID 附加到请求上下文,供下游处理器使用 final updatedRequest = request.change( context: { ...request.context, 'userId': jwt.payload['sub'] as String, 'userEmail': jwt.payload['email'] as String, }, );

return innerHandler(updatedRequest); }; }; }

code

`request.change(context: {...})` 是 Shelf 中间件向处理器传递数据的方式,相当于在 Express 或 ASP.NET 中间件中将数据附加到请求上。任何下游处理器都可以通过 `request.context['userId']` 获取当前认证用户的信息。

## 错误处理

无论你如何谨慎编写处理器,在生产环境中总会发生意外失败 —— 请求体格式错误、数据库超时、未处理的边界情况等。

与其让每个处理器单独管理自己的错误响应,我们将在一个中间件中集中处理所有错误,该中间件包裹整个处理管道。这可以保证每个端点返回一致的错误响应结构,并防止内部错误细节泄露给客户端。

创建 lib/middleware/error_middleware.dart:

import 'dart:convert'; import 'package:shelf/shelf.dart';

Middleware errorMiddleware() { return (Handler innerHandler) { return (Request request) async { try { return await innerHandler(request); } on FormatException catch (e) { return Response( 400, body: jsonEncode({'error': 'Invalid request body: ${e.message}'}), headers: {'Content-Type': 'application/json'}, ); } catch (e, stackTrace) { // 在服务端记录完整的错误和堆栈跟踪 print('Unhandled error: $e'); print(stackTrace);

// 绝不向客户端暴露内部错误详情 return Response( 500, body: jsonEncode({'error': 'An internal server error occurred'}), headers: {'Content-Type': 'application/json'}, ); } }; }; }

code

创建 lib/middleware/logger_middleware.dart:

import 'package:shelf/shelf.dart';

Middleware loggerMiddleware() { return (Handler innerHandler) { return (Request request) async { final start = DateTime.now();

final response = await innerHandler(request);

final duration = DateTime.now().difference(start).inMilliseconds; print( '[${DateTime.now().toIso8601String()}] ' '\({request.method} \){request.url.path} ' '→ \({response.statusCode} (\){duration}ms)', );

return response; }; }; }

code

## 集成所有组件

在处理器、仓库和中间件都准备就绪后,最后一步是将它们连接成一个可运行的服务器。路由负责将 URL 前缀映射到对应的处理器,管道按正确顺序堆叠中间件,入口点则按顺序启动一切 —— 加载环境变量、运行迁移脚本并启动服务器。

创建 lib/router.dart:

import 'package:shelf_router/shelf_router.dart'; import 'handlers/auth_handler.dart'; import 'handlers/user_handler.dart'; import 'handlers/profile_handler.dart'; import 'middleware/auth_middleware.dart'; import 'repositories/user_repository.dart'; import 'repositories/profile_repository.dart'; import 'services/auth_service.dart';

Router createRouter() { final userRepository = UserRepository(); final profileRepository = ProfileRepository(); final authService = AuthService();

final authHandler = AuthHandler(userRepository, authService); final userHandler = UserHandler(userRepository); final profileHandler = ProfileHandler(profileRepository, userRepository);

final router = Router();

// 公共路由,无需认证 router.mount('/auth', authHandler.router.call);

// 受保护路由,应用认证中间件 router.mount( '/users', Pipeline() .addMiddleware(authMiddleware(authService)) .addHandler(userHandler.router.call), );

router.mount( '/users', Pipeline() .addMiddleware(authMiddleware(authService)) .addHandler(profileHandler.router.call), );

return router; }

code

创建入口文件 bin/server.dart:

// 此处省略具体实现,但应包含加载环境变量、运行迁移、启动服务器等逻辑

code

import 'dart:io'; import 'package:shelf/shelf.dart'; import 'package:shelf/shelf_io.dart' as shelf_io; import '../lib/config/database.dart'; import '../lib/config/env.dart'; import '../lib/middleware/error_middleware.dart'; import '../lib/middleware/logger_middleware.dart'; import '../lib/router.dart';

void main() async { // 加载环境变量 Env.load();

// 运行数据库迁移 await Database.runMigrations();

// 构建处理器管道 final router = createRouter();

final handler = Pipeline() .addMiddleware(errorMiddleware()) .addMiddleWare(loggerMiddleware()) .addHandler(router.call);

// 启动服务器 final server = await shelf_io.serve( handler, InternetAddress.anyIPv4, Env.port, );

print('🚀 服务器运行在端口 ${server.port}'); }

code

运行服务器:

dart run bin/server.dart

✅ 数据库连接成功:localhost:5432/user_profile_api

✅ 迁移已应用:migrations/001_create_users.sql

✅ 迁移已应用:migrations/002_create_profiles.sql

🚀 服务器运行在端口 8080

code

## 部署

服务器已在本地运行,所有接口均正常工作。现在是时候将其部署上线了。

我们将介绍两种部署路径:首先使用 Docker Compose 将应用和数据库打包在一起进行本地生产环境测试;然后部署到 Fly.io,您的 API 将通过互联网访问,并配备托管的 PostgreSQL 数据库和自动 TLS。

### Dockerfile

在项目根目录创建 Dockerfile:

FROM dart:stable AS build

WORKDIR /app COPY pubspec.* ./ RUN dart pub get

COPY . . RUN dart compile exe bin/server.dart -o bin/server

FROM debian:stable-slim

RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/*

WORKDIR /app COPY --from=build /app/bin/server bin/server COPY --from=build /app/migrations migrations/

EXPOSE 8080

CMD ["bin/server"]

code

这是一个多阶段构建。第一阶段使用完整的 Dart SDK 镜像将服务器编译为原生二进制文件。第二阶段仅将编译后的二进制文件和迁移脚本复制到最小化的 Debian 镜像中——不包含 Dart SDK、源代码或构建工具。最终镜像精简且适合生产环境。

### 使用 Docker Compose 进行本地生产测试

更新 docker-compose.yml,将应用与数据库一起配置:

version: '3.8'

services: postgres: image: postgres:16-alpine container_name: user_profile_db environment: POSTGRES_DB: user_profile_api POSTGRES_USER: dart_user POSTGRES_PASSWORD: dart_password ports:

  • "5432:5432"

volumes:

  • postgres_data:/var/lib/postgresql/data

healthcheck: test: ["CMD-SHELL", "pg_isready -U dart_user -d user_profile_api"] interval: 5s timeout: 5s retries: 5

api: build: . container_name: user_profile_api ports:

  • "8080:8080"

environment: DB_HOST: postgres DB_PORT: 5432 DB_NAME: user_profile_api DB_USER: dart_user DB_PASSWORD: dart_password JWT_SECRET: local_test_secret_replace_in_production JWT_EXPIRY_HOURS: 24 PORT: 8080 depends_on: postgres: condition: service_healthy

volumes: postgres_data:

code

Postgres 服务的健康检查确保 API 容器仅在数据库准备就绪后才启动(这是服务同时启动时常见的生产问题)。

构建并运行所有组件:

docker compose up --build

code

### 部署到 Fly.io

Fly.io 是容器化后端服务最干净的部署目标之一。它支持全球分发、自动 TLS 和托管的 PostgreSQL 数据库。

**步骤 1 – 安装并认证:**

macOS

brew install flyctl

认证

fly auth login

code

**步骤 2 – 启动应用:**

fly launch

code

Fly 会自动检测 Dockerfile 并询问一些问题:应用名称、区域以及是否创建 PostgreSQL 数据库。对 PostgreSQL 提示选择“是”,Fly 将为您配置一个托管数据库并自动注入连接字符串。

**步骤 3 – 设置环境变量:**

fly secrets set JWT_SECRET="your_production_secret_here" fly secrets set JWT_EXPIRY_HOURS="24"

code

数据库连接变量将在 Fly 为 PostgreSQL 集群配置完成后自动设置。

**步骤 4 – 部署:**

fly deploy

code

Fly 会构建 Docker 镜像,推送到其注册表,并部署到您选择的区域。完成后:

fly status

您的应用正在运行于 https://your-app-name.fly.dev

code

**步骤 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"}'

code

## 测试 API

当服务器在本地 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" }'

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

创建个人资料:
code
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"
  }'

更新用户:

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..."

结论

你刚刚使用 Dart 构建并部署了一个生产级别的 REST API —— 这正是你从 Flutter 中已经熟悉的语言。无需学习新语言,也无需适应新范式。只是在不同上下文中运行 Dart。

Shelf 的设计思想(处理器、中间件、管道、路由器)刻意保持极简。它不会替你做决定,而是提供可组合的原始组件,让你自由组装成项目所需的精确架构。这种理念对那些习惯于自己构建整洁架构而非依赖强制性框架的 Flutter 开发者来说会非常熟悉。

你在这里构建的内容 —— 模型、仓库、服务、处理器和中间件 —— 正是你在 Flutter 中应用的关注点分离原则,在后端场景下的延伸。概念可以迁移,Dart 技能可以迁移,架构纪律也可以迁移。

通过这些,你会明白 Dart 是一种强大的语言,能够横跨前后端生态系统。除了 Shelf,我们还有 Dartfrog 和 Serverpod,它们在后端领域同样表现出色。关于这些内容,将在后续文章中进一步介绍。

所以,试试看吧,之后记得感谢我!

  • * *
  • * *

免费学习编程。freeCodeCamp 的开源课程已帮助超过 40,000 人获得开发者工作。立即开始

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