Nas páginas anteriores, um tema se repete repetidamente: gerenciar seu próprio código é bastante simples, mas gerenciar as dependências dele é muito mais difícil. Há todos os tipos de dependência: às vezes, há uma dependência em uma tarefa (como "enviar a documentação antes de eu 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, há dependências externas na sua equipe ou em um terceiro que pertence à sua organização ou a um terceiro. De qualquer forma, a ideia de "Preciso disso antes de ter isso" é algo que se repete repetidamente 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
Os projetos que usam sistemas de build baseados em artefatos, como o Bazel, são divididos em um conjunto
de módulos, em que os módulos expressam dependências uns com os outros usando arquivos
BUILD
. 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
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 precisa abranger. No Bazel,
um módulo é representado por um destino que especifica uma unidade compilável, como uma
java_library
ou uma go_binary
. Em um extremo, o projeto inteiro pode ser
contido em um único módulo ao colocar um arquivo BUILD
na raiz e
globalmente agrupando todos os arquivos de origem do projeto. Por outro lado, quase todos os arquivos de origem podem ser transformados no próprio módulo, exigindo efetivamente
que cada arquivo seja listado em um arquivo BUILD
todos os outros de que depende.
A maioria dos projetos está entre esses extremos, e a escolha envolve um equilíbrio entre desempenho e capacidade de manutenção. O uso de um único módulo para
todo o projeto pode significar que você nunca precisará tocar no arquivo BUILD
, exceto
ao adicionar uma dependência externa. No entanto, isso significa que o sistema de build precisa
criar o projeto inteiro de uma só vez. Isso significa que não será possível carregar em paralelo ou distribuir partes do build nem armazenar em cache partes já criadas. Um módulo por arquivo é o oposto: o sistema de build
tem a flexibilidade máxima no armazenamento em cache e na programação das etapas do build, mas
os engenheiros precisam se esforçar mais na manutenção de listas de dependências sempre
que mudam os arquivos que fazem referência a elas.
Embora a granularidade exata varie de acordo com a linguagem (e muitas vezes até mesmo dentro
da linguagem), o Google tende a favorecer módulos significativamente menores do que um normalmente
grava 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 dentro da 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
. Pants, outro sistema de build
baseado no Bazel, chama isso de regra 1:1:1. As linguagens com convenções de empacotamento
mais fracas costumam definir vários destinos por arquivo BUILD
.
Os benefícios de destinos de build menores começam a aparecer em escala porque
levam a builds distribuídos mais rápidos e a uma necessidade menos frequente de recriar destinos.
As vantagens se tornam ainda mais atraentes quando o teste entra em cena, já que
destinos mais refinados significam que o sistema de build pode ser muito mais inteligente
ao executar 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, avançamos para atenuar a desvantagem ao investir em
ferramentas para gerenciar automaticamente os arquivos BUILD
e evitar sobrecarregar os desenvolvedores.
Algumas dessas ferramentas, como buildifier
e buildozer
, estão disponíveis com 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, uma
propriedade que determina quais outros destinos podem depender dela. 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. Geralmente, as equipes do Google tornarão públicos os alvos somente se os alvos representarem bibliotecas amplamente utilizadas disponíveis para qualquer equipe do Google.
As equipes que exigem que outros coordenem com eles antes de usar o código vão
manter uma lista de permissões de alvos de clientes como a visibilidade do alvo. 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 seja
particular.
Gerenciamento de dependências
Os módulos precisam ser capazes de 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). Expressar essas
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 refinados, 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 são diferentes das externas porque são criadas com base na origem, e não 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 na mesma confirmação/revisão no repositório. Um problema que precisa ser tratado com cuidado em 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 C comum. O destino A deve ser capaz de 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 criados. Portanto, 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 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 usava C por meio de uma dependência em B quebraria. Efetivamente, as dependências de um destino se tornaram parte do contrato público e nunca podiam ser alteradas com segurança. Isso significa que as dependências acumuladas ao longo do tempo e os builds no Google começaram a ficar mais lentos.
O Google acabou resolvendo esse problema introduzindo um "modo de dependência transitiva estrita" no Bazel. Nesse modo, o Bazel detecta se um destino tenta fazer referência a 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 a dependência automaticamente. Lançar essa mudança em toda a base de código do Google e refatorar cada um dos nossos 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 estão muito mais rápidos, já que os destinos têm menos dependências desnecessárias, e os engenheiros podem remover dependências desnecessárias sem se preocupar em quebrar destinos que dependam delas.
Como de costume, a aplicação de dependências transitivas rígidas envolvia uma compensação. Isso deixava
os 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 acidentalmente, e os engenheiros
precisavam dedicar mais esforço para adicionar dependências aos arquivos BUILD
. Desde então,
desenvolvemos ferramentas que reduzem essas tarefas repetitivas, detectando automaticamente muitas dependências
ausentes e adicionando-as a arquivos BUILD
sem qualquer intervenção
do desenvolvedor. No entanto, mesmo sem essas ferramentas, descobrimos que a compensação vale a pena
à medida que a base do código é dimensionada: 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. Ele
aplica dependências transitivas rígidas
ao código Java por padrão (link em inglês).
Dependências externas
Se uma dependência não for interna, terá que ser externa. 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 no estado em que se encontra, em vez de ser criada com base na origem. Uma das maiores diferenças entre dependências externas e internas é que dependências externas têm versões, que existem independentemente do código-fonte do projeto.
Gerenciamento de dependências automático e 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 vai ser transferida por download 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 faz o download da 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 em que mais de um engenheiro trabalha. O problema das 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 interruptivas (mesmo quando alegam usar o controle de versões semântico). Portanto, um build que funcionou em um dia pode ser corrompido no próximo sem uma maneira fácil de detectar o que mudou ou revertê-lo para um estado funcional. Mesmo que o build não seja corrompido, 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 alteração no controle de origem, elas podem ser facilmente descobertas e revertidas. Além disso, é possível verificar 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 moderadas, a sobrecarga do gerenciamento manual de versões vale a pena pela estabilidade que ele oferece.
A regra da uma versão
Versões diferentes de uma biblioteca geralmente são representadas por artefatos distintos. 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 poderia escolher qual versão da dependência queria usar. Isso causa muitos problemas na prática. Por isso, o Google aplica uma regra de uma versão estrita para todas as dependências de terceiros na nossa base de código.
O maior problema ao permitir várias versões é a dependência diamante. 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á corrompido 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 pode estar dependendo de uma versão diferente. Seguir a regra de uma versão impossibilita esse conflito. Se um destino adicionar uma dependência a uma biblioteca de terceiros, todas as dependências existentes já estarão nessa 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 fazem o download recursivo de cada dependência transitiva por padrão, o que significa que adicionar uma única dependência ao projeto pode fazer com que dezenas de artefatos sejam baixados no total.
Isso é muito conveniente: ao adicionar uma dependência a uma nova biblioteca, seria um grande problema ter que 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 distintas da mesma biblioteca de terceiros, essa estratégia necessariamente viola a regra de uma versão e causa o problema de dependência diamante. Se o destino depender de duas bibliotecas externas que usam versões diferentes da mesma dependência, não há como saber qual você vai escolher. 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 faz o download automático de dependências transitivas.
Infelizmente, não há uma solução perfeita. A alternativa do 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 geram 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. 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 ter que se preocupar com o gerenciamento de dependências transitivas por conta própria e podem se livrar do uso de 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 cada vez 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 resultados de build em cache usando dependências externas
As dependências externas são geralmente 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 parte do próprio código como artefatos, permitindo que outras partes do código dependam deles como terceiros em vez de dependências internas. Isso pode teoricamente acelerar os builds se a criação dos artefatos for lenta, mas o download for rápido.
No entanto, isso também introduz muita sobrecarga e complexidade: alguém precisa ser responsável por criar cada um desses artefatos e fazer o upload deles para o repositório de artefatos, e os clientes precisam garantir que estejam atualizados com a versão mais recente. A depuração também se torna muito mais difícil, porque partes diferentes do sistema serão criadas a partir de pontos distintos 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 para ser criados é usar um sistema de build que ofereça suporte ao armazenamento em cache remoto, conforme descrito anteriormente. Esse sistema salva os artefatos resultantes de cada build em um local compartilhado entre engenheiros. Assim, se um desenvolvedor depender de um artefato criado recentemente por outra pessoa, o sistema de build vai fazer o download automaticamente em vez de criá-lo. Isso oferece todos os benefícios de desempenho de depender diretamente dos artefatos e ainda garantir que os builds sejam tão consistentes, como se tivessem sido sempre criados da 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 pode parar 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
referenciado por um design próprio, permitindo injetar um código arbitrário
no build. Ambos os problemas podem ser atenuados espelhando quaisquer artefatos de que você depende nos servidores que você controla e bloqueando seu sistema de compilação de acessar repositórios de artefatos de terceiros, como o Maven Central. A desvantagem é que
esses espelhos exigem esforço e recursos para serem mantidos, portanto, a escolha de
usá-los com frequência depende da escala do projeto. Esse 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 o build
falhe se o artefato for adulterado. Outra alternativa que evita
completamente o problema é disponibilizar as dependências do seu projeto. Quando um projeto
fornece as dependências, ele as coloca no controle de origem junto com o
código-fonte do projeto, seja 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 é personalizado para lidar com um monorepo extremamente grande. Portanto, os fornecedores podem não ser uma opção para todas as organizações.