Prática de Concorrência: Web Crawler

Prática de Concorrência: Web Crawler

Analogia

Imagine que você é um gerente de agência de viagens que precisa enviar múltiplas equipes para diferentes cidades simultaneamente para coletar informações de viagem:

É exatamente assim que um web crawler concorrente funciona. Vamos implementá-lo em Go.


Requisitos do Projeto

Vamos desenvolver um web crawler concorrente com as seguintes capacidades:

  1. Rastreamento concorrente: Múltiplas goroutines rastreiam páginas web simultaneamente
  2. Limitação de taxa: Controla concorrência máxima para evitar sobrecarregar o servidor alvo
  3. Deduplicação: A mesma URL não é rastreada duas vezes
  4. Tentativa de erro: Tentativa automática em caso de falha de rastreamento com backoff exponencial
  5. Desligamento gracioso: Após receber sinal de interrupção, espera tarefas em execução completarem antes de sair

Design do Sistema

┌─────────────┐
│ URLs Semente │  Fila de URLs semente
└──────┬──────┘
       ▼
┌─────────────┐
│ Fila de URLs │  Channel de URLs a rastrear
│  (channel)   │
└──────┬──────┘
       ▼
┌──────────────────────────────────────┐
│         Pool de Workers              │
│  ┌─────────┐ ┌─────────┐ ┌────────┐ │
│  │Worker 1 │ │Worker 2 │ │Worker N│ │  Número limitado de workers
│  └────┬────┘ └────┬────┘ └───┬────┘ │
└───────┼───────────┼──────────┼───────┘
        ▼           ▼          ▼
┌─────────────────────────────────────┐
│      Channel de Resultados          │  Agregação de resultados
│    ┌──────────┐  ┌──────────┐       │
│    │ Mapa de  │  │ Tentativa│       │  Deduplicação + Tentativa
│    │  Dedup   │  │          │       │
│    └──────────┘  └──────────┘       │
└─────────────────────────────────────┘

Exemplo 1: Código Completo

GO
package main

import (
	"context"
	"fmt"
	"io"
	"math"
	"net/http"
	"net/url"
	"os"
	"os/signal"
	"regexp"
	"strings"
	"sync"
	"syscall"
	"time"
)

// ============================================================
// Definições de Estruturas de Dados
// ============================================================

// CrawlTask representa uma tarefa de rastreamento
type CrawlTask struct {
	URL   string // URL alvo
	Depth int    // Profundidade atual
}

// CrawlResult representa o resultado de um rastreamento
type CrawlResult struct {
	URL        string        // URL rastreada
	Title      string        // Título da página (extração simplificada)
	BodyLength int           // Comprimento do conteúdo da página
	Depth      int           // Profundidade de rastreamento
	Err        error         // Informação de erro (se houver)
	Latency    time.Duration // Duração do rastreamento
}

// CrawlerConfig é a configuração do crawler
type CrawlerConfig struct {
	MaxConcurrency int           // Concorrência máxima
	MaxDepth       int           // Profundidade máxima de rastreamento
	MaxRetries     int           // Contagem máxima de tentativas
	RequestTimeout time.Duration // Timeout por requisição
	RateInterval   time.Duration // Intervalo de limitação de taxa
}

// DefaultConfig retorna a configuração padrão
func DefaultConfig() CrawlerConfig {
	return CrawlerConfig{
		MaxConcurrency: 3,
		MaxDepth:       2,
		MaxRetries:     3,
		RequestTimeout: 10 * time.Second,
		RateInterval:   500 * time.Millisecond,
	}
}

// ============================================================
// Deduplicador (conjunto de URLs visitadas thread-safe)
// ============================================================

// Deduplicator lida com deduplicação de URLs
type Deduplicator struct {
	visited map[string]bool
	mu      sync.Mutex
}

// NewDeduplicator cria um deduplicador
func NewDeduplicator() *Deduplicator {
	return &Deduplicator{
		visited: make(map[string]bool),
	}
}

// Mark marca uma URL como visitada; retorna true se for uma nova URL
func (d *Deduplicator) Mark(url string) bool {
	d.mu.Lock()
	defer d.mu.Unlock()

	if d.visited[url] {
		return false // Já visitada
	}
	d.visited[url] = true
	return true // Nova URL
}

// Count retorna o número de URLs visitadas
func (d *Deduplicator) Count() int {
	d.mu.Lock()
	defer d.mu.Unlock()
	return len(d.visited)
}

// ============================================================
// Limitador de Taxa (controle de concorrência baseado em semáforo)
// ============================================================

