Promisesの紹介
実際に使ってみて、評価した結果
あるモバイルアプリ開発プロジェクトで実際にこのPromisesを導入してみたところ、なかなか良好な使い勝手とパフォーマンスでした。今後は有力な選択肢になるかと思い、その基本機能について原典を元に参考資料としてまとめておくことにしました。
そもそもPromise(プロミス)とは
Promise(プロミス)とは、並列プログラミング言語における同期処理実行を構成するための概念の一つです。
一般的には、非同期タスクのイベント結果を返し、もしそのタスクが失敗した時はエラー原因を返すといった機構を表現するものです。類似した概念として、Futuresといったものもあります。
先行事例としては、既にJavascriptなどで実装されている例が多いと思います。Javascriptの世界においては、ECMAScript 2015(ES6)でpromise機能が導入されました。
Promisesフレームワーク
Promisesは、SwiftとObjective-Cで、同期処理構造を実現するモダンなフレームワークです。
本稿では、Swiftに絞って、基本的な使い方を確認していきます。
特徴
ざっくり言うと、RxSwiftなどの人気フレームワークに比較して、容量や処理性能が優れているのだそうです。ベンチマークを計測し、2倍以上バイナリサイズが小さい、かつ直列キュー、並列キュー上の処理性能が優れているなど、実測データが紹介されています。例えば、列キューに連結された処理ブロックに到達する平均時間は2.5倍程速いなど、色々良いと言われています。その他、注目すべき特徴は下記の通りです。
- シンプル: 既存コード上にも展開しやすい、直感的なAPIを持っている
- 互換性: Objective-CとSwiftの両方をサポートしている
- 軽量: GCDやコールバックとほぼ同等レベルのパフォーマンスを達成する最小のオーバーヘッド
- 柔軟: どのスレッドやキュー上でも発動するオブザーバー処理ブロックを使用できる
- 安全: 全ての処理ブロックは、GCDによって管理される
- テスト済み: 100%のコードカバレッジでテストされている
導入
今回はCocoaPodsでの導入方法を紹介します。
CocoaPodsで導入
podがインストール済み、Xcodeプロジェクト作成済みであることを前提として、Podfileに下記を追記します。
# targetは環境に応じて変更
target 'PromisesExample' do
use_frameworks!
pod 'PromisesSwift', '~> 1.2.7'
end
プロジェクトディレクトリでpod install
を実行し、open PromisesExample.xcworkspace
でXcodeを起動します。
Swiftコード上では、モジュールのインポートを行うと使えます。
一度ビルドを通してから追記するのが良いかもしれません。
import Promises
その他の方法
Bazel, Swift Package Manager, Carthageで行う方法もありますが、これは割愛します。
基本的な使い方
プロミスの作成
非同期処理をするには、所定の作業ブロックを指定します。そしてfulfill()をパラメータ付きで発動し、成功処理、reject()でエラーオブジェクトを引数にとって失敗処理を行います。successはBool型のフラグ、someErrorは何らかのErrorオブジェクトとして次のような構文で記述します。
非同期処理の基本
Swift:
// 保留状態の処理を変数に格納
let promise = Promise<String>(on: .main) { fulfill, reject in
// 指定のディスパッチキュー上で非同期処理される
if success {
// 成功処理
fulfill("Hello world.")
} else {
// エラー処理
reject(someError)
}
}
非同期処理の省略形
Promisesフレームワークは、デフォルトでメインディスパッチキューを使用します。なので、 下記のコードも同様の処理となります。
Swift:
// 保留状態の処理を変数に格納
let promise = Promise<String> { fulfill, reject in
// デフォルトキュー上で非同期処理される
if success {
// 成功した場合
fulfill("Hello world.")
} else {
// 失敗、エラーになった場合
reject(someError)
}
}
doブロック 非同期処理の最小構文
さらに、doブロックでもっとも簡潔な記述をすると次のようにできます。
Swift:
let promise = Promise { () -> String in
// デフォルトキュー上で非同期処理される
guard success else { throw someError }
return "Hello world"
}
保留する
非同期処理ブロックを指定しないでプロミスオブジェクトを保留するには、pending()関数を用います。
そして後段の処理で結果を返すことができます。
Swift:
let promise = Promise<String>.pending()
// ...
if success {
promise.fulfill("Hello world")
} else {
promise.reject(someError)
}
解決済みプロミス生成
既に結果を解決済みのプロミスを生成できると便利な場合があります。その時は、初期値またはエラーをプロミスのコンストラクタに渡してください。例えば、次のようになります。
Swift:
func data(at url: URL) -> Promise<Data?> {
// 妥当性チェックなどの場面で、即座にnilを返すことができる
if url.absoluteString.isEmpty {
return Promise(nil)
}
return load(url)
}
成功処理の監視
プロミスからの結果通知を受け取るには、then
演算子を使います。
様々な方法で保留プロミスの結果を処理することができます。
- プロミス上で
fulfill
メソッドを実行 - 非同期処理ブロック内で
fulfill
メソッドを実行 -
then
ブロックから戻り値を返却 - エラーなしで、解決済みのプロミスを返却
Then
then演算子は引数に一つの処理ブロックを取ります。処理ブロックは、前段の処理結果を引数で受け取り、必要な処理を経由して、他のプロミスオブジェクトや値、エラーを戻すようにします。
Swift:
let numberPromise = Promise(42)
// 別のプロミスを返す
let chainedStringPromise = numberPromise.then { number in
return self.string(from: number)
}
// 値を返す
let chainedStringPromise = numberPromise.then { number in
return String(number)
}
// エラーを返す
let chainedStringPromise = numberPromise.then { number in
throw NSError(domain: "", code: 0, userInfo: nil)
}
// Voidを返す
let chainedStringPromise = numberPromise.then { number in
print(number)
// Implicit 'return number' here.
}
注意: chainedStringPromiseは、Voidを返す一つの例です。
thenの処理ブロックをメインスレッドでなく、別のキューで実行させたい場合は、次のように記述します。
numberPromise.then(on: backgroundQueue) { number in
return String(number)
}
Then のパイプライン
そして、これが最も重要な機能です。非同期処理のプロミスをパイプラインでつないで、まとめて同期処理を行うところです。次のようなサンプルで実行します。
Swift:
// 処理1、文字列を返す非同期Promise
func work1(_ string: String) -> Promise<String> {
return Promise {
return string
}
}
// 処理2、数値を返す非同期Promise
func work2(_ string: String) -> Promise<Int> {
return Promise {
return Int(string) ?? 0
}
}
// 処理3、数値を返す関数
func work3(_ number: Int) -> Int {
return number * number
}
// 処理1が実行されたら、
work1("10").then { string in
// 処理2を実行し、
return work2(string)
}.then { number in
// 処理3を実行して、
return work3(number)
}.then { number in
// 結果を出力して終了する
print(number) // 100
}
// Swiftでは上記の例をもっとシンプルに記述することができます。
work1("10").then(work2).then(work3).then { number in
print(number) // 100
}
失敗処理の監視
プロミスからのエラー、失敗通知を受け取るには、catch
演算子を使います。
様々な方法でプロミスの失敗を処理することができます。
- プロミス上で
reject
メソッドを実行 - 非同期処理ブロック(asyncやdoブロック)内で
reject
メソッドを実行 -
then
ブロックからエラーを返却またはthrow - エラーありで、解決済みのプロミスを返却
Catch
catch演算子は一つの処理ブロックを取ります。処理ブロックは、プロミスの処理で失敗した時のエラーを受け取ります。
Swift:
number(from: "abc").catch { error in
print("Cannot convert string to number: \(error)")
}
Catch のパイプライン
従来のコールバックのネストは、コードが複雑化してしまうなど、開発が困難になることもあります。予期せぬバグの温床となったり、メンテが大変になるなど、問題化しやすい方法と言えます。
本Promisesにおいては、エラーが発生した場合、自動的にパイプラインに伝播され、残りの処理ブロックは無視されて、エラーが通知される仕組みを持っています。catch
演算子は、処理連鎖のどこに配置されてもよく、柔軟にエラーハンドリングができるものです。
Swift:
// エラーの定義
struct CustomError: Error {}
// 処理1、文字列を返す非同期Promise
func work1(_ string: String) -> Promise<String> {
return Promise {
return string
}
}
// 処理2、数値を返す非同期Promise
func work2(_ string: String) -> Promise<Int> {
return Promise { () -> Int in
guard let number = Int(string), number > 0 else { throw CustomError() }
return number
}
}
// 処理3、数値を返す関数
func work3(_ number: Int) -> Int {
return number * number
}
// 処理1が実行されたら、
work1("abc").then { string in
// 処理2を実行し、文字列を変換しようとしてエラーが発生
return work2(string)
}.then { number in
// 前段の処理でエラーになるので到達しない
return work3(number)
}.then { number in
// 前段の処理でエラーになるので到達しない
print(number)
}.catch { error in
// エラーが捕捉され、ログ出力して終了する
print("Cannot convert string to number: \(error)")
}
拡張機能
このフレームワークで、async, do, then, catchといった基本の演算子を使っていれば、一般的な非同期処理には十分に実装することができます。
それでもやはり、より一層ハイレベルなパターンにも対応した拡張機能が多数提供されています。
各機能を列挙すると次のようになりますが、詳しくは原典をあたりましょう。通常は、基本パターンで間に合うはずです。
All, Always, Any, Await, Delay, Race, Recover, Reduce, Retry, Timeout, Validate, Wrap
アンチパターン
やってはいけない、誤った使用方法があります。ごく簡単ですが、紹介しておきましょう。
破損したサイクル
正しいパイプラインとなっていないケース。
Swift:
func asyncCall() -> Promise<Data> {
let promise = doSomethingAsync()
promise.then(processData)
return promise
}
プロミスのネスト
入れ子構造にするのはやめた方が良いとのこと。
Swift:
loadSomething().then { something in
self.loadAnother().then { another in
self.doSomething(with: something, and: another)
}
}
まとめ
ということで、最後にまとめておきます。
async/await方式より簡潔な記述はできないが、十分に便利なライブラリになっていると思います。いわゆるコールバック地獄から開放されより見通しやすい非同期処理が実現できるでしょう。アプリ開発の場面で、しばらく採用していきたいと思いました。