miniconfigというものを作っていました

はじめに

以前に、 miniconfig というライブラリを作っていたことを思い出したのですが。 あらためて見なおしてみると結構便利かもしれません。

miniconfig

これはすごく雑に言うと Pyramid というウェブフレームワークの設定などをを読み取りアプリケーションインスタンスを作成する際に使われる Configurator オブジェクトから基礎となる部分を取り出したものです。

pyramidの Configurator はわりとZCA(Zope Component Architecture)に強く依存しているところがあるのですが、それを省いて単体で使えるようになっています。

ここでの Configurator というのは以下の2つの役割を持っています。

  • settignsという名の辞書に値を埋める
  • 設定(configuration)のための副作用を集める

使い方

基本的には ConfiguratorCore というクラスを継承して、利用したいアプリ毎の設定に利用します。 make_app() というメソッドを定義するのが最小の構成かもしれません。

そして、実行時には設定ファイルなどからsettingsのデフォルト値を取り出し何らかの処理を施した後、make_app()を実行してアプリケーションのインスタンスを作成し実行するという形になります。

from miniconfig import ConfiguratorCore
from myapp import App

class Configurator(ConfiguratorCore):
    def make_app(self):
        self.commit()
        return App(self.settings)

def includeme(config):
    pass

if __name__ == "__main__":
    # 設定ファイルからdictを作成
    with open("~/.config.json") as rf:
        settings = json.load(rf)
    configurator = Configurator(settings)
    config.include(includeme)
    app = configurator.make_app()
    app.run()

設定の読み込み

設定の読み込みは include() によって行われます。モジュールを指定した場合には、そのモジュールの includeme() が関数を指定した場合にはその関数が呼ばれます。

# モジュールを指定
config.include("myapp.foo")
# 関数を指定
config.include("myapp.foo:includeme")
# 関数を直接渡す
from myapp.foo import includeme
config.include(includeme)

利用する側のモジュールはどうするかと言うと、includemeはconfigを受け取る単なる関数を定義してください。 例えば、以下のような設定による条件分岐を行いたい時に利用することが多いです。

  • デバッグモードの時には別のcomponentを利用する
  • メールの送信機能が無効の時にはdummyのconsole出力をする機能の方を利用したい。

利用するオブジェクトを取り出す際に直接importしても良いですが、maybe_dotted() というメソッドが使えます。

def includeme(config):
    # debug modeがtrueの場合には詳細の出力を行うcomponentの方を使う
    if config.settings.get("debug_mode") == True:
        ComponentFactory = config.maybe_dotted(".components:VerboseComponent")
    else:
        ComponentFactory = config.maybe_dotted(".components:Component")

    # framework側の設定を利用
    add_component(ComponentFactory, config.settings["MAX_RETRY"])

また、ある機能の設定用の処理が他の設定用の処理に影響を与えるのはあまり良くないため、通常は、コンポーネントの登録などの副作用がある処理は関数で包み action() に渡します。

def includeme(config):
    # debug modeがtrueの場合には詳細の出力を行うcomponentの方を使う
    if config.settings.get("debug_mode") == True:
        ComponentFactory = config.maybe_dotted(".components:VerboseComponent")
    else:
        ComponentFactory = config.maybe_dotted(".components:Component")

    def register():
        # framework側の設定を利用
        add_component(ComponentFactory, config.settings["MAX_RETRY"])
    config.action(register)

アプリケーションの利用者として使う分にはこれでおしまいです。

何らかのフレームワークを作成したい場合のCustomConfiguratorの作成

もし、何らかのドメインに対するフレームワークを作成したい場合には、あなたの Configurator が独自の操作を持つことができると便利かもしれません。 そのような Configurator が持つ操作を directive と言います。幾つかの directive を事前に定義しておくことであなたのフレームワークのユーザーは、あたかもそれが設定を行うための自然な方法であるかのように使う事ができます。

その directive の登録に関しても行うことは以前と同様です。つまり、あなたの提供したいモジュールのトップレベルに includeme() の関数を作成し、あなたの作った Configurator でそれが有効になるよう include() を呼んでおくだけです。

# registering your framework specific actions

def overwrite_settings(config, predicate, name, value):
    def register():
        if predicate():
            config.settings[name] = value
    config.action(register)


def overwrite_settings_on_debug(config, name, value):
    overwrite_settings(config, lambda: config.settings["debug_mode"], name, value)


def includeme(config):
    config.add_directive("overwrite_settings_on_debug", overwrite_settings_on_debug)

今回は、デバッグモードで実行されている場合には、settingsの値を上書きするような directive を作ってみました。 このようにして directive を登録しておくと、あたかも Configurator の持つ操作として利用する事ができます。

# your framework user

config.include("yourframework")
config.overwrite_settings_on_debug("MAX_RETRY", 1)
config.overwrite_settings_on_debug("SAME_DOMAIN_WAIT", 0)

action() で登録されるclosureが実行されるタイミングは?

今までしれっと流してきましたが、実際に設定を有効にするための副作用は config.action() に渡すことで登録していました。 ここで登録された関数が呼ばれるタイミングは何時でしょう? 答えを言うと、それは config.commit() が呼ばれるタイミングです。

冒頭でのConfiguratorの定義をする際に make_app() を作ろうという箇所がありましたが、そこで config.commit() が呼ばれていました。

class Configurator(ConfiguratorCore):
    def make_app(self):
        self.commit()
        return App(self.settings)

ところで、自前のフレームワークなどを作っている時など、ある操作はある操作の前に実行したいなどと、実際に副作用が起きる順序を定義できた方が良いと思うこともあると思います。これは実は、config.action() にもう一つ引数を渡すことで対応できます。

from miniconfig import PHASE1_CONFIG, PHASE2_CONFIG

def includeme(config):
    config.action(action0)
    condig.action(action1, order=PHASE2_CONFIG)
    condig.action(action2, order=PHASE1_CONFIG)

miniconfig では事前にデフォルトの副作用より前に実行したいものに関して、 PHASE1_CONFIG, PHASE2_CONFIG という設定値を用意しています。 そして、config.commit() が実行された際には一度このorder引数で渡された値によりソートされてから実行されます。 例えば上記のコードの通りに登録した場合には以下の様な順序で実行される事になります。

  1. action2 (またはその他 PHASE1_CONFIG を指定されていたaction)
  2. action1 (またはその他 PHASE2_CONFIG を指定されていたaction)
  3. action0