Сообщить об ошибке.

Шаблон проектирования Factory и его реализация в Python

Содержание:


Что такое шаблон проектирования "Фабрика"?

Шаблон проектирования "Фабрика", используется для создания конкретных реализаций общего интерфейса. Он отделяет процесс создания объекта от кода, который зависит от интерфейса объекта.

Например, приложению требуется объект с определенным интерфейсом для выполнения своих задач. Конкретная реализация интерфейса определяется некоторым параметром.

Вместо использования сложной условной структуры if/elif/else для определения конкретной реализации приложение делегирует это решение отдельному компоненту, который создает конкретный объект. При таком подходе код приложения упрощается, что делает его более многоразовым и простым в обслуживании.

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

# test.py
import json
import xml.etree.ElementTree as et

class Song:
    def __init__(self, song_id, title, artist):
        self.song_id = song_id
        self.title = title
        self.artist = artist

class SongSerializer:
    def serialize(self, song, format):
        if format == 'JSON':
            song_info = {
                'id': song.song_id,
                'title': song.title,
                'artist': song.artist
            }
            return json.dumps(song_info)
        elif format == 'XML':
            song_info = et.Element('song', attrib={'id': song.song_id})
            title = et.SubElement(song_info, 'title')
            title.text = song.title
            artist = et.SubElement(song_info, 'artist')
            artist.text = song.artist
            return et.tostring(song_info, encoding='unicode')
        else:
            raise ValueError(format)

В приведенном примере есть базовый класс Song для представления песни и класс SongSerializer, который может преобразовывать объект песни в его строковое представление в соответствии со значением аргумента format.

Метод SongSerializer.serialize() поддерживает два формата: JSON и XML, при использовании любого другого формата возникает исключение ValueError.

Используем интерактивный режим Python, для просмотра работы кода:

# $ python3 -i test.py
>>> song = Song('1', 'Water of Love', 'Dire Straits')
>>> serializer = SongSerializer()
>>> serializer.serialize(song, 'JSON')
# '{"id": "1", "title": "Water of Love", "artist": "Dire Straits"}'
>>> serializer.serialize(song, 'XML')
# '<song id="1"><title>Water of Love</title><artist>Dire Straits</artist></song>'
>>> serializer.serialize(song, 'YAML')
# Traceback (most recent call last):
#   File "<stdin>", line 1, in <module>
#   File "./test.py", line 30, in serialize
#     raise ValueError(format)
# ValueError: YAML

Создается объекты песни и сериализатора. Сериализатор преобразует песню в ее строковое представление с помощью метода SongSerializer.serialize(). Метод принимает объект песни в качестве параметра, а также строковое значение, представляющее нужный формат. Последний вызов использует 'YAML' в качестве формата, который не поддерживается сериализатором, поэтому возникает исключение ValueError.

Этот пример короткий и упрощенный, но все же в нем много сложностей. В коде существует три логических пути развития событий в зависимости от значения аргумента format и это может показаться не таким уж важным, но приведенный выше пример довольно сложно поддерживать.

Проблемы со сложным ветвлением кода.

Приведенный выше пример демонстрирует все проблемы, которые можно встретить в сложном логическом коде при использовании операторов ветвления, применяемых для изменения поведения приложения. Использование условных структур if/elif/else в совокупности с большими блоками кода в каждом из условий усложняет чтение, понимание и сопровождение кода.

Приведенный выше код может показаться несложным для чтения и понимания, тем не менее, его трудно поддерживать, т.к. он берет на себя слишком много задач. "Принцип единой ответственности" гласит, что модуль, класс или даже метод должны иметь единую четко определенную ответственность. Он должен делать только одну вещь и иметь только одну причину для изменения.

При внесении изменений в метод SongSerializer.serialize() увеличивается риск появления новых ошибок или нарушения существующей функциональности. Рассмотрим все ситуации, которые могут потребовать модификации:

  1. Метод необходимо будет менять при введении нового формата (например YAML) для реализации сериализации.
  2. При изменении объекта Song. Добавление или удаление свойств класса Song потребует изменения реализации, чтобы приспособиться к новой структуре.
  3. Когда для формата изменяется строковое представление (JSON в JSON API), то метод SongSerializer.serialize() тоже должен измениться, т.к. преобразование в JSON жестко закодировано в реализации метода.

