Saltar al contenido principal

Versionamiento de paquetes

Cómo maneja el versionamiento de los paquetes la herramienta de gestión de paquetes de Dart, pub.

El gestor de paquetes pub te ayuda a trabajar con el versionamiento. Esta guía explica un poco sobre la historia del versionamiento y el enfoque de pub al respecto.

Considera esto como información avanzada. Para aprender por qué pub fue diseñado de la forma en que lo fue, sigue leyendo. Si quieres usar pub, consulta los otros documentos.

El desarrollo de software moderno, especialmente el desarrollo web, se apoya en gran medida en la reutilización de muchísimo código existente. Eso incluye código que escribiste en el past, pero también código de terceros, desde grandes frameworks hasta pequeñas bibliotecas de utilidad. No es raro que una aplicación dependa de docenas de paquetes y bibliotecas diferentes.

Es difícil subestimar lo poderoso que es esto. Cuando ves historias de pequeñas empresas emergentes de la web que construyen un sitio en pocas semanas que obtiene millones de usuarios, la única razón por la que pueden lograrlo es porque la comunidad de código abierto ha puesto a sus pies un banquete de software.

Pero esto no es gratuito: existe un desafío en la reutilización de código, especialmente al reutilizar código que tú no mantienes. Cuando tu app utiliza código desarrollado por otras personas, ¿qué sucede cuando lo cambian? Ellos no quieren romper tu app, y tú ciertamente tampoco. Resolvemos este problema mediante el versionamiento.

Un nombre y un número

#

Cuando dependes de alguna pieza de código externa, no dices simplemente "Mi app usa widgets." Dices: "Mi app usa widgets 2.0.5." Esa combinación de nombre y número de versión identifica de manera única un fragmento de código inmutable. Las personas que actualizan widgets pueden realizar todos los cambios que deseen, pero prometen no tocar ninguna versión ya lanzada. Pueden publicar la versión 2.0.6 o 3.0.0 y no te afectará en lo más mínimo porque la versión que usas no cambia.

Cuando desees obtener esos cambios, siempre puedes apuntar tu app a una versión más nueva de widgets y no tienes que coordinar con esos desarrolladores para hacerlo. Sin embargo, eso no resuelve el problema por completo.

Los números de versión analizados en esta guía pueden diferir del número de versión establecido en el nombre del archivo del paquete. Pueden incluir -0 o -beta. Estas notaciones no afectan la resolución de dependencias.

Resolver dependencias compartidas

#

Depender de versiones específicas funciona bien cuando tu gráfico de dependencias es en realidad solo un árbol de dependencias. Si tu aplicación depende de un montón de paquetes, y esas cosas a su vez tienen sus propias dependencias y así sucesivamente, todo funciona bien siempre y cuando ninguna de esas dependencias se superponga.

Considera el siguiente ejemplo:

gráfico de dependencias

Entonces, tu app utiliza widgets y templates, y ambos usan collection. Esto se llama una dependencia compartida. Ahora, ¿qué sucede cuando widgets quiere usar collection 2.3.5 y templates quiere collection 2.3.7? ¿Qué pasa si no se ponen de acuerdo en una versión?

Bibliotecas no compartidas (el enfoque de npm)

#

Una opción es simplemente dejar que la app use ambas versiones de collection. Tendrá dos copias de la biblioteca en diferentes versiones y widgets y templates obtendrán cada uno la que desean.

Esto es lo que hace npm para node.js. ¿Funcionaría para Dart? Considera este escenario:

  1. collection define alguna clase Dictionary.
  2. widgets obtiene una instancia de ella a partir de su copia de collection (2.3.5). Luego la pasa a my_app.
  3. my_app envía el diccionario a templates.
  4. Esta, a su vez, lo envía a su versión de collection (2.3.7).
  5. El método que lo recibe tiene una anotación de tipo Dictionary para ese objeto.

