Obtendo recursos embutidos em JARs com NIO.2

Digamos que no nosso projeto temos a seguinte estrutura:

.
└── src
    ├── meu-pacote
    │   └── nio2
    │       └── MinhaClasse.java
    └── config.xml

Dentro de MinhaClasse, estou interessado em obter o conteúdo do arquivo config.xml.

Para isso:

  • obtemos a URI do recurso através do método getResource de nosso classe seguido ao método toURI
  • utilizamos o método get da classe auxiliar Paths para obter um Path a partir da URI
  • criamos uma String a partir dos bytes retornados pelo método readAllBytes da classe auxiliar Files
public class MinhaClasse {
  public static void main(String[] args) throws URISyntaxException, IOException {

    URI uriDoRecurso = MinhaClasse.class.getResource("/config.xml").toURI();
    Path pathDoRecurso = Paths.get(uriDoRecurso);
    String conteudoDoRecurso = new String(Files.readAllBytes(pathDoRecurso));
    System.out.println(conteudoDoRecurso);

  }
}

Ao executarmos o código anterior de dentro de nossa IDE, o código tem o efeito esperado. Tudo funciona!

Mas e se exportarmos para um JAR?

Digamos que nosso JAR seja chamado minha-lib.jar. Para que a nossa classe seja executada por padrão na execução do JAR, conteria a configuração Main-Class do arquivo META-INF/MANIFEST.MF.

O conteúdo de minha-lib.jar seria o seguinte:

.
├── meu-pacote
│   └── nio2
│       └── MinhaClasse.class
├── config.xml
├── META-INF
│   └── MANIFEST.MF

Poderíamos então executar o JAR com o comando:

java -jar minha-lib.jar

Teríamos como resultado a seguinte exceção:

Exception in thread "main" java.nio.file.FileSystemNotFoundException
	at com.sun.nio.zipfs.ZipFileSystemProvider.getFileSystem(ZipFileSystemProvider.java:171)
	at com.sun.nio.zipfs.ZipFileSystemProvider.getPath(ZipFileSystemProvider.java:157)
	at java.nio.file.Paths.get(Paths.java:143)
	at meu-pacote.nio2.MinhaClasse.main(MinhaClasse.java:14)

Porque será que foi lançada a exceção FileSystemNotFoundException?

O que acontece é que um recurso que está embutido dentro de um JAR tem uma URI diferente. No nosso caso, seria parecida com: jar:file:/home/alexandre/minha-lib.jar!/config.xml

Observe que temos primeiramente o caminho até o JAR e, depois do !, o caminho do recurso que está embutido dentro do JAR.

Nossa primeira alteração no programa deve ser a detecção que temos um recurso de um JAR. Podemos fazer isso verificando se há o caracter !:

URI uriDoRecurso = MinhaClasse.class.getResource("/config.xml").toURI();
Path pathDoRecurso;
if(uriDoRecurso.toString().contains("!")) { //é de JAR
  //lógica para obter recurso do JAR...
} else {
  pathDoRecurso = Paths.get(uriDoRecurso);
}

Para obter a URI do recurso relativa à URI do JAR, devemos utilizar a ideia de sistema de arquivos do NIO.2, disponível a partir do Java 7. Um sistema de arquivos é abstraído através da classe abstrata java.nio.file.FileSystem. Por padrão, é utilizado o sistema de arquivos do sistema operacional. Porém, podemos utilizar um sistema de arquivos a partir de um JAR ou arquivo ZIP.

Os seguintes passos deverão ser tomados:

  • separar as partes da URI que são do JAR e do recurso dentro do JAR
  • criar uma URI que aponta para o JAR
  • criar um FileSystem a partir da URI do JAR
  • obter o Path do recurso relativo ao sistema de arquivos do JAR

Teremos, então, o código:

URI uriDoRecurso = MinhaClasse.class.getResource("/config.xml").toURI();
Path pathDoRecurso;
if(uriDoRecurso.toString().contains("!")) { //é de JAR
  String[] partesDaURI = uriDoRecurso.toString().split("!");
  URI uriDoJAR = URI.create(partesDaURI[0]);
  FileSystem fs = FileSystems.newFileSystem(uriDoJAR, new HashMap<String, String>());
  pathDoRecurso = fs.getPath(partesDaURI[1]);
} else {
  pathDoRecurso = Paths.get(uriDoRecurso);
}

Copiando arquivos de um diretório e seus sub-diretórios com Java 7+

Vamos dizer que temos um diretório chamado arquivos com o seguinte conteúdo:

.
└── arquivos
    ├── .bookignore
    ├── book.properties
    ├── imgs
        └── cover.jpg
    └── .gitignore

Queremos copiar todos os arquivos bem como o conteúdo do sub-diretório imgs para outro diretório chamado copia.

Fazer isso é fácil com os novos recursos do pacote java.nio disponíveis a partir do Java 7. Essas novidades foram definidas na JSR-203 e ficaram conhecidas como NIO.2.