// RateLimiter é um limitador de taxa baseado em semáforo de channel
type RateLimiter struct {
	semaphore chan struct{}
	interval  time.Duration
}

// NewRateLimiter cria um limitador de taxa
// maxConcurrency: concorrência máxima
// interval: intervalo mínimo entre duas requisições
func NewRateLimiter(maxConcurrency int, interval time.Duration) *RateLimiter {
	return &RateLimiter{
		semaphore: make(chan struct{}, maxConcurrency),
		interval:  interval,
	}
}

// Acquire adquire uma permissão de execução
func (rl *RateLimiter) Acquire() {
	rl.semaphore <- struct{}{} // Envia para channel com buffer; bloqueia quando cheio
}

// Release libera uma permissão de execução
func (rl *RateLimiter) Release() {
	time.Sleep(rl.interval) // Limitação de taxa: mantém intervalo mínimo
	<-rl.semaphore          // Recebe do channel com buffer, liberando espaço
}

// ============================================================
// Buscador Web
// ============================================================

// Fetcher lida com requisições HTTP reais
type Fetcher struct {
	client *http.Client
}

// NewFetcher cria um buscador
func NewFetcher(timeout time.Duration) *Fetcher {
	return &Fetcher{
		client: &http.Client{
			Timeout: timeout,
		},
	}
}

// Fetch busca a URL especificada e retorna o título e comprimento do conteúdo
func (f *Fetcher) Fetch(ctx context.Context, rawURL string) (title string, bodyLen int, err error) {
	// Cria uma requisição com contexto para suporte a cancelamento
	req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
	if err != nil {
		return "", 0, fmt.Errorf("falha ao criar requisição: %w", err)
	}

	// Define User-Agent para simular um navegador
	req.Header.Set("User-Agent", "GoCrawler/1.0")

	resp, err := f.client.Do(req)
	if err != nil {
		return "", 0, fmt.Errorf("requisição falhou: %w", err)
	}
	defer resp.Body.Close()

	// Verifica código de status HTTP
	if resp.StatusCode != http.StatusOK {
		return "", 0, fmt.Errorf("código de status HTTP: %d", resp.StatusCode)
	}

	// Lê corpo da resposta (limita tamanho para prevenir estouro de memória)
	const maxSize = 1 << 20 // 1MB
	body, err := io.ReadAll(io.LimitReader(resp.Body, maxSize))
	if err != nil {
		return "", 0, fmt.Errorf("falha ao ler resposta: %w", err)
	}

	// Extração simples do conteúdo da tag <title>
	title = extractTitle(string(body))

	return title, len(body), nil
}

// extractTitle extrai o título do HTML
func extractTitle(html string) string {
	re := regexp.MustCompile(`(?i)<title>(.*?)</title>`)
	matches := re.FindStringSubmatch(html)
	if len(matches) > 1 {
		return strings.TrimSpace(matches[1])
	}
	return "(sem título)"
}

// ============================================================
// Extrator de Links
// ============================================================

// ExtractLinks extrai todos os links do HTML (versão simplificada)
func ExtractLinks(body string, baseURL string) []string {
	re := regexp.MustCompile(`(?i)href=["']([^"']+)["']`)
	matches := re.FindAllStringSubmatch(body, -1)

	var links []string
	base, err := url.Parse(baseURL)
	if err != nil {
		return links
	}

	for _, match := range matches {
		if len(match) < 2 {
			continue
		}
		href := match[1]

		// Analisa URL relativa
		parsed, err := url.Parse(href)
		if err != nil {
			continue
		}

		// Converte para URL absoluta
		absolute := base.ResolveReference(parsed)

		// Mantém apenas links HTTP/HTTPS
		if absolute.Scheme == "http" || absolute.Scheme == "https" {
			// Remove fragmento (#âncora)
			absolute.Fragment = ""
			links = append(links, absolute.String())
		}
	}

	return links
}

// ============================================================
// Engine do Crawler
// ============================================================

// Crawler é o engine principal do crawler
type Crawler struct {
	config    CrawlerConfig
	fetcher   *Fetcher
	dedup     *Deduplicator
	limiter   *RateLimiter
	taskCh    chan CrawlTask   // Fila de tarefas
	resultCh  chan CrawlResult // Channel de resultados
	wg        sync.WaitGroup  // Espera todos os workers completarem
}

