Base de código do Bazel

Informar um problema Ver código-fonte Nightly · 7.4 . 7.3 · 7.2 · 7.1 · 7.0 · 6.5

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 (cerca de 350 KLOC de código de produção e cerca de 260 KLOC de código) e ninguém conhece o cenário completo: 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 do caminho não se percam em uma floresta escura com o caminho direto perdido, este documento tenta oferecer uma visão geral da base de código para que seja mais fácil trabalhar nela.

A versão pública do código-fonte do Bazel está disponível no GitHub em github.com/bazelbuild/bazel. Ela não é a "fonte da verdade", é 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 de solicitação de envio normal do GitHub e importadas manualmente por um Googler para a árvore de origem interna e, em seguida, reexportadas para o GitHub.

Arquitetura cliente/servidor

A maior parte do Bazel reside em um processo do servidor que fica 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=) ficam 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 do 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") assume o controle. Ele configura um processo de servidor adequado usando as seguintes etapas:

  1. Verifica se ele já foi extraído. Se não, ele faz isso. É de onde vem a implementação do servidor.
  2. 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.
  3. Se necessário, encerra o processo do servidor antigo
  4. Se necessário, inicia um novo processo do 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á canalizada de volta para o terminal. Apenas um comando pode ser executado ao mesmo tempo. 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 bloqueador principal é o ciclo de vida de BlazeModules e alguns estados 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: a função 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 ele deve ujexec() e com quais argumentos.

Quando uma pessoa pressiona Ctrl-C, o cliente a traduz para 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 um pouco complicado de diretórios durante um build. Uma descrição completa está disponível em Layout do diretório de saída.

O "espaço de trabalho" é a árvore de origem em que o Bazel é executado. Ele geralmente corresponde a algo que você fez check-out do controle de origem.

O Bazel coloca todos os dados na "raiz do usuário de saída". Isso geralmente é $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 na soma de verificação na base de instalação. Por padrão, ele está 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 faz gravações. Cada base de saída tem no máximo uma instância do servidor do Bazel em execução a qualquer momento. Geralmente, ele fica em $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 "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, porque é uma mudança muito incompatível.
  • Arquivos criados durante o build.

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:

  1. 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 o código-fonte, como versão ou help) e se outro comando está em execução.

  2. O comando certo é encontrado. Cada comando precisa implementar a interface BlazeCommand e ter a anotação @Command. Isso é um pouco um antipadrão, seria bom se todos os metadados necessários para um comando fossem descritos por métodos em BlazeCommand.

  3. 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.

  4. Um barramento de eventos é criado. O bus de eventos é um fluxo de eventos que acontecem durante o build. Algumas delas são exportadas para fora do Bazel sob a égide do Build Event Protocol para informar ao mundo como o build é feito.

  5. O comando assume 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.

  6. O conjunto de padrões de destino na linha de comando é analisado e os curingas como //pkg:all e //pkg/... são resolvidos. Isso é implementado em AnalysisPhaseRunner.evaluateTargetPatterns() e refinado no Skyframe como TargetPatternPhaseValue.

  7. 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).

  8. 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 de "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:

  1. Opções relacionadas a uma linguagem de programação (CppOptions ou JavaOptions). Elas precisam ser uma subclasse de FragmentOptions e, por fim, são agrupadas em um objeto BuildOptions.
  2. Opções relacionadas à forma como o Bazel executa ações (ExecutionOptions)