En lo que respecta a Dart, collection 2.3.5 y collection 2.3.7 son bibliotecas completamente ajenas. Si tomas una instancia de la clase Dictionary de una y la pasas a un método en la otra, ese es un tipo de Dictionary completamente diferente. Eso significa que no coincidirá con la anotación de tipo Dictionary en la biblioteca receptora. Vaya.

Debido a esto (y a los dolores de cabeza de intentar depurar una app que tiene múltiples versiones de elementos con el mismo nombre), hemos decidido que el modelo de npm no es una buena opción.

Bloqueo de versión (el enfoque del callejón sin salida)

#

En su lugar, cuando dependes de un paquete, tu app solo utiliza una copia única de ese paquete. Cuando tienes una dependencia compartida, todo lo que depende de ella tiene que ponerse de acuerdo sobre qué versión usar. Si no lo hacen, obtienes un error.

Sin embargo, eso no resuelve realmente tu problema. Cuando obtienes ese error, necesitas poder resolverlo. Supongamos que te has metido en esa situación en el ejemplo anterior. Quieres usar widgets y templates, pero están usando diferentes versiones de collection. ¿Qué haces?

La respuesta es intentar actualizar uno de ellos. templates quiere collection 2.3.7. ¿Hay una versión posterior de widgets a la que puedas actualizar que funcione con esa versión?

En muchos casos, la respuesta será "no". Míralo desde la perspectiva de las personas que desarrollan widgets. Quieren publicar una nueva versión con nuevos cambios en su código, y quieren que la mayor cantidad de personas posible pueda actualizar a ella. Si se apegan a su versión actual de collection, entonces cualquiera que esté usando la versión actual de widgets también podrá incorporar esta nueva.

Si actualizaran su dependencia de collection, todos los que actualicen widgets tendrían que hacerlo también, lo quieran o no. Eso es doloroso, por lo que terminas con un desincentivo para actualizar las dependencias. Eso se llama bloqueo de versión (version lock): todos quieren hacer avanzar sus dependencias, pero nadie puede dar el primer paso porque obliga a todos los demás a hacerlo también.

Restricciones de versión (el enfoque de Dart)

#

Para resolver el bloqueo de versión, relajamos las restricciones que los paquetes imponen a sus dependencias. Si widgets y templates pueden indicar un rango de versiones de collection con las que funcionan, entonces eso nos da suficiente margen de maniobra para avanzar nuestras dependencias hacia versiones más nuevas. Mientras haya superposición en sus rangos, aún podemos encontrar una sola versión que los deje a ambos contentos.

Este es el modelo que sigue bundler, y también es el modelo de pub. Cuando agregas una dependencia en tu pubspec, puedes especificar un rango de versiones que puedes aceptar. Si el pubspec para widgets se viera así:

yaml
dependencies:
  collection: '>=2.3.5 <2.4.0'

Podrías elegir la versión 2.3.7 para collection. Una única versión concreta satisfaría las restricciones para ambos paquetes, widgets y templates.

Versiones semánticas

#

Cuando agregas una dependencia a tu paquete, a veces querrás especificar un rango de versiones que deseas permitir. ¿Cómo sabes qué rango elegir? Debes ser compatible con versiones futuras, por lo que idealmente el rango abarca versiones futuras que aún no se han lanzado. Pero, ¿cómo sabes que tu paquete va a funcionar con alguna versión nueva que ni siquiera existe todavía?

Para resolver eso, deben ponerse de acuerdo sobre qué significa un número de versión. Imagina que los desarrolladores de un paquete del que dependes dicen: "Si realizamos algún cambio incompatible con versiones anteriores, prometemos incrementar el número de versión mayor." Si confías en ellos, y sabes que tu paquete funciona con la versión 2.3.5 del suyo, puedes confiar en que funcionará hasta la 3.0.0. Puedes establecer tu rango como:

yaml
dependencies:
  collection: ^2.3.5

