Introduction à la ZCA

Le dans «fr python» par Cyprien Le Pannérer
Mots-clés:

La ZCA est ensemble de bibliothèque implémentant une série de design pattern :

  • interface,
  • registry,
  • factory,
  • adapter.

ZCA signifie Zope Component Architecture. La ZCA a été créé pour zope mais est utilisable sans difficulté en dehors de zope. Il ne faut surtout pas se laisser effrayer par le mote zope : la ZCA n'est pas zope. Il n'y a pas besoin de comprendre zope pour s'en servir.

Les design patterns

Les design patterns ou patron de conceptions sont une série de concepts de programation objet établie par le gang of four. Ces patrons sont des organisations de code ou de classes récurrentes dans la programtion objet.

Le gang of four en établit 23. Je ne vais pas les détailler ici mais seulement les quatre cités plus haut. Ces patrons ne sont pas spécifiques à un langage mais sont implémentables dans la plupart des langages objets.

Installation

Seuls deux packages sont nécéssaires : zope.interface et zope.component.

Via pip :

$ pip install zope.interface zope.component

Buildout :

[buildout]
parts =
       ZCA

[ZCA]
recipe = zc.recipe.egg
eggs =
      zope.interface
      zope.component

Interface

Utilisée seule, l'interface est le pattern le moins intéressant en python. Les interfaces sont très (voire trop) utilisées en Java.

Une interface est une classe abstraite (classe non instansciable ni héritable (sauf par une autre interface)) qui est un contrat pour les classes l'implémentant.

from zope.interface import Interface
from zope.interface import Attribute


class IDuck(Interface):
    """
    Duck description.
    """

    name = Attribute("name of the duck")

    def kwack():
        """
        Sound of a duck
        """

Ce code définit une classe IDuck (Une convention assez fréquente quelque soit le langage est de préfixer par un I.) qui est une interface. On définit ici qu'un canard doit avoir un nom et caquete.

Définisons deux especes de canards : cygne et colvert.

from zope.interface import Interface
from zope.interface import Attribute
from zope.interface import implementer


class IDuck(Interface):
     """
     Duck description.
     """

     name = Attribute("name of the duck")

     def kwack():
         """
         Sound of a duck.
         """


@implementer(IDuck)
class Swan(object):
     """
     Swan implementation of IDuck.
     """

     def __init__(self, name):
         self.name = name

     def kwack(self):
         print("swan kwack")


@implementer(IDuck)
class Mallard(object):
     """
     Mallard implementation of IDuck.
     """

     def __init__(self, name):
         self.name = name

     def kwack(self):
         print("Mallard kwack")


s = Swan('foo')
m = Mallard('baz')

for duck in [s, m]:
    print(duck.name)
    duck.kwack()

A l'usage cela donne :

$ bin/python interface.py
foo
swan kwack
baz
Mallard kwack

Sauf que grace au duck typing de python le code suivant produit exactement le même résultat.

class Swan(object):
     """
     Swan implementation of Duck.
     """

     def __init__(self, name):
         self.name = name

     def kwack(self):
         print("swan kwack")


class Mallard(object):
     """
     Mallard implementation of Duck.
     """

     def __init__(self, name):
         self.name = name

     def kwack(self):
         print("Mallard kwack")


s = Swan('foo')
m = Mallard('baz')

for duck in [s, m]:
    print(duck.name)
    duck.kwack()

So what ? Comme dit plus haut ça ne sert pas à grand chose en python... Un petit rappel sur le duck typing : « Si ça fait coin, c'est un canard. ». En python, il n'y a pas de typage statique. Les types ne comptent pas (ou très peu), Les méthodes et les attributs sont plus importants.

Avec la ZCA et contrairement à java, les interfaces sont un contrat entre developppeurs. Le premier s'engage à utiliser les méthodes d'une interface et le second à écrire une classe qui implémente cette interface. En java, c'est un contrat entre le code et le compilateur : si le contrat n'est pas respecté, le code ne compile pas.

Avec la ZCA, il n'y a pas de contrainte :

