Utilizando Perl para acessar múltiplos bancos de dados

No cenário de hoje a nossa empresa possui diversos departamentos, cada um com seus próprios sistemas, provendo as mais variadas funcionalidades, cada um com suas especificidades, sendo escritos em linguagens diferentes e acessando bancos de dados diferentes, etc.

Nós somos responsáveis por produzir um relatório que possui dados de dois departamentos diferentes cujos sistemas não se conversam.

A solução canônica hoje em dia é ter uma arquitetura baseada em serviços, eventualmente com APIs REST, onde só é preciso enviar um request e está feito o trabalho.

Mas a nossa empresa ainda não chegou lá e vamos precisar lidar com os sistemas legados.

Para resolver esse problema, vamos usar o DBI.

O que é DBI?

O DBI é uma interface pra acesso a bancos de dados que é independente do tipo de banco de dados, ou seja, não importa se é MySQL, MariaDB ou Postgres, ele vai oferecer o mesmo conjunto de métodos.

Dessa forma a gente só precisa se familiarizar com a API do DBI que vamos conseguir interagir com qualquer banco de dados.

Para isso o DBI utiliza um conjunto de "drivers", um para cada banco de dados, os módulos DBD::*, que abstraem as partes que são específicas de cada engine.

Se você for na busca do metacpan, digitar DBD:: e esperar ele sugerir uma lista de opções, vai ver uma quantidade razoável de engines suportadas pelo DBI.

O modus operandi padrão é você se conectar no banco, utilizar os métodos de acesso aos dados e por fim desconectar.

#!/usr/bin/env perl

use DBI;

$dbh = DBI->connect($data_source, $username, $auth, \%attr);

$sth = $dbh->prepare($statement);
$rv = $sth->execute(@bind_values);
$rows = $sth->fetchall_arrayref( $slice, $max_rows );

$rc = $dbh->disconnect;

A documentação do DBI é bem tranquila, especialmente se você está familiarizado com esse passo a passo de lidar com banco de dados.

O DBI também suporta a criação de transactions, ou como conhecemos, transações. Assim você pode utilizar um conjunto complexo de instruções SQL de forma atômica, claro se o banco de dados que você estiver usando permitir esse tipo de coisa.

O DBI é bastante configurável, então você pode personalizar bastante as conexões, desde o encoding padrão, o comportamento do tratamento de erro, ativar ou dessativar funções e muito mais.

Dê sempre uma olhada na documentação, tanto do DBI quanto do módulo DBD::* que você estiver utilizando para poder encontrar a sintaxe correta da opção que você estiver precisando.

O Problema

A nossa empresa fictícia fornece cursos diversos para seus clientes. O Departamento de Vendas nos pediu um relatório com todos os alunos aprovados nos nossos cursos, contendo cada curso onde esses alunos foram aprovados, de modo a poder enviar emails sugerindo outros cursos.

O problema é que a informação parece estar espalhada pela empresa, em departamentos diferentes.

O Departamento de Clientes é responsável pelo cadastro e os dados do clientes. Eles utilizam banco de dados PostgreSQL (postgres para os íntimos) e guardam numa tabela clients, dados como nome, username e email de cada cliente.

Também temos o Departamento de Cursos, que é responsável pelos cursos que a empresa oferece aos clientes. Eles utilizam banco de dados MariaDB (mariadb) e tem numa tabela course_history o nome do curso, o username do aluno e a nota do aluno no curso.

Dado que não existem serviços que possamos acessar, nosso trabalho é criar um programa que se conecte a ambos os bancos de dados[1], obtenha os dados necessários e gere um relatório em formato CSV.

Montando o cenário

Embora esteja fora do escopo deste post explicar como configurar containers, para demonstrar o nosso cenário, utilizamos docker e docker-compose para simular os diferentes bancos de dados.

Para executar o exemplo, partimos do pressuposto que você tenha instalado e configurado na sua máquina o docker, docker-compose e um alias chamado compose apontando para o comando docker-compose.

Caso você não tenha isso tudo configurado, pode pular essa seção sem prejuízos.

Resumidamente, o compose vai criar containers executando as respectivas engines que vão funcionar como mini servidores e vai mapear as portas 4001 e 4002 da máquina local para as respectivas portas dos bancos de dados em seus respectivos containers.

Assim se você quiser se conectar a cada banco utilizando uma ferramenta como o DBeaver, por exemplo, basta apontar para essas duas portas na máquina local.

Os clientes que o nosso compose disponibiliza, por outro lado, acessam as respectivas bases de dados através da própria rede interna entre os containers que ele mesmo cria.

Pra começar, baixe o código completo direto do repositório de exemplos e navegue até o diretório 2020-09/databases.

Iniciando o Postgres
user@host$ compose up -d pgdb
Creating network "databases_blog-post" with driver "bridge"
Creating databases_pgdb_1 ... done

O comando acima vai iniciar um container com o postgres em segundo plano e executar os scripts de inicialização criando a base de dados de exemplo, usuário, dados, etc.

Note que se for o primeiro comando executado, ele também vai criar uma rede interna entre os containers, chamada "databases_blog-post".

Iniciando o MariaDB
user@host$ compose up -d mydb
Creating databases_mydb_1 ... done

O comando acima, assim como com o postgres, inicializa um container mariadb e executa os scripts de inicialização criando tabelas, usuários, dados, etc.

Recomeçando do zero

Em ambos os casos, a inicialização dos dados só acontece na primeira vez que os containers são iniciados. Se você quiser apagar tudo e começar novamente, basta executar co comando abaixo:

user@host$ compose down && docker volume rm databases_pgdata databases_mydata
Stopping databases_mydb_1 ... done
Stopping databases_pgdb_1 ... done
Removing databases_mydb_1 ... done
Removing databases_pgdb_1 ... done
Removing network databases_blog-post
databases_pgdata
databases_mydata
Acessando Postgres via CLI
user@host$ compose run pg-cli
Starting databases_pgdb_1 ... done
Password:
psql (12.4 (Debian 12.4-1.pgdg100+1))
Type "help" for help.

pgdb_sample=>

Após entrar com a senha do usuário definida no arquivo .env, teminamos no prompt do psql.

Acessando MariaDB via CLI
user@host$ compose run my-cli
Starting databases_mydb_1 ... done
Enter password:
Reading table information for completion of table and column names
You can turn off this feature to get a quicker startup with -A

Welcome to the MariaDB monitor. Commands end with ; or \g.
Your MariaDB connection id is 4
Server version: 10.5.5-MariaDB-1:10.5.5+maria~focal mariadb.org binary distribution

Copyright (c) 2000, 2018, Oracle, MariaDB Corporation Ab and others.

Type 'help;' or '\h' for help. Type '\c' to clear the current input statement.

MariaDB [mydb_sample]>

Assim como no caso do postgres, após entrar com a senha do usuário, caímos no prompt do mysql, o cliente de linha de comando do mariadb.

Note que os comandos acima são definidos pelo arquivo docker-compose.yml presente no projeto de exemplo.

Executando o exemplo

Como vamos acessar dois bancos diferentes e os drivers do DBI utilizam as bibliotecas nativas de cada um, é preciso ter instalados os pacotes correspondentes. No ubuntu isso pode ser feito com:

user@host$ sudo apt update && sudo apt install -y build-essential libpq-dev libmariadb-dev

Em seguida utilizamos o carton para instalar as dependências:

user@host$ carton install

E executamos o exemplo com:

user@host$ carton exec -- perl ./report.pl

O relatório

O código completo está disponível no projeto de exemplo. Abaixo comentamos cada parte separadamente.

#!/usr/bin/env perl

use v5.32;
use strict;
use warnings;

use DBI;
use Text::CSV_XS qw( csv );

O trecho acima é apenas o cabeçalho padrão de programas Perl onde indicamos entre outras coisas, que vamos utilizar o DBI para acesso ao banco e o Text::CSV_XS para gerar o arquivo CSV.

## conexão ao postgres
my $pg_host = 'localhost';
my $pg_port = '4001';
my $pg_db = 'pgdb_sample';
my $pg_dsn = "DBI:Pg:dbname=$pg_db;host=$pg_host;port=$pg_port";
my $pg_user = 'blabos';
my $pg_pass = 'blabos';
my $pg_dbh = DBI->connect( $pg_dsn, $pg_user, $pg_pass );

Aqui, de forma bem vertical pra ser mais fácil de visulalizar, fazemos a conexão com o banco e obtemos um database handler que guardamos da variável $pg_dbh. É através desse handler que vamos chamar os métodos de acesso ao banco do DBI.

