27 janvier 2009

Développement : à propos d'injection de dépendances (DI)

Petit topo sur la DI telle que je la pratique en Java en ce moment.

Généralités

L'injection de dépendances (en anglais Dependency Injection, DI) est aujourd'hui un principe fondamental de la programmation. Elle évite par exemple de devoir instancier directement des implémentations. Du code comme celui-ci :
public class PeopleManagerImpl implements PeopleManager {

    private final EmailSender sender = new EmailSenderImpl();
    private final Collection<Person> people = 
        new ArrayList<Person>();

    public void welcomeNewPerson(String name, String email) {
        people.add(new Person(name, email));
        sender.sendMail(mail, "Welcome " + name);
    }
}
Devient par exemple avec l'injection de dépendances :
import static com.google.common.base.Preconditions.checkNotNull;
import com.google.inject.Inject;

public class PeopleManagerImpl implements PeopleManager {

    private final EmailSender sender;
    private final Collection<Person> people = 
        new ArrayList<Person>();

    @Inject
    public PeopleManagerImpl(EmailSender sender) {
        this.sender = checkNotNull(sender);
    }

    public void welcomeNewPerson(String name, String email) {
        people.add(new Person(name, email));
        sender.sendMail(mail, "Welcome " + name);
    }
}
L'injection de dépendances est particulièrement utile pour les tests, ainsi que pour la programmation orientée aspects (AOP).

Comparée à d'autres types d'injection de champs, par exemple via des accesseurs ou en manipulant directement les champs, je trouve que l'injection par le constructeur est la plus fiable. Vu qu'on peut tout manipuler au runtime en Java cela relève au final d'une sorte de convention, mais cette convention est bien relayée par les compilateurs et les IDEs.

Comme on l'aura compris au vu du code précédent, c'est l'approche suivie par Google Guice, bibliothèque que j'apprécie particulièrement. Parmi ses avantages :
  • son API est typée (Java 5),
  • la responsabilité de la bibliothèque est bel et bien limitée à l'injection de dépendances,
  • la configuration se fait en Java, donc avec les avantages du typage, et non en XML ou autre.
Pour le code ci-dessus, voici un exemple de code client qui récupère une instance et l'utilise :
PeopleManager peopleManager =    
    injector.getInstance(PeopleManager.class);
peopleManager.welcomeNewPerson(
    "Jean Dupont", "jean.dupont@xxx.com");
Injecter une ressource avec cycle de vie

Parlons par exemple de l'injection d'une connexion SQL, qu'il s'agit de fermer à la fin de son utilisation. Je sais je sais, je suis vieux jeu en gérant mon mapping O/R moi-même.

Le code suivant accède à la base de données :
public class PeopleManagerImpl implements PeopleManager {

    private final Collection<Person> people = 
        new ArrayList<Person>();

    public void persistPeople() {
        try {
            Connection cxn = ...; // ?
            try {
                PreparedStatement pstmt = 
                  cxn.prepareStatement("INSERT INTO people"
                    + " (name, email) VALUES (?, ?)");
                try {
                    for (Person p : people) {
                        pstmt.setString(1, p.getName());
                        pstmt.setString(2, p.getName());
                        pstmt.executeUpdate();
                    }
                } finally {
                    pstmt.close();
                }
            } finally {
                cxn.close();
            }
        } catch (SQLException e) {
            throw new UncheckedBusinessException(e);
        }
    }
}


Noter la transformation des SQLException en exceptions métier (qui soient Runtime, histoire d'alléger le code).

La question est : comment récupérer l'objet Connection ? On ne peut pas l'injecter par le constructeur de PeopleManagerImpl, car chaque appel de méthode qui accède à la base de données se termine par un cxn.close() donc au bout du deuxième appel sur le même objet cxn ça plantera.

La solution est évidemment d'injecter une factory, en l'occurrence un provider qui crée une nouvelle  connexion à chaque fois :
public class PeopleManagerImpl implements PeopleManager {

    private final ConnectionProvider cxnProvider;
    ...

    @Inject
    public PeopleManagerImpl(ConnectionProvider cxnProvider) {
        this.cxnProvider = checkNotNull(cxnProvider);
    }

    public void persistPeople() {
        try {
            Connection cxn = cxnProvider.createConnection();
            try {
                PreparedStatement pstmt = 
                    cxn.prepareStatement(...);
                ...
            } finally {
                cxn.close();
            }
        } catch (SQLException e) {
            throw new UncheckedBusinessException(e);
        }
    }
}
Personnellement je trouve super pénible cette obligation d'appeler soi-même la méthode create() sur la factory et de faire le close() final (eh oui, il n'y a pas de destructeurs en Java), sans compter la transformation de l'exception. 9 lignes de code pour rien au total.

Injection de paramètres dans les méthodes

J'ai donc trouvé une autre solution : changer la signature des méthodes dans l'implémentation,  pour y injecter des objets dont le cycle de vie est géré par le container.

Cela implique de casser le lien entre interface et implémentation. Même si je déclare ce lien à la compilation par une interface paramétrée - que j'ai appelée BeanImpl -, la cohérence entre les méthodes de l'implémentation et celles de l'interface ne sont vérifiées qu'au runtime.

Les méthodes à appeler dans le cycle de vie sont également annotées. Quant à la classe d'implémentation elle-même, elle continue d'être gérée par Guice, ce qui permet d'utiliser @Inject.
public class PeopleManagerImpl 
implements BeanImpl<PeopleManager> {

     final ConnectionProvider cxnProvider;

     @Inject
     public PeopleManagerImpl(ConnectionProvider cxnProvider) {
          this.cxnProvider = checkNotNull(cxnProvider);
     }

     public void persistPeople(@BeanInject Connection cxn) 
     throws SQLException {
            PreparedStatement pstmt = cxn.prepareStatement(...);
            try {
                ...
            } finally {
                pstmt.close();
            }
     }

     @InitBeanInject
     private Connection createConnection() 
     throws SQLException {
          return cxnProvider.createConnection();
     }

     @ReleaseBeanInject
     private void releaseConnection(Connection cxn) 
     throws SQLException {
          cxn.close();
     }
}
Je gagne ainsi en lisibilité dans mes méthodes (sauf quand le code est affiché dans Blogger, apparemment...), donc en productivité de développement.


Noter que le code du client est toujours le suivant :
PeopleManager peopleManager = 
    injector.getInstance(PeopleManager.class);
...
À la compilation, il n'y a plus qu'un lien lâche entre l'interface et l'implémentation-cœur. Une autre contrainte de cette méthode est que l'interface du Bean PeopleManager ne peut pas avoir deux méthodes de même nom avec des signatures différentes. En fait cela était déjà une convention dans mes programmes, pour des raisons de portabilité vers d'autres langages comme PHP.

En tout cas j'aime bien.

Aucun commentaire: