Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

I/O, NIO.2, argumentos e jar executável

O mercadinho fecha, o processo termina, e todo o estoque montado em memória morre junto: na manhã seguinte, o sistema nasce vazio de novo. Falta a última peça de um sistema de verdade, guardar e recuperar dados em arquivo:

void main() throws IOException {
    Path arquivo = Path.of("estoque.txt");
    List<String> linhas = List.of(
        "7891000100103;Café 500g;19.90;25",
        "7891000200100;Queijo minas;39.80;8"
    );
    Files.write(arquivo, linhas);

    List<String> lidas = Files.readAllLines(arquivo);
    IO.println(lidas.size() + " produtos no arquivo");
}
$ java Persistencia.java
2 produtos no arquivo

No arquivo-fonte compacto, nada disso pede import; na moldura completa, Path e Files vêm de java.nio.file e IOException vem de java.io. Duas classes fazem quase todo o trabalho de arquivo do dia a dia. Path é o endereço: representa um caminho no disco, sem tocar nele, criado por Path.of. Files é o operário: a classe de métodos estáticos que age no endereço, escrevendo, lendo, conferindo exists, criando pastas com createDirectories, apagando. Ao lado de readAllLines, Files.readString devolve o conteúdo inteiro numa única String, o par para arquivo pequeno lido de uma vez. As duas classes pertencem ao pacote java.nio.file, apelidado NIO.2, a geração atual da biblioteca de arquivos; a geração anterior aparece no apêndice de legado.

Path monta e decompõe caminhos sem tocar o disco. resolve acrescenta uma parte ao fim, Path.of("dados").resolve("estoque.txt") produzindo dados/estoque.txt com o separador que o sistema operacional usa, e é a forma correta de emendar caminho, no lugar da concatenação com a barra escrita à mão, que quebra na primeira máquina de outro sistema.

ChamadaO que faz
Path.of("dados", "estoque.txt")monta o caminho a partir das partes
caminho.resolve("estoque.txt")acrescenta uma parte ao fim
caminho.getFileName() / getParent()a última parte / a pasta que a contém
caminho.toAbsolutePath()o caminho completo, resolvido do diretório de trabalho
caminho.normalize()limpa os . e .. do meio do caminho

E o operário faz mais do que ler e escrever inteiro:

ChamadaO que faz
Files.exists(p) / notExists(p)se existe / se comprovadamente não existe
Files.size(p)o tamanho em bytes
Files.copy(origem, destino)copia; recusa sobrescrever sem a opção que autoriza
Files.move(origem, destino)move, o que também renomeia
Files.delete(p) / deleteIfExists(p)apaga; a primeira lança se não existir
Files.createDirectories(p)cria a pasta e as que faltarem no caminho
Files.createTempFile(prefixo, sufixo)cria um arquivo temporário na pasta do sistema
Files.list(pasta)o conteúdo da pasta, como stream; é recurso a fechar

exists e notExists não são opostos exatos, e a diferença aparece em pasta sem permissão: quando o programa não consegue nem descobrir se o arquivo está lá, os dois respondem false, e é por isso que a pergunta honesta antes de ler costuma ser um try em volta da leitura, e não uma conferência antes dela. createTempFile é o que torna testável a camada de arquivo do capítulo 25: um arquivo temporário é o arquivo criado pelo sistema numa pasta descartável, com nome único, e cada teste escreve no seu em vez de no estoque de verdade.

E a assinatura do main carrega a novidade prometida no capítulo 13: quase tudo em Files lança IOException, a exceção checked do mundo externo, porque disco cheio, permissão negada e arquivo sumido não são bugs do programa, são respostas possíveis do ambiente. O compilador cobra a escolha de sempre: tratar com catch, para o mercadinho reagir, ou declarar com throws, para a decisão subir. O main de laboratório declara; o sistema de verdade trata, e a prática deste capítulo faz as duas versões.

O caminho relativo e o diretório de trabalho

O programa acima rodou dentro da pasta do projeto e funcionou. No dia seguinte, o operador o executa da pasta de cima:

$ java mercadinho/Persistencia.java

O estoque.txt continua onde sempre esteve, dentro de mercadinho/. O que acontece?

$ java mercadinho/Persistencia.java
2 produtos no arquivo

A saída é idêntica à de ontem, e a pasta de cima amanhece com um estoque.txt novo. Path.of("estoque.txt") é um caminho relativo: ele se resolve contra o diretório de trabalho, a pasta onde o comando foi digitado, não a pasta onde o programa mora. Como o programa escreve antes de ler, o Files.write criou o arquivo no lugar errado sem reclamar, e o readAllLines leu esse recém-nascido; o estoque verdadeiro, dentro de mercadinho/, nunca foi tocado, e a partir daqui existem dois arquivos divergindo em silêncio. Um programa que só lesse cairia com NoSuchFileException, uma descendente de IOException com nome honesto, e a queda seria o desfecho bom: erro visível em vez de dado duplicado. É o irmão gêmeo do classpath: “não encontrou”, e também “criou onde não devia”, quase nunca significa que o arquivo não existe, significa que o programa partiu de outro lugar. As defesas do dia a dia: imprimir arquivo.toAbsolutePath() na dúvida, e deixar o caminho vir de fora, que é a deixa dos argumentos adiante.

