Affichage des articles dont le libellé est Ant. Afficher tous les articles
Affichage des articles dont le libellé est Ant. Afficher tous les articles

samedi 15 décembre 2012

Run background process for your Maven integration tests

The Maven Failsaif plugin makes it very simple to run integration test written with JUnit or TestNG. This example shows how to use the Maven Failsafe Plugin to run Integration Tests that properly starts and stop a Jetty server during both the pre-integration-test phase and the post-integration-test phase. This solution can be easyly derivated to start and stop any background process that is well integrated as a Maven plugin :
<plugin>
    <groupId>${plugin.groupId}</groupId>
    <artifactId>${plugin.artifactId}</artifactId>
    <version>${plugin.version}</version>
    <configuration>
        <anything>...</anything>
    </configuration>
    <executions>
        <execution>
            <id>do-it-before</id>
            <phase>pre-integration-test</phase>
            <goals>
                <goal>${plugin.start.goal}</goal>
            </goals>
            <configuration>
                <something>...</something>
            </configuration>
        </execution>
        <execution>
            <id>do-it-after</id>
            <phase>post-integration-test</phase>
            <goals>
                <goal>${plugin.stop.goal}</goal>
            </goals>
            <configuration>
                <whatever>...</whatever>
            </configuration>
        </execution>
    </executions>
</plugin>
Sometimes, there is no plugin or maven integration that you can use to start and stop the background process(es) required by your intergation test case to run. You can start and stop the desired process(es) manually but let see how to do this properly with Maven.

Maven exec plugin

The Maven exec plugin provides 2 goals to help execute system and Java programs. However, the programs execution are blocking. It is thus impossible to run tests during the intergation-test phase with running programs started during the pre-integration-test phase. Hopefully, the Maven antrun plugin will help us...

Maven antrun plugin

The Maven antrun plugin provides the ability to run Ant tasks from within Maven. It can do anything you can put into an ant build.xml script. In particular, it is possible to use the Ant exec task with all its parameters. The solution to our problem is the spawn parameter of the exec task that will run the specified executable asynchronously in its own process :
<exec executable="command-to-run"
      dir="base/dir/for/the/command"
      spawn="true"> <!-- run it asynchronously and in background baby -->
    <arg value="arg"/>
    <arg value="arg"/>
    ...
    <arg value="arg"/>
</exec>
Okay but now, we want to start third parties programs with the Maven antrun plugin during the pre-integration-test phase, keeping them running during the integration-test phase and to shut them down during the post-integration-test phase. Assuming we have plateform dependent scripts to start and stop Zookeeper, Kafka or anything else... our pom.xml will look like this :
<plugin>
    <artifactId>maven-antrun-plugin</artifactId>
    <version>1.6</version>
    <executions>
        <execution>
            <id>start-third-parties</id>
            <phase>pre-integration-test</phase>
            <configuration>
                <target>
                    <exec executable="${run.command}"
                          dir="${basedir}/../scripts"
                          spawn="true">
                        <arg value="${run.command.additionnal.arg}"/>
                        <arg value="${basedir}/../scripts/${zookeeper.start.script}"/>
                    </exec>
                    <exec executable="${run.command}"
                          dir="${basedir}/../scripts"
                          spawn="true">
                        <arg value="${run.command.additionnal.arg}"/>
                        <arg value="${basedir}/../scripts/${kafka.start.script}"/>
                    </exec>
                </target>
            </configuration>
            <goals>
                <goal>run</goal>
            </goals>
        </execution>
        <execution>
            <id>stop-third-parties</id>
            <phase>post-integration-test</phase>
            <configuration>
                <target>
                    <exec executable="${run.command}"
                          dir="${basedir}/../scripts"
                          spawn="false">
                        <arg value="${run.command.additionnal.arg}"/>
                        <arg value="${basedir}/../scripts/${zookeeper.stop.script}"/>
                    </exec>
                    <exec executable="${run.command}"
                          dir="${basedir}/../scripts"
                          spawn="false">
                        <arg value="${run.command.additionnal.arg}"/>
                        <arg value="${basedir}/../scripts/${kafka.stop.script}"/>
                    </exec>
                </target>
            </configuration>
            <goals>
                <goal>run</goal>
            </goals>
       </execution>
    </executions>
</plugin>
There is no need to spawn the shutdown of our third parties programs, but it's possible too...

Okay but what if I want to use both Linux and Windows ?

No problem ! Maven profiles are here to help you. You just have to define two profiles wich <activation> relies on the os family like this :
<profile>
    <id>windows-properties</id>
    <activation>
        <os>
          <family>Windows</family>
        </os>
    </activation>
    <properties>
        <run.command>cmd</run.command>
        <run.command.additionnal.arg>/c</run.command.additionnal.arg>
        <zookeeper.start.script>start-zookeeper.bat</zookeeper.start.script>
        <zookeeper.stop.script>stop-zookeeper.bat</zookeeper.stop.script>
        <kafka.start.script>start-kafka.bat</kafka.start.script>
        <kafka.stop.script>stop-kafka.bat</kafka.stop.script>
    </properties>
</profile>
<profile>
    <id>linux-properties</id>
    <activation>
        <os>
          <family>unix</family>
        </os>
    </activation>
    <properties>
        <run.command>sh</run.command>
        <run.command.additionnal.arg></run.command.additionnal.arg>
        <zookeeper.start.script>start-zookeeper.sh</zookeeper.start.script>
        <zookeeper.stop.script>stop-zookeeper.sh</zookeeper.stop.script>
        <kafka.start.script>start-kafka.sh</kafka.start.script>
        <kafka.stop.script>stop-kafka.sh</kafka.stop.script>
    </properties>
</profile>
Et voila, the start and stop scripts properties will be set correctly depending on the runtime OS.

Okay but I don't want to run whole intergation tests all the time ?

This time again, you can use a Maven profile but without <activation>. Plugins will be activated only if you specify the profile inside the maven command :
mvn clean install -P integration-tests
<profiles>
    <profile>
        <id>integration-tests</id>
        <build>
            <plugins>
                <plugin>
                    <artifactId>maven-failsafe-plugin</artifactId>
                    <version>2.12.2</version>
                    <executions>
                        <execution>
                            <goals>
                                <goal>integration-test</goal>
                                <goal>verify</goal>
                            </goals>
                        </execution>
                    </executions>
                </plugin>
                <plugin>
                    <artifactId>maven-antrun-plugin</artifactId>
                    <version>1.6</version>
                    <executions>
                        <execution>
                            <id>start-third-parties</id>
                            <phase>pre-integration-test</phase>
                            <configuration>
                                <target>
                                    <!-- ANT stuff -->
                                </target>
                            </configuration>
                            <goals>
                                <goal>run</goal>
                            </goals>
                        </execution>
                        <execution>
                            <id>stop-third-parties</id>
                            <phase>post-integration-test</phase>
                            <configuration>
                                <target>
                                    <!-- ANT stuff -->
                                </target>
                            </configuration>
                            <goals>
                                <goal>run</goal>
                            </goals>
                       </execution>
                    </executions>
                </plugin>
            </plugins>
        </build>
    </profile>
</profiles>

About the stop scripts

Stop scripts are sometimes not easy to write... but there is always a way to kill a process ! The examples bellow are made for Kafka (replace kafka.Kafka by anything that can identify the process you started) :

on Linux

ps ax | grep -i 'kafka.Kafka' | grep -v grep | awk '{print $1}' | xargs kill -SIGTERM
You may have to pay to run it on Mac OS X indeed...

on Windows

wmic process where (commandline like "%%kafka.Kafka%%" and not name="wmic.exe") delete
You only need to double '%' if you want the command to be executed within a .bat file.

Conclusion

This solution might work for a lot of third parties appliations that you might want to run in background in order to pass integration tests on your whole application layers :
  • run a queue like Kafka or ZeroMQ that doesn't provide Maven integration yet
  • run a database like MySQL, Cassandra (Maven plugin available btw...) or HBase
  • run an independent instance of anything : Memcached, ElasticSearch or Solar for example
