Base de código do Bazel

Informar um problema Acessar a origem

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 350KLOC código de produção e aproximadamente 260 KLOC de código de teste) e ninguém está familiarizado com toda a paisagem: todos conhecem muito bem o vale específico, mas poucos sabem o que há sobre as colinas em todas as direções.

Para que as pessoas no meio da jornada não se encontrem dentro de uma escuridão pela floresta com o caminho simples sendo perdido, este documento tenta oferecer uma visão geral da base de código para que seja mais fácil começar a trabalhar nela.

A versão pública do código-fonte do Bazel fica no GitHub em github.com/bazelbuild/bazel (link em inglês). Essa não é a "fonte da verdade". Ela deriva de uma árvore de origem interna do Google que contém outras funcionalidades que não são úteis fora dele. O objetivo de longo prazo é fazer do GitHub a fonte da verdade.

As contribuições são aceitas por meio do mecanismo de solicitação de envio normal do GitHub e importadas manualmente por um Googler para a árvore de origem interna e, em seguida, exportadas de volta para o GitHub.

Arquitetura de cliente/servidor

A maior parte do Bazel reside em um processo do 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 estão depois de (-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 comando.

Cada instância de servidor tem um único espaço de trabalho associado (conjunto de árvores de origem conhecidas como "repositórios") e cada espaço de trabalho geralmente tem uma única instância de servidor ativa. Isso pode ser 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 apropriado usando as seguintes etapas:

  1. Verifica se ele já foi extraído. Caso contrário, ela faz isso. É de onde vem a implementação do servidor.
  2. Verifica se há uma instância ativa do servidor funcionando: ela está em execução, tem as opções de inicialização corretas e usa o diretório do espaço de trabalho certo. Para encontrar o servidor em execução, ele verifica o diretório $OUTPUT_BASE/server, em que há um arquivo de bloqueio com a porta em que o servidor está detectando.
  3. Se necessário, encerra o processo antigo do servidor.
  4. Se necessário, inicia um novo processo do servidor.

Depois que um processo adequado de servidor estiver pronto, o comando que precisa ser executado será comunicado a ele por uma interface gRPC. Em seguida, a saída do Bazel será enviada 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 constrangedora. O principal bloqueador é o ciclo de vida de BlazeModules 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 desvantagem interessante é a implementação de bazel run: o trabalho desse comando é executar algo que o Bazel acabou de criar, mas ele não pode fazer isso no processo do servidor porque ele não tem um terminal. Em vez disso, ele informa ao cliente qual binário precisa ser ujexec() e com quais argumentos.

Quando alguém pressiona Ctrl-C, o cliente a converte em uma chamada "Cancel" 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 para o servidor.

O código-fonte do cliente está em src/main/cpp, e o protocolo usado para se comunicar com o servidor está em src/main/protobuf/command_server.proto .

O 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 "repositório principal" é a árvore de origem em que o Bazel é executado. Isso geralmente corresponde a algo que você conferiu no controle de origem. A raiz desse diretório é conhecida como a "raiz do espaço de trabalho".

O Bazel coloca todos os dados na "raiz do usuário de saída". Geralmente, é $HOME/.cache/bazel/_bazel_${USER}, mas pode ser substituído usando a opção de inicialização --output_user_root.

A "base instalada" é o local 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 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 lugar onde a instância do Bazel anexada a um espaço de trabalho específico grava. Cada base de saída tem no máximo uma instância de servidor Bazel em execução a qualquer momento. Geralmente é às $OUTPUT_USER_ROOT/<checksum of the path to the workspace>. Ela pode ser alterada 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 estar em execução 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 em $OUTPUT_BASE/execroot. Durante a criação, o diretório de trabalho é $EXECROOT/<name of main repository>. Estamos planejando mudar 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

Depois que o servidor do Bazel recebe o controle e é informado sobre um comando que precisa ser executado, a seguinte sequência de eventos acontece:

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

  2. O comando correto é encontrado. Cada comando precisa implementar a interface BlazeCommand e ter a anotação @Command. Esse é um pouco de antipadrão, e seria bom se todos os metadados de que um comando precisa 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 barramento de eventos é um fluxo de eventos que ocorrem durante a criação. Alguns são exportados para fora do Bazel sob a egis do Build Event Protocol para informar ao mundo como o build funciona.

  5. O comando assume o controle. Os comandos mais interessantes são aqueles que executam um build: criar, testar, executar, cobertura e assim por diante. Essa funcionalidade é implementada por BuildTool.

  6. 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 em AnalysisPhaseRunner.evaluateTargetPatterns() e reificado no Skyframe como TargetPatternPhaseValue.

  7. A fase de carregamento/análise é executada para produzir o gráfico de ações, ou seja, um gráfico acíclico direcionado de comandos que precisam ser executados para o build.

  8. A fase de execução é executada. Isso significa executar todas as ações necessárias para criar os destinos de nível superior solicitados.

Opções de linha de comando

As opções da linha de comando para uma invocação do Bazel são descritas em um objeto OptionsParsingResult, que, por sua vez, contém um mapa de "classes de opções" para os valores das opções. Uma "classe de opção" é uma subclasse de OptionsBase e agrupa opções de linha de comando relacionadas entre si. Exemplo:

  1. Opções relacionadas a uma linguagem de programação (CppOptions ou JavaOptions). 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 são projetadas para serem consumidas na fase de análise e por RuleContext.getFragment() em Java ou ctx.fragments em Starlark. Alguns deles, por exemplo, incluir verificação em C++ ou não, são lidos na fase de execução, mas isso sempre exige um encanamento explícito, já que BuildConfiguration não está disponível. Para saber mais, consulte a seção "Configurações".

AVISO:gostamos de fingir que as instâncias do OptionsBase são imutáveis e usá-las dessa maneira (por exemplo, uma parte de SkyKeys). Esse não é o caso, e modificar essas instâncias é uma boa maneira de interromper o Bazel de maneiras sutis difíceis de depurar. Infelizmente, torná-los realmente imutáveis é um grande empreendimento. Não há problema em modificar uma FragmentOptions imediatamente após a construção antes que qualquer outra pessoa tenha a chance de manter uma referência a ela e antes que equals() ou hashCode() sejam chamados.

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

  1. Alguns estão conectados ao Bazel (CommonCommandOptions)
  2. Da anotação @Command em cada comando do Bazel
  3. Em ConfiguredRuleClassProvider, estas opções de linha de comando estão relacionadas a linguagens de programação individuais
  4. As regras do Starlark também podem definir as próprias opções. Consulte este link.

Cada opção, exceto as opções definidas pelo 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 com um texto de ajuda.

O tipo Java do valor de uma opção de linha de comando geralmente é algo simples (uma string, um número inteiro, um booleano, um rótulo etc.). No entanto, também há suporte para opções de tipos mais complicados. Nesse caso, o trabalho de conversão da string da linha de comando para o tipo de dados recai em uma implementação de com.google.devtools.common.options.Converter.

A árvore de origem, conforme visto pelo Bazel

O Bazel trabalha para criar software, 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. Geralmente representa um único projeto. O ancestral do Bazel, Blaze, operado em um monorepo, ou seja, uma única árvore de origem que contém todo o código-fonte usado para executar o build. Por outro lado, o Bazel oferece suporte a projetos com código-fonte abrangendo vários repositórios. O repositório em que o Bazel é invocado é chamado de "repositório principal". Os outros são chamados de "repositórios externos".

Um repositório é marcado por um arquivo de limite do repositório (MODULE.bazel, REPO.bazel ou em contextos legados, WORKSPACE ou WORKSPACE.bazel) no diretório raiz. O repositório principal é a árvore de origem de onde você está invocando o Bazel. Os repositórios externos são definidos de várias maneiras. Consulte a visão geral de dependências externas para mais informações.

O código de repositórios externos é vinculado simbólico ou transferido por download em $OUTPUT_BASE/external.

Ao executar o build, toda a árvore de origem precisa estar montada. Isso é feito pelo SymlinkForest, que vincula todos os pacotes no repositório principal a $EXECROOT e todos os repositórios externos a $EXECROOT/external ou $EXECROOT/...

Pacotes

Todo repositório é composto de 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 por que os arquivos BUILD ainda são aceitos é que o ancestral do Bazel, Blaze, usou esse nome de arquivo. No entanto, acabou sendo um segmento de caminho muito usado, especialmente no Windows, em que os nomes de arquivo não diferenciam maiúsculas de minúsculas.

Os pacotes são independentes entre si: as mudanças no arquivo BUILD de um pacote não podem fazer com que outros pacotes sejam alterados. A adição ou remoção de arquivos BUILD _pode _change 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". Ela é implementada 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. É basicamente um mapa de uma string (o nome de um destino) para o próprio destino.

Uma grande parte de complexidade durante o carregamento de pacote é globbing: o Bazel não exige que todos os arquivos de origem sejam explicitamente listados e, em vez disso, pode executar globs (como glob(["**/*.java"])). Ao contrário do shell, ele é compatível com globs recursivos que descem em subdiretórios, mas não em subpacotes. Isso exige acesso ao sistema de arquivos e, como ele pode ser lento, implementamos todos os tipos de truques para fazer com que ele seja executado em paralelo e da maneira mais eficiente possível.

O globbing é implementado nas seguintes classes:

  • LegacyGlobber, um globo rápido e animado que não vê o Skyframe
  • SkyframeHybridGlobber, uma versão que usa o Skyframe e retorna 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 pacote "externo" (relacionado a dependências externas) 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 descrevam outra coisa. Confira a lista abaixo:

  • Os mapeamentos de repositório
  • Os conjuntos de ferramentas registrados
  • Plataformas de execução registradas

O ideal seria ter mais separação entre a análise do pacote "externo" e a análise de pacotes regulares, para que Package não precise atender às necessidades de ambos. Infelizmente, é difícil fazer isso porque os dois estão muito relacionados.

Rótulos, destinos e regras

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

  1. Arquivos:itens que são a entrada ou a saída do build. No bazel, eles são chamados de artefatos (discutidos em outros lugares). 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 existem algumas que não dependem de linguagem, 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 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:

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

Um tipo de regra (como "biblioteca C++") é chamado de "classe de regra". As classes de regras podem ser implementadas em Starlark (a função rule()) ou em Java (chamadas de "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 de arquivos BUILD usando a instrução load(). Já as classes de regra Java são conhecidas "inteticamente" pelo Bazel, em virtude de serem registradas com ConfiguredRuleClassProvider.

As classes de regra contêm informações como:

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

Observação de terminologia:na base de código, geralmente usamos "Regra" para significar o destino criado por uma classe de regra. No entanto, no Starlark e na documentação voltada ao usuário, "Rule" precisa ser usado exclusivamente para se referir à própria classe de regra. 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 os destinos desse tipo.

Estrutura do arranha-céu

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 direcionado com arestas apontando de qualquer dado para as dependências, ou seja, outros dados que precisam ser conhecidos para construí-los.

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; apenas objetos imutáveis podem ser acessados por eles. Essa invariante quase sempre é válida. Caso isso não aconteça, como para as classes de opções individuais BuildOptions, que são membros de BuildConfigurationValue e do SkyKey, tentamos muito não mudá-las ou 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=deps, que despeja o gráfico, um SkyValue por linha. É melhor fazer isso para builds minúsculos, 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 uma determinada SkyKey em uma SkyValue, o Skyframe invocará o SkyFunction correspondente ao tipo da chave. Durante a avaliação da função, ela pode solicitar outras dependências do Skyframe chamando as várias sobrecargas de SkyFunction.Environment.getValue(). Isso tem o efeito colateral de registrar essas dependências no gráfico interno do Skyframe, para que ele saiba que precisa reavaliar a função quando qualquer uma das dependências mudar. Em outras palavras, o armazenamento em cache e o cálculo incremental do Skyframe funcionam na granularidade de SkyFunctions e SkyValues.

Sempre que um SkyFunction solicitar uma dependência indisponível, getValue() retornará um valor nulo. A função precisa gerar o controle de volta para o Skyframe retornando um valor nulo. Posteriormente, o Skyframe avaliará a dependência indisponível e reiniciará a função desde o início. Só que 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. No entanto, isso não inclui o trabalho feito para avaliar a dependência SkyValues, que são armazenadas em cache. Portanto, geralmente resolvemos esse problema das seguintes maneiras:

  1. Declarar dependências em lotes (usando getValuesAndExceptions()) para limitar o número de reinicializações.
  2. Divisão de um SkyValue em partes separadas calculadas por SkyFunctions diferentes, para que elas possam ser calculadas 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 "por trás do Skyframe". Com SkyFunctions complexas, o gerenciamento de estado entre reinicializações pode ficar complicado, então os StateMachines foram introduzidos para uma abordagem estruturada da simultaneidade lógica, incluindo hooks para suspender e retomar cálculos hierárquicos em um SkyFunction. Exemplo: DependencyResolver#computeDependencies usa um StateMachine com getState() para calcular o conjunto potencialmente grande de dependências diretas de um destino configurado, o que, caso contrário, pode resultar em reinicializações caras.

Essencialmente, o Bazel precisa desses tipos de soluções alternativas porque centenas de milhares de nós do Skyframe em trânsito são comuns e o suporte do Java a linhas de execução leves não supera a implementação StateMachine desde 2023.

Starlark

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, garantias fortes de imutabilidade para permitir leituras simultâneas. Ele não está completo e 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 Go independente aqui. Atualmente, a implementação do Java usada no Bazel é um intérprete.

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

  1. Arquivos BUILD. É aqui que os novos destinos de build são definidos. O código Starlark em execução nesse contexto tem acesso apenas ao conteúdo do próprio arquivo BUILD e aos arquivos .bzl carregados por ele.
  2. O arquivo MODULE.bazel. É aqui que as dependências externas são definidas. O código Starlark em execução nesse contexto tem acesso muito limitado a algumas diretivas predefinidas.
  3. Arquivos .bzl. É aqui que são definidas novas regras de build, regras de repositório e extensões de módulo. O código Starlark pode definir novas funções e carregar de outros arquivos .bzl.

Os dialetos disponíveis para arquivos BUILD e .bzl são um pouco diferentes, porque expressam coisas distintas. Uma lista de diferenças está disponível aqui.

Saiba mais sobre o Starlark neste link.

Fase de carregamento/análise

É na fase de carregamento/análise que o Bazel determina quais ações são necessárias para criar uma regra específica. A unidade básica é um "destino configurado", ou seja, 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 em objetos Package que os representam
  2. 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, os nós de folha primeiro e depois até 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 opções de linha de comando que o usuário quer que sejam transmitidos ao compilador C++).
  2. As dependências diretas. Os provedores de informações transitivas deles estão disponíveis para a regra que está sendo analisada. Eles são chamados assim porque fornecem uma "visualização completa" 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 destino. Este é o resultado do carregamento do pacote em que o destino está. Para as regras, isso inclui os atributos, que geralmente é o que importa.
  4. A implementação do destino configurado. Para as regras, isso pode ser no Starlark ou em Java. Todos os destinos configurados sem regra são implementados em Java.

O resultado da análise de um destino configurado é:

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

A API oferecida para as regras Java é RuleContext, que é o equivalente ao argumento ctx das regras do Starlark. A API dele é mais eficiente, mas, ao mesmo tempo, é mais fácil fazer Bad ThingsTM, por exemplo, escrever um código com complexidade de tempo ou espaço quadrático (ou pior), fazer o servidor do 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 fica 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 diversas 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 a compilação cruzada ou quando estamos criando um app Android multiuso, 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 é encapsulado por BuildConfiguration, que fornece outras funcionalidades. Ela 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 a necessidade de analisar novamente todo o build se, por exemplo, o número de execuções de teste solicitadas mudar, mesmo que isso afete apenas os destinos de teste. Temos planos de "cortar" as configurações para que esse não seja o caso, mas ele ainda não esteja pronto.

Quando uma implementação de regra precisa de parte da configuração, ela precisa 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 C++ não precisem ser analisados novamente.

A configuração de uma regra não é necessariamente igual à da regra "mãe". O processo de mudança de 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 uma Rule, em que a transição acontece, e uma BuildOptions (a configuração original) para uma ou mais BuildOptions (a configuração de saída).
  2. Em qualquer borda de entrada de 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:

  1. Para declarar que uma dependência específica é usada durante o build e, portanto, ela precisa ser criada na arquitetura de execução.
  2. Declarar que uma dependência específica precisa ser criada para várias arquiteturas (como para código nativo em APKs Android grandes)

Se uma transição de configuração resultar em várias configurações, ela é chamada de transição dividida.

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

Provedores de informações transitivas

Os provedores de informações transitivas são uma maneira (e o _only _way) de destinos configurados informarem informações sobre outros destinos configurados que dependem deles. A razão pela qual "transitivo" está no nome é que isso geralmente é algum tipo de visualização completa do fechamento transitivo de um destino configurado.

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

  1. Um objeto de classe Java. Isso 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. Isso é legado e muito desencorajado, porque é suscetível a conflitos de nomes. Esses provedores de informações transitivas são subclasses diretas de build.lib.packages.Info .
  3. Símbolo do 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ância Provider.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 no Starlark

Destinos configurados

Os destinos configurados são implementados como RuleConfiguredTargetFactory. Existe 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 construir o valor de retorno. Ela consiste no seguinte:

  1. A filesToBuild, o conceito confuso de "o conjunto de arquivos que esta regra representa". Esses são os arquivos criados quando o destino configurado está na linha de comando ou nos srcs de uma regra geral.
  2. Os arquivos de execução, regulares e de dados.
  3. Os grupos de saída. 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 usando 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 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. 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, é um mapa do caminho de um arquivo na árvore de arquivos de execução para a instância Artifact que o representa. Ele é 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 é igual ao caminho de execução dele. Usamos isso para economizar memória RAM.
  • Há vários tipos de entradas legadas nas árvores de arquivos de execução que também precisam ser representadas.

Os arquivos de execução são coletados usando RunfilesProvider. Uma instância dessa classe representa os arquivos de execução que um destino configurado (como uma biblioteca) e as necessidades de fechamento transitivo dele. Eles são reunidos como um conjunto aninhado (na verdade, são implementados usando conjuntos aninhados sob a capa). Cada destino unifica os arquivos de execução das dependências, adiciona alguns dos 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 é dependente do atributo "dados" e uma para cada outro tipo de dependência recebida. Isso ocorre porque um destino às vezes apresenta arquivos de execução diferentes quando depende de um atributo de dados. Esse é um comportamento legado indesejado que ainda não conseguimos remover.

Os arquivos de execução de binários são representados como uma instância de RunfilesSupport. Isso é diferente de Runfiles, porque RunfilesSupport tem a capacidade de ser criado (ao contrário de Runfiles, que é apenas um mapeamento). Isso precisa dos seguintes componentes extras:

  • O manifesto de arquivos runfiles de entrada. Esta é uma descrição serializada da árvore de 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 apenas se o conteúdo do manifesto for alterado.
  • O manifesto de arquivos runfiles de saída. Ele é usado por bibliotecas de tempo de execução que processam árvores de arquivos de execução, principalmente no Windows, que às vezes não oferece suporte a links simbólicos.
  • O intermediário dos arquivos de execução. Para que exista uma árvore de runfiles, é necessário criar a árvore de links simbólicos e o artefato a que 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 todas elas.
  • Argumentos de linha de comando para executar o binário cujos arquivos de execução o objeto RunfilesSupport representa.

Aspectos

Os aspectos são uma maneira de "propagar a computação para o gráfico de dependências". Elas estão descritas para usuários do Bazel aqui. Um bom exemplo de motivação 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" de buffers de protocolo) em qualquer linguagem de programação precisa ser acoplada à regra proto_library. Assim, se dois destinos na mesma linguagem dependerem do mesmo buffer de protocolo, ela será criada apenas uma vez.

Assim como destinos configurados, eles são representados no Skyframe como um SkyValue, e a forma como eles são criados é muito semelhante a 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 conhece o destino configurado e os provedores dele.

O conjunto de aspectos propagados no gráfico de dependência é especificado para cada atributo usando a função Attribute.Builder.aspects(). Há algumas classes com nomes confusos que participam do processo:

  1. AspectClass é a implementação do aspecto. Ela pode estar em Java (nesse caso, é uma subclasse) ou no Starlark (nesse caso, é uma instância de StarlarkAspectClass). É análoga a RuleConfiguredTargetFactory.
  2. AspectDefinition é a definição do aspecto. Inclui os provedores necessários, os provedores que fornece e 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 o gráfico de dependências. No momento, é um mapa de string a string. Um bom exemplo da utilidade dos buffers de protocolo são os buffers de protocolo: se uma linguagem tem várias APIs, as informações sobre para qual API os buffers de protocolo precisam ser criados precisam ser propagadas no gráfico de dependência.
  4. Aspect representa todos os dados necessários para calcular um aspecto que se propaga no gráfico de dependência. Ela consiste na classe Aspecto, sua definição e seus parâmetros.
  5. RuleAspect é a função que determina quais aspectos uma regra específica precisa propagar. É uma função Rule -> 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 para um ambiente de desenvolvimento integrado Java provavelmente vai querer saber sobre todos os arquivos .jar no caminho de classe, mas alguns deles são buffers de protocolo. Nesse caso, o aspecto do ambiente de desenvolvimento integrado precisará ser anexado ao par (regra proto_library + aspecto proto Java).

A complexidade dos aspectos é capturada na classe AspectCollection.

Plataformas e conjuntos de ferramentas

Ele é compatível com 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 que o código é criado. Essas arquiteturas são chamadas de plataformas na comparação do Bazel (documentação completa aqui).

Uma plataforma é descrita por um mapeamento de chave-valor das configurações de restrição (como o conceito de "arquitetura da CPU") para valores de restrição (como uma CPU específica, como x86_64). Temos um "dicionário" das configurações e valores de restrição mais usados no repositório @platforms.

O conceito de conjunto de ferramentas vem do fato de que, dependendo de quais plataformas o build está sendo executado e para quais plataformas é destinada, pode ser necessário usar diferentes compiladores. Por exemplo, um conjunto de ferramentas C++ específico pode ser executado em um SO específico e 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, os conjuntos de ferramentas são anotados com o conjunto de restrições de execução e plataforma de destino compatíveis. Para fazer isso, a definição de um conjunto de ferramentas é dividida em duas partes:

  1. Uma regra toolchain() que descreve o conjunto de restrições de execução e destino que um conjunto de ferramentas oferece suporte e informa o tipo dele (como C++ ou Java) dele. O último é representado pela regra toolchain_type()
  2. 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 dele. As regras *_toolchain() específicas da linguagem contêm muito mais informações do que isso, o que leva mais tempo para o carregamento.

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

  1. No arquivo MODULE.bazel 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 para um destino configurado é determinada por PlatformOptions.computeTargetPlatform() . É uma lista de plataformas porque, em algum momento, queremos oferecer suporte a várias plataformas de destino, mas isso ainda não foi implementado.

O conjunto de conjuntos de ferramentas a ser usado para um destino configurado é determinado por ToolchainResolutionFunction. É uma função de:

  • O conjunto de conjuntos de ferramentas registrados (no arquivo MODULE.bazel e na configuração)
  • As plataformas de execução e destino desejadas (na configuração)
  • O conjunto de tipos de conjunto de ferramentas exigidos pelo destino configurado (em UnloadedToolchainContextKey)
  • O conjunto de restrições da plataforma de execução do destino configurado (o atributo exec_compatible_with) e da configuração (--experimental_add_exec_constraints_to_targets), em UnloadedToolchainContextKey

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

Em seguida, os conjuntos de ferramentas são carregados usando 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á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 converter entre as sinalizações legadas e as restrições da plataforma de estilo novo. O código está em PlatformMappingFunction e usa uma "pequena linguagem" não do Starlark.

Restrições

Às vezes, é necessário designar um destino como compatível com apenas algumas plataformas. Infelizmente, o Bazel tem vários mecanismos para alcançar esse objetivo:

  • Restrições específicas da regra
  • environment_group() / environment()
  • Restrições da plataforma

As restrições específicas de regras são usadas principalmente nas regras do Google para Java. Elas são saídas 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= .

Environment_group() e Environment()

Essas regras são um mecanismo legado e não são amplamente usadas.

Todas as regras de build podem declarar para quais "ambientes" elas podem ser criadas. Um "ambiente" é uma instância da regra environment().

Existem várias maneiras de especificar os ambientes compatíveis para uma regra:

  1. 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.
  2. Pelo atributo compatible_with=. Isso declara os ambientes que uma regra aceita além dos ambientes "padrão" aceitos por padrão.
  3. Pelos atributos no nível do pacote default_restricted_to= e default_compatible_with=.
  4. Por meio de especificações padrão em regras environment_group(). Cada ambiente pertence a um grupo de pares com temas 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 precisam ser compatíveis por "padrão" se não forem especificados pelos atributos restricted_to= / environment(). Uma regra sem esses atributos herda todos os padrões.
  5. Por meio de um padrão de classe de regra. Isso substitui os padrões globais para todas as instâncias da classe de regras 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() é implementada como uma regra normal, 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 cria um destino com mesmo nome. Isso evita uma dependência cíclica que surgiria porque cada ambiente precisa declarar o grupo a que pertence e cada grupo de ambientes precisa declarar os próprios ambientes padrão.

Um build pode ser restrito a um determinado ambiente com a opção de linha de comando --target_environment.

A implementação da verificação de restrição está em RuleContextConstraintSemantics e TopLevelConstraintSemantics.

Restrições da plataforma

A maneira "oficial" atual de descrever com quais plataformas um destino é compatível é usar as mesmas restrições usadas para descrever conjuntos de ferramentas 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), é preciso evitar que outras pessoas dependam do seu código arbitrariamente. Caso contrário, de acordo com a lei da Hyrum, as pessoas vão passar a confiar em comportamentos que você considerou ser os detalhes de implementação.

