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 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 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 código o datos que pertenecen a otro equipo (ya sea en tu organización o en un tercero). Pero, en cualquier caso, la idea de “Necesito eso antes de poder tener esto” es algo que se repite de forma reiterada en el diseño de los sistemas de compilación, y la administración de dependencias es quizás la tarea 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, 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 efecto enorme en el rendimiento del sistema de compilación y en la cantidad de trabajo que se necesita para mantenerlo.
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 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, 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 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 una compensación entre el rendimiento y la capacidad de 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). 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, ya que generan compilaciones distribuidas más rápidas y una necesidad menos frecuente de volver a compilar los objetivos.
Las ventajas se vuelven aún más convincentes después de que las pruebas entran en juego, ya que los objetivos de mayor precisión 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 cualquier cambio determinado. Debido a que Google cree en los beneficios sistémicos de usar objetivos más pequeños, hemos avanzado en la mitigación de las desventajas invirtiendo en herramientas para administrar automáticamente los archivos BUILD
y evitar sobrecargar 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 visibilidad más amplia a los objetivos de una lista definida explícitamente de archivos BUILD
o, en el caso de la visibilidad pública, a todos los objetivos del espacio de trabajo.
Al igual que con la mayoría de los lenguajes de programación, lo mejor es minimizar la visibilidad lo
más posible. Por lo general, los equipos de Google solo publican los objetivos si estos representan bibliotecas de uso general disponibles para cualquier equipo de Google.
Los equipos que requieran que otras personas se coordinen con ellos antes de usar su código mantendrán una lista de entidades permitidas de objetivos de clientes como visibilidad de su objetivo. Los objetivos de implementación internos de cada equipo se restringirán solo a los directorios que sean propiedad del equipo, y la mayoría de los archivos BUILD
tendrán solo un objetivo que no sea privado.
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 externas en que se compilan desde la fuente en lugar de descargarse como artefacto precompilado mientras se ejecuta la compilación. Esto también significa que no hay 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 del 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 objetivo de biblioteca común C. El objetivo A debería poder usar clases definidos en el destino C?
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, comenzamos a ver problemas. Supongamos que B se refactorizó, de modo que ya no necesario depender de C. Si se quitara la dependencia de B en C, A y cualquier otro objetivo que use C a través de una dependencia en B fallaría. En efecto, las dependencias de un objetivo se convirtieron en parte de su contrato público y nunca se pudieron cambiar de forma segura. 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 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, 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 explícitamente sus de 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 de costumbre, aplicar 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 tuvieron que esforzarse más para 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 del usuario. 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 el código Java de forma predeterminada.
Dependencias externas
Si una dependencia no es interna, debe ser externa. Las dependencias externas son aquellas que se basan 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 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 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 suelen ser un 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 que generen errores (incluso cuando afirman usar el control de versiones semánticas), por lo que una compilación que funcionó un día podría fallar al día siguiente sin una forma sencilla de detectar qué cambió o de revertirla a un estado funcional. Incluso si la compilación no falla, puede haber cambios sutiles de comportamiento o rendimiento que son imposibles de rastrear.
Por el contrario, como las dependencias administradas de forma manual requieren un cambio en el control de código fuente, se pueden descubrir y revertir fácilmente, y es posible consultar una versión anterior del repositorio para compilar con dependencias anteriores. 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 quería usar. Esto causa muchos problemas en la práctica, por lo que Google aplica una estricta 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 el problema de dependencia de diamante. Supongamos que el destino A depende del destino 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 del mismo externa, el destino A no funcionará porque ahora depende implícitamente de dos diferentes versiones de la misma biblioteca. En realidad, 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 podría depender de una versión diferente. Si se sigue la regla de una versión, este conflicto es imposible. Si un objetivo agrega una dependencia a una biblioteca de terceros, las dependencias existentes ya estarán en esa misma versión, por lo que pueden coexistir.
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 de forma recursiva cada dependencia transitiva de forma predeterminada, lo que significa que agregar una sola dependencia en tu proyecto podría provocar que se descarguen decenas de artefactos en total.
Esto es muy conveniente: cuando se agrega una dependencia a una biblioteca nueva, sería un gran problema tener que hacer un seguimiento de cada una de las dependencias transitivas de esa biblioteca y agregarlas todas de forma manual. 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.
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 use para esa dependencia en todo el repositorio. Por fortuna, Bazel proporciona herramientas que pueden generar automáticamente un archivo de este tipo 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
para 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. Pequeño es posible que los proyectos prefieran no tener que preocuparse por administrar dependencias transitivas y podrían dejar de usar las funciones las dependencias. Esta estrategia se vuelve cada vez 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 escalas más grandes, el costo de administrar dependencias de forma manual es mucho menor que el costo de abordar los problemas causados por la administración automática de dependencias.
Almacenamiento en caché de resultados de compilación con dependencias externas
Por lo general, las dependencias externas son proporcionadas por terceros que lanzan versiones 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. 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 al día con la versión más reciente. 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. Existe un riesgo de disponibilidad si la fuente de terceros (como un repositorio de artefactos) falla, 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 de terceros, 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 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 vende sus dependencias, las incluye en el control de código fuente junto con el código fuente del proyecto, ya sea como fuente o como objetos binarios. Esto significa, en la práctica, 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 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.