Паттерн Фабрика и его реализация в Python

Оглавление

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

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

В книге GoF Factory Method описывается как креативный паттерн проектирования. Креативные паттерны проектирования связаны с созданием объектов, а Factory Method - это паттерн проектирования, который создает объекты с общим интерфейсом.

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

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

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

Введение в фабричный метод

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

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

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

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

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

# 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, который может преобразовывать объект song в его представление string в соответствии со значением параметра format.

Метод .serialize() поддерживает два различных формата: JSON и XML. Любой другой format указанный формат не поддерживается, поэтому возникает 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, а затем преобразуете песню в ее строковое представление с помощью метода .serialize(). Метод принимает в качестве параметра объект song, а также строковое значение, представляющее нужный вам формат. В последнем вызове в качестве формата используется YAML, который не поддерживается serializer, поэтому возникает исключение ValueError.

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

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

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

Приведенный выше код может показаться несложным для чтения или понимания, но подождите, пока вы не увидите финальный код в этом разделе!

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

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

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

  • При изменении объекта Song: Добавление или удаление свойств в классе Song потребует изменения реализации, чтобы приспособить новую структуру.

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

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

Поиск общего интерфейса

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

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

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

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

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

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

Мартин Фаулер в своей книге Рефакторинг: Improving the Design of Existing Code определяет рефакторинг как "процесс изменения программной системы таким образом, который не изменяет внешнее поведение кода, но улучшает его внутреннюю структуру". Если вы хотите увидеть рефакторинг в действии, ознакомьтесь с беседой Real Python Code Conversation Refactoring: Подготовьте свой код, чтобы получить помощь.

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

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

Желаемый интерфейс - это объект или функция, которая принимает 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.

Чтобы завершить реализацию Factory Method, вы добавляете новый метод ._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(), чтобы завершить реализацию метода Factory. Следующий пример показывает полный код:

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() - это код приложения, который зависит от интерфейса для выполнения своей задачи.

Это называется клиент компонентом паттерна. Определенный интерфейс называется компонентом product. В нашем случае продукт - это функция, которая принимает Song и возвращает строковое представление.

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

Поскольку вы начали с некоторого существующего кода, все компоненты Factory Method являются членами одного класса SongSerializer.

Обычно это не так, и, как вы можете видеть, ни один из добавленных методов не использует параметр self. Это хороший признак того, что они не должны быть методами класса 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')

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

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

Если вы удалите SongSerializer и измените метод .serialize() на функцию, то вам придется изменить все места в приложении, использующие SongSerializer, и заменить вызовы на новую функцию.

Если у вас нет очень высокого процента покрытия кода юнит-тестами, это не то изменение, которое вы должны делать.

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

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

Вы можете выполнить тот же набор инструкций в интерактивном интерпретаторе 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 для преобразования песни в ее string представление, указывая format. Поскольку YAML не является поддерживаемым форматом, возникает ошибка ValueError.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Основные требования к приведенному выше примеру заключаются в том, что вы хотите сериализовать Song объекты в их string представление. Судя по всему, приложение предоставляет функции, связанные с музыкой, поэтому вполне вероятно, что ему потребуется сериализация объектов другого типа, например 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 интерфейс, но он должен быть достаточно хорош для наших целей и для демонстрации метода Factory.

Интерфейс 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 даже не знает, что цель состоит в преобразовании данных в строку. Это важно, потому что вы можете использовать этот интерфейс для создания другого типа serializer, который при необходимости преобразует информацию Song в совершенно другое представление. Например, в будущем ваше приложение может потребовать преобразовать объект Song в двоичный формат.

До сих пор мы видели реализацию клиента (ObjectSerializer) и продукта (serializer). Пришло время завершить реализацию фабричного метода и предоставить создателя. Создателем в примере является переменная factory в ObjectSerializer.serialize().

Метод фабрики как фабрика объектов

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

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

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

Теперь вы реализуете 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 и решает, какую конкретную реализацию создать и вернуть. Это относительно простое решение, которое позволяет нам проверить функциональность всех компонентов Factory Method.

Давайте перейдем к интерактивному интерпретатору 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

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

Недостающая часть - это то, что SerializerFactory должен быть изменен, чтобы включить поддержку новых форматов. Эта проблема легко решается в новом дизайне, потому что 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. Объект создателя является именем класса конкретного Serializer. Это возможно потому, что все классы Serializer предоставляют по умолчанию .__init__() для инициализации экземпляров.

Регистрационная информация хранится в _creators словаре . Метод .get_serializer() извлекает зарегистрированного создателя и создает нужный объект. Если запрашиваемый format не был зарегистрирован, то выдается сообщение 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, чтобы сделать его доступным.

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

>>> 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}

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Лучший подход - определить новый тип объекта, который предоставляет общий интерфейс и отвечает за создание конкретного сервиса. Этот новый тип объекта будет называться Builder. Объект 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. После получения access_code он создает и возвращает экземпляр SpotifyService.

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

Давайте сделаем то же самое для Пандоры:

# 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__(), реализованных в классах-строителях.

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

Фабрика объектов общего назначения (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 может быть любой объект, реализующий интерфейс callable. Это означает, что 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. Затем с этим экземпляром регистрируются конструкторы. Для 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), поэтому поведение остается прежним, но код лучше читается в контексте нашего приложения. Вы также переименовали предыдущую переменную factory в 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

Заключение

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

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

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

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

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

Дальнейшее чтение

Если вы хотите узнать больше о Factory Method и других паттернах проектирования, я рекомендую Design Patterns: Elements of Reusable Object-Oriented Software от GoF, которая является отличным справочником по широко распространенным паттернам проектирования.

Также в книге Heads First Design Patterns: A Brain-Friendly Guide Эрика Фримена и Элизабет Робсон содержит увлекательное, легко читаемое объяснение паттернов проектирования.

В Википедии есть хороший каталог паттернов дизайна со ссылками на страницы наиболее распространенных и полезных паттернов.

Вернуться на верх