Búsqueda de sitios web

El patrón del método Factory y su implementación en Python


Este artículo explora el patrón de diseño Factory Method y su implementación en Python. Los patrones de diseño se convirtieron en un tema popular a finales de los 90 después de que la llamada Banda de los Cuatro (GoF: Gamma, Helm, Johson y Vlissides) publicaran su libro Patrones de diseño: elementos de software reutilizable orientado a objetos.

El libro describe los patrones de diseño como una solución de diseño central para problemas recurrentes en el software y clasifica cada patrón de diseño en categorías según la naturaleza del problema. A cada patrón se le da un nombre, una descripción del problema, una solución de diseño y una explicación de las consecuencias de su uso.

El libro de GoF describe el Método Factory como un patrón de diseño creacional. Los patrones de diseño de creación están relacionados con la creación de objetos, y Factory Method es un patrón de diseño que crea objetos con una interfaz común.

Este es un problema recurrente que hace que Factory Method sea uno de los patrones de diseño más utilizados, y es muy importante entenderlo y saber aplicarlo.

Al final de este artículo, podrás:

  • Comprender los componentes del método Factory
  • Reconocer oportunidades para utilizar Factory Method en sus aplicaciones
  • Aprenda a modificar el código existente y mejorar su diseño utilizando el patrón.
  • Aprenda a identificar oportunidades donde Factory Method es el patrón de diseño apropiado
  • Elija una implementación adecuada del método Factory
  • Sepa cómo implementar una solución reutilizable y de propósito general de Factory Method

Introduciendo el método de fábrica

Factory Method es un patrón de diseño creacional que se utiliza para crear implementaciones concretas de una interfaz común.

Separa el proceso de creación de un objeto del código que depende de la interfaz del objeto.

Por ejemplo, una aplicación requiere un objeto con una interfaz específica para realizar sus tareas. La implementación concreta de la interfaz se identifica mediante algún parámetro.

En lugar de utilizar una estructura condicional compleja if/elif/else para determinar la implementación concreta, la aplicación delega esa decisión a un componente independiente que crea el objeto concreto. Con este enfoque, el código de la aplicación se simplifica, haciéndolo más reutilizable y más fácil de mantener.

Imagine una aplicación que necesita convertir un objeto Song en su representación string utilizando un formato específico. Convertir un objeto a una representación diferente a menudo se denomina serialización. A menudo verás estos requisitos implementados en una única función o método que contiene toda la lógica y la implementación, como en el siguiente código:

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

En el ejemplo anterior, tiene una clase Song básica para representar una canción y una clase SongSerializer que puede convertir un objeto song en su . >representación de cadena según el valor del parámetro format.

El método .serialize() admite dos formatos diferentes: JSON y XML. Cualquier otro formato especificado no es compatible, por lo que se genera una excepción ValueError.

Usemos el shell interactivo de Python para ver cómo funciona el código:

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

Creas un objeto song y un serializador, y conviertes la canción a su representación de cadena usando el método .serialize(). El método toma el objeto song como parámetro, así como un valor de cadena que representa el formato que desea. La última llamada utiliza YAML como formato, que no es compatible con el serializador, por lo que se genera una excepción ValueError.

Este ejemplo es breve y simplificado, pero aún tiene mucha complejidad. Hay tres rutas lógicas o de ejecución según el valor del parámetro format. Puede que esto no parezca gran cosa y probablemente haya visto código con más complejidad que este, pero el ejemplo anterior sigue siendo bastante difícil de mantener.

Los problemas del código condicional complejo

El ejemplo anterior muestra todos los problemas que encontrará en el código lógico complejo. El código lógico complejo utiliza estructuras if/elif/else para cambiar el comportamiento de una aplicación. El uso de estructuras condicionales if/elif/else hace que el código sea más difícil de leer, de entender y de mantener.

Puede que el código anterior no parezca difícil de leer o comprender, ¡pero espera hasta ver el código final en esta sección!

Sin embargo, el código anterior es difícil de mantener porque hace demasiado. El principio de responsabilidad única establece que un módulo, una clase o incluso un método debe tener una responsabilidad única y bien definida. Debería hacer sólo una cosa y tener sólo una razón para cambiar.

