Java 101

Java I/O

Java I/O


Esse post faz parte de uma série introdutória sobre Java, se você não conhece a linguagem e não leu os posts anteriores, recomendo os ler para ter uma visão melhor da plataforma. Nessa série, já falamos sobre o que é o ecossistema Java, o que é a biblioteca Collections e como Java faz Orientação a Objetos, esses tópicos são necessários para o que vamos falar agora: I/O.

O que é I/O?!?

Quando pensamos em um computador a primeira coisa que pensamos é no que fazemos online: enviar um tweet, responder email, ver um vídeo ou mesmo ler esse post. Mas um computador não entende essas atividades, para ele tudo são bits, ou seja, todas essa são atividades podem ser traduzidas em outras atividades de baixo nível. Quando eu uso o termo "baixo nível" entenda como algo de menor abstração, por exemplo, para você ler esse post, o seu navegador teve que renderizar uma página HTML, que foi requisitada de um servidor HTTP usando uma conexão Socket, que na verdade é apenas uma troca de bits entre vários computadores. Essas atividades sempre envolvem trocas de informações que só são possível através de algo chamado serialização.

Serialização seria a transformação de uma informação em um formato intermediário para que ela possa transitar entre dois processos. Ou seja, a informação que você está lendo agora é composta de alguns arquivos HTML, Javascript, CSS, PNG e JPEG que são enviadas através da web usando o protocolo HTTP sobre TLS.

— Você não ia falar de I/O? Que papo é esse de serialização e internet?!?

Sim, I/O é outra forma de falar sobre serialização. Toda informação para ser enviada ela passa pelos passos de (1) serialização, (2) escrita, (3) transmissão, (4) leitura e (5) desserialização. O processo de transmissão pode ser o envio dessa informação através de uma API, ou o armazenamento dela em um banco de dados ou mesmo a escrita no disco para que possa ser lida no futuro. Os formatos de serialização de dados são bem interessantes de se analisar, mas não é o foco desse post, aqui focaremos em conhecer as bibliotecas que a JVM nos oferece para que possamos ler e escrever objetos onde bem desejarmos.

Na JVM existem dois pacotes que lidam com serialização em Java. O mais conhecido deles é o java.io onde estão definidas as classes para leitura síncrona. Já no java.nio estão definidas as classes para leitura assíncrona (Non-blocking I/O).

Seria hipocrisia da minha parte dizer que você deve conhecer esses pacotes por completo, eu não os conheço. Só quem trabalha especificamente com I/O deve conhecer bem essas classes. Não se surpreenda se um desenvolvedor com anos de experiência em Java procurar no Google "how to read a text file in Java". Isso acontece porque esses pacotes são complexos e por isso difíceis de serem internalizados. Mas você deve saber algumas informações importantes e nós vamos trabalhar elas aqui.

  1. Porque a interface Serializable? Devo usar?

  2. O que é um InputStream e um OutputStream?

  3. Qual Stream devo usar?

  4. Qual a diferença entre um Stream e os Readers/Writers?

Nós não vamos falar de NIO, esse será o assunto de um post mais a frente. Não estranhe se você perceber que um Sênior não sabe como usar as classes desse pacote, em muitos casos elas são usadas apenas pelos frameworks o que implica que muitos desenvolvedores nunca tiveram contato com ela.

A diferença entre IO e NIO

Talvez você tenha ficado curioso do motivo de existirem dois pacotes para I/O. Não ficou? Bom, existem dois pacotes diferentes porque NIO é um conceito muito mais novo do que IO. IO existe desde que os computadores existem e sempre foi um problema para qualquer software. Se você não fez faculdade de Ciência da Computação, saiba que existe até uma matéria só pensando em como se criar estrutura de dados para arquivos, isso porque ao se ler um arquivos nos deparamos com alguns problemas que deveriam ser óbvios: (1) o tempo de leitura é muito inferior ao tempo de acesso a memória, (2) os dados são armazenados em blocos que não são facilmente rearranjáveis e (3) a leitura de blocos próximos é mais rápida que a leitura de blocos distantes. Os discos mais novos não possuem o problema (3), mas mesmo assim ler e escrever de arquivo não pode ser feito da mesma forma que ler e escrever na memória.

— Escrever na memória?!?! Eu nunca escrevi na memória!!!

Todo programa, ao ser executado, está armazenado na memória. Essa é uma operação tão comum que é transparente para linguagens alto nível. Se estivéssemos escrevendo em C seria preciso alocar e desalocar memória. Mas em Java a alocação é feita com um new e a memória é desalocada automaticamente. Mas não é possível alocar espaça em disco.

Se compararmos a escrita e memória com a escrita em disco, ou interface de redes, vemos que a primeira é tão rápida que pode ser considerada imediata. Já os outros tipos de escrita não podem ser consideradas imediatas, por isso surgiram uma série de interfaces capazes de avisar ao software quando o dado está pronto para ser lido. É nesse ponto que diferenciamos IO de NIO! O pacote java.io são classes usadas para leitura/escrita bloqueante, enquanto o pacote java.nio são classes de leitura/escrita não bloqueante. E como NIO é mais recente que o IO tradicional, seu pacote foi inserido em uma versão do Java bem mais recente (JDK 1.4).

Arquivos, Sockets e Linux 🐧

Uma das grandes vantagens do Sistema Operacional Linux é que tudo são arquivos. Quase todas as funcionalidades do sistema operacional são expostas através de arquivos mapeados no sistema de arquivos. Assim ao invés de fazer uma chamada de sistema complexa para, por exemplo, obter o tempo que a máquina está em operação, basta ler o arquivo /proc/uptime. Ou ler o arquivo /proc/cpuinfo para obter uma série de informações sobre a CPU.

Essa foi uma escolha arquitetural do sistema que se tornou bastante eficaz porque cria uma interface comum entre diversas operações. Por exemplo, se você for procurar no Windows a maneira de se ver todos os processo em execução, verá que tem uma API (lembre-se que API não se refere só a API REST) complexa, mas em um Linux basta executar ls /proc e todos os diretórios com números são processos. Para saber mais informações dos processos, basta acessar alguns arquivos dentro dessas pastas.

Essa informação pode parecer perdida, mas ela tem uma relação profunda com o que veremos a seguir. Quando o Linux escolhe mapear tudo como arquivo, a escolha feita é por se tratar diversas formas de dados por uma mesma interface. Arquivos são fáceis de serem lidos, então ao expor tudo como arquivo é fácil conseguir acessar essas informações. A JVM também traz a mesma abordagem! Tudo em serialização vai se resumir a poucas classes. A operação de leitura de um arquivo ou leitura de um socket são tão semelhantes que podem ser executadas pelo mesmo código.

Apresentação do pacote java.io

Para entendermos o pacote java.io primeiro precisamos entender o que é um Stream (ou fluxo em tradução livre). Não confunda Stream de I/O com Stream de Collections, eles tem um conceito parecido, mas são aplicados em locais diferentes. Stream significa fluxo e quando falamos de Stream estamos falando de uma informação que flui em sentido único.

Para entender melhor é preciso pensar em como era feito antes…​ As bibliotecas do C para leitura de arquivo/socket não fazem diferenciação entre as interfaces de leitura e escrita, ao se criar um canal de comunicação temos um inteiro que identifica o canal e esse inteiro pode ser usado tanto para leitura como para escrita. Observe a documentação das funções read e write e veja que elas recebem os mesmo argumentos.

read
Figura 1. Documentação da função read
write
Figura 2. Documentação da função write

Em Java foi decidido que haveria uma diferenciação lógica entre leitura e escrita. Ao se ler um arquivo poderíamos ter o fluxo de leitura (InputStream ou Reader) e o fluxo de escrita (OutputStream ou Writer). Cada um desses fluxos teria uma orientação única, isso significa que um InputStream apenas lê e o OutputStream apenas escreve. É por isso que se usa o nome Stream.

Essa é a primeira informação importante do pacote java.io: As interfaces de leitura são separadas das interfaces de escrita! Para apresentar o pacote em um diagrama de classes foi até preciso criar essa separação para possibilitar que melhor visualização.

