Le modèle de méthode d’usine et son implémentation en Python

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