El método .serialize() en SongSerializer requerirá cambios por muchas razones diferentes. Esto aumenta el riesgo de introducir nuevos defectos o romper la funcionalidad existente cuando se realizan cambios. Echemos un vistazo a todas las situaciones que requerirán modificaciones en la implementación:

  • Cuando se introduce un nuevo formato: El método tendrá que cambiar para implementar la serialización a ese formato.

  • Cuando el objeto Song cambia: Agregar o eliminar propiedades a la clase Song requerirá que la implementación cambie para adaptarse a la nueva estructura.

  • Cuando cambia la representación de cadena para un formato (JSON simple versus API JSON): El método .serialize() tendrá que cambiar si la representación de cadena deseada para un formato cambia porque la representación está codificada en la implementación del método .serialize().

La situación ideal sería si cualquiera de esos cambios en los requisitos pudiera implementarse sin cambiar el método .serialize(). Veamos cómo puedes hacerlo en las siguientes secciones.

Buscando una interfaz común

El primer paso cuando ve código condicional complejo en una aplicación es identificar el objetivo común de cada una de las rutas de ejecución (o rutas lógicas).

El código que utiliza if/elif/else normalmente tiene un objetivo común que se implementa de diferentes maneras en cada ruta lógica. El código anterior convierte un objeto song a su representación string usando un formato diferente en cada ruta lógica.

Según el objetivo, se busca una interfaz común que pueda usarse para reemplazar cada una de las rutas. El ejemplo anterior requiere una interfaz que toma un objeto song y devuelve una string.

Una vez que tenga una interfaz común, proporcione implementaciones independientes para cada ruta lógica. En el ejemplo anterior, proporcionará una implementación para serializar en JSON y otra para XML.

Luego, proporciona un componente independiente que decide la implementación concreta que se utilizará en función del formato especificado. Este componente evalúa el valor de format y devuelve la implementación concreta identificada por su valor.

En las siguientes secciones, aprenderá cómo realizar cambios en el código existente sin cambiar el comportamiento. A esto se le conoce como refactorizar el código.

Martin Fowler en su libro Refactoring: Improving the Design of Existing Code define la refactorización como "el proceso de cambiar un sistema de software de tal manera que no altere el comportamiento externo del código pero mejore su estructura interna". Si desea ver la refactorización en acción, consulte Refactorización de conversaciones sobre código Python real: prepare su código para obtener ayuda.

Comencemos a refactorizar el código para lograr la estructura deseada que utiliza el patrón de diseño Factory Method.

Refactorización del código en la interfaz deseada

La interfaz deseada es un objeto o una función que toma un objeto Song y devuelve una representación string.

El primer paso es refactorizar una de las rutas lógicas en esta interfaz. Para ello, agregue un nuevo método ._serialize_to_json() y transfiera el código de serialización JSON a él. Luego, cambia el cliente para que lo llame en lugar de tener la implementación en el cuerpo de la declaración 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)

Una vez que realice este cambio, podrá verificar que el comportamiento no haya cambiado. Luego, haga lo mismo con la opción XML introduciendo un nuevo método ._serialize_to_xml(), moviendo la implementación a él y modificando la ruta elif para llamarlo.

El siguiente ejemplo muestra el código refactorizado:

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

La nueva versión del código es más fácil de leer y comprender, pero aún se puede mejorar con una implementación básica del Factory Method.

Implementación básica del método de fábrica

La idea central en Factory Method es proporcionar un componente separado con la responsabilidad de decidir qué implementación concreta debe usarse en función de algún parámetro específico. Ese parámetro en nuestro ejemplo es el format.

Para completar la implementación del método Factory, agrega un nuevo método ._get_serializer() que toma el formato deseado. Este método evalúa el valor de format y devuelve la función de serialización correspondiente:

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)

Ahora, puede cambiar el método .serialize() de SongSerializer para usar ._get_serializer() para completar la implementación del método Factory. El siguiente ejemplo muestra el código completo:

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

La implementación final muestra los diferentes componentes del Método Factory. El método .serialize() es el código de la aplicación que depende de una interfaz para completar su tarea.

Esto se conoce como el componente cliente del patrón. La interfaz definida se denomina componente producto. En nuestro caso, el producto es una función que toma una Song y devuelve una representación de cadena.

Los métodos ._serialize_to_json() y ._serialize_to_xml() son implementaciones concretas del producto. Finalmente, el método ._get_serializer() es el componente creador. El creador decide qué implementación concreta utilizar.

Debido a que comenzó con algún código existente, todos los componentes de Factory Method son miembros de la misma clase SongSerializer.

Normalmente, este no es el caso y, como puede ver, ninguno de los métodos agregados utiliza el parámetro self. Esto es una buena indicación de que no deberían ser métodos de la clase SongSerializer y pueden convertirse en funciones externas:

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

La mecánica del Factory Method es siempre la misma. Un cliente (SongSerializer.serialize()) depende de una implementación concreta de una interfaz. Solicita la implementación desde un componente creador (get_serializer()) usando algún tipo de identificador (format).

El creador devuelve la implementación concreta de acuerdo con el valor del parámetro al cliente, y el cliente utiliza el objeto proporcionado para completar su tarea.

Puede ejecutar el mismo conjunto de instrucciones en el intérprete interactivo de Python para verificar que el comportamiento de la aplicación no haya cambiado:

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

Creas una song y un serializer, y usas el serializer para convertir la canción a su representación string especificando un formato. Dado que YAML no es un formato compatible, se genera ValueError.

Reconocer oportunidades para utilizar el método de fábrica

El método Factory debe usarse en todas las situaciones en las que una aplicación (cliente) depende de una interfaz (producto) para realizar una tarea y existen múltiples implementaciones concretas de esa interfaz. Debe proporcionar un parámetro que pueda identificar la implementación concreta y utilizarlo en el creador para decidir la implementación concreta.

Existe una amplia gama de problemas que se ajustan a esta descripción, así que echemos un vistazo a algunos ejemplos concretos.

Reemplazar código lógico complejo: las estructuras lógicas complejas en el formato if/elif/else son difíciles de mantener porque se necesitan nuevas rutas lógicas a medida que cambian los requisitos.

Factory Method es un buen reemplazo porque puede colocar el cuerpo de cada ruta lógica en funciones o clases separadas con una interfaz común, y el creador puede proporcionar la implementación concreta.

El parámetro evaluado en las condiciones se convierte en el parámetro para identificar la implementación concreta. El ejemplo anterior representa esta situación.

Construir objetos relacionados a partir de datos externos: Imagine una aplicación que necesita recuperar información de los empleados de una base de datos u otra fuente externa.

Los registros representan empleados con diferentes roles o tipos: gerentes, oficinistas, asociados de ventas, etc. La aplicación puede almacenar un identificador que represente el tipo de empleado en el registro y luego usar el método Factory para crear cada objeto Employee concreto a partir del resto de la información del registro.

Admite múltiples implementaciones de la misma función: una aplicación de procesamiento de imágenes necesita transformar una imagen satelital de un sistema de coordenadas a otro, pero existen múltiples algoritmos con diferentes niveles de precisión para realizar la transformación.

La aplicación puede permitir al usuario seleccionar una opción que identifique el algoritmo concreto. Factory Method puede proporcionar la implementación concreta del algoritmo basado en esta opción.

Combinación de funciones similares en una interfaz común: Siguiendo el ejemplo de procesamiento de imágenes, una aplicación necesita aplicar un filtro a una imagen. El filtro específico a utilizar puede identificarse mediante alguna entrada del usuario, y Factory Method puede proporcionar la implementación concreta del filtro.

Integración de servicios externos relacionados: una aplicación de reproductor de música desea integrarse con múltiples servicios externos y permitir a los usuarios seleccionar de dónde proviene su música. La aplicación puede definir una interfaz común para un servicio de música y utilizar Factory Method para crear la integración correcta según las preferencias del usuario.

Todas estas situaciones son similares. Todos definen un cliente que depende de una interfaz común conocida como producto. Todos proporcionan un medio para identificar la implementación concreta del producto, de modo que todos puedan utilizar el Método Factory en su diseño.

Ahora puede observar el problema de serialización de ejemplos anteriores y proporcionar un mejor diseño teniendo en cuenta el patrón de diseño Factory Method.