Essas opções foram projetadas para serem usadas na fase de análise e (por RuleContext.getFragment() em Java ou ctx.fragments em Starlark). Algumas delas (por exemplo, se o C++ deve incluir a verificação ou não) são lidas na fase de execução, mas isso sempre requer uma tubulação explícita, já que BuildConfiguration não está disponível. 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 (por exemplo, uma 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 realmente imutáveis é uma tarefa difícil. Modificar um FragmentOptions imediatamente após a construção, antes que qualquer outra pessoa tenha a chance de manter uma referência a ele e antes que equals() ou hashCode() seja chamado.

O Bazel aprende sobre classes de opções das seguintes maneiras:

  1. Alguns estão integrados ao Bazel (CommonCommandOptions).
  2. Na anotação @Command em cada comando do Bazel
  3. De ConfiguredRuleClassProvider (são opções de linha de comando relacionadas a linguagens de programação individuais)
  4. As regras do Starlark também podem definir as próprias opções (confira aqui).

Cada opção (exceto as definidas pelo 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 com 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, a conversão da string da linha de comando para o tipo de dados é uma implementação de com.google.devtools.common.options.Converter.

A árvore de origem, conforme vista pelo Bazel

O Bazel cria softwares, o que acontece lendo e interpretando 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. Ele geralmente representa um único projeto. O ancestral do Bazel, 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. Já o Bazel oferece suporte a projetos cujo código-fonte abrange vários repositórios. O repositório em que 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 chamado WORKSPACE (ou WORKSPACE.bazel) no diretório raiz. Esse arquivo contém informações "globais" para todo o build, 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 é usado geralmente para extrair repositórios necessários para um repositório que é referenciado explicitamente (chamamos isso de "padrão deps.bzl").

O código de repositórios externos é vinculado ou baixado em $OUTPUT_BASE/external.

Ao executar o build, toda a árvore de origem precisa ser montada. Isso é feito por SymlinkForest, que cria um link simbólico de cada pacote no repositório principal para $EXECROOT e cada repositório externo para $EXECROOT/external ou $EXECROOT/... O primeiro, é claro, torna impossível ter um pacote chamado external no repositório principal. É por isso que estamos migrando para fora dele.

Pacotes

Cada repositório é composto por pacotes, uma coleção de arquivos relacionados e uma especificação das dependências. Elas são especificadas por um arquivo chamado BUILD ou BUILD.bazel. Se ambos existirem, o Bazel vai preferir BUILD.bazel. O motivo pelo qual os 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_ 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 do pacote é o globbing: o Bazel não exige que todos os arquivos de origem sejam listados explicitamente e, em vez disso, pode executar globs (como glob(["**/*.java"])). Ao contrário do shell, ele oferece suporte a globs recursivos que descem para subdiretórios (mas não para subpacotes). Isso requer acesso ao sistema de arquivos e, como isso pode ser lento, implementamos vários truques para que ele seja executado em paralelo e da maneira mais eficiente possível.

O glob é implementado nas seguintes classes:

  • LegacyGlobber, um globber rápido e felizmente desinformado sobre o Skyframe
  • SkyframeHybridGlobber, uma versão que usa o Skyframe e reverte ao globber legado para evitar "reinicializações do Skyframe" (descritas 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 objetos que descrevem pacotes regulares não podem conter campos que descrevem outra coisa. São eles:

  • 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 intimamente relacionados.

Rótulos, destinos e regras

Os pacotes são compostos por destinos, que têm os seguintes tipos:

  1. 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.
  2. Regras:descrevem as etapas para derivar as saídas das entradas. Elas geralmente são associadas a uma linguagem de programação (como cc_library, java_library ou py_library), mas há algumas que não dependem de idioma (como genrule ou filegroup).
  3. Grupos de pacotes:discutidos na seção Visibilidade.

O nome de um destino é chamado de rótulo. A sintaxe dos identificadores é @repo//pac/kage:name, em que repo é o nome do repositório em que o identificador está e pac/kage é o diretório em que o arquivo BUILD está. name é o caminho do arquivo (se o identificador se refere a um arquivo de origem) em relação ao diretório do pacote. Ao se referir a um destino na linha de comando, algumas partes do rótulo podem ser omitidas:

  1. Se o repositório for omitido, o rótulo será levado para o repositório principal.
  2. Se a parte do pacote for omitida (como name ou :name), o rótulo será considerado no pacote do diretório de trabalho atual. Não são permitidos caminhos relativos que contenham referências de nível superior (..).

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:

  1. Seus atributos (como srcs, deps): tipos, valores padrão, restrições etc.
  2. As transições e aspectos de configuração anexados a cada atributo, se houver
  3. A implementação da regra
  4. Os provedores de informações transitivas que a regra "normalmente" cria

Observação sobre a terminologia:na base de código, geralmente usamos "Regra" para indicar o destino criado por uma classe de regra. Mas no Starlark e na documentação voltada ao usuário, "Rule" deve ser usado exclusivamente para se referir à classe de regras. O destino é apenas um "destino". Além disso, apesar de RuleClass ter "class" no nome, não há uma relação de herança do Java entre uma classe de regra e os 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 dirigido com bordas apontando de qualquer parte dos dados para as dependências, ou seja, outras partes dos dados que precisam ser conhecidas para construí-lo.

Os nós no gráfico são chamados de SkyValues, e os nomes deles são chamados de SkyKeys. Ambos são profundamente imutáveis. 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 SkyKey), tentamos muito não mudar elas ou mudar de maneiras que não sejam observáveis de fora. Por isso, tudo o que é computado no Skyframe (como alvos configurados) também precisa ser imutável.

A maneira mais conveniente de observar o gráfico do Skyframe é executar bazel dump --skyframe=deps, que descarta o gráfico, uma SkyValue por linha. É melhor fazer isso para builds pequenos, já que eles podem ficar muito grandes.

O Skyframe está no pacote com.google.devtools.build.skyframe. O pacote com.google.devtools.build.lib.skyframe, com nome semelhante, contém a implementação do Bazel sobre o Skyframe. Confira mais informações sobre o Skyframe neste link.

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 SkyFunctions e SkyValues.

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, o Skyframe vai avaliar a dependência indisponível e reiniciar a função desde o início. Só que, dessa vez, a chamada getValue() vai 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 é armazenada em cache. Portanto, geralmente contornamos esse problema da seguinte forma:

  1. Declarar dependências em lotes (usando getValuesAndExceptions()) para limitar o número de reinicializações.
  2. Dividir uma SkyValue em partes separadas computadas por diferentes SkyFunctions, para que elas possam ser computadas e armazenadas em cache de forma independente. Isso precisa ser feito estrategicamente, já que tem o potencial de aumentar o uso de memória.
  3. Armazenar o estado entre reinicializações, usando SkyFunction.Environment.getState() ou mantendo um cache estático ad hoc "atrás do Skyframe".

Basicamente, precisamos desses tipos de soluções alternativas porque temos centenas de milhares de nós do Skyframe em execução e o Java não oferece suporte a linhas de execução leves.

Starlark

O Starlark é a linguagem específica do domínio que as pessoas usam para configurar e estender o Bazel. Ele foi concebido como um subconjunto restrito do Python que tem muito menos tipos, mais restrições no fluxo de controle e, o mais importante, garante a imutabilidade para permitir leituras simultâneas. Ele não é Turing-completo, 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 do Go aqui. A implementação Java usada no Bazel é atualmente um intérprete.

O Starlark é usado em vários contextos, incluindo:

  1. O idioma BUILD. É aqui que as novas regras são definidas. O código Starlark executado nesse contexto só tem acesso ao conteúdo do arquivo BUILD e dos arquivos .bzl carregados por ele.
  2. Definições de regras. É assim que novas regras (como suporte a um novo idioma) são definidas. O código Starlark executado nesse contexto tem acesso à configuração e aos dados fornecidos pelas dependências diretas (mais informações a seguir).
  3. O arquivo WORKSPACE. É aqui que os repositórios externos (códigos que não estão na árvore de origem principal) são definidos.
  4. 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. Confira uma lista de diferenças aqui.

Confira mais informações sobre o Starlark neste link.

Fase de carregamento/análise

A fase de carregamento/análise é onde 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 sensível, 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:

  1. Carregar pacotes, ou seja, transformar arquivos BUILD nos objetos Package que os representam.
  2. Analisar 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 folha e depois os que estão na linha de comando. As entradas para a análise de um único destino configurado são:

  1. 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++).
  2. 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 um "resumo" 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++).
  3. O próprio alvo. 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.
  4. A implementação do destino configurado. Para regras, isso pode ser em Starlark ou Java. Todas as segmentações não configuradas com regras são implementadas em Java.