Charset: onde bytes viram texto

Arquivo não guarda texto: guarda bytes, e a tabela que traduz caracteres em bytes chama-se charset. O charset dominante é o UTF-8, capaz de codificar toda a tabela Unicode, e os métodos de Files o usam por padrão nas duas direções, escrever e ler. O defeito clássico dessa camada é a discordância: texto gravado com um charset e lido com outro transforma acento em sujeira, o “Café” vindo de fora que aparece como “Café” no relatório. Com Files dos dois lados não há discordância; ao receber arquivo de sistemas alheios, planilhas e afins, o charset da origem é a primeira pergunta a fazer, e as versões dos métodos que recebem um Charset explícito, como StandardCharsets.UTF_8, deixam a escolha escrita no código.

try-with-resources

Ler o arquivo inteiro com readAllLines serve até o arquivo crescer; a leitura linha a linha usa um leitor aberto, e tudo que se abre no sistema operacional precisa ser fechado, com exceção ou sem. O capítulo 13 prometeu a escrita dedicada para isso:

try (var leitor = Files.newBufferedReader(arquivo)) {
    String linha;
    while ((linha = leitor.readLine()) != null) {
        processar(linha);
    }
}

O try-with-resources declara o recurso entre parênteses e garante o fechamento ao sair do bloco, por qualquer porta: fim normal, return ou exceção subindo. O contrato por trás é a interface AutoCloseable, de um método só, close; tudo que a implementa pode morar nesses parênteses, e tudo que representa recurso do sistema a implementa. A regra é curta: todo recurso nasce dentro de um try-with-resources, e o finally de fechamento vira peça de museu, lida em código antigo e não escrita em código novo. O readLine devolvendo null no fim do arquivo é a convenção dessa família de leitores, um dos reencontros legítimos com o null.

Do lado da escrita, Files.newBufferedWriter(arquivo) devolve o escritor que se fecha do mesmo jeito, para gravar linha a linha sem montar o conteúdo inteiro em memória. E a escrita padrão apaga o que estava lá, decisão que precisa ser tomada de propósito: para acrescentar ao fim, em vez de substituir, entram as opções de abertura.

Files.writeString(vendasDoDia, linha + System.lineSeparator(),
                  StandardOpenOption.CREATE, StandardOpenOption.APPEND);

StandardOpenOption é o enum das opções de abertura de arquivo: CREATE cria quando ainda não existe, APPEND grava a partir do fim do que já está gravado, e TRUNCATE_EXISTING é o que apaga, ativo por omissão na escrita. Sem APPEND, cada venda registrada apagaria as anteriores, que é o defeito clássico do primeiro registro em arquivo, descoberto no fim do dia com uma venda só no relatório. O System.lineSeparator() devolve a quebra de linha do sistema em que o programa roda, e usá-lo em vez de escrever a sequência de escape à mão mantém o arquivo legível nas ferramentas de qualquer sistema operacional.

O estoque em arquivo usa ponto e vírgula como separador, e o fornecedor novo cadastra o produto com capricho:

Produto novo = new Produto("7891000300107", "Açúcar; cristal 5kg", new BigDecimal("21.50"), 30);
gravarNoEstoque(novo);

A gravação corre sem erro. O que acontece quando o sistema carregar o arquivo na manhã seguinte?

A carga cai com NumberFormatException: o ponto e vírgula dentro do nome virou separador, a linha ganhou uma coluna a mais, e a conversão de preço recebeu “ cristal 5kg“. A queda acontece longe da causa, no dia seguinte, com o arquivo já corrompido no disco; e ela é o desfecho bom, porque bastaria o deslocamento cair numa coluna de texto para a linha carregar em silêncio, com os dados trocados e ninguém avisado. Dado corrompido em silêncio é o pior resultado de uma gravação. As saídas honestas, em ordem de esforço: proibir o separador no dado, com a invariante do construtor recusando ; no nome; escapar o separador na escrita e desfazer na leitura; ou adotar um formato com biblioteca pronta, o caminho de sistemas maiores, fora do escopo daqui. O mercadinho adota a primeira, uma linha no construtor, e um teste de regressão a vigia.

Argumentos de linha de comando, de novo

O String[] args da moldura completa do main fecha o circuito da previsão do caminho relativo: o arquivo do estoque não precisa estar cravado no fonte.

public static void main(String[] args) throws IOException {
    Path arquivo = args.length > 0 ? Path.of(args[0]) : Path.of("estoque.txt");
    // carrega e segue
}
$ java -jar mercadinho.jar /dados/mercadinho/estoque.txt

