Administración de dependencias

Cuando exploras 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 (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 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 detallados 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 contener un solo módulo si se coloca un archivo BUILD en la raíz y se globalizan de forma recurrente todos los archivos fuente de ese proyecto. En el otro extremo, casi todos los archivos de origen podrían convertirse en su propio módulo, lo que requiere que cada archivo se incluya en una lista de 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 opuesto: 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 por mantener las listas de dependencias cada vez que cambian los archivos a los que hacen referencia.

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 de los que normalmente se escribirían 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 poseer varios cientos de objetivos dentro de su base de código. En el caso de 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 esta regla de 1:1:1). Los lenguajes con convenciones de empaquetado menos seguras 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 conducen a 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 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ía verse afectado por cualquier cambio determinado. Dado que Google cree en los beneficios sistémicos de usar objetivos más pequeños, mitigamos algunos avances al invertir en herramientas para administrar automáticamente los archivos BUILD a fin de evitar abrumar a los desarrolladores.

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, 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, de cada destino del lugar de trabajo.

Al igual que con la mayoría de los lenguajes de programación, suele ser mejor minimizar la visibilidad tanto como sea posible. En general, los equipos de Google hacen que los objetivos sean públicos solo si esos objetivos representan bibliotecas muy usadas 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 del objetivo. 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 un solo destino que no sea privado.

Administra dependencias

Los módulos deben poder referirse a los demás. La desventaja de dividir una base de código en módulos precisos es que debes administrar las dependencias entre esos módulos (aunque las herramientas pueden ayudar a automatizar esto). Por lo general, la expresión de estas dependencias termina 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 en que se compilan a partir de 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, ya que 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 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 depende de un destino C de una biblioteca común. ¿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 todos los símbolos definidos en C se conocen. 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 necesita depender de C. Si luego se quitara la dependencia de B en C, se dañaría A y cualquier otro destino que usara C mediante una dependencia en B. En efecto, las dependencias de un destino pasaron a formar parte de su contrato público y nunca podrían cambiarse de forma segura. Esto significa que las dependencias se acumularon con el tiempo y que las compilaciones de Google comenzaron 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. 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 ellos.

Como de costumbre, la aplicación forzosa de dependencias transitivas estrictas implicaba un equilibrio. Hizo que los archivos de compilación fueran más detallados, ya que las bibliotecas que se usan con frecuencia ahora deben enumerarse de forma explícita en muchos lugares en lugar de extraerse por accidente, y los ingenieros debían esforzarse más en 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 agregamos 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 explícitamente 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 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.

Administración de dependencias automática frente a administración 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 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 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 automáticamente pueden ser convenientes para proyectos pequeños, pero, por lo general, generan desastres en proyectos de tamaño no trivial 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 trabajo. Incluso si la compilación no se rompe, puede haber cambios sutiles en el comportamiento o el rendimiento que es imposible de rastrear.

Por el contrario, debido a que las dependencias administradas manualmente requieren un cambio en el control de la fuente, se pueden detectar y revertir con facilidad, y es posible revisar una versión anterior del repositorio para compilar con dependencias más antiguas. 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 de una versión

Por lo general, las diferentes versiones de una biblioteca se representan con artefactos diferentes, por lo que, 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 nombres diferentes. De esa manera, cada destino podría elegir qué versión de la dependencia quería 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 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 tarde para agregar una dependencia en la versión 2 de la misma biblioteca externa, el destino A se interrumpirá 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 cualquiera de los usuarios de ese destino dependa 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 felizmente.

Dependencias externas transitivas

Tratar con 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 en el repositorio. Las herramientas de compilación como Maven o Gradle suelen descargar recursivamente 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 todas de forma manual. Sin embargo, también presenta una gran desventaja: debido a 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 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 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. Desafortunadamente, no hay una solución milagrosa: 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 usada 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 contiene las dependencias transitivas de un conjunto de artefactos de Maven. Esta herramienta se puede ejecutar una vez con el objetivo de 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, aquí se debe elegir entre conveniencia y escalabilidad. Es posible que los proyectos pequeños prefieran no tener que preocuparse por administrar las dependencias transitivas y que puedan dejar de usar las 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 los resultados inesperados se vuelven cada vez más frecuentes. A mayor escala, el costo de la administración manual de dependencias es mucho menor que el de abordar los problemas causados por la administración automática de dependencias.

Almacena en caché los resultados de compilación mediante 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 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 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 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, ya que 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 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 compiló otra persona 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 desde 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 las dependencias externas

La dependencia de artefactos de fuentes de terceros es inherentemente riesgosa. 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 vulnera el sistema de terceros, 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 de los que dependes en los servidores que controlas y bloqueas el acceso de tu sistema de compilación a repositorios de artefactos de terceros, como Maven Central. La desventaja es que estos duplicados requieren esfuerzo y recursos de mantenimiento, por lo que la elección de si usarlos 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 solicita que el hash de cada artefacto de terceros se especifique 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 el problema es proveer las dependencias de tu proyecto. Cuando un proyecto suministra sus dependencias, las registra en el control de 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 internamente 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 su sistema de control de origen está creado a medida para manejar un monorepo extremadamente grande, por lo que podría no ser una opción para todas las organizaciones.