Un ejemplo de serialización de objetos

Los requisitos básicos para el ejemplo anterior son que desea serializar objetos Song en su representación string. Parece que la aplicación proporciona funciones relacionadas con la música, por lo que es posible que la aplicación necesite serializar otro tipo de objetos como Lista de reproducción o Álbum.

Idealmente, el diseño debería admitir la adición de serialización para nuevos objetos mediante la implementación de nuevas clases sin requerir cambios en la implementación existente. La aplicación requiere que los objetos se serialicen en múltiples formatos como JSON y XML, por lo que parece natural definir una interfaz Serializer que pueda tener múltiples implementaciones, una por formato.

La implementación de la interfaz podría verse así:

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

La interfaz Serializer es un concepto abstracto debido a la naturaleza dinámica del lenguaje Python. Los lenguajes estáticos como Java o C# requieren que las interfaces se definan explícitamente. En Python, se dice que cualquier objeto que proporcione los métodos o funciones deseados implementa la interfaz. El ejemplo define la interfaz Serializer como un objeto que implementa los siguientes métodos o funciones:

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

Esta interfaz se implementa mediante las clases concretas JsonSerializer y XmlSerializer.

El ejemplo original utilizaba una clase SongSerializer. Para la nueva aplicación, implementará algo más genérico, como ObjectSerializer:

# In serializers.py

class ObjectSerializer:
    def serialize(self, serializable, format):
        serializer = factory.get_serializer(format)
        serializable.serialize(serializer)
        return serializer.to_str()

La implementación de ObjectSerializer es completamente genérica y solo menciona un serializable y un format como parámetros.

El formato se utiliza para identificar la implementación concreta del Serializer y se resuelve mediante el objeto factory. El parámetro serializable se refiere a otra interfaz abstracta que debe implementarse en cualquier tipo de objeto que desee serializar.

Echemos un vistazo a una implementación concreta de la interfaz serializable en la clase 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)

La clase Song implementa la interfaz Serializable proporcionando un método .serialize(serializer). En el método, la clase Song utiliza el objeto serializer para escribir su propia información sin ningún conocimiento del formato.

De hecho, la clase Song ni siquiera sabe que el objetivo es convertir los datos en una cadena. Esto es importante porque podría utilizar esta interfaz para proporcionar un tipo diferente de serializador que convierta la información de Song a una representación completamente diferente si es necesario. Por ejemplo, su aplicación podría requerir en el futuro convertir el objeto Song a un formato binario.

Hasta ahora, hemos visto la implementación del cliente (ObjectSerializer) y el producto (serializer). Es hora de completar la implementación del Método Factory y proporcionar al creador. El creador en el ejemplo es la variable factory en ObjectSerializer.serialize().

Método de fábrica como fábrica de objetos

En el ejemplo original, implementaste al creador como una función. Las funciones están bien para ejemplos muy simples, pero no brindan demasiada flexibilidad cuando cambian los requisitos.

Las clases pueden proporcionar interfaces adicionales para agregar funcionalidad y pueden derivarse para personalizar el comportamiento. A menos que tengas un creador muy básico que nunca cambiará en el futuro, querrás implementarlo como una clase y no como una función. Este tipo de clases se denominan fábricas de objetos.

Puede ver la interfaz básica de SerializerFactory en la implementación de ObjectSerializer.serialize(). El método utiliza factory.get_serializer(format) para recuperar el serializador de la fábrica de objetos.

Ahora implementará SerializerFactory para cumplir con esta interfaz:

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

La implementación actual de .get_serializer() es la misma que usó en el ejemplo original. El método evalúa el valor de format y decide la implementación concreta para crear y devolver. Es una solución relativamente sencilla que nos permite verificar la funcionalidad de todos los componentes de Factory Method.

Vayamos al intérprete interactivo de Python y veamos cómo funciona:

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

El nuevo diseño de Factory Method permite que la aplicación introduzca nuevas funciones agregando nuevas clases, en lugar de cambiar las existentes. Puede serializar otros objetos implementando la interfaz Serializable en ellos. Puede admitir nuevos formatos implementando la interfaz Serializer en otra clase.

