作为Flutter工程师,你已经熟悉Dart语言了。你了解异步/等待机制,会使用模型和仓库进行开发,也懂得如何采用清晰的架构设计,并且已经成功部署过实际应用程序。

从你目前的水平到能够构建并部署可生产环境的后端系统,这个差距其实比你想象的要小得多。

所欠缺的并不是某种新的编程语言或全新的开发范式,而是需要理解:在没有Widget树、没有BuildContext、也没有Flutter框架的情况下,Dart语言该如何运行——也就是如何让一个程序仅通过处理HTTP请求、与数据库交互并向客户端发送响应来完成任务。

这篇文章正是围绕这个主题展开的。

我们将使用Dart和Shelf从零开始构建一个完整的用户及个人资料管理REST API,将其连接到在Docker中运行的PostgreSQL数据库,通过JWT认证机制确保安全性,最后将其部署到Fly.io平台上。

完成这些步骤后,你将会拥有一个完全用Dart编写的可生产级后端系统,而这种语言正是你已经熟悉的。

这篇文章属于一个系列文章的一部分。在这个系列中,我们将使用三种不同的框架来构建同一个项目:本文会使用Shelf,下一篇文章会使用Serverpod,再下一篇文章则会使用Dart Frog。这样你就可以直接对比这些框架在解决相同问题时的不同之处了。

目录

先决条件

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

  • 对Dart和Flutter开发有扎实的了解

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

  • 已安装并运行Docker Desktop

  • 拥有Fly.io账户(免费套餐即可,fly.io官网可注册)

  • 已安装Fly CLI工具(在macOS上使用brew install flyctl,在Windows/Linux系统上使用官方安装程序进行安装)

  • 需要一个用于查询数据库的工具,例如TablePlus或DBeaver——这两种工具都适用

Dart在服务器端的运行原理

当您运行一个Flutter应用程序时,Flutter框架会承担大量的工作:管理组件树、处理渲染流程、协调状态变化,并响应各种平台事件。而您的Dart代码则位于这些功能的顶层。

但在服务器端,这一切都不存在。没有组件树,也没有负责管理用户界面生命周期的框架。服务器上只运行着一个Dart进程,该进程会监听某个端口,接收HTTP请求,执行相应操作,然后发送响应结果。

Dart的标准库`dart:io`提供了在最低层实现这些功能所需的一切工具:

import 'dart:io';

void main() async {
  final server = await HttpServer.bind('0.0.0.0', 8080);
  print('服务器正在8080端口运行');

  await for (final request in server) {
    request.response
      ..statusCode = 200
      ..write('这是来自Dart的响应')
      ..close();
  }
}

这就是用原始的Dart代码实现的HTTP服务器。不需要任何第三方包或框架,所有请求都通过`HttpServer`流进行处理,响应内容也是直接写入流中的。

这种实现方式虽然可行,但扩展性较差。一旦需要路由功能、中间件、认证机制或结构化的错误处理机制,使用原始的`dart:io`就会变得非常麻烦。而Shelf正是为了解决这些问题而设计的。

什么是Shelf?

Shelf是一个专为Dart开发的可组合式Web服务器中间件库,由Dart团队维护。它并不试图成为一个完整的开发框架,而是为您提供构建框架所需的基本组件,让您能够根据实际需求灵活地组装所需的系统结构。

Shelf的核心设计理念基于以下四个概念:

  • 处理器:一种接收请求并返回响应的函数。在Shelf中,所有的功能最终都可以被看作是处理器。

  • 中间件:一种包裹处理器的函数,可以在处理器执行前后添加额外的逻辑。日志记录、身份验证和错误处理都属于中间件的范畴。

  • 处理流程:一系列按顺序执行的中间件,最终由一个处理器完成请求的处理。

  • 路由器:负责将URL路径和HTTP方法映射到相应的处理器上。

如果您曾经使用过Flutter中的导航器或提供者中间件机制,那么这种组合式的开发模式一定会让您感到熟悉——各种具有单一职责的小模块被组合在一起,从而形成一个功能完备的整体系统。

项目设置

创建项目

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

dart create -t server-shelf user_profile_api
cd user_profile_api

我们需要将所需的依赖项添加到`pubspec.yaml`文件中:

name: user_profile_api
description: 使用Dart和Shelf构建的用户与资料管理REST API
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

运行以下命令以安装依赖项:

dart pub get

项目结构

现在我们将构建一个后端项目结构,这种结构对Flutter工程师来说非常直观,易于使用,同时也符合后端开发的常规规范:

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工程师已经熟悉的开发模式是一致的:模型、仓库和服务属于相同的概念范畴;处理逻辑模块对应于视图模型或控制器;中间件则用于实现拦截等功能。

