Accueil > Non classé > Les dynamic finders du pauvre

Les dynamic finders du pauvre

Avec la montée en popularité de frameworks à la Ruby on Rails, nous autres développeurs Java avons de quoi être jaloux du concept de dynamic finder supporté par exemple dans Grails, comme nous l’expliquait Cyril dans son troisième article consacré à ce sujet. Sortie de nulle part, une méthode Song.findAllByAlbumIsNull() lui permettait de ramener tous les objets de type Song dont l’attribut album était non renseigné.

Puisque les langages dynamiques1 se permettent de pratiquer la magie noire (au moyen du concept de method_missing popularisé par Ruby), je vous propose de mettre en œuvre notre meilleur vaudou pour obtenir, en Java, un résultat similaire avec un minimum d’efforts. On pourrait appeler ça des “demonic finders” si vous voulez.

Architecture classique

Dans la plupart des applications JEE que nous produisons, on observe le découpage classique Présentation > Service > DAO > stockage, les DAO (ou Data Access Object) étant spécialisés par classe d’entité qu’ils manipulent (ou idéalement par classe d’entité racine). Ainsi si votre application manipule des Account et des User, vous trouverez un AccountDAO et un UserDAO. Et parce que l’hypocrisie persiste (ah ah), vous aurez également introduit une interface (disons IAccountDAO) au cas où vous changeriez d’implémentation, passant par exemple de Hibernate à un stockage par gravure sur peau de mouton.

Je vous propose d’exploiter la présence de cette interface (contenant par exemple des méthodes telles que List<Account> IAccountDAO.findAllByOwner(Person owner) ou User IUserDAO.findByFirstnameAndLastname(String firstname, String lastname)) et de s’arrêter là. Pas la peine d’écrire l’implémentation Hibernate de cette interface alors que tout le contrat est précisé dans la signature de la méthode. Si GORM peut le faire, pourquoi pas nous ?

Si vous voulez suivre les détails d’implémentation finale dès maintenant, rendez-vous à la fin de ce billet pour retrouver un lien vers le code source. Les principes utilisés sont expliqués dans les grandes lignes à partir d’ici.

Parce qu’il s’agit plus d’un Proof of Concept que d’une vraie implémentation (voir plus bas), nous nous limiterons à la syntaxe reconnue par GORM. Une méthode finder de DAO peut ainsi ramener une seule ou plusieurs entités selon qu’elle commence par findBy ou findAllBy. Le type de retour s’adapte en conséquence. En ce qui concerne les critères, on se limitera à deux maximum (surtout en ce qui concerne le parsing, le reste du moteur pouvant s’accomoder de plus) composés le cas échéant par un opérateur booléen And ou Or. Les opérateurs supportés pour la comparaison seront ceux de GORM : http://www.grails.org/OperatorNamesInDynamicMethods, l’opérateur par défaut étant Equals.

Ainsi, la signature d’une méthode de finder devra se conformer à ceci :

1
List<T> | T find[All]ByProp1Operator1[[And|Or]Prop2Operator2](...)

ce qui, avec ma maîtrise toute relative des expressions régulières et la volonté d’aller au plus vite, devrait satisfaire l’expression suivante :


1
2
3
String regex = "^find(All)?By([A-Z][a-zA-Z]*?)
(Equals|LessThan|LessThanEquals|GreaterThan|GreaterThanEquals|Like|Ilike|NotEqual|Between|IsNotNull|IsNull)?
((And|Or)([A-Z][a-zA-Z]*?)(Equals|LessThan|LessThanEquals|GreaterThan|GreaterThanEquals|Like|Ilike|NotEqual|Between|IsNotNull|IsNull)?)?$"

Génération démonique de classe

Le principe sera le suivant : nous allons générer dynamiquement une classe implémentant l’interface de notre DAO sachant requêter via Hibernate notre base de données. Parce que le client (typiquement la couche service) manipule notre DAO au travers de l’interface, il n’y verra que du feu (c’est démoniaque je vous dis) :

dynamicfinder1

