Le modèle de méthode d'usine et son implémentation en Python
Cet article explore le modèle de conception de la méthode d'usine et son implémentation en Python. Les modèles de conception sont devenus un sujet populaire à la fin des années 90 après que le soi-disant Gang of Four (GoF: Gamma, Helm, Johson et Vlissides) a publié son livreDesign Patterns: Elements of Reusable Object-Oriented Software.
Le livre décrit les modèles de conception comme une solution de conception de base aux problèmes récurrents dans les logiciels et classe chaque modèle de conception encategories selon la nature du problème. Chaque modèle reçoit un nom, une description du problème, une solution de conception et une explication des conséquences de son utilisation.
Le livre du GoF décrit la méthode d'usine comme un modèle de conception créative. Les modèles de conception créatifs sont liés à la création d'objets, et la méthode d'usine est un modèle de conception qui crée des objets avec une interface commune.
C’est un problème récurrent quemakes Factory Method one of the most widely used design patterns, et il est très important de le comprendre et de savoir comment l’appliquer.
By the end of this article, you will:
-
Comprendre les composants de la méthode d'usine
-
Reconnaître les opportunités d'utilisation de la méthode d'usine dans vos applications
-
Apprenez à modifier le code existant et à améliorer sa conception en utilisant le modèle
-
Apprenez à identifier les opportunités où la méthode d'usine est le modèle de conception approprié
-
Choisissez une implémentation appropriée de la méthode d'usine
-
Savoir comment implémenter une solution réutilisable à usage général de la méthode d'usine
Free Bonus:5 Thoughts On Python Mastery, un cours gratuit pour les développeurs Python qui vous montre la feuille de route et l'état d'esprit dont vous aurez besoin pour faire passer vos compétences Python au niveau supérieur.
Présentation de la méthode d'usine
La méthode d'usine est un modèle de conception créatif utilisé pour créer des implémentations concrètes d'une interface commune.
Il sépare le processus de création d'un objet du code qui dépend de l'interface de l'objet.
Par exemple, une application nécessite un objet avec une interface spécifique pour effectuer ses tâches. L'implémentation concrète de l'interface est identifiée par un paramètre.
Au lieu d'utiliser une structure conditionnelle complexeif/elif/else
pour déterminer l'implémentation concrète, l'application délègue cette décision à un composant séparé qui crée l'objet concret. Avec cette approche, le code d'application est simplifié, ce qui le rend plus réutilisable et plus facile à entretenir.
Imaginez une application qui a besoin de convertir un objetSong
en sa représentationstring
en utilisant un format spécifié. La conversion d'un objet en une représentation différente est souvent appelée sérialisation. Vous verrez souvent ces exigences implémentées dans une seule fonction ou méthode qui contient toute la logique et l'implémentation, comme dans le code suivant:
# 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)
Dans l'exemple ci-dessus, vous avez une classe de baseSong
pour représenter un morceau et une classeSongSerializer
qui peut convertir un objetsong
en sa représentationstring
en fonction de la valeur de le paramètreformat
.
La méthode.serialize()
prend en charge deux formats différents:JSON etXML. Tout autreformat
spécifié n'est pas pris en charge, donc une exceptionValueError
est déclenchée.
Utilisons le shell interactif Python pour voir comment fonctionne le code:
>>>
>>> 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')
'Water of Love Dire Straits '
>>> serializer.serialize(song, 'YAML')
Traceback (most recent call last):
File "", line 1, in
File "./serializer_demo.py", line 30, in serialize
raise ValueError(format)
ValueError: YAML
Vous créez un objetsong
et unserializer
, et vous convertissez le morceau en sa représentation sous forme de chaîne en utilisant la méthode.serialize()
. La méthode prend l'objetsong
comme paramètre, ainsi qu'une valeur de chaîne représentant le format souhaité. Le dernier appel utiliseYAML
comme format, qui n'est pas pris en charge par lesserializer
, donc une exceptionValueError
est déclenchée.
Cet exemple est court et simplifié, mais il a toujours beaucoup de complexité. Il existe trois chemins logiques ou d'exécution en fonction de la valeur du paramètreformat
. Cela peut ne pas sembler être un gros problème, et vous avez probablement vu du code avec plus de complexité que cela, mais l'exemple ci-dessus est encore assez difficile à maintenir.
Les problèmes avec le code conditionnel complexe
L'exemple ci-dessus présente tous les problèmes que vous trouverez dans un code logique complexe. Le code logique complexe utilise les structuresif/elif/else
pour modifier le comportement d'une application. L'utilisation des structures conditionnelles deif/elif/else
rend le code plus difficile à lire, plus difficile à comprendre et plus difficile à maintenir.
Le code ci-dessus peut ne pas sembler difficile à lire ou à comprendre, mais attendez de voir le code final dans cette section!
Néanmoins, le code ci-dessus est difficile à maintenir car il en fait trop. Lesingle responsibility principle indique qu'un module, une classe ou même une méthode doit avoir une seule responsabilité bien définie. Il ne devrait faire qu'une chose et n'avoir qu'une seule raison de changer.
La méthode.serialize()
dansSongSerializer
nécessitera des modifications pour de nombreuses raisons différentes. Cela augmente le risque d'introduire de nouveaux défauts ou de rompre les fonctionnalités existantes lorsque des modifications sont apportées. Examinons toutes les situations qui nécessiteront des modifications de la mise en œuvre:
-
When a new format is introduced: La méthode devra changer pour implémenter la sérialisation dans ce format.
-
When the
Song
object changes: L'ajout ou la suppression de propriétés à la classeSong
nécessitera une modification de l'implémentation afin de s'adapter à la nouvelle structure. -
When the string representation for a format changes (plain JSON vs JSON API): La méthode
.serialize()
devra changer si la représentation sous forme de chaîne souhaitée pour un format change car la représentation est codée en dur dans l'implémentation de la méthode.serialize()
.
La situation idéale serait si l'une de ces modifications des exigences pouvait être mise en œuvre sans changer la méthode.serialize()
. Voyons comment procéder dans les sections suivantes.
À la recherche d'une interface commune
La première étape lorsque vous voyez du code conditionnel complexe dans une application consiste à identifier l'objectif commun de chacun des chemins d'exécution (ou chemins logiques).
Le code qui utiliseif/elif/else
a généralement un objectif commun qui est implémenté de différentes manières dans chaque chemin logique. Le code ci-dessus convertit un objetsong
en sa représentationstring
en utilisant un format différent dans chaque chemin logique.
En fonction de l'objectif, vous recherchez une interface commune qui peut être utilisée pour remplacer chacun des chemins. L'exemple ci-dessus nécessite une interface qui prend un objetsong
et renvoie unstring
.
Une fois que vous avez une interface commune, vous fournissez des implémentations distinctes pour chaque chemin logique. Dans l'exemple ci-dessus, vous fournirez une implémentation pour sérialiser en JSON et une autre pour XML.
Ensuite, vous fournissez un composant distinct qui décide de l'implémentation concrète à utiliser en fonction desformat
spécifiés. Ce composant évalue la valeur deformat
et renvoie l'implémentation concrète identifiée par sa valeur.
Dans les sections suivantes, vous apprendrez à apporter des modifications au code existant sans modifier le comportement. Ceci est appelérefactoring le code.
Martin Fowler dans son livreRefactoring: Improving the Design of Existing Code définit le refactoring comme «le processus de modification d'un système logiciel de manière à ne pas modifier le comportement externe du code mais à améliorer sa structure interne».
Commençons par refactoriser le code pour obtenir la structure souhaitée qui utilise le modèle de conception de la méthode d'usine.
Refactorisation du code dans l'interface souhaitée
L'interface souhaitée est un objet ou une fonction qui prend un objetSong
et renvoie une représentationstring
.
La première étape consiste à refactoriser l'un des chemins logiques dans cette interface. Pour ce faire, ajoutez une nouvelle méthode._serialize_to_json()
et y déplacez le code de sérialisation JSON. Ensuite, vous changez le client pour l'appeler au lieu d'avoir l'implémentation dans le corps de l'instructionif
:
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)
Une fois que vous avez effectué cette modification, vous pouvez vérifier que le comportement n'a pas changé. Ensuite, vous faites la même chose pour l'option XML en introduisant une nouvelle méthode._serialize_to_xml()
, en y déplaçant l'implémentation et en modifiant le cheminelif
pour l'appeler.
L'exemple suivant montre le code refactorisé:
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 nouvelle version du code est plus facile à lire et à comprendre, mais elle peut encore être améliorée avec une implémentation de base de Factory Method.
Implémentation de base de la méthode d'usine
L'idée centrale de Factory Method est de fournir un composant distinct chargé de décider quelle implémentation concrète doit être utilisée en fonction d'un paramètre spécifié. Ce paramètre dans notre exemple est leformat
.
Pour terminer l'implémentation de la méthode d'usine, vous ajoutez une nouvelle méthode._get_serializer()
qui prend lesformat
souhaités. Cette méthode évalue la valeur deformat
et renvoie la fonction de sérialisation correspondante:
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)
Note: La méthode._get_serializer()
n'appelle pas l'implémentation concrète, et elle renvoie simplement l'objet fonction lui-même.
Maintenant, vous pouvez changer la méthode.serialize()
deSongSerializer
pour utiliser._get_serializer()
pour terminer l'implémentation de la méthode d'usine. L'exemple suivant montre le code complet:
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')
L'implémentation finale montre les différents composants de la méthode d'usine. La méthode.serialize()
est le code d'application qui dépend d'une interface pour accomplir sa tâche.
Ceci est appelé le composantclient du motif. L'interface définie est appelée le composantproduct. Dans notre cas, le produit est une fonction qui prend unSong
et renvoie une représentation sous forme de chaîne.
Les méthodes._serialize_to_json()
et._serialize_to_xml()
sont des implémentations concrètes du produit. Enfin, la méthode._get_serializer()
est le composantcreator. Le créateur décide quelle implémentation concrète utiliser.
Étant donné que vous avez commencé avec du code existant, tous les composants de la méthode d'usine sont membres de la même classeSongSerializer
.
Ce n'est généralement pas le cas et, comme vous pouvez le voir, aucune des méthodes ajoutées n'utilise le paramètreself
. C'est une bonne indication qu'elles ne devraient pas être des méthodes de la classeSongSerializer
, et qu'elles peuvent devenir des fonctions externes:
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')
Note: La méthode.serialize()
dansSongSerializer
n'utilise pas le paramètreself
.
La règle ci-dessus nous dit qu'elle ne devrait pas faire partie de la classe. C'est correct, mais vous traitez avec du code existant.
Si vous supprimezSongSerializer
et changez la méthode.serialize()
en fonction, vous devrez alors changer tous les emplacements de l’application qui utilisentSongSerializer
et remplacer les appels à la nouvelle fonction.
À moins que vous n'ayez un pourcentage très élevé de couverture de code avec vos tests unitaires, ce n'est pas un changement que vous devriez faire.
La mécanique de la méthode d'usine est toujours la même. Un client (SongSerializer.serialize()
) dépend d'une implémentation concrète d'une interface. Il demande l'implémentation à un composant créateur (get_serializer()
) en utilisant une sorte d'identifiant (format
).
Le créateur renvoie l'implémentation concrète en fonction de la valeur du paramètre au client, et le client utilise l'objet fourni pour terminer sa tâche.
Vous pouvez exécuter le même ensemble d'instructions dans l'interpréteur interactif Python pour vérifier que le comportement de l'application n'a pas changé:
>>>
>>> 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')
'Water of Love Dire Straits '
>>> serializer.serialize(song, 'YAML')
Traceback (most recent call last):
File "", line 1, in
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
Vous créez unsong
et unserializer
, et utilisez lesserializer
pour convertir le morceau en sa représentationstring
en spécifiant unformat
. Étant donné queYAML
n'est pas un format pris en charge,ValueError
est déclenché.
Reconnaître les opportunités d'utilisation de la méthode d'usine
La méthode d'usine doit être utilisée dans toutes les situations où une application (client) dépend d'une interface (produit) pour effectuer une tâche et il existe plusieurs implémentations concrètes de cette interface. Vous devez fournir un paramètre qui peut identifier l'implémentation concrète et l'utiliser dans le créateur pour décider de l'implémentation concrète.
Il existe un large éventail de problèmes qui correspondent à cette description. Voyons donc quelques exemples concrets.
Replacing complex logical code: Les structures logiques complexes au formatif/elif/else
sont difficiles à maintenir car de nouveaux chemins logiques sont nécessaires à mesure que les exigences changent.
La méthode d'usine est un bon remplacement car vous pouvez mettre le corps de chaque chemin logique dans des fonctions ou des classes distinctes avec une interface commune, et le créateur peut fournir l'implémentation concrète.
Le paramètre évalué dans les conditions devient le paramètre permettant d'identifier la mise en œuvre concrète. L'exemple ci-dessus représente cette situation.
Constructing related objects from external data: Imaginez une application qui a besoin de récupérer des informations sur les employés à partir d'une base de données ou d'une autre source externe.
Les enregistrements représentent des employés avec différents rôles ou types: directeurs, employés de bureau, vendeurs, etc. L'application peut stocker un identifiant représentant le type d'employé dans l'enregistrement, puis utiliser la méthode d'usine pour créer chaque objetEmployee
concret à partir du reste des informations de l'enregistrement.
Supporting multiple implementations of the same feature: Une application de traitement d'image doit transformer une image satellite d'un système de coordonnées à un autre, mais il existe plusieurs algorithmes avec différents niveaux de précision pour effectuer la transformation.
L'application peut permettre à l'utilisateur de sélectionner une option qui identifie l'algorithme concret. Factory Method peut fournir l'implémentation concrète de l'algorithme basé sur cette option.
Combining similar features under a common interface: Suite à l'exemple de traitement d'image, une application doit appliquer un filtre à une image. Le filtre spécifique à utiliser peut être identifié par certaines entrées utilisateur, et la méthode d'usine peut fournir la mise en œuvre concrète du filtre.
Integrating related external services: Une application de lecteur de musique souhaite s'intégrer à plusieurs services externes et permettre aux utilisateurs de sélectionner la provenance de leur musique. L'application peut définir une interface commune pour un service de musique et utiliser la méthode d'usine pour créer l'intégration correcte en fonction des préférences de l'utilisateur.
Toutes ces situations sont similaires. Ils définissent tous un client qui dépend d'une interface commune connue sous le nom de produit. Ils fournissent tous un moyen d'identifier la mise en œuvre concrète du produit, afin qu'ils puissent tous utiliser la méthode d'usine dans leur conception.
Vous pouvez maintenant examiner le problème de sérialisation des exemples précédents et fournir une meilleure conception en prenant en considération le modèle de conception de la méthode d'usine.
Un exemple de sérialisation d'objets
Les exigences de base pour l'exemple ci-dessus sont que vous souhaitez sérialiser les objetsSong
dans leur représentationstring
. Il semble que l'application offre des fonctionnalités liées à la musique, il est donc plausible que l'application ait besoin de sérialiser d'autres types d'objets commePlaylist
ouAlbum
.
Idéalement, la conception devrait prendre en charge l'ajout de la sérialisation pour les nouveaux objets en implémentant de nouvelles classes sans nécessiter de modifications de l'implémentation existante. L'application nécessite que les objets soient sérialisés dans plusieurs formats tels que JSON et XML, il semble donc naturel de définir une interfaceSerializer
qui peut avoir plusieurs implémentations, une par format.
L'implémentation de l'interface pourrait ressembler à ceci:
# 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')
Note: L'exemple ci-dessus n'implémente pas une interfaceSerializer
complète, mais il devrait être assez bon pour nos besoins et pour démontrer la méthode d'usine.
L'interfaceSerializer
est un concept abstrait en raison de la nature dynamique du langagePython. Les langages statiques comme Java ou C # nécessitent que les interfaces soient définies explicitement. En Python, tout objet qui fournit les méthodes ou fonctions souhaitées est censé implémenter l'interface. L'exemple définit l'interfaceSerializer
comme un objet qui implémente les méthodes ou fonctions suivantes:
-
.start_object(object_name, object_id)
-
.add_property(name, value)
-
.to_str()
Cette interface est implémentée par les classes concrètesJsonSerializer
etXmlSerializer
.
L'exemple d'origine utilisait une classeSongSerializer
. Pour la nouvelle application, vous implémenterez quelque chose de plus générique, commeObjectSerializer
:
# In serializers.py
class ObjectSerializer:
def serialize(self, serializable, format):
serializer = factory.get_serializer(format)
serializable.serialize(serializer)
return serializer.to_str()
L'implémentation deObjectSerializer
est complètement générique et ne mentionne qu'unserializable
et unformat
comme paramètres.
Leformat
est utilisé pour identifier l'implémentation concrète desSerializer
et est résolu par l'objetfactory
. Le paramètreserializable
fait référence à une autre interface abstraite qui doit être implémentée sur tout type d'objet que vous souhaitez sérialiser.
Jetons un coup d'œil à une implémentation concrète de l'interfaceserializable
dans la classeSong
:
# 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 classeSong
implémente l'interfaceSerializable
en fournissant une méthode.serialize(serializer)
. Dans la méthode, la classeSong
utilise l'objetserializer
pour écrire ses propres informations sans aucune connaissance du format.
En fait, la classeSong
ne sait même pas que l’objectif est de convertir les données en chaîne. Ceci est important car vous pouvez utiliser cette interface pour fournir un type différent deserializer
qui convertit les informationsSong
en une représentation complètement différente si nécessaire. Par exemple, votre application peut nécessiter à l'avenir de convertir l'objetSong
en un format binaire.
Jusqu'à présent, nous avons vu l'implémentation du client (ObjectSerializer
) et du produit (serializer
). Il est temps de terminer la mise en œuvre de la méthode d'usine et de fournir le créateur. Le créateur dans l'exemple est la variablefactory
enObjectSerializer.serialize()
.
Méthode d'usine comme fabrique d'objets
Dans l'exemple d'origine, vous avez implémenté le créateur en tant que fonction. Les fonctions sont très bien pour des exemples très simples, mais elles n'offrent pas trop de flexibilité lorsque les exigences changent.
Les classes peuvent fournir des interfaces supplémentaires pour ajouter des fonctionnalités et peuvent être dérivées pour personnaliser le comportement. À moins d'avoir un créateur très basique qui ne changera jamais à l'avenir, vous voulez l'implémenter en tant que classe et non en tant que fonction. Ces types de classes sont appelées usines d'objets.
Vous pouvez voir l'interface de base deSerializerFactory
dans l'implémentation deObjectSerializer.serialize()
. La méthode utilisefactory.get_serializer(format)
pour récupérer lesserializer
de la fabrique d'objets.
Vous allez maintenant implémenterSerializerFactory
pour répondre à cette interface:
# 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()
L'implémentation actuelle de.get_serializer()
est la même que celle utilisée dans l'exemple d'origine. La méthode évalue la valeur deformat
et décide de l'implémentation concrète à créer et à renvoyer. Il s'agit d'une solution relativement simple qui nous permet de vérifier la fonctionnalité de tous les composants de la méthode d'usine.
Passons à l'interpréteur interactif Python et voyons comment cela fonctionne:
>>>
>>> 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')
'Water of Love Dire Straits '
>>> serializer.serialize(song, 'YAML')
Traceback (most recent call last):
File "", line 1, in
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
La nouvelle conception de Factory Method permet à l'application d'introduire de nouvelles fonctionnalités en ajoutant de nouvelles classes, au lieu de modifier celles existantes. Vous pouvez sérialiser d'autres objets en y implémentant l'interfaceSerializable
. Vous pouvez prendre en charge de nouveaux formats en implémentant l'interfaceSerializer
dans une autre classe.
L'élément manquant est queSerializerFactory
doit changer pour inclure la prise en charge des nouveaux formats. Ce problème est facilement résolu avec la nouvelle conception carSerializerFactory
est une classe.
Prise en charge de formats supplémentaires
L'implémentation actuelle deSerializerFactory
doit être modifiée lorsqu'un nouveau format est introduit. Il est possible que votre application ne prenne jamais en charge d'autres formats, mais vous ne le savez jamais.
Vous voulez que vos conceptions soient flexibles et, comme vous le verrez, prendre en charge des formats supplémentaires sans changerSerializerFactory
est relativement simple.
L'idée est de fournir une méthode enSerializerFactory
qui enregistre une nouvelle implémentation deSerializer
pour le format que nous voulons prendre en charge:
# 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)
La méthode.register_format(format, creator)
permet d'enregistrer de nouveaux formats en spécifiant une valeurformat
utilisée pour identifier le format et un objetcreator
. L'objet créateur se trouve être le nom de classe desSerializer
concrets. Ceci est possible car toutes les classesSerializer
fournissent un.__init__()
par défaut pour initialiser les instances.
Les informations d'enregistrement sont stockées dans le dictionnaire_creators
. La méthode.get_serializer()
récupère le créateur enregistré et crée l'objet souhaité. Si leformat
demandé n'a pas été enregistré, alorsValueError
est déclenché.
Vous pouvez maintenant vérifier la flexibilité de la conception en implémentant unYamlSerializer
et vous débarrasser desValueError
ennuyeux que vous avez vus précédemment:
# 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)
Note: Pour implémenter l'exemple, vous devez installerPyYAML
dans votre environnement en utilisantpip install PyYAML
.
JSON et YAML sont des formats très similaires, vous pouvez donc réutiliser la plupart de l'implémentation deJsonSerializer
et écraser.to_str()
pour terminer l'implémentation. Le format est ensuite enregistré avec l'objetfactory
pour le rendre disponible.
Utilisons l'interpréteur interactif Python pour voir les résultats:
>>>
>>> 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'))
Water of Love Dire Straits
>>> print(serializer.serialize(song, 'YAML'))
{artist: Dire Straits, id: '1', title: Water of Love}
En implémentant la méthode Factory à l'aide d'une fabrique d'objets et en fournissant une interface d'enregistrement, vous pouvez prendre en charge de nouveaux formats sans modifier le code d'application existant. Cela minimise le risque de briser les fonctionnalités existantes ou d'introduire des bogues subtils.
Une fabrique d'objets à usage général
L'implémentation deSerializerFactory
est une énorme amélioration par rapport à l'exemple d'origine. Il offre une grande flexibilité pour prendre en charge de nouveaux formats et évite de modifier le code existant.
Pourtant, l'implémentation actuelle est spécifiquement ciblée sur le problème de sérialisation ci-dessus, et elle n'est pas réutilisable dans d'autres contextes.
La méthode d'usine peut être utilisée pour résoudre un large éventail de problèmes. Une fabrique d'objets offre une flexibilité supplémentaire à la conception lorsque les exigences changent. Idéalement, vous souhaiterez une implémentation d'Object Factory qui puisse être réutilisée dans n'importe quelle situation sans répliquer l'implémentation.
La fourniture d'une implémentation à usage général d'Object Factory présente certains défis, et dans les sections suivantes, vous examinerez ces défis et implémenterez une solution pouvant être réutilisée dans n'importe quelle situation.
Tous les objets ne peuvent pas être créés égaux
Le plus grand défi pour implémenter une fabrique d'objets à usage général est que tous les objets ne sont pas créés de la même manière.
Toutes les situations ne nous permettent pas d'utiliser un.__init__()
par défaut pour créer et initialiser les objets. Il est important que le créateur, dans ce cas la fabrique d'objets, renvoie des objets entièrement initialisés.
Ceci est important car si ce n'est pas le cas, le client devra terminer l'initialisation et utiliser du code conditionnel complexe pour initialiser complètement les objets fournis. Cela va à l'encontre de l'objectif du modèle de conception de la méthode d'usine.
Pour comprendre la complexité d'une solution à usage général, examinons un problème différent. Supposons qu'une application souhaite s'intégrer à différents services musicaux. Ces services peuvent être externes à l'application ou internes afin de prendre en charge une collection de musique locale. Chacun des services a un ensemble d'exigences différent.
Note: Les exigences que je définis pour l'exemple le sont à des fins d'illustration et ne reflètent pas les exigences réelles que vous devrez mettre en œuvre pour intégrer des services commePandora ouSpotify.
L'objectif est de fournir un ensemble d'exigences différent qui montre les défis de la mise en œuvre d'une fabrique d'objets à usage général.
Imaginez que l'application veuille s'intégrer à un service fourni par Spotify. Ce service nécessite un processus d'autorisation où une clé client et un secret sont fournis pour l'autorisation.
Le service renvoie un code d'accès qui doit être utilisé lors de toute communication ultérieure. Ce processus d'autorisation est très lent et ne doit être effectué qu'une seule fois, de sorte que l'application souhaite conserver l'objet de service initialisé et l'utiliser à chaque fois qu'il a besoin de communiquer avec Spotify.
Dans le même temps, d'autres utilisateurs souhaitent s'intégrer à Pandora. Pandora peut utiliser un processus d'autorisation complètement différent. Il nécessite également une clé client et un secret, mais il renvoie une clé client et un secret qui doivent être utilisés pour d'autres communications. Comme avec Spotify, le processus d'autorisation est lent et ne doit être effectué qu'une seule fois.
Enfin, l'application met en œuvre le concept d'un service de musique local où la collection de musique est stockée localement. Le service nécessite que l'emplacement de la collection de musique dans le système local soit spécifié. La création d'une nouvelle instance de service se fait très rapidement, de sorte qu'une nouvelle instance peut être créée chaque fois que l'utilisateur souhaite accéder à la collection de musique.
Cet exemple présente plusieurs défis. Chaque service est initialisé avec un ensemble de paramètres différent. De plus, Spotify et Pandora nécessitent un processus d'autorisation avant que l'instance de service puisse être créée.
Ils souhaitent également réutiliser cette instance pour éviter d'autoriser l'application plusieurs fois. Le service local est plus simple, mais il ne correspond pas à l'interface d'initialisation des autres.
Dans les sections suivantes, vous allez résoudre ces problèmes en généralisant l'interface de création et en implémentant une fabrique d'objets à usage général.
Création d'objets séparés pour fournir une interface commune
La création de chaque service de musique concret a son propre ensemble d'exigences. Cela signifie qu'une interface d'initialisation commune pour chaque implémentation de service n'est ni possible ni recommandée.
La meilleure approche consiste à définir un nouveau type d'objet qui fournit une interface générale et est responsable de la création d'un service concret. Ce nouveau type d'objet sera appelé unBuilder
. L'objetBuilder
possède toute la logique pour créer et initialiser une instance de service. Vous implémenterez un objetBuilder
pour chacun des services pris en charge.
Commençons par regarder la configuration de l'application:
# 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'
}
Le dictionnaireconfig
contient toutes les valeurs requises pour initialiser chacun des services. L'étape suivante consiste à définir une interface qui utilisera ces valeurs pour créer une implémentation concrète d'un service musical. Cette interface sera implémentée dans unBuilder
.
Regardons l'implémentation desSpotifyService
etSpotifyServiceBuilder
:
# 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'
Note: L'interface du service de musique définit une méthode.test_connection()
, qui devrait suffire à des fins de démonstration.
L'exemple montre unSpotifyServiceBuilder
qui implémente.__call__(spotify_client_key, spotify_client_secret, **_ignored)
.
Cette méthode est utilisée pour créer et initialiser lesSpotifyService
concrets. Il spécifie les paramètres requis et ignore tous les paramètres supplémentaires fournis via**_ignored
. Une fois que leaccess_code
est récupéré, il crée et renvoie l'instance deSpotifyService
.
Notez queSpotifyServiceBuilder
conserve l'instance de service et n'en crée une nouvelle que la première fois que le service est demandé. Cela évite de passer plusieurs fois par le processus d'autorisation comme spécifié dans les exigences.
Faisons de même pour 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'
LePandoraServiceBuilder
implémente la même interface, mais il utilise différents paramètres et processus pour créer et initialiser lesPandoraService
. Il conserve également l'instance de service, donc l'autorisation ne se produit qu'une seule fois.
Enfin, regardons la mise en œuvre du service 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)
LesLocalService
nécessitent juste un emplacement où la collection est stockée pour initialiser lesLocalService
.
Une nouvelle instance est créée chaque fois que le service est demandé car il n'y a pas de processus d'autorisation lent. Les exigences sont plus simples, vous n'avez donc pas besoin d'une classeBuilder
. Au lieu de cela, une fonction retournant unLocalService
initialisé est utilisée. Cette fonction correspond à l'interface des méthodes.__call__()
implémentées dans les classes du générateur.
Une interface générique pour Object Factory
Une fabrique d'objets à usage général (ObjectFactory
) peut exploiter l'interface génériqueBuilder
pour créer toutes sortes d'objets. Il fournit une méthode pour enregistrer unBuilder
basé sur une valeurkey
et une méthode pour créer les instances d'objet concrètes basées sur leskey
.
Regardons l'implémentation de nosObjectFactory
génériques:
# 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 structure d'implémentation deObjectFactory
est la même que celle que vous avez vue dansSerializerFactory
.
La différence réside dans l'interface qui expose pour prendre en charge la création de tout type d'objet. Le paramètre de générateur peut être n'importe quel objet qui implémente l'interfacecallable. Cela signifie qu'unBuilder
peut être une fonction, une classe ou un objet qui implémente.__call__()
.
La méthode.create()
nécessite que des arguments supplémentaires soient spécifiés comme arguments de mot-clé. Cela permet aux objetsBuilder
de spécifier les paramètres dont ils ont besoin et d'ignorer le reste sans ordre particulier. Par exemple, vous pouvez voir quecreate_local_music_service()
spécifie un paramètrelocal_music_location
et ignore le reste.
Créons l'instance d'usine et enregistrons les générateurs pour les services que vous souhaitez prendre en charge:
# 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)
Le modulemusic
expose l'instanceObjectFactory
via l'attributfactory
. Ensuite, les générateurs sont enregistrés auprès de l'instance. Pour Spotify et Pandora, vous enregistrez une instance de leur générateur correspondant, mais pour le service local, vous passez simplement la fonction.
Écrivons un petit programme qui illustre la fonctionnalité:
# 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)}')
L'application définit un dictionnaireconfig
représentant la configuration de l'application. La configuration est utilisée comme arguments de mot clé pour la fabrique quel que soit le service auquel vous souhaitez accéder. L'usine crée l'implémentation concrète du service de musique en fonction du paramètrekey
spécifié.
Vous pouvez maintenant exécuter notre programme pour voir comment cela fonctionne:
$ 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
Vous pouvez voir que l'instance correcte est créée en fonction du type de service spécifié. Vous pouvez également voir que la demande du service Pandora ou Spotify renvoie toujours la même instance.
Spécialisation de la fabrique d'objets pour améliorer la lisibilité du code
Les solutions générales sont réutilisables et évitent la duplication de code. Malheureusement, ils peuvent également masquer le code et le rendre moins lisible.
L'exemple ci-dessus montre que, pour accéder à un service de musique,music.factory.create()
est appelé. Cela peut être source de confusion. D'autres développeurs peuvent croire qu'une nouvelle instance est créée à chaque fois et décider qu'ils doivent rester autour de l'instance de service pour éviter le lent processus d'initialisation.
Vous savez que ce n’est pas ce qui se passe car la classeBuilder
conserve l’instance initialisée et la renvoie pour les appels suivants, mais cela n’est pas clair à la lecture du code.
Une bonne solution consiste à spécialiser une implémentation à usage général pour fournir une interface concrète au contexte de l'application. Dans cette section, vous spécialiserezObjectFactory
dans le contexte de nos services de musique, afin que le code de l'application communique mieux l'intention et devienne plus lisible.
L'exemple suivant montre comment spécialiserObjectFactory
, en fournissant une interface explicite au contexte de l'application:
# 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)
Vous dérivezMusicServiceProvider
deObjectFactory
et exposez une nouvelle méthode.get(service_id, **kwargs)
.
Cette méthode invoque les.create(key, **kwargs)
génériques, donc le comportement reste le même, mais le code se lit mieux dans le contexte de notre application. Vous avez également renommé la variablefactory
précédente enservices
et vous l'avez initialisée en tant queMusicServiceProvider
.
Comme vous pouvez le voir, le code d'application mis à jour se lit beaucoup mieux maintenant:
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)}')
L'exécution du programme montre que le comportement n'a pas changé:
$ 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
Conclusion
La méthode d'usine est un modèle de conception créative largement utilisé qui peut être utilisé dans de nombreuses situations où plusieurs implémentations concrètes d'une interface existent.
Le modèle supprime le code logique complexe difficile à maintenir et le remplace par une conception réutilisable et extensible. Le modèle évite de modifier le code existant pour prendre en charge de nouvelles exigences.
Ceci est important car le changement de code existant peut introduire des changements de comportement ou des bogues subtils.
Dans cet article, vous avez appris:
-
En quoi consiste le modèle de conception de la méthode d'usine et quels sont ses composants
-
Comment refactoriser le code existant pour tirer parti de la méthode d'usine
-
Situations dans lesquelles la méthode d'usine doit être utilisée
-
Comment les usines d'objets offrent plus de flexibilité pour implémenter la méthode d'usine
-
Comment implémenter une fabrique d'objets à usage général et ses défis
-
Comment spécialiser une solution générale pour fournir un meilleur contexte
Lectures complémentaires
Si vous souhaitez en savoir plus sur la méthode d'usine et d'autres modèles de conception, je recommandeDesign Patterns: Elements of Reusable Object-Oriented Software du GoF, qui est une excellente référence pour les modèles de conception largement adoptés.
En outre,Heads First Design Patterns: A Brain-Friendly Guide par Eric Freeman et Elisabeth Robson fournit une explication amusante et facile à lire des modèles de conception.
Wikipedia a un bon catalogue dedesign patterns avec des liens vers des pages pour les modèles les plus courants et les plus utiles.