Este documento é uma descrição da base de código e de como o Bazel é estruturado. Ele é destinado a pessoas dispostas a contribuir com o Bazel, não para usuários finais.
Introdução
A base de código do Bazel é grande (aproximadamente 350 KLOC código de produção e aproximadamente 260 KLOC código de teste) e ninguém está familiarizado com o cenário inteiro: todos conhecem muito bem o vale específico deles, mas poucos sabem o que há pelas colinas em todas as direções.
Para que as pessoas no meio da jornada não se encontrem dentro de uma floresta escura com o caminho direto sendo perdido, este documento tenta fornecer uma visão geral da base do código para que seja mais fácil começar a trabalhar nela.
A versão pública do código-fonte do Bazel está no GitHub em github.com/bazelbuild/bazel (link em inglês). Essa não é a "fonte da verdade", ela é derivada de uma árvore de origem interna do Google que contém outras funcionalidades que não são úteis fora do Google. O objetivo de longo prazo é tornar o GitHub a fonte da verdade.
As contribuições são aceitas pelo mecanismo regular de solicitação de envio do GitHub. Elas são importadas manualmente por um Googler para a árvore de origem interna e, em seguida, reexportadas para o GitHub.
Arquitetura de 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 que 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=
) estão antes do nome do comando a ser executado
e outras ficam depois (-c opt
). O primeiro tipo é chamado de "opção de inicialização" e
afeta o processo do servidor como um todo, enquanto o último tipo, a "opção
de comando", afeta apenas um único comando.
Cada instância de servidor tem uma única árvore de origem associada ("espaço de trabalho") e cada espaço de trabalho geralmente tem uma única instância de servidor ativa. Isso pode ser contornado 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") recebe o controle. Ele configura um processo de servidor apropriado seguindo estas etapas:
- Verifica se ele já foi extraído. Se não, ele faz isso. É daí que vem a implementação do servidor.
- Verifica se há uma instância ativa do servidor que funciona: ela está em execução, tem as opções de inicialização corretas e usa o diretório do espaço de trabalho correto. Ele
encontra o servidor em execução observando o diretório
$OUTPUT_BASE/server
, em que há um arquivo de bloqueio com a porta em que o servidor está escutando. - Se necessário, encerra o processo do servidor antigo
- Se necessário, inicia um novo processo de servidor
Depois que um processo adequado do servidor estiver pronto, o comando que precisa ser executado será comunicado por uma interface gRPC. Em seguida, a saída do Bazel é enviada de volta para o terminal. Apenas um comando pode estar em execução ao mesmo tempo. Isso é
implementado usando um mecanismo de bloqueio elaborado com partes em C++ e partes em
Java. Existe alguma infraestrutura para executar vários comandos em paralelo, já que a incapacidade de executar bazel version
em paralelo com outro comando é um pouco constrangedor. O principal bloqueador é o ciclo de vida de 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 diferença interessante é a implementação de bazel run
: a
função desse comando é executar algo que o Bazel acabou de criar, mas 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 executar ujexec() e com quais argumentos.
Quando alguém pressiona Ctrl-C, o cliente a converte em uma chamada de cancelamento na conexão gRPC, que tenta encerrar o comando o mais rápido possível. Após o terceiro Ctrl-C, o cliente envia um SIGKILL ao 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 ponto de entrada principal 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 no Layout do diretório de saída.
O "espaço de trabalho" é a árvore de origem em que o Bazel é executado. Geralmente, corresponde a algo que você conferiu no controle de origem.
Ele coloca todos os dados na "raiz do usuário de saída". Geralmente, ele é
$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" é para onde o Bazel é extraído. Isso é feito automaticamente,
e cada versão do Bazel recebe um subdiretório com base na soma de verificação dele na
base de instalação. Ele fica em $OUTPUT_USER_ROOT/install
por padrão 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 faz gravações. 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 é, entre outras coisas, útil para contornar a limitação de que apenas
uma instância do Bazel pode ser executada em um 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 "exec", um diretório que contém links simbólicos para todo o código-fonte
do build atual. Ele fica neste endereço:
$OUTPUT_BASE/execroot
. Durante a criação, o diretório de trabalho é$EXECROOT/<name of main repository>
. Planejamos mudar isso para$EXECROOT
, embora seja um plano de longo prazo por ser uma mudança muito incompatível. - Arquivos criados durante a criação.
Processo de execução de um comando
Depois que o servidor do Bazel recebe o controle e é informado sobre um comando que precisa ser executado, acontece 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 os que não têm nada a ver com o 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 antipadrão, seria bom se todos os metadados de que um comando precisa fossem descritos por métodos emBlazeCommand
).As opções da 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 de eventos que ocorrem durante a criação. Algumas delas são exportadas para fora do Bazel sob o princípio do protocolo de evento de build, a fim de informar ao mundo como o build é criado.
O comando recebe o controle. Os comandos mais interessantes são aqueles que executam um build: build, teste, execução, cobertura e assim por diante: essa funcionalidade é implementada por
BuildTool
.O conjunto de padrões de destino na linha de comando é analisado e caracteres curinga, como
//pkg:all
e//pkg/...
, são resolvidos. Isso é implementado emAnalysisPhaseRunner.evaluateTargetPatterns()
e reificado no Skyframe comoTargetPatternPhaseValue
.A fase de carregamento/análise é executada para produzir o gráfico de ações (um gráfico acíclico dirigido de comandos que precisam ser executados para o build).
A fase de execução é executada. Isso significa que todas as ações necessárias para criar os destinos de nível superior solicitados são executadas.
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 das "classes de opções" para os valores das opções. Uma "classe de opções" é 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 consumidas na fase de análise e (por meio de RuleContext.getFragment()
em Java ou ctx.fragments
em Starlark).
Algumas delas (por exemplo, se você quer fazer ou não a verificação de inclusão de C++) são lidas
na fase de execução, mas isso sempre exige o encanamento explícito, já que
BuildConfiguration
não está disponível nessa etapa. Para mais informações, consulte a
seção "Configurações".
AVISO:gostamos de fingir que as instâncias de OptionsBase
são imutáveis e
usá-las dessa maneira (como parte de SkyKeys
). Esse não é o caso, e
modificá-las é uma boa maneira de quebrar o Bazel de maneiras sutis que são difíceis
de depurar. Infelizmente, torná-los imutáveis é um grande esforço.
Não há problema em modificar um FragmentOptions
imediatamente após a construção antes que outra pessoa
tenha a chance de manter uma referência a ele e antes que equals()
ou hashCode()
seja chamado.
O Bazel aprende sobre as classes de opções das seguintes maneiras:
- Algumas estão conectadas ao Bazel (
CommonCommandOptions
). - Da anotação @Command em cada comando do Bazel
- De
ConfiguredRuleClassProvider
(são 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. Confira aqui.
Cada opção (exceto as definidas por Starlark) é uma variável de 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, como 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, o job de conversão da
string da linha de comando para o tipo de dados cai em uma implementação de
com.google.devtools.common.options.Converter
.
A árvore de origem, conforme visto pelo Bazel
Ele cria softwares ao ler e interpretar o código-fonte. A totalidade do código-fonte em que o Bazel opera é chamada de "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. 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, oferece suporte a projetos com código-fonte que abrange vários repositórios. O repositório de onde o Bazel é invocado é chamado de "repositório principal". Os outros são chamados de "repositórios externos".
Um repositório é marcado por um arquivo chamado WORKSPACE
(ou WORKSPACE.bazel
) no
seu diretório raiz. Esse arquivo contém informações "globais" para toda a versão, por exemplo, o conjunto de repositórios externos disponíveis. Ele funciona como um
arquivo Starlark normal, o que significa que é possível load()
outros arquivos Starlark.
Isso é comumente usado para extrair repositórios necessários para um repositório
que é referenciado explicitamente (chamamos isso de "padrão deps.bzl
").
O código dos repositórios externos tem um link simbólico ou é transferido por download em
$OUTPUT_BASE/external
.
Ao executar o build, toda a árvore de origem precisa ser reunida. Isso
é feito pelo SymlinkForest, que vincula todos os pacotes no repositório principal ao
$EXECROOT
e todos os repositórios externos a $EXECROOT/external
ou
$EXECROOT/..
. O primeiro impossibilita ter um pacote
chamado external
no repositório principal. Por isso, estamos migrando desse
repositório.
Pacotes
Cada repositório é composto por 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 ambos existirem, o Bazel vai preferir BUILD.bazel
. O motivo
pelo qual arquivos BUILD
ainda são aceitos é que o ancestral do Bazel, Blaze, usou esse
nome de arquivo. No entanto, ele acabou sendo um segmento de caminho usado com frequência, especialmente
no Windows, em que os nomes dos arquivos não diferenciam maiúsculas de minúsculas.
Os pacotes são independentes um do outro: mudanças no arquivo BUILD
de um pacote
não podem causar mudanças nos outros pacotes. A adição ou remoção de arquivos BUILD
pode alterar outros pacotes, já que os globs recursivos param nos limites dos pacotes 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 intérprete de 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 do pacote é globglob(["**/*.java"])
Isso requer acesso ao
sistema de arquivos e, como isso 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 planeta rápido e alegre que não reconhece o SkyframeSkyframeHybridGlobber
, uma versão que usa o Skyframe e reverte ao globber legado para evitar "reinicializações do Skyframe" (descritos abaixo).
A própria classe Package
contém alguns membros que são usados exclusivamente para
analisar o arquivo WORKSPACE e que não fazem sentido para pacotes reais. Essa é
uma falha de design porque os objetos que descrevem pacotes regulares não podem conter
campos que descrevam outra coisa. Por exemplo:
- Os mapeamentos do repositório
- Os conjuntos de ferramentas registrados
- As plataformas de execução registradas
O ideal é que haja mais separação entre a análise do arquivo WORKSPACE e a
análise de pacotes normais para que Package
não precise atender às necessidades
de ambos. Infelizmente, isso é difícil de fazer porque os dois estão profundamente
entrelaçados.
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, eles são chamados de artefatos (discutidos em outro lugar). Nem todos os arquivos criados durante a criação 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. Elas
geralmente estão associadas a uma linguagem de programação (como
cc_library
,java_library
oupy_library
), mas há algumas que não dependem de linguagem (comogenrule
oufilegroup
). - Grupos de pacotes:discutidos 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 um destino na linha de comando, algumas partes do rótulo podem ser omitidas:
- Se o repositório for omitido, o rótulo será levado para o repositório principal.
- Se a parte do pacote for omitida (como
name
ou:name
), o rótulo será usado para estar no pacote do diretório de trabalho atual (caminhos relativos que contêm referências de nível superior (..) não são permitidos).
Um tipo de regra (como "biblioteca C++") é chamado de "classe de regras". As classes de regras 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 da linguagem serão implementadas no Starlark, mas algumas famílias de regras legadas, como Java ou C++, ainda estão em Java por enquanto.
As classes de regra Starlark precisam ser importadas no início dos arquivos BUILD
usando a instrução load()
, enquanto as classes de regra do Java são conhecidas "innamente" pelo
Bazel, em virtude de serem registradas com ConfiguredRuleClassProvider
.
As classes de regra contêm informações como:
- Os atributos (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 transitivos que a regra "geralmente" cria
Observação de terminologia:na base do código, geralmente usamos "Rule" para indicar o destino
criado por uma classe de regra. No entanto, no Starlark e na documentação voltada ao usuário, "Rule" (Regra) deve ser usado exclusivamente para se referir à própria classe de regras. O destino é apenas um "destino". Observe também que, apesar de RuleClass
ter "class" no nome, não há relação de herança Java entre uma classe de regra e destinos desse tipo.
Skyframe
O framework de avaliação subjacente ao Bazel é chamado de Skyframe. O modelo dela é que tudo o que precisa ser criado durante um build é organizado em um gráfico acíclico dirigido com bordas que apontam de qualquer dado para as dependências dele, ou seja, outros dados que precisam ser conhecidos para construí-lo.
Os nós no gráfico são chamados de SkyValue
s, e os nomes deles são
SkyKey
s. Ambos são profundamente imutáveis; apenas objetos imutáveis devem ser acessados a partir deles. Essa invariante quase sempre acontece, e caso isso não aconteça
(como para as classes de opções individuais BuildOptions
, que fazem parte de
BuildConfigurationValue
e da SkyKey
), tentamos muito não mudá-las
ou alterá-las apenas de maneiras que não sejam observáveis de fora.
A partir disso, tudo o que é calculado no Skyframe (como
destinos configurados) também precisa ser imutável.
A maneira mais conveniente de observar o gráfico do Skyframe é executar bazel dump
--skyframe=detailed
, que despeja o gráfico, um SkyValue
por linha. É melhor fazer isso
para builds pequenos, já que eles podem ficar muito grandes.
O Skyframe fica 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. Mais informações sobre o Skyframe
estão disponíveis aqui.
Para avaliar um determinado SkyKey
em um SkyValue
, o Skyframe invoca a
SkyFunction
correspondente ao tipo de 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 que precisa reavaliar a função quando alguma 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
solicita uma dependência indisponível, getValue()
retornará um valor nulo. Em seguida, a função retorna o controle ao Skyframe,
retornando um valor nulo. Em algum momento posterior, o Skyframe avaliará a dependência indisponível e reiniciará a função do início. Só desta vez a chamada getValue()
será bem-sucedida com um resultado não nulo.
Uma consequência disso é que qualquer cálculo realizado dentro do SkyFunction
antes da reinicialização precisa ser repetido. Mas isso não inclui o trabalho feito para
avaliar a dependência SkyValues
, que são armazenadas em cache. Portanto, normalmente contornamos esse problema da seguinte maneira:
- Declarar dependências em lotes (usando
getValuesAndExceptions()
) para limitar o número de reinicializações. - Dividir um
SkyValue
em partes separadas calculadas porSkyFunction
s diferentes, para que eles possam ser calculados e armazenados em cache de maneira independente. Isso precisa ser feito estrategicamente, já que tem o potencial de aumentar o uso de memória. - Armazenamento do 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".
Essencialmente, precisamos desses tipos de soluções alternativas porque temos rotineiramente centenas de milhares de nós do Skyframe em trânsito, e o Java não oferece suporte a linhas de execução leves.
Lark
Starlark é a linguagem específica do domínio que as pessoas usam para configurar e estender o Bazel. Ele é concebido como um subconjunto restrito do Python que tem muito menos tipos, mais restrições no fluxo de controle e, o mais importante, fortes garantias de imutabilidade para ativar leituras simultâneas. Ele não é completo de Turing, o que desencoraja alguns (mas não todos) usuários de tentar realizar tarefas gerais de programação dentro da linguagem.
O Starlark é implementado no pacote net.starlark.java
.
Ele também tem uma implementação independente do Go aqui. A implementação
Java usada no Bazel é atualmente um intérprete.
O Starlark é usado em vários contextos, incluindo:
- O idioma
BUILD
. É aqui que as novas regras são definidas. O código Starlark em execução nesse contexto só tem acesso ao conteúdo do próprio arquivoBUILD
e aos arquivos.bzl
carregados por ele. - Definições de regras. É assim que novas regras (como suporte a um novo idioma) são definidas. O código Starlark em execução nesse contexto tem acesso à configuração e aos dados fornecidos pelas dependências diretas. Falaremos mais sobre isso posteriormente.
- O arquivo WORKSPACE. É aqui que são definidos os repositórios externos (código que não está na árvore de origem principal).
- Definições de regras de repositório. É aqui que novos tipos de repositórios externos são definidos. O código Starlark em execução nesse contexto pode executar um código arbitrário na máquina em que o Bazel é executado e chegar fora do espaço de trabalho.
Os dialetos disponíveis para arquivos BUILD
e .bzl
são um pouco diferentes,
porque expressam coisas diferentes. Uma lista das diferenças está disponível
aqui.
Saiba mais sobre o Starlark neste link.
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. Sua unidade básica é um "destino configurado", que é, muito sensatamente, um par (destino, configuração).
Ela é chamada de "fase de carregamento/análise" porque pode ser dividida em duas partes distintas, que costumavam ser 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, primeiro os nós de folha e depois os que estão na 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 itens como opções de linha de comando que o usuário quer transmitir para o compilador C++)
- As dependências diretas. Os provedores de informações transitivas estão disponíveis para a regra que está sendo analisada. Eles são chamados assim porque fornecem uma "agregação" das informações no fechamento transitivo do destino configurado, como todos os arquivos .jar no caminho de classe 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 as regras, isso inclui os atributos, que geralmente são importantes.
- A implementação do destino configurado. Para regras, isso pode estar em Starlark ou em Java. Todos os destinos que não são configurados por regras são implementados em Java.
A saída da análise de um destino configurado é:
- Os provedores de informações transitivas que configuraram destinos que dependem delas podem acessar
- os artefatos que ele pode criar e as ações que os produzem;
A API oferecida para regras Java é RuleContext
, que é o equivalente ao argumento
ctx
das regras do Starlark. A API dela é mais eficiente, mas, ao mesmo
tempo, é mais fácil fazer Bad ThingsTM. Por exemplo, escrever código com complexidade de tempo ou
espaço é quadrática (ou pior), para fazer o servidor Bazel falhar com uma
exceção Java ou violar invariantes (como modificar acidentalmente 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 no mesmo 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 compilação cruzada ou quando estamos criando um app Android pesado (que contém código nativo para várias arquiteturas de CPU).
Conceitualmente, a configuração é uma instância de BuildOptions
. No entanto, na
prática, BuildOptions
é unido por BuildConfiguration
, que fornece
outras funcionalidades diferentes. Ele se propaga da parte superior do
gráfico de dependência para a parte inferior. Se mudar, o build vai precisar
ser analisado novamente.
Isso resulta em anomalias, como a necessidade de analisar todo o build novamente se, por exemplo, o número de execuções de teste solicitadas mudar, mesmo que isso afere apenas os destinos de teste. Temos planos de "cortar" as configurações para que esse não seja o caso, mas ainda não esteja pronto.
Quando uma implementação de regra precisa de parte da configuração, é necessário declará-la
na definição usando RuleClass.Builder.requiresConfigurationFragments()
. Isso serve para evitar erros (como regras do Python usando o fragmento Java) e
facilitar o corte de configuração para que, por exemplo, se as opções do Python mudarem, os destinos
em C++ não precisarão ser analisados novamente.
A configuração de uma regra não é necessariamente a mesma da regra "pai". O processo de mudança da configuração em uma borda de dependência é chamado de "transição de configuração". Isso pode acontecer em dois lugares:
- Em uma borda de dependência. Essas transições são especificadas em
Attribute.Builder.cfg()
e são funções de umaRule
(em que a transição acontece) e umaBuildOptions
(a configuração original) para uma ou maisBuildOptions
(a configuração de saída). - Em qualquer borda de entrada para um destino configurado. Elas são especificadas em
RuleClass.Builder.cfg()
.
As classes relevantes são TransitionFactory
e ConfigurationTransition
.
As transições de configuração são usadas, por exemplo:
- Declarar que uma dependência específica é usada durante o build e que ela precisa ser criada na arquitetura de execução
- Para declarar que uma dependência específica precisa ser criada para várias arquiteturas (como para código nativo em APKs grandes do Android).
Se uma transição de configuração resultar em várias configurações, ela é chamada de transição de divisão.
As transições de configuração também podem ser implementadas no Starlark. Confira a documentação neste link (em inglês).
Provedores de informações transitivas
Os provedores de informações transitivas são uma forma (e a _única _way) de informações sobre outros destinos configurados que dependem deles. O motivo pelo qual "transitivo" está no nome é que geralmente é algum tipo de visualização completa do fechamento transitivo de um destino configurado.
Geralmente, há uma correspondência individual entre os provedores de informações transitivas Java
e os do Starlark. A exceção é DefaultInfo
, que é uma mescla de
FileProvider
, FilesToRunProvider
e RunfilesProvider
, porque essa API era
considerada mais do que uma transliteração direta da API Java.
A chave é uma das seguintes coisas:
- Um objeto de classe Java. Isso só está disponível para provedores que não podem
ser acessados pelo Starlark. Esses provedores são uma subclasse de
TransitiveInfoProvider
. - Uma string. Isso é legado e altamente desencorajado, já que é suscetível a
conflitos de nomes. Esses provedores de informações transitivas são subclasses diretas de
build.lib.packages.Info
. - Símbolo de provedor. Ela pode ser criada 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.
Novos provedores implementados em Java precisam ser implementados usando BuiltinProvider
.
O uso de NativeProvider
foi descontinuado (ainda não tivemos tempo de removê-lo), e
as subclasses TransitiveInfoProvider
não podem ser acessadas pelo Starlark.
Metas configuradas
As metas configuradas são implementadas como RuleConfiguredTargetFactory
. Existe uma
subclasse para cada classe de regra implementada em Java. Os destinos configurados do Starlark
são criados usando o StarlarkRuleConfiguredTargetUtil.buildRule()
.
As fábricas de destino configuradas precisam usar RuleConfiguredTargetBuilder
para
criar o valor de retorno. Ele consiste nos seguintes itens:
- A
filesToBuild
, o conceito vazado de "o conjunto de arquivos que essa regra representa". Esses são os arquivos criados quando o destino configurado está na linha de comando ou nos srcs de uma regra geral. - Os arquivos de execução, regulares e dados.
- Os grupos de saída deles. Esses são vários "outros conjuntos de arquivos" que a regra pode
criar. Eles podem ser acessados usando o atributo output_group da
regra do grupo de arquivos em BUILD e o provedor
OutputGroupInfo
em Java.
Arquivos de execução
Alguns binários precisam de arquivos de dados para serem executados. Um exemplo proeminente são testes que precisam de arquivos de entrada. Isso é representado no Bazel pelo conceito de "runfiles". Uma "árvore de runfiles" é uma árvore de diretórios dos arquivos de dados para um binário específico. Ele é criado no sistema de arquivos como uma árvore de links simbólicos com links simbólicos individuais apontando para os arquivos na origem das árvores de saída.
Um conjunto de arquivos de execução é representado como uma instância Runfiles
. Conceitualmente, ele é um
mapa do caminho de um arquivo na árvore de arquivos de execução para a instância Artifact
que
o representa. Isso é um pouco mais complicado do que uma única Map
por dois
motivos:
- Na maioria das vezes, o caminho dos arquivos de execução de um arquivo é o mesmo do execpath. Usamos isso para economizar espaço na RAM.
- Há vários tipos legados de entradas em árvores de arquivos de execução que também precisam ser representados.
Os arquivos de execução são coletados usando RunfilesProvider
: uma instância dessa classe
representa os arquivos de um destino configurado (como uma biblioteca) e as necessidades
de fechamento transitivo. Eles são reunidos como um conjunto aninhado. Na verdade, eles são
implementados usando conjuntos aninhados cobertos pela capa. Cada destino une os arquivos de execução
das dependências, adiciona alguns e envia o conjunto resultante
para cima no gráfico de dependência. Uma instância de RunfilesProvider
contém duas instâncias de Runfiles
, uma para quando a regra depende do atributo "dados" e
uma para cada outro tipo de dependência de entrada. Isso ocorre porque um destino
às vezes apresenta arquivos de execução diferentes quando ele depende de um atributo de
dados. Esse é um comportamento legado indesejado que ainda não
foi removido.
Os arquivos de execução de binários são representados como uma instância de RunfilesSupport
. Isso
é diferente de Runfiles
porque RunfilesSupport
pode ser
realmente criado (diferente de Runfiles
, que é apenas um mapeamento). Isso
necessita dos seguintes componentes extras:
- O manifesto dos arquivos de execução de entrada. Essa é uma descrição serializada da árvore do Runfiles. Ele é usado como um proxy para o conteúdo da árvore de arquivos de execução, e o Bazel supõe que essa árvore muda somente se o conteúdo do manifesto mudar.
- O manifesto dos arquivos de execução de saída. Ele é usado por bibliotecas de ambiente de execução que processam árvores de arquivos de execução, especialmente no Windows, que às vezes não oferece suporte a links simbólicos.
- O intermediário dos arquivos de execução. Para que ela exista, é necessário criar a árvore de links simbólicos e o artefato para o qual os links simbólicos apontam. Para diminuir o número de bordas de dependência, o intermediário de arquivos de execução pode ser usado para representar tudo isso.
- Argumentos de linha de comando para executar o binário que contém os arquivos de execução
representados pelo objeto
RunfilesSupport
.
Aspectos
Os aspectos são uma forma de "propagar a computação pelo gráfico de dependências". Elas estão
descritas para os usuários do Bazel
aqui. Um bom exemplo motivador são os buffers de protocolo: uma regra proto_library
não pode saber sobre nenhuma linguagem específica, mas a criação da implementação de uma mensagem de buffer de protocolo (a "unidade básica" de buffers de protocolo) em qualquer linguagem de programação precisa ser acoplada à regra proto_library
para que, se dois destinos na mesma linguagem dependerem do mesmo buffer de protocolo, ele será criado apenas uma vez.
Assim como os destinos configurados, eles são representados no Skyframe como um SkyValue
,
e a maneira como são construídos é muito semelhante à forma como os destinos configurados são
criados: eles têm uma classe de fábrica chamada ConfiguredAspectFactory
que tem
acesso a um RuleContext
, mas, ao contrário das fábricas de destino configuradas, ele também sabe
sobre o destino configurado ao qual está anexado e os provedores.
O conjunto de aspectos propagados para o 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. Pode ser 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, os provedores que ele fornece e contém uma referência à implementação, como a instânciaAspectClass
adequada. Ele é análogo aRuleClass
.AspectParameters
é uma maneira de parametrizar um aspecto que é propagado pelo gráfico de dependências. No momento, ele é um mapa de string a string. Um bom exemplo de por que eles são úteis são os buffers de protocolo: se uma linguagem tiver várias APIs, as informações sobre para qual API os buffers de protocolo precisam ser criados precisam ser propagadas pelo gráfico de dependência.Aspect
representa todos os dados necessários para calcular um aspecto que se propaga no gráfico de dependência. Ela 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 caminho de classe de um ambiente de desenvolvimento integrado Java provavelmente
vai querer saber sobre todos os arquivos .jar no caminho de classe, mas alguns são
buffers de protocolo. Nesse caso, o aspecto do ambiente de desenvolvimento integrado vai querer ser anexado ao
par (regra proto_library
+ aspecto proto Java).
A complexidade dos aspectos em aspectos é capturada na classe
AspectCollection
.
Plataformas e conjuntos 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 no idioma do Bazel (a documentação completa aqui)
Uma plataforma é descrita por um mapeamento de chave-valor das configurações de restrição (como o
conceito de "arquitetura de CPU") para os valores de restrição (como uma CPU
específica, como x86_64). Temos um "dicionário" dos valores e configurações de restrição
mais usados no repositório @platforms
.
O conceito de conjunto de ferramentas vem do fato de que, dependendo das plataformas em que o build é executado e das plataformas de destino, pode ser necessário usar compiladores diferentes. Por exemplo, um determinado conjunto de ferramentas C++ pode ser executado em um SO específico e ser direcionado a outros sistemas operacionais. O Bazel precisa determinar o compilador C++ usado com base na execução definida e na plataforma de destino. Consulte a documentação sobre conjuntos de ferramentas neste link (link em inglês).
Para isso, os conjuntos de ferramentas são anotados com o conjunto de restrições de execução e plataforma de destino compatíveis. Para 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 que um conjunto de ferramentas aceita e informa o tipo (como C++ ou Java) de conjunto de ferramentas. Essa última é representada pela regratoolchain_type()
. - Uma regra específica da linguagem que descreve o conjunto de ferramentas real (como
cc_toolchain()
).
Isso é feito dessa maneira, porque precisamos conhecer as restrições de cada
conjunto de ferramentas para fazer a resolução, e as regras *_toolchain()
específicas da linguagem contêm muito mais informações do que isso, o que leva mais tempo
para carregar.
As plataformas de execução são especificadas de uma das seguintes maneiras:
- No arquivo ESPAÇO DE TRABALHO, 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 para um destino configurado é determinada por
PlatformOptions.computeTargetPlatform()
. É uma lista de plataformas porque queremos
oferecer suporte a várias plataformas de destino, mas ela ainda não
foi implementada.
O conjunto de conjuntos de ferramentas a serem usados para um destino configurado é determinado por
ToolchainResolutionFunction
. É uma função de:
- O conjunto de conjuntos de ferramentas registrados (no arquivo WORKSPACE e na configuração)
- A execução e as plataformas de destino desejadas (na configuração)
- O conjunto de tipos de conjunto de ferramentas exigido 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 conjunto de ferramentas (representado como uma instância ToolchainTypeInfo
) para o rótulo do conjunto selecionado. Ele é chamado de "descarregado" porque não contém os
conjuntos de ferramentas em si, apenas os rótulos.
Em seguida, os conjuntos de ferramentas são carregados com ResolvedToolchainContext.load()
e usados pela implementação do destino configurado que os solicitou.
Também temos um sistema legado que depende da existência de uma única configuração de "host" e configurações de destino representadas por vários sinalizadores 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 fazer a conversão entre as sinalizações legadas e as restrições da plataforma no novo estilo.
O código está em PlatformMappingFunction
e usa um "pequeno idioma"
não Starlark.
Restrições
Às vezes, você quer designar um destino como compatível com apenas algumas plataformas. Infelizmente, o Bazel tem vários mecanismos para fazer isso:
- Restrições específicas de regras
environment_group()
/environment()
- Restrições da plataforma
As restrições específicas de regras são usadas principalmente no Google para regras Java. Elas estão
sendo desativadas 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
constraints=
.
ambiente_group() e ambiente()
Essas regras são um mecanismo legado e não são amplamente utilizadas.
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 com suporte para uma regra:
- Pelo atributo
restricted_to=
. Essa é a forma mais direta de especificação: ela declara o conjunto exato de ambientes que a regra aceita para esse grupo. - Pelo atributo
compatible_with=
. Isso declara ambientes com suporte de uma regra, além de ambientes "padrão" com suporte por padrão. - Por meio dos atributos no nível do pacote
default_restricted_to=
edefault_compatible_with=
. - Por especificações padrão em regras
environment_group()
. Cada ambiente pertence a um grupo de pares temáticos (como "arquiteturas de CPU", "versões do JDK" ou "sistemas operacionais móveis"). A definição de um grupo de ambientes inclui quais desses ambientes têm suporte de "padrão" se não forem especificados 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 de 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 esse recurso.
environment()
é implementado como uma regra regular, enquanto environment_group()
é uma subclasse de Target
, mas não Rule
(EnvironmentGroup
) e uma
função disponível por padrão em Starlark
(StarlarkLibrary.environmentGroup()
) que cria um destino
de mesmo nome. Isso ocorre para evitar uma dependência cíclica que surgiria porque cada ambiente precisa declarar o grupo a que pertence, e cada grupo de ambiente 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ções está em
RuleContextConstraintSemantics
e TopLevelConstraintSemantics
.
Restrições da plataforma
A maneira "oficial" atual de descrever com quais plataformas um destino é compatível é usando as mesmas restrições usadas para descrever conjuntos de ferramentas e plataformas. Está em análise na solicitação de envio no 10945.
Visibilidade
Se você trabalha em uma grande base de código 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 (link em inglês), as pessoas vão confiar em comportamentos que você considera serem detalhes de implementação.
O Bazel oferece suporte a isso pelo mecanismo chamado visibilidade: você pode declarar que um destino específico só pode depender do uso do atributo visibilidade. Esse atributo é um pouco especial porque, embora contenha uma lista de rótulos, eles podem codificar um padrão sobre nomes de pacotes em vez de um ponteiro para qualquer destino específico. (Sim, isso é uma falha de design.)
Isso é implementado nos seguintes locais:
- A interface
RuleVisibility
representa uma declaração de visibilidade. Pode ser uma constante (totalmente pública 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 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 o próprio destino (
PackageGroup
) e o destino configurado (PackageGroupConfiguredTarget
). Provavelmente, poderíamos substituí-los 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 sobre umpackage_group
e aincludes
transitiva dele. - A conversão de listas de rótulos de visibilidade para dependências é feita em
DependencyResolver.visitTargetVisibility
e em alguns outros locais diferentes. - 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 une o conjunto agregado em um provedor de informações transitivas. Assim, os destinos configurados que dependem dele podem fazer o mesmo. Por exemplo:
- Os arquivos principais 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 compile ou execute
- O conjunto de arquivos Python no fechamento transitivo de uma regra do Python
Se fizéssemos isso de forma simples usando, por exemplo, List
ou Set
, o uso da memória quadrática seria o caso: se houvesse uma cadeia de N regras e cada uma adicionasse um
arquivo, teríamos 1+2+...+N membros de coleção.
Para contornar esse problema, criamos o conceito de
NestedSet
. Ela é uma estrutura de dados composta por outras instâncias de NestedSet
e alguns membros próprios, formando um gráfico acíclico dirigido
de conjuntos. Elas são imutáveis e os membros podem ser iterados. Definimos várias ordens de iteração (NestedSet.Order
): preorder, pós-ordem, topológica (um nó sempre vem depois de seus ancestrais) e "não se importa, mas deve ser sempre o mesmo".
A mesma estrutura de dados é chamada de depset
no Starlark.
Artefatos e ações
O build real consiste em um conjunto de comandos que precisam ser executados para produzir
a saída que o usuário quer. 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 acíclico dirigido, bipartito e
chamado de
"gráfico de ações".
Há dois tipos de artefatos: 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. **A atualização é verificada pelo cálculo da soma de verificação, com mtime como atalho. Não fazemos checksum do arquivo se o ctime não tiver sido alterado.
- Artefatos de links simbólicos não resolvidos. Eles são verificados quanto à atualização chamando readlink(). Ao contrário dos artefatos regulares, eles podem ser links simbólicos pendentes. Geralmente usado nos casos em que é possível empacotar alguns arquivos em um tipo de arquivo.
- Artefatos de árvores. Não são arquivos únicos, mas árvores de diretórios. Eles são verificados para garantir a atualização ao conferir o conjunto de arquivos e o conteúdo. Eles são representados como
TreeArtifact
. - Artefatos de metadados constantes. Mudanças nesses artefatos não acionam uma recompilação. Isso é usado exclusivamente para informações de carimbo de data/hora do build: não queremos recriar só porque a hora atual mudou.
Não há motivo fundamental para que os artefatos de origem não sejam de árvore ou
artefatos de links simbólicos não resolvidos, mas ainda não os implementamos (é
preciso, porque referenciar um diretório de origem em um arquivo BUILD
é um dos
alguns problemas de incorreção conhecidos de longa data do Bazel). Temos uma
implementação desse tipo de trabalho que é ativada pela
propriedade da JVM BAZEL_TRACK_SOURCE_DIRECTORIES=1
Um tipo notável de Artifact
são os intermediários. Elas são indicadas por instâncias Artifact
que são as saídas de MiddlemanAction
. Elas são usadas para casos especiais:
- A agregação de intermediários é usada para agrupar artefatos. Dessa forma, se muitas ações usarem o mesmo grande conjunto de entradas, não teremos arestas de dependência N*M, apenas N+M (que estão sendo substituídas por conjuntos aninhados).
- Intermediários de dependência de programação garantem que uma ação seja executada antes de outra.
Eles são usados principalmente para inspeção, mas também para compilação C++. Consulte
CcCompilationContext.createMiddleman()
para uma explicação. - Os intermediários de arquivos de execução são usados para garantir a presença de uma árvore de arquivos de execução para que não seja necessário depender separadamente do manifesto de saída e de cada artefato referenciado pela árvore de arquivos de execução.
As ações são mais bem entendidas como um comando que precisa ser executado, o ambiente necessário e o conjunto de saídas que ele produz. Os itens a seguir são os principais componentes da descrição de uma açã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 a plataforma) em que ele precisa ser executado \
Há também 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 é
uma SpawnAction
ou uma StarlarkAction
(as mesmas, mas não devem ser
classes separadas), embora Java e C++ tenham os próprios tipos de ação
(JavaCompileAction
, CppCompileAction
e CppLinkAction
).
Eventualmente, queremos mover tudo para SpawnAction
. JavaCompileAction
está bem próximo, mas C++ é um caso especial devido à análise de arquivos .d e à verificação incluída.
O gráfico de ações é principalmente "incorporado" no gráfico do Skyframe: conceitualmente, a
execução de uma ação é representada como uma invocação de
ActionExecutionFunction
. O mapeamento de uma borda de dependência do gráfico de ação para uma
borda de dependência do Skyframe é descrito em
ActionExecutionFunction.getInputDeps()
e Artifact.key()
e tem algumas
otimizações para manter baixo o número de bordas do Skyframe:
- Os artefatos derivados não têm os próprios
SkyValue
s. Em vez disso,Artifact.getGeneratingActionKey()
é usado para descobrir o token da ação que o gera. - Os conjuntos aninhados têm sua própria chave de Skyframe.
Ações compartilhadas
Algumas ações são geradas por vários destinos configurados. As regras do Starlark são mais limitadas, porque só têm permissão para colocar as ações derivadas em um diretório determinado pela configuração e pelo pacote delas (mas, mesmo assim, as regras no mesmo pacote podem entrar em conflito), mas as regras implementadas em Java podem colocar artefatos derivados em qualquer lugar.
Isso é considerado um erro de recurso, mas se livrar dele é muito difícil, porque produz economias significativas no tempo de execução quando, por exemplo, um arquivo de origem precisa ser processado de alguma forma e é referenciado por várias regras (handwave-handwave). Isso custa um pouco de RAM: cada instância de uma ação compartilhada precisa ser armazenada separadamente na memória.
Se duas ações gerarem o mesmo arquivo de saída, elas precisarão ser exatamente as mesmas:
ter as mesmas entradas, as mesmas 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, analisando cada ação.
Ela é implementada em SkyframeActionExecutor.findAndStoreArtifactConflicts()
e é um dos únicos locais no Bazel que exigem uma visualização "global" do
build.
A fase de execução
É quando o Bazel começa a executar ações de compilação, 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 a finalidade explícita de expressar "se esse destino estiver na linha de
comando, crie esses 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 ações executadas localmente com uma árvore de origem completa. Isso é
processado pela classe SymlinkForest
e funciona anotando cada destino
usado na fase de análise e criando uma única árvore de diretórios que vincula
simultaneamente todos os pacotes com um destino usado a partir do local real. Uma alternativa seria
transmitir os caminhos corretos para os comandos, considerando o --package_path
.
Isso não é desejável, porque:
- Ela muda as linhas de comando de ação quando um pacote é movido de uma entrada de caminho de pacote para outra, o que costumava ser uma ocorrência comum.
- Se uma ação for executada remotamente, isso resultará em linhas de comando diferentes do que se fosse executada localmente
- Isso requer uma transformação de linha de comando específica para a ferramenta em uso. Considere a diferença entre caminhos de classe do Java e caminhos de inclusão do C++.
- Alterar a linha de comando de uma ação invalida a entrada do cache correspondente
- O uso de
--package_path
está sendo descontinuado de modo lento e contínuo
Em seguida, o Bazel começa a percorrer o gráfico de ações (o gráfico dirigido bipartite
composto por ações e os artefatos de entrada e saída) e 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 armazenamento em cache que podem ser atingidas por trás do Skyframe:
ActionExecutionFunction.stateMap
contém dados para fazer com que as reinicializações do Skyframe deActionExecutionFunction
sejam baratas.- O cache de ações locais contém dados sobre o estado do sistema de arquivos
- Os sistemas de execução remota geralmente também contêm o próprio cache
O cache de ações locais
Esse cache é outra camada que fica atrás do Skyframe. Mesmo que uma ação seja executada novamente no Skyframe, ela ainda pode ser um hit no cache de ações locais. Ele representa o estado do sistema de arquivos local e é serializado no disco. Isso significa que, quando um novo servidor Bazel é iniciado, é possível receber hits do cache de ações locais, mesmo que o gráfico do Skyframe esteja vazio.
Esse cache é verificado em busca de ocorrências usando o método ActionCacheChecker.getTokenIfNeedToExecute()
.
Ao contrário do nome, é um mapa do caminho de um artefato derivado até a ação que o emitiu. A ação é descrita como:
- O conjunto dos arquivos de entrada e saída e a soma de verificação deles
- A "chave de ação", que geralmente é a linha de comando que foi executada, mas,
em geral, representa tudo o que não é capturado pela soma de verificação dos
arquivos de entrada. Por exemplo, para
FileWriteAction
, é a soma de verificação dos dados gravados.
Há também um "cache de ação de cima para baixo" altamente experimental que ainda está em desenvolvimento, que usa hashes transitivos para evitar o acesso ao cache tantas vezes.
Descoberta e remoção 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 ocorrem de duas formas:
- Uma ação pode descobrir novas entradas antes da execução ou decidir que algumas
das entradas não são realmente necessárias. O exemplo canônico é o C++,
em que é melhor adivinhar quais arquivos de cabeçalho um arquivo C++
usa no fechamento transitivo para que não enviemos todos
os arquivos para executores remotos. Portanto, temos a opção de não registrar todos
os arquivos de cabeçalho como uma "entrada", mas verificar o arquivo de origem em busca de cabeçalhos que um arquivo de cabeçalho
usa de forma transitiva e somente marcar os arquivos de cabeçalho em excesso no Bazel usado para implementar
#include
. - 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 a criação e, para evitar o constrangimento de ter uma incrementabilidade 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.
Elas são implementadas usando métodos na ação:
Action.discoverInputs()
é chamado. Ela retornará um conjunto aninhado de artefatos determinados como necessários. Eles precisam ser artefatos de origem para que não haja bordas 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 eram necessárias. Isso pode resultar em builds incrementais incorretos se uma entrada usada for informada como não utilizada.
Quando um cache de ações retorna um hit em uma nova instância de ação (como criada
após a reinicialização do servidor), o Bazel chama o próprio 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: estratégias/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 sandboxes, ou remotamente. O
conceito que incorpora isso é chamado de ActionContext
(ou Strategy
, já que
corretamos apenas na metade do caminho com uma renomeação...)
O ciclo de vida de um contexto de ação é o seguinte:
- Quando a fase de execução é iniciada, as instâncias
BlazeModule
precisam informar quais contextos de ação elas têm. Isso acontece no construtor deExecutionTool
. Os tipos de contexto de ação são identificados por uma instânciaClass
Java que se refere a uma subinterface deActionContext
e qual interface o contexto da ação precisa implementar. - O contexto da ação apropriada é selecionado entre os disponíveis e é encaminhado para
ActionExecutionContext
eBlazeExecutor
. - Contextos de solicitações de ações usando
ActionExecutionContext.getContext()
eBlazeExecutor.getStrategy()
(deve haver apenas uma maneira de fazer isso etc.)
As estratégias são livres para chamar outras estratégias para fazer seus respectivos trabalhos. Isso é usado, por exemplo, na estratégia dinâmica que inicia ações local e remotamente e usa o que terminar primeiro.
Uma estratégia notável é a que implementa processos de worker persistentes
(WorkerSpawnStrategy
). A ideia é que algumas ferramentas têm um tempo de inicialização longo
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 de que o processo do worker não carrega
o estado observável entre solicitações individuais.
Se a ferramenta mudar, o processo do worker precisará ser reiniciado. Para determinar se um worker
pode ser reutilizado, é preciso calcular uma soma de verificação para a ferramenta usada com
WorkerFilesHash
. Para isso, é preciso 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 arquivos de execução do Spawn
são
contados como partes da ferramenta.
Mais informações sobre estratégias (ou contextos de ação):
- Informações sobre várias estratégias para executar ações estão disponíveis aqui.
- As informações sobre a estratégia dinâmica, em que executamos uma ação local e remotamente para ver o que termina primeiro, estão disponíveis aqui.
- Informações sobre as complexidades da execução local de ações estão disponíveis aqui.
O gerente 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 difere de uma ação para outra: quanto mais recursos uma ação exigir, menos instâncias precisarão ser executadas ao mesmo tempo para evitar a sobrecarga da 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). Então, quando os contextos de ação fazem algo
que exige recursos locais, eles chamam ResourceManager.acquireResources()
e são bloqueados até que os recursos necessários estejam disponíveis.
Uma descrição mais detalhada do gerenciamento de recursos locais está disponível neste link.
A estrutura do diretório de saída
Cada ação requer um local separado no diretório de saída em que coloca as saídas. O local dos artefatos derivados geralmente é o seguinte:
$EXECROOT/bazel-out/<configuration>/bin/<package>/<artifact name>
Como é determinado o nome do diretório associado a uma configuração específica? Há duas propriedades desejadas conflitantes:
- Se duas configurações puderem ocorrer no mesmo build, elas precisarão ter diretórios diferentes para que ambas possam ter a própria versão da mesma ação. Caso contrário, se as duas configurações discordarem, como 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 "quase" a mesma coisa, elas precisam ter o mesmo nome para que as ações executadas em uma possam ser reutilizadas na outra se as linhas de comando corresponderem. Por exemplo, mudanças nas opções da linha de comando no compilador Java não devem resultar em ações de compilação em C++ que sejam executadas novamente.
Até agora, não encontramos uma maneira fundamentada de resolver esse problema, que tem semelhança com o problema de corte de configuração. Uma discussão mais longa sobre as opções está disponível aqui. As principais áreas problemáticas são as regras do Starlark (cujos autores geralmente não têm familiaridade 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 do caminho para a configuração seja
<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, uma
soma de verificação do conjunto de transições de configuração do Starlark é adicionada para que os usuários
não causem conflitos de ações. Está longe de ser perfeito. Isso é implementado em
OutputDirectories.buildMnemonic()
e depende de cada fragmento de configuração
adicionar a própria parte ao nome do diretório de saída.
Testes
O Bazel oferece um suporte avançado para a execução de testes. Ela aceita estas opções:
- Executar testes remotamente (se houver um back-end de execução remota disponível)
- Execução de testes várias vezes em paralelo (para eliminar a fragmentação ou coletar dados de tempo)
- Fragmentação de testes (divisão de casos de teste no mesmo teste em vários processos para aumentar a velocidade).
- Nova execução de testes instáveis
- Agrupar testes em conjuntos de testes
Os testes são destinos regulares configurados que têm um TestProvider, que descreve como o teste precisa ser executado:
- Os artefatos cuja criação resultou na execução do teste. Esse é um arquivo de "status de cache" 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 precisa ser executado (como o tempo limite do teste)
Como determinar quais testes serão executados
Determinar quais testes serão executados é um processo complexo.
Primeiro, durante a análise do padrão de destino, os conjuntos de testes são expandidos recursivamente. A
expansão é implementada em TestsForTargetPatternFunction
. Um problema
surpreendente é que, se um conjunto de testes não declara testes, ele se refere a
todos os testes do pacote. Isso é implementado no Package.beforeBuild()
adicionando um atributo implícito chamado $implicit_tests
para testar regras do pacote.
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, e o
resultado é colocado em TargetPatternPhaseValue.getTestsToRunLabels()
. A razão pela 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, ele é processado mais adiante em BuildView.createResult()
: destinos com
falha na análise são filtrados, e os testes são divididos em testes exclusivos e
não exclusivos. Em seguida, ele é colocado em AnalysisResult
, que é como
ExecutionTool
sabe quais testes serão executados.
Para oferecer alguma 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, essa
é uma reimplementação, então provavelmente difere do modelo acima de
várias maneiras sutis.
Executar testes
Os testes são executados solicitando artefatos de status de cache. Isso
resulta na execução de um TestRunnerAction
, que finalmente 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 de testes e do que ele pode esperar está disponível neste link. De modo mais simples, um código de saída 0 significa sucesso, qualquer outro 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 detalhando os casos de teste individuais no fragmento de teste.test.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". Ele é usado por testes que querem gerar arquivos, além dos que são mostrados no terminal.
Durante a execução do teste, podem ocorrer duas coisas que não podem durante a criação de destinos regulares: execução de teste exclusivo e streaming de saída.
Alguns testes precisam ser executados no modo exclusivo, por exemplo, não em paralelo com
outros. Isso pode ocorrer ao adicionar tags=["exclusive"]
à
regra de teste ou executar o teste com --test_strategy=exclusive
. Cada teste
exclusivo é executado por uma invocação do Skyframe separada, solicitando a execução do
teste após o build "principal". Isso é implementado em
SkyframeExecutor.runExclusiveTest()
.
Ao contrário das ações normais, em que a 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
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 um teste para que as saídas de testes diferentes não sejam intercaladas.
Isso é implementado na classe StreamedTestOutput
devidamente nomeada e funciona
pesquisando as mudanças no arquivo test.log
do teste em questão e despejando novos
bytes para o terminal em que o Bazel é definido.
Os resultados dos testes executados ficam disponíveis no barramento de eventos observando
vários eventos (como TestAttempt
, TestResult
ou TestingCompleteEvent
).
Eles são despejados no Build Event Protocol e emitidos no console
por AggregatingTestListener
.
Coleta de cobertura
A cobertura é informada pelos testes no formato LCOV nos arquivos
bazel-testlogs/$PACKAGE/$TARGET/coverage.dat
.
Para coletar 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 determina 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 tempos de execução de coleta de cobertura separados). O script de wrapper é responsável por converter os arquivos resultantes para o formato LCOV, se necessário, e os mescla 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, o que significa que a instrumentação da cobertura é adicionada no momento da compilação (como C++) e outras fazem a instrumentação on-line. Isso significa que a instrumentação da cobertura é adicionada no momento da execução.
Outro conceito central é a cobertura dos valores de referência. Essa é a cobertura de uma biblioteca,
binário ou teste caso nenhum código tenha sido executado. O problema que ele resolve é que, se você
quiser computar a cobertura de teste para 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, o que fazemos é emitir um arquivo de cobertura para cada binário que contenha apenas os arquivos para os quais coletamos cobertura, sem linhas cobertas. O arquivo de cobertura do valor de referência para uma meta está em
bazel-testlogs/$PACKAGE/$TARGET/baseline_coverage.dat
. Ela também é gerada
para binários e bibliotecas, além de testes, se você transmitir a
flag --nobuild_tests_only
para o Bazel.
No momento, a cobertura do valor de referência está corrompida.
Acompanhamos dois grupos de arquivos para a 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 é exatamente isso, um conjunto de arquivos para instrumentar. Em ambientes de execução de cobertura on-line, isso pode ser usado no momento da execução para decidir quais arquivos instrumentar. Ele também é usado para implementar a cobertura do valor de referência.
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. Na prática, isso consiste em arquivos específicos do ambiente de execução. Por exemplo, o gcc emite arquivos .gcno durante a compilação. Elas serão adicionadas ao conjunto de entradas de ações de teste se o modo de cobertura estiver ativado.
A coleta ou não da cobertura é armazenada 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, mas também significa que, se
esse bit for invertido, todos os destinos precisarão ser analisados novamente. Algumas linguagens, como
C++, exigem diferentes opções de compilador para emitir códigos que podem coletar cobertura,
o que atenua esse problema, já que uma nova análise é necessária.
Os arquivos de suporte de cobertura dependem dos rótulos em uma dependência implícita para que possam ser substituídos pela política de invocação, o que permite diferentes versões do Bazel. Idealmente, essas diferenças seriam removidas, e padronizamos 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 é processado por
CoverageReportActionFactory
e chamado de BuildView.createResult()
. Ela
recebe acesso às ferramentas necessárias observando o atributo :coverage_report_generator
do primeiro teste que é executado.
O mecanismo de consulta
O Bazel usa uma pequena linguagem para fazer 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 com a subclasse AbstractBlazeQueryEnvironment
.
Outras funções de consulta podem ser feitas criando subclasses de QueryFunction
. Para permitir os resultados da consulta de streaming, 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. Eles são implementados como subclasses de
OutputFormatter
.
Um requisito sutil de alguns formatos de saída de consulta (protótipo) é que o Bazel precisa emitir _todas_as informações fornecidas pelo carregamento do pacote para que seja possível diferenciar a saída e determinar se um destino específico foi alterado. Como consequência, os valores de atributos precisam ser serializáveis. É por isso que há poucos tipos de atributos sem que nenhum atributo tenha valores Starlark complexos. A solução alternativa comum é usar um rótulo e anexar as informações complexas à regra com esse rótulo. Essa não é uma solução alternativa muito satisfatória, e seria muito bom aumentar esse requisito.
O sistema de módulos
O Bazel pode ser estendido adicionando módulos a ele. Cada módulo precisa subclassificar
BlazeModule
(o nome é uma relíquia do histórico do Bazel quando ele era
chamado 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 funções "não essenciais" de 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 que o BlazeModule
oferece é um pouco perigoso. Não
o use como um exemplo de bons princípios de design.
O ônibus de eventos
A principal maneira de o BlazeModules se comunicar 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 e os módulos podem registrar listeners para os
eventos em que têm 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 êxito ou não (
TargetCompleteEvent
) - Um teste foi executado (
TestAttempt
,TestSummary
)
Alguns desses eventos são representados fora do Bazel no Build Event Protocol (são BuildEvent
s). Assim, não apenas BlazeModule
s, mas também elementos
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
serviço de evento de build) para fazer streaming de eventos.
Isso é implementado nos pacotes Java build.lib.buildeventservice
e
build.lib.buildeventstream
.
Repositórios externos
Enquanto o Bazel foi originalmente projetado para ser usado em um monorepo (uma árvore de origem única contendo tudo o que é preciso criar), ele vive em um mundo em que isso não é necessariamente verdadeiro. Os "repositórios externos" são uma abstração usada para conectar esses dois mundos: eles representam o código necessário para o build, mas que não está na árvore de origem principal.
O arquivo WORKSPACE
O conjunto de repositórios externos é determinado pela análise do arquivo do ESPAÇO DE TRABALHO. Por exemplo, uma declaração como esta:
local_repository(name="foo", path="/foo/bar")
O resultado no repositório chamado @foo
está disponível. O problema é que é possível
definir novas regras de repositório em arquivos do Starlark, que
podem ser usadas para carregar um novo código Starlark, que pode ser usado 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 blocos delineados por instruções
load()
. O índice de bloco é indicado por WorkspaceFileKey.getIndex()
e calcular WorkspaceFileFunction
até que o índice X significa avaliá-lo até a instrução X load()
.
Como buscar repositórios
Antes que o código do repositório esteja disponível para o Bazel, ele precisa ser
fetched (link em inglês). Isso faz com que o Bazel crie um diretório em
$OUTPUT_BASE/external/<repository name>
.
A busca do repositório acontece com estas etapas:
PackageLookupFunction
percebe que precisa de um repositório e cria umRepositoryName
como umSkyKey
, que invocaRepositoryLoaderFunction
- O
RepositoryLoaderFunction
encaminha a solicitação paraRepositoryDelegatorFunction
por motivos pouco claros. O código diz que é para evitar novos downloads em caso de reinicializações do Skyframe, mas esse não é um raciocínio muito sólido. - O
RepositoryDelegatorFunction
descobre a regra de repositório que foi solicitada pela iteração dos fragmentos do arquivo WORKSPACE até que o repositório solicitado seja encontrado. - Encontramos o
RepositoryFunction
adequado que implementa a busca do repositório. Ele pode ser a implementação do Starlark do repositório 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 são codificados pela soma de verificação
(
RepositoryCache
). Isso exige que a soma de verificação esteja disponível no arquivo de ESPAÇO DE TRABALHO, mas isso é bom para a hermética de qualquer maneira. Isso é compartilhado por todas as instâncias do servidor Bazel na mesma estação de trabalho, independentemente do espaço de trabalho ou da base de saída em que elas estão sendo executadas. - Um "arquivo de marcador" é escrito para cada repositório em
$OUTPUT_BASE/external
que contém uma soma de verificação da regra usada para buscá-lo. Se o servidor do Bazel for reiniciado, mas a soma de verificação não mudar, ela não será buscada novamente. Isso é implementado emRepositoryDelegatorFunction.DigestWriter
. - A opção de linha de comando
--distdir
designa outro cache usado para procurar artefatos para download. Isso é útil em configurações empresariais, em que o Bazel não pode buscar itens aleatórios da Internet. Isso é implementado porDownloadManager
.
Após o download de um repositório, os artefatos dele são tratados como artefatos
de origem. Isso representa um problema porque o Bazel geralmente verifica a atualização
dos artefatos de origem chamando stat() neles, e esses artefatos também
são invalidados quando a definição do repositório em que estão muda. Assim,
os FileStateValue
s de um artefato em um repositório externo precisam depender do
repositório externo. Isso é gerenciado por ExternalFilesHelper
.
Diretórios gerenciados
Às vezes, os repositórios externos precisam modificar arquivos na raiz do espaço de trabalho (como um gerenciador de pacotes que hospeda os pacotes transferidos por download em um subdiretório da árvore de origem). Isso contraria a suposição de que o Bazel faz com que os arquivos de origem sejam modificados apenas pelo usuário, e não por si só, e permite que os pacotes se refiram a todos os diretórios na raiz do espaço de trabalho. Para que esse tipo de repositório externo funcione, o Bazel faz duas coisas:
- Permite que o usuário especifique subdiretórios do espaço de trabalho que o Bazel não pode acessar. Elas são listadas em um arquivo chamado
.bazelignore
e a funcionalidade é implementada emBlacklistedPackagePrefixesFunction
. - Codificamos o mapeamento do subdiretório do espaço de trabalho para o repositório externo
em que ele é processado em
ManagedDirectoriesKnowledge
e processamosFileStateValue
s que se referem a eles da mesma maneira que os repositórios externos comuns.
Mapeamentos de repositório
Pode acontecer de vários repositórios quererem depender do mesmo repositório,
mas em versões diferentes. Essa é uma instância do "problema da dependência
diamante". Por exemplo, se dois binários em repositórios separados no build
quirem depender do Guava, ambos provavelmente se referirão ao Guava com rótulos
iniciando @guava//
e esperarão que isso signifique versões diferentes dele.
O Bazel permite remapear os rótulos de repositórios externos para que a
string @guava//
possa se referir a um repositório Guava (como @guava1//
) no
repositório de um binário e em outro (como @guava2//
) no
repositório do outro.
Como alternativa, isso também pode ser usado para join diamantes. Se um repositório
depender de @guava1//
e outro depender de @guava2//
, o mapeamento do
repositório permitirá remapear os dois repositórios 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, ela aparece no Skyframe como membro de
WorkspaceFileValue
, onde é transferida para:
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 itens como$(location)
, que não são analisados na fase de carregamento.BzlLoadFunction
para resolver rótulos em instruções load()
Bits JNI
O servidor do Bazel é escrito principalmente em Java. A exceção são as partes que o Java não pode fazer sozinho ou que não podia fazer sozinho quando foi implementado. Em grande parte, isso se limita à interação com o sistema de arquivos, o controle de processos e vários outros aspectos 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 uma saída do console parece algo simples, mas a confluência da execução de vários processos (às vezes remotamente), armazenamento em cache refinado, o desejo de ter uma saída de terminal boa e colorida e ter um servidor de longa duração torna isso difícil.
Logo após o recebimento da chamada RPC do cliente, duas instâncias RpcOutputStream
são criadas (para stdout e stderr) que encaminham os dados impressos para o cliente. Eles são agrupados em um OutErr
(um par (stdout, stderr). Tudo o que precisa ser impresso no console passa por esses fluxos. Em seguida, esses streams são transferidos para
BlazeCommandDispatcher.execExclusively()
.
A saída é impressa por padrão com sequências de escape ANSI. Quando eles não são
desejados (--color=no
), eles são removidos por uma AnsiStrippingOutputStream
. Além
disso, System.out
e System.err
são redirecionados para esses fluxos de saída.
Dessa forma, as informações de depuração podem ser impressas usando
System.err.println()
e ainda acabam na saída do terminal do cliente
(que é diferente daquela do servidor). Se um processo
gerar saída binária (como bazel query --output=proto
), não ocorre nenhum registro de
stdout.
Mensagens curtas (erros, avisos e similares) são expressas pela
interface EventHandler
. Eles são diferentes daqueles que são postados no
EventBus
(isso é confuso). Cada Event
tem um EventKind
(erro,
aviso, informações e alguns outros) e pode ter um Location
(o local no
código-fonte que causou o evento).
Algumas implementações de EventHandler
armazenam os eventos recebidos. Isso é 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 (Event
s regulares _não _aparecem lá). Essas são
implementações de ExtendedEventHandler
e o principal uso delas é repetir eventos
EventBus
armazenados em cache. Todos esses eventos EventBus
implementam Postable
, mas nem
tudo o que é postado em EventBus
implementa essa interface necessariamente.
Apenas aqueles que são armazenados em cache por um ExtendedEventHandler
(seria bom e
a maioria das coisas faz, mas não é aplicado).
A saída do terminal é principalmente emitida por UiEventHandler
, que é
responsável por toda a formatação sofisticada da saída e os relatórios de progresso
que o Bazel
faz. Ela tem duas entradas:
- O ônibus de eventos
- O stream do evento era transmitido pelo Repórter
A única conexão direta que a máquina de execução de comando (por exemplo, o restante do
Bazel) tem com o stream de RPC para o cliente é por meio de Reporter.getOutErr()
,
que permite acesso direto a esses streams. Ela só é usada quando um comando precisa
despejar grandes quantidades de dados binários possíveis (como bazel query
).
Como criar o perfil do Bazel
O Bazel é rápido. O Bazel também é lento, porque os builds tendem a crescer até o limite do que é suportável. Por esse motivo, o Bazel inclui um criador de perfil que pode
ser usado para criar o perfil de builds e do próprio Bazel. Ele é implementado em uma classe
chamada Profiler
. Ela é ativada por padrão, embora registre apenas
dados resumidos para que a sobrecarga seja tolerável. A linha de comando
--record_full_profiler_data
faz com que ela registre tudo o que puder.
Ele emite um perfil no formato Chrome Profiler. A visualização é melhor no Chrome. O modelo de dados é o das pilhas de tarefas: é possível iniciar e encerrar tarefas, e elas precisam 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 pelo maior tempo
possível para que possamos criar o perfil de tudo. Para adicionar algo ao perfil,
chame Profiler.instance().profile()
. Ela retorna um Closeable
, cujo fechamento
representa o fim da tarefa. Ele é melhor usado com declarações
"try-with-resources".
Também fazemos perfis de memória rudimentares em MemoryProfiler
. Ele também está sempre ativado
e registra principalmente o tamanho máximo de heap e o comportamento de GC.
Como testar o Bazel
O Bazel tem dois tipos principais de testes: aqueles que o observam como uma "caixa preta" e os que executam apenas a fase de análise. Chamamos os antigos de "testes de integração" e o segundo de "testes de unidade", embora sejam mais como testes de integração que são, bem, menos integrados. Também temos alguns testes de unidade reais, quando são necessários.
Há dois tipos de testes de integração:
- Aqueles implementados usando uma estrutura de teste bash muito elaborada em
src/test/shell
. - Aquelas implementadas em Java. Eles são implementados como subclasses de
BuildIntegrationTestCase
BuildIntegrationTestCase
é o framework de teste de integração preferencial, já que
é bem equipado para a maioria dos cenários de teste. Por ser um framework Java, ele
oferece capacidade de depuração e integração perfeita com muitas ferramentas comuns de
desenvolvimento. 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 de rascunho que pode ser usado para gravar arquivos BUILD
. Em seguida, vários métodos
auxiliares podem solicitar destinos configurados, alterar a configuração e declarar
várias coisas sobre o resultado da análise.