Шаблон фабричного метода и его реализация в Python

Spread the love

В этой статье рассматривается шаблон проектирования Фабричный Метод (Factory Method) и его реализация в Python. Шаблоны проектирования стали популярной темой в конце 90-х годов после того, как так называемая «Банда четырех» (GoF: Gamma, Helm, Johson и Vlissides) опубликовала свою книгу Design Patterns: Elements of Reusable Object-Oriented Software.

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

Книга GoF описывает фабричный метод как порождающий шаблон проектирования. Порождающие шаблоны проектирования – это такие шаблонные которые связаны с созданием объектов. Создание объектов наиболее частая задача, которую решает Фабричный метод (Factory Method). Это один из самых широко используемых шаблонов проектирования. Поэтому очень важно понимать его и знать, как его применять.

К концу этой статьи вы будете:

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

Введение

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

Он позволяет разделить процесс создания объекта от кода, который зависит от интерфейса объекта.

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

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

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

# In serializer_demo.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.

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

Давайте посмотрим в интерактивной оболочки Python, как работает этот код:

>>> import serializer_demo as sd
>>> song = sd.Song('1', 'Water of Love', 'Dire Straits')
>>> serializer = sd.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 "./serializer_demo.py", line 30, in serialize
    raise ValueError(format)
ValueError: YAML

Здесь мы создаем объект  song  и  serializer. Далее преобразуем  song  в его строковое представление (то есть сериализуем), используя метод .serialize(). Метод принимает объект  song  в качестве параметра, а также строковое значение, представляющее желаемый формат. В последнем вызове мы используем формат YAML, который не поддерживается serializer, поэтому возникает исключение ValueError .

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

Проблемы со сложным условным кодом

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

Приведенный выше код трудно поддерживать, потому что в нем заложено слишком много функционала. Принцип единой ответственности (single responsibility principle) гласит, что модуль, класс или даже метод должны иметь одну четко определенную задачу.

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

  • Необходимость внедрения нового формата: Нужно будет внести изменения в метод, чтобы реализовать новый формат для сериализации.
  • Когда поменяется объект Song: Добавление или удаление свойств в классе Song потребует изменения его реализации, чтобы приспособиться к новой структуре.
  • Когда изменяется формат строкового представление (например вместо JSON потребуется JSON API): Нужно будет внести изменения в метод .serialize(), если изменится желаемое строковое представление для формата, поскольку представление жестко закодировано в реализации метода  .serialize().

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

В поисках общего интерфейса

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

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

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

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

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

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

Мартин Фаулер в своей книге «Рефакторинг: улучшение дизайна существующего кода»( Refactoring: Improving the Design of Existing Code) определяет рефакторинг как «процесс изменения программной системы таким образом, чтобы не изменить внешнее поведение кода, но улучшить его внутреннюю структуру».

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

Рефакторинг кода: создание желаемого интерфейса

Нам нужен такой интерфейс что бы это был объект или функция, которая принимает объект Song и возвращал бы объект типа string.

Первым шагом является рефакторинг одного из логических путей в этот интерфейс. Добавим новый метод ._serialize_to_json()  и переместим код сериализации JSON в него. Затем добавим его вызов, вместо реализации сериализации в теле  if:

class SongSerializer:
    def serialize(self, song, format):
        if format == 'JSON':
            return self._serialize_to_json(song)
        # The rest of the code remains the same

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

После внесения этих изменений мы можем убедиться, что поведение не изменилось. Затем сделаем то же самое для опции XML, вводя новый метод  ._serialize_to_xml(), и перемещая реализацию к нему и изменяя логический путь его вызова elif.

В следующем примере показан переработанный код:

class SongSerializer:
    def serialize(self, song, format):
        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 Method.

Базовая реализация фабричного метода

Основная идея фабричного метода – предоставить отдельному компоненту ответственность за решение о том, какую конкретную реализацию следует использовать на основе определенного параметра. Этот параметр в нашем примере называется format.

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

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)

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

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

class SongSerializer:
    def serialize(self, song, format):
        serializer = self._get_serializer(format)
        return serializer(song)

    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)

    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')

Данный код так же показывает нам различные компоненты фабричного метода. Метод .serialize() – это код приложения, который зависит от интерфейса. Он называется client – то есть клиентским компонентом шаблона. Определенный интерфейс называется product. В нашем случае product – это функции, которые принимают Song  и возвращают строковое представление.

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

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

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')

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

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

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

>>> import serializer_demo as sd
>>> song = sd.Song('1', 'Water of Love', 'Dire Straits')
>>> serializer = sd.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 "./serializer_demo.py", line 13, in serialize
    serializer = get_serializer(format)
  File "./serializer_demo.py", line 23, in get_serializer
    raise ValueError(format)
ValueError: YAML

Здесь мы создаем song  и  serializer,  и используем serializer  для преобразования песни в ее строковое представление с указанием формата. Поскольку YAML не поддерживаемый формат, появляется исключение ValueError.

Определение возможности использования фабричного метода

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

Существует широкий спектр проблем, которые соответствуют этому описанию, поэтому давайте взглянем на некоторые конкретные примеры:

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

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

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

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

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

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

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

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

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

Пример сериализации объекта

Основные требования для приведенного выше примера состояли в том, что нам надо было сериализовать объекты  Song  в их строковое представление. Наш пример был связан с музыкой, допустим, что приложению потребуется сериализовать объекты другого типа, такие как Playlist  или Album.

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

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

# In 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:

# In 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 используется для идентификации конкретной реализации  Serializer  и определяется объектом factory. Параметр serializable относится к другому абстрактному интерфейсу, который должен быть реализован для любого типа объекта, который нужно будет сериализовать.

Давайте посмотрим на конкретную реализацию  serializable  интерфейса в классе  Song:

# In 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.start_object('song', self.song_id)
        serializer.add_property('title', self.title)
        serializer.add_property('artist', self.artist)

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

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

До сих пор мы рассматривали реализацию client (ObjectSerializer) и product (serializer). Настало время завершить реализацию фабричного метода и определить creator. Creator в этом примере будет фабрикой переменных в ObjectSerializer.serialize().

Фабричный метод как объект Фабрика

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

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

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

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

# In 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()

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

Давайте проверим в интерпретаторе Python и посмотрим, как работает наш код:

>>> import songs
>>> import 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

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

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

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

Текущая реализация требует изменение  SerializerFactory при добавление нового формата. Если нам нужно, чтобы наши проекты были более гибкими, то реализация поддержки дополнительных форматов без изменения  SerializerFactory относительно проста.

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

# In 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)

Метод  .register_format(format, creator)  позволяет регистрировать новые форматы путем указания значения  format, используемого для идентификации формата и объекта creator. Объект creator – это имя класса конкретного сериализатора  Serializer. Это возможно, потому что все классы  Serializer предоставляют по умолчанию  .__init__()  для инициализации экземпляров.

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

Теперь мы можем проверить гибкость проекта, внедрив YamlSerializer  и избавиться от надоедливой ошибки  ValueError:

# In yaml_serializer.py

import yaml
import serializers

class YamlSerializer(serializers.JsonSerializer):
    def to_str(self):
        return yaml.dump(self._current_object)


serializers.factory.register_format('YAML', YamlSerializer)

Примечание. Для реализации примера нам необходимо установить PyYAML, используя pip install PyYAML.

JSON и YAML – очень похожие форматы, поэтому можно повторно использовать большую часть реализации JsonSerializer и переписать .to_str()  для завершения реализации. Затем формат регистрируем в объекте factory .

Давайте посмотрим результаты:

>>> import serializers
>>> import songs
>>> import yaml_serializer
>>> song = songs.Song('1', 'Water of Love', 'Dire Straits')
>>> serializer = serializers.ObjectSerializer()

>>> print(serializer.serialize(song, 'JSON'))
{"id": "1", "title": "Water of Love", "artist": "Dire Straits"}

>>> print(serializer.serialize(song, 'XML'))
<song id="1"><title>Water of Love</title><artist>Dire Straits</artist></song>

>>> print(serializer.serialize(song, 'YAML'))
{artist: Dire Straits, id: '1', title: Water of Love}

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

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

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

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

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

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

Не все объекты могут быть одинаковыми

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

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

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

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

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

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

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

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

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

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

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

Создание отдельного объекта для предоставления общего интерфейса

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

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

Давайте начнем с создания конфигурации нашего приложения:

# In program.py