// NewCrawler cria um crawler
func NewCrawler(config CrawlerConfig) *Crawler {
	return &Crawler{
		config:   config,
		fetcher:  NewFetcher(config.RequestTimeout),
		dedup:    NewDeduplicator(),
		limiter:  NewRateLimiter(config.MaxConcurrency, config.RateInterval),
		taskCh:   make(chan CrawlTask, 100),
		resultCh: make(chan CrawlResult, 100),
	}
}

// CrawlWithRetry realiza um rastreamento com tentativa (backoff exponencial)
func (c *Crawler) CrawlWithRetry(ctx context.Context, task CrawlTask) CrawlResult {
	var lastErr error

	for attempt := 0; attempt <= c.config.MaxRetries; attempt++ {
		// Verifica se o contexto foi cancelado
		select {
		case <-ctx.Done():
			return CrawlResult{
				URL:   task.URL,
				Depth: task.Depth,
				Err:   ctx.Err(),
			}
		default:
		}

		// Nenhuma espera necessária para a primeira tentativa
		if attempt > 0 {
			// Backoff exponencial: 1s, 2s, 4s ...
			backoff := time.Duration(math.Pow(2, float64(attempt-1))) * time.Second
			fmt.Printf("  ↻ Tentativa %d/%d: %s (esperando %v)\n",
				attempt, c.config.MaxRetries, task.URL, backoff)

			select {
			case <-time.After(backoff):
			case <-ctx.Done():
				return CrawlResult{
					URL:   task.URL,
					Depth: task.Depth,
					Err:   ctx.Err(),
				}
			}
		}

		start := time.Now()
		title, bodyLen, err := c.fetcher.Fetch(ctx, task.URL)
		latency := time.Since(start)

		if err == nil {
			// Sucesso
			return CrawlResult{
				URL:        task.URL,
				Title:      title,
				BodyLength: bodyLen,
				Depth:      task.Depth,
				Latency:    latency,
			}
		}

		lastErr = err
	}

	return CrawlResult{
		URL:   task.URL,
		Depth: task.Depth,
		Err:   fmt.Errorf("falhou após %d tentativas: %w", c.config.MaxRetries, lastErr),
	}
}

// Worker é a goroutine worker
func (c *Crawler) Worker(ctx context.Context, id int) {
	defer c.wg.Done()

	fmt.Printf("[Worker %d] Iniciado\n", id)

	for task := range c.taskCh {
		// Verifica contexto
		select {
		case <-ctx.Done():
			fmt.Printf("[Worker %d] Sinal de saída recebido, parando\n", id)
			return
		default:
		}

		// Limitação de taxa: adquire permissão
		c.limiter.Acquire()

		fmt.Printf("[Worker %d] Rastreando: %s (profundidade %d)\n", id, task.URL, task.Depth)

		// Executa rastreamento (com tentativa)
		result := c.CrawlWithRetry(ctx, task)

		// Libera permissão
		c.limiter.Release()

		// Envia resultado
		select {
		case c.resultCh <- result:
		case <-ctx.Done():
			return
		}
	}

	fmt.Printf("[Worker %d] Saindo\n", id)
}

// Start inicia o crawler
func (c *Crawler) Start(ctx context.Context, seedURLs []string) {
	// Inicia pool de workers
	for i := 0; i < c.config.MaxConcurrency; i++ {
		c.wg.Add(1)
		go c.Worker(ctx, i)
	}

	// Submete URLs semente
	go func() {
		for _, rawURL := range seedURLs {
			if c.dedup.Mark(rawURL) {
				c.taskCh <- CrawlTask{URL: rawURL, Depth: 0}
			}
		}
	}()

	// Inicia goroutine de processamento de resultados e descoberta de links
	go c.processResults(ctx)
}

// processResults processa resultados de rastreamento e descobre novos links
func (c *Crawler) processResults(ctx context.Context) {
	for result := range c.resultCh {
		if result.Err != nil {
			fmt.Printf("✗ Falhou: %s — %v\n", result.URL, result.Err)
			continue
		}

		fmt.Printf("✓ Sucesso: %s\n", result.URL)
		fmt.Printf("  Título: %s\n", result.Title)
		fmt.Printf("  Tamanho: %d bytes | Tempo: %v\n", result.BodyLength, result.Latency)

		// Se não atingiu profundidade máxima, pode continuar descobrindo links
		// (Simplificado aqui; em produção, você re-rastrearia a página para obter HTML para extração de links)
		if result.Depth < c.config.MaxDepth {
			// Demo: posta novos links na fila de tarefas
			// Em produção, você extrairia links do HTML do resultado aqui
			fmt.Printf("  Profundidade %d/%d, pode continuar descobrindo links filhos\n", result.Depth, c.config.MaxDepth)
		}
	}
}

