De um tradutor: estamos trabalhando ativamente na tradução da plataforma nos trilhos do Java 11 e estamos pensando em como desenvolver bibliotecas Java eficientemente (como o YARG ), levando em consideração os recursos do Java 8/11, para que você não precise criar ramificações e lançamentos separados. Uma solução possível é um JAR de várias versões, mas nem tudo é tranquilo.
O Java 9 inclui uma nova opção de tempo de execução Java, denominada JARs com várias liberações. Esta é talvez uma das inovações mais controversas da plataforma. TL; DR: consideramos esta uma solução incorreta para um problema sério . Neste post, explicaremos por que pensamos assim e também mostraremos como criar um JAR, se você realmente quiser.
JARs multi-release , ou MR JARs, é um novo recurso da plataforma Java, introduzido no JDK 9. Aqui descreveremos em detalhes os riscos significativos associados ao uso dessa tecnologia e como criar JARs multi-release usando Gradle, se você ainda quiser.
De fato, um JAR com várias versões é um arquivo Java que inclui várias variantes da mesma classe para trabalhar com versões diferentes do tempo de execução. Por exemplo, se você estiver trabalhando no JDK 8, o ambiente Java usará a versão da classe para o JDK 8 e, no Java 9, será usada a versão do Java 9. Da mesma forma, se a versão for criada para uma liberação futura do Java 10, o tempo de execução utilizará essa versão em vez da versão para Java 9 ou a versão padrão (Java 8).
Sob o gato, entendemos o dispositivo do novo formato JAR e descobrimos se isso é tudo.
Quando usar JARs com várias liberações
- Tempo de execução otimizado. Esta é uma solução para o problema que muitos desenvolvedores enfrentam: ao desenvolver um aplicativo, não se sabe em qual ambiente ele será executado. No entanto, para algumas versões do tempo de execução, você pode incorporar versões genéricas da mesma classe. Suponha que você queira exibir o número da versão do Java no qual o aplicativo está sendo executado. Para Java 9, você pode usar o método Runtime.getVersion. No entanto, este é um novo método disponível apenas no Java 9+. Se você precisar de outros tempos de execução, por exemplo, Java 8, precisará analisar a propriedade java.version. Como resultado, você terá 2 implementações diferentes de uma função.
APIs conflitantes: a resolução de conflitos entre APIs também é um problema comum. Por exemplo, você precisa oferecer suporte a dois tempos de execução, mas em um deles a API foi preterida. Existem 2 soluções comuns para esse problema:
- O primeiro é reflexão. Por exemplo, você pode especificar a interface VersionProvider, depois 2 classes específicas Java8VersionProvider e Java9VersionProvider e carregar a classe correspondente no tempo de execução (é engraçado que, para escolher entre duas classes, você precise analisar o número da versão!). Uma das opções para esta solução é criar uma única classe com vários métodos chamados usando reflexão.
- Uma solução mais avançada é usar alças de método sempre que possível. Muito provavelmente, o reflexo lhe parecerá inibitório e desconfortável e, em geral, do jeito que é.
Alternativas conhecidas para JARs de liberação múltipla
A segunda maneira, mais simples e compreensível, é criar 2 arquivos diferentes para diferentes tempos de execução. Em teoria, você cria duas implementações da mesma classe no IDE e compilar, testar e empacotá-las corretamente em 2 artefatos diferentes é a tarefa do sistema de construção. Esta é uma abordagem que tem sido usada na goiaba ou no Spock há muitos anos. Mas também é necessário para idiomas como o Scala. E tudo porque existem tantas opções para o compilador e o tempo de execução que a compatibilidade binária se torna quase inatingível.
Mas existem muitos outros motivos para usar arquivos JAR separados:
- JAR é apenas uma maneira de embalar.
é um artefato de montagem que inclui classes, mas isso não é tudo: recursos, como regra, também são incluídos no arquivo morto. A embalagem, como a manipulação de recursos, tem um preço. A equipe da Gradle visa melhorar a qualidade da compilação e reduzir o tempo de espera para o desenvolvedor compilar resultados, testes e o processo de compilação em geral. Se o arquivo morto aparecer no processo muito cedo, um ponto de sincronização desnecessário será criado. Por exemplo, para compilar classes dependentes da API, a única coisa necessária são os arquivos .class. Não são necessários arquivos jar nem recursos no jar. Da mesma forma, apenas os arquivos e recursos de Grade são necessários para executar os testes Gradle. Para o teste, não há necessidade de criar um jar. Só será necessário para um usuário externo (ou seja, ao publicar). Mas se a criação de um artefato se tornar obrigatória, algumas tarefas não poderão ser executadas em paralelo e todo o processo de montagem será inibido. Se para pequenos projetos isso não é crítico, para projetos corporativos de grande escala esse é o principal fator de desaceleração.
- é muito mais importante que, sendo um artefato, um jar-archive não possa transportar informações sobre dependências.
É improvável que as dependências de cada classe no tempo de execução Java 9 e Java 8 sejam as mesmas. Sim, em nosso exemplo simples, isso será verdade, mas para projetos maiores isso não é verdade: geralmente o usuário importa a backport da biblioteca para a funcionalidade Java 9 e a utiliza para implementar a versão da classe Java 8. No entanto, se você empacotar as duas versões em um archive, em um artefato haverá elementos com diferentes árvores de dependência. Isso significa que se você estiver trabalhando com Java 9, terá dependências que nunca serão necessárias. Além disso, polui o caminho da classe, criando conflitos prováveis para os usuários da biblioteca.
E, finalmente, em um projeto, você pode criar JARs para diferentes propósitos:
- para API
- para java 8
- para java 9
- com ligação nativa
- etc.
O uso incorreto do classificador de dependências leva a conflitos relacionados ao compartilhamento do mesmo mecanismo. Normalmente, fontes ou javadocs são instalados como classificadores, mas na realidade eles não têm dependências.
- Não queremos gerar inconsistências; o processo de construção não deve depender de como você obtém as classes. Em outras palavras, o uso de jars com várias liberações tem um efeito colateral: chamar do arquivo JAR e chamar do diretório de classes agora são coisas completamente diferentes. Eles têm uma enorme diferença na semântica!
- Dependendo da ferramenta usada para criar o JAR, você pode acabar com arquivos JAR incompatíveis! A única ferramenta que garante que, quando você empacotar duas opções de classe em um archive, elas terão uma única API aberta - o próprio utilitário jar . O que, sem razão, não envolve necessariamente ferramentas de montagem ou mesmo usuários. JAR é essencialmente um "envelope" semelhante a um arquivo ZIP. Portanto, dependendo de como você o coleta, você terá comportamentos diferentes ou talvez colete um artefato incorreto (e você não notará).
Maneiras mais eficientes de gerenciar arquivos JAR individuais
A principal razão pela qual os desenvolvedores não usam arquivos separados é o inconveniente de coletar e usar. A culpa é das ferramentas de construção, que antes de Gradle não lidavam com isso. Em particular, aqueles que usaram esse método no Maven só podiam confiar na função classificadora fraca para publicar artefatos adicionais. No entanto, o classificador não ajuda nessa situação difícil. Eles são usados para vários propósitos, desde a publicação de códigos-fonte, documentação, javadocs, até a implementação de opções de biblioteca (guava-jdk5, guava-jdk7, ...) ou vários casos de uso (api, fat jar, ...). Na prática, não há como mostrar que a árvore de dependência do classificador é diferente da árvore de dependência do projeto principal. Em outras palavras, o formato POM é fundamentalmente quebrado porque Representa como o componente é montado e os artefatos que ele fornece. Suponha que você precise implementar 2 arquivos jar diferentes: jar clássico e gordo, que inclui todas as dependências. Maven decide que dois artefatos têm árvores de dependência idênticas, mesmo que isso esteja obviamente errado! Nesse caso, isso é mais do que óbvio, mas a situação é a mesma que nos JARs de vários lançamentos!
A solução é lidar com as opções corretamente. Gradle pode fazer isso gerenciando dependências com base em opções. Atualmente, este recurso está disponível para desenvolvimento no Android, mas também estamos trabalhando em sua versão para Java e aplicativos nativos!
O gerenciamento de dependências baseado em variantes é baseado no fato de que módulos e artefatos são coisas completamente diferentes. O mesmo código pode funcionar perfeitamente em diferentes tempos de execução, levando em conta requisitos diferentes. Para aqueles que trabalham com compilação nativa, isso é óbvio há muito tempo: compilamos para i386 e amd64 e de forma alguma podemos interferir nas dependências da biblioteca i386 com arm64 ! No contexto do Java, isso significa que, para o Java 8, é necessário criar uma versão do arquivo JAR “java 8”, em que o formato da classe corresponderá ao Java 8. Esse artefato conterá metadados com informações sobre quais dependências usar. Para Java 8 ou 9, as dependências correspondentes à versão serão selecionadas. É simples assim (na verdade, o motivo não é que o tempo de execução seja apenas um campo de opções, você pode combinar vários).
Obviamente, ninguém havia feito isso antes devido à complexidade excessiva: Maven, aparentemente, nunca permitiria que uma operação tão complicada fosse realizada. Mas com Gradle é possível. A equipe Gradle está atualmente trabalhando em um novo formato de metadados que informa aos usuários qual opção usar. Simplificando, uma ferramenta de construção deve lidar com a compilação, teste, empacotamento e processamento de tais módulos. Por exemplo, o projeto deve funcionar nos tempos de execução Java 8 e Java 9. Idealmente, você precisa implementar 2 versões da biblioteca. Isso significa que existem 2 compiladores diferentes (para evitar o uso da API Java 9 ao trabalhar no Java 8), 2 diretórios de classe e, finalmente, 2 arquivos JAR diferentes. E também, muito provavelmente, será necessário testar 2 tempos de execução. Ou você implementa 2 arquivos, mas decide testar o comportamento da versão Java 8 no tempo de execução Java 9 (porque isso pode acontecer na inicialização!).
Esse esquema ainda não foi implementado, mas a equipe Gradle fez progressos significativos nessa direção.
Como criar JAR com várias liberações usando Gradle
Mas se essa função ainda não estiver pronta, o que devo fazer? Relaxe, os artefatos corretos são criados da mesma maneira. Antes que a função acima apareça no ecossistema Java, existem duas opções:
- boa maneira antiga usando reflexão ou diferentes arquivos JAR;
- use JARs com várias liberações (observe que essa pode ser uma solução ruim, mesmo se houver bons casos de uso).
Qualquer que seja sua escolha, arquivos diferentes ou JARs com vários lançamentos, o esquema será o mesmo. JARs multi-release são essencialmente a embalagem errada: devem ser uma opção, mas não a meta. Tecnicamente, o layout de origem é o mesmo para JARs individuais e externos. Este repositório descreve como criar um JAR de várias liberações usando Gradle. A essência do processo é descrita brevemente abaixo.
Antes de tudo, lembre-se sempre de um mau hábito dos desenvolvedores: eles executam Gradle (ou Maven) usando a mesma versão do Java na qual os artefatos estão planejados para serem lançados. Além disso, algumas vezes uma versão posterior é usada para iniciar o Gradle e a compilação ocorre com um nível anterior da API. Mas não há razão específica para fazê-lo. Em Gradle, a compilação de Ross é possível. Ele permite que você descreva a posição do JDK, além de executar a compilação como um processo separado, para compilar o componente usando esse JDK. A melhor maneira de configurar vários JDKs é configurar o caminho para o JDK por meio de variáveis de ambiente, como é feito neste arquivo . Então você só precisa configurar o Gradle para usar o JDK correto, com base na compatibilidade com a plataforma de origem / destino . Vale ressaltar que, começando com o JDK 9, as versões anteriores do JDK não são necessárias para a compilação cruzada. Isso cria um novo recurso, -release. Gradle usa essa função e irá configurar o compilador conforme necessário.
Outro ponto importante é o conjunto da fonte de designação. Conjunto de fontes é um conjunto de arquivos de origem que precisam ser compilados juntos. Um JAR é obtido através da compilação de um ou mais conjuntos de fontes. Para cada conjunto, o Gradle cria automaticamente uma tarefa de compilação personalizada apropriada. Isso significa que, se houver fontes para Java 8 e Java 9, essas fontes estarão em conjuntos diferentes. É exatamente assim que funciona no conjunto de fontes do Java 9 , no qual haverá uma versão da nossa classe. Isso realmente funciona e você não precisa criar um projeto separado, como no Maven. Mas o mais importante é que esse método permite ajustar a compilação do conjunto.
Uma das dificuldades de ter versões diferentes de uma classe é que o código da classe raramente é independente do restante do código (possui dependências com classes que não estão no conjunto principal). Por exemplo, sua API pode usar classes que não precisam de fontes especiais para dar suporte ao Java 9. Ao mesmo tempo, eu não gostaria de recompilar todas essas classes comuns e compactar suas versões para o Java 9. Elas são classes comuns e, portanto, devem existir separadamente de classes para um JDK específico. Nós o configuramos aqui : adicione uma dependência entre o conjunto de origem do Java 9 e o conjunto principal, para que, ao compilar a versão do Java 9, todas as classes comuns permaneçam no caminho de classe da compilação.
A próxima etapa é simples : você precisa explicar a Gradle que o conjunto principal de fontes será compilado com o nível da API do Java 8 e o conjunto para o Java 9 com o nível do Java 9.
Todas as opções acima ajudarão você a usar as duas abordagens mencionadas anteriormente: implementar arquivos JAR separados ou JARs com várias liberações. Como a postagem é sobre esse tópico, vejamos um exemplo de como fazer com que Gradle construa um JAR de vários lançamentos:
jar { into('META-INF/versions/9') { from sourceSets.java9.output } manifest.attributes( 'Multi-Release': 'true' ) }
Este bloco descreve: empacotando classes para Java 9 no diretório META-INF / version / 9 , que é usado para MR JARs, e configurando o rótulo de liberação múltipla no manifesto.
E é isso, seu primeiro MR JAR está pronto!
Mas, infelizmente, o trabalho sobre isso ainda não acabou. Se você trabalhou com Gradle, sabe que, ao usar o plug-in de aplicativo, é possível executar o aplicativo diretamente através da tarefa de execução . No entanto, devido ao fato de Gradle geralmente tentar minimizar a quantidade de trabalho, a tarefa de execução deve usar os diretórios de classe e os diretórios de recursos processados. Para JARs com várias liberações, isso é um problema porque os JARs são necessários imediatamente! Portanto, em vez de usar o plug-in, você terá que criar sua própria tarefa , e este é um argumento contra o uso de JARs com várias liberações.
E por último, mas não menos importante, mencionamos que precisaríamos testar duas versões da classe. Para isso, você pode usar apenas a VM em um processo separado, porque não há equivalente do marcador de liberação para o tempo de execução Java. A idéia é que apenas um teste precise ser gravado, mas será executado duas vezes: no Java 8 e Java 9. Essa é a única maneira de garantir que as classes específicas do tempo de execução funcionem corretamente. Por padrão, Gradle cria uma tarefa de teste e usa os diretórios de classe da mesma maneira, em vez do JAR. Portanto, faremos duas coisas: criar uma tarefa de teste para Java 9 e configurar as duas tarefas para que usem o JAR e os tempos de execução Java especificados. A implementação terá a seguinte aparência:
test { dependsOn jar def jdkHome = System.getenv("JAVA_8") classpath = files(jar.archivePath, classpath) - sourceSets.main.output executable = file("$jdkHome/bin/java") doFirst { println "$name runs test using JDK 8" } } task testJava9(type: Test) { dependsOn jar def jdkHome = System.getenv("JAVA_9") classpath = files(jar.archivePath, classpath) - sourceSets.main.output executable = file("$jdkHome/bin/java") doFirst { println classpath.asPath println "$name runs test using JDK 9" } } check.dependsOn(testJava9)
Agora, quando a tarefa for iniciada, verifique se Gradle irá compilar cada conjunto de fontes usando o JDK desejado, criar um JAR com várias liberações e executar os testes usando esse JAR nos dois JDKs. Versões futuras do Gradle ajudarão você a fazer isso de forma mais declarativa.
Conclusão
Para resumir. Você aprendeu que os JARs com várias versões são uma tentativa de resolver o problema real que muitos desenvolvedores de bibliotecas enfrentam. No entanto, esta solução para o problema parece estar incorreta. Gerenciamento correto de dependências, vinculando artefatos e opções, cuidando do desempenho (a capacidade de executar o maior número possível de tarefas em paralelo) - tudo isso faz do MR JAR uma solução para os pobres. Esse problema pode ser resolvido corretamente usando as opções. E, no entanto, enquanto o gerenciamento de dependências com opções da Gradle está em desenvolvimento, os JARs com vários lançamentos são bastante convenientes em casos simples. Nesse caso, esta postagem ajudará você a entender como fazer isso e como a filosofia de Gradle difere do Maven (conjunto de fontes versus projeto).
Por fim, não negamos que há casos em que os JARs com vários releases fazem sentido: por exemplo, quando não se sabe em qual ambiente o aplicativo será executado (não uma biblioteca), mas isso é uma exceção. Neste post, descrevemos os principais problemas enfrentados pelos desenvolvedores de bibliotecas e como os JARs com vários releases tentam resolvê-los. A modelagem correta de dependências como opções melhora o desempenho (por meio de paralelismo refinado) e reduz os custos de manutenção (evitando a complexidade imprevista) em comparação com os JARs de liberação múltipla. Na sua situação, MR JARs também podem ser necessários, então Gradle já cuidou disso. Dê uma olhada neste projeto de amostra e tente você mesmo.