Administración de dependencias

Informar un problema Ver fuente Nightly · 8.3 · 8.2 · 8.1 · 8.0 · 7.6

Al revisar las páginas anteriores, un tema se repite una y otra vez: administrar tu propio código es bastante sencillo, pero administrar sus dependencias es mucho más difícil. Existen todo tipo de dependencias: a veces, hay una dependencia de una tarea (como "envía la documentación antes de marcar una versión como completa"), y, a veces, hay una dependencia de un artefacto (como "necesito tener la versión más reciente de la biblioteca de visión por computadora para compilar mi código"). A veces, tienes dependencias internas de otra parte de tu base de código y, a veces, tienes dependencias externas de código o datos que pertenecen a otro equipo (ya sea en tu organización o un tercero). Pero, en cualquier caso, la idea de "necesito esto antes de poder tener aquello" es algo que se repite constantemente en el diseño de los sistemas de compilación, y la administración de dependencias es quizás el trabajo más fundamental de un sistema de compilación.

Cómo trabajar con módulos y dependencias

Los proyectos que usan sistemas de compilación basados en artefactos, como Bazel, se dividen en un conjunto de módulos, y estos expresan dependencias entre sí a través de archivos BUILD. La organización adecuada de estos módulos y dependencias puede tener un gran efecto tanto en el rendimiento del sistema de compilación como en el trabajo que requiere su mantenimiento.

Cómo usar módulos detallados y la regla 1:1:1

La primera pregunta que surge cuando se estructura una compilación basada en artefactos es decidir cuánta funcionalidad debe abarcar un módulo individual. En Bazel, un módulo se representa con un destino que especifica una unidad compilable, como un java_library o un go_binary. En un extremo, todo el proyecto podría estar contenido en un solo módulo colocando un archivo BUILD en la raíz y agrupando de forma recursiva todos los archivos fuente de ese proyecto. En el otro extremo, casi todos los archivos fuente podrían convertirse en su propio módulo, lo que requeriría que cada archivo se incluyera en un archivo BUILD todos los demás archivos de los que depende.

La mayoría de los proyectos se encuentran en algún punto intermedio entre estos extremos, y la elección implica una compensación entre el rendimiento y la capacidad de mantenimiento. Usar un solo módulo para todo el proyecto podría significar que nunca necesites tocar el archivo BUILD, excepto cuando agregues una dependencia externa, pero significa que el sistema de compilación siempre debe compilar todo el proyecto de una vez. Esto significa que no podrá paralelizar ni distribuir partes de la compilación, ni tampoco podrá almacenar en caché las partes que ya compiló. Un módulo por archivo es lo contrario: el sistema de compilación tiene la máxima flexibilidad para almacenar en caché y programar los pasos de la compilación, pero los ingenieros deben esforzarse más para mantener las listas de dependencias cada vez que cambian los archivos que hacen referencia a otros.

Si bien la granularidad exacta varía según el idioma (y, a menudo, incluso dentro del mismo idioma), Google tiende a favorecer los módulos significativamente más pequeños de lo que se podría escribir normalmente en un sistema de compilación basado en tareas. Un objeto binario de producción típico en Google suele depender de decenas de miles de destinos, y hasta un equipo de tamaño moderado puede tener varios cientos de destinos dentro de su base de código. En lenguajes como Java, que tienen una sólida noción integrada de empaquetado, cada directorio suele contener un solo paquete, destino y archivo BUILD (Pants, otro sistema de compilación basado en Bazel, denomina a esto la regla 1:1:1). Los lenguajes con convenciones de empaquetado más débiles suelen definir varios destinos por archivo BUILD.

Los beneficios de los destinos de compilación más pequeños realmente comienzan a mostrarse a gran escala porque generan compilaciones distribuidas más rápidas y una necesidad menos frecuente de recompilar destinos. Las ventajas se vuelven aún más convincentes después de que las pruebas entran en escena, ya que los destinos más detallados significan que el sistema de compilación puede ser mucho más inteligente a la hora de ejecutar solo un subconjunto limitado de pruebas que podrían verse afectadas por cualquier cambio determinado. Dado que Google cree en los beneficios sistémicos de usar objetivos más pequeños, hemos avanzado en la mitigación de las desventajas invirtiendo en herramientas para administrar automáticamente los archivos BUILD y evitar sobrecargar a los desarrolladores.

Algunas de estas herramientas, como buildifier y buildozer, están disponibles con Bazel en el directoriobuildtools.

Cómo minimizar la visibilidad del módulo

Bazel y otros sistemas de compilación permiten que cada destino especifique una visibilidad, una propiedad que determina qué otros destinos pueden depender de él. Solo se puede hacer referencia a un destino privado dentro de su propio archivo BUILD. Un destino puede otorgar una visibilidad más amplia a los destinos de una lista definida de forma explícita de archivos BUILD o, en el caso de la visibilidad pública, a todos los destinos del espacio de trabajo.

Al igual que con la mayoría de los lenguajes de programación, suele ser mejor minimizar la visibilidad lo más posible. En general, los equipos de Google solo harán públicos los destinos si estos representan bibliotecas de uso generalizado disponibles para cualquier equipo de Google. Los equipos que requieran que otros se coordinen con ellos antes de usar su código mantendrán una lista de entidades permitidas de los clientes objetivo como visibilidad de su objetivo. Los objetivos de implementación internos de cada equipo se restringirán solo a los directorios que sean propiedad del equipo, y la mayoría de los archivos BUILD tendrán solo un objetivo que no sea privado.

Administra dependencias

Los módulos deben poder hacer referencia entre sí. La desventaja de dividir una base de código en módulos detallados es que debes administrar las dependencias entre esos módulos (aunque las herramientas pueden ayudar a automatizar esto). Por lo general, la expresión de estas dependencias termina siendo la mayor parte del contenido de un archivo BUILD.

Dependencias internas

En un proyecto grande dividido en módulos detallados, es probable que la mayoría de las dependencias sean internas, es decir, en otro destino definido y compilado en el mismo repositorio de código fuente. Las dependencias internas difieren de las externas en que se compilan a partir del código fuente en lugar de descargarse como un artefacto precompilado durante la ejecución de la compilación. Esto también significa que no hay una noción de "versión" para las dependencias internas: un destino y todas sus dependencias internas siempre se compilan en la misma confirmación o revisión en el repositorio. Un problema que se debe abordar con cuidado en relación con las dependencias internas es cómo tratar las dependencias transitivas (figura 1). Supongamos que el destino A depende del destino B, que a su vez depende de un destino de biblioteca común C. ¿El destino A debería poder usar las clases definidas en el destino C?

Dependencias transitivas

Figura 1. Dependencias transitivas

En lo que respecta a las herramientas subyacentes, no hay ningún problema con esto; tanto B como C se vincularán al destino A cuando se compile, por lo que A conocerá todos los símbolos definidos en C. Bazel permitió esto durante muchos años, pero, a medida que Google creció, comenzamos a ver problemas. Supongamos que B se refactorizó de modo que ya no necesitaba depender de C. Si se quitara la dependencia de B en C, se interrumpirían A y cualquier otro destino que usara C a través de una dependencia en B. En efecto, las dependencias de un destino pasaron a formar parte de su contrato público y nunca se pudieron cambiar de forma segura. Esto significaba que las dependencias se acumulaban con el tiempo y las compilaciones en Google comenzaban a ralentizarse.

Finalmente, Google resolvió este problema con la introducción de un "modo de dependencia transitiva estricta" en Bazel. En este modo, Bazel detecta si un destino intenta hacer referencia a un símbolo sin depender de él directamente y, si es así, falla con un error y un comando de shell que se puede usar para insertar automáticamente la dependencia. Implementar este cambio en toda la base de código de Google y refactorizar cada uno de nuestros millones de objetivos de compilación para que enumeren explícitamente sus dependencias fue un esfuerzo de varios años, pero valió la pena. Nuestras compilaciones ahora son mucho más rápidas, ya que los destinos tienen menos dependencias innecesarias, y los ingenieros pueden quitar las dependencias que no necesitan sin preocuparse por dañar los destinos que dependen de ellas.

Como de costumbre, aplicar dependencias transitivas estrictas implicó una compensación. Hizo que los archivos de compilación fueran más detallados, ya que las bibliotecas que se usan con frecuencia ahora deben enumerarse de forma explícita en muchos lugares en lugar de incluirse de forma incidental, y los ingenieros debieron dedicar más esfuerzo a agregar dependencias a los archivos BUILD. Desde entonces, desarrollamos herramientas que reducen este trabajo detectando automáticamente muchas dependencias faltantes y agregándolas a los archivos BUILD sin intervención del desarrollador. Sin embargo, incluso sin esas herramientas, descubrimos que la compensación vale la pena a medida que se escala la base de código: agregar explícitamente una dependencia al archivo BUILD es un costo único, pero lidiar con dependencias transitivas implícitas puede causar problemas continuos mientras exista el destino de compilación. De forma predeterminada, Bazel aplica dependencias transitivas estrictas en el código Java.

Dependencias externas

Si una dependencia no es interna, debe ser externa. Las dependencias externas son aquellas que se basan en artefactos que se compilan y almacenan fuera del sistema de compilación. La dependencia se importa directamente desde un repositorio de artefactos (al que se suele acceder a través de Internet) y se usa tal cual, en lugar de compilarse desde el código fuente. Una de las mayores diferencias entre las dependencias externas e internas es que las dependencias externas tienen versiones, y esas versiones existen independientemente del código fuente del proyecto.

Comparación entre la administración de dependencias automática y manual

Los sistemas de compilación pueden permitir que las versiones de las dependencias externas se administren de forma manual o automática. Cuando se administra de forma manual, el archivo de compilación enumera de forma explícita la versión que desea descargar del repositorio de artefactos, a menudo con una cadena de versión semántica, como 1.1.4. Cuando se administra automáticamente, el archivo fuente especifica un rango de versiones aceptables, y el sistema de compilación siempre descarga la más reciente. Por ejemplo, Gradle permite que se declare una versión de dependencia como “1.+” para especificar que cualquier versión secundaria o de parche de una dependencia es aceptable, siempre y cuando la versión principal sea 1.

Las dependencias administradas automáticamente pueden ser convenientes para proyectos pequeños, pero suelen ser una receta para el desastre en proyectos de tamaño no trivial o en los que trabaja más de un ingeniero. El problema con las dependencias administradas automáticamente es que no tienes control sobre cuándo se actualiza la versión. No hay forma de garantizar que las partes externas no realicen actualizaciones que interrumpan el funcionamiento (incluso cuando afirman usar el versionado semántico), por lo que una compilación que funcionó un día podría dejar de funcionar al día siguiente sin una forma sencilla de detectar qué cambió o de revertirla a un estado de funcionamiento. Incluso si la compilación no se interrumpe, puede haber cambios sutiles en el comportamiento o el rendimiento que son imposibles de rastrear.

En cambio, como las dependencias administradas de forma manual requieren un cambio en el control de código fuente, se pueden descubrir y revertir fácilmente, y es posible extraer una versión anterior del repositorio para compilar con dependencias anteriores. Bazel requiere que las versiones de todas las dependencias se especifiquen de forma manual. Incluso en escalas moderadas, la sobrecarga de la administración manual de versiones vale la pena por la estabilidad que proporciona.

La regla de una versión

Por lo general, las diferentes versiones de una biblioteca se representan con diferentes artefactos, por lo que, en teoría, no hay motivos para que las diferentes versiones de la misma dependencia externa no se puedan declarar en el sistema de compilación con nombres diferentes. De esa manera, cada destino podía elegir qué versión de la dependencia quería usar. Esto causa muchos problemas en la práctica, por lo que Google aplica una estricta regla de una versión para todas las dependencias de terceros en nuestra base de código.

El mayor problema de permitir varias versiones es el problema de dependencia de diamante. Supongamos que el destino A depende del destino B y de la versión 1 de una biblioteca externa. Si más adelante se refactoriza el destino B para agregar una dependencia en la versión 2 de la misma biblioteca externa, el destino A se interrumpirá porque ahora depende de forma implícita de dos versiones diferentes de la misma biblioteca. En efecto, nunca es seguro agregar una nueva dependencia de un destino a cualquier biblioteca de terceros con varias versiones, ya que cualquiera de los usuarios de ese destino podría depender de una versión diferente. Seguir la regla de una versión única hace que este conflicto sea imposible: si un destino agrega una dependencia en una biblioteca de terceros, las dependencias existentes ya estarán en esa misma versión, por lo que pueden coexistir sin problemas.

Dependencias externas transitivas

Lidiar con las dependencias transitivas de una dependencia externa puede ser particularmente difícil. Muchos repositorios de artefactos, como Maven Central, permiten que los artefactos especifiquen dependencias en versiones particulares de otros artefactos en el repositorio. Las herramientas de compilación, como Maven o Gradle, suelen descargar de forma recursiva cada dependencia transitiva de forma predeterminada, lo que significa que agregar una sola dependencia en tu proyecto podría provocar que se descarguen decenas de artefactos en total.

Esto es muy conveniente: cuando se agrega una dependencia en una biblioteca nueva, sería muy engorroso tener que rastrear cada una de las dependencias transitivas de esa biblioteca y agregarlas todas de forma manual. Sin embargo, también hay una gran desventaja: debido a que diferentes bibliotecas pueden depender de diferentes versiones de la misma biblioteca de terceros, esta estrategia necesariamente incumple la regla de una versión y genera el problema de dependencia en forma de diamante. Si tu destino depende de dos bibliotecas externas que usan diferentes versiones de la misma dependencia, no se sabe cuál obtendrás. Esto también significa que actualizar una dependencia externa podría causar fallas aparentemente no relacionadas en todo el código base si la nueva versión comienza a extraer versiones en conflicto de algunas de sus dependencias.

Bazel no solía descargar automáticamente las dependencias transitivas. Antes, se usaba un archivo WORKSPACE que requería que se enumeraran todas las dependencias transitivas, lo que generaba muchos problemas al administrar dependencias externas. Desde entonces, Bazel agregó compatibilidad para la administración automática de dependencias externas transitivas en forma del archivo MODULE.bazel. Consulta la descripción general de las dependencias externas para obtener más detalles.

Una vez más, la elección aquí es entre conveniencia y escalabilidad. Es posible que los proyectos pequeños prefieran no tener que preocuparse por administrar las dependencias transitivas por sí mismos y puedan usar dependencias transitivas automáticas. Esta estrategia se vuelve cada vez menos atractiva a medida que crecen la organización y la base de código, y los conflictos y los resultados inesperados se vuelven cada vez más frecuentes. A mayor escala, el costo de administrar las dependencias de forma manual es mucho menor que el costo de lidiar con los problemas que causa la administración automática de dependencias.

Almacenamiento en caché de los resultados de la compilación con dependencias externas

Las dependencias externas suelen ser proporcionadas por terceros que lanzan versiones estables de bibliotecas, tal vez sin proporcionar código fuente. Algunas organizaciones también pueden optar por poner a disposición parte de su propio código como artefactos, lo que permite que otros fragmentos de código dependan de ellos como dependencias externas en lugar de internas. En teoría, esto puede acelerar las compilaciones si los artefactos tardan en compilarse, pero se descargan rápidamente.

Sin embargo, esto también introduce una gran cantidad de sobrecarga y complejidad: alguien debe ser responsable de compilar cada uno de esos artefactos y subirlos al repositorio de artefactos, y los clientes deben asegurarse de mantenerse actualizados con la versión más reciente. La depuración también se vuelve mucho más difícil porque diferentes partes del sistema se habrán compilado desde diferentes puntos del repositorio, y ya no habrá una vista coherente del árbol de origen.

Una mejor manera de resolver el problema de que los artefactos tardan mucho en compilarse es usar un sistema de compilación que admita el almacenamiento en caché remoto, como se describió anteriormente. Este sistema guarda los artefactos resultantes de cada compilación en una ubicación compartida entre los ingenieros, por lo que, si un desarrollador depende de un artefacto que compiló recientemente otra persona, el sistema de compilación lo descarga automáticamente en lugar de compilarlo. Esto proporciona todos los beneficios de rendimiento de depender directamente de los artefactos y, al mismo tiempo, garantiza que las compilaciones sean tan coherentes como si siempre se compilaran desde la misma fuente. Esta es la estrategia que Google usa de forma interna, y Bazel se puede configurar para usar una caché remota.

Seguridad y confiabilidad de las dependencias externas

Depender de artefactos de fuentes externas es inherentemente riesgoso. Existe un riesgo de disponibilidad si la fuente externa (como un repositorio de artefactos) deja de funcionar, ya que es posible que toda la compilación se detenga si no puede descargar una dependencia externa. También existe un riesgo de seguridad: si un atacante vulnera el sistema de terceros, podría reemplazar el artefacto al que se hace referencia por uno de su propio diseño, lo que le permitiría insertar código arbitrario en tu compilación. Ambos problemas se pueden mitigar duplicando los artefactos de los que dependes en servidores que controlas y bloqueando el acceso de tu sistema de compilación a repositorios de artefactos de terceros, como Maven Central. La desventaja es que mantener estos espejos requiere esfuerzo y recursos, por lo que la decisión de usarlos a menudo depende de la escala del proyecto. El problema de seguridad también se puede evitar por completo con una pequeña sobrecarga si se requiere que el hash de cada artefacto de terceros se especifique en el repositorio de origen, lo que provoca que la compilación falle si se manipula el artefacto. Otra alternativa que evita por completo el problema es incluir las dependencias del proyecto en el proveedor. Cuando un proyecto incluye sus dependencias, las incorpora al control de código fuente junto con el código fuente del proyecto, ya sea como código fuente o como archivos binarios. Esto significa que todas las dependencias externas del proyecto se convierten en dependencias internas. Google usa este enfoque de forma interna y registra cada biblioteca de terceros a la que se hace referencia en todo Google en un directorio third_party en la raíz del árbol de origen de Google. Sin embargo, esto funciona en Google solo porque el sistema de control de código fuente de Google se creó de forma personalizada para controlar un monorepo extremadamente grande, por lo que la incorporación de proveedores podría no ser una opción para todas las organizaciones.