Идеальная ситуация, это когда при изменений требований, можно эти требования реализовать без изменения метода SongSerializer.serialize().

Ищем общий интерфейс.

Первый шаг, когда в приложении есть сложное ветвление кода - это определить общую цель каждого из путей выполнения.

Код, использующий if/elif/else, обычно имеет общую цель, которая реализуется по-разному в каждом логическом пути. Приведенный выше код преобразует объект песни в его строковое представление, используя разные форматы в каждом логическом пути.

Исходя из цели, необходимо найти общий интерфейс, которым можно заменить каждый из путей. В примере выше требуется интерфейс, который принимает объект Song и возвращает строку.

Когда есть общий интерфейс, то далее необходимо предоставить отдельные реализации для каждого логического пути. В примере выше предоставлена реализация для сериализации в JSON и еще одну для XML.

Затем предоставляется отдельный компонент, который определяет конкретную реализацию для использования на основе указанного формата. Этот компонент оценивает значение формата и возвращает конкретную реализацию, идентифицированную его значением.

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

Рефакторинг кода в интерфейс.

Интерфейс - это объект или функция, которая принимает объект Song и возвращает строковое представление.

На первом шаге, преобразуем один из логических путей в интерфейс. То есть добавим новый метод SongSerializer._serialize_to_json() и переместим в него код сериализации JSON. Затем изменим код для блока if format == 'JSON':, так, чтобы он вызывал новый метод:

# пример рефакторинга
class SongSerializer:
    def serialize(self, song, format):
        if format == 'JSON':
            return self._serialize_to_json(song)
        # Остальная часть кода остается прежней

    def _serialize_to_json(self, song):
        payload = {
            'id': song.song_id,
            'title': song.title,
            'artist': song.artist
        }
        return json.dumps(payload)

После внесения этого изменения - поведение не изменилось. Теперь сделаем то же самое для параметра XML, вводя новый метод SongSerializer._serialize_to_xml(), перемещая в него реализацию и изменяя вызов для блока elif format == 'XML':.

В следующем примере показан рефакторинг кода:

# изменяем класс в test.py
class SongSerializer:
    def serialize(self, song, format):
        """Интерфейс - принимает объект `Song` и 
        возвращает строковое представление
        """
        if format == 'JSON':
            return self._serialize_to_json(song)
        elif format == 'XML':
            return self._serialize_to_xml(song)
        else:
            raise ValueError(format)

    def _serialize_to_json(self, song):
        payload = {
            'id': song.song_id,
            'title': song.title,
            'artist': song.artist
        }
        return json.dumps(payload)

    def _serialize_to_xml(self, song):
        song_element = et.Element('song', attrib={'id': song.song_id})
        title = et.SubElement(song_element, 'title')
        title.text = song.title
        artist = et.SubElement(song_element, 'artist')
        artist.text = song.artist
        return et.tostring(song_element, encoding='unicode')

Новая версия легче читается и понимается, но ее все еще можно улучшить с помощью базовой реализации "Фабрики".

Базовая реализация шаблона "Factory".

Основная идея шаблона проектирования "Factory" состоит в том, чтобы предоставить отдельный компонент, ответственный за принятие решения о том, какую конкретную реализацию следует использовать на основе определенного параметра. В рассматриваемом примере, этим параметром является формат.

Чтобы завершить реализацию шаблона "Factory", добавим новый метод SongSerializer._get_serializer(), который принимает нужный формат. Этот метод будет оценивать значение формата и возвращать соответствующую функцию сериализации:

class SongSerializer:
    def _get_serializer(self, format):
        if format == 'JSON':
            return self._serialize_to_json
        elif format == 'XML':
            return self._serialize_to_xml
        else:
            raise ValueError(format)

Примечание. Метод SongSerializer._get_serializer() не вызывает конкретную реализацию, а просто возвращает сам объект метода.

Теперь, для завершения реализации изменим метод SongSerializer.serialize(). В следующем примере показан полный код:

