Qu'est ce qu'on se fait ch ... !

Aller au contenu | Aller au menu | Aller à la recherche

Write once , run everywhere ... sauf sur google app engine

Après la mise à jour des différents composants ( GAE #1.3.5 , primefaces 2.1RC1, ...) me voila reparti à re-développer une appli sur GAE. Après quelques galères tests unitaires, j'ai pu me rendre compte des nombreuses limitations à JPA / GAE.

Les requêtes

D'abord, seul JPA V1 est implémenté. Ça a l'air con comme ca, mais par exemple on ne peut pas utiliser les CriteriaQuery alors que l'API JDO fournie par Google fournit les Filter.Après , me direz vous, c'est pas l'extase, ca ne fait pas exactement la même chose, on peut coder directement les critères en JPQL. Mais voila dès que vous voulez faire des recherches un peu larges avec par exemple un formulaire de recherche à critères multiples, vous pouvez obtenir l'erreur suivante :

testcase: testFindLike(info.touret.mycellar.test.BottleTest):        Caused an ERROR
Problem with query <SELECT A FROM info.touret.winecellar.pojo.Bottle A WHERE A.name like 'BOUTEILLE%'  or A.vintage like '2001%'>: Or filters cannot be applied to multiple properties (found both name and vintage).
org.datanucleus.store.appengine.query.DatastoreQuery$UnsupportedDatastoreFeatureException: Problem with query <SELECT A FROM info.touret.winecellar.pojo.Bottle A WHERE A.name like 'BOUTEILLE%'  or A.vintage like '2001%'>: Or filters cannot be applied to multiple properties (found both name and vintage).
        at org.datanucleus.store.appengine.query.DatastoreQuery.addLeftPrimaryOrExpression(DatastoreQuery.java:1131)
        at org.datanucleus.store.appengine.query.DatastoreQuery.addLeftPrimaryExpression(DatastoreQuery.java:1105)
        at org.datanucleus.store.appengine.query.DatastoreQuery.addPrefix(DatastoreQuery.java:931)
        at org.datanucleus.store.appengine.query.DatastoreQuery.handleMatchesOperation(DatastoreQuery.java:891)
        at org.datanucleus.store.appengine.query.DatastoreQuery.addExpression(DatastoreQuery.java:864)
        at org.datanucleus.store.appengine.query.DatastoreQuery.addExpression(DatastoreQuery.java:835)

Un peu bête ...

Les jointures et fetching

Sur ce sujet, je me suis arraché les cheveux pas mal de temps. Exemple : ne Jointure OneToMany ne me ramenait pas du tout les entités en question lors d'un select. Que faire ?? Après quelques recherches sur la toile, je me suis rendu à l'évidence, que GAE ne gérait que les jointures via les clés primaires. Oubliez les belles jointures bi directionnelles et uni directionnelles JPA . L'insertion , la modification ne peut s'effectuer que par les clés primaires

Exemple avec une relation onetoone

@OneToOne(cascade = {CascadeType.REFRESH})
    private Key producer;

Mais il est possible de "hacker" la matrice en rajoutant un autre attribut à notre classe qui aurait la configuration suivante :

@OneToOne(cascade = {CascadeType.REFRESH})
    @Column(name = "producer", insertable = false, updatable = false)
    private Producer producerAlias;

Donc pour les insertions, suppressions, nous sommes obligés de passer par l'instance de la classe Key, par contre, une sélection passerait par l'alias. Cette manipulation permet de gérer la jointure ( avec fetch !) directement au niveau de la requête JPQL.

Exemple :

Query query = em.createQuery("select from Bottle b join b.producerAlias");

C'est un peu biaisé, mais bon ca simplifie la vie au niveau des requêtes.

Après, ce n'est que mon avis, je me suis trouvé pas mal obligé de dé-normaliser mes relations entre entités. Par exemple, je me suis mis dans l'idée de faire un nuage de tags. Bien au lieu de créer un pojo tag qui serai persisté directement dans big table, j'ai préféré créer un attribut tagline pour mon entité maître. Ca m'a pris moins de temps à créer. après va falloir que j'optimise les requêtes par une gestion de cache par exemple.

Conclusion :

Quand on développe sur GAE, il faut à mon avis bien penser aux contraintes de cette plateforme, surtout sur la persistance des données. Le développeur JAVAEE habitué à hibernate/jpa peut vite pédaler dans la choucroute au début. A mon avis ( et pas que ) JDO est à préférer. l'API semble connaître moins de limites.

Pas mal de plaintes on été faite à ce sujet. Je viens de voir un article sur une recherche full text. A voir ...

Google App Engine / JSF 2 / Facelets / Primefaces

Retour sur GWT

Après quelques semaines sur GWT, je me suis vite lassé. Si on n'a pas d'éediteur graphique digne de ce nom ou une librairie intéressante de widget,on perd pas mal de temps à réaliser des applications dignes de ce nom ( à moins de s'appeler google biensûr...) Je suis donc revenu aux fondamentaux et me voila revenu sur JSF. J'en ai profité pour tester la version 2.0 sur Google App Engine. Et la c'est le drâme. C'est un vrai parcours du combattant ou un champ de mines, à vous de voir.

Problèmes rencontrés

Pas mal de problèmes que j'ai eu ont été résolus en me référant sur ce tutoriel ou sur cette page chez google

Pour faire simple, voici un résumé des problèmes

  • La version 1.3.2 de l'appengine ne fonctionnait pas chez moi avec netbeans :-( Obligé de passer en 1.3.1
  • L'implémentation standard de JSF2 n'est pas supporté par défaut dans GAE car elle utilise la classe InitialContext qui n'est pas supportée.
  • Il y a pas mal de paramètres à ajouter dans le web.xml ( cf ci-après )
  • Pas de page de debug de facelets car nous sommes obligé de travailler dans un mode différent de DEVELOPMENT
  • Le scope VIEW n'est pas "trop" supporté
  • Problème XALAN ( voir après)

Dépendances à ajouter

Commons-logging

Petite dépendance nécessaire et qui n'apparaît qu'à l'éxecution

log4j

Idem

facestrace

Très utile pour le debug. De plus ce composant est bien intégré à primefaces. Il suffit d'ajouter le paramètre de requête HTTP trace=true pour que la console facestrace apparaisse.

xalan

Si comme moi vous avez l'erreur suivante

    com.sun.faces.config.ConfigurationException: CONFIGURATION FAILED! 
    com.sun.org.apache.xalan.internal.xsltc.runtime.BasisLibrary is a restricted class. Please see the Google App 
    Engine developer's guide for more details.

Vous devez ajouter les librairies serializer.jar et xalan.jar provenant du projet xalan dans le répertoire WEB-INF/lib de votre webapp.

Ma Configuration

web.xml

<?xml version="1.0" encoding="UTF-8"?>
<web-app version="2.5" xmlns="http://java.sun.com/xml/ns/javaee" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://java.sun.com/xml/ns/javaee http://java.sun.com/xml/ns/javaee/web-app_2_5.xsd">
    <display-name>
        wine cellar
    </display-name>
    <description>
        wine cellar
    </description>
    <!-- ***** GAE 1.3.0 appears to handle server-side state saving. *****  -->
    <context-param>
        <param-name>javax.faces.STATE_SAVING_METHOD</param-name>
        <param-value>client</param-value>
    </context-param>
    <context-param>
        <param-name>javax.faces.PROJECT_STAGE</param-name>
        <param-value>Production</param-value>
    </context-param>
    <context-param>
        <param-name>javax.faces.DEFAULT_SUFFIX</param-name>
        <param-value>.xhtml</param-value>
    </context-param>
    <context-param>
        <param-name>com.sun.faces.expressionFactory</param-name>
        <param-value>com.sun.el.ExpressionFactoryImpl</param-value>
    </context-param>
    <context-param>
        <description>
            Set this flag to true if you want the JavaServer Faces
            Reference Implementation to validate the XML in your
            faces-config.xml resources against the DTD. Default
            value is false.
        </description>
        <param-name>com.sun.faces.validateXml</param-name>
        <param-value>true</param-value>
    </context-param>
    <!-- ***** Accommodate Single-Threaded Requirement of Google AppEngine  -->
    <context-param>
        <description>
            When enabled, the runtime initialization and default ResourceHandler
            implementation will use threads to perform their functions. Set this
            value to false if threads aren't desired (as in the case of running
            within the Google Application Engine).
 
            Note that when this option is disabled, the ResourceHandler will not
            pick up new versions of resources when ProjectStage is development.
        </description>
        <param-name>com.sun.faces.enableThreading</param-name>
        <param-value>false</param-value>
    </context-param>
    <!-- primefaces -->
    <context-param>
        <param-name>com.sun.faces.allowTextChildren</param-name>
        <param-value>true</param-value>
    </context-param>
    <!-- Faces Servlet -->
    <context-param>
        <param-name>com.sun.faces.enableMultiThreadedStartup</param-name>
        <param-value>false</param-value>
    </context-param>
    <servlet>
        <servlet-name>Faces Servlet</servlet-name>
        <servlet-class>javax.faces.webapp.FacesServlet</servlet-class>
        <load-on-startup>1</load-on-startup>
    </servlet>
    <servlet>
        <servlet-name>Resource Servlet</servlet-name>
        <servlet-class>
            org.primefaces.resource.ResourceServlet
        </servlet-class>
    </servlet>
    <servlet-mapping>
        <servlet-name>Resource Servlet</servlet-name>
        <url-pattern>/primefaces_resource/*</url-pattern>
    </servlet-mapping>
    <servlet-mapping>
        <servlet-name>Faces Servlet</servlet-name>
        <url-pattern>/faces/*</url-pattern>
        <url-pattern>*.jsf</url-pattern>
    </servlet-mapping>
    <session-config>
        <session-timeout>30</session-timeout>
    </session-config>
    <welcome-file-list>
        <welcome-file>index.jsp</welcome-file>
        <welcome-file>index.xhtml</welcome-file>
        <welcome-file>index.html</welcome-file>
    </welcome-file-list>
</web-app>

Le fichier faces-config.xml

<faces-config version="2.0"
    xmlns="http://java.sun.com/xml/ns/javaee" 
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" 
    xsi:schemaLocation="http://java.sun.com/xml/ns/javaee http://java.sun.com/xml/ns/javaee/web-facesconfig_2_0.xsd">
<application>
    <locale-config>
      <default-locale>en</default-locale>
    </locale-config>
  </application>
 
    <managed-bean>
...
    </managed-bean>
 
</faces-config>

Limitations

Et oui il y a des limitations à tout ça : L'un des gros plus de la spec JSF2 est la scope view qui permet de conserver l'état d'un composant au sein d'une page dans n'importe quelle étape du cycle de vie JSF. Bref, c'est entre le scope session et le scope request. GAE ne semble pas trop le supporter. Il est même préférable de n'utiliser que le scope request. Personnellement j'ai du adapter mon design à un mode réellement sans état. GAE ne supporte pas le PROJECT_STAGE Development Il faut donc le paramétrer tel quel

<context-param>
        <param-name>javax.faces.PROJECT_STAGE</param-name>
        <param-value>Production</param-value>
    </context-param>

Cette configuration semble occulter la page d'erreur standard de facelets qui est très utile pendant le développement. Heureusement facestrace est la.

Enfin, l'une des grosses limitations est la suppression des données entre chaque arrêt/relance du SDK GAE. Je n'ai pas encore cherché trouvé comment les garder en mémoire.

Conclusion

A cette armé mexicaine de composants, il ne me manque qu'une solution d'IoC ( probablement CDI ) et surement un framework d' AOP. GAE fournit déjà pas mal de composants permettant de gérer les objets en mémoire et d'améliorer les perfs.

La suite dans un prochain numéro...

Installer et configurer Glassfish en mode cluster - 1ère partie

Après avoir fait l'équivalent sous JBOSS, j'essaye de rentrer dans le mouve et réitérer la même chose sous Glassfish

Création d'une application démo

J'ai réalisé pour l'occasion une petite application de démonstration. Elle gère un panier. Dans un premier temps, je lui fait gérer ce dernier en session HTTP. Après, je le ferai via un EJB STATEFUL

Voici l'architecture en vrac:

  • JSF 1.2
  • Facelets
  • Richfaces
  • EJB3
  • JPA
  • JDK 6

Bref, que du lourd techniquement :-D D'un point de vue fonctionnel, ce n'est , certes pas très transcendant. J'ai utilisé la base de données Travel fournie en standard sur netbeans.

Un screenshot de l'appli : glassfish-1.jpg

Configuration GLASSFISH

Création du domaine

Il faut initialiser un domaine glassfish. Si comme moi, vous avez installé Glassfish à partir du bundle netbeans, nous aurez à modifier légèrement le fichier $GLASSFISH_HOME/setup-cluster.xml

Pour info, la propriété domain.name est spécifié comme suit:

<property name="domain.name" value="domain1"/>

Il faut la modifier pour que le domaine puisse être crée.

warning.png ATTENTION : vous devez faire attention aux ports spécifiés dans ce fichier. Il ne faut pas qu'ils soient en conflit avec des applications existantes ( un domaine glassfish existant, un serveur jboss perdu dans un coin ...)

Après lancer la commande suivante :

$GLASSFISH_HOME/lib/ant/bin/ant -f setup-cluster.xml
Buildfile: setup-cluster.xml
[...]
validate-java:
     [echo] Current Java Version 1.5.0_16
[...]

create.domain:
     [exec] Using port 4848 for Admin.
     [exec] Using port 8080 for HTTP Instance.
     [exec] Using port 7676 for JMS.
     [exec] Using port 3700 for IIOP.
     [exec] Using port 8181 for HTTP_SSL.
     [exec] Using default port 3820 for IIOP_SSL.
     [exec] Using default port 3920 for IIOP_MUTUALAUTH.
     [exec] Using default port 8686 for JMX_ADMIN.
     [exec] Domain being created with profile:developer, as specified by variabe AS_ADMIN_PROFILE in configuration file.
     [exec] The file in given locale [fr_FR] at: [C:\java\servers\glassfish-v2u2\lib\install\templates\locales\fr_FR\index.html] could not be found. Using defult (en_US) index.html instead.
     [exec] Security Store uses: JKS
     [exec] Domain domain-cluster-1 created.
     [exec] Admin login information for host [localhost] and port [4848] is beig overwritten with credentials provided. This is because the --savelogin option
was used during create-domain command.
     [exec] Login information relevant to admin user name [admin] for this doman [domain-cluster-1] stored at [C:\Documents and Settings\touret-a\.asadminpass
 successfully.
     [exec] Make sure that this file remains protected. Information stored in this file will be used by asadmin commands to manage this domain.
   [delete] Deleting: c:\java\servers\glassfish-v2ur2\passfile

BUILD SUCCESSFUL
Total time: 29 seconds

Peut être que l'installation multi JDK me fera défaut....Wait & see ....

Lancement du serveur

Avec asadmin:

$ bin/asadmin.bat start-domain --user admin domain-cluster-1
Starting Domain domain-cluster-1, please wait.
Log redirected to C:\java\servers\glassfish-v2ur2\domains\domain-cluster-1\logs\server.log.
29 oct. 2008 08:56:34 com.sun.jbi.installer.JBIComponentsConfigurator configure
INFO: Configuring jbi components...
29 oct. 2008 08:56:42 com.sun.jbi.installer.JBIComponentsConfigurator configure 
INFO: OpenESB JBI Components Configuration Successful
The operation CONFIGURE on the addon jbi_components_configurator is complete.
Redirecting output to C:/java/servers/glassfish-v2ur2/domains/domain-cluster-1/l
ogs/server.log
Domain domain-cluster-1 is ready to receive client requests. Additional services are being started in background.
Domain [domain-cluster-1] is running [Sun Java System Application Server 9.1_02
(build b04-fcs)] with its configuration and logs at: [C:\java\servers\glassfish-v2ur2\domains].
Admin Console is available at [http://localhost:4848].
Use the same port [4848] for "asadmin" commands.
User web applications are available at these URLs:
[http://localhost:8080 https://localhost:8181 ].
Following web-contexts are available:
[/web1  /__wstx-services ].
Standard JMX Clients (like JConsole) can connect to JMXServiceURL:
[service:jmx:rmi:///jndi/rmi://127.0.0.1:8686/jmxrmi] for domain management purposes.
Domain listens on at least following ports for connections:
[8080 8181 4848 3700 3820 3920 8686 ].
Domain does not support application server clusters and other standalone instances.

Pour les utilisateurs du couple netbeans/ glassfish

Vous devez vous connecter sur la console d'administration et effectuer les actions suivantes

Dans le menu "Serveur d'Applications" glassfish001.jpg

Activer le support du mode cluster glassfish002.jpg

puis redémarrer le serveur d'applications

Création du cluster

$ bin/asadmin.bat create-cluster --user admin --host 127.0.0.1 --port 4848 cluster-1
Please enter the admin password>
Command create-cluster executed successfully.

Création de l'agent

$ bin/asadmin.bat create-node-agent --user admin --host 127.0.0.1 --port 4848 cluster-1-node-agent-1
Please enter the admin password>
Command create-node-agent executed successfully.

Après la création on les démarre:

$ bin/asadmin.bat start-node-agent cluster-1-node-agent-1
Please enter the admin user name>admin
Please enter the admin password>
Please enter the master password [Enter to accept the default]:>
Redirecting output to C:/java/servers/glassfish-v2ur2/nodeagents/cluster-1-node-agent-1/agent/logs/server.log
Redirecting application output to C:/java/servers/glassfish-v2ur2/nodeagents/cluster-1-node-agent-1/agent/logs/server.log
Command start-node-agent executed successfully.

Création des instances contenues dans le noeud (node)

Première instance

$ bin/asadmin.bat create-instance --user admin --nodeagent cluster-1-node-agent -1 --cluster cluster-1 cluster-1-nodeagent-1-instance-1
Using 38á081 for HTTP_LISTENER_PORT.
Using 38á182 for HTTP_SSL_LISTENER_PORT.
Using 33á821 for IIOP_SSL_LISTENER_PORT.
Using 37á677 for JMS_PROVIDER_PORT.
Using 33á701 for IIOP_LISTENER_PORT.
Using 38á687 for JMX_SYSTEM_CONNECTOR_PORT.
Using 33á921 for IIOP_SSL_MUTUALAUTH_PORT.
Command create-instance executed successfully.

Deuxième instance

$ bin/asadmin.bat create-instance --user admin --nodeagent cluster-1-node-agent-1 --cluster cluster-1 cluster-1-nodeagent-1-instance-2
Using 38á082 for HTTP_LISTENER_PORT.
Using 38á183 for HTTP_SSL_LISTENER_PORT.
Using 33á822 for IIOP_SSL_LISTENER_PORT.
Using 37á678 for JMS_PROVIDER_PORT.
Using 33á702 for IIOP_LISTENER_PORT.
Using 38á688 for JMX_SYSTEM_CONNECTOR_PORT.
Using 33á922 for IIOP_SSL_MUTUALAUTH_PORT.
Command create-instance executed successfully.

Démarrage des instances

$ bin/asadmin.bat start-instance cluster-1-nodeagent-1-instance-1
Command start-instance executed successfully.
$ bin/asadmin.bat start-instance cluster-1-nodeagent-1-instance-2
Command start-instance executed successfully.

Conclusion

J'en ai fini de la première partie, qui était la création de mon premier cluster. Mon ressentiment est très positif. On peut faire la totalité avec asadmin, la configuration des ports est automatique, ce qui est un GROS voire ENORME avantage par rapport à JBOSS :-). Je trouve même que ca rend beaucoup plus professionnel et me conforte dans l'idée que les serveurs d'application JAVA EE propriétaires sont désormais obsolètes. Un gros inconvénient, c'est qu'il existe qu'un seul domaine. Je pense faire prochainement un DAS et un domaine applicatif. La suite dans un prochain épisode

Mon CRUD Session Bean

Vu que 80% des actions effectuées sur un projet tiennent du CRUD, il m' est apparu utile de créer un EJB générique qui pourrait être utilisé ou étendu dans d'autres EJB session

Voici la bête. Elle réutilise le findByExample que j'ai crée avant

C'est simple mais vachement utile

@Stateless
public class CRUDBean<T> implements CRUDLocal<T> {

    @PersistenceContext
    EntityManager em;

    public T add(T entity) {
        em.persist(entity);
        return entity;
    }

    public T update(T entity) {
        return em.merge(entity);
    }

    public void delete(T entity) {
        em.remove(entity);
    }

    public List<T> findAll(Class<T> entityDescription) {
            return em.createQuery("select o from " + entityDescription.getName() + " o ").getResultList();
    }

    public T findByPrimaryKey(Class<T> entityDescription, Long id) {
        return em.find(entityDescription, id);
    }

    public List<T> findByExample(T example, OPERATOR operator) {
        List<T> results = new ArrayList<T>();
        StringBuffer queryByExample =
                new StringBuffer("select A from ").append(
                example.getClass().getSimpleName()).append(" A where ");
        Field[] fields = example.getClass().getDeclaredFields();
        // on remplit la requete
        int numberOfParameters = 0;
        final String CLAUSE = " ".concat(String.valueOf(operator)).concat(" ");
        try {
            for (Field current : fields) {
                current.setAccessible(TRUE);
                if (!Modifier.isStatic(current.getModifiers())) {
                    if (current.get(example) != null) {
                        queryByExample = queryByExample.append(" A.").append(
                                current.getName()).
                                append(" = ").
                                append(":").
                                append(current.getName()).append(CLAUSE);
                        numberOfParameters++;

                    }
                }
            }
            if (queryByExample.substring(queryByExample.length() - CLAUSE.length()).equalsIgnoreCase(CLAUSE)) {
                queryByExample =
                        queryByExample.delete(queryByExample.length() - CLAUSE.length() + 1, queryByExample.length());
            }
            // on peuple la requete
            Query query = em.createQuery(queryByExample.toString());

            for (Field current : fields) {
                current.setAccessible(TRUE);
                if (!Modifier.isStatic(current.getModifiers())) {
                    if (current.get(example) != null) {
                        if (current != null) {
                            query.setParameter(current.getName(), current.get(example));
                        }
                    }
                }
            }
            results = query.getResultList();
        } catch (Exception e) {

        }
        return results;
    }
}


Pour exécuter ce code à forte valeur ajoutée, il vous faudra un vrai serveur JAVA EE5 ( donc vous pouvez oublier WEBLOGIC 10 qui ne supporte pas les EJB SESSION GENERIQUES :-D)

Ajouter à cet EJB, vous pouvez coupler un validateur appelé par un AroundInvoke et le tour est joué :-)

Le findByExample en JPA

Une des grosses fonctionnalités qui manque cruellement dans la version JPA1. L'idéee est de créer une requête dynamiquement par rapport aux attributs d'un pojo . Ce genre d'une fonctionnalité peut être utilisé dans les écrans de recherche avancé où l'utilisateur peut insérer un ou plusieurs renseignements.

Si vous n'avez pas comme implémentation JPA Toplink Essentials ou Hibernate, vous devrez donc réaliser à la mimine cette fonctionnalité.

Il y a encore quelques améliorations à réaliser sans doute mais voici le code :

  public List<T> findByExample(T example) {
        List<T> results = new ArrayList<T>();
        StringBuffer queryByExample =
            new StringBuffer("select A from ").append(
            example.getClass().getSimpleName()).append(" A where ");
        Field[] fields = example.getClass().getDeclaredFields();
        // on remplit la requete
        int numberOfParameters = 0;
        final String AND_CLAUSE = " AND ";
        try {
            for (Field current : fields) {
                current.setAccessible(TRUE);
                if (!Modifier.isStatic(current.getModifiers())) {
                    if (current.get(example) != null) {
                        queryByExample = queryByExample.append(" A.").append(
                            current.getName()).
                            append(" = ").
                            append(":").
                            append(current.getName()).append(AND_CLAUSE);
                        numberOfParameters++;

                    }
                }
            }
            if (queryByExample.substring(queryByExample.length() - AND_CLAUSE.
                                         length()).equalsIgnoreCase(AND_CLAUSE)) {
                queryByExample =
                    queryByExample.delete(queryByExample.length() - AND_CLAUSE.
                                          length() + 1, queryByExample.length());
            }
                // on peuple la requete
            Query query = em.createQuery(queryByExample.toString());
            // @TODO: a refactoriser --> copier / coller
            for (Field current : fields) {
                current.setAccessible(TRUE);
                if (!Modifier.isStatic(current.getModifiers())) {
                    if (current.get(example) != null) {
                        if (current != null) {
                            query.setParameter(current.getName(), current.get(example));
                        }
                    }
                }
            }
            results = query.getResultList();
        } catch (Exception e) {
            System.err.println(e.getMessage());
            e.printStackTrace();
        }
        return results;
    } 

Après deux ans d'utilisation d'un FRAMEWORK Struts Spring Hibernate

il était temps de faire un bilan du déploiement d'applications JAVA EE basées sur les célèbres FRAMEWORKS STRUTS SPRING et HIBERNATE..

Lire la suite...