config = {
    'spotify_client_key': 'THE_SPOTIFY_CLIENT_KEY',
    'spotify_client_secret': 'THE_SPOTIFY_CLIENT_SECRET',
    'pandora_client_key': 'THE_PANDORA_CLIENT_KEY',
    'pandora_client_secret': 'THE_PANDORA_CLIENT_SECRET',
    'local_music_location': '/usr/data/music'
}

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

Давайте посмотрим на реализацию  SpotifyService  и  SpotifyServiceBuilder:

# In music.py

class SpotifyService:
    def __init__(self, access_code):
        self._access_code = access_code

    def test_connection(self):
        print(f'Accessing Spotify with {self._access_code}')


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

    def __call__(self, spotify_client_key, spotify_client_secret, **_ignored):
        if not self._instance:
            access_code = self.authorize(
                spotify_client_key, spotify_client_secret)
            self._instance = SpotifyService(access_code)
        return self._instance

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

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

В этом примере показан  SpotifyServiceBuilder который реализует .__call__(spotify_client_key, spotify_client_secret, **_ignored).

Этот метод используется для создания и инициализации конкретного  SpotifyService. Он определяет обязательные параметры и игнорирует любые дополнительные параметры, предоставленные через  **._ignored. После получения кода доступа он создает и возвращает экземпляр  SpotifyService .

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

Давайте сделаем то же самое для Pandora:

# In music.py

class PandoraService:
    def __init__(self, consumer_key, consumer_secret):
        self._key = consumer_key
        self._secret = consumer_secret

    def test_connection(self):
        print(f'Accessing Pandora with {self._key} and {self._secret}')


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

    def __call__(self, pandora_client_key, pandora_client_secret, **_ignored):
        if not self._instance:
            consumer_key, consumer_secret = self.authorize(
                pandora_client_key, pandora_client_secret)
            self._instance = PandoraService(consumer_key, consumer_secret)
        return self._instance

    def authorize(self, key, secret):
        return 'PANDORA_CONSUMER_KEY', 'PANDORA_CONSUMER_SECRET'

 PandoraServiceBuilder  реализует тот же интерфейс, но использует другие параметры и процессы для создания и инициализации  PandoraService.

Наконец, давайте посмотрим на реализацию локальной службы:

# In music.py

class LocalService:
    def __init__(self, location):
        self._location = location

    def test_connection(self):
        print(f'Accessing Local music at {self._location}')


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

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

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

Общий интерфейс к фабрике объектов

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

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

# In 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 может быть любым объектом, который реализует вызываемый интерфейс. Это означает, что  Builder  может быть функцией, классом или объектом, который реализует  .__call__().

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

Давайте создадим экземпляр фабрики и зарегистрируем сборщики для сервисов, которые мы хотим поддерживать:

# In music.py
import object_factory

# Omitting other implementation classes shown above

factory = object_factory.ObjectFactory()
factory.register_builder('SPOTIFY', SpotifyServiceBuilder())
factory.register_builder('PANDORA', PandoraServiceBuilder())
factory.register_builder('LOCAL', create_local_music_service)

Модуль music  предоставляет экземпляр  ObjectFactory  через атрибут  factory . Затем Builders регистрируются в экземпляре. Для Spotify и Pandora мы регистрируем экземпляр их соответствующего компоновщика, но для локального сервиса мы просто передаем функцию.

Далее напишем небольшую программу, которая демонстрирует все эту функциональность:

# In program.py
import music

config = {
    'spotify_client_key': 'THE_SPOTIFY_CLIENT_KEY',
    'spotify_client_secret': 'THE_SPOTIFY_CLIENT_SECRET',
    'pandora_client_key': 'THE_PANDORA_CLIENT_KEY',
    'pandora_client_secret': 'THE_PANDORA_CLIENT_SECRET',
    'local_music_location': '/usr/data/music'
}

pandora = music.factory.create('PANDORA', **config)
pandora.test_connection()

spotify = music.factory.create('SPOTIFY', **config)
spotify.test_connection()

local = music.factory.create('LOCAL', **config)
local.test_connection()

pandora2 = music.services.get('PANDORA', **config)
print(f'id(pandora) == id(pandora2): {id(pandora) == id(pandora2)}')

spotify2 = music.services.get('SPOTIFY', **config)
print(f'id(spotify) == id(spotify2): {id(spotify) == id(spotify2)}')

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

