Compondo seu comportamento: herança, Chain of Responsibility e Interceptors

Compondo seu comportamento: herança, Chain of Responsibility e Interceptors
gas
gas

Compartilhe

São diversos os momentos em que temos a tentação de usar herança para implementar funcionalidades de maneira rápida. Um exemplo simples é o polêmico caso de Properties e Hashtable em Java.

Alguns padrões também costumam ser implementados através de herança são cadeias de responsabilidade, decorators, template method, filtros/interceptadores, entre outros. O exemplo a seguir mostra a adição de comportamento através de herança próxima ao que acontece com uma Servlet, ou ainda um InputStream que delega para outro:

 class Server { Response service(Request req) { return new Response("<html>get para " + req.getPath() + "</html>"); } }

class CacheableServer extends Server { Response service(Request req) { Response resp = super(req); resp.addHeader("Cache-control", "public, max-age=7200"); return resp; } }

class LoggingServer extends CacheableServer { Response service(Request req) { Logger.debug("Requesting " + req.getPath()); return super(req); } } 
Imersão dev Back-end: mergulhe em programação hoje, com a Alura e o Google Gemini. Domine o desenvolvimento back-end e crie o seu primeiro projeto com Node.js na prática. O evento é 100% gratuito e com certificado de participação. O período de inscrição vai de 18 de novembro de 2024 a 22 de novembro de 2024. Inscreva-se já!

Encadear comportamentos através de herança de classes apresenta diversos problemas de acoplamento e dificulta a customização de um processo.

Se eu desejasse um servidor com log mas sem as características de Cache, deveria criar uma quarta classe, LoggingWithoutCacheServer que herda de Server e reescrever o código de log. Costuma-se então sugerir a utilização de herança para evitar copiar-e-colar de código, mas podemos ver que somente com ela isso não é verdade.

Herança de classes é positivo por causa do polimorfismo, mas cria uma acoplamento perigoso, já discutido por diversos autores, em especial nos livros Effective Java e Design Patterns.

Em Ruby temos uma abordagem semelhante de reutilização de comportamento através da herança de classe ou através da inclusão de módulos:

 module ServiceProvider def service(req) Response.new "<html>get para #{req.path}</html>" end end

module CacheableProvider def service(req) res = super(req) res.headers```"Cache-control"
 = "public, max-age=7200" res end end

module LoggingProvider def service(req) Logger.debug "Requesting #{req.path}" super(req) end end 

E agora podemos descrever nosso serviço através da inclusão dos módulos, na ordem que for necessária:

 class Server include ServiceProvider include CacheableProvider include LoggingProvider end 

Se desejamos uma outra ordem de execução qualquer, basta mudar a ordem ou adicionar novos módulos que delegam para super quando desejado. Aqui ainda temos problemas de herança apresentados no post de 2006: o acoplamento da classe Server com os módulos é muito forte e, pior ainda, criamos dependências implicitas entre os módulos que antes não se conheciam: se um dos providers de log possui um método chamado info e o outro provider possui outro método info, sua aplicação não funcionará como o esperado.

A inclusão de módulos para compor a herança, seja em tempo de codificação ou execução, mantem o acoplamento alto. Note que ambas as abordagens permitem a adição de novo comportamento ou encadeamento dos mesmos visando minimizar a atualização na classe filha, mas acabando por necessitar que o autor de cada módulo conheça os detalhes dos outros, aumentando o acoplamento.

Favorecer composição de comportamentos através da agregação de objetos diminui o acoplamento entre os mesmos. Em Java teríamos:

 interface Server { Response service(Request req); } class DefaultServer implements Server { public Response service(Request req) { return new Response("<html>get para " + req.getPath() + "</html>"); } } class CacheableServer implements Server { private Server delegate; CacheableServer(Server delegate) { this.delegate = delegate; } public Response service(Request req) { Response resp = delegate.service(req); resp.addHeader("Cache-control", "public, max-age=7200"); return resp; } } class LoggingServer implements Server { private Server delegate; LoggingServer(Server delegate) { this.delegate = delegate; } public Response service(Request req) { Logger.debug("Requesting " + req.getPath()); return delegate.service(req); } } 

E a criação de nosso serviço ou processo pode ser totalmente customizada:

 Server log = new LoggingServer(new Server()); Server logAndCache = new LoggingServer(new CachingServer(new DefaultServer())); 

Para quem gostaria de utilizar essa prática de compor o processamento de uma requisição em diversas fases, como as empresas de host, a funcionalidade de Valves do Tomcat em 2004 permitia que criassem filtros, anteriormente possuindo apis não publicadas em sua versão 3, exatamente como aqueles que o middleware Rack faz hoje em dia:

 module Rack class CustomLog def initialize app @app = app end def call env status, headers, body = @app.call env if env```'HTTP\_METHOD'
=='HEAD' body = nil end ```status, headers, body
 end end end 

Em linguages funcionais como Javascript ou com suporte a ponteiros de função, como C, podemos criar o mesmo tipo de composição de comportamento:

 server = function (req) { // return nova resposta }

function support\_log(base) { return function(req) { var res = base(req); console.log("logando o resultado"); return req; } }

function support\_head(base) { return function(req) { var res = base(req); if(req.method == "HEAD"){ res.body = ""; } return res; } }

function parse(req) { return support\_head(support\_log(server))(req); } 

Note que em todos os casos, assim como na sequência de converters e wrappers do XStream, favorecemos composição ao invés de herança.

Ao mesmo tempo, encontramos uma dificuldade na hora de implementar a composição: como compartilhar objetos para serem acessados entre diferentes comportamentos que foram adicionados ao nosso objeto original?

A solução mais encontrada em todas as linguagens mencionadas é a criação de um escopo novo contendo todos os objetos a serem compartilhados (contexto), um objeto que funciona como um mapa. Em linguagens com tipagem estática isso pode implicar em um perigo a mais, um cast forçado:

 public Response service(Request req, Context ctx) { User user = (User) ctx.get("logged\_in\_user"); Logger.debug("Requesting " + req.getPath()); return delegate.service(req); } 

Essa é a solução adotada pelo servlet API, além de ser adotado pela implementação do Rack middleware com o env.

Uma outra solução pouco utilizada e que poderia aumentar a legibilidade em troca de menos visibilidade é fazer o lazy load de acesso a variável através do method_missing, algo como:

 def method\_missing(sym, args) return env```sym
 if env.include?(sym.to\_s) super(sym, args) end 

Compor um comportamento através de outros é fundamental para diminuir a complexidade de métodos e classes. Podemos fazer isso através de herança, pagando-se um preço alto, ou utilizar chain of responsability, interceptors e outros padrões, que podem ser implementados puramente através de interfaces e composição.

Veja outros artigos sobre Programação