Este documento es una descripción de la base de código y de cómo se estructura Bazel. Está destinado a personas que quieran 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 KLOC y código de prueba de ~260 KLOC) y nadie está familiarizado con todo el panorama: todos conocen muy bien su valle en particular, pero pocos saben lo que hay sobre las colinas en cada dirección.
Para que las personas que están en la mitad de su recorrido no se encuentren en un bosque oscuro con la pérdida directa del recorrido, 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.
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 la verdad", sino que se deriva de un árbol de fuentes interno de Google que contiene funciones adicionales que no son útiles fuera de Google. El objetivo a largo plazo es hacer de GitHub la fuente de información.
Las contribuciones se aceptan a través del mecanismo normal de solicitudes de extracción de GitHub, y un Googler las importa de forma manual al árbol de fuentes interno y, luego, las vuelve a exportar a GitHub.
Arquitectura cliente-servidor
La mayor parte de Bazel reside en un proceso del servidor que permanece en la RAM entre compilaciones. Esto permite que Bazel mantenga el estado entre compilaciones.
Por este motivo, 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 ejecutará y otras están después (-c opt
). El primer tipo se denomina "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 origen asociado ("espacio de trabajo") y cada espacio de trabajo suele tener una sola instancia de servidor activa. Para evitar esto, especifica una base de salida personalizada (consulta la sección "Diseño del directorio" para obtener más información).
Bazel se distribuye como un solo ejecutable ELF que también es un archivo .zip válido.
Cuando escribes bazel
, el ejecutable ELF anterior implementado en C++ (el “cliente”) obtiene el control. Establece un proceso de servidor adecuado con los siguientes pasos:
- Comprueba si ya se extrajo. De lo contrario, lo hará. De aquí proviene la implementación del servidor.
- Verifica si hay una instancia de servidor activa que funcione: que esté en ejecución, que tenga las opciones de inicio correctas y que use el directorio de espacio de trabajo correcto. Para encontrar el servidor en ejecución, busca en el directorio
$OUTPUT_BASE/server
, donde hay un archivo de bloqueo con el puerto en el que el servidor está escuchando. - Si es necesario, finaliza el proceso del servidor anterior.
- Si es necesario, inicia un nuevo proceso del servidor.
Una vez que se prepara un proceso de servidor adecuado, el comando que se debe ejecutar se le comunica a través de una interfaz gRPC y, luego, el resultado de Bazel se canaliza a la terminal. Solo se puede ejecutar un comando a la vez. Esto se implementa con un mecanismo de bloqueo elaborado con partes en C++ y partes en Java. Existe cierta infraestructura para ejecutar varios comandos en paralelo, ya que la incapacidad de ejecutar bazel version
en paralelo con otro comando es un poco vergonzosa. 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 el cliente debe mostrar. Una alteración interesante es la implementación de bazel run
: el
trabajo de este comando es ejecutar algo que Bazel acaba de compilar, pero no puede hacerlo
desde el proceso del servidor porque no tiene una terminal. En su lugar, le indica al cliente qué objeto binario debe ujexec()
y con qué argumentos.
Cuando uno presiona Ctrl + C, el cliente lo traduce a una llamada a Cancel en la conexión de gRPC, que intenta finalizar el comando lo antes posible. Después de la tercera combinación Ctrl + C, el cliente envía un 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()
controla las llamadas de gRPC desde el cliente.
Diseño del directorio
Bazel crea un conjunto de directorios un poco complicado durante una compilación. Hay una descripción completa en Diseño del directorio de salida.
El "lugar de trabajo" es el árbol de origen en el que se ejecuta Bazel. Por lo general, corresponde a algo que verificaste en el control de 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 instalación" es donde se extrae Bazel. Esto se hace automáticamente
y cada versión de Bazel obtiene un subdirectorio en función de su suma de verificación en la
base de instalación. De forma predeterminada, está en $OUTPUT_USER_ROOT/install
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 escribe la instancia de Bazel adjunta a un espacio de trabajo específico. Cada base de salida tiene como máximo una instancia del servidor de Bazel en ejecución en cualquier momento. Por lo general, está en $OUTPUT_USER_ROOT/<checksum of the path
to the workspace>
. Se puede cambiar con la opción de inicio --output_base
, que, entre otras cosas, es útil para evitar la limitación de que solo se puede ejecutar una instancia de Bazel en cualquier espacio de trabajo en un momento determinado.
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>
. Planeamos 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 de ejecutar un comando
Una vez que el servidor de Bazel obtiene el control y se le informa sobre un comando que debe ejecutar, se produce la siguiente secuencia de eventos:
Se le informa a
BlazeCommandDispatcher
sobre la solicitud nueva. Decide si el comando necesita un espacio 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 está ejecutando otro comando.Se encontró el comando correcto. Cada comando debe implementar la interfaz
BlazeCommand
y debe tener la anotación@Command
(esto es un antipatrón, sería bueno que todos los metadatos que necesita un comando se describieran mediante 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 de eventos que ocurren durante la compilación. Algunos de ellos se exportan fuera de Bazel bajo la supervisión del Protocolo de eventos de compilación para informar al mundo cómo se realiza 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étera.
BuildTool
implementa esta funcionalidad.El conjunto de patrones de destino en la línea de comandos se analiza y se resuelven los comodines, como
//pkg:all
y//pkg/...
. Esto se implementa enAnalysisPhaseRunner.evaluateTargetPatterns()
y se vuelve a definir en Skyframe comoTargetPatternPhaseValue
.La fase de carga o análisis se ejecuta para producir el grafo de acciones (un grafo acíclico dirigido de comandos que se deben ejecutar para la compilación).
Se ejecuta la fase de ejecución. Esto significa que se ejecutan todas las acciones necesarias para compilar los objetivos de nivel superior que se solicitan.
Opciones de línea de comandos
Las opciones de la 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 las opciones de línea de comandos que se relacionan 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 (a través de RuleContext.getFragment()
en Java o ctx.fragments
en Starlark).
Algunos de ellos (por ejemplo, si se debe incluir el escaneo en C++) se leen
en la fase de ejecución, pero eso siempre requiere una canalización explícita, ya que
BuildConfiguration
no está disponible en ese momento. Para obtener más información, consulta la sección "Parámetros de configuración".
ADVERTENCIA: Nos gusta fingir que las instancias de OptionsBase
son inmutables y usarlas de esa manera (como parte de SkyKeys
). No es el caso, y modificarlas es una forma muy buena de romper Bazel de maneras sutiles que son difíciles de depurar. Lamentablemente, hacerlos realmente inmutables es un gran esfuerzo.
(Modificar un FragmentOptions
inmediatamente después de la construcción antes de que cualquier otra persona tenga la oportunidad de mantener una referencia a él y antes de que se llame a equals()
o hashCode()
está bien).
Bazel obtiene información sobre las clases de opciones de las siguientes maneras:
- Algunos están integrados en Bazel (
CommonCommandOptions
). - Desde 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 algo de texto de ayuda.
El tipo de Java del valor de una opción de línea de comandos suele ser algo simple (una cadena, un número entero, un valor booleano, una etiqueta, etcétera). Sin embargo, también admitimos opciones de tipos más complejos. En este caso, la tarea de convertir de la cadena de línea de comandos al tipo de datos corresponde a una implementación de com.google.devtools.common.options.Converter
.
El árbol fuente, como lo ve Bazel
Bazel se dedica a compilar software, lo que se logra leyendo y interpretando el código fuente. La totalidad del código fuente en el que opera Bazel se denomina “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. El ancestro de Bazel, Blaze, funcionaba en un monorepo, es decir, un solo árbol de origen 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 a Bazel se denomina “repositorio principal” y los demás se denominan “repositorios externos”.
Un repositorio está marcado por un archivo llamado WORKSPACE
(o WORKSPACE.bazel
) en su directorio raíz. Este archivo contiene información "global" para toda la compilación, por ejemplo, el conjunto de repositorios externos disponibles. Funciona como un archivo de Starlark normal, lo que significa que se pueden usar load()
en otros archivos de Starlark.
Por lo general, se usa para extraer repositorios que necesita un repositorio al que se hace referencia de forma explícita (lo llamamos "patrón deps.bzl
").
El código de los repositorios externos se vincula simbólicamente o se descarga en $OUTPUT_BASE/external
.
Cuando se ejecuta la compilación, se debe unir todo el árbol de origen. Esto lo hace SymlinkForest
, que crea un symlink para cada paquete del repositorio principal a $EXECROOT
y cada repositorio externo a $EXECROOT/external
o $EXECROOT/..
(por supuesto, lo primero hace que sea imposible tener un paquete llamado external
en el repositorio principal; por eso estamos migrando de él).
Paquetes
Cada repositorio se compone de paquetes, una colección de archivos relacionados y una especificación de las dependencias. Se especifican en un archivo llamado BUILD
o BUILD.bazel
. Si existen ambos, Bazel prefiere BUILD.bazel
. El motivo por el que aún se aceptan los archivos BUILD
es que Blaze, el ancestro de Bazel, usó este nombre de archivo. Sin embargo, resultó ser un segmento de ruta de acceso de uso general, en especial en Windows, donde los nombres de archivo no distinguen mayúsculas de minúsculas.
Los paquetes son independientes entre sí: los cambios en el archivo BUILD
de un paquete no pueden hacer que cambien otros paquetes. La adición o eliminación de archivos BUILD
_puede_ cambiar otros paquetes, ya que los globs recursivos se detienen en los límites de los paquetes y, por lo tanto, la presencia de un archivo BUILD
detiene la recursividad.
La evaluación de un archivo BUILD
se denomina "carga del paquete". Se implementa en la clase PackageFactory
, funciona llamando al intérprete Starlark y requiere conocimiento del conjunto de clases de reglas disponibles. El resultado de la carga del paquete es un objeto Package
. En su mayoría, es un mapa de una cadena (el nombre de un objetivo) al objetivo en sí.
Una gran parte de la complejidad durante la carga de paquetes es el globbing: Bazel no requiere que todos los archivos de origen se enumeren de forma explícita y, en su lugar, puede ejecutar globs (como glob(["**/*.java"])
). A diferencia de la shell, admite globs recursivos que descienden a subdirectorios (pero no a 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.
La expansión de glob se implementa en las siguientes clases:
LegacyGlobber
, un globador rápido y sin conocimiento de SkyframeSkyframeHybridGlobber
, una versión que usa Skyframe y vuelve al globizador heredado para evitar los "reinicios de Skyframe" (que se describen a continuación)
La clase Package
contiene algunos miembros que se usan exclusivamente para analizar el archivo WORKSPACE y que no tienen sentido para paquetes reales. Esta es una falla de diseño porque los objetos que describen paquetes normales no deben contener campos que describan algo más. Por ejemplo:
- Las asignaciones del repositorio
- Las cadenas de herramientas registradas
- Las plataformas de ejecución registradas
Idealmente, habría más separación entre el análisis del archivo WORKSPACE y el análisis de paquetes normales, de modo que Package
no tenga que satisfacer las necesidades de ambos. Lamentablemente, esto es difícil de hacer porque los dos están conectados bastante.
Etiquetas, objetivos y reglas
Los paquetes se componen de destinos, que tienen los siguientes tipos:
- Files: Los elementos que son la entrada o el resultado de la compilación En la jerga 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: Describen los pasos para obtener los resultados a partir de las entradas. Por lo general, se asocian con un lenguaje de programación (como
cc_library
,java_library
opy_library
), pero hay algunos que no se pueden usar en el lenguaje (comogenrule
ofilegroup
). - Grupos de paquetes: Se analizan en la sección Visibilidad.
El nombre de un objetivo 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 de origen) en relación con el directorio del paquete. Cuando te refieres a un destino en la línea de comandos, se pueden omitir algunas partes de la etiqueta:
- Si se omite el repositorio, se considera que la etiqueta está en el repositorio principal.
- Si se omite la parte del paquete (como
name
o:name
), se considera que la etiqueta está en el paquete del directorio de trabajo actual (no se permiten rutas de acceso relativas que contengan referencias de nivel superior (..)).
Un tipo 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 (las llamadas "reglas nativas", tipo RuleClass
). A largo plazo, cada regla específica del lenguaje se implementará en Starlark, pero algunas familias de reglas heredadas (como Java o C++) aún están en Java por el momento.
Las clases de reglas de Starlark deben importarse al comienzo de los archivos BUILD
con la sentencia load()
, mientras que Bazel conoce "de forma innata" las clases de reglas de Java, ya que están registradas con ConfiguredRuleClassProvider
.
Las clases de reglas contienen información como la siguiente:
- Sus atributos (como
srcs
ydeps
): sus tipos, valores predeterminados, restricciones, etcétera - Las transiciones de configuración y los aspectos asociados a cada atributo, si los hay
- La implementación de la regla
- Los proveedores de información transitiva que la regla "suele" crear
Nota de terminología: En la base de código, a menudo usamos "Rule" para hacer referencia al objetivo que crea una clase de regla. Sin embargo, en Starlark y en la documentación para el usuario, se debe usar "Rule" exclusivamente para referirse a la clase de regla en sí; el objetivo es solo un "objetivo". Además, ten en cuenta que, a pesar de que RuleClass
tiene "class" en su nombre, no hay una relación de herencia de Java entre una clase de regla y los objetivos de ese tipo.
Skyframe
El framework de evaluación subyacente a 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 apuntan desde cualquier dato hacia sus dependencias, es decir, otros datos que deben conocerse para su construcción.
Los nodos del gráfico se denominan SkyValue
y sus nombres se denominan SkyKey
. Ambos son profundamente inmutables; solo se debe poder acceder a los objetos inmutables desde ellos. Esta invariancia casi siempre se mantiene y, en caso de que no sea así (como en el caso de las clases de opciones individuales BuildOptions
, que es un miembro de BuildConfigurationValue
y su SkyKey
), nos esforzamos mucho por no cambiarlas o cambiarlas solo de formas que no se puedan observar desde el exterior.
De esto se deduce que todo lo que se calcula dentro de Skyframe (como los objetivos 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, una SkyValue
por línea. Lo mejor es hacerlo para compilaciones diminutas, ya que pueden ser bastante grandes.
Skyframe se encuentra en el paquete com.google.devtools.build.skyframe
. El paquete com.google.devtools.build.lib.skyframe
, que tiene un nombre similar, contiene la implementación de Bazel sobre Skyframe. Aquí encontrarás más información sobre Skyframe.
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, puede solicitar otras dependencias de 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 Skyframe sepa volver a evaluar la función cuando cambie alguna de sus dependencias. En otras palabras, el almacenamiento en caché y el procesamiento incremental de Skyframe funcionan con la granularidad de SkyFunction
y SkyValue
.
Cada vez que un SkyFunction
solicite una dependencia que no esté disponible, getValue()
mostrará un valor nulo. Luego, la función debe devolver el control a Skyframe mostrando 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 con un resultado no nulo.
Una consecuencia de esto es que cualquier cálculo realizado dentro de 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, normalmente solucionamos este problema de la siguiente manera:
- Declarar dependencias en lotes (mediante
getValuesAndExceptions()
) para limitar la cantidad de reinicios - Dividir un
SkyValue
en partes separadas que calculan diferentesSkyFunction
para que se puedan calcular y almacenar en caché de forma independiente Esto se debe hacer de forma estratégica, ya que tiene el potencial de aumentar el uso de la memoria. - Almacenamiento del estado entre reinicios, ya sea con
SkyFunction.Environment.getState()
o manteniendo una caché estática ad hoc "detrás de Skyframe".
Básicamente, necesitamos estos tipos de soluciones alternativas, ya que solemos tener cientos de miles de nodos de Skyframe en tránsito y Java no admite subprocesos ligeros.
Starlark
Starlark es el lenguaje específico del dominio que las personas usan 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 más importante, garantías de inmutabilidad sólidas para permitir lecturas simultáneas. No es Turing-completo, lo que desalienta a algunos (pero no a todos) los usuarios a intentar realizar tareas de programación generales dentro del lenguaje.
Starlark se implementa en el paquete net.starlark.java
.
También tiene una implementación independiente de Go aquí. Actualmente, la implementación de Java que se usa en Bazel es 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 Starlark que se ejecuta en este contexto solo tiene acceso al contenido del archivoBUILD
y a los archivos.bzl
que carga. - Definiciones de reglas. De esta manera, 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 a los datos que proporcionan sus dependencias directas (más información sobre este tema más adelante).
- El archivo WORKSPACE. Aquí es donde se definen los repositorios externos (código que no está en el árbol fuente principal).
- Definiciones de las reglas del repositorio. Aquí es donde se definen los nuevos tipos de repositorios externos. El código Starlark que se ejecuta en este contexto puede ejecutar código arbitrario en la máquina en la que se ejecuta Bazel y salir del espacio de trabajo.
Los dialectos disponibles para los archivos BUILD
y .bzl
son ligeramente diferentes porque expresan diferentes cosas. Puedes encontrar una lista de las diferencias aquí.
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 “objetivo configurado”, que es, de manera bastante razonable, un par (objetivo, configuración).
Se llama "fase de carga/análisis" porque se puede dividir en dos partes distintas, que solían ser serializadas, pero ahora pueden superponerse con el tiempo:
- Cargar paquetes, es decir, convertir archivos
BUILD
en los objetosPackage
que los representan - Analizar los destinos configurados, es decir, ejecutar la implementación de las reglas para producir el gráfico de acciones
Cada destino configurado en el cierre transitivo de los destinos configurados solicitados en la línea de comandos debe analizarse de abajo hacia arriba; es decir, primero los nodos de hoja y, luego, hasta los de la línea de comandos. Las entradas para el análisis de un solo destino configurado son las siguientes:
- La configuración. ("cómo" compilar esa regla; por ejemplo, la plataforma de destino, pero también elementos como las opciones de línea de comandos que el usuario desea que se pasen al compilador de C++)
- Las dependencias directas. Sus proveedores de información transitiva están disponibles para la regla que se analiza. Se llaman así porque proporcionan una “combinación” de la información en el cierre transitivo del destino configurado, como todos los archivos .jar en la ruta de acceso de clases o todos los archivos .o que deben vincularse a un objeto binario C++).
- El objetivo en sí Este es el resultado de cargar el paquete en el que se encuentra el objetivo. En el caso de las reglas, esto incluye sus atributos, que suelen ser lo importante.
- La implementación del objetivo configurado. En el caso de las reglas, puede ser en Starlark o en Java. Todos los objetivos 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 los destinos que dependen de ella pueden acceder a los servicios
- 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 el equivalente del argumento ctx
de las reglas de Starlark. Su API es más potente, pero, al mismo tiempo, es más fácil hacer cosas malas™, por ejemplo, escribir código cuya complejidad de tiempo o espacio es cuadrática (o peor), hacer que el servidor de Bazel falle con una excepción de Java o violar invariantes (como modificar, de forma inadvertida, una instancia de Options
o hacer que un objetivo configurado sea mutable).
El algoritmo que determina las dependencias directas de un destino configurado se encuentra en DependencyResolver.dependentNodeMap()
.
Configuraciones
Las configuraciones son el "cómo" de compilar 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 cuando se realiza una compilación cruzada o cuando se compila una app para Android de gran tamaño (una que contiene código nativo para varias arquitecturas de CPU).
De forma conceptual, la configuración es una instancia BuildOptions
. Sin embargo, en la práctica, BuildOptions
está unido a BuildConfiguration
, que proporciona varias funciones adicionales. Se propaga desde la parte superior del gráfico de dependencias hasta la parte inferior. Si cambia, se debe volver a analizar la compilación.
Esto genera anomalías, como tener que volver a analizar toda la compilación si, por ejemplo, cambia la cantidad de ejecuciones de prueba solicitadas, aunque eso solo afecte a los destinos de prueba (tenemos planes para "recortar" las configuraciones para que esto no sea así, 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 se hace para evitar errores (como reglas de Python que usan 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 destinos 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 suceder en dos lugares:
- En un perímetro de dependencia Estas transiciones se especifican en
Attribute.Builder.cfg()
y son funciones de unRule
(donde se produce la transición) y unBuildOptions
(la configuración original) a una o másBuildOptions
(la configuración de salida). - En cualquier borde entrante a un destino configurado. Estos se especifican en
RuleClass.Builder.cfg()
.
Las clases relevantes son TransitionFactory
y ConfigurationTransition
.
Se usan transiciones de configuración, por ejemplo:
- Para declarar que se usa una dependencia en particular durante la compilación y, por lo tanto, debe compilarse en la arquitectura de ejecución
- Para declarar que una dependencia en particular se debe compilar para varias arquitecturas (como para el código nativo en APKs de Android grandes)
Si una transición de configuración genera varias configuraciones, se denomina transición dividida.
Las transiciones de configuración también se pueden implementar en Starlark (documentación aquí).
Proveedores de información transitiva
Los proveedores de información transitiva son una forma (y la _única_ forma) de que los destinos configurados informen sobre otros destinos configurados que dependen de ellos. El motivo por el que se incluye el término “transitivo” en su nombre es que, por lo general, se trata de una especie de resumen de la clausura transitiva de un objetivo configurado.
Por lo general, existe 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 combinación de FileProvider
, FilesToRunProvider
y RunfilesProvider
porque esa API se considera más similar a Starlark que una transliteración directa de Java).
Su clave es una de las siguientes cosas:
- 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. Esto es heredado y se desaconseja, 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. Esto se puede crear desde Starlark con la función
provider()
y es la forma recomendada de crear proveedores nuevos. El símbolo está representado por 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 de quitarlo) y no se puede acceder a las subclases de 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 que se muestra. Consta de lo siguiente:
- Su
filesToBuild
, el concepto confuso de "el conjunto de archivos que representa esta regla". Estos son los archivos que se compilan cuando el destino configurado está en la línea de comandos o en los srcs de un genrule. - Sus archivos de ejecución, normales y de datos.
- Sus grupos de salida. Estos son varios "otros conjuntos de archivos" que puede compilar la regla. Se puede acceder a ellos con el atributo output_group de la
regla filegroup en BUILD y con el proveedor
OutputGroupInfo
en Java.
Archivos de ejecución
Algunos objetos binarios necesitan archivos de datos para ejecutarse. Un ejemplo importante son las pruebas que necesitan archivos de entrada. En Bazel, esto se representa con el concepto de “runfiles”. Un “árbol de runfiles” 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 dirigen a los archivos en la fuente de los árboles de salida.
Un conjunto de archivos de ejecución se representa como una instancia de Runfiles
. Es conceptualmente un mapa desde la ruta de un archivo en el árbol de archivos de ejecución hasta la instancia Artifact
que lo representa. Es un poco más complicado que usar una sola Map
por dos motivos:
- La mayoría de las veces, la ruta de acceso de runfiles de un archivo es la misma que su execpath. Los usamos para ahorrar algo de RAM.
- Existen varios tipos heredados de entradas en árboles de archivos de ejecución, que también deben representarse.
Los archivos de ejecución se recopilan con RunfilesProvider
: una instancia de esta clase representa los archivos de ejecución de un destino configurado (como una biblioteca) y sus necesidades de cierre transitivo, y se recopilan como un conjunto anidado (de hecho, se implementan con conjuntos anidados en segundo plano): cada destino une los archivos de ejecución de sus dependencias, agrega algunos de los suyos y, luego, envía el conjunto resultante hacia arriba en el gráfico de dependencias. Una instancia RunfilesProvider
contiene dos instancias Runfiles
, una para cuando se depende de la regla mediante el atributo “datos” y una para cada otro tipo de dependencia entrante. Esto se debe a que, a veces, un destino presenta diferentes archivos de ejecución cuando se depende de él a través de un atributo de datos. Este es un comportamiento heredado no deseado que aún no hemos quitado.
Los archivos de ejecución 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). Para ello, se requieren los siguientes componentes adicionales:
- El manifiesto de runfiles de entrada. Esta es una descripción serializada del árbol de archivos de ejecución. Se usa como proxy para el contenido del árbol de runfiles, y Bazel supone que el árbol de runfiles cambia solo si cambia el contenido del manifiesto.
- El manifiesto de runfiles de salida. Las bibliotecas del entorno de ejecución que manejan árboles de runfiles, en particular en Windows, que a veces no admiten vínculos simbólicos, usan esto.
- El intermediario de runfiles. Para que exista un árbol de archivos de ejecución, es necesario compilar el árbol de symlink y el artefacto al que apuntan. Para disminuir la cantidad de aristas de dependencia, se puede usar el intermediario de runfiles para representar todo esto.
- Argumentos de la línea de comandos para ejecutar el objeto binario cuyos archivos de ejecución representa el objeto
RunfilesSupport
.
Aspectos
Los aspectos son una forma de "propagar el procesamiento por el gráfico de dependencia". 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 compilar la implementación de un mensaje de búfer de protocolo (la "unidad básica" de los búferes de protocolo) en cualquier lenguaje de programación debe estar acoplado a la regla proto_library
para que, si dos destinos 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 construyen es muy similar a la forma en que 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 destinos configurados, también conoce 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 con la función Attribute.Builder.aspects()
. Existen algunas
clasas con nombres confusos que participan en el proceso:
AspectClass
es la implementación del aspecto. Puede estar 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 proveedores que proporciona y contiene una referencia a su implementación, como la instanciaAspectClass
adecuada. Es similar aRuleClass
.AspectParameters
es una forma de parametrizar un aspecto que se propaga hacia abajo en el gráfico de dependencias. Actualmente es una cadena a la asignación de cadenas. Un buen ejemplo de por qué es útil son los búferes de protocolo: si un lenguaje tiene varias APIs, la información sobre para qué API se deben compilar los búferes de protocolo debe propagarse por el gráfico de dependencias.Aspect
representa todos los datos necesarios para calcular un aspecto que se propaga por 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 algo inesperada es que los aspectos pueden adjuntarse a otros aspectos. Por ejemplo, es probable que un aspecto que recopile la ruta de clase de un IDE de Java quiera saber sobre todos los archivos .jar de la ruta de clase, pero algunos de ellos son búferes de protocolo. En ese caso, el aspecto del IDE se adjuntará al par (regla proto_library
+ aspecto de proto de Java).
La complejidad de los aspectos sobre 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 en el lenguaje de Bazel (documentación completa aquí).
Una plataforma se describe mediante una asignación de par clave-valor de 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 restricciones más usados en el repositorio @platforms
.
El concepto de cadena de herramientas proviene del hecho de que, según las plataformas en las que se ejecuta la compilación y las plataformas a las que se orienta, es posible que se deban usar diferentes compiladores. Por ejemplo, una cadena de herramientas de C++ en particular puede ejecutarse en un SO específico y poder orientarse a otros SO. Bazel debe determinar el compilador de C++ que se usa en función de la ejecución establecida y la plataforma de destino (consulta la documentación sobre las cadenas de herramientas aquí).
Para ello, las cadenas de herramientas se anotan con el conjunto de restricciones de ejecución y de la plataforma 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 qué tipo (como C++ o Java) es de la cadena de herramientas (esta última se representa con 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 para realizar la resolución de la cadena de herramientas y las reglas *_toolchain()
específicas del lenguaje contienen mucha más información que eso, por lo que tardan más en cargarse.
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
.
La plataforma de destino para un destino configurado se determina mediante PlatformOptions.computeTargetPlatform()
. Es una lista de plataformas porque, en algún momento, queremos admitir varias plataformas de destino, pero aún no se implementó.
ToolchainResolutionFunction
determina el conjunto de cadenas de herramientas que se usarán para un destino configurado. Es una función de lo siguiente:
- El conjunto de cadenas de herramientas registradas (en el archivo WORKSPACE y en 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 objetivo 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, en esencia, un mapa del tipo de cadena de herramientas (representado como una instancia de ToolchainTypeInfo
) a la etiqueta de la cadena de herramientas seleccionada. Se llama "unload" porque no contiene las cadenas de herramientas en sí, sino solo sus etiquetas.
Luego, las cadenas de herramientas se cargan con ResolvedToolchainContext.load()
y las usa 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 representadas por varias marcas de configuración, como --cpu
. Estamos realizando la transición gradual al sistema anterior. Para manejar casos en los que las personas confían en los valores de configuración heredados, implementamos asignaciones de plataforma para traducir entre las marcas heredadas y las restricciones de la plataforma de estilo nuevo.
Su código está en PlatformMappingFunction
y usa un "lenguaje pequeño" que no es Starlark.
Limitaciones
A veces, se quiere designar un objetivo como compatible con solo algunas plataformas. Lamentablemente, Bazel tiene varios mecanismos para lograr este objetivo:
- Restricciones específicas de la regla
environment_group()
/environment()
- Restricciones de la plataforma
Las restricciones específicas de reglas se usan principalmente en Google para las reglas de Java. Se están eliminando y no están disponibles en Bazel, pero el código fuente puede contener referencias a ellas. El atributo que administra esto se denomina constraints=
.
environment_group() y environment()
Estas reglas son un mecanismo heredado y no se usan ampliamente.
Todas las reglas de compilación pueden declarar para qué "entornos" se pueden compilar, en los que un "entorno" es una instancia de la regla environment()
.
Existen varias formas de especificar los entornos compatibles para una regla:
- A través del atributo
restricted_to=
. Esta es la forma más directa de especificación, ya que declara el conjunto exacto de entornos que admite la regla para este grupo. - A través del atributo
compatible_with=
. Esto declara los entornos que admite una regla, además de los entornos "estándares" que se admiten de forma predeterminada. - A través de los atributos
default_restricted_to=
ydefault_compatible_with=
a nivel del paquete. - A través de especificaciones predeterminadas en las reglas
environment_group()
. Cada entorno pertenece a un grupo de pares relacionados por tema (como "arquitecturas de CPU", "versiones de JDK" o "sistemas operativos móviles"). La definición de un grupo de entorno incluye cuál de estos entornos debe ser compatible con "predeterminado" si no se especifica lo contrario en los atributosrestricted_to=
oenvironment()
. Una regla sin esos atributos hereda todos los valores predeterminados. - A través de una clase de regla predeterminada Esto anula los valores predeterminados globales para todas las instancias de la clase de regla dada. Esto se puede usar, por ejemplo, para que todas las reglas de
*_test
se puedan probar sin que cada instancia tenga que declarar esta función 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, en última instancia, crea un objetivo epónimo. Esto se hace para evitar una dependencia circular que surgiría porque cada entorno debe declarar el grupo de entornos al que pertenece y cada grupo de entornos 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 se encuentra en RuleContextConstraintSemantics
y TopLevelConstraintSemantics
.
Restricciones de la plataforma
La manera "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 las 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), procura evitar que el resto dependa de tu código de manera arbitraria. De lo contrario, según la ley de Hyrum, las personas se acostumbrarán a depender de comportamientos que considerabas detalles de la implementación.
Bazel admite esto a través del mecanismo llamado visibilidad: puedes declarar que solo se puede depender de un objetivo en particular con el atributo visibility. Este atributo es un poco especial porque, aunque contiene una lista de etiquetas, estas etiquetas pueden codificar un patrón sobre los nombres de los paquetes en lugar de un puntero a cualquier destino en particular. (Sí, esta 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 completamente privada) o una lista de etiquetas. - Las etiquetas pueden hacer referencia a grupos de paquetes (lista predefinida de paquetes), a paquetes directamente (
//pkg:__pkg__
) o a 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 objetivo (
PackageGroup
) y objetivo configurado (PackageGroupConfiguredTarget
). Si quisiéramos, podríamos reemplazarlos por reglas simples. Su lógica se implementa con la ayuda de lo siguiente:PackageSpecification
, que corresponde a un solo patrón como//pkg/...
;PackageGroupContents
, que corresponde a un solo atributopackages
depackage_group
; yPackageSpecificationProvider
, que se agrega a unpackage_group
y suincludes
transitivo. - La conversión de listas de etiquetas de visibilidad a dependencias se realiza en
DependencyResolver.visitTargetVisibility
y en algunos otros lugares misceláneos. - La verificación real se realiza en
CommonPrerequisiteValidator.validateDirectPrerequisiteVisibility()
.
Conjuntos anidados
A menudo, un destino configurado agrega un conjunto de archivos de sus dependencias, agrega los suyos y une el conjunto agregado en un proveedor de información transitivo 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
- Es el conjunto de archivos .jar que deben estar en la ruta de acceso de clases para que una regla de Java compile o ejecute.
- Es el conjunto de archivos de Python en la clausura transitiva de una regla de Python.
Si lo hiciéramos de forma ingenua con, por ejemplo, List
o Set
, terminaríamos con un uso de memoria cuadrático: si hay una cadena de N reglas y cada regla agrega un archivo, tendríamos 1+2+…+N miembros de la colección.
Para evitar este problema, se nos ocurrió el concepto de NestedSet
. Es una estructura de datos que se compone de otras instancias de NestedSet
y algunos miembros propios, lo que forma un grafo acíclico dirigido
de conjuntos. Son inmutables y se puede iterar sobre sus miembros. Definimos un orden de iteración múltiple (NestedSet.Order
): pedido anticipado, pedido posterior, topológico (un nodo siempre aparece después de sus principales) y "no me importa, pero debería ser el mismo cada vez".
La misma estructura de datos se llama depset
en Starlark.
Artefactos y acciones
La compilación real consiste en un conjunto de comandos que se deben ejecutar para producir el resultado que el usuario desea. Los comandos se representan como instancias de la clase Action
y los archivos se representan como instancias de la clase Artifact
. Se organizan en un grafo bipartito, dirigido y acíclico llamado “grafo de acciones”.
Los artefactos pueden ser de dos tipos: artefactos de origen (los que están disponibles antes de que Bazel comience a ejecutarse) y artefactos derivados (los que se deben compilar). Los artefactos derivados pueden ser de varios tipos:
- ** Artefactos normales. **Se verifican si están actualizados calculando su suma de comprobación, con mtime como atajo. No calculamos la suma de comprobación del archivo si su ctime no cambió.
- Artefactos de symlink sin resolver. Para verificar si están actualizados, se llama a readlink(). A diferencia de los artefactos normales, estos pueden ser symlinks colgantes. Por lo general, se usa en casos en los que se agrupan algunos archivos en algún tipo de archivo.
- Artefactos de árbol. No son archivos individuales, sino árboles de directorios. Se verifica su actualización mediante el conjunto de archivos que contiene y su contenido. Se representan como
TreeArtifact
. - Artefactos de metadatos constantes. Los cambios en estos artefactos no activan una recompilación. Se usa exclusivamente para la información de sellos de compilación: no queremos volver a compilarla 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 no resueltos, solo que aún no los implementamos (aunque deberíamos hacerlo; hacer referencia a un directorio de origen en un archivo BUILD
es uno de los pocos problemas de incorrección conocidos de larga data con Bazel; tenemos una implementación que funciona de alguna manera, que está habilitada por la propiedad JVM BAZEL_TRACK_SOURCE_DIRECTORIES=1
).
Un tipo notable de Artifact
son los intermediarios. Se indican con instancias de Artifact
que son los resultados de MiddlemanAction
. Se usan para casos especiales en algunos casos:
- Los intermediarios agregadores se usan para agrupar artefactos. Esto se hace para que, si muchas acciones usan el mismo conjunto grande de entradas, no tengamos N × M aristas de dependencia, solo N + M (se reemplazan por conjuntos anidados).
- Los intermediarios de dependencias de programación garantizan que una acción se ejecute antes que otra.
Se usan principalmente para linting, pero también para la compilación de C++ (consulta
CcCompilationContext.createMiddleman()
para obtener una explicación). - Los intermediarios de runfiles se usan para garantizar la presencia de un árbol de runfiles, de modo que no sea necesario 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 configurar
- Anotaciones que describen el entorno (como la plataforma) en el que se debe ejecutar \
También hay algunos otros casos especiales, como escribir un archivo cuyo contenido conoce Bazel. Son una subclase de AbstractAction
. La mayoría de las acciones son SpawnAction
o StarlarkAction
(las mismas, pero podría decirse que no deberían ser clases separadas), aunque Java y C++ tienen sus propios tipos de acciones (JavaCompileAction
, CppCompileAction
y CppLinkAction
).
En algún momento, queremos mover todo a SpawnAction
. JavaCompileAction
es bastante similar, pero C++ es un caso especial debido al análisis del archivo .d y la inclusión del 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 del gráfico de acciones 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 su lugar, se usaArtifact.getGeneratingActionKey()
para encontrar la clave de la acción que la genera. - Los conjuntos anidados tienen su propia clave de Skyframe.
Acciones compartidas
Algunas acciones se generan a partir de varios destinos configurados. Las reglas de Starlark son más limitadas, ya que solo pueden colocar sus acciones derivadas en un directorio determinado por su configuración y su paquete (pero, de todos modos, las reglas en el mismo paquete pueden entrar en conflicto). Sin embargo, las reglas implementadas en Java pueden colocar artefactos derivados en cualquier lugar.
Esto se considera un error, pero deshacerse de él es muy difícil, porque genera ahorros significativos en el tiempo de ejecución cuando, por ejemplo, un archivo fuente debe procesarse de alguna manera y varias reglas hacen referencia a ese archivo (handwave-handwave). Esto tiene el costo de contar con un poco de RAM: 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, los mismos resultados 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 analizando cada acción.
Esto se implementa en SkyframeActionExecutor.findAndStoreArtifactConflicts()
y es uno de los pocos lugares de Bazel que requiere una vista “global” de la compilación.
La fase de ejecución
Es en este momento cuando Bazel comienza a ejecutar acciones de compilación, como los comandos que producen resultados.
Lo primero que hace Bazel después de la fase de análisis es determinar qué
artefactos deben compilarse. La lógica de esto 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 destino está en la línea de comandos, compila 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 desde diferentes ubicaciones en el sistema de archivos (--package_path
),
debe proporcionar acciones ejecutadas de forma local con un árbol de origen completo. La clase SymlinkForest
se encarga de esto y funciona tomando nota de cada destino que se usa en la fase de análisis y compilando un solo árbol de directorios que crea symlinks para cada paquete con un destino usado desde su ubicación real. Una alternativa sería pasar las rutas de acceso correctas a los comandos (teniendo en cuenta --package_path
).
Esto no es conveniente por los siguientes motivos:
- Cambia las líneas de comandos de acción cuando se mueve un paquete de una entrada de ruta de acceso a otra (solía ser un evento común).
- Genera líneas de comandos diferentes si una acción se ejecuta de forma remota que si se ejecuta de forma local.
- Requiere una transformación de línea de comandos específica para la herramienta en uso (considera la diferencia entre las rutas de acceso de Java y las rutas de acceso de inclusión de C++).
- Si cambias la línea de comandos de una acción, se invalida su entrada de caché de acciones.
--package_path
dejará de estar disponible de forma gradual
Luego, Bazel comienza a recorrer el grafo de acciones (el grafo bipartito y dirigido que se compone de acciones y sus artefactos de entrada y salida) y a ejecutar acciones.
La ejecución de cada acción se representa con una instancia de la clase SkyValue
ActionExecutionValue
.
Dado que ejecutar una acción es costoso, tenemos algunas capas de almacenamiento en caché que se pueden usar detrás de Skyframe:
ActionExecutionFunction.stateMap
contiene datos para que los reinicios de Skyframe deActionExecutionFunction
sean económicos.- La caché de acciones locales contiene datos sobre el estado del sistema de archivos
- Los sistemas de ejecución remota también suelen contener su propia caché.
La caché de acciones locales
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 hit en la caché de acciones locales. Representa el estado del sistema de archivos local y se serializa en el disco, lo que significa que, cuando se inicia un nuevo servidor de Bazel, se pueden obtener hits de la caché de acciones locales, aunque el gráfico de Skyframe esté vacío.
Esta caché se verifica en busca de hits con el método ActionCacheChecker.getTokenIfNeedToExecute()
.
Al contrario de 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 no captura la suma de comprobación de los archivos de entrada (como en
FileWriteAction
, es la suma de comprobación de los datos que se escriben).
También existe una "caché de acciones de arriba abajo" muy experimental que aún está en desarrollo y que usa hashes transitivos para evitar ir a la caché tantas veces.
Descubrimiento y poda 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 formas:
- Una acción puede descubrir entradas nuevas antes de su ejecución o decidir que algunas de sus entradas no son realmente necesarias. El ejemplo canónico es C++, en el que es mejor hacer una suposición fundamentada sobre qué archivos de encabezado usa un archivo C++ desde su clausura transitiva 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 "entradas", sino analizar el archivo fuente en busca de encabezados incluidos de forma transitiva y solo marcar esos archivos de encabezado como entradas que se mencionan en las sentencias
#include
(hacemos una sobreestimación para no tener que implementar un preprocesador C completo). Actualmente, esta opción está configurada de forma fija como "false" en Bazel y solo se usa en Google. - Una acción puede detectar 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 usaron después del hecho y, para evitar la vergüenza de tener una incrementalidad peor que Make, Bazel aprovecha este hecho. Esto ofrece una mejor estimación que el escáner de inclusión, ya que se basa en el compilador.
Se implementan con métodos en Action:
- Se llama a
Action.discoverInputs()
. Debería mostrar un conjunto anidado de artefactos que se determinan como obligatorios. Estos deben ser artefactos de origen para que no haya bordes de dependencia en el gráfico de acciones que no tengan un equivalente en el gráfico de destino configurado. - La acción se ejecuta llamando a
Action.execute()
. - Al final de
Action.execute()
, la acción puede llamar aAction.updateInputs()
para indicarle a Bazel que no se necesitaban todas sus entradas. Esto puede generar compilaciones incrementales incorrectas si una entrada que se usó se informa como que no se usó.
Cuando una caché de acciones muestra un acierto en una instancia de Action nueva (como la que se crea
después de reiniciar un servidor), Bazel llama a updateInputs()
para que el conjunto de
entradas refleje el resultado del descubrimiento y la poda de entradas que se realizaron antes.
Las acciones de Starlark pueden usar la función para declarar algunas entradas como no utilizadas con el argumento unused_inputs_list=
de ctx.actions.run()
.
Varias formas de ejecutar acciones: estrategias o 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 lo encarna se denomina ActionContext
(o Strategy
, ya que solo logramos cambiar el nombre a la mitad…).
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 de
BlazeModule
qué contextos de acción tienen. Esto sucede en el constructor deExecutionTool
. Los tipos de contexto de acción se identifican con una instancia deClass
de Java que hace referencia a una subinterfaz deActionContext
y la interfaz que el contexto de acción debe implementar. - Se selecciona el contexto de acción adecuado 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 son libres de llamar a otras estrategias para que hagan su trabajo. Esto se usa, por ejemplo, en la estrategia dinámica que inicia acciones de forma local y remota y, luego, usa lo que finalice primero.
Una estrategia notable es la que implementa procesos de trabajo persistentes (WorkerSpawnStrategy
). La idea es que algunas herramientas tienen un tiempo de inicio largo y, por lo tanto, deben reutilizarse entre acciones en lugar de iniciar una nueva para cada acción (esto representa un posible problema de exactitud, ya que Bazel depende de la promesa del proceso de trabajo de que no lleva un estado observable entre solicitudes individuales).
Si cambia la herramienta, se debe reiniciar el proceso del trabajador. Para determinar si se puede volver a usar un trabajador, se calcula una suma de comprobació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 son entradas. El creador de la acción lo determina: 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):
- Aquí encontrarás información sobre varias estrategias para ejecutar acciones.
- La información sobre la estrategia dinámica, en la que ejecutamos una acción de forma local y remota para ver cuál termina primero, está disponible aquí.
- La información sobre las complejidades de la ejecución de acciones de forma 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: cuanto más recursos requiera una acción, menos instancias deben ejecutarse 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 en forma de una instancia de ResourceSet
(CPU y RAM). Luego, cuando los contextos de acción hacen algo
que requiere recursos locales, llaman a ResourceManager.acquireResources()
y se bloquean hasta que los recursos requeridos estén disponibles.
Aquí encontrarás una descripción más detallada de la administración de recursos locales.
La estructura del directorio de salida
Cada acción requiere un lugar separado en el directorio de salida en el que coloca sus resultados. La ubicación de los artefactos derivados suele ser 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 configuraciones en la misma compilación, deben tener directorios diferentes para que ambas 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 sabrá qué acción elegir (un “conflicto de acción”).
- Si dos configuraciones representan "más o menos" lo mismo, deberían tener el mismo nombre para que las acciones ejecutadas en una puedan reutilizarse en la otra si las líneas de comandos coinciden. Por ejemplo, los cambios en las opciones de línea de comandos para el compilador de Java no deberían provocar que se vuelvan a ejecutar las acciones de compilación de C++.
Hasta ahora, no encontramos una forma de resolver este problema de principios, que tiene similitudes con el problema de recorte de configuración. Un análisis más extenso sobre las opciones está disponible aquí. Las principales áreas problemáticas son las reglas de Starlark (cuyos autores suelen no estar familiarizados con Bazel) y los aspectos, que agregan otra dimensión al espacio de elementos que pueden producir el "mismo" archivo de salida.
El enfoque actual es que el segmento de ruta de la configuración es <CPU>-<compilation mode>
con varios sufijos agregados para que las transiciones de configuración implementadas en Java no generen conflictos de acción. Además, se agrega una suma de comprobación del conjunto de transiciones de configuración de Starlark para que los usuarios no puedan causar conflictos de acción. No es para nada perfecto. Esto se implementa en OutputDirectories.buildMnemonic()
y se basa en que cada fragmento de configuración agregue su propia parte al nombre del directorio de salida.
Pruebas
Bazel tiene una amplia compatibilidad para ejecutar pruebas. Es compatible con:
- Ejecución de pruebas de forma remota (si hay un backend de ejecución remota disponible)
- Ejecutar pruebas varias veces en paralelo (para corregir errores o recopilar datos de tiempo)
- Fragmentación de pruebas (división de casos de prueba en la misma prueba en varios procesos para aumentar la velocidad)
- Cómo volver a ejecutar pruebas inestables
- Cómo agrupar pruebas en paquetes de pruebas
Las pruebas son destinos configurados normales que tienen un TestProvider, que describe cómo se debe ejecutar la prueba:
- Los artefactos cuya compilación dio como resultado la prueba que se estaba ejecutando. Este es un archivo de “estado de la caché” que contiene un mensaje
TestResultData
serializado - Es la cantidad de veces que se debe ejecutar la prueba.
- Es 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)
Determina qué pruebas ejecutar
Determinar qué pruebas se ejecutan es un proceso complejo.
Primero, durante el análisis del patrón de destino, los conjuntos de pruebas se expanden de forma recursiva. La expansión se implementa en TestsForTargetPatternFunction
. Un detalle un poco
sorprendente es que, si un paquete de pruebas no declara ninguna prueba, se refiere a
todas las pruebas de su paquete. Para implementar esto en Package.beforeBuild()
, se agrega un atributo implícito llamado $implicit_tests
para probar las reglas del paquete.
Luego, las pruebas se filtran por tamaño, etiquetas, tiempo de espera y lenguaje según las opciones de 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 los atributos de reglas que se pueden filtrar no se pueden configurar es que esto ocurre antes de la fase de análisis, por lo que la configuración no está disponible.
Luego, se procesa más en BuildView.createResult()
: se filtran los objetivos cuyo análisis falló 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.
Para brindar transparencia a este proceso elaborado, el operador de consulta tests()
(implementado en TestsFunction
) está disponible para determinar qué pruebas se ejecutan cuando se especifica un destino específico en la línea de comandos. Lamentablemente, es una reinstalación, por lo que probablemente se desvíe de lo anterior de varias maneras sutiles.
Cómo ejecutar pruebas
La forma en que se ejecutan las pruebas es solicitando artefactos de estado de la caché. Esto genera la ejecución de un TestRunnerAction
, que, en última instancia, 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 según un protocolo elaborado que usa variables de entorno para indicarles a las pruebas lo que se espera de ellas. Aquí, puedes encontrar una descripción detallada de lo que Bazel espera de las pruebas y lo que las pruebas pueden esperar de Bazel. En el caso 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 la caché, cada proceso de prueba emite una serie de otros
archivos. Se colocan en el "directorio de registro de prueba", 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
, el resultado de la consola de la prueba. stdout y stderr no están separados.test.outputs
, el "directorio de salidas no declaradas", que usan las pruebas que desean generar archivos además de lo que imprimen en la terminal.
Hay dos cosas que pueden ocurrir durante la ejecución de pruebas que no pueden ocurrir durante la compilación de destinos normales: la ejecución de pruebas exclusiva y la transmisión de salida.
Algunas pruebas deben ejecutarse en modo exclusivo, por ejemplo, no en paralelo con otras pruebas. Esto se puede obtener agregando tags=["exclusive"]
a la
regla de prueba o ejecutando la prueba con --test_strategy=exclusive
. Cada prueba exclusiva se ejecuta a través de una invocación de Skyframe separada 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 la acción finaliza, el usuario puede solicitar que se transmita el resultado de las pruebas para que se le informe sobre el progreso de una prueba de larga duración. Esto se especifica con la opción de línea de comandos --test_output=streamed
y implica la ejecución exclusiva de pruebas para que los resultados de las diferentes pruebas no se entrelacen.
Esto se implementa en la clase StreamedTestOutput
con un nombre muy apropiado y funciona a través de la recopilación de cambios en el archivo test.log
de la prueba en cuestión y la volcado de bytes nuevos en la terminal donde se aplican las reglas de Bazel.
Los resultados de las pruebas ejecutadas están disponibles en el bus de eventos a través de 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.
Recopilación de cobertura
Las pruebas informan la cobertura en formato LCOV en los archivos bazel-testlogs/$PACKAGE/$TARGET/coverage.dat
.
Para recopilar cobertura, cada ejecución de prueba se une a 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 de cobertura. Luego, ejecuta la prueba. Una prueba puede ejecutar varios subprocesos y constar de 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 de combinarlos en un solo archivo.
Las estrategias de prueba realizan la interposición de collect_coverage.sh
y requieren que collect_coverage.sh
esté en las entradas de la prueba. Esto se logra mediante el atributo implícito :coverage_support
, que se resuelve en el valor de la marca de configuración --coverage_support
(consulta TestConfiguration.TestOptions.coverageSupport
).
Algunos lenguajes realizan instrumentación sin conexión, lo que significa que la instrumentación de cobertura se agrega en el tiempo de compilación (como C++), mientras que otros realizan instrumentación en línea, lo que significa que la instrumentación de cobertura se agrega en el tiempo de ejecución.
Otro concepto fundamental es la cobertura de referencia. Esta es la cobertura de una biblioteca, un objeto binario o una prueba si no se ejecutó ningún código. 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 puede 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 archivo binario que contiene solo los archivos para los que recopilamos la cobertura sin líneas cubiertas. El archivo de cobertura de referencia para un destino se encuentra en bazel-testlogs/$PACKAGE/$TARGET/baseline_coverage.dat
. También se genera para objetos binarios y bibliotecas, además de pruebas, si pasas la marca --nobuild_tests_only
a Bazel.
Actualmente, la cobertura del modelo de referencia no funciona.
Hacemos 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 el caso de 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 porque 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 este bit está invertido, todos los objetivos deben volver a analizarse (algunos lenguajes, como C++, requieren diferentes opciones de compilador para emitir código que pueda recopilar cobertura, lo que mitiga este problema de alguna manera, ya que entonces se necesita un nuevo análisis de todas formas).
Se depende de los archivos de compatibilidad de cobertura a través de etiquetas en una dependencia implícita para que la política de invocación pueda anularlos, lo que les permite diferir entre las diferentes versiones de Bazel. Idealmente, se quitarían estas diferencias y se estandarizaría 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
controla esto y se llama desde BuildView.createResult()
. Para obtener acceso a las herramientas que necesita, observa el 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 preguntarle varias cosas sobre varios gráficos. Se proporcionan los siguientes tipos de consulta:
bazel query
se usa para investigar el gráfico de destino.bazel cquery
se usa para investigar el grafo de destino configurado.bazel aquery
se usa para investigar el gráfico de acciones.
Cada uno de estos se implementa mediante la subclasificación AbstractBlazeQueryEnvironment
.
Se pueden realizar funciones de consulta adicionales mediante la subclasificación QueryFunction
. Para permitir la transmisión de resultados de consultas, en lugar de recopilarlos en una estructura de datos, se pasa un query2.engine.Callback
a QueryFunction
, que lo llama para 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. Se implementan como subclases de OutputFormatter
.
Un requisito sutil de algunos formatos de salida de consultas (proto, definitivamente) es que Bazel debe emitir _toda_ la información que proporciona la carga de paquetes para que se pueda comparar la salida y determinar si cambió un destino en particular. En consecuencia, los valores de los atributos deben ser serializables, por lo que hay muy pocos tipos de atributos sin atributos que tengan valores de Starlark complejos. 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 alternativa muy satisfactoria, y sería muy bueno quitar este requisito.
El sistema de módulos
Para extender Bazel, puedes agregarle módulos. Cada módulo debe crear una subclase de BlazeModule
(el nombre es una reliquia de la historia de Bazel cuando se llamaba Blaze) y obtiene información sobre varios eventos durante la ejecución de un comando.
Por lo general, se usan para implementar varias partes de la funcionalidad "no principal" que solo algunas versiones de Bazel (como la que usamos en Google) necesitan:
- Interfaces para los sistemas de ejecución remota
- Comandos nuevos
El conjunto de puntos de extensión que ofrece BlazeModule
es un poco desordenado. No
lo uses como ejemplo de buenos principios de diseño.
El bus de eventos
La forma principal en que BlazeModules se comunica con el resto de Bazel es a través de 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 que se compilarán (
TargetParsingCompleteEvent
). - Se determinaron las configuraciones de nivel superior (
BuildConfigurationEvent
). - Se compiló un destino, de forma correcta 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 BlazeModule
, sino también los elementos fuera del proceso de Bazel, observen la compilación. Se puede acceder a ellos como un archivo que contiene mensajes de protocolo, o bien Bazel puede conectarse a un servidor (llamado servicio de eventos de compilación) para transmitir eventos.
Esto se implementa en los paquetes build.lib.buildeventservice
y build.lib.buildeventstream
de Java.
Repositorios externos
Si bien Bazel se diseñó originalmente para usarse en un monorepo (un árbol de fuente única que contiene todo lo que se necesita compilar), Bazel vive en un mundo en el que esto no es necesariamente cierto. Los "repositorios externos" son una abstracción que se usa para cerrar la brecha entre estos dos mundos: representan el código que es necesario para la compilación, pero que no está en el árbol de origen principal.
El archivo WORKSPACE
El conjunto de repositorios externos se determina a través del análisis del archivo WORKSPACE. Por ejemplo, una declaración como esta:
local_repository(name="foo", path="/foo/bar")
Los resultados en el repositorio llamado @foo
están disponibles. Lo que complica esto es que se pueden definir reglas de repositorio nuevas en archivos Starlark, que luego se pueden usar para cargar código Starlark nuevo, que se puede usar para definir reglas de repositorio nuevas, y así sucesivamente.
Para controlar este caso, el análisis del archivo WORKSPACE (en WorkspaceFileFunction
) se divide en fragmentos delineados por sentencias load()
. El índice de fragmento se indica con WorkspaceFileKey.getIndex()
y calcular WorkspaceFileFunction
hasta el índice X significa evaluarlo hasta la Xª sentencia load()
.
Recupera 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 razones poco claras (el código indica que debe evitar volver a descargar elementos en caso de reinicios de Skyframe, pero no es un razonamiento muy sólido).RepositoryDelegatorFunction
averigua la regla del repositorio que se le solicita recuperar iterando por los fragmentos del archivo WORKSPACE hasta que se encuentra el repositorio solicitado.- Se encuentra el
RepositoryFunction
adecuado que implementa la recuperación del repositorio, ya sea la implementación de Starlark del repositorio o un mapa codificado en la memoria 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 se clave con su suma de verificación (
RepositoryCache
). Esto requiere que la suma de verificación esté disponible en el archivo WORKSPACE, pero eso es bueno para la hermeticidad de todos modos. Todas las instancias del servidor de Bazel en la misma estación de trabajo comparten esto, independientemente de la base de trabajo o de salida en la que se ejecuten. - Se escribe un "archivo de marcador" para cada repositorio en
$OUTPUT_BASE/external
que contenga una suma de verificación de la regla que se usó para recuperarla. Si se reinicia el servidor de Bazel, pero la suma de comprobació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 contiene se consideran artefactos fuente. Esto plantea un problema porque Bazel suele verificar la actualización de los artefactos de origen llamando a stat() en ellos, 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
controla esto.
Directorios administrados
A veces, los repositorios externos necesitan 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 está en conflicto con la suposición que hace Bazel de que el usuario solo modifica los archivos fuente y no por sí solo, y permite que los paquetes hagan referencia a todos los directorios de la raíz del espacio de trabajo. Para que este tipo de repositorio externo funcione, Bazel hace dos cosas:
- Permite que el usuario especifique subdirectorios del lugar de trabajo a los 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 espacio de trabajo al repositorio externo que lo controla en
ManagedDirectoriesKnowledge
y controlamos losFileStateValue
que se refieren a ellos de la misma manera que los de los repositorios externos normales.
Asignaciones de repositorios
Puede suceder que varios repositorios deseen depender del mismo repositorio,
pero en diferentes versiones (esta es una instancia del "problema de dependencia de diamante"). Por ejemplo, si dos objetos binarios en repositorios separados en la compilación quieren depender de Guava, es probable que ambos hagan referencia a Guava con etiquetas que comienzan con @guava//
y se espera que eso signifique diferentes versiones de ella.
Por lo tanto, Bazel permite reasignar etiquetas de repositorio externas para que la cadena @guava//
pueda hacer referencia a un repositorio de Guava (como @guava1//
) en el repositorio de un binario y otro repositorio de Guava (como @guava2//
) en 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 de repositorios permite volver 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 valores de etiqueta de las reglas del 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 sentencias load().
Bits de JNI
El servidor de Bazel está_ casi por completo_ escrito en Java. La excepción son las partes que Java no puede hacer por sí solo o no podía hacer por sí solo cuando lo implementamos. Esto se limita principalmente a la interacción con el sistema de archivos, el control de procesos y otros elementos de bajo nivel.
El código 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 el resultado de la consola parece algo sencillo, pero la confluencia de ejecutar varios procesos (a veces de forma remota), el almacenamiento en caché detallado, el deseo de tener un resultado de la terminal agradable y colorido y tener un servidor de larga duración lo hacen no trivial.
Inmediatamente después de que llega la llamada RPC 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 OutErr
(un par (stdout, stderr). Todo lo que se debe imprimir en la consola pasa por estos flujos. Luego, estas transmisiones se entregan a BlazeCommandDispatcher.execExclusively()
.
De forma predeterminada, el resultado se imprime con secuencias de escape ANSI. Cuando no se desean (--color=no
), se quitan con un AnsiStrippingOutputStream
. Además, System.out
y System.err
se redireccionan a estas transmisiones de salida.
Esto es para que la información de depuración se pueda imprimir con System.err.println()
y, de todos modos, terminar en el resultado de la terminal del cliente (que es diferente del del servidor). Se debe tener cuidado de que, si un proceso produce un resultado binario (como bazel query --output=proto
), no se realice ningún cambio en 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 en el 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 almacenado en caché, por ejemplo, las advertencias emitidas por un objetivo configurado almacenado en caché.
Algunos EventHandler
también permiten publicar eventos que, finalmente, se dirigen al bus de eventos (los Event
normales no _no _aparecen allí). Estas son implementaciones de ExtendedEventHandler
y su uso principal es volver a 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 almacena en caché un ExtendedEventHandler
(sería bueno y la mayoría de las cosas lo hacen, pero no se aplica).
El resultado de la terminal se emite principalmente a través de UiEventHandler
, que es responsable de todo el formato de salida elegante y los informes de progreso que genera Bazel. Tiene dos entradas:
- El bus de eventos
- El flujo de eventos que se canaliza a través de Reporter
La única conexión directa que la máquina de ejecución de comandos (por ejemplo, el resto de Bazel) tiene 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
).
Cómo generar perfiles de Bazel
Bazel es rápido. Bazel también es lento, ya que las compilaciones tienden a crecer hasta el límite de lo que es tolerable. Por este motivo, Bazel incluye un generador de perfiles que puede
usarse para generar perfiles de compilaciones y Bazel en sí. Se implementa en una clase que se llama Profiler
. Está activado de forma predeterminada, aunque solo registra datos resumidos para que su 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, que se ve mejor en Chrome. Su modelo de datos es el de pilas de tareas: se pueden iniciar y finalizar tareas, y se supone que deben anidarse de forma ordenada entre sí. Cada subproceso de Java obtiene su propia pila de tareas. TODO: ¿Cómo funciona esto 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, e intenta estar activo el mayor tiempo posible para que podamos generar perfiles de todo. Para agregar algo al perfil, llama a Profiler.instance().profile()
. Muestra una Closeable
, cuyo cierre representa el final de la tarea. Se recomienda usar con sentencias try-with-resources.
También realizamos perfiles de memoria rudimentarios en MemoryProfiler
. También está siempre activado y, en su mayoría, registra los tamaños máximos del montón y el comportamiento de GC.
Pruebas de 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 primeras "pruebas de integración" y a las últimas "pruebas de unidades", aunque son más como pruebas de integración que están menos integradas. También tenemos algunas pruebas de unidades reales, cuando son necesarias.
Existen dos tipos de pruebas de integración:
- Las que se implementan con un framework de prueba de bash muy elaborado en
src/test/shell
- Las que están implementadas en Java. 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 depurabilidad y una integración perfecta 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 en blanco que puedes usar para escribir archivos BUILD
. Luego, varios métodos auxiliares pueden solicitar objetivos configurados, cambiar la configuración y confirmar varios aspectos sobre el resultado del análisis.