Todos os artigos

// Knowledge.log — 技術記事

Por que Files.readString quebra o arquivo que o colega salvou no Windows

JDK 25: Files.readString(Path) é UTF-8, não o charset da JVM. Byte 0xE9, MalformedInputException, e o contrato explícito na leitura.

O colega exporta um CSV pelo Excel, ou salva um JSON no Bloco de Notas como "ANSI", e manda o arquivo para o repositório de integração. O seu código chama Files.readString(path) e entrega o texto ao parser. O pipeline no Linux quebra com java.nio.charset.MalformedInputException: Input length = 1. Ninguém editou o conteúdo, e o diff do arquivo está vazio.

A primeira reação costuma ser culpar o Linux do CI, mas ele não tem nada a ver com isso. Pelo javadoc, Files.readString(Path) decodifica os bytes como UTF-8, em qualquer sistema operacional. O arquivo do colega não está em UTF-8.

Abaixo está a reprodução com um arquivo de um byte só, a explicação de por que o JEP 400 não muda esse método e de por que -Dfile.encoding também não muda, e a correção em duas partes: um contrato UTF-8 explícito para os arquivos que você mesmo grava e um charset declarado para o arquivo que chega do parceiro.

Ambiente

  • Temurin 25.0.4 (25.0.4+7-LTS), sem --enable-preview
  • Maven 3.9.12, maven.compiler.release=25
  • maven-compiler-plugin 3.14.0, maven-surefire-plugin 3.6.0
  • org.junit.jupiter:junit-jupiter 6.1.3
  • Linux, platform encoding: UTF-8, sem Spring

O pom.xml, resumido:

<groupId>devdojo</groupId>
<artifactId>files-readstring-charset-fixture</artifactId>
<version>1.0.0-SNAPSHOT</version>
<properties>
  <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
  <maven.compiler.release>25</maven.compiler.release>
  <junit.version>6.1.3</junit.version>
</properties>
<dependencies>
  <dependency>
    <groupId>org.junit.jupiter</groupId>
    <artifactId>junit-jupiter</artifactId>
    <version>${junit.version}</version>
    <scope>test</scope>
  </dependency>
</dependencies>
<build>
  <plugins>
    <plugin>
      <groupId>org.apache.maven.plugins</groupId>
      <artifactId>maven-compiler-plugin</artifactId>
      <version>3.14.0</version>
      <configuration>
        <release>25</release>
      </configuration>
    </plugin>
    <plugin>
      <groupId>org.apache.maven.plugins</groupId>
      <artifactId>maven-surefire-plugin</artifactId>
      <version>3.6.0</version>
    </plugin>
  </plugins>
</build>

O project.build.sourceEncoding=UTF-8 define como o compilador lê os seus .java. Ele não muda em nada a forma como readString decodifica o CSV do parceiro. Essa propriedade aparece em muito "fix" de encoding copiado da internet, e neste problema ela não faz nada.

Um byte, um arquivo e o readString sem charset

Para reproduzir, não é preciso CSV nem JSON. Basta o byte 0xE9:

  • Em windows-1252 e em ISO-8859-1, 0xE9 é o é (U+00E9).
  • Em UTF-8, 0xE9 sozinho é uma sequência mal formada. Ele abre uma sequência de três bytes, e os outros dois não vêm. O é em UTF-8 são dois bytes: C3 A9.

Um nome como "José" salvo como ANSI vira, no disco, exatamente esse caso: a letra acentuada ocupa um byte que não é UTF-8 válido.

O javadoc de Files no JDK 25 diz que Files.readString(Path) lê o conteúdo e decodifica os bytes usando o charset UTF-8. Ele não consulta Charset.defaultCharset() nem segue o -Dfile.encoding. Quando você quer outro charset, a sobrecarga é Files.readString(Path, Charset). O mesmo vale para Files.newBufferedReader(Path): UTF-8 declarado no javadoc, com a sobrecarga newBufferedReader(Path, Charset).

A classe Demo do projeto grava {(byte) 0xE9} num arquivo temporário e tenta ler de várias formas. Esta é a saída neste host com o default da JVM (UTF-8):

bytes=[-23]
Charset.defaultCharset()=UTF-8
new String(bytes, defaultCharset)=�
new String(bytes, ISO-8859-1)=é
Files.readString(path) threw java.nio.charset.MalformedInputException: Input length = 1
Files.readString(path, windows-1252)=é
Files.readString(path, ISO-8859-1)=é
Files.newBufferedReader(path) threw java.nio.charset.MalformedInputException: Input length = 1
newBufferedReader(path, windows-1252)=é

Repare nas duas reações diferentes ao mesmo byte. O arquivo é um só, e o que muda é a política do decodificador:

  • Files.readString e Files.newBufferedReader reportam a entrada mal formada e lançam MalformedInputException.
  • new String(bytes, UTF-8) substitui a entrada mal formada por U+FFFD (�) e segue em frente.

Das duas, a exceção é a mais honesta. O � não quebra nada na hora. Ele só aparece semanas depois, num relatório com o cliente "Jos�".

