第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()
}
}
这就是你的迁移工作:
- 为迁移创建一个新的类型,在数据库中创建用户表。
- 按照
Migration
的要求实现prepare(on:)
。 - 为
User
设置模式,表的名称为users
。 - 使用默认属性创建
ID
列。 - 为其他两个属性创建列。这两个都是
String
,并且是必需的。这些列的名称与每个属性的属性包装器中定义的键相匹配。 - 创建表格。
- 按照
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 }
}
}
现在看起来应该很熟悉了;以下是它的作用:
- 定义一个新的类型
UsersController
,符合RouteCollection
。 - 按照
RouteCollection
的要求实现boot(routes:)
。 - 为路径
/api/users
创建一个新的路由组。 - 注册
createHandler(_:)
以处理对/api/users
的POST
请求。 - 定义路由处理函数。
- 从请求体中解码用户。
- 保存解码后的用户。
save(on:)
返回EventLoopFuture<Void>
,所以使用map(_:)
来等待保存完成并返回保存的用户。
最后,打开routes.swift,在
routes(_:)`的末尾添加以下内容:
// 1
let usersController = UsersController()
// 2
try app.register(collection: usersController)
下面是这个的作用:
- 创建一个
UsersController
实例。 - 在路由器上注册新的控制器实例,以连接路由。
再次打开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))
}
下面是这个的作用:
- 定义一个新的路由处理程序,
getAllHandler(_:)
,返回EventLoopFuture<[User]>
。 - 使用
Fluent
查询返回所有的用户。 - 定义一个新的路由处理程序,
getHandler(_:)
,返回EventLoopFuture<User>
。 - 返回请求中名为
userID
的参数所指定的用户。
在boot(routes:)
的最后注册这两个路由处理程序:
// 1
usersRoute.get(use: getAllHandler)
// 2
usersRoute.get(":userID", use: getHandler)
下面是这个的作用:
- 注册
getAllHandler(_:)
来处理对/api/users/
的GET
请求。 - 注册
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
发送请求,你会在响应中看到已保存的用户:
设置关系¶
在Vapor
中建立父子关系的模型与数据库建立关系的方式一致,但是以一种Swifty
的方式。因为一个用户拥有每个首字母缩写,所以你为首字母缩写添加一个用户属性。数据库将其表示为缩略语表中对用户的引用。这使得Fluent
能够有效地搜索数据库。
要获得一个用户的所有首字母缩写,你要检索所有包含该用户引用的首字母缩写。要获得一个首字母缩写的用户,你要使用该首字母缩写的用户。Fluent
使用属性包装器来实现这一切。
打开Acronym.swift
,在var long: String
后面添加一个新的属性:
@Parent(key: "userID")
var user: User
这将在模型中添加一个User
属性。它使用@Parent
属性包装器来创建两个模型之间的链接。注意这个类型不是可选的,所以一个缩写必须有一个用户。@Parent
是另一个特殊的Fluent
属性包装器。它告诉Fluent
这个属性代表了一个父子关系的父方。Fluent
使用它来查询数据库。@Parent
也允许你只用一个User
的ID
来创建一个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
}
以下是你改变的内容:
-
为用户的
ID
在初始化器中添加一个新参数,类型为User.IDValue
。这是由Model
定义的一个类型别名,它可以解析为UUID
。 -
设置
user
属性包装器的预测值的ID
。正如上面所讨论的,这可以避免你必须进行查找以获得完整的User
模型来创建`Acronym'。
最后,打开CreateAcronym.swift
。在.create()
之前添加以下一行:
.field("userID", .uuid, .required)
这将使用提供给@Parent
属性包装器的键,为user
添加新的列。列的类型,uuid
,与CreateUser
的ID
列类型一致。
域名传输对象(DTOs
)¶
你可以发送一个带有JSON
有效载荷的请求来匹配新的Acronym
模型。然而,它看起来像:
{
"short": "OMG",
"long": "Oh My God",
"user": {
"id": "2074AD1A-21DC-4238-B3ED-D076BBE5D135"
}
}
因为Acronym
有一个user
属性,JSON
必须与之匹配。属性包装器允许你只为user
发送一个id
,但它的创建仍然很复杂。为了解决这个问题,你使用了一个域名传输对象或DTO
。DTO
是一种类型,代表客户应该发送或接收什么。你的路由处理程序然后接受一个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 }
以下是更新后的代码变化:
- 将请求主体解码为
CreateAcronymData
,而不是Acronym
。 - 从收到的数据中创建一个
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
下面是这个的作用:
- 停止运行中的
Docker
容器postgres
。这是当前运行数据库的容器。 - 移除
Docker
容器postgres
,以删除任何现有的数据。 - 启动一个新的运行
PostgreSQL
的Docker
容器。更多信息,请参阅第6章,"配置数据库"。
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
。你的应用程序就会用指定的用户创建缩写:
最后,打开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)
}
}
下面是这个路由处理程序的工作:
- 定义一个新的路由处理程序,
getUserHandler(_:)
,它返回EventLoopFuture<User>
。 - 获取请求参数中指定的首字母缩写,并解包返回的未来。
- 使用属性包装器从数据库中获取首字母缩写的所有者。这将对
User
表进行查询,以找到数据库中保存有ID
的用户。如果你试图用acronym.user
访问该属性,你会得到一个错误,因为你没有从数据库中检索到用户。第31章,"高级Fluent
",讨论了急于加载和处理属性的问题。
在boot(routes:)
的结尾处注册路由处理程序:
acronymsRoutes.get(":acronymID", "user", use: getUserHandler)
这将HTTP GET
请求连接到/api/acronyms/<ACRONYM ID>/user
到getUserHandler(_:)
。
建立并运行应用程序,然后在RESTed
中创建一个新的请求。配置该请求如下:
- URL: http://localhost:8080/api/acronyms/
/user - method: GET
发送请求,你会看到响应返回首字母缩写的用户:
获取子类¶
获取一个模型的子类也遵循类似的模式。打开User.swift
,在下面添加一个新属性var username: String
:
@Children(for: \.$user)
var acronyms: [Acronym]
这定义了一个新的属性--用户的首字母缩写。你用@Children
属性包装器来注释这个属性。@Children
告诉Fluent
,acronyms
代表父子关系中的子女。这就像你在第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)
}
}
下面是这个路由处理程序的工作:
- 定义一个新的路由处理程序,
getAcronymsHandler(_:)
,返回EventLoopFuture<[Acronym]>
。 - 获取请求参数中指定的用户,并解包返回的未来。
- 使用上面创建的新的属性包装器,使用
Fluent
查询来返回所有的首字母缩写词。记住,这是使用属性包装器的预测值,不是包装的值。
在boot(routes:)
的结尾处注册路由处理程序:
usersRoute.get(
":userID",
"acronyms",
use: getAcronymsHandler)
这将HTTP GET
请求连接到/api/users/<user ID>/acronyms
的getAcronymsHandler(_:)
。
建立并运行应用程序,然后在RESTed
中创建一个新的请求。配置该请求如下:
- URL: http://localhost:8080/api/users/
/acronyms - method: GET
发送请求,你会看到响应会返回用户的首字母缩写:
外键约束¶
外键约束描述了两个表之间的联系。它们经常被用于验证。目前,数据库中的用户表和缩写表之间没有联系。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
字符串,但并不指任何用户,因为数据库是空的。发送请求;你会得到一个错误,说有一个违反外键约束的问题:
像你之前做的那样创建一个用户并复制ID
。再次发送创建首字母缩写的请求,这次使用有效的ID
。应用程序创建首字母缩写,没有任何错误。
接下来去哪?¶
在本章中,你学到了如何使用Fluent
在Vapor
中实现父子关系。这使你可以开始在数据库中的模型之间创建复杂的关系。下一章将介绍数据库中另一种类型的关系:兄弟姐妹关系。