A saída da análise de um destino configurado é:

  1. Os provedores de informações transitivas que configuraram os destinos que dependem dele podem acessar
  2. os artefatos que ele pode criar e as ações que os produzem;

A API oferecida para 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 com complexidade de tempo ou espaço quadrática (ou pior), fazer com que o servidor do Bazel falhe com uma exceção do 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 BuildOptions. No entanto, na prática, BuildOptions é encapsulado por BuildConfiguration, que fornece outras funcionalidades diferentes. Ele se propaga da parte superior do gráfico de dependência para a parte inferior. Se ele mudar, o build precisará ser analisado 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 "recortar" 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 essa parte na definição usando RuleClass.Builder.requiresConfigurationFragments(). Isso é feito para evitar erros (como regras do Python que usam o fragmento Java) e para facilitar o corte de configuração. Assim, se as opções do Python mudarem, os destinos do C++ não precisam ser analisados novamente.

A configuração de uma regra não é necessariamente igual à da regra "mãe". 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:

  1. Em uma borda de dependência. Essas transições são especificadas em Attribute.Builder.cfg() e são funções de um Rule (onde a transição acontece) e um BuildOptions (a configuração original) para um ou mais BuildOptions (a configuração de saída).
  2. Em qualquer borda 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:

  1. Para declarar que uma dependência específica é usada durante o build e, portanto, precisa ser criada na arquitetura de execução
  2. 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 será chamada de transição dividida.

As transições de configuração também podem ser implementadas no Starlark (documentação aqui).

Provedores de informações transitivas

Os provedores de informações transitivas são uma maneira (e a _única_) de destinos configurados informarem sobre outros destinos configurados que dependem deles. O motivo pelo qual "transitivo" está no nome é que geralmente é um tipo de consolidação do fechamento transitivo de um destino configurado.

Geralmente, há uma correspondência 1:1 entre os provedores de informações transitivas do Java e do Starlark (a exceção é DefaultInfo, que é uma combinação de FileProvider, FilesToRunProvider e RunfilesProvider, porque essa API foi considerada mais parecida com o Starlark do que uma transliteração direta do Java). A chave é uma das seguintes coisas:

  1. Um objeto de classe Java. Esse recurso está disponível apenas para provedores que não são acessíveis pelo Starlark. Esses provedores são uma subclasse de TransitiveInfoProvider.
  2. Uma string. Essa abordagem é legada e não é recomendada, porque é suscetível a conflitos de nome. Esses provedores de informações transitivas são subclasses diretas de build.lib.packages.Info.
  3. Um símbolo de provedor. Isso 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ância Provider.Key em Java.

Os novos provedores implementados em Java precisam ser implementados usando BuiltinProvider. NativeProvider foi descontinuado (ainda não tivemos tempo de removê-lo) e as subclasses TransitiveInfoProvider não podem ser acessadas pelo Starlark.

