monogusaにdefault componentという機能を追加した

github.com

monogusaにdefault componentという機能を追加した。何故欲しくなったのかなどを思考の整理のためにメモしておく。

default component?

このdefault componentという名前は独自の名前で暫定的な名前。通常のcomponentは名前と型によってmappingされる。一方default componentは戻り値の型のみによってmappingされる。

component

例えば以下のコードのuse()上でFooオブジェクトを利用しているが、これは登録した関数名と引数名が一致している必要があった。以下のように利用する側も登録する側もfooという名前を利用していれば問題なく取得できた。

00use.py

from monogusa import component


class Foo:
    pass


@component
def foo() -> Foo:
    Foo()


def use(foo: Foo) -> None:
    print(foo)


if __name__ == "__main__":
    from monogusa.cli import run

    run()

一方で利用する側と名前が食い違っていた場合にはエラーになっていた。 つまり以下の様なffooのような名前が一致していないコードはエラーになっていた。

01use.py

--- 00use.py 2020-02-13 19:55:00.000000000 +0900
+++ 01use.py  2020-02-13 19:56:50.000000000 +0900
@@ -10,8 +10,8 @@
     return Foo()
 
-def use(foo: Foo) -> None:
-    print(foo)
+def use(f: Foo) -> None:
+    print(f)
ValueError: component (f : <class '__main__.Foo'>) is not found

default_component

一方、default_componentは型のみによって取得されるので以下の様な名前が一致していないコードも動く。

02use.py

--- 01use.py 2020-02-13 19:56:50.000000000 +0900
+++ 02use.py  2020-02-13 20:02:33.000000000 +0900
@@ -1,11 +1,11 @@
-from monogusa import component
+from monogusa import default_component
 
 
 class Foo:
     pass
 
 
-@component
+@default_component
 def foo() -> Foo:
     return Foo()
<__main__.Foo object at 0x1104ec3a0>

resolve_args()

ちなみにいちいちコマンドとして実行しなくても、以下の様な形で依存を注入した結果を取得する事ができる。

03resolve.py

from monogusa import component
from monogusa.dependencies import resolve_args


@component
def one() -> int:
    return 1


def use(one: int) -> None:
    print(one)


print("!", resolve_args(use))
# ! [1]

なぜdefault componentが欲しくなったか?

なぜdefault componentが欲しくなったか?理由をメモしておく。 これは特定のusecaseにおけるdefault実装を用意したいと思ったため。

例えば、CLI, slack bot, discord botのそれぞれにおいてreply_message()のような操作を持つオブジェクトを考えてみる。例えば以下の様なProtocolを満たすもの。1

import typing_extensions as tx

@tx.runtime_checkable
class Replier(tx.Protocol):
    def reply_message(self, text:str) -> None:
        ...

このReplierはCLIでは単純にstdoutに出力するだけのものかもしれない。

class ForConsole:
    def reply_message(self, text:str) -> None:
        print("reply message:", text)

一方でslack botやdiscord bot用のそれなら特定のメッセージへのreplyとして返すような実装になるだろう。

class ForSlackBot:
    def reply_message(self, text:str) -> None:
        # 何らかの内部実装を呼び出す
        self.internal.reply(text)

さて、ここでmonogusaの機能について振り返ってみると「既存の関数定義を色々なやり方で公開する機能」というような位置づけのものになる。つまり以下の様な形になる。

  • 既存の関数定義を、CLIアプリケーションとして (monogusa.cli)
  • 既存の関数定義を、web APIとして (monogusa.web)
  • 既存の関数定義を、chatbotのコマンドとして (monogusa.chatbot.slackbot, monogusa.chatbot.discordbot)

ここで例えばslackbot用の実装を利用するcomponentを考えたときに、default実装を要求したくなることがあった。2

まだ、未実装だが、例えばある特定のメッセージに反応を返すbotを考えたときにこの種の機能が欲しくなる。 (例えばsubscribe, MessageEventというデコレーターとイベントのセットで特定のイベントが購読できるようになるという機能が実装された未来の世界について考えている)

from monogusa.events import subscribe, MessageEvent

@subscribe(MessageEvent)
def on_amazon_url(ev: MessageEvent, replier: Replier) -> None:
    if "https://amazon.co.jp" not in ev.content:
        return

    replier.reply_message("OK")

ここで、元のメッセージへの反応をするためにどうすれば良いかわからなくなる。そこで例えば先程言及したような振る舞いを持つオブジェクトがそれぞれのcontext上で登録されているとする。そのような場合はそのオブジェクトを利用して反応を返してあげれば良い。

つまりそれぞれのusecaseごとにReplierというprotocolを実装したcomponentが存在しているというイメージ。 (あるいはMessageEvent自体がreplyみたいなメソッドを持つ未来もあるかもしれない。それでも内部にコミュニケーション用のオブジェクトとしてこの種のオブジェクトを持つことになるはず)

このとき、依存として埋め込まれる replierがそれ以外の名前の引数として扱われた場合にエラーになるのは嬉しくない。たとえばr: Replierなどが許されないとするとちょっと厳しい。引数名の部分はimportして利用したりなどが不可能なものなので、登録されているcomponentの名前を事前に知っておく必要がでてくる。これは使い勝手が悪い。

幸いtype hintの部分つまり型の部分に関しては値なのでimport可能なものになる。なのでこれを利用することには意味がある。 (特にinterface/protocolと実装という形で詳細や依存を閉じ込めておけるのであれば不要なimportなども避けられるかもしれない)

そんなわけでdefault componentが欲しくなった。

追記: 全部がdefault componentではだめか?

考えてみると全部がdefault componentという世界観も考えられる。

すごくざっくり以下の様にまとめるとして、default componentだけではだめか?

  • component -- nameと型がセットのcomponent。named component
  • default component -- 型だけからマッピングされるcomponent。

これはやってやれないことはないけれど。同じ型を違う形で使うことが難しくなる。クラスとインスタンスの関係は通常1:Nなのにも関わらず1:1を暗黙に課すというのは気持ち悪いように感じる。

あるいは同じミドルウェアに接続するオブジェクトだがリクエスト先が異なるようなものに関しても型レベルで分ける必要がある(といっても継承するだけといえばそれだけだけれど)。やっぱりそれがデフォルトなのは気持ち悪いように感じる。

基本的にはstrictな通常のcomponentの方をユーザーが使うという方向になるような気がしている。

gist


  1. ちなみに isinstance(val,type) でのassertionが入るので、runtime_checkable()を付ける必要がある。ただ、factoryを記述するタイミングでmypyでチェックされるならこのassertionは無効にしても良いかもしれない。あるいはabcモジュールを使って仮想継承させる形にしてProtocolを使わないという方法もあるかもしれない。ZCAのように継承関係を見るということまではしないつもり。

  2. あるいは、個別にcomponentを登録することはせずにcomponentのrepositoryを登録するという形でも良いかもしれない