使用Docker配置数据库

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

volumes:
  postgres_data:

启动数据库服务:

docker compose up -d

确认数据库已成功运行:

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

环境配置

在项目根目录下创建一个 `.env` 文件:

DB_HOST=localhost
DB_PORT=5432
DB_NAME=user_profile_api
DB_USER=dart_user
DB_PASSWORD=dart_password
JWT_SECRET=你的超级秘密密钥——在生产环境中请更换此值
JWT_EXPIRY_HOURS=24
PORT=8080

再创建一个名为 `.env.example` 的文件,其中包含相同的键,但不设置对应的值。这就是你需要提交到 Git 中的内容:

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

将 `.env` 文件添加到 `.gitignore` 文件中:

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 相关的概念是非常重要的——不仅要了解它们的功能,还要明白它们为何会被设计成这样的结构。

处理程序

处理程序是 Shelf 中最基本的单元,它本质上就是一个函数:

import 'package:shelf/shelf.dart';

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

输入是一个 `Request` 对象,输出是一个 `Response` 对象。这就是处理程序的基本契约。你编写的每一个接口点其实都是一种处理程序;而每一段中间件代码,也都是一个接收处理程序作为输入、然后返回另一个处理程序作为输出的函数。

处理程序也可以被设计为异步执行的:

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

请求与响应

`Request` 对象包含了关于传入的 HTTP 请求的所有信息:

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('请求已处理完毕');
}

对于常见的状态码,`Response`类提供了相应的构造函数:

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`头信息:

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

路由器

`shelf.router`会将URL模式和HTTP方法映射到相应的处理函数上:

import 'package:shelf/router/shelf_router.dart';

final router = Router();

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

路径参数可以通过`request.params['id']`在处理函数中获取。

管道与中间件

“管道”会将多个中间件按顺序连接起来,并在最后添加处理函数:

import 'package:shelf/shelf.dart';

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

中间件是一种具有如下签名的函数:

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

      final response = await innerHandler(request);

      // 在处理函数执行之后
      print('返回的响应状态码: ${response.statusCode}');

      return response;
    };
  };
}

外层函数会返回一个中间件。这个中间件会接收链中的下一个处理函数,并返回一个新的处理函数。正是这种嵌套结构使得中间件能够在内部处理函数之前和之后执行代码。

连接PostgreSQL数据库

数据库连接管理器

创建`lib/config/database.dart`文件:

import 'package:postgres/postgres.dart';
import 'env.dart';

class Database {
  static Connection? _connection;

  static Future get connection async {
    if (_connection != null) return _connection!;
    _connection = await _connect();
    return _connection!;
  }

  static Future _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 close() async {
    await _connection?.close();
    _connection = null;
  }
}

这是一种单例连接管理器——Flutter工程师在实现共享服务时也会使用这种模式。当首次访问数据库时,会创建一次连接对象,之后所有的数据库请求都会重用这个连接。

运行迁移脚本

需要创建一个用于存储迁移脚本的文件夹以及相应的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);

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

在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}`);
  }
}

构建API层

现在数据库连接已经建立完毕,迁移脚本也已准备就绪,我们可以开始构建实际的API层了。

这一部分将介绍用户模型、数据存储库以及相应的处理逻辑。模型用于定义数据的结构,数据存储库负责处理所有的数据库交互操作,而处理逻辑则负责将HTTP请求转换为数据存储库的调用,并将响应结果发送回客户端。我们会先构建用户相关的功能,然后再在此基础上构建与个人资料相关的功能。

用户模型

用户模型表示数据库中的一条用户记录。它直接对应于在迁移脚本中创建的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.updated;

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

`fromRow`方法会将PostgreSQL查询结果中的数据行映射到`User`对象上。`toJson`方法会故意忽略`passwordHash`字段——在API响应中,绝对不应该返回用户的密码信息。

用户仓库