Tell me if you use it for something else ;-)

lundi 13 décembre 2010

Automatisation des livraisons pour l'Android Market

Dans la dernière version de son SDK Android, Google a intégré l'utilitaire ProGuard d'obfuscation et d'optimisation de code. Cette intégration par Google facilite l'automatisation du processus de génération d'applications optimisées à destination de l'Android market. Voyons comment il nous est possible de générer deux apk (Android Package) signés, obfusqués, optimisés et aux fonctionnalités différentes en ne maintenant qu'une unique version du code source et un script Ant.

Ennoncé du problème

Prenons l'exemple de l'application Niveau à bulle. Cette application est présente en deux versions sur l'Android market. Une version gratuite, sans publicité et une version payante permettant aux personnes qui le souhaite de faire un petit don au développer ;-)

La seule différence entre les deux applications est la présence ou non des liens "Donate" et "My Applications" au niveau des préférences.



Afin de ne pas complexifier le développement, nous voudrions ne disposer que d'une unique version des sources à maintenir et d'un moyen pour générer nos deux apk aussi rapidement qui si nous n'en avions qu'un !

Génération des scripts

Assurez-vous dans un premier temps de disposez de la version 8 du SDK Android. Pour générer les scripts initiaux permettant d'automatiser le build de l'application, il faut lancer la commande suivante à partir du répertoire tools du SDK Android :

cd %ANDROID_HOME%/tools
android update project --path ./Level


Vous vous retrouvez ainsi avec trois fichiers supplémentaires à la racine de votre projet :
build.xml
Script Ant permettant de générer un livrable Android non signé, non obfuscé et non optimisé
local.properties
Fichier de configuration du script Ant spécifique à votre ordinateur et permettant d'indiquer les chemins vers votre keystore, le répertoire d'installation du SDK Android, ... Ce fichier sera généralement spécifique à chaque poste de développement, et non géré en gestion de configuration.
proguard.cfg
Fichier de configuration spécifique à ProGuard et adapté aux applications Android.

Pour lancer la construction d'un livrable non signé, non obfuscé, il suffit de lancer la tache "release" du script build.xml :

ant release

Pour générer un livrable signé, non obfuscé, il suffit d'ajouter deux propriétés au fichier de configuration local.properties (puis relancer la tache "release" de Ant) :

key.store=/chemin/vers/mon/keystore.ks
key.alias=alias


Enfin, pour générer un livrable signé et obfuscé, il ne reste plus qu'à configurer l'utilisation de ProGuard par le SDK Android. Pour cela nous allons créer un fichier build.properties gérable en gestion de configuration et contenant la déclaration du fichier de configuration ProGuard (puis relancer la tache "release" de Ant) :

proguard.config=proguard.cfg

Il est important de re-tester son application de manière minutieuse lorsque l'on souhaite la publier en version obfuscé. En effet, Android permet de déclarer l'appel de code java depuis des fichiers XML. C'est notamment le cas du fichier AndroidManifest.xml. D'une manière générale, le fichier de configuration proguard.cfg généré pour nous par le SDK préserve le code qui est référencé par le fichier AndroidManifest.xml. Ce n'est en revenche pas le cas des click listeners introduits en version 1.6 d'Android. Si vous déclarer le click listener d'un Button dans un fichier de layout, la configuration ProGuard ne préservera pas la méthode appelée dans l'activité utilisant le layout :
<button android:onclick="monClickListener"/>
Si vous souhaitez conserver vos click listeners, vous devrez vous plonger dans la documentation ProGuard et modifier le fichier proguard.cfg.

Adaptation du code

Nous allons maintenant nous intéresser au moyen de maintenir deux versions différentes d'une application au moyen d'un unique code source. L'exemple étant simple, la solution le sera également Afin de ne conserver les préférences "Donate" et "My Applications" que pour l'application gratuite, nous allons modifier la PreferenceActivity LevelPreferences.java en ajoutant des conditions vraies ou fausses autour du code qui distingue les deux versions :
package net.androgames.level;

import net.androgames.level.config.DisplayType;
import net.androgames.level.config.Provider;
import net.androgames.level.config.Viscosity;
import android.app.AlertDialog;
import android.app.Dialog;
import android.content.ActivityNotFoundException;
import android.content.DialogInterface;
import android.content.Intent;
import android.content.SharedPreferences;
import android.net.Uri;
import android.os.Bundle;
import android.preference.CheckBoxPreference;
import android.preference.Preference;
import android.preference.PreferenceActivity;
import android.preference.PreferenceCategory;
import android.preference.PreferenceManager;
import android.preference.Preference.OnPreferenceChangeListener;
import android.preference.Preference.OnPreferenceClickListener;

public class LevelPreferences extends PreferenceActivity implements OnPreferenceChangeListener {

    public static final String KEY_SHOW_ANGLE           = "preference_show_angle";
    public static final String KEY_DISPLAY_TYPE         = "preference_display_type";
    public static final String KEY_SOUND                = "preference_sound";
    public static final String KEY_LOCK                 = "preference_lock";
    public static final String KEY_LOCK_LOCKED          = "preference_lock_locked";
    public static final String KEY_LOCK_ORIENTATION     = "preference_lock_orientation";
    public static final String KEY_APPS                 = "preference_apps";
    public static final String KEY_DONATE               = "preference_donate";
    public static final String KEY_SENSOR               = "preference_sensor";
    public static final String KEY_VISCOSITY            = "preference_viscosity";
    public static final String KEY_ECONOMY              = "preference_economy";

    private static final String PUB_APPS     = "market://search?q=pub:\"Antoine Vianey\"";
    private static final String PUB_DONATE   = "market://details?id=net.androgames.level.donate";
    
    private static final int DIALOG_CALIBRATE_AGAIN = 0;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        addPreferencesFromResource(R.xml.preferences);
        if (true) {
            PreferenceCategory appsCategory = new PreferenceCategory(this);
            appsCategory.setTitle(R.string.preference_apps_category);
            Preference appsPreference = new Preference(this);
            appsPreference.setTitle(R.string.preference_apps);
            appsPreference.setSummary(R.string.preference_apps_summary);
            appsPreference.setKey(KEY_APPS);
            Preference donatePreference = new Preference(this);
            donatePreference.setTitle(R.string.preference_donate);
            donatePreference.setSummary(R.string.preference_donate_summary);
            donatePreference.setKey(KEY_DONATE);
            getPreferenceScreen().addPreference(appsCategory);
            appsCategory.addPreference(donatePreference);
            appsCategory.addPreference(appsPreference);
        }
    }

    public void onResume() {
        super.onResume();
        // enregistrement des listerners
        findPreference(KEY_DISPLAY_TYPE).setOnPreferenceChangeListener(this);
        findPreference(KEY_SENSOR).setOnPreferenceChangeListener(this);
        findPreference(KEY_VISCOSITY).setOnPreferenceChangeListener(this);
        findPreference(KEY_ECONOMY).setOnPreferenceChangeListener(this);
        // mise a jour de l'affichage
        SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(this);
        findPreference(KEY_DISPLAY_TYPE).setSummary(DisplayType.valueOf(
                prefs.getString(LevelPreferences.KEY_DISPLAY_TYPE, "ANGLE")).getSummary()); 
        findPreference(KEY_SENSOR).setSummary(Provider.valueOf(
                prefs.getString(LevelPreferences.KEY_SENSOR, "ORIENTATION")).getSummary());
        findPreference(KEY_VISCOSITY).setSummary(Viscosity.valueOf(
                prefs.getString(LevelPreferences.KEY_VISCOSITY, "MEDIUM")).getSummary());
        findPreference(KEY_VISCOSITY).setEnabled(
                !((CheckBoxPreference) findPreference(KEY_ECONOMY)).isChecked());
        if (true) {
            // lancement du market
            findPreference(KEY_APPS).setOnPreferenceClickListener(new OnPreferenceClickListener() {
                @Override
                public boolean onPreferenceClick(Preference preference) {
                    Intent intent = new Intent(Intent.ACTION_VIEW);
                    intent.setData(Uri.parse(PUB_APPS));
                    LevelPreferences.this.startActivity(intent);
                    return true;
                }
            });
            findPreference(KEY_DONATE).setOnPreferenceClickListener(new OnPreferenceClickListener() {
                @Override
                public boolean onPreferenceClick(Preference preference) {
                    Intent intent = new Intent(Intent.ACTION_VIEW);
                    intent.setData(Uri.parse(PUB_DONATE));
                    try {
                        LevelPreferences.this.startActivity(intent);
                    } catch (ActivityNotFoundException anfe) {}
                    return true;
                }
            });
        }
    }

    @Override
    public boolean onPreferenceChange(Preference preference, Object newValue) {
        String key = preference.getKey();
        if (KEY_DISPLAY_TYPE.equals(key)) {
            preference.setSummary(DisplayType.valueOf((String) newValue).getSummary());
        } else if (KEY_SENSOR.equals(key)) {
            preference.setSummary(Provider.valueOf((String) newValue).getSummary());
            showDialog(DIALOG_CALIBRATE_AGAIN);
        } else if (KEY_VISCOSITY.equals(key)) {
            preference.setSummary(Viscosity.valueOf((String) newValue).getSummary());
        } else if (KEY_ECONOMY.equals(key)) {
            findPreference(KEY_VISCOSITY).setEnabled(!((Boolean) newValue));
        }
        return true;
    }
    
    protected Dialog onCreateDialog(int id) {
        Dialog dialog;
        switch(id) {
            case DIALOG_CALIBRATE_AGAIN:
                AlertDialog.Builder builder = new AlertDialog.Builder(this);
                builder.setTitle(R.string.calibrate_again_title)
                        .setIcon(android.R.drawable.ic_dialog_alert)
                        .setCancelable(true)
                           .setNegativeButton(R.string.ok, new DialogInterface.OnClickListener() {
                               public void onClick(DialogInterface dialog, int id) {
                                   dialog.dismiss();
                               }
                           })
                           .setMessage(R.string.calibrate_again_message);
                dialog = builder.create();
                break;
            default:
                dialog = null;
        }
        return dialog;
    }
    
}
Les directives if (true) {...} et if (false) {...} seront automatiquement optimisées par ProGuard lors de la phase d'optimisation du code : le code inaccessible ou inutilisé est automatiquement supprimé par l'outil.