@implementer(IDuck)
class Cat(object):
     """
     Dude, this is a non sens !!!
     """

     def __init__(self, name):
         self.name = name

s = Swan('foo')
m = Mallard('baz')
c = Cat('bar')

for duck in [s, m, c]:
    print(duck.name)
    duck.kwack()

et donc à l'exécution :

$ bin/python interface.py
foo
swan kwack
baz
Mallard kwack
bar
Traceback (most recent call last):
  File "bin/python", line 78, in <module>
    execfile(__file__)
  File "/tmp/interface.py", line 58, in <module>
    duck.kwack()
AttributeError: 'Cat' object has no attribute 'kwack'

On peut implémenter une interface sans la respecter. Ce n'est pas très pertinent mais possible. L'interface ne sert que de contrat et de documentation.

Utilisée seule l'interface ZCA n'apporte rien à part ajouter un tas de lignes inutiles.

Les registres

Les registres (voire plutôt le registre) ne sont pas un pattern du GoF. Un registre est un singleton (lui c'est un design pattern) qui mémorise l'instance d'autres classes.

class Registry(object):
     _instance = None

     def __new__(cls):
         if cls._instance is None:
             cls._intance = Registry()
         return cls._instance

     def __init__(self):
         self._memory = {}

    def save(self, name, instance):
        self._memory[name] = instance

    def get(self, name):
        return self._memory[name]


reg1 = Registry()
reg2 = Registry()

print(reg2 is reg1)

Nous avons ici une classe qui n'est instanciée qu'une seule fois et qui nous permet de sauver des objets puis de les récupérer. Ce mécanisme est intéressant si l'on utilise des objets couteux à intancier et très rapide à utiliser. Dans mon boulot, je travaille beaucoup sur des fichiers xmls que je dois valider à l'aide d'une grammaire (xsd le plus souvant). La construction de l'outil de validation prends un temps certain.

from glob import iglob
from lxml import etree

# ces 2 lignes coutent
xsdRoot = etree.parse(pathToXsdFile)
xsd = etree.XMLSchema(xsdRoot)

for xml in iglob("*.xml"):
    root = etree.parse(xml)
    print(xsd.validate(root))

Ce code représente un cas idéal où tous les xml sont au même endroit et en même temps. Dans une version plus proche de la réalité, les xmls sont servis par un webservice qui doit répondre le plus vite possibe.

Dans un __init__.py, on aurait le code suivant :

reg = Registry()
reg.save('xsd', xsd) # le xsd de notre bloc de code précédent.

et dans le fichier views.py :

# récupération du xml à valider plus dans un code imaginaire avant

reg = Registry()
xsd = reg.get('xsd')

return xsd.validate(xml)

Une autre solution à ce problème aurait été de faire un singleton avec cette xsd mais si on a besoin d'une autre classe de ce genre il faut refaire un singleton. De plus, on pourrait avoir plusieurs types de grammaires avec chacun leur xsd associée et on ne peut plus faire un singleton dans ce cas la.

reg = Registry()
reg.save('xsd1', xsd1)
reg.save('xsd2', xsd2)
reg.save('xsd3', xsd3)
reg = Registry()
xsd = reg.get(request.POST['xsd'])

return xsd.validate(xml)

Revenons à la ZCA, celle-ci offre un registry[2] : le gsm. gsm signifie Global Site Manager. Le gsm peut mémoriser des objets selon une interface.

[2]C'est inexact : le GSM est un registry offert par la ZCA mais on peut très bien hériter de la classe zope.interface.registry.Components pour implémenter le sien.

Reprenons notre exemple de xsd (inspiré de mon job).

from zope.interface import Interface
from zope.interface import implementer
from zope.component import getGlobalSiteManager

# Créons une classe IValid
class IValid(Interface):
    def validate(document):
        """
        Validate a document.
        """

# puis notre classe de validation
@implementer(IValiad)
class Xsd(object):
    def __init__(self, xsdfile):
        xsdRoot = etree.parse(pathToXsdFile)
        self._xsd = etree.XMLSchema(xsdRoot)

   def validate(document):
       """
       Validate a document.
       """
       return self._xsd.validate(document)


xsd = Xsd('/path/to/gramar.xsd')

gsm = getGlobalSiteManager()
gsm.registerUtility(xsd, IValid)

Et dans un lointain fichier :

from zope.component import getUtility

xsd = getUtility(IDuck)

xsd.validate(someDocument)

Avec ce mécanisme, on ne peut récupérer qu'un seule instance de Xsd ce qui n'apporte rien par rapport à un singleton. L'intérêt est d'avoir des instances différentes pour chacune des grammaires que l'on manipule.

gsm = getGlobalSiteManager()

xsd1 = Xsd('/path/to/gramar1.xsd')
gsm.registerUtility(xsd1, IValid, 'gramar1')

xsd2 = Xsd('/path/to/gramar2.xsd')
gsm.registerUtility(xsd2, IValid, 'gramar2')

xsd3 = Xsd('/path/to/gramar3.xsd')
gsm.registerUtility(xsd3, IValid, 'gramar3')

Si l'on reprend l'exemple plus haut :

from zope.component import getUtility

xsd = getUtility(IValid, request.POST['grammar'])

return xsd.validate(document)

Le registry nous permet de sauver des instances pour les utiliser plus tard. C'est un outil extrèmement pratique mais il faut le réserver aux objets dont la construction est coûteuse. Un usage abusif du registry va transformer votre code en code spagetti. Un inconveniant majeur de cet outil est qu'on perd la notion d'import de nos parties métiers.

Les factories

Une factory est une classe[1] qui instancie une autre classe.

[1]Le GoF définit cela comme une méthode. La zca, comme beaucoup, a définit la factory comme une classe.
class Duck(object):
     """
     This is a  duck.
     """

     def __init__(self, name):
          self.name = name


class FactoryDuck(object):
    """
    This is an egg ?
    """
    def __call__(self, name):
        """
        Lets make a duck.
        """
        return Duck(name)


fd = FactoryDuck()
duck = fd('foo')

print(type(duck))
print(duck.name)
$ bin/python /tmp/factory.py
<class '__main__.Duck'>
foo

Une factory sert à déporter l'instanciation d'un objet à une autre classe pour plusieurs raisons comme :

  • on appelle fréquement une classe qui a tendance à changer de chemin au fil de ses versions,
  • lors de l'écriture du code, on ignore la classe à instancier (ex: la connexion à une base donnée).

Explorons le premier cas. C'est un pis-aller à un mauvais problème. Notre code utilise un code tier qui à tendance à changer d'arborescence. Ce code est fourni par le pypi, par un fournisseur avec qui il faut utiliser un protocole proprio dont il faut utilise le code fourni. Bref un cas ou on est contraint d'utiliser un code mouvant.

Dans la première version, la classe Connection est dans fournisseur.class, dans la seconde, le fournisseur a lu un bouquin sur java et donc la classe se trouve dans com.fournisseur.class et dans une troisième version fournisseur.classes.connector etc, etc. L'effet de bord de cette promenade est qu'il faut repasser dans tous nos fichiers appelant ce code pour corriger.

Une solution est d'utiliser une factory[3] pour palier en partie à ce problème.

# cet import est soumis au bon vouloir du fournisseur
from fournisseur.classes.connector import Connection

class FactoryConnection(object):
    def __call__(self, *args, **kwargs):
        return Connection(args, kwargs)
[3]Ici la factory est une classe mais cela pourrait être une méthode. J'utilise ici une classe par goût et par habitude.

Puis dans notre code, on utilise partout cette factory pour ne pas réécrire en permanence nos imports mais seulement un dans la definition de la factory.

instance = FactoryConnection(args1, args2, kwargs1='value1')

On aurait également pu faire un héritage pour résoudre ce problème. C'est une solution à un mauvais problème. Je mentionne cet usage qui n'est pas très courant mais il peut arriver qu'on le croise.

L'usage plus courant (et plus sain) est le cas où l'on ignore la classe à instancier lors de l'écriture du programe. L'exemple typique est la connexion à la base de données dans un ORM comme SQLAlchemy.

Lorsque le programme est écrit on ignore (volontairement parfois) le SGBD qui sera utilisé lors du déploiement. Lors du lancement, le connecteur au SGBD utilise une factory pour savoir quelle classe de connexion à la base de données utiliser.

Imaginons qu'on doive décompresser des archives zip ou tar selon le type de fichier.

import os.path
from zipfile import ZipFile
from tarfile import TarFile


class FactoryArchive(object):
    """
    Build Archive.
    """
    _choices = {'zip': ZipFile,
               'tar': TarFile}

    def __call__(self, filename):
        """
        Return the correct class.
        """
        _, ext = os.path.splitext(os.path.basename(filename))
        return self._choices[ext.lower()](filename, mode='r')

L'usage du code serait le suivant :

# filename is something like /foo/bar/baz.tar or /foo.bar.baz.zip

factory = FactoryArchive()
archive = factory(filename)

archive.extractall('/tmp')

À l'écriture du code, on ignore si TarFile ou ZipFile seront appelés [4]

[4]Ici le code va planter lamentablement sur foo.tar.bz2 car le cas n'est pas géré. Il convient de prévoir les exceptions pour gérer ces cas.

La ZCA contient déja une classe Factory qui implémente une classe IFactory.

# factories for build ZipFile or TarFile.
from zipfile import ZipFile
from tarfile import TarFile

from zope.component.factory import Factory

factoryZip = Factory(ZipFile, 'zip')
factoryTar = Factory(TarFile, 'tar')

Et à l'usage :

import os.path

choices = {'zip': factoryZip,
          'tar': factoryTar}

_, ext = os.path.splitext(os.path.basename(filename))

factory = choices[ext.lower()]
archive = factory(filename)

archive.extractall('/tmp')

Jusque là cela ne réduit pas beaucoup le code ni le simplifie vraiment. Couplé au gsm vu plus haut les choses commencent à devenir plus intéressantes.

# factories for build ZipFile or TarFile.
from zipfile import ZipFile
from tarfile import TarFile

from zope.component.interfaces import IFactory
from zope.component import getGlobalSiteManager
from zope.component.factory import Factory

factoryZip = Factory(ZipFile, 'zip')
factoryTar = Factory(TarFile, 'tar')

gsm = getGlobalSiteManager()

gsm.registerUtility(factoryZip, IFactory, 'zip')
gsm.registerUtility(factoryTar, IFactory, 'tar')

Ce qui devient à l'usage :

import os.path

from zope.component import getUtility
from zope.component.interfaces import IFactory

_, ext = os.path.splitext(os.path.basename(filename))

factory = getUtility(IFactory, ext)
archive = factory(filename)

archive.extractall('/tmp')

On ne réduit pas le nombre de lignes de façon significative mais on réduit l'écriture de code complexe qu'on écrit soit même pour le déléguer à la ZCA.[5]

[5]Dans cet exemple, on ignore complètement qu'on utilise factoryZip ou factoryTar. Cela peut-être à double tranchant et faire du code spagetti.

La classe Factory implémente l'interface IFactory. On peut écrire soit même des classes factory l'implémentant.

from zipfile import ZipFile
from tarfile import TarFile

from zope.component.interfaces import IFactory
from zope.component import getGlobalSiteManager
from zope.interface import implementer

@implementer(IFactory)
class ArchiveFactory(object)
    _choices = {'zip': ZipFile,
               'tar': TarFile}

    def __call__(self, filename):
        """
        Return the correct class.
        """
        _, ext = os.path.splitext(os.path.basename(filename))
        return self._choices[ext.lower()](filename, mode='r')


gsm = getGlobalSiteManager()
gsm.registerUtility(ArchiveFactory(), IFactory, 'archive')

Ce qui devient à l'usage :

from zope.component import getUtility
from zope.component.interfaces import IFactory

factory = getUtility(IFactory, 'archive')
archive = factory(filename)

archive.extractall('/tmp')

Les adaptateurs

Les adapters sont des classes qui présentent une classe implémentant une interface en présentant une autre.

Une classe Foo ne sait manipuler que des IBar mais on n'a sous la main que BazA et BazC qui implémentent tout deux IBaz.

from zope.interface import Interface
from zope.interface import implementer
from zope.interface import adapater


class IBar(Interface):
     """
     Interface IBar.
     """
     def bar():
         """
         Method bar.
         """

class IBaz(Interface):
     """
     Interface IBaz
     """

     def foo1(arg1):
         """
         First action.
         """

    def foo2():
        """
        And the second one.
        """


 @impleteter(IFoo)
 class Baz1(object):
     def foo1(self, arg1):
         self.arg1 = arg1

     def foo2(self):
         print('Baz1', self.arg1)

 @impleteter(IFoo)
 class Baz2(object):
     def foo1(self, arg1):
         self.arg1 = arg1

     def foo2(self):
         print('Baz2', self.arg1)

@adapater(IFoo)
@impleteter(IBar)
class AdapaterFoo(object):
    """
    """
    def __init__(self, baz):
        self._baz = baz

    def baz(self):
        self._baz.foo1("default args")
        self._baz.foo2()


#registering the adapater
gsm = getGlobalSiteManager()
gsm.registerAdapter(AdapaterFoo, name='adapt foo')

Nous avons donc créé deux interfaces, classes et un adapteur. Si on veut ce servir de ce code cela donnera ceci :

from zope.component import getAdapter
# baz est un objet Baz1 ou Baz2
# barUSer est un objet utilisant des IBar

adapte = getAdapter(baz, IBar)
# adapte est un AdapterFoo

barUser.set(adapte)
#[...]

Cet exemple est quelque peu abstrait et un peu long. Le point intéressant est que lorsque qu'une nouvelle classe implémentant IFoo sera créée il n'y aura aucun code supplémentaire à écrire pour s'en servir. Sans les adaptateurs, il aurait fallu écrire des classes spécifiques pour Baz1, Baz2 et toute autre nouvelle classe implémentant IFoo.

Mélangeons tout ça

Jusque ici tout mes exemples étaient assez abstraits, je vous en propose un plus constistant en mélangeant tout ce qu'on a vu précédement.

L'exemple est un programme qui va acquérir des archives, les décompresser, les modifier puis les stocker via différents services. Notre programme prendra ses instructions d'un fichier texte passé en argument.

Nous allons supposer que nos différentes parties viennent de différents composants déja écrits par des tiers ou pour d'autres projets.

La gestion des ftp et http se fera par une interface IRemote avec deux implémentations : http et ftp (ainsi qu'un abstract pour factoriser).

L'API simpliste spécifiée par l'interface IRemote nous permet de télécharger et d'uploader un fichier.

class IRemote(Interface):
    """
    Interface for handle remote file.
    """

    def get(filename):
        """
        Get a file.
        """

    def put(filename):
        """
        Upload a file.
        """


class AbstractRemote(object):
    def __init__(self, url):
        """
        Take an url as argument.
        """
        self._url = url


@implementer(IRemote)
class FTPRemote(AbstractRemote):
    def __init__(self, url):
        super(FTPRemote, self).__init__(url)
        self._ftp = FTP(self._url)

    def get(self, filename):
        self._ftp.login()
        self._ftp.retrbinary('RETR %s' % os.path.basename(filename),
                             open(filename, 'wb').write)
        self._ftp.close()

    def put(self, filename):
        self._ftp.login()
        self._ftp.storbinary('STOR %s' % os.path.basename(filename), 4,
                             open(filename, 'rb').read)
        self._ftp.close()


@implementer(IRemote)
class HTTPRemote(object):
    def get(self, filename):
        with open(filename, 'wb') as tmp:
            req = requests.get(self._url + '/' + os.path.basename(filename))
            tmp.write(req.read())

    def put(self, filename):
        with open(filename, 'rb') as tmp:
            files = {'data': tmp}
            requests.post(self._url + '/' + os.path.basename(filename), files)

Le composant suivant est la gestion des archives. Comme pour les accès distants, on adope une interface simpliste IArchive avec deux mréethodes : extractAll et compressAll.

class IArchive(Interface):
    """
    Inferface for handle compressed archive.
    """
    def extractAll(pathTo):
        """
        Exctract all file in dir pathTo.
        """

    def compressAll(pathFrom):
        """
        Compress all files from dir pathFrom.
        """


class AbstractArchive(object):
    def __init__(self, filename):
        self._filename = filename


@implementer(IArchive)
class ZipArchive(AbstractArchive):

    def extractAll(self, pathTo):
        with ZipFile(self._filename, 'r') as tmp:
            tmp.extractall(pathTo)

    def compressAll(self, pathFrom):
        with ZipFile(self._filename, 'w') as tmp:
            for todo in os.walk(pathFrom):
                tmp.write(todo, os.path.basename(todo))

@implementer(IArchive)
class TarArchive(AbstractArchive):

    def extractAll(self, pathTo):
        with TarFile(self._filename, 'r') as tmp:
            tmp.extractall(pathTo)

    def compressAll(self, pathFrom):
        with TarFile(self._filename, 'w') as tmp:
            for todo in os.walk(pathFrom):
                tmp.write(todo, os.path.basename(todo))

Et pour finir, puisées dans notre mirobolante bibliothèque de composants, les modifications à apporter aux contenus des fichiers.

class ITransform(Interface):

    def open(filename):

    def close():

    def transform(newName):

    filename = Attribute("filename")

class AbstractTransform(object):

    def open(self, filename):
        self._filename = filename
        self._content = open(self._filename, 'rb')

    def close(self):
        self._content.close()

    @property
    def filename(self):
        return filename

@implementer(ITransform)
class LowerTransform(AbstractTransform):

    def transform(self, newName):
        with open(newName, 'wb') as tmp:
            for line in self._content:
                tmp.write(line.lower())


@implementer(ITransform)
class UpperTransform(AbstractTransform):

    def transform(self, newName):
        with open(newName, 'wb') as tmp:
            for line in self._content:
                tmp.write(line.upper())

@implementer(ITransform)
class Rot13Transform(AbstractTransform):

    def transform(self, newName):
        import string
        rot13 = string.maketrans( "ABCDEFGHIJKLMabcdefghijklmNOPQRSTUVWXYZnopqrstuvwxyz",
                                  "NOPQRSTUVWXYZnopqrstuvwxyzABCDEFGHIJKLMabcdefghijklm")

        with open(newName, 'wb') as tmp:
            for line in self._content:
                tmp.write(string.translate(line, rot13))


@implementer(ITransform)
class Base64Transform(AbstractTransform):

    def transform(self, newName):
        import base64
        with open(newName, 'wb') as tmp:
            for line in self._content:
                tmp.write(base64.b64encode(line))

Tous ces composants sont pré-écrits pour des projets précédents ou sont écrits pour être utilisés dans d'autre projets dont les spécificités sont différentes du projet actuel. Il nous faudra enregistrer les factories de ces classes dans le gsm pour les utiliser plus tard.

Mais d'abord il nous faut désigner nos actions à l'aide d'une interface IAction.

class IAction(Interface):
    """
    Interface of action classes
    """
    def init(filename):
        """
        """

    def process(args):
        """
        """

Une fois IAction décrite, il suffit d'écrire la série d'adapters et de les enregistrer :

# Now we need adapters
gsm = getGlobalSiteManager()


@implementer(IAction)
@adapter(IRemote)
class AdapterGet(object):
    def __init__(self, adapte):
        self._adapte = adapte


    def process(self, arg):
        self._adapte.get(arg)


@implementer(IAction)
@adapter(IRemote)
class AdapterPut(object):
    def __init__(self, adapte):
        self._adapte = adapte


    def process(self, arg):
        self._adapte.put(arg)

gsm.registerAdapter(AdapterGet, 'get')
gsm.registerAdapter(AdapterPut, 'put')


@implementer(IAction)
@adapter(IArchive)
class AdapterExtract(object):
    def __init__(self, adapte):
        self._adapte = adapte

    def process(self, arg):
        self._adapte.extractAll(arg)


@implementer(IAction)
@adapter(IArchive)
class AdapterCompress(object):
    def __init__(self, adapte):
        self._adapte = adapte

    def process(self, arg):
        self._adapte.compressAll(arg)


@implementer(IAction)
@adapter(ITransform)
class AdapterTranform(object):
    def __init__(self, adapte):
        self._adapte = adapte

    def process(self, arg):
        tmp = tempfile.mkstemp()
        self._adapte.open()
        self._adapte.transform(tmp)
        self._adapte.close()

        os.move(tmp, self._adapte.filename)


gsm.registerAdapter(AdapterTranform)

On enregistre ensuite la série de factories. Les factories ne sont pas enregistrées par les composants mais dans notre programme. Cela permet de les nommer comme on le désire dans chaque programme.

ftpFactory = Factory(FTPRemote, 'ftp')
gsm.registerUtility(ftpFactory, IFactory, 'ftp')

httpFactory = Factory(HTTPRemote, 'http')
gsm.registerUtility(httpFactory, IFactory, 'http')

zipFactory = Factory(ZipArchive, 'zip')
gsm.registerUtility(zipFactory, IFactory, 'zip')

tarFactory = Factory(TarArchive, 'tar')
gsm.registerUtility(tarFactory, IFactory, 'tar')

Et parce que j'en ai marre, on factorise le truc.

# I'm bored, I refactore the next factories
for klass, doc in [(LowerTransform, 'lower'),
                   (UpperTransform, 'upper'),
                   (Rot13Transform, 'rot13'),
                   (Base64Transform, 'base64')]:
    factory = Factory(klass, doc)
    gsm.registerUtility(factory, IFactory, doc)

Et pour finir la partie spécifique de notre programme :

def splitInstruction(instruction):
    """
    """
    action, arg1, arg2 = instruction.split(' ')
    if '_' in action:
        return action.split('_'), arg1, arg2
    else:
        return action, ' ', arg1, arg2

instructions = open(sys.argvs[1])

for instruction in instructions:
    actionName, variante, arg1, arg2 = splitInstruction(instruction)

    factory = getUtility(IFactory, actionName)

    instance = factory()(arg1)

    if variante:
        action = getAdapter(instance, IAction, variante)
    else:
        action = getAdapter(instance, IAction)

    action.process(arg2)

Si on doit rajouter une classe qui implémente une interface que nous avons déja adaptée l'écriture du code devient très facile et très courte.

L'ajout d'une nouvelle classe Base64Decode qui implémente déja ITransform se résume à l'import de Base64Decode et l'ajout d'une factory pour Base64Decode.

factory = Factory(Base64Decode, 'base64decode')
gsm.registerUtility(factory, IFactory, 'base64decode')

De même pour une série de classes implémentant la même interface, il suffira d'écrire l'adapater et la série de factories.

Conclusion

La ZCA n'est pas spécifique à zope ou plone ; on la retrouve dans des projets comme pyramid ou twisted.

La ZCA oblige à penser sous forme de composants « agnostiques » et plus généralistes puis à les incorporer, les adaptant à nos applications métiers. À court terme, le processus est plus coûteux car il faut concevoir de façon générique mais à moyen terme et surtout à long terme le temps de développement se réduit beaucoup car on réutilise en permanence des composants déja écrits. Ces composants étant plus petits et plus mono taches sont faciles à tester.

Je n'ai pas tout abordé voici quelques pointeurs qui peuvent être utiles pour aller plus loin ou completer mes dires :

  • un article en anglais sur la ZCA,
  • sa traduction en français,
  • billet de blog du traducteur du lien précédent.

Merci à jpcw pour la relecture.