Base de código do Bazel

Informar um problema Mostrar fonte Por noite · 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. Ela destina-se a pessoas dispostas a contribuir com o Bazel, não a usuários finais.

Introdução

A base de código do Bazel é grande (cerca de 350 KLOC de código de produção e 260 KLOC de teste). e ninguém está familiarizado com o cenário inteiro: todos sabem vale muito bem, mas poucos sabem o que há sobre as colinas em cada direção

Para que as pessoas no meio da jornada não se encontrem em um floresta escurecida com o caminho simples sendo perdido, este documento tenta fornecer uma visão geral da base de código para que seja mais fácil começar a usar trabalhando nisso.

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

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

Arquitetura de cliente/servidor

A maior parte do Bazel reside em um processo de servidor que permanece na RAM entre os builds. Isso permite que o Bazel mantenha o estado entre builds.

É por isso que a linha de comando do Bazel tem dois tipos de opções: inicialização e kubectl. 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 depois de (-c opt). o tipo anterior é chamado de "opção de inicialização" e afeta o processo do servidor como um todo, enquanto o último tipo, o "comando afeta apenas um comando.

Cada instância de servidor tem uma única árvore de origem associada ("espaço de trabalho") e cada o espaço de trabalho geralmente tem uma única instância ativa do servidor. Isso pode ser contornado especificando uma base de saída personalizada. Consulte a seção "Layout do diretório" para 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 foi implementado em C++ (o "cliente") tem o controle. Ele configura um processo de servidor apropriado usando o seguintes etapas:

  1. Verifica se ele já foi extraído. Se não, ele faz isso. Isso é de onde vem a implementação do servidor.
  2. Verifica se há uma instância ativa do servidor que funciona (em execução, tem as opções de inicialização corretas e usa o diretório do espaço de trabalho correto. Ela encontra o servidor em execução conferindo o diretório $OUTPUT_BASE/server em que há um arquivo de bloqueio com a porta em que o servidor está escutando.
  3. Se necessário, encerra o processo do servidor antigo
  4. Se necessário, inicia um novo processo de servidor

Depois que um processo de servidor adequado estiver pronto, o comando que precisa ser executado será comunicada a ele por uma interface gRPC, a saída do Bazel é enviada de volta ao terminal. Apenas um comando pode estar em execução ao mesmo tempo. Isso é implementado usando um mecanismo de bloqueio elaborado com peças em C++ e peças Java. Há uma infraestrutura para executar vários comandos em paralelo, já que a incapacidade de executar bazel version em paralelo com outro comando é um pouco constrangedor. O principal obstáculo é o ciclo de vida de BlazeModules e algum estado em BlazeRuntime.

Ao final de um comando, o servidor do Bazel transmite o código de saída que o cliente deve retornar. Uma diferença interessante é a implementação de bazel run: o trabalho desse comando é executar algo que o Bazel acabou de criar, mas isso não é possível. do processo do servidor porque ele não tem um terminal. Em vez disso, ele informa ao cliente qual binário deve ujexec() e com quais argumentos.

Quando alguém pressiona Ctrl-C, o cliente a converte em uma chamada Cancelar no 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 para se comunicar com o servidor é em src/main/protobuf/command_server.proto .

O ponto de entrada principal do servidor é BlazeRuntime.main(), e as chamadas gRPC do cliente são processados por GrpcServerImpl.run().

Layout do diretório

O Bazel cria um conjunto de diretórios um pouco complicado durante um build. Um está disponível no Layout do diretório de saída.

O "espaço de trabalho" é a árvore de origem em que o Bazel é executado. Geralmente, corresponde a algo que você conferiu no controle de origem.

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

A "base instalada" é para onde o Bazel é extraído. Isso é feito automaticamente e cada versão do Bazel recebe um subdiretório com base na soma de verificação dele na base instalada. Ele é $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 em que a instância do Bazel é anexada a uma instância espaço de trabalho grava. Cada base de saída tem no máximo uma instância do servidor Bazel em execução a qualquer momento. Geralmente é às $OUTPUT_USER_ROOT/<checksum of the path to the workspace>. Ele pode ser mudado usando a opção de inicialização --output_base. o que é, entre outras coisas, útil para contornar a limitação que só 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 todos os o código do build atual. Ele fica neste endereço: $OUTPUT_BASE/execroot. Durante o build, o diretório de trabalho será $EXECROOT/<name of main repository>. Planejamos mudar isso para $EXECROOT, embora seja uma de um plano de longo prazo, porque é uma mudança incompatível.
  • Arquivos criados durante a criação.

Processo de execução de um comando

Depois que o servidor do Bazel recebe o controle e é informado sobre um comando, precisa executada, acontece 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 para aqueles que não têm nada a ver com 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 precisam ter a anotação @Command, que é um pouco seria bom se todos os metadados necessários para um comando fossem descrito por métodos em BlazeCommand)

  3. As opções da linha de comando são analisadas. Cada comando tem uma linha de comando diferente, , que são descritas na anotação @Command.

  4. Um barramento de eventos é criado. O barramento de eventos é um fluxo de eventos durante o build. Algumas delas são exportadas para fora do Bazel sob o comando o protocolo de eventos de build para informar ao mundo como o build vai

  5. O comando recebe o controle. Os comandos mais interessantes são os que executam uma build: criar, testar, executar, cobertura e assim por diante: essa funcionalidade é implementado por BuildTool.

  6. O conjunto de padrões de segmentação na linha de comando é analisado e caracteres curinga, como //pkg:all e //pkg/... foram resolvidos. Isso é implementado AnalysisPhaseRunner.evaluateTargetPatterns() e reificado no Skyframe como TargetPatternPhaseValue.

  7. A fase de carregamento/análise é executada para produzir o gráfico de ações (um método gráfico acíclico 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 criar os destinos de nível superior solicitados são executados.

Opções de linha de comando

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

  1. Opções relacionadas a uma linguagem de programação (CppOptions ou JavaOptions). Elas precisam ser uma subclasse da FragmentOptions e são encapsuladas 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 (seja usando RuleContext.getFragment() em Java ou ctx.fragments em Starlark). Alguns deles (por exemplo, se é necessário fazer verificação com C++ ou não) são lidos na fase de execução, mas isso sempre exige explicação explícita, BuildConfiguration não está disponível então. 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á-los dessa maneira (como parte de SkyKeys). Esse não é o caso e modificá-las é uma boa maneira de quebrar o Bazel de maneiras sutis e difíceis para depurar. Infelizmente, torná-los imutáveis é um grande esforço. Modificar um FragmentOptions imediatamente após a construção antes de qualquer outra pessoa tenha a chance de manter uma referência a ele e antes que equals() ou hashCode() sejam chamado, não tem problema.

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

  1. Algumas estão conectadas 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 (consulte aqui)

Cada opção (excluindo as opções definidas por Starlark) é uma variável membro de Subclasse FragmentOptions que tem a anotação @Option, que especifica o nome e o tipo da opção de linha de comando, além de um texto de ajuda.

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

A árvore de origem, conforme visto pelo Bazel

O Bazel cria software, o que acontece ao ler e a interpretação do código-fonte. A totalidade do código-fonte em que o Bazel opera é chamado de "espaço de trabalho", e é estruturado em repositórios, pacotes e regras de firewall.

Repositórios

Um "repositório" é uma árvore de origem na qual um desenvolvedor trabalha; geralmente representa um único projeto. O ancestral do Bazel, o Blaze, operava em um monorepo, Ou seja, uma única árvore de origem que contém todo o código-fonte usado para executar o build. O Bazel, por outro lado, dá suporte a projetos com código-fonte que abrange vários repositórios. O repositório de onde o Bazel é invocado é chamado de repositório", os outros são chamados de "repositórios externos".

Um repositório é marcado por um arquivo chamado WORKSPACE (ou WORKSPACE.bazel) em diretório raiz. Esse arquivo contém informações que são "globais" ao todo build, por exemplo, o conjunto de repositórios externos disponíveis. Funciona como um arquivo Starlark normal, o que significa que é possível load() outros arquivos Starlark. Isso é comumente usado para extrair repositórios necessários para um repositório explicitamente referenciado (chamamos isso de "padrão deps.bzl")

O código de repositórios externos tem um link simbólico ou é baixado em $OUTPUT_BASE/external:

Ao executar o build, toda a árvore de origem precisa ser reunida. este é feito por SymlinkForest, que vincula simbolicamente todos os pacotes no repositório principal. para $EXECROOT e cada repositório externo para $EXECROOT/external ou $EXECROOT/.. (o primeiro, é claro, impossibilita ter um pacote chamado external no repositório principal. é por isso que estamos migrando ela)

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 por que arquivos BUILD ainda são aceitos é que o ancestral do Bazel, o Blaze, usava esse nome do arquivo. No entanto, ele acabou sendo um segmento de caminho usado com frequência, especialmente no Windows, que 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 alterações em outros pacotes. Adição ou remoção de arquivos BUILD _can _alterar outros pacotes, já que globs recursivos param nos limites de pacotes e, portanto, a presença de um arquivo BUILD interrompe a recursão.

A avaliação de um arquivo BUILD é chamada de "carregamento de pacote". Está implementado na classe PackageFactory, chama o intérprete de Starlark e requer conhecimento do conjunto de classes de regras disponíveis. O resultado do pacote carregando é um objeto Package. É basicamente um mapa de uma string (o nome de um destino) ao próprio destino.

Uma grande parte da complexidade durante o carregamento do pacote é globular: o Bazel não tem exigem que cada arquivo de origem seja listado explicitamente e, em vez disso, pode executar globs (como glob(["**/*.java"])). Ao contrário do shell, ele aceita globs recursivos que vão para subdiretórios, mas não para subdiretórios. Isso requer acesso a no sistema de arquivos e, como isso pode demorar, implementamos todos os tipos de truques para executá-lo em paralelo e com a maior eficiência possível.

O globbing é implementado nas seguintes classes:

  • LegacyGlobber, um planeta rápido e alegre que não reconhece o Skyframe
  • SkyframeHybridGlobber, uma versão que usa o Skyframe e é revertida para no globber legado para evitar "reinicializações do Skyframe" (descrito abaixo)

A própria classe Package contém alguns membros que são usados exclusivamente para analisar o arquivo WORKSPACE e quais não fazem sentido para pacotes reais. Isso é uma falha de design porque os objetos que descrevem pacotes regulares não podem conter campos que descrevem outra coisa. São eles:

  • Os mapeamentos do repositório
  • Os conjuntos de ferramentas registrados
  • As plataformas de execução registradas

O ideal é que haja uma separação maior entre a análise do arquivo WORKSPACE analisar pacotes regulares para que Packagenão precise suprir as necessidades de ambos. Infelizmente, isso é difícil de fazer porque os dois estão interligados profundamente.

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. Em No linguajar do Bazel, eles são chamados de artefatos (discutidos em outro lugar). Nem todas os arquivos criados durante o build são destinos. é comum que uma saída de O Bazel não precisa ter um rótulo associado.
  2. Regras:descrevem as etapas para derivar as saídas das entradas. Eles geralmente estão associados a uma linguagem de programação (como cc_library, java_library ou py_library), mas há alguns 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 rótulos é @repo//pac/kage:name, em que repo é o nome do repositório do rótulo pac/kage é o diretório em que o arquivo BUILD está e name é o caminho do o arquivo (se o rótulo se referir a um arquivo de origem) relativo ao diretório do . 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á considerado na pasta principal repositório de dados.
  2. Se a parte do pacote for omitida (como name ou :name), o rótulo será usado para estar no pacote do diretório de trabalho atual (caminhos relativos que contenham referências de nível superior (..) não são permitidas)

Um tipo de regra (como "biblioteca C++") é chamado de "classe de regras". As classes de regras podem ser implementados em Starlark (a função rule()) ou em Java (assim chamado "native rules", digite RuleClass). A longo prazo, cada linguagem específica será implementada no Starlark, mas algumas famílias de regras legadas (como ou C++) ainda estão em Java por enquanto.

As classes de regra do Starlark precisam ser importadas no início dos arquivos BUILD usando a instrução load(), enquanto as classes de regra Java são "intencionais" conhecido por Bazel, em virtude de estar registrado com ConfiguredRuleClassProvider.

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

  1. Os atributos (como srcs, deps): tipos, valores padrão, restrições etc.
  2. As transições e os 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 "geralmente" cria

Observação de terminologia:na base de código, geralmente usamos "Regra" significar o alvo criados por uma classe de regra. Mas no Starlark e na documentação voltada ao usuário, "Regra" deve ser usado exclusivamente para se referir à própria classe de regras; a meta é apenas um "alvo". Observe também que, apesar de RuleClass ter "class" na nome, não há relacionamento de herança Java entre uma classe de regra e os destinos desse tipo.

Skyframe

O framework de avaliação subjacente ao Bazel é chamado de Skyframe. Seu modelo é que tudo o que precisa ser construído durante um build é organizado em uma gráfico acíclico com bordas apontando de quaisquer dados para suas 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 seus nomes são chamados SkyKey. Ambos são profundamente imutáveis; apenas objetos imutáveis devem ser que estão acessíveis para eles. Essa invariante quase sempre se aplica e, caso não (como nas classes de opções individuais BuildOptions, que é membro da BuildConfigurationValue e a SkyKey dele), fazemos o possível para não mudar ou para mudá-los somente de maneiras que não são observáveis de fora. A partir disso, tudo que é calculado no Skyframe (como destinos configurados) também precisam ser imutáveis.

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 em builds muito pequenos, já que podem ficar muito grandes.

O Skyframe fica no pacote com.google.devtools.build.skyframe. O o pacote com.google.devtools.build.lib.skyframe com nome semelhante contém implementação do Bazel sobre o Skyframe. Mais informações sobre o Skyframe estão disponíveis aqui.

Para avaliar um determinado SkyKey em um SkyValue, o Skyframe invoca o SkyFunction correspondente ao tipo da chave. Durante o ciclo de vida ele poderá solicitar outras dependências do Skyframe chamando o várias sobrecargas de SkyFunction.Environment.getValue(). Ele tem efeito colateral de registrar essas dependências no gráfico interno do Skyframe, de modo que que o Skyframe saiba que precisa reavaliar a função quando qualquer uma das dependências mudar. Em outras palavras, o armazenamento em cache e a computação incremental do Skyframe funcionam a granularidade de SkyFunctions e SkyValues.

Sempre que um SkyFunction solicita uma dependência indisponível, getValue() retornará nulo. A função deve então retornar o controle ao Skyframe retornando nulo. Em algum momento posterior, o Skyframe avaliará indisponível, reinicie a função desde o início — somente esse momento em que a chamada getValue() será bem-sucedida com um resultado não nulo.

Uma consequência disso é que qualquer computação realizada dentro do SkyFunction antes da reinicialização precisam ser repetidos. Mas isso não inclui o trabalho feito para avaliar a dependência SkyValues, que são armazenadas em cache. Por isso, normalmente trabalhamos em torno desse problema ao:

  1. Declarando dependências em lotes (usando getValuesAndExceptions()) limitar o número de reinicializações.
  2. Dividir um SkyValue em partes separadas, calculadas por diferentes SkyFunctions, para que possam ser calculados e armazenados em cache de forma independente. Isso deve ser feito estrategicamente, pois tem o potencial de aumentar a memória uso.
  3. Armazenamento do estado entre reinicializações, seja usando SkyFunction.Environment.getState() ou manter um cache estático ad hoc "atrás do Skyframe".

Essencialmente, precisamos desses tipos de soluções alternativas porque temos centenas de milhares de nós Skyframe em trânsito, e o Java não oferece suporte e encadeamentos leves.

Lark

Starlark é a linguagem específica de domínio que as pessoas usam para configurar e ampliar o Bazel Ela é concebida como um subconjunto restrito do Python que tem muito menos tipos, mais restrições no fluxo de controle e, o mais importante, forte imutabilidade para permitir leituras simultâneas. Ele não está completo de Turing, desencorajamos alguns (mas não todos) os usuários de tentar realizar tarefas de programação dentro da linguagem.

O Starlark é implementado no pacote net.starlark.java. Ele também tem uma implementação independente do Go aqui. A linguagem 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. Código Starlark em execução neste contexto só tem acesso ao conteúdo do arquivo BUILD e os arquivos .bzl carregados por ele.
  2. Definições de regras. É assim que as novas regras (como o suporte a um novo linguagem) estão definidos. O código Starlark em execução nesse contexto tem acesso a a configuração e os dados fornecidos por suas dependências diretas (mais informações mais tarde).
  3. O arquivo WORKSPACE. É aqui que os repositórios externos (códigos na árvore de origem principal) estão definidos.
  4. Definições de regras de repositório. É aqui que novos tipos de repositórios externos estão definidos. O código Starlark em execução nesse contexto pode executar código arbitrário a máquina em que o Bazel está sendo executado e chegar fora do espaço de trabalho.

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

Mais informações sobre o Starlark estão disponíveis aqui.

Fase de carregamento/análise

Na fase de carregamento/análise, o Bazel determina quais ações são necessárias para criar uma regra específica. Sua unidade básica é um "objetivo configurado", ou seja, sensivelmente, um par (alvo, configuração).

Ela é chamada de "fase de carregamento/análise" porque ele pode ser dividido em dois 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 do de regras para produzir o gráfico de ação

Cada destino configurado no fechamento transitivo dos destinos configurados solicitados na linha de comando precisam ser analisados de baixo para cima. ou seja, os nós de folhas 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, o plataforma, mas também coisas como opções de linha de comando que o usuário deseja passados para o compilador C++)
  2. As dependências diretas. Os provedores de informações transitivas deles estão disponíveis à regra que está sendo analisada. Eles são chamados assim porque fornecem uma "roll-up" das informações no fechamento transitivo do fluxo destino, 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 de destino está. Para regras, isso inclui seus atributos, que geralmente são o que é importante.
  4. A implementação do destino configurado. Para regras, isso pode em Starlark ou Java. Todas as metas que não são configuradas por regras foram implementadas em Java.

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

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

A API oferecida para as regras de Java é RuleContext, que é equivalente à Argumento ctx das regras de Starlark. Sua API é mais eficiente, mas com o mesmo é mais fácil fazer Bad ThingsTM, por exemplo, escrever código cuja hora ou é quadrática (ou pior), para fazer o servidor Bazel falhar com uma exceção Java ou para violar invariantes (como modificar acidentalmente um instância Options ou tornando um destino configurado mutável).

O algoritmo que determina as dependências diretas de um destino configurado mora em DependencyResolver.dependentNodeMap().

Configurações

As configurações são o "como" de criar um público-alvo: para qual plataforma, com qual 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 para o build e o código de destino e estamos fazendo compilação cruzada ou quando criar um aplicativo Android multiuso (que contenha código nativo para vários recursos de CPU arquiteturas de

Conceitualmente, a configuração é uma instância de BuildOptions. No entanto, em prática, BuildOptions é encapsulada por BuildConfiguration, que fornece funcionalidades adicionais. Ela se propaga da parte superior gráfico de dependência para a parte inferior. Se 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 muda, mesmo que apenas afeta os destinos de teste (temos planos de "cortar" as configurações para que isso seja não é o caso, mas ele ainda não está pronto).

Quando a implementação de uma regra precisa de parte da configuração, é necessário declarar na definição usando RuleClass.Builder.requiresConfigurationFragments() , Isso serve tanto para evitar erros (como regras de Python usando o fragmento Java) e para facilitar o corte de configuração para que, por exemplo, se as opções do Python mudarem, o C++ e os alvos não precisam ser analisados novamente.

A configuração de uma regra não é necessariamente a mesma do seu "pai" regra de firewall. O processo de alteração 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 Attribute.Builder.cfg() e são funções de uma Rule (em que o a transição acontecer) 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 ela precisa ser incorporada na arquitetura de execução
  2. Para declarar que uma dependência específica deve ser criada para vários arquiteturas (como para código nativo em APKs grandes do Android)

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

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

Provedores de informações transitivas

Provedores de informações transitivas são um caminho (e o _único _caminho) de destinos configurados para informar sobre outros destinos configurados que dependem deles. O motivo "transitivo" em seu nome é que isso geralmente é algum tipo de agrupamento de o fechamento transitivo de um destino configurado.

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

  1. Um objeto de classe Java. Isso só está disponível para provedores que não acessível pelo Starlark. Esses provedores são uma subclasse TransitiveInfoProvider:
  2. Uma string. Isso é legado e altamente desencorajado, já que é suscetível a entre em conflito. Esses provedores de informações transitivas são subclasses diretas de build.lib.packages.Info .
  3. Símbolo de provedor. Ela pode ser criada no Starlark usando a provider(). e é a forma recomendada de criar 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 Não é possível acessar subclasses TransitiveInfoProvider no Starlark.

Metas configuradas

As metas configuradas são implementadas como RuleConfiguredTargetFactory. Há um para cada classe de regra implementada em Java. Destinos configurados pelo Starlark são criados usando StarlarkRuleConfiguredTargetUtil.buildRule() .

As fábricas de destino configuradas precisam usar RuleConfiguredTargetBuilder para construa o valor de retorno. Ele consiste nos seguintes itens:

  1. O filesToBuild, o conceito confuso 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 no srcs de uma genrule.
  2. Os arquivos de execução, regulares e dados.
  3. Os grupos de saída deles. Estes são vários "outros conjuntos de arquivos" a regra pode ser construído. Eles podem ser acessados usando o atributo output_group da regra de grupo de arquivos em BUILD e usar o provedor OutputGroupInfo em Java.

Arquivos de execução

Alguns binários precisam de arquivos de dados para serem executados. Um exemplo famoso são os testes que precisam arquivos de entrada. Isso é representado no Bazel pelo conceito de "runfiles". Um "árvore do 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, é uma 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 uma única Map para dois motivos:

  • Na maioria das vezes, o caminho dos arquivos de execução de um arquivo é o mesmo do execpath. Usamos isso para economizar espaço na RAM.
  • Há vários tipos legados de entradas em árvores do Runfiles, que também precisam que devem ser representados.

Os arquivos de execução são coletados usando RunfilesProvider: uma instância dessa classe. representa os arquivos de execução de um destino configurado (como uma biblioteca) e seus necessidades de fechamento e são reunidas como um conjunto aninhado implementado usando conjuntos aninhados ocultos: cada destino une os arquivos de execução. das dependências, adiciona algumas das próprias e envia o conjunto resultante para cima, no gráfico de dependência. Uma instância RunfilesProvider contém duas Runfiles uma para quando a regra depende dos "dados" e uma para cada outro tipo de dependência de entrada. Isso ocorre porque uma meta às vezes, apresenta arquivos de execução diferentes quando eles dependem de um atributo de dados. do que de outra forma. Esse é um comportamento legado indesejado que não enfrentamos já está removendo.

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 sendo criada, diferentemente de Runfiles, que é apenas um mapeamento. Isso precisa dos seguintes componentes adicionais:

  • O manifesto dos arquivos de execução de entrada. É uma descrição serializada do árvore de arquivos de execução. É usado como um proxy para o conteúdo da árvore de arquivos de execução. e o Bazel pressupõe que a árvore de arquivos de execução muda somente se o conteúdo da mudança de manifesto.
  • O manifesto dos arquivos de execução de saída. Ele é usado por bibliotecas de ambiente de execução que lidar com árvores de arquivos de execução, especialmente no Windows, que às vezes não oferecem suporte links simbólicos.
  • O intermediário dos arquivos de execução. Para que uma árvore de arquivos de execução exista, é necessário para criar a árvore de links simbólicos e o artefato para o qual os links simbólicos apontam. Para para diminuir o número de arestas de dependência, o intermediário dos arquivos de execução pode ser costumava representar tudo isso.
  • 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 forma de "propagar a computação pelo gráfico de dependências". São descritos para os usuários do Bazel aqui. Uma boa um exemplo de motivação são os buffers de protocolo: uma regra proto_library não deve saber sobre qualquer linguagem específica, mas criar a implementação de um protocolo (a "unidade básica" dos buffers de protocolo) em qualquer programação deve ser acoplada à regra proto_library para que, se dois destinos em da mesma linguagem dependem do mesmo buffer de protocolo, ele é criado apenas uma vez.

Assim como os destinos configurados, eles são representados no Skyframe como um SkyValue. e a maneira como eles são construídos é muito semelhante à forma como os destinos configurados criados: eles têm uma classe de fábrica chamada ConfiguredAspectFactory, que tem acesso a um RuleContext, mas, diferentemente das fábricas de destino configuradas, ele também sabe sobre o destino configurado ao qual está anexado e os provedores dele.

O conjunto de aspectos propagados pelo gráfico de dependência é especificado para cada usando a função Attribute.Builder.aspects(). Existem alguns 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 ao RuleConfiguredTargetFactory.
  2. AspectDefinition é a definição do aspecto. ela inclui provedores necessários, os provedores que ela fornece e contém uma referência aos a implementação dele, como a instância AspectClass apropriada. Está análoga a RuleClass.
  3. AspectParameters é uma forma de parametrizar um aspecto que é propagado para baixo. o gráfico de dependência. No momento, ele é um mapa de string a string. Um bom exemplo por que isso é útil são os buffers de protocolo: se uma linguagem tiver várias APIs, o informações sobre para qual API os buffers de protocolo devem ser criados propagadas pelo gráfico de dependências.
  4. Aspect representa todos os dados necessários para computar um aspecto que se propaga pelo gráfico de dependências. Ele consiste na classe de aspecto, definição e seus parâmetros.
  5. RuleAspect é a função que determina quais aspectos uma regra específica devem se propagar. É um Rule -> função Aspect.

Uma complicação um pouco inesperada é que alguns aspectos podem se vincular a outros. por exemplo, um aspecto que coleta o classpath de um Java IDE provavelmente querem saber sobre todos os arquivos .jar no caminho de classe, mas alguns deles são buffers de protocolo. Nesse caso, será necessário anexar o aspecto do ambiente de desenvolvimento integrado ao (regra proto_library + aspecto do proto Java).

A complexidade dos aspectos dos aspectos é capturada na aula AspectCollection:

Plataformas e conjuntos de ferramentas

O Bazel oferece suporte a builds multiplataforma, ou seja, builds em que haja múltiplas arquiteturas em que as ações de compilação são executadas e múltiplas arquiteturas para qual código é criado. No Bazel, essas arquiteturas são chamadas de plataformas. linguagem (documentação completa) aqui)

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

O conceito de conjunto de ferramentas vem do fato de que, dependendo de quais plataformas em que o build está sendo executado e em quais plataformas são segmentadas, pode ser necessário usar compiladores diferentes; por exemplo, um determinado conjunto de ferramentas C++ pode ser executado em uma para um sistema operacional específico e segmentar outros sistemas operacionais. O Bazel precisa determinar o código compilador que é usado com base na execução definida e na plataforma de destino (documentação sobre conjuntos de ferramentas aqui.

Para isso, os conjuntos de ferramentas são anotados com o conjunto de execução e restrições da plataforma de destino que suportam. Para fazer isso, a definição de um conjunto de ferramentas são divididos em duas partes:

  1. Uma regra toolchain() que descreva o conjunto de execução e destino de que um conjunto de ferramentas oferece suporte e informa o tipo (como C++ ou Java) de conjunto de ferramentas que é (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 forma porque precisamos conhecer as restrições para cada para fazer a resolução do conjunto de ferramentas e configurações As regras *_toolchain() contêm muito mais informações do que isso, por isso levam mais tempo de carregamento.

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

  1. No arquivo ESPAÇO DE TRABALHO, usando a função register_execution_platforms()
  2. Na linha de comando usando --extra_execution_platforms opção

O conjunto de plataformas de execução disponíveis é calculado em RegisteredExecutionPlatformsFunction .

A plataforma de destino para um destino configurado é determinada pela PlatformOptions.computeTargetPlatform() . É uma lista de plataformas porque querem oferecer suporte a várias plataformas de destino, mas isso não foi implementado ainda.

O conjunto de conjuntos de ferramentas a serem usados para um destino configurado é determinado pelos ToolchainResolutionFunction: É uma função de:

  • O conjunto de conjuntos de ferramentas registrados (no arquivo WORKSPACE e 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 de plataforma de execução do destino configurado (o exec_compatible_with) e a configuração (--experimental_add_exec_constraints_to_targets), em UnloadedToolchainContextKey

O resultado é um UnloadedToolchainContext, que é essencialmente um mapa da tipo de conjunto de ferramentas (representado como uma instância ToolchainTypeInfo) para o rótulo de o conjunto de ferramentas selecionado. Ele se chama "descarregado" porque ele não contém em si, somente os rótulos.

Em seguida, os conjuntos de ferramentas são carregados usando ResolvedToolchainContext.load(). e usados pela implementação do destino configurado que as solicitou.

Também temos um sistema legado que depende da existência de um único "host" e as configurações de destino representadas por várias flags de configuração, como --cpu . Estamos fazendo a transição gradual para as opções acima sistema. Para lidar com casos em que as pessoas dependem da configuração legada os valores de conversão, implementamos mapeamentos de plataforma para fazer a conversão entre as sinalizações legadas e as restrições da plataforma do novo estilo. O código está em PlatformMappingFunction e usa um "pouco não Starlark" idioma".

Restrições

Às vezes, você quer designar um destino como compatível com apenas alguns plataformas. Infelizmente, o Bazel tem vários mecanismos para fazer isso:

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

Restrições específicas de regras são usadas principalmente no Google para regras Java. eles são e eles não estão disponíveis no Bazel, mas o código-fonte pode contêm referências a ele. O atributo que rege isso é chamado constraints= .

ambiente_group() e ambiente()

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

Todas as regras de build podem declarar quais "ambientes" para a qual eles podem ser construídos, "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 declara o conjunto exato de ambientes com suporte para a regra para este grupo.
  2. Pelo atributo compatible_with=. Isso declara aos ambientes uma regra além de "padrão", ambientes com suporte padrão.
  3. Usando os atributos de nível de pacote default_restricted_to= e default_compatible_with=.
  4. Por especificações padrão em regras environment_group(). Todas de rede pertence a um grupo de pares tematicamente relacionados (como arquiteturas de terceiros", "versões do JDK" ou "sistemas operacionais para dispositivos móveis"). O a definição de um grupo de ambientes inclui qual desses ambientes é compatível com "default" caso não seja especificado de outra forma pelo Atributos restricted_to= / environment(). Uma regra sem essa herdam todos os padrões.
  5. Por um padrão de classe de regra. Isso substitui os padrões globais de todas instâncias da classe de regra especificada. Isso pode ser usado, por exemplo, para tornar todas as regras *_test possam ser testadas sem que cada instância precise declarar essa capacidade.

environment() é implementado como uma regra regular, enquanto environment_group() é uma subclasse de Target, mas não de Rule (EnvironmentGroup) e um disponível por padrão no Starlark (StarlarkLibrary.environmentGroup()) que, por fim, cria um nome alvo. Isso serve para evitar uma dependência cíclica que surgiria porque cada ambiente precisa declarar o grupo a que pertence e cada grupo de ambiente de execução precisa declarar seus ambientes padrão.

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

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

Restrições da plataforma

A posição "oficial" atual é uma forma de descrever em quais plataformas um público-alvo é compatível é usar as mesmas restrições usadas para descrever os conjuntos de ferramentas e as plataformas. Está em análise na solicitação de envio N.o 10945 (link em inglês).

Visibilidade

Se você trabalha em uma grande base de código com muitos desenvolvedores (como no Google), você evitar que outras pessoas dependam arbitrariamente das suas o código-fonte. Caso contrário, de acordo com a Lei de Hyrum, as pessoas vão confiar em comportamentos que você considerou serem implementados detalhes.

O Bazel oferece essa função pelo mecanismo visibilidade: é possível declarar que uma determinado segmento só pode depender do uso do visibilidade. Isso atributo é um pouco especial porque, embora contenha uma lista de marcadores, esses rótulos podem codificar um padrão sobre nomes de pacotes em vez de um ponteiro para qualquer um alvo específico. (Sim, isso é uma falha de design.)

Isso é implementado nos seguintes locais:

  • A interface RuleVisibility representa uma declaração de visibilidade. Ela pode ser uma constante (totalmente pública ou totalmente particular) ou uma lista de rótulos.
  • Os rótulos podem se referir a grupos de pacotes (lista predefinida de pacotes) pacotes diretamente (//pkg:__pkg__) ou subárvores de pacotes (//pkg:__subpackages__). Ela é 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 destino configurado (PackageGroupConfiguredTarget). Poderíamos provavelmente e substituí-las por regras simples, se quiséssemos. A lógica deles é implementada com a ajuda de PackageSpecification, que corresponde a uma padrão único, como //pkg/...; PackageGroupContents, que corresponde para um único atributo packages de package_group. e PackageSpecificationProvider, que agrega mais de um package_group e seu includes transitivo.
  • A conversão de listas de rótulos de visibilidade para dependências é feita em DependencyResolver.visitTargetVisibility e mais alguns outros itens lugares.
  • A verificação real é feita CommonPrerequisiteValidator.validateDirectPrerequisiteVisibility()

Conjuntos aninhados

Muitas vezes, um destino configurado agrega um conjunto de arquivos de suas dependências, adiciona seu próprio e encapsula o conjunto agregado em um provedor de informações transitivas para que os destinos configurados que dependem dele podem fazer o mesmo. Exemplos:

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

Se fizéssemos isso de forma ingênua usando, por exemplo, List ou Set, acabaríamos com Uso da memória quadrática: se houver uma cadeia de N regras e cada regra adicionar um teríamos 1+2+...+N membros de coleção.

Para contornar esse problema, criamos o conceito de NestedSet: Ela é uma estrutura de dados composta por outros NestedSet. instâncias e alguns membros próprios, formando assim um gráfico acíclico dirigido de conjuntos. Elas são imutáveis e os membros podem ser iterados. Nós definimos ordem de iteração múltipla (NestedSet.Order): pedido antecipado, pós-pedido, topológico (um nó sempre vem depois de seus ancestrais) e "não se importa, mas esse sempre iguais".

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 do classe Action, e os arquivos são representados como instâncias da classe Artifact. Elas estão dispostas em um gráfico acíclico dirigido, bipartito, "gráfico de ações".

Há dois tipos de artefatos: artefatos de origem (que estão disponíveis antes do Bazel começar a ser executado) e artefatos derivados (aqueles que precisam ser construídos). Os artefatos derivados podem ser de vários tipos:

  1. **Artefatos regulares. **Elas são verificadas para garantir a atualização a soma de verificação, com mtime como atalho; não fazemos checksum do arquivo se ctime não mudou.
  2. Artefatos de link simbólico não resolvidos. Elas são verificadas pelo chame readlink(). Ao contrário dos artefatos normais, eles podem estar pendentes links simbólicos. Geralmente usado nos casos em que é preciso empacotar alguns arquivos em um algum tipo de arquivo.
  3. Artefatos de árvores. Não são arquivos únicos, mas árvores de diretórios. Eles são verificadas pelo conjunto de arquivos e seus conteúdo. Eles são representados como TreeArtifact.
  4. Artefatos de metadados constantes. As mudanças nesses artefatos não acionam uma ser reconstruída. Isso é usado exclusivamente para informações de carimbo de data/hora da versão: não queremos reconstruir só 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 (estamos deveria, no entanto, fazer referência a um diretório de origem em um arquivo BUILD é uma das alguns problemas de incorreção antigos conhecidos com o Bazel. temos um implementação que funciona, e é possível propriedade da JVM BAZEL_TRACK_SOURCE_DIRECTORIES=1)

Um tipo notável de Artifact são os intermediários. Elas são indicadas por Artifact. instâncias que são as saídas de MiddlemanAction. Eles são usados para alguns casos especiais:

  • A agregação de intermediários é usada para agrupar artefatos. Isso é para que Se muitas ações usarem o mesmo grande conjunto de entradas, não teremos N*M bordas de dependência, somente N+M (que estão sendo substituídas por conjuntos aninhados)
  • Intermediários de dependência de programação garantem que uma ação seja executada antes de outra. Elas são usadas principalmente para inspeção, mas também para compilação em C++ (consulte CcCompilationContext.createMiddleman() para conferir 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 que você não precise depender separadamente do manifesto de saída e que cada único artefato referenciado pela árvore de arquivos de execução.

As ações são mais bem entendidas como um comando que precisa ser executado, que ele precisa e o conjunto de saídas que ele produz. Os itens a seguir são os principais componentes da descrição de uma ação:

  • A linha de comando que precisa ser executada
  • Os artefatos de entrada necessários
  • As variáveis de ambiente que precisam ser definidas
  • Anotações que descrevem o ambiente (como a plataforma) em que ele precisa ser executado .

Há também outros casos especiais, como gravar um arquivo cujo conteúdo é conhecidos pelo Bazel. Elas são uma subclasse de AbstractAction. A maioria das ações são um SpawnAction ou um StarlarkAction (o mesmo, eles não deveriam 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 é mas o C++ é um caso especial devido à análise de arquivos .d e incluem verificação.

Na maioria das vezes, o gráfico de ações é "incorporado" para o gráfico Skyframe: conceitualmente, a a execução de uma ação é representada como uma invocação ActionExecutionFunction: O mapeamento de uma borda de dependência do gráfico de ações para um A borda de dependência do Skyframe é descrita em ActionExecutionFunction.getInputDeps() e Artifact.key() e tem alguns para manter baixo o número de bordas de Skyframe:

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

Ações compartilhadas

Algumas ações são geradas por vários destinos configurados. As regras Starlark são são mais limitadas, pois só podem colocar as ações derivadas determinado pela configuração e pelo pacote (mas mesmo assim, regras no mesmo pacote podem entrar em conflito), mas as regras implementadas em Java podem colocar os artefatos derivados em qualquer lugar.

Isso é considerado um erro de recurso, mas eliminar isso é muito difícil porque produz uma economia significativa no tempo de execução quando, por exemplo, arquivo de origem precisa ser processado de alguma forma e esse arquivo é referenciado por várias regras (handwave-handwave). Isso custa um pouco de RAM: cada instância de uma ação compartilhada precisa ser armazenada separadamente na memória.

Se duas ações gerarem o mesmo arquivo de saída, elas precisarão ser exatamente as mesmas: têm as mesmas entradas, as mesmas saídas e executam a mesma linha de comando. Isso relação de equivalência é implementada em Actions.canBeShared() e é verificados entre as fases de análise e execução, analisando cada ação. Isso foi implementado em SkyframeActionExecutor.findAndStoreArtifactConflicts() e é um dos únicos locais no Bazel que exigem uma configuração do ser construído.

A fase de execução

É quando o Bazel começa a executar ações de compilação, como comandos que para produzir saídas.

A primeira coisa que o Bazel faz após a fase de análise é determinar o que É preciso construir artefatos. A lógica para isso é codificada TopLevelArtifactHelper Em termos gerais, é o filesToBuild do os destinos configurados na linha de comando e o conteúdo de um bloco grupo com a finalidade explícita de expressar "se este destino estiver no 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 trabalha anotando cada destino usada na fase de análise e construir uma única árvore de diretórios que vincula cada pacote com um destino usado a partir de seu local real. Uma alternativa seria transmitir os caminhos corretos para os comandos, considerando o --package_path. Isso não é desejável, porque:

  • Muda as linhas de comando de ação quando um pacote é movido de um caminho de pacote entrada para outra (uma ocorrência comum)
  • Se uma ação for executada remotamente, o resultado serão linhas de comando diferentes ele é executado localmente
  • 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 de ações
  • O uso de --package_path está sendo descontinuado de modo lento e contínuo

Em seguida, o Bazel começa a atravessar o gráfico de ações (o gráfico bipartite dirigido). composta de ações e seus artefatos de entrada e saída) e ações em execução. A execução de cada ação é representada por uma instância do SkyValue. classe ActionExecutionValue.

Como a execução de uma ação é cara, temos algumas camadas de armazenamento em cache que podem ser atingido por trás do Skyframe:

  • O arquivo ActionExecutionFunction.stateMap contém dados para reiniciar o Skyframe de ActionExecutionFunction baratos
  • O cache de ações locais contém dados sobre o estado do sistema de arquivos
  • Os sistemas de execução remota geralmente também contêm o próprio cache

O cache de ações locais

Esse cache é outra camada que fica atrás do Skyframe. mesmo que uma ação seja for executada novamente no Skyframe, ela ainda poderá ser um hit no cache de ações locais. Ela representa o estado do sistema de arquivos local e é serializado no disco, o que Isso significa que, ao iniciar um novo servidor do Bazel, é possível acessar o cache de ações locais mesmo que o gráfico do Skyframe esteja vazio.

Esse cache é verificado quanto a ocorrências usando o método ActionCacheChecker.getTokenIfNeedToExecute() .

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

  1. O conjunto dos arquivos de entrada e saída e a soma de verificação deles
  2. Sua "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 do arquivos de entrada (como para FileWriteAction, é a soma de verificação dos dados que está escrito)

Há também um "cache de ação de cima para baixo" altamente experimental que ainda está no de desenvolvimento de software, que usa hashes transitivos para evitar que o maior número de vezes.

Descoberta e remoção de entradas

Algumas ações são mais complicadas do que apenas ter um conjunto de entradas. Mudanças no o conjunto de entradas de uma ação tem duas formas:

  • Uma ação pode descobrir novas entradas antes da execução ou decidir que algumas das entradas não são realmente necessárias. O exemplo canônico é C++, em que é melhor adivinhar quais arquivos de cabeçalho um arquivo C++ usa a partir de seu fechamento transitivo para que não tenhamos cuidado para enviar cada para executores remotos. Portanto, temos a opção de não registrar todos como uma "entrada", mas verifique o arquivo de origem para cabeçalhos incluídos e apenas marcar esses arquivos de cabeçalho como entradas que são mencionado nas instruções #include (superestimamos para não precisar implementar um pré-processador C completo). Essa opção atualmente está conectada "falso" no Bazel e é usada apenas no Google.
  • Uma ação pode realizar 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 usada após o fato e para evitar o constrangimento de ter a incrementabilidade do que o Make, o Bazel usa esse fato. Isso oferece uma abordagem do que o verificador de inclusão, porque depende do compilador.

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

  1. Action.discoverInputs() é chamado. Ele deve retornar um conjunto aninhado de Artefatos determinados como obrigatórios. Eles precisam ser artefatos de origem para que não haja arestas de dependência no gráfico de ações sem uma equivalente no gráfico de destino configurado.
  2. A ação é executada chamando Action.execute().
  3. Ao final de Action.execute(), a ação pode chamar Action.updateInputs() para informar ao Bazel que nem todas as entradas foram necessários. Isso pode resultar em builds incrementais incorretos se uma entrada usada for relatadas como não usadas.

Quando um cache de ações retorna uma ocorrência em uma nova instância de ação (como após a reinicialização do servidor), o Bazel chama o próprio updateInputs() para que o conjunto de "inputs" reflete o resultado da descoberta e da remoção de entradas feitas anteriormente.

As ações do Starlark podem usar a instalação 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 executados localmente, localmente, mas em vários tipos de sandboxes, ou remotamente. O conceito que incorpora isso é chamado de ActionContext (ou Strategy, já que foi apenas até a metade 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 o que contextos de ação que elas têm. Isso acontece no construtor ExecutionTool: Os tipos de contexto de ação são identificados por um Class do Java. que se refere a uma subinterface de ActionContext e qual interface que o contexto de ação deve implementar.
  2. O contexto de ação apropriado é selecionado entre os disponíveis e é encaminhados para ActionExecutionContext e BlazeExecutor .
  3. Contextos de solicitação de ações usando ActionExecutionContext.getContext() e BlazeExecutor.getStrategy() (deve haver apenas uma maneira de fazer ele...)

As estratégias são livres para chamar outras para que façam seu trabalho; ela é usada para exemplo, na estratégia dinâmica que inicia ações local e remotamente, e usa o que terminar primeiro.

Uma estratégia notável é a que implementa processos de worker persistentes, (WorkerSpawnStrategy). A ideia é que algumas ferramentas têm um tempo de inicialização longo e, portanto, deve ser reutilizado entre ações, em vez de iniciar uma nova para cada ação. Isso representa um possível problema de correção, já que o na promessa do processo do worker de que ele não carrega estado entre solicitações individuais)

Se a ferramenta mudar, o processo do worker precisará ser reiniciado. Se um worker pode ser reutilizado é determinado pela computação de uma soma de verificação para a ferramenta usada usando WorkerFilesHash: Ela depende de saber quais entradas da ação representam que representam entradas da ferramenta, Isso é determinado pelo criador da ação: Spawn.getToolFiles() e os arquivos de execução do Spawn são contados como partes da ferramenta.

Mais informações sobre estratégias (ou contextos de ação):

  • Informações sobre várias estratégias para executar ações estão disponíveis aqui.
  • Informações sobre a estratégia dinâmica, na qual realizamos uma ação tanto local e remotamente para conferir o que terminar primeiro aqui.
  • Informações sobre as complexidades da execução local de ações estão disponíveis aqui.

O gerente de recursos local

O Bazel pode executar muitas ações em paralelo. O número de ações locais deve ser executado em paralelo difere de uma ação para outra: quanto mais recursos um ação exigir, menos instâncias devem estar em execução ao mesmo tempo para evitar sobrecarregando a máquina local.

Isso é implementado na classe ResourceManager: cada ação precisa ser anotado com uma estimativa dos recursos locais necessários na forma de um ResourceSet instância (CPU e RAM). Então, quando os contextos de ação fazem algo que exige recursos locais, ela chama ResourceManager.acquireResources() e ficam bloqueados até os recursos necessários ficarem disponíveis.

Uma descrição mais detalhada do gerenciamento de recursos locais está disponível aqui.

A estrutura do diretório de saída

Cada ação exige um local separado no diretório de saída, onde ela é colocada das saídas. O local dos artefatos derivados geralmente é o seguinte:

$EXECROOT/bazel-out/<configuration>/bin/<package>/<artifact name>

Como é o nome do diretório que está associado a um determinado configuração determinada? Há duas propriedades desejadas conflitantes:

  1. Se duas configurações puderem ocorrer no mesmo build, elas deverão ter diretórios diferentes para que ambos possam ter a própria versão do mesmo ação; caso contrário, se as duas configurações discordarem, linha de ação que produz o mesmo arquivo de saída, o Bazel não sabe qual ação escolher (um "conflito de ação")
  2. Se duas configurações representam aproximadamente a mesma coisa, elas deveriam ter com o mesmo nome para que as ações executadas em um possam ser reutilizadas no outro caso as linhas de comando corresponder: por exemplo, mudanças nas opções de linha de comando para o compilador Java não pode fazer com que as ações de compilação do C++ sejam executadas novamente.

Até agora, não encontramos uma maneira fundamentada de resolver esse problema, o que tem semelhanças com o problema de corte de configuração. Uma discussão mais longa está disponível aqui. As principais áreas problemáticas são as regras dos Starlark (cujos autores geralmente não são intimamente familiarizado com Bazel) e aspectos que adicionam outra dimensão à espaço de coisas que podem produzir "o mesmo" arquivo de saída.

A abordagem atual é que o segmento do caminho para a configuração seja <CPU>-<compilation mode> com vários sufixos adicionados para que a configuração transições implementadas em Java não resultam em conflitos de ação. Além disso, um o checksum do conjunto de transições de configuração do Starlark é adicionado para que os usuários não podem causar conflitos de ação. Está longe de ser perfeito. Isso é implementado OutputDirectories.buildMnemonic() e depende de cada fragmento de configuração adicionando a própria parte ao nome do diretório de saída.

Testes

O Bazel tem suporte avançado para a execução de testes. Ela aceita estas opções:

  • Executar testes remotamente (se houver um back-end de execução remota disponível)
  • Execução de testes várias vezes em paralelo (para redução da redução ou coleta de tempo) dados)
  • Fragmentação de testes (dividir casos de teste no mesmo teste com vários processos) para saber mais sobre velocidade)
  • Nova execução de testes instáveis
  • Agrupar testes em conjuntos de testes

Testes são destinos regulares configurados que têm um TestProvider, que descreve como o teste deve ser executado:

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

Como determinar quais testes serão executados

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

Primeiro, durante a análise do padrão de destino, os conjuntos de testes são expandidos recursivamente. O a expansão é implementada em TestsForTargetPatternFunction. Um pouco surpreendente é que, se um conjunto de testes não declara testes, ele se refere every no pacote. Isso é implementado em Package.beforeBuild() pela adicionando um atributo implícito chamado $implicit_tests para testar regras do pacote.

Em seguida, os testes são filtrados por tamanho, tags, tempo limite e idioma de acordo com o opções da linha de comando. Isso é implementado em TestFilter e chamado do TargetPatternPhaseFunction.determineTests() durante a análise de destino e o resultado é colocado em TargetPatternPhaseValue.getTestsToRunLabels(). O motivo por que os atributos de regra que podem ser filtrados não são configuráveis é que este ocorre antes da fase de análise, portanto, a configuração não é disponíveis.

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

Para dar mais transparência a esse processo, o tests() o operador de consulta (implementado em TestsFunction) está disponível para informar quais testes são executados quando um determinado destino é especificado na linha de comando. Está uma reimplementação, então ele provavelmente vai se desviar do que foi descrito acima de várias maneiras sutis.

Como executar testes

Os testes são executados solicitando artefatos de status de cache. Assim, resulta na execução de uma TestRunnerAction, que chama a função O TestActionContext escolhido pela opção de linha de comando --test_strategy que executa o teste da maneira solicitada.

Os testes são executados de acordo com um protocolo elaborado que usa variáveis de ambiente para informar aos testes o que é esperado deles. Uma descrição detalhada do que o Bazel o que espera dos testes e o que esperar do Bazel está disponível aqui. No mais simples: um código de saída 0 significa sucesso, enquanto qualquer outro significa falha.

Além do arquivo de status de cache, cada processo de teste emite vários outros . Eles são colocados no "diretório de registros de teste" que é o subdiretório chamado testlogs do diretório de saída da configuração de destino:

  • test.xml, um arquivo XML no estilo JUnit detalhando os casos de teste individuais em o fragmento de teste
  • test.log, a saída do console do teste. stdout e stderr não são separadas.
  • test.outputs, o "diretório de saídas não declaradas"; ela é usada por testes que querem gerar arquivos, além dos que são impressos no terminal.

Durante a execução do teste, podem ocorrer duas coisas que não podem durante Criar destinos regulares: 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 ocorrer ao adicionar tags=["exclusive"] ao regra de teste ou executar o teste com --test_strategy=exclusive . Cada um é executado por uma invocação de Skyframe separada, solicitando a execução da após o "main" ser construído. Isso é implementado SkyframeExecutor.runExclusiveTest():

Ao contrário das ações normais, cuja saída terminal é despejada quando a ação terminar, o usuário pode solicitar que o resultado dos testes seja transmitido para que possa saber o progresso de um teste de longa duração. Ela é especificada pela A opção de linha de comando --test_output=streamed implica em teste exclusivo para que as saídas de diferentes testes não sejam intercaladas.

Isso é implementado na classe StreamedTestOutput, devidamente nomeada e funciona da seguinte forma: Sondagem de mudanças no arquivo test.log do teste em questão e despesa nova bytes ao terminal em que o Bazel rege.

Os resultados dos testes executados estão disponíveis no barramento de eventos observando vários eventos (como TestAttempt, TestResult ou TestingCompleteEvent). Eles são despejados no protocolo de evento de build e emitidos para o console por AggregatingTestListener.

Coleta de cobertura

A cobertura é informada pelos testes no formato LCOV nos arquivos bazel-testlogs/$PACKAGE/$TARGET/coverage.dat .

Para coletar cobertura, cada execução de teste é encapsulada em um script chamado collect_coverage.sh .

Este script configura o ambiente de 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 de partes escritas em várias linguagens de programação diferentes (com partes ambientes de execução de coleta de cobertura). O script de wrapper é responsável pela conversão os arquivos resultantes no formato LCOV, se necessário, e os mescla em um único .

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 é realizada pelo atributo implícito :coverage_support, que é resolvido para o valor da flag de configuração --coverage_support (consulte TestConfiguration.TestOptions.coverageSupport)

Alguns idiomas fazem instrumentação off-line, ou seja, a cobertura é adicionada no momento da compilação (como C++) e outros fazem on-line ou seja, a instrumentação de cobertura é adicionada na execução tempo de resposta.

Outro conceito central é a cobertura dos valores de referência. Essa é a cobertura de uma biblioteca, binário ou testar se nenhum código foi executado nele. O problema que ele resolve é que, desejar computar a cobertura de teste para um binário, não será suficiente mesclar o de todos os testes porque pode haver código no binário que não está vinculado a qualquer teste. Portanto, o que fazemos é emitir um arquivo de cobertura para cada Binário que contém apenas os arquivos para os quais coletamos informações, sem cobertura linhas O arquivo de cobertura do valor de referência para uma meta está em bazel-testlogs/$PACKAGE/$TARGET/baseline_coverage.dat . Ele também é gerado para binários e bibliotecas, além de testes, se você passar no Sinalização --nobuild_tests_only para o Bazel.

No momento, a cobertura do valor de referência está corrompida.

Acompanhamos dois grupos de arquivos para a coleta da cobertura de cada regra: o conjunto de 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 ambientes de execução de cobertura on-line, pode ser usado no tempo de execução para decidir quais arquivos devem ser instrumento. Ele também é usado para implementar a cobertura do valor de referência.

O conjunto de arquivos de metadados de instrumentação é o conjunto de arquivos extras necessários para um teste para gerar os arquivos LCOV que o Bazel exige. Na prática, isso consiste em específicos do ambiente de execução. Por exemplo, o gcc emite arquivos .gcno durante a compilação. Elas serão adicionadas ao conjunto de entradas das ações de teste se o modo de cobertura for ativado.

A coleta ou não da cobertura é armazenada no BuildConfiguration: Isso é útil porque é uma maneira fácil de alterar o teste e o gráfico de ação, dependendo deste bit, mas também significa que se invertido, todos os alvos precisam ser reanalisados (algumas linguagens, como O C++ exige diferentes opções de compilador para emitir código que possa coletar cobertura, o que atenua um pouco esse problema, já que uma nova análise é necessária de qualquer forma).

Os arquivos de suporte de cobertura dependem dos marcadores em um campo implícito para que possam ser substituídos pela política de invocação, o que permite diferentes entre as versões do Bazel. Idealmente, essas seria removida, e padronizamos uma delas.

Também geramos um "relatório de cobertura" que mescla a cobertura coletada para todos os testes em uma invocação do Bazel. Isso é feito pela CoverageReportActionFactory e é chamado em BuildView.createResult() . Ela tenha acesso às ferramentas necessárias no :coverage_report_generator do primeiro teste que é executado.

O mecanismo de consulta

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

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

Cada um deles é implementado com a subclasse AbstractBlazeQueryEnvironment. Outras funções de consulta podem ser feitas criando subclasses de QueryFunction , Para permitir o streaming de resultados de consulta, em vez de coletá-los para alguns estrutura de dados, um query2.engine.Callback é transmitido para QueryFunction, que chama para resultados que quer retornar.

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

Um requisito sutil de alguns formatos de saída de consulta (proto, com certeza) é que O Bazel precisa emitir _todas__as informações fornecidas pelo carregamento do pacote para que é possível diferenciar a saída e determinar se um objetivo específico mudou. Como consequência, os valores dos atributos precisam ser serializáveis, e é por isso que não são poucos tipos de atributos sem nenhum atributo que tenha Starlark complexo valores. A solução alternativa comum é usar um rótulo e anexar o arquivo informações à regra com esse rótulo. Não é uma solução alternativa muito satisfatória e seria ótimo remover esse requisito.

O sistema de módulos

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

Eles são usados principalmente para implementar várias partes "não essenciais" funcionalidade que apenas algumas versões do Bazel (como a que usamos no Google) precisam de:

  • Interfaces para sistemas de execução remota
  • Novos comandos

O conjunto de pontos de extensão que o BlazeModule oferece é um pouco perigoso. O que não fazer usá-lo como um exemplo de bons princípios de design.

O ônibus de eventos

A principal maneira de o BlazeModules se comunicar com o restante do Bazel é por um barramento de eventos (EventBus): uma nova instância é criada para cada build, várias partes do Bazel. pode postar eventos nele e os módulos podem registrar listeners para os eventos aos quais estão interesse. Por exemplo, os seguintes itens são representados como eventos:

  • A lista de destinos de build a serem compilados foi determinada (TargetParsingCompleteEvent)
  • As configurações de nível superior foram determinadas (BuildConfigurationEvent)
  • Um destino foi criado, com êxito ou não (TargetCompleteEvent)
  • Um teste foi executado (TestAttempt, TestSummary)

Alguns desses eventos são representados fora do Bazel no Protocolo de evento de build (são BuildEvents). Isso permite não apenas BlazeModules, mas também itens fora do processo do Bazel para observar o build. Eles são acessíveis como um com mensagens de protocolo, ou o Bazel pode se conectar a um servidor (chamado o serviço de evento de build) para transmitir eventos.

Isso é implementado nas APIs build.lib.buildeventservice e build.lib.buildeventstream pacotes Java.

Repositórios externos

Já o Bazel foi originalmente projetado para ser usado em um monorepo (uma única fonte que contém tudo o que é preciso para criar), o Bazel vive em um mundo onde isso não é necessariamente verdade. "Repositórios externos" são uma abstração usada para unir esses dois mundos: eles representam o código necessário para o build, mas não está na árvore de origem principal.

O arquivo WORKSPACE

O conjunto de repositórios externos é determinado pela análise do arquivo do ESPAÇO DE TRABALHO. Por exemplo, uma declaração como esta:

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

O resultado no repositório chamado @foo está disponível. Onde isso acontece complicado é que se pode definir novas regras de repositório em arquivos Starlark, que pode ser usado para carregar um novo código Starlark, que pode ser usado para definir novos regras de repositório etc.

Para lidar com esse caso, a análise do arquivo WORKSPACE (em WorkspaceFileFunction) é dividida em blocos delineados por load(). declarações. O índice do bloco é indicado por WorkspaceFileKey.getIndex() e computar WorkspaceFileFunction até que o índice X significa avaliá-lo até que Xa instrução load().

Como buscar repositórios

Antes que o código do repositório seja disponibilizado para o Bazel, ele precisa ser buscados. Com isso, o Bazel cria um diretório em $OUTPUT_BASE/external/<repository name>:

A busca do repositório acontece com estas 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 evitar rebaixar os dados no caso de reinicializações do Skyframe, mas não raciocínio muito sólido)
  3. O RepositoryDelegatorFunction descobre a regra de repositório solicitada busque iterando as partes do arquivo WORKSPACE até que o repositório for encontrado
  4. Encontramos o RepositoryFunction apropriado que implementa o repositório fetching; é a implementação do repositório pelo Starlark mapa codificado para repositórios implementados em Java.

Há várias camadas de armazenamento em cache, já que buscar um repositório pode ser caras:

  1. Há um cache para arquivos baixados que são codificados pela soma de verificação deles. (RepositoryCache). Isso exige que a soma de verificação esteja disponível no WORKSPACE, mas ainda assim é bom para hermética. Isto é compartilhado por todas as instâncias do servidor Bazel na mesma estação de trabalho, espaço de trabalho ou na base de saída em que estão sendo executados.
  2. Um "arquivo de marcador" é gravado para cada repositório em $OUTPUT_BASE/external que contém uma soma de verificação da regra que foi usada para buscá-lo. Se o Bazel o servidor for reiniciado, mas a soma de verificação não mudar, ele não será buscado novamente. Isso é implementado em RepositoryDelegatorFunction.DigestWriter .
  3. A opção de linha de comando --distdir designa outro cache usado para procurar artefatos para download. Isso é útil em ambientes corporativos em que ele não deve 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 origem artefatos. Isso pode ser um problema porque o Bazel geralmente verifica se há atualizações de artefatos de origem chamando stat() neles, e esses artefatos também são invalidado quando a definição do repositório em que ele se encontra muda. Assim, As FileStateValues de um artefato em um repositório externo precisam depender de repositório externo. Isso é gerenciado por ExternalFilesHelper.

Diretórios gerenciados

Às vezes, os repositórios externos precisam modificar arquivos na raiz do espaço de trabalho (como um gerenciador de pacotes que hospeda os pacotes baixados em um subdiretório de a árvore de origem). Isso vai contra a suposição de que o Bazel faz essa fonte são modificados somente pelo usuário, e não por si só, e permite que os pacotes refere-se a todos os diretórios na raiz do espaço de trabalho. Para tornar esse tipo de trabalho de repositório externo, o Bazel faz duas coisas:

  1. Permite que o usuário especifique subdiretórios do espaço de trabalho em que o Bazel não é podem alcançar. 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 pelo qual ele é processado em ManagedDirectoriesKnowledge e processar FileStateValues que se referem a eles da mesma forma que os usuários repositórios externos.

Mapeamentos de repositório

Pode acontecer de vários repositórios quererem depender do mesmo repositório, mas em versões diferentes (esta é uma instância do bloco de problema). Por exemplo, se dois binários em repositórios separados no build dependerem do Guava, ambos provavelmente se referirão ao Guava com rótulos começando @guava// e esperam que isso signifique versões diferentes dele.

Assim, o Bazel permite remapear rótulos de repositórios externos para que A string @guava// pode se referir a um repositório Guava (como @guava1//) no de um binário e outro repositório Guava (como @guava2//) do repositório do outro.

Como alternativa, isso também pode ser usado para mesclar diamantes. Se um repositório depende de @guava1// e outro depende de @guava2//, o mapeamento de repositório permite 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 WorkspaceFileValue, onde é responsável por:

  • Package.Builder.repositoryMapping, que é usado para transformar os rótulos com valor atributos de regras no pacote por RuleClass.populateRuleAttributeValues()
  • Package.repositoryMapping, que é usado na fase de análise (por resolver problemas como $(location), que não são analisados no carregamento fase)
  • 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 funciona sozinho ou não funciona sozinho quando o implementamos. Isso é limitado principalmente à interação com o sistema de arquivos, o controle de processo e várias outras coisas de baixo nível.

O código C++ reside em src/main/native e as classes Java com código nativo são:

  • NativePosixFiles e NativePosixFileSystem
  • ProcessUtils
  • WindowsFileOperations e WindowsFileProcesses
  • com.google.devtools.build.lib.platform

Saída do console

Emitir saída do console parece algo simples, mas a confluência de executar vários processos (às vezes remotamente), armazenamento em cache detalhado, a vontade de uma boa saída de terminal colorida e um servidor de longa duração não trivial.

Logo após a chamada RPC chegar do cliente, dois RpcOutputStream instâncias são criadas (para stdout e stderr) que encaminham os dados impressos para ao cliente. Eles são agrupados em OutErr (stdout, stderr) ). Qualquer item que precise ser impresso no console passa por córregos. Em seguida, esses streams são transferidos BlazeCommandDispatcher.execExclusively():

A saída é impressa por padrão com sequências de escape ANSI. Quando estes não são desejados (--color=no), eles serão removidos por uma AnsiStrippingOutputStream. Em Além disso, System.out e System.err são redirecionados a esses streams de saída. Dessa forma, as informações de depuração podem ser impressas usando System.err.println() e ainda vão para a saída do terminal do cliente (diferente do código do servidor). É tomado o cuidado de que, se um processo produz saída binária (como bazel query --output=proto), sem mostrar stdout. acontecer.

Mensagens curtas (erros, avisos e similares) são expressas por meio do interface EventHandler. Eles são diferentes daqueles que se publicam no o EventBus (isso é confuso). Cada Event tem um EventKind (erro, aviso, informações e alguns outros) e podem ter um Location (o lugar em o código-fonte que causou o evento).

Algumas implementações de EventHandler armazenam os eventos recebidos. Isso é usado para reproduzir informações na interface do usuário causadas por vários tipos de processamento em cache, como os avisos emitidos por um destino configurado em cache.

Algumas EventHandlers também permitem postar eventos que, com o tempo, acabam se conectando o barramento de eventos (os Events normais _não _aparecem lá). São implementações de ExtendedEventHandler, e o principal uso delas é repetir conteúdo armazenado em cache EventBus. Todos esses eventos EventBus implementam Postable, mas não tudo o que é postado no EventBus necessariamente implementa essa interface. somente aqueles armazenados em cache por um ExtendedEventHandler (seria bom a maioria das coisas faz; mas não é aplicado)

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

  • O ônibus de eventos
  • O fluxo do evento era direcionado pelo Repórter

A única conexão direta que o equipamento de execução de comando (por exemplo, o restante do Bazel) tem para o stream de RPC para o cliente é por Reporter.getOutErr(), que permite acesso direto a eles. Ela só é usada quando um comando precisa para fazer o despejo de grandes quantidades de dados binários possíveis (como bazel query).

Como criar o perfil do Bazel

O Bazel é rápido. Ele também é lento, porque os builds tendem a crescer até que o limite do que é tolerável. Por isso, o Bazel inclui um criador de perfil que pode ser usada para criar o perfil de builds e do próprio Bazel. Ela é implementada em uma classe chamado Profiler. Ele é ativado por padrão, mas só grava dados resumidos para que sua sobrecarga seja tolerável; A linha de comando O --record_full_profiler_data faz com que ele grave tudo o que pode.

Ele emite um perfil no formato Chrome Profiler. a melhor visualização dele no Chrome. Seu modelo de dados é o de pilhas de tarefas: é possível iniciar e encerrar tarefas e elas precisam estar bem aninhadas umas nas outras. Cada linha de execução Java recebe a própria pilha de tarefas. TODO: como isso funciona com ações e estilo de transmissão de continuação?

O criador de perfil é iniciado e interrompido em BlazeRuntime.initProfiler() e BlazeRuntime.afterCommand(), respectivamente, e tenta permanecer ativo por tanto tempo possível para que possamos criar o perfil de tudo. Para adicionar algo ao perfil, chame Profiler.instance().profile(). Ela retorna um Closeable, com um fechamento representa o fim da tarefa. É melhor usado com recursos "testar com recursos" declarações.

Também fazemos perfis de memória rudimentares em MemoryProfiler. Também está sempre ativado e registra principalmente o tamanho máximo de heap e o comportamento do GC.

Como testar o Bazel

O Bazel tem dois tipos principais de testes: aqueles que o observam como uma "caixa preta". e aqueles que só executam a fase de análise. Chamamos os antigos de "testes de integração", e o último "teste de unidade", embora sejam mais como testes de integração que são menos integradas. Também temos alguns testes de unidade reais, nos quais eles necessários.

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

  1. Aqueles implementados usando uma estrutura de teste bash muito elaborada em src/test/shell
  2. Aquelas implementadas em Java. Eles são implementados como subclasses BuildIntegrationTestCase

O BuildIntegrationTestCase é o framework de teste de integração recomendado, já que é bem equipado para a maioria dos cenários de teste. Por se tratar de um framework Java, oferece capacidade de depuração e integração total com vários recursos comuns de ferramentas de visualização. Há muitos exemplos de classes BuildIntegrationTestCase Repositório do Bazel.

Os testes de análise são implementados como subclasses de BuildViewTestCase. Há um um sistema de arquivos de rascunho que você pode usar para escrever arquivos BUILD, além de diversas funções podem solicitar destinos configurados, alterar a configuração e declarar várias coisas sobre o resultado da análise.