lunes, 8 de octubre de 2012

Java: Distribuyendo aplicaciones de escritorio (¿de escri-cuálooo?) con Maven, One-JAR y Launch4J

Cof, cof, cof, ¿recuerdas las aplicaciones de escritorio?. Sí, esos programas que descargabas de Internet y los metías en el disco duro de tu PC para ejecutarlos cuando quisieras. Sí, sí, eso que utilizaba la gente antes de que llegara "la nube" y lo absorbiera todo. Cuando los PCs aún existían, en lugar de los móviles listillos y las tabletas no menos listillas.

Vale, vale, esto aún no es así del todo, pero en poco tiempo es muy probable que este comentario lo hagáis vosotros mismos cuando habléis con el becario jovencito de  turno y activéis vuestro modo "abuelo cebolleta". Así que me he decidido a publicar esta "receta Java" cuanto antes, para que no se quede tan retro como mis batallitas sobre el código máquina del Spectrum. Lo cierto es que aunque pasadas de moda, las aplicaciones de escritorio, ya sean aplicaciones con interfaz gráfica o aplicaciones de consola, siguen teniendo su huequecillo. Y no es menos cierto que distribuir este tipo de aplicaciones es algo que Java siempre ha dificultado enormemente, de una forma más bien absurda. Como en muchos otros aspectos de Java, fue la comunidad open source la que proporcionó las soluciones al problema. Ninguna de ellas es precisamente nueva, pero tampoco las conoce todo el mundo.

El asunto es que Sun pensó en pequeño: "Sólo necesitamos un tipo de archivo en el que podamos meter mogollón de clases y que sea capaz de saber cuál es la clase con el main". Alzó el todopoderoso dedo de la creación, y dio vida al fichero JAR. Esto ya de por sí es una forma perfectamente válida de distribuir una aplicación Java. Si le haces doble click al fichero, te ejecuta la aplicación. Si haces un sencillo script con un java -jar, ya tienes algo sencillo para ejecutar desde consola.

El problema es que ese mismo fichero JAR no sólo sirve para distribuir aplicaciones, sino que también sirve para distribuir librerías. Y si quieres librerías, en Java las tienes a patadas. Por supuesto, podrías desempaquetar las librerías y volver a empaquetarlas junto a tu aplicación en un precioso fichero JAR, y eso funcionaría. Pero es un mecanismo, la verdad, bastante guarro, aparte de que se pueden perder ficheros duplicados, como por ejemplo los ficheros de licencia de las librerías, cuya presencia a menudo es requerida por la propia licencia.

Otra opción es jugar con el classpath para que se incluyan los JAR de las librerías. Puedes hacerlo a nivel de script o, mejor aún, puedes modificar la sección Class-path del fichero Manifest de tu JAR principal. En cualquier caso, a poco que sean unos cuantos te va a salir un churro difícil de manejar, y además es muy fácil que eso te acabe dando problemas. Y estás convirtiendo el script en parte integral del programa, por lo que si alguien necesita modificarlo, dificultará la actualización de versiones. Funciona, pero es sucio, y cuantas más librerías tengas más sucio será. Aun así, es con diferencia el método que he visto más utilizado.

En este contexto, una buena solución es usar One-JAR. One-JAR lo que hace es permitirte crear un JAR que a su vez incluya dentro el resto de JARs. Se comportará como un JAR normal, en cuanto a que puedes hacerlo ejecutable sin problemas, pero además si lo descomprimes verás que dentro están todos los ficheros JAR de librerías, con el nombre y versión de cada una, sus ficheros de licencia, etc. Todo muy muy limpio. Si además añades en el Class-path del fichero Manifest el directorio ".", o algún subdirectorio que vayas a meter en la aplicación, podrás añadir junto al jar ficheros de configuración que el usuario pueda editar y que el código busque a nivel de classpath. Ejemplo típico: el fichero log4j.properties de la librería log4j.

Si además estamos utilizando Maven para la compilación y creación del paquete (y si no lo estamos utilizando, ya estamos tardando en hacerlo), utilizar One-JAR es tan sencillo como añadir esto en nuestro fichero pom.xml:

<project ...="...">
  (...)
  
  <build>
    <plugins>
      <!-- Manifest con Main al hacer jar -->
      <plugin>
        <artifactid>maven-jar-plugin</artifactid>
        <configuration>
          <archive>
            <manifest>
              <mainclass>com.andresviedma.chachiapp.Main</mainclass>
              <addclasspath>true</addclasspath>
            </manifest>
          </archive>
        </configuration>
      </plugin>
      
      <!-- Creación directorio distrib para generar ahí los ficheros -->
      <plugin>
        <artifactid>maven-antrun-plugin</artifactid>
        <executions>
          <execution>
            <id>crear_distrib_dir</id>
            <phase>package</phase>
            <goals><goal>run</goal></goals>
            <configuration>
              <tasks>
                <mkdir dir="${project.build.directory}/distrib">
              </mkdir></tasks>
            </configuration>
          </execution>
        </executions>
      </plugin>

      <!-- Generación fichero OneJar -->
      <plugin>
        <groupid>org.dstovall</groupid>
        <artifactid>onejar-maven-plugin</artifactid>
        <version>1.4.0</version>
        <executions>
          <execution>
            <phase>package</phase>
            <goals><goal>one-jar</goal></goals>
            <configuration>
              <onejarversion>0.96</onejarversion>
              <filename>distrib/${project.build.finalName}-all.jar</filename>
              <attachtobuild>true</attachtobuild>
              <classifier>all</classifier>
            </configuration>
          </execution>
        </executions>
      </plugin>
    </plugins>
  </build>

  <!-- Repositorio con plugin OneJar -->
  <pluginrepositories>
    <pluginrepository>
      <id>onejar-maven-plugin.googlecode.com</id>
      <url>http://onejar-maven-plugin.googlecode.com/svn/mavenrepo</url>
    </pluginrepository>
  </pluginrepositories>
  
  <dependencies>
    <dependency>
     <groupid>com.simontuffs</groupid>
     <artifactid>one-jar-boot</artifactid>
     <version>0.96</version>
     <scope>runtime</scope>
    </dependency>
 </dependencies>
</project>


NOTA: El código que estoy poniendo está probado con Maven 2.0.9, imagino que para otras versiones no habrá que tocar gran cosa, pero avisado quedas. Si no estamos utilizando Maven, crear un One-JAR con Ant por ejemplo también es muy fácil. De hecho crearlo a mano copiando ficheros en directorios y comprimiéndolos también.

¡Hecho! Ya tenemos nuestro precioso fichero JAR enorme, independiente y ejecutable por sí solo, ¡más mono!. Le añadimos un script que haga un sencillo "java -jar xxx", lo metemos todo en un zip y ya lo tenemos.

Ahora bien... esta solución es muy buena por ejemplo para Linux, donde se suele suponer que el usuario no se asusta ante el concepto de script, y estos funcionan perfectamente en cualquier situación, y donde todo el mundo asume que para ejecutar una cosa muchas veces hay que instalar primero otra (el JRE, claro). Sin embargo, si queremos hacer un paquete aún más completo para sistemas Windows, y que no asuste al usuario más inexperto, podemos darle todavía una vuelta de tuerca más. Existen otras soluciones, como por ejemplo JSmooth, pero aquí voy a contar cómo hacerlo con Launch4J.

Al igual que en el caso del One-JAR, la generación de un precioso fichero .exe, con su iconito, su splash y todo, se puede automatizar sin problemas con Maven. Es más, en teoría no necesitas generarlo desde Windows, lo puedes generar incluso desde tu Linux o Mac, y que sea el usuario final el que lo ejecute en Windows (para ello tendrás que tener instalado MinGW). Por último, puedes meterle incluso un JRE dentro del propio fichero EXE, o pedirle que detecte si el ordenador destino tiene uno instalado, y que si no lo tiene dé un mensaje bien clarito para que el usuario se lo descargue. Y una cosita más: para facilitarlo todo, podemos aprovechar el mismo fichero JAR que hemos creado en el punto anterior gracias a One-JAR. Para conseguirlo, vamos a aumentar el POM anterior añadiendo la generación del EXE, con lo que nos quedaría así:

<?xml version="1.0" encoding="UTF-8"?>
<project ...="...">
  (...)

  <build>
    <plugins>
      <!-- Manifest con Main al hacer jar -->
      <plugin>
        <artifactId>maven-jar-plugin</artifactId>
        <configuration>
          <archive>
            <manifest>
              <mainClass>com.andresviedma.chachiapp.Main</mainClass>
              <addClasspath>true</addClasspath>
            </manifest>
          </archive>
        </configuration>
      </plugin>
      
      <!-- Creación directorios exe y distrib para generar ahí los ficheros -->
      <plugin>
        <artifactId>maven-antrun-plugin</artifactId>
        <executions>
          <execution>
            <id>crear_exe_dir</id>
            <phase>package</phase>
            <goals><goal>run</goal></goals>
            <configuration>
              <tasks>
                <mkdir dir="${project.build.directory}/exe" />
                <mkdir dir="${project.build.directory}/distrib" />
              </tasks>
            </configuration>
          </execution>
        </executions>
      </plugin>

      <!-- Generación fichero OneJar -->
      <plugin>
        <groupId>org.dstovall</groupId>
        <artifactId>onejar-maven-plugin</artifactId>
        <version>1.4.0</version>
        <executions>
          <execution>
            <phase>package</phase>
            <goals><goal>one-jar</goal></goals>
            <configuration>
              <onejarVersion>0.96</onejarVersion>
              <filename>distrib/${project.build.finalName}-all.jar</filename>
              <attachToBuild>true</attachToBuild>
              <classifier>all</classifier>
            </configuration>
          </execution>
        </executions>
      </plugin>
      
      <!-- Generación de ejecutable Windows .exe con launch4j -->
      <plugin>
        <groupId>org.bluestemsoftware.open.maven.plugin</groupId>
        <artifactId>launch4j-plugin</artifactId>
        <version>1.0.0.2</version>
        
        <executions>
          <execution>
            <id>l4j-clui</id>
            <phase>package</phase>
            <goals>
              <goal>launch4j</goal>
            </goals>
            <configuration>
              <dontWrapJar>false</dontWrapJar>
              <headerType>gui</headerType>  <!-- Console si queremos aplicación de consola -->
              
              <jar>${project.build.directory}/distrib/${project.build.finalName}-all.jar</jar>
              <outfile>${project.build.directory}/exe/ChachiAndrew.exe</outfile>
              
              <!-- Optional, sets the title of the error message box that's displayed if Java cannot be found for instance.  -->
              <errTitle>Chachi Andrew</errTitle>
              
              <!-- Optional, constant command line arguments. -->
              <cmdLine />
              
              <!-- Optional. Change current directory to an arbitrary path relative to the executable. -->
              <chdir />
              
              <priority>normal</priority>
              <downloadUrl>http://java.com/download</downloadUrl>
              <supportUrl />
              
              <!-- Set the process name as the executable filename and use Xp style manifests (if any). -->
              <customProcName>true</customProcName>
              
              <!-- Optional, defaults to false in GUI header, always true in console header. When enabled the launcher waits for the Java application to finish and returns it's exit code. -->
              <stayAlive>false</stayAlive>
              <manifest />
              <icon>src/main/resources/com/andresviedma/chachiapp/resources/chachiapp.ico</icon>
              
              <jre>
                <!-- The <path> property is used to specify the absolute or relative path (to the executable) of a bundled JRE. Sólo si no se quiere que se use la actual -->
                <path />
                <!-- Search for Java, if an appropriate version cannot be found display error message and open the Java download page. -->
                <minVersion>1.5.0</minVersion>
                <maxVersion />
                <!--  Optional, max heap size in MB. -->
                <maxHeapSize>256</maxHeapSize>
              </jre>
              
              <!-- Pantalla de splash -->
              <splash>
                <!-- Necesario que esté en formato bmp -->
                <file>src/main/resources/com/andresviedma/chachiapp/resources/splashChachiApp.bmp</file>
                <!-- Optional, defaults to true. Close the splash screen when an application window or Java error message box appears. If set to false, the splash screen will be closed on timeout. -->
                <waitForWindow>true</waitForWindow>
                <!-- Optional, defaults to 60. Number of seconds after which the splash screen must be closed. Splash timeout may cause an error depending on <timeoutErr>. -->
                <timeout>30</timeout>
                <!--  Optional, defaults to true. True signals an error on splash timeout, false closes the splash screen quietly. -->
                <timeoutErr>false</timeoutErr>
              </splash>
              
              <!-- Información de versión -->
              <versionInfo>
                <!-- Version number 'x.x.x.x' -->
                <fileVersion>0.0.0.0</fileVersion>
                <!-- Free form file version, for example '1.20.RC1' -->
                <txtFileVersion>${project.version}</txtFileVersion>
                <!-- File description presented to the user. -->
                <fileDescription>${project.description}</fileDescription>
                <!-- Legal copyright. -->
                <copyright>(C) Andrés Viedma</copyright>
                <!-- Version number 'x.x.x.x' -->
                <productVersion>0.0.0.0</productVersion>
                <!-- Version number en texto libre -->
                <txtProductVersion>${project.version}</txtProductVersion>
                <!-- Text. -->
                <productName>${project.name}</productName>
                <!-- Optional text. -->
                <companyName>Andrés Viedma</companyName>
                <!-- Internal name without extension, original filename or module name for example. -->
                <internalName>${project.name}</internalName>
                <!-- Original name of the file without the path. Allows to determine whether a file has been renamed by a user. -->
                <originalFilename>ChachiAndrew.exe</originalFilename>
              </versionInfo>
              
              <!-- Mensajes de error que saldrán en caso de producirse -->
              <messages>
                <startupErr>Error al inicio de la ejecución</startupErr>
                <bundledJreErr>Esta aplicación estaba configurada para usar una Java JRE pero no existe o está corrupta.</bundledJreErr>
                <jreVersionErr>Esta aplicación requiere una versión distinta de JRE.</jreVersionErr>
                <launcherErr>No hay ninguna JRE instalada o está corrupta.</launcherErr>
                <instanceAlreadyExistsMsg>La aplicación ya se está ejecutando en otro proceso.</instanceAlreadyExistsMsg>
              </messages>
            </configuration>
          </execution>
        </executions>
      </plugin>
    </plugins>
  </build>

  <!-- Repositorio con plugin OneJar -->
  <pluginRepositories>
    <pluginRepository>
      <id>onejar-maven-plugin.googlecode.com</id>
      <url>http://onejar-maven-plugin.googlecode.com/svn/mavenrepo</url>
    </pluginRepository>
  </pluginRepositories>
  
  <dependencies>
    <dependency>
      <groupId>com.simontuffs</groupId>
      <artifactId>one-jar-boot</artifactId>
      <version>0.96</version>
      <scope>runtime</scope>
    </dependency>
  </dependencies>
