domingo, 28 de outubro de 2007

Diff recursivo com filtro

Às vezes nos deparamos com problemas aparentemente simples mas que nos dão trabalho pra resolver. Não estou reclamando... esses são, normalmente, os problemas mais divertidos e instrutivos.

Ontem um colega mandou o seguinte problema numa lista de discussão:

Srs,

Estou fazendo um diff recursivo, comparando dois diretórios de arquivos de códigos, eu gostaria que neste diff não fosse considerado as linhas com comentários.

Ex:

//linha de comentário

/*
* comentário
*/

Alguém conhece uma maneira de fazer isso?

Ele quer comparar código fonte Java desconsiderando as diferenças dentro dos comentários.

Eu sabia que o comando GNU diff, presente em todas as distribuições Linux, tem algumas opções para desprezar espaços em branco. Dei uma olhada no manpage pra ver se por acaso haveria alguma opção pra desprezar comentários... não custava nada, né?

-E --ignore-tab-expansion
Ignore changes due to tab expansion.

-b --ignore-space-change
Ignore changes in the amount of white space.

-w --ignore-all-space
Ignore all white space.

-B --ignore-blank-lines
Ignore changes whose lines are all blank.

--strip-trailing-cr
Strip trailing carriage return on input.

É um bocado de opção pra ignorar espaço em branco, não é? Tantas opções e, ainda assim, nenhuma me servia. Aliás, isso me fez pensar se essa proliferação de opções específicas não poderia ser evitada com a implementação de uma opção de filtro mais genérica. Quero dizer, o diff poderia suportar uma opção pra qual pudéssemos passar uma expressão regular especificando strings que deveriam ser ignoradas. Ou, talvez, outra opção passando um comando que o diff pudesse chamar para filtrar os arquivos que fosse comparar.

