Header Logo
Ⓒ 2024- @itatibs
[Python]段階を踏んでDIコンテナを学んでみる

Category: tech
Tags(仮): Python,DI,DIコンテナ,Python-CleanArchitecture
Date: 2021/8/8

長い前置き(ここは読まなくても良い)

弊社の流れとして、開発するときはDDD(Domain Driven Design)を意識してほしいだの、Clean Architectureで設計しろだの データエンジニアの自分としてはあんまり聞き慣れないワードが最近よく出てくるようになりました。

たぶん、そういうアーキテクチャやフーレムワークってWebアプリを作ったり基幹に近いシステムを作るためのガイドラインとして策定してる意図だと思うので、 実際推奨される言語がnodejs系だったりで、自分は分野上Pythonを使ってETLの真似事をしたりその場限りのスクリプトを作るのが関の山だったので、 設計思想のお達しをされた当初はあんまり自分事とは思わずにスルーしていました。

ところがどっこい、最近はなんか大掛かりなデータ収集システムを作ってよ、とかWebにあるデータ自動的にしてほしいというオーダーをゆるっとふわっと受けることが多く、しかも大体自分一人で作るので、そうなるとその場でえいやっとテストもせずに作ったものだと品質だとか他の人に引き継ぐときに、される側は適当に書かれたコードだけ渡されても絶対やだよなー(それが自分なら絶対無理)と思い、じゃぁなるべく会社の人には説明しやすいように作るようにしよう->設計思想をなるべく合わせようと思い、DDDとかClean Architectureについて調べるようになりました。

まだまだここら辺の設計思想については自分の中に馴染めておらず、何個かサンプルでアプリ作ってみないとわかんねーよなーと思いつつ、 こういうアーキテクチャでたびたび出てくるDIってなんやねんというところで早速つまづいてしまったので、 サンプルコードを自分が理解できる範囲で書き起こして落とし込むようにしました。

PythonでのDIコンテナを体で覚える

正直まだ、DIについて完全に理解したという領域にもいっていません。
一旦、以下くらいの理解をしました。

  • DI

    • Dependency Injection: 依存性注入
  • DIコンテナ

    • DI機能を提供するフレームワーク
  • 依存性注入

    • 依存している部分を切り離して、それぞれを疎に考えやすくする

初めてDIコンテナという言葉を聞いたのは前職でWeb系の人がSpringをしているときによく話していたときで、当時はDockerのコンテナと混同して理解すら放棄していたのですが、単純にDIを簡単に使えるようにしてくれる機能くらいのものということがわかり、ようやく自分の中で敷居が下がりました。

ちなみに、ここではDIの考え方とか理屈は一切抜きにして、実際にサンプルを作ってDIにリファクタしていくなかで、こういうところが便利よね、とか確かに元のサンプルの不便さがなくなるよね、というのを実感して体で覚えていきます。
ここ数日Webに転がってる記事だけ見ても全然分からず、逆に力技な記事を書いてる人はいないので、そういう部分を担えればと思います。

そのため、DIを説明するときに必ずあるといっても良いクラス間の関係を示す図や、依存性を注入していることを示す図は一切出てきません。

サンプル1: 何も考えずに作ってみる

サンプルのユースケースとして、Taskというオブジェクトを作り、コンソール画面に出力するサンプルコードを考えます。
サンプル1は以下のようなコードになります。

@dataclassについてはあまり気にしないでください。
コンストラクタで変数を書いて代入するのを省いてくれるくらいなものです。

from dataclasses import dataclass @dataclass(frozen=True) class Task(): num: int status: str subject: str def presentor(self): print('num is {}, status is {}, subject is {}'.format( self.num, self.status, self.subject )) @dataclass(frozen=True) class TaskTest(): num: int status: str subject: str def presentor(self): print('[Test]: num is {}, status is {}, subject is {}'.format( self.num, self.status, self.subject )) # Taskにデータ保持と出力機能がある # 一部分変えたいときでも、全て複製する必要がある if __name__ == '__main__': task01 = Task(num=1, status='new', subject='task01') task01.presentor() task02 = TaskTest(num=2, status='new', subject='task02') task02.presentor()

実行すると、以下のようにちょっと出力が異なる2文が出力されます。

❯ python main01.py num is 1, status is new, subject is task01 [Test]: num is 2, status is new, subject is task02

2つ目の方は出力の先頭に[Test]:というのが追加されているだけです。
ただ、こんな感じで全てを1つのクラスに詰め込むと、ちょっとした変更をするだけでまるまるコピーする必要があり、これはすごい冗長になるなぁと流石の自分でも思いました。

サンプル2: 機能を分離してみる

DIしてみる前に、まずはTaskクラスのデータを持っている箇所と出力する箇所を分けてみます。
出力する方をPresentorとかPresentorTestとしています。
そうすることで出力方法を変えるくらいだったら、データを持つ箇所は修正せずに使えるので、ちょっと冗長さが無くなると思います。

from dataclasses import dataclass @dataclass(frozen=True) class Task(): num: int status: str subject: str class Presentor(): def presentor(self, task: Task): print('num is {}, status is {}, subject is {}'.format( task.num, task.status, task.subject )) class PresentorTest(): def presentor(self, task: Task): print('[Test]: num is {}, status is {}, subject is {}'.format( task.num, task.status, task.subject )) class ApplicationTask(): def print(self, task: Task, presentor: Presentor): presentor.presentor(task) def print_test(self, task: Task, presentor: PresentorTest): presentor.presentor(task) # データ保持とデータ出力の機能を分離した # ついでにApprlicationTaskという動かす部分を作った # ApplicationTaskにPresentorが依存している # ->例えば、presentorの出力をするときに、printメソッド、print_testメソッドが必要 if __name__ == '__main__': task01 = Task(num=1, status='new', subject='task01') presentor01 = Presentor() ApplicationTask().print(task01, presentor01) task02 = Task(num=2, status='new', subject='task02') presentor02 = PresentorTest() ApplicationTask().print_test(task02, presentor02)

ついでにApplicationTaskというクラスに出力処理の制御を任せてみました。
こうすることで、Taskは使いまわして使えるようになり、ApplicationTaskで実行というなんとも良い感じになってきましたが、 出力を変えるために引数のPresentorPresentorTestのどっちかを気にする必要があったり、printとかprint_testとかいちいち呼び出す側の修正が必要になるのはメンドくせぇなという感じです。

例えばさらに出力にバリエーションを持たせようとすると、その度に出力側、呼び出し側の2箇所に修正入るので面倒だし修正忘れてバグりそうなのが嫌ですね。

サンプル3: DIしてみる

そんなこんなで、いよいよDIというのを使ってみて、そこらへんの煩わしさを解決できるようにします。
DIする、というのはざっくりいうとPresentorとかPresentorTestみたいな複数ありそうな機能に、そいつらの親玉であるPresentorInterfaceみたいな子に継承してもらうこと前提のインタフェースを作って、呼び出す側はそのインターフェースを使って操作してもらうことらしいです。
そして、このインタフェースを抽象クラスといい、DIでは何かが何かを依存するときは抽象なやつに依存しろといわれるヤツになります。
こういう抽象だの依存だの、頭が緩い自分みたいな人種にはとっつきにくい言葉が連発されるのでこういう分野は本当に苦手です。

from abc import ABCMeta, abstractclassmethod from dataclasses import dataclass @dataclass(frozen=True) class Task(): num: int status: str subject: str class PresentorInterface(metaclass=ABCMeta): @abstractclassmethod def presentor(self, task: Task): pass class Presentor(PresentorInterface): def presentor(self, task: Task): print('num is {}, status is {}, subject is {}'.format( task.num, task.status, task.subject )) class PresentorTest(PresentorInterface): def presentor(self, task: Task): print('[Test]: num is {}, status is {}, subject is {}'.format( task.num, task.status, task.subject )) class ApplicationTask(): def print(self, task: Task, presentor: PresentorInterface): # ここは抽象クラスであるPresentorInerfaceを使う # Presentorなのか、PresentorTestなのかは知らなくて良い presentor.presentor(task) # ApplicationはPresentorのインタフェースに依存する # そのため、Presentorなのか、PresentorTestなのかは気にしなくてよい # これが、DI if __name__ == '__main__': task01 = Task(num=1, status='new', subject='task01') presentor01 = Presentor() ApplicationTask().print(task01, presentor01) task02 = Task(num=2, status='new', subject='task02') presentor02 = PresentorTest() ApplicationTask().print(task02, presentor02)

PresentorInterfaceにあるmetaclass=ABCMeta@abstractclassmethodはPythonでインターフェースを使うために必要な記載となります。
インタフェースとか前職の新人研修でJavaやったときにちらっと聞いたとき以来だなぁとか呑気に思ってますが、実際pythonで軽い処理をする程度しかやらない自分にはこれまで必要ない機能だったので新鮮です。この機会に体で覚えます。

抽象クラスを作ってあげて、呼び出し側のApplicationTaskではそちらを引数に取ることで、Presentorなのか、PresentorTestなのかを特に気にせずに処理することができます。
実際はApplicationTaskに投げるPresentorを作る際にPresentorなのか、PresentorTestなのかを決めて作って渡しているので、そのときにどちらの機能を使うのか決めることになります。

自分はサンプル1-3までここまでやって、ようやくそうなんだ、じゃぁDI便利だねと腑に落ちることができました。
最初っからサンプル3だけ見ると、理屈は分かっても自分で使いこなせず、実際サンプル作ってみてもなんか違うものができあがってしまい沼に入る予感がしたので、こういうステップは地道ですが体で覚えるには必要かなと思いました。

サンプル4: DIコンテナを使う

ここまでで、DIなんとなく使えそうという感覚を掴んだ上で、もう少しDIを楽に使えるようにします。
例えば、サンプル3だとmain処理でpresentor01 = Presentor()とかpresentor02 = PresentorTest()みたいに、自分で明示的に指定しているんですが、DIする数が増えてくると、管理するのが面倒になってきそうだなと思ってきました。

そんなときに、DIの関係を良い感じで管理してくれるDIコンテナ機能を活用します。
SpringみたいなDIを使うフレームワークでは標準で使えるのかもしれないですが、Pythonみたいな言語には最初っからそういう機能はなく、外部機能として提供されていることが多いみたいです。

PythonにもいろいろDIコンテナ機能を提供してくれるモジュールがありますが、今回はinjectorでDIコンテナ機能を使ってみました。

pip install injector

でインストールできます。

from abc import ABCMeta, abstractclassmethod from dataclasses import dataclass from injector import Injector, inject, Module, Binder @dataclass(frozen=True) class Task(): num: int status: str subject: str class PresentorInterface(metaclass=ABCMeta): @abstractclassmethod def presentor(self, task: Task): pass class Presentor(PresentorInterface): def presentor(self, task: Task): print('num is {}, status is {}, subject is {}'.format( task.num, task.status, task.subject )) class PresentorTest(PresentorInterface): def presentor(self, task: Task): print('[Test]: num is {}, status is {}, subject is {}'.format( task.num, task.status, task.subject )) class ApplicationTask(): @inject def __init__(self, presentor: PresentorInterface): # ここは抽象クラスであるPresentorInerfaceを使う # Presentorなのか、PresentorTestなのかは知らなくて良い self.presentor = presentor def print(self, task: Task): self.presentor.presentor(task) class TaskDIModule(Module): def configure(self, binder: Binder) -> None: binder.bind(PresentorInterface, to=Presentor) class TaskDIMduleTest(Module): def configure(self, binder: Binder) -> None: binder.bind(PresentorInterface, to=PresentorTest) # Injectorを使うことで、DIコンテナを使うことができる # DIコンテナ経由で使うことで、TaskDIModule, TaskDIModuleTestのように # それぞれの用途にそったモジュールを切り替えることができる # テストのときは、テスト用のDIコンテナを指定するだけであとは変えなくても良い if __name__ == '__main__': task01 = Task(num=1, status='new', subject='task01') injector = Injector([TaskDIModule()]) application = injector.get(ApplicationTask) application.print(task01) task02 = Task(num=2, status='new', subject='task02') injector = Injector([TaskDIMduleTest()]) application = injector.get(ApplicationTask) application.print(task02)

まだinjectorの使い方ちゃんと読んでなくて、サンプルをマネしただけなのですが、 恐らく抽象クラスを呼び出す箇所で@injectを挿入しておき、 Moduleクラスを継承したクラスでbinder.bindを使ってDIの関係性を指定しておけば良いかと思います。

今回の例だと、Presentorなのか、PresentorTestなのかの使い分けのためだけにModuleを使っているので、むしろコード増えて一見煩雑になってそうですが、たとえばテスト環境単位(スタブを使うローカル環境、Dockerで動く結合試験環境、本番環境)ごとに接続するDBとかが違う場合はそういう単位でDIの関係性を書いておけば、そこだけ切り替えてあげるだけで簡単に操作できるので楽そうだなぁという印象を持ったので、悪い印象ではないです。

DIを学んでClean Architectureに備える

以上で、なんとなくDIについての苦手意識を払拭することができました。
サンプルコードはこちらのGitHubにも載せています。

次に、いよいよDDDだのClean Architectureについて理解していこうと思います。
というより、理論を読んでもさっぱり分からずやっぱり自分が理解できるサンプルコードを作らないと無理よねということが分かったので、こちらについてもできればまたまとめてみたいと思います。