JEP 400 mudou o default da JVM, não este método

O JEP 400, entregue no Java 18 e ainda válido no JDK 25, tornou o UTF-8 o charset default das APIs que dependem do default. O objetivo foi fazer new String(byte[]), InputStreamReader sem charset, FileReader e companhia se comportarem igual em qualquer sistema operacional.

Os métodos de Files sem charset não estão nesse grupo. Eles já diziam UTF-8 no javadoc antes do JEP 400, então o JEP não os "consertou" nem os "quebrou". Por isso trocar o -Dfile.encoding não ajuda. Rodando a Demo com -Dfile.encoding=ISO-8859-1 e com -Dfile.encoding=windows-1252, o resultado foi este:

API chamadaCharset sem argumentoDefault UTF-8-Dfile.encoding=ISO-8859-1 ou windows-1252
Files.readString(path)UTF-8 (javadoc)MalformedInputExceptionMalformedInputException
Files.newBufferedReader(path)UTF-8 (javadoc)MalformedInputExceptionMalformedInputException
new String(bytes, Charset.defaultCharset())default da JVM�é
new InputStreamReader(Files.newInputStream(path))default da JVMcode point 65533 (�)code point 233 (é)
Files.readString(path, Charset.forName("windows-1252"))o que você passouéé

Nas três execuções, native.encoding ficou UTF-8. O file.encoding e o Charset.defaultCharset() acompanharam a flag, e Files.readString(path) lançou a mesma exceção nas três.

Resumindo: colocar -Dfile.encoding=windows-1252 no MAVEN_OPTS do CI e manter readString(path) não resolve. Muda a leitura feita pelo new String em outro canto do código, deixa o readString exatamente como estava e ainda torna o build dependente de uma flag que ninguém vai lembrar por que existe.

A documentação de Charset explica o default charset. A de InputStreamReader mostra o construtor sem charset usando esse default. São contratos diferentes do contrato de Files.readString(Path), e é por isso que o mesmo arquivo se comporta de um jeito em cada API.

O teste que grava 0xE9 no @TempDir

Não versione um .csv "quebrado" no repositório. Editor, .gitattributes e ferramenta de merge mexem em encoding sem avisar, e o teste passaria a testar o editor de quem fez o último commit. O teste grava os bytes direto no @TempDir, sem nenhum String no caminho:

package com.devdojo.encoding;

import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.nio.charset.Charset;
import java.nio.charset.MalformedInputException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Arrays;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;

class ReadStringCharsetTest {

    private static final byte[] LATIN1_E_ACUTE = {(byte) 0xE9};

    private static Path latin1File(Path dir) throws IOException {
        Path p = dir.resolve("colega-windows.csv");
        Files.write(p, LATIN1_E_ACUTE);
        return p;
    }

    @Test
    void readStringWithoutCharsetThrowsMalformedInputException(@TempDir Path dir) throws IOException {
        Path p = latin1File(dir);
        assertEquals("[-23]", Arrays.toString(Files.readAllBytes(p)));
        MalformedInputException ex = assertThrows(
                MalformedInputException.class,
                () -> Files.readString(p));
        assertTrue(ex.getMessage() != null && ex.getMessage().contains("Input length = 1"),
                () -> "unexpected message: " + ex.getMessage());
    }

    @Test
    void readStringWithWindows1252AndIso88591ReturnsEAcute(@TempDir Path dir) throws IOException {
        Path p = latin1File(dir);
        assertEquals("é", Files.readString(p, Charset.forName("windows-1252")));
        assertEquals("é", Files.readString(p, Charset.forName("ISO-8859-1")));
        assertEquals("é", Files.readString(p, StandardCharsets.ISO_8859_1));
        assertEquals("é", PartnerFiles.readWindows1252(p));
        assertEquals("é", PartnerFiles.readString(p, Charset.forName("windows-1252")));
    }

    @Test
    void newBufferedReaderWithoutCharsetAlsoUtf8OnJdk25(@TempDir Path dir) throws IOException {
        Path p = latin1File(dir);
        MalformedInputException ex = assertThrows(MalformedInputException.class, () -> {
            try (BufferedReader r = Files.newBufferedReader(p)) {
                r.readLine();
            }
        });
        assertTrue(ex.getMessage() != null && ex.getMessage().contains("Input length = 1"));
        try (BufferedReader r = Files.newBufferedReader(p, Charset.forName("windows-1252"))) {
            assertEquals("é", r.readLine());
        }
        try (BufferedReader r = new BufferedReader(
                new InputStreamReader(Files.newInputStream(p), Charset.forName("windows-1252")))) {
            assertEquals("é", r.readLine());
        }
    }

    @Test
    void defaultCharsetStringCtorIsNotTheSameContractAsReadString(@TempDir Path dir) throws IOException {
        Path p = latin1File(dir);
        String viaDefault = new String(Files.readAllBytes(p), Charset.defaultCharset());
        assertEquals("UTF-8", Charset.defaultCharset().name());
        assertEquals("\uFFFD", viaDefault, "UTF-8 decode of 0xE9 yields U+FFFD, readString throws instead");
        assertThrows(MalformedInputException.class, () -> Files.readString(p));
    }
}
mvn -q test

Resultado neste host: ReadStringCharsetTest com Tests run: 4, Failures: 0, Errors: 0, Skipped: 0.

O último teste prende o default em UTF-8 de propósito, porque é o default do JDK 25 desde o JEP 400. Se alguém injetar -Dfile.encoding no Surefire, esse teste quebra, e quebra com razão: ele está documentando que o build passou a depender de uma flag.

Arquivo seu em UTF-8, arquivo do parceiro em windows-1252

A correção depende de quem grava o arquivo.

Se o arquivo é seu (seu serviço grava e seu serviço lê), o contrato é UTF-8, escrito no código dos dois lados:

Files.writeString(path, conteudo, StandardCharsets.UTF_8);
String lido = Files.readString(path, StandardCharsets.UTF_8);

Na prática, readString(path, UTF_8) faz o mesmo que readString(path). A diferença é que o charset fica visível no código. Quem ler essa linha daqui a um ano vê que o contrato é UTF-8 sem precisar abrir o javadoc.

Se o arquivo vem de fora (parceiro, planilha exportada, "salvar como ANSI"), não chame Files.readString(Path) sem charset. Declare o charset em um único ponto de leitura:

package com.devdojo.encoding;

import java.io.IOException;
import java.nio.charset.Charset;
import java.nio.file.Files;
import java.nio.file.Path;

/**
 * Inbound partner files (CSV/JSON saved as Windows-1252 / ISO-8859-1) must
 * never go through {@link Files#readString(Path)} — that overload is UTF-8.
 */
public final class PartnerFiles {

    private PartnerFiles() {}

    public static String readString(Path path, Charset charset) throws IOException {
        return Files.readString(path, charset);
    }

    public static String readWindows1252(Path path) throws IOException {
        return readString(path, Charset.forName("windows-1252"));
    }
}

Quando o encoding muda de parceiro para parceiro, ele vira configuração: PartnerFiles.readString(path, Charset.forName(configEncoding)). Os detalhes que costumam pegar:

  • windows-1252 não existe em StandardCharsets. Use Charset.forName("windows-1252"). A constante que existe é StandardCharsets.ISO_8859_1.
  • Para 0xE9, windows-1252 e ISO-8859-1 dão o mesmo é, e o teste confirma isso. Os dois diferem na faixa 0x80–0x9F, onde o windows-1252 coloca caracteres imprimíveis como aspas curvas e o €. Para arquivos "ANSI" vindos do Windows, windows-1252 é o nome certo.
  • A mensagem da exceção é só Input length = 1, sem o caminho e sem o charset esperado. Num job com quarenta arquivos, isso não ajuda ninguém. No ponto de leitura, capture CharacterCodingException (que é a superclasse de MalformedInputException) e registre no log o path e o charset usado antes de relançar. Quando o CI quebrar, o log vai dizer qual arquivo chegou em qual encoding.

A recomendação da DevDojo: em código de produção, nenhuma leitura de arquivo externo usa Files.readString(Path) ou Files.newBufferedReader(Path) sem charset, e os arquivos internos passam StandardCharsets.UTF_8 explicitamente na escrita e na leitura. O helper com charset fixo deixa de ser suficiente quando o mesmo parceiro manda arquivos em encodings diferentes de um envio para o outro. Nesse caso o problema é de contrato com o parceiro: combine o encoding no acordo de integração ou peça UTF-8. Adivinhar charset a partir dos bytes é outro assunto, e este texto não cobre.

O que fica de fora e próximo passo

Este texto trata de arquivo em disco lido com NIO. Charset de resposta HTTP, Content-Type e clientes Spring são outro contrato, e estão em charset no client HTTP do Spring. O InputStreamReader sem charset, assim como o FileReader, segue o default da JVM. É uma armadilha parecida, mas com regra diferente: ali o -Dfile.encoding muda o resultado, como a tabela mostrou.

Para conferir no seu projeto, procure as leituras de arquivo sem charset. O grep abaixo é aproximado e deixa passar chamadas com vírgula dentro do argumento, mas pega a maioria:

grep -rnE 'Files\.(readString|newBufferedReader)\([^,]*\)' src/main/java

Para cada ocorrência que lê arquivo de outra pessoa, passe o charset explícito e escreva um teste que grava 0xE9 no @TempDir, como o ReadStringCharsetTest. Depois rode mvn -q test. Se quiser comprovar que a flag da JVM não ajuda, rode java -Dfile.encoding=ISO-8859-1 -cp target/classes com.devdojo.encoding.Demo e veja Files.readString(path) lançar a mesma MalformedInputException, enquanto Charset.defaultCharset() informa ISO-8859-1 duas linhas acima.

java

// Continue.training — 次のステップ

Conhecimento só conta quando vira prática.

Volte ao artigo, execute os exemplos e compartilhe o que aprendeu.

Explorar mais artigos