// Wait espera todas as tarefas completarem
func (c *Crawler) Wait() {
	// Fecha channel de tarefas, notifica workers que não há mais tarefas
	close(c.taskCh)
	// Espera todos os workers saírem
	c.wg.Wait()
	// Fecha channel de resultados
	close(c.resultCh)
}

// Stats retorna estatísticas do crawler
func (c *Crawler) Stats() (visited int) {
	return c.dedup.Count()
}

// ============================================================
// Programa Principal
// ============================================================

func main() {
	fmt.Println("========================================")
	fmt.Println("  Go Web Crawler Concorrente v1.0")
	fmt.Println("========================================")
	fmt.Println()

	// Carrega configuração
	config := DefaultConfig()
	// Pode ser sobrescrito via argumentos de linha de comando ou arquivo de configuração
	// config.MaxConcurrency = 5

	// Cria um contexto cancelável
	ctx, cancel := context.WithCancel(context.Background())
	defer cancel()

	// Captura sinal de interrupção do sistema (Ctrl+C) para desligamento gracioso
	sigCh := make(chan os.Signal, 1)
	signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)

	go func() {
		sig := <-sigCh
		fmt.Printf("\n⚠ Sinal recebido: %v, encerrando graciosamente...\n", sig)
		cancel() // Cancela contexto, notifica todos os workers
	}()

	// Cria crawler
	crawler := NewCrawler(config)

	// Lista de URLs semente
	seedURLs := []string{
		"https://httpbin.org/html",
		"https://httpbin.org/links/5",
		"https://httpbin.org/range/100",
		"https://httpbin.org/delay/1",
	}

	fmt.Printf("Configuração: concorrência=%d, profundidade=%d, tentativas=%d\n",
		config.MaxConcurrency, config.MaxDepth, config.MaxRetries)
	fmt.Printf("URLs semente: %d\n", len(seedURLs))
	fmt.Println("----------------------------------------")

	// Inicia crawler
	crawler.Start(ctx, seedURLs)

	// Desligamento automático após um tempo (em produção, use outras condições)
	go func() {
		time.Sleep(30 * time.Second)
		fmt.Println("\n⏱ Timeout, acionando desligamento...")
		cancel()
	}()

	// Espera todas as tarefas completarem
	crawler.Wait()

	// Exibe estatísticas
	fmt.Println("----------------------------------------")
	fmt.Printf("Rastreamento concluído! Visitou %d URLs\n", crawler.Stats())
}
▶ Experimente

Análise do Código

1. Limitação de Taxa: Channel de Semáforo

GO
// Channel com buffer como semáforo
semaphore: make(chan struct{}, maxConcurrency)

// Adquire permissão: bloqueia quando channel está cheio
rl.semaphore <- struct{}{}

// Libera permissão
<-rl.semaphore

Um channel com tamanho de buffer N permite que no máximo N goroutines mantenham permissões simultaneamente; a goroutine N+1 será bloqueada. Isso controla a concorrência com mais precisão que time.Ticker.

2. Deduplicação: Mapa Protegido por Mutex

GO
func (d *Deduplicator) Mark(url string) bool {
    d.mu.Lock()
    defer d.mu.Unlock()
    if d.visited[url] {
        return false
    }
    d.visited[url] = true
    return true
}

Múltiplas goroutines podem descobrir a mesma URL simultaneamente; o método Mark usa um mutex para garantir atomicidade — apenas uma goroutine pode "reivindicar" aquela URL.

3. Tentativa de Erro: Backoff Exponencial

GO
backoff := time.Duration(math.Pow(2, float64(attempt-1))) * time.Second
Tentativa # Tempo de Espera
1 segundo
2 segundos
4 segundos

Backoff exponencial previne tentativas em "estilo avalanche" durante falhas do servidor, dando tempo ao servidor para se recuperar.

4. Desligamento Gracioso: Context + Sinal

GO
ctx, cancel := context.WithCancel(context.Background())

// Escuta sinais do sistema
go func() {
    <-sigCh
    cancel() // Cancela contexto
}()

// Worker verifica contexto
select {
case <-ctx.Done():
    return // Sai
default:
    // Continua trabalhando
}

cancel() notifica todas as goroutines escutando ctx.Done(), alcançando desligamento coordenado.

5. Fluxo Geral de Colaboração

main()
  │
  ├─ Cria contexto (cancelável)
  ├─ Inicia N goroutines Worker
  ├─ Submete URLs semente ao taskCh
  ├─ Inicia goroutine processResults
  │
  ├─ Espera...
  │   ├─ Worker pega tarefas do taskCh
  │   ├─ Worker limita taxa → rastreia → tenta novamente
  │   ├─ Worker envia resultados ao resultCh
  │   └─ processResults processa resultados, descobre novos links
  │
  └─ cancel() dispara → Todos os workers saem → Programa encerra

❓ Perguntas Frequentes

P1: Por que usar um channel em vez de sync.Mutex para controle de concorrência?

Channels não apenas fornecem exclusão mútua mas também comunicação. Um channel de semáforo combina naturalmente "limitação de concorrência" e "passagem de tarefas" — quando o channel está cheio, novas goroutines esperam automaticamente; quando uma goroutine libera, as que estão esperando são automaticamente acordadas. Isso é mais conciso do que usar Mutex + WaitGroup separadamente. Além disso, channels suportam select, facilitando combinar com context.Done() para bloqueio cancelável.

P2: Por que deduplicar no momento da submissão da tarefa em vez de antes do rastreamento?

Na verdade, ambos são necessários. Deduplicar na submissão da tarefa previne que tarefas duplicadas sejam inseridas no channel, economizando memória e tempo de processamento. Porém, se múltiplas goroutines descobrirem o mesmo novo link quase simultaneamente, a deduplicação apenas na submissão ainda pode ter "corridas" — então o método Mark deve ser thread-safe. Em crawlers de grande escala, um Bloom Filter é tipicamente usado para lidar eficientemente com deduplicação massiva de URLs.

P3: Por que não simplesmente matar o programa? Por que "desligamento gracioso"?

Matar forçadamente pode causar: arquivos corrompidos sendo escritos, conexões de banco de dados não liberadas, requisições incompletas alcançando o servidor alvo. Desligamento gracioso permite que cada worker complete sua tarefa atual antes de sair, garantindo consistência de dados. context.WithCancel é o padrão do Go para implementar desligamento gracioso.

P4: Este crawler pode lidar com páginas renderizadas por JavaScript?

Não. net/http apenas busca HTML bruto sem executar JavaScript. Se você precisa rastrear SPAs (Aplicações de Página Única), precisará integrar um navegador headless como Chromedp ou Rod. A arquitetura neste tutorial é extensível — basta substituir a implementação do Fetcher.


📖 Resumo

Esta seção aplicou abrangentemente múltiplos conceitos centrais de concorrência do Go através de um projeto completo de web crawler:

Conceito Aplicação Código-chave
goroutine Múltiplos workers trabalhando em paralelo go c.Worker(ctx, i)
channel Fila de tarefas, passagem de resultados, semáforo taskCh, resultCh, semaphore
sync.Mutex Protege segurança de concorrência do mapa de deduplicação d.mu.Lock()
context Propagação de cancelamento, desligamento gracioso ctx.WithCancel
select Multiplexação, controle de timeout select { case <-ctx.Done() ... }
sync.WaitGroup Espera todos os workers completarem c.wg.Wait()

Estes componentes trabalham juntos para formar um sistema concorrente robusto. Dominar este padrão de combinação "channel + context + WaitGroup" é a base para escrever programas Go concorrentes de nível de produção.


📝 Exercícios

Atualmente, processResults apenas imprime informações de profundidade sem realmente extrair links filhos. Modifique o código para extrair links do HTML após um rastreamento bem-sucedido e postar novas URLs no taskCh.

Dica: Você precisará modificar o método Fetch para também retornar o conteúdo HTML, depois chamar ExtractLinks em processResults.

Exercício 2: Implementar Persistência de Resultados

Adicione uma struct Saver que escreve resultados de rastreamento em um arquivo em formato JSON. Requisitos:

Exercício 3: Implementar Limitação de Taxa por Domínio

A limitação de taxa atual é global. Implemente um limitador de taxa agrupado por domínio que:


Próxima Lição

Após completar este exercício prático, continue com Lição 19: Processamento de Strings para dominar os internos de strings do Go, operações comuns e técnicas de otimização de desempenho.

Web-Tutorial.com

Equipe Técnica Web-Tutorial

Uma plataforma de tutoriais mantida por diversos desenvolvedores. Cada tutorial é escrito e revisado por profissionais da área correspondente. Trabalhamos para manter nosso conteúdo preciso e confiável — se encontrar algum problema, avise-nos.

100%