O Bazel aceita isso pelo mecanismo chamado visibilidade: é possível declarar que um destino específico só pode ser dependente usando o 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 identificadores podem se referir a grupos de pacotes (lista predefinida de pacotes), 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 destino (PackageGroup) e destino configurado (PackageGroupConfiguredTarget). Provavelmente, poderíamos substituí-los por regras simples se quiséssemos fazer isso. A lógica delas é 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 mais de um package_group e a includes transitiva dele.
  • A conversão das listas de rótulos de visibilidade para 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 o próprio e une o conjunto agregado em um provedor de informações transitivas para que destinos configurados que dependem dele possam fazer o mesmo. Exemplos:

  • Os arquivos principais do C++ usados para um build
  • Os arquivos de objeto que representam o fechamento transitivo de um cc_library
  • O conjunto de arquivos .jar que precisam estar no caminho de classe para que uma regra Java seja compilada ou executada
  • O conjunto de arquivos Python no fechamento transitivo de uma regra Python

Se fizermos isso de forma simples usando, por exemplo, List ou Set, o uso de memória quadrática será usado: 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. Ele é uma estrutura de dados composta por outras instâncias NestedSet e alguns membros próprios, formando um gráfico acíclico direcionado de conjuntos. Elas são imutáveis e seus membros podem ser iterados. Definimos várias ordens de iteração (NestedSet.Order): preorder, pós-ordem, topologia (um nó sempre vem depois dos ancestrais) e "não se importa, mas deve ser a mesma todas as vezes".

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, como instâncias da classe Artifact. Eles são organizados em um gráfico acíclico, direcionado e bipartido, chamado de "gráfico de ações".

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

  1. **Artefatos regulares. **Elas são verificadas quanto à atualização por meio do cálculo da soma de verificação, com o mtime como atalho. Não faremos a soma de verificação do arquivo se o ctime não tiver sido modificado.
  2. Artefatos de link simbólico não resolvidos. Eles são verificados quanto à atualização chamando readlink(). Ao contrário dos artefatos normais, eles podem conter links simbólicos. Geralmente usado nos casos em que um deles empacota alguns arquivos em um arquivo de algum tipo.
  3. Artefatos de árvore. Não são arquivos únicos, mas árvores de diretórios. Eles são verificados quanto à atualização, verificando o conjunto de arquivos nele e seus conteúdos. Eles são representados como TreeArtifact.
  4. Artefatos de metadados constantes. As mudanças nesses artefatos não acionam uma recriação. Ele é usado exclusivamente para informações de carimbo de build: não queremos recriar apenas porque o horário atual mudou.