Até o Java 1.3, havia somente o pacote java.io sem suporte adequado a abstrações de alto nível como channel, apenas com I/O bloqueante, suporte ruim a character encodings.

O Java 1.4 veio com o pacote java.nio que resolvia várias dessas limitações.

Com o NIO.2 do Java 7, foi simplificada a API do pacote java.nio, dado suporte a I/O assíncrono, criada uma abstração de sistema de arquivos, entre outras melhorias.

Para representar um diretório, devemos usar a classe Path, obtendo instâncias a partir da classe auxiliar Paths.

Path origem = Paths.get("./arquivos");
Path destino = Paths.get("./copia");

Para navegar no diretório de origem de forma a incluir sub-diretórios (ou seja, recursivamente) podemos usar o método estático walkFileTree da classe auxiliar Files. Precisamos passar um objeto que implementa a interface FileVisitor.

Files.walkFileTree(origem, new Copiador(origem, destino));

A classe Copiador define todos os métodos exigidos pela interface FileVisitor:

  • preVisitDirectory – chamado antes de visitar um novo sub-diretório
  • postVisitDirectory – chamado depois de visitar um sub-diretório e todos seus descendentes
  • visitFile – chamado ao visitar cada novo arquivo
  • visitFileFailed – chamando quando há falha em algum arquivo

Em cada método, devemos retornar um valor da enum FileVisitResult. Retornaremos FileVisitResult.CONTINUE, para continuar a navegação recursiva nos sub-diretórios.

class Copiador implements FileVisitor<Path> {
  private Path origem;
  private Path destino;
  public Copiador(Path origem, Path destino) {
    this.origem = origem;
    this.destino = destino;
  }
  @Override
  public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs) throws IOException {
    return FileVisitResult.CONTINUE;
  }
  @Override
  public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException {
    //copia arquivo para destino...
    return FileVisitResult.CONTINUE;
  }
  @Override
  public FileVisitResult visitFileFailed(Path file, IOException exc) throws IOException {
    throw exc;
  }
  @Override
  public FileVisitResult postVisitDirectory(Path dir, IOException exc) throws IOException {
    return FileVisitResult.CONTINUE;
  }
}

Ainda não preenchemos a lógica de copiar os arquivos, que deveria ficar no método visitFile.

Antes disso, podemos simplificar um pouco o código herdando da classe auxiliar SimpleFileVisitor e sobre-escrevendo apenas o método necessário.

class Copiador extends SimpleFileVisitor<Path> {
  private Path origem;
  private Path destino;
  public Copiador(Path origem, Path destino) {
    this.origem = origem;
    this.destino = destino;
  }
  @Override
  public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException {
    //copia arquivo para destino...
    return FileVisitResult.CONTINUE;
  }
}

No método visitFile, vamos pegar o caminho absoluto do arquivo que está sendo visitado transformando-o em um caminho relativo em relação ao diretório de origem.

Então, podemos obter o caminho do mesmo arquivo mas relativo ao destino.

Precisamos criar qualquer diretório “pai” intermediário necessário para o arquivo de destino, através do método estático createDirectories da classe auxiliar Files.

Aí, basta invocar o método estático copy de Files para efetuar a cópia.

No caso de algum diretório ou arquivo já existir, é lançada a exceção FileAlreadyExistsException, que vamos ignorar.

Teremos o código:

Path caminhoAbsoluto = file.toAbsolutePath();
Path caminhoRelativo = origem.relativize(caminhoAbsoluto);
Path arquivoDestino = destino.resolve(caminhoRelativo.toString());
try {
  Files.createDirectories(arquivoDestino);
  Files.copy(file, arquivoDestino);
} catch (FileAlreadyExistsException ex) {
  //se o diretorio ou arquivo ja existir, nao faz nada (nem sobreescreve)
}

Se quisermos sobre-escrever arquivos já existentes, basta passar o valor StandardCopyOption.REPLACE_EXISTING para o método copy.

Files.copy(file, target, StandardCopyOption.REPLACE_EXISTING);

Poderíamos utilizar uma classe anônima ao invés de ter uma classe que herda de SimpleFileVisitor. Juntando tudo, teríamos:

final Path origem = Paths.get("./src");
final Path destino = Paths.get("./copia");
Files.walkFileTree(origem, new SimpleFileVisitor<Path>() {
  @Override
  public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException {
    Path caminhoAbsoluto = file.toAbsolutePath();
    Path caminhoRelativo = origem.relativize(caminhoAbsoluto);
    Path arquivoDestino = destino.resolve(caminhoRelativo.toString());
    try {
      Files.createDirectories(arquivoDestino);
    } catch (FileAlreadyExistsException ex) {
      //se o diretorio ja existir, nao faz nada (nem sobreescreve)
    }
    Files.copy(file, arquivoDestino, StandardCopyOption.REPLACE_EXISTING);
    return FileVisitResult.CONTINUE;
  }
});