# изменяем класс в test.py
class SongSerializer:
    def serialize(self, song, format):
        # Так как метод `_get_serializer()` возвращает объекты 
        # методов, то переменной `serializer` присваивается один  
        # их этих объектов, в зависимости от аргумента `format`
        serializer = self._get_serializer(format)
        # здесь происходит вызов объекта метода
        # с передачей в него аргумента `song`
        return serializer(song)

    def _get_serializer(self, format):
        if format == 'JSON':
            # возвращает объект метода `_serialize_to_json()`
            return self._serialize_to_json
        elif format == 'XML':
            # возвращает объект метода `_serialize_to_xml()`
            return self._serialize_to_xml
        else:
            raise ValueError(format)

    def _serialize_to_json(self, song):
        payload = {
            'id': song.song_id,
            'title': song.title,
            'artist': song.artist
        }
        return json.dumps(payload)

    def _serialize_to_xml(self, song):
        song_element = et.Element('song', attrib={'id': song.song_id})
        title = et.SubElement(song_element, 'title')
        title.text = song.title
        artist = et.SubElement(song_element, 'artist')
        artist.text = song.artist
        return et.tostring(song_element, encoding='unicode')

Окончательная реализация показывает различные компоненты шаблона "Factory". Метод SongSerializer.serializer() - это код приложения, который зависит от интерфейса для выполнения своей задачи. Он называется клиентским компонентом шаблона. Интерфейс, который был определен - называется компонентом продукта. В нашем случае продукт - это функция, которая принимает Song и возвращает строковое представление.

Методы SongSerializer._serialize_to_json() и SongSerializer._serialize_to_xml() являются конкретными реализациями продукта. Наконец, метод SongSerializer._get_serializer() является компонентом-создателем. Создатель решает, какую конкретную реализацию использовать.

"Шаблон проектирования Factory" может быть реализован по другому. Обычно шаблон Factory, не использует аргумент self ни в одном из добавленных методов. Это хороший признак того, что методы могут быть вынесены за пределы класса SongSerializer, и, например, быть внешними функциями:

Примечание. Метод SongSerializer.serialize() в не использует аргумент self.

# изменяем класс в test.py
class SongSerializer:
    def serialize(self, song, format):
        serializer = get_serializer(format)
        return serializer(song)

# выносим методы в функции
def get_serializer(format):
    if format == 'JSON':
        return _serialize_to_json
    elif format == 'XML':
        return _serialize_to_xml
    else:
        raise ValueError(format)

def _serialize_to_json(song):
    payload = {
        'id': song.song_id,
        'title': song.title,
        'artist': song.artist
    }
    return json.dumps(payload)

def _serialize_to_xml(song):
    song_element = et.Element('song', attrib={'id': song.song_id})
    title = et.SubElement(song_element, 'title')
    title.text = song.title
    artist = et.SubElement(song_element, 'artist')
    artist.text = song.artist
    return et.tostring(song_element, encoding='unicode')

Механика "шаблона проектирования Factory" всегда одинакова. Клиент SongSerializer.serialize() зависит от конкретной реализации интерфейса. Он запрашивает реализацию у компонента-создателя get_serializer(), используя какой-то идентификатор format.

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

Убедимся, что поведение приложения не изменилось:

# $ python3 -i test.py
>>> song = Song('1', 'Water of Love', 'Dire Straits')
>>> serializer = SongSerializer()
>>> serializer.serialize(song, 'JSON')
# '{"id": "1", "title": "Water of Love", "artist": "Dire Straits"}'
>>> serializer.serialize(song, 'XML')
# '<song id="1"><title>Water of Love</title><artist>Dire Straits</artist></song>'
>>> serializer.serialize(song, 'YAML')
# Traceback (most recent call last):
#   File "<stdin>", line 1, in <module>
#   File "./test.py", line 13, in serialize
#     serializer = get_serializer(format)
#   File "./test.py", line 23, in get_serializer
#     raise ValueError(format)
# ValueError: YAML

Признаки, где нужно использовать "Фабрику".

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

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

  • Замена сложного логического кода. Сложные логические структуры в формате if/elif/else трудно поддерживать, поскольку по мере изменения, требуются новые логические пути.

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

    Параметр, оцениваемый в условиях, становится параметром для идентификации конкретной реализации. Приведенный выше пример реализуют эту ситуацию.

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

    Записи представляют сотрудников с разными ролями или типами: менеджеры, офисные служащие, продавцы и т. д. Приложение может сохранить идентификатор, представляющий тип сотрудника в записи, а затем использовать "Фабрику" для создания каждого конкретного объекта Employee из остальной информации в записи.

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

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

  • Объединение схожих функций в общем интерфейсе. Следуя примеру обработки изображения, приложению необходимо применить фильтр к изображению. Конкретный фильтр может быть выбран пользовательским вводом, и "Фабрика" может предоставить конкретную реализацию фильтра.

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

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

