Sobre NuGet y versiones…

Que NuGet ha supuesto una revolución en .NET es más que evidente. Lejos han quedado aquellos tiempos en que gestionábamos las dependencias como podíamos. Poco a poco el modelo de desarrollo está migrando de estar basado en “dependencias a ensamblados” a “dependencias a paquetes”, y a medida que netcore vaya teniendo una mayor relevancia esto irá a más.

Pero esta gestión semi-automatizada de las dependencias también trae sus propios quebraderos de cabeza…

En nodejs es muy común hablar del “npm hell” o el infierno que puede suponer la gestión de paquetes usando npm. Que al cabo de un tiempo alguien se baje el código de tu repositorio y que no le funcione o bien que actualices un paquete y se terminen rompiendo 400 más, es algo muy (demasiado) habitual. ¿Tenemos en .NET un nuget hell?


TL;DR

Este es un post bastante largo, así que aquí hay un resúmen de los puntos elementales tratados:

  1. Este post trata sobre NuGet y los nuevos csproj. Algunas cosas pueden ser válidas en versiones anteriores de NuGet y csproj clásicos pero no asumas nada al respecto.
  2. La gestión de paquetes es complicada. No hay una estrategia válida en todos los casos.
  3. Los paquetes NuGet no existen en tiempo de ejecución. Solo tenemos ensamblados.
  4. El csproj referencia a NuGets, pero los ensamblados solo referencian a otros ensamblados
  5. Las versiones de NuGet y de ensamblados pueden ser distintas
  6. NuGet trabaja sólo con versiones de NuGet
  7. El CLR trabaja sólo con versiones de ensamblados
  8. Un paquete NuGet se restaura en una única versión (por cada proyecto). NuGet debe encontrar “cual es la mejor versión” de un paquete teniendo en cuenta todos los paquetes que le referencian.
  9. NuGet intenta proporcionar siempre las versiones más bajas posibles de un paquete
  10. Se puede recibir una versión (de una dependencia) más baja que la que se haya pedido
  11. Se puede recibir una versión (de una dependencia) más alta que la que se haya pedido
  12. Se puede recibir una versión (de una dependencia) fuera del rango de versiones que se hayan pedido
  13. A veces NuGet nos avisa, pero a veces no
  14. Los warnings de NuGet en general son peligrosos

Y ahora si tienes ganas de leer… el post 😉

Preparando el entorno: UltraConsoleLib

Para empezar create un directorio vacío que vamos a usar como servidor de NuGet. En mi caso he creado D:\TestNuget, pero puedes usar el que quieras.

Vamos a crearnos una librería de clases que sea netstandard1.4. Ya sabes que ahora la buena práctica es intentar que las librerías cumplan netstandard para que así sean utilizables por cualquier plataforma (netfx, netcore, …) que implemente la versión de netstandard que nuestra librería cumple.

Nuestra librería, llamada UltraConsoleLib, tendrá una sola clase con un solo método:

public class UltraConsole
{
    public static void WriteLine (string message, params object[] args)
    {
        Console.WriteLine($"UC 1.0 {message}", args);
    }
}

Esa es la versión de UltraConsole 1.0.

Ahora vamos a publicar UltraConsole a NuGet. Para ello, desde VS2017 hacemos click con el botón derecho en el proyecto (en el solution explorer) y pulsamos “Pack”. Pack es el comando para crear un paquete NuGet y eso nos dejará el paquete UltraConsoleLib.1.0.0.nupkg en el directorio bin/Debug. Copia este paquete al directorio vacío que has creado antes.

Creando un cliente de UltraConsoleLib

Esa es fácil: Abre otro VS2017 y antes que nada añade el directorio que hemos usado como servidor de NuGet local. Para ello ve a Tools –> NuGet Package Manager –> Package Manager Settings –> Package Sources y allí añade el directorio que has creado antes. Con eso VS2017 usará también ese directorio para resolver dependencias NuGet.

image

Ahora ya puedes crear un proyecto, que sea también una librería de clases netstandard1.4. Llamemos UltraLogger a esa librería. Lo primero es instalar el paquete “UltraConsoleLib” mediante Install-Package. Como has añadido el “package source” en VS2017 esto te tiene que funcionar:

image

Si ahora abres el csproj, verás que se ha añadido un elemento <PackageReference>:

<PackageReference Include="UltraConsoleLib" Version="1.0.0" />

Esta es una nueva capacidad del nuevo csproj: las referencias a NuGet van directamente dentro del csproj, de forma análoga a como las teníamos en difunto project.json.

El fichero NuGet.config

Veamos ahora como configurar el entorno si no usas VS. Lo primer es modificar el fichero UltraLogger.csproj para añadir un elemento <PackageReference> que contiene la referencia. Solo ten presente que <PackageReference> debe ir dentro de un elemento <ItemGroup>. Si tienes cualquier <ItemGroup> en el csproj puedes añadir el <PackageReference> en él, y si no lo creas y listos.

Una vez tienes la referencia en el csproj debes añadir un fichero NuGet.config en el mismo directorio del proyecto, con el siguiente contenido:

<?xml version="1.0" encoding="utf-8"?>
<configuration>
  <packageSources>
    <add key="repositoryPath" value="D:\TestNuGet" />
  </packageSources>
</configuration>

(Por supuesto usa el valor de tu directorio en el atributo value del tag packageSources/add).

Si ahora ejecutas un “dotnet restore” desde la línea de comandos (en el mismo directorio del proyecto) todo debería funcionar:

image

Ahora ya puedes editar el código de UltraLogger para crear una clase UltraLogger que use UltraConsoleLib:

public class UltraLogger
{
    public void LogError(string message, params object [] args)
    {
        UltraConsoleLib.UltraConsole.WriteLine("[ERR] " + message, args);
    }
}

Perfecto. Ahora publica UltraLogger en NuGet. Si usas VS simplemente usa la opción “Pack” desde el solution explorer y si usas la CLI simplemente teclea “dotnet pack” desde el directorio del proyecto. Finalmente copia el fichero nupkg al directorio que uses como servidor de NuGet.

Creando el cliente

Vamos a crear un proyecto ahora, que use esas dos referencias. Para ello creamos una aplicación de linea de comandos vacía. Procura que sea una aplicación netcore. Eso es porque, lamentablemente, una aplicación netfx todavía usa el csproj antiguo y para nuestras pruebas nos va mejor tener el csproj nuevo. Si no, sería irrelevante ya que nuestras librerías netstandard se pueden usar en netfx y en netcore 🙂

Ahora añadimos una referencia a UltraLogger  (editando el csproj a mano o bien usando VS e Install-Package). En el método Main de nuestro cliente instanciamos un Logger con un mensaje cualquiera:

static void Main(string[] args)
{
    new UltraLoggerLib.UltraLogger()
        .LogError("Test Error");
}

Por supuesto esto funciona y se muestra por pantalla “UC 1.0 [ERR] Test Error”. La situación de las dependencias que tenemos ahora es la siguiente:

Client ---> UltraLogger (1.0.0) ---> UltraConsoleLib (1.0.0)

Actualizando UltraConsoleLib

Vamos a crear una versión 1.1 de UltraConsoleLib. Para ello abre el VS que tenía ese proyecto y simplemente cambia el método WriteLine para que en lugar de poner “UC 1.0” ponga “UC 1.1”:

public class UltraConsole
{
    public static void WriteLine (string message, params object[] args)
    {
        Console.WriteLine($"UC 1.1 {message}", args);
    }
}

Ahora desde VS vete a las propiedades del proyecto y en la pestaña package coloca la versión 1.1.0:

SNAGHTML68dcaa

Si no usas VS simplemente añade una etiqueta <Version>,  justo después de la etiqueta <TargetFramework>, con el valor de la versión:

  <PropertyGroup>
    <TargetFramework>netstandard1.4</TargetFramework>
    <Version>1.1.0</Version>
  </PropertyGroup>

Ahora publica de nuevo el paquete NuGet y copialo a tu directorio que actúa como servidor de NuGet (observa que ahora se llama UltraConsoleLib.1.1.0.nupkg).

Regla #0 de NuGet

Esa regla nos limitaremos a anunciarla, ya que es muy simple: Un paquete NuGet se resuelve siempre a una única version. Es decir, nunca tenemos a la vez un paquete en dos o más versiones distintas.

