搞事情系列文章主要是为了继续延续本身的 “T” 字形战略所作,同时也表明着毕设相关内容的学习总结。本文是
Vapor
部分的第一篇,主要记录了第一次上手Swift
最火的服务端框架Vapor
所遇到的问题、思考和总结。node
从 SwiftNIO
开源后,以前对 Swift Server Side
彻底不关心的我再也按耐不住了!尤为是还看到了这篇文章,我相信这个文章确定大部分同窗都浏览过,看完后我也十分的激动,难道使用 Swift
统一先后端开发的日子就要到了吗?直到最近在毕设的“压迫”下,我才认认真真的学习使用 Swift
开发服务端。目前在 github 上 star 最多的是 Vapor
,其次是 Perfect
。python
为何选择 Vapor
?mysql
2018 @Swift
大会上虾神对 Swift Serve Side
作了一个 lightning talk,对 Vapor
十分赞赏;Vapor
关注度也更高一些;Vapor
在语法和相关 API
的设计上会更加 Swifty
一些;Swift Sever Side
框架中它的 star
是最多。可是,在刚开始时估计是学校的网太破了,致使生成 Xcode
模版文件时真的是巨慢!!!有一次等了二十分钟,还失败了!中途切回了 Perfect
,而后 Perfect
一样也有一些其它问题,又换回来。git
vapor
详见官网。github
Hello, world!
vapor new yourProjectName
。建立模版工程,固然能够加上 --template=api
来建立提供对应服务的模版工程,但我测试了一下好像跟其它模版工程没什么区别。vapor xcode
。建立 Xcode 工程,特别特别慢,并且会有必定概率失败。(估计是学校的网太破Vapor
默认是 SQLite
的内存数据库。我本来想看看 Vapor
自带的 SQLite
数据库中的表,但没翻着,最后想了一下,这是内存数据库啊,也就是说,每次 Run
数据都会被清空。能够从 config.swift
中看出:sql
// ...
let sqlite = try SQLiteDatabase(storage: .memory)
// ...
复制代码
在 Vapor
文档中写了推荐使用 Fluent
ORM 框架进行数据库表结构的管理,刚开始我并不了解关于 Fluent
的任何内容,能够查看模版文件中的 Todo.swift
:shell
import FluentSQLite
import Vapor
final class Todo: SQLiteModel {
/// 惟一标识符
var id: Int?
var title: String
init(id: Int? = nil, title: String) {
self.id = id
self.title = title
}
}
/// 实现数据库操做。如增长表字段,更新表结构
extension Todo: Migration { }
/// 容许从 HTTP 消息中编解码出对应数据
extension Todo: Content { }
/// 容许使用动态的使用在路由中定义的参数
extension Todo: Parameter { }
复制代码
从模版文件中的 Model
能够看出来建立一张表结构至关因而描述一个类,以前有使用过 Django
的经验,看到 Vapor
的这种 ORM 这么 Swifty
确实眼前一亮。Vapor
一样能够遵循 MVC
设计模式进行构建,在生成的模版文件中也确实是基于 MVC
去作的。数据库
若是咱们只使用 Vapor
作 API
服务,能够不用管 V
层,在 Vapor
的“视图”部分,使用的 Leaf
库作的渲染,具体细节由于没学习过不作展开。macos
而对于 C
来讲,总体的思路跟以往写 App 时的思路大体至关,在 C
层中处理好数据和视图的关系,只不过此处只须要处理数据和数据之间的关系就行了。swift
import Vapor
/// Controls basic CRUD operations on `Todo`s.
final class TodoController {
/// Returns a list of all `Todo`s.
func index(_ req: Request) throws -> Future<[Todo]> {
return Todo.query(on: req).all()
}
/// Saves a decoded `Todo` to the database.
func create(_ req: Request) throws -> Future<Todo> {
return try req.content.decode(Todo.self).flatMap { todo in
return todo.save(on: req)
}
}
/// Deletes a parameterized `Todo`.
func delete(_ req: Request) throws -> Future<HTTPStatus> {
return try req.parameters.next(Todo.self).flatMap { todo in
return todo.delete(on: req)
}.transform(to: .ok)
}
}
复制代码
从以上模版文件中生成的 TodoController
能够看出,大量结合了 Future
异步特性,初次接触会有点懵,有同窗推荐结合 PromiseKit
其实会更香。
SQLite
到 MySQL
为何要换,缘由很简单,不是 SQLite
很差,仅仅只是由于没用过而已。这部分 Vapor
官方文档讲的不够系统,虽然都点到了可是过于分散,并且感受 Vapor
的文档是否是跟 Apple 学了一套,细节都不展开,遇到一些字段问题得亲自写下代码,而后看实现和注释,不写以前很难知道在描述什么。
Package.swift
在 Package.swift
中写下对应库依赖,
import PackageDescription
let package = Package(
name: "Unicorn-Server",
products: [
.library(name: "Unicorn-Server", targets: ["App"]),
],
dependencies: [
.package(url: "https://github.com/vapor/vapor.git", from: "3.0.0"),
// here
.package(url: "https://github.com/vapor/fluent-mysql.git", from: "3.0.0"),
],
targets: [
.target(name: "App",
dependencies: [
"Vapor",
"FluentMySQL"
]),
.target(name: "Run", dependencies: ["App"]),
.testTarget(name: "AppTests", dependencies: ["App"])
]
)
复制代码
触发更新
vapor xcode
复制代码
Vapor
搞了我几回,更新依赖的时候特别慢,并且还更新失败,致使我如今每次更新时都要去确认一遍依赖是否更新成功。
更新成功后,咱们就能够根据以前生成的模版文件 Todo.swift
的样式改为 MySQL
版本的 ORM:
import FluentMySQL
import Vapor
/// A simple user.
final class User: MySQLModel {
/// The unique identifier for this user.
var id: Int?
/// The user's full name.
var name: String
/// The user's current age in years.
var age: Int
/// Creates a new user.
init(id: Int? = nil, name: String, age: Int) {
self.id = id
self.name = name
self.age = age
}
}
/// Allows `User` to be used as a dynamic migration.
extension User: Migration { }
/// Allows `User` to be encoded to and decoded from HTTP messages.
extension User: Content { }
/// Allows `User` to be used as a dynamic parameter in route definitions.
extension User: Parameter { }
复制代码
以上是我新建的 User Model,换成 Todo Model 也是同样的。改动的地方只有两个,import FluentMySQL
和继承自 MySQLModel
。这点还算不错,经过 Fluent
抹平了各类数据库的使用,无论你底层是什么数据库,都只须要导入而后切换继承便可。
config.swift
import FluentMySQL
import Vapor
/// 应用初始化完会被调用
public func configure(_ config: inout Config, _ env: inout Environment, _ services: inout Services) throws {
// === mysql ===
// 首先注册数据库
try services.register(FluentMySQLProvider())
// 注册路由到路由器中进行管理
let router = EngineRouter.default()
try routes(router)
services.register(router, as: Router.self)
// 注册中间件
// 建立一个中间件配置文件
var middlewares = MiddlewareConfig()
// 错误中间件。捕获错误并转化到 HTTP 返回体中
middlewares.use(ErrorMiddleware.self)
services.register(middlewares)
// === mysql ===
// 配置 MySQL 数据库
let mysql = MySQLDatabase(config: MySQLDatabaseConfig(hostname: "", port: 3306, username: "", password: "", database: "", capabilities: .default, characterSet: .utf8mb4_unicode_ci, transport: .unverifiedTLS))
// 注册 SQLite 数据库配置文件到数据库配置中心
var databases = DatabasesConfig()
// === mysql ===
databases.add(database: mysql, as: .mysql)
services.register(databases)
// 配置迁移文件。至关于注册表
var migrations = MigrationConfig()
// === mysql ===
migrations.add(model: User.self, database: .mysql)
services.register(migrations)
}
复制代码
注意 MySQLDatabaseConfig
的配置信息。若是咱们的 MySQL
版本在 8 以上,目前只能选择 unverifiedTLS
进行验证链接MySQL容器时使用的安全链接选项,也即 transport
字段。在代码中用 // === mysql ===
进行标记的代码块是跟模版文件中使用 SQLite
所不一样的地方。
运行工程,进入 MySQL
进行查看。
mysql> show tables;
+----------------------+
| Tables_in_unicorn_db |
+----------------------+
| fluent |
| Sticker |
| User |
+----------------------+
3 rows in set (0.01 sec)
mysql> desc User;
+-------+--------------+------+-----+---------+----------------+
| Field | Type | Null | Key | Default | Extra |
+-------+--------------+------+-----+---------+----------------+
| id | bigint(20) | NO | PRI | NULL | auto_increment |
| name | varchar(255) | NO | | NULL | |
| age | bigint(20) | NO | | NULL | |
+-------+--------------+------+-----+---------+----------------+
3 rows in set (0.01 sec)
复制代码
Vapor
不像 Django
那般在生成的表加上前缀,而是你 ORM 类名是什么,最终生成的表名就是什么,这点很喜欢!
Vapor
一样也没有像 Django
那么强大的工做流,不少人都说 Perfect
像 Django
,我本身的认为 Vapor
像 Flask
。
对 Vapor
修改表字段,不只仅只是修改 Model
属性这么简单,一样也不像 Django
中修改完后,执行 python manage.py makemigrations
和 python manage.py migrate
就结束了,咱们须要本身建立迁移文件,本身写清楚这次表结构到底发生了什么改变。
在泊学的这篇文章中推荐在 App
目录下建立一个 Migrations group
,方便操做。但我思考了一下,这么作势必会形成 Model
和对应的迁移文件割裂,而后在另一个上级文件夹中又要对不一样迁移文件所属的 Model
作切分,这很显然是有一些问题的。最后,我脑子冒出了一个很是可怕的想法:“Django
是一个很是强大、架构很是良好的框架!”。
最后个人目录是这样的:
Models
└── User
├── Migrations
│ ├── 19-04-30-AddUserCreatedTime.swift
│ └── 19-04-30-DeleteUserNickname.swift
├── UserController.swift
└── User.swift
复制代码
这是 Django
中的一个 app
文件树:
user_avatar
├── __init__.py
├── admin.py
├── apps.py
├── migrations
│ ├── 0001_initial.py
│ ├── 0002_auto_20190303_2154.py
│ ├── 0002_auto_20190303_2209.py
│ ├── 0003_auto_20190303_2154.py
│ ├── 0003_auto_20190322_1638.py
│ ├── 0004_merge_20190408_2131.py
│ └── __init__.py
├── models.py
├── tests.py
├── urls.py
└── views.py
复制代码
已经删除掉了一些非重要信息。能够看到,Django
的 app
文件夹结构很是好!注意看 migrations
文件夹下的迁移文件命名。若是开发能力不错的话,咱们是能够作到与业务无关的 app
发布供他人直接导入到工程中。
不过关于工程文件的管理,这是一个智者见智的事情啦~对于我我的来讲,我反而更加喜欢 Vapor
/Flask
一系,由于须要什么再加什么,整个设计模式也能够按照本身的喜爱来作。
给 User
Model 添加一个 createdTime
字段。
import FluentMySQL
struct AddUserCreatedTime: MySQLMigration {
static func prepare(on conn: MySQLConnection) -> EventLoopFuture<Void> {
return MySQLDatabase.update(User.self, on: conn, closure: {
$0.field(for: \User.fluentCreatedAt)
})
}
static func revert(on conn: MySQLConnection) -> EventLoopFuture<Void> {
// 直接返回
return conn.future()
}
}
复制代码
使用 Swift
开发服务端很容易受到使用 Swift
作其它开发的影响。刚开始时我确实认为在 Model
中把须要删除的字段删除就行了,然而运行工程后去查数据库发现并非这么一回事。
首先,咱们须要先建立一个文件来写 Model
的迁移代码,但这不是必须的,你能够把该 Model
后续须要进行表字段的 CURD 都写在同一个文件中,由于每个迁移都是一个 struct
。个人作法是像上文所说,对每个迁移都作新文件,而且每个迁移文件都写上“时间”和“作了什么”。
在 prepare
方法中调用 DatabaseKit
的 create
方法,Fluent
支持大部分数据库,且都基于 DatabaseKit
对支持的这些大部分数据库作了二次封装。
经过 Fluent
对表删除一个字段,须要在增长表字段时就要作好,不然须要从新写一个迁移文件,例如,咱们能够把上文代码中的 revert
方法改成:
static func revert(on conn: MySQLConnection) -> EventLoopFuture<Void> {
return MySQLDatabase.update(User.self, on: conn, closure: {
$0.deleteField(for: \User.fluentCreatedAt)
})
}
复制代码
若是此时咱们直接运行工程,是不会有任何效果的,由于直接运行工程并不会触发 revert
方法,咱们须要激活 Vapor
两个命令,在 config.swift
中:
var commands = CommandConfig.default()
commands.useFluentCommands()
services.register(commands)
复制代码
接着,在终端中输入:vapor build && vapor run revert
便可撤销上一次新增的字段。使用 vapor build && vapor run revert -all
能够撤销所有生成的表。
问题来了!当个人 revert
方法中写明当撤销迁移时,把表进行删除,一切正常。
return MySQLDatabase.delete(User.self, on: conn)
复制代码
但若是我要执行当撤销迁移时,把表中 fluentCreatedAt
字段删除时,失败!!!搞了 N 久也没有成功,几乎翻遍了网上全部内容,也无法解决,几乎都是这么写而后执行撤回迁移命令就生效了。后边再看吧。
暂留。
在 Vapor
中有两种对用户鉴权的方式。一为适用 API
服务的 Stateless
方式,二为适用于 Web
的 Sessions
,
// swift-tools-version:4.0
import PackageDescription
let package = Package(
name: "Unicorn-Server",
products: [
.library(name: "Unicorn-Server", targets: ["App"]),
],
dependencies: [
.package(url: "https://github.com/vapor/vapor.git", from: "3.0.0"),
.package(url: "https://github.com/SwiftyJSON/SwiftyJSON.git", from: "4.0.0"),
.package(url: "https://github.com/vapor/fluent-mysql.git", from: "3.0.0"),
// 添加 auth
.package(url: "https://github.com/vapor/auth.git", from: "2.0.0"),
],
targets: [
.target(name: "App",
dependencies: [
"Vapor",
"SwiftyJSON",
"FluentMySQL",
// 添加 auth
"Authentication"
]),
.target(name: "Run", dependencies: ["App"]),
.testTarget(name: "AppTests", dependencies: ["App"])
]
)
复制代码
执行 vapor xcode
拉取依赖并从新生成 Xcode
工程。
在 config.swift
中增长:
public func configure(_ config: inout Config, _ env: inout Environment, _ services: inout Services) throws {
// ...
try services.register(AuthenticationProvider())
// ...
}
复制代码
简单来讲,该方式就是验证密码。咱们须要维护一个作 Basic Authorization
方式进行鉴权的 Path
集合。请求属于该集合中的 Path
时,都须要把用户名和密码用 :
进行链接成新的字符串,且作 base64
加密,例如,username
为 pjhubs
,password
为 pjhubs123
,则,拼接后的结果为 pjhubs:pjhubs123
,加密完的结果为 cGpodWJzOnBqaHViczEyMw==
。按照以下格式添加到每次发起 HTTP
请求的 header
中:
Authorization: Basic cGpodWJzOnBqaHViczEyMw==
复制代码
当用户登陆成功后,咱们应该返回一个完整的 token
用于标识该用户已经在咱们系统中登陆且验证成功,并让该 token
和用户进行关联。使用 Bearer Authorization
方式进行权限验证,咱们须要自行生成 token
,可使用任何方法进行生成,Vapor
官方并无提供对应的生成工具,只要可以保持全局惟一便可。每次进行 HTTP
请求时,把 token
按照以下格式直接添加到 HTTP request
中,假设这次请求的 token
为 pxoGJUtBVn7MXWoajWH+iw==
,则完整的 HTTP header
为:
Authorization: Bearer pxoGJUtBVn7MXWoajWH+iw==
复制代码
Token
Modelimport Foundation
import Vapor
import FluentMySQL
import Authentication
final class Token: MySQLModel {
var id: Int?
var userId: User.ID
var token: String
var fluentCreatedAt: Date?
init(token: String, userId: User.ID) {
self.token = token
self.userId = userId
}
}
extension Token {
var user: Parent<Token, User> {
return parent(\.userId)
}
}
// 实现 `BearerAuthenticatable` 协议,并返回绑定的 `tokenKey` 以告知使用 `Token` Model 的哪一个属性做为真正的 `token`
extension Token: BearerAuthenticatable {
static var tokenKey: WritableKeyPath<Token, String> { return \Token.token }
}
extension Token: Migration { }
extension Token: Content { }
extension Token: Parameter { }
// 实现 `Authentication.Token` 协议,使 `Token` 成为 `Authentication.Token`
extension Token: Authentication.Token {
// 指定协议中的 `UserType` 为自定义的 `User`
typealias UserType = User
// 置顶协议中的 `UserIDType` 为自定义的 `User.ID`
typealias UserIDType = User.ID
// `token` 与 `user` 进行绑定
static var userIDKey: WritableKeyPath<Token, User.ID> {
return \Token.userId
}
}
extension Token {
/// `token` 生成
static func generate(for user: User) throws -> Token {
let random = try CryptoRandom().generateData(count: 16)
return try Token(token: random.base64EncodedString(), userId: user.requireID())
}
}
复制代码
在 config.swift
中写下 Token
的配置信息。
migrations.add(model: Token.self, database: .mysql)
复制代码
User
Model让 User
和 Token
进行关联。
import Vapor
import FluentMySQL
import Authentication
final class User: MySQLModel {
var id: Int?
var phoneNumber: String
var nickname: String
var password: String
init(id: Int? = nil,
phoneNumber: String,
password: String,
nickname: String) {
self.id = id
self.nickname = nickname
self.password = password
self.phoneNumber = phoneNumber
}
}
extension User: Migration { }
extension User: Content { }
extension User: Parameter { }
// 实现 `TokenAuthenticatable`。当 `User` 中的方法须要进行 `token` 验证时,须要关联哪一个 Model
extension User: TokenAuthenticatable {
typealias TokenType = Token
}
extension User {
func toPublic() -> User.Public {
return User.Public(id: self.id!, nickname: self.nickname)
}
}
extension User {
/// User 对外输出信息,由于并不想把整个 `User` 实体的全部属性都暴露出去
struct Public: Content {
let id: Int
let nickname: String
}
}
extension Future where T: User {
func toPublic() -> Future<User.Public> {
return map(to: User.Public.self) { (user) in
return user.toPublic()
}
}
}
复制代码
使用 Basic Authorization
方式作用户鉴权后,咱们就能够把须要使用鉴权的方法和非鉴权的方法按照以下方式在 UserController.swift
文件分开进行路由,若是这个文件你没有,须要新建一个。
import Vapor
import Authentication
final class UserController: RouteCollection {
// 重载 `boot` 方法,在控制器中定义路由
func boot(router: Router) throws {
let userRouter = router.grouped("api", "user")
// 正常路由
let userController = UserController()
router.post("register", use: userController.register)
router.post("login", use: userController.login)
// `tokenAuthMiddleware` 该中间件可以自行寻找当前 `HTTP header` 的 `Authorization` 字段中的值,并取出与该 `token` 对应的 `user`,并把结果缓存到请求缓存中供后续其它方法使用
// 须要进行 `token` 鉴权的路由
let tokenAuthenticationMiddleware = User.tokenAuthMiddleware()
let authedRoutes = userRouter.grouped(tokenAuthenticationMiddleware)
authedRoutes.get("profile", use: userController.profile)
authedRoutes.get("logout", use: userController.logout)
authedRoutes.get("", use: userController.all)
authedRoutes.get("delete", use: userController.delete)
authedRoutes.get("update", use: userController.update)
}
func logout(_ req: Request) throws -> Future<HTTPResponse> {
let user = try req.requireAuthenticated(User.self)
return try Token
.query(on: req)
.filter(\Token.userId, .equal, user.requireID())
.delete()
.transform(to: HTTPResponse(status: .ok))
}
func profile(_ req: Request) throws -> Future<User.Public> {
let user = try req.requireAuthenticated(User.self)
return req.future(user.toPublic())
}
func all(_ req: Request) throws -> Future<[User.Public]> {
return User.query(on: req).decode(data: User.Public.self).all()
}
func register(_ req: Request) throws -> Future<User.Public> {
return try req.content.decode(User.self).flatMap({
return $0.save(on: req).toPublic()
})
}
func delete(_ req: Request) throws -> Future<HTTPStatus> {
return try req.parameters.next(User.self).flatMap { todo in
return todo.delete(on: req)
}.transform(to: .ok)
}
func update(_ req: Request) throws -> Future<User.Public> {
return try flatMap(to: User.Public.self, req.parameters.next(User.self), req.content.decode(User.self)) { (user, updatedUser) in
user.nickname = updatedUser.nickname
user.password = updatedUser.password
return user.save(on: req).toPublic()
}
}
}
复制代码
须要注意的是,若是某个路由方法须要从 token
关联的用户取信息才须要 let user = try req.requireAuthenticated(User.self)
这行代码取用户,不然若是咱们仅仅只是须要对某个路由方法进行鉴权,只须要加入到 tokenAuthenticationMiddleware
的路由组中便可。
而且, 咱们不须要传入当前登陆用户有关的任何信息,仅仅只须要一个 token
便可。
config.swift
最后,把咱们实现了 RouteCollection
协议的 userController
加入到 config.swift
中进行路由注册便可。
import Vapor
public func routes(_ router: Router) throws {
// 用户路由
let usersController = UserController()
try router.register(collection: usersController)
}
复制代码
感受当一些设计模式的 tips 杂糅在一块儿后,就特别像 Django
。可是和 Django
又有很大的不一样,在一些细节上 Vapor
处理的不够好,看得云里雾里的,文档不够简单明了,或许,老外都这样?
在此次的学习当中,心中冒出了不少次“为何我要用这个破东西?”,但每次冒出这个想法时,最后都忍住了,由于这但是 Swift
啊!
github 地址:Unicorn-Server