Não há motivo fundamental para que os artefatos de origem não sejam artefatos de árvore ou artefatos de link simbólico não resolvidos, mas ainda não o implementamos. No entanto, fazer referência a um diretório de origem em um arquivo BUILD é um dos poucos problemas de incorreção conhecidos de longa data com o Bazel. Temos uma implementação desse tipo de trabalho, que é ativada pela propriedade BAZEL_TRACK_SOURCE_DIRECTORIES=1 da JVM.

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. Eles são usados para casos especiais de algumas coisas:

  • A agregação de intermediários é usada para agrupar artefatos. Isso significa que, se muitas ações usarem o mesmo grande conjunto de entradas, não teremos N*M bordas de dependência, somente 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. Eles são usados principalmente para inspeção, mas também para compilação em C++. Consulte CcCompilationContext.createMiddleman() para ver 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.

Ações são mais entendidas como um comando que precisa ser executado, o ambiente de que precisa e o conjunto de saídas que produz. Estes 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 configuradas
  • Anotações que descrevem o ambiente (como a plataforma) que ele precisa executar em \

Há também alguns outros casos especiais, como gravar um arquivo com conteúdo conhecido pelo Bazel. Elas são uma subclasse da AbstractAction. A maioria das ações é uma SpawnAction ou uma StarlarkAction (as mesmas, mas não podem 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 é bem próximo, mas o C++ é um caso especial devido à análise de arquivos .d e inclui a verificação.

Em sua maioria, o gráfico de ações é "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ções para uma borda de dependência do Skyframe é descrito em ActionExecutionFunction.getInputDeps() e Artifact.key() e tem algumas otimizações para manter o número de bordas de Skyframe baixo:

  • Os artefatos derivados não têm os próprios SkyValues. Em vez disso, Artifact.getGeneratingActionKey() é usado para descobrir a chave da ação que a gera.
  • Os conjuntos aninhados têm a própria chave do Skyframe.

Ações compartilhadas

Algumas ações são geradas por vários destinos configurados. As regras do Starlark são mais limitadas porque só permitem colocar as ações derivadas em um diretório determinado pela configuração e pelo pacote (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 recurso incorreto, mas se livrar dele é muito difícil, porque produz economia significativa no tempo de execução quando, por exemplo, um arquivo de origem precisa ser processado de alguma forma e esse arquivo é referenciado por várias regras (handwave-handwave). Isso tem o custo de alguma 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 precisarão 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 únicos locais no Bazel que exigem uma visualização "global" do build.

Fase de execução

É quando o Bazel começa a executar ações de build, como comandos que produzem saídas.

A primeira coisa que o Bazel faz após a fase de análise é determinar quais artefatos precisam ser criados. A lógica para isso é codificada em TopLevelArtifactHelper. Em termos gerais, é o filesToBuild dos destinos configurados na linha de comando e o conteúdo de um grupo de saída especializado para expressar explicitamente "se esse destino estiver na linha de comando, criar 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 todos os destinos usados na fase de análise e criando uma única árvore de diretórios que cria um link simbólico para cada pacote com um destino usado do local real. Uma alternativa seria transmitir os caminhos corretos para os comandos, considerando --package_path. Isso não é o ideal porque:

  • Ele muda as linhas de comando de ação quando um pacote é movido de uma entrada de caminho para outra, que era uma ocorrência comum.
  • Se uma ação for executada remotamente, o resultado será linhas de comando diferentes do que a executada localmente.
  • Ela exige uma transformação de linha de comando específica para a ferramenta em uso. Considere a diferença entre caminhos de classe do Java e caminhos de inclusão do C++.
  • Alterar a linha de comando de uma ação invalida a entrada no cache de ação
  • O uso de --package_path está sendo descontinuado e constante

Em seguida, ele começa a percorrer o gráfico de ações (o grafo direcionado e bipartido composto de ações e os respectivos artefatos de entrada e saída) e executando 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 tornar as reinicializações do Skyframe de ActionExecutionFunction baratas.
  • O cache de ações locais contém dados sobre o estado do sistema de arquivos
  • Em geral, os sistemas de execução remota também tê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 uma ocorrência no cache de ações locais. Ele representa o estado do sistema de arquivos local e é serializado em disco, o que significa que, quando um servidor do Bazel é iniciado, é possível receber ocorrências em cache de ação local, mesmo que o gráfico do Skyframe esteja vazio.

Esse cache é verificado quanto a hits usando o método ActionCacheChecker.getTokenIfNeedToExecute() .

Ao contrário do nome, ele é um mapa que vai do caminho de um artefato derivado até a ação que o emitiu. A ação é descrita como:

  1. O conjunto de arquivos de entrada e saída e a soma de verificação 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 o número de acessos ao cache muitas 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 têm duas formas:

  • Uma ação pode descobrir novas entradas antes de ser executada ou decidir que algumas delas não são realmente necessárias. O exemplo canônico é o C++, em que é melhor prever quais arquivos de cabeçalho um arquivo C++ usa no fechamento transitivo, para não enviar todos os executores remotos. Portanto, temos a opção de não registrar cada arquivo de cabeçalho como uma "entrada", mas verificar o arquivo de origem em busca de cabeçalhos transitivamente de cabeçalhos e marcar instruções "completo" em #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 principais foram usados após o fato e, para evitar o constrangimento de ter uma incrementabilidade pior do que Make, o Bazel usa esse fato. Isso oferece uma estimativa melhor do que o scanner de inclusão, porque depende do compilador.

Elas são implementadas usando métodos na ação:

  1. Action.discoverInputs() for chamado. Ela precisa 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.
  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 eram necessárias. Isso poderá resultar em builds incrementais incorretos se uma entrada usada for informada como não utilizada.

Quando um cache de ação retorna um hit em uma nova instância de ação (por exemplo, criada após uma reinicialização do servidor), o Bazel chama updateInputs() para que o conjunto de entradas reflita o resultado da descoberta de entrada e da remoção feitas anteriormente.

As ações do Starlark podem usar a funcionalidade 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, no local, mas em vários tipos de sandboxes, ou remotamente. O conceito que incorpora isso é chamado de ActionContext (ou Strategy, já que só fizemos uma mudança na metade do caminho).

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 do 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 qual interface o contexto da ação precisa implementar.
  2. O contexto de ação apropriado é selecionado entre os disponíveis e é encaminhado para ActionExecutionContext e BlazeExecutor .
  3. As ações solicitam contextos usando ActionExecutionContext.getContext() e BlazeExecutor.getStrategy(). Na verdade, deve haver apenas uma maneira de fazer isso...

As estratégias são livres para chamar outras estratégias no seu trabalho. Isso é usado, por exemplo, na estratégia dinâmica que inicia ações local e remotamente e depois usa a que terminar primeiro.

Uma estratégia notável é a que implementa processos de worker persistentes (WorkerSpawnStrategy). A ideia é que algumas ferramentas têm um longo tempo de inicialização e, portanto, precisam ser reutilizadas entre as ações, em vez de iniciar uma nova para cada ação. Isso representa um possível problema de correção, já que o Bazel depende da promessa do processo de worker de que ele não carrega 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 usando WorkerFilesHash. É 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 da 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 neste link.
  • Informações sobre as complexidades da execução de ações localmente estão disponíveis aqui.

O gerente de recursos local

Ele pode executar muitas ações em paralelo. O número de ações locais que precisam ser executadas em paralelo difere de uma ação para outra: quanto mais recursos uma ação exigir, menos instâncias vã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). 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 local separado no diretório de saída, onde ela coloca as saídas. A localização dos artefatos derivados geralmente é a 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 desejáveis conflitantes:

  1. 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ções").
  2. Se duas configurações representarem "mais ou menos" a mesma coisa, elas precisarão ter o mesmo nome para que as ações executadas em uma possam ser reutilizadas para a outra se as linhas de comando corresponderem. Por exemplo, mudanças nas opções de linha de comando no compilador Java não devem resultar na nova execução de ações de compilação em C++.

Até agora, não criamos uma maneira baseada em princípios de resolver esse problema, que tem semelhanças com o corte da configuração. Uma discussão mais longa sobre as opções está disponível neste link. As principais áreas problemáticas são as regras de Starlark (cujos autores geralmente não têm familiaridade com o Bazel) e aspectos, que adicionam outra dimensão ao espaço de itens que podem produzir o "mesmo" arquivo de saída.

A abordagem atual é que o segmento de caminho da 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 possam causar conflitos de ação. 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 suporte avançado à execução de testes. Ela aceita estas opções:

  • Executar testes remotamente (se um back-end de execução remota estiver disponível)
  • Executar testes várias vezes em paralelo (para desflar ou coletar dados de tempo).
  • Fragmentar testes (dividir casos de teste no mesmo teste em vários processos para aumentar a velocidade)
  • Como executar novamente testes instáveis
  • Como agrupar testes em conjuntos de testes

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

  • Os artefatos que resultam 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 será executado
  • O número de fragmentos em que o teste deve ser dividido
  • Alguns parâmetros de execução do teste (como o tempo limite do teste)

Como determinar quais testes serão executados

Determinar quais testes são executados é um processo elaborado.

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 pacote de testes não declara nenhum teste, ele se refere a todos os testes no pacote. Isso é implementado no 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.

Em seguida, isso é processado em BuildView.createResult(): destinos com análise que falharam são filtrados, e os testes são divididos em exclusivos e não exclusivos. Em seguida, ele é colocado em AnalysisResult, que é como o ExecutionTool sabe quais testes executar.

Para dar mais transparência a esse processo elaborado, o operador de consulta tests() (implementado em TestsFunction) está disponível para informar quais testes são executados quando um destino específico é especificado na linha de comando. Infelizmente, é uma reimplementação, então ela provavelmente se desvia do acima de várias maneiras sutis.

Executar testes

Para executar os testes, solicite artefatos de status de cache. Isso resulta na execução de um TestRunnerAction, que por fim 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 dizer aos testes o que é esperado deles. Uma descrição detalhada do que o Bazel espera dos testes e o que pode esperar do Bazel está disponível aqui (link em inglês). Basicamente, 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 que detalha 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 do que eles imprimem no terminal.

Existem duas coisas que podem acontecer durante a execução do teste que não podem durante a criação de destinos regulares: a execução de teste exclusivo e o streaming de saída.

Alguns testes precisam ser executados no modo exclusivo, por exemplo, não em paralelo com outros. Isso pode ser conseguido 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 é despejada quando a ação termina, o usuário pode solicitar que a saída de testes seja transmitida para informar sobre o progresso de um teste de longa duração. Isso é especificado pela opção de linha de comando --test_output=streamed e implica execução de teste exclusiva para que as saídas de testes diferentes não sejam intercaladas.

Isso é implementado na classe StreamedTestOutput, devidamente nomeada e funciona pesquisando alterações no arquivo test.log do teste em questão e despejando novos bytes no terminal em que o Bazel regras.

Os resultados dos testes executados são disponibilizados no barramento de eventos pela observação de vários eventos (como TestAttempt, TestResult ou TestingCompleteEvent). Eles são descartados no Build Event Protocol e são 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 a cobertura, cada execução de teste é encapsulada em um script chamado collect_coverage.sh .

Este script configura o ambiente do teste para ativar a coleta de cobertura e determinar onde os arquivos de cobertura são gravados pelos ambientes de execução de cobertura. Em seguida, ele executa o teste. Um teste pode executar vários subprocessos e consistir em partes escritas em várias linguagens de programação diferentes (com ambientes de execução de coleta de cobertura separados). O script 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 requer que collect_coverage.sh esteja nas entradas do teste. Isso é feito pelo atributo implícito :coverage_support, que é resolvido como o valor da sinalização de configuração --coverage_support (consulte TestConfiguration.TestOptions.coverageSupport).

Algumas linguagens executam instrumentação off-line, o que significa que a instrumentação de cobertura é adicionada no tempo de compilação (como C++) e outras fazem a instrumentação on-line, o que significa que a instrumentação de cobertura é adicionada no momento da execução.

Outro conceito central é a cobertura 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 do teste de um binário, não basta mesclar a cobertura de todos os testes, porque pode haver código no binário que não está vinculado a nenhum teste. Portanto, 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 um destino está em bazel-testlogs/$PACKAGE/$TARGET/baseline_coverage.dat . Ela também é gerada para binários e bibliotecas, além de testes, quando você transmite a flag --nobuild_tests_only para o Bazel.

A cobertura do valor de referência está corrompida no momento.

Rastreamos dois grupos de arquivos para coletar a 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. Para tempos de execução de cobertura on-line, isso pode ser usado durante a execução para decidir quais arquivos instrumentar. Ele também é usado para implementar a cobertura de 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 exigidos pelo Bazel. Na prática, isso consiste em arquivos específicos do ambiente de execução. Por exemplo, gcc emite arquivos .gcno durante a compilação. Elas são adicionadas ao conjunto de entradas de ações de teste se o modo de cobertura estiver ativado.

Se a cobertura está ou não sendo coletada é 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 o C++, exigem diferentes opções de compilador para emitir código que pode coletar cobertura, o que atenua esse problema, já que uma nova análise é necessária de qualquer maneira.

Os arquivos de suporte de cobertura dependem dos identificadores em uma dependência implícita para que possam ser substituídos pela política de invocação, o que permite que eles sejam diferentes entre as diferentes versões do Bazel. O ideal seria que essas diferenças fossem removidas, e nós 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 pelo CoverageReportActionFactory e chamado em BuildView.createResult() . Ela tem acesso às ferramentas necessárias ao observar o atributo :coverage_report_generator do primeiro teste executado.

O mecanismo de consulta

O Bazel tem uma pouca linguagem usada para fazer várias perguntas sobre diversos gráficos. Os seguintes tipos de consulta são fornecidos:

  • bazel query é usado para investigar o gráfico de destino
  • bazel cquery é usado para investigar o gráfico de destino configurado
  • bazel aquery é usado para investigar o gráfico de ações

Cada uma delas é implementada pela subclassificação AbstractBlazeQueryEnvironment. Outras funções de consulta podem ser feitas com a subclassificação QueryFunction. Para permitir o streaming dos resultados da consulta, em vez de coletá-los em alguma estrutura de dados, um query2.engine.Callback é transmitido para QueryFunction, que o chama de resultados a serem retornados.

O resultado de uma consulta pode ser emitido de várias maneiras: rótulos, rótulos e classes de regras, XML, protobuf e assim por diante. Elas são implementadas como subclasses de OutputFormatter.

Um requisito sutil de alguns formatos de saída de consulta (proto, definitivamente) é que o Bazel precisa emitir _todas_as informações que o carregamento do pacote fornece para que seja possível diferenciar a saída e determinar se um destino específico mudou. Como consequência, os valores dos atributos precisam ser serializáveis. É por isso que existem tão poucos tipos de atributo sem que nenhum deles tenha valores complexos de Starlark. A solução alternativa mais comum é 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 eliminar esse requisito.

O sistema de módulos

Ele pode ser estendido adicionando módulos a ele. Cada módulo precisa subclassificar BlazeModule (o nome é uma relíquia da história do Bazel quando ele era chamado de Blaze) e recebe informações sobre vários eventos durante a execução de um comando.

Eles são usados principalmente para implementar várias partes de funcionalidades "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 o BlazeModule oferece é um pouco impreciso. Não use isso como um exemplo de bons princípios de design.

Ônibus do evento

A principal maneira de os BlazeModules se comunicarem com o restante do Bazel é por um barramento de eventos (EventBus): uma nova instância é criada para cada build, várias partes do Bazel podem postar eventos nela, e os módulos podem registrar listeners para os eventos em que estão interessados. Por exemplo, os itens a seguir 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 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 não apenas BlazeModules, mas também coisas fora do processo do Bazel para observar o build. Eles podem ser acessados como um arquivo que contém mensagens de protocolo ou o Bazel pode se conectar a um servidor (chamado de serviço de evento de build) 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 é necessário para criar), ele vive em um mundo em que isso não é necessariamente verdade. Os "repositórios externos" são uma abstração usada para ligar esses dois mundos: eles representam o código necessário para o build, mas 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")

Resultados no repositório chamado @foo. Isso se torna complicado: é possível definir novas regras de repositório em arquivos do Starlark, que podem ser usadas para carregar um novo código do 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 a computação WorkspaceFileFunction até que o índice X significa avaliá-lo até a instrução load() X.

Como buscar repositórios

Antes que o código do repositório fique disponível para o Bazel, ele precisa ser fetched. 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. O PackageLookupFunction percebe que precisa de um repositório e cria um RepositoryName como um SkyKey, que invoca RepositoryLoaderFunction
  2. RepositoryLoaderFunction encaminha a solicitação para RepositoryDelegatorFunction por motivos pouco claros (o código diz que é para evitar um novo download de itens em caso de reinicializações do Skyframe, mas não é um motivo muito sólido)
  3. RepositoryDelegatorFunction descobre a regra de repositório que precisa buscar iterando os blocos do arquivo do ESPAÇO DE TRABALHO até que o repositório solicitado seja encontrado.
  4. O RepositoryFunction apropriado que implementa a busca do repositório é encontrado. 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 a busca de um repositório pode ser muito cara:

  1. Há um cache para arquivos transferidos por download que é codificado pela soma de verificação (RepositoryCache). Isso exige que a soma esteja disponível no arquivo do ESPAÇO DE TRABALHO, mas isso é bom para a hermética. Isso é compartilhado por cada instância de servidor do Bazel na mesma estação de trabalho, independentemente do espaço de trabalho ou da base de saída em que elas estejam sendo executadas.
  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 for alterada, a busca não será feita novamente. Isso é implementado em RepositoryDelegatorFunction.DigestWriter .
  3. A opção de linha de comando --distdir designa outro cache usado para procurar artefatos para o download. Isso é útil em configurações corporativas em que o Bazel não precisa buscar itens aleatórios na Internet. Isso é implementado por DownloadManager .

