mardi 8 janvier 2013

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

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).