Para que esto funcione, entonces, necesitamos definir ese conjunto de promesas. Afortunadamente, otras personas inteligentes han hecho el trabajo de descifrar todo esto y lo llamaron versionamiento semántico.

Eso describe el formato de un número de versión y las diferencias de comportamiento exactas de la API cuando realizas un incremento hacia un número de versión posterior. Pub requiere que las versiones tengan ese formato, y para llevarse bien con la comunidad de pub, tu paquete debe seguir la semántica que este especifica. Debes asumir que los paquetes de los que dependes también la siguen (¡y si descubres que no es así, házselo saber a sus autores!).

Aunque el versionamiento semántico no promete ninguna compatibilidad entre versiones anteriores a la 1.0.0, la convención de la comunidad de Dart es tratar esas versiones semánticamente también. La interpretación de cada número simplemente se desplaza un espacio hacia abajo: pasar de 0.1.2 a 0.2.0 indica un cambio disruptivo, pasar a 0.1.3 indica una nueva característica, y pasar a 0.1.2+1 indica un cambio que no afecta a la API pública. Para simplificar, evita usar + después de que la versión alcance la 1.0.0.

Ahora tenemos casi todas las piezas que necesitamos para tratar con el versionamiento y la evolución de las API. Veamos cómo funcionan en conjunto y qué hace pub.

Resolución de restricciones

#

Cuando defines tu paquete, enumeras sus dependencias inmediatas (immediate dependencies)Dependencia inmediataUna dependencia que un paquete de Dart utiliza directamente. Obtén más información. Estos son los paquetes que utiliza tu paquete. Para cada uno de estos paquetes, especificas el rango de versiones que tu paquete permite. Cada uno de esos paquetes dependientes puede tener a su vez sus propias dependencias. Estas se denominan dependencias transitivas (transitive dependencies)Dependencia transitivaUna dependencia que un paquete utiliza indirectamente porque una de sus dependencias la requiere. Obtén más información. Pub recorre estas y construye el gráfico de dependencias completo para tu app.

Para cada paquete en el gráfico, pub examina todo lo que depende de él. Reúne todas sus restricciones de versión e intenta resolverlas simultáneamente. Básicamente, interseca sus rangos. Luego, pub examina las versiones reales que se han lanzado para ese paquete y selecciona la más reciente que cumpla con todas esas restricciones.

Por ejemplo, supongamos que nuestro gráfico de dependencias contiene collection y tres paquetes dependen de él. Sus restricciones de versión son:

>=1.7.0
^1.4.0
<1.9.0

Los desarrolladores de collection han lanzado estas versiones del mismo:

1.7.0
1.7.1
1.8.0
1.8.1
1.8.2
1.9.0

El número de versión más alto que encaja en todos esos rangos es 1.8.2, por lo que pub elige ese. Eso significa que tu app y cada paquete que utiliza tu app usarán collection 1.8.2.

Contexto de restricciones

#

El hecho de que la selección de la versión de un paquete tenga en cuenta cada paquete que depende de él tiene una consecuencia importante: la versión específica que se seleccionará para un paquete es una propiedad global de la app que utiliza ese paquete.

El siguiente ejemplo muestra lo que esto significa. Supongamos que tenemos dos apps. Aquí están sus pubspecs:

yaml
name: my_app
dependencies:
  widgets:
yaml
name: other_app
dependencies:
  widgets:
  collection: '<1.5.0'

Ambas dependen de widgets, cuyo pubspec es:

yaml
name: widgets
dependencies:
  collection: '>=1.0.0 <2.0.0'

El paquete other_app depende directamente de collection en sí. La parte interesante es que resulta que tiene una restricción de versión diferente sobre él que la que tiene widgets.

Esto significa que no puedes simplemente mirar el paquete widgets de forma aislada para averiguar qué versión de collection usará. Depende del contexto. En my_app, widgets usará collection 1.9.9. Pero en other_app, widgets tendrá que cargar con collection 1.4.9 debido a la otra restricción que other_app impone sobre él.