Após o download de um repositório, os artefatos dele são tratados como artefatos de origem. Isso é 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 se encontram muda. Assim, FileStateValues para um artefato em um repositório externo precisam depender do repositório externo. Isso é processado pelo ExternalFilesHelper.

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 do Guava, eles provavelmente se referirão ao Guava com rótulos começando com @guava// e esperarão que isso signifique versões diferentes dele.

Portanto, 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 outro repositório Guava (como @guava2//) o repositório do outro.

Isso também pode ser usado para join diamonds. Se um repositório depender de @guava1// e outro depender de @guava2//, o mapeamento vai permitir que um remapeie os dois repositórios para usar um repositório @guava// canônico.

O mapeamento é especificado no arquivo ESPAÇO DE TRABALHO como o atributo repo_mapping de definições de repositório individuais. Em seguida, ela aparece no Skyframe como membro do WorkspaceFileValue, onde é mesclada para:

  • Package.Builder.repositoryMapping, que é usado para transformar os atributos com valor de rótulo das regras no pacote por RuleClass.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 poderia fazer sozinho quando o implementamos. Isso se limita principalmente à interação com o sistema de arquivos, controle de processos e vários outros itens 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

Emissão de saída do console parece algo simples, mas a confluência da execução de vários processos (às vezes remotamente), o armazenamento em cache refinado, o desejo de ter uma saída de terminal agradável e colorida e ter um servidor de longa duração torna isso difícil.

Logo após a chamada de RPC chegar do cliente, são criadas duas instâncias RpcOutputStream (para stdout e stderr) que encaminham os dados impressos nelas para o cliente. Em seguida, eles são encapsulados em um OutErr (um par (stdout, stderr). Tudo o que precisa ser impresso no console passa por esses fluxos. Em seguida, esses streams são entregues para BlazeCommandDispatcher.execExclusively().

Por padrão, a saída é impressa com sequências de escape ANSI. Quando eles não são necessários (--color=no), eles são removidos por um AnsiStrippingOutputStream. Além disso, System.out e System.err são redirecionados para esses fluxos de saída. Dessa forma, as informações de depuração podem ser impressas usando System.err.println() e ainda chegam na saída do terminal do cliente, que é diferente da saída do servidor. Se um processo produzir saída binária (como bazel query --output=proto), é necessário ter cuidado para que não seja feita a comunicação de stdout.

Mensagens curtas (erros, avisos e similares) são expressas pela interface EventHandler. Esses elementos são diferentes do que é publicado no EventBus, o que é confuso. Cada Event tem um EventKind (erro, aviso, informações e alguns outros) e pode ter Location (o local no código-fonte que causou o evento).

Algumas implementações de EventHandler armazenam os eventos que recebem. Isso é usado para reproduzir informações na IU 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 a postagem de eventos que acabam chegando ao barramento de eventos (Events normais _not _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 que é publicado em EventBus necessariamente implementa essa interface. Apenas os eventos armazenados em cache por um ExtendedEventHandler são úteis, e a maioria das coisas funciona, mas não é aplicada.

A saída do terminal é principalmente emitida pelo UiEventHandler, que é responsável por toda a formatação de saída e relatórios de progresso que o Bazel faz. Ela tem duas entradas:

  • Ônibus do evento
  • O fluxo de eventos é direcionado para 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 stream de RPC para o cliente é por meio de Reporter.getOutErr(), que permite acesso direto a esses streams. Ele só é usado quando um comando precisa despejar grandes quantidades de possíveis dados binários (como bazel query).

Como criar perfis no Bazel

O Bazel é rápido. O Bazel também é lento porque os builds tendem a crescer até a borda do que é aceitável. Por esse motivo, o Bazel inclui um criador de perfil que pode ser usado para criar perfis de builds e do próprio Bazel. Ele é implementado em uma classe adequadamente chamada Profiler. Ele é ativado por padrão, mas registra 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.

Ela emite um perfil no formato do Chrome Profiler. A visualização dele é melhor no Chrome. O modelo de dados é o de pilhas de tarefas: é possível iniciar tarefas e tarefas finais, e elas precisam estar perfeitamente aninhadas entre si. 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(). Ele retorna um Closeable, cujo fechamento representa o final da tarefa. Ele é melhor usado com declarações try-with-resources.

Também fazemos a criação de perfil rudimentar de memória no MemoryProfiler. Ele também está sempre ativado e registra principalmente tamanhos máximos de heap e comportamento de GC.

Como testar o Bazel

Ele tem dois tipos principais de testes: os que o observam como uma "caixa preta" e os que só executam a fase de análise. Chamamos os primeiros de "testes de integração" e os últimos de "testes de unidade", embora sejam mais como testes de integração, bem, menos integrados. Também realizamos alguns testes de unidade reais em que eles são necessários.

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

  1. Aqueles implementados usando um framework de teste bash muito elaborado em src/test/shell
  2. Aques implementados em Java. Elas são implementadas 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 total 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. Existe 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, mudar a configuração e declarar várias coisas sobre o resultado da análise.