Destinos configurados

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 usando StarlarkRuleConfiguredTargetUtil.buildRule().

As fábricas de destino configuradas precisam usar RuleConfiguredTargetBuilder para criar o valor de retorno. Ele consiste no seguinte:

  1. O filesToBuild, o conceito vago 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.
  2. Os arquivos de execução, regulares e dados.
  3. Seus grupos de saída. Esses são vários "outros conjuntos de arquivos" que a regra pode criar. Elas podem ser acessadas usando o atributo output_group da regra filegroup no BUILD e usando o provedor OutputGroupInfo em Java.

Arquivos de execução

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 runfiles" é uma árvore de diretórios dos arquivos de dados de um binário específico. Ela é criada 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. É um pouco mais complicado do que um único 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 RAM.
  • Há vários tipos legados de entradas em árvores de arquivos de execução, que também precisam ser representados.

Os runfiles são coletados usando RunfilesProvider: uma instância dessa classe representa os runfiles de um destino configurado (como uma biblioteca) e as necessidades de fechamento transitivo, 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ê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 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 tem a capacidade de ser criado (diferente de Runfiles, que é apenas um mapeamento). Isso exige os seguintes componentes adicionais:

  • O manifesto dos arquivos de execução 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 assume que a árvore de arquivos de execução muda apenas 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 de runfiles. Para que uma árvore de arquivos de execução 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 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 precisa saber sobre 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 precisa ser acoplada à regra proto_library para que, se duas metas 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 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, ela também conhece o destino configurado a que está anexada e seus provedores.

O conjunto de aspectos propagados para baixo 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:

  1. AspectClass é a implementação do aspecto. Pode ser em Java (nesse caso, é uma subclasse) ou em Starlark (nesse caso, é uma instância de StarlarkAspectClass). É análogo a RuleConfiguredTargetFactory.
  2. 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ância AspectClass adequada. É análogo a RuleClass.
  3. AspectParameters é uma maneira de parametrizar um aspecto que é propagado para baixo no gráfico de dependência. Atualmente, é 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 precisam ser criados precisam ser propagadas pelo gráfico de dependência.
  4. Aspect representa todos os dados necessários para calcular um aspecto que se propaga pelo gráfico de dependência. Ela consiste na classe de aspecto, na definição e nos parâmetros dela.
  5. RuleAspect é a função que determina quais aspectos uma regra específica precisa propagar. É uma função Rule -> Aspect.

Uma complicação um tanto inesperada é que os aspectos podem ser anexados a outros aspectos. Por exemplo, um aspecto que coleta o classpath de um ambiente de desenvolvimento integrado (IDE) Java provavelmente quer saber sobre todos os arquivos .jar no classpath, mas alguns deles são buffers de protocolo. Nesse caso, o aspecto do ambiente de desenvolvimento integrado vai querer se anexar ao par (regra proto_library + aspecto de proto Java).

A complexidade dos aspectos em aspectos é capturada na classe AspectCollection.

Plataformas e toolchains

Ele 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 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" dos valores e configurações 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, um toolchain C++ específico pode ser executado em um SO específico e pode ser direcionado a outros SOs. O Bazel precisa determinar o compilador C++ usado com base na execução definida e na plataforma de destino (documentação para conjuntos de ferramentas aqui).

Para fazer isso, as cadeias de ferramentas são anotadas com o conjunto de execução e as restrições da plataforma de destino com suporte. Para fazer isso, a definição de um conjunto de ferramentas é dividida em duas partes:

  1. Uma regra toolchain() que descreve o conjunto de execução e as restrições de destino que uma toolchain oferece suporte e informa o tipo de toolchain (como C++ ou Java). A última é representada pela regra toolchain_type().
  2. Uma regra específica da linguagem que descreve o conjunto de ferramentas real (como cc_toolchain()).

Isso é feito porque precisamos saber as restrições de cada toolchain para fazer a resolução de toolchain, e as regras *_toolchain() específicas do idioma contêm muito mais informações do que isso, então elas levam mais tempo para carregar.

As plataformas de execução são especificadas de uma das seguintes maneiras:

  1. No arquivo WORKSPACE usando a função register_execution_platforms()
  2. 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. É uma função de:

  • O conjunto de toolchains 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), em UnloadedToolchainContextKey

O resultado é um UnloadedToolchainContext, que é basicamente um mapa do tipo de conjunto de ferramentas (representado como uma instância ToolchainTypeInfo) para o rótulo do conjunto de ferramentas selecionado. Ele é chamado de "descarregado" porque não contém os conjuntos de ferramentas em si, apenas os rótulos.

Em seguida, as cadeias de ferramentas 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 da existência de uma única configuração de "host" e 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 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" que não seja 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 de plataforma

As restrições específicas de regras são usadas principalmente no Google para regras 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 governa isso é chamado de constraints=.

environment_group() e environment()

