Swift on Server Tour 2 连通你的数据库与服务器

在本章,我们将设计 Micro Blog 中 Post(帖子)的数据模型,并使用 PostgreSQL 作为我们的数据库来存储内容,在最后,我们会编写一个单元测试,来测试 Post 的创建功能。

Table of Contents

设计 Post 的数据模型

在我们平时使用的微博系统里,帖子都是由用户发布的,因此作为数据模型的设计,通常的顺序也是先设计用户,然后再设计 Post 的数据模型。

但为了更好的理解如何构建数据之间的关系,我决定这次从 Post 的数据模型开始。

一个简单的 Post 含有以下两个属性

  • content 内容
  • createdAt 创建时间

如果用表格来表示我们的数据,那么看起来就是这样的

content createdAt
这是第一篇博文 2023/7/9 14:42

当发布了更多的数据的时候,表格内容就会变成这样

content createdAt
这是第一篇博文 2023/7/9 14:42
在 LONCAFE 写代码感觉不错呢! 2023/7/9 14:44

我们最好给每条记录再加上一个不重复的 ID,这样我们就可以通过 ID 来快速准确的表示某一条帖子。

id content createdAt
0 这是第一篇博文 2023/7/9 14:42
1 在 LONCAFE 写代码感觉不错呢! 2023/7/9 14:44

Hint

事实上数据在存储在数据库的时候,也正是一个个类似这样的表格

接下来,我们尝试用 Swift 中的 来表示这个数据结构

class Post {
let id: Int
let content: String
let createdAt: Date
}

这样,我们就得到了 Post 这个数据模型最原始的状态。

让 Vapor 认识 Post

现在 Vapor 还不知道如何在数据库里操作 Post 类型的数据,因为我们还有很多信息没有提供给 Vapor,比如:

  1. Vapor 并不知道 Post 这个数据模型与「存储在数据库内的表结构」的对应关系
  2. Vapor 并不知道我们要用的数据库是什么,也不知道如何连接到那个数据库

那么接下来就一步步的解决这些问题

将 Post 写成 Vapor Model

Vapor 使用自己的 Fluent 来完成与数据库的通信,这个功能也被通称为 ORM (Object-relational mapping)

我们修改 Package.swift 来加入 Fluent 的依赖,因为我们不再是一个简单的 HelloWorld 了,因此也顺便把 name 改成 MicroBlog 吧。

// swift-tools-version:5.8
import PackageDescription
let package = Package(
name: "MicroBlog",
platforms: [
.macOS(.v12)
],
dependencies: [
.package(url: "https://github.com/vapor/vapor.git", from: "4.77.0"),
.package(url: "https://github.com/vapor/fluent.git", from: "4.4.0"),
],
targets: [
.executableTarget(
name: "App",
dependencies: [
.product(name: "Vapor", package: "vapor"),
.product(name: "Fluent", package: "fluent"),
]
),
]
)

接下来,我们创建 Sources/App/Models/Post.swift

import Vapor
import Fluent
final class Post: Model {
// 数据库中的表名
static let schema = "posts"
// 唯一性标识符
@ID(key: .id)
var id: UUID?
// 内容
@Field(key: "content")
var content: String
// 创建时间
@Timestamp(key: "created_at", on: .create)
var createdAt: Date?
init() { }
init(id: UUID? = nil, content: String) {
self.id = id
self.content = content
}
}

在 Vapor 中,我们通过一些 Property Wrapper 来辅助完成 Model 和数据库中表的关系映射

  • schema - 是指在数据库存储这个类型的数据的「表的名称」即 posts
  • @ID 是数据在数据库表中的「唯一性标识符」,在 Vapor 中默认推荐使用 UUID 随机字符串来作为 ID,并会默认使用字符串 id 在数据库的表中作为字段名,你可以使用 @ID(custom: "") 来修改这个字段名。
  • @Field 是表示要存储在数据库中的数据属性,通过 @Field(key: "content") 我们显式的声明了 var content: String 对应的是数据库表中 content 这个字段。
  • @Timestamp 是一个特殊的 @Field 类型,专门用来表示存储的是时间,同时带有一个 trigger 功能,在这里我们使用 on: .create 来表示,当 Post 被创建时,自动记录时间。

至此 Vapor 就可以认识 Post 这个数据类型啦。

使用 Docker 启动 PostgreSQL 数据库

我们使用 PostgreSQL 作为我们的数据库服务,直接在电脑上安装 PostgreSQL 是一件比较复杂的事情,通过 Docker 我们可以简化这个过程。

Hint

Docker 是一种轻量级的容器化技术,将 App 运行所需要的运行时封装在一起变成一个沙盒环境,通过这项技术,我们可以在 Linux 系统上无缝的启动其他 App 而不需要在 Host 上安装各种依赖。你可以通过安装 Docker Desktop 来使用这项技术

编写 docker-compose.yml

docker-compos.yml 是容器编排文件,我们在这个文件中描述自己所需要的容器 App 以及其运行的环境变量,网络设置等。

首先在项目根目录里创建 docker-compose.yml ,编写以下内容

version: '3.7' # 定义 Docker Compose 文件的版本,此处使用的是版本 3.7
volumes: # 定义卷部分
db_data: # docker 会使用这个键作为名字,自动创建 db_data 卷来存储数据
services: # 定义服务部分
db: # db 服务配置
image: 'postgres:15-alpine' # 使用 PostgreSQL 15 Alpine 版本的镜像
volumes: # 定义挂载卷
- 'db_data:/var/lib/postgresql/data/pgdata' # 将 db_data 卷挂载到容器的 /var/lib/postgresql/data/pgdata 目录
environment: # 定义环境变量
PGDATA: '/var/lib/postgresql/data/pgdata' # 设置 PGDATA 环境变量为 /var/lib/postgresql/data/pgdata
POSTGRES_USER: 'vapor_username' # 设置 POSTGRES_USER 环境变量为 vapor_username
POSTGRES_PASSWORD: 'vapor_password' # 设置 POSTGRES_PASSWORD 环境变量为 vapor_password
POSTGRES_DB: 'vapor_database' # 设置 POSTGRES_DB 环境变量为 vapor_database
ports: # 定义端口映射,将主机的 5432 端口映射到容器的 5432 端口
- '5432:5432'

现在我们在终端中进入 docker-compose.yml 所在的位置,使用 docker-compose up db 命令就可以启动数据库服务了,数据库将在本机的 5432 端口监听。

Caution

如果你看到了这样的错误 Cannot connect to the Docker daemon at unix:///var/run/docker.sock. Is the docker daemon running? 请检查是否启动了 Docker Desktop

让 Vapor 连接到数据库

接下来,我们的目标是让 Vapor 获取到我们的服务器信息,连接到我们的服务器。

我们首先修改 Package.swift 增加 Fluent 对 PostgreSQL 的支持

// swift-tools-version:5.8
import PackageDescription
let package = Package(
name: "MicroBlog",
platforms: [
.macOS(.v12)
],
dependencies: [
.package(url: "https://github.com/vapor/vapor.git", from: "4.77.0"),
.package(url: "https://github.com/vapor/fluent.git", from: "4.4.0"),
.package(url: "https://github.com/vapor/fluent-postgres-driver.git", from: "2.7.2"),
],
targets: [
.executableTarget(
name: "App",
dependencies: [
.product(name: "Vapor", package: "vapor"),
.product(name: "Fluent", package: "fluent"),
.product(name: "FluentPostgresDriver", package: "fluent-postgres-driver"),
]
),
]
)

随后,在 Sources/App/main.swift 中增加连接数据库的信息

// ...
app.databases.use(.postgres(configuration: SQLPostgresConfiguration(
hostname: "localhost",
port: 5432,
username: "vapor_username",
password: "vapor_password",
database: "vapor_database",
tls: .prefer(try .init(configuration: .clientDefault)))
), as: .psql)
try app.run()

至此,Vapor 便知道如何连接到数据库了,但目前数据库还是一张白纸,并没有建立我们所需要的数据表,因此,在最后我们还需要写一个叫做 Migration 的东西,来更新数据库上的表结构。

使用 Migration 来创建数据库表

Migration 是 Fluent 中用来对数据库表结构进行迁移的功能,接下来我们来了解如何使用这个工具。

Sources/App/Migrations/1_CreatePost.swift 写入以下内容

import Fluent
// 定义 CreatePost 结构体,实现 AsyncMigration 协议
struct CreatePost: AsyncMigration {
// 准备方法,在数据库上进行准备操作
func prepare(on database: Database) async throws {
// 创建 Post 表的数据库模式对象
try await database.schema(Post.schema)
.id() // 添加 id 列
.field("content", .string, .required) // 添加 content 列,类型为字符串,不能为空
.field("created_at", .datetime) // 添加 created_at 列,类型为日期时间
.create() // 创建 Post 表
}
// 回滚方法,在数据库上进行回滚操作
func revert(on database: Database) async throws {
try await database.schema(Post.schema).delete() // 删除 Post 表的数据库模式对象
}
}

在运行时,以上的代码会被转换成 SQL 语句,以 prepare 中的代码为例,将会被转换成如下 SQL 代码

CREATE TABLE IF NOT EXISTS public.posts
(
id uuid NOT NULL,
content text COLLATE pg_catalog."default" NOT NULL,
created_at timestamp with time zone,
CONSTRAINT posts_pkey PRIMARY KEY (id)
)

因此 Migration 也只是 Fluent 这个 ORM 对 SQL 的封装,通过这种封装,可以大幅减少我们编写 SQL 时出错的情况,并能通过常用场景的 SQL 语句优化来提升性能的表现。

如果你需要用到数据库的高级功能的话,Fluent 也支持直接使用 SQL 语句进行 Migration.