Шаблон "Фабрика объектов".

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

В идеале проект должен поддерживать добавление сериализации для новых объектов путем реализации новых классов без необходимости внесения изменений в существующую реализацию. Цель разбираемого приложения, сериализация объектов в несколько форматов, таких как JSON и XML, поэтому кажется естественным определить интерфейс сериализатора, который может иметь несколько реализаций, по одной для каждого формата.

Реализация интерфейса может выглядеть примерно так:

# создаем `serializers.py`
import json
import xml.etree.ElementTree as et

class JsonSerializer:
    def __init__(self):
        self._current_object = None

    def start_object(self, object_name, object_id):
        self._current_object = {
            'id': object_id
        }

    def add_property(self, name, value):
        self._current_object[name] = value

    def to_str(self):
        return json.dumps(self._current_object)


class XmlSerializer:
    def __init__(self):
        self._element = None

    def start_object(self, object_name, object_id):
        self._element = et.Element(object_name, attrib={'id': object_id})

    def add_property(self, name, value):
        prop = et.SubElement(self._element, name)
        prop.text = value

    def to_str(self):
        return et.tostring(self._element, encoding='unicode')

Примечание. В приведенном примере реализован не полный интерфейс Serializer.

Интерфейс Serializer - это абстрактное понятие из-за динамической природы языка Python. Статические языки, такие как Java или C#, требуют явного определения интерфейсов. В Python говорят, что любой объект, предоставляющий нужные методы или функции, реализует интерфейс. В примере интерфейс Serializer определяется как объект, реализующий следующие методы или функции:

  • .start_object(object_name, object_id)
  • .add_property(name, value)
  • .to_str()

Этот интерфейс реализуется конкретными классами JsonSerializer и XmlSerializer.

В исходном примере использовался класс SongSerializer. Для нового приложения нужно реализовывать нечто более общее, например ObjectSerializer:

# добавляем serializers.py
class ObjectSerializer:
    def serialize(self, serializable, format):
        serializer = factory.get_serializer(format)
        serializable.serialize(serializer)
        return serializer.to_str()

Реализация ObjectSerializer полностью универсальна, и в качестве параметров в ней упоминаются только сериализуемый объект serializable и формат format.

Для идентификации конкретной реализации сериализатора используется format и разрешается объектом фабрики. Аргумент serializable относится к другому абстрактному интерфейсу, который должен быть реализован для любого типа объекта, который необходимо сериализовать.

Конкретная реализация сериализуемого интерфейса в классе Song:

# создаем songs.py
class Song:
    def __init__(self, song_id, title, artist):
        self.song_id = song_id
        self.title = title
        self.artist = artist

    def serialize(self, serializer):
        # serializer - это объект сериализатора
        serializer.start_object('song', self.song_id)
        serializer.add_property('title', self.title)
        serializer.add_property('artist', self.artist)

Класс Song реализует интерфейс serializable, предоставляя метод Song.serialize(serializer). В этом методе, класс Song, использует объект сериализатора serializer для записи в него собственной информации, не зная формата.

Класс Song даже не знает, что целью является преобразование данных в строку. Это важно, т.к. в дальнейшем можно использовать этот интерфейс для предоставления другого типа сериализатора, который при необходимости преобразует информацию о песне в совершенно другое представление. Например, приложению в будущем может потребоваться преобразовать объект Song в двоичный формат.

В завершенном виде "шаблон Factory" и предоставляет "создателя", которым является фабрика переменных в методе ObjectSerializer.serialize().

Шаблона Factory как фабрика объектов.

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

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

Можно увидеть базовый интерфейс SerializerFactory в реализации ObjectSerializer.serialize(). Метод использует factory.get_serializer(format) для извлечения сериализатора из фабрики объектов.

Теперь реализуем SerializerFactory для соответствия этому интерфейсу:

# добавляем serializers.py
class SerializerFactory:
    """Фабрика объектов."""
    def get_serializer(self, format):
        if format == 'JSON':
            return JsonSerializer()
        elif format == 'XML':
            return XmlSerializer()
        else:
            raise ValueError(format)

factory = SerializerFactory()

Текущая реализация SerializerFactory.get_serializer() такая же, как и в исходном примере. Метод оценивает значение формата и возвращает конкретную реализацию. Это относительно простое решение, позволяющее выполнять проверку всех компонентов Factory.

Смотри как он работает:

>>> import songs, serializers
>>> song = songs.Song('1', 'Water of Love', 'Dire Straits')
>>> serializer = serializers.ObjectSerializer()
>>> serializer.serialize(song, 'JSON')
# '{"id": "1", "title": "Water of Love", "artist": "Dire Straits"}'
>>> serializer.serialize(song, 'XML')
# '<song id="1"><title>Water of Love</title><artist>Dire Straits</artist></song>'
>>> serializer.serialize(song, 'YAML')
# Traceback (most recent call last):
#   File "<stdin>", line 1, in <module>
#   File "./serializers.py", line 39, in serialize
#     serializer = factory.get_serializer(format)
#   File "./serializers.py", line 52, in get_serializer
#     raise ValueError(format)
# ValueError: YAML

Новый дизайн шаблона Factory позволяет приложению вводить новые функции, добавляя новые классы, а не изменяя существующие. Можно сериализовать другие объекты, реализуя на них интерфейс Serializable. Также можно поддерживать новые форматы, реализуя интерфейс Serializer в другом классе.

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

Поддержка дополнительных форматов.

Текущую реализацию SerializerFactory необходимо менять при введении нового формата. Приложению может никогда не потребоваться поддержка каких-либо дополнительных форматов, но этого, как обычно бывает, никто на 100% не знает.

Идея состоит в том, чтобы предоставить в SerializerFactory метод, который регистрирует новую реализацию сериализатора serializer (который передается в метод Song.serialize()) для формата, который необходимо поддерживать:

# меняем класс в `serializers.py`
class SerializerFactory:

    def __init__(self):
        self._creators = {}

    def register_format(self, format, creator):
        self._creators[format] = creator

    def get_serializer(self, format):
        creator = self._creators.get(format)
        if not creator:
            raise ValueError(format)
        return creator()

# создаем фабрику объектов
factory = SerializerFactory()
# регистрируем классы сериализаторов
factory.register_format('JSON', JsonSerializer)
factory.register_format('XML', XmlSerializer)

Метод SerializerFactory.register_format(format, creator) позволяет регистрировать новые форматы format, которое используется для идентификации объекта-создателя. Объект-создатель - это имя класса конкретного сериализатора. Регистрация классов сериализаторов (JsonSerializer и XmlSerializer) возможна, благодаря методу __init__() в этих классах, для инициализации экземпляров.

Регистрируемые объекты хранятся в словаре _creators. Метод SerializerFactory.get_serializer() извлекает зарегистрированного создателя и создает нужный объект. Если запрошенный формат не был зарегистрирован, то возникает ValueError.

При создании сериализатора YamlSerializer можно увидеть гибкость дизайна. Класс YamlSerializer создадим в отдельном файле (это не обязательно, его можно дописать в конец файл serializers.py). Форматы JSON и YAML очень похожи, поэтому можно повторно использовать большую часть реализации класса JsonSerializer, переопределив только метод .to_str(). И чтобы сделать доступным новый формат, необходимо зарегистрировать созданный класс в фабрике объектов:

# файл yaml_serializers.py
# импортируем основной файл `serializers.ру`
import serializers
import yaml

# наследуемся от `serializers.JsonSerializer`
class YamlSerializer(serializers.JsonSerializer):
    # переопределяем метод `to_str()`
    def to_str(self):
        return yaml.dumps(self._current_object)

# регистрируем класс `YamlSerializer` в фабрике объектов
serializers.factory.register_format('YAML', YamlSerializer)

Примечание. Чтобы пример работал, необходимо установить модуль pyyaml, например, в виртуальное окружение.

Смотрим как это работает:

>>> import serializers, songs
>>> import yaml_serializers
>>> song = songs.Song('1', 'Water of Love', 'Dire Straits')
>>> serializer = serializers.ObjectSerializer()
>>> serializer.serialize(song, 'JSON')
# {"id": "1", "title": "Water of Love", "artist": "Dire Straits"}
>>> serializer.serialize(song, 'XML')
# <song id="1"><title>Water of Love</title><artist>Dire Straits</artist></song>
>>> serializer.serialize(song, 'YAML')
# "artist: Dire Straits\nid: '1'\ntitle: Water of Love\n"

Реализуя шаблон Factory с помощью фабрики объектов и предоставляя интерфейс регистрации, можно поддерживать новые форматы, не изменяя какой-либо существующий код приложения. Это сводит к минимуму риск нарушения существующих функций или внесения неявных ошибок.

Реализация SerializerFactory - это огромное улучшение по сравнению с первоначальным примером. Он обеспечивает большую гибкость для поддержки новых форматов и позволяет избежать изменения существующего кода.

Тем не менее, последняя реализация специально нацелена на проблему сериализации, описанную выше, и ее нельзя повторно использовать в других контекстах.

Фабрика объектов общего назначения.

Шаблон проектирования "Factory" может быть использован для решения широкого круга задач. Фабрика объектов обеспечивает дополнительную гибкость конструкции при изменении требований. В идеале нужна реализация ObjectFactory, которую можно повторно использовать в любой ситуации без дублирования реализации.

Проблема "Фабрики общего назначения".

Самая большая проблема при реализации "Фабрики общего назначения" заключается в том, что не все объекты создаются одинаково.

Не во всех ситуациях можно использовать метод .__init__() по умолчанию для создания и инициализации объектов. Важно, чтобы создатель, в данном случае "Фабрика объектов", возвращал полностью инициализированные объекты.

Это важно, потому что если это не так, то клиенту придется завершить инициализацию и использовать сложный условный код для полной инициализации предоставленных объектов. Это противоречит цели "шаблона проектирования Factory".

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

Представьте, что приложению нужно интегрироваться с услугой, предоставляемой определенным музыкальном сервисом. Для этой службы требуется процесс авторизации, при котором предоставляются клиентский ключ key и secret.

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

В то же время другие пользователи хотят интегрироваться с другим музыкальным сервисом, который может использовать совершенно другой процесс авторизации. Он также требует key и secret клиента, но при этом возвращает key и secret потребителя, которые следует использовать для других коммуникаций. Как и в случае с первым сервисом, процесс авторизации медленный, и его следует выполнять только один раз.

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

Этот пример имеет несколько проблем. Каждая служба инициализируется с различным набором параметров. Кроме того, первые два музыкальных сервиса требуют авторизации перед созданием экземпляра службы. Также необходимо повторно использовать экземпляр, чтобы избежать многократной авторизации приложения. Локальный сервис проще, но он не соответствует интерфейсу инициализации других.

Отдельный объект для общего интерфейса.

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

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

Начнем с конфигурации приложения:

# файл `program.py`
config = {
    'music1_client_key': 'MUSIC1_CLIENT_KEY',
    'music1_client_secret': 'MUSIC1_CLIENT_SECRET',
    'music2_client_key': 'MUSIC2_CLIENT_KEY',
    'music2_client_secret': 'MUSIC2_CLIENT_SECRET',
    'local_music_location': '/usr/data/music'
}

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

Смотрим на реализацию Music1Service и Music1ServiceBuilder первого музыкального сервиса:

# файл `music.py`
class Music1Service:
    def __init__(self, access_code):
        self._access_code = access_code

    def test_connection(self):
        print(f'Доступ к сервису Music1 с {self._access_code}')


class Music1ServiceBuilder:
    def __init__(self):
        self._instance = None

    def __call__(self, music1_client_key, music1_client_secret, **_ignored):
        if not self._instance:
            access_code = self.authorize(
                music1_client_key, music1_client_secret)
            self._instance = Music1Service(access_code)
        return self._instance

    def authorize(self, key, secret):
        return 'ACCESS_CODE'

Примечание. Интерфейс музыкального сервиса определяет метод Music1Service.test_connection(), которого должно быть достаточно для демонстрационных целей.

В примере показан Music1ServiceBuilder, реализующий .__call__(spotify_client_key, spotify_client_secret, **_ignored). Этот метод используется для создания и инициализации конкретного Music1Service. Он указывает обязательные параметры и игнорирует любые дополнительные параметры, предоставленные через **_ignored. После получения кода доступа он создает и возвращает экземпляр Music1Service.

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

