Encontrando dependências desatualizadas em aplicações Java

Alguns times ainda só se preocupam em atualizar as dependências de seus projetos quando precisam de um recurso novo ou quando algo deixa de funcionar. A desculpa normalmente é a mesma: falta de tempo.

O resultado disso é que aos poucos a aplicação enferruja e, quando decidem finalmente atualizar as dependências, aparecem várias incompatibilidades, necessidade de reestruturações ou refatorações no código e muita dor de cabeça. O esforço ainda é multiplicado caso o projeto não tenha testes funcionais automatizados, que permitiriam validar se as atualização causaram algum efeito colateral indesejado. Uma arquitetura ruim também atrapalha significativamente este processo

Aos poucos este comportamento tóxico recorrente pode gerar traumas nos times, que passam a evitar novas atualizações, postergando para quando “tiverem um tempo livre”. Mas já sabemos que quando algo dói, devemos fazer com mais frequência. E se for trabalhoso, automatizar o máximo possível.

Encontrando dependências desatualizadas

Como quase todo problema comum e recorrente, a comunidade open-source já fez boa parte do trabalho para nós. Existe um plugin tanto para o gradle quanto para o maven que realiza o trabalho pesado, gerando um relatório detalhado da situação atual das dependências de nosso projeto.

Neste artigo me concentro em como configurar o plugin do gradle mas existe um plugin muito similar para o maven.

Gradle versions plugin

O gradle versions plugin fornece uma nova task para determinar quais dependências têm atualizações disponíveis. Além disso, o plugin também verifica se há atualizações para o próprio gradle.

A configuração é bastante simples: basta inclui-lo em seu build.gradle conforme exemplo abaixo.

plugins {
id 'com.github.ben-manes.versions' version '0.20.0'
}

Nesta configuração utilizamos a 0.20.0, mas você pode consultar qual a última versão estável nesta página.

Em seguida basta executar a task gradle correspondente.

./gradlew dependencyUpdates -Drevision=release

O parâmetro Drevision é opcional. Atribuindo o valor release fará com que o plugin ignore dependências do tipo snapshot e, como evitar dependências snapshot de terceiros é uma boa prática, sempre incluo este parâmetro em meus projetos.

Ao executar esta task será impresso no console um relatório dividido em três partes. A primeira indica as dependências que já estão atualizadas, uma segunda parte listará quais dependências possuem novas versões e, por último, será informado se o próprio gradle possui uma versão mais recente disponível.

Abaixo temos um exemplo do resultado de uma execução deste plugin.

 — — — — — — — — — — — — — — — — — — — — — — — — — — — — — — 
: Project Dependency Updates (report to plain text file)
— — — — — — — — — — — — — — — — — — — — — — — — — — — — — —
The following dependencies are using the latest release version:
— com.github.ben-manes.versions:com.github.ben-manes.versions.gradle.plugin:0.20.0
— com.h2database:h2:1.4.197
— com.sun.xml.bind:jaxb-core:2.3.0.1
— io.springfox:springfox-swagger-ui:2.9.2
— io.springfox:springfox-swagger2:2.9.2
The following dependencies have later release versions:
— com.sun.xml.bind:jaxb-impl [2.3.1 -> 2.4.0-b180830.0438]
http://jaxb.java.net
— io.micrometer:micrometer-core [1.1.0 -> 1.1.1]
https://github.com/micrometer-metrics/micrometer
— javax.xml.bind:jaxb-api [2.3.1 -> 2.4.0-b180830.0359]
https://github.com/javaee/jaxb-spec
— org.hibernate:hibernate-envers [5.3.7.Final -> 5.4.0.CR2]
http://hibernate.org/orm
— org.junit.jupiter:junit-jupiter-api [5.3.1 -> 5.3.2]
http://junit.org/junit5/
— org.junit.jupiter:junit-jupiter-engine [5.3.1 -> 5.3.2]
http://junit.org/junit5/
— org.junit.jupiter:junit-jupiter-params [5.3.1 -> 5.3.2]
http://junit.org/junit5/
— org.owasp:dependency-check-gradle [4.0.0 -> 4.0.0.1]
https://github.com/jeremylong/dependency-check-gradle
Gradle updates:
— Gradle: [4.10.2 -> 5.0]
Generated report file build/dependencyUpdates/report.txt

O mesmo resultado será armazenado em um arquivo de saída (build/dependencyUpdates/report.txt) em formato texto. Caso prefira, o plugin permite ainda gerar este relatório em HTML, XML, JSON ou até mesmo em uma formatação customizada.

Quando atualizar as dependências?

A execução deste plugin apenas gera um relatório informativo. Adicionar esta task na pipeline de integração continua de seu projeto pode ser interessante, mas não necessariamente agrega muito valor. Minha primeira reação ao conhecer este plugin foi tentar atualizar todas as dependências de forma indiscriminada, mas nem sempre isso é uma boa ideia.

Podemos separar as atualizações disponíveis em três tipos:

Dependências diretas com novas versões patch ou minor

Dependências que explicitamente incluímos em nossos projetos e possuem atualizações do tipo patch ou minor costumam ser representadas por incremento nos últimos dois dígitos da versão, indicando possivelmente uma correção de bug e melhorias. Este tipo de atualização em geral não introduz quebras e pode ser aplicada com maior facilidade. Se após atualizar estas dependências todos os testes automatizados continuarem passando, então podemos promover a build para produção. Mas a aplicação deve ser bem monitorada, afinal, sempre podem aparecer efeitos colaterais indesejados.

Dependências diretas com novas versões major