Pour réussir cette prouesse, pas besoin d’aller chercher bien loin, tout ce dont nous avons besoin se trouve déjà dans le JDK au travers du package java.lang.reflect et plus particulièrement le duo formé par la classe Proxy et l’interface InvocationHandler. C’est une classe implémentant InvocationHandler, abstraction du comportement qui devra se dérouler quand notre client appellera une méthode de notre interface, que nous devrons écrire. Cet InvocationHandler sera mentionné au moment de la création du proxy (le code suivant fait d’une pierre deux coups, il crée une classe et en renvoie une instance) :


1
2
3
ClassLoader cl = IUserDAO.class.getClassLoader();
InvocationHandler h = new MyInvocationHandler();
IUserDAO dao = (IUserDAO) Proxy.newProxyInstance(cl, new Class[] {IUserDAO.class}, h);

Ensuite, lorsque notre client (par exemple un objet de la classe service) manipulera le dao, le proxy déléguera à l’unique méthode de notre InvocationHandler, invoke, lui passant en paramètre :

  • une référence vers l’objet proxy
  • une instance de Method représentant la méthode invoquée sur le proxy. A partir de cet objet, on retrouve notamment le nom de la méthode invoquée, par exemple findAllByOwner
  • la liste des arguments avec lesquels la méthode a été invoquée sur le proxy
dynamicfinder2

1
2
3
4
5
6
7
8
9
// Lorsque notre service appelle
List<Account> list = dao.findAllByOwner(o)

// nous nous retrouvons au sein de la méthode invoke de notre InvocationHandler :
public Object invoke(Object proxy, Method m, Object[] args) throws Throwable {
    System.out.println(m); // affiche "findAllByOwner()"
    System.out.println(args[0]); // affiche l'objet "o" ci-dessus
    return null;
}

Il va falloir à présent trouver comment renvoyer un résultat plus pertinent que null, mettant en jeu Hibernate.

La solution dans les grandes lignes

Avant d’aller plus loin, présentons d’abord l’utilisation que nous ferons de notre solution. Ayant créé une interface de DAO contenant des méthodes finder, nous voulons que la librairie nous fournisse une instance de DAO respectant ce contrat, sans avoir à implémenter nous même les méthodes. De manière sous-jacente, les DAOs ainsi créés s’appuieront sur Hibernate (nous reviendrons sur ce point plus tard) :


1
2
3
SessionFactory sessionFactory = // on suppose que l'on a une sessionFactory
DynamicFinderFactory factory = new HibernateDynamicFinderFactory(sessionFactory);
ICustomerDAO customerDAO = factory.buildDAO(ICustomerDAO.class);

C’est au moment de la création de l’instance du DAO que le gros du travail sera effectué. Si nous construisons un modèle mémoire représentant les différentes méthodes du DAO (indiquant si elles ramènent une ou plusieurs instances, qu’il y a une contrainte sur telle propriété avec tel opérateur de comparaison, etc.) alors il n’y aura plus qu’à utiliser l’API Criteria d’Hibernate pour satisfaire la requête. Ce modèle mémoire associe à chaque méthode une instance de FinderDescriptor :


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
Map<Method, FinderDescriptor> descriptors = ..

avec

public class FinderDescriptor {

   public final boolean returnsAll; // findAll ou find unique ?

   public final BooleanCombinator comb; // And ou Or

   public final List<PropertyAndComparator> props;
...
}

et

public class PropertyAndComparator {

   public final String property;

   public final Comparator comparator; // Equals, Like, etc.
}

Ce modèle est construit très simplement en parsant le nom de chacune des méthodes de l’interface passée en paramètre, au moyen de l’expression régulière exposée plus haut (d’où la présence de nombreux groupes de capture dans celle-ci). On en profite au passage pour vérifier que ces signatures sont cohérentes (findAll devra renvoyer une liste, la présence du comparateur Between implique le passage en paramètre de deux valeurs, etc.)

De manière traditionnelle, chaque classe de DAO est responsable d’un type d’entité particulier, c’est à dire que IAccountDAO renvoie soit un Account soit une List<Account>. On pourrait deviner la classe de l’entité manipulée par réflexion, mais nous choisissons de nous simplifier la vie en imposant au développeur d’annoter l’interface de son DAO avec @DataAccessObject, créée pour l’occasion. Ceci pourra être exploité plus tard, nous en reparlerons ensuite :