Como não achei nenhuma solução direta no diff, passei a pensar em como remover os comentários do código Java. Se fosse apenas o comentário estilo C++ (//...) eu usaria um sed, mas como há também o comentário tipo C (/*...*/) não pensei duas vezes antes de usar Perl. O script mais conciso e elegante que obtive eu chamei de stripcomments.pl:

#!/usr/bin/perl
$/ = undef; # slurp mode
$_ = <>; # slurp file
s:/*.*?*/::sg; # strip C comments
s://.*::g; # strip C++ comments
print;

Ele lê todo o arquivo em memória e remove os comentários com expressões regulares simples. Com o tamanho da memória dos desktops atuais eu não creio que ler o arquivo todo seja problemático. Qualquer solução mais comedida no uso de memória seria muito mais complexa.

Pois bem, a idéia é passar pro diff o resultado da aplicação deste filtro a cada um dos seus dois argumentos. A melhor maneira que eu conheço pra fazer isso é usando a sintaxe de substituição de processos da bash. O comando seria o seguinte:

diff <(stripcomments.pl file1) <(stripcomments.pl file2)

Hmmm... há duas coisas no resultado deste diff de que eu não gosto. A primeira é que eu prefiro o resultado no formato unified, que eu obtenho com a opção -u. A segunda é que como o diff recebe o resultado do filtro ele não sabe o nome dos arquivos originais.

Descobri ontem que eu posso instruí-lo sobre o nome dos argumentos através da opção -L. O comando final ficou assim:

diff -u -L file1 <(stripcomments.pl file1) -L file2 <(stripcomments.pl file2)

OK, eu já tenho o filtro e já sei como fazer o diff usá-lo. Mas há um problema: meu colega quer um diff recursivo. Logo, eu não posso passar os nomes dos arquivos diretamente.

Estudei um pouco mais e descobri outra opção interessante do diff: a -q, ou --brief. (Só de curiosidade: o comando "man diff | grep '^ *-' | wc -l" mostra que o diff suporta 44 opções diferentes!) Com esta opção o diff não mostra as diferenças em si, apenas o nome dos arquivos diferentes.

Minha idéia final foi a seguinte. Primeiro eu executo um "diff -qr" nos dois diretórios pra descobrir quais arquivos são diferentes. Do resultado disso eu coleciono os pares de arquivos diferentes e comparo cada par com o comando diff acima, utilizando o filtro. O script final eu chamei de diff-java-stripped.pl:


01 #!/usr/bin/perl

02 BEGIN {
03 $ENV{PATH} = '/bin:/usr/bin';
04 $ENV{LANG} = 'C';
05 }

06 use strict;
07 use warnings;

08 my $usage = "$0 DIR1 DIR2\n";

09 my $dir1 = shift or die $usage;
10 my $dir2 = shift or die $usage;

11 my @pairs; # hold pairs of different files

12 open DIFF, "diff -rq $dir1 $dir2 |"
13 or die "Can't exec diff -rq: $!";
14 while (readline(*DIFF)) {
15 if (/^Files (.*) and (.*) differ$/) {
16 push @pairs, [$1, $2];
17 } else {
18 print;
19 }
20 }
21 close DIFF or die "closing DIFF";

22 open BASH, "| bash --norc"
23 or die "Can't exec bash --norc: $!";
24 for my $pair (@pairs) {
25 my ($l, $r) = @$pair;
26 print BASH "diff -u -L '$l' <(stripcomments.pl '$l') -L '$r' <(stripcomments.pl '$r')\n";
27 }
28 close BASH or die "closing BASH";

O primeiro diff é chamado na linha 12 e é processado no loop da linha 14. A saída do comando mostra três tipos de linhas, dependendo do tipo de diferença encontrada:

Only in dir1: file1
Only in dir2: file2
Files dir1/file3 and dir2/file3 differ

Quando um arquivo só existe debaixo de um dos dois diretórios o comando mostra a linha "Only in dir: file". Nesse caso, só estou interessado em mostrar este fato ao usuário.

Quando há dois arquivos diferentes com o mesmo nome dos dois lados, então eu guardo os caminhos de ambos em @pairs para compará-los depois.

O loop da linha 24 obtém cada par encontrado e constrói uma linha de comando para compará-los depois de devidamente filtrados. Cada linha de comando destas é submetida para execução por uma bash invocada a propósito na linha 22.

A definição da variável de ambiente LANG na linha 4 é para garantir que as mensagens do comando diff sejam em inglês independentemente do locale em que o comando esteja sendo executado.

Tá aí. Acho que ficou legal. E vocês?

(Obs: Foi difícil embutir os scripts acima neste post porque o editor do Blogger teimava eu não aceitar alguns caracteres e eu eliminar alguns newlines. Tive até que usar uma sintaxe alternativa na linha 14 porque não consegui fazer a sintaxe normal ser aceita.)

terça-feira, 16 de outubro de 2007

Deduplication

Falando sobre storage, mais especificamente sobre backup, uma das tecnologias mais badaladas ultimamente é a data deduplication, ou de-dup. A idéia básica é detectar redundâncias durante o backup pra reduzir drasticamente a utilização de storage pra cópia. O stream de backup é dividido em blocos e cada bloco é identificado por uma assinatura digital. As assinaturas de todos os blocos copiados são mantidas num banco de dados. Assim, quando um novo bloco tem a mesma assinatura que outro já copiado os dados em si não precisam ser salvos novamente, apenas o banco de dados é atualizado com o índice do novo bloco apontando para o mesmo local em que o anterior foi salvo.

Confesso que até há pouco eu não havia conseguido perceber como é que os fornecedores deste tipo de solução são capazes de anunciar reduções de armazenamento da ordem de 20 ou de até 50 vezes. Não me parecia possível encontrar tanta redundância assim nos dados armazenados em disco.

Mas, acabei de ouvir um podcast no qual o Curtis Preston explica direitinho como a deduplication funciona e como é possível reduzir dezenas de vezes o consumo de storage de backup. O segredo é que o backup é um processo cíclico. Uma política de backup comum é realizar um backup full de todos os discos a cada quatro semanas. Isso significa que a cada quatro semanas todos os dados de um servidor são copiados pra fita novamente. Se usarmos um sistema de de-dup, normalmente a maioria dos dados já estaria armazenada no sistema de backup e não precisaria ser armazenada novamente, como acontece atualmente com nosso sistema de fitas.

Aliás, os sistemas de de-dup normalmente utilizam disco e não fita como storage de armazenamento exatamente porque os dados redundantes precisam ser acessados de modo aleatório e não sequencial durante um restore. A utilização de disco ao invés de fita é mais cara mas tem a vantagem de permitir restores mais rápidos e simultâneos, independentemente do número de unidades de fita disponíveis no momento.

Estou achando que vale a pena.