Сделаем то же самое для второго музыкального сервиса:

# добавляем в файл `music.py`
class Music2Service:
    def __init__(self, consumer_key, consumer_secret):
        self._key = consumer_key
        self._secret = consumer_secret

    def test_connection(self):
        print(f'Доступ к сервису Music2 с {self._key} и {self._secret}')


class Music2ServiceBuilder:
    def __init__(self):
        self._instance = None

    def __call__(self, music2_client_key, music2_client_secret, **_ignored):
        if not self._instance:
            consumer_key, consumer_secret = self.authorize(
                music2_client_key, music2_client_secret)
            self._instance = Music2Service(consumer_key, consumer_secret)
        return self._instance

    def authorize(self, key, secret):
        return 'CONSUMER_KEY', 'CONSUMER_SECRET'

Music2ServiceBuilder реализует тот же интерфейс, но использует другие параметры и процессы для создания и инициализации Music2Service. Он также сохраняет экземпляр службы, поэтому авторизация происходит только один раз.

Реализация локального сервиса:

# добавляем в файл `music.py`
class LocalService:
    def __init__(self, location):
        self._location = location

    def test_connection(self):
        print(f'Прослушивание музыки в папке {self._location}')

def create_local_music_service(local_music_location, **_ignored):
    return LocalService(local_music_location)

Для инициализации LocalService просто нужно указать местоположение, где хранится коллекция.

Новый экземпляр создается каждый раз, когда запрашивается служба, т.к. нет медленного процесса авторизации. Требования проще, поэтому не нужен класс Builder. Вместо него используется функция, возвращающая инициализированный LocalService. Эта функция соответствует интерфейсу методов .__call__(), реализованных в классах строителей *Builder.

Универсальный интерфейс для фабрики объектов.

Фабрика объекта общего назначения ObjectFactory может использовать интерфейс универсальный интерфейс для создания всех видов объектов. Он предоставляет метод для регистрации строителя *Builder на основе ключевого значения и способа для создания инстанций конкретных объектов на основе ключа.

Смотрим на реализацию универсальной ObjectFactory:

# файл `object_factory.py`
class ObjectFactory:
    def __init__(self):
        self._builders = {}

    def register_builder(self, key, builder):
        self._builders[key] = builder

    def create(self, key, **kwargs):
        builder = self._builders.get(key)
        if not builder:
            raise ValueError(key)
        return builder(**kwargs)

Структура реализации ObjectFactory такая же, как и в SerializerFactory. Разница заключается в интерфейсе, который поддерживает создание любого типа объекта. Аргумент builder может быть любым объектом, реализующим вызываемый интерфейс. Это означает, что он может быть функцией, классом или объектом, реализующим метод .__call__() (т.е. любой вызываемый объект).

Метод ObjectFactory.create() требует, чтобы дополнительные аргументы были указаны как ключевые. Это позволяет передаваемым объектам builder указывать нужные им параметры и игнорировать остальные в произвольном порядке. Например, create_local_music_service() задает параметр local_music_location и игнорирует остальные.

Создадим экземпляр фабрики и зарегистрируем в там сервисы:

# добавляем в `music.py`
# импортируем файл `object_factory.py`
import object_factory

# создаем экземпляр фабрики объектов
factory = object_factory.ObjectFactory()
# регистрируем объекты сервисов
factory.register_builder('MUSIC1', Music1ServiceBuilder())
factory.register_builder('MUSIC2', Music2ServiceBuilder())
factory.register_builder('LOCAL', create_local_music_service)

В файле music.py предоставляется экземпляр ObjectFactory через атрибут factory. Затем в экземпляре регистрируются строители. Для онлайн музыкальных сервисов регистрируются экземпляры соответствующих компоновщиков, для локальной службы - передается простая функция.

Итоговая программа, демонстрирующая функциональность:

# файл `program.py`
import music

config = {
    'music1_client_key': 'MUSIC1_CLIENT_KEY',
    'music1_client_secret': 'MUSIC1_CLIENT_SECRET',
    'music2_client_key': 'MUSIC2_CLIENT_KEY',
    'music2_client_secret': 'MUSIC2_CLIENT_SECRET',
    'local_music_location': '/usr/data/music'
}

