搞事情之 Vapor 初探
搞事情繫列文章主要是為了繼續延續自己的 “T” 字形戰略所做,同時也代表著畢設相關內容的學習總結。本文是Vapor
部分的第一篇,主要記錄了第一次上手Swift
最火的服務端框架Vapor
所遇到的問題、思考和總結。
前言
從SwiftNIO
開源後,之前對Swift Server Side
完全不關心的我再也按耐不住了!尤其是還看到了這篇文章,我相信這個文章肯定大部分同學都瀏覽過,看完後我也十分的激動,難道使用Swift
統一前後端開發的日子就要到了嗎?直到最近在畢設的“壓迫”下,我才認認真真的學習使用Swift
開發服務端。目前在 github 上 star 最多的是Vapor
,其次是Perfect
。
為什麼選擇Vapor
?
-
在
2018 @Swift
大會上蝦神對Swift Serve Side
做了一個 lightning talk,對Vapor
十分讚揚; -
陸陸續續看了網上的一些資料,發現大家對
Vapor
關注度也更高一些; -
Vapor
在語法和相關API
的設計上會更加Swifty
一些; -
github 上的所有
Swift Sever Side
框架中它的star
是最多。
但是,在剛開始時估計是學校的網太破了,導致生成Xcode
模版檔案時真的是巨慢!!!有一次等了二十分鐘,還失敗了!中途切回了Perfect
,然後Perfect
同樣也有一些其它問題,又換回來。
開始
下載vapor
詳見官網 。
執行Hello, world!
-
vapor new yourProjectName
。建立模版工程,當然可以加上--template=api
來建立提供對應服務的模版工程,但我測試了一下好像跟其它模版工程沒什麼區別。 -
vapor xcode
。建立 Xcode 工程,特別特別慢,而且會有一定機率失敗。(估計是學校的網太破
MVC —— M
Vapor
預設是SQLite
的記憶體
資料庫。我原本想看看Vapor
自帶的SQLite
資料庫中的表,但沒翻著,最後想了一下,這是記憶體資料庫啊,也就是說,每次Run
資料都會被清空。可以從config.swift
中看出:
// ... let sqlite = try SQLiteDatabase(storage: .memory) // ... 複製程式碼
在Vapor
文件中寫了推薦使用Fluent
ORM 框架進行資料庫表結構的管理,剛開始我並不瞭解關於Fluent
的任何內容,可以檢視模版檔案中的Todo.swift
:
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
去做的。
MVC —— C
如果我們只使用Vapor
做API
服務,可以不用管V
層,在Vapor
的“檢視”部分,使用的Leaf
庫做的渲染,具體細節因為沒學習過不做展開。
而對於C
來說,整體的思路跟以往寫 App 時的思路大致相當,在C
層中處理好資料和檢視的關係,只不過此處只需要處理資料和資料之間的關係就好了。
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
搞了我幾次,更新依賴的時候特別慢,而且還更新失敗,導致我現在每次更新時都要去確認一遍依賴是否更新成功。
更新 ORM
更新成功後,我們就可以根據之前生成的模版檔案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 久也沒有成功,幾乎翻遍了網上所有內容,也沒法解決,幾乎都是這麼寫然後執行撤回遷移命令就生效了。後邊再看吧。
修改一個表字段
暫留。
Auth
在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
簡單來說,該方式就是驗證密碼。我們需要維護一個做Basic Authorization
方式進行鑑權的Path
集合。請求屬於該集合中的Path
時,都需要把使用者名稱和密碼用:
進行連線成新的字串,且做base64
加密,例如,username
為pjhubs
,password
為pjhubs123
,則,拼接後的結果為pjhubs:pjhubs123
,加密完的結果為cGpodWJzOnBqaHViczEyMw==
。按照如下格式新增到每次發起HTTP
請求的header
中:
Authorization: Basic cGpodWJzOnBqaHViczEyMw== 複製程式碼
Bearer Authorization
當用戶登入成功後,我們應該返回一個完整的token
用於標識該使用者已經在我們系統中登入且驗證成功,並讓該token
和使用者進行關聯。使用Bearer Authorization
方式進行許可權驗證,我們需要自行生成token
,可以使用任何方法進行生成,Vapor
官方並沒有提供對應的生成工具,只要能夠保持全域性唯一即可。每次進行HTTP
請求時,把token
按照如下格式直接新增到HTTP request
中,假設此次請求的token
為pxoGJUtBVn7MXWoajWH+iw==
,則完整的HTTP header
為:
Authorization: Bearer pxoGJUtBVn7MXWoajWH+iw== 複製程式碼
建立Token
Model
import 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) } 複製程式碼