[Small Bites] Brincando de Crawlers e algumas Dicas Úteis

2015 May 15, 09:10 h - tags: learning ruby

Eu queria fazer uma limpeza geral na minha conta do Slack (que já tinha mais do que o suficiente de imagens-memes entupindo o limite de armazenamento), o problema é que ele não tem um "marcar tudo" e "deletar tudo" na interface de administração, mas felizmente ele tem APIs!

Então eu escrevi este pequeno script em Ruby que tem 4 dicas que vale a pena compartilhar:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
require 'rubygems'
require 'bundler/setup'
Bundler.require(:default)
require 'typhoeus/adapters/faraday'

# documentations:
# https://api.slack.com/methods/files.list
# https://api.slack.com/methods/files.delete

api_token = ENV['SLACK_API_TOKEN'] or abort "no api token"
per_page = 500
uri = "/api/files.list?token=%s&ts_from=0&ts_to=now&types=all&count=%s&page=%s"

conn = Faraday.new(:url => 'https://slack.com/') do |faraday|
  faraday.request  :url_encoded             # form-encode POST params
  faraday.response :logger                  # log requests to STDOUT
  faraday.adapter  :typhoeus
end

response = conn.get( uri % [api_token, per_page, 1] )
json = Oj.load(response.body)
total_pages = json["paging"]["pages"].to_i

responses = []
conn.in_parallel do
  (1..total_pages).each do |page|
    responses << conn.get( uri % [api_token, per_page, page] )
  end
end

files_ids = []
errors = []
Parallel.each(responses, in_threads: 10) do |response|
  begin
    json = Oj.load(response.body)
    Parallel.each(json["files"], in_threads: 10) do |file|
      files_ids << file["id"]
    end
  rescue => e
    errors << e
  end
end

uri_delete = "https://slack.com/api/files.delete?token=%s&file=%s"
conn.in_parallel do
  files_ids.each do |file_id|
    responses << conn.get( uri_delete % [api_token, file_id] )
  end
end

puts responses.map do |response|
  Oj.load(response.body)["ok"]
end

Dica 1

Mesmo em pequenos scripts, use Bundler!

Dica 2

Para acessar páginas Web, use a gem "Faraday" junto com a gem "Typhoeus".

Por que Faraday?

Porque ela abstrai diversos clientes HTTP. Você pode mudar entre Net::HTTP ou Excon ou Typhoeus com mínimo esforço e pode ser útil para diferenciar cenários de produção com um adapter e testes com outro adapter, por exemplo.

Por que Typhoeus?

Porque ela é capaz de realizar diversas requisições em paralelo e o que mais queremos num crawler é paralelizar o máximo possível (vide linhas 25..29 e 45..48 para exemplos). O único cuidado é não puxar coisa demais e estourar o acumulador de retornos (no exemplo, o array responses).

Para evitar estourar memória em vez de acumular internamente num array como no exemplo, já use a gem "Dalli" e vá acumulando num Memcached ou acumule num Redis ou MongoDB ou qualquer outro sistema de armazenamento que suporte inserções assíncronas (não bloqueantes, como um RDBMS normal).

Dica 3

Se estiver recebendo respostas JSON, use um parser rápido como a gem "Oj". Em conjunto com a gem "oj_mimic_json" - que você deve colocar junto na Gemfile se for um projeto Rails - ela substitui a padrão gem JSON que o ActiveSupport usa. A Oj é de 2 a 4 vezes mais rápido do que a gem JSON por isso vale a pena usá-la. Claro, ela é rápida assim porque possui uma extension feita em C, ou seja não funciona com JRuby, apenas com MRI.

Dica 4

Uma vez que recebemos dezenas de respostas, queremos processá-las o mais rápido possível e fazer um mesmo responses.each { |response| ... } é lento porque é linear.

Para resolver isso podemos recorrer à gem "Parallel" (vide exemplos nas linhas 33..42). Ela vai iterar pela coleção paralelamente usando threads.

Em Ruby MRI, lembrar que temos o Global Interpreter Lock (GIL) que bloqueia a execução paralela porque a mudança de estados em código C sem mutexes e outras proteções poderia corromper a execução. Então não precisamos nos preocupar com estado global compartilhado sendo alterado em paralelo por múltiplas threads mas por outro lado não temos multithreading real o tempo todo.

Mas temos como usar threads em operações bloqueantes, I/O bound, ou que tem muita espera (operações com arquivos ou rede). Nesse caso, quando uma thread bloquear esperando a resposta de algum I/O, outra thread pode de fato executar em paralelo. E daí o valor de usar threads no caso de crawlers ou scripts que façam import/export em banco de dados, etc.

Para o caso de I/O bound, usar threads. Mas se o caso for algo CPU-bound, de processamento de dados, cálculos, mais pesados, threads não vão ajudar. Para isso em vez da opção :in_threads você usa a opção :in_processes que vai fazer um fork() do seu processo e vai rodar realmente em paralelo em processos independentes e isolados, sem estado compartilhado.

Como desde o Ruby 2.0 ele suporta "Copy on Write" (ou "COW") de tal forma que mesmo duplicando um processo Ruby, ele não vai duplicar a memória, eles vão compartilhar a memória que não muda e cada processo isola somente a memória que ele mudar. Isso torna rodar processos em paralelo não tão caros em termos de consumo de memória e muito mais fáceis de escrever (sem precisar se preocupar com mutexes e semáforos, já que tudo rodará garantidamente isolado).

Ou seja, para casos justamente como um crawler ou um ETL, a gem "Parallel" pode ser uma mão na roda. Mas sempre meça para saber se as coisas estão realmente paralelas ou se você não está bloqueando suas threads e caindo no caso de execução linear sem saber.

Comments

comentários deste blog disponibilizados por Disqus