O caos dos scrapers macarrônicos
O GOrimpo surgiu como um projeto de nicho, um projeto pessoal que ia me ajudar em algo que eu tenho muito interesse (retro games) e que eu poderia aplicar todo tipo de tecnologia e transformar ele no meu laboratório de estudos, mas em menos de 1 semana meu segundo post sobre o GOrimpo passou das 70 mil visualizações, e o projeto nichado pra retro games atraiu olhares de bastante gente!
Um problema nos Scrapers tradicionais é que costumam ter códigos macarrônicos, ou até mesmo um arquivo .py gigante de milhares de linhas. Com código de rede misturado com lógica de negócio, dependência forte de bibliotecas (se mudar de playwright pra selenium, tem que reescrever tudo) e dificuldade de testar sem subir o browser. A Arquitetura Hexagonal (ou Ports & Adapters) é o que separa um script descartável de uma ferramenta escalável.
A anatomia do GOrimpo
No GOrimpo, implementei a Arquitetura Hexagonal pensando em crescer para outros marketplaces, como enjoei, mercadolivre, etc. e também pensando em outras formas de notificar o usuário (discord, whatsapp…). Essa tabela abaixo mostra melhor a arquitetura:
| camada | responsabilidade |
|---|---|
| Domain (core) | o que é uma oferta, como filtramos preços… |
| ports (interfaces) | as interfaces para Notifier, Scraper… |
| adapters | as implementações pros ports, como OLXScraper, Telegram, SQLite… |
Beleza, a teoria é linda, mas onde os arquivos moram? No GOrimpo, a separação física é o que garante que eu não misture negócio com infraestrutura:
├── cmd
│ └── gorimpo
├── data
├── docs
└── internal
├── adapters
│ ├── config
│ ├── infrastructure
│ ├── notifier
│ ├── repository
│ ├── scraper
│ └── telemetry
└── core
├── domain
├── ports
└── services
Plug & Play de verdade: O poder do desacoplamento
Dessa forma, o core não faz a menor ideia que a OLX existe, ele sabe que é um Scraper, mas não sabe se é da OLX, enjoei, ebay ou qualquer outro marketplace, tudo por conta da nossa interface. Isso permite que a troca de qualquer um dos componentes seja extremamente rápida, se amanhã o Telegram acabar com os bots, a gente cria um novo DiscordNotifier implementa a interface e faz pequenas alterações no main.go
olxScraper := scraper.NewOLX(Version != "dev", cfg, idGen)
enjoeiScraper := scraper.NewEnjoei(Version != "dev", cfg, idGen)
meliScraper := scraper.NewMercadoLivre(Version != "dev", cfg, idGen)
ebayScraper := scraper.NewEbay(Version != "dev", cfg, idGen)
// outras implementações ...
gorimpoSvc := services.NewGorimpoService(olxScraper, repo, telegram, metrics, cfg)
// qualquer um dos scrapers acima é aceito!
// gorimpoSvc := services.NewGorimpoService(enjoeiScraper, repo, telegram, metrics, cfg)
// gorimpoSvc := services.NewGorimpoService(meliScraper, repo, telegram, metrics, cfg)
// gorimpoSvc := services.NewGorimpoService(meliScraper, repo, telegram, metrics, cfg)
// ...
a intenção quando criarmos outros scrapers de outros marketplaces é permitir um slice de scrapers, de modo que o gorimpo faça buscas em múltiplos sites de forma paralela
Como demonstrei no código acima, qualquer uma das implementações é aceita, já que o polimorfismo em Go é implementado implicitamente através de interfaces, assim, desde que as funções estejam imlpementadas, ele entende que é uma interface válida! Sem palavras chave ou herança, facilitando a criação de código flexível e desacoplado.
O primeiro PR externo
E um PR feito pelo Diego Ritzel (mais uma vez, obrigado, Diego!) foi a prova viva que a arquitetura hexagonal do GOrimpo estava simples de entender, sem nem ao menos ler um CONTRIBUTING.md (ainda não tinha feito, erro meu) ele conseguiu implementar um novo adapter para Gotify, que chegará na v1.3.0. Graças a interface ports.Notifier, ele conseguiu implementar um novo adapter de forma fácil, com poucas mudanças no código, e a regra de negócio permaneceu intacta, sem se importar qual era o notificador. Hoje, na v1.2.0, qualquer notificador novo pode ser implementado de forma simples e segura.
Interfaces em Go
Mas pro Diego conseguir criar esse adapter, precisamos ter a interface implementada, aqui tem um exemplo de como implementei a interface do Notifier:
package ports
import "github.com/LXSCA7/gorimpo/internal/core/domain"
type Notifier interface {
SetRoutes(routes map[string]string)
Send(offer domain.Offer, category, searchTerm string, showSearchTerm bool) error
SendText(message, category string) error
SendPhoto(data []byte, caption string, category string) error
CreateCategory(name string) (string, error)
}
Olhando para a interface de Notificação hoje, vejo que ela poderia ser ainda mais segregada. No Go, é muito fácil cair na tentação de criar interfaces grandes, mas a verdadeira elegância está na composição. Uma das melhorias mapeadas é quebrar o Notifier em pequenos pedaços (Text, Photo, Category), permitindo que notificadores mais simples não precisem implementar métodos que não utilizam.
A ideia é fazer algo parecido com o que foi feito na interface do Scraper, utilizar a Interface Embedding (Composição de Interfaces) do Go, algo que só conheci após a interface do Scraper ja ter sido finalizada. Dê uma olhada nela:
package ports
import "github.com/LXSCA7/gorimpo/internal/core/domain"
type Scraper interface {
Search(term string) ([]domain.Offer, error)
}
type VisualScraper interface {
Scraper
GetLastScreenshot() []byte
}
Essa interface permite que, ao invés de poluirmos a interface básica Scraper com métodos de screenshot que um scraper de API não saberia resolver (como fiz no Notifier), nós implementamos a Composição de Interfaces, permitindo que um adapter implemente a que for mais conveniente (ou até mais de uma). Desse modo, a implementação seria algo como:
// base
type Notifier interface {
SendText(message, category string) error
}
// para notificadores que suportam mídia (Telegram, Discord)
type VisualNotifier interface {
Notifier
SendPhoto(data []byte, caption string, category string) error
}
// para notificadores que gerenciam canais/tópicos (Telegram, Discord, Slack)
type CategorizedNotifier interface {
Notifier
CreateCategory(name string) (string, error)
}
Assim, melhorando o desacoplamento da aplicação!
Garantindo o contrato em tempo de compilação
Para implementar no Telegram, a gente pode forçar que o compilador verifique se nosso adapter está implementando corretamente os ports:
var _ ports.Notifier = (*TelegramAdapter)(nil)
var _ ports.CategorizedNotifier = (*TelegramAdapter)(nil)
var _ ports.VisualNotifier = (*TelegramAdapter)(nil)
E para validar se poderemos ou não enviar uma foto, podemos usar:
if v, ok := notifier.(ports.VisualNotifier); ok { v.SendPhoto(...) }
Isso é o SOLID na veia. Em vez de uma interface gigante, temos interfaces pequenas. Se o meu scraper é via API, ele satisfaz apenas a Scraper. Se ele é via Browser e eu quero tirar print do erro, ele satisfaz a VisualScraper. O Core decide como usar cada uma sem nunca saber quem está por trás delas.
Conclusão
O GOrimpo ainda está na v1.2.0 e tem muito chão pela frente. O aprendizado com a VisualScraper já está sendo levado para a refatoração dos Notifiers na v1.3.0.
No fim das contas, arquitetura não é sobre seguir um manual debaixo do braço, mas sobre construir algo que não te dê medo de mudar seis meses depois. E você, como tem protegido seu core da bagunça dos scrapers?
Bora trocar uma ideia lá no repositório do GOrimpo!