Administración de dependencias

Informa un problema Ver código fuente

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. Hay todo tipo de dependencias: a veces hay una dependencia en una tarea (por ejemplo, "enviar la documentación antes de marcar una versión como completa") y, a veces, hay una dependencia en un artefacto (por ejemplo, "necesito tener la versión más reciente de la biblioteca de Vision para la computadora" a fin de compilar mi código). A veces, tienes dependencias internas en otra parte de tu base de código y, a veces, tienes organizaciones externas en código o terceros pertenecientes a tu código o terceros. Sin embargo, en cualquier caso, la idea de "necesito eso antes de poder hacer esto" es algo que se repite de forma repetida 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.

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 y los módulos 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 se necesita para mantener.

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ántas funcionalidades debe abarcar un módulo individual. En Bazel, un módulo se representa mediante un destino que especifica una unidad compilable, como un java_library o un go_binary. En un extremo, todo el proyecto se podía contener en un solo módulo colocando un archivo BUILD en la raíz y globalizando de manera recurrente todos los archivos de origen de ese proyecto. En el otro extremo, casi todos los archivos de origen se podrían convertir en su propio módulo, lo que requiere que cada archivo se incluya en una lista de un archivo BUILD cada uno de los que dependa.

La mayoría de los proyectos se encuentran entre estos extremos, y la elección implica una compensación entre el rendimiento y el mantenimiento. El uso de un solo módulo para todo el proyecto puede significar que nunca necesitas tocar el archivo BUILD, excepto cuando agregas 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é las partes que ya esté compilada. Un módulo por archivo es lo opuesto: el sistema de compilación tiene la máxima flexibilidad en los pasos de almacenamiento en caché y programación de la compilación, pero los ingenieros deben realizar un gran esfuerzo para mantener las listas de dependencias cada vez que cambian a qué archivos hacen referencia.

Si bien el nivel de detalle exacto varía según el idioma (y, a menudo, dentro del lenguaje), Google suele preferir módulos significativamente más pequeños que uno que, por lo general, se puede escribir en un sistema de compilación basado en tareas. Por lo general, un objeto binario de producción en Google depende de decenas de miles de destinos, y hasta 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 de empaquetado sólida, cada directorio, por lo general, contiene un solo paquete, destino y archivo BUILD (Pants, otro sistema de compilación basado en Bazel, lo llama esta regla 1:1:1). Los idiomas con convenciones de empaquetado más débil 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 volver a compilar destinos. Las ventajas se vuelven aún más convincentes después de que las pruebas entran en juego, ya que los objetivos más precisos implican que el sistema de compilación puede ser mucho más inteligente cuando se ejecuta solo un subconjunto limitado de pruebas que podrían verse afectados por cualquier cambio. Debido a que Google cree en los beneficios sistémicos del uso de objetivos más pequeños, realizamos algunos avances para mitigar el inconveniente. Para ello, invertimos en herramientas de administración automática de archivos BUILD a fin de evitar que los desarrolladores se vean afectados.

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 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 objetivos de una lista definida de forma explícita de los archivos BUILD o, en el caso de la visibilidad pública, a cada destino del lugar de trabajo.

Al igual que con la mayoría de los lenguajes de programación, generalmente es mejor minimizar la visibilidad tanto como sea posible. Por lo general, los equipos de Google solo harán públicos los objetivos si estos representan las 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 orientaciones de cliente como la visibilidad de su objetivo. Los objetivos de implementación internos de cada equipo se restringirán solo a directorios del propietario, y la mayoría de los archivos BUILD tendrán solo un destino que no es privado.

Administra dependencias

Los módulos deben poder referirse entre sí. La desventaja de dividir una base de código en módulos detallados es que necesitas administrar las dependencias entre esos módulos (aunque las herramientas pueden ayudar a automatizar esto). Expresar estas dependencias suele terminar siendo 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, ya 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 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 objetivo A depende del objetivo B, que depende de un destino 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, esto no es un problema; tanto B como C se vincularán al destino A cuando se compile, de manera que A sea conocido por A. Bazel lo permitió durante muchos años, pero, a medida que Google crecía, comenzamos a ver problemas. Supongamos que B se refactorizó de tal manera que ya no necesita depender de C. Si se quita la dependencia de B en C, se interrumpirá A y cualquier otro objetivo que use C mediante una dependencia en B. Efectivamente, las dependencias de un objetivo se volvieron parte de su contrato público y nunca se pudo cambiar de manera segura. Esto significa que las dependencias que se acumulan a lo largo del tiempo y las compilaciones en Google comenzaron a disminuir.

Google resolvió este problema cuando introdujo un "modo estricto de dependencia transitiva" 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 la dependencia de forma automática. Lanzar 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 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 romper los destinos que dependen de ellas.

Como siempre, la aplicación de dependencias transitivas estrictas implicaba una compensación. Hacía 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 manera accidental, y los ingenieros necesitaban dedicar más esfuerzo a agregar dependencias a archivos BUILD. Desde entonces, desarrollamos herramientas que reducen este trabajo repetitivo, ya que detectan automáticamente muchas dependencias faltantes y las agregan a archivos BUILD sin la intervención del desarrollador. Sin embargo, incluso sin esas herramientas, descubrimos que la compensación vale la pena cuando se escala la base de código. Agregar una dependencia al archivo BUILD de manera explícita es un costo único, pero lidiar con dependencias transitivas implícitas puede causar problemas continuos, siempre y cuando 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 (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 diferencias más importantes entre las dependencias internas y externas es que las dependencias externas tienen versiones y estas son independientes del código fuente del proyecto.

Administración de dependencias automática versus 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 desea descargar del repositorio de artefactos, a menudo mediante una string de versión semántica, como 1.1.4. Cuando se administra de forma automática, el archivo de origen 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 la 1.

Las dependencias administradas de forma automática pueden ser convenientes para proyectos pequeños, pero, por lo general, son una receta para la catástrofe 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 de forma automática 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 romperse sin una forma fácil de detectar lo que cambió o revertirla a un estado de trabajo. Incluso si la compilación no se rompe, puede haber cambios sutiles de comportamiento o rendimiento que son imposibles de rastrear.

En cambio, debido a que las dependencias administradas de forma manual requieren un cambio en el control de código fuente, se pueden descubrir y revertir con facilidad, y es posible revisar 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 a escalas moderadas, la sobrecarga de la administración manual de versiones vale la pena por la estabilidad que proporciona.

La regla en una versión

Por lo general, diferentes versiones de una biblioteca están representadas por artefactos diferentes. Por lo tanto, en teoría, no hay motivo 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 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 de una versión estricta para todas las dependencias de terceros en nuestra base de código.

El mayor problema con permitir múltiples versiones es el problema de dependencia de diamantes. Supongamos que el destino A depende del destino B y de la v1 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. Efectivamente, nunca es seguro agregar una nueva dependencia de un destino a una biblioteca de terceros con múltiples versiones, porque cualquiera de los usuarios de ese destino ya podría depender 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, todas las dependencias existentes ya estarán en esa misma versión, por lo que pueden 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 particulares de otros artefactos del repositorio. Las herramientas de compilación como Maven o Gradle a menudo descargan de forma recurrente 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 tener que rastrear cada una de las dependencias transitivas de esa biblioteca y agregarlas de forma manual. Sin embargo, también hay una gran desventaja: como 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 diferentes versiones 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 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 dependencias transitivas. Por desgracia, no existe la 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 para esa dependencia en todo el repositorio. Por suerte, Bazel proporciona herramientas que pueden generar automáticamente ese archivo que contiene las dependencias transitivas de un conjunto de artefactos de Maven. Esta herramienta se puede ejecutar una vez para generar el archivo WORKSPACE inicial de un proyecto, y 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 tener que preocuparse por administrar las dependencias transitivas por sí mismos y puedan evitar el uso de dependencias transitivas automáticas. Esta estrategia se vuelve menos y 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 manualmente las dependencias es mucho menor que el de lidiar con los problemas causados por la administración automática de dependencias.

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

Con frecuencia, las dependencias externas las proporcionan 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 artefacto, lo que permite que otros fragmentos de código dependan de ellos 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 también genera 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 estar 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 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 tanto, si un desarrollador depende de un artefacto que otra persona compiló recientemente, el sistema de compilación lo descargará 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 a partir de 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

Según los artefactos de fuentes de terceros, es intrínsecamente riesgoso. Existe un riesgo de disponibilidad si la fuente de terceros (como un repositorio de artefactos) falla, ya que toda tu compilación podría detenerse si no puede descargar una dependencia externa. También existe un riesgo para la seguridad: si un atacante vulnera el sistema de un tercero, este podría reemplazar el artefacto referenciado por uno propio, lo que le permitirá insertar código arbitrario en tu compilación. Ambos problemas se pueden mitigar si duplicas cualquier artefacto que dependas de servidores que controlas y evitas que tu sistema de compilación acceda a repositorios de artefactos de terceros como Maven Central. La compensación es que estas duplicaciones requieren esfuerzo y mantenimiento, por lo que la elección de 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 código fuente, lo que hace que la compilación falle si se altera el artefacto. Otra alternativa que evita por completo el problema es suministrar las dependencias de tu proyecto. Cuando un proyecto suministra a 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. 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 fuente de Google. Sin embargo, esto solo funciona en Google porque el sistema de control de código fuente de la empresa está diseñado para administrar un monorepo extremadamente grande, por lo que la opción de proveedores podría no ser una opción para todas las organizaciones.