Compondo seu comportamento: herança, Chain of Responsibility e Interceptors
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.