Nas páginas anteriores, um tema se repete repetidamente: gerenciar seu próprio código é bastante simples, mas gerenciar suas dependências é muito fica mais difícil. Há todos os tipos de dependências: às vezes, há um dependência de uma tarefa (como “enviar a documentação antes de marcar uma versão como concluído"), e às vezes há uma dependência em um artefato (como "Preciso ter a versão mais recente da biblioteca de visão computacional para criar meu código"). Às vezes, você tem dependências internas em outra parte da base de código, e às vezes você tem dependências externas em código ou dados que pertencem a outra equipe (seja da sua organização ou de um terceiro). Mas, em qualquer caso, a ideia de "eu que preciso antes de ter isso" é algo que se repete repetidamente no design de sistemas de build, e gerenciar dependências é talvez fundamental de um sistema de build.
Como lidar com módulos e dependências
Os projetos que usam sistemas de build baseados em artefatos, como o Bazel, são divididos em um conjunto
de módulos, com módulos expressando dependências uns com os outros via BUILD
. A organização adequada desses módulos e dependências pode gerar um enorme
no desempenho do sistema de build e no trabalho necessário para
manter.
Como usar módulos refinados e a regra de 1:1:1
A primeira pergunta que surge ao estruturar um build baseado em artefatos é
decidir quanta funcionalidade um módulo individual deve abranger. No Bazel,
Um módulo é representado por um destino que especifica uma unidade edificável, como uma
java_library
ou go_binary
. Em um extremo, todo o projeto poderia ser
em um único módulo colocando um arquivo BUILD
na raiz e
recursivamente agrupando todos os arquivos de origem do projeto. No outro
quase todos os arquivos de origem poderiam ser transformados em um módulo próprio,
exigindo que cada arquivo seja listado em um arquivo BUILD
todos os outros arquivos de que depende.
A maioria dos projetos se encaixa em algum desses extremos, e a escolha envolve uma
um equilíbrio entre desempenho e capacidade de manutenção. Usar um único módulo para o
o projeto inteiro pode significar que nunca será necessário tocar no arquivo BUILD
, exceto
ao adicionar uma dependência externa, mas isso significa que o sistema de build precisa
sempre compilar o projeto inteiro de uma só vez. Isso significa que ele não vai conseguir
paralelizar ou distribuir partes da compilação nem armazená-las em cache
que ele já foi criado. Um módulo por arquivo é o oposto: o sistema de build
tem flexibilidade máxima no armazenamento em cache e no agendamento das etapas do build, mas
os engenheiros precisam se esforçar mais para manter listas de dependências
eles mudam os arquivos referentes a cada uma delas.
Embora a granularidade exata varie de acordo com o idioma (e muitas vezes até mesmo
linguagem natural), o Google tende a favorecer módulos significativamente menores do que
geralmente são programados em
um sistema de build baseado em tarefas. Um binário de produção típico
O Google costuma depender de dezenas de milhares de alvos e até mesmo um
pode ter várias centenas de destinos dentro de sua base de código. Para idiomas como
Java que têm uma forte noção integrada de empacotamento, e cada diretório geralmente
contém um único pacote, destino e arquivo BUILD
(Pants, outro sistema de build
baseada em Bazel, chama isso de regra 1:1:1). Idiomas com empacotamento mais fraco
frequentemente definem vários destinos por arquivo BUILD
.
Os benefícios de destinos de build menores realmente começam a aparecer em escala, porque eles
resultam em builds distribuídos mais rápidos e uma necessidade menos frequente de recriar destinos.
As vantagens tornam-se ainda mais convincentes depois que o teste entra em cena, pois
com destinos mais refinados, o sistema de build pode ser muito mais inteligente
executar apenas um subconjunto limitado de testes que podem ser afetados por um
mudar. Porque o Google acredita nos benefícios sistêmicos de usar
das metas, fizemos alguns avanços na mitigação dos pontos negativos ao investir
ferramentas para gerenciar automaticamente arquivos BUILD
e evitar sobrecarregar os desenvolvedores.
Algumas dessas ferramentas, como buildifier
e buildozer
, estão disponíveis nas
o Bazel no
Diretório buildtools
.
Minimizar a visibilidade do módulo
O Bazel e outros sistemas de build permitem que cada destino especifique uma visibilidade: um
que determina quais outros destinos podem depender dele. Um destino particular
só podem ser referenciados no próprio arquivo BUILD
. Uma segmentação pode conceder acesso
visibilidade para os destinos de uma lista explicitamente definida de arquivos BUILD
ou,
caso da visibilidade pública, para todos os destinos no espaço de trabalho.
Como na maioria das linguagens de programação, é geralmente melhor minimizar a visibilidade
o máximo possível. Em geral, as equipes do Google tornarão os alvos públicos somente se
essas metas representam bibliotecas amplamente utilizadas e disponíveis para qualquer equipe do Google.
Equipes que exigem que outros coordenem com eles antes de usar seu código irão
manter uma lista de permissões de alvos do cliente como visibilidade do alvo. Cada
os objetivos de implementação internos da equipe serão restritos apenas aos diretórios
da equipe, e a maioria dos arquivos BUILD
terá apenas um destino que não é
privados.
Gerenciamento de dependências
Os módulos precisam ser capazes de se referir uns aos outros. A desvantagem de dividir
em módulos refinados é que você precisa gerenciar as dependências
entre esses módulos (embora as ferramentas possam ajudar a automatizar isso). Expressar isso
dependências geralmente são a maior parte do conteúdo em um arquivo BUILD
.
Dependências internas
Em um projeto grande dividido em módulos refinados, a maioria das dependências é provavelmente são internos, ou seja, em outro destino definido e construído no mesmo repositório de origem. As dependências internas são diferentes das externas no que eles sejam criados a partir da origem, em vez de baixados como um artefato pré-criado. durante a execução do build. Isso também significa que não há noção de “versão” para dependências internas: um destino e todas as respectivas dependências internas estão sempre criados na mesma confirmação/revisão no repositório. Um problema que deve ser com cuidado no que diz respeito às dependências internas é a maneira de tratar dependências transitivas (Figura 1). Suponha que o destino A dependa do destino B, depende de um destino de biblioteca C comum. O destino A deve ser capaz de usar classes definidos no destino C?
Figura 1. Dependências transitivas
No que diz respeito às ferramentas subjacentes, não há problema com isso. ambos B e C serão vinculados ao destino A quando ele for construído, de modo que quaisquer símbolos definidos em C são conhecidos por A. O Bazel permitiu isso por muitos anos, mas à medida que o Google cresceu, nós começou a encontrar problemas. Suponha que B tenha sido refatorado precisavam depender de C. Se a dependência de B em C fosse então removida, A e qualquer outro o destino que usava C por meio de uma dependência em B seria corrompido. Efetivamente, a meta as dependências se tornaram parte do contrato público e nunca foram mudou. Isso significava que as dependências se acumulavam ao longo do tempo e os builds no Google começou a desacelerar.
O Google resolveu esse problema com a implementação de uma modo de dependência" no Bazel. Neste modo, o Bazel detecta se um destino tenta referenciar um símbolo sem depender diretamente dele e, em caso afirmativo, falha com uma e um comando shell que pode ser usado para inserir automaticamente o . Lançar essa mudança em toda a base de código do Google e refatorar cada um de nossos milhões de destinos de versão para listar explicitamente seus dependências foi um esforço de vários anos, mas valeu a pena. Nossas construções são agora muito mais rápido, já que os destinos têm menos dependências desnecessárias, e engenheiros são capacitados a remover dependências desnecessárias sem se preocupar sobre quebrar os alvos que dependem deles.
Como de costume, a aplicação de dependências transitivas estritas envolvia uma compensação. Isso fez
Arquivos de build mais detalhados, já que as bibliotecas usadas com frequência agora precisam ser listadas
explicitamente em muitos lugares, em vez de puxados acidentalmente, e os engenheiros
para adicionar dependências a arquivos BUILD
. Desde então
desenvolveu ferramentas que reduzem essas tarefas repetitivas, detectando automaticamente muitos
dependências e adicioná-las a um arquivo BUILD
sem a necessidade de desenvolvedores
intervenção. Mas mesmo sem essas ferramentas, descobrimos que a troca é bem
valer a pena conforme a base de código é escalonada: adicionar explicitamente uma dependência ao arquivo BUILD
.
é um custo único, mas lidar com dependências transitivas implícitas pode causar
problemas contínuos, desde que o destino de build exista. Júlio
aplica dependências transitivas rígidas
no código Java por padrão.
Dependências externas
Se uma dependência não for interna, terá que ser externa. As dependências externas são em artefatos criados e armazenados fora do sistema de build. O é importada diretamente de um repositório de artefatos (normalmente acessado pela Internet) e usados no estado em que se encontram, em vez de terem sido criados com o código-fonte. Um de as maiores diferenças entre dependências externas e internas é que dependências externas têm versões, e essas versões existem o código-fonte do projeto.
Gerenciamento de dependências automático e manual
Os sistemas de build podem permitir o gerenciamento das versões de dependências externas
manual ou automaticamente. Quando gerenciado manualmente, o arquivo de build
lista explicitamente a versão que quer baixar do repositório de artefatos;
geralmente usando uma string de versão semântica, como
como 1.1.4
. Quando gerenciado automaticamente, o arquivo de origem especifica um intervalo de
e aceitáveis. O sistema sempre faz o download da mais recente. Para
Por exemplo, o Gradle permite que uma versão de dependência seja declarada como “1.+” para especificar
que qualquer versão secundária ou de patch de uma dependência é aceitável, desde que o
a versão principal é 1.
Dependências gerenciadas automaticamente podem ser convenientes para projetos pequenos, mas elas geralmente são uma receita para um desastre em projetos de tamanho não trivial ou que sendo trabalhada por mais de um engenheiro. O problema do recurso dependências gerenciadas é que você não tem controle sobre quando a versão é atualizado. Não há como garantir que partes externas não vão (mesmo quando alegam usar o controle de versões semântico), um build que funcionou em um dia pode ficar quebrado no outro sem uma maneira fácil de detectar o que mudou ou revertê-lo para um estado de trabalho. Mesmo que o build não quebre, há podem ser mudanças sutis de comportamento ou desempenho que não são impossíveis de acompanhar.
Por outro lado, como as dependências gerenciadas manualmente exigem uma mudança na origem controle, eles podem ser facilmente descobertos e revertidos, e é possível confira uma versão mais antiga do repositório para criar com dependências mais antigas. O Bazel exige que as versões de todas as dependências sejam especificadas manualmente. Mesmo escalas moderadas, o overhead do gerenciamento manual de versões vale a pena para a estabilidade que ele oferece.
A regra da uma versão
Versões diferentes de uma biblioteca geralmente são representadas por diferentes artefatos, então, em teoria, não há motivo para que versões diferentes do mesmo a dependência não pudesse ser declarada no sistema de compilação com nomes diferentes. Dessa forma, cada destino poderia escolher qual versão da dependência usar. Isso causa muitos problemas na prática, então o Google aplica uma rígida Regra de uma versão para todas as dependências de terceiros na nossa base de código.
O maior problema em permitir várias versões é a dependência losango problema. Suponha que o destino A dependa do destino B e da v1 de um servidor biblioteca. Se o destino B for refatorado posteriormente para adicionar uma dependência à v2 do mesmo biblioteca externa, o destino A será corrompido porque agora depende implicitamente de dois versões diferentes da mesma biblioteca. Efetivamente, nunca é seguro adicionar um a nova dependência de um destino para qualquer biblioteca de terceiros com várias versões porque qualquer um dos usuários desse público-alvo já poderia estar dependendo de um para a versão anterior. Seguir a regra da uma versão torna esse conflito impossível, se um O destino adiciona uma dependência a uma biblioteca de terceiros, qualquer dependência existente já estarão na mesma versão, para que possam coexistir.
Dependências externas transitivas
Lidar com as dependências transitivas de uma dependência externa pode ser muito difícil. Muitos repositórios de artefatos, como o Maven Central, permitem artefatos para especificar dependências em versões específicas de outros artefatos no repositório. Ferramentas de build como Maven ou Gradle costumam fazer o download de cada um dependência transitiva por padrão, ou seja, adicionar uma única dependência seu projeto pode fazer com que dezenas de artefatos sejam baixados total.
Isso é muito conveniente: ao adicionar uma dependência a uma nova biblioteca, ter que rastrear cada uma das dependências transitivas dessa biblioteca e adicione todos manualmente. Mas também há uma desvantagem: porque diferentes podem depender de versões diferentes da mesma biblioteca de terceiros, isso a estratégia viola necessariamente a regra da uma versão e leva à perda de dados problema de dependência. Caso seu destino dependa de duas bibliotecas externas que usam versões diferentes da mesma dependência, não há como dizer qual você conseguir. Isso também significa que atualizar uma dependência externa pode causar falhas não relacionadas em toda a base de código se a nova versão começar a extrair versões conflitantes de algumas de suas dependências.
Por esse motivo, o Bazel não faz o download automático de dependências transitivas.
E, infelizmente, não existe uma solução perfeita. A alternativa de Bazel é exigir uma
arquivo global que lista cada um dos componentes externos
dependências e uma versão explícita usada para essa dependência em todo o
repositório de dados. Felizmente, o Bazel fornece ferramentas capazes de
gere esse arquivo contendo as dependências transitivas de um conjunto de
artefatos. Esta ferramenta pode ser executada uma vez para gerar o arquivo WORKSPACE
inicial.
de um projeto. Esse arquivo pode ser atualizado manualmente para ajustar as versões
de cada dependência.
Mais uma vez, a escolha aqui é entre conveniência e escalonabilidade. Pequena projetos podem preferir não ter que se preocupar com o gerenciamento de dependências transitivas e podem ser capazes de evitar o uso de transitivo automático dependências. Essa estratégia se torna cada vez menos atraente à medida que a organização e a base de código cresce, e os conflitos e resultados inesperados aumentam cada vez mais frequentes. Em escalas maiores, o custo de gerenciar dependências manualmente é muito menor do que o custo de lidar com problemas causados por dependência automática de projetos.
Como armazenar resultados de build em cache usando dependências externas
As dependências externas são frequentemente fornecidas por terceiros que lançam versões estáveis de bibliotecas, talvez sem fornecer código-fonte. Algumas as organizações também podem optar por disponibilizar parte do próprio código como os artefatos, permitindo que outras partes do código dependam deles como se fossem de terceiros, do que as dependências internas. Isso pode teoricamente acelerar os builds se os artefatos têm criação lenta, mas o download é rápido.
No entanto, isso também introduz muita sobrecarga e complexidade: alguém precisa responsável por criar cada um desses artefatos e fazer o upload deles para o repositório de artefatos, e os clientes precisam se manter atualizados sobre para a versão mais recente. A depuração também se torna muito mais difícil, pois diferentes partes do sistema serão construídas de diferentes pontos no e não há mais uma visualização consistente da árvore de origem.
Uma maneira melhor de resolver o problema de artefatos que demoram para serem construídos é usar um sistema de compilação que ofereça suporte ao armazenamento em cache remoto, conforme descrito anteriormente. Que o sistema de build salva os artefatos resultantes de cada build em um local compartilhado entre engenheiros. Assim, se um desenvolvedor depender de um artefato foi criado recentemente por outra pessoa, o sistema de build faz o download automático em vez de criar. Isso oferece todos os benefícios de desempenho depender diretamente dos artefatos, sem deixar de garantir que os builds sejam consistentes, como se fossem sempre construídos da mesma fonte. Esta é a usada internamente pelo Google, e o Bazel pode ser configurado para usar uma cache.
Segurança e confiabilidade de dependências externas
Depender de artefatos de fontes de terceiros é inerentemente arriscado. Há um
risco de disponibilidade se a origem de terceiros (como um repositório de artefatos)
porque todo o build pode parar se não for possível fazer o download.
uma dependência externa. Também há um risco à segurança: se o sistema de terceiros
for comprometido por um invasor, ele poderá substituir os dados
artefato com design próprio, o que permite injetar código arbitrário
no seu build. Ambos os problemas podem ser atenuados espelhando os artefatos que você
dependem dos servidores que você controla e impede que o sistema de compilação acesse
repositórios de artefatos de terceiros, como o Maven Central. A desvantagem é que
a manutenção desses espelhos exige esforço e recursos, portanto, a escolha
usá-los muitas vezes depende da escala do projeto. O problema de segurança também pode
ser completamente evitados com pouca sobrecarga, já que exige o hash de cada
artefato de terceiros seja especificado no repositório de origem, fazendo com que
falhar se o artefato for adulterado. Outra alternativa que completamente
evitar o problema é disponibilizar as dependências do seu projeto. Quando um projeto
fornecedores as dependências, ele os insere no controle de origem junto com a
do código-fonte do projeto, como fonte ou como binários. Isso significa que
que todas as dependências externas do projeto sejam convertidas em dependências
dependências. O Google usa essa abordagem internamente, verificando todos os dados
referência no Google em um diretório third_party
na raiz
da árvore de origem do Google. No entanto, isso funciona no Google somente porque nossa
sistema de controle de origem é personalizado para lidar com um monorepo extremamente grande, portanto
a disponibilização de pacotes de terceiros pode não ser uma opção para todas as organizações.