搞事情之 Vapor 初探

PJHubs發表於2019-05-04

搞事情繫列文章主要是為了繼續延續自己的 “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

如果我們只使用 VaporAPI 服務,可以不用管 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 其實會更香。

SQLiteMySQL

為什麼要換,原因很簡單,不是 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 那麼強大的工作流,很多人都說 PerfectDjango,我自己的認為 VaporFlask

Vapor 修改表欄位,不僅僅只是修改 Model 屬性這麼簡單,同樣也不像 Django 中修改完後,執行 python manage.py makemigrationspython 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

已經刪除掉了一些非重要資訊。可以看到,Djangoapp 資料夾結構非常好!注意看 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 方法中呼叫 DatabaseKitcreate 方法,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 方式,二為適用於 WebSessions

新增依賴

// 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 加密,例如,usernamepjhubspasswordpjhubs123,則,拼接後的結果為 pjhubs:pjhubs123,加密完的結果為 cGpodWJzOnBqaHViczEyMw==。按照如下格式新增到每次發起 HTTP 請求的 header 中:

Authorization: Basic cGpodWJzOnBqaHViczEyMw==

Bearer Authorization

當使用者登入成功後,我們應該返回一個完整的 token 用於標識該使用者已經在我們系統中登入且驗證成功,並讓該 token 和使用者進行關聯。使用 Bearer Authorization 方式進行許可權驗證,我們需要自行生成 token,可以使用任何方法進行生成,Vapor 官方並沒有提供對應的生成工具,只要能夠保持全域性唯一即可。每次進行 HTTP 請求時,把 token 按照如下格式直接新增到 HTTP request 中,假設此次請求的 tokenpxoGJUtBVn7MXWoajWH+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

UserToken 進行關聯。

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

PJ 的 iOS 開發之路

本作品採用《CC 協議》,轉載必須註明作者和本文連結
優秀的人遵守規則,頂尖的人創造規則

相關文章