GitHub のプルリクで blame する ghpr-blame.vim つくった
先日 kazuho さんが git blame でプルリクを表示するスクリプトをつくってらっしゃって,便利そうだったので Vim プラグインをつくってみました. ファイルの各行がどのプルリクで変更されたかを確認し,気になるプルリクはその場で詳細を確認することもできます.
使い方
インストールはお好みの Vim プラグインマネージャを使うなどしてください.
1. ファイルを開いて :GHPRBlame
を実行
:GHPRBlame
を実行すると裏で git-blame
が走り,カレントバッファのファイルの各行のプルリク情報を git blame --line-porcelain
で引っ張ってきます.
引っ張ってきた情報を元にカレントバッファの左に細長い一時バッファが開き,そこに各行に紐付いたプルリク番号が表示されます(プルリクに紐付いていない行は何も表示されません).
これによって,各行がどのプルリクによって入ったものかが分かります.
2. プルリクの詳細を見たい行で <CR>
を押す
プルリクの詳細が見たい位置にカーソルを移動し,<CR>
(g:ghpr_show_pr_mapping
で別のキーにも変更可能)を押すと,カレントバッファのウィンドウの右か下に一時バッファが開き,そのプルリクの情報(タイトル,URL,作成者,マージされた日付,本文)を確認することができます.
<CR>
を押した際に裏で GitHub API を叩いて対象のプルリクの情報を引っ張ってきているので,表示に少し時間がかかります.一応キャッシュしているので2回目以降同じプルリクは高速に表示できます.
3. 終了する
一番左のプルリク一覧のウィンドウを閉じると自動で右側のウィンドウも閉じ,キャッシュや<CR>
マッピングを削除します.もしくは :GHPRBlameQuit
で明示的に終了することもできます.
まとめ
ファイルの各行の変更をプルリク単位で調べられる ghpr-blame.vim をつくりました.これ系のは tig みたいに TUI なツールのほうが便利かなと思いつつ,結局コードを読む時は Vim で開いて追いながら読むことが多いので Vim プラグインとしてつくってみました. もし気になったらお試しください.
Goのコマンドラインツールをセルフアップデートするためのライブラリつくった
突然ですが,Goでコマンドラインツールを書く時,ツールの配布はどうしているでしょうか?
go get
でインストールできるようにする- GitHub 上にリリースして,ダウンロードして使ってもらう
- システムのパッケージマネージャ(Homebrew など)を使う
などがメジャーかと思います.
ただ,これらの選択肢はどれも問題があります.
go get -u
は常にリポジトリの HEAD をインストールしてしまうため,ユーザがインストールしたタイミングに依存したバイナリができてしまいます.これを避けるには dev ブランチを切ってそっちで開発する必要がありますが,Go のツールはそうなってないものが多く,どのタイミングで go get -u
したら良いかユーザには容易に判断できません.また,仮に dev ブランチ運用したとしても依存ライブラリの更新のタイミングは制御できず,vendoring などを使う必要があります.
GitHub 上でのリリースは各バージョンごとにテストが通ったものをリリースノート付きでリリースできますが,ユーザが自発的にアップデートを確認し,手でバイナリをダウンロードしてくる必要があります.
システムのパッケージマネージャは上記の問題を解決してくれますが,プラットフォームに依存し,アップデート時に余計な手間も出ます(例えば Homebrew なら homebrew-core へのプルリクなど).
Go は全てを静的リンクした1つのバイナリを簡単にクロスコンパイルできる利点を持っているため,これをうまく使えないかと思い,go-github-selfupdateをつくりました.この記事ではその紹介をします.
go-github-selfupdate とは
go-github-selfupdate とは,GitHub 上にある最新のリリースを検知して,自分自身のバージョンよりも高ければ自動で自分自身のバイナリを更新する(セルフアップデート)機能を提供するためのライブラリです.
以下のような流れでセルフアップデートを行います.
- GitHub の Releases API を使い,プレリリースでない最新のリリースを Git のタグ名から判定してリリース物の URL を取得
- リリース物をダウンロードし,zip や gzip,tar.gz などの圧縮形式・アーカイブ形式をファイル名から判定し,自動で展開・解凍して目的のバイナリを引っ張ってくる
- inconshreveable/go-update を使って自身の実行ファイルをダウンロードした新しいものに差し替える
これによって,GitHub で公開している最新の安定版バイナリにそのコマンドから自動検知・更新することができます.
Travis CI や AppVeyor を使って Linux, Windows, macOS でテストし,正常系だけでなく異常系もテストしています(カバレッジ90%以上). また,inconshreveable/go-update を使っているので,実行ファイルの差し替えに失敗した場合には自動で元の実行ファイルにロールバックしてくれます. zip,gzip,tar.gz の解凍・展開には Go の標準ライブラリを使っています.
使い方
お試し CLI
テストにも使っているお試しの CLI があるので,下記のように試せます.これはリリースページのバイナリにセルフアップデートする(v1.2.3 → v1.2.4)例です.
$ go get -u github.com/rhysd/go-github-selfupdate/tree/master/cmd/selfupdate-example $ # バージョンを確認 $ selfupdate-example -version v1.2.3 $ # セルフアップデートを実行 $ selfupdate-example -selfupdate $ # アップデートされている $ selfupdate-example -version v1.2.4 $ # もちろん再度実行してもすでに最新なので何も起きない $ selfupdate-example -selfupdate $ selfupdate-example -version v1.2.4
コード例
セルフアップデートの各段階の処理を関数として公開していますが,一番簡単なのは selfupdate.UpdateSelf()
を使うことです.
import ( "log" "github.com/blang/semver" "github.com/rhysd/go-github-selfupdate/selfupdate" ) const version = "1.2.3" func doSelfUpdate() { v := semver.MustParse(version) latest, err := selfupdate.UpdateSelf(v, "owner/repo") if err != nil { log.Println("Binary update failed:", err) return } if latest.Version.Equals(v) { // latest version is the same as current version. It means current binary is up to date. log.Println("Current binary is the latest version", version) } else { log.Println("Successfully updated to version", latest.Version) log.Println("Release note:\n", latest.ReleaseNotes) } }
(後ほど説明しますが)このライブラリではバージョンの大小比較のためにセマンティックバージョニングを前提としています.
UpdateSelf()
はツールの現在のバージョンと,ツールがホストされているリポジトリの情報(owner/repo
)を受け取り,更新した結果のリリースを表す構造体(selfupdate.Release
)とエラーを返します.
エラーはダウンロードしてきたファイルが破損していた場合など,セルフアップデートが実行できなかった場合に返されます.
現在のツールのバージョンが最新だった場合はエラーではないので,latest
のVersion
フィールドに現在のバージョンと同じ値が返されます.
なので,アップデート後のバージョンと今のバージョンを比較する(latest.Version.Equals(v)
)ことで実行ファイルの差し替えが行われたかを知ることができます.latest
にはバージョンの他にリリースノートやリリースページの URL なども含まれるため,必要な場合は適宜利用できます.
この(ユーザへの出力を含めて)14行だけで実行ファイルをアップデートする仕組みを組み込むことができました.
次にもう少し複雑な例を出してみます. バージョンアップデート前にユーザに「このバージョンにアップデートするけど良い?」と確認を取りたいとします.
import ( "bufio" "github.com/blang/semver" "github.com/rhysd/go-github-selfupdate/selfupdate" "log" "os" ) const version = "1.2.3" func confirmAndSelfUpdate() { latest, found, err := selfupdate.DetectLatest("owner/repo") if err != nil { log.Println("Error occurred while detecting version:", err) return } v := semver.MustParse(version) if !found || latest.Version.Equals(v) { log.Println("Current version is the latest") return } fmt.Print("Do you want to update to", latest.Version, "? (y/n): ") input, err := bufio.NewReader(os.Stdin).ReadString('\n') if err != nil || (input != "y\n" && input != "n\n") { log.Println("Invalid input") return } if input == "n\n" { return } if err := selfupdate.UpdateTo(latest.AssetURL, os.Args[0]); err != nil { log.Println("Error occurred while updating binary:", err) return } log.Println("Successfully updated to version", latest.Version) }
少々長いですが,全体の流れとしては,UpdateSelf()
の中でやっている処理をバラして,
selfupdate.DetectLatest()
で GitHub 上での最新のリリースを検知してアップデートの必要があるかチェック- ユーザに y/n で確認するプロンプトを出す
selfupdate.UpdateTo()
で実行ファイルをダウンロード,解凍・展開,置き換えする
というものです.特に難しい部分は無いはずです.リリースが無い場合は異常系では無いので,selfupdate.DetectLatest()
はリリースがあったかどうかとエラーを別に返すようにしています.
リリース物の名前付けルール
どのバイナリがどの環境向けのものかを判別するために,下記のような名前付けでリリース物を配布しているという前提で実装しています.
{cmd}_{goos}_{goarch}{.ext}
{cmd}
はコマンドラインツールの名前です.この中に _
を含んでいても構いません.
{goos}
は OS の種類(runtime.GOOS
),{goarch}
はアーキタイプ(runtime.GOARCH
)です.{.ext}
はファイルの拡張子で,何もなし(圧縮なし),.zip
,.gz
,.tar.gz
のいずれかで,対応する圧縮・アーカイブが施されているものとします.
また,セパレータには _
の代わりに -
が使え,Windows 限定で .exe.zip
のように .exe
が付いても大丈夫なようになっています(実行ファイルをそのまま圧縮しても良いように).
なので,foo-bar
をコマンド名とすると下記のようなファイルはリリース物として認識されます.
foo-bar_linux_amd64
foo-bar_linux_amd64.zip
foo-bar_linux_amd64.tar.gz
foo-bar_linux_amd64.gz
foo-bar-linux-amd64.tar.gz
リリース名(Git のタグ名)の名前付けルール
go-github-selfupdateは Git のタグを見てそのリリースのバージョンを判定します(リリースタイトルではないので注意).
タグ名は正規表現で言うと \d+\.\d+\.\d+
を含んでいる必要があり,それより手前の文字列は削除されます.なので,v1.2.3
やver1.2.3
,release-1.2.3
などは全てバージョン 1.2.3
として認識されます.
この命名規則に合致しないもの(例えば nightly
など)や,プレリリースに指定されているものなどは単に無視されます.
リリース物の構造
まとめると下記のような構造でリリースすれば正しくgo-github-selfupdateで認識できます.
コマンド名を foo-bar
とすると,一例としてリリースは下記のようになります.
v1.2.4
foo-bar_linux_amd64.tar.gz
foo-bar
foo-bar_darwin_amd64.tar.gz
foo-bar
foo-bar_windows_amd64.exe.zip
foo-bar.exe
- ...(他のプラットフォーム向けのバイナリ)
v1.2.3
foo-bar_linux_amd64.tar.gz
foo-bar
foo-bar_darwin_amd64.tar.gz
foo-bar
foo-bar_windows_amd64.exe.zip
foo-bar.exe
- ...(他のプラットフォーム向けのバイナリ)
難しく考えなくても,gox でクロスビルドしてアーカイブし,ghrでリリースしたりしていれば大体この構造になっているのではないかと思います.
デバッグする
go-github-selfupdate内では(デフォルトで無効な)ログを仕込んでありますので,ツールのデバッグ時には下記のようにしてログを有効にすることで内部でどういう処理が行われているかを標準エラー出力に表示することができます.
selfupdate.EnableLog()
適用例
手前味噌ですが,いくつか自分のツールにはすでに組み込んでみています.
- dot-github:
.github
ディレクトリのジェネレータ - dotfiles: dotfiles のシンボリックリンクの管理ツール
- github-clone-all: GitHub からリポジトリをクローンしてくるツール
おまけ CLI ツール
go-github-selfupdateを使い,いくつか簡単なコマンドラインツールも作成しました.
- detect-latest-release: 引数に与えられたリポジトリの最新のリリースを検知するコマンド
- go-get-release:
go get
のような Go のコマンドをインストールするツール.go get
と違い GitHub の最新のリリース物をダウンロードしてきて$GOPATH/bin
に配置する
例えば下記のように ghr の安定版をインストールしたり,
$ go-get-release github.com/tcnksm/ghr Command was updated to the latest version 0.5.4: /Users/rhysd/.go/bin/ghr
下記のように dot-github の最新バージョン(やリリースノートなど)を知ったりすることができます.
$ detect-latest-release rhysd/dot-github 1.3.0
go-github-selfupdateを下地として使っているので,飽くまで前章で解説した名前付けルールに従っているものでしか使えません(それ以外ではリリースが検知できないと思います).
tj/go-updateとの違い
実はすでに同じ目的のライブラリとして tj/go-update がありますが,下記の点が違います
tj/go-update は
- Windows をサポートしていない
- バージョンのプレフィックスは
v
のみ許可 - プレリリースかどうかを見ない
- テストが少ない(正常系の1ケースのみ)
- GitHub だけでなく Apex というリリース置き場をサポートしている
まとめ
GitHub 上にある最新のリリースを検知して,自分自身のバージョンよりも高ければ自動で自分自身のバイナリを更新する(セルフアップデート)機能を提供するためのライブラリ,go-github-selfupdateを作成しました.これによって,配布者にとってはリリースの配布がより楽になり,ユーザにとってはリリースされた安定版に手軽にアップデートできるという利点があるのではないかと思います.
Vim script で ES6 Promise 実装した
Vim Advent Calendar 2017 の19日目の記事です.Vim script で ES6 の Promise を実装した話を書きます.
もし Vim script が分からなくても,最後の章「Promise の実装の詳細」は Vim script とは独立した内容になっているので,Promise の実装に興味があれば読んでみてください.
TL;DR
実装はこちら
Promise とは
ES6 (ES2015) で ECMAScript に標準に導入された非同期処理を扱うためのライブラリです. これを使うことにより,「未来のある時点でいずれ値を持つ」値を扱うことができ,ES5 まではコールバックで扱う必要があった非同期処理を逐次処理的な書き方で書きつつ,エラーも一貫した方法で扱えます.
例えば,ネットワークのどこかから取ってきたデータをファイルに保存する処理は,ネットワークからの fetch とファイルへの保存の2回の非同期処理を挟むので
fetch('https://example.com', function (err, data) { if (err) { console.log('Error', err); return; } writeFile('data.txt', data, function (err) { if (err) { console.log('Error', err); return; } console.log('Done!'); }); });
のようにネストしたコールバックとエラーハンドリングで処理がぶつ切りになってしまうのですが,Promise
を使うと
fetch('https://example.com').then(function (data) { return writeFile('data.txt', data); }).then(function () { console.log('Done!'); }).catch(function (err) { console.log('Error', err); });
のように,本来実行される順序でコードを書くことができ(fetch
→ writeFile
),エラー処理も .catch
メソッドによって分離できます.
なぜ Promise を Vim script で実装したのか
Vim 7.4 までは,Vim script は基本的にユーザからの入力をブロッキングして処理をするようになっていました.そのため,Vim プラグイン作者たちは vimproc という Vim script から popen や socket などを扱える C ライブラリを書いたり, CursorHold
イベントを繰り返し発火させてポーリングするなど,様々なワークアラウンドで対処してきました.
ですが,Vim 8 になり,job や channel,terminal といった,ユーザの処理をブロックしない非同期処理の機能が続々と入りだしました(一部は Neovim が先に実装したアイデアが Vim8 にポーティングされました).
これによって Vim プラグインはワークアラウンドを使わずともユーザの入力をブロックしない快適なプラグインをつくる下地を手に入れると同時に,非同期処理と戦わなければならなければならなくなりました. しかしコールバック方式では,少し処理が複雑になったりエラー処理を真面目にやると,すぐメンテナンス性が低下してしまうのは JavaScript での経験で分かっています. さらに,ちょうど同じ時期に Vim8(および後に Neovim にも)ラムダ式が入りました.
ということで,JavaScript で使われているアイデア(Promise)を Vim にポーティングしました.
使い方
vital.vim の標準モジュール入りを目指して実装しています.現在はまだプルリクの段階ですが,あとはテストとドキュメントを整備すればレビュー可能になる予定です. master にマージされました!標準で利用することができます
:Vitalize
で Async.Promise
をプラグインに入れるか,vital#vital#import('Async.Promise')
でインポートすることで試せます.
API はそれなりに ECMAScript と互換なので,ECMAScript の Promise を知っている人はほぼドキュメントを見なくても使うことができると思います.
MDN の Promise のドキュメントを見ていただいたほうが分かりやすいかもしれません. 詳細な仕様が知りたい!というマッチョな方はECMA-262の仕様を見ていただいても良いでしょう.
let s:Promise = vital#vital#new().import('Async.Promise') " Promise な値を生成する let p = s:Promise.new({resolve -> resolve(42)}) let p = s:Promise.new({_, reject -> reject('error!')}) " resolve する値が定数のときの省略形(上記と同じ) let p = s:Promise.resolve(42) let p = s:Promise.reject('error!') " .then() で非同期な処理に順番を付ける (20 と出力される) let p1 = s:Promise.new({resolve -> resolve(10)}) \.then({i -> i + 10}).then({i -> execute('echom i', '')}) " .catch() で例外を補足できる let p2 = s:Promise.new({-> execute('throw "ERROR!"')}) \.catch({err -> execute('echom string(err)', '')}) " Output: { 'exception' : 'ERROR!', 'throwpoint' : '...' } " .all() で待ち合わせ call s:Promise.all([p1, p2]).then({-> execute('echom "Both p1 and p2 done!"', '')}) " .race() でどれか1つ少なくとも完了 call s:Promise.race([p1, p2]).then({-> execute('echom "Either p1 or p2 done!"', ''}))
詳細は help ドキュメントを参照してください.
具体例
これだけではピンとこないかもしれないので,具体例を少し見てみましょう.ここではコードを少し簡略化して書いています.実際に使う場合はもう少し考慮しないといけないエッジケースがある場合がありますので,ご注意ください.
タイマー
一番分かりやすいタイマー機能で Promise を使ってみます. Vim script の timer は指定したミリ秒時間後にコールバックで指定した処理を遅らせられる機能です. ですので,下記のようにして wrap して Promise オブジェクトを返す関数をつくることで,一定秒数後に発火する処理を Promise を使って書けるようになります.
let s:Promise = vital#vital#new().import('Async.Promise') function! s:wait(ms) return s:Promise.new({resolve -> timer_start(a:ms)}) endfunction call s:wait(500).then({-> execute('echo "After 500ms"', '')})
Next tick
タイマーのコールバックは Vim が入力待ちの時(busy でない時)に発火するとドキュメントに明記されています. よって,他にもっと大切な処理がある場合はそちらを優先し,優先度の低い処理は Vim が入力待ちになるまで遅らせるのに使えます.
function! s:next_tick() return s:Promise.new({resolve -> timer_start(0, resolve)}) endfunction call s:next_tick() \.then({-> 'ここで優先度の低い処理をする'}) \.catch({err -> execute('echom ' . string(err), '')})
タイマーの待ち時間を 0 に指定しているので,Vim が入力待ちになった(=優先度の高い処理が完了した)直後に .then
で指定した処理が行われます.
ジョブ
Promise が最も威力を発揮するのはjob機能だと思います.job は非同期にコマンドを実行し,コールバックで channel を通してコマンドと標準入出力のやりとりをします.
ここでは job を wrap し,非同期にコマンドを実行する処理を Promise で書くとどうなるかを見てみます.
let s:V = vital#vital#new() let s:Promise = s:V.import('Async.Promise') function! s:read_to_buf(buf, chan) abort for part in ['err', 'out'] let out = '' while ch_status(a:chan, {'part' : part}) ==# 'buffered' let out .= ch_read(a:chan, {'part' : part}) . "\n" endwhile let a:buf[part] = out endfor endfunction function! s:sh(...) abort let cmd = join(a:000, ' ') let buf = {} return s:Promise.new({resolve, reject -> job_start(cmd, { \ 'close_cb' : {ch -> \ s:read_to_buf(buf, ch) \ }, \ 'exit_cb' : {ch, code -> \ code ? reject(buf.err) : resolve(buf.out) \ }, \ })}) endfunction
ここでは非同期にシェルコマンドを実行する s:sh()
関数を定義しています.
s:read_to_buf()
は与えられた channel からバッファされた標準入出力と標準エラー出力を読み出し,引数 buf
で与えられた辞書に保存するヘルパー関数です.
一番肝なのは return s:Promise.new(...)
の部分です.close_cb
はコマンドからの出力が終わり channel が閉じられるタイミングで発火するので,ここでコマンドからの出力を読んでおきます.さらにコマンドの実行が終わるタイミングで発火する exit_cb
でステータスコードを受け取り,0(成功)なら標準出力と共に Promise を resolve,非0(失敗)なら Promise を reject します.
早速,まずは試しに ls -l
コマンドに使ってみましょう.
call s:sh('ls', '-l') \.then({out -> execute('echom ' . string('Output: ' . out), '')}) \.catch({err -> execute('echom ' . string('Error: ' . err), '')})
これだけで,コマンドを非同期に実行して,その出力に対して何か処理をするというコードが,しかも逐次的に書けるようになりました.
コマンドが失敗した場合は .catch()
で指定した処理が発動します.
もう少し複雑な例を見てみます. 4つのリポジトリを一気に clone し,全て完了したタイミングで何か処理をしたいとします.ですが,ネットワークの状態やリポジトリのサイズなどにより,どのリポジトリの clone が最後に完了するかは分かりません.
call s:Promise.all([ \ s:sh('git', 'clone', 'https://github.com/thinca/vim-quickrun.git'), \ s:sh('git', 'clone', 'https://github.com/tyru/open-browser-github.git'), \ s:sh('git', 'clone', 'https://github.com/easymotion/vim-easymotion.git'), \ s:sh('git', 'clone', 'https://github.com/rhysd/clever-f.vim.git'), \]).then({-> exeute('echom "All repositories were successfully cloned!"', '')}) \.catch({err -> execute('echom "Failed to clone: " . ' string(err), '')})
s:sh
と Promise.all
を使えば,これだけで OK です.
.then
の中の処理は4つのリポジトリの clone が全て完了した時点で発火します.また,4つのリポジトリのうち,1つでも clone が失敗すれば,.catch
の中の処理が呼ばれてエラー処理を行うことができ,.then
の中の処理は呼ばれません.
次の例は,タイムアウト付きのコマンド実行です. 特定のコマンドを実行したい,しかし時間がかかりすぎるときはエラーにするか,別の処理をしたいというケースです
call s:Promise.race([ \ s:sh('git', 'clone', 'https://github.com/vim/vim.git').then({-> v:false}), \ s:wait(10000).then({-> v:true}), \]).then({timed_out -> execute(timed_out ? 'Timeout!' : 'Cloned!', '')})
上記で定義した,一定ミリ秒待ってから発火する Promise を返す関数 s:wait()
と,Promise.raceを使います.
Vim のリポジトリを clone する処理をする Promise オブジェクトと,10秒間待つ Promise オブジェクトのうち,速いほうで resolve されるので,10秒内に clone できなければ待たずに次の処理にいく,ただしタイムアウトしたかどうかは引数 timed_out
で知ることができるという処理になっています.
このように,繰り返さない非同期処理(未来のある時点以降は値を持つような処理)を組み合わせる際に,Promise は非常に強力な道具であることが分かりました.また,最後に .catch
でエラー処理をぶら下げておけば,それまでのうちどこでエラーが起きても最終的に .catch
の処理内で拾うことができるため,エラー処理が簡潔になります.
最後にさらに実践的な例を見てみます.
GitHub API を叩いて,Vim のリポジトリのうちもっとも :+1:
リアクションが多い issue の URL を取得してみましょう.
まずは GitHub Search API を呼ぶ関数をつくってみます.
let s:HTTP = s:V.import('Web.HTTP') function! s:github_issues(query) abort let q = s:HTTP.encodeURIComponent(a:query) let url = 'https://api.github.com/search/issues?q=' . q return s:sh('curl', url).then({data -> json_decode(data).items}) endfunction
vital.vim の Web.HTTP
モジュールを使って検索クエリをエンコードし,先ほど定義した s:sh()
関数と curl でリクエストを送り,結果を json_decode
組み込み関数でパースして返すだけです.たった6行ですね.
次にこれを使って API を使ってみます.
call s:github_issues('repo:vim/vim sort:reactions-+1') \.then({issues -> execute('echom ' . string(issues[0].url), '')}) \.catch({err -> execute('echom ' . string('ERROR: ' . err), '')})
合計たった9行で実装できました.エラーも .catch
で処理できています.ちなみに最も👍の多い issue は #1735 でした.
今回は試していませんが,Neovim のリモートプラグインへの RPC 経由の request も同様にうまく扱えると思います.(request を送り,結果が返ってくる Promise オブジェクトを定義)
Vim script 版の JavaScript 版との違い
例外が投げられた時の処理
JavaScript とは違い,Vim script では文字列を例外として throw します.:catch
節内では throw された文字列を v:exception
で取りますが,例外が発生した位置はそれとは別に v:throwpoint
で取得します.
このため,例外が投げられた時に単純に v:exception
で reject すると例外の発生位置が取れなくなってしまいます.そのため,Async.Promise では throw された文字列で reject する代わりに
{'exception' : v:exception, 'throwpoint' : v:throwpoint}
という辞書で reject するようになっています.
" Output: { 'exception' : 'ERROR!', 'throwpoint' : '...' } call s:Promise.new({-> execute('throw "ERROR!"')}) \.catch({err -> execute('echom string(err)', '')})
これによって .catch()
の中では例外の中身と例外が発生した場所の両方を知ることができます. s:Promise.new()
の中で reject()
を呼んだ場合や .then()
または .catch()
の戻り値に reject された Promise の値を指定した時など,
例外が投げられる以外の原因で Promise が reject されたときは JavaScript 版と同じです.
" Output: 'ERROR!' call s:Promise.new({_, reject-> reject('ERROR!')}) \.catch({err -> execute('echom string(err)', '')})
.then()や.catch()の発火のタイミング
Vim script 版の .then
/.catch
と JavaScript 版の .then
/.catch
では引数に指定された関数の発火タイミングが違います.
Vim script 版では下記の(1)〜(3)の順序で処理が行われる(引数で渡された関数が同期的に実行される)のに対し,
(1) call s:Promise.new({resolve, reject -> (2)}).then({-> (3)}) (4)
JavaScript 版では下記の(1)〜(3)の順序で処理が行われます(引数で渡された関数が非同期的に実行される).
(1) new Promise((resolve, reject) => (2)).then({-> (4)}) (3)
Vim script でも .then
の中でタイマーを噛ませば同じ処理は行えるのですが,頻繁に呼ばれるので,ただでさえ遅い Vim script を無駄に遅くしたくないという意図でこの挙動になっています.(最終的には要ベンチマーク)
ご指摘をいただいて修正しました.@azu_reさん,ありがとうございました.
この違いはプルリクのブランチの最新で撤廃されました.現在は .then
や .catch
が呼ばれた段階でレシーバの Promise オブジェクトがすでに settled になっていても,引数に渡された関数は timer_start
をはさんで非同期に実行されることが保証されるようになりました.これによって,実行は .then
や .catch
の単位で行われることが保証され,ユーザの入力を優先するようになります.ベンチマークを取ったところ timer_start
をはさんでも1回の実行は(手元の環境で)1ms 未満と問題無さそうだったため処理を変更しました.
Promise の実装の詳細
実装はこちら
ここからは Promise がどういったもので,どのように実装されているのかを説明します.Vim script に限らない話になっています.このライブラリは es6-promise という polyfill ライブラリ(ES5 まででも ES6 Promise を使えるようにするための shim)の実装を参考にしています.
Promise の内部状態の遷移
まずは,Promise が内部でどうやって非同期処理の状態を管理しているかについて説明します.
Promise には内部的に 'pending', 'fulfilled', 'rejected' という3つの状態のうちいずれかを持っています.
- pending: 処理が未完了の状態
- fulfilled: 処理がエラー無く完了した状態
- rejected: 処理中にエラーが発生した状態
pending から fulfilled または rejected へは一方向の遷移になります.つまり,Promise オブジェクトが生成された直後の状態は pending であり,その後 fulfilled か rejected のどちらかに遷移するか,または永遠に pending のままになります.この pending から fulfilled/rejected への遷移処理は publish と呼ばれ,fulfilled か rejected になった状態を settled と呼んでいます.(下図はMDNより引用)
例えば,次の処理を考えてみましょう.
const p1 = new Promise(resolve => resolve('OK') /* (2) */); const p2 = p1.then(() => { throw 'OOPS'; /* (3) */ }); const p3 = p2.catch(reason => console.log('ERROR:', reason) /* (4) */); const p4 = p3.then(() => 'END' /* (5) */); /* (1) */
処理は上記の (1)〜(5) の順番に実行されます.
表にすると,各 Promise の値 p1
, p2
, p3
, p4
の状態は下記のように遷移していきます.
実行フェーズ | p1 |
p2 |
p3 |
p4 |
---|---|---|---|---|
(1) | pending | pending | pending | pending |
(2) | fulfilled | pending | pending | pending |
(3) | fulfilled | rejected | pending | pending |
(4) | fulfilled | rejected | fulfilled | pending |
(5) | fulfilled | rejected | fulfilled | fulfilled |
p1
から p2
に順番に Promise の遷移が伝搬している様子がわかると思います.
Promise 内部のデータ構造
内部では .then
などで Promise の値がネストするたびに,Promise の値は自分の次に処理されるべき Promise の値を子として持ちます.なので,実は Promise は内部的には木構造になっています.
上記の例のように Promise は直列に処理を行うことが多いので,直感的には単方向リストでは?と思うかもしれません.ですが,実は次のような場合がありえます.
const p1 = new Promise(resolve => resolve(42)); const p2 = p1.then(() => 10); const p3 = p1.then(() => 20);
この場合,Promise のツリーはイメージとしては
{ result: 42 /*p1*/, children: [ { result: 10 /*p2*/, children: [] }, { result: 20 /*p3*/, children: [] }, ] }
となります.実際には各 Promise の値は上記の3状態のいずれかを表すメンバーと,子要素の resolve された時または reject された時に呼ばれるコールバック関数を配列で保持しています.
Promise { state: 'pending', result: undefined, children: [ Promise{...}, Promise{...} ], on_fulfilled: [ resolved_fun1, resolved_fun2 ], on_rejected: [ rejected_fun1, rejected_fun2 ], }
よって,new Promise
で値をつくり,それに対して .then
や .catch
を呼び…という処理は実は内部で木構造を構築する処理をしています..then
を読んだ時,すでにレシーバ(=親)の Promise オブジェクトが fulfilled か rejected になっていれば,.then
または.catch
の中身は即座に実行されますが,そうでなければ子は親からのコールバックを待って pending 状態のまま親の子として .then
や .catch
のコールバックと共に登録されます.この処理を subscribe と呼んでいます.
次に,生成された Promise オブジェクトがどう resolve/reject されていくかを見てみます.
new Promise(resolve => resolve(42))
のコールバック内で,resolve(42)
が呼ばれる時に何が起きているのでしょうか.
resolve()
が呼ばれるresolve()
で引数に与えられた値が Promise の結果として保存される(これより後で.then
を追加されても大丈夫なように)- Promise の状態を fulfilled に変更します
- 親が fulfilled で終了したので,子要素の Promise オブジェクトそれぞれについて,
on_fulfilled
のほうのコールバックを呼びます - コールバックはいずれ
resolve
かreject
のどちらかを呼ぶので,それによって子要素のそれぞれで 1. からまたスタートします
このようにして,木の根から葉へと,親の状態に応じたコールバック(on_fulfilled
or on_rejected
)を実行しながら葉へと向かって非同期処理が走っていくようになっています.
なんとなくイメージは掴めましたでしょうか?もしさらに詳しく知りたければ,es6-promise を clone して各所に console.log
を挟み,実際に処理を走らせてみると様子が分かると思います.
まとめ
Vim script で ES6 Promise のライブラリを実装しました.現在はまだプルリク中ですが,いずれ(年内目標で)vital.vimで使えるようにしたいと思っています. メンテナンス性を損なうこと無く,よりユーザが快適に使えるプラグインの作成の役に立てば良いなと思っています.