Administración de dependencias

Informar un problema Ver fuente Noche /}1}

En 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 de 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 en los datos de una organización de terceros. Pero, en cualquier caso, la idea de "necesito eso antes de poder tener esto" es algo que se repite de manera reiterada 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.

Maneja 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, en los que 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 tanto en el rendimiento del sistema de compilación como en la cantidad de trabajo que se necesita para mantenerlo.

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

La primera pregunta que surge cuando se estructura una compilación basada en artefactos es decidir cuánta funcionalidad debe incluir 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 colocando un archivo BUILD en la raíz y agrupando de manera recurrente todos los archivos de origen de ese proyecto. En el otro extremo, casi todos los archivos de origen podrían convertirse en su propio módulo, lo que requeriría que cada archivo se enumerara en un archivo BUILD cada otro de los que dependa.

La mayoría de los proyectos se encuentran en algún punto entre estos extremos, y la elección implica un equilibrio entre rendimiento y mantenimiento. El uso de un solo módulo para todo el proyecto puede implicar 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 de una vez. Esto 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 opuesto: el sistema de compilación tiene la máxima flexibilidad en el almacenamiento en caché y la programación de los pasos de la compilación, pero los ingenieros deben esforzarse más para mantener las listas de dependencias cada vez que cambian qué archivos hacen referencia a cuáles.

Aunque el nivel de detalle exacto varía según el idioma (y, a menudo, incluso dentro del idioma), Google tiende a priorizar módulos significativamente más pequeños que los que se podrían escribir en un sistema de compilación basado en tareas. Un objeto binario de producción típico de Google suele depender de decenas de miles de objetivos, y hasta un equipo de tamaño moderado puede tener cientos de objetivos dentro de su base de código. En el caso de 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, 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 objetivos de compilación más pequeños realmente comienzan a mostrarse a gran escala porque conducen a compilaciones distribuidas más rápido y a una necesidad menos frecuente de volver a compilar los objetivos. Las ventajas se vuelven aún más convincentes después de la implementación de las pruebas, ya que objetivos más detallados implican que el sistema de compilación puede ser mucho más inteligente si solo ejecuta un subconjunto limitado de pruebas que podrían verse afectados por cualquier cambio determinado. Dado que Google cree en los beneficios sistémicos del uso de objetivos más pequeños, dimos grandes avances en la mitigación de las desventajas invirtiendo en herramientas que administren automáticamente los archivos BUILD para 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 de los módulos

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 él. Solo se puede hacer referencia a un destino privado dentro de su propio archivo BUILD. Un destino puede otorgar una mayor visibilidad 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 en el lugar de trabajo.

Al igual que con la mayoría de los lenguajes de programación, por lo general, es mejor minimizar la visibilidad tanto como sea posible. Por lo general, los equipos de Google hacen públicos los objetivos solo si estos representan bibliotecas muy usadas y 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 objetivos de clientes permitidos como visibilidad de sus objetivos. Los objetivos de implementación internos de cada equipo se restringirán solo a los directorios que pertenecen al 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 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 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 existe la 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 debe manejarse con cuidado con respecto a 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 C común de la biblioteca. ¿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 A se conoce como A cualquier símbolo definido en C. Bazel permitió esto durante muchos años, pero a medida que Google crecía, empezamos a ver problemas. Supongamos que B se refactorizó de manera que ya no necesitaba depender de C. Si luego se quitara la dependencia de B en C, A y cualquier otro objetivo que usara C mediante una dependencia en B se dañarían. Efectivamente, las dependencias de un objetivo pasaron a formar parte de su contrato público y nunca se pudieron cambiar de forma segura. Esto significaba que las dependencias se acumularon con el tiempo y las compilaciones de Google comenzaron a disminuir su velocidad.

Con el tiempo, Google resolvió este problema con la introducción de un “modo de dependencia transitivo estricto” en Bazel. En este modo, Bazel detecta si un destino 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 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. Nuestras compilaciones ahora 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 de romper los objetivos que dependen de ellas.

Como siempre, la aplicación forzosa de dependencias transitivas estrictas implicaba una compensación. Hizo que los archivos de compilación fueran más detallados, ya que ahora las bibliotecas de uso frecuente deben enumerarse de forma explícita en muchos lugares en lugar de extraerse de manera accidental, y los ingenieros debían esforzarse más en agregar dependencias a los archivos BUILD. Desde entonces, desarrollamos herramientas que reducen este trabajo repetitivo detectando automáticamente muchas dependencias faltantes y agregándolas a un archivo BUILD sin la intervención de un desarrollador. Pero incluso sin esas herramientas, descubrimos que la compensación era valiosa a medida que la base de código escalaba: agregar de forma 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 destino de compilación. Bazel aplica dependencias transitivas estrictas en 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 mayores diferencias entre las dependencias internas y externas es que las dependencias externas tienen versiones, y esas versiones 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 las versiones de 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 quiere descargar del repositorio de artefactos, a menudo, mediante 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 declarar una versión de dependencia 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 de forma automática pueden ser convenientes para proyectos pequeños, pero, por lo general, son una receta para desastres en proyectos de tamaño no trivial o en los que más de un ingeniero esté trabajando. 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 manera fácil de detectar qué cambió o revertirla a un estado operativo. Incluso si la compilación no falla, puede haber comportamientos o cambios de rendimiento sutiles que son imposibles de rastrear.