Assim como as anteriores, são dependências que explicitamente incluímos em nossos projetos, mas neste caso possuem atualizações do tipo major. Costumam ser representadas por incremento no primeiro dígito da versão, indicando que sua atualização pode introduzir quebras de contrato nas APIs públicas e, por este motivo, demandam um maior planejamento, já que podem exigir alterações no código.

Nossa aplicação pode ter a sorte de depender apenas de APIs públicas que não sofreram grandes alterações em suas assinaturas. Nesses cenários, seguimos o mesmo processo anterior: atualizamos a versão, executamos os testes automatizados e monitoramos o ambiente de produção.

Mas caso a atualização traga grandes quebras no código, então o time precisa decidir qual o melhor momento para fazer a transição. Nesta ocasião o uso de um boa arquitetura de software costuma ajudar muito, já que quanto menor for o acoplamento de nossa aplicação com APIs de terceiros, mais fácil será sua atualização.

É importante destacar que embora muitas bibliotecas sigam o versionamento semântico descrito acima, esta pratica não é obrigatória. Existe a possibilidade de encontrar uma biblioteca que apresente quebras de contrato sem que o primeiro dígito da versão seja alterado. O modelo de versionamento pode ser diferentes em cada biblioteca.

Dependências transitivas

Muitas vezes dependências de terceiros que adicionamos explicitamente em nosso projeto dependem de outras bibliotecas, que por sua vez podem depender de outras bibliotecas e assim por diante, formando uma grande arvore de dependências. Chamamos estas bibliotecas que não foram explicitamente declaradas mas que são necessárias em nossos projetos de dependências transitivas. É responsabilidade do gradle (ou do maven) gerar esta arvore, baseando-se nas configurações declaradas em cada biblioteca.

O problema ocorre quando a última versão disponível de uma dependência direta de nosso projeto está referenciando bibliotecas desatualizadas, que irão compor nosso projeto por transitividade.

Exemplo de uma arvore de dependências

A figura acima representa a arvore de dependências de um projeto hipotético. Neste exemplo, nossa aplicação depende diretamente das bibliotecas A e D. Mas a biblioteca A depende da B e C, enquanto a D depende da E e F. Então, para que nossa aplicação funcione corretamente, precisamos em tempo de execução que as bibliotecas A, B, C, D, E e F estejam carregadas.

Imagine que nosso projeto aponte para as últimas versões disponíveis de A e D, mas infelizmente a dependência A está utilizando uma versão antiga da dependência B (representada na arvore pela cor vermelha).

Neste cenário temos duas opções: podemos esperar até que o mantenedor da dependência A disponibilize uma nova versão utilizando a versão mais recente da biblioteca B ou podemos declarar explicitamente em nosso projeto a versão mais recente da dependências transitiva em questão. Se escolhermos a última opção, o gradle ou maven irá priorizar a versão que estiver declarada diretamente em nosso projeto, sobrescrevendo assim a versão desatualizada. Desta forma, nossa arvore passa a ser representada pelo diagrama a seguir.

Exemplo de uma arvore de dependências com uma dependência transitiva atualizada

Note que agora a dependência B aparece duas vezes em nossa arvore. Primeiro como uma dependência direta e atualizada e depois como uma dependência transitiva e desatualizada. O gradle (ou o maven) irá sempre dar preferência para a versão que estiver mais próxima do nosso projeto. Ou seja, neste caso, a versão atualizada da dependência B será carregada em tempo de execução. Mas esta solução irá gerar outros dois problemas.

O primeiro é que agora nossa aplicação depende explicitamente de uma nova dependência e isso gera um custo maior na gestão de configuração. Podemos encarar isso como um débito técnico.

Outro grande problema é que a dependência A foi compilada e testada pelo seu mantenedor utilizando uma versão diferente da que será carregada em tempo de execução em nosso projeto. Classes ou métodos podem ter deixado de existir ou ter sua assinatura ou comportamento alterados, o que pode ocasionar problemas de compatibilidade entre as bibliotecas de nossa aplicação em tempo de execução.

Por causa destes possíveis impactos não costumo forçar a atualização de dependências transitivas, optando por esperar que o mantenedor da biblioteca faça a atualização, devidos testes, eventuais correções e libere uma nova versão.

Mas existem circunstâncias onde somos obrigados a enfrentar esta situação. Quando detectamos algum bug que esteja impactando nossa aplicação ou caso encontre alguma falha de segurança crítica em uma dependência transitiva desatualizada, então não temos outra alternativa a não ser forçar a atualização e testar exaustivamente (de preferência de forma automatizada) nossa aplicação. Nestes casos é um boa prática encararmos esta correção como algo temporário. Assim que o mantenedor da dependência também atualizar a versão, descartamos esta configuração e voltamos a utilizar a dependência de forma transitiva, simplificando assim a gestão de configuração de nosso projeto.

Conclusão

Manter as dependências de nossos projetos atualizadas pode parecer uma tarefa simples, mas existem vários fatores que precisam ser levados em consideração durante a realização desta atividade.

Embora seja possível automatizar parte do processo, ainda é necessário planejamento e uma análise do time para decidir quais dependências devem ser atualizadas e qual estratégia utilizar em cada caso. Por isso, deve ser parte da rotina do time exercer esta tarefa continuamente.

É importante buscar um equilíbrio, atuando de forma proativa para evitar que nossas aplicações enferrujarem ou apresentarem falhas de segurança, mas também sempre atuando de maneira responsável para evitar a inclusão de uma complexidade desnecessária na configuração do projeto ou eventuais falhas devido a incompatibilidade de versões. Como sempre, uma boa arquitetura, cobertura de testes automatizados, monitoração e deploy contínuo também facilitam a realização desta atividade.