Fluent 前面我们有提到过,它是一个
ORM
库,在创建项目的时候,我们也选用了它。现在我们开始使用它去搭建数据层吧!
设置FLuent
因为在第一篇创建项目的时候我们已经通过SPM设置了Fluent的依赖了,所以我们现在可以直接使用,如果你还没有添加依赖的话,可以通过修改Package.swift
添加Fluent的依赖。
首先我们开始在 configure
文件配置数据库文件吧。
import Fluent
import FluentSQLiteDriver
// configures your application
public func configure(_ app: Application) throws {
// ...
/// setup Fluent with a SQLite database under the Resources directory
let dbPath = app.directory.resourcesDirectory + "db.sqlite"
app.databases.use(.sqlite(.file(dbPath)), as: .sqlite)
//...
}
SQLite database driver
可以将数据保存到内存或文件存储中,我们配置它以将所有的内容持久化到 Resources
目录下的 db.sqlite
文件中,如果不存在的话请创建一个 Resources
目录。
使用 Models
下一步,我们开始为数据库创建一个Model实体,该实体用于表现博客文章的类别。使用Fluent 搭建model 很容易,我们只需要遵守Model
协议。
我们将会把数据库相关的内容放在Database
目录下,然后我们在创建一个Models
目录,最后在这个目录新建一个BlogCategoryModel
文件。
/// FILE: Sources/App/Modules/Blog/Database/Models/BlogCategoryModel.swift
import Vapor
import Fluent
final class BlogCategoryModel: Model {
static let schema: String = "blog_categories"
struct FieldKeys {
struct v1 {
static var title: FieldKey { "title" }
}
}
@ID() var id: UUID?
@Field(key: FieldKeys.v1.title) var title: String
@Children(for: \.$category) var posts: [BlogPostModel]
init() { }
init(id: UUID? = nil, title: String) {
self.id = id
self.title = title
}
}
schema
属性是数据库里面的表名,FieldKey
类型用代表着表的每一个行名称,所以这里我们的"blog_categories"表下有一行"title"字段,这里我们通过不同版本管理我们的FieldKeys
, 这样能方便我们进字段的版本控制与跟踪。
数据库字段都是swift的属性,通过基于Fluent
库提供的属性wrapper
装饰可以表示查询和更复杂的映射关系。
-
@ID wrapper
代表唯一的标识符 -
@Field wrapper
设置数据库的常规属性字段 -
@Children @Parent wrapper
都用于创建关系之间的链接
每一个属性wrapper都会有一个关联的字段,models 应该有一个对应的唯一标识符,最新版本的 Fluent
中使用UUID
类型是首选项,当然也可以使用类似 Int
等类型去表示,但是UUID
是最合适的选项。而常规的数据库字段,我们能使用swift中任意类型,可以存储字符串、数字、枚举,甚至是复杂的 JSON 对象。
接着我们根据前面的认识去定义 BlogPostModel
.
///FILE: Sources/App/Modules/Blog/Database/Models/BlogPostModel.swift
import Vapor
import Fluent
final class BlogPostModel: Model {
static let schema: String = "blog_posts"
struct FieldKeys {
struct v1 {
static var title: FieldKey { "title" }
static var slug: FieldKey { "slug" }
static var imageKey: FieldKey { "image_key" }
static var excerpt: FieldKey { "excerpt" }
static var date: FieldKey { "date" }
static var content: FieldKey { "content" }
static var categoryId: FieldKey { "category_id" }
}
}
@ID() var id: UUID?
@Field(key: FieldKeys.v1.title) var title: String
@Field(key: FieldKeys.v1.slug) var slug: String
@Field(key: FieldKeys.v1.imageKey) var imageKey: String
@Field(key: FieldKeys.v1.excerpt) var excerpt: String
@Field(key: FieldKeys.v1.date) var date: Date
@Field(key: FieldKeys.v1.content) var content: String
@Parent(key: FieldKeys.v1.categoryId) var category: BlogCategoryModel
init() {}
init(id: UUID?,
title: String,
slug: String,
imageKey: String,
excerpt: String,
date: Date,
content: String,
categoryId: UUID) {
self.id = id
self.title = title
self.slug = slug
self.imageKey = imageKey
self.excerpt = excerpt
self.date = date
self.content = content
$category.id = categoryId
}
}
如你所见,我们使用@Parent
与 BlogCategoryModel
关联了起来,这代表着一个类别下面有多个文章。下面我们先处理数据库迁移代码。
数据库迁移
迁移是创建、更新或删除一个或多个数据库表的过程。 换句话说,改变数据库模式的一切都是迁移。 你需要知道你可以注册多个迁移的脚本,并且 Vapor 将按照你将它们添加到迁移数组中的顺序运行它们。 我也更喜欢使用简单的 v1、v2、vN 后缀对它们进行版本控制。
我们会通过迁移去创建新的表,或者新增属性与数据填充。下面我们把Blog
模块每个版本的迁移都放在BlogMigrations
这个枚举里面。
/// FILE: Sources/App/Modules/Blog/Database/Migrations/BlogMigrations.swift
import Fluent
enum BlogMigrations {
struct v1: AsyncMigration {
func prepare(on database: Database) async throws {
try await database.schema(BlogCategoryModel.schema)
.id()
.field(BlogCategoryModel.FieldKeys.v1.title, .string, .required)
.create()
try await database.schema(BlogPostModel.schema)
.id()
.field(BlogPostModel.FieldKeys.v1.title, .string, .required)
.field(BlogPostModel.FieldKeys.v1.slug, .string, .required)
.field(BlogPostModel.FieldKeys.v1.imageKey, .string, .required)
.field(BlogPostModel.FieldKeys.v1.excerpt, .data, .required)
.field(BlogPostModel.FieldKeys.v1.date, .datetime, .required)
.field(BlogPostModel.FieldKeys.v1.content, .data, .required)
.field(BlogPostModel.FieldKeys.v1.categoryId, .uuid, .required)
.foreignKey(BlogPostModel.FieldKeys.v1.categoryId,
references: BlogCategoryModel.schema, .id,
onDelete: DatabaseSchema.ForeignKeyAction.setNull,
onUpdate: .cascade)
.unique(on: BlogPostModel.FieldKeys.v1.slug)
.create()
}
func revert(on database: Database) async throws {
try await database.schema(BlogCategoryModel.schema).delete()
try await database.schema(BlogPostModel.schema).delete()
}
}
}
我们在prepare方法内,新建我们的schema
, 通过await
来保证顺序,并且实现了revert
方法,可以在异常的时候回滚操作。
最后通过foreignKey
可以约束对应字段,使得关联出现变更时,可以同步更新。例如,如果parent category
被删除,你可以删除所有children
或将引用children
的类别设置为 null
。
下面,我们开始处理BlogPostModel
的数据填充,同样的,我们还是使用随机数据去填充。
import Fluent
enum BlogMigrations {
//...
struct seed: AsyncMigration {
func prepare(on database: Database) async throws {
let categories = (1...4).map { index in
BlogCategoryModel(title: "Sample category #\(index)")
}
try await categories.create(on: database)
try await (1...9).map { index in
BlogPostModel(id: nil, title: "Sample post #\(index)",
slug: "sample-post-\(index)",
imageKey: "/img/posts/\(String(format: "%02d", index + 1)).jpg",
excerpt: "Lorem ipsum",
date: Date().addingTimeInterval(-Double.random(in: 0...(86400 * 60))),
content: "Lorem ipsum dolor sit amet.",
categoryId: categories[Int.random(in: 0..<categories.count)].id!)
}
.create(on: database)
}
func revert(on database: Database) async throws {
try await BlogCategoryModel.query(on: database).delete()
try await BlogPostModel.query(on: database).delete()
}
}
}
因为类别和文章存在父子关系,所以我们必须先数据填充类别再填充文章。
最后一步是register
我们的迁移脚本,以便应用程序可以在需要时运行它们。 为了做到这一点,我们可以简单地更改configure
方法,以便每个模块都能够register
所需的迁移。
模块和模块接口
在上一篇文章中,我们在configure
文件中注册了我们的路由器。 到目前为止,每个模块都有一个路由器,同样的也可以有对应的数据库迁移。 我们应该意识到这是一种常见的模式,所以我们应该定义协议来管理这些东西。
/// FILE: Sources/App/Framework/ModuleInterface.swift
import Vapor
public protocol ModuleInterface {
static var identifier: String { get }
func boot(_ app: Application) throws
}
public extension ModuleInterface {
func boot(_ app: Application) throws {}
static var identifier: String { String(describing: self).dropLast(6).lowercased() }
}
ModuleInterface
是一个通用模块接口,可以通过Application
调用boot
来准备模块启动所需的组件。 之后我们可以再扩展这个接口。 我们通过 extension 为这个方法提供一个默认的实现,让它成为可选的实现。
现在可以在 Web 目录中实现一个 WebModule
对象文件。
/// FILE: Sources/App/Modules/Web/WebModule.swift
import Vapor
struct WebModule: ModuleInterface {
let router = WebRouter()
func boot(_ app: Application) throws {
try router.boot(routes: app.routes)
}
}
下一个就是在Blog目录实现BlogModule
/// FILE: Sources/App/Modules/Blog/BlogModule.swift
import Vapor
struct BlogModule: ModuleInterface {
let router = BlogRouter()
func boot(_ app: Application) throws {
app.migrations.add(BlogMigrations.v1())
app.migrations.add(BlogMigrations.seed())
try router.boot(routes: app.routes)
}
}
我们要保证表创建v1
在数据填充之前seed
,Fluent
的执行顺序也是按照我们添加顺序执行的。
对于数据库模型,我们还可以提出一个通用的 DatabaseModelInterface
,因为我们想把模块标识添加在数据库schema
中,我们系统中的数据库模型会始终将 UUID
类型作为primary id
,因此我们可以基于此创建 where 约束来限制我们的模型。
/// FILE: Sources/App/Framework/DatabaseModelInterface.swift
import Vapor
import Fluent
public protocol DatabaseModelInterface: Fluent.Model where Self.IDValue == UUID {
associatedtype Module: ModuleInterface
static var identifier: String { get }
}
public extension DatabaseModelInterface {
static var schema: String { Module.identifier + "_" + identifier}
static var identifier: String {
String(describing: self).dropFirst(Module.identifier.count).dropLast(5).lowercased() + "s"
}
}
现在我们就去更新我们models的代码,使用这个新定义的接口吧。
import Vapor
import Fluent
final class BlogCategoryModel: DatabaseModelInterface {
typealias Module = BlogModule
static let identifier = "categories"
//...
}
/// FILE: Sources/App/Modules/Blog/Database/Models/BlogPostModel.swift import Vapor
import Fluent
final class BlogPostModel: DatabaseModelInterface {
typealias Module = BlogModule
static let identifier = "posts"
// ...
}
现在我们回到configure.swift
将之前注册路由的代码改为使用我们的模块接口吧。
/// FILE: Sources/App/configure.swift
import Vapor
import Fluent
import FluentSQLiteDriver
// configures your application
public func configure(_ app: Application) throws {
// uncomment to serve files from /Public folder
app.middleware.use(FileMiddleware(publicDirectory: app.directory.publicDirectory))
/// extend paths to always contain a trailing slash
app.middleware.use(ExtendPathMiddleware())
/// setup Fluent with a SQLite database under the Resources directory
let dbPath = app.directory.resourcesDirectory + "db.sqlite"
app.databases.use(.sqlite(.file(dbPath)), as: .sqlite)
/// setup modules
let modules: [ModuleInterface] = [
WebModule(),
BlogModule()
]
for module in modules {
try module.boot(app)
}
}
这样,我们就已经准备好实际迁移数据库的工作,并会开始了解更多有关 Vapor 命令的信息。
迁移命令
你需要知道,如果你通过run
运行该应用,它将默认执行一个名为 serve
的命令,但你不必显式提供 serve
参数。 此命令负责启动底层 HTTP
服务器,当你运行应用时,serve 命令将使用给定的主机名和端口开始监听。 这是 Vapor 中的默认命令。
为了运行数据迁移,你必须使用 migrate
参数启动应用程序,这将运行迁移脚本而不是启动 Web 服务器。
swift run Run migrate
或者,你可以在 Xcode 中的 Edit Scheme 菜单下设置命令行参数:
如果您使用的是 Xcode,您可以简单地复制该 scheme
并为不同 scheme
设置不同的参数。这样您就可以通过简单地选择来运行数据迁移,例如 使用 Migrate
scheme而不是 Run
scheme。
在第一次迁移期间,Fluent 将创建一个名为 _fluent_migrations 的内部表。迁移系统正在使用此查找表来检测哪些迁移已经执行,以及下次运行 migrate 命令时需要做什么。
你可以通过运行带有 --revert 标志的 migrate 命令来恢复最后一批迁移。这将仅恢复最后一批迁移,因此你可能需要多次运行它才能恢复所有内容。或者,你可以从磁盘中删除整个 SQLite 数据库文件,这将重置所有内容。
数据库文件将在工作目录下创建。浏览 SQLite 文件非常简单,您可以下载并使用 https://tableplus.com 应用或通过 brew install tableplus 命令安装它。
使用 AUTO-MIGRATION
如果您使用 migrate 参数运行应用程序,则会看到提示,你必须确认迁移。 你可以通过在运行应用程序时提供--auto-migrate
标志作为额外参数来跳过确认。
另一种选择是在应用程序启动时自动调用迁移过程。 我们将选择这种方法,因为我们总是希望拥有最新版本的数据库schema
。 稍后你会看到,有时当你必须启动应用程序(在容器内)时,你将没有机会预先运行迁移命令。
// configures your application
public func configure(_ app: Application) throws {
// ...
/// use automatic database migration
try app.autoMigrate().wait()
}
app.autoMigrate
方法回返回一个EventLoopFuture
,这一次我们要等到迁移处理好所有事情并且数据库准备好使用。 当然,在配置方法中调用此命令与使用终端运行带有 --auto-migrate 标志的 migrate 命令具有相同的效果。
当然,在将后端服务器部署到生产环境之前,你应该测试所有内容。 不要忘记备份你的数据库并准备好在需要时恢复它们。 事故可能发生,人们会犯错误,所以要避免出现不可挽回的损失前,应该想好如何解决可能出现的问题
查询与数据转换
现在我们已经使用数据填充进行了有效的迁移,我们可以开始使用 Fluent 来检索我们的模型。 查询所有 BlogPostModel
对象并用这些类型替换之前的 BlogPost
会相对简单,但可能不是一个好设计。所以我们还是要把数据层与展示层模型分开。
这将是定义 API
层对象的好机会,我们还可以与前端客户端共享这些类型的数据传输对象 (DTO),因此例如 iOS
应用程序可以重用这部分API
层而无需重复写代码。 这将为我们的系统增加一层额外的安全性,从长远来看,我们可以节省大量时间。 让我们重构 BlogPost 吧。
首先,我们需要在 Objects 文件夹下创建一个名为 Blog 的新命名空间。
/// FILE: Sources/App/Modules/Blog/Objects/Blog.swift
enum Blog {
}
我们将使用这个命名空间在其下放置 DTOs,我们的第一个 DTO 是博客文章实体的列表对象。 让我们移动BlogPost.swift
文件到 Objects目录下的。
/// FILE: Sources/App/Modules/Blog/Objects/BlogPost.swift
import Foundation
extension Blog {
enum Post {
}
}
extension Blog.Post {
struct List: Codable {
let id: UUID
let title: String
let slug: String
let image: String
let excerpt: String
let date: Date
}
}
我们还需要一个博客类别DTO,所以让我们创建一个。
/// FILE: Sources/App/Modules/Blog/Objects/BlogCategory.swift
import Foundation
extension Blog {
enum Category {
}
}
extension Blog.Category {
struct List: Codable {
let id: UUID
let title: String
}
}
为了呈现我们的博客文章详细信息页面,我们还需要一个博客文章详细信息对象,其中包含相关的类别列表对象和博客文章的内容。
/// FILE: Sources/App/Modules/Blog/Objects/BlogPost.swift
extension Blog.Post {
//...
struct Detail: Codable {
let id: UUID
let title: String
let slug: String
let image: String
let excerpt: String
let date: Date
let category: Blog.Category.List
let content: String
}
}
如果你仔细思考一下,这很像实际的 RESTful JSON API 接口的样子。 之后我们将会重用这些对象来为我们的应用程序创建 API。 现在我们可以使用 DTO 以安全的方式展示我们的 HTML 模板。 首先我们必须改变更新 BlogPostsContext
struct BlogPostsContext {
let icon: String
let title: String
let message: String
let posts: [Blog.Post.List]
}
下一个是BlogPostContext
,我们将在那里使用 Blog.Post.Detail 对象
struct BlogPostContext {
let post: Blog.Post.Detail
}
现在让我们专注于BlogFrontendController,我将向你展示如何使用 Fluent 查询所有帖子。
import Vapor
import Fluent
struct BlogFrontendController {
func blogView(req: Request) throws -> Response {
let postsModel = try await BlogPostModel
.query(on: req.db)
.sort(\.$date, .descending)
.all()
let posts = postsModel.map {
Blog.Post.List(id: $0.id!, title: $0.title, slug: $0.slug, image: $0.imageKey, excerpt: $0.excerpt, date: $0.date)
}
let ctx = BlogPostsContext(icon: "🔥 ", title: "Blog", message: "Hot news and stories about everything.", posts: posts)
return req.templates.renderHtml(BlogPostsTemplate(ctx))
}
func postView(req: Request) throws -> Response {
let slug = req.url.path.trimmingCharacters(in: .init(charactersIn: "/"))
guard let post = try await BlogPostModel
.query(on: req.db)
.filter(\.$slug == slug)
.with(\.$category)
.first() else {
return req.redirect(to: "/")
}
let ctx = BlogPostContext(post: Blog.Post.Detail.init(id: post.id!,
title: post.title,
slug: post.slug,
image: post.imageKey,
excerpt: post.excerpt,
date: post.date,
category: .init(id: post.category.id!, title: post.category.title),
content: post.content))
return req.templates.renderHtml(BlogPostTemplate(.init(post: post)))
}
}
我们使用模型上的静态查询方法从数据库表中请求实体。这将返回一个查询构建器实例,你可以通过添加各种过滤器、限制和排序选项来调整它。使用 with 方法,你可以将关系对象加载到模型中。 all 方法将执行查询并将请求的行作为模型对象返回。之后我们将看到更多数据库查询的示例。
由于我们使用异步函数从数据库中获取博客文章,因此我们还必须将 async 关键字添加到两个请求处理方法的函数签名中。幸运的是,Vapor 能够注册同步和异步请求处理程序,但是如果你不输入async 关键字,就无法在函数内部调用异步方法。
总结
到此,我们已经探索了Fluent是如何工作的,也把我们的Blog模块的数据层搭建好了。 我们还学到了很多关于使用各种字段类型和通过属性包装器的关系来建模数据库实体的知识。 除了我们当前的模块化结构之外,我们还引入了模块和数据库模型协议,并且我们已经了解了为什么分离模板context或DTO来展示 Fluent model实体是件好事。