搞事情之 Vapor 初探

搞事情繫列文章主要是爲了繼續延續本身的 「T」 字形戰略所作,同時也表明着畢設相關內容的學習總結。本文是 Vapor 部分的第一篇,主要記錄了第一次上手 Swift 最火的服務端框架 Vapor 所遇到的問題、思考和總結。node

前言

SwiftNIO 開源後,以前對 Swift Server Side 徹底不關心的我再也按耐不住了!尤爲是還看到了這篇文章,我相信這個文章確定大部分同窗都瀏覽過,看完後我也十分的激動,難道使用 Swift 統一先後端開發的日子就要到了嗎?直到最近在畢設的「壓迫」下,我才認認真真的學習使用 Swift 開發服務端。目前在 github 上 star 最多的是 Vapor,其次是 Perfectpython

爲何選擇 Vapormysql

  • 2018 @Swift 大會上蝦神對 Swift Serve Side 作了一個 lightning talk,對 Vapor 十分讚賞;
  • 陸陸續續看了網上的一些資料,發現你們對 Vapor 關注度也更高一些;
  • Vapor 在語法和相關 API 的設計上會更加 Swifty 一些;
  • github 上的全部 Swift Sever Side 框架中它的 star 是最多。

可是,在剛開始時估計是學校的網太破了,致使生成 Xcode 模版文件時真的是巨慢!!!有一次等了二十分鐘,還失敗了!中途切回了 Perfect,而後 Perfect 一樣也有一些其它問題,又換回來。git

開始

下載 vapor

詳見官網github

運行 Hello, world!

  • vapor new yourProjectName。建立模版工程,固然能夠加上 --template=api 來建立提供對應服務的模版工程,但我測試了一下好像跟其它模版工程沒什麼區別。
  • vapor xcode。建立 Xcode 工程,特別特別慢,並且會有必定概率失敗。(估計是學校的網太破

MVC —— M

Vapor 默認是 SQLite內存數據庫。我本來想看看 Vapor 自帶的 SQLite 數據庫中的表,但沒翻着,最後想了一下,這是內存數據庫啊,也就是說,每次 Run 數據都會被清空。能夠從 config.swift 中看出:sql

// ...
let sqlite = try SQLiteDatabase(storage: .memory)
// ...
複製代碼

Vapor 文檔中寫了推薦使用 Fluent ORM 框架進行數據庫表結構的管理,剛開始我並不瞭解關於 Fluent 的任何內容,能夠查看模版文件中的 Todo.swiftshell

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 庫作的渲染,具體細節由於沒學習過不作展開。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 其實會更香。

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 開發之路

相關文章
相關標籤/搜索