awscliのコマンドの補完の遅延にイラッとしたので速くすることを考えてみた
はじめに
awsのCLIとしてawscliがある。version 2を使ってくれと書かれていたりはするものの、この記事はversion 1。まぁそこまで大差はないと思う。
補完を有効にしなかった場合には、どのコマンドを使えば良いかの確認に aws help
を使い、どのサブコマンドを使えば良いかの確認に aws <command> help
を使い必須の引数をhelpで確認して、ようやく実行ができる。この種の作業にしばしばイライラさせられる1。
補完の設定
awscliを利用するときに以下のような設定を追加すると補完が有効になる。これはbashの例2。$(which aws_completer)
と書くこともあるかもしれない。
complete -C '/usr/local/aws/bin/aws_completer' aws
これで補完が効いて満足となるかと思ったがそうでもなかった。
補完にかかる時間
TABを入力すれば補完される。この補完にはaws_completerが使われるが、補完が機能するまでにちょっとした遅延が存在する。その遅延の時間を測ってみると、手元の環境では0.6sから0.7s程度掛かっていた。
ちなみに今書いている環境が貧弱なのでこれくらい時間がかかるが、もう少しまともな環境なら0.2sから0.3sくらいになるのではないか。別の環境で測ったときにはその程度の時間だったような気がする。補完などの機能においては、これくらいの時間でさえもイライラする遅延として感じるようだ。
ほとんどの時間は awscli.completer
モジュールのimport時間のようなので、もはや補完などの特定の機能については、深いところに置いたモジュールを読み込んだら負け、もっと言えばインタプリタを立ち上げたら負けなのかもしれない。
$ time python -c 'import awscli.completer' real 0m0.421s user 0m0.363s sys 0m0.055s $ time python -c 'import awscli.completer' real 0m0.430s user 0m0.369s sys 0m0.055s
ちなみにimport後の処理で0.2sから0.3程度かかるようだ。
キャッシュして読み込む
この遅延をどうにかしたい。ということでテキトーな補完を書いてみた。どうやら、awscli.completer
を使って補完の候補を生成しているようだ。そんなわけで候補を ~/.config/compgen/<profile>/<command>/<command name>
あたりの場所に事前に生成しておき、補完時にはこれをcatで読み込むだけにしてみることにする。
以下のようなコードを書く。
import sys import pathlib import queue import awscli.completer dirpath = pathlib.Path("~/.config/compgen/default/aws").expanduser() dirpath.mkdir(parents=True, exist_ok=True) q = queue.Queue() q.put(["aws"]) c = awscli.completer.Completer() while not q.empty(): path = q.get() if len(path) > 2: continue fpath = dirpath / ".".join(path) print(f"write {fpath}", file=sys.stderr) with open(fpath, "w") as wf: for line in c.complete(" ".join(path), point=None): print(line, file=wf) q.put([*path, line.strip()])
以下のようなファイルが生成される
$ ls ~/.config/compgen/default/aws/* ~/.config/compgen/default/aws/aws ~/.config/compgen/default/aws/aws.accessanalyzer ~/.config/compgen/default/aws/aws.acm ~/.config/compgen/default/aws/aws.acm-pca ~/.config/compgen/default/aws/aws.alexaforbusiness ~/.config/compgen/default/aws/aws.amplify ... ~/.config/compgen/default/aws/aws.workspaces ~/.config/compgen/default/aws/aws.xray $ ls ~/.config/compgen/default/aws/* | wc 236 236 12774
そして以下のような補完関数を書いてあげる3。
# complete -C "$(which aws_completer)" aws function _aws_completion(){ local first profile datapath profile="default" cmdname="${COMP_WORDS[0]}" datapath="~/.config/compgen/${profile}/${cmdname}" local filepath case ${COMP_CWORD} in 1) filepath="${datapath}/${cmdname}" if [ -f ${filepath} ]; then COMPREPLY=( $(compgen -W "`cat ${filepath}`" -- ${COMP_WORDS[COMP_CWORD]}) ) else COMPREPLY=( $(compgen -f -- ${COMP_WORDS[COMP_CWORD]})) fi ;; 2) filepath="${datapath}/${cmdname}.${COMP_WORDS[1]}" if [ -f ${filepath} ]; then COMPREPLY=( $(compgen -W "`cat ${filepath}`" -- ${COMP_WORDS[COMP_CWORD]}) ) else COMPREPLY=( $(compgen -f -- ${COMP_WORDS[COMP_CWORD]})) fi ;; *) COMPREPLY=( $(compgen -f -- ${COMP_WORDS[COMP_CWORD]})) ;; esac } complete -o nosort -F _aws_completion aws # complete -F _aws_completion aws
少し細工をして時間を測るようにしたところ、0.02sから0.03s程度になった。快適。 flagなどにも対応させたかったりするなーとは思ったりもした。
付録: 補完にかかる時間の計測方法
ちなみにどうやって時間を測ったかと言うと以下のようなコードに書き換えた。version 2はバイナリのようなのでこのようなことはできないかもしれないし。もう少し早いかもしれない。ただ、pythonで書かれていることは変わらないようだ。https://github.com/aws/aws-cli/tree/v2 がブランチなようなので。
--- ~/venvs/my/bin/aws_completer 2020-10-24 12:53:23.000000000 +0900 +++ /tmp/after 2020-10-24 12:54:07.000000000 +0900 @@ -13,6 +13,9 @@ # language governing permissions and limitations under the License. import os +import time + +st = time.time() if os.environ.get('LC_CTYPE', '') == 'UTF-8': os.environ['LC_CTYPE'] = 'en_US.UTF-8' import awscli.completer @@ -27,3 +30,6 @@ # If the user hits Ctrl+C, we don't want to print # a traceback to the user. pass + with open('/tmp/aws_completer', 'a') as wf: + import sys + print(time.time() - st, sys.argv, file=wf)
このときの結果が以下の様なものだった。
0.8783209323883057 ['~/venvs/my/bin/aws_completer', 'aws', '', 's3'] 0.570117712020874 ['~/venvs/my/bin/aws_completer', 'aws', '', 's3'] 0.6095981597900391 ['~/venvs/my/bin/aws_completer', 'aws', '', 'ecs'] 0.637779951095581 ['~/venvs/my/bin/aws_completer', 'aws', '', 'ecs'] 0.6307311058044434 ['~/venvs/my/bin/aws_completer', 'aws', '', 'ecs'] 0.5969369411468506 ['~/venvs/my/bin/aws_completer', 'aws', '', 'ecs'] 0.6082282066345215 ['~/venvs/my/bin/aws_completer', 'aws', '', 'ecs'] 0.5688650608062744 ['~/venvs/my/bin/aws_completer', 'aws', 'servicedi', 'aws'] 0.613184928894043 ['~/venvs/my/bin/aws_completer', 'aws', '', 'servicediscovery'] 0.6116440296173096 ['~/venvs/my/bin/aws_completer', 'aws', '', 'servicediscovery'] 0.6048803329467773 ['~/venvs/my/bin/aws_completer', 'aws', '', 'servicediscovery'] 0.6156458854675293 ['~/venvs/my/bin/aws_completer', 'aws', '', 'servicediscovery'] 0.6055679321289062 ['~/venvs/my/bin/aws_completer', 'aws', '', 'servicediscovery'] 0.6923770904541016 ['~/venvs/my/bin/aws_completer', 'aws', '', 'servicediscovery']
キャッシュした方の時間の計測方法
以下のような形でナノ秒の表現でのdiffを出力していた。
local st st="$(gdate +%N)" echo "$(echo $(gdate +%N) - $st | bc -l)":${filepath} >> /tmp/aws_compgen
ここで 29666000 * (10 ** -9) = 0.029666
。
29666000:~.config/compgen/default/aws/aws 18947000:~.config/compgen/default/aws/aws 16910000:~.config/compgen/default/aws/aws 16431000:~.config/compgen/default/aws/aws 18454000:~.config/compgen/default/aws/aws.ecs 15494000:~.config/compgen/default/aws/aws.s3 15465000:~.config/compgen/default/aws/aws.s3 15542000:~.config/compgen/default/aws/aws.s3 21761000:~.config/compgen/default/aws/aws 16756000:~.config/compgen/default/aws/aws 16119000:~.config/compgen/default/aws/aws 16808000:~.config/compgen/default/aws/aws 16162000:~.config/compgen/default/aws/aws.servicediscovery 15574000:~.config/compgen/default/aws/aws.servicediscovery
functional optionsの整理。interfaceにすることの意味について。
goのfunctional optionsに対する自分の理解と、いわゆるoption部分をinterfaceにしておくことの意味について考えてみる。
functional optionsについて
まずはfunctional optionsについてのおさらい。functional optionsはoptionalな引数をいい感じに受け取る関数を作るための方法。
詳しい話は以下の記事が参考になる。
一応整理しておくと、ある処理に対してrequiredな引数とoptionalな引数を考えたとき、optionalな引数の取扱いをどうするかという話。例えば、host名をrequiredな引数として取り、portと最大接続数(maxConns)をoptionalな引数として取るNewServerというfactory関数を考えたときに、以下の様な定義して置くと柔軟なものになる。
type Config struct { Port int MaxConns int } func NewServer(host string, options ...func(*Config)) *Server { c := &Config{ Port: 8080, MaxConns: 1, } for _, opt := range options { opt(c) } return &Server{ Host: host, Port: c.Port, MaxConns: c.MaxConns, } }
以下のように自由に設定を渡せる様になる。
NewServer(host) NewServer(host, WithPort(44444)) NewServer(host, WithMaxConns(10)) NewServer(host, WithPort(44444), WithMaxConns(10))
ここまではおさらい。
option部分の定義の仕方
option部分の定義の仕方にもいろいろある。一般的には簡略化して以下のどちらかになる。
// 直接使う func (*Config) // new typeしておく type ServerOpt func (*Config)
前者と後者の違いはOptionを受け取る関数(e.g. WithTimeoutなどのこと)の数ではなく形状を変更しようとしたときに、手間が減るかどうかと言うような感じ。まぁぶっちゃけた話どちらでもそう変わらない。
これをinterfaceにしてはどうか?
本題はここからで、もう少しfunctional optionsに対して真面目に付き合ったときにinterfaceにすることに価値があるだろうか?というような事を考えてみる。例えば以下のような形で定義してみる。
type ServerOpt interface { Apply(*Config) }
これに意味があるだろうか?
Config自体がinterfaceを満たすようにしてみる
例えば、受け取っていたConfigもまたこれを満たすようにしてみる。これに意味があるか?
少し考えてみると、Configをそのまま受け取るような記述も許すようにできるようになりそうだ。そしてそれがWithXXXの形式の関数と併用できる。
defaultConfig := &Config{Port: 4444, MaxConn} NewServer(host, defaultConfig) NewServer(host, defaultConfig, WithPort(44444))
ただし定義は少し煩雑になる。
func (c *Config) Apply(target *Config) { target.Port = c.Port target.MaxConns = c.MaxConns } type ServerOpt interface { Apply(*Config) } type ServerOptFunc func(*Config) func (f ServerOptFunc) Apply(c *Config) { f(c) } func WithPort(port int) ServerOpt { return ServerOptFunc(func(c *Config) { c.Port = port }) }
とはいえ、形式にこだわらなければ、option部分の定義をinterfaceにしなくても、Configを受け取るWithXXXを定義してあげれば良いという話はあるかもしれない。
func WithConfig(override *Config) ServerOpt { return func(c *Config) { // zero値チェックをしたほうが自然かもしれない c.Port = override.Port c.MaxConns = override.MaxConns } }
埋め込みを同時に使って共通のoptionをもたせる
もう少し考えてみよう。例えばXXXListというある型の値の一覧を返すAPIがあり、それの対象となる型が複数存在するときのことを考えてみる。
- Foo に対して FooList()
- Bar に対して BarList()
- Boo に対して BooList()
そして、このAPIはLimitというパラメーターで返す一覧の長さを制限できるとする。このようなときについて考えてみる。
実装としては、Foo,Bar,BooのそれぞれがLimitというパラメーターを保持している。毎回似たような設定を記述するのはすこぶる悪い。埋め込みで定義してしまう。
type ListParams struct { Limit int Offset int // 何か他のパラメーター } type FooListParams struct { ListParams // 何か他のパラメーター } type BarListParams struct { ListParams } type BooListParams struct { ListParams }
これに対するfunctional optionsについて考えてみる。WithLimitをそれぞれに対して定義していくのはだるくはないか?加えて名前空間を汚染してしまうのでWithFooListLimit, WithBarListLimit, WithBooListLimitのような関数を定義することになる?これは結構バカバカしい。Limit以外にパラメーターが増えた場合には特に。一般に、対象の種類m、パラメーターの種類nとしたときに、m * n分のoption関数を定義する必要がある。
対象となる型の数だけoption関数を定義する
埋め込んだListParamsそのものを値として取るoption関数を考えてみる。不格好ではあるが、対象の種類mの分だけの定義で良くなった。しかし、不格好。
func WithFooListParams(apply func(*ListParams)) func(p *FooParams) { func (p *FooParams){ apply(p.ListParams) } }
使うときはこうなる。
FooList(WithFooListParams(WithLimit(100)) BarList(WithBarListParams(WithLimit(100)))
不格好。
パラメーターの種類だけoption関数を定義する
理想の話をすれば、WithXXX, WithYYY, ...というパラメーターの種類nの分の定義だけで済ませられるようなことを考えたい。つまり今回で言えばWithLimit1つで済ませたい。
それぞれinterfaceが要求するmethodの名前を変えてあげると、これを達成する事はできる。
type LimitOptFunc func(*ListParams) func WithLimit(limit int) LimitOptFunc { return func(p *ListParams) { p.Limit = limit } } type FooListOpt interface { ApplyFoo(*FooListParams) } func (f LimitOptFunc) ApplyFoo(p *FooListParams) { f(&p.ListParams) } type BarListOpt interface { ApplyBar(*BarListParams) } func (f LimitOptFunc) ApplyBar(p *BarListParams) { f(&p.ListParams) }
定義自体は煩雑になるが、同じWithLimitを別の型に対しても使う事ができる。名前空間の汚染もない。
func main() { fmt.Printf("%[1]T %+[1]v\n", FooList(WithLimit(100))) fmt.Printf("%[1]T %+[1]v\n", BarList(WithLimit(100))) }
まぁ、常用するとしたらマクロみたいな何かが欲しくなったりはする。
最後に
functional optionsのoptionの定義部分をinterfaceにしたときに何か良い効果を得る事ができるか考えたりしてみた。
色々書いたが用法用量守って正しく使いましょう。煩雑になるので。