Este documento es una descripción de la base de código y cómo se estructura Bazel. Está dirigido a personas dispuestas a contribuir a Bazel, no a usuarios finales.
Introducción
La base de código de Bazel es grande (código de producción de ~350,000 KLOC y código de prueba de alrededor de 260 KLOC) y nadie conoce el panorama completo: todos conocen su valle en particular, pero pocos saben lo que se encuentra en las colinas de cada dirección.
En este documento, se intenta ofrecer una descripción general de la base de código para que sea más fácil comenzar a trabajar en ella a fin de que las personas en la mitad del viaje no se encuentren en la oscuridad de un bosque con la ruta sencilla que se perdió.
La versión pública del código fuente de Bazel se encuentra en GitHub, en github.com/bazelbuild/bazel. Esta no es la "fuente de confianza", sino que se deriva de un árbol de fuentes internas de Google que contiene funciones adicionales que no son útiles fuera de Google. El objetivo a largo plazo es que GitHub sea la fuente de información.
Las contribuciones se aceptan a través del mecanismo normal de solicitud de extracción de GitHub, se importan de forma manual a un Googler en el árbol de fuentes internas y, luego, se vuelven a exportar a GitHub.
Arquitectura del cliente/servidor
La mayor parte de Bazel reside en un proceso del servidor que permanece en la memoria RAM entre las compilaciones. Esto permite que Bazel mantenga el estado entre compilaciones.
Es por eso que la línea de comandos de Bazel tiene dos tipos de opciones: inicio y comando. En una línea de comandos como esta:
bazel --host_jvm_args=-Xmx8G build -c opt //foo:bar
Algunas opciones (--host_jvm_args=
) están antes del nombre del comando que se ejecuta y otras son después de (-c opt
); el tipo anterior se llama "opción de inicio" y afecta al proceso del servidor en su totalidad, mientras que el segundo tipo, la "opción de comando", solo afecta a un solo comando.
Cada instancia de servidor tiene un solo árbol de fuentes asociado ("workspace") y cada lugar de trabajo suele tener una única instancia de servidor activa. Para evitarlo, especifica una base de salida personalizada (consulta la sección "Diseño de directorio" para obtener más información).
Bazel se distribuye como un único ejecutable de ELF, que también es un archivo ZIP válido.
Cuando escribes bazel
, el ejecutable de ELF anterior implementado en C++ (el "cliente") obtiene el control. Configura un proceso adecuado del servidor mediante los siguientes pasos:
- Comprueba si ya se extrajo. De lo contrario, lo hace. Aquí es donde proviene la implementación del servidor.
- Comprueba si hay una instancia de servidor activa que funcione: en ejecución, con las opciones de inicio correctas y con el directorio de lugar de trabajo correcto. Para encontrar el servidor en ejecución, observa el directorio
$OUTPUT_BASE/server
, en el que hay un archivo de bloqueo con el puerto en el que escucha el servidor. - Si es necesario, finaliza el proceso del servidor anterior
- Si es necesario, inicia un nuevo proceso del servidor.
Una vez que un proceso de servidor adecuado está listo, el comando que debe ejecutarse se comunica a través de una interfaz de gRPC y, luego, el resultado de Bazel se canaliza de nuevo a la terminal. Solo se puede ejecutar un comando a la vez. Esto se implementa mediante un mecanismo de bloqueo elaborado con partes en C++ y partes en Java. Hay cierta infraestructura para ejecutar varios comandos en paralelo, ya que la incapacidad de ejecutar bazel version
en paralelo con otro comando es algo vergonzoso. El bloqueador principal es el ciclo de vida de los BlazeModule
y algún estado en BlazeRuntime
.
Al final de un comando, el servidor de Bazel transmite el código de salida que debe mostrar el cliente. Una arruga interesante es la implementación de bazel run
: el
trabajo de este comando es ejecutar algo que Bazel acaba de compilar, pero no puede hacer eso desde el proceso del servidor porque no tiene una terminal. Por lo tanto, le indica al cliente qué objeto binario debe ujexec()
y con qué argumentos.
Cuando se presiona Ctrl-C, el cliente lo traduce a una llamada de cancelación en la conexión de gRPC, que intenta finalizar el comando lo antes posible. Después del tercer Ctrl-C, el cliente envía una SIGKILL al servidor.
El código fuente del cliente se encuentra en src/main/cpp
, y el protocolo que se usa para comunicarse con el servidor está en src/main/protobuf/command_server.proto
.
El punto de entrada principal del servidor es BlazeRuntime.main()
, y GrpcServerImpl.run()
maneja las llamadas de gRPC del cliente.
Diseño del directorio
Bazel crea un conjunto algo complejo de directorios durante una compilación. Puedes encontrar una descripción completa en el diseño del directorio de salida.
El “lugar de trabajo” es el árbol fuente en el que se ejecuta Bazel. Por lo general, corresponde a algo que verificaste desde el control del código fuente.
Bazel coloca todos sus datos en la “raíz del usuario de salida”. Por lo general, es $HOME/.cache/bazel/_bazel_${USER}
, pero se puede anular con la opción de inicio --output_user_root
.
La “base de instalaciones” es el lugar donde se extrae Bazel. Esto se hace automáticamente,
y cada versión de Bazel obtiene un subdirectorio basado en su suma de verificación en la
base de instalaciones. Se encuentra en $OUTPUT_USER_ROOT/install
de forma predeterminada y se puede cambiar con la opción de línea de comandos --install_base
.
La “base de salida” es el lugar en el que la instancia de Bazel adjunta a un lugar de trabajo
específico escribe. Cada base de salida tiene como máximo una instancia de servidor de Bazel
que se ejecuta en cualquier momento. Por lo general, es a las $OUTPUT_USER_ROOT/<checksum of the path
to the workspace>
. Se puede cambiar con la opción de inicio --output_base
,
que es útil, entre otras cosas, para evitar que solo
se pueda ejecutar una instancia de Bazel en cualquier lugar de trabajo en cualquier momento.
El directorio de salida contiene, entre otros elementos, lo siguiente:
- Los repositorios externos recuperados en
$OUTPUT_BASE/external
. - La raíz de ejecución, un directorio que contiene symlinks a todo el código fuente de la compilación actual. Se encuentra en
$OUTPUT_BASE/execroot
. Durante la compilación, el directorio de trabajo es$EXECROOT/<name of main repository>
. Tenemos pensado cambiar esto a$EXECROOT
, aunque es un plan a largo plazo porque es un cambio muy incompatible. - Archivos compilados durante la compilación.
El proceso para ejecutar un comando
Una vez que el servidor de Bazel obtiene el control y se le informa sobre un comando que necesita ejecutar, ocurre la siguiente secuencia de eventos:
Se informó a
BlazeCommandDispatcher
sobre la nueva solicitud. Decide si el comando necesita un lugar de trabajo para ejecutarse (casi todos los comandos, excepto los que no tienen nada que ver con el código fuente, como la versión o la ayuda) y si se ejecuta otro comando.Se encontró el comando correcto. Cada comando debe implementar la interfaz
BlazeCommand
y debe tener la anotación@Command
(esto es un poco de antipatrón; sería bueno si todos los metadatos que necesitara un comando fueran descritos por métodos enBlazeCommand
)Se analizan las opciones de la línea de comandos. Cada comando tiene diferentes opciones de línea de comandos, que se describen en la anotación
@Command
.Se crea un bus de eventos. El bus de eventos es una transmisión para los eventos que ocurren durante la compilación. Algunos de estos se exportan al exterior de Bazel bajo el egisto del protocolo de eventos de compilación para indicarle al mundo cómo va la compilación.
El comando obtiene el control. Los comandos más interesantes son los que ejecutan una compilación: compilación, prueba, ejecución, cobertura, etc.
BuildTool
implementa esta funcionalidad.Se analiza el conjunto de patrones de destino de la línea de comandos y se resuelven los comodines como
//pkg:all
y//pkg/...
. Esto se implementa enAnalysisPhaseRunner.evaluateTargetPatterns()
y se reforma en Skyframe comoTargetPatternPhaseValue
.La fase de carga y análisis se ejecuta a fin de producir el grafo de acción (un grafo acíclico dirigido de comandos que se debe ejecutar para la compilación).
Se ejecuta la fase de ejecución. Esto significa que se ejecutan todas las acciones necesarias para compilar los destinos de nivel superior solicitados.
Opciones de línea de comandos
Las opciones de línea de comandos para una invocación de Bazel se describen en un
objeto OptionsParsingResult
, que, a su vez, contiene un mapa de “clases
de opciones” a los valores de las opciones. Una "clase de opción" es una subclase de OptionsBase
y agrupa opciones de línea de comandos que están relacionadas entre sí. Por ejemplo:
- Opciones relacionadas con un lenguaje de programación (
CppOptions
oJavaOptions
). Estas deben ser una subclase deFragmentOptions
y, finalmente, se unen a un objetoBuildOptions
. - Opciones relacionadas con la forma en que Bazel ejecuta acciones (
ExecutionOptions
)
Estas opciones están diseñadas para consumirse en la fase de análisis y (a través de RuleContext.getFragment()
en Java o ctx.fragments
en Starlark).
Algunos de ellos (por ejemplo, si hacer que C++ incluya el análisis o no) se leen en la fase de ejecución, pero eso siempre requiere plomería explícita, ya que BuildConfiguration
no está disponible en ese momento. Para obtener más información, consulta la sección “Configuraciones”.
ADVERTENCIA: Queremos simular que las instancias de OptionsBase
son inmutables y
usarlas de esa manera (como una parte de SkyKeys
). Este no es el caso, y
modificarlas es una excelente manera de interrumpir Bazel de formas sutiles y difíciles de depurar. Lamentablemente, hacerlos inmutables es un gran esfuerzo.
Modificar un FragmentOptions
inmediatamente después de la construcción antes de que otra persona tenga la oportunidad de mantener una referencia a ella y antes de que se llame a equals()
o hashCode()
Bazel aprende sobre las clases de opciones de las siguientes maneras:
- Algunos están conectados con cable a Bazel (
CommonCommandOptions
) - A partir de la anotación
@Command
en cada comando de Bazel - Desde
ConfiguredRuleClassProvider
(estas son opciones de línea de comandos relacionadas con lenguajes de programación individuales) - Las reglas de Starlark también pueden definir sus propias opciones (consulta aquí).
Cada opción (excepto las opciones definidas por Starlark) es una variable de miembro de una subclase FragmentOptions
que tiene la anotación @Option
, que especifica el nombre y el tipo de la opción de línea de comandos junto con un texto de ayuda.
Por lo general, el tipo de Java del valor de una opción de línea de comandos es algo simple (una string, un número entero, un valor booleano, una etiqueta, etcétera). Sin embargo, también admitimos opciones de tipos más complicados; en este caso, el trabajo de conversión de la string de línea de comandos al tipo de datos cae en una implementación de com.google.devtools.common.options.Converter
.
El árbol de fuentes, como se ve en Bazel
Bazel trabaja en la compilación de software, que se lleva a cabo mediante la lectura y la interpretación del código fuente. La totalidad del código fuente en el que opera Bazel se denomina “el lugar de trabajo” y se estructura en repositorios, paquetes y reglas.
Repositorios
Un "repositorio" es un árbol de fuentes en el que trabaja un desarrollador. Por lo general, representa un solo proyecto. Blaze, el principal de Bazel, operaba en un monorepo, es decir, un solo árbol de fuentes que contiene todo el código fuente que se usa para ejecutar la compilación. Por el contrario, Bazel admite proyectos cuyo código fuente abarca varios repositorios. El repositorio desde el que se invoca Bazel se denomina "repositorio principal", y los otros se llaman "repositorios externos".
Un repositorio está marcado con un archivo llamado WORKSPACE
(o WORKSPACE.bazel
) en su directorio raíz. Este archivo contiene información que es "global" para toda la compilación, por ejemplo, el conjunto de repositorios externos disponibles. Funciona como un archivo Starlark normal, lo que significa que se pueden load()
otros archivos Starlark.
Por lo general, se usa para extraer los repositorios que necesita un repositorio al que se hace referencia de manera explícita (lo llamamos "patrón deps.bzl
").
El código de los repositorios externos tiene un symlink o se descarga en $OUTPUT_BASE/external
.
Cuando se ejecuta la compilación, se debe unir todo el árbol de fuentes. SymlinkForest
$EXECROOT
$EXECROOT/external
$EXECROOT/..
external
Paquetes
Cada repositorio se compone de paquetes, una colección de archivos relacionados y una especificación de las dependencias. Esto se especifica mediante un archivo llamado BUILD
o BUILD.bazel
. Si ambos existen, Bazel prefiere BUILD.bazel
. El motivo
por el que se siguen aceptando los archivos BUILD
es que el principal de Bazel, Blaze, usó este
nombre de archivo. Sin embargo, resultó ser un segmento de ruta de acceso de uso frecuente, especialmente en Windows, en el que los nombres de archivos no distinguen mayúsculas de minúsculas.
Los paquetes son independientes entre sí: los cambios en el archivo BUILD
de un paquete no pueden causar que cambien otros paquetes. La adición o eliminación de archivos BUILD
_puede _cambiar otros paquetes, ya que los globs recurrentes se detienen en los límites del paquete y, por lo tanto, la presencia de un archivo BUILD
detiene la recursión.
La evaluación de un archivo BUILD
se denomina “carga de paquetes”. Se implementa en la clase PackageFactory
, funciona llamando al intérprete de Starlark y requiere conocimiento del conjunto de clases de reglas disponibles. El resultado de la carga de un paquete es un objeto Package
. Es mayormente un mapa de una string (el nombre de un objetivo) al propio destino.
Un gran fragmento de complejidad durante la carga del paquete es global: Bazel no
requiere que se enumere cada archivo de origen de forma explícita y, en su lugar, puede ejecutar globs
(como glob(["**/*.java"])
). A diferencia de la shell, admite globs recursivos que
descenden en subdirectorios (pero no en subpaquetes). Esto requiere acceso al sistema de archivos y, como puede ser lento, implementamos todo tipo de trucos para que se ejecute en paralelo y de la manera más eficiente posible.
El Globbing se implementa en las siguientes clases:
LegacyGlobber
, un globo rápido y feliz que no sabe del SkyframeSkyframeHybridGlobber
, una versión que usa Skyframe y vuelve al globo terráqueo heredado para evitar los "reinicios del Skyframe" (como se describe a continuación)
La clase Package
contiene algunos miembros que se usan exclusivamente para analizar el archivo WORKSPACE y que no tiene sentido para paquetes reales. Esta es una falla de diseño porque los objetos que describen paquetes regulares no deben contener campos que describan algo más. Incluye las siguientes herramientas:
- Las asignaciones de repositorios
- Las cadenas de herramientas registradas
- Las plataformas de ejecución registradas
Lo ideal sería que haya más separación entre el análisis del archivo WORKSPACE y el análisis de paquetes regulares, de modo que Package
no necesite satisfacer las necesidades de ambos. Lamentablemente, esto es difícil porque ambos están entrelazados bastante.
Etiquetas, objetivos y reglas
Los paquetes se componen de objetivos que tienen los siguientes tipos:
- Archivos: Son elementos de entrada o salida de la compilación. En la terminología de Bazel, los llamamos artefactos (que se analizan en otro lugar). No todos los archivos creados durante la compilación son destinos; es común que un resultado de Bazel no tenga una etiqueta asociada.
- Reglas: estas describen los pasos para derivar los resultados de sus entradas. Por lo general, se asocian con un lenguaje de programación (como
cc_library
,java_library
opy_library
), pero hay algunos que son independientes del lenguaje (comogenrule
ofilegroup
). - Grupos de paquetes: Se analizan en la sección Visibilidad.
El nombre de un destino se denomina Etiqueta. La sintaxis de las etiquetas es @repo//pac/kage:name
, en la que repo
es el nombre del repositorio en el que se encuentra la etiqueta, pac/kage
es el directorio en el que se encuentra el archivo BUILD
y name
es la ruta de acceso del archivo (si la etiqueta hace referencia a un archivo fuente) en relación con el directorio del paquete. Cuando se hace referencia a un destino en la línea de comandos, se pueden omitir algunas partes de la etiqueta:
- Si se omite el repositorio, se toma la etiqueta para estar en el repositorio principal.
- Si se omite la parte del paquete (como
name
o:name
), se toma la etiqueta para estar en el paquete del directorio de trabajo actual (no se permiten las rutas de acceso relativas que contienen referencias de nivel superior [...]).
Una clase de regla (como "biblioteca C++") se denomina "clase de regla". Las clases de reglas se pueden implementar en Starlark (la función rule()
) o en Java (denominadas “reglas nativas”) de tipo RuleClass
. A largo plazo, cada regla específica del lenguaje se implementará en Starlark, pero, por el momento, algunas familias de reglas heredadas (como Java o C++).
Las clases de reglas de Starlark deben importarse al comienzo de los archivos BUILD
mediante la declaración load()
, mientras que Bazel conoce la clase de regla de Java "por naturaleza", ya que está registrada en ConfiguredRuleClassProvider
.
Las clases de reglas contienen la siguiente información:
- Sus atributos (como
srcs
,deps
): sus tipos, valores predeterminados, restricciones, etcétera - Las transiciones de configuración y los aspectos adjuntos a cada atributo, si corresponde
- La implementación de la regla
- Los proveedores de información transitiva que crea la regla "por lo general"
Nota de terminología: En la base de código, a menudo usamos “Rule” para hacer referencia al objetivo creado por una clase de regla. Sin embargo, en Starlark y en la documentación orientada al usuario, "Rule" se debe usar de forma exclusiva para hacer referencia a la clase de regla; el objetivo es solo un "objetivo". Además, ten en cuenta que, a pesar de que RuleClass
tiene la "clase" en su nombre, no hay relación de herencia de Java entre una clase de regla y objetivos de ese tipo.
Skyframe
El framework de evaluación subyacente de Bazel se llama Skyframe. Su modelo es que todo lo que se debe compilar durante una compilación se organiza en un grafo acíclico dirigido con bordes que apunta desde cualquier dato a sus dependencias, es decir, otros datos que se deben construir.
Los nodos del gráfico se llaman SkyValue
y sus nombres se llaman SkyKey
. Ambos son profundamente inmutables; solo se debe poder acceder a los objetos inmutables desde ellos. Esta invariable casi siempre es válida y, en caso de que no sea así, como en las clases de opciones individuales BuildOptions
, que es miembro de BuildConfigurationValue
y su SkyKey
, hacemos todo lo posible para no cambiarlas o cambiarlas de formas que no sean observables desde el exterior.
De esto se deduce que todo lo que se calcula dentro de Skyframe (como los destinos configurados) también debe ser inmutable.
La forma más conveniente de observar el gráfico de Skyframe es ejecutar bazel dump
--skyframe=deps
, que vuelca el gráfico, uno SkyValue
por línea. Es mejor hacerlo para compilaciones pequeñas, ya que puede ser bastante grande.
Skyframe se encuentra en el paquete com.google.devtools.build.skyframe
. El paquete com.google.devtools.build.lib.skyframe
, cuyo nombre es similar, contiene la implementación de Bazel sobre Skyframe. Obtén más información sobre Skyframe aquí.
Para evaluar un SkyKey
determinado en un SkyValue
, Skyframe invocará el SkyFunction
correspondiente al tipo de clave. Durante la evaluación de la función, es posible que solicite otras dependencias a Skyframe llamando a las diversas sobrecargas de SkyFunction.Environment.getValue()
. Esto tiene el efecto secundario de registrar esas dependencias en el gráfico interno de Skyframe, de modo que este pueda volver a evaluar la función cuando cambie cualquiera de sus dependencias. En otras palabras, el almacenamiento en caché y el procesamiento incremental de Skyframe funcionan con el nivel de detalle de SkyFunction
y SkyValue
.
Cuando un objeto SkyFunction
solicita una dependencia que no está disponible, getValue()
mostrará un valor nulo. Luego, la función debe volver a mostrar el control en Skyframe y mostrar un valor nulo. En algún momento, Skyframe evaluará la dependencia no disponible y, luego, reiniciará la función desde el principio. Solo esta vez, la llamada a getValue()
se realizará correctamente y mostrará un resultado no nulo.
Una consecuencia de esto es que cualquier cálculo realizado dentro del SkyFunction
antes del reinicio debe repetirse. Sin embargo, esto no incluye el trabajo realizado para evaluar la dependencia SkyValues
, que se almacena en caché. Por lo tanto, a menudo, solucionamos este problema de las siguientes maneras:
- Declarar las dependencias en lotes (mediante
getValuesAndExceptions()
) para limitar la cantidad de reinicios - Desglosar una
SkyValue
en partes separadas calculadas por diferentesSkyFunction
para que puedan calcularse y almacenarse en caché de forma independiente Esto se debe hacer de manera estratégica, ya que puede aumentar el uso de la memoria. - Almacenar el estado entre reinicios, ya sea usando
SkyFunction.Environment.getState()
o manteniendo una caché estática ad hoc “detrás de la parte posterior de Skyframe”
Básicamente, necesitamos estos tipos de soluciones alternativas porque siempre tenemos cientos de miles de nodos Skyframe en tránsito y Java no admite subprocesos ligeros.
Starlark
Starlark es el lenguaje específico del dominio que se usa para configurar y extender Bazel. Se concibe como un subconjunto restringido de Python que tiene muchos menos tipos, más restricciones en el flujo de control y, lo que es más importante, garantías de inmutabilidad sólidas para habilitar lecturas simultáneas. No es una versión completa de Turing, que disuade a algunos (pero no a todos) a realizar tareas de programación generales en el lenguaje.
Starlark se implementa en el paquete net.starlark.java
.
También tiene una implementación independiente de Go aquí. La implementación
de Java que se usa en Bazel es actualmente un intérprete.
Starlark se usa en varios contextos, incluidos los siguientes:
- El idioma
BUILD
. Aquí es donde se definen las reglas nuevas. El código de Starlark que se ejecuta en este contexto solo tiene acceso al contenido del archivoBUILD
y a los archivos.bzl
que cargó. - Definiciones de las reglas. Así se definen las reglas nuevas (como la compatibilidad con un idioma nuevo). El código de Starlark que se ejecuta en este contexto tiene acceso a la configuración y los datos que proporcionan sus dependencias directas (más información sobre esto más adelante).
- Archivo WORKSPACE Aquí es donde se definen los repositorios externos (código que no está en el árbol de fuentes principal).
- Definiciones de las reglas del repositorio. Aquí es donde se definen los nuevos tipos de repositorios externos. El código de Starlark que se ejecuta en este contexto puede ejecutar un código arbitrario en la máquina en la que se ejecuta Bazel y llegar fuera del lugar de trabajo.
Los dialectos disponibles para archivos BUILD
y .bzl
son ligeramente diferentes porque expresan cosas diferentes. Aquí encontrarás una lista de las diferencias.
Obtén más información sobre Starlark aquí.
La fase de carga o análisis
En la fase de carga o análisis, Bazel determina qué acciones se necesitan para compilar una regla en particular. Su unidad básica es un “destino configurado”, que es, casi sin problema, un par (objetivo, configuración).
Se denomina "fase de carga y análisis" porque se puede dividir en dos partes distintas, que solían serializarse, pero ahora se pueden superponer con el tiempo:
- Cargar paquetes, es decir, convertir los archivos
BUILD
en los objetosPackage
que los representan - Analizar objetivos configurados, es decir, ejecutar la implementación de las reglas para producir el grafo de acción
Cada destino configurado en el cierre transitivo de los destinos configurados solicitados en la línea de comandos debe analizarse de forma descendente, es decir, primero los nodos de hoja y, luego, los que están en la línea de comandos. Las entradas al análisis de un solo destino configurado son las siguientes:
- La configuración. ("cómo" crear esa regla; por ejemplo, la plataforma de destino, pero también opciones como la línea de comandos que el usuario quiere pasar al compilador de C++)
- Las dependencias directas. Sus proveedores de información transitiva están disponibles para la regla que se analiza. Se llaman de esa manera porque proporcionan una “lista completa” de la información en el cierre transitivo del destino configurado, como todos los archivos .jar de la ruta de clase o todos los archivos .o que deben vincularse a un objeto binario C++.
- El objetivo en sí. Este es el resultado de la carga del paquete en el que se encuentra el destino. En el caso de las reglas, esto incluye sus atributos, que suelen ser los que importan.
- La implementación del destino configurado. Para las reglas, esto puede ser en Starlark o en Java. Todos los destinos configurados sin reglas se implementan en Java.
El resultado de analizar un destino configurado es el siguiente:
- Los proveedores de información transitiva que configuraron destinos que dependen de él pueden acceder
- Los artefactos que puede crear y las acciones que los producen
La API que se ofrece a las reglas de Java es RuleContext
, que es equivalente al argumento ctx
de las reglas de Starlark. Su API es más potente, pero, al mismo tiempo, es más fácil realizar acciones de Bad ThingsTM, por ejemplo, escribir código cuya complejidad de tiempo o espacio sea cuadrática (o peor), hacer que el servidor de Bazel falle con una excepción de Java o infringir variantes (por ejemplo, modificando de forma inadvertida una instancia de Options
o haciendo que un destino configurado sea mutable).
El algoritmo que determina las dependencias directas de un destino configurado se encuentra en DependencyResolver.dependentNodeMap()
.
Parámetros de configuración
Las configuraciones son la forma de crear un destino: para qué plataforma, con qué opciones de línea de comandos, etcétera.
Se puede compilar el mismo destino para varias configuraciones en la misma compilación. Esto es útil, por ejemplo, cuando se usa el mismo código para una herramienta que se ejecuta durante la compilación y para el código de destino, y se realiza una compilación cruzada o cuando se compila una app de Android multiarquitectura (una que contiene código nativo para varias arquitecturas de CPU).
De forma conceptual, la configuración es una instancia de BuildOptions
. Sin embargo, en la práctica, BuildOptions
se une a BuildConfiguration
que proporciona varias funciones adicionales. Se propaga desde la parte superior del gráfico de dependencias hasta la parte inferior. Si cambia, la compilación se debe volver a analizar.
Esto da como resultado anomalías, como tener que volver a analizar toda la compilación si, por ejemplo, la cantidad de ejecuciones de pruebas solicitadas cambia, aunque esto solo afecte a los objetivos de prueba (tenemos planes de “recortar” la configuración para que este no sea el caso, pero aún no esté listo).
Cuando una implementación de reglas necesita parte de la configuración, debe declararla en su definición con RuleClass.Builder.requiresConfigurationFragments()
. Esto es para evitar errores (como las reglas de Python con el fragmento de Java) y para facilitar el recorte de configuración, de modo que, si cambian las opciones de Python, no sea necesario volver a analizar los objetivos de C++.
La configuración de una regla no es necesariamente la misma que la de su regla “superior”. El proceso de cambiar la configuración en un perímetro de dependencia se denomina “transición de configuración”. Puede producirse en dos lugares:
- En un perímetro de dependencia Estas transiciones se especifican en
Attribute.Builder.cfg()
y son funciones de unaRule
(donde se produce la transición) y unaBuildOptions
(la configuración original) a una o másBuildOptions
(la configuración de salida). - En cualquier perímetro entrante a un destino configurado Estos se especifican en
RuleClass.Builder.cfg()
.
Las clases relevantes son TransitionFactory
y ConfigurationTransition
.
Se usan las transiciones de configuración, por ejemplo:
- Declarar que se usa una dependencia en particular durante la compilación y que, por lo tanto, se debe compilar en la arquitectura de ejecución
- A fin de declarar que se debe compilar una dependencia en particular para varias arquitecturas (por ejemplo, para código nativo en APK multiarquitectura)
Si una transición de configuración da como resultado varias configuraciones, se denomina transición dividida.
Las transiciones de configuración también se pueden implementar en Starlark (consulta la documentación aquí).
Proveedores de información transitiva
Los proveedores de información transitiva son una manera (y solo _way) de los destinos configurados para informar sobre otros destinos configurados que dependen de ellos. El motivo por el que "transitivo" es su nombre es que, por lo general, es una especie de roll-up del cierre transitivo de un destino configurado.
Por lo general, hay una correspondencia 1:1 entre los proveedores de información transitiva de Java y los de Starlark (la excepción es DefaultInfo
, que es una amalgama de FileProvider
, FilesToRunProvider
y RunfilesProvider
, ya que se consideró que la API era más de Starlark que una transliteración directa de Java).
Su clave es una de las siguientes opciones:
- Un objeto de clase de Java. Esta opción solo está disponible para los proveedores a los que no se puede acceder desde Starlark. Estos proveedores son una subclase de
TransitiveInfoProvider
. - Una string. Es un proceso heredado y no se recomienda, ya que es susceptible a conflictos de nombres. Estos proveedores de información transitiva son subclases directas de
build.lib.packages.Info
. - Un símbolo de proveedor Se puede crear desde Starlark con la función
provider()
y es la forma recomendada para crear proveedores nuevos. El símbolo se representa mediante una instanciaProvider.Key
en Java.
Los proveedores nuevos implementados en Java deben implementarse con BuiltinProvider
.
NativeProvider
dejó de estar disponible (aún no tuvimos tiempo para quitarlo) y no se puede acceder a las subclases TransitiveInfoProvider
desde Starlark.
Destinos configurados
Los destinos configurados se implementan como RuleConfiguredTargetFactory
. Hay una subclase para cada clase de regla implementada en Java. Los destinos configurados de Starlark se crean a través de StarlarkRuleConfiguredTargetUtil.buildRule()
.
Las fábricas de destino configuradas deben usar RuleConfiguredTargetBuilder
para construir su valor de retorno. Consta de los siguientes elementos:
- Su
filesToBuild
, el concepto confuso del "conjunto de archivos que representa esta regla". Estos son los archivos que se compilan cuando el destino configurado se encuentra en la línea de comandos o en los srcs de una genrule. - Sus runfiles, regulares y de datos.
- Sus grupos de salida Estos son varios "otros conjuntos de archivos" que puede compilar la regla. Se puede acceder a ellas mediante el atributo output_group de la regla del grupo de archivos en BUILD y con el proveedor
OutputGroupInfo
en Java.
Archivos en ejecución
Algunos objetos binarios necesitan archivos de datos para ejecutarse. Un ejemplo destacado son las pruebas que necesitan archivos de entrada. Esto se representa en Bazel con el concepto de “runfiles”. Un “árbol de archivos run” es un árbol de directorios de los archivos de datos para un objeto binario en particular. Se crea en el sistema de archivos como un árbol de symlinks con symlinks individuales que apuntan a los archivos en la fuente de los árboles de resultados.
Un conjunto de runfiles se representa como una instancia de Runfiles
. Conceptualmente, es un mapa de la ruta de un archivo en el árbol de runfiles a la instancia de Artifact
que lo representa. Es un poco más complicado que una sola Map
por dos razones:
- En la mayoría de los casos, la ruta de ejecución de archivos de un archivo es la misma que su execpath. Usamos esto para ahorrar algo de RAM.
- Existen varios tipos de entradas heredadas en los árboles de runfiles, que también deben representarse.
Los runfiles se recopilan con RunfilesProvider
: una instancia de esta clase representa los runfiles un destino configurado (como una biblioteca) y sus necesidades de cierre transitivo y se recopilan como un conjunto anidado (de hecho, se implementan mediante conjuntos anidados debajo de la portada): cada destino une los archivos runrun de sus dependencias, agrega algunos propios y, luego, envía el conjunto resultante hacia arriba en el gráfico de dependencias. Una instancia de RunfilesProvider
contiene dos instancias de Runfiles
, una para cuando se depende de la regla mediante el atributo "data" y otra para cada otro tipo de dependencia entrante. Esto se debe a que, a veces, un destino
presenta diferentes runfiles cuando se depende de ellos mediante un atributo
de datos. Este es un comportamiento heredado no deseado que aún no hemos eliminado.
Los runfiles de objetos binarios se representan como una instancia de RunfilesSupport
. Esto es diferente de Runfiles
porque RunfilesSupport
tiene la capacidad de compilarse (a diferencia de Runfiles
, que es solo una asignación). Esto requiere los siguientes componentes adicionales:
- El manifiesto del archivo de entrada de manifiesto. Esta es una descripción serializada del árbol de archivos de ejecución. Se usa como proxy para el contenido del árbol de archivos de ejecución y Bazel supone que el árbol de archivos de ejecución cambia solo si cambia el contenido del manifiesto.
- El manifiesto del runfiles de salida. Las bibliotecas del entorno de ejecución que controlan los árboles de ejecución, en particular en Windows, usan esta biblioteca, que a veces no admite vínculos simbólicos.
- El intermediario de archivos de ejecución. Para que exista un árbol de runfiles, se debe compilar el árbol de symlinks y el artefacto al que apuntan los symlinks. A fin de reducir la cantidad de bordes de dependencias, se pueden usar los intermediarios de runfiles para representarlos todos.
- Argumentos de la línea de comandos para ejecutar el objeto binario cuyos archivos de ejecución represente el objeto
RunfilesSupport
.
Aspectos
Los aspectos son una forma de "propagar el cálculo en el gráfico de dependencias". Se describen
para los usuarios de Bazel
aquí. Un buen ejemplo motivador son los búferes de protocolo: una regla proto_library
no debería conocer ningún lenguaje en particular, pero la implementación de un mensaje de búfer de protocolo (la "unidad básica" de búferes de protocolo) en cualquier lenguaje de programación debe acoplarse a la regla proto_library
para que, si dos objetivos en el mismo lenguaje dependen del mismo búfer de protocolo, se compile solo una vez.
Al igual que los destinos configurados, se representan en Skyframe como un SkyValue
, y la forma en que se crean es muy similar a cómo se compilan los destinos configurados: tienen una clase de fábrica llamada ConfiguredAspectFactory
que tiene acceso a un RuleContext
, pero, a diferencia de las fábricas de destino configuradas, también sabe sobre el destino configurado al que está conectado y sus proveedores.
El conjunto de aspectos propagados por el gráfico de dependencias se especifica para cada atributo mediante la función Attribute.Builder.aspects()
. Hay algunas clases con nombres confusos que participan en el proceso:
AspectClass
es la implementación del aspecto. Puede ser en Java (en cuyo caso es una subclase) o en Starlark (en cuyo caso es una instancia deStarlarkAspectClass
). Es análogo aRuleConfiguredTargetFactory
.AspectDefinition
es la definición del aspecto; incluye los proveedores que requiere, los que proporciona y contiene una referencia a su implementación, como la instancia deAspectClass
adecuada. Es análogo aRuleClass
.AspectParameters
es una forma de parametrizar un aspecto que se propaga hacia el gráfico de dependencias. Actualmente, es un mapa de string a string. Un buen ejemplo de por qué es útil son los búferes de protocolo: si un lenguaje tiene varias API, la información sobre la API para la que deben compilarse los búferes de protocolo se debe propagar al grafo de dependencia.Aspect
representa todos los datos necesarios para calcular un aspecto que se propaga hacia el gráfico de dependencias. Consiste en la clase de aspecto, su definición y sus parámetros.RuleAspect
es la función que determina qué aspectos debe propagar una regla en particular. Es una funciónRule
->Aspect
.
Una complicación bastante inesperada es que algunos aspectos pueden adjuntarse a otros. Por ejemplo, es probable que un aspecto que recopila la ruta de clase para un IDE de Java quiera conocer todos los archivos .jar de la ruta, pero algunos son búferes de protocolo. En ese caso, el aspecto del IDE querrá adjuntarse al par (regla proto_library
+ aspecto de proto de Java).
La complejidad de los aspectos se captura en la clase AspectCollection
.
Plataformas y cadenas de herramientas
Bazel admite compilaciones multiplataforma, es decir, compilaciones en las que puede haber varias arquitecturas en las que se ejecutan las acciones de compilación y varias arquitecturas para las que se compila el código. Estas arquitecturas se denominan plataformas de Bazel (la documentación completa aquí)
Una plataforma se describe mediante una asignación de clave-valor desde la configuración de restricciones (como el concepto de "arquitectura de CPU") a los valores de restricción (como una CPU en particular, como x86_64). Tenemos un “diccionario” de la configuración y los valores de la restricción más usados en el repositorio @platforms
.
El concepto de cadena de herramientas se basa en el hecho de que, según las plataformas en las que se ejecute la compilación y las plataformas a las que se orienten, es posible que uno deba usar diferentes compiladores; por ejemplo, una cadena de herramientas de C++ específica puede ejecutarse en un SO específico y poder orientarse a otros SO. Bazel debe determinar el compilador C++ que se usa en función de la plataforma configurada de ejecución y destino (consulta la documentación de las cadenas de herramientas aquí).
Para ello, las cadenas de herramientas se anotan con el conjunto de ejecuciones y las restricciones de las plataformas de destino que admiten. Para ello, la definición de una cadena de herramientas se divide en dos partes:
- Una regla
toolchain()
que describe el conjunto de restricciones de ejecución y destino que admite una cadena de herramientas, y le indica el tipo (como C++ o Java) de la cadena de herramientas (la última está representada por la reglatoolchain_type()
). - Una regla específica del lenguaje que describe la cadena de herramientas real (como
cc_toolchain()
)
Esto se hace de esta manera porque necesitamos conocer las restricciones de cada
cadena de herramientas a fin de poder resolver la cadena de herramientas y las reglas
*_toolchain()
específicas del lenguaje contienen más información, por lo que tardan más
en cargar.
Las plataformas de ejecución se especifican de una de las siguientes maneras:
- En el archivo WORKSPACE con la función
register_execution_platforms()
: - En la línea de comandos, con la opción de línea de comandos --extra_execution_platforms
El conjunto de plataformas de ejecución disponibles se calcula en
RegisteredExecutionPlatformsFunction
.
PlatformOptions.computeTargetPlatform()
determina la plataforma de destino para un destino configurado . Es una lista de plataformas porque, con el tiempo, queremos admitir varias plataformas de destino, pero aún no se ha implementado.
ToolchainResolutionFunction
determina el conjunto de cadenas de herramientas que se usarán para un destino configurado. Es una función de:
- El conjunto de cadenas de herramientas registradas (en el archivo WORKSPACE y la configuración)
- Las plataformas de ejecución y destino deseadas (en la configuración)
- El conjunto de tipos de cadena de herramientas que requiere el destino configurado (en
UnloadedToolchainContextKey)
- El conjunto de restricciones de la plataforma de ejecución del destino configurado (el
atributo
exec_compatible_with
) y la configuración (--experimental_add_exec_constraints_to_targets
) enUnloadedToolchainContextKey
Su resultado es un UnloadedToolchainContext
, que es básicamente un mapa del
tipo de cadena de herramientas (representado como una instancia de ToolchainTypeInfo
) a la etiqueta de
la cadena de herramientas seleccionada. Se denomina "sin descargar" porque no contiene las
cadenas de herramientas en sí, sino solo sus etiquetas.
Luego, las cadenas de herramientas se cargan con ResolvedToolchainContext.load()
y se usan en la implementación del destino configurado que las solicitó.
También tenemos un sistema heredado que se basa en que haya una sola configuración de “host” y configuraciones de destino que se representen mediante varias marcas de configuración, como --cpu
. Estamos haciendo la transición gradual al sistema anterior. A fin de manejar casos en los que las personas dependen de los valores de configuración
heredados, implementamos
asignaciones de plataformas
para traducir entre las marcas heredadas y las restricciones de la plataforma nueva.
Su código está en PlatformMappingFunction
y usa un "lenguaje pequeño" que no es Starlark.
Limitaciones
A veces, se desea designar un destino como compatible con unas pocas plataformas. Desafortunadamente, tiene varios mecanismos para lograr este fin:
- Restricciones específicas de las reglas
environment_group()
/environment()
- Restricciones de la plataforma
Las restricciones específicas de la regla se usan mayormente dentro de Google para las reglas de Java; están
en camino y no están disponibles en Bazel, pero el código fuente puede
contener referencias. El atributo que controla esto se denomina constraints=
.
Environment_group() y Environment()
Estas reglas son un mecanismo heredado y no se utilizan ampliamente.
Todas las reglas de compilación pueden declarar para qué "entornos" se pueden compilar, y un "entorno" es una instancia de la regla environment()
.
Existen varias formas en que se pueden especificar los entornos compatibles para una regla:
- A través del atributo
restricted_to=
Esta es la forma de especificación más directa; declara el conjunto exacto de entornos que admite la regla para este grupo. - A través del atributo
compatible_with=
De esta manera, se declaran los entornos que admite una regla, además de los entornos "estándar" que se admiten de forma predeterminada. - A través de los atributos de nivel de paquete
default_restricted_to=
ydefault_compatible_with=
- Mediante especificaciones predeterminadas en las reglas de
environment_group()
Cada entorno pertenece a un grupo de apps similares relacionadas (por ejemplo, "arquitecturas de CPU", "versiones de JDK" o "sistemas operativos para dispositivos móviles"). La definición de un grupo de entornos incluye cuáles de estos entornos deben ser compatibles con “predeterminado” si no se especifica en los atributosrestricted_to=
oenvironment()
. Una regla sin esos atributos hereda todos los valores predeterminados. - Se usa una clase de regla predeterminada. Esto anula los valores predeterminados globales para todas las instancias de la clase de regla determinada. Esto se puede usar, por ejemplo, para que se puedan probar todas las reglas
*_test
sin que cada instancia tenga que declarar esta capacidad de forma explícita.
environment()
se implementa como una regla normal, mientras que environment_group()
es una subclase de Target
, pero no de Rule
(EnvironmentGroup
), y una función que está disponible de forma predeterminada desde Starlark (StarlarkLibrary.environmentGroup()
) que, finalmente, crea un objetivo epónimo. Esto es para evitar una dependencia cíclica que podría surgir porque cada entorno debe declarar el grupo al que pertenece y cada grupo debe declarar sus entornos predeterminados.
Una compilación se puede restringir a un entorno determinado con la opción de línea de comandos --target_environment
.
La implementación de la verificación de restricciones está en RuleContextConstraintSemantics
y TopLevelConstraintSemantics
.
Restricciones de la plataforma
La forma “oficial” actual de describir con qué plataformas es compatible un destino es usar las mismas restricciones que se usan para describir las cadenas de herramientas y plataformas. Está en proceso de revisión en la solicitud de extracción #10945.
Visibilidad
Si trabajas en una base de código grande con muchos desarrolladores (como en Google), debes tener cuidado para evitar que todos los demás lo hagan de forma arbitraria según tu código. De lo contrario, según la ley de Hyrum, las personas dependrán de los comportamientos que consideraste detalles de implementación.
Bazel admite esto mediante un mecanismo llamado visibilidad: puedes declarar que un destino en particular solo puede depender del uso del atributo visibilidad. Este atributo es un poco especial porque, aunque contiene una lista de etiquetas, estas pueden codificar un patrón en los nombres de paquetes en lugar de un puntero para cualquier destino en particular. (Sí, es una falla de diseño).
Esto se implementa en los siguientes lugares:
- La interfaz
RuleVisibility
representa una declaración de visibilidad. Puede ser una constante (completamente pública o totalmente privada) o una lista de etiquetas. - Las etiquetas pueden hacer referencia a grupos de paquetes (lista predefinida de paquetes) o paquetes directamente (
//pkg:__pkg__
) o subárboles de paquetes (//pkg:__subpackages__
). Esto es diferente de la sintaxis de la línea de comandos, que usa//pkg:*
o//pkg/...
. - Los grupos de paquetes se implementan como su propio destino (
PackageGroup
) y como objetivo configurado (PackageGroupConfiguredTarget
). Probablemente podríamos reemplazarlos por reglas simples si lo deseamos. Su lógica se implementa con la ayuda de:PackageSpecification
, que corresponde a un solo patrón como//pkg/...
;PackageGroupContents
, que corresponde a un solo atributopackages
depackage_group
; yPackageSpecificationProvider
, que agregapackage_group
y suincludes
transitivo. - La conversión de listas de etiquetas de visibilidad a dependencias se realiza en
DependencyResolver.visitTargetVisibility
y algunos otros lugares. - La verificación se realiza en
CommonPrerequisiteValidator.validateDirectPrerequisiteVisibility()
.
Conjuntos anidados
A menudo, un destino configurado agrega un conjunto de archivos de sus dependencias, agrega uno propio y, luego, une el conjunto agregado en un proveedor de información transitiva para que los destinos configurados que dependen de él puedan hacer lo mismo. Ejemplos:
- Los archivos de encabezado de C++ que se usan para una compilación
- Los archivos de objeto que representan el cierre transitivo de un
cc_library
- El conjunto de archivos .jar que deben estar en la ruta de clase para que una regla de Java se compile o ejecute
- El conjunto de archivos de Python en el cierre transitivo de una regla de Python
Si usáramos, por ejemplo, List
o Set
, de manera ingenua, obtendríamos un uso de memoria cuadrático: si hay una cadena de reglas N y cada regla agrega un archivo, tendríamos más de 1 + 2 miembros de colección + N.
Para evitar este problema, se nos ocurrió el concepto de NestedSet
. Es una estructura de datos compuesta por otras instancias de NestedSet
y algunos miembros propios, lo que forma un grafo acíclico dirigido de conjuntos. Son inmutables y sus miembros pueden iterarse. Definimos varios órdenes de iteración (NestedSet.Order
): pedido anticipado, posorden, topológico (un nodo siempre viene después de sus principales) y “no importa, pero debe ser el mismo cada vez”.
La misma estructura de datos se llama depset
en Starlark.
Artefactos y acciones
La compilación real consta de un conjunto de comandos que se deben ejecutar para producir el resultado que desea el usuario. Los comandos se representan como instancias de la clase Action
, y los archivos se representan como instancias de la clase Artifact
. Están dispuestas en un grafo acíclico bipartito, dirigido y llamado "gráfico de acción".
Los artefactos se dividen en dos tipos: artefactos de origen (unos que están disponibles antes de que Bazel comience a ejecutarse) y artefactos derivados (unos que deben compilarse). Los artefactos derivados pueden ser de varios tipos:
- **Artefactos frecuentes. **Para verificar que estén actualizados, se calcula su suma de verificación con mtime como atajo. No se suma la verificación al archivo si su horario no cambió.
- Artefactos de symlinks sin resolver. Se verifican que estén actualizados mediante una llamada a readlink(). A diferencia de los artefactos normales, pueden ser vínculos simbólicos. Por lo general, se usa en los casos en que uno empaqueta algunos archivos en un archivo de algún tipo.
- Artefactos de árbol. Estos no son archivos individuales, sino árboles de directorios. Se verifican que estén actualizados mediante una revisión del conjunto de archivos que contiene y su contenido. Se representan como un
TreeArtifact
. - Artefactos de metadatos constantes. Los cambios en estos artefactos no activan una recompilación. Se usa exclusivamente para información de sellos de compilación: no queremos volver a compilar solo porque cambió la hora actual.
No hay un motivo fundamental por el que los artefactos de origen no puedan ser artefactos de árbol o
artefactos de symlink sin resolver, solo es que aún no lo hemos implementado (sin embargo, debemos hacer referencia a un directorio de origen en un archivo BUILD
es uno
de los pocos problemas conocidos de errores incorrectos con Bazel; tenemos una
implementación que funciona con la
propiedad de JVM de BAZEL_TRACK_SOURCE_DIRECTORIES=1
)
Un tipo notable de Artifact
son los intermediarios. Se indican mediante instancias Artifact
que son los resultados de MiddlemanAction
. Se usan para casos especiales como los siguientes:
- La agregación de intermediarios se usa para agrupar artefactos. De esta manera, si muchas acciones usan el mismo conjunto grande de entradas, no tenemos bordes de dependencia N*M, solo N+M (se reemplazan por conjuntos anidados).
- La programación de intermediarios de dependencias garantiza que una acción se ejecute antes que otra.
Se usan principalmente para análisis con lint, pero también para la compilación de C++ (consulta una explicación de
CcCompilationContext.createMiddleman()
). - Los intermediarios de runfiles se usan para garantizar la presencia de un árbol de archivos de ejecución, de modo que no tenga que depender por separado del manifiesto de salida y de cada artefacto al que hace referencia el árbol de runfiles.
Las acciones se entienden mejor como un comando que debe ejecutarse, el entorno que necesita y el conjunto de resultados que produce. Los siguientes son los componentes principales de la descripción de una acción:
- La línea de comandos que se debe ejecutar
- Los artefactos de entrada que necesita
- Las variables de entorno que se deben establecer
- Anotaciones que describen el entorno (como la plataforma) en el que se debe ejecutar
También hay otros casos especiales, como escribir un archivo cuyo contenido
sea conocido por Bazel. Son una subclase de AbstractAction
. La mayoría de las acciones son SpawnAction
o StarlarkAction
(mismamente, no deben ser clases separadas), aunque Java y C++ tienen sus propios tipos de acciones (JavaCompileAction
, CppCompileAction
y CppLinkAction
).
Con el tiempo, queremos mover todo a SpawnAction
. JavaCompileAction
es bastante cercano, pero C++ es un caso especial debido al análisis de archivos .d y se incluye el análisis.
El gráfico de acciones está mayormente "incorporado" en el gráfico de Skyframe: conceptualmente, la ejecución de una acción se representa como una invocación de ActionExecutionFunction
. La asignación de un borde de dependencia de gráfico de acción a un borde de dependencia de Skyframe se describe en ActionExecutionFunction.getInputDeps()
y Artifact.key()
y tiene algunas optimizaciones para mantener baja la cantidad de bordes de Skyframe:
- Los artefactos derivados no tienen sus propios
SkyValue
. En cambio, se usaArtifact.getGeneratingActionKey()
a fin de descubrir la clave para la acción que la genera. - Los conjuntos anidados tienen su propia clave Skyframe.
Acciones compartidas
Algunas acciones se generan mediante varios destinos configurados; las reglas de Starlark son más limitadas, ya que solo se les permite colocar sus acciones derivadas en un directorio determinado por su configuración y su paquete (pero aun así, las reglas en el mismo paquete pueden entrar en conflicto), pero las reglas implementadas en Java pueden colocar artefactos derivados en cualquier lugar.
Esto se considera un atributo incorrecto, pero deshacerse de él es realmente difícil porque produce ahorros significativos en el tiempo de ejecución cuando, por ejemplo, un archivo de origen debe procesarse de alguna manera y a ese archivo se hace referencia mediante varias reglas (mando de mano). Esto tiene un costo adicional: cada instancia de una acción compartida debe almacenarse en la memoria por separado.
Si dos acciones generan el mismo archivo de salida, deben ser exactamente iguales: tener las mismas entradas, las mismas salidas y ejecutar la misma línea de comandos. Esta relación de equivalencia se implementa en Actions.canBeShared()
y se verifica entre las fases de análisis y ejecución mediante el análisis de cada acción.
Esto se implementa en SkyframeActionExecutor.findAndStoreArtifactConflicts()
y es uno de los pocos lugares de Bazel que requieren una vista “global” de la
compilación.
La fase de ejecución
En este momento, Bazel comienza a ejecutar acciones de compilación, como los comandos que producen resultados.
Lo primero que Bazel hace después de la fase de análisis es determinar qué artefactos se deben compilar. La lógica está codificada en TopLevelArtifactHelper
. En términos generales, es el filesToBuild
de los destinos configurados en la línea de comandos y el contenido de un grupo de salida especial con el propósito explícito de expresar "si este objetivo está en la línea de comandos, compilar estos artefactos".
El siguiente paso es crear la raíz de ejecución. Dado que Bazel tiene la opción de leer
paquetes de origen de diferentes ubicaciones en el sistema de archivos (--package_path
),
debe proporcionar acciones ejecutadas de forma local con un árbol de fuentes completo. Esto lo controla la clase SymlinkForest
. Para ello, toma nota de cada destino utilizado en la fase de análisis y crea un solo árbol de directorios que haga un symlink a cada paquete con un destino utilizado desde su ubicación real. Una alternativa sería pasar las rutas de acceso correctas a los comandos (teniendo en cuenta --package_path
).
lo cual no es recomendable por los siguientes motivos:
- Cambia las líneas de comandos de las acciones cuando un paquete se mueve de una entrada de ruta de paquete a otra (que solía ser un caso común).
- Da como resultado diferentes líneas de comandos si una acción se ejecuta de forma remota en comparación con una ejecución local
- Requiere una transformación de línea de comandos específica para la herramienta en uso (considera la diferencia entre las rutas de clase de Java y las de inclusión de C++).
- Cambiar la línea de comandos de una acción invalida la entrada de la caché de acciones
--package_path
dejó de estar disponible de manera lenta y constante
Luego, Bazel comienza a recorrer el gráfico de acciones (el grafo bipartito, dirigido
compuesto por acciones y sus artefactos de entrada y salida) y las acciones en ejecución.
La ejecución de cada acción está representada por una instancia de la clase ActionExecutionValue
de SkyValue
.
Dado que ejecutar una acción es costoso, tenemos algunas capas de almacenamiento en caché que se pueden aplicar a Skyframe:
ActionExecutionFunction.stateMap
contiene datos para que los reinicios de Skyframe deActionExecutionFunction
sean económicos.- La caché de acción local contiene datos sobre el estado del sistema de archivos.
- Los sistemas de ejecución remota también suelen contener su propia caché
Caché de acción local
Esta caché es otra capa que se encuentra detrás de Skyframe; incluso si una acción se vuelve a ejecutar en Skyframe, puede ser un acierto en la caché de acción local. Representa el estado del sistema de archivos local y se serializa en el disco, lo que significa que cuando se inicia un servidor nuevo de Bazel, se pueden obtener aciertos de caché de acción local, a pesar de que el gráfico de Skyframe esté vacío.
Se verifica esta caché en busca de hits con el método ActionCacheChecker.getTokenIfNeedToExecute()
.
Contrario a su nombre, es un mapa de la ruta de acceso de un artefacto derivado a la acción que lo emitió. La acción se describe de la siguiente manera:
- El conjunto de sus archivos de entrada y salida, y su suma de verificación
- Su "clave de acción", que suele ser la línea de comandos que se ejecutó, pero, en general, representa todo lo que la captura de verificación de los archivos de entrada no captura (como para
FileWriteAction
, la suma de verificación de los datos que se escriben).
También hay una "caché de acción de arriba hacia abajo" muy experimental que aún está en desarrollo, y que usa hashes transitivos para evitar ir a la caché tantas veces.
Descubrimiento y reducción de entradas
Algunas acciones son más complicadas que solo tener un conjunto de entradas. Los cambios en el conjunto de entradas de una acción se presentan de dos maneras:
- Una acción puede descubrir entradas nuevas antes de su ejecución o decidir que algunas
de ellas no son realmente necesarias. El ejemplo canónico es C++, en el que es mejor hacer una conjetura sobre los archivos de encabezado que usa un archivo de C++ desde su cierre transitivo para que no tengamos que enviar todos los archivos a ejecutores remotos. Por lo tanto, tenemos la opción de no registrar todos los archivos de encabezado como "entrada", pero escaneamos el archivo de origen para detectar encabezados transitivos y solo marcamos este archivo como una entrada que se encuentra en las declaraciones que se incluyen en la información.
#include
- Una acción puede darse cuenta de que algunos archivos no se usaron durante su ejecución. En C++, esto se denomina "archivos .d": el compilador indica qué archivos de encabezado se usaban después del hecho y, para evitar la vergüenza de tener un incremento aún mayor que Make, Bazel usa este hecho. Esto ofrece una mejor estimación que el analizador de inclusión, ya que depende del compilador.
Se implementan con métodos en Action:
- Se llama a
Action.discoverInputs()
. Debería mostrar un conjunto anidado de artefactos que se requiera. Estos deben ser artefactos de origen para que no haya bordes de dependencia en el grafo de acción que no tengan un equivalente en el grafo de destino configurado. - La acción se ejecuta mediante una llamada a
Action.execute()
. - Al final de
Action.execute()
, la acción puede llamar aAction.updateInputs()
para indicarle a Bazel que no se necesitaron todas sus entradas. Esto puede generar compilaciones incrementales incorrectas si una entrada usada se informa como sin usar.
Cuando una caché de acciones muestra un hit en una instancia de Action nueva (como creada
después de reiniciar el servidor), Bazel llama a updateInputs()
para que el conjunto de
entradas refleje el resultado del descubrimiento y la reducción de entradas que se realizó antes.
Las acciones de Starlark pueden usar la instalación para declarar algunas entradas como no utilizadas mediante el argumento unused_inputs_list=
de ctx.actions.run()
.
Distintas formas de ejecutar acciones: Strategies/ActionContexts
Algunas acciones se pueden ejecutar de diferentes maneras. Por ejemplo, una línea de comandos se puede ejecutar de forma local, local, pero en varios tipos de zonas de pruebas o de forma remota. El concepto que encarna esto se denomina ActionContext
(o Strategy
, ya que solo tuvimos un cambio de nombre con éxito)
El ciclo de vida de un contexto de acción es el siguiente:
- Cuando se inicia la fase de ejecución, se les pregunta a las instancias
BlazeModule
qué contextos de acción tienen. Esto sucede en el constructor deExecutionTool
. Los tipos de contexto de acción se identifican mediante una instanciaClass
de Java que hace referencia a una subinterfaz deActionContext
y qué interfaz debe implementar el contexto de acción. - El contexto de acción adecuado se selecciona de los disponibles y se reenvía a
ActionExecutionContext
yBlazeExecutor
. - Las acciones solicitan contextos con
ActionExecutionContext.getContext()
yBlazeExecutor.getStrategy()
(en realidad, solo debería haber una forma de hacerlo...)
Las estrategias pueden llamar a otras estrategias para que hagan su trabajo. Esto se usa, por ejemplo, en la estrategia dinámica que inicia acciones de manera local y remota, y luego usa lo que finalice primero.
Una estrategia notable es la que implementa procesos de trabajador persistentes
(WorkerSpawnStrategy
). La idea es que algunas herramientas tienen un tiempo de inicio prolongado y, por lo tanto, se deben volver a usar entre acciones en lugar de comenzar una nueva por
cada acción (esto representa un posible problema de corrección, ya que Bazel
depende de la promesa del proceso de trabajador de que no lleva un estado
observable entre solicitudes individuales).
Si la herramienta cambia, es necesario reiniciar el proceso del trabajador. Determinar si se puede reutilizar un trabajador mediante el cálculo de una suma de verificación para la herramienta que se usa con WorkerFilesHash
Se basa en saber qué entradas de la acción representan parte de la herramienta y cuáles representan. Esto lo determina el creador de la acción: Spawn.getToolFiles()
y los archivos de ejecución de Spawn
se cuentan como partes de la herramienta.
Más información sobre estrategias (o contextos de acción):
- La información sobre las diversas estrategias para ejecutar acciones está disponible aquí.
- La información sobre la estrategia dinámica, en la que ejecutamos una acción de manera local y remota, para ver cuál finaliza primero está disponible aquí.
- La información sobre las particularidades de ejecutar acciones de manera local está disponible aquí.
El administrador de recursos local
Bazel puede ejecutar muchas acciones en paralelo. La cantidad de acciones locales que deben ejecutarse en paralelo difiere de una acción a otra: cuantos más recursos requiera una acción, menos instancias deberían estar en ejecución al mismo tiempo para evitar sobrecargar la máquina local.
Esto se implementa en la clase ResourceManager
: cada acción debe anotarse con una estimación de los recursos locales que requiere como una instancia de ResourceSet
(CPU y RAM). Luego, cuando los contextos de acción realizan algo que requiere recursos locales, llaman a ResourceManager.acquireResources()
y se bloquean hasta que los recursos necesarios estén disponibles.
Puedes encontrar una descripción más detallada de la administración de recursos locales aquí.
La estructura del directorio de salida
Cada acción requiere un lugar independiente en el directorio de salida en el que coloca sus resultados. Por lo general, la ubicación de los artefactos derivados es la siguiente:
$EXECROOT/bazel-out/<configuration>/bin/<package>/<artifact name>
¿Cómo se determina el nombre del directorio asociado con una configuración en particular? Hay dos propiedades deseables en conflicto:
- Si pueden ocurrir dos parámetros de configuración en la misma compilación, deberán tener directorios diferentes para que ambos puedan tener su propia versión de la misma acción; de lo contrario, si las dos configuraciones no están de acuerdo, como la línea de comandos de una acción que produce el mismo archivo de salida, Bazel no sabe qué acción elegir (un “conflicto de acción”).
- Si dos configuraciones representan "aproximadamente" lo mismo, deberían tener el mismo nombre, de modo que las acciones ejecutadas en una se puedan volver a usar en la otra si las líneas de comando coinciden: por ejemplo, los cambios en las opciones de la línea de comandos para el compilador de Java no deberían hacer que se vuelvan a ejecutar acciones de compilación de C++.
Hasta ahora, no hemos encontrado una forma adecuada de resolver este problema, que tenga similitudes con el problema de cortar la configuración. Si deseas conocer un análisis más extenso de las opciones, haz clic aquí. Las principales áreas problemáticas son las reglas de Starlark (cuyos autores no suelen estar familiarizados con Bazel) y los aspectos, que agregan otra dimensión al espacio de los elementos que pueden producir el "mismo" archivo de salida.
El enfoque actual es que el segmento de ruta de la configuración sea <CPU>-<compilation mode>
con varios sufijos agregados, de modo que las transiciones de configuración implementadas en Java no generen conflictos de acciones. Además, se agrega una suma de verificación del conjunto de transiciones de configuración de Starlark para que los usuarios no puedan causar conflictos de acción. No es la mejor opción. Esto se implementa en OutputDirectories.buildMnemonic()
y depende de que cada fragmento de configuración agregue su propia parte al nombre del directorio de salida.
Pruebas
Bazel ofrece una amplia compatibilidad para ejecutar pruebas. Es compatible con:
- Ejecutar pruebas de forma remota (si hay un backend de ejecución remota disponible)
- Ejecución de pruebas varias veces en paralelo (deflación o recopilación de datos de tiempo)
- Fragmentación de pruebas (dividiendo los casos de prueba en la misma prueba en varios procesos para obtener velocidad)
- Vuelve a ejecutar pruebas inestables
- Cómo agrupar pruebas en conjuntos de pruebas
Las pruebas son objetivos configurados con regularidad que tienen un TestProvider, que describe cómo se debe ejecutar la prueba:
- Los artefactos cuyo resultado es la ejecución de la prueba Este es un archivo de "estado de la caché" que contiene un mensaje
TestResultData
serializado - La cantidad de veces que se debe ejecutar la prueba
- La cantidad de fragmentos en los que se debe dividir la prueba
- Algunos parámetros sobre cómo se debe ejecutar la prueba (como el tiempo de espera de la prueba)
Cómo determinar qué pruebas ejecutar
Determinar las pruebas que se ejecutan es un proceso elaborado.
En primer lugar, durante el análisis de patrones de destino, los conjuntos de pruebas se expanden de forma recursiva. La expansión se implementa en TestsForTargetPatternFunction
. Una arruga sorprendente es que, si un paquete de pruebas no declara una prueba, se refiere a todas las pruebas del paquete. Esto se implementa en Package.beforeBuild()
mediante la adición de un atributo implícito llamado $implicit_tests
para probar las reglas de los paquetes.
Luego, las pruebas se filtran por tamaño, etiquetas, tiempo de espera e idioma según las opciones de la línea de comandos. Esto se implementa en TestFilter
y se llama desde TargetPatternPhaseFunction.determineTests()
durante el análisis de destino y el resultado se coloca en TargetPatternPhaseValue.getTestsToRunLabels()
. El motivo por el que no se pueden configurar los atributos de las reglas que se pueden filtrar es que esto sucede antes de la fase de análisis. Por lo tanto, la configuración no está disponible.
Esto se procesa más en BuildView.createResult()
: los objetivos cuyo
análisis falló se filtran y las pruebas se dividen en pruebas exclusivas y
no exclusivas. Luego, se coloca en AnalysisResult
, que es la forma en que ExecutionTool
sabe qué pruebas ejecutar.
A fin de brindar transparencia a este proceso elaborado, el operador de consultas tests()
(implementado en TestsFunction
) está disponible para determinar qué pruebas se ejecutan cuando se especifica un destino en particular en la línea de comandos. Desafortunadamente, es una reimplementación, por lo que es probable que se desvíe de las formas anteriores y de manera sutil.
Cómo ejecutar pruebas
La forma en que se ejecutan las pruebas es mediante la solicitud de artefactos de estado de caché. Esto genera la ejecución de un TestRunnerAction
, que finalmente llama al TestActionContext
elegido por la opción de línea de comandos --test_strategy
que ejecuta la prueba de la manera solicitada.
Las pruebas se ejecutan de acuerdo con un protocolo elaborado que usa variables de entorno para indicarles qué se espera de ellas. Puedes encontrar una descripción detallada de Bazel de las pruebas y qué pueden esperar de Bazel aquí. En el más simple, un código de salida de 0 significa éxito, todo lo demás significa falla.
Además del archivo de estado de caché, cada proceso de prueba emite varios archivos más. Se colocan en el “directorio de registro de pruebas”, que es el subdirectorio llamado testlogs
del directorio de salida de la configuración de destino:
test.xml
, un archivo en formato XML de estilo JUnit que detalla los casos de prueba individuales en el fragmento de pruebatest.log
: Es el resultado de la consola de la prueba. stdout y stderr no están separados.test.outputs
, el “directorio de resultados no declarados”, que usan las pruebas que desean generar archivos además de lo que imprimen en la terminal.
Durante la ejecución de la prueba, pueden ocurrir dos cosas que no lo hacen durante la compilación de objetivos regulares: ejecución de prueba exclusiva y transmisión de resultados.
Algunas pruebas se deben ejecutar en modo exclusivo, por ejemplo, no en paralelo con otras pruebas. Para ello, puedes agregar tags=["exclusive"]
a la regla de prueba o ejecutar la prueba con --test_strategy=exclusive
. Cada prueba exclusiva es ejecutada por una invocación de Skyframe independiente que solicita la ejecución de la prueba después de la compilación "principal". Esto se implementa en SkyframeExecutor.runExclusiveTest()
.
A diferencia de las acciones normales, cuyo resultado de la terminal se vuelca cuando finaliza la acción, el usuario puede solicitar que se transmitan los resultados de las pruebas para obtener información sobre el progreso de una prueba de larga duración. Esto se especifica mediante la opción de línea de comandos --test_output=streamed
y, además, implica una ejecución de prueba exclusiva para que los resultados de diferentes pruebas no se intercalan.
Esto se implementa en la clase StreamedTestOutput
con el nombre apropiado y funciona mediante el sondeo de cambios en el archivo test.log
de la prueba en cuestión y el volcado de bytes nuevos a la terminal en la que se rigen las reglas de Bazel.
Los resultados de las pruebas ejecutadas están disponibles en el bus de eventos mediante la observación de varios eventos (como TestAttempt
, TestResult
o TestingCompleteEvent
). Se vuelcan en el protocolo de eventos de compilación y AggregatingTestListener
los emite a la consola.
Colección de cobertura
Las coberturas se informan en las pruebas con el formato LCOV
bazel-testlogs/$PACKAGE/$TARGET/coverage.dat
.
Para recopilar cobertura, cada ejecución de prueba se une en una secuencia de comandos llamada collect_coverage.sh
.
Esta secuencia de comandos configura el entorno de la prueba para habilitar la recopilación de cobertura y determinar dónde se escriben los archivos de cobertura en los entornos de ejecución. Luego, ejecuta la prueba. Una prueba puede ejecutar varios subprocesos y consiste en partes escritas en varios lenguajes de programación diferentes (con entornos de ejecución de recopilación de cobertura independientes). La secuencia de comandos del wrapper es responsable de convertir los archivos resultantes al formato LCOV si es necesario y los combina en un solo archivo.
La estrategia de prueba realiza la intersección de collect_coverage.sh
y requiere que collect_coverage.sh
esté en las entradas de la prueba. Esto se logra mediante el atributo implícito :coverage_support
, que se resuelve con el valor de la marca de configuración --coverage_support
(consulta TestConfiguration.TestOptions.coverageSupport
).
Algunos lenguajes realizan una instrumentación sin conexión, lo que significa que la instrumentación de cobertura se agrega en el tiempo de compilación (como C++) y otras realizan la instrumentación en línea, lo que significa que la instrumentación de cobertura se agrega en el momento de la ejecución.
Otro concepto fundamental es la cobertura de los modelos de referencia. Esta es la cobertura de una biblioteca, un objeto binario o una prueba si no se ejecutó ningún código en ella. El problema que resuelve es que, si quieres calcular la cobertura de prueba de un objeto binario, no es suficiente combinar la cobertura de todas las pruebas, ya que podría haber código en el objeto binario que no esté vinculado a ninguna prueba. Por lo tanto, lo que hacemos es emitir un archivo de cobertura para cada objeto binario que contenga solo los archivos que recopilamos, sin líneas cubiertas. El archivo de cobertura del modelo de referencia de un destino es bazel-testlogs/$PACKAGE/$TARGET/baseline_coverage.dat
. También se genera
para objetos binarios y bibliotecas, además de las pruebas si pasas la
marca --nobuild_tests_only
a Bazel.
La cobertura de referencia no funciona en este momento.
Realizamos un seguimiento de dos grupos de archivos para la recopilación de cobertura de cada regla: el conjunto de archivos instrumentados y el conjunto de archivos de metadatos de instrumentación.
El conjunto de archivos instrumentados es solo eso, un conjunto de archivos para instrumentar. En los entornos de ejecución de cobertura en línea, se puede usar durante el tiempo de ejecución para decidir qué archivos instrumentar. También se usa para implementar la cobertura del modelo de referencia.
El conjunto de archivos de metadatos de instrumentación es el conjunto de archivos adicionales que necesita una prueba para generar los archivos LCOV que Bazel requiere. En la práctica, esto consiste en archivos específicos del entorno de ejecución; por ejemplo, gcc emite archivos .gcno durante la compilación. Estas se agregan al conjunto de entradas de acciones de prueba si el modo de cobertura está habilitado.
Si se recopila o no la cobertura, se almacena en BuildConfiguration
. Esto es útil, ya que es una manera fácil de cambiar la acción de prueba y el gráfico de acción según este bit, pero también significa que, si se da vuelta este bit, todos los objetivos deben volver a analizarse (algunos lenguajes, como C++, requieren diferentes opciones del compilador para emitir código que pueda recopilar cobertura, lo que mitiga un poco el problema, ya que se necesita un nuevo análisis).
Los archivos de compatibilidad de cobertura dependen de etiquetas en una dependencia implícita, de modo que se puedan anular mediante la política de invocación, lo que les permite diferir entre las diferentes versiones de Bazel. Lo ideal sería que estas diferencias se quitaran y que estandarizáramos en una de ellas.
También generamos un “informe de cobertura” que combina la cobertura recopilada para
cada prueba en una invocación de Bazel. CoverageReportActionFactory
se encarga de esto y se llama desde BuildView.createResult()
. Obtiene acceso a las herramientas que necesita mediante la observación del atributo :coverage_report_generator
de la primera prueba que se ejecuta.
El motor de consultas
Bazel tiene un pequeño lenguaje que se usa para hacerle varias preguntas sobre diversos gráficos. Se proporcionan los siguientes tipos de consulta:
bazel query
se usa para investigar el gráfico de destinobazel cquery
se usa para investigar el grafo de destino configuradobazel aquery
se usa para investigar el gráfico de acción.
Cada uno de estos se implementa mediante la subclasificación de AbstractBlazeQueryEnvironment
.
Se pueden realizar funciones de consulta adicionales mediante la subclasificación de QueryFunction
. Para permitir resultados de consulta de transmisión, en lugar de recopilarlos a alguna estructura de datos, se pasa un query2.engine.Callback
a QueryFunction
, que lo llama a fin de obtener los resultados que desea mostrar.
El resultado de una consulta se puede emitir de varias maneras: etiquetas, etiquetas y clases de reglas, XML, protobuf, etcétera. Estos se implementan como subclases de OutputFormatter
.
Un requisito sutil de algunos formatos de salida de consultas (proto, sin duda) es que Bazel debe emitir _toda _la información que proporciona la carga de paquetes para que se pueda diferenciar el resultado y determinar si cambió un destino en particular. Como consecuencia, los valores de los atributos deben ser serializables, por lo que solo hay pocos tipos de atributos sin atributos que tengan valores complejos de Starlark. La solución alternativa habitual es usar una etiqueta y adjuntar la información compleja a la regla con esa etiqueta. No es una solución muy satisfactoria y sería muy bueno quitar este requisito.
El sistema de módulos
Bazel se puede extender agregando módulos. Cada módulo debe incluir la subclase BlazeModule
(el nombre es una reliquia de la historia de Bazel cuando solía llamarse Blaze) y obtiene información sobre varios eventos durante la ejecución de un comando.
Se usan principalmente para implementar varias partes de la funcionalidad "núcleo" que solo necesitan algunas versiones de Bazel (como la que usamos en Google):
- Interfaces con sistemas de ejecución remota
- Comandos nuevos
El conjunto de puntos de extensión que ofrece BlazeModule
es un poco al azar. No la uses como ejemplo de buenos principios de diseño.
El autobús del evento
La forma principal en que BlazeModules se comunica con el resto de Bazel es mediante un bus de eventos
(EventBus
): Se crea una instancia nueva para cada compilación, varias partes de Bazel
pueden publicar eventos en ella y los módulos pueden registrar objetos de escucha para los eventos que
les interesan. Por ejemplo, los siguientes elementos se representan como eventos:
- Se determinó la lista de destinos de compilación
(
TargetParsingCompleteEvent
) - Se determinó la configuración de nivel superior (
BuildConfigurationEvent
). - Se compiló correctamente un destino o no (
TargetCompleteEvent
) - Se ejecutó una prueba (
TestAttempt
,TestSummary
)
Algunos de estos eventos se representan fuera de Bazel en el
Protocolo de eventos de compilación
(son BuildEvent
). Esto permite que no solo los objetos BlazeModule
, sino también elementos
fuera del proceso de Bazel observen la compilación. Se puede acceder a ellos como un
archivo que contiene mensajes de protocolo o como Bazel para conectarse a un servidor (llamado
servicio de eventos de compilación) para transmitir eventos.
Esto se implementa en los paquetes Java build.lib.buildeventservice
y build.lib.buildeventstream
.
Repositorios externos
Si bien Bazel se diseñó en un principio para usarse en un monorepo (un único árbol de fuentes que contiene todo lo que se necesita construir), Bazel vive en un mundo en el que no es necesariamente así. Los “repositorios externos” son una abstracción que se usa a fin de unir estos dos mundos: representan el código necesario para la compilación, pero que no está en el árbol de fuentes principal.
El archivo WORKSPACE
El conjunto de repositorios externos se determina mediante el análisis del archivo WORKSPACE. Por ejemplo, una declaración como la siguiente:
local_repository(name="foo", path="/foo/bar")
Los resultados en el repositorio denominado @foo
estarán disponibles. Cuando esto se complica, es que se pueden definir nuevas reglas de repositorio en archivos de Starlark, que luego se pueden usar para cargar un nuevo código de Starlark, que se puede usar a fin de definir nuevas reglas de repositorio, etcétera.
Para manejar este caso, el análisis del archivo WORKSPACE (en WorkspaceFileFunction
) se divide en fragmentos delimitados por declaraciones load()
. El índice de fragmento se indica mediante WorkspaceFileKey.getIndex()
, que calcula WorkspaceFileFunction
hasta que el índice X significa evaluarlo hasta la declaración X.load()
.
Recuperando repositorios
Antes de que el código del repositorio esté disponible para Bazel, se debe recuperar. Esto hace que Bazel cree un directorio en
$OUTPUT_BASE/external/<repository name>
.
La recuperación del repositorio se realiza en los siguientes pasos:
PackageLookupFunction
se da cuenta de que necesita un repositorio y crea unRepositoryName
comoSkyKey
, que invoca aRepositoryLoaderFunction
RepositoryLoaderFunction
reenvía la solicitud aRepositoryDelegatorFunction
por motivos poco claros (el código indica que es para evitar la descarga de contenido en caso de reinicios de Skyframe, pero no es un razonamiento sólido).RepositoryDelegatorFunction
descubre la regla de repositorio que se le solicita mediante la iteración sobre los fragmentos del archivo WORKSPACE hasta que se encuentre el repositorio solicitado.- Se encontró el
RepositoryFunction
adecuado que implementa la recuperación del repositorio. Puede ser la implementación de Starlark del repositorio o un mapa hard-coded para los repositorios que se implementan en Java.
Existen varias capas de almacenamiento en caché, ya que recuperar un repositorio puede ser muy costoso:
- Hay una caché para los archivos descargados que tiene la clave de su suma de verificación (
RepositoryCache
). Esto requiere que la suma de verificación esté disponible en el archivo WORKSPACE, pero, de todos modos, es buena para la hermeticidad. Esto se comparte con cada instancia del servidor de Bazel en la misma estación de trabajo, sin importar el lugar de trabajo o la base de salida en los que se ejecuten. - Se escribe un "archivo de marcador" para cada repositorio de
$OUTPUT_BASE/external
que contiene una suma de verificación de la regla que se usó para recuperarlo. Si el servidor de Bazel se reinicia, pero la suma de verificación no cambia, no se vuelve a recuperar. Esto se implementa enRepositoryDelegatorFunction.DigestWriter
. - La opción de línea de comandos
--distdir
designa otra caché que se usa para buscar artefactos que se descargarán. Esto es útil en la configuración empresarial en la que Bazel no debe recuperar elementos aleatorios de Internet.DownloadManager
lo implementa .
Una vez que se descarga un repositorio, los artefactos que este contiene se tratan como artefactos de origen. Esto genera un problema porque Bazel suele verificar la actualización de los artefactos de origen mediante una llamada a stat(), y estos artefactos también se invalidan cuando cambia la definición del repositorio en el que se encuentran. Por lo tanto, los FileStateValue
de un artefacto en un repositorio externo deben depender de su repositorio externo. ExternalFilesHelper
se encarga de esto.
Directorios administrados
A veces, los repositorios externos deben modificar archivos en la raíz del lugar de trabajo (como un administrador de paquetes que aloja los paquetes descargados en un subdirectorio del árbol de fuentes). Esto es contradictorio con la suposición de que Bazel hace que solo el usuario modifique los archivos de origen y que los paquetes hagan referencia a cada directorio en la raíz del lugar de trabajo. Para que este tipo de repositorio externo funcione, Bazel realiza dos acciones:
- Permite al usuario especificar subdirectorios del lugar de trabajo en el que no puede acceder Bazel. Se enumeran en un archivo llamado
.bazelignore
, y la funcionalidad se implementa enBlacklistedPackagePrefixesFunction
. - Codificamos la asignación del subdirectorio del lugar de trabajo al repositorio externo en el que se controla en
ManagedDirectoriesKnowledge
y manejamos a losFileStateValue
que hacen referencia a ellos de la misma manera que los de los repositorios externos normales.
Asignaciones de repositorios
Puede ocurrir que varios repositorios dependan del mismo repositorio, pero en diferentes versiones (esta es una instancia del "problema de dependencia de diamantes"). Por ejemplo, si dos objetos binarios de repositorios separados en la compilación quieren depender de Guava, es probable que ambos hagan referencia a Guava con etiquetas que comiencen con @guava//
y esperen que eso signifique diferentes versiones de este.
Por lo tanto, Bazel permite que uno vuelva a asignar las etiquetas de repositorio externo para que la
string @guava//
pueda hacer referencia a un repositorio de Guava (como @guava1//
) en el
repositorio de un objeto binario y a otro repositorio de Guava (como @guava2//
) el
repositorio del otro.
Como alternativa, también se puede usar para unir diamantes. Si un repositorio depende de @guava1//
y otro depende de @guava2//
, la asignación del repositorio permite que uno vuelva a asignar ambos repositorios para usar un repositorio @guava//
canónico.
La asignación se especifica en el archivo WORKSPACE como el atributo repo_mapping
de las definiciones de repositorios individuales. Luego, aparece en Skyframe como miembro de WorkspaceFileValue
, donde se conecta a lo siguiente:
Package.Builder.repositoryMapping
, que se usa para transformar los atributos con valor de etiqueta de las reglas en el paquete porRuleClass.populateRuleAttributeValues()
Package.repositoryMapping
, que se usa en la fase de análisis (para resolver elementos como$(location)
, que no se analizan en la fase de carga)BzlLoadFunction
para resolver etiquetas en declaraciones load()
Bits de JNI
El servidor de Bazel está_ mayormente_ escrito en Java. La excepción son las partes que Java no puede hacer por sí sola ni puede hacer por sí sola cuando la implementamos. Esto se limita principalmente a la interacción con el sistema de archivos, el control de procesos y varios otros elementos de bajo nivel.
El código de C++ se encuentra en src/main/native y las clases de Java con métodos nativos son las siguientes:
NativePosixFiles
yNativePosixFileSystem
ProcessUtils
WindowsFileOperations
yWindowsFileProcesses
com.google.devtools.build.lib.platform
Resultado de la consola
Emitir un resultado de la consola parece una tarea sencilla, pero la confluencia de ejecutar varios procesos (a veces de forma remota), el almacenamiento en caché detallado, el deseo de tener una salida de terminal agradable y colorida y tener un servidor de larga duración hace que sea no trivial.
Justo después de que la llamada RPC llega del cliente, se crean dos instancias de RpcOutputStream
(para stdout y stderr) que reenvían los datos impresos al cliente. Luego, se unen en un par OutErr
(stdout, stderr). Todo lo que se necesite imprimir en la consola pasa por estos flujos. Luego, estas transmisiones se entregan a BlazeCommandDispatcher.execExclusively()
.
El resultado se imprime de forma predeterminada con secuencias de escape ANSI. Cuando estos no son deseados (--color=no
), se quitan mediante un AnsiStrippingOutputStream
. Además, System.out
y System.err
se redireccionan a estas transmisiones de salida.
De este modo, la información de depuración se puede imprimir con System.err.println()
y, aun así, terminar en la salida de la terminal del cliente (que es diferente de la del servidor). Se debe tener en cuenta que, si un proceso produce un resultado binario (como bazel query --output=proto
), no se realizará la administración de los datos stdout.
Los mensajes cortos (errores, advertencias y similares) se expresan a través de la interfaz EventHandler
. En particular, son diferentes de lo que se publica en EventBus
(esto es confuso). Cada Event
tiene un EventKind
(error, advertencia, información y algunos otros) y puede tener un Location
(el lugar del código fuente que causó el evento).
Algunas implementaciones de EventHandler
almacenan los eventos que recibieron. Se usa para volver a reproducir información en la IU causada por varios tipos de procesamiento en caché, por ejemplo, las advertencias que emite un destino configurado en caché.
Algunos EventHandler
también permiten publicar eventos que, con el tiempo, llegan al bus de eventos (los Event
normales _no _aparecen allí). Estas son implementaciones de ExtendedEventHandler
y su uso principal es reproducir eventos EventBus
almacenados en caché. Todos estos eventos EventBus
implementan Postable
, pero no todo lo que se publica en EventBus
implementa necesariamente esta interfaz; solo aquellos que se almacenan en caché mediante un ExtendedEventHandler
(sería bueno y la mayoría de las cosas sí lo hacen; sin embargo, no se aplica de manera forzosa).
El resultado de la terminal se emite en su mayoría a través de UiEventHandler
, que es responsable de todos los formatos de resultados y los informes de progreso de Bazel. Tiene dos entradas:
- El autobús del evento
- El flujo de eventos canalizado a través de Reporter
La única conexión directa que tiene la maquinaria de ejecución de comandos (por ejemplo, el resto de
Bazel) con la transmisión de RPC al cliente es a través de Reporter.getOutErr()
,
que permite el acceso directo a estas transmisiones. Solo se usa cuando un comando necesita volcar grandes cantidades de datos binarios posibles (como bazel query
).
Perfilado de Bazel
Bazel es rápido. Bazel también es lento, porque las compilaciones tienden a crecer hasta alcanzar el límite de lo soportable. Por este motivo, Bazel incluye un generador de perfiles que se puede
usar para generar perfiles de compilaciones y de Bazel. Se implementa en una clase llamada Profiler
de forma correcta. Está activada de forma predeterminada, aunque solo registra datos abreviados para que la sobrecarga sea tolerable. La línea de comandos --record_full_profiler_data
hace que registre todo lo que pueda.
Emite un perfil en el formato del generador de perfiles de Chrome; es mejor verlo en Chrome. Su modelo de datos es el de las pilas de tareas: se pueden iniciar tareas y finalizar tareas, y deben estar anidadas perfectamente entre sí. Cada subproceso de Java obtiene su propia pila de tareas. TODO: ¿Cómo funciona con las acciones y el estilo de paso de continuación?
El generador de perfiles se inicia y se detiene en BlazeRuntime.initProfiler()
y BlazeRuntime.afterCommand()
, respectivamente, y se trata de permanecer activo durante el mayor tiempo posible para poder generar perfiles de todo. Para agregar algo al perfil, llama a Profiler.instance().profile()
. Muestra un Closeable
, cuyo cierre representa el final de la tarea. Es mejor usarla con declaraciones de prueba con recursos.
También hacemos perfiles de memoria rudimentarios en MemoryProfiler
. También está activada y registra principalmente los tamaños máximos de montón y el comportamiento de recolección de elementos no utilizados.
Probando Bazel
Bazel tiene dos tipos principales de pruebas: las que observan Bazel como una "caja negra" y las que solo ejecutan la fase de análisis. Llamamos a las anteriores "pruebas de integración" y las últimas "pruebas de unidades", aunque son más similares a las pruebas de integración que están menos integradas. También tenemos algunas pruebas de unidades reales, en las que son necesarias.
En las pruebas de integración, tenemos dos tipos:
- Uno implementado con un framework de prueba Bash muy elaborado en
src/test/shell
- Las que se implementan en Java Estos se implementan como subclases de
BuildIntegrationTestCase
.
BuildIntegrationTestCase
es el framework de prueba de integración preferido, ya que está bien equipado para la mayoría de las situaciones de prueba. Como es un framework de Java, proporciona capacidad de depuración y una integración continua con muchas herramientas de desarrollo comunes. Hay muchos ejemplos de clases BuildIntegrationTestCase
en el
repositorio de Bazel.
Las pruebas de análisis se implementan como subclases de BuildViewTestCase
. Hay un sistema de archivos desde cero que puedes usar para escribir archivos BUILD
. Luego, varios métodos auxiliares pueden solicitar destinos configurados, cambiar la configuración y confirmar varios aspectos sobre el resultado del análisis.