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 claseSong
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)
Nota: El método ._get_serializer()
no llama a la implementación concreta y simplemente devuelve el objeto de función en sí.
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')
Nota: El método .serialize()
en SongSerializer
no utiliza el parámetro self
.
La regla anterior nos dice que no debería ser parte de la clase. Esto es correcto, pero se trata de código existente.
Si eliminas SongSerializer
y cambias el método .serialize()
a una función, tendrás que cambiar todas las ubicaciones en la aplicación que usan SongSerializer
y reemplace las llamadas a la nueva función.
A menos que tenga un porcentaje muy alto de cobertura de código con sus pruebas unitarias, este no es un cambio que deba hacer.
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 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')
Nota: El ejemplo anterior no implementa una interfaz Serializer
completa, pero debería ser lo suficientemente buena para nuestros propósitos y para demostrar el método Factory.
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)
Nota: Para implementar el ejemplo, necesita instalar PyYAML
en su entorno usando pip install PyYAML
.
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.
Nota: Los requisitos que defino para el ejemplo tienen fines ilustrativos y no reflejan los requisitos reales que tendrá que implementar para integrarse con servicios como Pandora o Spotify.
La intención es proporcionar un conjunto diferente de requisitos que muestre los desafíos de implementar una fábrica de objetos de propósito general.
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'
Nota: La interfaz del servicio de música define un método .test_connection()
, que debería ser suficiente para fines de demostración.
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.