Ex Manga Downloadr - Parte 4: Aprendendo Através do Refactoring
Ontem adicionei suporte ao Mangafox na minha ferramenta de download e isso também acabou jogando um pouco de código sujo no meu código que já não estava lá essas coisas. Hora de uma faxina pra valer.
Você consegue ver tudo o que fiz desde ontem pra limpar a casa através da excelente página de compare do Github
Antes de mais nada: agora a escolha de ter adicionado uma quantidade razoável de testes vai pagar dividendos. Neste refactoring eu mudei assinaturas de funções, formatos de resposta, movi uma boa quantidade de código de lugar, e sem os testes essa empreitada teria me tomado o dia inteiro ou mais, deixando o esforço de refactor questionável desde o início.
A cada passo do refactoring eu podia rodar “mix test” e trabalhar até chegar no status verde:
Finished in 13.5 seconds (0.1s on load, 13.4s on tests)
12 tests, 0 failuresOs testes estão demorando bastante porque fiz a escolha de que os testes unitários do MangaReader e do Mangafox fossem de fato online, buscando direto dos sites. Demora mais pra rodar a suíte mas eu sei que se ela quebrar e eu não tiver mexido naquele código, então os sites de origem mudaram seus formatos e eu preciso mudar o parser. Eu poderia ter adicionado fixtures pra fazer os testes rodarem mais rápido, mas o objetivo do meu parser é estar correto.
Macros pro Resgate!
Cada módulo de fonte tem 3 sub-módulos: ChapterPage, IndexPage e Page. Todos eles tem uma função principal que parece com este pedaço de código:
defmodule ExMangaDownloadr.Mangafox.ChapterPage do
require Logger
...
def pages(chapter_link) do
Logger.debug("Fetching pages from chapter #{chapter_link}")
case HTTPotion.get(chapter_link, [headers: ["User-Agent": @user_agent, "Accept-encoding": "gzip"], timeout: 30_000]) do
%HTTPotion.Response{ body: body, headers: headers, status_code: 200 } ->
body = ExMangaDownloadr.Mangafox.gunzip(body, headers)
{ :ok, fetch_pages(chapter_link, body) }
_ ->
{ :err, "not found"}
end
end
...
end(Listing 1.1)
Ele chama “HTTPotion.get/2” mandando um monte de opções HTTP e recebe uma struct “%HTTPotion.Response” que então é decomposta pra pegar o body e os headers. Aplica gunzip no body se necessário e vai parsear o HTML em si.
Código parecido existe em 6 módulos diferentes, com links diferentes e funções de parser diferentes. É muita repetição, mas e se a gente fizesse o código acima ficar parecido com o snippet abaixo?
defmodule ExMangaDownloadr.Mangafox.ChapterPage do
require Logger
require ExMangaDownloadr
def pages(chapter_link) do
ExMangaDownloadr.fetch chapter_link, do: fetch_pages(chapter_link)
end
...
end(Listing 1.2)
Mudei 9 linhas pra apenas 1. E aliás, essa mesma linha pode ser escrita assim:
ExMangaDownloadr.fetch chapter_link do
fetch_pages(chapter_link)
endParece familiar? É como todo bloco na linguagem Elixir, você pode escrevê-lo no formato de bloco “do/end” ou da maneira que ele realmente é por baixo dos panos: uma keyword list com uma chave chamada “:do”. E a forma como esse macro é definido é assim:
defmodule ExMangaDownloadr do
...
defmacro fetch(link, do: expression) do
quote do
Logger.debug("Fetching from #{unquote(link)}")
case HTTPotion.get(unquote(link), ExMangaDownloadr.http_headers) do
%HTTPotion.Response{ body: body, headers: headers, status_code: 200 } ->
{ :ok, body |> ExMangaDownloadr.gunzip(headers) |> unquote(expression) }
_ ->
{ :err, "not found"}
end
end
end
...
end(Listing 1.3)
Tem um monte de detalhes pra considerar quando se escreve um macro e eu recomendo ler a documentação sobre Macros. O código está basicamente copiando o corpo da função do “ChapterPage.pages/1” (Listing 1.1) e colando dentro do bloco “quote do .. end” (Listing 1.3).
Dentro daquele código a gente tem “unquote(link)” e “unquote(expression)”. Você também precisa ler a documentação sobre “Quote and Unquote”. Ele simplesmente expande esse código “externo” dentro do código do macro pra adiar a execução até o código do quote do macro de fato ser executado, em vez de rodar naquele exato momento. Eu sei, complicado de embrulhar a cabeça em volta disso na primeira vez.
A linha de fundo é: qualquer código que estiver dentro do bloco “quote” vai ser “inserido” onde a gente chamou “ExMangaDownloadr.fetch/2” na função “pages/1” da Listing 1.2, junto com o código unquoted que você passou como parâmetro.
O código resultante vai parecer com o código original da Listing 1.1.
Pra simplificar, se você estivesse em Javascript isso seria um código parecido:
function fetch(url) {
eval("doSomething('" + url + "')");
}
function pages(page_link) {
fetch(page_link);
}“Quote” seria como o corpo de string num eval e “unquote” só concatenando o valor que você passou dentro do código que está sendo eval-ado. É uma metáfora grosseira porque “quote/unquote” é muito mais poderoso e mais limpo que o feio “eval” (que você não deveria estar usando, aliás!) Mas essa metáfora serve pra você entender o código acima.
Outro lugar onde usei um macro foi pra salvar a lista de imagens num arquivo de dump e carregá-la depois caso a ferramenta dê crash por algum motivo, pra não ter que começar tudo do zero. O código original era assim:
dump_file = "#{directory}/images_list.dump"
images_list = if File.exists?(dump_file) do
:erlang.binary_to_term(File.read!(dump_file))
else
list = [url, source]
|> Workflow.chapters
|> Workflow.pages
|> Workflow.images_sources
File.write(dump_file, :erlang.term_to_binary(list))
list
end(Listing 1.4)
E agora que você entende macros, vai entender o que eu fiz aqui:
defmodule ExMangaDownloadr do
...
defmacro managed_dump(directory, do: expression) do
quote do
dump_file = "#{unquote(directory)}/images_list.dump"
images_list = if File.exists?(dump_file) do
:erlang.binary_to_term(File.read!(dump_file))
else
list = unquote(expression)
File.write(dump_file, :erlang.term_to_binary(list))
list
end
end
end
...
end
defmodule ExMangaDownloadr.CLI do
alias ExMangaDownloadr.Workflow
require ExMangaDownloadr
...
defp process(manga_name, directory, {_url, _source} = manga_site) do
File.mkdir_p!(directory)
images_list =
ExMangaDownloadr.managed_dump directory do
manga_site
|> Workflow.chapters
|> Workflow.pages
|> Workflow.images_sources
end
...
end
...
endE pronto! E agora você vê como blocos “do .. end” são implementados. Ele simplesmente passa a expressão como o valor na keyword list da definição do macro. Vamos definir um macro bobo:
defmodule Foo
defmacro foo(do: expression) do
quote do
unquote(expression)
end
end
endE agora as seguintes chamadas são todas equivalentes:
require Foo
Foo.foo do
IO.puts(1)
end
Foo.foo do: IO.puts(1)
Foo.foo(do: IO.puts(1))
Foo.foo([do: IO.puts(1)])
Foo.foo([{:do, IO.puts(1)}])Isso é macros combinados com Keyword Lists que eu expliquei em artigos anteriores e é simplesmente uma List com tuplas onde cada tupla tem uma chave atom e um valor.
Mais Macros
Outra oportunidade pra refatorar foram os módulos “mangareader.ex” e “mangafox.ex” que eram só usados nos testes unitários “mangareader_test.ex” e “mangafox_test.ex”. Esse é o código antigo do “mangareader.ex”:
defmodule ExMangaDownloadr.MangaReader do
defmacro __using__(_opts) do
quote do
alias ExMangaDownloadr.MangaReader.IndexPage
alias ExMangaDownloadr.MangaReader.ChapterPage
alias ExMangaDownloadr.MangaReader.Page
end
end
end E é assim que ele era usado no “mangareader_test.ex”:
defmodule ExMangaDownloadr.MangaReaderTest do
use ExUnit.Case
use ExMangaDownloadr.MangaReader
...Era só um atalho pra fazer o alias dos módulos pra usá-los diretamente dentro dos testes. Eu simplesmente movi o módulo inteiro como um macro no módulo “ex_manga_downloadr.ex”:
...
def mangareader do
quote do
alias ExMangaDownloadr.MangaReader.IndexPage
alias ExMangaDownloadr.MangaReader.ChapterPage
alias ExMangaDownloadr.MangaReader.Page
end
end
def mangafox do
...
end
defmacro __using__(which) when is_atom(which) do
apply(__MODULE__, which, [])
end
endE agora posso usá-lo assim no arquivo de teste:
defmodule ExMangaDownloadr.MangaReaderTest do
use ExUnit.Case
use ExMangaDownloadr, :mangareader
...O macro especial “using” é chamado quando eu dou “use” num módulo, e eu posso até passar argumentos pra ele. A implementação então usa “apply/3” pra chamar dinamicamente o macro correto. É exatamente assim que o Phoenix importa os behaviors apropriados pra Models, Views, Controllers, Router, por exemplo:
defmodule Pxblog.PageController do
use Pxblog.Web, :controller
...Os macros estão abertos num arquivo Phoenix e disponíveis no módulo “web/web.ex”, então só copiei o mesmo comportamento. E agora tenho 2 arquivos a menos pra me preocupar.
Pequenos refactorings
No código anterior eu usava o “String.to_atom/1” pra converter a string do nome do módulo num atom, pra ser usado depois em chamadas “apply/3”:
defp manga_source(source, module) do
case source do
"mangareader" -> String.to_atom("Elixir.ExMangaDownloadr.MangaReader.#{module}")
"mangafox" -> String.to_atom("Elixir.ExMangaDownloadr.Mangafox.#{module}")
end
endMudei pra isto:
"mangareader" -> :"Elixir.ExMangaDownloadr.MangaReader.#{module}"
"mangafox" -> :"Elixir.ExMangaDownloadr.Mangafox.#{module}"É só um atalho pra fazer a mesma coisa.
No parser eu também estava usando o Floki de forma errada. Então dá uma olhada nesse pedaço de código antigo:
defp fetch_manga_title(html) do
Floki.find(html, "#mangaproperties h1")
|> Enum.map(fn {"h1", [], [title]} -> title end)
|> Enum.at(0)
end
defp fetch_chapters(html) do
Floki.find(html, "#listing a")
|> Enum.map fn {"a", [{"href", url}], _} -> url end
endAgora usando as funções helper melhores que o Floki provê:
defp fetch_manga_title(html) do
html
|> Floki.find("#mangaproperties h1")
|> Floki.text
end
defp fetch_chapters(html) do
html
|> Floki.find("#listing a")
|> Floki.attribute("href")
endEsse foi um caso de não ter lido a documentação como eu deveria. Bem mais limpo!
Fiz outras pequenas limpezas mas acho que isso cobre as mudanças principais. E finalmente, subi a versão pra “1.0.0” também!
def project do
[app: :ex_manga_downloadr,
- version: "0.0.1",
+ version: "1.0.0",
elixir: "~> 1.1",E falando em versões, estou usando Elixir 1.1 mas preste atenção porque Elixir 1.2 está logo aí na esquina e traz algumas gracinhas. Por exemplo, aquele macro que fazia o alias de alguns módulos poderia ser escrito assim agora:
def mangareader do
quote do
alias ExMangaDownloadr.MangaReader.{IndexPage, ChapterPage, Page}
end
endE essa é só 1 feature entre muitas outras melhorias de sintaxe e suporte ao mais novo Erlang R18. Fique de olho nos dois!