pipenvの起動を早くしたという話のimport timeの確認を3.7以前の頃はどうしてたかという話

pipenvの起動を早くしたという話です。良い話ですね。

https://dev.to/methane/how-to-speed-up-python-application-startup-time-nkf

ところでリンク先の記事はpython3.7で導入されるPYTHONPROFILEIMPORTTIMEの紹介なのですが。古の民(3.7以前の人々)はどのように解決していたのでしょうか?似たようなことを昔やっていたことがあり。その時はimport hookで無理矢理頑張る方法をとったりしていました。

modulegraph

完全には同じ事はできないのですが。import hookを可能な限り早めのタイミングで時刻を記録可能なものに置き換えて実行するみたいな感じにしていました。

以前書いたmoduleknifeというrepositoryのmodulegraphというコマンドで似たようなことをしています。

https://github.com/podhmo/moduleknife

もちろんpython(pure python)のレイヤーでのimport hookの置き換えなので、素のまっさらなpythonが立ち上がるまでの部分は計測不能なのですが。通常のライブラリやアプリを作る上で素のまっさらなpythonが立ち上がる時間自体はどうやっても早くできない固定コストという風に考えれば納得できなくもないということで無視しちゃってます。

pipenvのimport timeの高速化の部分について

同じ事ができるか実際に試してみると。

00import.py

import pipenv

modulegraphというコマンド経由でpythonスクリプトを実行すると、ここでgraphviz用のdotファイルが生成されます。

$ modulegraph --metadata=time --outfile=./00.dot 00import.py
$ graphviz -Tsvg 00.svg > 00.svg

ちなみに.dotファイルの中のコメントを見ると元の記事の通りにIpythonとpkg_resource関連が怪しいということがわかります(実行にかかった時間で降順でsort)。

// load ~/my/lib/python3.6/site-packages/pipenv/__init__.py ... 0.5924477577209473s
// load ~/my/lib/python3.6/site-packages/pipenv/cli.py ... 0.5918803215026855s
// load ~/my/lib/python3.6/site-packages/pipenv/patched/dotenv/__init__.py ... 0.2650315761566162s
// load ~/my/lib/python3.6/site-packages/pipenv/patched/dotenv/ipython.py ... 0.2640550136566162s
// load ~/my/lib/python3.6/site-packages/IPython/__init__.py ... 0.2633070945739746s
// load ~/my/lib/python3.6/site-packages/IPython/terminal/embed.py ... 0.23813724517822266s
// load ~/my/lib/python3.6/site-packages/IPython/terminal/interactiveshell.py ... 0.1785898208618164s
// load ~/my/lib/python3.6/site-packages/pipenv/patched/pip/__init__.py ... 0.17690563201904297s
// load ~/my/lib/python3.6/site-packages/pipenv/patched/pip/utils/__init__.py ... 0.11338138580322266s
// load ~/my/lib/python3.6/site-packages/pipenv/patched/pip/_vendor/pkg_resources/__init__.py ... 0.10068082809448242s
// load ~/my/lib/python3.6/site-packages/pkg_resources/__init__.py ... 0.0988779067993164s
// load ~/my/lib/python3.6/site-packages/urllib3/__init__.py ... 0.045993804931640625s

補足するとこの時間は例えば foo パッケージから bar パッケージを読むという構造になっていた場合に以下の様な形で計測されます。

tf0 = import fooの前の時刻
  import foo
  tb0 = import barの前の時刻
    import bar
  tb1 = import barの後の時刻
tf1 = import fooの後の時刻

このとき
import fooの時間は tf1 - tf0
import barの時間は tb1 - tb0

これだけでも記事と同様にpkg_resourceの読み込みとdotenv経由のipythonの読み込みが遅いということはわかります。 ついでにgraphvizでグラフを生成してみましょう。

$ graphviz -Tsvg 00.svg > 00.svg

すごく大きくて何だかわからないグラフが表示されます(蛇足ですが画像ではなくsvgの場合検索が効くのが便利です)。

f:id:podhmo:20180124033811p:plain
生成されたgraphvizの巨大なグラフ

一応探してみるとpkg_resourceやipythonの部分が見つかります。

f:id:podhmo:20180124033928p:plainf:id:podhmo:20180124033923p:plain
探してみるとIpythonやpkg_resourceの時間も表示されている

goでmockを自動生成する以外に大きなinterfaceを扱う方法を考えたりしてた

goでmockを自動生成する以外に大きなinterfaceを扱う方法を考えたりしてた。基本的には綺麗に小さく分割するが正解であるし。そうするべきなのだけれど。それ以外の方法を考えたのでメモしておく。あとから見直してもちょっとトリッキーだと思うので常用するかは不明。

問題

テストに必要なinterfaceが大きくて実装するのがめんどう。

type I interface {
    F()
    G()
    H()
}

特定のinterfaceに対してちょうど良いサイズに分割するということを怠った場合にinterfaceが肥大化してしまう事がある。テストを書こうにも実装し直すコストが大きくなりすぎるとそれはそれで取り回しが悪い。

