November 23, 2013

Контейнеры внедрения зависимостей для python

Начнем издалека - создание объекта инстанцированием класса плохо совместимо с идеями ООП. Они гласят, что код должен зависеть от интерфейсов, а не от реализаций. До тех пор пока на вход нашему коду приходят готовые объекты - все хорошо. Он будет с готовностью принимать любые типы, реализующие требуемый интерфейс, но как только мы начинаем создавать новые объекты ситуация меняется. Теперь код зависит от конкретного класса, что усложняет следующие задачи:

  • Изменение класса на другой, хоть и реализующий тот же интерфейс. Приходится вручную менять все точки инстанцирования, и, возможно, перекомпилировать код;
  • Выбор конкретного класса на основе внешних условий или точки инстанцирования;
  • Использование уже готового объекта - взятого из пула или какого то конкретного (синглетон);
  • Построение объекта с большим количеством зависимостей - приходиться передавать в точку конструирования все данные для построения множества взаимосвязанных объектов;
  • Не классическая проблема для ICC, но из той-же области:
Без подсветки синтаксиса
class A(object):
    def __init__(self, val):
        self.val = val

    def __add__(self, val):
        return A(self.val + val)

class B(A):
    pass

print B(1) + 1 # <__main__.A object at 0x18877d0>

А хотелось бы получить экземпляр В.

Все эти проблемы связаны общей причиной - код делает работу, которую он делать не должен - инстанцирование конкретных классов. На самом деле чаще всего нам не нужен фиксированный класс. Нам нужен класс, предоставляющий определенный интерфейс. Нужно отвязаться от явного указания класса и передать его создание стороннему коду. Фактически мы хотим "виртуализовать" инстанцирование.

В самом простом случае можно воспользоваться фабричной функцией(ФФ). Если же мы хотим конфигурировать поведение ФФ, или сохранять состояние между вызовами (синглетон, пул объектов, etc), то логично сделать ФФ методом класса, в экземпляре которого будут храниться настройки. Такой класс может быть синглетоном(если конфигурация глобальная), или передаваться образом по цепочке вызовов во все точки, где нужно инстанцирование. Этот класс как раз и называется Inversion of Control Container (ICC дальше).

Для его использования нужно заменить прямое инстанцирование классов на вызов метода ICC. Параметрами метода будут требуемый интерфейс, и, возможно, контекст вызова и часть параметров для конструктора (последнее применяется редко). ICC возвращает готовый экземпляр. Конкретный класс для инстанцирования и параметры конструктора настраиваются програмно или берутся из конфигурационного файла.

Типичный пример - создание виртуальной машины в libvirt. Основная функция API принимает xml строку, описывающую виртуальную машину. Эта строка чаще всего берется вызывающим кодом из внешнего источника, потому как в большинстве случаев ему не важны подробности конфигурации для работы с VM соответственно и код создания можно унифицировать, а строку с конфигурацией использовать как черный ящик.

ICC также можно рассматривать как шаблон проектирования, объединяющий и унифицирующий другие порождающие шаблоны - ФФ, синглетон, и прочее.

Java и C# имеет различные реализации ICC (java spring, dagger) которые используются очень широко. Для питона же они практически не применяются. Сначала я покажу как написать pythonic ICC, а потом рассмотрю почему он не нужен. Написание своего связанно с тем, что по уже готовые пишутся людьми только что пришедшими с Java/C# и не отличаются питонистичностью.

Итак что можно хотеть от идеального ICC? Во-первых оставить пользовательский код почти без изменений. Во-вторых поддерживать возможность возвращать при инстанцировании целевого класса экземпляры другого класса, или определенный объект или результат вызова некоторой функции.

Итак был такой код:

Без подсветки синтаксиса
class Bee(object):
    def __init__(self, x):
        pass

class Cee(object):
    def __init__(self, x):
        pass

assert isinstance(Bee(1), Bee)

Мы хотим иметь возможность не меняя код инстанцирования Bee выбирать что именно будет получаться - экземпляр Bee или Cee. С позиции duck typing классы Bee и Cee реализуют один и тот-же интерфейс и взаимозаменяемы, хоть мы это и не декларируем явным наследованием.

В принципе инстанцирование можно и не менять, но тогда его поведение будет не совсем очевидным. С первого взгляда кажется, что мы инстанцируем обычный класс Bee, а в итоге получаем экземпляр другого класса, который к классу Bee никакого отношения не имеет. Т.е. isinstance(Bee(), Bee) == False. Поэтому немного изменим пример. Bee и Cee будут наследовать общий интерфейс IBee и именно этот интерфейс мы и будем инстанцировать.

Без подсветки синтаксиса
class IBee(IOCInterface):
    def __init__(self, x):
        pass