Essas regras são um mecanismo legado e não são muito 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 com suporte para uma regra:

  1. Pelo atributo restricted_to=. Essa é a forma mais direta de especificação. Ela declara o conjunto exato de ambientes com suporte à regra para esse grupo.
  2. Pelo atributo compatible_with=. Isso declara os ambientes com suporte a uma regra, além dos ambientes "padrão" que têm suporte por padrão.
  3. Por meio dos atributos no nível do pacote default_restricted_to= e default_compatible_with=.
  4. Com as especificações padrão nas regras environment_group(). Cada ambiente pertence a um grupo de pares temáticos relacionados (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 atributos restricted_to= / environment(). Uma regra sem esses atributos herda todos os padrões.
  5. 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 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 acaba criando um destino com o mesmo nome. Isso evita uma dependência cíclica que surgiria porque cada ambiente precisa declarar o grupo de ambiente ao qual 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 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 está em análise na solicitação de envio #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, as pessoas vão depender de comportamentos que você considerou 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, esses rótulos 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 lugares:

  • A interface RuleVisibility representa uma declaração de visibilidade. Ele pode ser uma constante (totalmente pública ou totalmente privada) ou uma lista de identificadores.
  • 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 o próprio destino (PackageGroup) e o destino configurado (PackageGroupConfiguredTarget). Poderíamos substituí-los por regras simples, se quiséssemos. A lógica deles é implementada com a ajuda de: PackageSpecification, que corresponde a um único padrão como //pkg/...; PackageGroupContents, que corresponde a um único atributo packages do package_group; e PackageSpecificationProvider, que agrega em um package_group e seu includes transitivo.
  • A conversão de listas de rótulos de visibilidade em dependências é feita em DependencyResolver.visitTargetVisibility e em alguns outros lugares diversos.
  • 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 agrupa 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 do Python

Se fizéssemos isso de forma simples usando, por exemplo, List ou Set, teríamos um uso de memória quadrático: se houver uma cadeia de N regras e cada regra adicionar um arquivo, teríamos 1+2+...+N membros da 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. Eles são imutáveis, e os membros 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 precisa ser o mesmo sempre".

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 e bipartite chamado "gráfico de ação".

Os artefatos são de dois tipos: artefatos de origem (disponíveis antes da execução do Bazel) e artefatos derivados (que precisam ser criados). Os artefatos derivados podem ser de vários tipos:

  1. **Artefatos regulares. **Eles são verificados quanto à atualização computando a soma de verificação, com mtime como um atalho. Não fazemos a soma de verificação do arquivo se o ctime não tiver mudado.
  2. Artefatos de link simbólico 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 em casos em que alguns arquivos são agrupados em um arquivo.
  3. Artefatos de árvores. Eles não são arquivos únicos, mas árvores de diretórios. A verificação de atualização é feita pelo conjunto de arquivos e conteúdos. Elas são representadas como TreeArtifact.
  4. Artefatos de metadados constantes. As mudanças nesses artefatos não acionam uma reconstrução. Ele é usado exclusivamente para informações de carimbo de build: não queremos fazer uma recriação apenas porque o horário 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 com o Bazel). Temos uma implementação desse tipo de trabalho que é ativada pela propriedade da JVM BAZEL_TRACK_SOURCE_DIRECTORIES=1

Um tipo importante 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. Isso é feito para que, se muitas ações usem o mesmo conjunto grande de entradas, não tenhamos bordas de dependência N*M, apenas N+M (elas estão sendo substituídas por conjuntos aninhados)
  • A programação de intermediários de dependência garante que uma ação seja executada antes de outra. Elas são usadas principalmente para linting, mas também para a compilação do 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 melhor entendidas como um comando que precisa ser executado, o ambiente necessário e o conjunto de saídas que elas produzem. 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 alguns outros casos especiais, como gravar um arquivo cujo conteúdo é conhecido pelo Bazel. Eles são uma subclasse de AbstractAction. A maioria das ações é uma SpawnAction ou uma StarlarkAction (o mesmo, elas não precisam ser classes separadas), embora o Java e o C++ tenham tipos de ação próprios (JavaCompileAction, CppCompileAction e CppLinkAction).

No final, queremos mover tudo para SpawnAction. JavaCompileAction está bem próximo, mas o C++ é um caso especial devido à análise de arquivos .d e à verificação de inclusão.

O gráfico de ação é "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 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 SkyValues próprios. Em vez disso, Artifact.getGeneratingActionKey() é usado para descobrir o token da ação que o 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 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 gera 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 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, 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. Isso é implementado em SkyframeActionExecutor.findAndStoreArtifactConflicts() e é um dos poucos lugares no Bazel que exige uma visualização "global" do build.

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. Grosso modo, é o filesToBuild das metas configuradas na linha de comando e o conteúdo de um grupo de saída especial com o propósito explícito de expressar "se essa meta 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:

  • 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
  • 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 --package_path está sendo descontinuado aos poucos

