Ontem eu estava entrevistando para um cargo de desenvolvedor Ruby em uma empresa aqui em Denver quando meu entrevistador colocou um bom desafio de metaprogramação: ele me pediu para escrever um auditor de método automatizado.
Ele descreveu um método de classe que pegaria o nome de um método de instância e geraria uma trilha de papel automática e logada a qualquer momento que o método alvo fosse chamado. Algo como isto:
Que produziria uma saída parecida com isto:
Produzi uma solução que funcionava mas envolvia alguns hacks potencialmente perigosos para Object#send
. Antes de apresentar minha solução, eu disse “isso provavelmente é uma má idéia, mas…” e eu estava certo. Boas ideias raramente começam assim.
A solução que ele propôs envolvia o método de invólucro frequentemente ignorado Module#prepend
e eu achei que era fixe o suficiente para justificar um write-up.
A estratégia era usar o comportamento de Module#prepend
para criar um método de invólucro gerado dinamicamente para Messenger#share
.
Naquele wrapper, teremos uma mensagem “Performing” disparada antes do corpo do método ser executado, a própria execução do método, e depois uma mensagem final “Exiting” assim que a execução do método tiver terminado com sucesso.
But to understand how to use Module#prepend
to do this, we need first a good understanding of how method inheritance works in Ruby.
Ancestor Chains
Cada objecto Ruby fica no fim do que se chama uma Ancestor Chain. É uma lista dos objetos dos antepassados dos quais o objeto herda. Ruby usa esta Corrente Ancestral para determinar qual versão de um método (se houver alguma) é executada quando o objeto recebe uma mensagem.
Pode realmente ver a árvore dos antepassados para qualquer objeto chamando Module#ancestors
. Por exemplo, aqui está a cadeia de ancestrais para a nossa Messenger
class:
Quando nós #include
ou #prepend
um módulo em uma classe, Ruby faz alterações na cadeia de ancestrais dessa classe, ajustando quais métodos são encontrados e em que ordem. A grande diferença entre #include
e #prepend
é onde essa mudança é feita. Vamos criar um módulo chamado Auditable
que irá (eventualmente) conter o código que faz a nossa magia de auditoria:
Se usarmos Module#include
para importar os métodos (actualmente inexistentes) de Auditable
, o Ruby irá apertar esse módulo como um antepassado da nossa classe Messenger
. Aqui, veja por si mesmo:
Quando chamamos um método de Messenger
, o Ruby irá olhar para o que está definido na classe Messenger
e – se não conseguir encontrar um método que corresponda à chamada – subirá a cadeia de ancestrais para Auditable
para procurar novamente. Se ele não encontrar o que está procurando em Auditable
ele passa para Object
e assim por diante.
Isso é #include
. Se em vez disso usarmos Module#prepend
para importar o conteúdo do módulo, obtemos um efeito totalmente diferente:
#prepend
faz de Messenger
um antepassado de Auditable
. Espere. O quê?
Isto pode parecer que Messenger
é agora uma superclasse de Auditable
, mas não é exactamente isso que está a acontecer aqui. Instâncias da classe Messenger
ainda são instâncias da classe Messenger
mas o Ruby irá agora procurar métodos para usar para Messenger
no módulo Auditable
antes de os procurar na própria classe Messenger
.
E isso, amigos, é o que vamos aproveitar para construir este auditor: se criarmos um método chamado Auditable#share
, o Ruby irá descobrir isso antes de encontrar Messenger#share
. Podemos então usar um super
chamar em Auditable#share
para acessar (e executar!) o método original definido em Messenger
.
O Módulo em si mesmo
Não vamos realmente criar um método chamado Auditable#share
. Por que não? Porque queremos que este seja um utilitário flexível. Se codificarmos o método Auditable#share
, poderemos usá-lo apenas em métodos que implementem o método #share
. Ou pior, teríamos que reimplementar este mesmo padrão de auditor para cada método que queremos auditar. Não obrigado.
Então, ao invés disso, vamos definir nosso método dinamicamente e o método da classe audit_method
para dispará-lo:
Quando audit_method
é chamado em uma classe implementadora, um método é criado no módulo Auditable
com o mesmo nome do método para auditoria. No nosso caso, ele irá criar um método chamado Auditable#share
. Como mencionado, Ruby irá encontrar este método antes de encontrar o método original em Messenger
, porque estamos a pré-utilizar o módulo Auditable
na classe de implementação.
Isso significa que podemos usar uma super chamada para alcançar a cadeia dos antepassados e executar Messenger#send
. Quando fazemos isso, passamos os argumentos que coletamos (*arguments
) pela chain também.
Após termos chamado o método original, imprimimos nossa mensagem de saída e a chamamos de um dia. Bom trabalho, gang!
>
Arrasar tudo junto
Agora é só uma questão de prepend
levar este módulo para a nossa classe Messenger
e devemos estar prontos para ir:
E bom Deus funciona:
As implicações aqui são enormes para a auditoria, mas há mais neste truque. Você pode usar o prepending para mudar o comportamento dos objetos sem alterar os objetos em si. Esta é uma forma adaptável e clara de criar componentes de maior ordem em Ruby. Teste de desempenho, tratamento de erros. Você pode fazer muito aqui.
Conclusion
Módulos Ruby são mais complicados do que a maioria das pessoas pensam. Não é tão simples como “despejar” código de um lugar para o outro, e compreender essas diferenças desbloqueia algumas ferramentas realmente limpas para o seu cinto de utilidades.
>
Você deve ter notado que eu só falei sobre Module#include
e Module#prepend
hoje, e que eu não toquei em Module#extend
. Isso é porque funciona muito diferente dos seus primos. Vou escrever uma explicação detalhada de Module#extend
em breve para completar o conjunto.
Por agora, se quiserem saber mais, recomendo a leitura dos módulos Ruby: Incluir vs Pré-Gaste vs Extender por Léonard Hetsch. Foi muito útil para juntar tudo isto.