Administración de dependencias

Al revisar las páginas anteriores, hay un tema que 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 en una tarea (como "enviar la documentación antes de marcar una versión como completa") y, a veces, hay una dependencia en un artefacto (como "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, a veces, tienes dependencias externas en el código o los datos de tu organización. Sin embargo, en cualquier caso, la idea de "Necesito eso antes de poder tener esto" es algo que se repite repetidamente en el diseño de sistemas de compilación, y la administración de dependencias es quizás el trabajo más fundamental de un sistema de compilación.

Trabaja 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 efecto en el rendimiento del sistema de compilación y en el trabajo que requiere mantener.

Cómo usar módulos depurados y la regla de 1:1:1

La primera pregunta que surge cuando estructuras 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 java_library o 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 globaliza de forma recurrente todos los archivos fuente de ese proyecto. Por el otro lado, casi todos los archivos fuente podrían convertirse en su propio módulo, lo que requirió que cada archivo se enumeró en un archivo BUILD cada otro archivo del que depende.

La mayoría de los proyectos se encuentran entre estos extremos, y la elección implica un equilibrio entre el rendimiento y la capacidad de mantenimiento. Si usas un solo módulo para todo el proyecto, es posible que nunca necesites tocar el archivo BUILD, excepto cuando agregues una dependencia externa. Sin embargo, significa que el sistema de compilación siempre debe compilar todo el proyecto a la vez. Eso significa que no podrá paralelizar ni distribuir partes de la compilación ni almacenar en caché partes que ya se 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 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 cuáles.

Si bien el nivel de detalle exacto varía según el lenguaje (y, a menudo, incluso dentro del lenguaje), Google tiende a favorecer módulos significativamente más pequeños que los que se suelen 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 objetivos. Incluso un equipo de tamaño moderado puede tener varios cientos de objetivos 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, llama a esto la regla de 1:1:1). Los lenguajes 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 en realidad comienzan a mostrarse a gran escala porque conducen a compilaciones más rápidas y a una necesidad menos frecuente de volver a compilar los objetivos. Las ventajas se vuelven aún más atractivas después de que entran en juego las pruebas, ya que los objetivos más detallados implican que el sistema de compilación puede 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, mitigamos las desventajas mediante la inversión en herramientas para administrar automáticamente los archivos BUILD y evitar abrumar a los desarrolladores.

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

Minimiza la visibilidad del módulo

Bazel y otros sistemas de compilación permiten que cada destino especifique una visibilidad, es decir, una propiedad que determina qué otros destinos pueden depender de ella. Solo se puede hacer referencia a un destino privado dentro de su propio archivo BUILD. Un destino puede otorgar una visibilidad más amplia de 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 en el lugar de trabajo.

Como 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 hacen que los objetivos sean públicos solo si representan bibliotecas que se usan con mucha frecuencia y que están disponibles para cualquier equipo de Google. Los equipos que requieren que otros se coordinen con ellos antes de usar su código mantendrán una lista de entidades permitidas de clientes como visibilidad de su objetivo. 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 un solo destino que no sea privado.

Administra dependencias

Los módulos deben poder referirse unos a otros. 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). La expresión de estas dependencias suele ser la mayor parte del contenido en un archivo BUILD.

Dependencias internas

En un proyecto grande y 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 de la fuente en lugar de descargarse como un artefacto precompilado mientras se ejecuta la compilación. Esto también significa que no hay noción de “versión” para las dependencias internas, ya que un destino y todas sus dependencias internas siempre se compilan en la misma confirmación y revisión en el repositorio. Un problema que se debe manejar con cuidado con respecto a las dependencias internas es cómo tratar las dependencias transitivas (Figura 1). Supongamos que el objetivo A depende del destino B, que depende de un destino C de una biblioteca común. ¿El destino 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 A conoce cualquier símbolo definido 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 tal manera que ya no necesita 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 objetivo pasaron a formar parte de su contrato público y nunca se podrían cambiar de forma segura. Esto significó que las dependencias se acumularon con el tiempo y las compilaciones en Google comenzaron a ralentizarse.

Con el tiempo, Google resolvió este problema mediante 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, de ser 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 enumerar de forma explícita 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 objetivos que dependen de ellas.

Como siempre, aplicar dependencias transitivas estrictas implicaba un equilibrio. Los archivos de compilación eran más detallados, ya que las bibliotecas que se usan con frecuencia ahora deben enumerarse de manera explícita en muchos lugares en lugar de extraerse por accidente, y los ingenieros debían esforzarse más para agregar dependencias a los archivos BUILD. Desde entonces, desarrollamos herramientas que reducen este trabajo repetitivo, ya que detectan automáticamente muchas dependencias faltantes y las agregan a un archivo 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 de manera explícita una dependencia al archivo BUILD es un costo único, pero lidiar con dependencias transitivas implícitas puede causar problemas continuos mientras 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 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 como está en lugar de compilarse desde la fuente. Una de las mayores diferencias entre las dependencias internas y externas es que las externas tienen versiones que existen independientemente del código fuente del proyecto.

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

Los sistemas de compilación pueden permitir que se administren las versiones de las dependencias externas 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 de origen especifica un rango de versiones aceptables, y el sistema de compilación siempre descarga la última. Por ejemplo, Gradle permite declarar una versión de dependencia como "1.+" para especificar que se acepta cualquier versión secundaria o de parche de una dependencia, siempre que la versión principal sea 1.

Las dependencias administradas automáticamente pueden ser convenientes para proyectos pequeños, pero, por lo general, generan desastres en proyectos de tamaño importante o en los que está 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 rotundas (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 una forma fácil de detectar lo que cambió o revertir a un estado de funcionamiento. Incluso si la compilación no se rompe, puede haber cambios sutiles en el comportamiento o el rendimiento que son imposibles de rastrear.

Por el contrario, debido a que las dependencias administradas de forma manual requieren un cambio en el control de la fuente, se pueden descubrir y revertir con facilidad, y es posible probar una versión anterior del repositorio para compilar con dependencias anteriores. Bazel requiere que se especifiquen las versiones de todas las dependencias 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 sola versión

Por lo general, las diferentes versiones de una biblioteca están representadas por distintos artefactos, por lo que, en teoría, no hay razones para que las diferentes versiones de la misma dependencia externa no se puedan declarar a la vez en el sistema de compilación con nombres diferentes. De esa manera, cada destino podría elegir qué versión de la dependencia deseaba usar. Esto genera 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 dependencia de diamantes. 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 adelante para agregar una dependencia en la versión 2 de la misma biblioteca externa, el destino A fallará porque ahora depende implícitamente de dos versiones diferentes de la misma biblioteca. En efecto, nunca es seguro agregar una dependencia nueva de un destino a una biblioteca de terceros con varias versiones, ya que es posible que los usuarios de ese destino dependan de una versión diferente. Seguir la regla de una versión hace que este conflicto sea imposible: si un objetivo agrega una dependencia en una biblioteca de terceros, cualquier dependencia existente ya estará en esa misma versión, por lo que puede coexistir feliz.

Dependencias externas transitivas

Manejar 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 del repositorio. Las herramientas de compilación como Maven o Gradle suelen descargar de forma recursiva cada dependencia transitiva de forma predeterminada, por lo 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 tener que rastrear cada una de las dependencias transitivas de esa biblioteca y agregarlas todas de forma manual. Sin embargo, también tiene una gran desventaja: dado que 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 dependencia de diamantes. Si tu destino depende de dos bibliotecas externas que usan versiones diferentes 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 conflictivas de algunas de sus dependencias.

Por este motivo, Bazel no descarga automáticamente dependencias transitivas. Y, lamentablemente, no hay soluciones milagrosas: la alternativa de Bazel es exigir un archivo global que enumere cada una de las dependencias externas del repositorio y una versión explícita que se use para esa dependencia en todo el repositorio. Por suerte, Bazel proporciona herramientas que pueden generar de forma automática 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 WORKSPACE inicial para un proyecto y, luego, ese archivo se puede actualizar de forma manual para ajustar las versiones de cada dependencia.

Sin embargo, la opción aquí es entre conveniencia y escalabilidad. Es posible que los proyectos pequeños prefieran no tener que preocuparse por administrar dependencias transitivas y que 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 resultados inesperados se vuelven cada vez más frecuentes. A mayor escalas, el costo de administrar dependencias de forma manual es mucho menor que el de lidiar con problemas causados por la administración automática de dependencias.

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

Las dependencias externas las suelen proporcionar terceros que lanzan versiones estables de bibliotecas, quizás sin proporcionar 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 ellos como dependencias de terceros en lugar de dependencias 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 genera 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 compilarán diferentes partes del sistema a partir de distintos puntos del repositorio y ya no habrá una vista coherente del árbol de fuentes.

Una mejor manera de resolver el problema de los artefactos que tardan mucho tiempo en compilarse es usar un sistema de compilación que admita el almacenamiento en caché remoto, como se describió antes. 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 que, si un desarrollador depende de un artefacto que otra persona compiló recientemente, 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 a nivel interno, y Bazel se puede configurar para usar una caché remota.

Seguridad y confiabilidad de las dependencias externas

El hecho de depender de artefactos de fuentes de terceros es inherentemente riesgoso. 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 detenerse si no puede descargar una dependencia externa. También existe un riesgo de seguridad: si el sistema de terceros se ve comprometido por un atacante, este podría reemplazar el artefacto al que se hace referencia por uno de su propio diseño, lo que le permite inyectar código arbitrario en tu compilación. Ambos problemas se pueden mitigar si duplicas cualquier artefacto del que dependas en los servidores que controlas y bloqueas el sistema de compilación para que no acceda a repositorios de artefactos de terceros, como Maven Central. La desventaja es que estos espejos requieren esfuerzo y recursos para mantener, por lo que decidir si usarlos a menudo depende de la escala del proyecto. El problema de seguridad también se puede evitar por completo con poca sobrecarga si se solicita que se especifique el hash de cada artefacto de terceros en el repositorio de código fuente, lo que provoca que la compilación falle si se altera el artefacto. Otra alternativa que elude por completo al problema es crear proveedores de las dependencias de tu proyecto. Cuando un proyecto proveedora sus dependencias, las incluye en el control del código fuente junto con el código fuente del proyecto, ya sea como fuente o como objeto binario. 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 de terceros a la que se hace referencia en Google en un directorio third_party en la raíz del árbol de fuentes de Google. Sin embargo, esto funciona en Google solo porque el sistema de control de origen de Google está creado de forma personalizada para manejar un monorepo extremadamente grande, por lo que podría no ser una opción para todas las organizaciones.