Administración de dependencias

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. Hay todo tipo de dependencias: a veces, hay una dependencia de una tarea (como “enviar 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 en otra parte de tu base de código y, a veces, tienes dependencias externas en el código o los datos que pertenecen a otro equipo (ya sea en tu organización o un tercero). Pero, en cualquier caso, la idea de “necesito eso antes de poder tener esto” es algo que se repite una y otra vez 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 los módulos expresan dependencias entre sí a través de BUILD archivos. La organización adecuada de estos módulos y dependencias puede tener un gran efecto en el rendimiento del sistema de compilación y en la cantidad de trabajo que requiere el mantenimiento.

Usa 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 mediante un destino que especifica una unidad compilable, como una java_library o una go_binary. En un extremo, todo el proyecto podría estar contenido en un solo módulo si se coloca un archivo BUILD en la raíz y se agrupan 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 enumere 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 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 a la 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 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 deben esforzarse más para mantener listas de dependencias cada vez que cambian los archivos que hacen referencia a qué.

Aunque la granularidad exacta varía según el lenguaje (y, a menudo, incluso dentro del lenguaje), Google tiende a favorecer los módulos significativamente más pequeños de lo que se podría escribir 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. Para lenguajes como Java que tienen una noción integrada sólida de empaquetado, cada directorio suele contener un solo paquete, destino y archivo BUILD (Pants, otro sistema de compilación basado en Bazel, lo llama 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 escala, ya que generan compilaciones distribuidas más rápidas y una necesidad menos frecuente de volver a compilar 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 para ejecutar solo un subconjunto limitado de pruebas que podrían verse afectadas por un cambio determinado. Debido a que Google cree en los beneficios sistémicos de usar destinos más pequeños, hemos avanzado en la mitigación de la desventaja invirtiendo en herramientas para administrar automáticamente los archivos BUILD para evitar sobrecargar a los desarrolladores.

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

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 BUILD archivo. 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 cada destino 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. Por lo general, los equipos de Google harán públicos los destinos solo si representan bibliotecas de uso generalizado disponibles para cualquier equipo de Google. Los equipos que requieren que otros coordinen con ellos antes de usar su código mantendrán una lista de entidades permitidas de destinos de clientes como la visibilidad de su destino. Los destinos de implementación interna de cada equipo se restringirán solo a los directorios que pertenecen al equipo, y la mayoría de los BUILD archivos 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). Expresar estas dependencias suele ser 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 origen. Las dependencias internas difieren de las dependencias externas en que se compilan a partir de la fuente en lugar de descargarse como un artefacto precompilado 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 o revisión en el repositorio. Un problema que se debe manejar 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 depende de un destino C de biblioteca común. ¿Debería el destino 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, A y cualquier otro destino que usara C a través de una dependencia en B se interrumpirían. En efecto, las dependencias de un destino se convirtieron en parte de su contrato público y nunca se pudieron cambiar de forma segura. Esto significó que las dependencias se acumularon con el tiempo y las compilaciones en Google comenzaron 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 destinos de compilación para enumerar de forma explícita 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 interrumpir los destinos que dependen de ellas.

Como de costumbre, la aplicación de dependencias transitivas estrictas implicó una compensación. Hizo que los archivos de compilación fueran más detallados, ya que las bibliotecas de uso frecuente ahora deben enumerarse de forma explícita en muchos lugares en lugar de extraerse de forma incidental, y los ingenieros debían esforzarse más para agregar dependencias a los archivos BUILD. Desde entonces, desarrollamos herramientas que reducen este trabajo detectando automáticamente muchas dependencias faltantes y agregándolas a un BUILD sin ninguna intervención del desarrollador. Pero incluso sin esas herramientas, descubrimos que la compensación valió la pena a medida que se escala la base de código: agregar de forma explícita una dependencia al archivo BUILD es un costo único, pero trabajar con dependencias transitivas implícitas puede causar problemas continuos mientras exista el destino 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 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 como está en lugar de compilarse a partir de la fuente. Una de las diferencias más importantes entre las dependencias externas e internas es que las dependencias externas tienen versiones, y esas versiones existen independientemente de el código fuente del proyecto.

Administración de dependencias automática frente a 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 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 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 de interrupción (incluso cuando afirman usar el versionado semántico), por lo que una compilación que funcionó un día podría interrumpirse al día siguiente sin una forma sencilla de detectar qué cambió o 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 sean imposibles de rastrear.

Por el contrario, debido a que las dependencias administradas manualmente requieren un cambio en el control de fuentes, se pueden descubrir y revertir con facilidad, y es posible extraer una versión anterior del repositorio para compilar con dependencias más antiguas. 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

Las diferentes versiones de una biblioteca suelen representarse con artefactos diferentes, por lo que, en teoría, no hay ningún motivo por el que no se puedan declarar diferentes versiones de la misma dependencia externa en el sistema de compilación con nombres diferentes. De esa manera, cada destino 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 estricta 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 la dependencia de diamante. Supongamos que el destino A depende del destino B y de la versión 1 de una biblioteca externa. Si el destino B se refactoriza más tarde 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 dependencia nueva de un destino a cualquier biblioteca de terceros con varias versiones, ya que cualquiera de los usuarios de ese destino ya podría depender de una versión diferente. Si sigues la regla de una versión, este conflicto será imposible. Si un destino agrega una dependencia en una biblioteca de terceros, cualquier dependencia existente ya estará en esa misma versión, por lo que podrán coexistir sin problemas.

Dependencias externas transitivas

Trabajar 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 causar 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 rastrear cada una de las dependencias transitivas de esa biblioteca y agregarlas todas de forma manual. Pero también hay una gran desventaja: debido a que diferentes bibliotecas pueden depender de diferentes versiones de la misma biblioteca de terceros, esta estrategia viola necesariamente la regla de una versión y genera el problema de la dependencia 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 toda 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 las dependencias transitivas. Y, lamentablemente, no hay una solución mágica: la alternativa de Bazel es requerir un archivo global que enumere todas las dependencias externas del repositorio y una versión explícita que se use para esa dependencia en todo el repositorio. Afortunadamente, Bazel proporciona herramientas que pueden generar automáticamente un archivo de este tipo que contenga las dependencias transitivas de un conjunto de artefactos de Maven. Esta herramienta se puede ejecutar una vez para generar el archivo inicial WORKSPACE de un proyecto, y ese archivo se puede actualizar de forma manual para ajustar las versiones de cada dependencia.

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. En escalas más grandes, el costo de administrar las dependencias de forma manual es mucho menor que el costo de abordar los problemas causados por la administración automática de dependencias

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

Las dependencias externas suelen ser proporcionadas por terceros que lanzan versiones estables de bibliotecas, quizás 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 otras partes del código dependan de ellos como dependencias externas en lugar de internas. En teoría, esto puede acelerar las compilaciones si los artefactos son lentos de compilar, pero rápidos de descargar.

Sin embargo, esto también introduce 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 versión más reciente. La depuración también se vuelve mucho más difícil porque se habrán compilado diferentes partes del sistema desde diferentes puntos del repositorio, y ya no hay una vista coherente del árbol de origen.

Una mejor manera de resolver el problema de que los artefactos tarden mucho en compilarse es usar un sistema de compilación que admita el almacenamiento en caché remoto, como se describió anteriormente. Un sistema de compilación de este tipo guarda los artefactos resultantes de cada compilación en una ubicación que se comparte 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 usa Google 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 intrínsecamente riesgoso. Existe un riesgo de disponibilidad si la fuente externa (como un repositorio de artefactos) deja de funcionar, ya que toda la compilación podría detenerse si no puede descargar una dependencia externa. También existe un riesgo de seguridad: si un atacante vulnera el sistema externo, 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 mediante la duplicación de cualquier artefacto del que dependas en servidores que controles y bloqueando el acceso de tu sistema de compilación a repositorios de artefactos externos como Maven Central. La compensación es que estos duplicados requieren esfuerzo y recursos para mantenerlos, por lo que la elección de usarlos suele depender de la escala del proyecto. El problema de seguridad también se puede evitar por completo con poca sobrecarga si se requiere que el hash de cada artefacto externo se especifique en el repositorio de origen, lo que hace que la compilación falle si se manipula el artefacto. Otra alternativa que evita por completo el problema es vender las dependencias de tu proyecto. Cuando un proyecto vende sus dependencias, las incluye en el control de fuentes junto con el código fuente del proyecto, ya sea como fuente o como objetos binarios. Esto significa que todas las dependencias externas del proyecto se convierten en dependencias internas. Google usa este enfoque de forma interna, y verifica cada biblioteca externa a la que se hace referencia en 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 fuentes de Google está compilado de forma personalizada para controlar un monorepo extremadamente grande, por lo que la venta podría no ser una opción para todas las organizaciones.