My technology blog
版本:Swift5.5,macOS 12,Xcode 13

过去几年,在Swift服务器端Vapor中,都使用EventLoopFuture执行异步任务。尽管EventLoopFuture功能强大,但是它依然有一些问题。首先,它使用闭包,这会慢慢使我们不能书写干净又可读的代码。第二,它需要花时间学习。async/await可以很好解决这两个问题。

在使用async/await后,你的代码会看起来像同步代码一样,没有任何闭包,并且更加易于阅读。很多人已经在vapor中使用它们了,并且目前为止,反响非常好。

在这个教程,你将会把一个使用EventLoopFuture的项目变为使用async/await。这个项目包含以下内容:

  • 实现交易API
  • 使用Fluent在数据库储存信息
  • 将routes迁移到async/await
译者注:routes是vapor中包含各种路由的地方。
小贴士:完全使用async/await需要Swift 5.5, Xcode 13.1 和 macOS Monterey 以上的操作系统
学习这个教程的前提是你已经熟练编写Vapor4程序。如果你是新手,请查看Getting Started with Server-Side Swift with Vapor 4
同时你将配合PostgreSQL与Fluent一起使用。如果你对Fluent和在Docker运行数据库不熟悉,请查看Using Fluent and Persisting Models in Vapor

起步

下载示例项目。这是一个简单的项目,只有一个API跟踪不同用户之间的交易。你会将它转变为使用async/await。

译者注:请前往https://www.raywenderlich.com/29095914-async-await-in-server-side-swift-and-vapor下载示例项目

打开项目,你可以发现里面包含了大量的文件和文件夹。

第一步,打开Package.swift,因为你将要使用async/await,所以你需要做一些修改。

在文件的第一行,定义了该程序使用的swift版本,请确保它大于5.5

// swift-tools-version:5.5

在下方的Package定义中,改变macOS版本到12

platforms: [
    .macOS(.v12)
],

最后,在targets中,定义一个executableTarget 的运行目标,而不是普通target

.executableTarget(name: "Run", dependencies: [.target(name: "App")]),

在Swift5.5中,可运行目标是必须的。在Xcode解决项目依赖关系的时候,打开终端。拷贝并粘贴以下代码到终端中,让你的PostgreSQL数据库在Docker中运行起来。

docker run --name traderdb -e POSTGRES_DB=vapor_database \
  -e POSTGRES_USER=vapor_username \
  -e POSTGRES_PASSWORD=vapor_password \
  -p 5432:5432 -d postgres

这将会创建一个在Docker中运行的PostgreSQL数据库,这个数据库叫traderdb。现在,放回Xcode。通过Command和R或者左上角的运行按钮,编译并运行项目。等待Xcode运行。然后,确保控制台是打开的。你会看到一个NOTICE,这代表了项目成功在http://127.0.0.1:8080上运行。

小贴士:你可以忽略没有自定义工作目录的警告,这对本教程没有影响。如果你喜欢解决这个问题,阅读Vapor官方文档

大功告成。现在,让我们学习Vapor中async/await的基础。

async/await和EventLoopFuture之间的桥梁

接下来你所有学习的东西,都通过两个简单而强大的工具实现。这允许开发者以最小的代价将他们的代码迁移到使用async/await,但是你还需要知道如何更好的使用它们。

将EventLoopFuture转换为async/await

第一个工具是EventLoopFuture中的get()函数,这个函数允许你通过async/await获得EventLoopFuture中的值。考虑以下代码:

let usernameFuture: EventLoopFuture<String> = getUsernameFromDatabase()

现在,你可以通过异步的方式,获取函数的值。注意调用get()

let username: String = try await usernameFuture.get()

如你所见,get()函数将EventLoopFuture<String>变为了一个简单的String值。

将async/await转换为EventLoopFuture

问题来了,如果你需要与get()函数相反的功能,怎么办?有时,你没有一部分代码的控制权,或者你希望推迟迁移时间。同时,你需要与使用async/awiat部分的代码协作。

这就是第二个工具派上用场的时候。想象你需要将下面的async/await代码转变为返回EventLoopFuture的代码:

let email: String = try await getUserEmailAsync()

在Vapor中,如果你可以访问一个EventLoop,那么你可以很简单的实现:

let emailFuture: EventLoopFuture<String> = eventLoop.performWithTask {
    return try await getUserEmailAsync()
}

在上述代码中,首先,你只需调用performWithTask(_:)函数,这个函数可以在任何EventLoop上使用。然后,在该函数的闭包中运行你的async代码,最后,返回这个异步代码的最终值(结果)。

这非常简单,但是更好的消息时,大部分Vapor核心包已经完成了对async/await的支持。大多时候,你不需要使用这两个函数。:]

理解以同步上下文的方式运行异步任务

所有async/await函数必须在异步上下文中调用。这意味着,你不可以运行async/await在任何非async函数中:

但是,一些时候你需要绕过这个限制。所以,这就是Task存在的原因。在Swift中,Task是一个异步环境,可以在任何地方使用。你可以简单的创建一个Task实例,并运行你的异步代码。

如你所见,Task也有priority参数。你可以为你要运行的异步任务设置不同的优先级,Swift会基于这个运行你的异步任务。

最好不要使用Task,因为SwiftNIO在未来会更少的控制你的异步操作。在Swift5.5,Swift自己管理async/await。但是不久之后,SwiftNIO会替代它。那时候你会感谢自己不随意创建Task:]

事实上,现在SwiftNIO没有完全控制async操作的执行,这会导致一个问题:你的async/await代码会比EventLoop代码稍微慢一点。

在Vapor中使用async/await

让我们再回去查看示例项目。打开位于Controllers文件夹的CrateController.swift文件。其中位于CrateControllerboot(routes:)函数是用来设置Api的:

func boot(routes: RoutesBuilder) throws {
    let crateGroup = routes.grouped("crates")
    // 1
    crateGroup.get(use: all)
    // 2
    crateGroup.post(use: create)
    // 3
    crateGroup.post("trade", use: trade)
}

在上面的代码中,有三个Api:

  1. GETHTTP请求,在 /crates获取所有交易
  2. POSTHTTP请求,在 /crates添加新交易
  3. POSTHTTP请求,在 /crates/trade实现(允许)交易

再来看看all(req:)函数,尝试将其转换为使用async/await书写:

private func all(req: Request) throws -> EventLoopFuture<[Crate]> {
    Crate.query(on: req.db).all()
}

基于你现在所学,你可以在创建(Create.query)请求上使用get()函数,然后就会以async/await的方式运行了。同样要给函数加上async标签。这样函数将会变成一个async throwing函数并返回[Crate]而不是仅仅普通的一个返回EventLoopFuture<[Crate]>的可抛出错误函数:

private func all(req: Request) async throws -> [Crate] {
    try await Crate.query(on: req.db).all().get()
}

再次编译并运行。这时候,所有问题都清楚了。第一步转换现在已经大功告成。

但是,先前我们说过这个小帮手并不时常需要使用。所以删除get()函数的调用:

try await Crate.query(on: req.db).all()

再次编译并运行。你会看见Xcode成功运行了项目,并且没有因为通过async的方式使用all()而抛出错误。为什么会这样?

为了更加清楚与方便的使用async/await,Vapor团队为所有常用的返回EventLoopFuture的函数提供了async重载。这意味着,你可以使用两种all()函数。原始的返回EventLoopFuture,而新的是一个async函数。在上述代码中,你使用了async/await的all()重载函数,所以Swift认为这没有问题。

对于crateGroup.get(use: all)也是如此。你已经将all(req:)从一个返回EventLoopFuture的函数变为了返回一个async值。Vapor团结已经为所有路由创建器提供了async重载函数。所以,Swift会自动使用async/await模式的get(use:),并不会抛出错误。

这就是Vapor支持async/await的幕后。多数使用EventLoopFuture的函数现在已经可以使用async/await方式调用,易如反掌。

现在,让我们将create(req:)转换为使用async/await。create(req:)通过你发送的数据创建新的交易到数据库。他看起来是这样的:

private func create(req: Request) throws -> EventLoopFuture<[Crate]> {
    let crates = try req.content.decode([Crate].self)
    return crates.create(on: req.db).transform(to: crates)
}

通过你所学的,你需要两个小步骤:

  • 将函数申明由-> EventLoopFuture<Value>改为async throws -> Value
  • 在内部使用async/await的变体函数

