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.

14 Comentários

  1. Luciano 28/06/2010 at 19:45 #

    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

  2. Paulo Silveira 28/06/2010 at 20:14 #

    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.

  3. Eduardo bohrer 29/06/2010 at 18:21 #

    A estrutura de Streams do java.io trabalha bastante com isso.

    Legal, valeu.

  4. Paulo Silveira 29/06/2010 at 19:18 #

    @Eduardo, isso mesmo, java.io é um excelente exemplo de decorators usando composição (mas tambem tem template method em InputStream que usa herança…)

  5. Marcio Duran 30/06/2010 at 00:08 #

    @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.

  6. Paulo Silveira 30/06/2010 at 00:15 #

    @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.

  7. Marcio Duran 30/06/2010 at 02:30 #

    @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.

  8. Paulo Silveira 30/06/2010 at 02:56 #

    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.

  9. Emerson Macedo 01/07/2010 at 16:55 #

    Paulo,

    a definição da interface ta errada. Faltou a definição do parâmetro Request req.

    Grande abraço.

  10. Paulo Silveira 01/07/2010 at 17:00 #

    Boa Emerson! Corrigido, obrigado.

  11. Alexandre Gazola 10/07/2010 at 14:03 #

    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

Deixe uma resposta