用户仓库是应用程序与`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;

  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(
      Sqlnamed('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(
      Sqlnamed('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;
  }
}

有几点需要注意。Sqlnamed使用命名参数(@paramName)而不是位置参数,这样可以防止SQL注入,并使查询语句更易于阅读。

此外,删除操作实际上是一种“软删除”:它只是将相关记录的is_active字段设置为FALSE,而并不会真正删除这些记录。这种做法在生产环境中是标准的处理方式——数据从来不会被彻底删除,而是被暂时停用而已。

在更新操作中,COALESCE(@firstName, first_name)的含义是:如果提供了新值,则使用新值;否则保留原有的值。这种方式能够优雅地处理部分字段的更新,而不需要每次都更新所有字段。

用户处理器

UserHandler类将数据存储库的相关操作暴露为HTTP接口。它在内部维护了一个Router实例,并将每个URL路径映射到相应的私有方法中,这样就可以将路由逻辑和处理逻辑集中在一个地方。

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('/', _getOne);
    router.put('/', _update);
    router.delete('/', _delete);
    return router;
  }

  Future _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 _getOne(Request request, String id) async {
    final user = await _repository.findById(id);

    if (user == null) {
      return Response.notFound(
        jsonEncode({'error': '用户未找到。',
        headers: {'Content-Type': 'application/json'},
      );
    }

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

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

    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': '用户未找到。',
        headers: {'Content-Type': 'application/json'},
      );
    }

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

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

    if (!deleted) {
      return Response.notFound(
        jsonEncode({'error': '用户未找到。',
        headers: {'Content-Type': 'application/json'},
      );
    }

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

个人资料模型

个人资料模型用于存储用户的扩展信息,这些信息与核心用户记录是分开存储的。通过“profiles”表中“user_id”字段的唯一索引,可以确保这种一对一的关系得到维护。除了“userId”之外,所有字段都是可空的,因为用户可以先创建一个包含部分信息的个人资料,然后随着时间的推移逐步补充完整这些信息。

创建文件“lib/models/profile.dart”:

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 creadoAt;
  final DateTime aktualizadoAt;

  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 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?,
        creadoAt: row['created_at'] as DateTime,
        aktualizadoAt: row['updated_at'] as DateTime,
      );

  Map toJson() => {
        'id': id,
        'userId': userId,
        'bio': bio,
        'avatarUrl': avatarUrl,
        'phone': phone,
        'location': location,
        'website': website,
        'createdAt': creadoAt.toIso8601String(),
        'updatedAt': aktualizadoAt.toIso8601String(),
      };
}

个人资料存储库

“ProfileRepository”负责处理与“profiles”表相关的所有数据库操作。与通过“id”来查找用户信息的用户存储库不同,大多数与个人资料相关的操作都是使用“userId”作为查询键的,因为客户端就是根据用户所属的账户来访问其个人资料的,而不是通过该个人资料的内部ID来访问的。

创建文件“lib/repositories/profile_repository.dart”:

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

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

  Future 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 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 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());
  }
}

资料处理类

资料处理类负责管理用户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('//profile', _getProfile);
    router.post('//profile', _createProfile);
    router.put('//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': '用户未找到'},
        headers: {'Content-Type': 'application/json'},
      );
    }

    final profile = await _profileRepository.findByUserId(userId);
    if (profile == null) {
      return Response_notFound(
        jsonEncode({'error': '资料信息未找到'],
        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': '用户未找到'},
        headers: {'Content-Type': 'application/json'},
      );
    }

    final existing = await _profileRepository.findByUserId(userId);
    if (existing != null) {
      return Response(
        409,
        body: jsonEncode({'error': '该用户的资料信息已存在'},
        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': '资料信息未找到'},
        headers: {'Content-Type': 'application/json'},
      );
    }

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

身份验证

在实现了核心的用户和资料管理功能之后,下一步就是保护API的安全性。

本项目中的身份验证机制分为两个部分:其中一个服务类负责处理加密操作,包括密码哈希以及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) {
    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),
      Expires: Duration(hours: EnvjwtExpiryHours),
    );
  }

  JWT? verifyToken(String token) {
    try {
      return JWT.verify(token, SecretKey(Env_jwtSecret));
    } catch (_) {
      return null;
    }
  }
}

`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 _register(Request request) async {
    final body = jsonDecode(await request.readAsString()) 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(
        400,
        body: jsonEncode({'error': '必须填写电子邮件、密码、名字和姓氏'},
        headers: {'Content-Type': 'application/json'},
      );
    }

    if (password.length < 8) {
      return Response(
        400,
        body: jsonEncode({'error': '密码长度至少为8个字符'},
        headers: {'Content-Type': 'application/json'},
      );
    }

    final existing = await _userRepository.findByEmail(email);
    if (existing != null) {
      return Response(
        409,
        body: jsonEncode({'error': '该电子邮件已存在'),
        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 _login(Request request) async {
    final body = jsonDecode(await request.readAsString()) as Map;

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

    if (email == null || password == null) {
      return Response(
        400,
        body: jsonEncode({'error': '必须填写电子邮件和密码'},
        headers: {'Content-Type': 'application/json'},
      );
    }

    final user = await _userRepository.findByEmail(email);

    // 故意设置模糊的错误提示,不确认该电子邮件是否确实存在
    if (user == null || !_authService.verifyPassword(password, user.passwordHash)) {
      return Response(
        401,
        body: jsonEncode({'error': '电子邮件或密码无效'),
        headers: {'Content-Type': 'application/json'},
      );
    }

    final token = _authService.generateToken(user);

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

登录错误信息的表述故意含糊其辞,会显示“电子邮件或密码无效”,而不是“找不到该电子邮件”或“密码错误”。如果能够确定是哪一部分出了问题,攻击者就能找出有效的账户信息。

认证中间件

创建文件 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': '授权头信息缺失或格式不正确'],
          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': '令牌无效或已过期'),
          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);
    };
  };
}

`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': '请求体格式不正确:${e.message}'),
          headers: {'Content-Type': 'application/json'},
        );
      } catch (e, stackTrace) {
        // 在服务器端记录完整的错误信息及堆栈跟踪
        print('未处理的错误:$e');
        print(stackTrace);

        // 绝不要将内部错误细节暴露给客户端
        return Response(
          500,
          body: jsonEncode({'error': '发生了内部服务器错误'),
          headers: {'Content-Type': 'application/json'},
        );
      }
    };
  };
}

创建文件 `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;
    };
  };
}

将所有组件连接起来

当处理器、数据存储层和中间件都准备就绪后,最后一步就是将它们全部连接成一个可以正常运行的服务器。路由器会将特定的 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;
}

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

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} 上启动');
}

运行服务器:

dart run bin/server.dart
# ✅ 数据库已连接:localhost:5432/user_profile_api
# ✅ 迁移脚本已应用:migrations/001_create_users.sql
# ✅ 迁移脚本已应用:migrations/002_create_profiles.sql
# 🚀 服务器正在8080端口运行

部署

服务器现已在本地运行,所有端点也都能够正常工作。现在,是时候将其正式部署到生产环境中了。

我们将介绍两种部署方案:首先使用Docker Compose将应用程序和数据库打包在一起,以便进行本地生产环境测试;之后再将部署到Fly.io平台上,在那里你的API可以通过互联网访问,同时还会使用托管的PostgreSQL数据库以及自动加密机制。

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

这是一个多阶段的构建过程。第一阶段使用完整的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:

对Postgres服务进行的健康检查可以确保只有在数据库准备好接受连接时,API容器才会启动——这对于避免服务同时启动时可能出现的故障非常有用。

完成所有配置后,执行构建并运行应用程序:

docker compose up --build

将应用部署到Fly.io平台

Fly.io是用于部署容器化后端服务的非常理想的平台。它能够处理全球范围内的服务分发、自动配置TLS连接,并负责管理PostgreSQL数据库。

步骤1 – 安装并登录:

# 在macOS系统上
brew install flyctl

# 登录
fly auth login

步骤2 – 启动应用程序:

fly launch

Fly会自动检测到Dockerfile文件,然后会询问用户一些信息:应用程序名称、所在地区以及是否需要创建PostgreSQL数据库。如果选择创建数据库,Fly会自动配置并管理该数据库,并生成相应的连接字符串。

步骤3 – 设置环境变量:

fly secrets set JWT_SECRET="你的生产密钥"
fly secrets set JWT_EXPIRY_HOURS="24"

Fly在配置PostgreSQL集群时会自动设置数据库连接相关的变量。

步骤4 – 部署应用程序:

fly deploy

Fly会生成Docker镜像,将其上传到指定的注册服务器中,然后部署到你选择的地区。部署完成后,你可以使用以下命令查看应用程序的状态:

fly status
# 你的应用程序正在https://your-app-name.fly.dev上运行

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

测试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"
  }'

响应结果:

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

结论

你刚刚使用Dart构建并部署了一个适用于生产环境的REST API——这种语言你之前在Flutter项目中就已经熟悉了。不需要学习新的语言或新的开发模式,只需要让Dart在不同的环境中运行而已。

Shelf所采用的设计架构(处理器、中间件、管道、路由器等)被刻意设计得非常简洁。它不会替你做出决策,而是提供一些可组合的基本组件,让你能够根据项目需求自行构建所需的架构。对于那些喜欢自己设计清晰、有条理的架构、而不是依赖现成的框架的Flutter开发者来说,这种设计理念一定会让他们感到熟悉。

你在这里所构建的各种组件——模型、仓库、服务、处理器以及中间件——其实都与你在Flutter项目中采用的分层开发原则是一样的。这些概念是可以直接迁移应用的,Dart相关的技能和架构设计方法也同样可以应用到这里。

通过这个例子,你可以清楚地看到:Dart确实是一种非常强大的语言,它既适用于前端开发,也适用于后端开发。除了Shelf之外,还有Dartfrog和Serverpod这些工具,在后端开发领域也表现非常好。关于这些工具的更多信息,我们会在后续的文章中详细介绍。

所以,赶紧试试看吧,之后再感谢我吧!

Comments are closed.