</project>


Todavía podemos darle alguna vuelta más. Por ejemplo, podemos hacer un assembly sencillo que genere un ZIP en el que se meta el .exe (o el One-JAR) junto a ficheros de ayuda, licencia, configuración o lo que queramos. O podemos crear un instalable Windows que cree los iconos en el menú de inicio y utilice ese mismo ejecutable creado anteriormente con Launch4J. Para eso podemos usar sistemas como NSIS que, ¡vaya hombre!, resulta que también tiene integración con Maven.

El caso es que usando Maven y tocando cuatro cosas en tu pom.xml, puedes automatizar por completo la creación de un paquete de distribución de tu aplicación de escritorio de una forma bastante cómoda.

Ahora te queda la parte más chunga, querido lector: que en un mundo dominado por la nube, el HTML5 y las aplicaciones para móviles... alguien se interese por tu aplicación de escritorio.

Cof, cof.



3 comentarios:

  1. Mola! Conoces Griffon? resuelve todos esos problemas de un plumazo. Eso si, hay que programar en Groovy :b

    ResponderEliminar
    Respuestas
    1. Pues no, no lo conocía. El caso es que imagino que Swing con Groovy debe ser bastante menos coñazo, porque te quitas mucho código "morralla", especialmente para hacer un GUI no demasiado raro y "de un solo uso", o sea, que no sean componentes reutilizables. Imagino que será compatible con repositorios Maven, así que entiendo que se podría hacer sin problems un GUI con Griffon sobre un modelo Java ya existente, lo cual me mola más aún. Me lo apunto mentalmente ;-)

      Eliminar
  2. excelente tu articulo me sirvio de mucho, si no lo tomas a mal me permito reconedarte esta lectura, sin animo de polemizar
    desde ya muchas gracias por tu aporte

    http://www.prismasoftwaregestion.com/blog/software-web-vs-software-%E2%80%9Cno-web%E2%80%9D-o-de-escritorio-22/

    ResponderEliminar

cookie consent