Em seguida, o Bazel começa a percorrer o gráfico de ações (o gráfico bipartido e dirigido composto por ações e seus 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 acessadas por trás do Skyframe:

  • ActionExecutionFunction.stateMap contém dados para fazer com que as reinicializações do Skyframe de ActionExecutionFunction sejam baratas.
  • O cache de ação local 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ção local

Esse cache é outra camada que fica atrás do Skyframe. Mesmo que uma ação seja realizada novamente no Skyframe, ela ainda pode ser um hit no cache de ação local. Ele representa o estado do sistema de arquivos local e é serializado no disco, o que significa que, ao iniciar um novo servidor do Bazel, é possível receber hits de cache de ação local, mesmo que o gráfico do Skyframe esteja vazio.

Esse cache é verificado para acertos 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:

  1. O conjunto de arquivos de entrada e saída e o checksum deles
  2. 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 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 ocorrem 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 é C++, em que é melhor fazer uma estimativa aproximada sobre quais arquivos de cabeçalho um arquivo C++ usa da sua fechamento transitivo para que não seja necessário enviar cada arquivo para executores remotos. Portanto, temos a opção de não registrar todos os arquivos de cabeçalho como "input", mas verificar o arquivo de origem para cabeçalhos incluídos de forma transitiva e marcar apenas os arquivos de cabeçalho como entradas mencionadas nas instruções #include. Essa opção está fixada em "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 depois do fato. 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 scanner de inclusão porque depende do compilador.

Eles são implementados usando métodos em Ação:

  1. A função Action.discoverInputs() é chamada. Ele precisa retornar um conjunto aninhado de artefatos que são determinados como necessários. Eles precisam ser artefatos de origem para que não haja bordas de dependência no gráfico de ação que não tenham um equivalente no gráfico de destino configurado.
  2. A ação é executada chamando Action.execute().
  3. No final de Action.execute(), a ação pode chamar Action.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 batida em uma nova instância de ação (como a criada após uma reinicialização do servidor), o Bazel chama updateInputs() para que o conjunto de entradas reflita o resultado da descoberta e poda de entrada feita anteriormente.

As ações do Starlark podem usar a facilidade para declarar algumas entradas como não usadas 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:

  1. 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 de ExecutionTool. Os tipos de contexto de ação são identificados por uma instância Class do Java que se refere a uma subinterface de ActionContext e que o contexto de ação precisa implementar.
  2. O contexto de ação adequado é selecionado entre os disponíveis e é encaminhado para ActionExecutionContext e BlazeExecutor.
  3. As ações solicitam contextos usando ActionExecutionContext.getContext() e BlazeExecutor.getStrategy(). Só deveria haver uma maneira de fazer isso…

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 importante é aquela 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 serem iniciadas novamente para cada ação. Isso representa um possível problema de correção, já que o Bazel depende da promessa do processo worker de que ele não carrega um estado observável entre solicitações individuais.

Se a ferramenta mudar, o processo do worker precisa ser reiniciado. A possibilidade de reutilização de um worker é determinada pela computação de um checksum para a 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 do Spawn são considerados 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 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.
  • Confira aqui informações sobre as complexidades da execução de ações localmente.

O gerenciador de recursos local

O Bazel pode executar várias 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 anexada com uma estimativa dos recursos locais necessários na forma de uma instância ResourceSet (CPU e RAM). Em seguida, 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.

Confira uma descrição mais detalhada do gerenciamento de recursos locais neste link.

A estrutura do diretório de saída

Cada ação requer um lugar separado no diretório de saída onde ela 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:

  1. 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, da 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").
  2. Se duas configurações representarem "aproximadamente" a mesma coisa, elas precisarão 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, as alterações nas opções de linha de comando para o compilador Java não devem resultar na reexecução de ações de compilação do C++.

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 (cujas regras geralmente não são familiares 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 da 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, 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. Ele 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 tem suporte avançado para a execução de testes. Ela aceita estas opções:

  • Como executar testes remotamente (se um back-end de execução remota estiver disponível)
  • Como executar testes várias vezes em paralelo (para deflacar 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
  • Como agrupar testes em pacotes de testes

Os testes são destinos configurados normalmente que têm um TestProvider, que descreve como o teste deve ser executado:

  • Os artefatos cujo build resulta na execução do teste. Este é um arquivo de "status do cache" que contém uma mensagem TestResultData serializada.
  • O número de vezes que o teste precisa ser executado
  • O número de fragmentos em que o teste precisa ser dividido
  • Alguns parâmetros sobre como o teste deve ser executado (como o tempo limite do teste)

Como determinar quais testes executar

Determinar quais testes serão executados é um processo complexo.

Primeiro, durante a análise de padrões de destino, os conjuntos de testes são expandidos de forma recursiva. A expansão é implementada em TestsForTargetPatternFunction. Uma surpresa surpreendente é que, se um conjunto de testes não declarar nenhum teste, ele se refere a todos os testes no pacote. Isso é implementado em Package.beforeBuild() adicionando 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, 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.

Isso é processado em BuildView.createResult(): os destinos que falharam na análise são filtrados e os testes são divididos em exclusivos e não exclusivos. Em seguida, ele é colocado em AnalysisResult, que é como 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, essa é uma reimplementação, então provavelmente difere do modelo 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 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 o que é esperado deles. Confira uma descrição detalhada do que o Bazel espera dos testes e o que os testes podem esperar do Bazel aqui. No mais simples, um código de saída 0 significa sucesso, qualquer outra coisa significa falha.

Além do arquivo de status do cache, cada processo de teste emite vários outros arquivos. Elas são colocadas no "diretório de registro 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 teste
  • test.log, a saída do console do teste. O stdout e o stderr não são separados.
  • test.outputs, o "diretório de saídas não declaradas";, que é 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 que não podem acontecer durante a criação de destinos normais: execução de teste exclusiva 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 que solicita 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 é descartada quando a ação é concluída, 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 com o nome adequado e funciona monitorando as mudanças no arquivo test.log do teste em questão e despejando novos bytes no terminal em que o Bazel governa.

Os resultados dos testes executados ficam disponíveis no bus de eventos ao observar 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 ambientes 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 mesclar 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 a instrumentação off-line, ou seja, a instrumentação de cobertura é adicionada no momento da compilação (como C++), e outras fazem a instrumentação on-line, ou seja, a instrumentação de 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 se nenhum código foi executado. O problema que ele resolve é que, se você quer calcular a cobertura de teste de um binário, não é suficiente 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 a cobertura, sem linhas cobertas. O arquivo de cobertura de referência de um destino está em bazel-testlogs/$PACKAGE/$TARGET/baseline_coverage.dat . Ele também é gerado para binários e bibliotecas, além de testes, se você transmitir a flag --nobuild_tests_only para o Bazel.

A cobertura de referência está indisponível no momento.

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 instrumentação. Para 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 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 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.

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 é tratado por CoverageReportActionFactory e é chamado de BuildView.createResult(). Ele acessa as ferramentas necessárias verificando o atributo :coverage_report_generator do primeiro teste executado.

O mecanismo de consulta

O Bazel tem uma pequena linguagem usada para perguntar várias coisas sobre vários gráficos. Os seguintes tipos de consulta são fornecidos:

  • O bazel query é usado para investigar o gráfico de destino.
  • 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 subclassificação de AbstractBlazeQueryEnvironment. Outras funções de consulta podem ser feitas subclassificando QueryFunction. Para permitir a transmissão de resultados de consulta, em vez de coletá-los em alguma estrutura de dados, um query2.engine.Callback é transmitido para QueryFunction, que é chamado para resultados que ele 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 (proto, com certeza) é que o Bazel precisa emitir _todas_ as informações que o carregamento de pacotes fornece para que seja possível comparar a saída e determinar se um destino específico mudou. Como consequência, os valores de atributo precisam ser serializáveis, e é por isso que há apenas alguns tipos de atributo sem atributos com valores complexos do Starlark. A solução usual é usar um rótulo e anexar as informações complexas à regra com esse rótulo. Não é uma solução alternativa muito satisfatória e seria muito bom remover esse requisito.

O sistema de módulos

O Bazel pode ser estendido com a adição de módulos. 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.

Elas são usadas principalmente para implementar vários recursos "não essenciais" 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 BlazeModule oferece é um pouco perigoso. Não use-o como exemplo de bons princípios de design.

O bus de eventos

A principal forma de comunicação dos BlazeModules com o restante do Bazel é por um bus de eventos (EventBus): uma nova instância é criada para cada build, várias partes do Bazel podem postar eventos nela e os módulos podem registrar listeners para os eventos em que estão interessados. Por exemplo, as seguintes coisas são representadas 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 sucesso 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 BuildEvents). Isso permite que não apenas BlazeModules, 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 Build Event Service) para transmitir 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 WORKSPACE. Por exemplo, uma declaração como esta:

    local_repository(name="foo", path="/foo/bar")

Os resultados no repositório chamado @foo estão disponíveis. O que complica é 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 de bloco é indicado por WorkspaceFileKey.getIndex() e calcular WorkspaceFileFunction até que o índice X significa avaliá-lo até a instrução X load().

Buscando repositórios

Antes que o código do repositório esteja 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:

  1. PackageLookupFunction percebe que precisa de um repositório e cria um RepositoryName como SkyKey, que invoca RepositoryLoaderFunction
  2. O RepositoryLoaderFunction encaminha a solicitação para RepositoryDelegatorFunction por motivos não claros. O código diz que é para evitar o reenvio de coisas em caso de reinicializações do Skyframe, mas não é um raciocínio muito sólido.
  3. RepositoryDelegatorFunction descobre a regra do repositório que precisa ser buscada iterando os pedaços do arquivo WORKSPACE até que o repositório solicitado seja encontrado.
  4. O RepositoryFunction apropriado é encontrado e implementado para a busca do repositório. Ele é 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:

  1. 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 do Bazel na mesma estação de trabalho, independentemente de qual espaço de trabalho ou base de saída elas estão executando.
  2. Um "arquivo de marcador" é gravado 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 em RepositoryDelegatorFunction.DigestWriter .
  3. A opção de linha de comando --distdir designa outro cache usado para procurar artefatos a serem transferidos por download. Isso é útil em configurações corporativas em que o Bazel não precisa buscar coisas aleatórias da Internet. Isso é implementado por DownloadManager .

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. Esses artefatos também são invalidados quando a definição do repositório em que estão muda. Assim, os FileStateValues de um artefato em um repositório externo precisam depender do repositório externo. Isso é tratado 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 é contrário à suposição do Bazel de que os arquivos de origem são modificados apenas pelo usuário e não por si mesmo, e permite que os pacotes se refiram a todos os diretórios na raiz do espaço de trabalho. Para fazer com que esse tipo de repositório externo funcione, o Bazel faz duas coisas:

  1. Permite que o usuário especifique subdiretórios do espaço de trabalho que o Bazel não pode acessar. Elas estão listadas em um arquivo chamado .bazelignore e a funcionalidade é implementada em BlacklistedPackagePrefixesFunction.
  2. Codificamos o mapeamento do subdiretório do espaço de trabalho para o repositório externo que o processa em ManagedDirectoriesKnowledge e processamos FileStateValues que se referem a eles da mesma maneira que os repositórios externos normais.

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 de dependência de diamante"). Por exemplo, se dois binários em repositórios separados no build quiserem depender da Guava, eles provavelmente vão se referir à Guava com rótulos que começam com @guava// e vão esperar que isso signifique versões diferentes dela.

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.

Também é possível usar esse método para juntar 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, ele aparece no Skyframe como um membro de WorkspaceFileValue, onde é conectado a:

  • Package.Builder.repositoryMapping, que é usado para transformar atributos com valor de rótulo de regras no pacote por RuleClass.populateRuleAttributeValues()
  • Package.repositoryMapping, que é usado na fase de análise (para resolver elementos como $(location), que não são analisados na fase de carregamento)
  • BzlLoadFunction para resolver rótulos em instruções load()

Bits da 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 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 e NativePosixFileSystem
  • ProcessUtils
  • WindowsFileOperations e WindowsFileProcesses
  • com.google.devtools.build.lib.platform

Saída do console

Emitir a saída do console parece uma coisa simples, mas a confluência de execução de vários processos (às vezes remotamente), o armazenamento em cache de granularidade fina, o desejo de ter uma saída de terminal bonita e colorida e ter um servidor de longa duração tornam isso não trivial.

Logo após a 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. Isso é para que as informações de depuração possam ser impressas usando System.err.println() e ainda terminar na saída do terminal do cliente, que é diferente da do servidor. Se um processo produzir saída binária (como bazel query --output=proto), não haverá munging do stdout.

Mensagens curtas (erros, avisos e outros) são expressas pela interface EventHandler. Elas são diferentes do que é postado 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 EventHandlers também permitem postar eventos que eventualmente chegam ao bus de eventos (Events normais _não_ aparecem lá). Estas são implementações de ExtendedEventHandler, e o uso principal delas é reproduzir eventos EventBus 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 é em grande parte emitida pelo UiEventHandler, que é responsável por todo o formato de saída e relatórios de progresso do Bazel. Ele tem duas entradas:

  • O bus de eventos
  • O fluxo de eventos transmitido por ele pelo Reporter

A única conexão direta que a máquina de execução de comando (por exemplo, o restante do Bazel) tem com o fluxo de 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 dados binários possíveis (como bazel query).

Criação de perfis 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 perfilador 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, embora registre apenas dados resumidos para que o overhead 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 do gerador de perfis do Chrome, que é melhor visualizado no Chrome. O modelo de dados é o das pilhas de tarefas: é possível iniciar e encerrar tarefas, e elas precisam estar aninhadas umas nas outras. Cada linha de execução Java recebe a própria pilha de tarefas. O que fazer:como isso funciona com ações e estilo de transmissão de continuação?

O criador de perfil é iniciado e interrompido em BlazeRuntime.initProfiler() e BlazeRuntime.afterCommand(), respectivamente, e tenta ficar ativo por o 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. É melhor usar com instruções try-with-resources.

Também fazemos perfis de memória rudimentares em MemoryProfiler. Ele também está sempre ativado e registra principalmente os tamanhos máximos de heap e o comportamento do GC.

Como 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 os primeiros de "testes de integração" e os últimos 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.

Há dois tipos de testes de integração:

  1. Implementadas usando um framework de teste bash muito elaborado em src/test/shell
  2. Implementadas em Java. Eles são implementados como subclasses de BuildIntegrationTestCase.

O BuildIntegrationTestCase é o framework de teste de integração preferencial, porque ele é 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. Vários métodos auxiliares podem solicitar destinos configurados, mudar a configuração e declarar várias coisas sobre o resultado da análise.