1
2
3
4
5
6
7
8
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface DataAccessObject {

    Class<?> entityType() default Object.class;

}

Une fois tous ces éléments construits, l’implémentation de notre HibernateCriteriaDynamicFinderInvocationHandler est triviale :


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public Object invoke(Object proxy, Method method, Object[] args)
 throws Throwable {
   FinderDescriptor descriptor = descriptors.get(method);

   Criteria criteria = obtainSession().createCriteria(
       entityClassToUse(method));
   int argIndex = 0;
   for (PropertyAndComparator pac : descriptor.props) {
       criteria.add(buildRestrictionForProperty(pac, args, argIndex));
       argIndex += pac.comparator.nbArguments;
   }

   return descriptor.returnsAll ? criteria.list() : criteria
     .uniqueResult();
 }

avec buildRestrictionForProperty() qui coule de source :


1
2
3
4
5
6
7
8
9
private Criterion buildRestrictionForProperty(PropertyAndComparator pac,
 Object[] args, int argOffset) {
   switch (pac.comparator) {
     case Between:
       return Restrictions.between(pac.property, args[argOffset],
          args[argOffset + 1]);
     case Equals:
       return Restrictions.eq(pac.property, args[argOffset]);
     ...

Découplage de la solution

Finalement, nous réalisons que le gros du travail réside dans la construction des FinderDescriptor pour chaque méthode. La bonne nouvelle est que ces descripteurs ne dépendent pas du tout d’Hibernate et que le code lié à Hibernate reste très simple. Ainsi, nous découpons la solution en deux morceaux, les spécificités du moteur ORM résidant dans des sous-classes si bien qu’il est tout à fait envisageable de réutiliser 90% du code présenté ici pour créer une implémentation s’appuyant sur l’API Criteria de JPA 2.0 par exemple.

On notera également que bien que ces constructions s’intègrent très bien dans une approche IoC, le code ne dépend nullement d’un framework tel que Spring, Guice ou autre. Si l’on envisage une intégration plus poussée, c’est cette intégration qui s’appuiera sur notre approche bas niveau, et non l’inverse. Ceci pourra faire l’objet d’un autre billet :).

Enfin, pour minimiser les dépendances, les proxies dynamiques ont été créés au moyen de java.lang.reflect.Proxy qui ne supporte que les interfaces. On pourrait envisager de créer des proxies directement à partir d’une classe, si le besoin s’en faisait sentir (voir plus loin). Dans ce cas, on pourrait utiliser cglib ou javassist qui n’adhèrent pas à l’interface InvocationHandler à proprement parler mais qui utilisent une construction réellement similaire. Écrire un adapter ne serait donc pas bien compliqué.

Limitations et idées futures

Cette librairie vise à démontrer la faisabilité technique d’une idée, elle n’est pas encore 100% pragmatique. Par exemple, il est évident que vos DAOs contiennent d’autres méthodes qui ne répondent pas aux critères de finder tels qu’exposés dans cet article (ne serait-ce que des méthodes d’insertion et de suppression). Il serait donc bon de pouvoir combiner des méthodes réellement écrites à la main (dans un “stub” de DAO, abstrait donc) et les méthodes générées automagiquement par notre librairie. Pour ce faire, il faudrait faire en sorte que notre DAO généré soit une sous-classe du stub, mais cela requiert l’utilisation d’une librairie telle que cglib.

Vous aurez également remarqué que les finders supportés par notre librairie pèchent par leur violation du principe de lazy-loading expliqué dans un article précédent, ce qui est un comble ! Il ne s’agit pas d’un retournement de veste, mais bien d’un raccourci pour les besoins de cet article. Il serait tout à fait possible (et cet exercice est laissé au lecteur) d’enrichir les objets FinderDescriptor avec la liste des objets à pré-fetcher, issus du parsing des méthodes qui deviennent findByPropCompFetchingXAndY().

Un dernier point qui n’est pas traité est que les critères doivent forcément représenter des propriétés directes de notre classe entité. Ainsi, findByName() implique que notre entité doit contenir une propriété name. Il serait sympathique de pouvoir faire des findByOwnerName(), alors que notre entité possède une propriété owner qui elle même a un champ name. Ceci est envisageable mais un peu plus complexe : à l’heure actuelle, si une propriété n’existe pas, c’est au moment de la requête que l’erreur sera remontée par Hibernate. Si l’on veut supporter les sous-propriétés, il faudrait avoir accès à un meta-modèle décrivant les propriétés de chaque entité. C’est un problème classique auquel j’avais été confronté lors de la création du projet LIQUidFORM : faire de l’introscpection à la recherche de getter/setters ne suffit pas, comme le mentionne G. King dans ce post. C’est ce qui l’a conduit à créer le meta-modèle de JPA2, expliqué en pratique ici.

Comme d’habitude, le code est disponible sur notre repository google code. N’hésitez pas à commenter, voire à améliorer le framework.

1 : notez que l’outil Spring ROO permet lui aussi de jouir du principe de finder dynamique, bien qu’il s’agisse là de génération de code statique. Voyez par exemple cet article sur le blog de SpringSource

Share
  1. Pierre-Yves RICAU
    29/01/2010 à 10:55 | #1

    Le framework disponible sur le repository est bien plus complet que ce qui est présenté dans l’article, je vous invite à y faire un tour c’est très instructif.

    Citation de l’article : Il serait donc bon de pouvoir combiner des méthodes réellement écrites à la main (dans un « stub » de DAO, abstrait donc) et les méthodes générées automagiquement par notre librairie. […] cela requiert l’utilisation d’une librairie telle que cglib.

    On pourrait aussi y parvenir d’une manière sioux en utilisant uniquement les proxy JDK : en définissant une interface mère comportant la définition des méthodes à la mano, et un stub qui implémenterait uniquement cette interface.

    Exemple :

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    public interface IStubUserDAO {
      Stroumph myCustomMethod();
    }

    [...]

    @DataAccessObject(entityType=User.class)
    public interface IUserDAO extends IStubUserDAO {
      User findByFirstname(String firstname);
    }

    [...]

    public class StubUserDAO implements IStubUserDAO {
      public Stroumph myCustomMethod() {
        return new GrandStroumph();
      }
    }

    [...]

    ClassLoader cl = IUserDAO.class.getClassLoader();
    InvocationHandler h = new MyInvocationHandler(IUserDAO.class, new StubUserDAO());
    IUserDAO dao = (IUserDAO) Proxy.newProxyInstance(cl, new Class[] {IUserDAO.class}, h);

    dao.findByFirstname("");
    dao.myCustomMethod();
    [...]
  2. Eric BOTTARD
    29/01/2010 à 11:40 | #2

    Comme le mentionne PY, le code actuel sur le trunk a été enrichi pour permettre les stubs (même si je ne suis pas satisfait du couplage induit sur l’interface, il serait plus pertinent d’annoter la classe stub. On y travaille). Jetez un oeil au test nommé annotationOnItfWithSpecifiedStub

    Concernant la ruse avec deux interfaces, je pense qu’un tel framework se doit d’être le moins intrusif possible (Le but est de gagner du temps, on le rappelle). S’il faut introduire une deuxième interface juste pour faire une pirouette technique, on n’a finalement rien gagné :)

  3. Maxime Picque
    29/01/2010 à 11:50 | #3

    Pour le cas des méthodes à la mano, je pense que nous pouvons imaginer un comportement du genre :

    1
    dao.delete[All][ByProp]([params])

    Il est donc tout à fait possible d’ajouter le delete à notre dynamic finder (mais ça complexifie d’autant plus le InvocationHandler) :

    1
    2
    3
    4
    5
    6
    7
    8
    public class FinderDescriptor {
        public final boolean delete;    

        public final boolean returnsAll; // findAll ou find unique ?
        public final BooleanCombinator comb; // And ou Or
        public final List props;
        ...
    }

    et

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
        FinderDescriptor descriptor = descriptors.get(method);
       
        if (descriptor.delete) {
            StringBuilder hqlQuery = new StringBuilder("delete from ");
            builder.append(entityClassToUse(method).getName());
            if (descriptor.props != null && !descriptor.props.isEmpty()) {
                builder.append(" where ");
                argIndex = 0;
                for (PropertyAndComparator pac : descriptor.props) {
                    // ajout des différents paramètres
                    builder.append(buildHQLRestrictionForProperty(pac, argIndex));
                    argIndex += pac.comparator.nbArguments;
                }
            }

            Query query = obtainSession().createQuery(hqlQuery.toString());
            argIndex = 0;
            for (PropertyAndComparator pac : descriptor.props) {
                // ajout des paramètres de sélection
                setHQLRestriction(query, pac, args, argIndex);
                argIndex += pac.comparator.nbArguments;
            }
        } else {
            // finder
        }
    }

    avec pour la méthode buildHQLRestrictionForProperty et setHQLRestriction :

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    private String buildHQLRestrictionForProperty(PropertyAndComparator pac, int argOffset) {
        switch (pac.comparator) {
        case Between:
            return pac.property + " between :arg" + argOffset + " and :arg" + (angOffser + 1);
        case Equals:
            return pac.property + " = :arg" + argOffset;
        [...]
        }
    }

    private void setHQLRestriction(Query query, PropertyAndComparator pac, Object[] args, int argOffset) {
        switch (pac.comparator) {
        case Between:
            query.setParameter("arg" + argIndex, args[argIndex]);
            query.setParameter("arg" + (argIndex + 1), args[argIndex + 1]);
            break;
        case Equals:
            query.setParameter("arg" + argIndex, args[argIndex]);
            break;
        [...]
        }
    }

    PS : j’ai fait ce code à la va vite, il pourrait y avoir des erreurs.

  4. Pierre-Yves RICAU
    29/01/2010 à 12:17 | #4

    @Eric BOTTARD
    C’est vrai que c’est plus beau avec des annotations, mais dès lors l’affirmation “tout ce dont nous avons besoin se trouve déjà dans le JDK” ne tient plus ;-) . Ceci dit, utiliser cglib c’est pas la mort :-)

  5. 29/01/2010 à 14:09 | #5

    La solution proposée est relativement identique à ce que Fait Hades (cf la session correspondante lors du dernier Devoxx).

    Pour ceux qui cherchent une autre piste, vous pouvez aussi tenter l’aventure avec un dev perso qui se rapproche plutôt de Spring ROO : Fonzie (http://code.google.com/p/loof/wiki/Fonzie)

    NB : ce n’est pas un clone, je l’ai sorti 2 jours avant l’annonce officielle de Roo !

  6. Eric BOTTARD
    29/01/2010 à 14:34 | #6

    Le framework Hades (que je découvre à l’instant) dont parle Nicolas (merci!) est très intéressant (avec une doc soignée, en tout cas) : http://redmine.synyx.org/wiki/hades

    Comme quoi, ce post qui relevait plus du POC et du mini tutorial sur Proxy/InvocationHandler, peut déboucher sur de belles découvertes !

    J’insiste en revanche sur le fait qu’un DAO qui n’est pas clair sur ce qu’il fetche est un problème de perfs à la clef…

    Sinon, Fonzie, c’est coooooool

  7. 09/02/2010 à 15:54 | #7

    C’est vraiment dans l’air du temps, cette idée !
    J’ai implémenté exactement la même chose dans gaedo (http://gaedo.origo.ethz.ch/blog/%5Buser-raw%5D/dynamic_finders_the_reference). La différence, à mon sens, c’est que je peux utiliser à peu près n’importe quel back-end, que ce soit Google App Engine datastore, une base de donnée relationnelle, …

  8. 10/02/2010 à 14:51 | #8

    A noter également que Doctrine, l’ORM PHP utilisé notamment dans symfony, propose les Magic Finders qui permettent de retrouver des objets en fonction de la valeur d’une ou plusieurs colonnes, combinées avec des And ou des Or.

    On pourra par exemple avoir un

    1
    Doctrine_Core::getTable('User')->findOneByUsernameAndPassword('bastien', 'monSuperPassword');

    .

  1. 06/05/2010 à 15:07 | #1