読者です 読者をやめる 読者になる 読者になる

外部のリソースを利用する機能の記述の仕方の話

外部のリソースを利用する機能の記述の仕方の話

(当初はテストの時の話をするつもりで書いていたが、特にテストに絞ったわけでもなくとりとめもなく書いてしまった)

はじめに

外部のリソースを利用する機能を作成する際のコードの書き方について考えたりしていた。断っておくと、ここでのテストという言葉はユニットテストを指している。一般的に、ユニットテストはその機能単体の振る舞いを検査するためのものなので、外部のリソースとの通信が発生しうる部分については取り除いて考えたい。実際にテストを書く時にはスタブなどを使うのだけれど、その時の振る舞いが云々かんぬんみたいな話しはするつもりはない。もっと単純な話。

外部リソースの例

外部リソースの例として外部サービスのAPI呼び出しなどが考えられる。おそらくこれがもっとも単純なものなのではないかと思う。とりあえず以下のような情報は必要になるのではないかと思う。

  • endpoint -- http://example.com/foo/api みたいなもの
  • access key -- 利用者を同定するための何らかの識別子 (e.g. username)
  • secret key -- 利用者の同定に利用するための情報 (e.g. password)

細かいことは考えるつもりはなくて、外部リソースのアクセスには何らかのアクセス情報が必要だということと、アクセス情報に不備があった場合に対象となる外部リソースの利用に失敗するということ、また逆に意図せず正しいアクセス情報を揃えてしまった場合に外部リソースの利用に成功してしまうということ。

最もシンプルな外部アクセスの利用

まず、最もシンプルな外部アクセスの利用を考えてみる。それはコードに全てを直接記述してしまう方法で以下の様になっている。

from xxxlib import get_client


def do_something(path, params):
    api_key = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
    secret_key = "yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy"
    endpoint = "<my endpoint>"

    client = get_client(api_key, secret_key, endpoint)
    return client.call(path, params)

環境による分岐(settings object)

普通に使う分にはこれで問題ない個人用途のスクリプトなどはこんなようなものかもしれない。ただしこれではテスト以前に困る場合に遭遇する。それは環境によって異なる設定を利用したい場合。通常settings objectあるいはcontext objectのようなものを用意してそちらに設定ファイルを書くことになる(まじめに解説すると、アプリケーション固有の静的な設定を持つものをsettings object。ある状態やロジックの状況に応じた動的な設定情報を管理するオブジェクトをcontext objectと呼んでいる)。このsettings objectをsingletonにする場合もあるし明示的に渡す場合もある。

何はともあれsettings objectを仲介することで異なる設定を利用することができるようになる。

# singletonなsettings object
from xxxlib import get_client
from myapp import settings


def do_something(path, params):
    endpoint = "<my endpoint>"
    client = get_client(settings.api_key, settings.secret_key, endpoint)
    return client.call(path, params)

おそらく、singletonになっているようなコードは何らかのフレームワークを利用するコードであることが多いかもしれない。そしてそれらのフレームワークでは設定値を上書きする特別な機構が用意されている。

例えば以下の様な形でsettings objectを上書きしたりする機構が存在するかもしれない

from yourframework.test import TestCase
from yourframework.testutil import override_setting

class Tests(TestCase):
    @override_settings(api_key="zzzz", secret_key="yyyy")
    def test_xxx(self):
        ...

一方、明示的に設定値を渡す場合は、面倒ではあるものの、何らかの特別な機構を用いずとも環境による分岐を達成できる。

# settingsを明示的に渡す例
from xxxlib import get_client
from myapp import settings as default_settings


def do_something(path, params, settings=default_settings):
    endpoint = "<my endpoint>"
    client = get_client(settings.api_key, settings.secret_key, endpoint)
    return client.call(path, params)

これらのsettings objectに設定される値は外部に漏らしてはいけないものが含まれるかもしれない。ところでこれらの値の設定の仕方は状況によって変わる。例えば、何らかのアプリを作成しそれを配布するという状況ではコードと設定値を分離する必要があるだろうし(例えば設定ファイルを別途読み込む形式にし、公開するリポジトリ上にはそれらの設定値を含んだファイルをコミットしない)。完全にクローズドなwebサービスのプライベートリポジトリの中に入れておくコードであれば直接コード上に含めても良いかもしれない。

利便性の範囲での変更

まだテストの話に移る気はない。とりあえず利便性の範囲においての変更を考えてみる。ある外部リソースへのアクセスの用途が1つであるとは限らない。普通は複数あるのではないかと思う。この時、毎回アクセス用の情報を設定するコードを書くのは面倒くさい。なので大抵はある特定の外部リソースへのアクセスを行うための設定を行う記述と実際の利用の記述を分ける事が多い。

from xxxlib import get_client as get_another_service_client
from myapp import settings as default_settings


def get_client(settings=default_settings):
    # 上の例に合わせると、endointを引数で受け取るようになるかもしれない
    # 渡す値が一定だったりする場合には固定値として渡してしまったりsettingsに含めたりするかもしれない
    return get_another_service_client(settings.api_key, settings.secret_key, settings.endpoint)


def do_something(client, path, params):
    return client.call(path, params)

# 使うときは
# do_something(get_client(), path, params)

また、すごく汎用的な操作であったり、利用例が単純なものばかりである場合の時にはこれらをオブジェクトにまとめることがあるかもしれない(とは言え、あくまでclient用の設定・構築と操作自体は一緒くたにせず分けて書いた方が良いとは思う)。

class AnotherService:
    def __init__(self, client):
        self.client = client

    def do_something(self, path, params):
        return self.client.call(path, params)

    def do_something2(self, path, params):
        return self.client.call2(path, params)

    def do_something3(self, path, params):
        return self.client.call3(path, params)
        
# 使うときは
# AnotherService(get_client()).do_something(path, params)

ただ、特に状態を細かく管理する必要がある場合だったり、何らかの指定された順序で処理を呼び出す必要があるなどであったりしないのであれば、都度clientオブジェクトを渡す関数で十分な気はする。

テストの話

ここからはテストの話。最初の全てをコードに書く方法は論外だとして、個々の機能のテストに関してはclientの設定とclientを利用した操作自体が分離されていればテストは可能になる。

また以下のような今まで作ってきた機能を実際に呼び出すアプリケーションコード自体のテストもmockなどを使えばテストは可能になっている。

# application code
from myapp import another
from myapp import another2
from myapp import settings

def do_action(context):
    client = another.get_client(settings)
    result = client.do_something()

    # 異なる外部リソースを使った処理もあるかもしれない
    client_b = another2.get_client(settings)
    client_b.do_something(context, result)

    # 例えばapi responseの値をdbに保存など何かの処理
    # このコード中に現れないけれどすごい雑多な処理が書かれているイメージ
    if context.xxx:
        do_update_result(context, result)
    else:
        notify_error(context, result)

もし、上のように決め打ちで記述されているならmockなどを使ってpatchすれば良いし。操作に利用するオブジェクトを明示的に受け渡すインターフェイスになっているのならば引数を上手く調整してあげれば良い。これくらいになってくると実際の操作に必要な引数と操作を提供するためのオブジェクトが混在して混乱しそうになるのでオブジェクトになっていれば嬉しい。明示的に受け渡すインターフェイスなら以下の様な感じになっているのかもしれない。

# 明示的に操作用のオブジェクトを受け渡す例
class XXXAction:
    def __init__(self, client, client_b):
        self.client = client
        self.client_b = client_b

    def __call__(self, context):
        result = self.client.do_something()

        # 異なる外部リソースを使った処理もあるかもしれない
        self.client_b.do_something(context, result)

        # 例えばapi responseの値をdbに保存など何かの処理
        # このコード中に現れないけれどすごい雑多な処理が書かれているイメージ
        if context.xxx:
            self.do_update_result(context, result)
        else:
            notify_error(context, result)


# より上の階層では以下の様な形で利用される
# client = another.get_client(settings)
# client_b = another2.get_client(settings)
# action = XXXAction(client, client_b)
# action(context)

とは言え、明示的に必要なオブジェクトを受け渡す設計を上手く行うのは難しいので、大抵の場合は直接色々記述されるひとつ前のような記述であることが多い。そして依存した部分の変更はmockなどで行われることが多いかもしれない。

import unittest
from unittest import mock


class Tests(unittest.TestCase):
    def _callFUT(self, *args, **kwargs):
        from myapp.xxx import do_action
        return do_action(*args, **kwargs)

    def test_xxx(self):
        context = {"name": "this is dummy context"}
        with mock.patch("myapp.another.get_client", return_value=dummy_another()) as m1:
            with mock.patch("myapp.another2.get_client", return_value=dummy_another2()) as m2:
                self._callFUT(context)

また、明示的に渡す場合でも、以下のような、clientが直接渡されない場合に何らかのdefault実装を用いてclientを取得するコードを利便性のために書くことも多いかもしれない。テストの時にはclientにあたる部分にfake objectなどを渡すということになる。

def yyyy_action(context, client=None):
    client = client or get_client()
    result = client.do_something(context)
    # その他いろいろな処理

実際アプリケーションコード上からは煩わしい記述がなくなるので便利。