## conexão ao mariadb
my $my_host = '127.0.0.1';
my $my_port = '4002';
my $my_db = 'mydb_sample';
my $my_dsn = "DBI:MariaDB:database=$my_db;host=$my_host;port=$my_port";
my $my_user = 'blabos';
my $my_pass = 'blabos';
my $my_dbh = DBI->connect( $my_dsn, $my_user, $my_pass );

Analogamente, conectamos no banco e obtemos o handler em $my_dbh.

Se você estiver executando o exemplo via docker-compose, por conta de uma especificidade do mariadb, é preciso utilizar o ip ao invés no nome localhost.

As diferenças ficam por conta das duas DSN que determinam qual é o driver (DBI::Pg ou DBI::MariaDB) e as opções específicas de cada um.

## pegando a lista de estudantes no postgres
my $clients = $pg_dbh->selectall_arrayref(
'select * from "pgdb_schema"."clients" order by name',
{ Slice => {} },
);

## pegando o histórico no mariadb
my $history = $my_dbh->selectall_arrayref(
'select * from course_history where grade > 5.0',
{ Slice => {} },
);

No trecho acima utilizamos o mesmo método selectall_arrayref() pra executar as queries nos bancos, mas cada uma a partir do handler correspondente ($pg_dbh ou $my_dbh).

Em ambos os casos, o resultado vai ser um ArrayRef onde cada elemento é um HashRef correspondente a uma linha do resultado.

Na primeira query pegamos todos os alunos da base do Departamento de Clientes e na segunda, todos os cursos cujos alunos tiveram nota maior que 5.0 na base do Departamento de Cursos.

Por fim, processamos o resultado e geramos o CSV com o código abaixo:

## gerando o relatório!
my @rows = ();
foreach my $student ( $clients->@* ) {
my @courses =
map { $_->{course} } ## 3. pega o nome do curso
grep { $_->{student} eq $student->{user} } ## 2. filtra para este estudante
$history->@*; ## 1. pega todos os cursos

## estrutura de dados para o csv
push @rows,
{
name => $student->{name},
email => $student->{email},
courses => join( ', ', @courses ),
};

## apenas mostrando na tela
say '=' x 80;
say "Parabéns $student->{name} por sua aprovação em:";
say ' * ' . $_ foreach @courses;
}

## uma última linha na tela
say '=' x 80;

## gerando o arquivo csv
csv( in => \@rows, out => 'report.csv' );

Iteramos pela lista de alunos e filtramos os cursos de cada um, montando os dados do CSV com as colunas name, email e course.

Depois de iterar pelos dados e popular o array @rows, geramos o arquivo report.csv no diretório corrente, utilizando a função csv() que mostramos no post anterior.

user@host cat report.csv
email,courses,name
beltro@examle.com,"COBOL for Dummies","Beltrano de Souza"
blabos@gmail.com,"Introduction to Perl, Mastering Regexes","Blabos de Blebe"
fulano.tal@email.com,"Basics of Machine Learning","Fulano de Tal"

Note como a função csv() do Text::CSV_XS é espertinha o suficiente para lidar corretamente com os casos onde há vírgulas (mais de um curso).

Conclusão

No post de hoje mostramos como usar o módulo DBI para acessar mais de um banco de dados diferente, de forma uniforme, utilizando o mesmo conjunto de métodos.

Com a API padronizada do DBI você não precisa se preocupar em aprender como lidar com bibliotecas diferentes toda vez que precisar acessar um banco de dados diferente.

Notas

[1]. Sim, sabemos que hoje em dia não é uma boa prática deixar todo mundo sair conectando diretamente no banco de dados, mas você se surpreenderia com a quantidade de casos reais onde esse exemplo fictício é uma necessidade...

Manipulando arquivos CSV usando Perl

No CPAN podemos encontrar vários módulos prontos para lidar com arquivos CSV, mas a forma mais simples que eu conheço é utilizando o módulo Text::CSV.

Ele é um wrapper através do qual você pode selecionar backends compatívies como o Text::CSV_PP, escrito inteiramente em Perl ou a sua encarnação turbinada o Text::CSV_XS, escrita em linguagem C.

Os exemplos completos desse post podem ser encontrados no repositório de exemplos do blog.

Criando um arquivo CSV a partir de um conjunto de dados

O código completo para criar um arquivo CSV a partir de um conjunto de dados é mostrado abaixo:

#!/usr/bin/env perl

use v5.30;
use strict;
use warnings;

use Text::CSV qw( csv );

my $data = [
{ 'name' => 'Fulana de Tal', 'pronoun' => 'Sra.', 'age' => 35, },
{ 'name' => 'Beltrano da Silva', 'pronoun' => 'Sr.', 'age' => 24, },
{ 'name' => 'Ciclano Augusto', 'pronoun' => undef, 'age' => 42, },
];

csv( 'in' => $data, 'out' => 'data.csv' );

A nossa estrutura de dados consiste em um conjunto de linhas, cada uma com três colunas contendo o nome o pronome de tratamento e a idade de pessoas.

Para criar um arquivo com esses dados, basta chamar a função csv() passando como parâmetro de entrada in um array de hashes* e como parâmetro de saída out o nome do arquivo desejado.

Simples assim.

Importando um arquivo para uma estrutura de dados

Analogamente, para ler o conteúdo de um arquivo CSV e colocá-lo numa estrutura de dados, podemos utilizar o seguinte código:

#!/usr/bin/env perl

use v5.30;
use strict;
use warnings;

use Text::CSV qw( csv );

my $data = csv( 'in' => 'data.csv', 'headers' => 'auto' );

foreach my $row ( $data->@* ) {
my $greetings =
!!$row->{'pronoun'}
? "Olá $row->{'pronoun'} $row->{'name'}!!!"
: "Ei $row->{'name'}!!!";

say $greetings;
}

Utilizaremos a mesma função csv(), só que agora passando como parâmetro de entrada in o nome do arquivo, além do parâmetro headers que indica para a função utilizar a primeira linha como cabeçalho.

A variável $data será um array de hashes contendo os dados do arquivo.

Pronto. Só isso.

No exemplo, acrescentamos um laço de repetição foreach apenas para mostrar os dados lidos.

Comentários adicionais

Os módulos Text::CSV e Text::CSV_XS possuem muito mais opções do que mostradas aqui. Este código é apenas a forma mais curta que eu encontrei pra ler e gravar arquivos CSV. Uma boa ideia é explorar a boa documentação de ambos.

Note, que o módulo sabe se virar sozinho quando alguma das "células" está "vazia", como é o caso do pronome do "Ciclano Augusto" no exemplo.

* Tecnicamente eu deveria dizer ArrayRef de HashRefs, mas é mais fácil de explicar usando os termos mais simples que eu citei.

Instalando módulos Perl com cpanm e Carton

O compartilhamento de código em Perl é feito por meio de módulos que são distribuídos na forma de pacotes que contém também as instruções para instalação e configuração desses módulos.

A principal fonte de módulos e pacotes em Perl é um repositório chamado CPAN (Comprehensive Perl Archive Network), que pode ser acessado pelo site metacpan.org.

O CPAN está online desde 1995 e possui atualmente mais de 196 mil módulos de mais de 14 mil autores diferentes. Se você precisa fazer alguma coisa em Perl, provavelmente já existe um módulo pronto no CPAN. Se não existe, você pode criar um e fazer o upload para lá.

Por muito tempo, a instalação de módulos foi feita por um aplicativo de linha de comando chamado... cpan, que depois de corretamente configurado era capaz de baixar e instalar os módulos de forma bem simples.

O problema era que essa configuração nem sempre foi muito simples. Você vai encontrar pela internet vários tutoriais ensinando como configurar o aplicativo cpan.

Por essas e outras já faz algum tempo que utilizamos uma opção mais moderna, um outro aplicativo chamado cpanm, criado pelo Tatsuhiko Miyagawa, muito mais simples, que não necessita configuração e ainda um monte de outras vantagens que vamos deixar pra outro dia.

A instalação tanto do cpanm quanto do carton (que vamos discutir mais abaixo), foi mostada no post anterior.

Instalando um módulo com o cpanm

A instalação de um módulo que esteja no CPAN é muito simples. Desde que você tenha acesso à internet, basta digitar no seu terminal:

user@host$ cpanm Paginator::Lite
--> Working on Paginator::Lite
... muitas linhas depois...
Successfully installed Paginator-Lite-2.001003
61 distributions installed
user@host$

O cpanm vai baixar a versão mais recente Paginator::Lite no formato .tar.gz pra em seguida desempacotá-lo e executar os comandos para o build, teste e posterior instalação do módulo, instalando também todas as dependências necessárias durante o processo.

