Vapor 教程系列 - 03 进阶数据库模型与 API 实现
⌛《实现基本 CRUD 操作与控制器封装》中我们一起了解了 CRUD 的数据库操作,并学习了如何使用 Fluent 框架和 Controller 的封装方式实现基本的 API✨。本文就跟着我开始“走关系”吧🫣,会了模型关系再复杂的 API 需求也能游刃有余解决,搞掂它!
- 第一部分:构建 Web API
- 创建项目和设置数据库与路由
- 实现基本 CRUD 操作与控制器封装
- 进阶数据库模型与 API 实现
- Web API 测试与稳定性验证
- 异步编程与全面错误处理
- 第二部分:制作简单的前端网页应用
- 第三部分:数据校验、用户验证和授权
- 第四部分:Vapor 的进阶使用
- 第五部分:生产部署
在开始本文的实操之前确保前面两节内容已经完成,仅是快食知识的话忽略提醒😂。
本文内容较多,可分多次食用。
父子关系 Parent-Child
父子关系的本质是一对一👨👩👧或者一对多👨👩👧👦的关系,对应模型就是一个模型拥有 一个 或 多个 其他模型。
举个例子 🌰:
🙋🏼和🐕🦺的关系,一个🙋🏼可能养 1 只或多只🐕🦺,但一个🐕🦺可能只有一个主人(🙋🏼)。
回到我们的 TIL 应用中来,用户(父级)创建字母缩写 - Acronym(子级),用户可以拥有很多首字母缩写 ,但是对于一个具体的字母缩写(子级)只可能是由某一个用户(父级)创建的。
构建 User 模型
下面我们需要新建一个 User 模型定义,并创建对应模型的迁移文件,最后创建 User 的 API 控制器(Controller)。
User 模型
打开项目,在 Sources/App/Models
中创建文件 User.swift
,添加如下模型定义。
代码注解(类似于之前的 Acronym 模型):
- 定义 User 模型类,遵循 Model 协议;
- 定义 schema ,这是每个模型都应该有的 let 而且是静态常量,这个名称对应的就是数据库中表名,一般使用 Snake 命名方式,在 Swift 代码中类名、变量名、方法名一般采用驼峰命名法,需要注意一下这个规范;
- 每个模型都应该有个用于查询数据的字段,对应定义名为 id,这个变量为 Optional 的 UUID 类型可以为空,同时使用 ID 装饰器,指定 key 为
id
类型; - 接着定义了两个 Field 对应到数据库表中的字段,使用 Field 装饰器指定 key 为表中字段名,如果变量是可选的,需要使用 OptionField 装饰器;
- 必须包含两个初始化方法,一个是空的模型初始化实现,用于数据库检索数据时初始化模型数据用,另一个初始化实现用于创建模型数据,这是必须的。
- User 模型包含两个 String 属性,分别用于存储用户的姓名和用户名。
下面需要添加模型的迁移文件,在 Sources/App/Migrations
中创建文件 CreateUser.swift
,内容参考如下。
类似于 Acronym 模型的迁移,这里:
- 为迁移创建一个新的类型
CreateUser
,主要是为了在数据库中创建 名为 users 表; - 添加必要的
prepare
和revert
方法分别用于创建数据表;
最后,需要在文件 Sources/App/configure.swift
中的 app.migrations.add(CreateAcronym())
前面添加 User 模型的迁移方法 app.migrations.add(CreateUser())
。
需要将新模型添加到迁移,以便于 Fluent 在下次应用程序启动时准备数据库中的表。
每次修改数据模型都要对数据库进行迁移,命令如下:
User API 控制器
在目录 Sources/App/Controllers
中添加新文件 UsersController.swift
,添加一个用于创建用户的新控制器,如下。
类似于前面添加 Acronym 的 API 路由一样:
- 定义了符合
RouteCollection
的新类型UsersController
; - 添加了必要的
boot
方法,先是为路径/api/users
创建了新的路由组,然后绑定create
方法到 POST 请求; - 添加
createHandler
方法,用于解析请求中的数据并保存到数据库中,最终返回解析后的 user 数据。
我们不要忘记注册控制器,打开文件 Sources/App/routes.swift
,添加以下代码。
按照《CRUD、检索操作与 Controller 接口封装》中讲到的 RESTful 接口设计规范,想一下除了上面创建 User 的接口外我们还应添加哪些?我再列一个表供参考。
创建 User 的接口实现了,所以开始实现剩下的接口。
在 Sources/App/Controllers/UsersController.swift
的 UserController
中添加获取所有用户、单个用户数据路由处理方法。
类似于 Acronyms 的 API 处理,这里不做过多解释了,如果不明白这里留言评论。
将两个方法在 boot
方法中绑定到路由组,boot
方法代码如下
此时构建并运行程序,我们仍然适用 RapidAPI 测试我们实现的 API。
💡 Tips:我这里使用的是 macOS 下的 RapidAPI 的客户端,与之前使用的 VScode 扩展 RapidAPI Client 使用方法一摸一样。
就像之前 Acronym 的API测试时创建的 API Requests 一样,也可以为 User 提前做好 API 测试请求。
先测试 创建 User。
- URL:
http://127.0.0.1:8080/api/users
- Method:
POST
💡Tips:记得对于发送 JSON 负载的请求,需要在 Headers 中添加Content-Type
,设为application/json
。后面创建和更新的接口测试请求都需要注意这一点!
- name:
水木
- username:
Daisy
使用 json 数据,点击 ❺ 位置的按钮就会发送请求。
正常的话我们就会看到显示响应 200 OK
,同时收到响应数据(包含了 id 信息)
测试获取所有用户的接口。
- URL:
http://127.0.0.1:8080/api/users
- Method:
GET
测试获取单个用户数据的接口。
- URL:
http://127.0.0.1:8080/api/users/<ID>
- Method:
GET
URL 中加了 ID 数据,这里的 ID 使用的是 Get All Users
获得的数据中的第一条数据的 ID。
搞好父子关系🫣
为了搞通“父(User)子(Acronym)”关系,需要通过模型定义声明关系,昭告世界(数据库)他们是有关系的,具体需要将 user 属性定义到 Acronym 中让每个 Acronym 搞清楚自己的 user 是谁。数据库会以此作为 acronym 表中 user 的引用。
这样做的好处是 Fluent 框架也能更轻易有效的检索数据。
如此一来,如果要获取某个 user 的所有 acronym 数据,就只需要检索包含某个 user 引用的全部 acronym 数据就可以了。同时 acronym 可以通过这个 user 引用找到对应的 user 数据,Fluent 通过装饰器的方式极大的方便了定义这些内容,两字“优雅”。
打开 Sources/App/Models/Acronym.swift
文件,在 var long: String
下面添加 user
:
使用 @Parent
属性装饰器创建 User
和 Acronym
两个模型之间的父子关系,有了这个 Fluent 就知道此属性表述父子关系的父级,说明使用 "userID" 来绑定关系。
这样方便查询数据之外,还允许仅使用 user 的 id
创建 Acronym 数据,不不需要完整 User 数据,有助于减少额外的数据查询。
另外,这个类型非可选,也就是说每个 acronym 都有 user。
还需要对其中的 init
方法改造,如下:
这样做主要是:
- 为用户
id
添加一个新参数,类型为User.IDValue
。这是一个由 Model 定义的类型别名,它解析为UUID
。 - 设置 user 属性包装器的映射值的
id
。这避免了您必须执行查找以获取完整的User
模型来创建Acronym
。
因为修改了模型定义,所以还得更新一下迁移文件,打开 Sources/App/Migrations/CreateAcronym.swift
,在 .create()
之前添加 .field("userID", .uuid, .required)
。
优化数据层级
这里用到一种叫做 Domain Transfer Objects(DTOs)的设计模式,优化数据解析和处理。
✨ 技术含义
Data Transfer Objects (DTOs) 是一种设计模式,用于在不同层次或部分的应用程序之间传输数据。DTOs通常用于封装数据,它们可以简化和优化网络通信,特别是在客户端和服务器之间交换大量数据时。DTOs可以减少网络请求的次数,因为它们允许一次传输多个数据项。
如果按照模型的定义创建或更新 Acronym
时发送的 JSON 有效负载数据,可能是如下的样子:
这是因为 Acronym
中有一个 user
属性,所以必须匹配起来,但这略嫌麻烦冗杂。要优化这个问题就用到了 DTO,使用 DTO 表示客户端应发送或接收内容的类型数据,然后路由处理解析的时候对 DTO 解析转换为代码可用的数据即可,打开 Sources/App/Controllers/AcronymsController.swift
文件添加 CreateAcronymData
定义:
这个 DTO 表示我们从客户端期望的 JSON 数据格式,请求负载就可以这样写了:
这样的话我们就需要调整 Acronym
创建路由的处理了,找到 Sources/App/Controllers/AcronymsController.swift
文件
这样在解析请求负载数据的时候不是原来解析 Acronym
模型了而是 CreateAcronymData
数据,然后穿件 Acronym
数据并保存。
至此就搞定了父子关系。
💡 Tips
因为数据模型多了字段所以需要在运行应用程序之前重置数据库(先删掉数据库)或者手动删除创建的表,以便 Fluent 实现新模型迁移和表的建立。
方法一(推荐)
我们只需要连接数据库,并删除所有的表即可,不需要重建整个数据库。
比如我这里使用可视化工具 tableplus,这里就不讲如何连接数据库了。连接上数据库后会看到有三个表如图,删掉即可。
删除后,执行命令重新创建数据库表。
方法二
删除整个数据库的方式,停止运行的应用程序,终端停止并删除 postgres
的数据库容器,然后重新启动新的数据库,终端命令参考:
- host: localhost
- port: 5432
- user: vapor_username
- password: vapor_password
- database: postgres
💡Tips
记得替换命令中的 user、password 和 db 名称。新的迁移还是可以在更新表的情况下不丢失生产数据,我会在后面专门的文章中介绍,这里先按照我的安排走。
创建好数据库后,记得执行命令创建数据表:
测试接口
构建应用并运行,然后测试更新后的创建 Acronym 的接口
- URL:
http://127.0.0.1:8080/api/acronyms
- Method:
POST
- short:
DTO
- long:
Data Transfer Objects
- userID:使用之前创建用户响应数据中的用户 id
同理,还得调整更新 Acronym
的路由处理:
同样,最好是也测试一下,这里就不展示了,自行测试吧🫣
关系数据查询
建立父子关系是为了更高效地查询数据,Fluent 将这一切变得非常简单。
获取父级数据
AcronymsController
中实现通过 acronym
数据直接获取对应的 User
数据。 Sources/App/Controllers/AcronymsController.swift
文件中添加 getUserHandler
方法:
通过路由参数 id 查询到 acronym 数据,然后通过属性装饰器 $user
从数据库中直接获取到 user 数据,底层是通过 id 到 users 表中查询对应的 user 数据。
这里不能通过 acronym.user
去获取,一定要注意,否则会出错,记住这种使用方式。
定义了路由处理方法,还需要绑定路由,需要绑定到 /api/acronyms/<acronymID>/user
上,在 boot
方法中添加:
保存,运行应用,测试这个接口。
- URL:
http://127.0.0.1:8080/api/acronyms/<acronymID>/user
- Method:
GET
获取子级数据
为了让 Fluent 快速高效通过 user 直接获取相关联的 acronym 数据,需要在 User
模型中定义 acronyms
属性,使用 @Children
装饰器,打开 Sources/App/Models/User.swift
,在 User
的 var username: String
之后添加:
这里通过 @Children
装饰器高速 Fluent,,acronyms
表述的是父子关系中的子级项。与 @Parent
不同的是,acronyms
不会再数据库的数据表中添加任何字段,只是被用来确定关系所链接的内容。这里就是通过指定 for
参数将属性包装器传递给子模型的父级属性装饰器的键路径,这里的话就是 \Acronym.$user
或者简写为 \.$user
,Fluent 便是通过这个关系说明快速检索子级数据。
我们打开 Sources/App/Controllers/UsersController.swift
,UsersController
中添加获取所有 acronym 数据的路由处理方法:
通过路由中 id
参数查询到 user
数据,然后直接通过 acronyms
属性检索获取所有隶属于 user
的 acronyms
数据。
记得将新增的 getAcronyms
方法绑定到对应路由 /api/users/<userID>/acronyms
,在 boot
方法中添加:
保存并构建运行应用,测试新增的接口。
- URL:
http://127.0.0.1:8080/api/users/<userID>/acronyms
- Method:
GET
外键约束
外键约束描述了两个表之间的链接 🔗。它们经常用于验证。目前,数据库中的 users
表和 acronysm
表之间没有链接。Fluent 是唯一知道该链接的,而使用外键约束其实也让数据库见证这个事情👀。为什么要使用外键?
- 它确保无法使用不存在的
user
创建acronym
。 - 在删除所有
acronym
之前,您无法删除user
。 - 在删除
acronyms
表之前,您无法删除users
表。
添加外键约束非常简单,只需要微调 Acronym
模型的迁移文件 Sources/App/Migrations/CreateAcronym.swift
中的 .field("userID", .uuid, .required)
为:
这里是添加了从 userID
列到 users
表中 id
列的引用。
因为我们一直是将 Acronym
的 userID
链接到 users
表,所以应该先创建 users
表,再创建 acronyms
表。也就是将 Sources/App/Configure.swift
中 migration 的顺序调整一下,user 的在前面:
停止应用运行,按照前面提到的步骤重新删除数据表或重建数据库。创建好新的数据库容器后,构建启动应用,使用不存在的 user id 和数据库存在 user
的 id
分别测试一下 Acronym
创建接口。
- URL:
http://127.0.0.1:8080/api/acronyms
- Method:
POST
- short:
DTO
- long:
Data Transfer Objects
- userID:第一次用不存在的 id ,第二次用已创建
user
的userID
对于不存在的 userID
后台响应了 500 错误,也可以看到提示是外键约束的内容,外键约束生效了。
下面响应成功的则是使用存在且有效 userID
的测试。
很好,搞到这里说明你已经相当有耐心了,算是真正搞定了常规的父子关系,你要不要休息一下♨️。
下一小节我们开始处理“兄弟关系”了。
处理好兄弟关系
兄弟关系与父子关系不一样的地方是,它描述的是两个模型之间的互相链接关系,也就是 多对多 的关系,并且两个模型之间没有约束。
比如你的宠物 🐱 和 玩具🪀 之间的关系,一只宠物 🐱 可以玩一个或者多个玩具🪀,反过来一个玩具🪀可以被一只或者多只 宠物 🐱 玩。
在我们的应用中,我们将对 acronym 进行分类(指定 category),acronym 和 category 之间就是兄弟关系,一个 acronym 可以指定多个 category,同时一个 category 可以包含多个 acronym。
构建 Category 模型
按照上面👆User 模型的构建思路,我们同样需要创建 Category 的模型文件、迁移文件、控制器文件,不同的是我们还需要为 兄弟关系 创建映射关系的表,称为 pivot。
Category 模型
在 Sources/App/Models
目录中创建文件 Category.swift
,添加 category
模型基本的定义:
模型除了基本的 id
属性之外,只包含一个 String
属性用来保存类别的名称。
在 Sources/App/Migrations
目录中添加迁移文件 CreateCategory.swift
,内容与之前几个模型的类似,代码如下:
我想对于代码应该不用过多解释了吧,注意 schema 要与模型定义中的一致。
然后打开 Sources/App/Configure.swift
文件,在 app.migrations.add(CreateAcronym())
添加 Category 的迁移 app.migrations.add(CreateCategory())
,这样在下次应用程序启动时 Fluent 就会在数据库中创建表。
Category 控制器
在 Sources/App/Controllers
中创建文件 CategoriesController.swift
文件,并添加相应代码如下:
代码简单解释一下,和前面各模型相关的路由控制器基本类似:
- 定义了新的控制器
CategoriesController
,实现协议RouteCollection
必须的方法boot
,并添加路由组,在组中绑定相应路由到具体的路由处理方法; - 实现了五个路由处理方法,分别是
createHandler
、getAllHandler
、getHandler
、updateHandler
、deleteHandler
,对应的是创建 category、获取所有 category 数据、获取指定id
的 category 数据、更新指定id
的 category 数据和删除指定id
的 category 数据。
具体接口设计如下:
下面我们需要把控制器注册给应用,打开 Sources/App/routes.swift
,在 route(_:)
方法内部最后添加代码:
至此,我们完成了接口的实现,运行应用并测试新增的三个接口。
这里只展示创建 category
接口的测试过程,剩余两个自行测试。
在 RapidAPI 客户端中添加 category 的请求,并开始测试,下面是创建 category 数据的测试信息:
- URL:
http://127.0.0.1:8080/api/categories
- Method:
POST
还是实用 JSON 数据,记得 Headers 中添加 Content-Type 为 application/json
,Body 中添加数据:
创建 Pivot 表
这个 Pivot 表是关系型数据库中多对多实现高效检索必须要创建的,通常包含两个字段,分别是两个多对多模型对应数据表中的主键。在我们应用这里,创建 Pivot 这么个映射表这样就能通过 id 快速检索某个 acronym 对应的所有 category,反过来亦然。
好,我们现在就开始创建,在 Sources/App/Models
中创建 AcronymCategoryPivot.swift
文件,并添加 Pivot 模型定义:
- 代码中定义了
AcronymCategoryPivot
模型,添加 id 属性,然后使用@Parent
装饰器将定义的 acronym 链接到模型 Acronym,使用@Parent
装饰器将定义的 category 链接到模型 Category; - 然后添加必要的两个初始化方法。
Pivot
也是一个模型,所以也需要创建它的迁移文件,在 Sources/App/Migrations
中创建 CreateAcronymCategorPivot.swift
文件,添加以下内容:
schema 与模型定义中的一致,这里如父子关系中 Acronym 的迁移文件中 user
,需要为 acronymID
和 categoryID
添加模型引用创建外键约束。
然后,打开配置文件 Sources/App/Configure.swift
中,在 app.migrations.add(CreateCategory())
之后添加 app.migrations.add(CreateAcronymCategoryPivot())
。
Fluent 框架为两个模型数据之间关系创建和删除提供了很便捷的方法,还需要我们做点准备,打开 Sources/App/Models/Acronym.swift
文件,在 user
属性后面添加 categories
属性:
这样可以极大方便我们检索某个 acronym 的所有 category 数据,可以看到使用了新的装饰器 @Siblings
,其中三个参数含义如下:
- through:Pivot的模型类型
- from:从 Pivot 到根模型的键路径,这里是使用 AcronymCategoryPivot 的 acronym 属性。
- to:从引用相关模型的 Pivot 键路径。这里是使用 AcronymCategoryPivot 上的 category 属性。
与 @Parent
一样, @Siblings
允许您将相关模型指定为属性,而无需它们初始化实例。同时,它会告诉 Fluent 在数据库中执行查询时如何映射兄弟项。
另外, @Parent
使用数据库中的父级 id
列,但 @Siblings
必须在数据库中的两个不同模型和 Pivot 之间进行联接查询。比较好的是,Fluent 实现了简单操作。
打开 Sources/App/Controllers/AcronymsController.swift
文件,AcronymsController
中 getUserHandler(_ req: Request)
下面添加代码:
attach
和 detach
分别是绑定和解绑 acronym
与 category
关系的路由实现,在两个方法中你会看到 Fluent 提供的模型方法 attachCategoriesHandler(_:on:)
和 detachCategoriesHandler(_:on:)
,方便很多!
在 boot
方法中绑定这两个方法到路由 /api/acronyms/<acronymID>/categories/<categoryID>
上。
运行应用,测试这两个新的 API,在 RapidAPI 中添加请求
绑定 acronym 和 category
- URL:
http://127.0.0.1:8080/api/acronyms/<acronymID>/categories/<categoryID>
- Method:
POST
解绑 acronym 和 category
- URL:
http://127.0.0.1:8080/api/acronyms/<acronymID>/categories/<categoryID>
- Method:
DELETE
查询兄弟关系数据
查询 Acronym 的 Category 数据
因为已经在 Acronym
模型中添加了 categories 属性,所以可以使用 Fluent 轻松查询某个 acronym 绑定的所有 category,下面我们添加对应的 API,继续编辑 Sources/App/Controllers/AcronymsController.swift
文件,在刚才添加的 detachCategoriesHandler
方法后面添加 getCategoriesHandler
方法:
直接通过属性装饰器修饰的 categories
就能获取即 .$categories.get(on: req.db)
,绑定这个路由方法到路由 /api/acronyms/<acronymID>/categories
上,在 boot
方法的 acronymRoutes.delete(":acronymID", "categories", ":categoryID", use: detachCategoriesHandler)
后面添加:
编译运行,正常的话使用 RapidAPI 客户端创建响应的请求就能获取到数据了。
- URL:
http://127.0.0.1:8080/api/acronyms/<acronymID>/categories
- Method:
GET
查询 Category 的 Acronym 数据
打开 Sources/App/Models/Category.swift
文件,在 name
属性下面添加 acronyms
属性,同 Acronym 模型中添加 categories
,使用 @Siblings
装饰器:
然后打开 Category 的 API 控制器文件 Sources/App/Controllers/CategoriesController.swift
,在 CategoriesController
中添加 getAcronymsHandler
方法:
然后将其绑定到路由 /api/categories/<categoryID>/acronyms
上,boot
方法 categoriesRoute.delete(":categoryID", use:deleteHandler)
下面添加:
重新运行应用测试获取某个 category 对应所有 acronym 的 API。
- URL:
http://127.0.0.1:8080/api/categories/<categoryID>/acronyms
- Method:
GET
啊,长舒一口气~本文内容结束,希望至此你还一切正常🙃
总结
通过本文内容的实操,相信你应该对模型与模型间的关系实现有了初步了解,同时对于接口的设计应该有了更深刻的体会了,RESTful 的接口设计还是很优雅的,布置一个小作业:
📑 Task
尝试为 User 添加上更新和删除的 API 并进行测试。
本文代码:s1-3.zip
RapidAPI API 测试请求文件:TILApp.paw