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.
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.
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:
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.
Criaremos uma constante HORAS, que terá um java.util.regex.Pattern com a regex: java 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:
```java
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:
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().
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: ```java
Stream horasSemanaisOrdenadas = horasSemanaisNaoVazias.sorted(); Map<String, Long> horasSemanaisAgrupadas = horasSemanaisOrdenadas .collect(Collectors.groupingBy(Function.identity(), Collectors.counting()));
Há 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.
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.