はじめに
この記事はpythonをよく使う筆者が、BEAR.SundayのRay.DIような”依存性の注入(Dependency Injection)“がpython界隈でもあるのかを探り、見つけたので試してみてる記事です。
更に、このInjectorの記事はQiitaにいっぱいあるので言うなれば“車輪の再発明“しまくっている記事なのであしからず
DIPとは
まず、DI:Dependency Injectionを行う前にキーワードになるのがSOLIDの原則のDにあたる
Dependency inversion principle:依存性逆転の原則
について少しだけ触れておきます。
依存性逆転の原則とは一言でいうと
「コードは同等以上のレベルの抽象に依存せよ、下位レベルの詳細に依存しない」
ということです。
cf: https://speakerdeck.com/koriym/bear-dot-sunday-2018?slide=19
↑スライドでは、ドライヤー(?)とコンセントの写真がありますが、
ドライヤーにとっての興味はあくまで、“コンセントに電源ケーブルを刺せば電流が流れてドライヤーの機能が使える“ことに興味があります。したがって、コンセントの中の配線や導線の種類などの“詳細“はドライヤーにとってはどうでもいいことなのです。
なので、ドライヤーを依存する側、コンセントを依存される側という風に考えた時にコンセントというインターフェースに依存させるようにしましょうということです。(わかりづらかったらすみません )
もしこれが、詳細に依存させている形だったとしたら、ドライヤーをコンセントに刺す時にコンセントの詳細に依存することになるので、コンセントの中身の構造を変更する時に必然的にドライヤーの行動にも影響を及ぼしてしまします。
抽象に依存させることで、依存による変更の影響を少なくする考えで、
これができると、抽象に依存させている部分(つまり呼び出し側)は変更することなく、その詳細を変更することができるというのがDIPの考え方です。
クリーンアーキテクチャや、オニオンアーキテクチャ、ポートアンドアダプター(ヘキサゴンアーキテクチャ)でも肝になる考え方です。
抽象に依存させるっていっても結局中身の実装にどうやってアクセスするの?
っていう話になってくると思います。そこでその1手法であるDIが出てきます。
DI
DIについては、この記事とかわかりやすかったので引用すると、
依存していた部分を、外から注入すること
ものすごく大雑把ですが、これに尽きます。
あるクラスの単体テストなどを考えた時に、そのクラスが別のクラスをnewしたりするとどう行った問題が起きるか書いてみます。
class Inner:
def __init__(self):
self.forty_two = 42
class Outer:
def __init__(self, inner: Inner):
self.inner = inner
inner = Inner()
outer = Outer(inner)
print(outer.inner.forty_two)
この場合だと、Outerクラスをテストしようとしてた時、Innerクラスの変数に依存しているので、Outerクラスの処理を変えてもテストが通らない可能性ができてます。
また、Innerクラスもテスト対象となるので、単体テストにはならないです()
じゃあ、どうしましょうかということなんですが、DIの出番で、Innerクラスオブジェクトを外からOuterクラスに注入するようにすればいいということになります。
実際にサンプルを下に書いていきます。
注目したInjector
今回触ったInjectorはalecthomasのinjectorというものです
https://github.com/alecthomas/injector
“Injector - Python dependency injection framework, inspired by Guice”
Guiceというのは、Googleの開発しているDIコンテナ(フレームワーク)で言語はJavaで作られています
Ray.DIはGoogleのGuiceの主要な機能を持つPHPのDIコンテナです。
alecthomasのinjectorもGuiceにインスパイアされているということで且つスターも多いので使ってみました。
サンプルを動かしてみる
まずはサンプルがREADMEにかかれているのでそのまま試してみる
環境はpython3.5以上だと動くので、python3.8-devをpyenvでinstallしている。
pyenv install 3.8-dev
pyenv local 3.8-dev
pip install injector
まずは注入する側のクラスをこのように書いてみる
class Inner:
def __init__(self):
self.forty_two = 42
注入するとこんな感じになる
from injector import Injector, inject
from inner import Inner
class Outer:
@inject
def __init__(self, inner: Inner):
self.inner = inner
injector = Injector()
outer = injector.get(Outer)
print(outer.inner.forty_two)
$ python injector_sample.py
42
注目すべきなのは、innerクラスを全くnewすることなくアノテーションで中身が“注入“されている点です。
結局これで何がいいのという話なのですが、まずその一つに
newしている場所をフレームワークなど一箇所に留めさせることができるという点があります。
このInjectorを使わないと、注入する際にnewしているコードを必ず書かなければならず、
システムがでかくなってくるごとに、“どこで作られたクラスなのか“を追うことが困難になる点があります。
また↑であげたようなテストの問題があります。
こういう風にinjectorを用いることで、中身をOuterクラスに注入できOuterクラスだけをテストすることが可能になりました。
python3.7以降だとdataclassという読み取り専用のクラスがアノテーションで定義することができるので、
これを用いると、下記のようなサンプルになります
from dataclasses import dataclass
from injector import Injector, inject
from inner import Inner
@inject
@dataclass
class Outer:
inner: Inner
injector = Injector()
outer = injector.get(Outer)
print(outer.inner.forty_two)
もうちょい深く
今のでだいたい“注入“自体わかったと思います。では「抽象に注入」するというのはどういうことかというので、FullSampleがあるのでそれに触れていきます。
We’ll use an in-memory SQLite database for our example:
とりあえず、SQLite3をつかってinjectしてみようということです。
RequestHandler.pyという形でSQLiteを使ったサンプルを書いてみます。
import sqlite3
from injector import inject
class RequestHandler:
@inject
def __init__(self, db: sqlite3.Connection):
self._db = db
def get(self):
cursor = self._db.cursor()
cursor.execute('SELECT key, value FROM data ORDER by key')
return cursor.fetchall()
さっきの簡易的な例からわかると思いますが、RequestHandlerの中のsqlite3.connectionクラスが注入されてそれを自身のクラスインスタンスに格納しているのがわかります。
また、getメソッドではkey, valueの値をdataテーブルから取得して返している処理が書かれています。
では、このSQLite3のconfig部分がどうやって書かれているか見てみます。
# Configの入れ物
class Configuration:
def __init__(self, connection_string):
self.connection_string = connection_string
ここにはほぼ何も書かれておらず、ただconnection_stringという文字列を受け取って自身のクラスインスタンスの変数に格納しているだけです。
実際に埋め込んでいる部分を書いてみましょう。
from configuration import Configuration
from injector import singleton
def configure_for_testing(binder):
configuration = Configuration(':memory:')
# Configurationクラスとして、中身は“configuration”を束縛
binder.bind(Configuration, to=configuration, scope=singleton)
ここではじめて':memory:'、つまり、インメモリの設定情報をメソッド内で埋め込んでいるところがわかりました。さらによく見ると、このbinderというクラスのbindメソッドを使って、Configurationクラスの中身をbind(束縛)しているのがわかります。ここのbindメソッドで初めて入れ物(インターフェース)に対しての詳細を注入している部分が出てきました。
そして、この設定を元にしたDBModuleを書いてみましょう
from injector import Module, singleton, provider
from configuration import Configuration
import sqlite3
class DatabaseModule(Module):
@singleton
@provider
def provide_sqlite_connection(self, configuration: Configuration) -> sqlite3.Connection:
conn = sqlite3.connect(configuration.connection_string)
cursor = conn.cursor()
cursor.execute('CREATE TABLE IF NOT EXISTS data (key PRIMARY KEY, value)')
cursor.execute('INSERT OR REPLACE INTO data VALUES (“hello”, “world”)')
return conn
bindされたものが"@provider"で与えられています。
provide_sqlite_connectionのメソッドの中にconfigurationという引数がありますが、この引数の中にさっきのbindしたものがprovideされています。あとは、このsqlite3のコネクションクラスがreturnされているだけです。(テーブルがなかったら作るなどの初期化を行なっています)
最後に実際のmain.pyをみてみましょう
from binder import configure_for_testing
from database_module import DatabaseModule
from injector import Injector
from request_handler import RequestHandler
# DatabaseModuleにbinderとしてconfigure_for_testingを与える
injector = Injector([configure_for_testing, DatabaseModule()])
handler = injector.get(RequestHandler)
print(tuple(map(str, handler.get()[0])))
Injectorクラスの引数にlist型でbindするメソッドと注入するModuleを与えています。
そしてRequestHandlerにSQLite3のコネクションクラスが注入されgetメソッドを行うと、“hello, world”と帰ってくるのです。
“:memory:“と書いている部分を“:example.db:“などに変えてbindをすれば、もちろんRequestHandlerの結果も“:example.db:“のもっているDBのもとから出力されるものに変わります。
RequestHandler側もさらにそれを使っているmain.pyも一切変更なしですみますね
最後に
このライブラリのREADMEにはこんなことが書かれています。
You're probably thinking something like: "this is a large amount of work just to give me a database connection", and you are correct; dependency injection is typically not that useful for smaller projects. It comes into its own on large projects where the up-front effort pays for itself in two ways:
- Forces decoupling. In our example, this is illustrated by decoupling our configuration and database configuration.
- After a type is configured, it can be injected anywhere with no additional effort. Simply @inject and it appears. We don't really illustrate that here, but you can imagine adding an arbitrary number of RequestHandler subclasses, all of which will automatically have a DB connection provided.
訳) あなたは多分“データベースのコネクションあたえるだけやのにめっちゃコード量おおくね?“って思っているでしょう。正解やで。DIは小さいプロジェクトに対してはあまり便利ではない。事前の努力(↑のようなRequestHandlerの例)で大きいプロジェクトにおいて2点恩恵が受けれます
- 分離の強制です. ↑の例において、これはコンフィグとデータベースコンフィグの分離を示した
- Configを記述後、追加の作業を行わなくてもどこでも注入できます。ただ、"@inject"とすればそこに現れます。実際に例では示さなかったが、任意の数のRequestHandlerのサブクラスが増えたとしても、そのすべてに対して自動的にDBのコネクションを与えることができます
次に余力があればFlaskにこのInjectorを使ってみようと思います。
長々ありがとうございました。