Gestão de dependências

Informar um problema Ver a fonte Nightly · 8.0 7.4 . 7.3 · 7.2 · 7.1 · 7.0 · 6.5

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 é muito mais difícil. Há vários 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 base de código e, às vezes, tem dependências externas em código ou dados de outra equipe (na sua organização ou em terceiros). De qualquer forma, a ideia de "preciso disso antes de ter aquilo" é algo que ocorre repetidamente no design de sistemas de build, e gerenciar dependências é talvez 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 que expressam dependências uns dos outros por arquivos BUILD. A organização adequada desses módulos e dependências pode ter um grande efeito no desempenho do sistema de build e no trabalho necessário para manutenção.

Como usar módulos detalhados e a regra 1:1:1

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 de build, como um 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 recursivamente agrupando todos os arquivos de origem do projeto. No outro extremo, quase todos os arquivos de origem podem ser transformados em módulos, exigindo que cada arquivo seja listado em um arquivo BUILD para todos os outros arquivos de que depende.

A maioria dos projetos fica entre esses extremos, e a escolha envolve um compromisso 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 build precisa sempre criar o projeto inteiro de uma só vez. Isso significa que ele não poderá distribuir ou paralelizar partes do build, nem armazenar em cache partes que já foram criadas. Um módulo por arquivo é o oposto: o sistema de build tem a máxima flexibilidade no cache e na programação de etapas do build, mas os engenheiros precisam se esforçar mais para manter listas de dependências sempre que mudarem as referências de arquivos.

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 um pode normalmente escrever em um sistema de build baseado em tarefas. Um binário de produção típico do 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. Linguagens com convenções de embalagem mais fracas geralmente definem várias metas por arquivo BUILD.

Os benefícios de metas de build menores começam a aparecer em grande escala porque levam a builds distribuídos mais rápidos e a uma necessidade menos frequente de reconstruir metas. As vantagens se tornam ainda mais interessantes quando o teste entra em cena, já que metas mais detalhadas 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 do uso de alvos menores, fizemos alguns avanços para reduzir 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.

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 no 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. Em geral, as equipes do Google só vão tornar os elementos públicos 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 segmentações de clientes como a visibilidade da segmentação. Os destinos de implementação internos de cada equipe serão restritos apenas a diretórios pertencentes à 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 um código-base em módulos de granularidade fina é que você precisa gerenciar as dependências entre esses módulos, embora ferramentas possam ajudar a automatizar isso. A expressão dessas dependências geralmente acaba sendo o conteúdo principal de um arquivo BUILD.

Dependências internas

Em um projeto grande dividido em módulos de granularidade fina, 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 serem baixadas como um artefato pré-criado durante a execução do build. Isso também significa que não há a 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 comum C. O destino A pode usar as classes definidas no destino C?

Dependências transitivas

Figura 1. Dependências transitivas

Quanto às ferramentas subjacentes, não há problema com isso. B e C serão vinculados ao destino A quando ele for criado. Assim, 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 notar 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 for removida, A e qualquer outro destino que usou C por uma dependência em B será quebrado. Na prática, as dependências de um alvo passaram a fazer parte do contrato público e nunca poderiam ser alteradas com segurança. Isso significa que as dependências acumuladas ao longo do tempo e os builds no Google começou a desacelerar.

O Google acabou resolvendo esse problema introduzindo um "modo de dependência estritamente transitiva" no Bazel. Nesse modo, o Bazel detecta se um destino tenta fazer referência a um símbolo sem depender dele diretamente. Se sim, ele falha com um erro e um comando de shell que pode ser usado para inserir automaticamente a dependência. Fazer essa mudança em toda a base de código do Google e refactorizar 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. 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 que não precisam sem se preocupar com a quebra de destinos que dependem delas.

