Administración de dependencias

En 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 todos los tipos de dependencias: a veces, hay una dependencia en una tarea (por ejemplo, "push la documentación antes de marcar una versión como completada") y, a veces, hay una dependencia en un artefacto (por ejemplo, "Necesito tener la versión más reciente de la biblioteca de visión artificial para compilar mi código"). A veces, tienes dependencias internas en otra parte de tu base de código y, en ocasiones, tienes una organización externa en el código o en terceros de tu propiedad o de un tercero. Pero, en cualquier caso, la idea de que "neceso eso antes de poder hacer eso" es algo que se repite con frecuencia en el diseño de sistemas de compilación, y la administración de dependencias es quizás la tarea más fundamental de un sistema de compilación.

Cómo tratar 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, con módulos que expresan dependencias entre sí a través de archivos BUILD. La organización adecuada de estos módulos y dependencias puede tener un gran impacto en el rendimiento del sistema de compilación y la cantidad de trabajo que se necesita para mantener.

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

La primera pregunta que aparece cuando se estructura una compilación basada en artefactos es decidir cuántas funcionalidades debe abarcar un módulo individual. En Bazel, un módulo se representa mediante un objetivo que especifica una unidad que se puede compilar, como java_library o 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 globalmente todos los archivos de origen de ese proyecto. En el otro extremo, casi todos los archivos de origen se podían convertir en su propio módulo, por lo que cada archivo debía incluirse en un archivo BUILD del que dependa.

La mayoría de los proyectos se encuentran entre estos extremos, y la elección implica un intercambio entre el rendimiento y el mantenimiento. Si usas un módulo único para todo el proyecto, es posible que nunca debas tocar el archivo BUILD, excepto cuando agregues una dependencia externa, pero significa que el sistema de compilación siempre debe compilar todo el proyecto a la vez. Esto significa que no podrá paralelizar ni distribuir partes de la compilación ni almacenar en caché partes que ya se hayan compilado. Un módulo por archivo es lo opuesto: 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 necesitan dedicar más esfuerzo a mantener listas de dependencias cada vez que cambian qué archivos hacen referencia a cuáles.

Si bien el nivel de detalle exacto varía según el idioma (y a menudo incluso dentro del idioma), Google suele preferir módulos más pequeños que lo que normalmente escribe 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 objetivos, y hasta un equipo de tamaño moderado puede tener varios cientos de objetivos dentro de su base de código. Para los lenguajes como Java, que tienen una noción integrada de empaquetado, cada directorio suele contener un solo paquete, destino y archivo BUILD (Pantalones, otro sistema de compilación basado en Bazel, lo llama la regla 1:1:1). Los idiomas con convenciones de empaquetado más débiles suelen definir varios objetivos por archivo BUILD.

Los beneficios de los objetivos de compilación más pequeños realmente comienzan a mostrarse a gran escala, ya que conducen a compilaciones distribuidas más rápidas y a una necesidad menor de volver a compilar destinos. Las ventajas se vuelven aún más atractivas después de que las pruebas entran en juego, ya que los objetivos más detallados implican que el sistema de compilación puede ser mucho más inteligente con la ejecución de solo un subconjunto limitado de pruebas que podrían verse afectadas por cualquier cambio determinado. Dado que Google cree en los beneficios sistémicos del uso de objetivos más pequeños, realizamos algunos pasos para mitigar los inconvenientes mediante la inversión en herramientas con el objetivo de administrar automáticamente los archivos BUILD a fin de evitar sobrecargar a los desarrolladores.

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

Cómo minimizar la visibilidad del módulo

Bazel y otros sistemas de compilación permiten que cada objetivo especifique una visibilidad: una propiedad que especifica qué otros destinos pueden depender de él. Los destinos pueden ser públicos, en cuyo caso, cualquier otro destino del lugar de trabajo puede hacer referencia a ellos; en ese caso, solo se puede hacer referencia a ellos desde el mismo archivo BUILD, o bien solo pueden ver una lista definida de otros explícitamente. Una visibilidad es básicamente lo opuesto de una dependencia: si el objetivo A quiere depender del objetivo B, el objetivo B debe hacerse visible para el objetivo A. Al igual que con la mayoría de los lenguajes de programación, suele ser mejor minimizar la visibilidad tanto como sea posible. En general, los equipos de Google hacen públicos los objetivos solo si estos representan bibliotecas de uso general disponibles para cualquier equipo de Google. Los equipos que requieren que otras personas colaboren con ellos antes de usar su código mantendrán una lista de anunciantes permitidos de objetivos de clientes como visibilidad de su destino. Los objetivos de implementación internos de cada equipo se restringirán solo a los directorios que son propiedad del equipo y la mayoría de los archivos BUILD tendrán solo un destino 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, expresar estas dependencias es 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 desde la fuente, en lugar de descargarse como un artefacto compilado previamente mientras se ejecuta 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/revisión en el repositorio. Un problema que se debe controlar con cuidado con respecto a las dependencias internas es cómo tratar las dependencias transitivas (Figura 1). Supongamos que el objetivo A depende del objetivo B, que depende de un objetivo común de la biblioteca C. ¿El objetivo A debería poder usar las clases definidas en el objetivo C?

Dependencias transitivas

Figura 1. Dependencias transitivas

En lo que respecta a las herramientas subyacentes, no hay problema con esto; tanto B como C se vincularán al destino A cuando se compile, por lo que cualquier símbolo definido en C es A. Bazel lo permitió durante muchos años, pero a medida que Google crecía, nos dispusimos a detectar problemas. Supongamos que se refactorizó B para que ya no necesite depender de C. Si se quita la dependencia de B en C, A y cualquier otro objetivo que utilice C a través de una dependencia en B se interrumpiría. Efectivamente, las dependencias de un objetivo se volvieron parte de su contrato público y nunca se pudieron cambiar de forma segura. Esto significa que las dependencias se acumulan con el tiempo y las compilaciones en Google comenzaron a ralentizarse.

Google resolvió este problema finalmente al incorporar un "modo de dependencia transitiva estricto" en Bazel. En este modo, Bazel detecta si un objetivo intenta hacer referencia a un símbolo sin depender directamente de él y, de ser así, falla con un error y un comando de shell que se puede usar para insertar la dependencia de forma automática. Lanzar este cambio en toda la base de código de Google y refactorizar cada uno de los millones de destinos de compilación para enumerar explícitamente sus dependencias fue un esfuerzo de varios años, pero valió la pena. Ahora nuestras compilaciones son mucho más rápidas, ya que los objetivos tienen menos dependencias innecesarias y los ingenieros pueden quitar las dependencias que no necesitan sin preocuparse por romper los destinos que dependen de ellas.

Como siempre, la aplicación de dependencias transitivas estrictas supuso una compensación. Hicimos los archivos de compilación 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 extraerse accidentalmente. Los ingenieros debían dedicar más esfuerzo a agregar dependencias a los archivos BUILD. Desde entonces, desarrollamos herramientas que reducen este trabajo repetitivo mediante la detección automática de muchas dependencias faltantes y las agregamos a archivos BUILD sin la intervención del desarrollador. Sin embargo, incluso sin esas herramientas, descubrimos que la compensación vale la pena a medida que escala la base de código: agregar una dependencia de forma explícita a un archivo BUILD es un costo único, pero lidiar con dependencias transitivas implícitas puede causar problemas en curso, siempre que exista el objetivo de compilación. Bazel aplica dependencias transitivas estrictas en el código Java de forma predeterminada.

Dependencias externas

Si una dependencia no es interna, debe ser externa. Las dependencias externas son aquellas que se encuentran en artefactos que se compilan y almacenan fuera del sistema de compilación. La dependencia se importa directamente desde un repositorio de artefactos (por lo general, se accede a través de Internet) y se usa tal como está, en lugar de compilarse desde la fuente. Una de las diferencias más importantes entre las dependencias externas e internas es que las dependencias externas tienen versiones, y estas versiones existen independientemente del código fuente del proyecto.

Administración automática o manual de dependencias

Los sistemas de compilación pueden permitir que las versiones de dependencias externas se administren de forma manual o automática. Cuando se administra de forma manual, el archivo de compilación muestra explícitamente la versión que desea descargar del repositorio de artefactos, por lo general, mediante una string 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 una versión de dependencia se declare como "1.+" para especificar que cualquier versión secundaria o de parche de una dependencia es aceptable siempre que la versión principal sea 1.

Las dependencias administradas automáticamente pueden ser convenientes para proyectos pequeños, pero, por lo general, son una receta para el desastre en proyectos de tamaño no trivial o en los que están trabajando 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 rotundos (incluso cuando afirman usar el control de versiones semántico), por lo que una compilación que funcionó un día podría fallar al siguiente sin ninguna manera fácil de detectar lo que cambió o revertirla a un estado operativo. Incluso si la compilación no se rompe, puede haber cambios sutiles de comportamiento o rendimiento que son imposibles de rastrear.

Por el contrario, debido a que las dependencias administradas manualmente requieren un cambio en el control de origen, se pueden descubrir y revertir con facilidad, y es posible verificar una versión anterior del repositorio para compilar con dependencias más antiguas. Bazel requiere que se especifiquen manualmente las versiones de todas las dependencias. A escalas moderadas, la sobrecarga de administración de versiones manuales vale la pena por la estabilidad que proporciona.

La regla de una versión

Las diferentes versiones de una biblioteca suelen estar representadas por distintos artefactos, por lo que, en teoría, no hay razón para que no se puedan declarar diferentes versiones de la misma dependencia externa en el sistema de compilación con diferentes nombres. De esa manera, cada objetivo podrí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 regla de una versión estricta para todas las dependencias de terceros en nuestra base de código.

El mayor problema de permitir varias versiones es el problema de la dependencia de diamantes. Supongamos que el objetivo A depende del objetivo B y de la versión 1 de una biblioteca externa. Si más tarde se refactoriza el destino B para agregar una dependencia en la versión 2 de la misma biblioteca externa, el destino A se romperá porque ahora depende implícitamente 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 sus usuarios ya podría depender de una versión diferente. Si sigues la regla de una versión, este conflicto es imposible: si un objetivo agrega una dependencia en una biblioteca de terceros, todas las dependencias existentes ya estarán en esa misma versión, por lo que podrán coexistir sin problemas.

Dependencias externas transitivas

Abordar 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 específicas de otros artefactos del repositorio. Las herramientas de compilación, como Maven o Gradle, a menudo descargan de manera recurrente cada dependencia transitiva de forma predeterminada, lo que significa que, si agregas una sola dependencia en tu proyecto, es posible que se descarguen decenas de artefactos en total.

Esto es muy conveniente: cuando se agrega una dependencia en una biblioteca nueva, sería muy difícil tener que buscar cada una de las dependencias transitivas de esa biblioteca y agregarlas manualmente. Sin embargo, también hay un gran inconveniente: debido a que las diferentes bibliotecas pueden depender de distintas versiones de la misma biblioteca de terceros, esta estrategia necesariamente infringe la regla de una versión y genera el problema de la dependencia del diamante. Si tu destino depende de dos bibliotecas externas que usan versiones diferentes de la misma dependencia, no podrás saber cuál obtendrás. Esto también significa que la actualización de una dependencia externa podría causar fallas aparentemente no relacionadas en la base de código si la versión nueva comienza a extraer versiones en conflicto de algunas de sus dependencias.

Por este motivo, Bazel no descarga automáticamente dependencias transitivas. Desafortunadamente, no existe la solución mágica. La alternativa de Bazel es solicitar un archivo global que enumere todas las dependencias externas del repositorio y una versión explícita utilizada para esa dependencia en todo el repositorio. Afortunadamente, Bazel proporciona herramientas que pueden generar automáticamente ese archivo con las dependencias transitivas de un conjunto de artefactos de Maven. Esta herramienta se puede ejecutar una vez a fin de generar el archivo WORKSPACE inicial para un proyecto y, luego, ese archivo se puede actualizar de forma manual para ajustar las versiones de cada dependencia.

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

Cómo almacenar en caché los resultados de compilación mediante dependencias externas

Por lo general, los terceros proporcionan dependencias externas que lanzan versiones estables de las bibliotecas, tal vez sin proporcionar el código fuente. Algunas organizaciones también pueden optar por hacer que parte de su propio código esté disponible como artefactos, lo que permite que otros fragmentos de código dependan de él como terceros en lugar de dependencias internas. En teoría, esto puede acelerar las compilaciones si los artefactos tardan en compilarse, pero se descargan rápido.

Sin embargo, esto conlleva mucha 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 última versión. La depuración también se vuelve mucho más difícil porque diferentes partes del sistema se compilarán desde diferentes puntos en el repositorio y ya no hay una vista coherente del árbol de fuentes.

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

Seguridad y confiabilidad de dependencias externas

La dependencia de artefactos de fuentes de terceros es inherentemente riesgosa. Existe un riesgo de disponibilidad si la fuente de terceros (como un repositorio de artefactos) deja de funcionar, ya que toda tu compilación podría interrumpirse si no puede descargar una dependencia externa. También existe un riesgo de seguridad: si un atacante se ve comprometido por un sistema de terceros, este puede reemplazar el artefacto mencionado por uno de su propio diseño, lo que le permite insertar código arbitrario en tu compilación. Ambos problemas se pueden mitigar si duplicas cualquier artefacto que dependas de los servidores que controlas y evitarás que el sistema de compilación acceda a repositorios de artefactos de terceros, como Maven Central. La compensación es que estos espejos requieren esfuerzo y recursos para mantenerse, por lo que la elección de usarlos a menudo depende de la escala del proyecto. El problema de seguridad también se puede evitar por completo con poca sobrecarga, ya que se requiere que se especifique el hash de cada artefacto de terceros en el repositorio de origen, lo que hace que la compilación falle si se altera el artefacto. Otra alternativa que evita por completo el problema es proveedores de las dependencias de tu proyecto. Cuando un proyecto otorga sus dependencias, las verifica en el control de origen junto con el código fuente del proyecto, ya sea como fuente o como objetos binarios. Eso significa que todas las dependencias externas del proyecto se convierten en dependencias internas. Google usa este enfoque de forma interna y verifica cada biblioteca de terceros a la que se hace referencia en Google en un directorio third_party en la raíz del árbol fuente de Google. Sin embargo, esto solo funciona en Google porque el sistema de control de fuente de Google está diseñado para manejar un monorepo extremadamente grande, por lo que es posible que el proveedor no sea una opción para todas las organizaciones.