Gestão de dependências

Analisando as páginas anteriores, um tema se repete: 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 em 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, há dependências internas em outra parte da sua base de código e, às vezes, você tem dependências internas em outra parte da sua base de código ou em dados de uma organização ou de terceiros. Mas, em qualquer caso, a ideia de "Preciso que antes de ter isso" é algo que se repete repetidamente no design dos sistemas de build, e gerenciar dependências talvez seja a tarefa 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, com módulos expressando dependências entre si por meio de arquivos BUILD. A organização adequada desses módulos e dependências pode ter um enorme efeito sobre o desempenho do sistema de build e quanto trabalho é necessário para manter.

Como usar módulos detalhados e a regra individual

A primeira pergunta que surge ao estruturar um build baseado em artefato é decidir quanta funcionalidade um módulo individual vai abranger. No Bazel, um módulo é representado por um destino que especifica uma unidade compilável, como uma java_library ou go_binary. Em um extremo, o projeto inteiro pode estar em um único módulo colocando um arquivo BUILD na raiz e reunindo todos os arquivos de origem desse projeto de maneira recursiva. Por outro lado, quase todos os arquivos de origem podem ser feitos no próprio módulo, exigindo efetivamente que cada arquivo seja listado em um arquivo BUILD a cada outro de que depende.

A maioria dos projetos fica entre esses extremos, e a escolha envolve uma compensação entre desempenho e manutenção. Usar 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, mas isso significa que o sistema de build precisa sempre criar o projeto inteiro de uma só vez. Isso significa que ele não será capaz de carregar em paralelo ou distribuir partes da versão em paralelo, nem armazenar em cache partes que já foram 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 de etapas do build, mas os engenheiros precisam dedicar mais esforço para manter as listas de dependências sempre que mudam quais arquivos fazem referência a qual deles.

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 gravaria 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 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. As linguagens com convenções de empacotamento mais fracas definem vários destinos por arquivo BUILD.

Os benefícios de destinos de build menores realmente começam a aparecer em escala, porque levam a builds distribuídos mais rápidos e à necessidade menos frequente de recriar os destinos. As vantagens ficam ainda mais atraentes depois que os testes entram em cena, já que destinos mais refinados significam que o sistema de compilação pode ser muito mais inteligente ao executar apenas um subconjunto limitado de testes que podem ser afetados por qualquer alteração. Como o Google acredita nos benefícios sistêmicos de usar destinos menores, avançamos alguns passos na mitigação da desvantagem, investindo em ferramentas para gerenciar automaticamente arquivos BUILD e não sobrecarregar os desenvolvedores.

Algumas dessas ferramentas, como buildifier e buildozer, estão disponíveis com o Bazel no diretório buildtools (em inglês).

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 dela. Um destino particular só pode ser referenciado dentro do próprio arquivo BUILD. Um destino pode conceder visibilidade mais ampla aos destinos de uma lista explicitamente definida 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 tornam os destinos públicos somente se eles representam bibliotecas amplamente usadas disponíveis para qualquer equipe do Google. As equipes que exigem que outras pessoas trabalhem com elas antes de usar o código vão manter uma lista de permissões com os segmentos de clientes como visibilidade dos objetivos. Os destinos de implementação internos de cada equipe serão restritos a apenas 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 se referir entre si. A desvantagem de dividir uma base de código em módulos refinados é que você precisa gerenciar as dependências desses módulos, embora as ferramentas possam ajudar a automatizar isso. Expressar essas dependências geralmente é 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 diferem das externas porque são criadas a partir da origem em vez de 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 respectivas dependências internas 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 comum C. O destino A deve poder usar classes definidas no destino C?

Dependências transitivas

Figura 1. Dependências transitivas

No que diz respeito às ferramentas subjacentes, não há problema com isso. Tanto B e C serão vinculados ao destino A quando forem criados, de modo que todos os símbolos definidos em C sejam conhecidos por A. O Bazel permitiu isso por muitos anos, mas, conforme o Google cresceu, começamos a ver problemas. Suponha que B tenha sido refatorado 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 de B seriam corrompidos. Efetivamente, as dependências de um destino se tornaram parte do contrato público e nunca podiam ser alteradas com segurança. Isso significava que as dependências acumuladas ao longo do tempo e os builds no Google começaram a ficar mais lentos.