Il ne reste plus qu'à créer un script Ant dont le but sera de remplacer la directive if (true) par if (false) et de lancer la tache ant release sur chacune des deux versions.

Automatisation des livraisons

Chaque application de l'Android market doit déclarer un nom de package unique dans son fichier AndroidManifest.xml. Pour publier deux versions différentes, il faut donc modifier le nom de package utilisé. Dans notre cas, nous en utiliserons deux :
  • net.androgames.level : pour la version gratuite
  • net.androgames.level.donate : pour la version payante 
Le renommage du package n'est pas sans conséquence, puisqu'il implique de déplacer l'activité principale (et autres activités, receivers, services, ...) dans le nouveau package déclaré, et de modifier la déclaration des packages et des imports pour ces classes et les classes référentes.

Imaginons que nous maintenions la version gratuite des sources avec l'utilisation des préférences supplémentaires. Dans un premier temps, nous allons dupliquer la totalité du projet dans un répertoire temporaire de travail :
<mkdir dir="${temp.dir}" />
<copy todir="${temp.dir}">
 <fileset dir=".">
     <exclude name="**/${temp.dir}/**" />
 </fileset>
</copy>
Il va ensuite falloir effectuer les modifications nécessaires sur le nom des packages, les imports et remplacer  la directive if (true) par if (false) :
<!-- debut des modifications specifiques -->

<replace file="${temp.dir}/AndroidManifest.xml" token="net.androgames.level"
            value="net.androgames.level.donate"/>

<replace dir="${temp.dir}" value="net.androgames.level.donate.R">
    <include name="**/*.java"/>
    <replacetoken><![CDATA[net.androgames.level.R]]></replacetoken>
</replace>