La pieza que falta es que SerializerFactory tiene que cambiar para incluir soporte para nuevos formatos. Este problema se resuelve fácilmente con el nuevo diseño porque SerializerFactory es una clase.

Admite formatos adicionales

La implementación actual de SerializerFactory debe cambiarse cuando se introduce un nuevo formato. Es posible que su aplicación nunca necesite admitir formatos adicionales, pero nunca se sabe.

Quiere que sus diseños sean flexibles y, como verá, admitir formatos adicionales sin cambiar SerializerFactory es relativamente fácil.

La idea es proporcionar un método en SerializerFactory que registre una nueva implementación de Serializer para el formato que queremos admitir:

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

El método .register_format(format, Creator) permite registrar nuevos formatos especificando un valor format utilizado para identificar el formato y un objeto creator. El objeto creador resulta ser el nombre de clase del Serializer concreto. Esto es posible porque todas las clases Serializer proporcionan un .__init__() predeterminado para inicializar las instancias.

La información de registro se almacena en el diccionario _creators. El método .get_serializer() recupera el creador registrado y crea el objeto deseado. Si el formato solicitado no se ha registrado, se genera ValueError.

Ahora puedes verificar la flexibilidad del diseño implementando un YamlSerializer y deshacerte del molesto ValueError que viste antes:

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

JSON y YAML son formatos muy similares, por lo que puede reutilizar la mayor parte de la implementación de JsonSerializer y sobrescribir .to_str() para completar la implementación. Luego, el formato se registra con el objeto factory para que esté disponible.

Usemos el intérprete interactivo de Python para ver los resultados:

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

Al implementar Factory Method utilizando Object Factory y proporcionar una interfaz de registro, puede admitir nuevos formatos sin cambiar el código de la aplicación existente. Esto minimiza el riesgo de romper funciones existentes o introducir errores sutiles.

Una fábrica de objetos de uso general

La implementación de SerializerFactory es una gran mejora con respecto al ejemplo original. Proporciona una gran flexibilidad para admitir nuevos formatos y evita modificar el código existente.

Aún así, la implementación actual está dirigida específicamente al problema de serialización anterior y no es reutilizable en otros contextos.

El método de fábrica se puede utilizar para resolver una amplia gama de problemas. Una fábrica de objetos brinda flexibilidad adicional al diseño cuando cambian los requisitos. Idealmente, querrá una implementación de Object Factory que pueda reutilizarse en cualquier situación sin replicar la implementación.

Existen algunos desafíos para proporcionar una implementación de propósito general de Object Factory, y en las siguientes secciones analizará esos desafíos e implementará una solución que pueda reutilizarse en cualquier situación.

No todos los objetos pueden ser creados iguales

El mayor desafío para implementar una fábrica de objetos de propósito general es que no todos los objetos se crean de la misma manera.

No todas las situaciones nos permiten usar un .__init__() predeterminado para crear e inicializar los objetos. Es importante que el creador, en este caso Object Factory, devuelva objetos completamente inicializados.

Esto es importante porque si no es así, el cliente tendrá que completar la inicialización y utilizar un código condicional complejo para inicializar completamente los objetos proporcionados. Esto anula el propósito del patrón de diseño Factory Method.

Para comprender las complejidades de una solución de propósito general, veamos un problema diferente. Digamos que una aplicación quiere integrarse con diferentes servicios de música. Estos servicios pueden ser externos a la aplicación o internos para admitir una colección de música local. Cada uno de los servicios tiene un conjunto diferente de requisitos.

Imaginemos que la aplicación quiere integrarse con un servicio proporcionado por Spotify. Este servicio requiere un proceso de autorización en el que se proporcionan una clave de cliente y un secreto para la autorización.

El servicio devuelve un código de acceso que debe utilizarse en cualquier comunicación posterior. Este proceso de autorización es muy lento y solo debe realizarse una vez, por lo que la aplicación desea mantener el objeto de servicio inicializado y usarlo cada vez que necesite comunicarse con Spotify.

Al mismo tiempo, otros usuarios quieren integrarse con Pandora. Pandora podría utilizar un proceso de autorización completamente diferente. También requiere una clave y un secreto de cliente, pero devuelve una clave y un secreto de consumidor que deben usarse para otras comunicaciones. Al igual que ocurre con Spotify, el proceso de autorización es lento y sólo debe realizarse una vez.

