リバースプロキシをライブラリに組み込む必要があったのでそのメモ。
proxy.py
github.com (accessed on 2021/12/10)
リバースプロキシなんて誰かが作ってるだろ、と探したらproxy.pyに行きついた。
高速・軽量・拡張性・安全性その他もろもろが売りらしい。
Python
のプロキシライブラリは意外に少なくてwebsocketにも対応してるのがこれくらいだった。(選択理由)
使ってみると確かに拡張性が良く、Pluginを導入するだけで広告ブロックからリバースプロキシまで様々な機能を追加することができる。 継承して必要な処理だけオーバーライドすればカスタムプラグインも容易に作成できる。
結論からいうと、このライブラリは使用しなかった。
理由はまだ実装途中らしく、パッチを当てなければリバースプロキシとして使えないからだ。 軽くソースコードを書き換えてHTTPリクエストはハンドルできるようになったが、Websocketの方がうまく実装できずこのライブラリを使用することを諦めた。
期待はできそうなのでGitHubでスターは付けておいた。
Go
他に良さそうなライブラリがなかったので他の言語で実装することを考えた。
候補はいろいろあるが最初に挙がるのはやはりGois Godである。
JavaScript
はPython
と同程度に遅いから使う必要が感じられないし、C/C++
での実装は煩雑になりそうなのでNG。
Julia
はコミュニティが小さいので文献を探すのが大変で、R
もまあ似たような理由。
というわけでコミュニティがデカくて、高速なコンパイル言語Go
から検索を開始したのである。
検索してびっくり仰天!なんとプロキシを標準ライブラリがサポートしているのである。
しかも実装が非常に単純!
package main import ( "log" "net/url" "net/http" "net/http/httputil" ) func main() { loc := "/go/is/god" proxyPass := "http://localhost:10000" url, err := url.Parse(proxyPass) if err != nil { log.Fatal(err) } http.Handle(loc, httputil.NewSingleHostReverseProxy(url)) http.ListenAndServe() }
これだけでNginx
の
location /go/is/god { proxy_pass http://localhost:10000 }
に相当する処理を実装出来て、必要なヘッダーもインジェクトしてくれる。もちろんWebsocket対応。
あとはこれをProgramableに書き換えて、こんな感じ。
package proxy import ( "fmt" "log" "net/http" "net/http/httputil" "net/url" ) type location struct { Loc string ProxyPass *url.URL } type ReverseProxy struct { locs []location } func NewReverseProxy() *ReverseProxy { locs := []location{} return &ReverseProxy{locs} } func (rp *ReverseProxy) AddLocation(loc, proxyPass string) error { target, err := url.Parse(proxyPass) if err != nil { return err } rp.locs = append(rp.locs, location{Loc: loc, ProxyPass: target}) return nil } func (rp * ReverseProxy) ServeForever(host string, port int) { addr := fmt.Sprintf("%s:%d", host, port) mux := http.NewServeMux() for _, loc := range rp.locs { mux.Handle(loc.Loc, httputil.NewSingleHostReverseProxy(loc.ProxyPass)) } srv := &http.Server{ Addr: addr, Handler: mux, } go func() { if err := srv.ListenAndServe(); err != http.ErrServerClosed { log.Fatalln(err) } }() }
Go is God
gopy
Go
ソースからCPython
バンディングソースを自動生成するプロジェクトである。
以前Cython
でやった”アレ”だ。
ライブラリを使用
go-extension
というライブラリを作成したので紹介する。
私の試行錯誤の結果をライブラリとしてまとめ、面倒な作業を行わずにビルド/配布を可能とする。
必要条件
Go1.16以上
インストール方法
pip install go-extension
使用方法
GoExtension
というクラスのインスタンスをsetup()
のext_modules
引数に渡すだけ。
setup.py
from setuptools import setup try: from go_extension import GoExtension except ImportError: from pip._internal.cli import main main.main(['install','-U','go-extension']) from go_extension import GoExtension ext = GoExtension( name='hello_go', packages=['github.com/hinihatetsu/go-extension-python/hello'] ) setup( ext_modules=[ext] )
ビルドコマンド
python setup.py build_ext --inplace
ライブラリを使用しない
以下はライブラリ作成前の試行錯誤の過程である。
必要条件
Go1.15以上
- pybindgen
- goimports
ということで、依存ライブラリごとインストールする。
pip install pybindgen go get golang.org/x/tools/cmd/goimports go get go get github.com/go-python/gopy
ビルド
いろいろ研究した結果、
gopy build -name=go -no-make -rename -vm=python -output=<output> <pkg>
が良さそうなビルドコマンドだと思われる。
- -name=go : 無くてもよいが、名前をわかりやすくするため。
- -no-make : Makefileが生成されなくなる。
- rename : GoのパスカルケースをPythonのスネークケースに変換してくれる。
- vm=python : Pythonの実行可能コマンド。
python
,python3
など複数ある場合に有効。デフォルトがpython3
。 - output=<output> : 出力先のディレクトリ。このディレクトリがそのままPythonパッケージになる。
- <pkg> : Goのパッケージ名(複数可)。例 : github.com/hinihatetsu/hogehoge
例えば、次のようなパッケージを考える。
tree /f │ go.mod │ setup.py │ ├─go_pkg │ main.go │ └─py_pkg __init__.py
ルートがPythonのプロジェクトディレクトリであり、かつGoのプロジェクトディレクトリ。
go_pkg
をPythonのパッケージpy_pkg/go
としてビルドしたいならば、
gopy build -name=go -no-make -rename -vm=python -output=py_pkg/go github.com/hinihatetsu/root/go_pkg
となる。
配布する
これも試行錯誤した結果、最終的にソースからビルドするときはGo
をインストールしてもらうことにした。
もちろん、ビルド済みのwheelも配布すれば実行環境にGo
は必要ない。
試行錯誤の例
- Dockerでビルドしてもらう → Dockerコンテナ内でも使える
setup.py
が書けなくて没。 - cソースから直接ビルドする → cgoはgccでビルドされるためWindows標準のmsvcではコンパイルできない。
- MinGWやDockerを利用すればWindowsでもビルドできるが、その手間は。。。
setup.py
からビルドするためにbuildgo.py
というモジュールをルートに置いた。
要約すると、GoExtension
という拡張パッケージクラスのサブクラスを新しく作って、それのビルド方法をコマンドbuild_ext
に教えてやった。
# buildgo.py from setuptools.extension import Extension from setuptools.command.build_ext import build_ext as _build_ext from setuptools import dep_util from setuptools._distutils import spawn from functools import reduce from pathlib import Path import os import shutil class GoExtension(Extension): name: str go_module_name: str pkgs: list[str] def __init__(self, name: str, go_module_name: str, pkgs: list[str], *args, **kwargs ) -> None: self.name = name self.go_module_name = go_module_name self.pkgs = pkgs super().__init__(name, [], *args, **kwargs) class build_ext(_build_ext): def build_extension(self, ext: Extension) -> None: if isinstance(ext, GoExtension): install_build_tools() self.build_go(ext) else: super().build_extension(ext) def build_go(self, ext: GoExtension) -> None: assert isinstance(ext, GoExtension) ext_path = self.get_ext_fullpath(ext.name) get_src = lambda lst, pkg: lst + [file.as_posix() for file in Path(pkg).iterdir()] sources = reduce(get_src, ext.pkgs, []) dry_run = not (self.force or dep_util.newer_group(sources, ext_path, 'newer')) outdir = Path(ext_path).parent / ext.name.split(sep='.')[-1] os.environ['LD_LIBRATY_PATH'] = (os.environ.get('LD_LIBRATY_PATH', '') + ':' + os.curdir).strip(':') cmd = [ 'gopy', 'build', '-name=go', '-no-make', '-rename', '-vm=python', f'-output={outdir}' ] + [ext.go_module_name+'/'+pkg for pkg in ext.pkgs] spawn.spawn(cmd, dry_run=int(dry_run)) class GoNotFoundError(Exception): pass def install_build_tools(): if not shutil.which('go'): raise GoNotFoundError('Go1.15 or above is required to build this package.') if not shutil.which('gopy'): print('installing gopy...') cmd = ['go', 'get', 'github.com/go-python/gopy'] spawn.spawn(cmd) if not shutil.which('goimports'): print('installing goimports...') cmd = ['go', 'get', 'golang.org/x/tools/cmd/goimports'] spawn.spawn(cmd) spawn.spawn(['python', '-m', 'pip', 'install', '-Uq', 'pybindgen'])
そしてsetup.py
を次のように書く。
from setuptools import setup import sys sys.path.append('.') from buildgo import GoExtension, build_ext go_pkg = GoExtension( name='py_pkg.go', go_module_name='github.com/hinihatetsu/root', pkgs=['go_pkg'] ) setup.py( ext_modules=[go_pkg], cmdclass={'build_ext': build_ext} )
最後にMANIFEST.in
をルートディレクトリに置いてGo関連のファイルをパッケージに含める。
include go_pkg/* include go.mod include go.sum
とりあえずこれでGithubからはpipでインストールできて、
pip install git+http://github.com/hinihatetsu/hogehoge
でGoがあればビルドしてくれる。
ただ何故かPyPIからインストールしようとすると、Goパッケージだと認識されなくてビルドに失敗する。 今のところPyPIのwheelとGithubからのインストールがあれば良いかなということで原因究明はToDo送りとなった。#ToDo
もう一つ確認している問題はpython setup.py build_ext -i
でインプレイスビルドができないので、インプレイスが必要な開発者用のMakefile
を別途作っておく必要がある。#ToDo
Dockerでビルドする例
Dockerコンテナとして利用する際にマルチステージビルドする例を示す。
FROM golang:1.17-buster as go FROM python:3.9-buster as builder COPY --from=go /usr/local/go /go WORKDIR /go ENV GOROOT /go ENV GOPATH /gohome ENV PATH $PATH:$GOROOT/bin:$GOPATH/bin RUN go get golang.org/x/tools/cmd/goimports \ && go get github.com/go-python/gopy RUN pip install -U pip \ && pip install git+https://github.com/hinihat/remoteweb FROM python:3.9-slim-buster as runner COPY --from=builder /usr/local/lib/python3.9/site-packages /usr/local/lib/python3.9/site-packages
余談
この作成した。setup.py
に組み込む部分も汎用性が高そうだからライブラリとして仕上げたいよね。
最近ライブラリのリリース作業ばかりやっているが、README・Docstringの整備だとかテストコードの拡充だとかドキュメントの生成(Sphinx)・公開(GitPages)だとか拡張パッケージのビルドだとかCIへの登録だとかPyPI
へのアップロードだとか、Pythonコード書くよりもしんどいことが多くて今週は毎日睡眠3時間とかで昨日爆睡して多少回復した。
これらの作業ってすべてのライブラリで必要なのに、ライブラリによって設定とか異なるから自動化できないのがキツイ。
Git
とかDocker
とかCI
でかなり開発環境は良くなったんだろうけど、それでもコーディング、テスト、デプロイのフローを作るのは結構きつくて個人の限界に挑戦しているって感じ。
正直このようなStableなものにする作業はクソだと感じているので絶対にプログラマになることはないな。
テストコードは絶対に必要で、アップデートする際に既存の仕様を壊すことなく実装できるし、問題が発生しても基底のライブラリのテストがしっかりしてあれば表面のコードを調べるだけになってトラブルシューティングが楽になる。(というのを実体験した。)
まとめ
ということで数日間を生贄にGoソースからPythonパッケージを生成・配布する手段を習得した。
Goは通信関係の実装が得意であるため、今後も積極的に活用していきたいと考えている。
GoでPythonを拡張できるんだったらもうCythonいらなくね?