dimanche 3 février 2013

Ma participation à CodeStory en java : Intro

La première étape du concours Code Story vient de se terminer, je vais donc tenter de faire un résumé de ma participation à ce concours. Pour les curieux, le classement est disponible ici.
Etant sans doute un peu malade, j'ai participé à ce concours 3 fois : en Java, en Ceylon et en Scala. Mon inscription principale avec le code le plus soigné et complet reste celle en Java. Je vais donc dans un premier temps vous faire un retour sur la partie Java. L'ensemble du code source de ma participation en java est disponible sur github
Les étapes de l'application étant nombreuses, je vais découper ça en plusieurs articles :

Principes du concours

Afin de comprendre les choix que j'ai faits au niveau du code, il est important de connaître les principes de ce concours. Au départ, on s'inscrit en donnant juste une URL publique (ex : http://serveur.mondomain.fr:8080). Le seul truc que l'on sait avant de commencer, c'est qu'on va recevoir une requête GET sur http://serveur.mondomain.fr:8080/?q=Quelle+est+ton+adresse+email à laquelle il faut répondre avec l'adresse email, pour les curieux, le règlement est disponible ici. On se doute également dès le départ qu'on recevra d'autres requêtes HTTP auxquelles il faudra répondre (d'où le besoin de logs).


Répondre à des questions fixes

Les premières requêtes n'étaient pas très compliquées, il s'agissait simplement de questions avec une réponse fixe attendue. Pour information, voici la liste des questions reçues :

  • ?q=Quelle est ton adresse email
  • ?q=Es tu abonne a la mailing list(OUI/NON)
  • ?q=Es tu heureux de participer(OUI/NON)
  • ?q=Es tu pret a recevoir une enonce au format markdown par http post(OUI/NON)
  • ?q=Est ce que tu reponds toujours oui(OUI/NON)
  • ?q=As tu bien recu le premier enonce(OUI/NON)
  • ?q=As tu bien recu le second enonce(OUI/NON)
  • ?q=As tu passe une bonne nuit malgre les bugs de l etape precedente(PAS_TOP/BOF/QUELS_BUGS)
  • ?q=As tu copie le code de ndeloof(OUI/NON/JE_SUIS_NICOLAS)
  • ?q=Souhaites-tu-participer-a-la-suite-de-Code-Story(OUI/NON)
Afin de pouvoir rapidement faire évoluer l'application pour ajouter la gestion de futures questions, voici comment j'ai développé.

Première étape le test JUnit (et oui j'ai fait du TDD), voici donc l'exemple du test pour la troisième question :

@Test
public void should_answer_to_participate() throws Exception {
    WebConversation wc = new WebConversation();
    WebResponse response = wc.getResponse(getURL() + "/?q=Es tu heureux de participer(OUI/NON)");
    assertEquals(200, response.getResponseCode());
    assertEquals("Response must be 'OUI'", "OUI", response.getText());
}
Le test est donc très simple, rapide à écrire, et se met à la place du robot pour effectuer le test (la librairie utilisée est HttpUnit).

Voyons maintenant comment j'ai codé le serveur. A travers les premières questions, on se rend compte qu'une partie des requêtes reçues va l'être avec le paramètre "q", j'ai donc décidé de créer la notion de QueryHandler chargée de répondre aux requêtes reçues avec ce paramètre. Voici la classe abstraite associée :

public abstract class AbstractQueryHandler {
    public abstract String getResponse(String query);
}

La tuyauterie pour appeler le handler (et le bon) et relativement simple. Côté handler HTTP, c'est juste la récupération du paramètre et l'appel d'une méthode de la classe chargée d'appeler le bon QueryHandler :

String query = request.getParameter(QUERY_PARAMETER);
if (query != null) {
    response = QueryType.getResponse(query);
}
La variable "response" contient le texte à renvoyer, mais également le status HTTP à utiliser. La classe "QueryType" est en fait un "enum", prenant en paramètre de constructeur le handler à utiliser, et ayant une méthode abstraite "isThisQueryType" prenant en paramètre la "query" et renvoyant un boolean permettant de savoir si c'est lui qui est responsable de cette "query". Et pour finir, cet enum contient une méthode statique chargée d'appeler le bon QueryHandler en fonction de la query. Voici la classe avec l'exemple de question vu plus haut :
import fr.ybonnel.codestory.WebServerResponse;
import javax.servlet.http.HttpServletResponse;

public enum QueryType {

    PARTICIAPATE(new FixResponseQueryHandler("OUI")) {
        @Override protected boolean isThisQueryType(String query) {
            return query.equals("Es tu heureux de participer(OUI/NON)");
        }
    };

    private AbstractQueryHandler queryHandler;

    QueryType(AbstractQueryHandler queryHandler) {
        this.queryHandler = queryHandler;
    }

    protected abstract boolean isThisQueryType(String query);

    public static WebServerResponse getResponse(String query) {
        for (QueryType oneQuestion : values()) {
            if (oneQuestion.isThisQueryType(query)) {
                return new WebServerResponse(HttpServletResponse.SC_OK, oneQuestion.queryHandler.getResponse(query));
            }
        }
        return new WebServerResponse(HttpServletResponse.SC_NOT_FOUND, "Query " + query + " is unknown");
    }
}
Vous pouvez donc voir dans cette enum l'apparition de la classe FixResponseQueryHandler, cette classe est très simple, son rôle est simplement de renvoyer une réponse fixe, la voici :
public class FixResponseQueryHandler extends AbstractQueryHandler {

    private String response;
    public FixResponseQueryHandler(String response) {
        this.response = response;
    }

    @Override public String getResponse(String query) {
        return response;
    }
}

Voici donc tous les éléments que j'ai mis en place pour les questions avec réponses fixes. A posteriori, on aurait pu mettre en place une simple Map (c'est ce que j'ai fait en Ceylon et en Scala), mais c'est le problème de coder au fur et à mesure que les questions arrivent, on ne sait pas trop ce qui va arriver ensuite... J'aurais évidement pu refactorer tout ça (les tests unitaires me permettant d'être sûr de ne pas tout casser), mais j'ai eu la flemme, d'autant que ce n'est pas la partie la plus intéressante, les énoncés suivants étant bien plus sympas.


Les logs

Un élément important de ce concours, c'est que nous ne recevions aucune consigne par mail, twitter ou pigeon voyageur. Le seul mode d'interaction était l'envoi d'une requête HTTP par le robot qui renvoyait la requête tant qu'on ne répondait pas correctement. Afin de pouvoir être réactif aux nouvelles requêtes en toute circonstance, je me suis ajouté un système de logs en base de donnée avec possibilité de les récupérer par un appel http (ce qui me permettait de la faire même depuis mon téléphone). Je ne vais pas vous détailler comment j'ai mis en place ce système, mais plutôt comment j'ai mis en place la BDD tout en gardant un système simple et rapide à tester.

Mise en place de la BDD

Comme vous le savez, je n'ai pas de stack compliquée pour cette application, pour mettre en place une BDD simple, je suis parti sur un h2 embarqué. Première étape ajout de la dépendance au pom.xml :

<dependency>
    <groupId>com.h2database</groupId>
    <artifactId>h2</artifactId>
    <version>1.3.170</version>
</dependency>

Sur l'utilisation de celle-ci, rien d'extra-ordinaire, je suis parti sur du jdbc à la main. Ça peux paraître archaïque, mais pour gérer 2 pauvres tables (une pour les logs et une pour les énoncés), pas besoin de sortir l'artillerie lourde. Pour les curieux, voici la classe centrale pour gérer la base :

import com.google.common.base.Throwables;
import org.h2.jdbcx.JdbcDataSource;

import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.SQLException;
import java.sql.Statement;

public enum DatabaseManager {

    INSTANCE;

    public static final String TYPE_Q = "Q";
    public static final String DB_DRIVER = "org.h2.Driver";
    public static final String DB_USER = "sa";

    private JdbcDataSource ds;

    DatabaseManager() {
        try {
            Class.forName(DB_DRIVER);

            boolean databaseExists = doesDatabaseExists();

            ds = new JdbcDataSource();
            ds.setURL(DatabaseUtil.getUrl());
            ds.setUser(DB_USER);
            ds.setPassword(DB_USER);

            if (!databaseExists) {
                createDatabase();
            }
        } catch (Exception exception) {
            Throwables.propagate(exception);
        }
    }

    private static boolean doesDatabaseExists() {
        boolean databaseExists = false;
        try {
            String url = DatabaseUtil.getUrl() + ";IFEXISTS=TRUE";
            Connection connection = DriverManager.getConnection(url, DB_USER, DB_USER);
            connection.close();
            databaseExists = true;
        } catch (SQLException ignore) {
        }
        return databaseExists;
    }

    public void createDatabase() throws SQLException {
        Connection conn = ds.getConnection();

        Statement statementDrop = conn.createStatement();
        statementDrop.executeUpdate("DROP TABLE IF EXISTS LOG");

        Statement statement = conn.createStatement();
        statement.executeUpdate("CREATE TABLE LOG (" +
                "HEURE TIMESTAMP," +
                "TYPE_LOG VARCHAR(10)," +
                "MESSAGE VARCHAR(500))");

        statementDrop = conn.createStatement();
        statementDrop.executeUpdate("DROP TABLE IF EXISTS ENONCE");

        statement = conn.createStatement();
        statement.executeUpdate("CREATE TABLE ENONCE (" +
                "ID INTEGER," +
                "TITLE VARCHAR(100)," +
                "ENONCE VARCHAR(4000))");

        conn.close();
    }

    private LogDao logDao;

    public LogDao getLogDao() {
        if (logDao == null) {
            logDao = new LogDao(ds);
        }
        return logDao;
    }

    private EnonceDao enonceDao;

    public EnonceDao getEnonceDao() {
        if (enonceDao == null) {
            enonceDao = new EnonceDao(ds);
        }
        return enonceDao;
    }
}

Le but était de vous montrer que pour faire des trucs simples, il n'est pas toujours utile de sortir l'artillerie lourde avec du JPA ou autre...

Et pour les tests unitaires?

Comme vous avez pu voir, les tests unitaires ne sont pas vraiment unitaire puisqu'ils testent l'application de bout en bout. Se pose du coup la question de la base de donnée qui ralentie les tests de manière inutile, heureusement h2 possède un mode mémoire qui rend la base très rapide et non persistante après les tests (ce qui permet de repartir à zéro à chaque fois). Pour passer d'un mode à l'autre, rien de sorcier :

public static String getUrl() {
    if (test) {
        return "jdbc:h2:mem:codestory;DB_CLOSE_DELAY=-1";
    }
    return "jdbc:h2:./codestory";
}
Il ne faut pas oublier le "DB_CLOSE_DELAY=-1" qui permet de garder la base soit active tant que la jvm existe (ça évite d'avoir des problèmes de structures qui disparaissent d'un test à l'autre).




À bientôt pour Scalaskel et la Calculette, puis pour le plus interessant : Jajascript et les performances.

2 commentaires:

Thierry LER a dit…

Waouu héritage dans les enum... J'ai mis un petit bout de temps à comprendre.

Unknown a dit…

Se servir des enums comme des classes normales est vraiment un pattern très puissant je trouve.

Enregistrer un commentaire