Este documento descreve a base de código e como o Bazel é estruturado. Ele é destinado a pessoas que querem contribuir com o Bazel, não para usuários finais.
Introdução
A base de código do Bazel é grande (~350 KLOC de código de produção e ~260 KLOC de código de teste), e ninguém conhece todo o cenário. Todos conhecem muito bem o próprio vale, mas poucos sabem o que há nas colinas em todas as direções.
Para que as pessoas no meio da jornada não se encontrem em uma floresta escura com o caminho direto perdido, este documento tenta dar uma visão geral da base de código para facilitar o início do trabalho nela.
A versão pública do código-fonte do Bazel está no GitHub em github.com/bazelbuild/bazel. Essa não é a "fonte da verdade". Ela é derivada de uma árvore de origem interna do Google que contém funcionalidades adicionais que não são úteis fora do Google. O objetivo de longo prazo é tornar o GitHub a fonte de verdade.
As contribuições são aceitas pelo mecanismo normal de solicitação de envio do GitHub e importadas manualmente por um funcionário do Google para a árvore de origem interna. Depois, elas são exportadas novamente para o GitHub.
Arquitetura cliente/servidor
A maior parte do Bazel reside em um processo de servidor que permanece na RAM entre os builds. Isso permite que o Bazel mantenha o estado entre builds.
Por isso, a linha de comando do Bazel tem dois tipos de opções: inicialização e comando. Em uma linha de comando como esta:
bazel --host_jvm_args=-Xmx8G build -c opt //foo:bar
Algumas opções (--host_jvm_args=
) aparecem antes do nome do comando a ser executado
e outras depois (-c opt
). O primeiro tipo é chamado de "opção de inicialização" e
afeta o processo do servidor como um todo, enquanto o segundo tipo, a "opção de
comando", afeta apenas um único comando.
Cada instância de servidor tem um único espaço de trabalho associado (coleção de árvores de origem conhecidas como "repositórios"), e cada espaço de trabalho geralmente tem uma única instância de servidor ativa. Isso pode ser evitado especificando uma base de saída personalizada (consulte a seção "Layout do diretório" para mais informações).
O Bazel é distribuído como um único executável ELF, que também é um arquivo .zip válido.
Quando você digita bazel
, o executável ELF acima implementado em C++ (o "cliente") assume o controle. Ele configura um processo de servidor adequado usando as seguintes etapas:
- Verifica se ele já foi extraído. Caso contrário, ele faz isso. É aqui que a implementação do servidor é feita.
- Verifica se há uma instância de servidor ativa que funciona: ela está em execução,
tem as opções de inicialização corretas e usa o diretório de espaço de trabalho certo. Ele
encontra o servidor em execução procurando no diretório
$OUTPUT_BASE/server
onde há um arquivo de bloqueio com a porta em que o servidor está detectando. - Se necessário, encerra o processo do servidor antigo.
- Se necessário, inicia um novo processo de servidor.
Depois que um processo de servidor adequado estiver pronto, o comando que precisa ser executado será
comunicado a ele por uma interface gRPC. Em seguida, a saída do Bazel será transmitida de volta
ao terminal. Só é possível executar um comando por vez. Isso é
implementado usando um mecanismo de bloqueio elaborado com partes em C++ e partes em
Java. Há alguma infraestrutura para executar vários comandos em paralelo, já que a incapacidade de executar bazel version
em paralelo com outro comando é um pouco embaraçosa. O principal bloqueador é o ciclo de vida dos BlazeModule
s
e algum estado em BlazeRuntime
.
No final de um comando, o servidor do Bazel transmite o código de saída que o cliente
precisa retornar. Uma questão interessante é a implementação de bazel run
: o trabalho desse comando é executar algo que o Bazel acabou de criar, mas ele não pode fazer isso no processo do servidor porque não tem um terminal. Em vez disso, ele informa
ao cliente qual binário deve exec()
e com quais argumentos.
Quando alguém pressiona Ctrl-C, o cliente traduz isso para uma chamada de cancelamento na conexão gRPC, que tenta encerrar o comando o mais rápido possível. Depois do terceiro Ctrl-C, o cliente envia um SIGKILL para o servidor.
O código-fonte do cliente está em src/main/cpp
, e o protocolo usado para
se comunicar com o servidor está em src/main/protobuf/command_server.proto
.
O principal ponto de entrada do servidor é BlazeRuntime.main()
, e as chamadas gRPC
do cliente são processadas por GrpcServerImpl.run()
.
Layout do diretório
O Bazel cria um conjunto de diretórios um pouco complicado durante um build. Uma descrição completa está disponível em Layout do diretório de saída.
O "repositório principal" é a árvore de origem em que o Bazel é executado. Normalmente, corresponde a algo que você fez check-out do controle de origem. A raiz desse diretório é conhecida como "raiz do espaço de trabalho".
O Bazel coloca todos os dados dele na "raiz do usuário de saída". Normalmente, é $HOME/.cache/bazel/_bazel_${USER}
, mas pode ser substituído usando a opção de inicialização --output_user_root
.
A "base de instalação" é onde o Bazel é extraído. Isso é feito automaticamente, e cada versão do Bazel recebe um subdiretório com base no checksum na base de instalação. Por padrão, ele fica em $OUTPUT_USER_ROOT/install
e pode ser alterado
usando a opção de linha de comando --install_base
.
A "base de saída" é o local em que a instância do Bazel anexada a um espaço de trabalho específico grava. Cada base de saída tem no máximo uma instância do servidor Bazel
em execução a qualquer momento. Geralmente, é às $OUTPUT_USER_ROOT/<checksum of the path
to the workspace>
. Ele pode ser alterado usando a opção de inicialização --output_base
, que é útil, entre outras coisas, para contornar a limitação de que apenas uma instância do Bazel pode ser executada em qualquer espaço de trabalho a qualquer momento.
O diretório de saída contém, entre outras coisas:
- Os repositórios externos buscados em
$OUTPUT_BASE/external
. - A raiz de execução, um diretório que contém symlinks para todo o código-fonte da build atual. Ele está localizado em
$OUTPUT_BASE/execroot
. Durante o build, o diretório de trabalho é$EXECROOT/<name of main repository>
. Estamos planejando mudar isso para$EXECROOT
, mas é um plano de longo prazo porque é uma mudança muito incompatível. - Arquivos criados durante o build.
O processo de execução de um comando
Quando o servidor do Bazel assume o controle e é informado sobre um comando que precisa executar, ocorre a seguinte sequência de eventos:
BlazeCommandDispatcher
é informado sobre a nova solicitação. Ele decide se o comando precisa de um espaço de trabalho para ser executado (quase todos os comandos, exceto aqueles que não têm nada a ver com código-fonte, como versão ou ajuda) e se outro comando está em execução.O comando certo é encontrado. Cada comando precisa implementar a interface
BlazeCommand
e ter a anotação@Command
. Isso é um pouco de um antipattern. Seria bom se todos os metadados necessários para um comando fossem descritos por métodos emBlazeCommand
.As opções de linha de comando são analisadas. Cada comando tem diferentes opções de linha de comando, que são descritas na anotação
@Command
.Um barramento de eventos é criado. O barramento de eventos é um fluxo para eventos que acontecem durante a build. Alguns deles são exportados para fora do Bazel sob a égide do Build Event Protocol para informar ao mundo como o build está sendo executado.
O comando assume o controle. Os comandos mais interessantes são aqueles que executam um build: build, test, run, coverage e assim por diante. Essa funcionalidade é implementada por
BuildTool
.O conjunto de padrões de destino na linha de comando é analisado, e os caracteres curinga, como
//pkg:all
e//pkg/...
, são resolvidos. Isso é implementado emAnalysisPhaseRunner.evaluateTargetPatterns()
e materializado no Skyframe comoTargetPatternPhaseValue
.A fase de carregamento/análise é executada para produzir o gráfico de ações (um gráfico acíclico direcionado de comandos que precisam ser executados para a build).
A fase de execução é iniciada. Isso significa executar todas as ações necessárias para criar as metas de nível superior solicitadas.
Opções de linha de comando
As opções de linha de comando para uma invocação do Bazel são descritas em um objeto
OptionsParsingResult
, que, por sua vez, contém um mapa de "classes de opções" para os valores das opções. Uma "classe de opção" é uma subclasse de
OptionsBase
e agrupa opções de linha de comando relacionadas entre si. Exemplo:
- Opções relacionadas a uma linguagem de programação (
CppOptions
ouJavaOptions
). Elas precisam ser uma subclasse deFragmentOptions
e são encapsuladas em um objetoBuildOptions
. - Opções relacionadas à forma como o Bazel executa ações (
ExecutionOptions
)
Essas opções foram projetadas para serem usadas na fase de análise (por RuleContext.getFragment()
em Java ou ctx.fragments
em Starlark).
Algumas delas (por exemplo, se é necessário fazer a verificação de inclusão do C++ ou não) são lidas
na fase de execução, mas isso sempre exige encanamento explícito, já que
BuildConfiguration
não está disponível nesse momento. Para mais informações, consulte a seção "Configurações".
AVISO:gostamos de fingir que as instâncias OptionsBase
são imutáveis e as usamos dessa forma (como parte de SkyKeys
). Não é o caso, e modificá-las é uma ótima maneira de quebrar o Bazel de maneiras sutis e difíceis de depurar. Infelizmente, torná-los imutáveis é uma tarefa difícil.
Modificar um FragmentOptions
imediatamente após a construção, antes que alguém tenha a chance de manter uma referência a ele e antes que equals()
ou hashCode()
sejam chamados, não tem problema.
O Bazel aprende sobre classes de opções das seguintes maneiras:
- Alguns são codificados no Bazel (
CommonCommandOptions
). - Da anotação
@Command
em cada comando do Bazel - De
ConfiguredRuleClassProvider
(opções de linha de comando relacionadas a linguagens de programação individuais) - As regras do Starlark também podem definir as próprias opções (consulte aqui).
Cada opção (exceto as definidas em Starlark) é uma variável membro de uma
subclasse FragmentOptions
que tem a anotação @Option
, que especifica
o nome e o tipo da opção de linha de comando, além de um texto de ajuda.
O tipo Java do valor de uma opção de linha de comando geralmente é algo simples (uma string, um número inteiro, um booleano, um rótulo etc.). No entanto, também oferecemos suporte a opções de tipos mais complicados. Nesse caso, a tarefa de converter da string da linha de comando para o tipo de dados fica a cargo de uma implementação de com.google.devtools.common.options.Converter
.
A árvore de origem, conforme vista pelo Bazel
O Bazel cria software, o que acontece lendo e interpretando o código-fonte. A totalidade do código-fonte em que o Bazel opera é chamada de "o espaço de trabalho" e é estruturada em repositórios, pacotes e regras.
Repositórios
Um "repositório" é uma árvore de origem em que um desenvolvedor trabalha e geralmente representa um único projeto. O ancestral do Bazel, o Blaze, operava em um monorepo, ou seja, uma única árvore de origem que contém todo o código-fonte usado para executar o build. O Bazel, por outro lado, é compatível com projetos cujo código-fonte abrange vários repositórios. O repositório de onde o Bazel é invocado é chamado de "repositório principal", e os outros são chamados de "repositórios externos".
Um repositório é marcado por um arquivo de limite do repositório (MODULE.bazel
, REPO.bazel
ou, em contextos legados, WORKSPACE
ou WORKSPACE.bazel
) no diretório raiz. O
repositório principal é a árvore de origem de onde você está invocando o Bazel. Os repositórios externos
são definidos de várias maneiras. Consulte a visão geral das dependências externas para mais informações.
O código de repositórios externos é vinculado por symlink ou baixado em
$OUTPUT_BASE/external
.
Ao executar o build, toda a árvore de origem precisa ser montada. Isso é feito pelo SymlinkForest
, que cria links simbólicos de todos os pacotes no repositório principal para $EXECROOT
e de todos os repositórios externos para $EXECROOT/external
ou $EXECROOT/..
.
Pacotes
Cada repositório é composto de pacotes, uma coleção de arquivos relacionados e uma especificação das dependências. Eles são especificados por um arquivo chamado
BUILD
ou BUILD.bazel
. Se os dois existirem, o Bazel vai preferir BUILD.bazel
. O motivo
pelo qual os arquivos BUILD
ainda são aceitos é que o ancestral do Bazel, o Blaze, usava esse
nome de arquivo. No entanto, acabou sendo um segmento de caminho usado com frequência, especialmente
no Windows, em que os nomes de arquivo não diferenciam maiúsculas de minúsculas.
Os pacotes são independentes uns dos outros: as mudanças no arquivo BUILD
de um pacote não podem causar alterações em outros pacotes. A adição ou remoção de arquivos BUILD
_pode_ mudar outros pacotes, já que os globs recursivos param nos limites do pacote
e, portanto, a presença de um arquivo BUILD
interrompe a recursão.
A avaliação de um arquivo BUILD
é chamada de "carregamento de pacote". Ele é implementado
na classe PackageFactory
, funciona chamando o interpretador Starlark e
requer conhecimento do conjunto de classes de regras disponíveis. O resultado do carregamento do pacote é um objeto Package
. É principalmente um mapa de uma string (o nome de um
destino) para o próprio destino.
Uma grande parte da complexidade durante o carregamento de pacotes é o globbing: o Bazel não exige que todos os arquivos de origem sejam listados explicitamente. Em vez disso, ele pode executar globs (como glob(["**/*.java"])
). Ao contrário do shell, ele é compatível com globs recursivos que descem para subdiretórios (mas não para subpacotes). Isso exige acesso ao sistema de arquivos e, como pode ser lento, implementamos todos os tipos de truques para que ele seja executado em paralelo e da maneira mais eficiente possível.
O globbing é implementado nas seguintes classes:
LegacyGlobber
, um globber rápido e sem conhecimento do SkyframeSkyframeHybridGlobber
, uma versão que usa o Skyframe e volta para o globber legado para evitar "reinicializações do Skyframe" (descritas abaixo)
A própria classe Package
contém alguns membros usados exclusivamente para
analisar o pacote "external" (relacionado a dependências externas) e que não
fazem sentido para pacotes reais. Isso é uma falha de design, porque objetos que descrevem pacotes regulares não devem conter campos que descrevem outra coisa. São eles:
- Os mapeamentos de repositório
- Os conjuntos de ferramentas registrados
- As plataformas de execução registradas
O ideal seria ter mais separação entre a análise do pacote "externo" e a análise de pacotes regulares para que o Package
não precise atender às necessidades dos dois. Infelizmente, isso é difícil de fazer porque os dois estão profundamente interligados.
Rótulos, destinos e regras
Os pacotes são compostos de destinos, que têm os seguintes tipos:
- Arquivos:itens que são a entrada ou a saída do build. Na linguagem do Bazel, chamamos de artefatos (discutido em outro lugar). Nem todos os arquivos criados durante o build são destinos. É comum que uma saída do Bazel não tenha um rótulo associado.
- Regras:descrevem as etapas para derivar as saídas das entradas. Eles geralmente são associados a uma linguagem de programação (como
cc_library
,java_library
oupy_library
), mas há alguns independentes de linguagem (comogenrule
oufilegroup
). - Grupos de pacotes:abordados na seção Visibilidade.
O nome de um destino é chamado de rótulo. A sintaxe dos rótulos é
@repo//pac/kage:name
, em que repo
é o nome do repositório em que o rótulo está, pac/kage
é o diretório em que o arquivo BUILD
está e name
é o caminho do
arquivo (se o rótulo se referir a um arquivo de origem) relativo ao diretório do
pacote. Ao se referir a uma meta na linha de comando, algumas partes do rótulo podem ser omitidas:
- Se o repositório for omitido, o rótulo será considerado no repositório principal.
- Se a parte do pacote for omitida (como
name
ou:name
), o rótulo será considerado no pacote do diretório de trabalho atual. Caminhos relativos que contenham referências de nível superior (..) não são permitidos.
Um tipo de regra (como "biblioteca C++") é chamado de "classe de regra". As classes de regra podem ser implementadas em Starlark (a função rule()
) ou em Java (as chamadas "regras nativas", tipo RuleClass
). A longo prazo, todas as regras específicas de linguagem serão implementadas em Starlark, mas algumas famílias de regras legadas (como Java ou C++) ainda estão em Java por enquanto.
As classes de regras do Starlark precisam ser importadas no início dos arquivos BUILD
usando a instrução load()
, enquanto as classes de regras do Java são "inerentemente" conhecidas pelo
Bazel, por serem registradas no ConfiguredRuleClassProvider
.
As classes de regras contêm informações como:
- Os atributos dele (como
srcs
,deps
): tipos, valores padrão, restrições etc. - As transições e os aspectos de configuração anexados a cada atributo, se houver
- A implementação da regra
- Os provedores de informações transitivas que a regra "normalmente" cria
Observação sobre a terminologia:na base de código, geralmente usamos "Rule" para indicar o destino criado por uma classe de regra. Mas em Starlark e na documentação voltada ao usuário, "Rule" deve ser usado exclusivamente para se referir à própria classe de regra. O destino é apenas um "destino". Além disso, apesar de RuleClass
ter "class" no nome, não há uma relação de herança Java entre uma classe de regra e destinos desse tipo.
Skyframe
O framework de avaliação do Bazel é chamado de Skyframe. O modelo é que tudo o que precisa ser criado durante um build é organizado em um gráfico acíclico direcionado com arestas apontando de qualquer parte de dados para as dependências dela, ou seja, outras partes de dados que precisam ser conhecidas para construí-la.
Os nós no gráfico são chamados de SkyValue
s, e os nomes deles são chamados de SkyKey
s. Ambos são profundamente imutáveis, e somente objetos imutáveis podem ser
acessados por eles. Essa invariante quase sempre é válida, e, caso não seja (como para as classes de opções individuais BuildOptions
, que é um membro de BuildConfigurationValue
e seu SkyKey
), tentamos não mudar ou mudar apenas de maneiras que não são observáveis de fora.
Assim, tudo o que é calculado no Skyframe (como
targets configurados) também precisa ser imutável.
A maneira mais conveniente de observar o gráfico do Skyframe é executar bazel dump
--skyframe=deps
, que despeja o gráfico, um SkyValue
por linha. É melhor fazer isso para builds pequenos, já que ele pode ficar bem grande.
O Skyframe está no pacote com.google.devtools.build.skyframe
. O pacote com nome semelhante com.google.devtools.build.lib.skyframe
contém a implementação do Bazel sobre o Skyframe. Saiba mais sobre o Skyframe neste link.
Para avaliar um determinado SkyKey
em um SkyValue
, o Skyframe vai invocar o
SkyFunction
correspondente ao tipo da chave. Durante a avaliação da função, ela pode solicitar outras dependências do Skyframe chamando as várias sobrecargas de SkyFunction.Environment.getValue()
. Isso tem o efeito colateral de registrar essas dependências no gráfico interno do Skyframe, para que o Skyframe saiba reavaliar a função quando qualquer uma das dependências mudar. Em outras palavras, o armazenamento em cache e a computação incremental do Skyframe funcionam na granularidade de SkyFunction
s e SkyValue
s.
Sempre que um SkyFunction
solicitar uma dependência indisponível, getValue()
vai retornar nulo. Em seguida, a função precisa retornar o controle ao Skyframe retornando null. Em algum momento posterior, o Skyframe vai avaliar a dependência indisponível e reiniciar a função desde o início. Só que, desta vez, a chamada getValue()
vai ser concluída com um resultado não nulo.
Como consequência, qualquer computação realizada dentro do SkyFunction
antes da reinicialização precisa ser repetida. Mas isso não inclui o trabalho feito para
avaliar a dependência SkyValues
, que são armazenadas em cache. Por isso, geralmente contornamos esse problema das seguintes maneiras:
- Declarar dependências em lotes (usando
getValuesAndExceptions()
) para limitar o número de reinicializações. - Dividir um
SkyValue
em partes separadas calculadas por diferentesSkyFunction
s para que possam ser calculadas e armazenadas em cache de forma independente. Isso deve ser feito de forma estratégica, já que tem o potencial de aumentar o uso da memória. - Armazenar o estado entre reinicializações, usando
SkyFunction.Environment.getState()
ou mantendo um cache estático ad hoc "atrás da parte de trás do Skyframe". Com SkyFunctions complexas, o gerenciamento de estado entre reinicializações pode ficar complicado. Por isso, osStateMachine
s foram introduzidos para uma abordagem estruturada da simultaneidade lógica, incluindo hooks para suspender e retomar cálculos hierárquicos em umSkyFunction
. Exemplo:DependencyResolver#computeDependencies
usa umStateMachine
comgetState()
para calcular o conjunto potencialmente enorme de dependências diretas de uma meta configurada, o que pode resultar em reinicializações caras.
Basicamente, o Bazel precisa desses tipos de soluções alternativas porque centenas de milhares de nós Skyframe em andamento são comuns, e o suporte do Java a threads leves não supera a implementação StateMachine
em 2023.
Starlark
O Starlark é a linguagem específica do domínio usada para configurar e estender o Bazel. Ele foi concebido como um subconjunto restrito do Python com muito menos tipos, mais restrições no fluxo de controle e, mais importante, garantias de imutabilidade para permitir leituras simultâneas. Ela não é Turing-completa, o que desencoraja alguns (mas não todos) usuários a tentar realizar tarefas gerais de programação na linguagem.
O Starlark é implementado no pacote net.starlark.java
.
Ele também tem uma implementação independente em Go aqui. A implementação em Java
usada no Bazel é atualmente um interpretador.
O Starlark é usado em vários contextos, incluindo:
BUILD
arquivos. É aqui que as novas metas de build são definidas. O código Starlark executado nesse contexto só tem acesso ao conteúdo do próprio arquivoBUILD
e aos arquivos.bzl
carregados por ele.- O arquivo
MODULE.bazel
. É aqui que as dependências externas são definidas. O código Starlark executado nesse contexto tem acesso muito limitado a algumas diretivas predefinidas. .bzl
arquivos. É aqui que novas regras de build, regras de repositório e extensões de módulo são definidas. O código Starlark aqui pode definir novas funções e carregar de outros arquivos.bzl
.
Os dialetos disponíveis para arquivos BUILD
e .bzl
são um pouco diferentes porque expressam coisas diferentes. Confira uma lista de diferenças
aqui.
Confira mais informações sobre o Starlark neste link.
A fase de carregamento/análise
Na fase de carregamento/análise, o Bazel determina quais ações são necessárias para criar uma regra específica. A unidade básica é um "destino configurado", que é, de forma bastante sensata, um par (destino, configuração).
Ela é chamada de "fase de carregamento/análise" porque pode ser dividida em duas partes distintas, que eram serializadas, mas agora podem se sobrepor no tempo:
- Carregar pacotes, ou seja, transformar arquivos
BUILD
nos objetosPackage
que os representam - Analisar os destinos configurados, ou seja, executar a implementação das regras para produzir o gráfico de ações
Cada destino configurado no fechamento transitivo dos destinos configurados solicitados na linha de comando precisa ser analisado de baixo para cima, ou seja, nós folha primeiro e depois até os da linha de comando. As entradas para a análise de um único destino configurado são:
- A configuração. ("como" criar essa regra; por exemplo, a plataforma de destino, mas também opções de linha de comando que o usuário quer transmitir ao compilador C++)
- As dependências diretas. Os provedores de informações transitivas estão disponíveis para a regra analisada. Eles são chamados assim porque fornecem um "resumo" das informações no fechamento transitivo do destino configurado, como todos os arquivos .jar no classpath ou todos os arquivos .o que precisam ser vinculados a um binário C++.
- O próprio destino. Esse é o resultado do carregamento do pacote em que o destino está. Para regras, isso inclui os atributos, que geralmente são o que importa.
- A implementação do destino configurado. Para regras, isso pode ser em Starlark ou em Java. Todas as segmentações não configuradas por regra são implementadas em Java.
A saída da análise de uma meta configurada é:
- Os provedores de informações transitivas que configuraram destinos dependentes podem acessar
- Os artefatos que ele pode criar e as ações que os produzem.
A API oferecida às regras Java é RuleContext
, que é o equivalente do argumento
ctx
das regras do Starlark. A API é mais poderosa, mas, ao mesmo tempo, é mais fácil fazer coisas ruins™, por exemplo, escrever código cuja complexidade de tempo ou espaço seja quadrática (ou pior), fazer o servidor Bazel falhar com uma exceção Java ou violar invariantes (como modificar inadvertidamente uma instância Options
ou tornar um destino configurado mutável).
O algoritmo que determina as dependências diretas de um destino configurado
está em DependencyResolver.dependentNodeMap()
.
Configurações
As configurações são o "como" de criar um destino: para qual plataforma, com quais opções de linha de comando etc.
O mesmo destino pode ser criado para várias configurações na mesma build. Isso é útil, por exemplo, quando o mesmo código é usado para uma ferramenta executada durante o build e para o código de destino, e estamos fazendo uma compilação cruzada ou criando um app Android grande (que contém código nativo para várias arquiteturas de CPU).
Conceitualmente, a configuração é uma instância BuildOptions
. No entanto, na prática, BuildOptions
é encapsulado por BuildConfiguration
, que fornece várias funcionalidades adicionais. Ele se propaga de cima para baixo no gráfico de dependências. Se ele mudar, será necessário analisar o build novamente.
Isso resulta em anomalias, como ter que analisar novamente todo o build se, por exemplo, o número de execuções de teste solicitadas mudar, mesmo que isso afete apenas os destinos de teste. Temos planos de "cortar" as configurações para que isso não aconteça, mas ainda não está pronto.
Quando uma implementação de regra precisa de parte da configuração, ela precisa declarar
isso na definição usando RuleClass.Builder.requiresConfigurationFragments()
. Isso evita erros (como regras do Python usando o fragmento Java) e facilita o corte da configuração. Assim, se as opções do Python mudarem, os destinos do C++ não precisarão ser analisados novamente.
A configuração de uma regra não é necessariamente a mesma da regra "principal". O processo de mudança da configuração em uma aresta de dependência é chamado de "transição de configuração". Isso pode acontecer em dois lugares:
- Em uma aresta de dependência. Essas transições são especificadas em
Attribute.Builder.cfg()
e são funções de umRule
(onde a transição acontece) e umBuildOptions
(a configuração original) para um ou maisBuildOptions
(a configuração de saída). - Em qualquer aresta de entrada para um destino configurado. Eles são especificados em
RuleClass.Builder.cfg()
.
As classes relevantes são TransitionFactory
e ConfigurationTransition
.
As transições de configuração são usadas, por exemplo:
- Para declarar que uma dependência específica é usada durante o build e, portanto, precisa ser criada na arquitetura de execução.
- Para declarar que uma dependência específica precisa ser criada para várias arquiteturas (como código nativo em APKs do Android fat)
Se uma transição de configuração resultar em várias configurações, ela será chamada de transição dividida.
As transições de configuração também podem ser implementadas em Starlark (documentação aqui).
Provedores de informações transitivas
Os provedores de informações transitivas são uma maneira (e a _única_ maneira) de os destinos configurados aprenderem sobre outros destinos configurados de que dependem e a única maneira de informar sobre si mesmos a outros destinos configurados que dependem deles. O motivo de "transitivo" estar no nome é que geralmente se trata de algum tipo de resumo do fechamento transitivo de uma meta configurada.
Geralmente, há uma correspondência de 1:1 entre provedores de informações transitivas do Java e do Starlark. A exceção é DefaultInfo
, que é uma fusão de FileProvider
, FilesToRunProvider
e RunfilesProvider
porque essa API foi considerada mais parecida com o Starlark do que uma transliteração direta da API Java.
A chave é uma das seguintes opções:
- Um objeto de classe Java. Isso só está disponível para provedores que não são acessíveis pelo Starlark. Esses provedores são uma subclasse de
TransitiveInfoProvider
. - Uma string. Essa é uma prática legada e não recomendada, já que é suscetível a conflitos de nomes. Esses provedores de informações transitivas são subclasses diretas de
build.lib.packages.Info
. - Um símbolo de provedor. Ele pode ser criado no Starlark usando a função
provider()
e é a maneira recomendada de criar novos provedores. O símbolo é representado por uma instânciaProvider.Key
em Java.
Os novos provedores implementados em Java precisam usar BuiltinProvider
.
NativeProvider
está descontinuado (ainda não tivemos tempo de remover) e as subclasses TransitiveInfoProvider
não podem ser acessadas pelo Starlark.
Metas configuradas
Os destinos configurados são implementados como RuleConfiguredTargetFactory
. Há uma subclasse para cada classe de regra implementada em Java. Os destinos configurados do Starlark
são criados com StarlarkRuleConfiguredTargetUtil.buildRule()
.
As fábricas de destino configuradas precisam usar RuleConfiguredTargetBuilder
para
construir o valor de retorno. Ele consiste no seguinte:
- O
filesToBuild
, o conceito vago de "o conjunto de arquivos que esta regra representa". Esses são os arquivos criados quando o destino configurado está na linha de comando ou nos srcs de uma genrule. - Os arquivos de execução, regulares e de dados.
- Os grupos de saída. Esses são vários "outros conjuntos de arquivos" que a regra pode
criar. É possível acessar esses grupos usando o atributo "output_group" da regra "filegroup" em BUILD e o provedor
OutputGroupInfo
em Java.
Runfiles
Alguns binários precisam de arquivos de dados para serem executados. Um exemplo importante são os testes que precisam de arquivos de entrada. Isso é representado no Bazel pelo conceito de "runfiles". Uma "árvore de arquivos de execução" é uma árvore de diretórios dos arquivos de dados de um determinado binário. Ele é criado no sistema de arquivos como uma árvore de symlink com symlinks individuais que apontam para os arquivos nas árvores de origem ou de saída.
Um conjunto de runfiles é representado como uma instância Runfiles
. Conceitualmente, é um mapa do caminho de um arquivo na árvore de arquivos de execução para a instância Artifact
que o representa. É um pouco mais complicado do que um único Map
por dois motivos:
- Na maioria das vezes, o caminho de runfiles de um arquivo é igual ao execpath. Usamos isso para economizar um pouco de RAM.
- Há vários tipos legados de entradas em árvores de runfiles, que também precisam ser representadas.
Os runfiles são coletados usando RunfilesProvider
: uma instância dessa classe representa os runfiles que um destino configurado (como uma biblioteca) e seu fechamento transitivo precisam, e eles são reunidos como um conjunto aninhado. Na verdade, eles são implementados usando conjuntos aninhados: cada destino une os runfiles das dependências, adiciona alguns próprios e envia o conjunto resultante para cima no gráfico de dependências. Uma instância RunfilesProvider
contém duas instâncias Runfiles
, uma para quando a regra depende do atributo "data" e outra para todos os outros tipos de dependência recebida. Isso ocorre porque um destino às vezes apresenta runfiles diferentes quando depende de um atributo de dados do que de outra forma. Esse é um comportamento legado indesejado que ainda não conseguimos
remover.
Os runfiles de binários são representados como uma instância de RunfilesSupport
. Isso é diferente de Runfiles
porque RunfilesSupport
pode ser criado de fato (ao contrário de Runfiles
, que é apenas um mapeamento). Isso exige os seguintes componentes adicionais:
- O manifesto de runfiles de entrada. Esta é uma descrição serializada da árvore de arquivos de execução. Ele é usado como um proxy para o conteúdo da árvore de arquivos de execução, e o Bazel pressupõe que a árvore de arquivos de execução muda somente se o conteúdo do manifesto mudar.
- O manifesto de runfiles de saída. Usado por bibliotecas de tempo de execução que processam árvores de runfiles, principalmente no Windows, que às vezes não oferece suporte a links simbólicos.
- Argumentos de linha de comando para executar o binário cujos runfiles o
objeto
RunfilesSupport
representa.
Aspectos
Os aspectos são uma maneira de "propagar a computação pelo gráfico de dependência". Elas são
descritas para usuários do Bazel
aqui. Um bom exemplo motivador são os buffers de protocolo: uma regra proto_library
não deve conhecer nenhuma linguagem específica, mas a criação da implementação de uma mensagem de buffer de protocolo (a "unidade básica" dos buffers de protocolo) em qualquer linguagem de programação deve ser acoplada à regra proto_library
para que, se dois destinos na mesma linguagem dependerem do mesmo buffer de protocolo, ele seja criado apenas uma vez.
Assim como os destinos configurados, eles são representados no Skyframe como um SkyValue
, e a forma como são construídos é muito semelhante à dos destinos configurados: eles têm uma classe de fábrica chamada ConfiguredAspectFactory
que tem acesso a um RuleContext
, mas, ao contrário das fábricas de destinos configurados, ela também conhece o destino configurado a que está anexada e seus provedores.
O conjunto de aspectos propagados no gráfico de dependência é especificado para cada
atributo usando a função Attribute.Builder.aspects()
. Há algumas classes com nomes confusos que participam do processo:
AspectClass
é a implementação do aspecto. Ele pode estar em Java (nesse caso, é uma subclasse) ou em Starlark (nesse caso, é uma instância deStarlarkAspectClass
). É análogo aRuleConfiguredTargetFactory
.AspectDefinition
é a definição do aspecto. Ele inclui os provedores necessários e os que fornece, além de conter uma referência à implementação, como a instânciaAspectClass
apropriada. Ele é análogo aRuleClass
.AspectParameters
é uma maneira de parametrizar um aspecto que é propagado pelo gráfico de dependência. No momento, é um mapa de string para string. Um bom exemplo de por que isso é útil são os buffers de protocolo: se uma linguagem tiver várias APIs, as informações sobre para qual API os buffers de protocolo devem ser criados serão propagadas pelo gráfico de dependência.Aspect
representa todos os dados necessários para calcular um aspecto que se propaga pelo gráfico de dependência. Ele consiste na classe de aspecto, na definição e nos parâmetros dela.RuleAspect
é a função que determina quais aspectos uma regra específica deve propagar. É uma funçãoRule
->Aspect
.
Uma complicação um pouco inesperada é que os aspectos podem ser anexados a outros aspectos. Por exemplo, um aspecto que coleta o classpath de uma IDE Java provavelmente vai querer saber sobre todos os arquivos .jar no classpath, mas alguns deles são buffers de protocolo. Nesse caso, o aspecto do IDE vai querer se anexar ao par (regra proto_library
+ aspecto do proto Java).
A complexidade dos aspectos sobre aspectos é capturada na classe AspectCollection
.
Plataformas e cadeias de ferramentas
O Bazel oferece suporte a builds multiplataforma, ou seja, builds em que pode haver várias arquiteturas em que as ações de build são executadas e várias arquiteturas para as quais o código é criado. Essas arquiteturas são chamadas de plataformas na linguagem do Bazel (documentação completa aqui).
Uma plataforma é descrita por um mapeamento de chave-valor de configurações de restrição (como o conceito de "arquitetura de CPU") para valores de restrição (como uma CPU específica, como x86_64). Temos um "dicionário" das configurações e valores de restrição mais usados no repositório @platforms
.
O conceito de toolchain vem do fato de que, dependendo das plataformas em que o build está sendo executado e das plataformas de destino, pode ser necessário usar compiladores diferentes. Por exemplo, uma toolchain específica de C++ pode ser executada em um SO específico e ser capaz de segmentar alguns outros SOs. O Bazel precisa determinar o compilador C++ usado com base na execução definida e na plataforma de destino (documentação para toolchains aqui).
Para isso, as toolchains são anotadas com o conjunto de restrições de execução e plataforma de destino que elas oferecem suporte. Para fazer isso, a definição de um conjunto de ferramentas é dividida em duas partes:
- Uma regra
toolchain()
que descreve o conjunto de restrições de execução e destino compatíveis com uma cadeia de ferramentas e informa o tipo (como C++ ou Java) de cadeia de ferramentas (o último é representado pela regratoolchain_type()
). - Uma regra específica do idioma que descreve o conjunto de ferramentas real (como
cc_toolchain()
)
Isso é feito dessa forma porque precisamos conhecer as restrições de cada
cadeia de ferramentas para fazer a resolução e as regras *_toolchain()
específicas da linguagem contêm muito mais informações do que isso, então levam mais
tempo para carregar.
As plataformas de execução são especificadas de uma das seguintes maneiras:
- No arquivo MODULE.bazel usando a função
register_execution_platforms()
- Na linha de comando usando a opção --extra_execution_platforms
O conjunto de plataformas de execução disponíveis é calculado em RegisteredExecutionPlatformsFunction
.
A plataforma de destino de um destino configurado é determinada por
PlatformOptions.computeTargetPlatform()
. É uma lista de plataformas porque queremos oferecer suporte a várias plataformas de destino, mas isso ainda não foi implementado.
O conjunto de toolchains a ser usado para um destino configurado é determinado por
ToolchainResolutionFunction
. Ela é uma função de:
- O conjunto de conjuntos de ferramentas registrados (no arquivo MODULE.bazel e na configuração)
- As plataformas de execução e de destino desejadas (na configuração)
- O conjunto de tipos de cadeia de ferramentas exigidos pelo destino configurado (em
UnloadedToolchainContextKey)
- O conjunto de restrições da plataforma de execução do destino configurado (o atributo
exec_compatible_with
) e a configuração (--experimental_add_exec_constraints_to_targets
), emUnloadedToolchainContextKey
.
O resultado é um UnloadedToolchainContext
, que é essencialmente um mapa do tipo de
ferramental (representado como uma instância ToolchainTypeInfo
) para o rótulo do
ferramental selecionado. Ele é chamado de "descarregado" porque não contém as
próprias cadeias de ferramentas, apenas os rótulos delas.
Em seguida, as toolchains são carregadas usando ResolvedToolchainContext.load()
e usadas pela implementação do destino configurado que as solicitou.
Também temos um sistema legado que depende de uma única configuração de "host" e de configurações de destino representadas por várias flags de configuração, como --cpu
. Estamos fazendo a transição gradual para o sistema acima. Para lidar com casos em que as pessoas dependem dos valores de configuração legados, implementamos mapeamentos de plataforma para traduzir entre as flags legadas e as restrições de plataforma no novo estilo.
O código está em PlatformMappingFunction
e usa uma "linguagem
pequena" que não é Starlark.
Restrições
Às vezes, é necessário designar um destino como compatível com apenas algumas plataformas. O Bazel tem (infelizmente) vários mecanismos para alcançar esse objetivo:
- Restrições específicas da regra
environment_group()
/environment()
- Restrições de plataforma
As restrições específicas da regra são usadas principalmente no Google para regras do Java. Elas estão sendo descontinuadas e não estão disponíveis no Bazel, mas o código-fonte pode conter referências a elas. O atributo que rege isso é chamado de
constraints=
.
environment_group() e environment()
Essas regras são um mecanismo legado e não são amplamente usadas.
Todas as regras de build podem declarar para quais "ambientes" elas podem ser criadas, em que um "ambiente" é uma instância da regra environment()
.
Há várias maneiras de especificar ambientes compatíveis para uma regra:
- Usando o atributo
restricted_to=
. Essa é a forma mais direta de especificação, declarando o conjunto exato de ambientes compatíveis com a regra. - Usando o atributo
compatible_with=
. Isso declara os ambientes que uma regra aceita, além dos ambientes "padrão" que são aceitos por padrão. - Usando os atributos
default_restricted_to=
edefault_compatible_with=
no nível do pacote. - Usando especificações padrão em regras
environment_group()
. Cada ambiente pertence a um grupo de elementos relacionados tematicamente (como "arquiteturas de CPU", "versões do JDK" ou "sistemas operacionais móveis"). A definição de um grupo de ambiente inclui quais desses ambientes devem ser compatíveis com "default" se não for especificado de outra forma pelos atributosrestricted_to=
/environment()
. Uma regra sem esses atributos herda todos os padrões. - Por um padrão de classe de regra. Isso substitui os padrões globais para todas as instâncias da classe de regra especificada. Isso pode ser usado, por exemplo, para tornar todas as regras
*_test
testáveis sem que cada instância precise declarar explicitamente essa capacidade.
environment()
é implementado como uma regra regular, enquanto environment_group()
é uma subclasse de Target
, mas não de Rule
(EnvironmentGroup
), e uma
função disponível por padrão no Starlark
(StarlarkLibrary.environmentGroup()
), que eventualmente cria um destino
epônimo. Isso evita uma dependência cíclica que surgiria porque cada ambiente precisa declarar o grupo a que pertence, e cada grupo precisa declarar os ambientes padrão.
Um build pode ser restrito a um determinado ambiente com a
opção de linha de comando --target_environment
.
A implementação da verificação de restrição está em
RuleContextConstraintSemantics
e TopLevelConstraintSemantics
.
Restrições de plataforma
A maneira "oficial" atual de descrever com quais plataformas um destino é compatível é usando as mesmas restrições usadas para descrever toolchains e plataformas. Ela foi implementada na solicitação de envio #10945.
Visibilidade
Se você trabalha em uma base de código grande com muitos desenvolvedores (como no Google), é importante evitar que todos dependam arbitrariamente do seu código. Caso contrário, de acordo com a lei de Hyrum, as pessoas vão depender de comportamentos que você considerou detalhes de implementação.
O Bazel oferece suporte a isso com o mecanismo chamado visibilidade: é possível limitar quais destinos podem depender de um destino específico usando o atributo visibility. Esse atributo é um pouco especial porque, embora contenha uma lista de rótulos, eles podem codificar um padrão em nomes de pacotes em vez de um ponteiro para um destino específico. (Sim, isso é uma falha de design.)
Isso é implementado nos seguintes locais:
- A interface
RuleVisibility
representa uma declaração de visibilidade. Ele pode ser uma constante (totalmente público ou totalmente particular) ou uma lista de rótulos. - Os rótulos podem se referir a grupos de pacotes (lista predefinida de pacotes), a pacotes diretamente (
//pkg:__pkg__
) ou a subárvores de pacotes (//pkg:__subpackages__
). Isso é diferente da sintaxe da linha de comando, que usa//pkg:*
ou//pkg/...
. - Os grupos de pacotes são implementados como um destino próprio (
PackageGroup
) e um destino configurado (PackageGroupConfiguredTarget
). Provavelmente, poderíamos substituir esses elementos por regras simples, se quiséssemos. A lógica é implementada com a ajuda de:PackageSpecification
, que corresponde a um único padrão como//pkg/...
;PackageGroupContents
, que corresponde a um único atributopackages
depackage_group
; ePackageSpecificationProvider
, que agrega umpackage_group
e seuincludes
transitivo. - A conversão de listas de rótulos de visibilidade para dependências é feita em
DependencyResolver.visitTargetVisibility
e em alguns outros lugares variados. - A verificação real é feita em
CommonPrerequisiteValidator.validateDirectPrerequisiteVisibility()
Conjuntos aninhados
Muitas vezes, um destino configurado agrega um conjunto de arquivos das dependências, adiciona os próprios e encapsula o conjunto agregado em um provedor de informações transitivo para que os destinos configurados que dependem dele possam fazer o mesmo. Exemplos:
- Os arquivos de cabeçalho C++ usados para um build
- Os arquivos de objeto que representam o fechamento transitivo de um
cc_library
- O conjunto de arquivos .jar que precisam estar no caminho de classe para que uma regra Java seja compilada ou executada.
- O conjunto de arquivos Python no fechamento transitivo de uma regra Python
Se fizermos isso de maneira simples usando, por exemplo, List
ou Set
, vamos acabar com o uso de memória quadrática: se houver uma cadeia de N regras e cada regra adicionar um arquivo, teremos 1+2+...+N membros da coleção.
Para contornar esse problema, criamos o conceito de um NestedSet
. É uma estrutura de dados composta por outras instâncias de NestedSet
e alguns membros próprios, formando um grafo acíclico direcionado
de conjuntos. Eles são imutáveis e podem ser iterados. Definimos várias ordens de iteração (NestedSet.Order
): pré-ordem, pós-ordem, topológica (um nó sempre vem depois dos ancestrais) e "não importa, mas deve ser a mesma a cada vez".
A mesma estrutura de dados é chamada de depset
em Starlark.
Artefatos e ações
O build real consiste em um conjunto de comandos que precisam ser executados para produzir
a saída desejada pelo usuário. Os comandos são representados como instâncias da classe Action
, e os arquivos são representados como instâncias da classe Artifact
. Eles são organizados em um gráfico bipartido, direcionado e acíclico chamado de "gráfico de ação".
Os artefatos são de dois tipos: de origem (disponíveis antes da execução do Bazel) e derivados (que precisam ser criados). Os artefatos derivados podem ser de vários tipos:
- Artefatos regulares. Para verificar se eles estão atualizados, calculamos a soma de verificação deles, com mtime como um atalho. Não calculamos a soma de verificação do arquivo se o ctime não mudou.
- Artefatos de link simbólico não resolvidos. Eles são verificados para garantir que estão atualizados chamando readlink(). Ao contrário dos artefatos regulares, eles podem ser symlinks soltos. Geralmente usado em casos em que se empacota alguns arquivos em um arquivo de algum tipo.
- Artefatos de árvore. Não são arquivos únicos, mas árvores de diretórios. Eles são verificados para garantir que estão atualizados, analisando o conjunto de arquivos e o conteúdo deles. Eles são representados como um
TreeArtifact
. - Artefatos de metadados constantes. As mudanças nesses artefatos não acionam uma recompilação. Isso é usado exclusivamente para informações de carimbo de build. Não queremos fazer uma recriação apenas porque a hora atual mudou.
Não há um motivo fundamental para que os artefatos de origem não possam ser artefatos de árvore ou
artefatos de symlink não resolvidos. Acontece que ainda não implementamos isso (mas
deveríamos. Referenciar um diretório de origem em um arquivo BUILD
é um dos
poucos problemas de incorreção conhecidos e de longa data com o Bazel. Temos uma
implementação que funciona, ativada pela propriedade
BAZEL_TRACK_SOURCE_DIRECTORIES=1
JVM).
As ações são melhor entendidas como um comando que precisa ser executado, o ambiente necessário e o conjunto de saídas que ele produz. Os principais componentes da descrição de uma ação são:
- A linha de comando que precisa ser executada
- Os artefatos de entrada necessários
- As variáveis de ambiente que precisam ser definidas
- Anotações que descrevem o ambiente (como plataforma) em que ele precisa ser executado \
Há também alguns outros casos especiais, como gravar um arquivo cujo conteúdo é
conhecido pelo Bazel. Elas são uma subclasse de AbstractAction
. A maioria das ações é
um SpawnAction
ou um StarlarkAction
(o mesmo, eles não deveriam ser
classes separadas), embora Java e C++ tenham seus próprios tipos de ação
(JavaCompileAction
, CppCompileAction
e CppLinkAction
).
Eventualmente, queremos mover tudo para SpawnAction
. JavaCompileAction
é bem parecido, mas o C++ é um caso especial devido à análise de arquivos .d e à verificação de inclusão.
O gráfico de ações é "incorporado" ao gráfico do Skyframe: conceitualmente, a execução de uma ação é representada como uma invocação de ActionExecutionFunction
. O mapeamento de uma aresta de dependência do gráfico de ações para uma aresta de dependência do Skyframe é descrito em ActionExecutionFunction.getInputDeps()
e Artifact.key()
e tem algumas otimizações para manter baixo o número de arestas do Skyframe:
- Os artefatos derivados não têm
SkyValue
s próprios. Em vez disso,Artifact.getGeneratingActionKey()
é usado para descobrir a chave da ação que a gera. - Os conjuntos aninhados têm a própria chave do Skyframe.
Ações compartilhadas
Algumas ações são geradas por vários destinos configurados. As regras do Starlark são mais limitadas, já que só podem colocar as ações derivadas em um diretório determinado pela configuração e pelo pacote. No entanto, regras no mesmo pacote podem entrar em conflito, mas as implementadas em Java podem colocar artefatos derivados em qualquer lugar.
Isso é considerado um recurso inadequado, mas é muito difícil se livrar dele porque gera uma economia significativa no tempo de execução quando, por exemplo, um arquivo de origem precisa ser processado de alguma forma e é referenciado por várias regras (gesto com as mãos). Isso tem um custo de RAM: cada instância de uma ação compartilhada precisa ser armazenada na memória separadamente.
Se duas ações gerarem o mesmo arquivo de saída, elas precisam ser exatamente iguais: ter as mesmas entradas e saídas e executar a mesma linha de comando. Essa relação de equivalência é implementada em Actions.canBeShared()
e verificada entre as fases de análise e execução ao analisar cada ação.
Isso é implementado em SkyframeActionExecutor.findAndStoreArtifactConflicts()
e é um dos poucos lugares no Bazel que exigem uma visualização "global" da build.
A fase de execução
É quando o Bazel começa a executar ações de build, como comandos que produzem saídas.
A primeira coisa que o Bazel faz após a fase de análise é determinar quais
artefatos precisam ser criados. A lógica para isso é codificada em
TopLevelArtifactHelper
. Em termos gerais, é o filesToBuild
dos
destinos configurados na linha de comando e o conteúdo de um grupo de saída
especial com o propósito explícito de expressar "se este destino estiver na linha de
comando, crie estes artefatos".
A próxima etapa é criar a raiz de execução. Como o Bazel tem a opção de ler pacotes de origem de diferentes locais no sistema de arquivos (--package_path
), ele precisa fornecer às ações executadas localmente uma árvore de origem completa. Isso é processado pela classe SymlinkForest
e funciona observando cada destino usado na fase de análise e criando uma única árvore de diretórios que vincula simbolicamente cada pacote com um destino usado do local real. Uma alternativa seria transmitir os caminhos corretos para os comandos, considerando --package_path
.
Isso é indesejável porque:
- Ele muda as linhas de comando de ação quando um pacote é movido de uma entrada de caminho de pacote para outra (era uma ocorrência comum).
- Isso resulta em linhas de comando diferentes se uma ação for executada remotamente ou localmente.
- Ela exige uma transformação de linha de comando específica para a ferramenta em uso (considere a diferença entre caminhos de classe Java e caminhos de inclusão C++)
- Mudar a linha de comando de uma ação invalida a entrada do cache dela.
- A API
--package_path
está sendo descontinuada de forma lenta e constante.
Em seguida, o Bazel começa a percorrer o gráfico de ações (o gráfico bipartido e direcionado composto de ações e seus artefatos de entrada e saída) e a executar ações.
A execução de cada ação é representada por uma instância da classe SkyValue
ActionExecutionValue
.
Como executar uma ação é caro, temos algumas camadas de cache que podem ser atingidas por trás do Skyframe:
ActionExecutionFunction.stateMap
contém dados para tornar reinicializações do Skyframe deActionExecutionFunction
baratas.- O cache de ações local contém dados sobre o estado do sistema de arquivos.
- Os sistemas de execução remota geralmente também têm um cache próprio.
O cache de ações locais
Esse cache é outra camada que fica atrás do Skyframe. Mesmo que uma ação seja reexecutada no Skyframe, ela ainda pode ser um acerto no cache de ação local. Ele representa o estado do sistema de arquivos local e é serializado em disco. Isso significa que, quando um novo servidor Bazel é iniciado, é possível receber hits do cache de ações local mesmo que o gráfico do Skyframe esteja vazio.
Esse cache é verificado para hits usando o método
ActionCacheChecker.getTokenIfNeedToExecute()
.
Ao contrário do nome, é um mapa do caminho de um artefato derivado para a ação que o emitiu. A ação é descrita como:
- O conjunto de arquivos de entrada e saída e o checksum deles
- A "chave de ação", que geralmente é a linha de comando executada, mas, em geral, representa tudo o que não é capturado pelo checksum dos arquivos de entrada (por exemplo, para
FileWriteAction
, é o checksum dos dados gravados).
Há também um "cache de ação de cima para baixo" altamente experimental que ainda está em desenvolvimento e usa hashes transitivos para evitar acessar o cache muitas vezes.
Descoberta e poda de entradas
Algumas ações são mais complicadas do que apenas ter um conjunto de entradas. As mudanças no conjunto de entradas de uma ação vêm de duas formas:
- Uma ação pode descobrir novas entradas antes da execução ou decidir que algumas delas não são realmente necessárias. O exemplo canônico é o C++,
em que é melhor fazer uma estimativa sobre quais arquivos de cabeçalho um arquivo C++
usa do fechamento transitivo para não precisar enviar todos os
arquivos para executores remotos. Portanto, temos uma opção de não registrar todos os
arquivos de cabeçalho como "entrada", mas verificar o arquivo de origem para cabeçalhos incluídos
transitoriamente e marcar apenas os arquivos de cabeçalho como entradas que são
mencionados em instruções
#include
. Superestimamos para não precisar implementar um pré-processador C completo. No momento, essa opção está codificada como "false" no Bazel e é usada apenas no Google. - Uma ação pode perceber que alguns arquivos não foram usados durante a execução. Em C++, isso é chamado de "arquivos .d": o compilador informa quais arquivos de cabeçalho foram usados após o fato e, para evitar o constrangimento de ter uma incrementalidade pior do que o Make, o Bazel usa esse fato. Isso oferece uma estimativa melhor do que o verificador de inclusão porque depende do compilador.
Eles são implementados usando métodos em "Action":
Action.discoverInputs()
é chamado. Ele vai retornar um conjunto aninhado de artefatos considerados necessários. Eles precisam ser artefatos de origem para que não haja arestas de dependência no gráfico de ações que não tenham um equivalente no gráfico de destino configurado.- A ação é executada chamando
Action.execute()
. - No final de
Action.execute()
, a ação pode chamarAction.updateInputs()
para informar ao Bazel que nem todas as entradas foram necessárias. Isso pode resultar em builds incrementais incorretos se uma entrada usada for informada como não utilizada.
Quando um cache de ação retorna uma correspondência em uma nova instância de ação (como uma criada
após uma reinicialização do servidor), o Bazel chama updateInputs()
para que o conjunto de
entradas reflita o resultado da descoberta e da remoção de entradas feitas anteriormente.
As ações do Starlark podem usar o recurso para declarar algumas entradas como não utilizadas
usando o argumento unused_inputs_list=
de
ctx.actions.run()
.
Várias maneiras de executar ações: Strategies/ActionContexts
Algumas ações podem ser executadas de maneiras diferentes. Por exemplo, uma linha de comando pode ser executada localmente, localmente, mas em vários tipos de sandbox, ou remotamente. O conceito que incorpora isso é chamado de ActionContext
(ou Strategy
, já que só renomeamos metade do nome...).
O ciclo de vida de um contexto de ação é o seguinte:
- Quando a fase de execução é iniciada, as instâncias
BlazeModule
são questionadas sobre os contextos de ação que têm. Isso acontece no construtor deExecutionTool
. Os tipos de contexto de ação são identificados por uma instância JavaClass
que se refere a uma subinterface deActionContext
e que interface o contexto de ação precisa implementar. - O contexto de ação adequado é selecionado entre os disponíveis e encaminhado para
ActionExecutionContext
eBlazeExecutor
. - Contextos de solicitação de ações usando
ActionExecutionContext.getContext()
eBlazeExecutor.getStrategy()
(na verdade, só deveria haver uma maneira de fazer isso…)
As estratégias podem chamar outras estratégias para fazer o trabalho delas. Isso é usado, por exemplo, na estratégia dinâmica que inicia ações local e remotamente e usa a que terminar primeiro.
Uma estratégia notável é a que implementa processos de worker permanentes (WorkerSpawnStrategy
). A ideia é que algumas ferramentas têm um longo tempo de inicialização e, portanto, precisam ser reutilizadas entre ações em vez de iniciar uma nova para cada ação. Isso representa um possível problema de correção, já que o Bazel depende da promessa do processo de worker de que ele não carrega um estado observável entre solicitações individuais.
Se a ferramenta mudar, o processo de worker precisará ser reiniciado. Para determinar se um worker pode ser reutilizado, é calculado um checksum da ferramenta usada com WorkerFilesHash
. Ele depende de saber quais entradas da ação representam parte da ferramenta e quais representam entradas. Isso é determinado pelo criador da ação: Spawn.getToolFiles()
e os runfiles de Spawn
são contados como partes da ferramenta.
Mais informações sobre estratégias (ou contextos de ação):
- Confira informações sobre várias estratégias para executar ações neste link.
- Informações sobre a estratégia dinâmica, em que executamos uma ação local e remotamente para ver qual termina primeiro, estão disponíveis aqui.
- Informações sobre as complexidades da execução de ações locais estão disponíveis aqui.
O gerenciador de recursos local
O Bazel pode executar muitas ações em paralelo. O número de ações locais que devem ser executadas em paralelo varia de ação para ação: quanto mais recursos uma ação exige, menos instâncias devem ser executadas ao mesmo tempo para evitar sobrecarregar a máquina local.
Isso é implementado na classe ResourceManager
: cada ação precisa ser
anotada com uma estimativa dos recursos locais necessários na forma de uma
instância ResourceSet
(CPU e RAM). Quando os contextos de ação fazem algo
que exige recursos locais, eles chamam ResourceManager.acquireResources()
e ficam bloqueados até que os recursos necessários estejam disponíveis.
Confira uma descrição mais detalhada do gerenciamento de recursos locais neste link.
A estrutura do diretório de saída
Cada ação exige um lugar separado no diretório de saída para colocar os resultados. O local dos artefatos derivados geralmente é o seguinte:
$EXECROOT/bazel-out/<configuration>/bin/<package>/<artifact name>
Como o nome do diretório associado a uma configuração específica é determinado? Há duas propriedades desejáveis conflitantes:
- Se duas configurações puderem ocorrer no mesmo build, elas precisarão ter diretórios diferentes para que ambas tenham a própria versão da mesma ação. Caso contrário, se as duas configurações discordarem, por exemplo, sobre a linha de comando de uma ação que produz o mesmo arquivo de saída, o Bazel não saberá qual ação escolher (um "conflito de ação").
- Se duas configurações representam "aproximadamente" a mesma coisa, elas devem ter o mesmo nome para que as ações executadas em uma possam ser reutilizadas na outra se as linhas de comando forem iguais. Por exemplo, as mudanças nas opções de linha de comando do compilador Java não devem resultar na nova execução das ações de compilação do C++.
Até agora, não encontramos uma maneira fundamentada de resolver esse problema, que tem semelhanças com o problema de corte de configuração. Confira uma discussão mais longa sobre as opções neste link. As principais áreas problemáticas são as regras do Starlark (cujos autores geralmente não estão familiarizados com o Bazel) e os aspectos, que adicionam outra dimensão ao espaço de coisas que podem produzir o "mesmo" arquivo de saída.
A abordagem atual é que o segmento de caminho para a configuração é
<CPU>-<compilation mode>
com vários sufixos adicionados para que as transições de configuração
implementadas em Java não resultem em conflitos de ação. Além disso, um checksum do conjunto de transições de configuração do Starlark é adicionado para que os usuários não causem conflitos de ação. Ele está longe de ser perfeito. Isso é implementado em
OutputDirectories.buildMnemonic()
e depende de cada fragmento de configuração
adicionando a própria parte ao nome do diretório de saída.
Testes
O Bazel oferece suporte avançado para a execução de testes. Ela aceita estas opções:
- Executar testes remotamente (se um back-end de execução remota estiver disponível)
- Executar testes várias vezes em paralelo (para remoção de falhas ou coleta de dados de tempo)
- Fragmentação de testes (divisão de casos de teste no mesmo teste em vários processos para aumentar a velocidade)
- Executar novamente testes instáveis
- Agrupamento de testes em pacotes de teste
Os testes são destinos regulares configurados que têm um TestProvider, que descreve como o teste deve ser executado:
- Os artefatos cuja criação resulta na execução do teste. Esse é um arquivo "cache
status" que contém uma mensagem
TestResultData
serializada. - O número de vezes que o teste deve ser executado
- O número de fragmentos em que o teste deve ser dividido.
- Alguns parâmetros sobre como o teste deve ser executado (como o tempo limite do teste)
Determinar quais testes executar
Determinar quais testes serão executados é um processo elaborado.
Primeiro, durante a análise do padrão de destino, os conjuntos de testes são expandidos de forma recursiva. A expansão é implementada em TestsForTargetPatternFunction
. Um detalhe um pouco
surpreendente é que, se um pacote de testes não declarar nenhum teste, ele se referirá a
todos os testes no pacote. Isso é implementado em Package.beforeBuild()
ao
adicionar um atributo implícito chamado $implicit_tests
às regras do pacote de testes.
Em seguida, os testes são filtrados por tamanho, tags, tempo limite e idioma de acordo com as opções da linha de comando. Isso é implementado em TestFilter
e chamado de
TargetPatternPhaseFunction.determineTests()
durante a análise de destino. O
resultado é colocado em TargetPatternPhaseValue.getTestsToRunLabels()
. O motivo
pelo qual os atributos de regra que podem ser filtrados não são configuráveis é que isso
acontece antes da fase de análise. Portanto, a configuração não está
disponível.
Em seguida, isso é processado em BuildView.createResult()
: os destinos cuja análise falhou são filtrados, e os testes são divididos em exclusivos e não exclusivos. Em seguida, ele é colocado em AnalysisResult
, que é como
o ExecutionTool
sabe quais testes executar.
Para dar mais transparência a esse processo elaborado, o operador de consulta tests()
(implementado em TestsFunction
) está disponível para informar quais testes são executados quando um destino específico é especificado na linha de comando. Infelizmente, é uma reimplementação, então provavelmente desvia do acima de várias maneiras sutis.
Como executar testes
Os testes são executados solicitando artefatos de status do cache. Isso resulta na execução de um TestRunnerAction
, que eventualmente chama o TestActionContext
escolhido pela opção de linha de comando --test_strategy
que executa o teste da maneira solicitada.
Os testes são executados de acordo com um protocolo elaborado que usa variáveis de ambiente para informar aos testes o que é esperado deles. Uma descrição detalhada do que o Bazel espera dos testes e o que os testes podem esperar do Bazel está disponível aqui. Na forma mais simples, um código de saída 0 significa sucesso, e qualquer outro valor significa falha.
Além do arquivo de status do cache, cada processo de teste emite vários outros
arquivos. Eles são colocados no "diretório de registros de teste", que é o subdiretório chamado testlogs
do diretório de saída da configuração de destino:
test.xml
, um arquivo XML no estilo JUnit que detalha os casos de teste individuais no fragmento de testetest.log
, a saída do console do teste. stdout e stderr não são separados.test.outputs
, o "diretório de saídas não declaradas", usado por testes que querem gerar arquivos além do que é impresso no terminal.
Há duas coisas que podem acontecer durante a execução do teste, mas não durante a criação de destinos regulares: execução exclusiva do teste e streaming de saída.
Alguns testes precisam ser executados no modo exclusivo, por exemplo, não em paralelo com outros testes. Isso pode ser feito adicionando tags=["exclusive"]
à regra de teste ou executando o teste com --test_strategy=exclusive
. Cada teste exclusivo é executado por uma invocação separada do Skyframe, solicitando a execução do teste após o build "principal". Isso é implementado em
SkyframeExecutor.runExclusiveTest()
.
Ao contrário das ações regulares, cuja saída do terminal é despejada quando a ação
termina, o usuário pode solicitar que a saída dos testes seja transmitida para que ele
seja informado sobre o progresso de um teste de longa duração. Isso é especificado pela opção de linha de comando --test_output=streamed
e implica a execução exclusiva de testes para que as saídas de testes diferentes não sejam intercaladas.
Isso é implementado na classe StreamedTestOutput
, que tem um nome adequado, e funciona
pesquisando mudanças no arquivo test.log
do teste em questão e despejando novos
bytes no terminal em que as regras do Bazel são executadas.
Os resultados dos testes executados estão disponíveis no barramento de eventos ao observar
vários eventos (como TestAttempt
, TestResult
ou TestingCompleteEvent
).
Eles são despejados no protocolo de eventos de build e emitidos para o console
por AggregatingTestListener
.
Coleta de cobertura
A cobertura é informada pelos testes no formato LCOV nos arquivos
bazel-testlogs/$PACKAGE/$TARGET/coverage.dat
.
Para coletar a cobertura, cada execução de teste é encapsulada em um script chamado
collect_coverage.sh
.
Esse script configura o ambiente do teste para ativar a coleta de cobertura e determinar onde os arquivos de cobertura são gravados pelos ambientes de execução de cobertura. Em seguida, ele executa o teste. Um teste pode executar vários subprocessos e consistir em partes escritas em várias linguagens de programação diferentes (com ambientes de execução de coleta de cobertura separados). O script wrapper é responsável por converter os arquivos resultantes para o formato LCOV, se necessário, e mesclá-los em um único arquivo.
A interposição de collect_coverage.sh
é feita pelas estratégias de teste e exige que collect_coverage.sh
esteja nas entradas do teste. Isso é feito pelo atributo implícito :coverage_support
, que é resolvido para o valor da flag de configuração --coverage_support
(consulte TestConfiguration.TestOptions.coverageSupport
).
Algumas linguagens fazem instrumentação off-line, ou seja, a instrumentação de cobertura é adicionada no momento da compilação (como C++), e outras fazem instrumentação on-line, ou seja, a instrumentação de cobertura é adicionada no momento da execução.
Outro conceito fundamental é a cobertura de linha de base. Essa é a cobertura de uma biblioteca, um binário ou um teste se nenhum código foi executado. O problema que ele resolve é que, se você quiser calcular a cobertura de teste de um binário, não basta mesclar a cobertura de todos os testes, porque pode haver código no binário que não está vinculado a nenhum teste. Portanto, emitimos um arquivo de cobertura para cada
binário que contém apenas os arquivos para os quais coletamos cobertura, sem linhas
cobertas. O arquivo de cobertura de linha de base padrão para um destino está em
bazel-testlogs/$PACKAGE/$TARGET/baseline_coverage.dat
, mas é recomendável que as regras gerem os próprios arquivos de cobertura de linha de base com conteúdo mais significativo do que apenas os nomes dos arquivos de origem.
Rastrear dois grupos de arquivos para coleta de cobertura de cada regra: o conjunto de arquivos instrumentados e o conjunto de arquivos de metadados de instrumentação.
O conjunto de arquivos instrumentados é apenas isso, um conjunto de arquivos para instrumentar. Para tempos de execução de cobertura on-line, isso pode ser usado durante a execução para decidir quais arquivos serão instrumentados. Ele também é usado para implementar a cobertura de linha de base.
O conjunto de arquivos de metadados de instrumentação é o conjunto de arquivos extras que um teste precisa para gerar os arquivos LCOV que o Bazel exige dele. Na prática, isso consiste em arquivos específicos do tempo de execução. Por exemplo, o gcc emite arquivos .gcno durante a compilação. Elas são adicionadas ao conjunto de entradas de ações de teste se o modo de cobertura estiver ativado.
Se a cobertura está sendo coletada ou não, isso é armazenado no
BuildConfiguration
. Isso é útil porque é uma maneira fácil de mudar a ação de teste e o gráfico de ações dependendo desse bit. No entanto, também significa que, se esse bit for invertido, todas as metas precisarão ser analisadas novamente. Algumas linguagens, como C++, exigem diferentes opções de compilador para emitir código que possa coletar cobertura, o que atenua um pouco esse problema, já que uma nova análise é necessária de qualquer maneira.
Os arquivos de suporte de cobertura dependem de rótulos em uma dependência implícita para que possam ser substituídos pela política de invocação, o que permite que eles sejam diferentes entre as versões do Bazel. O ideal seria remover essas diferenças e padronizar uma delas.
Também geramos um "relatório de cobertura" que mescla a cobertura coletada para cada teste em uma invocação do Bazel. Isso é tratado por
CoverageReportActionFactory
e é chamado de BuildView.createResult()
. Ele
acessa as ferramentas necessárias analisando o atributo :coverage_report_generator
do primeiro teste executado.
O mecanismo de consulta
O Bazel tem uma linguagem pequena usada para fazer várias perguntas sobre vários gráficos. Os seguintes tipos de consulta são fornecidos:
- O
bazel query
é usado para investigar o gráfico de destino. - O
bazel cquery
é usado para investigar o gráfico de destino configurado. - O
bazel aquery
é usado para investigar o gráfico de ações.
Cada um deles é implementado por uma subclasse de AbstractBlazeQueryEnvironment
.
Outras funções de consulta podem ser feitas por subclasses de QueryFunction
. Para permitir o streaming de resultados de consultas, em vez de coletá-los em alguma estrutura de dados, um query2.engine.Callback
é transmitido para QueryFunction
, que o chama para os resultados que quer retornar.
O resultado de uma consulta pode ser emitido de várias maneiras: rótulos, rótulos e classes de regras, XML, protobuf e assim por diante. Elas são implementadas como subclasses de
OutputFormatter
.
Um requisito sutil de alguns formatos de saída de consulta (proto, com certeza) é que o Bazel precisa emitir _todas_ as informações que o carregamento de pacotes fornece para que se possa diferenciar a saída e determinar se um destino específico mudou. Como consequência, os valores de atributo precisam ser serializáveis. Por isso, há poucos tipos de atributo sem atributos com valores complexos do Starlark. A solução alternativa comum é usar um rótulo e anexar as informações complexas à regra com esse rótulo. Não é uma solução muito satisfatória, e seria muito bom eliminar esse requisito.
O sistema de módulos
O Bazel pode ser estendido com a adição de módulos. Cada módulo precisa criar uma subclasse de
BlazeModule
(o nome é uma relíquia da história do Bazel, quando ele era chamado de
Blaze) e recebe informações sobre vários eventos durante a execução de
um comando.
Eles são usados principalmente para implementar várias partes da funcionalidade "não essencial" que apenas algumas versões do Bazel (como a que usamos no Google) precisam:
- Interfaces para sistemas de execução remota
- Novos comandos
O conjunto de pontos de extensão BlazeModule
oferecidos é um pouco aleatório. Não
use como exemplo de bons princípios de design.
O barramento de eventos
A principal maneira de os BlazeModules se comunicarem com o restante do Bazel é por um barramento de eventos
(EventBus
): uma nova instância é criada para cada build, várias partes do Bazel
podem postar eventos nele, e os módulos podem registrar listeners para os eventos de
interesse. Por exemplo, os seguintes itens são representados como eventos:
- A lista de destinos de build a serem criados foi determinada
(
TargetParsingCompleteEvent
) - As configurações de nível superior foram determinadas
(
BuildConfigurationEvent
) - Um destino foi criado, com ou sem sucesso (
TargetCompleteEvent
) - Um teste foi executado (
TestAttempt
,TestSummary
)
Alguns desses eventos são representados fora do Bazel no
Build Event Protocol
(eles são BuildEvent
s). Isso permite que não apenas BlazeModule
s, mas também coisas
fora do processo do Bazel observem o build. Eles podem ser acessados como um arquivo que contém mensagens de protocolo ou o Bazel pode se conectar a um servidor (chamado de serviço de eventos de build) para transmitir eventos.
Isso é implementado nos pacotes Java build.lib.buildeventservice
e
build.lib.buildeventstream
.
Repositórios externos
Embora o Bazel tenha sido originalmente projetado para ser usado em um monorepo (uma única árvore de origem que contém tudo o que é necessário para criar), ele vive em um mundo em que isso não é necessariamente verdade. "Repositórios externos" são uma abstração usada para unir esses dois mundos: eles representam o código necessário para o build, mas não estão na árvore de origem principal.
O arquivo WORKSPACE
O conjunto de repositórios externos é determinado pela análise do arquivo WORKSPACE. Por exemplo, uma declaração como esta:
local_repository(name="foo", path="/foo/bar")
Os resultados no repositório chamado @foo
ficam disponíveis. A complicação é que é possível definir novas regras de repositório em arquivos Starlark, que podem ser usados para carregar novos códigos Starlark, que podem ser usados para definir novas regras de repositório e assim por diante.
Para lidar com esse caso, a análise do arquivo WORKSPACE (em
WorkspaceFileFunction
) é dividida em partes delimitadas por instruções load()
. O índice do bloco é indicado por WorkspaceFileKey.getIndex()
e
calcular WorkspaceFileFunction
até o índice X significa avaliar até a
Xª instrução load()
.
Buscando repositórios
Antes que o código do repositório fique disponível para o Bazel, ele precisa ser
buscado. Isso faz com que o Bazel crie um diretório em
$OUTPUT_BASE/external/<repository name>
.
A busca do repositório acontece nas seguintes etapas:
- O
PackageLookupFunction
percebe que precisa de um repositório e cria umRepositoryName
como umSkyKey
, que invocaRepositoryLoaderFunction
RepositoryLoaderFunction
encaminha a solicitação paraRepositoryDelegatorFunction
por motivos não claros. O código diz que é para evitar o novo download em caso de reinicializações do Skyframe, mas não é um raciocínio muito sólido.RepositoryDelegatorFunction
descobre a regra do repositório que precisa buscar iterando pelos blocos do arquivo WORKSPACE até que o repositório solicitado seja encontrado.- O
RepositoryFunction
apropriado é encontrado e implementa a busca do repositório. Ele é a implementação do repositório em Starlark ou um mapa codificado para repositórios implementados em Java.
Há várias camadas de armazenamento em cache, já que buscar um repositório pode ser muito caro:
- Há um cache para arquivos baixados que é identificado pela soma de verificação deles (
RepositoryCache
). Isso exige que a soma de verificação esteja disponível no arquivo WORKSPACE, o que é bom para a hermeticidade. Ela é compartilhada por todas as instâncias do servidor Bazel na mesma estação de trabalho, independente do espaço de trabalho ou da base de saída em que estão sendo executadas. - Um "arquivo marcador" é gravado para cada repositório em
$OUTPUT_BASE/external
que contém um checksum da regra usada para buscá-lo. Se o servidor do Bazel for reiniciado, mas o checksum não mudar, ele não será buscado novamente. Isso é implementado emRepositoryDelegatorFunction.DigestWriter
. - A opção de linha de comando
--distdir
designa outro cache usado para pesquisar artefatos a serem baixados. Isso é útil em configurações corporativas em que o Bazel não deve buscar coisas aleatórias da Internet. Isso é implementado porDownloadManager
.
Depois que um repositório é baixado, os artefatos nele são tratados como artefatos de origem. Isso é um problema porque o Bazel geralmente verifica se os artefatos de origem estão atualizados chamando stat() neles. Além disso, esses artefatos são invalidados quando a definição do repositório em que estão muda. Assim, as
FileStateValue
s de um artefato em um repositório externo precisam depender do
repositório externo. Isso é tratado por ExternalFilesHelper
.
Mapeamentos de repositórios
É possível que vários repositórios queiram depender do mesmo repositório,
mas em versões diferentes (este é um exemplo do "problema de dependência
de diamante"). Por exemplo, se dois binários em repositórios separados no build
quiserem depender do Guava, presumivelmente ambos se referirão ao Guava com rótulos
iniciados em @guava//
e esperarão que isso signifique versões diferentes dele.
Portanto, o Bazel permite remapear rótulos de repositórios externos para que a
string @guava//
possa se referir a um repositório do Guava (como @guava1//
) no
repositório de um binário e outro repositório do Guava (como @guava2//
) no
repositório do outro.
Como alternativa, ele também pode ser usado para juntar diamantes. Se um repositório depender de @guava1//
e outro de @guava2//
, o mapeamento de repositórios permitirá remapear os dois para usar um repositório @guava//
canônico.
O mapeamento é especificado no arquivo WORKSPACE como o atributo repo_mapping
de definições de repositório individuais. Em seguida, ele aparece no Skyframe como um membro de
WorkspaceFileValue
, em que é conectado a:
Package.Builder.repositoryMapping
, que é usado para transformar atributos de regras com valor de rótulo no pacote porRuleClass.populateRuleAttributeValues()
.Package.repositoryMapping
, que é usado na fase de análise para resolver problemas como$(location)
, que não são analisados na fase de carregamento.BzlLoadFunction
para resolver rótulos em instruções load()
Bits de JNI
O servidor do Bazel é principalmente escrito em Java. A exceção são as partes que o Java não pode fazer sozinho ou não podia fazer sozinho quando o implementamos. Isso se limita principalmente à interação com o sistema de arquivos, o controle de processos e várias outras coisas de baixo nível.
O código C++ fica em src/main/native, e as classes Java com métodos nativos são:
NativePosixFiles
eNativePosixFileSystem
ProcessUtils
WindowsFileOperations
eWindowsFileProcesses
com.google.devtools.build.lib.platform
Saída do console
Emitir a saída do console parece algo simples, mas a confluência da execução de vários processos (às vezes remotamente), o armazenamento em cache refinado, o desejo de ter uma saída de terminal agradável e colorida e um servidor de longa duração tornam isso não trivial.
Logo após a chegada da chamada RPC do cliente, duas instâncias RpcOutputStream
são criadas (para stdout e stderr) que encaminham os dados impressos para
eles ao cliente. Eles são encapsulados em um OutErr
(um par (stdout, stderr)). Tudo o que precisa ser impresso no console passa por esses
fluxos. Em seguida, esses fluxos são entregues ao
BlazeCommandDispatcher.execExclusively()
.
Por padrão, a saída é impressa com sequências de escape ANSI. Quando não são desejados (--color=no
), eles são removidos por um AnsiStrippingOutputStream
. Além disso, System.out
e System.err
são redirecionados para esses fluxos de saída.
Assim, as informações de depuração podem ser impressas usando
System.err.println()
e ainda aparecer na saída do terminal do cliente
(que é diferente da do servidor). É preciso ter cuidado para que, se um processo produzir saída binária (como bazel query --output=proto
), não haja manipulação de stdout.
Mensagens curtas (erros, avisos e semelhantes) são expressas pela interface
EventHandler
. Essas informações são diferentes do que é postado no EventBus
(isso é confuso). Cada Event
tem um EventKind
(erro, aviso, informação e alguns outros) e pode ter um Location
(o lugar no código-fonte que causou o evento).
Algumas implementações de EventHandler
armazenam os eventos recebidos. Usado
para reproduzir informações na interface causadas por vários tipos de processamento em cache,
por exemplo, os avisos emitidos por um destino configurado em cache.
Alguns EventHandler
s também permitem postar eventos que acabam chegando ao barramento de eventos. Os Event
s regulares _não_ aparecem lá. São implementações de ExtendedEventHandler
, e o principal uso delas é reproduzir eventos EventBus
em cache. Todos esses eventos EventBus
implementam Postable
, mas nem tudo que é postado em EventBus
implementa necessariamente essa interface. Apenas os itens armazenados em cache por um ExtendedEventHandler
(seria bom e a maioria das coisas faz isso, mas não é obrigatório).
A saída do terminal é principalmente emitida pelo UiEventHandler
, que é
responsável por toda a formatação de saída sofisticada e relatórios de progresso do Bazel. Ele tem duas entradas:
- O barramento de eventos
- O fluxo de eventos transmitido para ele pelo Reporter
A única conexão direta que a máquina de execução de comandos (por exemplo, o restante do
Bazel) tem com o fluxo RPC para o cliente é por Reporter.getOutErr()
,
que permite acesso direto a esses fluxos. Ele só é usado quando um comando precisa
despejar grandes quantidades de possíveis dados binários (como bazel query
).
Criação de perfil do Bazel
O Bazel é rápido. O Bazel também é lento, porque os builds tendem a crescer até o limite do que é tolerável. Por isso, o Bazel inclui um criador de perfis que pode ser usado para criar perfis de builds e do próprio Bazel. Ele é implementado em uma classe chamada Profiler
. Ela é ativada por padrão, mas grava apenas
dados abreviados para que a sobrecarga seja tolerável. A linha de comando
--record_full_profiler_data
faz com que ela grave tudo o que puder.
Ele emite um perfil no formato do criador de perfil do Chrome, que é melhor visualizado no Chrome. O modelo de dados é de pilhas de tarefas: é possível iniciar e encerrar tarefas, e elas devem estar bem aninhadas umas nas outras. Cada linha de execução Java recebe a própria pilha de tarefas. TODO:como isso funciona com ações e estilo de passagem de continuação?
O criador de perfil é iniciado e interrompido em BlazeRuntime.initProfiler()
e BlazeRuntime.afterCommand()
, respectivamente, e tenta ficar ativo o máximo possível para que possamos criar um perfil de tudo. Para adicionar algo ao perfil,
chame Profiler.instance().profile()
. Ele retorna um Closeable
, cujo encerramento representa o fim da tarefa. É melhor usar com instruções
try-with-resources.
Também fazemos criação de perfis de memória rudimentar em MemoryProfiler
. Ele também está sempre ativado e registra principalmente tamanhos máximos de heap e comportamento de GC.
Testar o Bazel
O Bazel tem dois tipos principais de testes: aqueles que observam o Bazel como uma "caixa preta" e aqueles que executam apenas a fase de análise. Chamamos o primeiro de "testes de integração" e o segundo de "testes de unidade", embora sejam mais parecidos com testes de integração que são, bem, menos integrados. Também temos alguns testes de unidade reais, quando eles são necessários.
Existem dois tipos de testes de integração:
- Implementados usando um framework de teste bash muito elaborado em
src/test/shell
- Implementados em Java. Elas são implementadas como subclasses de
BuildIntegrationTestCase
O BuildIntegrationTestCase
é o framework de testes de integração preferido porque
é bem equipado para a maioria dos cenários de teste. Como é um framework Java, ele
oferece capacidade de depuração e integração perfeita com muitas ferramentas de desenvolvimento
comuns. Há muitos exemplos de classes BuildIntegrationTestCase
no
repositório do Bazel.
Os testes de análise são implementados como subclasses de BuildViewTestCase
. Há um sistema de arquivos temporário que pode ser usado para gravar arquivos BUILD
. Depois, vários métodos auxiliares podem solicitar destinos configurados, mudar a configuração e declarar várias coisas sobre o resultado da análise.