Finalmente, la aplicación implementa el concepto de un servicio de música local donde la colección de música se almacena localmente. El servicio requiere que se especifique la ubicación de la colección de música en el sistema local. La creación de una nueva instancia de servicio se realiza muy rápidamente, por lo que se puede crear una nueva instancia cada vez que el usuario quiera acceder a la colección de música.

Este ejemplo presenta varios desafíos. Cada servicio se inicializa con un conjunto diferente de parámetros. Además, Spotify y Pandora requieren un proceso de autorización antes de poder crear la instancia del servicio.

También quieren reutilizar esa instancia para evitar autorizar la aplicación varias veces. El servicio local es más sencillo, pero no coincide con la interfaz de inicialización de los demás.

En las siguientes secciones, resolverá estos problemas generalizando la interfaz de creación e implementando una fábrica de objetos de propósito general.

Creación de objetos separados para proporcionar una interfaz común

La creación de cada servicio musical concreto tiene sus propios requisitos. Esto significa que no es posible ni recomendable una interfaz de inicialización común para cada implementación de servicio.

El mejor enfoque es definir un nuevo tipo de objeto que proporcione una interfaz general y sea responsable de la creación de un servicio concreto. Este nuevo tipo de objeto se llamará Builder. El objeto Builder tiene toda la lógica para crear e inicializar una instancia de servicio. Implementará un objeto Builder para cada uno de los servicios admitidos.

Comencemos mirando la configuración de la aplicación:

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

El diccionario config contiene todos los valores necesarios para inicializar cada uno de los servicios. El siguiente paso es definir una interfaz que utilizará esos valores para crear una implementación concreta de un servicio de música. Esa interfaz se implementará en un Builder.

Veamos la implementación de SpotifyService y 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'

El ejemplo muestra un SpotifyServiceBuilder que implementa .__call__(spotify_client_key, spotify_client_secret, **_ignored).

Este método se utiliza para crear e inicializar el SpotifyService concreto. Especifica los parámetros requeridos e ignora cualquier parámetro adicional proporcionado a través de **_ignored. Una vez que se recupera el access_code, crea y devuelve la instancia SpotifyService.

Tenga en cuenta que SpotifyServiceBuilder mantiene la instancia de servicio y solo crea una nueva la primera vez que se solicita el servicio. Esto evita pasar por el proceso de autorización varias veces como se especifica en los requisitos.

Hagamos lo mismo con 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 implementa la misma interfaz, pero utiliza diferentes parámetros y procesos para crear e inicializar PandoraService. También mantiene la instancia de servicio disponible, por lo que la autorización solo ocurre una vez.

Finalmente, echemos un vistazo a la implementación del servicio local:

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

El LocalService solo requiere una ubicación donde se almacena la colección para inicializar el LocalService.

Se crea una nueva instancia cada vez que se solicita el servicio porque no existe un proceso de autorización lento. Los requisitos son más simples, por lo que no necesita una clase Builder. En su lugar, se utiliza una función que devuelve un LocalService inicializado. Esta función coincide con la interfaz de los métodos .__call__() implementados en las clases del constructor.

Una interfaz genérica para la fábrica de objetos

Una fábrica de objetos de propósito general (ObjectFactory) puede aprovechar la interfaz genérica Builder para crear todo tipo de objetos. Proporciona un método para registrar un Builder basado en un valor key y un método para crear instancias de objetos concretos basados en la key.

Veamos la implementación de nuestro ObjectFactory genérico:

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

La estructura de implementación de ObjectFactory es la misma que vio en SerializerFactory.

La diferencia está en la interfaz que se expone para admitir la creación de cualquier tipo de objeto. El parámetro del constructor puede ser cualquier objeto que implemente la interfaz invocable. Esto significa que un Builder puede ser una función, una clase o un objeto que implementa .__call__().

El método .create() requiere que se especifiquen argumentos adicionales como argumentos de palabras clave. Esto permite que los objetos Builder especifiquen los parámetros que necesitan e ignoren el resto sin ningún orden en particular. Por ejemplo, puede ver que create_local_music_service() especifica un parámetro local_music_location e ignora el resto.

