Plataforma .NET: Cómo fusionar exe y dlls en un único ejecutable para distribuir

17/06/2020
Artículo original

Imagen ornamental por Bilal O. en Unsplash, CC0

La plataforma .NET viene incluida con todas las versiones de Windows desde hace años y, por suerte, puedes crear una aplicación y distribuirla a tus usuarios sin necesidad de distribuir también la plataforma completa, ya que puedes partir de la base de que la tienen disponible, salvo quizá las versiones más recientes. Por ejemplo, si el target de tu aplicación es .NET 4.5 puedes casi garantizar que todos tus usuarios con un ordenador de los últimos 7 u 8 años lo tendrán preinstalado en el sistema. Si es la 4.7 o 4.8 y tienen Windows 10 casi seguro que también la tienen porque se habrán actualizado, no así si usan Windows 8, por ejemplo, un Windows Server antiguo.

En cualquier caso, y aunque no tengas que distribuir la plataforma .NET completa junto a tu aplicación, muchas de estas aplicaciones acaban haciendo uso de multitud de DLLs de terceros: desde paquetes NuGet hasta DLLs propias que creamos en proyectos separados si la aplicación es compleja. En ocasiones, sin embargo, lo que te interesaría es poder distribuir un único ejecutable a tus usuarios, sin ninguna otra dependencia más allá de la propia plataforma. Esto es lo que vamos a ver en el artículo de hoy.

Nota: lo explicado en este artículo es válido tan solo para la plataforma .NET "clásica", o sea, hasta .NET 4.8.x. No sirve para .NET Core, pero en un próximo artículo explicaré cómo hacerlo con esta otra plataforma también.

El proyecto de ejemplo

Para ilustrar cómo hacerlo voy a crear un pequeño proyecto de ejemplo que nos sirva para hacer una prueba de concepto. Se trata de una aplicación de consola llamada singleexe que lo único que hace es mostrar por consola la hora actual. Por defecto, la app muestra la hora en una línea y luego un mensaje de "Pulse una tecla para continuar...", para evitar que se cierre la consola si hacemos doble-clic sobre el ejecutable. Pero, por si acaso queremos usarlo en un archivo de script, permite también el uso de un parámetro -b o --batch para mostrar tan solo la hora y continuar automáticamente.

Para facilitar la gestión de parámetros existe un paquete NuGet muy útil llamado CommandLineParser que facilita mucho el trabajo y que, además, por defecto, añade dos parámetros extra, --help para mostrar la ayuda de los parámetros y --version para mostrar la versión actual de la aplicación. Aquí tienes la documentación, pero lo único que hay que hacer es crear una clase para definir los parámetros y luego procesarlos en main, facilitándonos mucho las cosas en aplicaciones con muchos parámetros y sin preocuparnos del orden o de tener dos opciones (corta y larga, y con - o --). En este caso es, obviamente, excesivo para lo que queremos hacer, pero nos sirve para el objetivo que buscamos de mezclar ensamblados.

Este es el código de nuestra aplicación de ejemplo, que te dejo en este ZIP (4,07KB) para que puedas examinarla (recuerda restaurar los paquetes NuGet antes de compilarla):

El código fuente de nuestra mini-app

Ahora podemos ejecutar nuestra app con -b o --batch y obtener el resultado esperado (además de con -v o --version y -h o --help para ver la versión u obtener ayuda).

Si vamos a la carpeta bin a ver el resultado de compilarlo veremos que además del ejecutable tenemos que distribuir una DLL, CommandLine.dll, que pesa 212Kb. Vale, no es un problema comparado con cuando debemos distribuir decenas de ellas, pero nos sirve para nuestro ejemplo.

Fusionando ejecutables con ILMerge

Mike Barnett es Principal Research Software Design Engineer en Microsoft. Hace años creó una herramienta llamada ILMerge que sirve precisamente para lo que necesitamos: combinar varios ensamblados en uno solo, facilitando además otras cuestiones, como firmarlo digitalmente, convertir en privados los tipos que no se expongan al exterior, etc..

Aunque hace años se incluía con el SDK de .NET 2.0 y por lo tanto con Visual Studio, si lo tienes ya en disco no te servirá: necesitas la versión más moderna para poder trabajar con todas las versiones de .NET.

Como no, para instalarlo necesitarás hacerlo a través de un paquete NuGet. Así que debes abrir un proyecto en Visual Studio Code y agregarlo usando la siguiente instrucción en la línea de comandos de NuGet:

Install-Package ilmerge

La consola de NuGet en Visual Studio tras instalar el paquete ilmerge

Esto instalará la aplicación en la ruta: C:\Users\<TuUsuario>\.nuget\packages\ilmerge\<Version>\tools\net452. Desde ahí puedes utilizarlo para combinar los ensamblados de cualquier aplicación como veremos enseguida.

Aunque lo instalas como una dependencia de tu proyecto con NuGet, realmente no lo es y no lo verás en el nodo de dependencias del Solution Explorer de Visual Studio. De hecho, una vez instalado, puedes editar el packages.config para eliminar la línea de este paquete, pues no le hace falta a tu proyecto en realidad y una vez lo hayas instalado en algún proyecto ya lo tienes disponible en la ruta anterior para usar cuando quieras:

El archivo packages.config con la linea de ilmerge destacada para aeliminar

Nota: yo lo he dejado en el proyecto de ejemplo para que se te instale nada más intentes compilarlo y se restauren las dependencias. Así que, si abres el proyecto y lo ejecutas, ya no te hace falta instalarlo a mano.

Vale. Ya tenemos la utilidad instalada. Ahora solo queda utilizarla. Para ello lo más sencillo es ir a la carpeta donde se ha instalado (o añadirla al PATH del sistema para que esté siempre disponible) y escribir:

ILMerge <ruta del ejecutable> <rutas de las DLLs, separadas con comas> /out:<ruta nuevo ejecutable>

Por ejemplo, en mi app ejemplo, escribiré:

ILMerge E:\singleexe\bin\Release\singleexe.exe E:\singleexe\bin\Release\CommandLine.dll /out:E:\singleexe.exe

Y lo que hará será mezclar en singleexe.exe la DLL CommandLine.dll y generar un nuevo ejecutable llamado singleexe.exe (podría ser cualquier otro nombre) en la carpeta raíz de mi unidad E:\ que contiene la mezcla de ambos.

Este nuevo singleexe.exe pesa mucho más que el original (230Kb frente a las 5,5KB del original) porque ya tiene dentro todo el código compilado de las DLLs de soporte que estábamos utilizando.

Ahora puedo distribuir ese .exe con un solo archivo, el cual funcionará sin ningún problema pues tiene toda la funcionalidad embebida:

La app de consola de ejemplo ejecutándose con varias opciones de línea de comandos

Podemos usar algunas opciones más que pueden ser interesantes.

Por ejemplo, cuando hay muchas DLLs que necesitamos incluir, meterlas una a una en la línea de comandos es tedioso y propenso a errores. Así que podemos sacar partido al parámetro /wildcards:true de modo que nos permita usar * y ? en los nombres de los ensamblados a mezclar. Gracias a esto nuestro comando anterior se convierte en:

ILMerge E:\singleexe\bin\Release\singleexe.exe /wildcards:true E:\singleexe\bin\Release\*.dll /out:E:\singleexe.exe

Fíjate en cómo ahora, en lugar de especificar el nombre de la DLL he puesto un *.dll de modo que todas las DLL que haya en esa ruta se mezclarán con mi ejecutable. Así, si en el futuro le añado nuevas dependencias a la aplicación no me tengo que preocupar de añadirlas a la operación: se añadirán automáticamente.

Otra opción interesante es /internalize. Como sabes, en .NET cualquier clase pública que tenga tu aplicación, incluso en un .exe, se puede utilizar desde cualquier otra aplicación .NET con tan solo añadir una referencia al ensamblado. Si utilizas /internalize lo que consigues es que todas las clases de los ensamblados que mezclas se conviertan al ámbito interno (internal), de modo que no se puedan utilizar desde otras aplicaciones. Fíjate, por ejemplo, cómo se ha convertido la clase Parse del ensamblado CommandLine.dll en una clase interna en el ejecutable final con todo mezclado y esta opción activada:

Detalle de la clase Parser de CommandLine en ILSpy: se ve que es privada tras la mezcla

Si hay alguna clase o espacio de nombres que no queramos internalizar y permitir que sea visible desde el exterior podemos usar una expresión regular como valor para el parámetro /internalize de modo que se excluirán de este proceso, por ejemplo:

ILMerge <ruta> /internalize:CommandLine.Parser

En el valor de este argumento puede ir cualquier cosa que sea interpretable correctamente por el analizador de expresiones regulares de .NET.

Aquí tienes todas las opciones, pero algunas que pueden interesarte son:

  • /lib:carpeta: para especificar en qué carpetas debe buscar los ensamblados. Así te ahorras la ruta completa, como he puesto en mi ejemplo, y puedes poner tan solo el nombre de los archivos.
  • /ndebug:false: si no especificamos nada, ILMerge negera un archivo .dbg de depuración para el nuevo ensamblado (como Visual studio, de hecho), de modo que lo podamos usar para depurar en producción en caso necesario. Si no lo queremos, podemos especificar este parámetro y se generará sólo el ensamblado final fusionado.
  • /target: el tipo de ensamblado que quieres generar. Puede ser library, exe o winexe para, respectivamente, DLLs, aplicaciones de consola o aplicaciones de escritorio para Windows (incluyendo WPF). Si no indicamos nada, genera el mismo tipo de ensamblado que el archivo inicial.
  • /union: si hay dos o más clases con el mismo nombre en los ensamblados que se mezclan, con este switch le indicamos que queremos que los fusione en una sola clase con ese nombre, en vez de tener varias diferentes o que se produzca un conflicto.
  • /version: por defecto, pone la misma versión que tenga nuestro ensamblado principal (el que indicamos de primero), pero si usamos esto le podemos indicar una versión diferente, por ejemplo /version:1.5.0.
  • /log:ruta_a_archivo.txt: por derecto ILMerge no muestra nada por pantalla cuando hace su labor. Sabremos que ha terminado porque simplemente no da un error. Si queremos ver todo lo que ocurre durante el proceso, con esto podemos establecer la ruta a un archivo de texto (con la extensión que queramos) en el que se guardarán los detalles.

Esta utilidad funciona con todo tipo de ensamblados, no sólo ejecutables. Por ejemplo, si una DLL depende de otras (incluso una compilada como aplicación Web), con esta técnica podríamos combinarlas en una única DLL para distribuirla de manera más "empaquetada".

Desde luego no es algo que vayas a usar todos los días, pero sí que es muy interesante para algunas situaciones especiales, en cuyo caso te puede salvar el día.

¡Espero que te resulte útil!