El resto de reglas nos permitirán saber cual es esa versión 🙂

Regla #1 de NuGet

Vayamos a nuestro Cliente, a ver qué ocurre ahora. Para ello vamos a borrar la información de los paquetes restaurados y luego los restauramos de nuevo. Para ello simplemente borra el fichero obj/project.assets.json del proyecto y los restauramos de nuevo (“dotnet restore” desde la CLI o bien un Build desde VS). Y así queda la situación:

image

Observa que Client depende de UltraLogger 1.0.0 que a su vez depende de UltraConsoleLib 1.0.0. Observa que se sigue usando la versión 1.0.0 de UltraConsoleLib, no la 1.1.0.

>>> Inicio paréntesis a la regla #1

¿Debería usar NuGet la 1.1.0? Bueno, el proyecto UltraLogger depende de UltraConsoleLib en su versión 1.0.0, no en su versión 1.1.0, ¿verdad? Pues no. Déjame que te cuente la regla #1 del PackageReference: Una referencia con versión X.Y.Z no significa que dependamos de la versión X.Y.Z. Significa que dependemos de la versión >= X.Y.Z.

<PackageReference Include="UltraConsoleLib" Version="1.0.0" />

Eso no significa que dependamos de UltraConsoleLib en su versión 1.0.0. Eso significa que dependemos de UltraConsoleLib con versión >= 1.0.0. Este >= es importante: para NuGet cualquier versión igual o superior 1.0.0 le sirve. Para comprobarlo haz una prueba rápida:

  1. Mueve la versión 1.0.0 de UltraConsoleLib fuera de NuGet (es decir, ponla en otro directorio que no sea el que usamos como servidor de NuGet)
  2. Borra el fichero obj/project.assets.json para forzar a NuGet a resolver todos los paquetes otra vez
  3. Vacía la cache de nuget mediante el comando “dotnet nuget locals global-packages –c”. Es importante vaciar la cache de NuGet porque si no, a pesar de que la versión 1.0.0 de UltraConsoleLib la has quitado de NuGet, NuGet la seguiría encontrando en la cache.

Ahora ya puedes resolver los paquetes otra vez y verás como UltraLogger recibe la versión 1.1.0 de UltraConsoleLib:

image

Por lo tanto eso nos demuestra que podemos recibir una referencia más alta que la que hemos pedido, pero nunca recibiremos una más baja que la que hemos pedido (vale, a veces sí, ya lo verás luego).

<<< Fin del paréntesis a la regla #1

Antes del paréntesis hemos visto como a pesar de haber la versión 1.1.0 de UltraConsoleLib, UltraLogger recibía la 1.0.0. El paréntesis nos ha enseñado como podemos recibir una versión “más alta” de la que hemos pedido. Combinando esas dos observaciones podemos obtener la regla #1:

Regla #1: NuGet siempre intenta resolver la versión MÁS BAJA POSIBLE de un paquete.

Regla #2 de NuGet

Antes de nada acuerdate de volver a colocar la versión 1.0.0 de UltraConsoleLib en NuGet. 🙂

El siguiente punto es tener la versión 1.1.0 de UltraLogger que dependa de la versión 1.1.0 de UltraConsoleLib: Simplemente cambia la <PackageReference> del csproj de UltraLogger y añade una etiqueta <Version> con 1.1.0. Lanza el comando Pack otra vez y añade el fichero UltraLogger.1.1.0.nupkg al directorio que usas como servidor de NuGet.

Ahora tenemos dos versiones de UltraLogger: La 1.0.0 que depende de UltraConsoleLib en la 1.0.0 y la 1.1.0 que depende de UltraConsoleLib en la 1.1.0.

Finalmente en el Cliente modifica la referencia a UltraLogger para que sea a la 1.1.0 y añade una referencia a UltraConsoleLib en su versión 1.0.0:

    <PackageReference Include="UltraLogger" Version="1.1.0" />
    <PackageReference Include="UltraConsoleLib" Version="1.0.0" />

Las dependencias que tenemos ahora son las siguientes:

Client ---> UltraLogger (1.1.0) ---> UltraConsoleLib ---> (1.1.0)
Client ---> UltraConsoleLib (1.0.0)

Ahora restaura los paquetes otra vez y obtendrás el warning de Detected package downgrade: UltraConsoleLib from 1.1.0 to 1.0.0. Si miras las dependencias ahora verás lo siguiente:

image

¿Lo ves? UltraLogger recibe una referencia a la versión 1.0.0 de UltraConsoleLib (a pesar de pedir una versión >= 1.0.0. Esto, precisamente, es lo que nos indica el warning. ¿Por qué ocurre esto? Por la regla #2.

Regla #2: Cuando “más arriba” (más cerca del cliente) en al árbol de dependencias está una referencia, más prioridad tiene.

¿Qué referencias (directas e indirectas) a UltraConsoleLib tenemos en el proyecto? Pues dos:

  1. Client->UltraConsoleLib (1.0.0)
  2. Client->UltraLogger (1.1.0) ->UltraConsoleLib (1.1.0)

De esas dos, la que está “más arriba” (o más cerca de Client) es la primera. Por tanto esa referencia toma prioridad, respecto al resto de referencias a UltraConsoleLib que aparecen “más abajo” en el árbol de dependencias. Esto está hecho para optimizar el proceso de decidir las dependencias de paquetes (especialmente en grafos grandes como la BCL) pero el precio que se paga es que podemos recibir una versión inferior a la que hemos pedido. Cuando eso ocurre, recibiremos el warning de “Detected package downgrade”.

Errores debidos a la Regla #2

Como digo la Regla #2 es potencialmente muy peligrosa. Veamos un ejemplo de ella.

Si restauras los paquetes, recibirás el warning de Detected package downgrade. Ahora compila y ejecuta el proyecto y… ¡kaboom!

Unhandled Exception: System.IO.FileLoadException: Could not load file or assembly 'UltraConsoleLib, Version=1.1.0.0, Culture=neutral, PublicKeyToken=null'. The located assembly's manifest definition does not match the assembly reference.

Nos da un error de que no se encuentra UltraConsoleLib (1.1.0). Lógico, porque dicha versión no existe, ya que se ha instalado la versión 1.0.0. Aunque…

¡Ojo con el concepto de versión!

Hasta ahora hemos estado hablando de versiones de paquetes NuGet, pero en tiempo de ejecución NuGet no existe. Así que esta “versión 1.1.0.0” no es la versión 1.1.0.0 del paquete NuGet si no la versión del ensamblado. Es decir, la versión de UltraConsoleLib.dll.

Esto te puede resultar confuso, pero recuerda: una cosa es la versión del paquete NuGet y otra la versión del ensamblado. NuGet se basa en la versión del paquete al restaurar dependencias, pero el CLR se basa en la versión del ensamblado para cargarlas. Y la clave es que la versión NuGet y la versión del ensmablado no tienen por qué coincidir. De hecho en el fichero csproj tenemos dos etiquetas distintas:

    • <Version> que indica la versión
    • <PackageVersion> que indica la versión de NuGet. Si no existe, se usa el valor de <Version>
    • <AssemblyVersion> que indica la versión del ensamblado. Si no existe se usa el valor de <Version>

Lo que nos importa a nosotros es que NuGet se fija en PackageVersion y el CLR en AssemblyVersion. Esto nos da la herramienta que necesitamos para intentar seguir…

Semantic versioning (semver)

No voy a entrar a discutir que es semver, pero he aquí las reglas para (intentar) seguir semver correctamente en NuGet. Cuando crees un paquete NuGet:

  • Si modificas la versión PATCH la versión del ensamblado no debe verse modificada. Es decir, paquetes NuGet con versiones 3.1.0, 3.1.1 y 3.1.2 deben tener la misma versión del ensamblado.
  • Si modificas la versión MINOR puedes modificar la versión del ensamblado, pero entonces ten presente que si se produce un downgrade package la aplicación afectada dejará de funcionar. Si al modificar la versión MINOR no modificas la versión del ensamblado, entonces la aplicación afectada solo dejará de funcionar si llama a cualquier método que hayas agregado nuevo en dicha versión. P. ej. en nuestro caso, si UltraConsoleLib (1.1.0) no hubiese cambiado la versión de UltraConsoleLib.dll el programa seguiría funcionando.
  • Si modificas la versión MAJOR deberías modificar la versión del ensamblado. Una versión MAJOR incorpora breaking changes, así que ya está bien que las aplicaciones afectadas por un downgrade package se vean afectadas.

Como desarrollador, si usas un paquete NuGet y recibes un downgrade package no deberías preocuparte si las versiones afectadas son de PATCH (p. ej. un downgrade de la 9.1.2 a la 9.1.0). Por otro lado si son de MINOR o MAJOR entonces ten por seguro que, probablemente, tendrás problemas.

Versiones de ensamblado… un poco más

Hasta ahora hemos visto como en el csproj establecemos una dependencia a una versión de NuGet. Ten presente que la traducción entre versiones NuGet y versiones de DLL se realiza cuando se compila el proyecto. P. ej., volvamos al proyecto UltraLogger (1.0.0) que tiene una <PackageReference> a UltraConsoleLib en su versión 1.0.0. Supongamos que dicha versión no existe en NuGet. Supongamos que en NuGet solo existe la 1.1.0

En este caso cuando restaures los paquetes recibirás un warning de NuGet:

Dependency specified was UltraConsoleLib (>= 1.0.0) but ended up with UltraConsoleLib 1.1.0.

Este warning es relativamente peligroso porque nos está diciendo que UltraLogger esperaba la versión 1.0.0 de UltraConsoleLib pero ha terminado con la 1.1.0. Si ahora compilamos el proyecto de UltraLogger es cuando se crea UltraLogger.dll y se crea la referencia al ensamblado de UltraConsoleLib. En mi caso la versión del ensamblado coincide con la de NuGet. Es decir:

  1. UltraConsoleLib (1.0.0) –> Contiene UltraConsoleLib v1.0,0
  2. UltraConsoleLib (1.1.0) –> Contiene UltraConsoleLib v1.1.0

La situación es que UltraLogger declara en el csproj una referencia a UltraConsoleLib (1.0.0) pero como no existía ha terminado con UltraConsoleLib (1.1.0). Al compilar eso significa que el ensamblado UltraLogger.dll tendrá una referencia a UltraConsoleLib.dll en su versión 1.1.0. De hecho si compilamos y abrimos UltraLogger.dll con ildasm podremos ver las siguientes líneas en el MANIFEST del ensamblado:

.assembly extern UltraConsoleLib
{
   .ver 1:1:0:0
}

Eso es la referencia de UltraLogger.dll hacia UltraConsoleLib.dll en su versión 1.1.0.0. Eso es lo que ve el CLR y eso es lo que busca. Nada de versiones de NuGets.

Ahora imagina un Cliente que tiene una <PackageReference> hacia este UltraLogger (1.0.0) que está en NuGet y compilamos este Cliente en un entorno cuyo NuGet sí que tiene UltraConsoleLib (1.0.0). Cuando restaures los paquetes:

  • NuGet restaurará UltraLogger (1.0.0). Y NuGet verá que UltraLogger (1.0.0) tiene una dependencia de UltrConsoleLib (1.0.0) (eso está en los metadatos del nupkg), así que…
  • NuGet restaurará UltraConsoleLib (1.0.0) (que ahora sí que existe).

Así ahora terminamos con UltraLogger.dll v1.0.0 y con UltraConsoleLib.dll v1.0.0. Pero si recuerdas UltraLogger.dll v1.0.0 tenía una referencia a UltraConsoleLib.dll v.1.1.0 que no está. ¿El resultado? El siguiente:

System.IO.FileLoadException: Could not load file or assembly 'UltraConsoleLib, Version=1.1.0.0, Culture=neutral, PublicKeyToken=null'. The located assembly's manifest definition does not match the assembly reference.

Por lo tanto: Es importante que entiendas la diferencia entre versión de NuGet, versión de ensamblado y que la referencia entre ensamblados se crea al momento en que se construye el proyecto. Y si ves el warning de Dependency specified was xxx but ended up with yyy, vigila porque eso puede dar problemas en un futuro: básicamente puedes asumir que los dará si ese warning te aparece debido a que te falta un feed de NuGet configurado.

Rangos de versiones en csproj

Recuerda que una <PackageReference> a una versión X.Y.Z significa que nos conformamos con cualquier versión igual o superior a X.Y.Z. Hemos visto que NuGet nos intentará dar la versión más baja posible que cumpla la condición (regla #1) y hemos visto como a veces podemos terminar con una versión más baja (por la regla #2) lo que nos puede causar problemas.

Pero podemos especificar rangos de versiones en el csproj. Simplemente en lugar de una versión, especifica un rango abierto (con paréntesis) o cerrado (con corchetes) entre versiones:

<PackageReference Include="UltraConsoleLib" Version="[1.0.0, 2.0.0)" />

Eso significa que aceptamos cualquier versión mayor o igual a 1.0.0 e inferior a 2.0.0.

Imagina que tenemos esto en UltraLogger (1.0.0). Ahora vamos a crear otro paquete de NuGet que use UltraConsoleLib. En mi caso he llamado UltraDebugger (1.0.0) a este paquete de NuGet y tiene las siguientes <PackageReferences>:

<PackageReference Include="UltraConsoleLib" Version="2.0.0" />

Ahora vamos al Cliente, que como podrás suponer tiene las referencias:

    <PackageReference Include="UltraLogger" Version="1.0.0" />
    <PackageReference Include="UltraDebugger" Version="1.0.0" />

Si ahora restauramos los paquetes de NuGet… obtendremos un error:

Version conflict detected for UltraConsoleLib.
  Client (>= 1.0.0) -> UltraDebugger (>= 1.0.0) -> UltraConsoleLib (>= 2.0.0)
  Client (>= 1.0.0) -> UltraLogger (>= 1.0.0) -> UltraConsoleLib (>= 1.0.0).

El error se da porque tenemos el siguiente árbol de dendencias:

Client ---> UltraDebugger >= (1.0.0) ---> UltraConsoleLib >= (2.0.0)
Client ---> UltraLogger >= (1.0.0) ---> UltraConsoleLib >= (1.0.0) && < (2.0.0)

O sera, UltraDebugger pide como mínimo la versión 2.0.0 de UltraConsoleLib y UltraLogger pide como mínimo la 1.0.0 y como máximo cualquier inferior a la 2.0.0 (recuerda el rango que metimos en el <PackageReference>. Así pues.. NuGet no puede resolver los paquetes.

¿Como solucionar este conflicto? Pues aplicando la regla #2. En este caso las dos dependencias a UltraConsoleLib estan “a la misma distancia” de Client (en ambos casos hay una dependencia por en medio). Pero si p. ej. añadimos a Client una referencia directa a una versión concreta de UltraConsoleLib esa dependencia (por la regla #2) mandará sobre las otras dos (es la que esá más arriba en el árbol). Así, si añadimos a Client la siguiente <PackageReference>:

<PackageReference Include="UltraConsoleLib" Version="2.0.0" />

Entonces ya podrás resolver los paquetes y todo el mundo recibirá UltraConsoleLib (2.0.0):

image

Observa como UltraLogger recibe (por la regla #2) la versión (2.0.0) de UltraConsoleLib a pesar de estar fuera del rango. Lamentablemente no recibimos ningún warning en este caso, lo que (en mi opinión) es bastante peligroso, ya que claramente un paquete recibe una versión “incorrecta” de una dependencia.

Por supuesto si la <PackageReference> del Client fuese:

<PackageReference Include="UltraConsoleLib" Version="1.0.0" />

Entonces tanto UltraLogger (1.0.0) como UltraDebugger (1.0.0) recibirían la versión (1.0.0) de UltraConsoleLib, y entonces el paquete que recibe una versión “incorrecta” es UltraDebugger. Pero en este caso NuGet sí que nos avisa con un downgrade package version detected.

O sea, NuGet nos avisa cuando un paquete recibe una versión inferior a la pedida, pero no nos avisa cuando un paquete recibe una versión superior a la máxima admitida.

Bueno… y creo que más o menos eso es todo lo que tenía pendiente de contar sobre NuGet y versiones. Como podéis ver… ¡hay más tela que la que parece!

Deja un comentario

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *