¿Cómo funciona NPM y el modelo de dependencias?

En estos momentos, NPM es el gestor de paquetes por excelencia en el mundo front end. Si, seguramente hay alternativas y si eres un desarrollador que utiliza Bower quizás te lleves un poco las manos a la cabeza, pero actualmente incluso Bower se está dejando de lado en favor de NPM.


Lo que más llama la atención es la manera en la que NPM funciona y su forma de entender la gestión de dependencias. Por desgracia - al menos en mi experiencia - no es algo que se entienda fácilmente ni a la primera, por lo que podríamos considerar este post como un intento de aclarar exáctamente que es, cómo funciona y como te afecta a ti como desarrollador o como usuario final.

NPM - Conceptos básicos

A alto nivel, NPM no es muy diferente de otros gestores de paquetes de otros lenguajes de programación. Los paquetes dependen de otros paquetes, y estos expresan estas dependencias con rangos de versiones. NPM utiliza el esquema semver (o Semantic Versioning) para expresar esos rangos. Lo importante a destacar aquí es que estos paquetes pueden depender de un rango de versiones de dependencias, en lugar de una versión específica de una dependencia o paquete en si.


Esto es bastante importante para cualquier ecosistema, ya que bloquear una libreria a un rango específico de dependencias puede causar problemas relativamente importantes. No obstante, en NPM esto es un poco menos de problema si lo comparamos a otros sistemas de paquetes como composer en PHP.


En definitiva, normalmente es bastante seguro para el creador de una librería el limitar las dependencias de una versión específica  sin afectar al comportamiento del resto de librerías o aplicaciones conectadas.  La parte así un poco compleja es la de determinar cuando hacer esto es una práctica segura y cuando no, y aquí es donde noto (y la mayoría de desarrolladores con las que colaboro está de acuerdo)  que la mayor parte de la gente no acaba de pillarlo (y yo al principio me incluyo).


Duplicación de dependencias en el arbol de dependencias

Si no lo habías descubierto ya, al contrario que otros gestores de paquetes, NPM instala un arbol de dependencias. Esto quiere decir, que cada paquete instalado obtiene su propio set de dependencias, en lugar de forzar a todos los paquetes a compartir el mismo set canónico de dependencias. Obviamente, prácticamente cualquier gestor de paquetes en existencia tiene que modelar un arbol de dependencias en algún punto de su desarrollo para su correcto funcionamiento, ya que es así como los programadores expresamos las dependencias entre diferentes paquetes.


Por ejemplo, consideramos dos paquetes, foo y bar.  Cada uno de ellos tiene su propio set de dependencias, que se puede expresar como un arbol:


foo

-- hola ^0.1.2

-- mundo ^1.0.7


bar

-- hola ^0.2.2

-- adios ^ 3.5.1


Imagina que una aplicación que depende tanto de foo como de bar. Claramente, las dependencias de mundo y adios carecen de ningún tipo de relación, por lo que la manera ne la que NPM las almacena y organiza no es algo muy interesante para lo que queremos tratar en este artículo. No obstante, si consideramos el caso de hola, tanto foo como bar requieren versiones que entran en conflicto.


La mayoría de paquetes ( incluyendo RubyGems, pip, Cabal...) sencillamente se detendrían aquí, dando algún tipo de error de versión de conflictos y ya te apañarás que seguro que te divierte arreglar esto. Esto ocurre porque en la mayoría de gestores de paquetes,  tan solo puede existir una única versión de cualquier paquete. En ese sentido,  la responsabilidad de cualquier gestor de paquetes es la de averiguar que set de paquetes y que versiones de cada uno de estos paquetes pueden utilizarse para satisfacer todas las necesidades de todos los paquetes simultaneamente.


Por contra, NPM tiene un trabajo algo más sencillo. Para NPM no hay problema en instalar diferentes verisones del mismo paquete porque cada paquete tiene su propio arbol de dependencias. En el ejemplo que hemos puesto arriba, el listado de directorios de los paquetes tendría este aspecto.


node_modules/

├── foo/

│     └── node_modules/

│             ├── hola/

│             └── mundo/

└── bar/

         └── node_modules/

         ├── hola/

         └── adios/


Como podemos observar, la estructura del directorio se parece mucho al arbol de dependencias original. El diagrama que mostramos arriba no es más que una simplificación. En la práctica, esto se traduce a que cada dependencia transitiva tendrá su propio directorio node_modules dentro de si misma y as i sucesivamente.


Como podéis imaginar, la estructura del directorio se complicar a medida que las dependencias entre paquetes aumenten (Además, desde la versión 3 de npm que se realizan ciertas optimizaciones para tratar de compartir dependencias cuando se puedan, pero por el momento vamos a ignorar esto porque no es necesario saberlo ni estudiarlo para entender el modelo de NPM).


Esta forma de estructurar las dependencias la verdad que es bastante simple ¿no os parece?   El efecto inmediato que podemos ver es que cada paquete tiene su propio "sandbox" , que funciona super bien para librerias de utilidad como por ejemplo ramda, lodash, underscore...  y otras librerías de ese estilo (sobretodo funcionales).


Si foo depende de ramda@^0.19.0 pero bar depende de ramda@^0.22.0 , ambas versiones de cada una de estas librerias puede coexistir perfectamente  sin ningún problema.


A simple vista, podríamos decir que este sistema obviamente es mejor que cualquier alternativa de gestión de paquetes plana, siempre y cuando la ejecución de dichos programas soporte la carga de los módulos necesarios en este esquema (por ejemplo, que dos modulos diferentes con una misma dependencia pero en versiones diferentes no tenga que utilizar una variable global.... ahora hablaremos de eso no te preocupes, que esto tiene algo de tela).


El mayor inconveniente es el aumento muy significativo del tamaño del código, ya que replicamos una y otra vez diferentes versiones de diferentes paquetes  dentro de nuestro arbol de dependencias una y otra y otra vez. Un código que ocupa mas y tiene más tamaño a menudo lleva aparejado una pérdida en el rendimiento de tu aplicación. Los programas más largos sencillamente no caben en la caché de una CPU tan fácilmente , y solamente por tener que paginar en cache un programa puede hacer que las cosas vayan bastante más lentas.  


Hay que entender esto como una solución de compromiso.  Estás sacrificando algo de rendimiento, no mantenimiento de código ni el hecho de que tu código sea mejor o peor.


El problema así más insidioso y que afecta más (y que abunda mucho en el ecosistema de NPM sin que de mucho que hablar) es como este aislamiento de dependencias puede afectar de un modo u otro a la comunicación entre diferentes paquetes.

tree

Toma, un árbol

Para relajar la vista mientras hablamos de estas dependencias.

Aislamiento de dependencias y valores que traspasan las fronteras de cada paquete

En el ejemplo anterior en el que hablabamos del paquete ramda, la forma en la que NPM maneja los paquetes realmente brilla, ya que Ramda provee un montón de funciones al mas puro estilo funcional. Pasar estas funciones de un paquete a otro es completamente seguro. Incluso mezclar diferentes funciones de diferentes versiones de Ramda estaría bien. Por desgracia, no todos los casos son exactamente iguales y tan sencillos de explicar.


Por ejemplo, vamos a pensar en react.  Los componentes de react no son sencillamente conjuntos de funciones ni datos. Son valores complejos que pueden ser extendidos, instanciados y renderizados en pantalla de mil maneras. React representa una estructura de componentes y estados que utiliza una forma interna y privada de representarse y que utiliza una forma muy específica de claves y valores para guardar sus datos, así como algunas de las funcionalidades más avanzadas del sistema de objetos de Javascript. Esta estructura interna puede cambiar entre versiones de React, por lo que un componente definido con la dependencia de react@0.3.0 probablemente no funcionará bien con react@15.3.1


 Ten esto en cuenta y considera dos paquetes  que definen sus propios componentes de react y los exportan para que los consumidores (o usuarios u otros programadores) los puedan utilizar. Imaginemos que el arbol de dependencias es el siguiente:


un-boton-bonito 

└── react ^0.3.0

un-modal-precioso 

└── react ^15.3.1


Dado que estos dos paquetes utilizan versiones completamente diferentes de react, NPM les daría a cada uno de ellos su propia copia de React, tal y como se pide, y cada uno de estos paquetes instalaría sin ningún problema dichas dependencias. No obstante, si intentases utilizar estos dos componentes a la vez, no funcionarían para nada. Una versión más nueva de React sencillamente no puede entender ni funcionar con una versión más antigua del mismo componente. Esperaría de el un comportamiento distinto del que react utiliza y al final tendrías algún tipo de error de ejecución muy confuso al que te costaría tiempo llegar y descifrar.


¿Que pasó? ¿Por qué no funcionó?  Bueno, el aislamiento de paquetes funciona genial cuando sus dependencias solamente se basan en detalles de implementación y que nunca son observables desde fuera del mismo paquete. No obstante, en el momento en el que la dependencia de un paquete se expone como parte de la interfaz, el aislamiento de dependencias no solamente es erroneo, sino que puede causar que el programa deje de funcionar en ejecución. Este es uno de los casos ejemplo en el que el modelo de gestión de dependencias tradicional funciona mucho mejor. En este caso, un sistema tradicional directamente te lanzaría algún tipo de WARNING o ERROR  alertándote de que estos dos paquetes sencillamente no funcionan entre si, en vez de esperar a que tu te comas el marrón  de tener que descubrirlo por tu cuenta en ejecución.


Esto puede no parecer que sea para tanto, a fin de cuentas, JavaScript es un lenguaje muy dinámico, por lo que las garantías estáticas son pocas y muy diferentes entre si, y si haces testing bien hecho deberías de capturar y ver estos problemas si ocurren.


Pero... pero... (y no me mires así)  también puede causar todo tipo de problemas innecesarios cuando dos paquetes en teoría si pueden trabajar juntos, pero NPM ha asignado  a cada uno su propia copia particular de un paquete en cuestión (sin darse cuenta de que podía darles a ambas la misma copia de un paquete dependiente en cuestión). En este caso, las cosas empiezan a romperse y a no funcionar y tu te tirarás horas delante del PC intentando averiguar que narices está pasando.


Si nos paramos a pensarlo y miramos fuera de lo que realmente hace NPM, y consideramos este modelo cuando se aplica a otro tipo de lenguaje, cada vez queda mas claro que este modelo sencillamente no funcionaría en muchos otros lenguajes de programación o sitios.  En lenguajes más estáticos como por ejemplo Haskell, este tipo de modelo de gestión de paquetes sencillamente no funcionará.


Si tenemos en cuenta como ha evolucionado el ecosistema de javascript, es cierto que la mayor parte de la gente puede prácticamente ignorar este sutil potencial que tiene Javascript para comportamientos inesperados sin ningún tipo de problema. Siendo más específicos,  JavaScript tiende a depender un poco más de lo que se conoce como "Duck typing" que otros tipos de comprobaciones más restrictivas como instanceof (Si se mueve como un pato, suena como un pato, huele como un pato, nada como un pato y parece un pato es que es un pato... duck typing!!) .  Mientras los objetos satisfazcan el mismo protocolo, serán compatibles aunque sus implementaciones no sean exáctamente las mismas. 


No obstante, NPM provee una solución robusta a este problema que permite a los creadores de paquetes expresar estas interdependencias de forma más sencilla.

PEER DEPENDENCIES  - Dependencias entre pares

Normalmente, las dependencias de un paquete de NPM se listan bajo el apartado "dependencies" del package.json .  No obstante, hay otro apartado no tan usado llamado "peerDependencies", que tiene el mismo formato que la lista de dependencias normal. La diferencia se muestra en como se comporta NPMP a la hora de resolver estas dependencias.  En vez de obtener su propia copia de una peer dependency, el paquete espera que esa dependencia la provea el paquete del que depende.

Esto quiere decir que las "peer dependencies" (dependencias entre pares)  se resuelven utilizando el método de dependencias tradicional. Tiene que haber una versión canónica que satisfazca todas las limitaciones del resto de paquetes.  Desde NPM3 , la forma en la que se resuelven este tipo de dependencias se ha complicado un poquitín (especificamente, las dependencias de pares no son instaladas automáticamente a menos que un paquete dependiente dependa explicitamente en el paquete en si). No obstante, la idea principal es la misma. 


¿Cómo afecta esto? Básicamente  los autoes de los paquetes deben de elegir para cada dependencia que instalan, si debería de ser una dependencia normal o una dependencia entre pares. (o peer dependency).


Aquí es donde la gente tiene tendencia a perderse un poco, incluso las personas que si que están familiarizadas con el mecanismo de dependencias entre pares. Afortunadamente, la solución es relativamente sencilla.... ¿es la dependencia en cuestión visible en cualquier lugar de la interfaz de dicho paquete?


Esto es a veces dificil de ver en Javascript, porque los "tipos" son invisibles: Esto quiere decir, que son dinámicos y rara vez se escriben de forma explícita (recordad que javascript no es un lenguaje tipificado). Solo porque los tipos sean dinámicos no quiere decir que no están ahí en tiempo de ejecución. Los tipos siguen estando ... si el tipo de una función en la interfaz pública de un paquete depende de alguna manera de una dependencia, debería de ser una dependencia de pares y no una dependencia en si.


Vamos a poner un ejemplo para ser algo más concretos.


import { merge, add } from 'ramda'

export const withDefaultConfig = (config) => merge({ path: '.' }, config)

export const add5 = add(5)


En este primer ejemplo podemos verlo de una manera más o menos obvia: en withDefaultConfig, se utiliza el módulo "merge" puramente como un detalle de implementación. Esto quiere decir que es seguro y no es parte de la interfaz del módulo. en add5, el resultado de add(5) es una función aplicada creada por el módulo ramda, así que técnicamente, un valor creado por un módulo ramda es parte de esta interfaz del módulo en si.


No obstante, la funcionalidad que tiene la constante add5 con respecto al resto de módulos externos es una función JavaScript que sencillamente añade 5 a su argumento, y no depende de ninguna funcionalidad específica de Ramda, por lo que ramda puede ser de forma segura una dependencia y no una dependencia entre pares.


Vamos a echarle un vistazo a otro ejemplo utilizando la librería de imágenes jpeg.


import { Jpeg } from 'jpeg'
export const createSquareBuffer = (size, cb) =>  createSquareJpeg(size).encode(cb)
export const createSquareJpeg = (size) => new Jpeg(Buffer.alloc(size * size, 0), size, size)


En este caso, la función createSquareBuffer invoca a un callback con un objeto de tipo Buffer de Node.js, por lo que la librería JPEG es un detalle de implementación. Si esta fuese la única  función expuesta por este módulo, entonces jpeg podría ser de forma segura una dependencia normal, No obstante, la función createSquareJpeg viola esta regla de la que estamos hablando, ya que devuelve un objeto Jpeg, que es un valor opaco con una estructura definida por la libreria jpeg.


En este caso,  el paquete debería de listar jpeg como una dependencia entre pares porque la forma en la que guarda jpeg y utiliza jpeg depende enteramente del módulo Jpeg. ¿Y si dos versiones de Jpeg tienen diferentes formas de crear un objeto Jpeg?  A lo mejor el objeto Jpeg es creado por un módulo de una manera, y es pasado a otro módulo que espera otra versión de Jpeg más moderna y quiere acceder a un método o un valor dentro del objeto Jpeg que existe en su versión de Jpeg pero no en la versión que utiliza el módulo original, y cuando intentamos acceder a dicho valor, o instanciarlo en una nueva variable, nos peta en ejecución porque es null o undefined. Ese paquete en cuestión debe de listar Jpeg como una dependencia entre pares, y asegurarse de que otros módulos que utilicen Jpeg tengan una versión de Jpeg que sea compatible (o esté en un rango aceptable de versiones entre ella).


Al revés también pasa lo mismo, por ejemplo:


import { writeFile } from 'fs'
export const writeJpeg = (filename, jpeg, cb) =>  jpeg.encode((image) => fs.writeFile(filename, image, cb))


El módulo de arriba no importa el paquete Jpeg, pero si depende del método "encode" de la interfaz Jpeg. Por ello, aunque no esté siendo utilizada explícitamente en ninguna parte del código, un paquete que contenga este módulo anteriormente mencionado debería de incluír Jpeg como una dependencia entre pares (porque puede haber alguna versión del módulo Jpeg que no tenga el método encode definido, porque en su momento no existió, o pasó a ser DEPRECATED y se borró).

El modelo NPM en otros lenguajes de programación

El modelo de gestión de paquetes de NPM es algo más complicado que el que utilizan otros lenguajes de programación, pero nos provee a los desarrolladores con una ventaja realmente importante:  Los detalles de implementación se mantienene como detalles de implementación. En otros sistemas - como me ha pasado alguna vez en composer - es posible que encuentres un infierno de dependencias cuando sabes que la versión en conflicto en realidad no crea problemas, pero el gestor de paquetes sencillamente te dice que no hay manera  porque el sistema de paquetes debe de escoger exactamente unversión canónica . No hay forma de hacer más progreso en el desarrollo de tu app sin ajustar el código en tus dependencias, y esto es muy muy frustrante.


Desde luego, el aislamiento de dependencias que presenta NPM no es el más avanzado que existe en el mundo de la gestión de paquetes - pero definitivamente es más poderoso y versatil que otros sistemas muy mainstream.  Por supuesto que otros idiomas de programación no podrían adoptar el modelo npm . Tener por ejemplo un namespace global podría prevenir múltiples versiones de un mismo paquete siendo instalados en tiempo de ejecución. La razón por la que NPM puede hacer lo que hace es en definitiva porque Node en si lo soporta ( y Javascript también).


Desde una perspectiva de Javascript, NPMP ha demostrado de que puede ser un buen gestor de dependencias. Si, no es perfecto pero es lo que hay y a menudo nos solventa muchos problemas con un compromiso más bien pequeño. ¿Y tu que opinas?

Lo más popular entre los lectores

Desarrollando una I.A que reconoce caras en Javascript

Desarrollando una I.A que reconoce caras en Javascript

Leave review
La tecnología está llena de cosas increíbles que nos sorprenden a cada paso...
Leer más
Producto Mínimo Viable de una app, by Hamro Dev

¿Qué es un MVP y por qué es tan importante?

Leave review
¿Que es un MVP? Si todavía no lo sabes, entra aqui y averigua por qué es ta...
Leer más
¿Cómo montar una empresa sin pagar autónomos?

¿Cómo montar una empresa sin pagar autónomos?

Leave review
Leer más
¿Cómo abrir una agencia de viajes?

¿Cómo abrir una agencia de viajes?

Leave review
¿Tienes pensado comenzar una agencia de viajes? Si la respuesta es sí , deb...
Leer más
¿Cómo abrir una página web en China?

¿Cómo abrir una página web en China?

Leave review
Una cosa es ofrecer el idioma chino a tus clientes y otra bien distinta es ...
Leer más
¿Cómo averiguar tu número de UDID en iPhone?

¿Cómo averiguar tu número de UDID en iPhone?

Leave review
El Unique Device Identifier (UDID) es un código de 40 dígitos hexadecimales...
Leer más
6 barreras de entrada que frenan el emprendimiento

6 barreras de entrada que frenan el emprendimiento

Leave review
"Barreras de entrada" es un término frecuente en los negocios que sirve par...
Leer más
Las barreras de entrada en el mundo de las apps

¿Qué es una barrera de entrada en el mundo de las apps?

Leave review
Leer más
Email *
Suscribirme

¿De qué va esto?

Lo más reciente

El análisis de redes sociales de Donald Trump que revela que duerme menos y está más enfadado.

El análisis de redes sociales de Donald Trump que revela que duerme menos y está más enfadado.

Leave review
Sin lugar a dudas, este análisis del uso de redes sociales lanza resultados...
Leer más
¿Cómo funciona NPM y el modelo de dependencias?

¿Cómo funciona NPM y el modelo de dependencias?

Leave review
En estos momentos, NPM es el gestor de paquetes por excelencia en el mundo ...
Leer más
Esta IA te puede eliminar de un video

Esta IA te puede eliminar de un video

Leave review
¿Te imaginas un programa con el que puedas borrar automáticamente a cualqui...
Leer más
Progressive Web Apps

Progressive Web Apps

Leave review
Leer más
Negocios online que funcionan

Negocios online que funcionan

Leave review
No te pierdas este listado de negocios online que ahora mismo están funcion...
Leer más
Los 5 pasos fundamentales para hacer una app

Los 5 pasos fundamentales para hacer una app

Leave review
Leer más
Las 10 Claves para hacer tu propio marketplace

Las 10 Claves para hacer tu propio marketplace

Leave review
Es innegable , desde que entraron en escena en las apps , los marketplaces ...
Leer más

Las categorías

Te puede interesar...