转换之后,你的代码看起来像这样:

private func create(req: Request) async throws -> [Crate] {
    // 1
    let crates = try req.content.decode([Crate].self)
    // 2
    try await crates.create(on: req.db)
    // 3
    return crates
}

这是每一步的意义:

  1. 解码发送给你的数据
  2. 将解码的数据储存到PostgreSQL数据库
  3. 返回数据

目前为止,你已经学习了Vapor中async/await的基本用法。稍后你会返回这里,并学习一些进阶用法。

在Fluent上顺滑的使用

Fluent作为Vapor核心库之一,同样提供了async/await支持。这意味着你可以随时用async在任意请求上。

你已经通过all(req:)create(req:)函数创建了几个不同的Fluent请求。现在,是时候了解如何在迁移(migration)上使用async/await了。

更新Fluent迁移

在Fluent迁移中使用async/await非常简单,只有一点不同。打开位于Migrations文件夹的CreateCrate.swift,你会看到以下代码:

struct CreateCrate: Migration {
    func prepare(on database: Database) -> EventLoopFuture<Void> {
        database
            .schema(Crate.schema)
            .id()
            .field(Crate.FieldKeys.owner, .string, .required)
            .field(Crate.FieldKeys.item, .string, .required)
            .create()
    }
    
    func revert(on database: Database) -> EventLoopFuture<Void> {
        database
            .schema(Crate.schema)
            .delete()
    }
}

现在,你可以非常方便的将它们变为使用async/await。你的代码最终应该是这样的:

struct CreateCrate: Migration {
    func prepare(on database: Database) async throws {
        try await database
            .schema(Crate.schema)
            .id()
            .field(Crate.FieldKeys.owner, .string, .required)
            .field(Crate.FieldKeys.item, .string, .required)
            .create()
    }
    
    func revert(on database: Database) async throws {
        try await database
            .schema(Crate.schema)
            .delete()
    }
}

尝试编译并运行。Xcode会抛出CreateCrate不符合Migration协议的错误:

这是什么原因?答案非常简单。Migration协议需要prepare(on:) -> EventLoopFuture<Void>revert(on:) -> EventLoopFuture<Void>,但是你将代码变为了async throws

所以Vapor团队想出了一个简单的解决办法。他们提供了AsyncMigration 协议。这个协议和Migration类似,但是它允许asyncprepare(on:)revert(on:)函数。

现在,将Migration替换为AsyncMigration

struct CreateCrate: AsyncMigration {
...

AsyncMigration本身符合Migration协议,所以CreateCrate依旧符合Migration协议。这一位你可以在无需任何更改的情况下使用CreateCrate,你不需要再去修改任何代码了!

编译并运行。你会发现没有任何错误,整个项目成功运行了。

总的来说,如果你之前使用的协议或者类型使用了EventLoopFuture,而你现在希望将它们变为使用async/await。那通常来讲,你只需要加上Async前缀即可。举个例子,AsyncMigration就是Migration的async/await版本,所以它有一个Async前缀。

理解并发

是时候,大展身手将其他路由变为asycn/await代码了。不过,你现在的知识会让你的转换并不理想。

打开CrateController.swift并查看tradeOne(on:tradingSides:)

private func tradeOne(
    on db: Database,
    tradingSides: TradeItem
) -> EventLoopFuture<HTTPStatus> {
    // 1
    Crate.query(on: db)
        .filter(\.$id ~~ [tradingSides.firstId, tradingSides.secondId])
        .all()
        .tryFlatMap { bothCrates in
            // 2
            guard bothCrates.count == 2 else {
                throw Abort(.badRequest)
            }
            let crate1 = bothCrates[0]
            let crate2 = bothCrates[1]
            // 3
            (crate1.owner, crate2.owner) = (crate2.owner, crate1.owner)
            // 4
            let saveCrate1 = crate1.save(on: db)
            let saveCrate2 = crate2.save(on: db)
            // 5
            return saveCrate1.and(saveCrate2).transform(to: .ok)
        }
}

在上述代码中:

  1. 你获取了所有交易。记住,TradeItem只是两个id的容器,它们的物品将会通过App交易。这个查询会找到所有有这两个id的交易。
  2. 确保返回了两个交易,然后再给每个单独分配一个常量。
  3. 之后,交换交易中的项目。crate1会有crate2的项目,反之亦然。这就是交易的发生地。
  4. 然后,储存新的交易到数据库中。
  5. 最后,返回保存到结果,并将其转化为200 OK的HTTP状态。

现在,你要将tradeOne(on:tradingSides:)转换为使用async/await。转换之后,你的代码应该看起来像这样:

private func tradeOne(
    on db: Database,
    tradingSides: TradeItem
) async throws -> HTTPStatus {
    // 1
    let bothCrates = try await Crate.query(on: db)
        .filter(\.$id ~~ [tradingSides.firstId, tradingSides.secondId])
        .all()
    // 2
    guard bothCrates.count == 2 else {
        throw Abort(.badRequest)
    }
    let crate1 = bothCrates[0]
    let crate2 = bothCrates[1]
    // 3
    (crate1.owner, crate2.owner) = (crate2.owner, crate1.owner)
    // 4
    try await crate1.save(on: db)
    try await crate2.save(on: db)
    // 5
    return .ok
}

在此过程中,你做了以下事情:

  1. 获取了所有有关交易。
  2. 确保获得了两个,并依次分配个变量。
  3. 交换项目。crate1有了crate2项目,反之亦然。这时候就发生了交易。
  4. 保存交易到数据库。
  5. 返回200 OK的HTTP状态。

编译并运行,你会发现trade(req:)抛出了有关tradeOne(on:tradingSides:)的错误。这非常正常,你刚刚把tradeOne(on:tradingSides:)转换为了async/await函数,但是你忘记转换trade(req:)了:

所以,你需要将它也转换为async/await函数,现在你的代码看起来是这样的:

private func trade(req: Request) throws -> EventLoopFuture<HTTPStatus> {
    // 1
    let allTrades = try req.content.decode([TradeItem].self)
    // 2
    return allTrades.map { tradingSides in
        tradeOne(on: req.db, tradingSides: tradingSides)
    }
    // 3
    .flatten(on: req.eventLoop)
    // 4
    .transform(to: .ok)
}

下面我们来解释一下这段代码:

  1. 解码收到的TradeItem数组。
  2. 遍历allTrades,然后对每个TradeItem使用tradeOne(on:tradingSides:)
  3. 现在,你有个存满EventLoopFuture<Void>的数组,你将它们展开,变为一个EventLoopFuture<Void>,这个EventLoopFuture<Void>包含了所有交易结果。
  4. 最后,你将它们转化为200 OK的HTTP状态。

尝试将它们变为使用async/await。之后,你的代码应该是这样的:

private func trade(req: Request) async throws -> HTTPStatus {
    // 1
    let allTrades = try req.content.decode([TradeItem].self)
    // 2
    for tradingSides in allTrades {
        _ = try await tradeOne(on: req.db, tradingSides: tradingSides)
    }
    // 3
    return .ok
}

在上述代码中,你做了以下几件事:

  1. 解码收到的TradeItem数组。
  2. 遍历allTrades,然后对每个TradeItem使用tradeOne(on:tradingSides:)。你并不需要tradeOne(on:tradingSides:)的结果,你需要关系的是有没有抛出错误。
  3. 最后,返回200 OK的HTTP状态。

看起来转换trade(req:)tradeOne(on:tradingSides:)非常简单。你或许没有注意到,async/await的代码与EventLoopFuture在执行上有些些不同。

理解通过TaskGroup实现并发

再次查看trade(req:),然后与旧代码比较一下:

这是使用EventLoopFuture的旧代码:

allTrades.map { tradingSides in
    tradeOne(on: req.db, tradingSides: tradingSides)
}
.flatten(on: req.eventLoop)

这是使用async/await的新代码:

for tradingSides in allTrades {
    _ = try await tradeOne(on: req.db, tradingSides: tradingSides)
}

现在有个问题,使用EventLoopFuture代码是同时运行的,而async/await代码再是一个接一个运行的。EventLoopFuture代码自创建就启动了,但是async/await代码中,每个循环都会遇到await,这会停止并等待直到结果返回。这就导致async/await变得非常慢。

解决方法是使用TaskGroupTaskGroup能让所有任务通过并发的方式完成。你需要通过withTaskGroup(of:returning:body:withThrowingTaskGroup(of:returning:body:)来使用TaskGroup。这两个函数的区别是第二函数会给你一个ThrowingTaskGroup,这个TaskGroupaddTask(priority:operation:)函数允许抛出错误。ThrowingTaskGroup正如其名,是一个允许抛出错误的TaskGroup变体。

所以更好的转换是:

private func trade(req: Request) async throws -> HTTPStatus {
    // 1
    let allTrades = try req.content.decode([TradeItem].self)
    // 2
    return try await withThrowingTaskGroup(
        of: HTTPStatus.self
    ) { taskGroup in
        // 3
        for tradingSides in allTrades {
            taskGroup.addTask {
                try await tradeOne(on: req.db, tradingSides: tradingSides)
            }
        }
        // 4
        try await taskGroup.waitForAll()
        // 5
        return .ok
    }
}

在这里,你做了如下事情:

  1. 解码收到的TradeItem数组。
  2. 通过withThrowingTaskGroup(of:returning:body:)创建一个新的TaskGroupof参数代表了addTask(priority:operation:)将要返回的值。你会注意没有使用到returning参数,这是因为Swift自动推断出了TaskGroup会返回HTTPStatus
  3. 遍历任务,并为每一个TaskGroup通过addTask(priority:operation:)TaskGroup添加新的任务。priority可以使用默认值,也就是不填写。
  4. 等待所有任务完成。一个非常重要的点是,即使没有waitForAll(),Swift依然会等待所有任务完成。不过,如果没有了它,那么tradeOne(on:tradingSides:)抛出错误的时候将不会通知你。
  5. 返回200 OK的HTTP状态。

注意,你可以遍历TaskGroup中的值,并获取每个任务的结果。你通过可以在TaskGroup中使用next()函数,或者使用便利:

for await taskResult in taskGroup {
    // do something with the `taskResult`
}

幸运的是,你不需要返回值,所有不需要去获取它们。

理解通过async let并发

现在,你“修复”了trade(req:)函数,查看tradeOne(on:tradingSides:),它包含以下两行:

try await crate1.save(on: db)
try await crate2.save(on: db)

这是另外一个不太理想的代码!每个交易保存的时候都会等待一次,二这两个操作并不互相依赖。为了解决这个问题,你任然可以使用TaskGroup,不过这样就杀鸡用牛刀了。最好的方式是将这行代码替换成下面的:

async let crate1Saving: Void = crate1.save(on: db)
async let crate2Saving: Void = crate2.save(on: db)
_ = try await (crate1Saving, crate2Saving)

你会注意到,save(on:)是一个async函数,但是这里没有await关键词,而是变为了async letasync变量是一个Swift的新功能。这允许将async函数的值赋值给一个变量,并且没有异步等待。这就是上面代码为什么没有await的原因。你讲两个保存操作的值赋值给crate1Savingcrate2Saving,然后你推迟了await操作到另外的地方。

最后一行是等待两个进程都完成了再继续。在这里,你将保存进度赋值给了一个定义为(crate1Saving, crate2Saving)的元组。然后使用try await等待他们同时完成。_ =的原因是你不需要这个操作的结果。

对你来说,TaskGroupasync是最好的解决办法,你当然可以使用Task.detached(priority:operation:)运行并发代码。你需要在下次再学习这个知识。:]

下一步?

你可以下载位于本教程顶端/底部的示例项目。

译者注:请前往https://www.raywenderlich.com/29095914-async-await-in-server-side-swift-and-vapor下载示例项目

在这个教程中,你学习了Vapor中最重要的异步知识,不过学无止境,你还有很多要学。

学习更多新的并发API,查看Modern Concurrency in Swift

如果你需要了解更多的Vapor新的async/await API,查看官方的Vapor documentation

我们希望你可以享受这个教程,如果你有任何问题可以前往原文评论,或者前往论讨论。

本文翻译自:https://www.raywenderlich.com/29095914-async-await-in-server-side-swift-and-vapor
转载本文请包含版权信息
You’ve successfully subscribed to UTS Blog
Welcome back! You’ve successfully signed in.
Great! You’ve successfully signed up.
Your link has expired
Success! Check your email for magic link to sign-in.