En cambio, 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 consultar una versión anterior del repositorio para compilar con dependencias más antiguas. Bazel requiere que las versiones de todas las dependencias se especifiquen manualmente. Incluso a escalas moderadas, la sobrecarga de la administración manual de versiones vale la pena debido a la estabilidad que proporciona.

La regla de una versión

Por lo general, las diferentes versiones de una biblioteca están representadas por artefactos distintos, por lo que, en teoría, no hay motivo para que no se puedan declarar versiones diferentes 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 deseaba usar. Esto genera muchos problemas en la práctica, por lo que Google aplica una regla de una versión estricta a 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 objetivo B y de la versión 1 de una biblioteca externa. Si posteriormente el destino B se refactoriza para agregar una dependencia en la v2 de la misma biblioteca externa, el destino A no funcionará porque ahora depende implícitamente de dos versiones diferentes de la misma biblioteca. De hecho, nunca es seguro agregar una dependencia nueva de un destino a una 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 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 inconvenientes.

Dependencias externas transitivas

Tratar las dependencias transitivas de una dependencia externa puede ser particularmente difícil. Muchos repositorios de artefactos, como Maven Central, permiten que estos especifiquen dependencias en versiones particulares de otros artefactos en el repositorio. Las herramientas de compilación como Maven o Gradle a menudo descargan recursivamente cada dependencia transitiva de forma predeterminada, lo que significa que agregar una sola dependencia en tu proyecto podría causar que se descarguen docenas de artefactos en total.

Esto resulta muy conveniente: cuando se agrega una dependencia a una biblioteca nueva, sería un gran problema tener que buscar cada una de las dependencias transitivas de esa biblioteca y agregarlas todas manualmente. Sin embargo, también existe una gran desventaja: debido a que diferentes bibliotecas pueden depender de distintas versiones de la misma biblioteca de terceros, esta estrategia infringe inevitablemente la regla de una versión y genera el problema de dependencia del diamante. 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 la actualización de una dependencia externa puede 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 dependencias transitivas automáticamente. Lamentablemente, no hay una solución mágica. La alternativa de Bazel es requerir un archivo global que enumere cada una de las dependencias externas del repositorio y una versión explícita que se usa para esa dependencia en todo el repositorio. Por suerte, Bazel proporciona herramientas que pueden generar automáticamente un archivo de este tipo que contenga las dependencias transitivas de un conjunto de artefactos Maven. Esta herramienta se puede ejecutar una vez para generar el archivo WORKSPACE inicial para un proyecto, y ese archivo se puede actualizar de forma manual para ajustar las versiones de cada dependencia.

Sin embargo, una vez más, la elección es entre conveniencia y escalabilidad. Es posible que los proyectos pequeños prefieran no tener que preocuparse por administrar dependencias transitivas por su cuenta y que puedan dejar de usar dependencias transitivas automáticas. Esta estrategia se vuelve cada vez menos atractiva a medida que crece 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 dependencias de forma manual es mucho menor que el de lidiar con los problemas causados por la administración automática de dependencias.

Cómo almacenar 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 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 compilación, pero rápidos de descargar.

Sin embargo, esto también genera una gran 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 las diferentes partes del sistema se compilaron 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 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 compartida 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 hubieran compilado a partir de la misma fuente. Esta es la estrategia que usa Google internamente. Bazel se puede configurar para que use 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 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 un atacante compromete el sistema de terceros, este podría reemplazar el artefacto al que se hace referencia con uno de su propio diseño, lo que le permite inyectar código arbitrario en tu compilación. Ambos problemas se pueden mitigar duplicando cualquier artefacto de los que dependas en servidores que controlas y bloqueando que tu sistema de compilación acceda a repositorios de artefactos de terceros, como Maven Central. La desventaja es que estas duplicaciones requieren esfuerzo y recursos para mantenerlas, por lo que la elección de si usarlas 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 manipuló el artefacto. Otra alternativa que elude el problema por completo es proveer las dependencias del proyecto. Cuando un proyecto proporciona sus dependencias, las registra en el control del código fuente junto con el código fuente del proyecto, ya sea como fuente o como objetos binarios. Esto significa, de manera efectiva, que todas las dependencias externas del proyecto se convierten en dependencias internas. Google usa este enfoque internamente y verifica cada biblioteca de terceros a la que se hace referencia 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 código fuente está diseñado para manejar un monorepo extremadamente grande, por lo que la entrega de proveedores puede no ser una opción para todas las organizaciones.