Argumento presente escolhe o arquivo, ausente cai no padrão, e o operador decide na execução, sem recompilar. É o suficiente para o mercadinho; sistemas com muitas opções adotam bibliotecas de linha de comando, mais uma fronteira anotada e não cruzada.

O jar executável

A linha acima usou java -jar, e falta pagá-la. O jar do capítulo 14 era uma caixa de bytecode para o classpath dos outros; o jar executável é a mesma caixa sabendo por onde começar. Quem guarda essa informação é o manifest: o arquivo de metadados que vive dentro de todo jar, em META-INF/MANIFEST.MF, pares de chave e valor sobre o pacote; a chave Main-Class aponta a classe cujo main o java -jar deve chamar. No Maven, isso é configuração do plugin que empacota:

<plugin>
    <groupId>org.apache.maven.plugins</groupId>
    <artifactId>maven-jar-plugin</artifactId>
    <configuration>
        <archive>
            <manifest>
                <mainClass>Loja</mainClass>
            </manifest>
        </archive>
    </configuration>
</plugin>
$ mvn package
$ java -jar target/mercadinho-1.0.jar

O nome em mainClass é o da classe com o main; quando o projeto ganhar pacotes, no capítulo 25, ele passa a ser o nome qualificado. Dois comandos separam o fonte de um arquivo único que roda em qualquer máquina com JDK: é a promessa de distribuição da JVM embrulhada para entrega. O projeto integrador fecha nesse formato.

Arquivo como stream. Files.lines(arquivo) devolve as linhas como um stream preguiçoso: as linhas são lidas do disco conforme a operação terminal puxa. O detalhe que pega: esse stream segura o arquivo aberto, é um recurso, e mora num try-with-resources como qualquer leitor; é o único stream comum do livro com essa exigência.

Prática

  1. Escreva RepositorioDeEstoqueEmArquivo com dois métodos: salvar(List<Produto>) e carregar(), no formato de quatro colunas, com try-with-resources na leitura linha a linha e IOException tratada com mensagem digna para o operador.

  2. Reproduza a previsão do caminho relativo executando de duas pastas diferentes, conserte com argumento de linha de comando, e imprima o caminho absoluto no início do programa como diagnóstico permanente.

  3. Reproduza a armadilha do separador, mostre o produto corrompido, e aplique a defesa da invariante: Produto recusa ; no nome. Escreva o teste de regressão com assertThrows.

  4. Acrescente ao repositório a criação da pasta de dados quando não existir, e o comportamento de primeiro uso: arquivo ausente devolve estoque vazio em vez de cair. Decida se carregar devolve lista ou Optional e justifique.

  5. Configure o jar executável, empacote, e rode o mercadinho de fora da pasta do projeto, passando o caminho do estoque por argumento. Confira o manifest gerado com unzip -p target/mercadinho-1.0.jar META-INF/MANIFEST.MF.

  6. Escreva o registro de vendas do dia num segundo arquivo, uma linha por venda, com APPEND. Rode o programa três vezes e confirme que as vendas das execuções anteriores continuam lá. Depois remova o APPEND, rode de novo e descreva o estrago.

  7. Monte o caminho do arquivo de estoque com Path.of e resolve a partir de uma pasta de dados recebida por argumento, criando a pasta com createDirectories quando faltar. Imprima getFileName, getParent e toAbsolutePath no início do programa.

  8. Escreva o teste da camada de arquivo com Files.createTempFile: grave três produtos, releia, compare com os originais e apague o arquivo no fim. Explique por escrito por que esse teste pode rodar em qualquer máquina e o do exercício 1 não podia.

Ficha do capítulo

ChamadaO que faz
Path.of("...")o endereço; não toca o disco
Files.write / readAllLines / readStringescrita e leitura inteiras, UTF-8
Files.exists / createDirectoriesconferência e criação de pastas
Files.copy / move / deleteIfExists / sizeas demais operações sobre o arquivo
arquivo temporáriocriado pelo sistema, com nome único; base do teste de infraestrutura
caminho.resolve / getFileName / getParentcomposição e decomposição de caminho
Files.newBufferedWriterescritor linha a linha; recurso a fechar
StandardOpenOption.APPENDacrescenta ao fim em vez de apagar o conteúdo
Files.newBufferedReaderleitor linha a linha; recurso a fechar
Files.linesas linhas como stream preguiçoso; recurso a fechar
java -jar arquivo.jarexecuta o jar pela Main-Class do manifest
TermoDefinição
Pathum caminho no disco, absoluto ou relativo ao diretório de trabalho
Filesas operações de arquivo, em métodos estáticos
IOExceptiona checked do mundo externo: disco, permissão, ausência
charseta tabela que traduz caracteres em bytes; UTF-8 é o padrão
try-with-resourcesrecurso declarado no try, fechado por qualquer saída
AutoCloseableo contrato de um método close; habilita o try-with-resources
manifestos metadados dentro do jar; Main-Class define a entrada
jar executáveljar com Main-Class, executado por java -jar