
Compartilhar:
Sina é um ex-membro da equipe da Vonage. Ele atuou como Java Developer Advocate na Vonage. Com formação acadêmica, ele tem uma curiosidade geral por tudo o que se relaciona a carros, computadores, programação, tecnologia e natureza humana. Em seu tempo livre, ele gosta de caminhar ou jogar videogames competitivos.
Um truque simples para resolver problemas de dependência do Java Runtime
Introdução
Se você trabalha com qualquer linguagem baseada na JVM, como Java, Kotlin, Scala, Groovy, Clojure etc., provavelmente já se deparou com ferramentas de gerenciamento de compilação e dependências, como Ant / Ivy, Maven, sbt, Leinengen ou Gradle.
Basicamente, o objetivo dessas ferramentas é automatizar o processo de compilação (e, às vezes, até mesmo de lançamento) do seu aplicativo. Elas se encarregam de compilar seu código, executar testes, gerar páginas de documentação (Javadocs) e produzir artefatos distribuíveis, como arquivos JAR.
Entre as principais responsabilidades e vantagens de usar uma ferramenta de automação de compilação está sua capacidade de gerenciar dependências de forma declarativa para você. Para projetos pequenos, é viável não usar nenhum sistema de compilação, optando, em vez disso, por um script simples de shell que utilize javac e jar para compilar e empacotar o aplicativo.
Isso se torna tedioso e mais difícil de manter assim que você começa a adicionar dependências de terceiros ao seu projeto. Encontrar a versão mais recente do arquivo JAR da dependência, usá-la como parte do seu processo de compilação e incluí-la no classpath tanto para compilação quanto em tempo de execução, sem deixar de tomar cuidado para não manter binários desnecessários no seu sistema de controle de versão, dá muito trabalho para gerenciar manualmente.
Embora as principais ferramentas de compilação do ecossistema Java ofereçam uma maneira declarativa de especificar dependências e gerenciar o processo para você, elas não são infalíveis. Ocasionalmente, você pode se deparar com problemas causados pela inclusão de dependências que, à primeira vista, não fazem sentido, sem causa ou solução óbvias.
Este artigo tem como objetivo explorar um cenário problemático de gerenciamento de dependências, as razões pelas quais ele ocorre e como resolvê-lo.
Projeto de exemplo
Para fins ilustrativos, utilizarei um projeto em Java com o Maven como exemplo mínimo funcional ao longo deste artigo. No entanto, os princípios também se aplicam ao Gradle e a outras linguagens da JVM. Sem mais delongas, vamos direto ao assunto!
Aqui está o conteúdo inicial do <project> elemento em pom.xml:
<groupId>org.example</groupId>
<artifactId>minimal</artifactId>
<version>1.0-SNAPSHOT</version>
<name>Dependency Example</name>
<packaging>jar</packaging>
<properties>
<maven.compiler.source>11</maven.compiler.source>
<maven.compiler.target>11</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
</properties>
<dependencies>
<dependency>
<groupId>com.vonage</groupId>
<artifactId>client</artifactId>
<version>7.10.0</version>
</dependency>
</dependencies>
Como você pode ver, estamos declarando uma dependência: o SDK Java da Vonage. Aqui está nossa classe principal, que usa a API de Account para obter nosso saldo e a Messages API para nos enviar esse saldo por mensagem de texto.
package com.example.minimal;
import com.vonage.client.VonageClient;
import com.vonage.client.messages.sms.SmsTextRequest;
public class BalancePrinter {
public static void main(String[] args) throws Throwable {
VonageClient client = VonageClient.builder()
.apiKey(System.getenv("VONAGE_API_KEY"))
.apiSecret(System.getenv("VONAGE_API_SECRET"))
.build();
var balance = client.getAccountClient().getBalance();
var balanceText = SmsTextRequest.builder()
.from("Vonage").to(System.getenv("TO_NUMBER"))
.text("Balance: €" + balance.getValue()).build();
var response = client.getMessagesClient().sendMessage(balanceText);
System.out.println(response.getMessageUuid());
}
}Quando você o executa a partir do seu IDE, ele funciona normalmente. Agora, vamos criar um arquivo JAR executável com todas as dependências, para que tenhamos um executável independente. Podemos fazer isso usando o plug-in do Maven Shade:
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-shade-plugin</artifactId>
<version>3.5.0</version>
<configuration>
<transformers>
<transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
<mainClass>com.example.minimal.BalancePrinter</mainClass>
</transformer>
</transformers>
</configuration>
<executions>
<execution>
<phase>package</phase>
<goals>
<goal>shade</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>
Quando executamos mvn clean install, obtemos exatamente isso: um JAR executável e independente (que está localizado em target/minimal-1.0-SNAPSHOT.jar). E funciona! No entanto, você percebeu os avisos gerados ao executar o mvn comando? No final, você verá algo assim:
"[AVISO] O maven-shade-plugin detectou que alguns arquivos estão presentes em dois ou mais JARs. Quando isso ocorre, apenas uma única versão do arquivo é copiada para o uber jar. Normalmente, isso não causa problemas e você pode ignorar esses avisos; caso contrário, tente excluir manualmente os artefatos com base no comando `mvn dependency:tree -Ddetail=true` e na saída exibida acima."
O que esse aviso está tentando nos dizer? Bem, o próprio SDK do Java tem dependências — você pode consultá-las na seção “Runtime Dependencies” no site mvnrepository.com. Essas dependências, por sua vez, têm suas próprias dependências e assim por diante.
Como estamos criando um único arquivo JAR que “simplifica” essa hierarquia de dependências, acabamos com muitos arquivos de classe. No entanto, algumas dessas dependências dependem do mesmo artefato. Esse processo de descompactar recursivamente cada JAR para que restem apenas as classes significa que, se houver várias versões da mesma dependência, apenas uma das classes será mantida. Afinal, não é possível ter dois arquivos com o mesmo nome no mesmo diretório.
Por exemplo, o ‘jackson-datatype-jsr310’ tem uma dependência do ‘jackson-core’, do qual também dependemos por meio do ‘jackson-databind’. Em algum ponto da cadeia, há também dependências da estrutura de log SLF4J, que é utilizada por muitas bibliotecas populares. O Maven destaca todas as classes nas quais há duplicatas ao simplificar a hierarquia de dependências.
Onde surgem os problemas
Conforme indica o aviso, normalmente isso não representa um problema. Se fosse o caso, seria muito difícil, se não impossível, automatizar a compilação até mesmo dos projetos mais simples com dependências, como o apresentado neste artigo.
A estratégia para selecionar qual versão da classe usar (ou seja, de qual dependência escolher) depende da ordem de carregamento das classes e de detalhes que estão além do escopo deste artigo, mas o ponto é que, em última instância, só pode haver uma instância de uma classe totalmente qualificada em qualquer instância de tempo de execução de uma aplicação Java. Quando são necessárias várias versões, o plug-in Shade possui uma estratégia de realocação para que elas possam coexistir.
Pensando nisso, quando isso se torna um problema? Se, por padrão, tudo funciona, quando é que ocorre uma falha? Para ilustrar, vamos voltar ao nosso exemplo.
Agora, vamos dividir isso adicionando a seguinte dependência à <dependencies> seção do nosso pom.xml:
<dependency>
<groupId>org.apache.httpcomponents</groupId>
<artifactId>httpclient</artifactId>
<version>4.0-alpha1</version>
</dependency>
Se você recompilar com mvn clean install e, em seguida, tentar executar o programa usando o arquivo JAR, você receberá o seguinte erro:
Exceção na thread “main”: java.lang.NoClassDefFoundError: org/apache/http/conn/HttpClientConnectionManager
Isso ocorre porque você está substituindo uma das dependências principais do SDK por uma versão muito mais antiga, que não contém uma classe da qual dependemos. O mesmo acontece se você executar o código novamente a partir do IDE. Em vez disso, você poderia tentar usar uma dependência diferente. Por exemplo:
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-core</artifactId>
<version>2.0.0</version>
</dependency>
Isso gerará o seguinte erro:
Exceção na thread “main”: java.lang.NoClassDefFoundError: com/fasterxml/jackson/core/util/JacksonFeature
Embora o SDK Java da Vonage não tenha dependência direta do 'jackson-core', isso ainda causa um problema, pois estamos substituindo a versão da biblioteca (e, consequentemente, quais arquivos de classe) incluída em nosso aplicativo.
Tempo de execução x Compilação
Você deve estar se perguntando: como é que nosso programa conseguiu ser compilado corretamente apesar dessa dependência incorreta, mas só percebemos o problema ao executá-lo? Isso se deve à diferença entre a forma como as dependências são resolvidas em tempo de compilação e em tempo de execução.
No momento da compilação, já temos todas as dependências necessárias. Afinal, o SDK Java da Vonage já foi compilado e testado, portanto não precisamos recompilar as classes; estamos carregando-o a partir de um JAR existente. No entanto, no momento da execução, substituímos as classes utilizadas durante a execução.
Os arquivos de classe já foram gerados durante a fase de compilação, mas podem existir versões diferentes dessas classes no classpath durante a execução.
Essa é a chave para entender os problemas com dependências em tempo de execução: quase sempre se deve a uma configuração incorreta do classpath. Sistemas de compilação como o Maven e o Gradle oferecem padrões sensatos e opções de configuração para ajudar nesse sentido, mas o uso incorreto deles, como mostrado acima, pode levar a erros. Vale a pena entender os diferentes escopos das declarações de dependência.
Por padrão, no Maven, uma dependência é definida para ser incluída tanto em tempo de execução quanto em tempo de compilação — o que é chamado de compile. Você pode ler mais sobre escopos na documentação oficial. Da mesma forma, no Gradle, o escopo padrão é implementation (em oposição a, por exemplo, compileOnly).
Embora a terminologia seja diferente, o escopo e a propagação das dependências são conceitos importantes a serem compreendidos quando se recorre a qualquer sistema de compilação para gerenciar suas dependências e, em última instância, o classpath da sua aplicação.
A Solução Simples
O título deste artigo prometia uma solução rápida para problemas de dependência. A verdade é que, embora o problema, em última análise, esteja relacionado a um classpath mal configurado, a solução dependerá em grande parte da complexidade do seu projeto e de suas dependências.
O mvn dependency:tree comando (e outros semelhantes em outras ferramentas de compilação) pode ajudar você a identificar dependências aninhadas e potencialmente conflitantes, mas, em última análise, cabe a você garantir que não esteja sobrescrevendo as dependências usadas por outras bibliotecas das quais você depende, pois isso pode levar a conflitos.
Às vezes, as interações entre certas dependências podem causar problemas; portanto, o primeiro passo é identificar quais dependências elas têm em comum e se há alguma discrepância de versão. Existe alguma versão que você possa usar e que seja compatível com todas as suas outras dependências? Se sim, declare-a explicitamente.
No entanto, a melhor maneira de minimizar conflitos desde o início é declarar apenas as dependências que você usa diretamente. Geralmente, é recomendável minimizar o número de dependências declaradas em seu projeto e usar apenas o que for necessário.
Quanto mais dependências você adicionar ao seu projeto, mais pesado fica o artefato final e maiores são as chances de surgirem problemas decorrentes de conflitos de dependências. Sem falar que as dependências podem comprometer a segurança do seu aplicativo, muitas vezes por meio de dependências aninhadas.
Por exemplo, digamos que você utilize uma biblioteca (Biblioteca A) que, indiretamente, depende de uma versão mais antiga de outra biblioteca (Biblioteca B) que apresenta uma falha de segurança. Se os mantenedores da Biblioteca A não atualizarem para a versão mais recente da Biblioteca B, essa falha também poderá afetar seu aplicativo. Você pode tentar resolver isso especificando diretamente a versão mais recente da Biblioteca B em seu pom.xml ou build.gradle. No entanto, essa abordagem não garante compatibilidade e transfere a responsabilidade dos mantenedores da Biblioteca A para VOCÊ.
Na maioria das vezes, como desenvolvedor, você nem sabe nem se importa com todas as classes incluídas em seu aplicativo por meio de dependências transitivas. Isto é, até que algo pare de funcionar.
Então, é preciso verificar o classpath. Existem algumas ferramentas que podem ajudar. Por exemplo, o Maven pode fornecer um “pom efetivo” usando o plug-in de mesmo nome. Isso lhe dará uma visão mais clara das configurações que estão sendo usadas na sua compilação (útil para projetos complexos).
Mas talvez o mais útil seja entender seu gráfico de dependências. Nesse caso, o mvn dependency:tree comando oferece uma visão clara de todas as bibliotecas e seu escopo. Procure aquelas com o runtime escopo e verifique se há algum conflito em potencial. Outras ferramentas de compilação possuem mecanismos semelhantes. Por exemplo, gradle dependencies no Gradle e dependencytree no Ivy. Você pode então usar essas informações para identificar onde pode haver dependências conflitantes.
Encerrando
Por enquanto é só isso! Espero que este artigo tenha sido informativo e útil para vocês. A principal lição a ser aprendida é que problemas de dependência em tempo de execução são resultado de um classpath mal configurado, o que significa que ou algo está faltando, ou a versão errada está sendo usada. Entender o escopo das suas dependências e os possíveis conflitos entre dependências transitivas vai ajudar você a chegar ao fundo da questão.
Se você tiver algum comentário ou sugestão, sinta-se à vontade para entrar em contato conosco no X, anteriormente conhecido como Twitter ou dê uma passada no nosso Slack da Comunidade. Se você gostou, confira meus outros artigos sobre Java.
Compartilhar:
Sina é um ex-membro da equipe da Vonage. Ele atuou como Java Developer Advocate na Vonage. Com formação acadêmica, ele tem uma curiosidade geral por tudo o que se relaciona a carros, computadores, programação, tecnologia e natureza humana. Em seu tempo livre, ele gosta de caminhar ou jogar videogames competitivos.