Vapor 框架学习记录(3)Fluent 搭建Blog数据层

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
    }
}

如你所见,我们使用@ParentBlogCategoryModel 关联了起来,这代表着一个类别下面有多个文章。下面我们先处理数据库迁移代码。

数据库迁移

迁移是创建、更新或删除一个或多个数据库表的过程。 换句话说,改变数据库模式的一切都是迁移。 你需要知道你可以注册多个迁移的脚本,并且 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在数据填充之前seedFluent的执行顺序也是按照我们添加顺序执行的。

对于数据库模型,我们还可以提出一个通用的 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模块的数据层搭建好了。 我们还学到了很多关于使用各种字段类型和通过属性包装器的关系来建模数据库实体的知识。 除了我们当前的模块化结构之外,我们还引入了模块和数据库模型协议,并且我们已经了解了为什么分离模板contextDTO来展示 Fluent model实体是件好事。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,294评论 6 493
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,493评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,790评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,595评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,718评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,906评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,053评论 3 410
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,797评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,250评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,570评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,711评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,388评论 4 332
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,018评论 3 316
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,796评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,023评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,461评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,595评论 2 350

推荐阅读更多精彩内容