class Bee(IBee):
    def __init__(self, x):
        print "Bee.__init__ called"

class Cee(IBee):
    def __init__(self, x):
        print "Cee.__init__ called"

IBee.register(Bee)
assert isinstance(IBee(1), Bee)

IBee.register(Cee)
assert isinstance(IBee(1), Cee)

Что бы это работало нужно перехватить конструирование объекта типа IBee и вернуть что-мы-там-хотим. Для этого вспоминаем, что конструирование объекта в python выражается следующим псевдокодом:

Без подсветки синтаксиса
# obj = Cls(x, y) ==>

obj = Cls.__new__(Cls, x, y)
if isinstance(obj, Cls):
    Cls.__init__(obj, x, y)

Т.е. Cls.__new__ возвращает пустой экземпляр типа Cls, Cls.__init__ наполняет его реальными данными. Очень похоже на operator new + конструктор в С++. Итак нам нужно перегрузить IBee.__new__ и возвращать из него наш объект.

Без подсветки синтаксиса
ioc = {}

class IOCInterface(object):
    def __new__(cls, *args, **kwargs):
        return ioc[cls](cls, *args, **kwargs)

    @classmethod
    def register(cls, impl):
        factory = lambda ccls, *args, **kwargs: \
            super(IOCInterface, ccls).__new__(impl, *args, **kwargs)
        cls.register_factory(factory)

    @classmethod
    def register_instance(cls, obj):
        cls.register_factory(lambda *args, **kwargs: obj)

    @classmethod
    def register_factory(cls, func):
        ioc[cls] = func

Немного пояснений. Класс IOCInterface будет базовым для всех интерфейсов. Переменная ioc будет хранить текущую конфигурацию - отображение интерфейса на фабричную функцию для этого интерфейса. Для простоты примера мы будем хранить конфигурацию в глобальной переменной. Перегруженный метод __new__ получает инстанцируемый класс первым параметром, а дальше идут параметры конструктора. Он берет зарегистрированную для этого класса фабричную функцию и создает новый объект с ее помощью. IOCInterface.register позволяет зарегистрировать класс для данного интерфейса. IOCInterface.register_instance - зарегистрировать синглетон. Для унификации они создают специальные фабричные функции.

Замечания:

  • Нельзя использовать cls.__new__ как фабричную функцию в IOCInterface.register, так как мы получим вечный цикл. Нужно "проскочить" IOCInterface в иерархии сcls;
  • Для классов с перегруженным __new__ нужно смотреть по ситуации;
  • Есть соблазн просто сохранять класс/синглетон в словарь и потом в __new__

делать что-то вида;

Без подсветки синтаксиса
def __new__(cls, *args, **kwargs):
    obj = ioc[cls]
    if isinstance(obj, type):
        return obj(cls, *args, **kwargs)
    elif type(obj, (types.FunctionType, types.LambdaType)):
        return obj(cls, *args, **kwargs)
    else:
        return obj

Делать этого не стоит, хотя бы потому что так мы не сможем зарегистрировать экземпляр класса с перегруженным __call__ или функцию как синглетон для типа

  • Теперь мы не может инстанцировать классы, которые прямо или опосредованно наследуют IOCInterface, без регистрации для них реализации. Т.е. Bee(1) вывалится с KeyError. В принципе это соответствует духу происходящего - мы запрещаем прямое инстанцирование классов. Разве что стоит перехватывать KeyError и генерировать понятное сообщение. Или изменить __new__ вот так
Без подсветки синтаксиса
def __new__(cls, *args, **kwargs):
    return ioc.get(cls, super(IOCInterface, cls).__new__)(cls, *args, **kwargs)

Тогда по-умолчанию Bee(1) будет создавать экземпляр Bee

  • В продолжение к предыдущему пункту - можно зарегистрировать фабричные функции и для IBee и для Bee;
  • У этого кода есть проблема с синглетонами. Если мы регистрируем для IBee синглетоном экземпляр IBee или любого производного от него класса, то при каждом вызове IBee() для этого класса будет заново вызываться конструктор. Нужно или отслеживать и игнорировать повторные вызовы конструктора, или сделать его пустым и вынести инициализацию в отдельный метод.

Но в питоне есть еще один способ перехватить конструирование нового экземпляра. Рассмотрим запись b = Bee(1). Что тут написано? Вызывается объект/функция Bee с параметром 1. Компилятор питона не имеет никакой семантической информации о программе (в отличии от компилятора, например, С++) - он владеет только синтаксической информацией. Для него len(a) и IBee(a) это просто вызов объекта. Т.е. питон превратит IBee(a) в IBee.__class__.__call__(IBee, 1). IBee.__class__ - это метакласс IBee. Где же происходит вызов IBee.__new__ и IBee.__init__? В type.__call__. Вызов __new__, а затем конструктора - это то, что записано в стандартном метаклассе Т.е. все выглядит примерно так:

Без подсветки синтаксиса
class type:
    # ...
    def __call__(self, *args, **kwargs):
        obj = Cls.__new__(Cls, x, y)
        if isinstance(obj, Cls):
            Cls.__init__(obj, x, y)
        return obj

Соответственно написав свой метакласс и перегрузив __call__ мы избавимся от проблемы с синглетоном.

Без подсветки синтаксиса
class IOCMeta(type):
    def __call__(self, *args, **kwargs):
        return ioc[self](self, *args, **kwargs)


class IOCInterface2(object):
    __metaclass__ = IOCMeta

    @classmethod
    def register(cls, impl):
        def factory(ccls, *args, **kwargs):
            return type.__call__(impl, *args, **kwargs)
        cls.register_factory(factory)

    @classmethod
    def register_instance(cls, obj):
        cls.register_factory(lambda *args, **kwargs: obj)

    @classmethod
    def register_factory(cls, func):
        ioc[cls] = func

Класс IOCInterface2 лишен проблемы с повторным вызовом __init__ для синглетона. Посмотрим как все получается, заодно прорекламирую всем замечательную замену для assert, assertEquals и др. - oktest:

Без подсветки синтаксиса
from oktest import ok

from python_ioc import IOCInterface2

# ...

IBee.register(Bee)
ok(IBee(1)).is_a(Bee)

IBee.register(Cee)
ok(IBee(1)).is_a(Cee)

IBee.register_instance(1)
ok(IBee(1)) == 1

print "All tests passed"
print "'Bee.__init__ called' message should appears once and only once"

IBee.register(Bee)
IBee.register_instance(IBee(1))
IBee(1)
IBee(1)

Наконец допишем поддержку контекста с помощью конструкции with (этот вариант поддерживает только регистрацию классов):

Без подсветки синтаксиса
@contextlib.contextmanager
def ioc_set(new_context):
    global ioc

    old_ioc = ioc.copy()

    for k, v in new_context.items():
        k.register(v)

    try:
        yield
    finally:
        ioc = old_ioc

Использование

Без подсветки синтаксиса
IBee.register(Bee)
ok(IBee(1)).is_a(Bee)

with ioc_set({IBee: Cee}):
    # until we exits with block IBee(xx) will gives an instance of Cee
    ok(IBee(1)).is_a(Cee)

ok(IBee(1)).is_a(Bee)

Использование контекста можно немного упростить, избежав {...} и вместо этого записав подменяемые классы в виде ioc_set(IBee=Cee, ....). Cделать так не совсем просто, поскольку питон передает IBee как строку "IBee", а не как объект IBee.Но можно подняться из ioc_set на один кадр стека вверх и посмотреть, что там лежит по имени "IBee":

Без подсветки синтаксиса
def ioc_set_hack(**kwargs):
    fr = sys._getframe(1)
    result_map = {}
    for name, obj in kwargs.items():
        if name in fr.f_locals:
            interface = fr.f_locals[name]
        else:
            interface = fr.f_globals[name]
        result_map[interface] = obj

    return ioc_context(result_map)

with ioc_set_hack(IBee=Cee):
    ok(IBee(1)).is_a(Cee)

Я бы не советовал использовать эту функцию, поскольку ее поведение совсем не очевидно. Вообще я против превращения питона в DSL, а то есть шанс получить на выходе читаемость, как местами у scala.

К полученным классам довольно легко прикрутить все возможности "серьезных" ICC: загрузку конфигураций из внешних файлов, поддержку многопоточности и прочее.

Итак ICC пишется в питоне очень просто - 1, 2, 3 и используется даже проще, чем в Java/C#, так почему же их нигде нет? Почему авторы этих фреймворков пишут, что они от них отрекаются?

А дело в том, что ICC, как шаблон проектирования, незаметно встроен в питон повсюду и встроенные возможности покрывают бОльшую часть случаев его применения. Ради оставшихся единичных примеров не стоит городить весь огород. YAGNI и KISS в чистом виде.

Принципиальная разница между Java и python состоит в том когда в конструкции инстанцирования происходит связывание объекта с именем. В Java - процессе компиляции. Если вы пишете obj = new SomeClass(), то компилятор совершенно точно знает, что это инстанцирование объекта и совершенно точно знает - какого. Без перекомпиляции эта строка никогда не будет значить ничего другого. В python, как я уже писал выше, запись obj = SomeClass() означает следующее - найди в пространстве имен ХХХ (globals() или locals()) объект по имени 'SomeClass' и вызови его. globals() выступает ICC, который мы конфигурируем во время исполнения программы:

Без подсветки синтаксиса
# same as SomeClass = type('SomeClass', (object,), {})
class SomeClass(object):pass

SomeOtherClass = SomeClass

Конструкция class в питоне всего лишь синтаксический сахар для объявления переменной, хранящей объект типа. Разница с приведенным выше ICC состоит в том, что в данном случае мы запрашиваем класс по имени внутри контейнера, а не по интерфейсу.

Точно так же каждый модуль в питоне является ICC контейнером, обращение к которому происходит взятием атрибута. mod.name это и есть разрешение реализации, привязанной к имени.

Вместо ручного присваивания можно использовать подменяющие функции, например patch. Которая работает подобно ioc_set (но умеет гораздо больше):

Без подсветки синтаксиса
class Class(object):
    pass

with patch('__main__.Class', list):
    ok(Class()).is_a(list)

Однако модель подмены по имени имеет одну слабость:

Без подсветки синтаксиса
# some_module.py
class Class(object):
    pass

# main.py
import some_module
from some_module import Class

def print_tp():
    print some_module.Class()
    print Class()

# unit_test.py
import main

with patch('some_module.Class', list):
    print_tp() # only some_module.Class() patched

with patch('some_module.Class', list):
    with patch('main.Class', list):
        print_tp() # both some_module.Class() and Class() patched

Проблема в том, что один объект может иметь множество имен и для корректной работы нужно или сделать подмену раньше, чем другие модули создадут новый имена или заменить их все. Впрочем это не большая расплата за возможность оставить код чистым от ICC и за возможность работать с классами и модулями, которые про ICC не знают. Так же проблема смягчается тем, что в основном использование ICC - это юниттесты и в них простительно некоторое количество таких ручных хаков.

Проблема с инстанцированием корректного класса решается совсем просто:

Без подсветки синтаксиса
class A(object):
    def __init__(self, val):
        self.val = val

    def __add__(self, val):
        #return A(self.val + val)
        return self.__class__(self.val + val)

class B(A):
    pass

print B(1) + 1 # <__main__.В object at 0x18877d0>

Относительно других причин использовать ICC: В питоне практически никогда не нужно создавать сложные иерархии объектов. Такое API не питонистично и врядли будет пользоваться популярностью. Три модуля, которые приходят на ум - urllib2, logging и unittest. Два последние "слизаны" с явовских модулей - log4j и junit соответственно. Для logging фактически есть встроенный ICC - построение дерева логеров по конфигурационному файлу, вместо unittest все использовали nosetests (в 2.7 его починили и теперь для юниттеста не нужно инстанцировать половину вселенной). А с urllib2 почти все пересели на requests.

Без подсветки синтаксиса
class A(object):
    def __init__(self, val):
        self.val = val

    def __add__(self, val):
        return A(self.val + val)

class B(A):
    pass

print B(1) + 1 # <__main__.A object at 0x18877d0>

Синглетон почти не нужен, поскольку нет проблем с порядком инициализации глобальных переменных, даже в многопсточной программе.

Наконец в питоне вызов функции не отличим от инстанцирования класса. Что-бы получить эту возможность в скале даже нагородили уродливый (и нарушающий DRY) огород из companion object. Так что если что-то у вас сегодня - класс, а завтра - фабричная функция, это нормально. Почти никто не заметит.

Ссылки:
          en.wikipedia.org/wiki/Don%27t_repeat_yourself
          gbracha.blogspot.com/2007/06/constructors-considered-harmful.html
          daily-scala.blogspot.com/2009/09/companion-object.html
          logging.apache.org/log4j/1.2/manual.html
          ru.wikipedia.org/wiki/JUnit
          www.python-requests.org/en/latest
          www.voidspace.org.uk/python/mock/patch.html
          lostechies.com/ryansvihla/2009/11/16/i-recant-my-ioc-ioc-containers-in-dynamic-languages-are-silly
          ru.wikipedia.org/wiki/YAGNI
          ru.wikipedia.org/wiki/KISS_%28%D0%BF%D1%80%D0%B8%D0%BD%D1%86%D0%B8%D0%BF%29
          springpython.webfactional.com
          code.google.com/p/pinsor
          github.com/rande/python-simple-ioc
          www.kuwata-lab.com/oktest/oktest-py_users-guide.html
          en.wikipedia.org/wiki/Factory_method_pattern
          koder-ua.blogspot.com/2011/12/libvirt-co-1.html
          ru.wikipedia.org/wiki/Spring_Framework#Inversion_of_Control
          github.com/square/dagger

Исходники этого и других постов со скриптами лежат тут - github.com/koder-ua. При использовании их, пожалуйста, ссылайтесь на koder-ua.blogspot.com.

No comments: