Manipulando arquivos com recursos do Java 8

Há algum tempo, minha esposa pediu que eu descobrisse quantas horas semanais os servidores públicos federais trabalham. Essa informação está disponível no Portal de Transparência do Governo Federal.

Baixei um .csv com mais de 730 mil linhas e com quase 320 MB depois de descompactado. Estudei a estrutura do arquivo e resolvi fazer um programa Java para extrair as informações necessárias, utilizando alguns recursos do Java 8.

É importante que você esteja minimamente familiarizado com lambdas, streams, default methods e method references, para entender bem esse post.

Lendo as linhas do arquivo

A primeira tarefa é ler as linhas do arquivo.

No Java 8, a classe java.nio.files.Files ganhou o método lines, que retorna um java.util.stream.Stream.

Temos que passar um java.nio.files.Path com o caminho do arquivo. Vamos utilizar a classe java.nio.files.Paths, para obter o arquivo 20140228_Cadastro.csv, que está na sub-pasta Downloads/201402_Servidores/ da minha pasta pessoal. A propriedade user.home contém o caminho da pasta pessoal do usuário.

Faremos isso com o código:

Path caminho = Paths.get(System.getProperty("user.home"), 
        "Downloads/201402_Servidores/20140228_Cadastro.csv");
Stream<String> linhas = Files.lines(caminho);

O código anterior, quando executado, lança a exceção java.nio.charset.MalformedInputException. Acontece que o método Files::lines considera por padrão o encoding UTF-8. Mas nosso arquivo está com o encoding ISO-8859-1. Precisamos passar um java.nio.charset.Charset, com o encoding correto. Para isso, vamos utilizar a classe java.nio.charset.StandardCharsets:

Path caminho = Paths.get(System.getProperty("user.home"),
        "Downloads/201402_Servidores/20140228_Cadastro.csv");
Stream<String> linhas = Files.lines(caminho,
        StandardCharsets.ISO_8859_1);

Poderíamos imprimir as linhas utilizando linhas.forEach(System.out::println). Mas não é nosso objetivo (além de demorar bastante!).

Encontrando a informação relevante

Estamos interessados apenas nas horas semanais, e não na linha toda. Quero extrair das linhas trechos como: 32,5 HORAS SEMANAIS, 40 HORAS SEMANAIS e DEDICACAO EXCLUSIVA.

Para extrair esses trechos, utilizaremos uma expressão regular, ou regex.

Criaremos uma constante HORAS, que terá um java.util.regex.Pattern com a regex:

private static final Pattern HORAS = Pattern
        .compile(".*([0-9]{2}(,[0-9])? HORAS SEMANAIS|DEDICACAO EXCLUSIVA).*");

Não esquente com a regex, já que não é o foco desse post. Foi criada aos poucos, analisando os dados do arquivo e verificando o resultado.

Precisamos aplicar a regex a cada linha do arquivo. Para isso utilizaremos o método Stream::map, que aplica a cada item do stream um lambda passado como parâmetro, retornando um novo stream com os novos itens.

Nosso lambda será responsável por aplicar a regex da constante HORA, utilizando um java.util.regex.Matcher para extrair a parte relevante da linha. Teremos o código:

Stream<String> horasSemanais = linhas.map(linha -> {
        Matcher matcher = HORAS.matcher(linha);
        return matcher.matches() ? matcher.group(1) : "";
});

A variável horasSemanais conterá um Stream somente com as informações de horas semanais de cada linha.

Filtrando linhas vazias

Há um problema na nossa regex: ela deixa passar uma linha que fica vazia. Vamos filtrar o stream horasSemanais, deixando apenas as linhas não vazias.

Para isso, podemos utilizar o método Stream::filter, que recebe como parâmetro um lambda. Permanecem apenas os itens para os quais o lambda retornou true, em um novo stream.

Utilizaremos um lambda que chama o método String::isEmpty, negando o resultado:

Stream<String> horasSemanaisNaoVazias = horasSemanais
        .filter(horaSemanal -> !horaSemanal.isEmpty());

Perceba que a sintaxe desse lambda ficou bem elegante!

Agora temos só as horas semanais, sem linhas vazias.

Se utilizarmos horasSemanaisNaoVazias.forEach(System.out::println), teríamos apenas as informações de horas semanais dos milhares de linhas:

DEDICACAO EXCLUSIVA
DEDICACAO EXCLUSIVA
40 HORAS SEMANAIS
40 HORAS SEMANAIS
DEDICACAO EXCLUSIVA
40 HORAS SEMANAIS
20 HORAS SEMANAIS
40 HORAS SEMANAIS
DEDICACAO EXCLUSIVA
20 HORAS SEMANAIS
40 HORAS SEMANAIS
32,5 HORAS SEMANAIS
32,5 HORAS SEMANAIS
40 HORAS SEMANAIS
...

Agrupando os valores

Estamos quase lá!

Na verdade, precisamos agrupar os dados obtidos no passo anterior, contando quantas ocorrências existem de cada tipo.

Fazer isso parece complicado, né? Mas com o stream do Java 8, podemos usar o método Stream::collect, passando alguma implementação da interface funcional java.util.stream.Collector.

Na classe java.util.stream.Collectors (no plural), há uma implementação de Collector que faz agrupamentos. Exatamente o que a gente precisa! Para obtê-la, devemos chamar o método Collectors::groupingBy. Devemos passar dois lambdas, um para montar as chaves e outro para montar os valores do java.util.Map que será retornado pelo agrupador.

No nosso caso, o lambda que monta as chaves deve retornar a própria linha que está sendo agrupada. Poderíamos representar isso com o lambda s -> s. Mas já existe um lambda pronto que simplesmente retorna o que recebe: o Function.identity().

Já o lambda que monta os valores deve contar as ocorrências de linhas iguais. Também existe um lambda pronto que faz contagens: o Collectors.counting().

Juntando tudo, teríamos:

Map<String, Long> horasSemanaisAgrupadas = horasSemanaisNaoVazias
        .collect(Collectors.groupingBy(Function.identity(),
                Collectors.counting()));

Pronto! Ao executarmos System.out.println(horasSemanaisAgrupadas), teríamos:

{40 HORAS SEMANAIS=571777, 60 HORAS SEMANAIS=9325, 25 HORAS SEMANAIS=1052,
44 HORAS SEMANAIS=7434, 12 HORAS SEMANAIS=69, 30 HORAS SEMANAIS=5509,
DEDICACAO EXCLUSIVA=115359, 32,5 HORAS SEMANAIS=2, 24 HORAS SEMANAIS=2216,
20 HORAS SEMANAIS=23069, 66 HORAS SEMANAIS=308, 36 HORAS SEMANAIS=1538}

Ordenando os resultados

Conseguimos agrupar os resultados! Bacana!

Mas a impressão não ficou lá essas coisas. Seria melhor que ordenássemos as chaves do Map, ou seja, os tipos de horas semanais.

Uma maneira fácil de fazer isso é ordenar todas as linhas antes de agrupá-las, usando o método Stream::sorted:

Stream<String> horasSemanaisOrdenadas = horasSemanaisNaoVazias.sorted();
Map<String, Long> horasSemanaisAgrupadas = horasSemanaisOrdenadas
        .collect(Collectors.groupingBy(Function.identity(),
                Collectors.counting()));

Se imprimirmos novamente a variável horasSemanaisAgrupadas, teremos:

{44 HORAS SEMANAIS=7434, 12 HORAS SEMANAIS=69, DEDICACAO EXCLUSIVA=115359,
40 HORAS SEMANAIS=571777, 60 HORAS SEMANAIS=9325, 25 HORAS SEMANAIS=1052,
20 HORAS SEMANAIS=23069, 66 HORAS SEMANAIS=308, 36 HORAS SEMANAIS=1538,
30 HORAS SEMANAIS=5509, 32,5 HORAS SEMANAIS=2, 24 HORAS SEMANAIS=2216}

algo de estranho… Apesar de termos ordenado o stream, o resultado impresso não está ordenado.

Acontece que o Collectors::groupingBy cria um java.util.HashMap por padrão, que não garante que a ordem de inserção será mantida. Para manter a ordem do Map, podemos usar um java.util.LinkedHashMap.

Há uma versão do método Collectors::groupingBy em que o segundo parâmetro é um lambda que cria o Map a ser utilizado no agrupamento. Passaremos o construtor padrão de LinkedHashMap com o method reference LinkedHashMap::new.

Teremos, então, com o seguinte código:

Map<String, Long> horasSemanaisAgrupadas = horasSemanaisOrdenadas
        .collect(Collectors.groupingBy(Function.identity(),
               LinkedHashMap::new, Collectors.counting()));

Agora, ao imprimirmos o valor da variável horasSemanaisAgrupadas, os valores ficariam ordenados corretamente:

{12 HORAS SEMANAIS=69, 20 HORAS SEMANAIS=23069, 24 HORAS SEMANAIS=2216,
25 HORAS SEMANAIS=1052, 30 HORAS SEMANAIS=5509, 32,5 HORAS SEMANAIS=2,
36 HORAS SEMANAIS=1538, 40 HORAS SEMANAIS=571777, 44 HORAS SEMANAIS=7434,
60 HORAS SEMANAIS=9325, 66 HORAS SEMANAIS=308, DEDICACAO EXCLUSIVA=115359}

Caprichando na impressão

Ótimo! Mas podemos melhorar um pouquinho a impressão do resultado.

Seria interessante imprimir primeiro a contagem e depois o tipo de horas semanais, formatando com tabs.

Para isso, podemos utilizar o método default forEach, da interface Map, passando um lambda que imprime da maneira que achamos melhor:

horasSemanaisAgrupadas.forEach((k,v) -> System.out.println(v+"\t"+k));

Finalmente, teríamos:

69      12 HORAS SEMANAIS
23069   20 HORAS SEMANAIS
2216    24 HORAS SEMANAIS
1052    25 HORAS SEMANAIS
5509    30 HORAS SEMANAIS
2       32,5 HORAS SEMANAIS
1538    36 HORAS SEMANAIS
571777  40 HORAS SEMANAIS
7434    44 HORAS SEMANAIS
9325    60 HORAS SEMANAIS
308     66 HORAS SEMANAIS
115359  DEDICACAO EXCLUSIVA

Tudo isso rápido pra caramba

Juntando tudo e omitindo variáveis intermediárias, teríamos a seguinte classe:

public class CargaHorariaServidores {

	private static final Pattern HORAS = Pattern
			.compile(".*([0-9]{2}(,[0-9])? HORAS SEMANAIS|DEDICACAO EXCLUSIVA).*");

	public static void main(String[] args) throws IOException {

		Path caminho = Paths.get(System.getProperty("user.home"),
				"Downloads/201402_Servidores/20140228_Cadastro.csv");

		Files.lines(caminho, StandardCharsets.ISO_8859_1)
				.map(linha -> {
					Matcher matcher = HORAS.matcher(linha);
					return matcher.matches() ? matcher.group(1) : "";
				})
				.filter(horaSemanal -> !horaSemanal.isEmpty())
				.sorted()
				.collect(
						Collectors.groupingBy(Function.identity(),
								LinkedHashMap::new, Collectors.counting()))
				.forEach((k, v) -> System.out.println(v + "\t" + k));
	}
}

Ao executar o código anterior na minha máquina (core i5, 6GB RAM, HDD), o arquivo de mais de 730 mil linhas é processado em menos de 8 segundos.

Fiz a mesma coisa com os comandos grep, sort e uniq do Unix, comparando os tempos de execução.

O Java foi 12x mais rápido. Surpreendente, não?

Como é tão rápido? Um dos motivos é que streams são lazy: não fazem nada, só preparam uma computação pra ser feita posteriormente. O collect é a operação terminal, que puxa a computação das linhas.

20 Comentários

  1. Michael Nascimento Santos 14/07/2015 at 09:45 #

    Parabéns Aquiles!

    Apenas uma melhoria, StandardCharsets.ISO_8859_1 já é uma constante pronta pra o exemplo que você deu. Seria divertido também comparar a performance deste exemplo com o uso de parallelStream().

  2. Alberto 14/07/2015 at 10:24 #

    Eita! Ótimo post! Adoro ver um pouco das novas APIs sendo usadas em casos realmente necessários.

  3. Alexandre Aquiles 14/07/2015 at 10:51 #

    Sugestões atendidas, Michel.

    Troquei Files.lines(caminho, Charset.forName("ISO-8859-1")) para Files.lines(caminho, StandardCharsets.ISO_8859_1).

    Sobre o uso de streams paralelas, um .parallel() após o Files.lines diminui em cerca de 30% o tempo para o usuário (real) com quase 2x mais tempo usando recursos do computador (user+sys).

  4. Paulo Dias 14/07/2015 at 12:45 #

    Parabéns pelo artigo.

  5. Rodrigo Peleias 14/07/2015 at 14:28 #

    Parabéns pelo artigo!! Muito bom ver novas soluções com o Java 8!!

  6. JOnh 15/07/2015 at 10:16 #

    Parabéns pelo artigo!
    Gostaria de saber qual manipulação de dados e mais rápida. E utilizando os recursos do Java 8 ou Java 1.4

  7. Chico Sokol 15/07/2015 at 11:02 #

    Muito legal o post, parabéns, Alexandre!

    Procurei como fazer a negação do isEmpty() de alguma maneira mais legível na hora de filtrar mas não achei nada na api do Java 8, seria legal se isso funcionasse:

    Stream horasSemanaisNaoVazias = horasSemanais
    .filter(String::empty.negate());

    Mas não rola, acho que precisa fazer cast e aí perde o sentido. Pena eles não terem feito um Predicates.not(predicado) como existe no guava… O que você acha? Mais ficaria mais legível?

    Além disso, reparei que está fazendo o sort de todas as linhas do arquivo, será que não seria mais eficiente ordenar só as chaves do Map final? Como você faria o código nesse caso? Não vejo alternativas sem deixar o código menos legível…

  8. Eduardo Braga 15/07/2015 at 11:38 #

    Cara, que legal!
    Começando de uma conversa informal e trazendo à tona a rapidez do Java 8. Estou começando com Java e gostei bastante da maneira como você estruturou o post, pude entender bem o que você fez. Parabéns!

  9. Alexandre Aquiles 15/07/2015 at 12:06 #

    Opa, Chico!

    Realmente, a negação do isEmpty poderia ser mais simples. Dá pra fazer algo como filter(((Predicate) String::isEmpty).negate()). Mas fica muito feio! Tirando o cast para Predicate o código não compila, dando o erro “Method reference not expected here”. Infelizmente, não dá pra usar um Method Reference diretamente, como se fosse um lambda. Uma outra maneira seria guardar o String::isEmpty em uma variável do tipo Predicate, logo no início do código. A solução de um Predicates.not parece uma boa!

    Sobre o sorted(), bem observado! Uma versão que ordena as informações depois de agrupada seria mais eficiente. Só que não poderíamos usar o collect() com Collectors.groupingBy porque é uma operação terminal, que não retorna mais uma stream, mas um Map. A questão aqui é escolher uma boa implementação de Map. Se trocassemos de LinkedHashMap para TreeMap não precisaríamos mais fazer o sorted() na stream, porque o TreeMap já orderna por chave (exatamente o que a gente quer)! Vou deixar o código do post com o sorted() para explorar esse método das streams, mas coloquei sua sugestão em um gist!

  10. Alexandre Aquiles 15/07/2015 at 14:16 #

    JOnh, excelente questão!

    Fiz uma pequena comparação utilizando recursos até o Java 1.3 com o pacote java.io, até o Java 1.7 com o pacote java.nio e também com os recursos do Java 8.

    Compilei a partir da JDK 8 com a opção -source do javac para fixar as versões e executei na JRE 8.

    Em termos de tempo de resposta, as versões antigona, a do Java 7 e do Java 8 com streams sequenciais tiveram performance semelhante, um pouquinho melhor em cada avanço na versão. Já uso de streams paralelas trouxe um ganho significativo na performance (cerca de 30% a menos), em relação às demais versões.

    Em termos de implementação, os novos recursos foram levando a menos linhas de código e a mais legibilidade (na minha opinião, pelo menos).

    Vale ressaltar que essas comparações não são nada definitivas. Uma comparação mais robusta deveria utilizar estatística e ferramentas como jmh ou caliper.

  11. Guilherme Mendes 15/07/2015 at 21:53 #

    Sei que não é exatamente o tema do artigo mas alguém sabe uma maneira de manipular RTF, mais precisamente concatená-lo utilizando Java? O que temos de mais atual para isso?

  12. Alexandre Aquiles 15/07/2015 at 23:21 #

    Nunca mexi com RTF em Java. É um formato antigo. Você precisa realmente usá-lo? Para .doc e .docx, o Apache POI é uma boa pedida.

    De qualquer forma, uma busca no google me levou ao jRTF, que tem uma API muito boa. Só não sei se funciona bem…

  13. vanderson 16/07/2015 at 13:47 #

    Obrigado!!

  14. Josimar Silva 17/07/2015 at 08:11 #

    Parabéns Alexandre.
    Excelente post. Primeira vez que vejo o uso massivo das novas features do Java 8 em um problema “real”.

  15. Rogerio J. Gentil 31/07/2015 at 09:54 #

    O nome da publicação “Manipulando arquivos com recursos do Java 8” é modesto para um texto tão rico em explicação e uso de APIs do Java 8! Muito bom…!!!

  16. Orlando 28/01/2016 at 16:09 #

    Parabéns!
    Ótimo post!

  17. abraao 21/05/2017 at 13:35 #

    Parabéns pelo artigo. seria possivel usar a mesma stream para contar um outro atributo do arquivo.

    Usar esse trecho duas vezes.

    Collectors.groupingBy(Function.identity(),
                    LinkedHashMap::new, Collectors.counting()));
    
    Collectors.groupingBy(Function.identity(),
                    LinkedHashMap::new, Collectors.counting()));
    
  18. Alexandre Aquiles 22/05/2017 at 16:31 #

    Abraão,

    Um Stream::collect é uma operação terminal, isto é, que consome a Stream, sem que essa possa ser usada novamente.

    Por isso, não é possível realizar dois collect seguidos.

    Uma alternativa, se você quiser contar mais de um atributo, é implementar o seu próprio Collector.

  19. Ana 05/06/2017 at 14:28 #

    Se fosse para eu pegar o indíce de cada linha, como um List usamos o get(1), como faria usando Stream?

  20. Alexandre Aquiles 11/06/2017 at 13:05 #

    Olá, Ana.

    Não sei se entendi muito bem, mas um exemplo comum é usar um IntStream, chamando .get(i) em uma lista e criando objetos que serão coletados em outra:

    List<A> listaA = //...
    List<B> listaB = IntStream.range(0, listaA.size())
      .mapToObj(i -> new B(i, listaA.get(i))
      .collect(toList());
    

    Para esse caso, talvez o velho for com a variável i fique mais legível.

Deixe uma resposta