Es por esto que cada app obtiene su propio archivo package_config.json: La versión concreta seleccionada para cada paquete depende del gráfico de dependencias completo de la app que lo contiene.

Resolución de restricciones para dependencias exportadas

#

Los autores de paquetes deben definir las restricciones del paquete con cuidado. Considera el siguiente escenario:

gráfico de dependencias

El paquete bookshelf depende de widgets. El paquete widgets, actualmente en la versión 1.2.0, exporta collection a través de export 'package:collection/collection.dart', y se encuentra en la versión 2.4.0. Los archivos pubspec son los siguientes:

yaml
name: bookshelf
dependencies:
  widgets: ^1.2.0
yaml
name: widgets
dependencies:
  collection: ^2.4.0

El paquete collection se actualiza luego a la versión 2.5.0. La versión 2.5.0 de collection incluye un nuevo método llamado sortBackwards(). bookshelf podría llamar a sortBackwards(), porque es parte de la API expuesta por widgets, a pesar de que bookshelf tiene únicamente una dependencia transitiva de collection.

Debido a que widgets tiene una API que no está reflejada en su número de versión, la app que utiliza el paquete bookshelf y llama a sortBackwards() podría fallar (crash).

Exportar una API hace que esa API sea tratada como si estuviera definida en el paquete mismo, pero no puede incrementar el número de versión cuando la API agrega características. Esto significa que bookshelf no tiene forma de declarar que necesita una versión de widgets que admita sortBackwards().

Por esta razón, cuando se trata de paquetes exportados, se recomienda que el autor del paquete mantenga un límite más estricto en los límites superior e inferior de una dependencia. En este caso, el rango para el paquete widgets debería estrecharse:

yaml
name: bookshelf
dependencies:
  widgets: '>=1.2.0 <1.3.0'
yaml
name: widgets
dependencies:
  collection: '>=2.4.0 <2.5.0'

Esto se traduce en un límite inferior de 1.2.0 para widgets y 2.4.0 para collection. Cuando alguien lanza la versión 2.5.0 de collection, pub actualiza widgets a la 1.3.0 y también actualiza las restricciones correspondientes.

El uso de esta convención garantiza que los usuarios tengan la versión correcta de ambos paquetes, incluso si uno de ellos no es una dependencia directa.

Archivos de bloqueo (Lockfiles)

#

Entonces, una vez que pub ha resuelto las restricciones de versión de tu app, ¿qué sigue? El resultado final es una lista completa de cada paquete del que depende tu app, ya sea directa o indirectamente, y la mejor versión de ese paquete que funcionará con las restricciones de tu app.

Para cada paquete, pub toma esa información, calcula un hash de contenidoHash de contenido de PubHashes SHA256 mantenidos por pub.dev para validar la integridad del paquete. Obtén más información a partir de ella, y escribe ambos en un archivo de bloqueo (lockfile)LockfileUn archivo llamado pubspec.lock que especifica las versiones de cada dependencia. Obtén más información en el directorio de tu app llamado pubspec.lock. Cuando pub compila el archivo .dart_tool/package_config.json para tu app, utiliza el archivo de bloqueo para saber a qué versiones de cada paquete hacer referencia (y si tienes curiosidad por ver qué versiones seleccionó, puedes leer el archivo de bloqueo para averiguarlo).

Lo siguiente importante que hace pub es que deja de tocar el archivo de bloqueo. Una vez que tienes un archivo de bloqueo para tu app, pub no lo tocará hasta que se lo indiques. Esto es importante. Significa que no empezarás a usar de forma espontánea nuevas versiones de paquetes aleatorios en tu app sin pretenderlo. Una vez que tu app está bloqueada, permanece bloqueada hasta que le indiques manualmente que actualice el archivo de bloqueo.

