Code Story : la sélection finale

25 février 2013

Tags : CodeStory, Devoxx, Google, Java

Si vous me suivez sur twitter, vous savez sans doute déjà que Jeudi dernier (21/02) avait lieu la finale de Code Story. Je vais tenter de vous raconter comment s'est passée cette soirée haute en stress

Le lieu

Cette soirée s'est passée chez google à Paris. En tant que fanboy Google, ça suffisait déjà à faire de cette soirée une super soirée :)

Pour ceux qui ne connaissent pas les bureaux de Google, je peux vous dire un truc, c'est que ça fait envie. Pour vous faire baver, voici simplement la photo du "coin" fumeurs :



Le déroulement

Tout est expliqué sur le blog de Code Story, voici cependant un petit résumé. On avait des données avec pour chaque participant à Code Story, sa ville, trois trucs qu'il aime et deux trucs qu'il n'aime pas. Avec ça on devait construire un site internet from scratch permettant aux participants de connaître ceux avec qui ils ont des atomes crochus.


Notre participation

Les participants étaient par binômes pour ce concours. J'étais pour ma part avec Alexandre Ardhuin (@a14n).

Nous sommes arrivés les mains dans les poches sans rien avoir préparé (ce qui nous a sans doute fait défaut). La première étape a donc été de monter une stack Web et de publier ça pour que ce soit accessible sur Internet. Cette étape nous a pris à peu près 1 heure, à la moitié du temps nous avions donc un magnifique site web accessible sur Internet qui affichait "coucou"...

Pour afficher autre chose que "coucou", nous avons utilisé le framework Angular.js ce qui nous a permis d'afficher rapidement la liste des participants, le tout codé à l'arrache dans un seul fichier index.html

Alexandre est ensuite parti sur une présentation géographique des participants via Google Map, mais il n'a pas pu aller au bout à cause des quotas sur le geo-codage, dommage...

Je suis pour ma part parti sur le refactoring du code pour séparer Javascript, templates HTML et index.html afin de pouvoir mettre en place les routes Angular.js. Tout cela afin d'obtenir un lien par participant affichant les détails de celui-ci. Toutefois, je suis resté bloqué sur de la syntaxe Javascript pendant au moins 10 minutes :


angular.module('CodeStory');
différent de :

angular.module('CodeStory', []);
Dans le premiers cas, rien ne s'affiche, et l'erreur dans le console est : "Error: No module: CodeStory", en bref, un compilateur c'est pas si mal!

Ce refactoring m'a pris beaucoup de temps du coup (environ 20 minutes), et par conséquent il nous restait 25 minutes à peine pour faire la page de détail d'un participant. Nous avons mis environ 15 minutes à mettre en place une page affichant les personnes de la même ville, puis 7 minutes pour afficher les personnes ayant un "Like" en commun. Le "git push" permettant de déployer la dernière fonctionnalité a été fait sur le gong (heureusement qu'on avait un déploiement simplifié!).

Le code final est dispo sur github.

Le résultat

Une fois l'épreuve terminée, chaque binôme a eu 3 minutes pour présenter l'application aux jury. Nous sommes passés en premier, ce qui a permis de faire rapidement retomber le stress...

Au final c'est Xavier Hanin (@Xavierhanin) et Christophe Labouisse (@XtlCnslt) qui ont gagné, et nous avons été second. Un grand bravo à eux, et je leur donne rendez-vous à Devoxx France pour les voir en action :)

Nos erreurs

Notre première erreur a été de venir les mains dans les poches, il était prévisible que le Jury allait nous demander de coder une appli Web, si nous étions venus avec une stack Web prête à déployer, nous aurions gagné une heure...

N'ayant pas de stack web prête à déployer nous aurions dû partir sur un site directement hébergé sur github, n'ayant jamais utilisé le backend, cela aurait fait l'affaire, et nous aurions encore une fois gagné une heure...

Pour finir, voici :

Pour les plus curieux le diff est dispo sur github



Si vous avez eu le courage de lire jusque là, vous aurez peut-être le courage de regarder la vidéo!

Commentaires

Code Story en java : Jajascript et les performances

05 février 2013

Tags : CodeStory, Jajascript, Java

Cet article est le dernier d'une série de trois articles sur ma participation à Code Story en Java. Les articles précédents sont :

Je vais essayer de vous faire un retour sur l'étape qui a donné le plus de difficultés à tous les participants : Jajascript.

L'énoncé

Voici l'énoncé tel que nous l'avons reçu :

Location d’astronef sur Jajascript

Votre cousin par alliance, Martin O. sur la planète Jajascript vient de monter sa petite entreprise de vol spatial privé: Jajascript Flight Rental. Il loue aux grosses corporations son astronef lorsqu’elles ont de fortes charges ou un pépin avec leurs propres appareils. Il s’occupe de la maintenance et de l’entretien de son petit astronef. Il ne pouvait s’en payer qu’un pour démarrer.

Ces grosses corporations envoient des commandes de location qui consistent en un intervalle de temps, et le prix qu’ils sont prêts à payer pour louer l’astronef durant cet intervalle.

Les commandes de tous les clients sont connues plusieurs jours à l’avance. Ce qui permet de faire un planning pour une journée. Les commandes viennent de plusieurs sociétés différentes et parfois elles se chevauchent. On ne peut donc pas toutes les honorer.

Idéalement, il faut donc être capable de prendre les plus rentables, histoire de maximiser les gains de sa petite entreprise, et de s’acheter d’autres astronefs. Votre cousin passe des heures à trouver le planning idéal et vous demande pour un planning donné de calculer une solution qui maximise son gain.

Exemple

Considérez par exemple le cas où la JajaScript Flight Rental à 4 commandes :

MONAD42 : heure de départ 0, durée 5, prix 10
META18 : heure de départ 3, durée 7, prix 14
LEGACY01 : heure de départ 5, durée 9, prix 8
YAGNI17 : heure de départ 5, durée 9, prix 7

La solution optimale consiste à accepter MONAD42 et LEGACY01, et le revenu est de 10 + 8 = 18. Remarquez qu’une solution à partir de MONAD42 et YAGNI17 est faisable (l’avion serait loué sans interruption de 0 à 14) mais non optimale car le bénéfice ne serait que de 17.

Précisions

L’identifiant d’un vol ne dépasse jamais 50 caractères, les heures de départs, durée et prix sont des entiers positifs raisonnablement grands.

Serveur

Votre serveur doit répondre aux requêtes http POST de la forme http://serveur/jajascript/optimize avec un payload de la forme :

[
{ "VOL": "NOM_VOL", "DEPART": HEURE, "DUREE": DUREE, "PRIX": PRIX }, ...
]

En reprenant l’exemple ci dessus :

[
{ "VOL": "MONAD42", "DEPART": 0, "DUREE": 5, "PRIX": 10 },
{ "VOL": "META18", "DEPART": 3, "DUREE": 7, "PRIX": 14 },
{ "VOL": "LEGACY01", "DEPART": 5, "DUREE": 9, "PRIX": 8 },
{ "VOL": "YAGNI17", "DEPART": 5, "DUREE": 9, "PRIX": 7 }
]

Vous devrez répondre le résultat suivant :

{
"gain" : 18,
"path" : ["MONAD42","LEGACY01"]
}

Le gain représentant la somme optimale, path représentant l’ordre des vols.

Bons calculs !


Premier algo très naïf

Le premier algo que j'ai pondu était très naïf, je calculais absolument toutes les solutions possibles (voir plusieurs fois chaque solution). Voici la méthode principale de l'époque :


private void calculate(Planning actualPlanning, Collection<Commande> commandesToAdd) {
if (actualPlanning != null) {
addToPlanningsIfBetter(actualPlanning);
}
for (Commande commandeToAdd : commandesToAdd) {
if (actualPlanning == null || actualPlanning.canAddCommande(commandeToAdd)) {
Planning newPlanning = new Planning(actualPlanning);
newPlanning.addCommande(commandeToAdd);
Collection <Commande> newCommandesToAdd =
Collections2.filter(commandesToAdd, new FilterCommande(commandeToAdd));
calculate(newPlanning, newCommandesToAdd);
}
}
}

Dans cet algo récursif, je parcours toutes les commandes, et pour chaque commande, je regarde si elle est compatible à mon planning actuel, si elle l'est je l'ajoute au planning et je fais l'appel récursif. Cet algo est donc ni élégant ni performant, mais a le mérite de marcher. Pour les étapes précédentes cela suffisait, je me suis donc arrêté là, enfin jusqu'à la surprise.


La surprise

Pour Jajascript, ils nous ont fait une petite surprise, ils se sont mis à tester les performances... La méthode pour tester les performances était la suivante : le robot envoie une requête avec un certain nombre de commandes, si je réponds en moins de 30 secondes, il en envoie plus, et ce jusqu'à ce que je réponde en plus de 30 secondes. Voici la liste complète des marches utilisées par le robot :

  • 5 commandes
  • 10 commandes
  • 15 commandes
  • 20 commandes
  • 25 commandes
  • 30 commandes
  • 35 commandes
  • 40 commandes
  • 45 commandes
  • 50 commandes
  • 55 commandes
  • 60 commandes
  • 65 commandes
  • 70 commandes
  • 75 commandes
  • 80 commandes
  • 85 commandes
  • 90 commandes
  • 95 commandes
  • 100 commandes
  • 150 commandes
  • 250 commandes
  • 500 commandes
  • 1000 commandes
  • 1500 commandes
  • 2000 commandes
  • 2500 commandes
  • 3000 commandes
  • 3500 commandes
  • 4000 commandes
  • 5000 commandes
  • 10000 commandes
  • 50000 commandes

Du coup mon premier algo naïf répondait en plus de 30 secondes à partir de 30 commandes (nous ne connaissions pas le max à l'époque qui semblait être aux alentours de 100 commandes). Il a donc fallu optimiser tout ça. Je ne vais pas vous présenter tous les commits d'optimisation par lesquels je suis passés (ça représente près de 50 commits...). Je vais plutôt tenter de vous montrer les grandes étapes ainsi que les performances associées. Pour les performances, je n'ai inclus que le calcul brut (sans la couche HTTP ou Json).


Algo récursif optimisé

Après quelques commits, je suis arrivé à un algo récursif un peu plus optimisé :


private void calculate(Planning actualPlanning, Collection<Commande> commandesToAdd) {
if (actualPlanning != null) {
addToPlanningsIfBetter(actualPlanning);
}
Iterator <Commande> itCommande = commandesToAdd.iterator();

while (itCommande.hasNext()) {
Commande commandeToAdd = itCommande.next();
itCommande.remove();
if (actualPlanning == null || actualPlanning.canAddCommande(commandeToAdd)) {
calculate(new Planning(actualPlanning).addCommande(commandeToAdd), newArrayList(commandesToAdd));
}
}
}

Les grosses différences pour en arriver là sont (pas toutes visibles dans le code ci-dessus) :

  • Tri des commandes par heure de départ croissante
  • On ne stocke que le meilleur planning
  • On enlève la commande courante avant de continuer dans la récursivité
  • On stocke l'heure de fin d'un planning afin de savoir rapidement si une commande est compatible avec

Pour les plus curieux, le diff complet est dispo sur github. Cet algo tiens jusqu'à 70 commandes à peu près (c'est déjà beaucoup mieux, mais on est loin des 50000 commandes...).


Algo récursif très optimisé

Afin d'aller au bout des optimisations de l'algo récursif, j'ai modifié/enlevé tout ce qui prenait du temps sans changer l'algo. L'optimisation principale dans cette étape a été le passage en types primitifs avec utilisation de tableau d'entier. Quand on regarde le code obtenu, on a un peu l'impression de revoir du C... Toutes ces optimisations permettent tout de même d'atteindre les 5000 commandes environs. Pour les plus curieux, le diff complet est disponible sur github.


Algo itératif

Atteignant les limites du récursif, il a fallu passer à un algo itératif. Cet algo repose sur une pile des dernières solutions trouvées, afin de regarder dans ces solutions laquelle est la meilleure pour une commande donnée. Voici la méthode principale :


private void calculateIteratif() {
// Parcours de toutes les commandes
for (int i=0; i<nbCommands;i++) {
Solution bestSolutionToAdd = null;
int bestPrice = -1;
for (Solution solution : lastSolutions) {
if (starts[i] >= solution.heureFin
&& solution.prix > bestPrice ) {
bestSolutionToAdd = solution;
bestPrice = solution.prix;
}
}

lastSolutions.removeFirst();

boolean[] newAceptedCommands = Arrays.copyOf(bestSolutionToAdd.acceptedCommands, bestSolutionToAdd.acceptedCommands.length);
newAceptedCommands[i] = true;
Solution newSolution = new Solution(ends[i], bestSolutionToAdd.prix + prices[i], newAceptedCommands);
lastSolutions.addLast(newSolution);
}

for (Solution solution : lastSolutions) {
addToPlanningsIfBetter(solution.acceptedCommands, solution.prix);

}
}

Ce passage en itératif permet d'atteindre 200000 commandes environ (ce qui était suffisant pour le concours). Pour les plus curieux, le diff complet est disponible sur github


Algo itératif optimisé

Je vais maintenant vous présenter le code final obtenu après encore quelques optimisations, et finalement un retour à de l'objet pur (ce qui me fait perdre un peu en performance, mais rend le code tellement plus lisible). Avant d'arriver à ce résultat, je suis passé par un BitSet plutôt qu'un tableau de booléens, ce qui m'avait fait gagner pas mal (classe à connaître donc). Si vous voulez voir tout les commits intermédiaires, ça se passe sur github.

Voici tout d'abord la classe Solution permettant de stocker une solution trouvée :


import com.google.common.base.Optional;
import com.google.common.primitives.Ints;

import java.util.LinkedList;
import java.util.List;

public class Solution implements Comparable<Solution> {
public final int price;
public final int endTime;
public final Optional <Solution> oldSolution;
public final Flight newFlight;

Solution(int price, Optional <Solution> oldSolution, Flight newFlight) {
this.price = price;
this.oldSolution = oldSolution;
this.newFlight = newFlight;
this.endTime = newFlight.endTime;
}

public List <Flight> getFlights() {

LinkedList <Flight> flights = new LinkedList <Flight>();
flights.add(newFlight);

Optional <Solution> currentSolution = oldSolution;
while (currentSolution.isPresent()) {
flights.addFirst(currentSolution.get().newFlight);
currentSolution = currentSolution.get().oldSolution;
}

return flights;
}

public boolean isBetterThan(Optional <Solution> bestSolutionToAdd) {
return !bestSolutionToAdd.isPresent() || price > bestSolutionToAdd.get().price;
}

@Override
public int compareTo(Solution o) {
return Ints.compare(price, o.price);
}
}
Le point principal à noter dans cette classe, est la façon de stocker une solution. Plutôt que de stocker une image complète d'une solution, on stocke la solution précédente plus la commande qu'on lui a ajoutée. Cette technique permet d'économiser énormément de mémoire, mais permet également de diminuer énormément le coup de la création d'une solution (appel au constructeur).

Voici maintenant la classe JajascriptService contenant tout l'algo :


import com.google.common.base.Function;
import com.google.common.base.Optional;
import com.google.common.collect.Lists;

import java.util.Collections;
import java.util.Iterator;
import java.util.LinkedList;
import java.util.List;

public class JajascriptService {

/**
* Flights to optimize.
*/
private List<Flight> flights;
/**
* Last solutions found.
*/
private LinkedList <Solution> lastSolutions = new LinkedList <Solution>();


public JajascriptService(List <Flight> flights) {
this.flights = flights;
Collections.sort(this.flights);
}

public JajaScriptResponse calculate() {

Solution solution = calculateSolution();

// Construct the path with the array of accepted flights.
List<String> path = Lists.transform(solution.getFlights(), new Function<Flight, String>() {
@Override
public String apply(Flight input) {
return input.getName();
}
});

return new JajaScriptResponse(solution.price, path);
}

private static class BestSolutions {
Optional <Solution> bestCompatibleSolution = Optional.absent();
Optional <Solution> bestSolutionWithEquivalentEndTime = Optional.absent();

int getPriceOfCompatibleSolution() {
return getPriceOfAnOptionalSolution(bestCompatibleSolution);
}

int getPriceOfEquivalentEndTimeSolution() {
return getPriceOfAnOptionalSolution(bestSolutionWithEquivalentEndTime);
}

private static int getPriceOfAnOptionalSolution(Optional <Solution> optionalSolution) {
return optionalSolution.isPresent() ? optionalSolution.get().price : 0;
}
}

/**
* @return an optimal solution.
*/
private Solution calculateSolution() {
// Iterate on all flights.
for (Flight flight : flights) {
// Pre-calculate endTime for future needs.
flight.calculateEndTime();

BestSolutions bestSolutions = getBestSolutionsForAFlight(flight);

int newPrice = flight.price + bestSolutions.getPriceOfCompatibleSolution();

if (newPrice > bestSolutions.getPriceOfEquivalentEndTimeSolution()) {
// Add the new solution to FIFO only if it's better than other solution with lower or equal endTime.
lastSolutions.addLast(new Solution(newPrice, bestSolutions.bestCompatibleSolution, flight));
}

if (bestSolutions.bestCompatibleSolution.isPresent()) {
// If we found a compatible solution, we remove all solution with endTime lower than last flight startTime and lower price.
removeOldSolutions(flight.startTime, bestSolutions.bestCompatibleSolution.get().price);
}
}

// Search the best solution in FIFO.
Collections.sort(lastSolutions);
return lastSolutions.getLast();
}

/**
* Remove all solution with lower or equal endTime than lastStartTime and lower price than priceOfCompatibleSolution.
*/
private void removeOldSolutions(int lastStartTime, int priceOfCompatibleSolution) {
Iterator <Solution> lastSolutionIterator= lastSolutions.iterator();

while (lastSolutionIterator.hasNext()) {
Solution oldSolution = lastSolutionIterator.next();
if (oldSolution.endTime <= lastStartTime
&& oldSolution.price < priceOfCompatibleSolution) {
lastSolutionIterator.remove();
}
}
}

/**
* Get the best solution in {@link JajascriptService#lastSolutions} for a flight.
*/
private BestSolutions getBestSolutionsForAFlight(Flight flight) {
BestSolutions bestSolutions = new BestSolutions();
// Search the best solution in FIFO we can take for this flight.
for (Solution solution : lastSolutions) {
if (flight.startTime >= solution.endTime && solution.isBetterThan(bestSolutions.bestCompatibleSolution)) {
bestSolutions.bestCompatibleSolution = Optional.of(solution);
}
if (flight.endTime >= solution.endTime && solution.isBetterThan(bestSolutions.bestSolutionWithEquivalentEndTime)) {
bestSolutions.bestSolutionWithEquivalentEndTime = Optional.of(solution);
}
}
return bestSolutions;
}
}
Cette version étant plutôt bien commentée, je vous laisse lire les commentaires...

Au final, cette version permet de traiter 1 000 000 de commandes en 200 millisecondes.


Différences entre les algos

En guise de récapitulatif, voici un petit graphique avec les performances de tous les algos




J'espère que ce retour sur ma participation à ce concours passionnant vous a plu. J'espère avoir le temps de vous faire un petit retour sur ma participation en Ceylon et en Scala, mais je ne vous garantis rien...

Commentaires

CodeStory en java : Scalaskel et la calculette

04 février 2013

Tags : CodeStory, Groovy, Java, Scalaskel, TDD

Cet article fait suite à l'article Ma participation à CodeStory en java : Intro.
Le but de cet article va être de vous parler de deux étapes du concours CodeStory : Scalaskel et la Calculette en vous présentant mon implémentation en Java.


Scalaskel

Voici l'énoncé tel que nous l'avons reçu par POST :

L’échoppe de monade sur Scalaskel.

Sur la planète Scalaskel, une planète en marge de la galaxie, aux confins de l’univers, la monnaie se compte en cents, comme chez nous. 100 cents font un groDessimal. Le groDessimal est la monnaie standard utilisable partout sur toutes les planètes de l’univers connu. C’est un peu compliqué à manipuler, mais si on ne s’en sert pas y’a toujours des erreurs d’arrondis incroyables quand on les soustrait ou on les divise, c’est idiot, mais c’est comme ça. Sur Scalaskel, on utilise rarement des groDessimaux, on utilise des pièces plus petites : Le Foo vaut 1 cent, le Bar vaut 7 cents, le Qix vaut 11 cents et le Baz vaut 21 cents.

Vous tenez une échoppe de monade et autres variables méta-syntaxiques sur Scalaskel. Pour faire face à l’afflux de touristes étrangers avec les poches remplies de groDessimaux vous avez besoin d’écrire un programme qui pour toute somme de 1 à 100 cents, vous donnera toutes les décompositions possibles en pièces de Foo, Bar, Qix ou Baz.

Par exemple, 1 cent ne peut se décomposer qu’en une seule pièce Foo. Par contre 7 cents peuvent se décomposer soit en 7 pièces Foo, soit en 1 pièce Bar.

Serveur Web :

Votre serveur doit répondre aux requêtes http GET de la forme http://serveur/scalaskel/change/X, X étant une valeur en cents de 1 à 100 cents.

La réponse attendue est un json de la forme :

[{“foo”: w, “bar”: x, “qix”: y, “baz”: z}, …]

Exemples Pour http://serveur/scalaskel/change/1 il faut répondre :

[ {“foo”: 1} ]

Pour http://serveur/scalaskel/change/7 il faut répondre :

[ {“foo”: 7}, {“bar”: 1} ]

L’ordre des valeurs dans le tableau json, ainsi que le formatage n’a pas d’importance à partir du moment où c’est du json valide, il s’entend.

Bon courage !

Première chose à mettre en place, le test unitaire (et oui, même si on est pressé, le TDD c'est bien) :


import com.meterware.httpunit.WebConversation;
import com.meterware.httpunit.WebResponse;

import static org.junit.Assert.assertEquals;

public class ScalaskelTest extends WebServerTestUtil {

@Test
public void should_answer_to_1cent() throws Exception {
WebConversation wc = new WebConversation();
WebResponse response = wc.getResponse(getURL() + "/scalaskel/change/1");
assertEquals(200, response.getResponseCode());
assertEquals("[{\"foo\":1}]", response.getText());
}

@Test
public void should_answer_to_7cent() throws Exception {
WebConversation wc = new WebConversation();
WebResponse response = wc.getResponse(getURL() + "/scalaskel/change/7");
assertEquals(200, response.getResponseCode());
assertEquals("[{\"foo\":7},{\"bar\":1}]", response.getText());
}

@Test
public void should_answer_to_11cent() throws Exception {
WebConversation wc = new WebConversation();
WebResponse response = wc.getResponse(getURL() + "/scalaskel/change/11");
assertEquals(200, response.getResponseCode());
assertEquals("[{\"foo\":11},{\"foo\":4,\"bar\":1},{\"qix\":1}]", response.getText());
}

@Test
public void should_answer_to_21cent() throws Exception {
WebConversation wc = new WebConversation();
WebResponse response = wc.getResponse(getURL() + "/scalaskel/change/21");
assertEquals(200, response.getResponseCode());
assertEquals("[{\"foo\":21},{\"foo\":14,\"bar\":1},{\"foo\":10,\"qix\":1},{\"foo\":7,\"bar\":2},{\"foo\":3,\"bar\":1,\"qix\":1},{\"bar\":3},{\"baz\":1}]", response.getText());
}
}
Rien d'extraordinaire dans ce test, il contient seulement les cas basiques (en même temps, le calcul à la main est assez chiant comme ça).

Si vous avez lu l'article précédent, vous savez que mon code en l'état ne gère pas les requêtes par path du type "/scalaskel/change/1", commençons donc par là. Dans la même idée que le "QueryHandler" présenté dans l'article précendent, j'ai d'abord créé un "PathHandler" :


import fr.ybonnel.codestory.WebServerResponse;
import javax.servlet.http.HttpServletRequest;

public abstract class AbstractPathHandler {
public abstract WebServerResponse getResponse(HttpServletRequest request, String payLoad, String... params) throws Exception;
}
La "request" sert à récupérer deux choses : la méthode (GET/POST) et le path. Le payload est utile pour les énoncés et jajascript (données envoyées en POST). Et pour finir les params sont le résultat de l'application d'un pattern que nous verrons ensuite.

Tout comme pour les Query, j'ai mis en place un enum relativement proche, mis à part qu'il n'a pas de méthodes abstraites. Cet enum gère de manière générique le routage avec la méthode et un pattern. Voici le code complet de cet enum :


import fr.ybonnel.codestory.WebServerResponse;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

public enum PathType {

INSERT_ENONCE(new InsertEnonceHandler(), "/enonce/(\\d+)", "POST"),
GET_ENONCES(new GetEnoncesHandler(), "/enonce(/)*", "GET"),
SCALASKEL_CHANGES(new ChangeScalaskelHandler(), "/scalaskel/change/(\\d+)", "GET"),
JAJASCRIPT(new OptimizeJajascriptHandler(), "/jajascript/optimize.*", "POST");

private AbstractPathHandler handler;

private Pattern pathPattern;
private String method;

PathType(AbstractPathHandler handler, String pathPattern, String method) {
this.handler = handler;
this.pathPattern = Pattern.compile(pathPattern);
this.method = method;
}


public Matcher isThisPath(String method, String path) {
if (this.method.equals(method)) {
return pathPattern.matcher(path);
} else {
return null;
}
}

public static WebServerResponse getResponse(HttpServletRequest request, String payLoad) throws Exception {
for (PathType onePath : values()) {
Matcher isThisPath = onePath.isThisPath(request.getMethod(), request.getPathInfo());
if (isThisPath != null && isThisPath.matches()) {
return onePath.handler.getResponse(request, payLoad, extractParameters(isThisPath));
}
}
return new WebServerResponse(HttpServletResponse.SC_NOT_FOUND, "This path is unknown");
}

private static String[] extractParameters(Matcher thisPath) {
String[] params = new String[thisPath.groupCount()];
for (int groupIndex = 1; groupIndex <= thisPath.groupCount(); groupIndex++) {
params[groupIndex - 1] = thisPath.group(groupIndex);
}
return params;
}
}

Maintenant que nous avons vu la tuyauterie, passons à l'algo de Scalaskel en lui-même. Au niveau modèle, j'ai deux classes, une représentant une solution (Change), et un enum représentant les pièces :


import com.fasterxml.jackson.annotation.JsonProperty;

public class Change {
@JsonProperty
private Integer foo;
@JsonProperty
private Integer bar;
@JsonProperty
private Integer qix;
@JsonProperty
private Integer baz;

public Change(Change change) {
if (change != null) {
foo = change.foo;
bar = change.bar;
qix = change.qix;
baz = change.baz;
}
}

public void pay(Coin coin) {
switch (coin) {
case FOO:
foo = foo == null ? 1 : foo + 1;
break;
case BAR:
bar = bar == null ? 1 : bar + 1;
break;
case QIX:
qix = qix == null ? 1 : qix + 1;
break;
case BAZ:
baz = baz == null ? 1 : baz + 1;
break;
}
}
}

import java.util.List;

import static com.google.common.collect.Lists.newArrayList;

public enum Coin {
FOO(1),
BAR(7),
QIX(11),
BAZ(21);

private int value;

Coin(int value) {
this.value = value;
}

public int getValue() {
return value;
}

public boolean canPay(int cents) {
return cents >= value;
}

private static class ListHolder {
private static final List<Coin> valuesAsLists = newArrayList(values());
}

public static List<Coin> valuesAsLists() {
return ListHolder.valuesAsLists;
}
}
La classe Change contient des annotations pour la sérialisation Json avec Jackson, elle contient également un constructeur par copie servant dans l'algo, ainsi qu'une méthode pay, permettant d'ajouter une pièce à une solution. Quand à la classe Coin, elle contient une méthode "canPay" permettant de savoir si on peut ajouter la pièce pour un nombre de cents restant. Les listes sont là pour pouvoir utiliser la méthode Collections2.filter de Guava.

Et pour finir, voici la classe principale : le service permettant de faire le calcul.


import com.google.common.base.Predicate;
import com.google.common.collect.Collections2;
import com.google.common.collect.Lists;

import java.util.List;

public enum ScalaskelChangeService {
INSTANCE;

public static ScalaskelChangeService getInstance() {
return INSTANCE;
}

public List<Change> calculateChanges(int cents) {
return completeChanges(cents, null, null);
}

private List<Change> completeChanges(int cents, Change currentChange, Coin lastCoin) {
// Stop condition of recursivity
if (cents == 0) {
return Lists.newArrayList(currentChange);
}
List<Change> changes = Lists.newArrayList();
for (Coin coin : Collections2.filter(
Coin.valuesAsLists(),
new FilterCoins(lastCoin, cents))) {
Change change = new Change(currentChange);
change.pay(coin);
changes.addAll(completeChanges(cents - coin.getValue(), change, coin));
}
return changes;
}

/**
* Filter coins with this rule :
* coin is keeped only if :
* <ul>
* <li>its value is bigger or equals thant lastCoin</li>
* <li>we can pay with the coin.</li>
* </ul>
*/
private static class FilterCoins implements Predicate {

private int minValue;
private int centsToPay;

private FilterCoins(Coin lastCoin, int centsToPay) {
minValue = lastCoin == null ? 0 : lastCoin.getValue();
this.centsToPay = centsToPay;
}

@Override
public boolean apply(Coin input) {
return minValue <= input.getValue() && input.canPay(centsToPay);
}
}
}
Cette classe est un enum pour le côté singleton. La classe FilterCoins permet de filtrer les pièces utilisables en fonction de la dernière pièce utilisée ainsi que le nombre de cents restant à payer. Pour l'algo, il s'agit d'un algo récursif relativement bourrin. Il est sûr que si les performances avaient été un critère, j'aurais sûrement modifié l'algo. J'espère que mon code est suffisamment clair pour ne pas avoir à l'expliquer d'avantage.

La dernière partie que nous n'avons pas vu est le "PathHandler" permettant de lier le service au WebServer :


import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import fr.ybonnel.codestory.WebServerResponse;
import fr.ybonnel.codestory.path.scalaskel.Change;
import fr.ybonnel.codestory.path.scalaskel.ScalaskelChangeService;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.util.List;

public class ChangeScalaskelHandler extends AbstractPathHandler {

private ObjectMapper objectMapper = new ObjectMapper().setSerializationInclusion(JsonInclude.Include.NON_NULL);

private boolean wrongParams(int centsToPay) {
return centsToPay <= 0 || centsToPay > 100;
}

@Override
public WebServerResponse getResponse(HttpServletRequest request, String payLoad, String... params) throws JsonProcessingException {
int centsToPay = Integer.parseInt(params[0]);
if (wrongParams(centsToPay)) {
return new WebServerResponse(HttpServletResponse.SC_FORBIDDEN, "Wrong parameters");
}

List<Change> changes = ScalaskelChangeService.getInstance().calculateChanges(centsToPay);
return new WebServerResponse(HttpServletResponse.SC_OK, objectMapper.writeValueAsString(changes));
}
}
Dans cette classe, vous pouvez voir une petite protection afin d'éviter de faire tomber le serveur en demandant le change de 1.000.000 de cents. On peut également y voir la sérialisation Json avec Jackson, mais rien de sorcier non plus.


La calculette

Pour cet exercice, pas d'énoncé, la première requête reçue fut : "/?q=1+1" à laquelle on se doute qu'il faut répondre "2". Je ne vais pas vous détailler toutes les étapes par lesquelles je suis passé pour arriver au bout. Voici la liste des requêtes reçues (dans l'ordre) :

  • 1+1
  • 2+2
  • 3+3
  • 4+4
  • 5+5
  • 6+6
  • 7+7
  • 8+8
  • 9+9
  • 1*1
  • 2*2
  • 3*3
  • 4*4
  • 5*5
  • 6*6
  • 7*7
  • 8*8
  • 9*9
  • 1+2*2
  • (1+2)*2
  • (1+2+3+4+5+6+7+8+9+10)*2
  • (1+2)/2
  • ((1+2)+3+4+(5+6+7)+(8+9+10)*3)/2*5
  • 1,5*4
  • ((1,1+2)+3,14+4+(5+6+7)+(8+9+10)*4267387833344334647677634)/2*553344300034334349999000
  • ((1,1+2)+3,14+4+(5+6+7)+(8+9+10)*4267387833344334647677634)/2*553344300034334349999000/31878018903828899277492024491376690701584023926880
  • (-1)+(1)
  • 1,0000000000000000000000000000000000000000000000001*1,0000000000000000000000000000000000000000000000001
Cette liste vous permet sans doute de voir par quelles étapes je suis passé :
  • Gestion des sommes
  • Gestion des multiplication
  • Gestion des priorités
  • Gestion des parenthèses
  • Gestion des divisions
  • Gestion des décimaux
  • Gestion des grands nombres
  • Gestion des nombres négatifs
  • Gestion des nombres de décimales élevés
Pour la première version de mon code, j'ai tout fait à la main à grand coup de Pattern, voici le résultat final :

import com.google.common.base.Throwables;
import fr.ybonnel.codestory.query.calculate.Operator;
import fr.ybonnel.codestory.query.calculate.SearchParanthesis;

import java.math.BigDecimal;
import java.math.RoundingMode;
import java.text.DecimalFormat;
import java.text.DecimalFormatSymbols;
import java.text.NumberFormat;
import java.text.ParseException;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

public class CalculateQueryHandler extends AbstractQueryHandler {

private static final String NOMBRE = "\\-?\\d+\\.?\\d*";
private static final String PATTERN_DIVIDE = "(" + NOMBRE + ")/(" + NOMBRE + ")";
private static final String PATTERN_MULTIPLY = "(" + NOMBRE + ")\\*(" + NOMBRE + ")";
private static final String PATTERN_ADD = "(" + NOMBRE + ")\\+(" + NOMBRE + ")";

// operators list by priority.
private List operators = new ArrayList() {{
// operator divide.
add(new Operator(PATTERN_DIVIDE) {
@Override
public BigDecimal operate(BigDecimal a, BigDecimal b) {
try {
return a.divide(b);
} catch (ArithmeticException exception) {
return a.divide(b, 1000, RoundingMode.HALF_UP);
}
}
});
// Operator Multiply.
add(new Operator(PATTERN_MULTIPLY) {
@Override
public BigDecimal operate(BigDecimal a, BigDecimal b) {
return a.multiply(b);
}
});
// Operator Add.
add(new Operator(PATTERN_ADD) {
@Override
public BigDecimal operate(BigDecimal a, BigDecimal b) {
return a.add(b);
}
});
}};

private Pattern patternParenthesis = Pattern.compile("\\((.*)\\)");
private NumberFormat format = new DecimalFormat("#0.#", new DecimalFormatSymbols(Locale.FRANCE));

public CalculateQueryHandler() {
format.setMaximumFractionDigits(500);
}

@Override
public String getResponse(String query) {
String result = null;
try {
result = calculateWithParenthesis(query.replace(' ', '+').replace(',', '.'));
} catch (ParseException e) {
Throwables.propagate(e);
}

try {
BigDecimal retour = new BigDecimal(result);
return format.format(retour);
} catch (NumberFormatException numberFormatException) {
numberFormatException.printStackTrace();
return null;
}
}

private String calculateWithParenthesis(String calculateQuery) throws ParseException {
Matcher matcherParenthsis = patternParenthesis.matcher(calculateQuery);

while (matcherParenthsis.find()) {
SearchParanthesis searchParanthesis = new SearchParanthesis(calculateQuery).invoke();
int start = searchParanthesis.getStart();
int end = searchParanthesis.getEnd();

// Calculate the content of parenthesis.
String queryBetweenParenthesis = calculateQuery.substring(start + 1, end - 1);
String result = calculateWithoutParenthesis(queryBetweenParenthesis);

// Replace the parenthesis group with result.
calculateQuery = calculateQuery.substring(0, start) + result + calculateQuery.substring(end);
matcherParenthsis = patternParenthesis.matcher(calculateQuery);
}

calculateQuery = calculateWithoutParenthesis(calculateQuery);
return calculateQuery;
}

private String calculateWithoutParenthesis(String calculateQuery) throws ParseException {

for (Operator operator : operators) {
Matcher matcher = operator.matcher(calculateQuery);

while (matcher.find()) {
BigDecimal a = new BigDecimal(matcher.group(1));
BigDecimal b = new BigDecimal(matcher.group(2));
BigDecimal result = operator.operate(a, b);

// Replace sur operation in string by result.
calculateQuery = calculateQuery.substring(0, matcher.start()) + result.toString() + calculateQuery.substring(matcher.end());

matcher = operator.matcher(calculateQuery);
}
}

return calculateQuery;
}
}
Je vous l'accorde, le code est relativement complexe, mais je suis assez fier d'avoir pondu une calculette à la main.

Devant tout ce code, j'ai décidé de simplifier tout ça grâce à groovy, vous allez voir le code est grandement simplifié :


import groovy.lang.GroovyShell;
import java.text.DecimalFormat;
import java.text.DecimalFormatSymbols;
import java.text.NumberFormat;
import java.util.Locale;

public class CalculateQueryHandler extends AbstractQueryHandler {
private NumberFormat format = new DecimalFormat("#0.#", new DecimalFormatSymbols(Locale.FRANCE));
private GroovyShell shell = new GroovyShell();

public CalculateQueryHandler() {
format.setMaximumFractionDigits(500);
}

@Override
public String getResponse(String query) {
Object object = shell.evaluate("return " + query.replace(' ', '+').replace(',', '.'));
return formatGroovyReturn(object);
}

private String formatGroovyReturn(Object object) {
try {
return format.format(object);
} catch (NumberFormatException numberFormatException) {
numberFormatException.printStackTrace();
return null;
}
}
}
Et voilà, merci groovy, finalement pas besoin de réinventer la roue...




Cet article est le deuxième d'une série de trois articles dont le dernier est sans doute le plus intéressant : Jajascript et les performance. Afin de vous donner un avant-goût, vous pouvez regarder l'article sur le blog Code Story : Location d’astronef sur Jajascript.

Commentaires

Ma participation à CodeStory en java : Intro

03 février 2013

Tags : CodeStory, Java, TDD

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.

Commentaires

Marre du cloud et du JEE -> vive l'auto-hébergement et les main.

08 janvier 2013

Tags : auto-hébergement, cloud, CodeStory, git, Java, JEE, Jetty, NoMock, simple

Récemment l'équipe CodeStory a lancé le concours pour la sélection 2013, informations ici.

Pour participer, il faut un serveur public qui répond à des requêtes HTTPs. Pour la première étape, il faut que le serveur réponde à la requête GET "http://foobar.com:9090/?q=Quelle+est+ton+adresse+email" avec votre adresse email.
Rien de bien compliqué (en tout cas pour le moment), mais il faut évidement que votre serveur puisse évoluer pour répondre aux prochaines questions.



Architecture technique

Pour répondre au besoin de CodeStory, il y a plusieurs solutions (en restant dans l'univers java) :

  • Du JEE (ou conteneur de servlet simple) hébergé chez cloudbees ou à la maison.
  • Du play hébergé chez heroku, cloudbees ou à la maison.
  • Du Google App Engine.
  • Ou beaucoup plus simple :)
Dans le cadre de CodeStory, j'ai décidé de partir sur le beaucoup plus simple (assez largement inspiré par une présentation que David Gageot avait faîte au BreizhJUG en 2011, disponible sur parleys). Je suis donc parti sur un jetty embarqué et démarré depuis un simple main.

Pour mettre en place cette "architecture", deux étapes très compliquées :

  • Le pom.xml
  • La classe main


Le pom.xml

Il faut juste ajouter la dépendance vers Jetty :


<dependency>
<groupId>org.mortbay.jetty</groupId>
<artifactId>jetty</artifactId>
<version>6.1.25</version>
</dependency>


La classe main

Le boulot de la classe main est simplement de démarrer le serveur et traiter les requêtes HTTP :


package fr.ybonnel.codestory;

import org.mortbay.jetty.Server;
import org.mortbay.jetty.handler.AbstractHandler;

import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

public class WebServer extends AbstractHandler {

public static final String QUERY_PARAMETER = "q";

@Override
public void handle(String target,
HttpServletRequest request,
HttpServletResponse httpResponse,
int dispatch)
throws IOException, ServletException {
// Traitement de la requète.
}

public static void main(String[] args) throws Exception {
int port = 10080;
if (args.length == 1) {
port = Integer.parseInt(args[0]);
}
Server server = new Server(port);
server.setHandler(new WebServer());
server.start();
server.join();
}
}


Les intérêts

L'intérêt d'une telle "architecture" est la simplicité, ce qui se traduit par trois avantages :

  • Rapidité de démarrage : 38ms sur mon poste qui n'est pas un fourdre de guerre.
  • Tests unitaires sans mock : grâce à la rapidité de démarrage, on peux faire des tests qui démarrent le serveur, exécutent une requête GET, et arrêtent le serveur. On se place donc à la place du client, ce qui est sans doute une garantie d'avoir le résultat attendu.
  • Facilité d'installation : juste un jar à exécuter (donc très simple que ce soit dans l'IDE ou dans sur un serveur).



Tests unitaires

Comme on l'a vu, pour les tests unitaires, rien de bien sorcier :

  • On démarre le serveur (dans une méthode @Before, pour qu'elle soit exécutée avant chaque test)
  • On fait le test (envoi d'une requête GET, et vérifications sur la réponse).
  • On arrête le serveur (dans une méthode @After).

Code complet du test de la première étape :


package fr.ybonnel.codestory;

import com.google.api.client.http.*;
import com.google.api.client.http.javanet.NetHttpTransport;
import org.junit.After;
import org.junit.Before;
import org.junit.Test;
import org.mortbay.jetty.Server;

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;

import static junit.framework.Assert.assertEquals;

public class WebServerTest {

public static final int PORT = 18080;
private Server server;

@Before
public void setup() throws Exception {
WebServer.setTest(true);
server = new Server(PORT);
server.setHandler(new WebServer());
server.start();

new Thread(){
@Override
public void run() {
try {
server.join();
} catch (InterruptedException ignore) {
}
}
}.start();
}

@After
public void teardown() throws Exception {
server.stop();
}

@Test
public void should_answear_to_whatsyourmail() throws Exception {
String url = "http://localhost:" + PORT + "/?q=Quelle+est+ton+adresse+email";
HttpResponse response = sendGetRequest(url);
assertEquals("Status code must be 200", 200, response.getStatusCode());
assertEquals("Response must be my mail",
"ybonnel@gmail.com",
responseToString(response));
}

private static final HttpTransport HTTP_TRANSPORT = new NetHttpTransport();

private HttpResponse sendGetRequest(String url) throws IOException {
HttpRequestFactory requestFactory =
HTTP_TRANSPORT.createRequestFactory();
HttpRequest request = requestFactory.buildGetRequest(new GenericUrl(url));
return request.execute();
}

private String responseToString(HttpResponse response) throws IOException {
BufferedReader bufReader = new BufferedReader(new InputStreamReader(
response.getContent(), response.getContentCharset()));
StringBuilder builder = new StringBuilder();
String line = bufReader.readLine();
while (line != null) {
builder.append(line);
line = bufReader.readLine()
}
return builder.toString();
}
}
Pour les tests suivant, seule la méthode @Test est à réécrire.
Pour faciliter l'écriture des requêtes http, j'utilise la librairie google-http-client, mais si vous avez mieux, je suis preneur.
EDIT : j'utilise maintenant JWebUnit, beaucoup plus simple.



Déploiement

Je n'ai pas encore parlé d'hébergement, ce qui pour un serveur qui doit être accessible publiquement reste important.

Ayant un serveur dédié à disposition, je suis parti sur de l'auto-hébergement. Si vous me demandez "pourquoi", je vous répondrai "parce que"...

Afin de m'auto-héberger j'ai suivi trois étapes :

  • Assemblage du jar
  • Démarrage et arrêt
  • Déploiement simplifié


Assemblage du jar

Mon build est sous maven, créer un jar contenant les dépendances n'est donc pas très compliqué, il suffit d'ajouter la configuration qui va bien dans le pom.xml :


<build>
<plugins>
<plugin>
<artifactId>maven-assembly-plugin</artifactId>
<configuration>
<archive>
<manifest>
<mainClass>fr.ybonnel.codestory.WebServer</mainClass>
</manifest>
</archive>
<finalName>${artifactId}</finalName>
<appendAssemblyId>false</appendAssemblyId>
<descriptorRefs>
<descriptorRef>jar-with-dependencies</descriptorRef>
</descriptorRefs>
</configuration>
</plugin>
</plugins>
</build>


Scripts de démarrage et d'arrêt

Pour le démarrage et l'arrêt, je ne me suis pas cassé la tête :

  • Démarrage :
    java -jar code-story.jar >> serveur.log 2>&1 &
  • Arrêt :
    ps -ef | grep java | grep code-story | grep -v grep | while read a b c 
    do
    kill -9 $b
    done


Déploiement simplifié

Le dernier truc auquel je tenais est un déploiement simple, je suis passé par deux étapes avec des logiques très différentes :

  • Déploiement par update des sources
  • Déploiement par git push

Déploiement par update des sources

Ma première façon de déployer était relativement simple, j'ai fait un clone de mon repo git sur le serveur. Donc pour redéployer, je faisait simplement un "git pull", suivi d'une compilation et restart du serveur.
Mon script de déploiement ressemblait donc à :


git pull
mvn clean install assembly:single
Il suffisait ensuite de redémarrer le serveur pour prendre en compte le nouveau jar.

Quelques inconvénients cependant à cette technique :

  • Il faut se connecter au serveur pour le mettre à jour.
  • On mélange les sources et la partie serveur au même endroit

Déploiement par git push

J'ai eu envie que le déploiement se résume à un "git push serveur master" depuis mon poste de dev (fortement inspiré de la façon de déployer sur heroku).

Première étape, créer le repo git sur le serveur (depuis le serveur) :


mkdir CodeStory.git
cd CodeStory.git
git init --bare
Et voilà, j'ai un repo git accessible par ssh.

Deuxième étape, pousser le contenu actuel sur le repo (depuis mon poste de dev) :


git remote add serveur ssh://ybonnel@XXX.XXX.XXX.XXX:XXXX/home/ybonnel/CodeStory.git
git push serveur master

Troisième étape, créer la partie serveur (sur le serveur donc) :


mkdir CodeStory-server
cd CodeStory-server/
cp ../CodeStory/target/code-story.jar .
cp ../CodeStory/scripts/* .
Mon répertoire "CodeStory-server" contient donc :
  • Le jar
  • Le script de démarrage et le script d'arrêt

Quatrième et dernière étape, créer le hook sur le repo git. Pour ce faire, j'ai créé le script "post-receive" dans "CodeStory.git/hooks" dont voici le contenu :


echo "Updating server..."
rm -rf /home/ybonnel/CodeStory
git clone /home/ybonnel/CodeStory.git /home/ybonnel/CodeStory
cd /home/ybonnel/CodeStory
./updateServeur.sh
echo "Update and restart of server are done"
Et voici le contenu du script "updateServeur.sh" :

mvn clean install assembly:single
if [ $? -eq 0 ]
then
cp scripts/* ../CodeStory-server/
cp target/code-story.jar ../CodeStory-server/code-story.jar.new
cd ../CodeStory-server
./stopServeur.sh
mv code-story.jar code-story.jar.old
mv code-story.jar.new code-story.jar
./startServeur.sh
sleep 1
tail -10 serveur.log
fi
Un fois ce hook mis en place, lorsque je fait un "git push serveur master" depuis mon poste de dev, une compile maven se lance, et si le build maven est OK, le serveur est mis à jour. Et je vois le résultat de la compile et du déploiement en direct lors de mon git push.



Et dans la vrai vie?

Maintenant vous allez me dire, c'est bien sympa ton truc, mais dans la vrai vie, les projets sont un peu plus compliqués que simplement fournir un email en réponse à un GET...
Les architectures Web modernes sont souvent composées d'une partie serveur qui répond du JSON, et une partie cliente qui joue avec (y a qu'à voir le succès de angular.js). Et avec des architectures de ce type, répondre du JSON est-il beaucoup plus compliqué que répondre une adresse email?

Pour information, mon site ybo-tv est hébergé sur un tomcat, mais il serait relativement facile de le basculer sur une architecture de ce type (pas de stack lourde juste pour répondre du JSON, c'est facilement faisable en spécifique).

Commentaires