Creemos la instancia de fábrica y registremos los constructores para los servicios que desea admitir:

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

El módulo music expone la instancia ObjectFactory a través del atributo factory. Luego, los constructores se registran en la instancia. Para Spotify y Pandora, registras una instancia de su constructor correspondiente, pero para el servicio local, simplemente pasas la función.

Escribamos un pequeño programa que demuestre la funcionalidad:

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

La aplicación define un diccionario config que representa la configuración de la aplicación. La configuración se utiliza como argumento de palabra clave para la fábrica, independientemente del servicio al que desee acceder. La fábrica crea la implementación concreta del servicio de música en función del parámetro key especificado.

Ahora puede ejecutar nuestro programa para ver cómo funciona:

$ 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

Puede ver que se crea la instancia correcta según el tipo de servicio especificado. También puedes ver que al solicitar el servicio Pandora o Spotify siempre te devuelve la misma instancia.

Especialización de Object Factory para mejorar la legibilidad del código

Las soluciones generales son reutilizables y evitan la duplicación de código. Desafortunadamente, también pueden oscurecer el código y hacerlo menos legible.

El ejemplo anterior muestra que, para acceder a un servicio de música, se llama a music.factory.create(). Esto puede generar confusión. Otros desarrolladores podrían creer que se crea una nueva instancia cada vez y decidir que deben mantener la instancia de servicio para evitar el lento proceso de inicialización.

Sabes que esto no es lo que sucede porque la clase Builder mantiene la instancia inicializada y la devuelve para llamadas posteriores, pero esto no queda claro con solo leer el código.

Una buena solución es especializar una implementación de propósito general para proporcionar una interfaz que sea concreta para el contexto de la aplicación. En esta sección, especializará ObjectFactory en el contexto de nuestros servicios de música, para que el código de la aplicación comunique mejor la intención y sea más legible.

El siguiente ejemplo muestra cómo especializar ObjectFactory, proporcionando una interfaz explícita para el contexto de la aplicación:

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

Deriva MusicServiceProvider de ObjectFactory y expone un nuevo método .get(service_id, **kwargs).

Este método invoca el .create(key, **kwargs) genérico, por lo que el comportamiento sigue siendo el mismo, pero el código se lee mejor en el contexto de nuestra aplicación. También cambió el nombre de la variable factory anterior a services y la inicializó como MusicServiceProvider.

Como puede ver, el código de la aplicación actualizado se lee mucho mejor ahora:

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

La ejecución del programa muestra que el comportamiento no ha cambiado:

$ 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

Conclusión

Factory Method es un patrón de diseño creacional ampliamente utilizado que se puede utilizar en muchas situaciones donde existen múltiples implementaciones concretas de una interfaz.

El patrón elimina código lógico complejo que es difícil de mantener y lo reemplaza con un diseño que es reutilizable y extensible. El patrón evita modificar el código existente para admitir nuevos requisitos.

Esto es importante porque cambiar el código existente puede introducir cambios en el comportamiento o errores sutiles.

En este artículo, aprendiste:

  • Qué es el patrón de diseño Factory Method y cuáles son sus componentes
  • Cómo refactorizar el código existente para aprovechar el método Factory
  • Situaciones en las que se debe utilizar el método Factory
  • Cómo las fábricas de objetos brindan más flexibilidad para implementar el método de fábrica
  • Cómo implementar una Fábrica de Objetos de propósito general y sus desafíos
  • Cómo especializar una solución general para proporcionar un mejor contexto

Otras lecturas

Si desea obtener más información sobre el método Factory y otros patrones de diseño, le recomiendo Patrones de diseño: elementos de software reutilizable orientado a objetos de GoF, que es una gran referencia para los patrones de diseño ampliamente adoptados.

Además, Heads First Design Patterns: A Brain-Friendly Guide de Eric Freeman y Elisabeth Robson proporciona una explicación divertida y fácil de leer sobre los patrones de diseño.

Wikipedia tiene un buen catálogo de patrones de diseño con enlaces a páginas de los patrones más comunes y útiles.

Artículos relacionados