注册并执行 Migration

我们需要在 Sources/App/main.swit 中将 CreatePost 注册到 App 中以便一会我们执行 Migration 的时候,App 知道内容是什么

//...
app.migrations.add([CreatePost()])
try app.run()

现在,在项目根目录执行 swift run App migrate ,输入 y 就可以完成数据库中表结构的更新。

The following migration(s) will be prepared:
+ App.CreatePost on default
Would you like to continue?
y/n> y

编写 Unit Test 测试创建 Post

接下来,我们编写 Unit Test 来测试 Post 的创建功能,在 Tests/AppTests/PostTests.swift 中写入以下内容

Hint

编写 Unit Test 可以针对功能进行自动化测试,确保我们服务器的功能不出现异常,我们将在后续章节中继续深入讨论 Unit Test 的使用

@testable import App
import XCTVapor
final class PostTests: XCTestCase {
func testCreatePost() async throws {
let app = Application(.testing)
defer { app.shutdown() }
// autoRevert 将自动执行所有 Migration 中 revert 的内容
try await app.autoRevert()
// autoMigrate 将自动执行所有 Migration 中 prepare 的内容
// 这两步将重建我们的数据库,为我们提供一个干净的测试环境
try await app.autoMigrate()
let post = Post(content: "Hello, world!")
try await post.save(on: app.db)
let postID = try? post.requireID()
// 如果 postID 不为 nil 则成功创建,测试通过
XCTAssertNotNil(postID)
}
}

随后,修改我们的 Package.swift 文件添加关于 Test 相关的描述

// swift-tools-version:5.8
import PackageDescription
let package = Package(
name: "MicroBlog",
platforms: [
.macOS(.v12)
],
dependencies: [
.package(url: "https://github.com/vapor/vapor.git", from: "4.77.0"),
.package(url: "https://github.com/vapor/fluent.git", from: "4.4.0"),
.package(url: "https://github.com/vapor/fluent-postgres-driver.git", from: "2.7.2"),
],
targets: [
.executableTarget(
name: "App",
dependencies: [
.product(name: "Vapor", package: "vapor"),
.product(name: "Fluent", package: "fluent"),
.product(name: "FluentPostgresDriver", package: "fluent-postgres-driver"),
]
),
.testTarget(name: "AppTests", dependencies: [
.target(name: "App"),
.product(name: "XCTVapor", package: "vapor"),
])
]
)

现在,我们可以通过在 Package.swift 所在的路径执行 swift test 来运行单元测试

现在我们会获得一个测试没有通过的提示

FluentKit/Databases.swift:162: Fatal error: No default database configured.
error: Exited with signal code 5

这是因为写在 main.swift 中关于数据库连接的内容并不会在测试中执行,我们需要重构这部分代码,使得两边都可以使用

使用 configure.swift 重构 App 初始化

Sources/App/configure.swift 中写入以下代码

import Fluent
import FluentPostgresDriver
import Vapor
public func configure(_ app: Application) async throws {
app.databases.use(.postgres(configuration: SQLPostgresConfiguration(
hostname: "localhost",
port: 5432,
username: "vapor_username",
password: "vapor_password",
database: "vapor_database",
tls: .prefer(try .init(configuration: .clientDefault)))
), as: .psql)
app.migrations.add([CreatePost()])
}

修改 main.swift 使用 configure

import Vapor
import Fluent
import FluentPostgresDriver
let app = Application()
app.http.server.configuration.port = 8080
defer { app.shutdown() }
app.get { req async in
"It works!"
}
try await configure(app)
try app.run()

修改 PostTests.swift 使用 configure

@testable import App
import XCTVapor
final class PostTests: XCTestCase {
func testCreatePost() async throws {
let app = Application(.testing)
defer { app.shutdown() }
try await configure(app)
// autoRevert 将自动执行所有 Migration 中 revert 的内容
try await app.autoRevert()
// autoMigrate 将自动执行所有 Migration 中 prepare 的内容
// 这两步将重建我们的数据库,为我们提供一个干净的测试环境
try await app.autoMigrate()
let post = Post(content: "Hello, world!")
try await post.save(on: app.db)
let postID = try? post.requireID()
// 如果 postID 不为 nil 则成功创建,测试通过
XCTAssertNotNil(postID)
}
}

现在运行 swift test 我们将会看到测试通过的信息

Test Case '-[AppTests.PostTests testCreatePost]' passed (0.263 seconds).

恭喜你,Vapor 和数据库连通起来了!

本章代码

你可以在 https://github.com/kevinzhow/swift-on-server-tour/tree/main/2 找到本章的相关代码。

拓展:使用 pgAdmin 查看数据库的内容

如果你希望查看数据库里创建了什么内容,使用 pgAdmin 可以连接到 PostgreSQL

Untitled.png

下章预告

在下一个章节,我们将编写 API 来实现 Post 的 CURD(Create Update Read Delete)并进一步学习测试的使用