解決方法は、1. 大きさを気にせず自動生成する(e.g. use vektra/mockery)、2. interfaceを小さくするの2つくらいは思いつく。ただ、小さくするのは意外と難しい。

難しいのだけれど。テスト用に擬似的にということに限定すると、実装部分を小さくする方法を見つけた(かもしれない)。見つけたのでそれに関するメモと説明を書いてみる。

埋め込みを使って擬似的に小さなinterfaceで済ませる

見つけた方法は以下の様な感じ。

小さなinterfaceをイメージした実装を用意する。それに埋め込みを使ってテスト時にだけ大きなinterfaceに持ち上げて使う。

例えば外部リソースにアクセスする機能をテストしたい場合。Clientというそこそこ大きなinterfaceがあるとする。

type Client interface {
    Create(ctx context.Context, body string) error
    Update(ctx context.Context, id string, body string) error
    Delete(ctx context.Context, id string) error
    GetChildren(ctx context.Context, id string) error
    PutChild(ctx context.Context, id string, child string) error
}

type actualClient struct {}

func (c *actualClient) Create(ctx context.Context, body string) error {
    // ...
    return nil
}
func (c *actualClient) Update(ctx context.Context, id string, body string) error {
    // ...
    return nil
}
// ...

ここで、Createだけに依存するテストを書こうとした時にその他すべてのメソッドも実装しなければいけないのはすごくめんどくさい。

問題は例えば以下の様なCreateだけのinterfaceを作ったとして、実装の方ではやっぱりClient全体を保持するコードが必要になってしまう場合があること。そしてそのような場合のテストを手軽に書きたい。

type Create interface {
    Create(ctx context.Context, body string) error
}

Create以外実装していないdummyObjectを作ってそれをテストに使うということをしばしばLL系の言語のテストでやったりしていた(具体的にはpython)。ところでduck typingの場合にはある範囲で想定するメソッドだけ実装されていれば良いのだけれど(あるいは綺麗に分割された小さなinterfaceを受け取るコードなら)。

大きなinterfaceをふんだんに使ったコードに対してどうにか抵抗をしたい。コンパイラを騙し小さなinterfaceを満たした実装を大きなinterfaceを満たした実装に持ち上げる方法があればどうにかなりそう。というところまで考えたのだけれど。goには型を受け取って型を返すみたいな機能は基本的には存在しない。

コード生成ということに手を染めるならそれこそmockで自動生成で良いわけだし。と考えたところで埋め込みを使えばどうにかなるかもしれないと思ったりした。

トリッキーではあるのだけれど。以下の様に大きなinterfaceを満たした元の実装を埋め込むことで、型チェックの世界においては小さなinterfaceを大きなinterfaceに持ち上げる事ができそう(トリッキーではあるけれど)。

type dummyCreate struct {
    *actualClient
    box []string
}

func (c *dummyCreate) Create(ctx context.Context, body string) error {
    c.box = append(c.box, body)
    return nil
}

actualClientは元々の実装なのでClientを満たす。そしてこの内部の埋め込んだactualClientをnilで初期化することにしてしまえば他の未実装のメソッドが呼ばれた時にはpanicする。テストなのでpanicで死んでも許容範囲内という酷い割り切り。

c := dummyCreate{box: []string{} }
c.Create(ctx, "foo") // OK

// c.Update()なども実装されているが内部ではnilなので呼び出しは失敗する

さらに他のpackageで使いたいけれど公開したくない場合

interfaceに対する実装をprivateにして、同じpackageでテストを書く分には上の方法で良いのだけれど。他のpackageのテストで使いたい場合もある。そのような場合にもなるべく元の実装はprivateなままにしたい。一方で埋め込みを使いたいのでimportできるようにpublicにせざる負えないみたいな微妙な状況。

コードは増えてしまうのだけれど。以下のような埋め込まれた値を生成する関数を公開するとできなくはない。

type overridecreate struct {
    fn func (ctx context.Context, body string) error
    *actualClient
}

func (c *overridecreate) Create(ctx context.Context, body string) error {
    return c.fn(ctx, body)
}

// Create : for test
func Create(fn func (ctx context.Context, body string) error) Client {
    return &overridecreate{fn: fn}
}

ちょっとだけsort.Sliceに仕組みは似ているかもしれない。利用したいメソッド部分を関数として取る構造を定義する。受け取った関数がテスト時には呼ばれる。ここでも実際の実装自体(actualClient)はnilなので他のメソッドが呼ばれた場合にはpanicする。testのときだけなのでこれはこれで実用上は問題ない(気持ち悪いけれど)。

box := []string{}
dummyClient := foo.Create(func (ctx context.Context, body string) error {
    box = append(box, body)
})

dummyClient.Create("create something")

おわりに

小さなinterfaceが擬似的にほしいということがあり。そのハンドリングを完全に行いたい場合のちょっとした思いつきのメモをした。 ふつうに使う分にはmockを自動生成(大きなinterfaceを気にしない)などのほうが無難..かもしれない。