Outro ponto da biblioteca C que explica o funcionamento do pacote java.io são as funções open e close. Em qualquer sistema operacional para se realizar a leitura em arquivo, ou em um socket, só é possível com a alocação de recurso. Isso é feito para evitar que processos diferentes criem estados inconsistentes. Quando um processo chama a função open para um determinado arquivo, ele não poderá ser aberto por outro processo enquanto não for liberado através da função close. Se a função close não for chamada, o arquivo só será liberado quando o processo morrer o que pode também gerar um estado inconsistente. Por isso era importante garantir na escrita do código que a função close sempre fosse chamada e que o arquivo sempre estivesse em um estado consistente. Lembre-se que leitura e escrita não são processos imediatos, se o programa finalizar ou o arquivo for fechado antes da escrita terminar, o arquivo fica em um estado inconsistente.

open
Figura 3. Documentação da função open
close
Figura 4. Documentação da função close

Agora volta ao Java…​ Em C era preciso criar mecanismos de garantir que o arquivo estava fechado antes que o programa finalizasse. Em Java isso foi internalizado na linguagem através de alguns mecanismos. Por isso temos as interfaces Closeable e AutoCloseable. Se um objeto precisa liberar recursos depois de usado, ele deve implementar a interface Closeable e o método close deve ser chamado. Até a versão 6 do Java era comum ver o close sendo chamado dentro do bloco finally de um try {} catch {} finally {}.

Reader reader = null;
try {
    reader = // inicia reader
    // lê dados
} catch (IOException ioe) {
    // trata exceção
} finally {
    if (reader != null) {
        try {
            reader.close();
        } catch (IOException ioe) {
            // trata exceção
        }
    }
}

Como esse código tem muito boilerplate (código sem significado único, repetido), o Java 7 trouxe um recurso na sintaxe chamado try-with-resources. Agora todo inicio de um try-catch é possível declarar um ou mais objetos que devem implementar a nova interface chamada AutoCloseable. Como esse é um recurso da linguagem, a interface AutoCloseable não faz parte do pacote java.io, ao contrário da interface Closeable, mas do package java.lang. Assim o bloco finally poderia ser removido sem prejuízo nenhum a lógica do programa.

try (Reader reader = /* inicia reader */) {
    // lê dados
} catch (IOException ioe) {
    // trata exceção
}

Agora que sabemos que (1) objetos de I/O devem liberar recursos e que as classes de I/O são do tipo Closeable, observe as principais classes do pacote. Vamos explorar um pouco delas.

IO Read
Figura 5. Java I/O classes de leitura
IO Write
Figura 6. Java I/O classes de escrita

Casos de Uso

Para explorar melhor essas classes, vamos dividir o pacote em 5 casos de usos bem comuns para biblioteca I/O.

  1. Como ler um arquivo?

  2. Como escrever um arquivo?

  3. Como ler dados do console?

  4. Como ler/escrever em Socket?

  5. Lidando objetos complexos

1. Como ler um arquivo?

Falamos anteriormente que a diferença entre um InputStream e um Reader é que o InputStream trabalha com bytes enquanto o Reader com caracteres. Agora vamos mostrar um exemplo prático? Imagina que você tem um arquivo texto em formato JSON, como fazer pra o ler? Se pensou em ler usando um Reader…​ vá com calma! A primeira coisa a fazer é decidir qual biblioteca vai ser usada para ler o JSON. A escolha deve começar pelo elemento mais complexo.

Para se ler um JSON, temos uma biblioteca praticamente onipresente: Jackson Databind! O coração dessa biblioteca é a classe ObjectMapper e ela define várias formas de se escrever em arquivo, a forma mais fácil nem chega a usar Stream ou Readers. O código abaixo foi retirado a própria documentação do ObjectMapper, observe que não se usa nem InputStream/OutputStream ou Readers/Writers.

final ObjectMapper mapper = new ObjectMapper(); // can use static singleton, inject: just make sure to reuse!
MyValue value = new MyValue();
// ... and configure
File newState = new File("my-stuff.json");
mapper.writeValue(newState, value); // writes JSON serialization of MyValue instance
// or, read
MyValue older = mapper.readValue(new File("my-older-stuff.json"), MyValue.class);

// Or if you prefer JSON Tree representation:
JsonNode root = mapper.readTree(newState);
// and find values by, for example, using a JsonPointer expression:
int age = root.at("/personal/age").getValueAsInt();

Mas isso não impede que se use eles para ler dados de um arquivo. A primeira missão que temos é mapear o objeto que devemos ler como um POJO. Em um projeto pessoal eu criei uma interface para inspecionar Cluster Kafka, o Kafka Tool. Nesse projeto, todas as configurações são salvas em arquivos JSON no diretório ~/.kafka-tool (arquivos começados com . são considerados ocultos no Linux), assim para armazenar as informações de Brokers é preciso primeiro mapear um broker. Depois de mapeador o broker é preciso carregar a lista de brokers do arquivo, para isso basta usar o código abaixo.

Path kafkaToolConfigPath = PAths.get(System.getProperty("user.home"), ".kafka-tool");
if (!kafkaToolConfigPath.toFile().exists()) {

    Path propertiesPath = kafkaToolConfigPath.resolve("kafka-properties.json");
    if (propertiesPath.toFile().exists()) {
        try (BufferedReader reader = Files.newBufferedReader(propertiesPath)) {
            return Optional.of(reader.lines()
                                     .collect(Collectors.joining()))
                           .filter(Predicate.not(String::isBlank))
                           .flatMap(value -> handleIoException(() -> mapper.readValue(value, KafkaBroker[].class)));
        } catch (IOException e) {
            logger.error("Error reading file!", e);
        }
    }
}
return Optional.empty();

Para ler usamos um BufferedReader porque ele permite ler todo o arquivo em texto facilmente, para isso usamos a o método Files.newBufferedReader, que pode ser lido através do método ObjectMapper.readValue que aceita String. Mas também podíamos abrir um InputStream usando Files.newInputStream e usar ele diretamente como parâmetro ObjectMapper.readValue

2. Como escrever um arquivo?

De forma bem similar podemos escreve em arquivos usando as mesmas APIs.

Path kafkaToolConfigPath = PAths.get(System.getProperty("user.home"), ".kafka-tool");
if (!kafkaToolConfigPath.toFile().exists()) {
    kafkaToolConfigPath.toFile().mkdir();
}

Path propertiesPath = kafkaToolConfigPath.resolve("kafka-properties.json");
ObjectMapper mapper = new ObjectMapper().enable(SerializationFeature.INDENT_OUTPUT)
try (BufferedWriter writer = Files.newBufferedWriter(propertiesPath, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING)) {
    writer.write(mapper.writeValueAsString(brokers));
} catch (IOException e) {
    logger.error("Error saving file!", e);
}

Para escrever usamos um BufferedWriter, através do Files.newBufferedWriter, porque é uma opção viável para se usar com ObjectMapper.writeValueAsString. Mas da mesma forma podíamos usar OutputStream, através do Files.newOutputStream, porque também é uma opção viável para se usar com ObjectMapper.writeValueAsBytes

3. Como ler dados do console?

Toda aplicação pode rodar em modo de linha de comando. Linha de comando é bastante útil porque possibilita que as aplicações sejam integradas a scripts de execução seguindo a Filosofia Unix: Escreva programas para lidar com fluxos de texto, porque essa é uma interface universal.

A primeira informação importante é saber que os streams de entrada, saída e erro estão expostos como variáveis globais na classe System. Assim podemos facilmente escrever um programa que lê da linha de comando com algumas linhas.

try(BufferedReader reader = new BufferedReader(new InputStreamReader(System.in))) {
    String name = reader.readLine();
    System.out.println(name);
}

Esse código é certo e funciona, mas existe uma outra classe que facilita em muito o tratamento de dados que vem do console, é a classe Scanner. Com ela é possível tratar os dados de entrada de forma mais fácil. Por exemplo se eu quiser fazer um programa para lê números do console, é possível fazer com poucas linhas.

