Plugins em Java com Service Providers

Cotuba

O Cotuba é uma aplicação (fictícia) de linha de comando (CLI) implementada em Java que transforma arquivos Markdown (.md) em ebooks nos formatos PDF ou EPUB.

Arquivo .md vai para o cotuba que transforma em .pdf e .epub

Para usar o Cotuba, é preciso ter o cotuba.jar e os JARs de suas dependências no Classpath e invocar a classe cotuba.Main:


java -cp "libs/*" cotuba.Main -f epub

Com a opção -cp do java, definimos que o Classpath terá todos os JARs do diretório libs.

Já a opção -f do Cotuba permite definir o formato epub ou pdf (o padrão).

Seria impressa a seguinte mensagem:


Arquivo gerado com sucesso: book.epub

O arquivo EPUB gerado teria o conteúdo a seguir:

EPUB gerado

Olhando um pouquinho mais de perto, o Cotuba faz o seguinte:
– pega parâmetros do usuário
– lê cada .md, faz o parse e os renderiza para HTML
– gera o PDF ou EPUB, de acordo com os parâmetros

Cada arquivo .md é considerado um capítulo diferente. O título do capítulo é extraído do maior heading: o # no Markdown.

A necessidade de plugins

A empresa Paradizo quer definir um tema que modifica o estilo do ebook gerado pelo Cotuba.

Poderíamos pedir que o time da Paradizo nos mandasse um JAR. No código do Cotuba, invocaríamos o código da Paradizo.

Mas e se tivéssemos outros usuários que querem diferentes temas? Ou, talvez, modificações no texto?

A cada capítulo renderizado de Markdown para HTML, queremos dar a chance de terceiros inserirem seu próprio CSS. Também queremos que terceiros possam executar seu próprio código assim que a geração do ebook acabar.

Fazer com que o Cotuba chame código de terceiros é inviável! Seriam classes que estariam em outros JARs, fora do cotuba.jar. Não sabemos quais são esses outros JARs nem o nome das classes e métodos que precisamos chamar. Não podemos depender delas!

Cotuba chamando o Tema Paradizo não é uma boa ideia

Precisamos de pontos de extensão para o Cotuba. Como implementá-los?

Pontos de extensão por meio de interfaces

O Cotuba não pode depender das classes de terceiros, mas essas podem depender do Cotuba. Podemos inverter as dependências!

Para isso, precisamos fornecer uma interface com um método que será chamado quando um capítulo acabou de ser renderizado para HTML e outro quando a geração do ebook foi finalizada.

Mas e se um usuário quiser apenas tratar cada capítulo e não o ebook final? Ao invés de uma só abstração, podemos segregar as interfaces!

Então, teríamos uma interface para a renderização de um capítulo:


package cotuba.plugin;

public interface LogoAposRenderizarMDParaHTML {
  void trata(Capitulo capitulo);
}

E outra interface para quando o ebook acabar de ser gerado:


package cotuba.plugin;

public interface AoFinalizarGeracao {
  void trata(Ebook ebook);
}

O Cotuba continuaria chamando métodos de terceiros em runtime. Porém, com essas interfaces, a dependência de código é invertida:

Tema Paradizo dependendo das interfaces do Cotuba mas com Cotuba chamando o tema da Paradizo no runtime

Um plugin de tema

Os desenvolvedores da Paradizo criaram uma implementação da interface LogoAposRenderizarMDParaHTML que usa a biblioteca JSoup para colocar um style no head do HTML do capítulo, definindo uma borda tracejada em volta do título:


package br.com.paradizo.cotuba.tema;

//imports...

public class TemaParadizo implements LogoAposRenderizarMDParaHTML {

  public void trata(Capitulo capitulo) {
    System.out.println("Executando o tema Paradizo para o capítulo: " + capitulo.getTitulo());
    String html = capitulo.getConteudoHTML();
    Document doc = Jsoup.parse(html);
    doc.select("head").prepend("<style> h1 { border: 1px dashed black; } </style>");
    capitulo.setConteudoHTML(doc.html());
  }

}

Temos interfaces que fornecem pontos de extensão para o Cotuba. Temos uma implementação para o tema da Paradizo.

Mas como ligar uma coisa com a outra, sem fazer com que o Cotuba dependa do código da Paradizo?

Como fazer que as implementações dos pontos de extensão do Cotuba sejam aplicadas pela simples presença de seus JARs no Classpath?

A ServiceLoader API

Existem algumas bibliotecas (e até um padrão bastante robusto) que permitem montar uma arquitetura de plugins em Java. Mas, a partir do Java 6, você pode fazer isso apenas com o Java SE. É a ServiceLoader API.

Dois conceitos são importantes:
– Service Provider Interface (SPI): as interfaces que definem um serviço. No nosso caso, as interfaces LogoAposRenderizarMDParaHTML e AoFinalizarGeracao.
– Service Provider: uma implementação da SPI. No nosso caso, a classe TemaParadizo.

Esse mecanismo é utilizado pelo carregamento de drivers JDBC e a configuração programática da Servlet 3.0.

No projeto do tema da Paradizo, precisamos criar dentro da pasta META-INF/services um arquivo com o fully qualified name da SPI que queremos implementar. No nosso caso, será cotuba.plugin.LogoAposRenderizarMDParaHTML:


tema-paradizo
└── META-INF
    └── services
        └── cotuba.plugin.LogoAposRenderizarMDParaHTML

Se você estiver usando o Maven, a pasta META-INF deverá ficar em src/main/resources.

Dentro do arquivo, colocaremos o fully qualified name do service provider. No nosso caso:


br.com.paradizo.cotuba.tema.TemaParadizo

Carregando os Service Providers

No projeto do Cotuba, vamos carregar os service providers, ou seja, as implementações da SPI usando a classe java.util.ServiceLoader:


ServiceLoader<LogoAposRenderizarMDParaHTML> loader = 
        ServiceLoader.load(LogoAposRenderizarMDParaHTML.class);

O método estático load vasculha o diretório META-INF/services dos JARs do Classpath e encontra as implementações da interface passada como parâmetro. O ServiceLoader retornado é um Iterator e, por isso, pode ser passado para um for each:


for (LogoAposRenderizarMDParaHTML implementacao : loader) {
  implementacao.trata(capitulo);
}

Se tivermos mais de uma implementação para uma mesma interface, todas são carregadas e seu código poderá invocar cada uma delas!
A ordem de carregamento das implementaçes não é garantida. Se quisermos definir uma ordem, é preciso criar algum mecanismo. Poderíamos colocar algum critério na interface e usá-lo num sort.

Mas aonde colocar esse código?

A partir do Java 8, podemos colocar esse código num método estático da própria interface. E ainda podemos usar lambdas:


package cotuba.plugin;

import java.util.ServiceLoader;

import cotuba.domain.Capitulo;

public interface LogoAposRenderizarMDParaHTML {

  void trata(Capitulo capitulo);

  public static void rodaPara(Capitulo capitulo) {
    ServiceLoader
      .load(LogoAposRenderizarMDParaHTML.class)
      .forEach(i -> i.trata(capitulo));
  }
}

Tá, mas aonde devo chamar esse método estático? No caso do Cotuba, no momento em que o Markdown acabou de ser renderizado para HTML. Isso acontece na classe RenderizadorDeMD:


public class RenderizadorDeMD {

  public List<Capitulo> renderizaParaHTML(Path diretorioDosMD) {
    List<Capitulo> capitulos = new ArrayList<>();
    obtemArquivosMD(diretorioDosMD)
      .forEach(arquivoMD -> {
        Capitulo capitulo = new Capitulo();
        String html = renderizaArquivoMDParaHTML(arquivoMD);
        capitulo.setConteudoHTML(html);
        capitulos.add(capitulo);
        LogoAposRenderizarMDParaHTML.rodaPara(capitulo); //inserido
      });
    return capitulos;
  }

  //demais métodos...
}

Com essa versão atualizada do Cotuba, o pessoal da Paradizo deve colocar tanto o cotuba.jar como o tema-paradizo.jar no Classpath e executar o comando:


java -cp "libs/*" cotuba.Main -f epub

Teremos impresso:


Executando o tema Paradizo para o capítulo: Aberturas de livros
Executando o tema Paradizo para o capítulo: Frases
Arquivo gerado com sucesso: book.epub

O tema, que coloca uma borda no título do capítulo, foi aplicado:

Foto do EPUB gerado com o tema da Paradizo

O código pode ser encontrado em:
https://github.com/alexandreaquiles/cotuba/tree/java8

* foto por Hugh Nelson

3 Comentários

  1. Rafael Ponte 28/03/2018 at 12:46 #

    Excelente post, Aquiles!

    Embora que nunca tenha tido a necessidade de usar Service Loaders, as próprias specs Java usam essa estratégia há anos, é praticamente o padrão de como escrever specs e até mesmo plugins/extensões em servidores de aplicação (quem já teve que estender uma lib de Logging ou ExceptionHandler num servidor de aplicação proprietário sabe do que eu falo).

    O pessoal da Google até criou uma forma simples de registrar os providers usando anotações, eliminando assim a necessidade de escrever o arquivo de XML (na verdade, o arquivo ainda é gerado no final das contas): https://github.com/google/auto/tree/master/service

    Com o Java 9 e sua modularização muitas specs serão obrigadas a mudar a forma como acessam código da aplicação/usuário (nosso código). Specs que usam de reflections para acessar/alterar atributos privados das classes da sua aplicação, como JPA ou Bean Validation, serão as mais afetadas devido as restrições de acesso e segurança de módulos do Java 9. Imagina uma lib de terceiros acessando seu código sem permissão? Java 9 veio para organizar a bagaça!

    Deve dá um trabalho de implementar agora, mas certamente trará maior desacoplamento entre módulos. Para mais detalhes tem esse post muito bem escrito: http://in.relation.to/2018/03/21/spec-api-modularity-patterns/

    Enfim, parabéns novamente, ficou muito bom!

  2. Alexandre Aquiles 28/03/2018 at 13:45 #

    Grande, Rafael!

    Obrigado pelas excelentes referências! Aprofundam no tema!

Deixe uma resposta