Administración de dependencias

Informar un problema . Ver fuente . Por la noche · 7.2 · 7.1 · 7.0 · 6.5 · 6.4

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 sea más difícil. Hay todo tipo de dependencias: a veces hay un depender de una tarea (por ejemplo, “enviar la documentación antes de marcar una versión como (completar”) y, a veces, depende de un artefacto (como “Necesito la última versión 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. a veces, dependes de código o datos que son propiedad de otro equipo (ya sea en tu organización o con un tercero). Pero, en cualquier caso, la idea de “I necesito eso antes de poder tener esto” es algo que aparece repetidamente en el del diseño de sistemas de compilación y la administración de dependencias es quizás el trabajo 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, con módulos que expresan dependencias entre sí a través de BUILD archivos. La organización adecuada de estos módulos y dependencias puede tener un enorme impacto tanto en el rendimiento del sistema de compilación como en la cantidad de trabajo necesario para mantener.

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 objetivo que especifica una unidad compilable, como una java_library o go_binary. En un extremo, se podría extender contenidos en un solo módulo colocando un archivo BUILD en la raíz y combinando de forma recurrente todos los archivos de origen de ese proyecto. En la otra fila extremo, casi todos los archivos fuente podrían convertirse en su propio módulo, lo que requiere que cada archivo se incluya en un archivo BUILD, cada otro archivo de los que depende.

La mayoría de los proyectos se encuentran entre estos extremos, y la elección implica para compensar el rendimiento y el mantenimiento. Usar un solo módulo para la todo un proyecto puede significar que nunca necesitarás tocar el archivo BUILD, excepto cuando se agrega una dependencia externa, pero significa que el sistema de compilación debe crear todo el proyecto de una sola vez. Esto significa que no podrá paralelizar o distribuir partes de la compilación, ni almacenar en caché partes que ya está construido. El uso de 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 cambian los archivos que hacen referencia a cuáles.

Aunque el nivel de detalle exacto varía según el idioma (y, a menudo, lenguaje), Google tiende a favorecer módulos significativamente más pequeños 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, e incluso de una red de puede tener cientos de objetivos dentro de su base de código. Para lenguajes como con una sólida noción integrada de empaquetado, cada directorio suele contiene un solo paquete, destino y archivo BUILD (Pants, otro sistema de compilación basado en Bazel, lo llama la regla 1:1:1). Lenguajes con empaquetado más débil las convenciones 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 porque tienen conducen a compilaciones distribuidas más rápido y a una necesidad menos frecuente de reconstruir los objetivos. Las ventajas se vuelven aún más convincentes después de que se hacen pruebas, objetivos más precisos implica que el sistema de compilación puede ser mucho más inteligente ejecutar solo un subconjunto limitado de pruebas que podrían verse afectados por cualquier cambio. Debido a que Google cree en los beneficios sistémicos de usar objetivos, logramos algunos avances en la mitigación del inconveniente al invertir en herramientas para administrar automáticamente 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 de los módulos

Bazel y otros sistemas de compilación permiten que cada destino especifique una visibilidad, como que determina qué otros destinos pueden depender de él. Un destino privado Solo se puede hacer referencia a este dentro de su propio archivo BUILD. Un objetivo puede otorgar permisos visibilidad para los destinos de una lista definida de forma explícita de archivos BUILD, o en el caso de la visibilidad pública, a todos los objetivos del espacio de trabajo.

Como en la mayoría de los lenguajes de programación, por lo general, es mejor minimizar la visibilidad, ya que tanto como sea posible. Generalmente, los equipos de Google hacen públicos los objetivos solo si esos objetivos 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 mantener una lista de objetivos de clientes permitidos como visibilidad de sus objetivos Cada los objetivos de implementación internos del equipo se restringirán solo a los directorios propiedad del equipo, por lo que la mayoría de los archivos BUILD tendrán solo un destino sin privada.

Administra dependencias

Los módulos deben poder referirse unos a otros. La desventaja de romper un en módulos detallados es que debes administrar las dependencias entre esos módulos (aunque las herramientas pueden ayudar a automatizar esto). Expresando estos por lo general, las dependencias terminan siendo la mayor parte del contenido de un archivo BUILD.

Dependencias internas

En un proyecto grande, dividido en módulos detallados, la mayoría de las dependencias probablemente sean internos; es decir, en otro destino definido y integrado en la misma en un repositorio de código fuente. Las dependencias internas difieren de las dependencias externas que se compilan desde la fuente en lugar de descargarse como un artefacto compilado previamente. mientras ejecutas la compilación. Esto también significa que no existe la noción de "versión" para las dependencias internas: un objetivo y todas sus dependencias internas que se compilan en la misma confirmación y revisión en el repositorio. Un problema que debería ser 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 destino de biblioteca común C. El objetivo A debería poder usar clases definidos en el destino C?

Dependencias transitivas

Figura 1. Dependencias transitivas

En lo que respecta a las herramientas subyacentes, esto no tiene ningún problema. ambos B y C se vincularán al destino A cuando se cree, de modo que cualquier símbolo definido en C son conocidos por A. Bazel permitió esto durante muchos años, pero a medida que Google crecía, comenzaste a ver problemas. Supongamos que B se refactorizó de tal manera que ya no necesario depender de C. Si luego se quitaba la dependencia de B de C, entonces A y cualquier otro el destino que usa C mediante una dependencia en B, fallaría. Efectivamente, la cobertura de un objetivo las dependencias pasaron a formar parte de su contrato público y nunca cambió. Esto significaba que las dependencias se acumulaban con el tiempo y las compilaciones en Google comenzó a ralentizarse.

Con el tiempo, Google resolvió este problema ingresando una “configuración transitiva “modo de dependencia” en Bazel. En este modo, Bazel detecta si un destino intenta hacer referencia a un símbolo sin depender directamente de él; de ser así, falla con un y un comando shell que puede usarse para insertar automáticamente 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 explícitamente sus de dependencias fue un esfuerzo de varios años, pero valió la pena. Nuestras construcciones ahora mucho más rápido, dado que los objetivos tienen menos dependencias innecesarias los ingenieros pueden quitar dependencias que no necesitan sin preocuparse sobre romper objetivos que dependen de ellos.

Como es habitual, aplicar dependencias transitivas estrictas implicaba un equilibrio. Hizo que archivos de compilación más detallados, ya que ahora se deben enumerar las bibliotecas de uso frecuente de forma explícita en muchos lugares, en lugar de extraerse accidentalmente, y los ingenieros necesario para dedicar más esfuerzo a agregar dependencias a los archivos BUILD. Desde entonces, desarrollaron herramientas que reducen este trabajo repetitivo detectando automáticamente muchos las dependencias y agregarlas a un archivo BUILD sin ningún desarrollador para la intervención y administración. Pero incluso sin tales herramientas, descubrimos que el sacrificio está bien. vale la pena a medida que se escala la base de código: se agrega 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 aquellos en artefactos que se compilan y almacenan fuera del sistema de compilación. El una dependencia se importa directamente desde un repositorio de artefactos (a los que se accede a través de Internet) y se usan como están, en lugar de compilarse a partir de la fuente. Uno de La diferencia más grande entre las dependencias internas y externas las dependencias externas tienen versiones, y esas versiones existen independientemente de el 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 dependencias externas de forma manual o automática. Cuando se administra de forma manual, el archivo muestra explícitamente la versión que quiere descargar del repositorio de artefactos a menudo, con una cadena de versión semántica, como como 1.1.4. Cuando se administra automáticamente, el archivo fuente especifica un rango versiones aceptables y el sistema de compilación siempre descarga la más reciente. Para 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 sea aceptable siempre que el versión principal es 1.

Las dependencias administradas automáticamente pueden ser convenientes para proyectos pequeños, pero por lo general, son una receta para los desastres en proyectos de tamaño no trivial o que en las que está trabajando más de un ingeniero. El problema de la configuración automática las dependencias administradas es que no tienes control sobre cuándo se crea la versión se actualicen. No hay forma de garantizar que terceros no tomen decisiones actualizaciones (incluso cuando dicen usar control de versiones semántico), por lo que trabajado un día podría fallar al siguiente, y no hay una manera fácil de detectar qué cambió o revertirla a un estado operativo. Incluso si la compilación no falla, puede pueden ser cambios sutiles en el comportamiento o el rendimiento que son imposibles de rastrear.

En cambio, debido a que las dependencias administradas manualmente requieren un cambio en la fuente tienen más control, pueden descubrirse y revertirse fácilmente, y es posible revisa una versión anterior del repositorio para compilar con dependencias antiguas. Bazel requiere que las versiones de todas las dependencias se especifiquen manualmente. En las a escalas moderadas, la sobrecarga de la administración manual de versiones vale la pena y 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 motivo para que diferentes versiones de la misma la dependencia no se pudo declarar 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 Regla de una versión para todas las dependencias de terceros en nuestra base de código.

El mayor problema de permitir varias versiones es la dependencia del diamante problema. Supongamos que el objetivo A depende del objetivo B y de v1 de una dirección biblioteca. Si posteriormente el destino B se refactoriza para agregar una dependencia en la v2 del mismo externa, el destino A no funcionará porque ahora depende implícitamente de dos diferentes versiones de la misma biblioteca. Nunca es seguro agregar una nueva dependencia de un destino a cualquier biblioteca de terceros con varias versiones porque cualquiera de los usuarios de ese objetivo podría depender de un proveedor de servicios versión. Seguir la regla de una versión imposibilita este conflicto (si una “target” agrega una dependencia en una biblioteca de terceros, cualquier dependencia existente ya estarán en esa versión, por lo que pueden coexistir sin problemas.

Dependencias externas transitivas

Tratar las dependencias transitivas de una dependencia externa particularmente difícil. Muchos repositorios de artefactos, como Maven Central, permiten artefactos para especificar dependencias en versiones particulares de otros artefactos en del repositorio. Las herramientas de compilación como Maven o Gradle suelen descargar cada una dependencia transitiva de forma predeterminada, lo que significa que agregar una sola dependencia en tu proyecto podría hacer que se descarguen docenas de artefactos en total.

Esto resulta muy conveniente: al agregar una dependencia en una biblioteca nueva, sería es un gran inconveniente tener que rastrear cada una de las dependencias transitivas de esa biblioteca y agregarlos manualmente. Pero también hay una gran desventaja: las bibliotecas pueden depender de diferentes versiones de la misma biblioteca de terceros, esto necesariamente infringe la regla de una versión y lleva al diamante problema de dependencia. Si tu destino depende de dos bibliotecas externas que usan diferentes versiones de la misma dependencia, no hay manera de saber con cuál que puedes obtener. Esto también significa que actualizar una dependencia externa podría causar fallas no relacionadas en la base de código si la nueva versión comienza a incorporarse versiones conflictivas de algunas de sus dependencias.

Por este motivo, Bazel no descarga dependencias transitivas automáticamente. Y, lamentablemente, no hay soluciones milagrosas: la alternativa de Bazel es exigir un global que enumera cada una de las aplicaciones externas del repositorio las dependencias y una versión explícita que se usa para esa dependencia en todo en un repositorio de confianza. Por suerte, Bazel proporciona herramientas que pueden mostrar automáticamente generar un archivo que contenga las dependencias transitivas de un conjunto de artefactos. Esta herramienta se puede ejecutar una vez para generar el archivo WORKSPACE inicial de un proyecto, y ese archivo se puede actualizar manualmente para ajustar las versiones de cada dependencia.

Sin embargo, una vez más, la elección es entre conveniencia y escalabilidad. Pequeño es posible que los proyectos prefieran no tener que preocuparse por administrar dependencias transitivas y podrían dejar de usar las funciones dependencias. Esta estrategia se vuelve cada vez menos atractiva a medida que la organización y la base de código crece, y los conflictos y resultados inesperados se vuelven frecuentes. A gran escala, el costo de administrar dependencias manualmente es mucho son menores que el costo de lidiar con los problemas causados por la dependencia automática y administración de posturas.

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

Las dependencias externas suelen ser proporcionadas por terceros que liberan estables de bibliotecas, quizás sin proporcionar código fuente. Algunos las organizaciones también pueden elegir que parte de su propio código esté disponible como artefactos, lo que permite que otros fragmentos de código dependan de ellos como terceros en lugar de que las dependencias internas. Esto puede acelerar, en teoría, las compilaciones si se detecta son lentos para compilar, pero se descargan rápido.

Sin embargo, esto también supone mucha sobrecarga y complejidad: alguien necesita será responsable de crear cada uno de esos artefactos y subirlos al de artefactos, y los clientes deben asegurarse de estar actualizados la última versión. La depuración también se vuelve mucho más difícil porque partes del sistema se hayan compilado a partir de diferentes puntos del en un repositorio de código fuente 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 construirse es usar un sistema de compilación que admita el almacenamiento en caché remoto, como se describió anteriormente. Este el sistema de compilación guarda los artefactos resultantes de cada compilación en una ubicación que se comparte entre los ingenieros, de modo que si un desarrollador depende de un artefacto que que otra persona haya creado recientemente, el sistema de compilación descarga automáticamente en lugar de compilarlo. Esto proporciona todos los beneficios de rendimiento de depender directamente de los artefactos y, al mismo tiempo, asegurarse de que las compilaciones sean coherentes como si siempre se hubieran creado a partir de la misma fuente. Este es el que usa Google internamente, y Bazel puede configurarse para que use un servidor la caché.

Seguridad y confiabilidad de las dependencias externas

Depender de artefactos de fuentes externas es intrínsecamente riesgoso. Hay un riesgo de disponibilidad si la fuente de terceros (como un repositorio de artefactos) se ya que toda la compilación podría detenerse si no se puede descargar una dependencia externa. También existe un riesgo de seguridad: si el sistema de terceros vulnerado por un atacante, este podría reemplazar la referencia con uno propio con su propio diseño, lo que les permite inyectar en tu compilación. Ambos problemas se pueden mitigar duplicando cualquier artefacto dependen de los servidores que controlas y que bloquean el acceso de tu sistema de compilación a repositorios de artefactos de terceros, como Maven Central. La desventaja es que estos espejos requieren esfuerzo y recursos para mantenerlos, por lo que la decisión de si usarlos a menudo depende de la escala del proyecto. El problema de seguridad también puede puede evitarse por completo con poca sobrecarga, ya que solicita el hash de cada que un artefacto de terceros se especifique en el repositorio de código fuente, lo que hace que la compilación a fallar si se manipuló el artefacto. Otra alternativa que completamente el problema es proveer las dependencias de tu proyecto. Cuando un proyecto sus dependencias, las verifica en el control de código fuente junto con el del código fuente del proyecto, ya sea como fuente o como objetos binarios. Esto significa efectivamente que todas las dependencias externas del proyecto se conviertan en dependencias dependencias. Google usa este enfoque internamente y verifica cada biblioteca 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 la el sistema de control de origen está diseñado para manejar un monorepo grande. la creación de proveedores de productos podría no ser una opción para todas las organizaciones.