Si tu paquete es para una app, ¡registras tu archivo de bloqueo en tu sistema de control de código fuente! De esa manera, todos en tu equipo usarán exactamente las mismas versiones de cada dependencia cuando compilen tu app. También usarás esto cuando despliegues tu app, para asegurarte de que tus servidores de producción utilicen exactamente los mismos paquetes con los que estás desarrollando.

Cuando las cosas salen mal

#

Por supuesto, todo esto presupone que tu gráfico de dependencias es perfecto e impecable. Incluso con rangos de versiones, la resolución de restricciones de pub y el versionamiento semántico, nunca puedes estar completamente a salvo de los peligros de la versionitis.

Podrías encontrarte con uno de los siguientes problemas:

Puedes tener restricciones disjuntas

#

Supongamos que tu app utiliza widgets y templates y ambos usan collection. Pero widgets solicita una versión entre la 1.0.0 y la 2.0.0, y templates quiere algo entre la 3.0.0 y la 4.0.0. Esos rangos ni siquiera se superponen. No hay ninguna versión posible que funcione.

Puedes tener rangos que no contengan una versión lanzada

#

Supongamos que después de juntar todas las restricciones en una dependencia compartida, tienes un rango estrecho de >=1.2.4 <1.2.6. No es un rango vacío. Si existiera una versión 1.2.4 de la dependencia, estarías a salvo. Pero tal vez nunca lanzaron esa versión. En su lugar, pasaron directamente de la 1.2.3 a la 1.3.0. Tienes un rango con nada dentro de él.

Puedes tener un gráfico inestable

#

Esta es, por mucho, la parte más desafiante del proceso de resolución de versiones de pub. El proceso se describió como construir el gráfico de dependencias y luego resolver todas las restricciones y elegir las versiones. Pero en realidad no funciona de esa manera. ¿Cómo podrías construir el gráfico de dependencias completo antes de haber elegido alguna versión? Los propios pubspecs son específicos de cada versión. Diferentes versiones del mismo paquete pueden tener diferentes conjuntos de dependencias.

A medida que seleccionas las versiones de los paquetes, estos van cambiando la forma de el gráfico de dependencias mismo. Conforme el gráfico cambia, eso podría cambiar las restricciones, lo que puede causar que selecciones diferentes versiones, y luego vuelvas a dar vueltas en círculos.

A veces este proceso nunca se establece en una solución estable. Mira hacia el abismo:

yaml
name: my_app
version: 0.0.0
dependencies:
  yin: '>=1.0.0'
yaml
name: yin
version: 1.0.0
dependencies:
yaml
name: yin
version: 2.0.0
dependencies:
  yang: '1.0.0'
yaml
name: yang
version: 1.0.0
dependencies:
  yin: '1.0.0'

En todos estos casos, no existe un conjunto de versiones concretas que funcione para tu app, y cuando esto sucede, pub reporta un error y te dice lo que está pasando. Definitivamente no te dejará en algún estado extraño en el que pienses que las cosas pueden funcionar, pero no sea así.

Resumen

#

En resumen:

  • Aunque la reutilización de código tiene ventajas, los paquetes requieren la capacidad de evolucionar de manera independiente.
  • El versionamiento hace posible esa independencia. Depender de versiones concretas individuales carece de flexibilidad. Sumado a las dependencias compartidas, conduce al bloqueo de versión.
  • Para hacer frente al bloqueo de versión, tu paquete debería depender de un rango de versiones. Pub luego recorre tu gráfico de dependencias y elige las mejores versiones para ti. Si no puede elegir una versión adecuada, pub te avisa.
  • Una vez que tu app tiene un conjunto sólido de versiones para sus dependencias, ese conjunto se fija en un archivo de bloqueo (lockfile). Eso garantiza que cada máquina que ejecute tu app utilice las mismas versiones de todas sus dependencias.

Para obtener más información sobre el algoritmo de resolución de versiones de pub, consulta el artículo sobre PubGrub en Medium.