🐤

Server Side Swiftをasync/awaitに書き換えていく

2021/12/19に公開

概要

もともとServer Side Swift Vapor4.0を用いて運用していたアプリケーションにasync/awaitを導入しコードをキレイにしたので、その所感をお伝えします。

よくある使ってみた系ではなく1年間実際に運用していたアプリケーションのリファクタになるため、実務的な内容になっています。
そのために抽象化も進んでおり、知らない関数なども出てくると思いますが何とか雰囲気で感じ取ってください。(Swiftへの愛があれば大丈夫!)

特にServer Side Swiftに興味がある人にとって良い参考になることを願っています。

背景

弊社では音楽ライブの体験を広げるSNS「OTOAKA」というiOSアプリを運営しています。

実はこのiOSアプリのバックエンドにはServer Side Swiftを採用しています。バックエンドサーバーはもう本番環境で1年ほど利用していると思います。(フロントエンド自体はコロコロ変わっていますが)
ほぼほぼ問題なく動作しており、おかげで日々充実した毎日を過ごしています。

Swiftはとても強力で安全性の高い言語ですが、非同期接続に必要なSwiftNIOのEventLoopFuture型だけが弱点でした。EventLoopFutureはいうなればJavaScriptでいうPromiseのようなもので、Promiseの複雑なネストを書いた事がある人ならば何となくその処理の複雑さが理解できると思います。

特にServer Side SwiftではEventLoopFutureモナドに包んで処理をするのが本当に厄介なのですが、例えばウチにはこんなコードが存在します。

let isLiked = LiveLike.query(on: db)
    .filter(\.$live.$id == id.rawValue)
    .filter(\.$user.$id == selfUserId.rawValue)
    .count().map { $0 > 0 }
let likeCount = LiveLike.query(on: db)
    .filter(\.$live.$id == id.rawValue)
    .count()
...

return live.and(isLiked).and(participants).and(likeCount).and(ticket).and(postCount).and(participatingFriends)
    .map { (
        $0.0.0.0.0.0.0,
        $0.0.0.0.0.0.1,
        $0.0.0.0.0.1,
        $0.0.0.0.1,
        $0.0.0.1,
        $0.0.1,
        $0.1
    ) }
    .map {
        (
        live: Domain.Live, isLiked: Bool, participants: Int, likeCount: Int,
        ticket: Domain.Ticket?, postCount: Int, participatingFriends: [Domain.User]
        ) -> Domain.Hoge in
        return ...
    }

EventLoopFuture同士をand()でまとめてmap()でポイっとする必要があり、お陰様でこのようなクソキモコードが完成します。はっきり言って地獄です。

ウチのサーバーサイドにはこんなコードが他に何十箇所も存在していました。一目瞭然ですが、このコードにはこのようなデメリットがあります。

可読性 - 意味を持たない行が存在する
- 関数のreturn付近に処理が偏る
作業効率 - ネストが深くなっていくとXcodeが補完を出してくれなくなる
- 普通に書きづらい
- タグの閉じ忘れが発生する
- flatMap()やmap()を多用すると返り値の型がTかEventLoopFuture<T>か分からなくなる
保守性 - コードが複雑でhuman faultsが頻発する

しかし逆に自分を信じなくなり、積極的にテストを書くようになるというメリットもあります。お陰様でテストケースが50以上まで増え、常に高いカバレッジを維持するようになりました。
とはいえ我々は一刻も早くこの負債を撲滅したいと思い、Vaporにasync/awaitが来るのを待ちわびていました。

さて、今年Swift Concurrencyが正式リリースされましたが、最近ちょうどVaporのドキュメントを読んでいたらいつの間にかasync/await仕様になっていることに気付きました。コミュニティを見ていてもasync/await対応はもう済んでいる様子だったので我々もこれを機にEventLoopFutureを滅ぼしていこうと思いました。

手順

  1. パッケージをアップデートする
  2. Swift5.5にアップデートする
  3. 小さなものからasync/awaitに書き換えていく

1. パッケージをアップデートする

GitHubのdiffを見ても既存のAPIと非互換のものは特にないようだったので一気に引き上げました。

Package.swift
    dependencies: [
-       .package(url: "https://github.com/vapor/vapor.git", from: "4.47.0"),
+       .package(url: "https://github.com/vapor/vapor.git", from: "4.54.0"),
-       .package(url: "https://github.com/vapor/fluent.git", from: "4.0.0"),
+       .package(url: "https://github.com/vapor/fluent.git", from: "4.4.0"),
-       .package(url: "https://github.com/vapor/jwt-kit.git", from: "4.0.0"),
+       .package(url: "https://github.com/vapor/jwt-kit.git", from: "4.3.0"),
-       .package(url: "https://github.com/vapor/fluent-kit.git", from: "1.10.0"),
+       .package(url: "https://github.com/vapor/fluent-kit.git", from: "1.18.0"),
-       .package(url: "https://github.com/apple/swift-nio.git", from: "2.18.0"),
+       .package(url: "https://github.com/apple/swift-nio.git", from: "2.36.0"),
        .package(url: "https://github.com/vapor/fluent-mysql-driver.git", from: "4.0.0"),
	...

swiftSettingsにこれを追加しておくとSwift Versionが5.5未満でも動くようになります。

	.unsafeFlags(["-Xfrontend", "-disable-availability-checking"])

2. Swift5.5にアップデートする

Dockerfile
# syntax=docker/dockerfile:experimental
# ================================
# Build image
# ================================
- FROM swift:5.3-focal as build
+ FROM swift:5.5-focal as build

...

# ================================
# Run image
# ================================
- FROM swift:5.3-focal-slim
+ FROM swift:5.5-focal-slim

...

テストを実行している場合、このように書き換えてしまってOKです。

- $ swiftenv exec swift test --enable-test-discovery
+ $ swiftenv exec swift test

3. 小さなものからasync/awaitに書き換えていく

一斉に書き換えてデプロイするのではなく、小さく小さくデプロイしていくようにしました。
書き換えの優先順位としてこのような方針を取っています。

  1. iOSから呼び出さないエンドポイントから書き換えていく
  2. 依存の小さなコードから書き換えていく
  3. 上のレイヤー(Controller)から書き換えていく

試しにとあるエンドポイントのControllerを書き換えるとこのようになります

HogeController
struct HogeController: RouteCollection {
    func boot(routes: RoutesBuilder) throws {
	try routes.on(
             endpoint: Endpoint.Hoge.self,
             use: injectProvider { req, uri, repository in
                 let user = try req.auth.require(Domain.User.self)
                 let groupRepository = makeGroupRepository(request: req)
-                let live = repository.getLive(by: uri.liveId)
-                let precondition = live.unwrap(orError: Abort(.notFound))
-                    .flatMap {
-                        groupRepository.isMember(of: $0.hostGroup.id, member: user.id).and(
-                            value: $0)
-                    }
-                    .guard({ $0.0 }, else: Abort(.badRequest))
-                return precondition.flatMap { _, live in
-                    repository.getParticipants(liveId: live.id, page: uri.page, per: uri.per)
-                }
+                guard let live = try await repository.getLive(by: uri.liveId).get() else { throw Abort(.notFound, stackTrace: nil) }
+                guard try await groupRepository.isMember(of: live.hostGroup.id, member: user.id).get() else { throw Abort(.badRequest, stackTrace: nil) }
+
+                return try await repository.getParticipants(liveId: live.id, page: uri.page, per: uri.per).get()
             })
	 ...
    }
}

とても抽象化されているので、見ても分からない関数などがあると思います。xxxxRepositoryはRepository Layerのクラス、routes.on()とinjectProvider()はそれをDIPするための関数です。

EventLoopFuture<T>.get()という関数はEventLoopFutureをasync/awaitに橋渡しするための関数で、awaitでEventLoopFuture型をキャッチする時に必要になります。この関数を利用することで部分的にasync/awaitに書き換えていくことが可能となります。

改めてasync/await部分の中身を見てみると

HogeController
		let user = try req.auth.require(Domain.User.self)
                let groupRepository = makeGroupRepository(request: req)
                guard let live = try await repository.getLive(by: uri.liveId).get() else { throw Abort(.notFound, stackTrace: nil) }
                guard try await groupRepository.isMember(of: live.hostGroup.id, member: user.id).get() else { throw Abort(.badRequest, stackTrace: nil) }
                
                return try await repository.getParticipants(liveId: live.id, page: uri.page, per: uri.per).get()

ネストもなくなりとてもキレイになりました。
行ごとに処理が分散し、意味を持つようになりました。

さらにRepositoryのとある関数もasync/awaitでリファクタすると

LiveRepository.swift
-    public func getLiveDetail(by id: Domain.Live.ID, selfUserId: Domain.User.ID) -> EventLoopFuture<
-        Domain.LiveDetail?
-    > {
-        let isLiked = LiveLike.query(on: db)
+    public func getLiveDetail(by id: Domain.Live.ID, selfUserId: Domain.User.ID) async throws -> Domain.LiveDetail {
+        let isLiked = try await LiveLike.query(on: db)
             .filter(\.$live.$id == id.rawValue)
             .filter(\.$user.$id == selfUserId.rawValue)
-            .count().map { $0 > 0 }
-        let likeCount = LiveLike.query(on: db)
+            .count() > 0
+        let likeCount = try await LiveLike.query(on: db)
             .filter(\.$live.$id == id.rawValue)
             .count()
-        let postCount = Post.query(on: db)
+        let postCount = try await Post.query(on: db)
             .filter(\.$live.$id == id.rawValue)
             .count()
-        let participatingFriends = LiveLike.query(on: db)
+        let likeUsers = try await LiveLike.query(on: db)
             .join(UserFollowing.self, on: \LiveLike.$user.$id == \UserFollowing.$target.$id)
             .filter(\.$live.$id == id.rawValue)
             .filter(UserFollowing.self, \.$user.$id == selfUserId.rawValue)
             .fields(for: LiveLike.self)
             .with(\LiveLike.$user)
             .all()
-            .flatMapEach(on: db.eventLoop) { [db] in Domain.User.translate(fromPersistance: $0.user, on: db) }
+            .map { $0.user }
+        var participatingFriends: [Domain.User] = []
+        for user in likeUsers {
+            let friend = try await Domain.User.translate(fromPersistance: user, on: db).get()
+            participatingFriends.append(friend)
         }
+        guard let live = try await Live.find(id.rawValue, on: db) else { throw Error.liveNotFound }
+        let domainLive = try await Domain.Live.translate(fromPersistance: live, on: db).get()

-        return Live.find(id.rawValue, on: db).optionalFlatMap { [db] in
-            let live = Domain.Live.translate(fromPersistance: $0, on: db)
-            return live.and(isLiked).and(likeCount).and(postCount).and(participatingFriends)
-                .map { (
-                    $0.0.0.0.0,
-                    $0.0.0.0.1,
-                    $0.0.0.1,
-                    $0.0.1,
-                    $0.1
-                ) }
-                .map {
-                    (
-                        live: Domain.Live, isLiked: Bool, likeCount: Int,
-                        postCount: Int, participatingFriends: [Domain.User]
-                    ) -> Domain.LiveDetail in
-                    return Domain.LiveDetail(
-                        live: live,
-                        isLiked: isLiked,
-                        likeCount: likeCount,
-                        postCount: postCount,
-                        participatingFriends: participatingFriends
-                    )
-                }
+        return Domain.LiveDetail(
+            live: domainLive,
+            isLiked: isLiked,
+            likeCount: likeCount,
+            postCount: postCount,
+            participatingFriends: participatingFriends
+        )
     }

こうなります。本当にキレイになりました。

ただこの部分を見ると配列の逐次処理は前の方が見やすいかなと思います。
JavaScriptと同様にmap()の中でawaitが書けないのが原因ですね。(Swiftだしいつか書けるようになるのではないかな)

+        let likeUsers = try await LiveLike.query(on: db)
             .join(UserFollowing.self, on: \LiveLike.$user.$id == \UserFollowing.$target.$id)
             .filter(\.$live.$id == id.rawValue)
             .filter(UserFollowing.self, \.$user.$id == selfUserId.rawValue)
             .fields(for: LiveLike.self)
             .with(\LiveLike.$user)
             .all()
-            .flatMapEach(on: db.eventLoop) { [db] in Domain.User.translate(fromPersistance: $0.user, on: db) }
+            .map { $0.user }
+        var participatingFriends: [Domain.User] = []
+        for user in likeUsers {
+            let friend = try await Domain.User.translate(fromPersistance: user, on: db).get()
+            participatingFriends.append(friend)
         }
+        guard let live = try await Live.find(id.rawValue, on: db) else { throw Error.liveNotFound }
+        let domainLive = try await Domain.Live.translate(fromPersistance: live, on: db).get()

さいごに

Vaporのasync/await対応がEventLoopFutureと共存できる形で本当に良かったです。全てのコードを書き換える必要があったらまだasync/awaitへの移行は見送っていたでしょう。

async/await実装のお陰でServer Side Swift Vaporはサーバサイド言語としても他の言語/フレームワークに対して利が大きくなったと考えています。

採用がボトルネックだと考えていましたが、書き方自体はもうほぼ他の言語と変わりありません。普段Node.jsやGolangを書いている人でもすんなり書けることは間違いないでしょう。

これを機にもっとServer Side Swiftの実例が増えていくことを願い、そのためにも我々はこれからも実例を創っていきます。

Discussion