Paulo Henrique Rodrigues Pinheiro

Um blog sobre programação para programadores!


Rust Pills — Download de arquivos

Mais um crawler está surgindo na praça. Decidi fazer em Rust, só pra exercitar. Por agora a tarefa aqui é simples: dada um lista de URLs, baixa-se o conteúdo de todas e salva-se localmente. Bora construir isso.

Rust Pills

A Tarefa

Recapitulando, dada uma lista de URLs, faz-se o download dos arquivos e os salva localmente em disco. Opa, mas não um por um, isso será feito usando concorrência.

Há alguns passos a seguir até o objetivo final, que serão seguidos aqui, com muita refatoração e incrementos.

A biblioteca

Será usada uma biblioteca pronta para o grande problema aqui: fazer o download de um arquivo via HTTP. A escolhida é a biblioteca hyper, que possui rotinas tanto para cliente como para ervidor. Existem outras opções, mas essa é a biblioteca padrão de fato.

A Estrutura

Em primeiro lugar, deve-se criar o projeto:


$ cargo new crawler --bin
     Created binary (application) `crawler` project

A estrutura criada:


$ tree crawler/
crawler/
├── Cargo.toml
└── src
 └── main.rs

1 directory, 2 files

O primeiro esboço do programa deve aceitar uma lista de URLs e trabalhar sobre ela:


//! Crawler — My own crawler in Rust!

use std::env; // argumentos env::args

fn main() {
    // Pega os argumentos, mas ignorando o primeiro
    // que é o nome do programa.
    let mut args = env::args();
    args.next();

    // imprime os argumetos
    for url in args {
        println!(“{}”, url);
    }
}

Salve como crawler/src/main.rs.

Apenas como lembrete, nesse ponto ainda temos um programa simples, sem dependências, portanto pode-se compilá-lo diretamente com o comando rustc. Será gerado um arquivo binário executável com o mesmo nome do arquivo fonte, sem o sufixo.

Compilando e executando:


$ cargo build
 Compiling crawler v0.1.0 (file:///home/paulohrpinheiro/Dropbox/projetos/crawler)
 Finished debug [unoptimized + debuginfo] target(s) in 0.46 secs
$ target/debug/crawler primeiro segundo terceiro quarto
primeiro
segundo
terceiro
quarto

Agora o objetivo é pegar cada argumento que o programa receber e tentar fazer o download do conteúdo. Nova nova versão:


//! Crawler — My own crawler in Rust!

extern crate hyper; // biblioteca (crate) não padrão

use std::env; // argumentos env::args

fn main() {
    // Pega os argumentos, mas ignorando o primeiro
    // que é o nome do programa.
    let mut args = env::args();
    args.next();

    // Pega o conteúdo de cada URL
    for url in args {
        println!(“{}”, url);

        // “pega” a URL
        let client = hyper::Client::new();
        match client.get(&url).send() {
            Ok(content) => println!(“{:?}”, content),
            Err(error) => println!(“{:?}”, error),
        }

        println!(“”);
     }
}

Deve-se informar o cargo a nova dependência (hyper), e a versão desejada. Basta editar o arquivo Cargo.toml e adicionar a entrada em dependencies:


[package]
name = "crawler"
version = "0.1.0"
authors = ["Paulo H. R. Pinheiro <[email protected]>"]

[dependencies]
hyper = "0.9"

Compilando, vê-se que o cargo resolve as dependências, baixa e compila o que for necessário. Eu tive um problema com o openssl, pois não tinha o pacote openssl-devel (uso Fedora) instalado:


$ cargo build
   Compiling bitflags v0.7.0
   Compiling gcc v0.3.38
   Compiling lazy_static v0.2.2
   Compiling semver v0.1.20
   Compiling winapi v0.2.8
   Compiling unicode-normalization v0.1.2
   Compiling typeable v0.1.2
   Compiling language-tags v0.2.2
   Compiling winapi-build v0.1.1
   Compiling rustc-serialize v0.3.21
   Compiling httparse v1.2.0
   Compiling openssl v0.7.14
   Compiling pkg-config v0.3.8
   Compiling rustc_version v0.1.7
   Compiling traitobject v0.0.1
   Compiling openssl-sys-extras v0.7.14
   Compiling kernel32-sys v0.2.2
   Compiling unicase v1.4.0
   Compiling matches v0.1.4
   Compiling unicode-bidi v0.2.3
   Compiling libc v0.2.17
   Compiling openssl-sys v0.7.17
Build failed, waiting for other jobs to finish...
error: failed to run custom build command for `openssl v0.7.14`
process didn't exit successfully: `/home/paulohrpinheiro/Dropbox/projetos/crawler/target/debug/build/openssl-5464f8f6e728c35a/build-script-build` (exit code: 101)
--- stdout
TARGET = Some("x86_64-unknown-linux-gnu")
OPT_LEVEL = Some("0")
PROFILE = Some("debug")
TARGET = Some("x86_64-unknown-linux-gnu")
debug=true opt-level=0
HOST = Some("x86_64-unknown-linux-gnu")
TARGET = Some("x86_64-unknown-linux-gnu")
TARGET = Some("x86_64-unknown-linux-gnu")
HOST = Some("x86_64-unknown-linux-gnu")
CC_x86_64-unknown-linux-gnu = None
CC_x86_64_unknown_linux_gnu = None
HOST_CC = None
CC = None
HOST = Some("x86_64-unknown-linux-gnu")
TARGET = Some("x86_64-unknown-linux-gnu")
HOST = Some("x86_64-unknown-linux-gnu")
CFLAGS_x86_64-unknown-linux-gnu = None
CFLAGS_x86_64_unknown_linux_gnu = None
HOST_CFLAGS = None
CFLAGS = None
running: "cc" "-O0" "-ffunction-sections" "-fdata-sections" "-g" "-m64" "-fPIC" "-o" "/home/paulohrpinheiro/Dropbox/projetos/crawler/target/debug/build/openssl-5464f8f6e728c35a/out/src/c_helpers.o" "-c" "src/c_helpers.c"
cargo:warning=src/c_helpers.c:1:25: fatal error: openssl/ssl.h: No such file or directory
cargo:warning= #include <openssl/ssl.h>
cargo:warning=                         ^
cargo:warning=compilation terminated.
ExitStatus(ExitStatus(256))

command did not execute successfully, got: exit code: 1

--- stderr
thread 'main' panicked at 'explicit panic', /home/paulohrpinheiro/.cargo/registry/src/github.com-1ecc6299db9ec823/gcc-0.3.38/src/lib.rs:958
note: Run with `RUST_BACKTRACE=1` for a backtrace.

Instalado o openssl-devel (em meu caso sudo dnf install openssl-devel), voltemos à compilação:


$ cargo build
   Compiling idna v0.1.0
   Compiling log v0.3.6
   Compiling num_cpus v1.1.0
   Compiling time v0.1.35
   Compiling mime v0.2.2
   Compiling hpack v0.2.0
   Compiling openssl v0.7.14
   Compiling openssl-sys-extras v0.7.14
   Compiling openssl-sys v0.7.17
   Compiling url v1.2.3
   Compiling solicit v0.4.4
   Compiling cookie v0.2.5
   Compiling openssl-verify v0.1.0
   Compiling hyper v0.9.12
   Compiling crawler v0.1.0 (file:///home/paulohrpinheiro/Dropbox/projetos/crawler)
    Finished debug [unoptimized + debuginfo] target(s) in 39.63 secs

Executando o programa com algumas URLs:


$ target/debug/crawler http://nonexist.nemaquinemnachina https://paulohrpinheiro.github.io https://api.github.com/zen
http://nonexist.nemaquinemnachina
Io(Error { repr: Custom(Custom { kind: Other, error: StringError("failed to lookup address information: Name or service not known") }) })

https://paulohrpinheiro.github.io
Response { status: Ok, headers: Headers { X-GitHub-Request-Id: 17EB2719:767D:69B12B1:58306ED8, Date: Sat, 19 Nov 2016 15:52:29 GMT, Connection: keep-alive, Content-Length: 2662, Vary: Accept-Encoding, Last-Modified: Thu, 17 Nov 2016 01:02:36 GMT, X-Cache-Hits: 1, Via: 1.1 varnish, Server: GitHub.com, X-Served-By: cache-atl6221-ATL, Access-Control-Allow-Origin: *, Expires: Sat, 19 Nov 2016 15:35:13 GMT, X-Fastly-Request-ID: 9fe5d7c6fe7ee4d17b01c1a30db131df80af6efa, Content-Type: text/html; charset=utf-8, Cache-Control: max-age=600, X-Timer: S1479570749.034835,VS0,VE0, X-Cache: HIT, Age: 292, Accept-Ranges: bytes, }, version: Http11, url: "https://paulohrpinheiro.github.io/", status_raw: RawStatus(200, "OK"), message: Http11Message { is_proxied: false, method: None, stream: Wrapper { obj: Some(Reading(SizedReader(remaining=2662))) } } }

https://api.github.com/zen
Response { status: Forbidden, headers: Headers { Connection: close, Cache-Control: no-cache, Content-Type: text/html, }, version: Http10, url: "https://api.github.com/zen", status_raw: RawStatus(403, "Forbidden"), message: Http11Message { is_proxied: false, method: None, stream: Wrapper { obj: Some(Reading(EofReader)) } } }

O endereço inexistente, tudo bem, o segundo endereço tá legal, mas tem algum erro no terceiro, mas ao menos de certa forma funcionou, porque esse erro foi gerado ao conectar-se, e o pior, no curl funciona:


$ curl https://api.github.com/zen
Mind your words, they are important.

Lendo a documentação da API do github, encontra-se o seguinte:

All API requests MUST include a valid User-Agent header. Requests with no User-Agent header will be rejected. We request that you use your GitHub username, or the name of your application, for the User-Agent header value. This allows us to contact you if there are problems.

Próximo passo é arrumar isso, incluindo uma entrada de User-Agent em nossa requisição HTTP, o que faremos com Headers:set()`:


//! Crawler — My own crawler in Rust!

extern crate hyper; // biblioteca (crate) não padrão.

use std::env;       // argumentos env::args.

fn main() {
    // Pega os argumentos, mas ignorando o primeiro
    // que é o nome do programa.
    let mut args = env::args();
    args.next();

    // Pega o conteúdo de cada URL
    for url in args {
        println!("{}", url);

        // Somos um respeitável e conhecido bot
        let mut headers = hyper::header::Headers::new();
        headers.set(
            hyper::header::UserAgent(
                "paulohrpinheiro-rustbot".to_string()
            )
        );

        // “pega” a URL
        let client = hyper::Client::new();
        match client.get(&url).headers(headers).send() {
            Ok(content) => println!("{:?}", content),
            Err(error) => println!("{:?}", error),
        }

        println!("");
    }
}

Compilando e executando:


$ target/debug/crawler http://nonexist.nemaquinemnachina http://www.pudim.com.br https://api.github.com/zen
http://nonexist.nemaquinemnachina
Io(Error { repr: Custom(Custom { kind: Other, error: StringError("failed to lookup address information: Name or service not known") }) })

http://www.pudim.com.br
Response { status: Ok, headers: Headers { Server: Apache/2.4.16 (Amazon) PHP/5.5.30, Content-Type: text/html; charset=UTF-8, Date: Sat, 19 Nov 2016 20:18:31 GMT, Content-Length: 851, Last-Modified: Wed, 23 Dec 2015 01:18:20 GMT, Accept-Ranges: bytes, ETag: "353-527867f65e8ad", }, version: Http11, url: "http://www.pudim.com.br/", status_raw: RawStatus(200, "OK"), message: Http11Message { is_proxied: false, method: None, stream: Wrapper { obj: Some(Reading(SizedReader(remaining=851))) } } }

https://api.github.com/zen
Response { status: Ok, headers: Headers { X-RateLimit-Reset: 1479589054, X-Served-By: 13d09b732ebe76f892093130dc088652, X-RateLimit-Remaining: 56, X-Frame-Options: deny, X-Content-Type-Options: nosniff, Server: GitHub.com, Vary: Accept-Encoding, Strict-Transport-Security: max-age=31536000; includeSubdomains; preload, Content-Type: text/plain;charset=utf-8, X-GitHub-Request-Id: BB6D95DD:31E3:17041A1E:5830B397, Date: Sat, 19 Nov 2016 20:18:32 GMT, Access-Control-Allow-Origin: *, Content-Security-Policy: default-src 'none', Status: 200 OK, X-RateLimit-Limit: 60, Access-Control-Expose-Headers: ETag, Link, X-GitHub-OTP, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset, X-OAuth-Scopes, X-Accepted-OAuth-Scopes, X-Poll-Interval, Content-Length: 26, X-XSS-Protection: 1; mode=block, }, version: Http11, url: "https://api.github.com/zen", status_raw: RawStatus(200, "OK"), message: Http11Message { is_proxied: false, method: None, stream: Wrapper { obj: Some(Reading(SizedReader(remaining=26))) } } }

Tudo em riba funcionando, agora o objetico éimprimir o conteúdo do que foi pego, usando Request::read_to_string, e melhorando um pouco a saída:


//! Crawler — My own crawler in Rust!

extern crate hyper; // biblioteca (crate) não padrão

use std::env;       // argumentos env::args
use std::io::Read;  // para usar read_to_string

fn get_url_content(url: &str) -> Result<String, String> {
    let mut content = String::new();

    // Somos um respeitável e conhecido bot
    let mut headers = hyper::header::Headers::new();
    headers.set(
        hyper::header::UserAgent(
            "paulohrpinheiro-rustbot".to_string()
        )
    );

    // Pega conteúdo
    let client = hyper::Client::new();
    match client.get(url).headers(headers).send() {
        Err(error) => Err(format!("{:?}", error)),
        Ok(mut response) => {
            match response.read_to_string(&mut content) {
                Ok(_) => Ok(content),
                Err(error) => Err(format!("{:?}", error)),
            }
        },
    }
}

fn main() {
    // Pega os argumentos, mas ignorando o primeiro
    // que é o nome do programa.
    let mut args = env::args();
    args.next();

    // Pega o conteúdo de cada URL
    for url in args {
        println!("{}", url);

        match get_url_content(&url) {
            Err(error)  => println!("{}", error),
            Ok(content) => println!("{}", content),
        }

        println!("\n\n");
     }
}

Eis a saída agora:


$ target/debug/crawler http://nonexist.nemaquinemnachina http://www.pudim.com.br https://api.github.com/zen
http://nonexist.nemaquinemnachina
Io(Error { repr: Custom(Custom { kind: Other, error: StringError("failed to lookup address information: Name or service not known") }) })

http://www.pudim.com.br
<html>
<html xmlns="http://www.w3.org/1999/xhtml">
<head>
    <title>Pudim</title>
    <link rel="stylesheet" href="estilo.css">
</head>
<body>
<div>
    <div class="container">
        <div class="image">
            <img src="pudim.jpg" alt="">
        </div>
        <div class="email">
            <a href="[email protected]">[email protected]</a>
        </div>
    </div>
</div>
<script>
    (function(i,s,o,g,r,a,m){i['GoogleAnalyticsObject']=r;i[r]=i[r]||function(){
                (i[r].q=i[r].q||[]).push(arguments)},i[r].l=1*new Date();a=s.createElement(o),
            m=s.getElementsByTagName(o)[0];a.async=1;a.src=g;m.parentNode.insertBefore(a,m)
    })(window,document,'script','//www.google-analytics.com/analytics.js','ga');

ga('create', 'UA-28861757-1', 'auto');
    ga('send', 'pageview');

</script>
</body>
</html>

https://api.github.com/zen
Favor focus over features.

Legal, mas falta a concorrência… O plano é o seguinte: primeiro será implementada a gravação do conteúdo em arquivos locais, depois implementa-se a concorência, usando threads.

Mas agora, a tarefa é salvar os arquivos. Na versão atual, o arquivo é mantido inteiro em memória. O nome do arquivo será a última parte do endereço. Se a última parte for o nome do site, esse será o nome do arquivo. E a função que pega os arquivos, portanto, não retornará o conteúdo, apenas um status, deixando em caso de sucesso, o arquivo salvo. Ah, em caso de sucesso retorna o nome do arquivo criado. Ficou assim:


//! Crawler — My own crawler in Rust!

extern crate hyper;         // biblioteca (crate) não padrão

use std::env;               // argumentos env::args
use std::io::{Read, Write}; // para IO de arquivos
use std::fs::File;          // para criar arquivos
use std::path::Path;        // configurar nome de arquivo

const ROBOT_NAME:  &'static str  = "paulohrpinheiro-crawler";
const BUFFER_SIZE: usize = 512;

fn download_content(url: &str) -> Result<String, String> {
    // Somos um respeitável e conhecido bot
    let mut headers = hyper::header::Headers::new();
    headers.set(hyper::header::UserAgent(ROBOT_NAME.to_string()));

    // Pega cabeçalhos (e possivelmente algum dado já)
    let client = hyper::Client::new();
    let mut response;

    match client.get(url).headers(headers).send() {
        Err(error) => return Err(format!("{:?}", error)),
        Ok(res)    => response = res,
    }

    // Cria arquivo para salvar conteúdo
    let filename = Path::new(&url).file_name().unwrap();
    let mut localfile;

    match File::create(filename) {
        Err(error)     => return Err(format!("{:?}", error)),
        Ok(filehandle) => localfile = filehandle,
    }

    // pega conteúdo e salva em arquivo
    loop {
        let mut buffer = [0; BUFFER_SIZE];

        match response.read(&mut buffer) {
            Err(read_error) => return Err(format!("{:?}", read_error)),
            Ok(bytes_read)  => {
                if bytes_read == 0 {
                    // não tem mais o que ler
                    break;
                }
                // vamos tentar escrever o que pegamos
                match localfile.write(&buffer[0..bytes_read]) {
                    Err(write_error) => return Err(format!("{:?}", write_error)),
                    Ok(bytes_write)  => {
                        if bytes_write != bytes_read {
                            return Err("Error in write.".to_string());
                        }
                    },
                }
            },
        }
    }

    return Ok(String::from(filename.to_str().unwrap()));
}

fn main() {
    // Pega os argumentos, mas ignorando o primeiro
    // que é o nome do programa.
    let mut args = env::args();
    args.next();

    // Pega o conteúdo de cada URL
    for url in args {
        print!("{} - ", url);

        match download_content(&url) {
            Err(error)   => println!("{:?}", error),
            Ok(filename) => println!("{:?}", filename),
        }

        print!("\n\n");
    }
}

Executando e vendo os arquivos gerados:


$ target/debug/crawler http://nonexist.nemaquinemnachina http://www.pudim.com.br https://api.github.com/zen
http://nonexist.nemaquinemnachina - "Io(Error { repr: Custom(Custom { kind: Other, error: StringError(\"failed to lookup address information: Name or service not known\") }) })"

http://www.pudim.com.br - "www.pudim.com.br"

https://api.github.com/zen - "zen"

$ cat zen
Design for failure.$
$
$ cat www.pudim.com.br
<html>
<html xmlns="http://www.w3.org/1999/xhtml">
<head>
    <title>Pudim</title>
    <link rel="stylesheet" href="estilo.css">
</head>
<body>
<div>
    <div class="container">
        <div class="image">
            <img src="pudim.jpg" alt="">
        </div>
        <div class="email">
            <a href="[email protected]">[email protected]</a>
        </div>
    </div>
</div>
<script>
    (function(i,s,o,g,r,a,m){i['GoogleAnalyticsObject']=r;i[r]=i[r]||function(){
                (i[r].q=i[r].q||[]).push(arguments)},i[r].l=1*new Date();a=s.createElement(o),
            m=s.getElementsByTagName(o)[0];a.async=1;a.src=g;m.parentNode.insertBefore(a,m)
    })(window,document,'script','//www.google-analytics.com/analytics.js','ga');

ga('create', 'UA-28861757-1', 'auto');
    ga('send', 'pageview');

</script>
</body>
$

Agora ficou legal, mas falta a cereja do bolo: concorrência. Muito simples, como está explicado nessa página:

Concurrency Rust's memory safety features also apply to its concurrency story too. Even concurrent Rust programs must be memory… https://doc.rust-lang.org/book/concurrency.html

As alterações foram mínimas.

Primeiro, declara-se um vetor para salvar os handlers, criamos as threads, e finalmente esperamos cada thread terminar:


let mut workers = vec![];

(...)

workers.push(thread::spawn(move || {
    (...)
}));

for worker in workers {
    let _ = worker.join();
}

O clojure move serve para garantir a propriedade dos ponteiros envolvidos no bloco de código.

Closures But we don't have to. Why is this? Basically, it was chosen for ergonomic reasons. While specifying the full type for… https://doc.rust-lang.org/book/closures.html#move-closures

Finalmente, nosso programa:


//! Crawler — My own crawler in Rust!

extern crate hyper;         // biblioteca (crate) não padrão

use std::env;               // argumentos env::args
use std::io::{Read, Write}; // para IO de arquivos
use std::fs::File;          // para criar arquivos
use std::path::Path;        // configurar nome de arquivo
use std::thread;            // concorrência

const ROBOT_NAME:  &'static str  = "paulohrpinheiro-crawler";
const BUFFER_SIZE: usize = 512;

fn download_content(url: &str) -> Result<String, String> {
    // Somos um respeitável e conhecido bot
    let mut headers = hyper::header::Headers::new();
    headers.set(hyper::header::UserAgent(ROBOT_NAME.to_string()));

    // Pega cabeçalhos (e possivelmente algum dado já)
    let client = hyper::Client::new();
    let mut response;

    match client.get(url).headers(headers).send() {
        Err(error) => return Err(format!("{:?}", error)),
        Ok(res)    => response = res,
    }

    // Cria arquivo para salvar conteúdo
    let filename = Path::new(&url).file_name().unwrap();
    let mut localfile;

    match File::create(filename) {
        Err(error)     => return Err(format!("{:?}", error)),
        Ok(filehandle) => localfile = filehandle,
    }

// pega conteúdo e salva em arquivo
    loop {
        let mut buffer = [0; BUFFER_SIZE];

        match response.read(&mut buffer) {
            Err(read_error) => return Err(format!("{:?}", read_error)),
            Ok(bytes_read)  => {
                if bytes_read == 0 {
                    // não tem mais o que ler
                    break;
                }
                // vamos tentar escrever o que pegamos
                match localfile.write(&buffer[0..bytes_read]) {
                    Err(write_error) => return Err(format!("{:?}", write_error)),
                    Ok(bytes_write)  => {
                        if bytes_write != bytes_read {
                            return Err("Error in write.".to_string());
                        }
                    },
                }
            },
        }
    }

    return Ok(String::from(filename.to_str().unwrap()));
}

fn main() {
    // Pega os argumentos, mas ignorando o primeiro
    // que é o nome do programa.
    let mut args = env::args();
    args.next();

    // vetor para as threads que serão criadas
    let mut workers = vec![];

    // Pega o conteúdo de cada URL
    for url in args {
        workers.push(thread::spawn(move || {
            print!("{} - ", url);

            match download_content(&url) {
                Err(error)   => println!("{:?}", error),
                Ok(filename) => println!("{:?}", filename),
            }

        print!("\n\n");
        }));

}

    // espera cada thread acabar
    for worker in workers {
        let _ = worker.join();
    }
}

Executando:


$ target/debug/crawler http://nonexist.nemaquinemnachina https://fastdl.mongodb.org/src/mongodb-src-r3.2.11.tar.gz http://ftp.unicamp.br/pub/apache//httpd/httpd-2.4.23.tar.gz https://www.isc.org/downloads/file/bind-9-10-4-p4/
https://fastdl.mongodb.org/src/mongodb-src-r3.2.11.tar.gz - http://ftp.unicamp.br/pub/apache//httpd/httpd-2.4.23.tar.gz - http://nonexist.nemaquinemnachina - https://www.isc.org/downloads/file/bind-9-10-4-p4/ - "Io(Error { repr: Custom(Custom { kind: Other, error: StringError(\"failed to lookup address information: Name or service not known\") }) })"

"httpd-2.4.23.tar.gz"

"bind-9-10-4-p4"

"mongodb-src-r3.2.11.tar.gz"

Para acompanhar o download, abra outro terminal e execute esse comando:


watch ls -lh

Como perceberá, os downloads estão ocorrendo em paralelo.