搞事情之 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 开发之路

相关文章
相关标签/搜索