跳转至

第9章:父母与子女的关系

第5章,"Fluent与持久的模型",介绍了模型的概念。在这一章中,你将学习如何在两个模型之间建立父-子关系。你还将学习这些关系的目的,如何在Vapor中建模以及如何用路由来使用它们。

Note

本章要求你已经设置并配置了PostgreSQL。按照第6章"配置数据库"的步骤,在Docker中设置PostgreSQL并配置Vapor应用程序。

父-子关系

父-子关系描述了一种关系,其中一个模型对一个或多个模型有"所有权"。它们也被称为一对一和一对多关系。

例如,如果你对人和宠物之间的关系进行建模,一个人可以有一个或多个宠物。一个宠物只能有一个主人。在TIL应用程序中,用户将创建首字母缩写。用户(父)可以有很多首字母缩写,而一个首字母缩写(子)只能由一个用户创建。

创建一个用户

Xcode中,在Sources/App/Models中为User类创建一个名为User.swift的新文件。接下来,在Sources/App/Migrations中创建一个迁移文件,CreateUser.swift。最后,在Sources/App/Controllers中为UsersController创建一个名为UsersController.swift的文件。

用户模型

Xcode中,打开User.swift,为用户创建一个基本模型:

import Fluent
import Vapor

final class User: Model, Content {
  static let schema = "users"

  @ID
  var id: UUID?

  @Field(key: "name")
  var name: String

  @Field(key: "username")
  var username: String

  init() {}

  init(id: UUID? = nil, name: String, username: String) {
    self.name = name
    self.username = username
  }
}

该模型包含两个String属性,用于保存用户的姓名和用户名。它还包含一个可选的id属性,存储数据库在保存模型时分配给它的ID。你用相关的属性包装器来注解每个属性。

接下来,打开CreateUser.swift,插入以下内容:

import Fluent

// 1
struct CreateUser: Migration {
  // 2
  func prepare(on database: Database) -> EventLoopFuture<Void> {
    // 3
    database.schema("users")
      // 4
      .id()
      // 5
      .field("name", .string, .required)
      .field("username", .string, .required)
      // 6
      .create()
  }

  // 7
  func revert(on database: Database) -> EventLoopFuture<Void> {
    database.schema("users").delete()
  }
}

这就是你的迁移工作:

  1. 为迁移创建一个新的类型,在数据库中创建用户表。
  2. 按照Migration的要求实现prepare(on:)
  3. User设置模式,表的名称为users
  4. 使用默认属性创建ID列。
  5. 为其他两个属性创建列。这两个都是String,并且是必需的。这些列的名称与每个属性的属性包装器中定义的键相匹配。
  6. 创建表格。
  7. 按照Migration的要求实现revert(on:)。这将删除名为users的表。

Finally, open configure.swift to add CreateUser to the migration list. Insert the following after app.migrations.add(CreateAcronym()):

最后,打开configure.swift,将CreateUser添加到迁移列表中。在app.migrations.add(CreateAcronym())之后插入以下内容:

app.migrations.add(CreateUser())

这样就把新的模型添加到了迁移中,这样Fluent就会在下一次应用程序启动时在数据库中准备好这个表。

用户控制器

打开UsersController.swift,创建一个可以创建用户的新控制器:

import Vapor

// 1
struct UsersController: RouteCollection {
  // 2
  func boot(routes: RoutesBuilder) throws {
    // 3
    let usersRoute = routes.grouped("api", "users")
    // 4
    usersRoute.post(use: createHandler)
  }

  // 5
  func createHandler(_ req: Request) 
    throws -> EventLoopFuture<User> {
    // 6
    let user = try req.content.decode(User.self)
    // 7
    return user.save(on: req.db).map { user }
  }
}

现在看起来应该很熟悉了;以下是它的作用:

  1. 定义一个新的类型UsersController,符合RouteCollection
  2. 按照RouteCollection的要求实现boot(routes:)
  3. 为路径/api/users创建一个新的路由组。
  4. 注册createHandler(_:)以处理对/api/usersPOST请求。
  5. 定义路由处理函数。
  6. 从请求体中解码用户。
  7. 保存解码后的用户。save(on:)返回EventLoopFuture<Void>,所以使用map(_:)来等待保存完成并返回保存的用户。

最后,打开routes.swift,在routes(_:)`的末尾添加以下内容:

// 1
let usersController = UsersController()
// 2
try app.register(collection: usersController)

下面是这个的作用:

  1. 创建一个UsersController实例。
  2. 在路由器上注册新的控制器实例,以连接路由。

再次打开UsersController.swift,在UsersController的末尾添加以下内容。这些函数分别返回一个所有用户的列表和一个单一用户:

// 1
func getAllHandler(_ req: Request) 
  -> EventLoopFuture<[User]> {
  // 2
  User.query(on: req.db).all()
}

// 3
func getHandler(_ req: Request) 
  -> EventLoopFuture<User> {
  // 4
  User.find(req.parameters.get("userID"), on: req.db)
      .unwrap(or: Abort(.notFound))
}

下面是这个的作用:

  1. 定义一个新的路由处理程序,getAllHandler(_:),返回EventLoopFuture<[User]>
  2. 使用Fluent查询返回所有的用户。
  3. 定义一个新的路由处理程序,getHandler(_:),返回EventLoopFuture<User>
  4. 返回请求中名为userID的参数所指定的用户。

boot(routes:)的最后注册这两个路由处理程序:

// 1
usersRoute.get(use: getAllHandler)
// 2
usersRoute.get(":userID", use: getHandler)

下面是这个的作用:

  1. 注册getAllHandler(_:)来处理对/api/users/GET请求。
  2. 注册getHandler(_:)来处理对/api/users/<user ID>GET请求。这使用一个动态路径组件,与你在getHandler(_:)中搜索的参数相匹配。

建立并运行应用程序,然后在RESTed中创建一个新的请求。配置该请求如下:

  • URL: http://localhost:8080/api/users
  • method: POST
  • Parameter encoding: JSON-encoded

添加两个带有名称和值的参数:

  • name: your name
  • username: a username of your choice

发送请求,你会在响应中看到已保存的用户:

img

设置关系

Vapor中建立父子关系的模型与数据库建立关系的方式一致,但是以一种Swifty的方式。因为一个用户拥有每个首字母缩写,所以你为首字母缩写添加一个用户属性。数据库将其表示为缩略语表中对用户的引用。这使得Fluent能够有效地搜索数据库。

要获得一个用户的所有首字母缩写,你要检索所有包含该用户引用的首字母缩写。要获得一个首字母缩写的用户,你要使用该首字母缩写的用户。Fluent使用属性包装器来实现这一切。

打开Acronym.swift,在var long: String后面添加一个新的属性:

@Parent(key: "userID")
var user: User

这将在模型中添加一个User属性。它使用@Parent属性包装器来创建两个模型之间的链接。注意这个类型不是可选的,所以一个缩写必须有一个用户。@Parent是另一个特殊的Fluent属性包装器。它告诉Fluent这个属性代表了一个父子关系的父方。Fluent使用它来查询数据库。@Parent也允许你只用一个UserID来创建一个Acronym,而不需要一个完整的User对象。这有助于避免额外的数据库查询。

将初始化器替换为以下内容以反映这一点:

// 1
init(
  id: UUID? = nil, 
  short: String, 
  long: String,
  userID: User.IDValue
) {
  self.id = id
  self.short = short
  self.long = long
  // 2
  self.$user.id = userID
}

以下是你改变的内容:

  1. 为用户的ID在初始化器中添加一个新参数,类型为User.IDValue。这是由Model定义的一个类型别名,它可以解析为UUID

  2. 设置user属性包装器的预测值的ID。正如上面所讨论的,这可以避免你必须进行查找以获得完整的User模型来创建`Acronym'。

最后,打开CreateAcronym.swift。在.create()之前添加以下一行:

.field("userID", .uuid, .required)

这将使用提供给@Parent属性包装器的键,为user添加新的列。列的类型,uuid,与CreateUserID列类型一致。

域名传输对象(DTOs)

你可以发送一个带有JSON有效载荷的请求来匹配新的Acronym模型。然而,它看起来像:

{
  "short": "OMG",
  "long": "Oh My God",
  "user": {
    "id": "2074AD1A-21DC-4238-B3ED-D076BBE5D135"
  }
}

因为Acronym有一个user属性,JSON必须与之匹配。属性包装器允许你只为user发送一个id,但它的创建仍然很复杂。为了解决这个问题,你使用了一个域名传输对象或DTODTO是一种类型,代表客户应该发送或接收什么。你的路由处理程序然后接受一个DTO,并将其转换为你的代码可以使用的东西。在AcronymsController.swift的底部,添加以下代码:

struct CreateAcronymData: Content {
  let short: String
  let long: String
  let userID: UUID
}

这个DTO代表我们期望从客户端得到的JSON

{
  "short": "OMG",
  "long": "Oh My God",
  "userID": "2074AD1A-21DC-4238-B3ED-D076BBE5D135"
}

接下来,将createHandler(_:)的主体替换为以下内容:

// 1
let data = try req.content.decode(CreateAcronymData.self)
// 2
let acronym = Acronym(
  short: data.short, 
  long: data.long,
  userID: data.userID)
return acronym.save(on: req.db).map { acronym }

以下是更新后的代码变化:

  1. 将请求主体解码为CreateAcronymData,而不是Acronym
  2. 从收到的数据中创建一个Acronym

这就是你需要做的建立关系的全部工作! 在你运行应用程序之前,你需要重置数据库。Fluent已经运行了CreateAcronym迁移,但是表现在有一个新的列。为了在表中添加新的列,你必须删除数据库,这样Fluent才会再次运行迁移。在Xcode中停止应用程序,然后在终端输入:

# 1
docker stop postgres
# 2
docker rm postgres
# 3
docker run --name postgres -e POSTGRES_DB=vapor_database \
  -e POSTGRES_USER=vapor_username \
  -e POSTGRES_PASSWORD=vapor_password \
  -p 5432:5432 -d postgres

下面是这个的作用:

  1. 停止运行中的Docker容器postgres。这是当前运行数据库的容器。
  2. 移除Docker容器postgres,以删除任何现有的数据。
  3. 启动一个新的运行PostgreSQLDocker容器。更多信息,请参阅第6章,"配置数据库"。

img

Note

新的迁移也可以改变表,这样你在改变模型时就不会丢失生产数据。第27章,"数据库/API版本管理和迁移 "涵盖了这一点。

Xcode中构建并运行应用程序,迁移运行。打开RESTed,按照本章前面的步骤创建一个用户。请确保你复制了返回的ID

RESTed中创建一个新的请求,并对其进行如下配置:

  • URL: http://localhost:8080/api/acronyms
  • method: POST
  • Parameter encoding: JSON-encoded

添加三个带有名称和值的参数:

  • short: OMG
  • long: Oh My God
  • userID: the ID you copied earlier

点击Send Request。你的应用程序就会用指定的用户创建缩写:

img

最后,打开AcronymsController.swift,将updateHandler(_:)替换为以下内容,以说明Acronym的新属性:

func updateHandler(_ req: Request) throws 
    -> EventLoopFuture<Acronym> {
  let updateData = 
    try req.content.decode(CreateAcronymData.self)
  return Acronym
    .find(req.parameters.get("acronymID"), on: req.db)
    .unwrap(or: Abort(.notFound))
    .flatMap { acronym in
      acronym.short = updateData.short
      acronym.long = updateData.long
      acronym.$user.id = updateData.userID
      return acronym.save(on: req.db).map {
        acronym
      }
    }
}

这就用请求中提供的新值来更新缩写的属性,包括新的用户ID

查询关系

用户和首字母缩写现在是以父子关系连接的。然而,在你能够查询这些关系之前,这并不是非常有用。Fluent再次让这一切变得简单。

获取父类

打开AcronymsController.swift,在sortedHandler(_:)后面添加一个新的路由处理程序:

// 1
func getUserHandler(_ req: Request) 
  -> EventLoopFuture<User> {
  // 2
  Acronym.find(req.parameters.get("acronymID"), on: req.db)
    .unwrap(or: Abort(.notFound))
    .flatMap { acronym in
      // 3
      acronym.$user.get(on: req.db)
    }
}

下面是这个路由处理程序的工作:

  1. 定义一个新的路由处理程序,getUserHandler(_:),它返回EventLoopFuture<User>
  2. 获取请求参数中指定的首字母缩写,并解包返回的未来。
  3. 使用属性包装器从数据库中获取首字母缩写的所有者。这将对User表进行查询,以找到数据库中保存有ID的用户。如果你试图用acronym.user访问该属性,你会得到一个错误,因为你没有从数据库中检索到用户。第31章,"高级Fluent",讨论了急于加载和处理属性的问题。

boot(routes:)的结尾处注册路由处理程序:

acronymsRoutes.get(":acronymID", "user", use: getUserHandler)

这将HTTP GET请求连接到/api/acronyms/<ACRONYM ID>/usergetUserHandler(_:)

建立并运行应用程序,然后在RESTed中创建一个新的请求。配置该请求如下:

  • URL: http://localhost:8080/api/acronyms//user
  • method: GET

发送请求,你会看到响应返回首字母缩写的用户:

img

获取子类

获取一个模型的子类也遵循类似的模式。打开User.swift,在下面添加一个新属性var username: String

@Children(for: \.$user)
var acronyms: [Acronym]

这定义了一个新的属性--用户的首字母缩写。你用@Children属性包装器来注释这个属性。@Children告诉Fluentacronyms代表父子关系中的子女。这就像你在第5章"Fluent和持久化模型"中看到的@ID@Field

@Parent不同,@Children并不代表数据库中的任何列。Fluent使用它来知道要为这个关系链接什么。你向属性封装器传递一个关键路径到子模型上的父属性封装器。在这种情况下,你使用Acronym.$user,或者直接使用\.$user。在检索所有的子模型时,Fluent使用这个来查询数据库。

Fluent对属性包装器的使用也使得它能够处理模型的编码和解码。User包含了一个所有首字母缩写的属性。通常Codable会要求你提供所有的首字母缩写来从JSON中创建一个用户。当创建一个首字母缩写时,你也必须实例化这个数组。@Children允许你拥有两全其美的方法--用一个属性来代表所有的孩子,而不必在创建模型时指定它。

打开UsersController.swift,在getHandler(_:)后面添加一个新的路由处理程序:

// 1
func getAcronymsHandler(_ req: Request) 
  -> EventLoopFuture<[Acronym]> {
  // 2
  User.find(req.parameters.get("userID"), on: req.db)
    .unwrap(or: Abort(.notFound))
    .flatMap { user in
      // 3
      user.$acronyms.get(on: req.db)
    }
}

下面是这个路由处理程序的工作:

  1. 定义一个新的路由处理程序,getAcronymsHandler(_:),返回EventLoopFuture<[Acronym]>
  2. 获取请求参数中指定的用户,并解包返回的未来。
  3. 使用上面创建的新的属性包装器,使用Fluent查询来返回所有的首字母缩写词。记住,这是使用属性包装器的预测值,不是包装的值。

boot(routes:)的结尾处注册路由处理程序:

usersRoute.get(
  ":userID", 
  "acronyms", 
  use: getAcronymsHandler)

这将HTTP GET请求连接到/api/users/<user ID>/acronymsgetAcronymsHandler(_:)

建立并运行应用程序,然后在RESTed中创建一个新的请求。配置该请求如下:

  • URL: http://localhost:8080/api/users//acronyms
  • method: GET

发送请求,你会看到响应会返回用户的首字母缩写:

img

外键约束

外键约束描述了两个表之间的联系。它们经常被用于验证。目前,数据库中的用户表和缩写表之间没有联系。Fluent是唯一知道这个链接的东西。

使用外键约束有很多好处:

  • 它确保你不能用不存在的用户创建首字母缩写。
  • 你不能删除用户,直到你删除了他们所有的首字母缩写。
  • 在你删除首字母缩写表之前,你不能删除用户表。

外键约束是在迁移中设置的。打开CreateAcronym.swift,将.field("userID", .uuid, .required)替换为以下内容:

.field("userID", .uuid, .required, .references("users", "id"))

这与之前的做法相同,但也从userID列添加了一个引用到用户表中的id列。

最后,因为你要将首字母缩写的userID属性链接到User表,所以你必须先创建User表。在configure.swift中,将User迁移移到Acronym迁移之前:

app.migrations.add(CreateUser())
app.migrations.add(CreateAcronym())

这可以确保Fluent以正确的顺序创建表。

Xcode中停止应用程序,并按照前面的步骤来删除数据库。

构建并运行该应用程序,然后在RESTed中创建一个新的请求。配置该请求如下:

  • URL: http://localhost:8080/api/acronyms/
  • method: POST
  • Parameter encoding: JSON-encoded

添加三个带有名称和值的参数:

  • short: OMG
  • long: Oh My God
  • userID: E92B49F2-F239-41B4-B26D-85817F0363AB

这是一个有效的UUID字符串,但并不指任何用户,因为数据库是空的。发送请求;你会得到一个错误,说有一个违反外键约束的问题:

img

像你之前做的那样创建一个用户并复制ID。再次发送创建首字母缩写的请求,这次使用有效的ID。应用程序创建首字母缩写,没有任何错误。

接下来去哪?

在本章中,你学到了如何使用FluentVapor中实现父子关系。这使你可以开始在数据库中的模型之间创建复杂的关系。下一章将介绍数据库中另一种类型的关系:兄弟姐妹关系。