O Google resolveu esse problema introduzindo um "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 de shell que pode ser usado para inserir a dependência automaticamente. Implementar essa mudança em toda a base de código do Google e refatorar cada um de 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. 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 dependências de que não precisam sem se preocupar com quebrar as metas que dependem delas.

Como de costume, aplicar dependências transitivas estritas envolvia uma compensação. Ele tornava os arquivos de build mais detalhados, já que agora as bibliotecas usadas com frequência precisam ser listadas explicitamente em muitos lugares, em vez de extraídas por acidente, e os engenheiros precisavam se esforçar mais para adicionar dependências a arquivos BUILD. Desde então, desenvolvemos ferramentas que reduzem esse trabalho detectando automaticamente muitas dependências ausentes e as adicionando a arquivos BUILD sem qualquer intervenção do desenvolvedor. Mas, mesmo sem essas ferramentas, descobrimos que vale a pena fazer isso à medida que a base de 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. O Bazel aplica dependências transitivas estritas no código Java por padrão.

Dependências externas

Se uma dependência não é interna, ela precisa 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 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 versus manual

Os sistemas de build podem permitir que as versões de dependências externas sejam gerenciadas de forma manual ou automática. Quando gerenciado manualmente, o arquivo de build 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 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.

As dependências gerenciadas automaticamente podem ser convenientes para projetos pequenos, mas geralmente são uma receita para o desastre em projetos de tamanho incomum ou em que mais de um engenheiro está trabalhando. 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 externos não façam atualizações interruptivas, mesmo quando alegam usar um controle de versões semântico. Assim, 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 de funcionamento. Mesmo que o build não falhe, pode haver comportamentos sutis ou mudanças de performance impossíveis de rastrear.

Por outro lado, como as dependências gerenciadas manualmente exigem uma mudança no controle da 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. Mesmo em escalas moderadas, a sobrecarga do gerenciamento manual de versões vale a pena pela estabilidade que ele oferece.

A regra da versão única

Versões diferentes de uma biblioteca geralmente são representadas por artefatos diferentes. 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 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 é o problema da dependência losango. 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 na 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 já pode depender de uma versão diferente. Seguir a regra de versão única 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 nessa mesma versão, para que possam coexistir feliz.

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 de cada dependência transitiva de maneira recursiva por padrão. Isso significa que a adição de uma única dependência ao projeto pode fazer com que o download de dezenas de artefatos seja feito 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 distintas da mesma biblioteca de terceiros, essa estratégia viola necessariamente a regra de uma versão e causa o problema de dependência diamante. Se o destino depende de duas bibliotecas externas que usam versões diferentes da mesma dependência, não há como saber qual você terá. 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. E, 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 capazes de 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. 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 e podem conseguir 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 aumentam, e conflitos e resultados inesperados ficam 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

Na maioria das vezes, as dependências externas 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 parte do próprio código como artefatos, permitindo que outras partes de código dependam delas como de terceiros em vez de dependências internas. Teoricamente, isso pode acelerar os builds se os artefatos forem lentos, mas rápidos de fazer o download.

No entanto, isso também gera 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 de pontos distintos no repositório e não haverá mais uma visualização consistente da árvore de origem.

Uma maneira melhor de resolver o problema de artefatos que demoram muito para ser criados é usar um sistema de compilação 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 engenheiros. Assim, se um desenvolvedor depender de um artefato criado recentemente por outra pessoa, o sistema vai fazer o download automaticamente em vez de criá-lo. Isso oferece todos os benefícios de desempenho de depender diretamente dos artefatos, além de garantir que os builds sejam consistentes como se fossem 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 das 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 desacelerado até ser interrompido 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, o invasor poderá substituir o artefato referenciado por um design próprio, permitindo que ele injete código arbitrário no build. Os dois problemas podem ser atenuados ao espelhar os artefatos de que você depende nos servidores que você controla e bloquear o acesso do sistema de build a 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. 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 o build falhe se o artefato for adulterado. Outra alternativa que ignora completamente o problema é disponibilizar as dependências do seu projeto. Quando um projeto fornece as dependências, ele as verifica no controle de origem junto com o código-fonte do projeto, seja como fonte 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 apenas no Google porque o sistema de controle de origem é personalizado para lidar com um monorepo extremamente grande. Dessa forma, a disponibilização de pacotes de terceiros pode não ser uma opção para todas as organizações.