Ao analisar as páginas anteriores, um tema se repete várias vezes: gerenciar seu próprio código é bastante simples, mas gerenciar as dependências dele é muito mais difícil. Há 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 de 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, tem dependências externas em código ou dados de propriedade de outra equipe (na sua organização ou de terceiros). Mas, em qualquer caso, a ideia de "preciso disso antes de poder ter isso" é algo que se repete no design de sistemas de build, e o gerenciamento de dependências é talvez o trabalho mais fundamental de um sistema de build.
Como lidar com módulos e dependências
Projetos que usam sistemas de build 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 por meio de BUILD
arquivos. A organização adequada desses módulos e dependências pode ter um enorme
efeito no desempenho do sistema de build e na quantidade de trabalho necessária para a
manutenção.
Como usar módulos detalhados e a regra 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 de build, como uma
java_library ou um go_binary. Em um extremo, todo o projeto pode ser
contido em um único módulo, colocando um arquivo BUILD na raiz e
agrupando recursivamente todos os arquivos de origem desse projeto. No outro
extremo, quase todos os arquivos de origem podem ser transformados em módulos próprios, efetivamente
exigindo que cada arquivo liste em um BUILD arquivo todos os outros arquivos de que depende.
A maioria dos projetos fica em algum lugar entre esses extremos, e a escolha envolve uma
compensação entre desempenho e capacidade de 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 isso significa que o sistema de build sempre precisa criar todo o projeto de uma só vez. Isso significa que ele não poderá
paralelizar ou distribuir partes do build nem armazenar em cache as partes
que já foram criadas. Um módulo por arquivo é o oposto: o sistema de build
tem a máxima flexibilidade no armazenamento em cache e no agendamento de etapas do build, mas
os engenheiros precisam gastar mais esforço na manutenção de listas de dependências sempre que
mudam quais arquivos referenciam quais.
Embora a granularidade exata varie de acordo com o idioma (e muitas vezes até mesmo dentro
do idioma), o Google tende a favorecer módulos significativamente menores do que os que normalmente são
escritos em um sistema de build 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 moderado
pode ter várias centenas de destinos na base de código. Para linguagens como
Java, que têm uma noção integrada forte de empacotamento, cada diretório geralmente
contém um único pacote, destino e arquivo BUILD (o Pants, outro sistema de build
baseado no Bazel, chama isso de regra 1:1:1). Linguagens com convenções de empacotamento
mais fracas geralmente definem vários destinos por arquivo BUILD.
Os benefícios de destinos de build menores realmente começam a aparecer em escala, porque eles
levam a builds distribuídos mais rápidos e a uma necessidade menos frequente de recriar destinos.
As vantagens se tornam ainda mais convincentes depois que os testes entram em cena, já que
destinos mais detalhados significam que o sistema de build pode ser muito mais inteligente ao
executar apenas um subconjunto limitado de testes que podem ser afetados por uma determinada
mudança. Como o Google acredita nos benefícios sistêmicos do uso de destinos menores, fizemos alguns avanços na mitigação da desvantagem investindo em
ferramentas para gerenciar automaticamente arquivos BUILD para evitar sobrecarregar os desenvolvedores.
Algumas dessas ferramentas, como buildifier e buildozer, estão disponíveis com
o Bazel no
buildtools diretório.
Como minimizar a visibilidade do módulo
O Bazel e outros sistemas de build 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 BUILD arquivo. Um destino pode conceder maior
visibilidade aos destinos de uma lista de arquivos BUILD definidos explicitamente 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. Geralmente, as equipes do Google tornam os destinos públicos apenas se
eles representarem bibliotecas amplamente usadas disponíveis para qualquer equipe do Google.
As equipes que exigem que outras pessoas se coordenem com elas antes de usar o código vão
manter uma lista de permissões de destinos de clientes como a visibilidade do destino. Os
destinos de implementação interna de cada equipe serão restritos apenas aos diretórios
de propriedade da equipe, e a maioria dos BUILD arquivos terá apenas um destino que não seja
particular.
Gerenciamento de dependências
Os módulos precisam poder se referir uns aos outros. A desvantagem de dividir uma
base de código em módulos detalhados é 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 acaba sendo 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 será interna, ou seja, em outro destino definido e criado no mesmo repositório de origem. As dependências internas diferem das externas em que são criadas a partir da origem, em vez de serem baixadas 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 dele são sempre criados no mesmo commit/revisão no repositório. Um problema que precisa ser tratado com cuidado em relação às dependências internas é como tratar as 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 pode usar classes definidas no destino C?
Figura 1. Dependências transitivas
No que diz respeito às ferramentas subjacentes, não há problema com isso. Tanto B quanto C serão vinculados ao destino A quando ele for criado, então todos os símbolos definidos em C serão conhecidos por A. O Bazel permitiu isso por muitos anos, mas, à medida que o Google cresceu, nós começamos a ver problemas. Suponha que B tenha sido refatorado de modo que não precisasse mais depender de C. Se a dependência de B em C fosse removida, A e qualquer outro destino que usasse C por meio de uma dependência em B seriam interrompidos. Efetivamente, as dependências de um destino se tornaram parte do contrato público e nunca puderam ser alteradas com segurança. Isso significava que as dependências se acumulavam ao longo do tempo e os builds no Google começaram a ficar mais lentos.
O Google acabou resolvendo esse problema ao introduzir um "modo de dependência transitiva estrita" no Bazel. Nesse modo, o Bazel detecta se um destino tenta referenciar um símbolo sem depender dele diretamente e, em caso afirmativo, falha com um erro e um comando de shell que pode ser usado para inserir automaticamente a dependência. A implementação dessa mudança em toda a base de código do Google e a refatoração de cada um dos nossos milhões de destinos de build para listar explicitamente as dependências foram um esforço de vários anos, mas valeu a pena. Nossos builds agora são muito mais rápidos, já que os destinos têm menos dependências desnecessárias, e os engenheiros podem remover as dependências de que não precisam sem se preocupar em interromper os destinos que dependem delas.
Como de costume, a aplicação de dependências transitivas estritas envolveu uma compensação. Isso tornou
arquivos de build mais detalhados, já que as bibliotecas usadas com frequência agora precisam ser listadas
explicitamente em muitos lugares, em vez de serem extraídas incidentalmente, e os engenheiros
precisavam gastar mais esforço adicionando dependências aos BUILD arquivos. Desde então, desenvolvemos ferramentas que reduzem esse trabalho detectando automaticamente muitas dependências ausentes e adicionando-as a um arquivo BUILD sem qualquer intervenção do desenvolvedor. Mas, mesmo sem essas ferramentas, descobrimos que a compensação vale a pena à medida que 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 enquanto o destino do build existir. O Bazel
aplica dependências transitivas estritas
ao 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 build. A dependência é importada diretamente de um repositório de artefatos (normalmente acessado pela Internet) e usada como está, em vez de ser criada a partir da origem. Uma das maiores diferenças entre dependências externas e internas é que as dependências externas têm versões, e essas versões existem independentemente do código-fonte do projeto.
Gerenciamento de dependências automático x manual
Os sistemas de build podem permitir que as versões de dependências externas sejam gerenciadas
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
1.1.4. Quando gerenciado automaticamente, o arquivo de origem especifica um intervalo de
versões aceitáveis, e o sistema de build sempre baixa a 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.
As 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 trabalhados por mais de um engenheiro. O problema com dependências gerenciadas automaticamente é que você não tem controle sobre quando a versão é atualizada. Não há como garantir que terceiros não façam atualizações significativas (mesmo quando afirmam usar a versão semântica), então um build que funcionou um dia pode ser interrompido no dia seguinte sem uma maneira fácil de detectar o que mudou ou de reverter para um estado de funcionamento. Mesmo que o build não seja interrompido, 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 fazer o check-out de 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 em escalas moderadas, a sobrecarga do gerenciamento manual de versões vale a pena pela estabilidade que oferece.
A regra de uma versão
Versões diferentes de uma biblioteca geralmente são representadas por artefatos diferentes, portanto, em teoria, não há motivo para que versões diferentes da mesma dependência externa não possam ser declaradas no sistema de build com nomes diferentes. Dessa forma, cada destino pode escolher qual versão da dependência quer usar. Isso causa muitos problemas na prática, então o Google aplica uma regra estrita 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 é o problema de dependência de diamante. Suponha que o destino A dependa do destino B e da versão 1 de uma biblioteca externa. Se o destino B for refatorado posteriormente para adicionar uma dependência na versão 2 da mesma biblioteca externa, o destino A será interrompido porque agora depende implicitamente de duas versões diferentes da mesma biblioteca. Efetivamente, nunca é seguro adicionar uma nova dependência de um destino a qualquer biblioteca de terceiros com várias versões, porque qualquer um dos usuários 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 em uma biblioteca de terceiros, todas as dependências atuais 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 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 build como Maven ou Gradle geralmente baixam recursivamente cada dependência transitiva por padrão, o que significa que adicionar uma única dependência em seu projeto pode causar o download de dezenas de artefatos no total.
Isso é muito conveniente: ao adicionar uma dependência em uma nova biblioteca, seria muito difícil rastrear cada uma das dependências transitivas dessa biblioteca e adicioná-las manualmente. Mas também há uma grande desvantagem: como bibliotecas diferentes podem depender de versões diferentes da mesma biblioteca de terceiros, essa estratégia viola necessariamente a regra de uma versão e leva ao problema de dependência de diamante. Se o destino depender de duas bibliotecas externas que usam versões diferentes da mesma dependência, não será possível saber qual delas você vai 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 esse motivo, o Bazel não baixa dependências transitivas automaticamente.
E, infelizmente, não há uma solução mágica. A alternativa do Bazel é exigir um arquivo
global que liste todas as 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 oferece ferramentas que podem gerar automaticamente
um 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
de 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 podem preferir não se preocupar com o gerenciamento de dependências transitivas e podem usar dependências transitivas automáticas. Essa estratégia se torna cada vez menos atraente à medida que a organização e a base de código crescem, e os conflitos e resultados inesperados se tornam mais frequentes. Em escalas maiores, o custo do gerenciamento manual de dependências é muito menor do que o custo de lidar com problemas causados pelo gerenciamento automático de dependências.
Como armazenar em cache resultados de build usando dependências externas
As dependências externas são fornecidas com mais frequência por terceiros que lançam versões estáveis de bibliotecas, talvez sem fornecer código-fonte. Algumas organizações também podem optar por disponibilizar parte do próprio código como artefatos, permitindo que outras partes do código dependam deles como dependências de terceiros, em vez de internas. Isso pode acelerar teoricamente os builds se os artefatos forem lentos para criar, mas rápidos para baixar.
No entanto, isso também introduz muita sobrecarga e complexidade: alguém precisa de 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 eles permaneçam atualizados com a versão mais recente. A depuração também se torna muito mais difícil porque diferentes partes do sistema foram criadas em pontos diferentes do 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 levam muito tempo para serem criados é usar um sistema de build que ofereça suporte ao armazenamento em cache remoto, conforme descrito anteriormente. Esse sistema de build salva os artefatos resultantes de cada build em um local compartilhado entre os engenheiros. Assim, se um desenvolvedor depender de um artefato que foi criado recentemente por outra pessoa, o sistema de build fará o download dele automaticamente em vez de criá-lo. Isso oferece todos os benefícios de desempenho de depender diretamente de artefatos, garantindo que os builds sejam tão consistentes quanto se fossem sempre criados na mesma origem. 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
Depender de artefatos de fontes de terceiros é inerentemente arriscado. Há um
risco de disponibilidade se a fonte de terceiros (como um repositório de artefatos) ficar
inativa, porque todo o build poderá ser interrompido se não for possível baixar
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 referenciado
por um design próprio, permitindo que ele injete código arbitrário
no seu build. Ambos os problemas podem ser mitigados espelhando todos os artefatos de que você
depende em servidores que você controla e bloqueando o acesso do sistema de build a
repositórios de artefatos de terceiros, como o Maven Central. A compensação é que
esses espelhos exigem esforço e recursos para serem mantidos. Portanto, a escolha de
usá-los geralmente depende da escala do projeto. O problema de segurança também pode
ser evitado completamente com pouca sobrecarga, exigindo que o hash de cada
artefato de terceiros seja especificado no repositório de origem, fazendo com que o build
falhe se o artefato for adulterado. Outra alternativa que evita completamente
o problema é disponibilizar as dependências do projeto. Quando um projeto
disponibiliza as dependências, ele as verifica no controle de origem junto com o
código-fonte do projeto, como 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 todas as bibliotecas de terceiros
referenciadas no Google 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 é criado 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.