<replace dir="${temp.dir}" value="net.androgames.level.donate.Level">
    <include name="**/*.java"/>
    <replacetoken><![CDATA[net.androgames.level.Level]]></replacetoken>
</replace>

<move todir="${temp.dir}/src/net/androgames/level/donate">
    <fileset dir="${temp.dir}/src/net/androgames/level">
        <include name="*.java"/>
    </fileset>
</move>

<replace dir="${temp.dir}" value="package net.androgames.level.donate;">
    <include name="**/*.java"/>
    <replacetoken><![CDATA[package net.androgames.level;]]></replacetoken>
</replace>

<replace file="${temp.dir}/src/net/androgames/level/donate/LevelPreferences.java">
      <replacetoken><![CDATA[if (true) {]]></replacetoken>
      <replacevalue><![CDATA[if (false) {]]></replacevalue>
</replace>

<!-- fin des modifications specifiques -->
Une fois les sources modifiées dans le répertoire temporaire, il peut être nécessaire de supprimer le répertoire gen contenant les fichiers java générées par le SDK afin que ceux-ci soient regénérés lors de la construction des livrables. Si votre version alternative contient des ressources différentes (strings, arrays, attrs, ...), cette régénération est indispensable pour éviter de nombreuses RuntimeException.

Il ne reste plus qu'à mettre le tout en musique dans un script Ant :
<?xml version="1.0" encoding="iso-8859-1"?>
<project name="Livraison Level" default="all">

    <property file="make.properties" />

    <target name="all" 
        description="Package les 2 versions" 
        depends="prepare-livraison, do-original, do-modified, clean"/>
    
    <target name="prepare-livraison" depends="clean">
        <mkdir dir="${temp.dir}" />
        <copy todir="${temp.dir}">
            <fileset dir=".">
                <exclude name="**/${temp.dir}/**" />
            </fileset>
        </copy>
        
        <replace file="${temp.dir}/build.xml" token="${original.name}" value="${modified.name}"/>

        <!-- debut des modifications specifiques -->
        
        <replace file="${temp.dir}/AndroidManifest.xml" token="net.androgames.level"
                    value="net.androgames.level.donate"/>
        
        <replace dir="${temp.dir}" value="net.androgames.level.donate.R">
            <include name="**/*.java"/>
            <replacetoken><![CDATA[net.androgames.level.R]]></replacetoken>
        </replace>
        
        <replace dir="${temp.dir}" value="net.androgames.level.donate.Level">
            <include name="**/*.java"/>
            <replacetoken><![CDATA[net.androgames.level.Level]]></replacetoken>
        </replace>
        
        <move todir="${temp.dir}/src/net/androgames/level/donate">
            <fileset dir="${temp.dir}/src/net/androgames/level">
                <include name="*.java"/>
            </fileset>
        </move>
        
        <replace dir="${temp.dir}" value="package net.androgames.level.donate;">
            <include name="**/*.java"/>
            <replacetoken><![CDATA[package net.androgames.level;]]></replacetoken>
        </replace>
        
        <replace file="${temp.dir}/src/net/androgames/level/donate/LevelPreferences.java">
              <replacetoken><![CDATA[if (true) {]]></replacetoken>
              <replacevalue><![CDATA[if (false) {]]></replacevalue>
        </replace>
        
        <!-- fin des modifications specifiques -->
        
    </target>
    
    <target name="clean">
        <delete dir="${temp.dir}"/>
    </target>

    <target name="do-original">
        <echo message="Création de la version originale" />
        <ant dir="." antfile="build.xml" target="release"/>
    </target>
    
    <target name="do-modified">
        <echo message="Création de la version modifiee" />
        <delete dir="${temp.dir}/gen" />
        <ant dir="${temp.dir}" antfile="build.xml" target="release"/>
        <move file="${temp.dir}/bin/${modified.name}-release.apk" todir="bin"/>
    </target>

</project>
Avec son fichier de configuration :

original.name=Level
modified.name=Level-donate
temp.dir=tmp


Vous vous retrouvez au final avec les deux apk signées, obfuscées et optimisées dans le répertoire bin à la racine du répertoire projet de l'application :

Fork me on GitHub