try(Scanner in = new Scanner(System.in)) {
    System.out.print("Qual o seu nome? ");
    String nome = in.nextLine();
    System.out.print("Quantos anos você tem? ");
    int idade = in.nextInt();
    System.out.println("Oi " + nome + "! Você tem " + idade + " anos!");
}

4. Como ler/escrever em Socket?

Sockets devem ser usados com parcimônia! Sockets permitem que dois processos se comuniquem entre si através de uma conexão TCP direta. O problema em usar Sockets é que em muitos casos você pode estar reimplementando um protocolo já conhecido. Mas as vantagens de se usar socket é que seu programa vai ter liberdade de se comunicar. Quando temos dois programas se comunicando por socket um deles será o cliente e o outro será o servidor, é o que chamamos de Socket Server.

Caso de Uso
Eu já implementei um caso de uso bastante complexo usando Socket, mas era porque tínhamos um servidor de geração de voz. Os clientes enviavam texto e outros parâmetros e recebiam de volta Stream de dados de acordo com o formato requerido (MP3, WAV, etc…​).

Não vamos entrar aqui em detalhes sobre como a classe Socket funciona, mas ao abrir um socket, ela vai dispor de dois Stream para leitura e escrita de dados. Assim podemos ter o servidor abaixo.

AtomicBoolean running = new AtomicBoolean(true);
ExecutorService threadPool = Executors.newFixedThreadPool(10); // thread para processar socket
try(ServerSocket server = new ServerSocket(5555)) {            // abre socket na porta 5555
    while (running.get()) {
        Socket socket = server.accept();                       // conexão aberta com cliente
        threadPool.submit(() -> {                              // Se não tratar dentro de uma thread não é possível abrir outras conexões
            try {
                process(socket.getInputStream(),               // encapsula toda comunicação
                        socket.getOutputStream());
            } finally {
                socket.close();                               // Só fecha o socket depois de finalizada a comunicação
            }
        });
    }
}

Já o cliente é um pouco mais simples porque não se espera que ele se conecte com mais de um servidor.

try (Socket socket = new Socket("localhost", 5555)) {
    process(socket.getInputStream(), socket.getOutputStream());
}

Eu não recomendo a você escrever um servidor socket em nenhuma hipótese. Caso você tenha um protocolo complexo que deve ser feito através de um servidor socket, eu recomendo usar o projeto Netty para que você consiga focar nas regras de negócios deixando funcionalidades como serialização, controle de threads e segurança como responsabilidade da biblioteca.

5. Lidando objetos complexos

Se você foi atento deve ter reparado que no diagrama de classe tem duas classes que parecem bastante úteis: ObjectInputStream e ObjectOutputStream. Essas duas classes permitem serializar qualquer objeto da JVM e enviar para outra JVM, é por causa dessas classes que existe a interface Serializable a qual eu citei na minha primeira pergunta e até agora não respondi. Pois vamos entender o motivo de deixar essa resposta por último?

Para serializar um objeto eu devo usar a interface Serializable? Não! Você pode usar qualquer biblioteca com formatos de serialização que são compreendidos por várias linguagens. A interface Serializable é usada para serializar objetos que só podem ser carregados na JVM através das classes ObjectInputStream e ObjectOutputStream. MAS essas classes não deve ser usadas porque elas tem várias falhas de segurança que podem ser exploradas. Então resposta curta: Não use essas classes!

Próximos passos

Eu espero que você tenha compreendido que como ler dados de várias fontes como arquivos ou sockets. Agora é hora de você aprender a usar bibliotecas de leituras de arquivos. Recomendo que você explore a biblioteca Jackson, assim como outras bibliotecas para se escrever JSON. Um bom exercício é comparar a performance de escrita entre várias bibliotecas e escolher a que você vai usar sempre.

Outros exercícios são tentar conhecer a biblioteca de leitura e XML, YAML, TOML ou qualquer outro formato que lhe interessar.

Originally published July 04, 2022