• 请不要在回答技术问题时复制粘贴 AI 生成的内容
pjhubs
V2EX  ›  程序员

搞事情之 Vapor 初探

  •  
  •   pjhubs ·
    windstormeye · May 4, 2019 · 3797 views
    This topic created in 2720 days ago, the information mentioned may be changed or developed.

    搞事情系列文章主要是为了继续延续自己的 “ 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)
    }
    

    后记

    感觉当一些设计模式的 tips 杂糅在一起后,就特别像 Django。但是和 Django 又有很大的不同,在一些细节上 Vapor 处理的不够好,看得云里雾里的,文档不够简单明了,或许,老外都这样?

    在这次的学习当中,心中冒出了很多次“为什么我要用这个破东西?”,但每次冒出这个想法时,最后都忍住了,因为这可是 Swift 啊!

    github 地址:Unicorn-Server

    PJ 的 iOS 开发之路

    1 replies  •  2019-05-05 11:22:23 +08:00
    free9fw
        1
    free9fw  
       May 5, 2019
    vapor 有一定的学习曲线
    About   ·   Help   ·   Advertise   ·   Blog   ·   API   ·   FAQ   ·   Privacy   ·   Solana   ·   5562 Online   Highest 6679   ·     Select Language
    创意工作者们的社区
    World is powered by solitude
    VERSION: 3.9.8.5 · 58ms · UTC 09:00 · PVG 17:00 · LAX 02:00 · JFK 05:00
    ♥ Do have faith in what you're doing.