# создаем экземпляры сервисов
music1 = music.factory.create('MUSIC1', **config)
music1.test_connection()
music2 = music.factory.create('MUSIC2', **config)
music2.test_connection()
local = music.factory.create('LOCAL', **config)
local.test_connection()

# создаем вторые экземпляры ОНЛАЙН сервисов
music_1 = music.factory.create('MUSIC1', **config)
music_2 = music.factory.create('MUSIC2', **config)

# убедимся, что это одни и те же экземпляры
# то есть, не производится авторизация дважды
print(f'id(music1) == id(music_1): {id(music1) == id(music_1)}')
print(f'id(music2) == id(music_2): {id(music2) == id(music_2)}')

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

Теперь можно посмотреть как работает программа:

$ python3 program.py 
Доступ к сервису Music1 с ACCESS_CODE
Доступ к сервису Music2 с CONSUMER_KEY и CONSUMER_SECRET
Прослушивание музыки в папке /usr/data/music
id(music1) == id(music_1): True
id(music2) == id(music_2): True

Специальная фабрика для улучшения читаемости кода.

Общие решения допускают повторное использование и позволяют избежать дублирования кода. К сожалению, они также могут скрывать код и делать его менее читаемым.

В приведенном выше примере показано, что для доступа к музыкальному сервису вызывается функция music.factory.create(). Это может привести к путанице. Другие разработчики могут подумать, что каждый раз создается новый экземпляр, и решат сохранить экземпляр службы, чтобы избежать медленного процесса инициализации.

Но этого не происходит, так как что классы *Builder для онлайн сервисов сохраняет инициализированный экземпляр и возвращает его для последующих вызовов, но это не ясно из кода.

Хорошим тоном является специализация реализации общего назначения для предоставления интерфейса, который является конкретным для контекста приложения.

В следующем примере показано, как специализировать ObjectFactory, предоставляя явный интерфейс для контекста приложения:

# добавляем в `music.py` после `import object_factory`
class ServiceProvider(object_factory.ObjectFactory):
    def get(self, service_id, **kwargs):
        return self.create(service_id, **kwargs)

# заменяем создание и работу с экземпляром `factory`
provider = ServiceProvider()
provider.register_builder('MUSIC1', Music1ServiceBuilder())
provider.register_builder('MUSIC2', Music2ServiceBuilder())
provider.register_builder('LOCAL', create_local_music_service)

Создаем класс ServiceProvider, наследуя его от ObjectFactory и добавляем новый метод ServiceProvider.get(service_id, **kwargs).

Этот метод вызывает общий .create(key, **kwargs), и следовательно поведение остается прежним, но код в контексте приложения читается лучше.

Теперь код самого приложения читается лучше:

# файл `program.py`
import music

config = {
    'music1_client_key': 'MUSIC1_CLIENT_KEY',
    'music1_client_secret': 'MUSIC1_CLIENT_SECRET',
    'music2_client_key': 'MUSIC2_CLIENT_KEY',
    'music2_client_secret': 'MUSIC2_CLIENT_SECRET',
    'local_music_location': '/usr/data/music'
}

# создаем экземпляры сервисов
music1 = music.provider.get('MUSIC1', **config)
music1.test_connection()
music2 = music.provider.get('MUSIC2', **config)
music2.test_connection()
local = music.provider.get('LOCAL', **config)
local.test_connection()

# создаем вторые экземпляры ОНЛАЙН сервисов
music_1 = music.provider.get('MUSIC1', **config)
music_2 = music.provider.get('MUSIC2', **config)

# убедимся, что это одни и те же экземпляры
# то есть, не производится авторизация дважды
print(f'id(music1) == id(music_1): {id(music1) == id(music_1)}')
print(f'id(music2) == id(music_2): {id(music2) == id(music_2)}')

Снова запустите файл program.py и посмотрите как работает программа.

Если хотите знать о других шаблонах проектирования:

  • Можно почитать юбилейное издание легендарной книги банды четырех "Паттерны объектно-ориентированного проектирования", которая является отличным справочником по широко распространенным шаблонам проектирования.
  • В книге Эрика Фримена и Элизабет Робсон "Head First Паттерны проектирования 2-е издание" представлено веселое и легко читаемое объяснение шаблонов проектирования.