また、不要な操作を取り除くために依存する操作用のオブジェクトの生成をlazy propertyにするということもあるかもしれない

テスト時の意図しない外部リソースの利用を防ぎたい

テストの書き方などはこの際脇に置いておいて、個人的に気になったのはここでのmockや設定値の記述が誤りになってしまった場合に意図せず外部リソースを利用してしまうということになったりすると困るのではということだった。

これに関してどうするのが良いのかな〜というのをあれこれ考えていたのだった。端的に言えば望まない外部リソースの利用を制限したいというわけなので、外部リソースが常に失敗するという状況を作れれば良い。

無効値の設定

一番単純な例は設定値として無効な値を渡しておくことかもしれない。例えばテスト時とアプリケーション実行時には異なる設定ファイルを利用することが出来るようにして、テスト用の設定ファイルには無効な値を設定しておく(あるいは設定を意図的に不足させる)。

## application code用

XXX_ACCESS_KEY = "xxxxxxxxxxxxx"
XXX_SECRET_KEY = "yyyyyyyyyyyyy"
XXX_ENDPOINT = "http://xxxx.xxx/api"

テスト用には以下の様な設定を使う。

## application code用

XXX_ACCESS_KEY = None
XXX_SECRET_KEY = None
XXX_ENDPOINT = None

そしてテスト際に何らかの方法でテスト用の設定値を使えるようにする。

# テストの実行
python mytest --settings=foo.bar.test
# アプリケーションの実行
python apprun --settings=foo.bar.default  # settings無しでもdefaultが使われるなど

(設定ファイルを分けて実行する機構をない場合には、テスト時の最初のfixtureで無効な設定に上書きするということでもしばらくは生きていけるかもしれない)

無効な値が設定されるのだから、意図せず外部のリソースにアクセスしてしまった場合にはエラーになる。この場合には、実際に外部リソースに繋ぎにいった上でのエラーになるか、設定値の不足によりKeyErrorやAttributeErrorが発生しコードの実行が止まるという形になるかもしれない。

とは言え、無効な設定値やテストに必要な設定の記述というのはアプリケーションコードが複雑になってくると意外と管理が面倒になってくる(アプリケーションコードに含まれる事前のチェックなどを上手くすり抜けたりするような設定が必要になってくるだったり。あるいは、実際の処理には不要なのだけれどテストしたい処理まで到達するために必要な設定などが存在してくるだったり)。

ここの部分もう少しスマートな方法があるのではないかと思う。

意図した外部リソースの代わりになるような処理を利用したい

管理にわりとコストの掛かるミドルウェアが必要な処理だったり、そもそも本番用しかアカウントが存在しないサービスだったりだとか、直接その外部リソース自体を利用したくはないという状況もあったりする。そのような場合に、何らかの迂回した処理を書きたいということがしばしばある。

フラグによる分岐

例えば、何らかのdummy objectを返すという実装をすることがある。

from myapp import settings as default_settings
from xxxlib import get_client as get_another_service_client


def get_client(settings=default_settings):
    if settings.XXX_USE_DUMMY:
        return get_dummy_client(settings)
    return get_another_service_client(settings.api_key, settings.secret_key, settings.endpoint)

このとき、以前にclientの設定・構築の処理とclientの利用の処理を分けていたことが活きてくる。単にclientの生成時にflagを見て、必要なら、外部リソースを利用した処理を迂回した処理を記述したdummy clientを返すようにすれば良い。

実装そのものの位置を指定

何らかのライブラリとして提供する場合には、この種のdispatch部分をユーザーに書いてもらうというのは難しいのでfactoryを指定するような形になるかもしれない。

from xxxlib import get_another_service_client
from importlib import import_module


def get_client():
    # 例えば "foo.bar:xxx" をfoo.bar moduleのxxxを取り出すという記法にする
    # この部分は通常何かのユーティリティとして提供されている
    # 場合によってはcacheされるかもしれない
    module, name = settings.XXX_CLIENT_FACTORY.split(":", -1)
    factory = getattr(import_module(module), name)
    return factory()

def get_actual_client():
    return get_another_service_client(settings.api_key, settings.secret_key, settings.endpoint)
    
def get_dummy_client():
    return _get_dummy_client()

この時、設定ファイルは以下の様になる。

# application用
XXX_CLIENT_FACTORY = "myapp.another:get_actual_client"
# test用
XXX_CLIENT_FACTORY = "myapp.another:get_dummy_client"

個人的にはフラグによる分岐はあまり好きではない。

とりとめもなくなってきたけれど。思えば遠くまで来たものだみたいなことを思ったりした。