awscliのコマンドの補完の遅延にイラッとしたので速くすることを考えてみた

はじめに

awsCLIとして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

  1. 他にもaws用のインタラクティブシェルとして立ち上がるaws-shellというパッケージもあるが、これはuiが自分には合わず途中でやめてしまった。

  2. 設定方法自体も公式のドキュメントに書かれている。 https://docs.aws.amazon.com/ja_jp/cli/latest/userguide/cli-configure-completion.html#cli-command-completion-configure

  3. この補完のコード自体は洗練されたものでもなんでもない。

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にしたときに何か良い効果を得る事ができるか考えたりしてみた。

色々書いたが用法用量守って正しく使いましょう。煩雑になるので。

gist