Compondo seu comportamento: herança, Chain of Responsibility e Interceptors
Postado em 28. jun, 2010 por Guilherme Silveira em Arquitetura
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);
}
}
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.
Guilherme Silveira (Google+)
13 Respostas para “Compondo seu comportamento: herança, Chain of Responsibility e Interceptors”
Trackbacks/Pingbacks
-
-
abril 12, 2011
[...] é delicado, e o desenvolvedor deve estar ciente de que ela pode trazer um acoplamento indesejado e suas alternativas. O uso de interfaces se encaixaria aqui com [...]
-
-
março 19, 2012
[...] é uma implementação de chain of responsibility trabalhando com funções ao invés de objetos. Nesse caso, cada chamada de addMethod armazena a [...]
ASSINE NOSSO RSS
Luciano
28. jun, 2010
Guilherme,
No exemplo em Java, onde você faz:
Server log = new LoggingServer(new Server());
Não está errado?Server não é a interface?
E onde está
class LoggingServer implements CacheableServer {
Não seria implements Server?
Abraços
Paulo Silveira
28. jun, 2010
Oi Luciano. Bem observado, obrigado. O primeiro era pra ser new DefaultServer ou qualquer outro server, e o segundo é mesmo implements Server. Obrigado pelo aviso dos typos, já está editado.
Eduardo bohrer
29. jun, 2010
A estrutura de Streams do java.io trabalha bastante com isso.
Legal, valeu.
Paulo Silveira
29. jun, 2010
@Eduardo, isso mesmo, java.io é um excelente exemplo de decorators usando composição (mas tambem tem template method em InputStream que usa herança…)
Marcio Duran
30. jun, 2010
@Luciano
1º É mesmo implements Server.
@Paulo
2º Não compreendi porque DefaultServer ou invés de LoggingServer já que a responsabilidade de receber os Server compete ao mesmo ?
3º Finalizando logAndCache recebe todas as interfaces, e ai sim customiza.
Paulo Silveira
30. jun, 2010
@Marcio o LoggingServer não pode ser o último: ele é um Decorator, é o mesmo de querer usar um BufferedReader como ultimo Reader da cadeia. Como voce ve pelo construtor dele, ele precisa de um Server delegate. Mas voce poderia sim usar qualquer outro Server que nao exigisse um delegate, seja o Default ou algum outro.
Marcio Duran
30. jun, 2010
@Paulo
Classe DefaultServer não é definido explicitamente um construtor , todavia um construtor default é gerado “DefaultServer( ) { }”, a mesma é Especialização de Server, não existe a responsabilidade dimânica do DefaultServer pois à ela não recebe Server delegate, não é Decorator, ok.
Então cabe aqui uma refatoração de class DefaultServer para class DefaultStaticService , isso tira a ideia de achar que a mesma tem uma ação de “Decorator” justamente por fazer implements server, e por não receber Server delegate, é unico é um Singleton.
Paulo Silveira
30. jun, 2010
Ola Marcio
O DefaultServer nao é um singleton e nem precisa ser um singleton nesse caso. Voce pode querer, por exemplo, ter varias instancias de DefaultServer, cada uma rodando numa porta diferente. Essa decisao se essa classe vai ser singleton não tem impacto nessa discussão apresentada, vai depender de outros fatores.
Emerson Macedo
01. jul, 2010
Paulo,
a definição da interface ta errada. Faltou a definição do parâmetro Request req.
Grande abraço.
Paulo Silveira
01. jul, 2010
Boa Emerson! Corrigido, obrigado.
Alexandre Gazola
10. jul, 2010
Parabéns pelo artigo!
Este assunto é muito importante… as pessoas estão bastante acostumadas a usar herança para reaproveitamento de comportamento, sem pensar nos problemas de acoplamento inerentes a ela. Acho que tudo se resume à avaliação do acoplamento gerado… as vezes, o uso de herança é bom o suficiente; e às vezes, não. Design evolucionário com TDD/Refactoring podem ajudar na avaliação e composição da melhor solução para o software num determinado instante.
abracos