Ao analisar as páginas anteriores, um tema é repetido várias vezes: gerenciar seu próprio código é bem simples, mas gerenciar as dependências dele é muito mais difícil. Existem todos os tipos de dependências: às vezes, há uma dependência de uma tarefa (como "enviar a documentação antes de marcar uma versão como concluída") 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 sua base de código e, às vezes, você tem dependências externas na equipe ou no código ou De qualquer forma, a ideia de "preciso disso antes de poder ter isso" é algo recorrente no design de sistemas de compilação, e o gerenciamento de dependências talvez seja o job mais fundamental de um sistema de compilação.
Como lidar com módulos e dependências
Projetos que usam sistemas de compilação baseados em artefatos, como o Bazel, são divididos em um conjunto
de módulos, com módulos que expressam dependências uns dos outros com arquivos
BUILD
. A organização adequada desses módulos e dependências pode ter um enorme
efeito no desempenho do sistema de compilação e na quantidade de trabalho necessária para
manter.
Como usar módulos detalhados e a regra 1:1:1
A primeira pergunta que aparece ao estruturar um build baseado em artefato é
decidir quanta funcionalidade um módulo individual precisa abranger. No Bazel, um módulo é representado por um destino especificando uma unidade edificável como java_library
ou go_binary
. Em um extremo, todo o projeto pode estar
contido em um único módulo colocando um arquivo BUILD
na raiz e
globando recursivamente todos os arquivos de origem do projeto. Por outro lado, quase todos os arquivos de origem podem ser criados no próprio módulo, o que
exige que cada arquivo seja listado em um arquivo BUILD
a cada dois arquivos de que ele depende.
A maioria dos projetos fica em algum lugar entre esses extremos, e a escolha envolve um equilíbrio entre desempenho e manutenção. Usar um único módulo para
todo o projeto pode significar que você nunca precisa tocar no arquivo BUILD
, exceto
ao adicionar uma dependência externa, mas significa que o sistema de compilação precisa
sempre criar o projeto inteiro de uma só vez. Isso significa que ele não poderá
paralelizar ou distribuir partes do build, nem armazenar em cache partes
que já foram criadas. Um módulo por arquivo é o oposto: o sistema de compilação
tem a flexibilidade máxima para as etapas de armazenamento em cache e programação do build, mas
os engenheiros precisam gastar mais esforço para manter listas de dependências sempre que
mudam quais arquivos fazem referência a quais arquivos.
A granularidade exata varia de acordo com a linguagem e, muitas vezes, até mesmo dentro da
linguagem, mas o Google tende a favorecer módulos significativamente menores do que um pode
ser escrito em um sistema de compilação baseado em tarefas. Um binário de produção típico no
Google geralmente depende de dezenas de milhares de destinos, e até mesmo uma equipe de tamanho
médio pode ter centenas de destinos na base de código. Para linguagens como
Java que têm uma forte noção integrada de empacotamento, cada diretório geralmente
contém um único pacote, destino e arquivo BUILD
. A calça, outro sistema de compilação
baseado no Bazel, chama isso de regra 1:1:1. Os idiomas com convenções de empacotamento
mais fracos geralmente definem vários destinos por arquivo BUILD
.
Os benefícios de destinos de criação menores realmente começam a ser exibidos em escala, porque eles
geram versões distribuídas mais rápidas e uma necessidade menos frequente de recriar destinos.
As vantagens se tornam ainda mais atrativas após a entrada no teste, já que
destinos mais refinados significam que o sistema de compilação pode ser muito mais inteligente sobre
a execução de apenas um subconjunto limitado de testes que podem ser afetados por qualquer
mudança. Como o Google acredita nos benefícios sistêmicos de usar destinos
menores, dedicamos alguns esforços para reduzir as desvantagens ao investir em
ferramentas de gerenciamento automático de arquivos BUILD
para evitar sobrecarregar os desenvolvedores.
Algumas dessas ferramentas, como buildifier
e buildozer
, estão disponíveis no Bazel no diretório buildtools
.
Como minimizar a visibilidade do módulo
O Bazel e outros sistemas de compilação permitem que cada destino especifique uma visibilidade, uma propriedade que determina quais outros destinos podem depender dele. Um destino particular
só pode ser referenciado no próprio arquivo BUILD
. Um destino pode conceder visibilidade mais ampla aos destinos de uma lista definida explicitamente de arquivos BUILD
ou, no caso de visibilidade pública, a 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 no Google só vão tornar as metas públicas se
elas representarem bibliotecas amplamente disponíveis e disponíveis para qualquer equipe no Google.
As equipes que exigem que outras pessoas trabalhem com eles antes de usar o código mantêm
uma lista de permissões de destinos de clientes como a visibilidade do destino. Os
destinos de implementação internos de cada equipe serão restritos apenas aos diretórios
de propriedade da equipe, e a maioria dos arquivos BUILD
terá apenas um destino que não é
privado.
Gerenciamento de dependências
Os módulos precisam se referir uns aos outros. A desvantagem de dividir uma
base de código em módulos refinados é que você precisa gerenciar as dependências
entre esses módulos, embora as ferramentas possam ajudar a automatizar isso. A expressão dessas dependências
geralmente é a maior parte do conteúdo em um arquivo BUILD
.
Dependências internas
Em um projeto grande dividido em módulos detalhados, a maioria das dependências provavelmente vai ser interna, ou seja, em outro destino definido e criado no mesmo repositório de origem. As dependências internas são diferentes das externas porque elas são criadas a partir da origem em vez de serem transferidas por download 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 dependências internas são sempre criadas na mesma confirmação/revisão no repositório. Um problema que precisa ser tratado com relação às dependências internas é como tratar dependências transitivas (Figura 1). Suponha que o destino A dependa do destino B, que depende de um destino de biblioteca comum C. O destino A precisa usar classes definidas no destino C?
Figura 1. Dependências transitivas
Em relação às ferramentas subjacentes, não há problema com isso. Tanto B quanto C serão vinculados ao destino A quando forem criados, de modo que todos os símbolos definidos em C são conhecidos por A. O Bazel permitiu isso por muitos anos, mas, à medida que o Google cresceu, começamos a ver problemas. Suponha que B tenha sido refatorada de modo que não precise mais depender de C. Se a dependência de B em C fosse removida, A e qualquer outro destino que usasse C por uma dependência em B seriam corrompidos. As dependências de um destino se tornaram parte do contrato público dele e nunca podem ser alteradas com segurança. Isso significa que as dependências se acumularam ao longo do tempo e as versões no Google começaram a desacelerar.
Por fim, o Google resolveu esse problema introduzindo o "modo de dependência transitiva estrita" no Bazel. Nesse modo, o Bazel detecta se um destino tenta referenciar um símbolo sem depender diretamente dele e, em caso afirmativo, falha com um erro e um comando do shell que pode ser usado para inserir automaticamente a dependência. Implementar essa mudança em toda a base de código do Google e refatorar cada um dos milhões de destinos de build para listar explicitamente as dependências foi um esforço de vários anos, mas valeu a pena. Agora, nossos builds são muito mais rápidos, porque os destinos têm menos dependências desnecessárias, e os engenheiros podem remover as dependências desnecessárias sem se preocupar com os destinos que dependem deles.
A aplicação de dependências transitivas rigorosas envolvia uma compensação. Os arquivos de build eram mais detalhados, porque as bibliotecas usadas com frequência agora precisam ser listadas de forma explícita em muitos lugares, e não extraídas acidentalmente, e os engenheiros precisavam gastar mais esforço adicionando dependências aos arquivos BUILD
. Desenvolvemos
ferramentas que reduzem esse esforço para detectar automaticamente muitas dependências
ausentes e adicioná-las a arquivos BUILD
sem qualquer intervenção do
desenvolvedor. Mas, mesmo sem essas ferramentas, descobrimos que a troca vale a pena
conforme a base de código é escalonada: adicionar explicitamente uma dependência a um arquivo BUILD
é um custo único, mas lidar com dependências transitivas implícitas pode causar
problemas contínuos enquanto o destino de build existir. O Bazel aplica dependências transitivas rigorosas no código Java por padrão.
Dependências externas
Se uma dependência não for interna, ela precisará ser externa. As dependências externas são aquelas em artefatos criados e armazenados fora do sistema de compilação. A dependência é importada diretamente de um repositório de artefatos, geralmente acessada pela Internet, e usada no estado em que se encontra, em vez de ser criada da origem. Uma das maiores diferenças entre dependências externas e internas é que as dependências externas têm versões, que existem independentemente do código-fonte do projeto.
Gerenciamento de dependências manual x automático
Os sistemas de compilação podem permitir que as versões de dependências externas sejam gerenciadas
manual ou automaticamente. Quando gerenciado manualmente, o buildfile lista explicitamente a versão que quer transferir por download do repositório de artefatos, geralmente usando uma string de versão semântica, como 1.1.4
. Quando gerenciado, o arquivo de origem especifica um intervalo de
versões aceitáveis, e o sistema de compilação sempre faz o download da versão mais recente. 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 a
versão principal seja 1.
Dependências gerenciadas automaticamente podem ser convenientes para projetos pequenos, mas geralmente são uma receita para desastres em projetos de tamanho não trivial ou que estão sendo processados por mais de um engenheiro. O problema com as dependências gerenciadas automaticamente é que você não tem controle sobre quando a versão é atualizada. Não há como garantir que as partes externas não façam atualizações interruptivas, mesmo quando elas alegam usar o controle de versões semântico. Portanto, uma versão que funcionava em um dia pode ser corrompida no dia seguinte, sem uma maneira fácil de detectar o que mudou ou de reverter para um estado de trabalho. Mesmo que a versão não quebre, pode haver mudanças sutis de comportamento ou desempenho que são impossíveis de rastrear.
Por outro lado, como as dependências gerenciadas manualmente exigem uma mudança no controle de origem, elas podem ser facilmente descobertas e revertidas, e é possível conferir 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. Em escalas até mesmo moderadas, a sobrecarga do gerenciamento manual de versões vale a pena pela disponibilidade de estabilidade.
A regra de versão única
Versões diferentes de uma biblioteca geralmente são representadas por artefatos distintos. Portanto, não há motivo para que não haja motivo para que versões diferentes da mesma dependência externa não pudessem ser declaradas no sistema de compilação com nomes diferentes. Dessa forma, cada destino poderia escolher qual versão da dependência ele queria usar. Isso causa muitos problemas na prática, então o Google aplica uma regra rígida de uma versão a todas as dependências de terceiros na nossa base de código.
O maior problema com a permissão de várias versões é o problema da dependência de diamantes. Suponha que o destino A dependa do destino B e da v1 de uma biblioteca externa. Se o destino B for refatorado posteriormente para adicionar uma dependência à v2 da mesma biblioteca externa, o destino A será interrompido porque agora depende implicitamente de duas versões diferentes da mesma biblioteca. Eficientemente, nunca é seguro adicionar uma nova dependência de um destino a qualquer biblioteca de terceiros com várias versões, porque qualquer usuário desse destino já pode estar dependendo de uma versão diferente. Seguir a regra de uma versão torna esse conflito impossível. Se um destino adicionar uma dependência a uma biblioteca de terceiros, todas as dependências existentes já estarão na mesma versão, para que possam coexistir alegremente.
Dependências externas transitivas
Lidar com as dependências transitivas de uma dependência externa pode ser particularmente difícil. Muitos repositórios de artefatos, como o Maven Central, permitem que os artefatos especifiquem dependências em versões específicas de outros artefatos no repositório. Ferramentas de compilação como Maven ou Gradle costumam fazer o download recursivamente de cada dependência transitiva por padrão, o que significa que a adição de uma única dependência no seu projeto pode fazer com que dezenas de artefatos sejam transferidos por download no total.
Isso é muito conveniente: ao adicionar uma dependência a uma nova biblioteca, seria muito difícil ter que rastrear cada uma das dependências transitivas dela e adicionar todas manualmente. Mas há uma grande desvantagem: como bibliotecas diferentes podem depender de diferentes versões da mesma biblioteca de terceiros, essa estratégia viola necessariamente a regra de versão única e leva ao problema de dependência de diamantes. Se o destino depender de duas bibliotecas externas que usam versões diferentes da mesma dependência, não haverá como dizer qual você receberá. Isso também significa que a atualização de uma dependência externa pode causar falhas aparentemente não relacionadas em toda a base de código, se a nova versão começar a extrair versões conflitantes de algumas das dependências.
Por isso, o Bazel não faz o download automático de dependências transitivas.
Infelizmente, não há uma solução mágica. A alternativa ao Bazel é exigir um arquivo global que liste cada uma das dependências externas do repositório e uma versão explícita usada para essa dependência em todo o repositório. Felizmente, o Bazel fornece ferramentas que podem gerar automaticamente esse arquivo contendo as dependências transitivas de um conjunto de artefatos do Maven. Essa ferramenta pode ser executada uma vez para gerar o arquivo WORKSPACE
inicial
para um projeto, e 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. Projetos pequenos talvez não precisem se preocupar em gerenciar dependências transitivas e usar as dependências transitivas automáticas. Essa estratégia vai ficando cada vez menos atraente à medida que a organização e a base de código crescem, e os conflitos e resultados inesperados se tornam 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 pelo gerenciamento automático de dependências.
Como armazenar em cache os resultados do build usando dependências externas
As dependências externas geralmente são fornecidas por terceiros que lançam versões estáveis de bibliotecas, talvez sem fornecer o código-fonte. Algumas organizações também podem optar por disponibilizar alguns códigos próprios como artefatos. Assim, outros códigos podem depender delas como de terceiros, não de dependências internas. Teoricamente, isso pode acelerar os builds se os artefatos forem lentos para serem criados, mas o download deles for rápido.
No entanto, isso também gera muita sobrecarga e complexidade: alguém precisa ser responsável por criar cada um desses artefatos e fazer upload deles para o repositório de artefatos, e os clientes precisam garantir que estejam sempre atualizados com a versão mais recente. A depuração também se torna muito mais difícil, porque partes diferentes do sistema são criadas de diferentes pontos no repositório e não há mais uma visualização consistente da árvore de origem.
Uma maneira melhor de resolver o problema de artefatos que demoram muito para criar é usar um sistema de compilação com suporte ao armazenamento em cache remoto, conforme descrito anteriormente. Esse sistema de compilação salva os artefatos resultantes de cada build em um local que é compartilhado entre engenheiros. Portanto, se um desenvolvedor depender de um artefato que foi criado recentemente por outra pessoa, o sistema de compilação vai fazer o download automaticamente em vez de o criar. Isso oferece todos os benefícios de desempenho, dependendo diretamente dos artefatos, sem deixar de garantir que os builds sejam consistentes como se fossem sempre da mesma fonte. Essa é a estratégia usada internamente pelo Google, e o Bazel pode ser configurado para usar um cache remoto.
Segurança e confiabilidade de dependências externas
Depende de artefatos de fontes de terceiros que são arriscados. Há um
risco de disponibilidade se a origem de terceiros, como um repositório de artefatos, ficar
inativa, porque toda a versão poderá ser interrompida se não for possível fazer o download
de uma dependência externa. Há também um risco de segurança: se o sistema de terceiros
for comprometido por um invasor, ele poderá substituir o artefato
mencionado por um design próprio, permitindo injetar um código arbitrário
no build. Os dois problemas podem ser atenuados com o espelhamento de todos os artefatos de que você
depende para os servidores que você controla e bloqueando o acesso do sistema de compilação
a repositórios de artefatos de terceiros, como o Maven Central. A desvantagem é que
esses espelhos exigem esforço e recursos para manter. Portanto, a escolha de
usá-los geralmente depende da escala do projeto. O problema de segurança também
pode ser completamente evitado com pouca sobrecarga, exigindo que o hash de cada
artefato de terceiros seja especificado no repositório de origem, fazendo com que a versão
falhe se o artefato for adulterado. Outra alternativa que evita completamente
o problema é fornecer as dependências do projeto. Quando um projeto
fornece suas dependências, ele as verifica no controle de origem junto ao
código-fonte do projeto, como de origem ou como binários. Isso significa
que todas as dependências externas do projeto são convertidas em dependências
internas. O Google usa essa abordagem internamente, verificando cada biblioteca
terceirizada referenciada em um diretório third_party
na raiz
da árvore de origem do Google. No entanto, isso funciona no Google apenas porque o
sistema de controle de origem do Google é personalizado para lidar com um monorepo que é muito grande. Por isso, o fornecimento pode não ser uma opção para todas as organizações.