Para converir que o módulo está instalado, basta executar:

user@host$ perl -MPaginator::Lite -E 'say $Paginator::Lite::VERSION'
2.001003
user@host$

Aqui vale ressaltar os passos que incluem build, teste e instalação porque os módulos no CPAN são distribuídos como código-fonte.

O código-fonte é então configurado e se tiver alguma parte em escrita em linguagem C, vai ser compilado especificamente para a sua máquina. Além disso, testes unitários são executados antes da instalação para garantir que o módulo funcionará corretamente.

Por isso, durante a instalação, você vai precisar ter na sua máquina as ferramentas pardão de build, como o make e eventualmente um compilador C, conforme comentamos no post anterior.

O comando cpanm possui diversas opções interessantes e a que eu mais utilizo é é a -L, que indica um diretório arbitrário onde instalar os módulos ao invés de instalar nos diretórios padrões.

user@host$ cpanm -L my-local-libs Paginator::Lite

O comando acima vai instalar o módulo Paginator::Lite no ditretório my-local-libs e você poderá referenciá-lo no seu código usando:

#!/usr/bin/env perl

use local::lib 'my-local-libs';
use Paginator::Lite;
...

Instalando uma versão específica de um módulo

Vimos que utilizando o cpanm podemos instalar a versão mais recente de qualquer módulo no cpam. Mas e se precisarmos de uma versão específica de determinado módulo? E se precisarmos de uma funcionalidade específica do Paginator::Lite que não existe na versão atual e que só existia na versão 1.03?

Uma solução seria baixar o tar.gz da versão específica e proceder com a instalação manualmente. Mas e se você tiver mais algum módulo com um problema semelhante, isso pode começar a ficar complicado.

Ao invés disso, podemos utilizar o carton, um aplicativo de linha de comando, também criado pelo Tatsuhiko Miyagawa, cujo objetivo é justamente travar determinadas versões de módulos que o seu projeto utiliza.

Para isso ele utiliza um arquivo de configuração chamado cpanfile e instala os módulos num diretório local do seu projeto, chamado... local.

Vamos criar um diretório para o nosso projeto e dentro dele um arquivo cpanfile com o seguinte conteúdo:

requires 'Paginator::Lite' => '== 1.03';

Em seguida basta executar:

user@host$ carton install
Installing modules using /home/blabos/my-project/cpanfile
Successfully installed Paginator-Lite-1.03
1 distribution installed
Complete! Modules were installed into /home/blabos/my-project/local
user@host$

O carton vai instalar os modulos dentro do diretório local, seguindo uma herarquia de diretórios particular do Perl.

user@host$ tree -L 2 local/
local
├── bin
├── cache
│   ├── authors
│   └── modules
└── lib
└── perl5

6 directories, 0 files
user@host$

Note que esse diretório é completamente independente dos outros diretórios onde os módulos eram instalados anteriormente e você vai precisar indicar ao Perl onde procurar pelos módulos.

Isso pode ser feito indicando diretamente no código, como mostrado anteriormente, ou de forma mais portável, utilizando o próprio comando carton.

Vamos criar um arquivo script.pl com o conteúdo abaixo:

#!/usr/bin/env perl

use v5.32;
use Paginator::Lite;

say $Paginator::Lite::VERSION;

E vamos executá-lo com o seguinte comando:

user@host$ perl script.pl
2.001003
user@host$

Note que essa é a versão instalada previamente via cpanm. O script via buscar os módulos nos diretórios que ele conhece e ele não sabe sobre a existência do diretório local.

Agora execute o mesmo script só que a partir do carton:

user@host$ carton exec -- perl script.pl
1.03
user@host$

O carton vai configurar as variáveis de ambiente necessárias para que o script reconheça o módulo instalado no diretório local do projeto.

Conclusão

No post de hoje apresentamos o uso do comando cpanm para instalar manualmente um módulo Perl distribuído via CPAN.

Também mostramos como utilizar o comando carton para gerenciar as versões de dependências do seu projeto.

Junto com o post anterior, agora você é capaz de instalar com o plenv uma versão específica da linguagem Perl, completamente isolada da versão do sistema, além de também ser capaz instalar módulos com o cpanm e gerenciar dependências do deu projeto com o carton.