Como de costume, a aplicação de dependências transitivas rígidas envolveu uma troca. Isso tornou 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 puxadas acidentalmente, e os engenheiros precisavam se esforçar mais para adicionar dependências aos arquivos BUILD. Desde então, desenvolvemos ferramentas que reduzem esse esforço detectando automaticamente muitas dependências ausentes e as adicionando a arquivos BUILD sem nenhuma intervenção do desenvolvedor. No entanto, mesmo sem essas ferramentas, descobrimos que o trade-off vale a pena à 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 enquanto o destino de build existir. O Bazel aplica dependências transitivas estritas no código Java por padrão.

Dependências externas

Se uma dependência não for interna, ela precisa 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 como está, em vez de ser criada a partir da origem. Uma das principais 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 automático de dependências x manual

Os sistemas de build podem permitir que as versões de dependências externas sejam gerenciadas manualmente ou automaticamente. Quando gerenciado manualmente, o buildfile lista explicitamente a versão que ele quer fazer o 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 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.

As dependências gerenciadas automaticamente podem ser convenientes para projetos pequenos, mas geralmente são uma receita para o desastre 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 partes externas não vão fazer atualizações quebradas (mesmo quando elas afirmam usar a versão semântica). Portanto, um build que funcionou um dia pode ser quebrado no dia seguinte sem uma maneira fácil de detectar o que mudou ou reverter para um estado funcional. Mesmo que o build não falhe, pode haver comportamentos sutis ou mudanças de 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 extrair 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, o overhead do gerenciamento manual de versões vale a pena pela estabilidade que oferece.

Regra de uma versão

Diferentes versões de uma biblioteca geralmente são representadas por artefatos diferentes. Em teoria, não há motivo para que diferentes versões 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 rígida para todas as dependências de terceiros na nossa base de código.

O maior problema de permitir várias versões é o problema de dependência de 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 vai falhar porque agora depende implicitamente de duas versões diferentes da mesma biblioteca. Na prática, 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. Ao seguir a regra de uma versão, esse conflito se torna impossível. Se um destino adiciona uma dependência a uma biblioteca de terceiros, todas as dependências 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 o Maven ou o 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 causar o download de dezenas de artefatos no total.

Isso é muito conveniente: ao adicionar uma dependência a uma nova biblioteca, seria muito trabalhoso 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 será usada. 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 puxar 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 simples. 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 fornece ferramentas que podem gerar automaticamente um arquivo com 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.

Novamente, a escolha aqui é entre conveniência e escalonabilidade. Projetos pequenos podem preferir não se preocupar em gerenciar dependências transitivas e podem conseguir usar dependências transitivas automáticas. Essa estratégia se torna 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 o custo de lidar com problemas causados pelo gerenciamento automático de dependências.

Armazenar em cache os resultados de 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 disponibilizar parte do próprio código como artefatos, permitindo que outros pedaços de código dependam deles como terceiros, em vez de dependências internas. Isso pode acelerar teoricamente os builds se os artefatos forem lentos para criar, mas rápidos para 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 fica 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. Portanto, se um desenvolvedor depender de um artefato que foi criado recentemente por outra pessoa, o sistema de build fará o download automaticamente em vez de criar. Isso oferece todos os benefícios de desempenho de depender diretamente de artefatos, garantindo que os builds sejam tão consistentes como se fossem sempre 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 de dependências externas

Depender de artefatos de fontes de terceiros é inerentemente arriscado. Há um risco de disponibilidade se a origem de terceiros (como um repositório de artefatos) for interrompida, porque todo o build pode 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, ele poderá substituir o artefato referenciado por um do próprio design, permitindo a injeção de código arbitrário no build. É possível atenuar os dois problemas espelhando os artefatos de que você depende em servidores que você controla e impedindo que o sistema de build acesse 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 muitas vezes depende da escala do projeto. O problema de segurança também pode ser totalmente 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 é vender as dependências do seu projeto. Quando um projeto fornece as dependências, ele vai verificar no controle de origem junto ao código-fonte do projeto, como fonte ou 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 foi criado de forma personalizada para processar um monorepo extremamente grande. Portanto, o fornecimento de serviços pode não ser uma opção para todas as organizações.