Теперь мы можем запустить нашу программу, чтобы увидеть, как она работает:

$ python program.py
Accessing Pandora with PANDORA_CONSUMER_KEY and PANDORA_CONSUMER_SECRET
Accessing Spotify with SPOTIFY_ACCESS_CODE
Accessing Local music at /usr/data/music
id(pandora) == id(pandora2): True
id(spotify) == id(spotify2): True

Мы можем увидеть, что правильный экземпляр создается в зависимости от указанного типа сервиса. Также видно что запрос службы Pandora или Spotify всегда возвращает один и тот же экземпляр.

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

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

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

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

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

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

# In music.py

class MusicServiceProvider(object_factory.ObjectFactory):
    def get(self, service_id, **kwargs):
        return self.create(service_id, **kwargs)


services = MusicServiceProvider()
services.register_builder('SPOTIFY', SpotifyServiceBuilder())
services.register_builder('PANDORA', PandoraServiceBuilder())
services.register_builder('LOCAL', create_local_music_service)

Мы извлекаем  MusicServiceProvider  из  ObjectFactory  и добавили новый метод для этого  .get(service_id, **kwargs).

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

Как видите, обновленный код приложения теперь выглядит намного лучше:

import music

config = {
    'spotify_client_key': 'THE_SPOTIFY_CLIENT_KEY',
    'spotify_client_secret': 'THE_SPOTIFY_CLIENT_SECRET',
    'pandora_client_key': 'THE_PANDORA_CLIENT_KEY',
    'pandora_client_secret': 'THE_PANDORA_CLIENT_SECRET',
    'local_music_location': '/usr/data/music'
}

pandora = music.services.get('PANDORA', **config)
pandora.test_connection()
spotify = music.services.get('SPOTIFY', **config)
spotify.test_connection()
local = music.services.get('LOCAL', **config)
local.test_connection()

pandora2 = music.services.get('PANDORA', **config)
print(f'id(pandora) == id(pandora2): {id(pandora) == id(pandora2)}')

spotify2 = music.services.get('SPOTIFY', **config)
print(f'id(spotify) == id(spotify2): {id(spotify) == id(spotify2)}')

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

$ python program.py
Accessing Pandora with PANDORA_CONSUMER_KEY and PANDORA_CONSUMER_SECRET
Accessing Spotify with SPOTIFY_ACCESS_CODE
Accessing Local music at /usr/data/music
id(pandora) == id(pandora2): True
id(spotify) == id(spotify2): True

Заключение

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

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

В этой статье вы узнали:

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

Автор Isaac Rodriguez

Привет, я Исаак. Я создаю, возглавляю и наставляю команды разработчиков, и в течение последних нескольких лет я сосредоточился на облачных сервисах и серверных приложениях, использующих Python и другие языки …больше об авторе…

Оригинал: The Factory Method Pattern and Its Implementation in Python


Была ли вам полезна эта статья?
[17 / 4.9]

Spread the love
Подписаться
Уведомление о
guest
9 Комментарий
Oldest
Newest Most Voted
Inline Feedbacks
View all comments
Andrey
Andrey
5 лет назад

Спасибо. Очень подробно разжевали этот паттерн. Продолжайте в том же духе.

Andrew
Andrew
5 лет назад

В первом листинге:

song_info = {
‘id’: song.song_id,
‘title’: song.title,
‘artist’: song.artist
}
return json.dumps(payload)

надо

return json.dumps(song_info)

Сергей
Сергей
5 лет назад

Спасибо за отличную статью!

TimofiiSorokin
3 лет назад

Ошибка во главе Базовая реализация фабричного метода
class SongSerializer:
def serialize(self, song, format):
serializer = get_serializer(format)
>>>> return serializer <<<<<<

def get_serializer(format):

Andy
Andy
2 лет назад

лишняя точка в предложении “Он определяет обязательные параметры и игнорирует любые дополнительные параметры, предоставленные через **._ignored” -> **>>.<<_ignored

Last edited 2 лет назад by Andy
Анонимно
Анонимно
2 лет назад

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

Арслан
Арслан
1 год назад

Спасибо за статью)

Гошан
Гошан
7 месяцев назад

Статья просто бомба! Очень спасибо!