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.
| Chamada | O 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:
| Chamada | O 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
-
Escreva
RepositorioDeEstoqueEmArquivocom dois métodos:salvar(List<Produto>)ecarregar(), no formato de quatro colunas, com try-with-resources na leitura linha a linha eIOExceptiontratada com mensagem digna para o operador. -
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.
-
Reproduza a armadilha do separador, mostre o produto corrompido, e aplique a defesa da invariante:
Produtorecusa;no nome. Escreva o teste de regressão comassertThrows. -
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
carregardevolve lista ouOptionale justifique. -
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. -
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 oAPPEND, rode de novo e descreva o estrago. -
Monte o caminho do arquivo de estoque com
Path.oferesolvea partir de uma pasta de dados recebida por argumento, criando a pasta comcreateDirectoriesquando faltar. ImprimagetFileName,getParentetoAbsolutePathno início do programa. -
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
| Chamada | O que faz |
|---|---|
Path.of("...") | o endereço; não toca o disco |
Files.write / readAllLines / readString | escrita e leitura inteiras, UTF-8 |
Files.exists / createDirectories | conferência e criação de pastas |
Files.copy / move / deleteIfExists / size | as demais operações sobre o arquivo |
| arquivo temporário | criado pelo sistema, com nome único; base do teste de infraestrutura |
caminho.resolve / getFileName / getParent | composição e decomposição de caminho |
Files.newBufferedWriter | escritor linha a linha; recurso a fechar |
StandardOpenOption.APPEND | acrescenta ao fim em vez de apagar o conteúdo |
Files.newBufferedReader | leitor linha a linha; recurso a fechar |
Files.lines | as linhas como stream preguiçoso; recurso a fechar |
java -jar arquivo.jar | executa o jar pela Main-Class do manifest |
| Termo | Definição |
|---|---|
Path | um caminho no disco, absoluto ou relativo ao diretório de trabalho |
Files | as operações de arquivo, em métodos estáticos |
IOException | a checked do mundo externo: disco, permissão, ausência |
| charset | a tabela que traduz caracteres em bytes; UTF-8 é o padrão |
| try-with-resources | recurso declarado no try, fechado por qualquer saída |
AutoCloseable | o contrato de um método close; habilita o try-with-resources |
| manifest | os metadados dentro do jar; Main-Class define a entrada |
| jar executável | jar com Main-Class, executado por java -jar |