搞事情繫列文章主要是爲了繼續延續本身的 「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