概要
- Promiseは便利だが、うまくモデリングしないと(時にうまくモデリングされていても)、深いネストとアロー関数の海におぼれてしまうことがある
- コンストラクタのresolveとrejectへの参照を保管しておくと、深いネストから解放されることがあるが結構書きづらく読みづらい
- resolveとrejectを抜き出すためのモジュールを導入すると、多少書きやすくなる(この記事での提案部分)
- 上手く使えばすっきりかけるが、多用が必要な時はたぶんモデリングを見直したほうがよい
- そもそももっといい方法があるぞ、ちゃんとしたデザインパターンがあるぞ、ということをご存知の方は教えて下さい
Promiseでネストが深くなる例
非同期で外部やユーザのアクションを待つときに、Promiseを受け取ることでawait
(やthen
)で同期っぽいコードですっきりと利用側のコードを書ける場合がある。(以下のようなイメージ)
this.ued = new UserEditorDialog
const data = await this.ued.show() // awaitでued.showが終わるのを待つ
// data を使った処理
しかしながら、(別のイベントの発火を受けてPromiseの値を返せる場合などは)Promiseのコンストラクタが複雑になって、ネストの深いアロー関数群が生成され嬉しくない場合がある。
class UserEditorDialog {
dialog: HTMLDivElement
okButton: HTMLButtonElement
ngButton: HTMLButtonElement
textArea: HTMLTextAreaElement
show() {
return new Promise((resolve, reject)=>{
this.dialog.hidden = false
this.okButton.onclick = () => {
this.dialog.hidden = true
resolve(new Data(this.textArea.value))
}
this.ngButton.onclick = () => {
this.dialog.hidden = true
reject("Ng button clicked")
}
})
}
}
ネストを解消する方法
深くなってしまったネストを解消ための手順として、resolveとrejectへの参照を保持しておくことで後からPromiseの値を解決することができる。
class UserEditorDialog {
dialog: HTMLDivElement
okButton: HTMLButtonElement
ngButton: HTMLButtonElement
textArea: HTMLTextAreaElement
private _resolve: (value?: Data) => void
private _reject: (reason?: any) => void
show() {
const promise = new Promise((resolve, reject)=>{
this._resolve = resolve
this._reject = reject
})
this.dialog.hidden = false
this.okButton.onclick = this.onOkClicked
this.ngButton.onclick = this.onNgClicked
return promise
}
onOkClicked() {
this.dialog.hidden = true
this._resolve(new Data(this.textArea.value))
}
onNgClicked() {
this.dialog.hidden = true
this._reject("Ng button clicked")
}
}
ただしこの方法のデメリットとして
- resolveとrejectが分かれていて、ぱっと見だいぶわかりづらい
- resolve/rejectし忘れて、永遠に解決されないPromiseが生じる(コンストラクタでも一緒だけど、忘れやすくなる気がする)
といった問題がある
(提案部分)いい感じのモジュールでラップすると少し良くかける
そこでresolveとrejectをまとめて扱えるように以下のようなモジュールを導入することを提案したい
export class PromiseOf<T> {
promise: Promise<T>
private _resolve: (value?: T | PromiseLike<T>) => void
private _reject: (reason?: any) => void
constructor() {
this.promise = new Promise<T>((resolve, reject) => {
this._resolve = resolve
this._reject = reject
})
}
resolve(value?: T | PromiseLike<T>){
this._resolve(value)
}
reject(reason?: any){
this._reject(reason)
}
}
export function getPromiseOf<T>(p:PromiseOf<T>): Promise<T> {
return p.promise
}
すると以下のように大分見通しよく書くことができる。
class UserEditorDialog {
completed: PromiseOf<Data> // PromiseOf<返り値>オブジェクトを追加
dialog: HTMLDivElement
okButton: HTMLButtonElement
ngButton: HTMLButtonElement
textArea: HTMLTextAreaElement
show() {
this.dialog.hidden = false
this.completed = new PromiseOf<Data>()
this.okButton.onclick = this.onOkClicked
this.ngButton.onclick = this.onNgClicked
}
onOkClicked() {
this.dialog.hidden = true
this.completed.resolve(new Data(this.textArea.value)) // PromiseOf オブジェクトをresolveする
}
onNgClicked() {
this.dialog.hidden = true
this.completed.reject("Ng button clicked") // PromiseOf オブジェクトをrejectする
}
}
使う側は直接Promiseを受け取らないので以下のような感じになる
this.ued = new UserEditorDialog
this.ued.show()
const data = await getPromiseOf(this.ued.completed)
// dataを使った処理
メリットとして
- 別のイベントを挟んでもネストとアロー関数が消えて見通しがよい
- 「AがBを呼び出してPromiseを貰って、AがCにPromiseを渡し、CがPromiseが解決した後の値を使う」みたいな(実はいらない)A-C間の依存があった場合に、「AがBを呼び出し、CはBの持ってるPromiseを参照して解決した後の値を使う」といった形にでき、A-C間の結合を切ることができる(シーケンス図がきれいになるイメージ/アクターモデルっぽいイメージ)
また、やや悪用すると、AOPみたいな機能の突き刺しを作りやすくなり、例えばカウンタやロギングとかを仕込みやすくなる。例えば下記の例だと、ダイアログの表示時とユーザの保存時にそれぞれdialogDisplayed
/saveCompleted
が解決され、イベントハンドラのように外側から用いることができる(このControllerクラス自体は振る舞いを持たず、外から勝手にPromiseを参照して、振る舞いを追加することができる)
class Controller {
dialogDisplayed = new PromiseOf<void>()
saveCompleted = new PromiseOf<void>()
// GET /edit/:id
async edit(id: number) {
try {
const userEditor: UserEditor = new UserEditor
const user: User = await userRepository.getUserById(id)
userEditor.showDialogue(user)
this.dialogDisplayed.resolve()
const newUser = await getPromiseOf(userEditor.completed)
await userRepository.save(newUser)
this.saveCompleted.resolve()
} catch (reason) {
this.dialogDisplayed.reject(reason)
this.saveCompleted.reject(reason)
}
}
}
上手く使えば、コードをきれいに整理でき、だいぶ見やすくなる(気がする)。
ただしトレーサビリティはそんなに高くないので多用すべきでなく、多用したくなったらモデリングを見直したほうがよい(気がする)
ご意見をください
イベントをまたいだPromiseの解決や、複数のパイプラインをまたぐデータ変換において、ネストやアロー関数地獄に陥ったので、こんなのあれば便利そうだぞと思って作りました。そして自分で使って実際に便利だと感じています。
が、「そもそもこんなものを導入しなくてもこう書けばいいんだぞ/こう書くのがセオリーだぞ」みたいなことをご存知の方がいたら是非教えてください。