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 base de código ou em outras dependências externas do código ou de uma organização de terceiros. Mas, em qualquer caso, a ideia de "preciso disso antes que eu possa ter isso" é algo que se repete repetidamente no design dos sistemas de compilação, e gerenciar dependências é talvez a tarefa mais fundamental de um sistema de compilação.

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 usando 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 precisa abranger. No Bazel, um módulo é representado por um destino que especifica uma unidade compilável, como java_library ou go_binary. Por um extremo, o projeto inteiro pode estar contido 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 transformados em um módulo próprio, 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 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 precisa sempre criar o projeto inteiro de uma só vez. Isso significa que ele não poderá carregar partes da compilação em paralelo ou distribuir partes da compilação, nem armazenar em cache partes que já foram criadas. O uso de 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 ao manter listas de dependências sempre que mudam os arquivos que fazem referência a qual arquivo.

Embora a granularidade exata varie de acordo com o idioma (e muitas vezes até mesmo dentro dele), o Google tende a favorecer módulos significativamente menores do que 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 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 definem vários destinos por arquivo BUILD.

Os benefícios dos destinos de build menores realmente começam a aparecer em escala, porque eles levam a builds distribuídos mais rápidos e à necessidade menos frequente de recriar os destinos. As vantagens se tornam 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 na execução de apenas um subconjunto limitado de testes que pode ser afetado por qualquer alteração. Como o Google acredita nos benefícios sistêmicos de usar destinos menores, fizemos algumas melhorias para mitigar a desvantagem, investindo em ferramentas para gerenciar automaticamente 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 (link em inglês).

Como minimizar a visibilidade do módulo

Ele 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 dentro do próprio arquivo BUILD. Um destino pode conceder mais visibilidade 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 só tornarão os destinos públicos se eles representarem bibliotecas amplamente usadas disponíveis para qualquer equipe do Google. As equipes que exigem que outras pessoas colaborem com eles antes de usar o código vão manter uma lista de permissões de segmentos de clientes como a visibilidade dos objetivos. 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 ter a opção de referenciar um ao outro. A desvantagem de dividir uma base de código em módulos detalhados é 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 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 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 sempre são 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 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 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 quanto C serão vinculados ao destino A quando forem criados, então todos os símbolos definidos em C são conhecidos por A. O Bazel permitiu isso por muitos anos, mas, à medida que o Google crescia, começamos a ver problemas. Suponha que B tenha sido refatorado de forma 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 uma dependência de B seria corrompido. Efetivamente, as dependências de um destino tornaram-se parte do contrato público e nunca puderam 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 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 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 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. Nossas versões agora são muito mais rápidas, já que os alvos têm menos dependências desnecessárias, e os engenheiros são capacitados para remover as dependências de que não precisam sem se preocupar em quebrar as metas que dependem delas.

Como de costume, aplicar dependências transitivas rígidas envolvia uma compensação. Isso tornava 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 extraídas de forma incidental, e os engenheiros precisavam se esforçar mais adicionando 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 o escalonamento da base de código: 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 de build existir. O Bazel aplica dependências transitivas rígidas 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 da origem. Uma das maiores diferenças entre dependências externas e internas é que as externas têm versões, 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 das dependências externas sejam gerenciadas de modo manual ou automático. 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 dela é aceitável, desde que a versão principal seja 1.

As dependências gerenciadas automaticamente podem ser convenientes para projetos pequenos, mas costumam ser 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 partes externas não façam atualizações interruptivas, mesmo quando alegam usar um controle de versão 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 falhe, 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 da origem, elas podem ser facilmente descobertas e revertidas, e é 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, o overhead do gerenciamento manual de versões vale a pena pela estabilidade que ele oferece.

A regra de uma versão

Versões diferentes de uma biblioteca geralmente são representadas por artefatos distintos. 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 poderia escolher qual versão da dependência quisesse 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 de dependência de 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 vai ser corrompido, porque agora depende implicitamente de duas versões diferentes da mesma biblioteca. Efetivamente, não é 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 versão única impossibilita esse conflito. Se um destino adicionar uma dependência a uma biblioteca de terceiros, todas as dependências já existentes já estarão na mesma versão, então elas podem coexistir felizes.

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, o que significa que adicionar uma única dependência ao projeto pode fazer com que o download de dezenas de artefatos no total seja feito.

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 adicionar todas 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 causa o problema de dependência de 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 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 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. Em projetos pequenos, talvez não precisem 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 conflitos e resultados inesperados se tornam cada vez mais frequentes. Em escalas maiores, o custo de gerenciar dependências manualmente é muito menor do que de lidar com problemas causados pelo gerenciamento automático de dependências.

Como armazenar resultados de build em cache 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 código-fonte. Algumas organizações também podem optar por disponibilizar alguns códigos próprios como artefatos, permitindo que outras partes do código dependam deles 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 apresenta 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 foram 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 compilação com suporte para 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 de build fará o download automaticamente em vez de criá-lo. Isso proporciona todos os benefícios de desempenho de depender diretamente de artefatos e ainda garantir que os builds sejam consistentes, como se fossem criados com a 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 pode ser interrompido 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 injetar código arbitrário no seu build. Ambos os problemas podem ser atenuados ao espelhar todos 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 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 o build falhe se o artefato for adulterado. Outra alternativa que substitui completamente o problema é disponibilizar as dependências do seu projeto. Quando um projeto fornece as dependências, ele as verifica no controle de origem 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 em todo o 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 é criado de forma personalizada para lidar com um monorepo extremamente grande. Portanto, a venda de produtos pode não ser uma opção para todas as organizações.