Sockets: Criando um httpd simples (Parte 6)

Pretendo que essa seja a última parte porque não vou implementar o httpd completamente. O objetivo aqui é apenas mostrar como fazê-lo.

Uma vez que a requisição tenha sido lida, juntamente com o header completo do protocolo HTTP, temos no buffer object da requisição as linhas, separadas por “\r\n” contendo o método (GET, PUT, POST, …), o path e os atributos. A questão agora é separar as linhas com a função strtok_r() — lempre-se que temos uma conexão por thread e strtok() não é reentrante. A primeira linha é especial: Ela contém o método e o path (bem como a versão “HTTP/1.1”). O formato exato deve ser verificado, de acordo como mostrei na primeira parte. Daí, separamos isso e codificamos os campos da requisição de acordo… E, ainda usando strtok_r(), obteremos as demais linhas, em loop…

Cada linha que segue a primeira contém informações no formato “Attributo: valor”. De novo, outra chamada a strtok_r() é feita, mas com o ponteiro de controle diferente, separando o primeiro item do segundo via comparação com a string “: “. De posse disso, chamamos tratadores especializados para cada um atributo — através de comparações de strings feitas com strcmp() ou, melhor ainda, strcasecmp(). Esses tratadores vão pegar a string contida depois do separador “: “ e convertê-la nos formatos exigidos pelo atributo. Por exemplo, o atributo “Content-Length” aceita apenas um valor numérico e precisaremos convertê-lo para um valor do tipo “unsigned long long” (ou “unsigned int” se você só vai permitir o tratamento até 4 GiB ou menos de payloads) e verificarmos se ele é um valor numérico, decimal, válido…

Atributos como “Cookies” aceitam uma grande string contendo “chave=atributo” separadas por “&” e têm que ser decodificadas de acordo (espaços são “+” e caracteres especiais são codificados em hexadecimal “%NN”)… Outros atributos podem aceitar formatos diferentes: “Connection”, por exemplo, aceita strings contendo “keep-alive” e “close”“Content-Type” a ceita uma string com o tipo de conteúdo seguindo a especificação MIME, mantida pela IANA (“text/plain”, “text/html”, “application/json”…). Cada atributo tem seu jeitinho…

Depois que todas as linhas forem decodificadas pela função process_request() e os respectivos valores armazenados na requisição, contida na sessão, foram copiados e validados, podemos chamar a função session_handler(), ambas brevemente descritas no exemplo da parte anterior dessa série de artigos. Ela vai pegar o path da requisição e compará-lo contra uma tabela que realizará saltos para funções específicas… O path “/”, por exemplo, poderá montar uma resposta padrão para a aplicação (a “homepage”). O path “/sendfile” poderá atualizar e ler o payload da requisição (lembre-se que, se tivermos algum payload, a leitura da requisição obterá parte dele!), gravando o stream de dados num arquivo… O path “/getfile” poderá fazer o contrário, ler blocos de 8 KiB de um arquivo e enviá-los na resposta… e por ai vai… É claro que podemos fazer algum filtro: O path “/something”, por exemplo, poderá chamar um script em bash e retornar para o requisitante, no payload da resposta, o que o script colocou em stdout… O importante é que seus handlers não usem funções não reentrantes, já que eles serão executados num ambiente multi threaded.

O path também precisa de um tratamento especial. Ele pode conter uma URL completa, inclusive contendo query strings, como em http://meusite.com.br/recurso?x=1&b=fred”. A função de processamento da requisição precisa saber separar o path da URI (a parte antes do path) e o path das query strings. Essas últimas podem entrar como se fossem atributos do path, no objeto da requisição… Para diferenciar: URI é um identificador. Ele contém apenas a identificação do site. Uma URL é um URI que contém a localização de um recurso (e atributos adicionais)… Nesse contexto, URI é o nome do site e o protocolo (http://meusite.com.br”, onde http://” é o protocolo e, obviamente, “meusite.com.br” o site).

Por que diabos uma URL pode aparecer na primeira linha da requisição? É que uma aplicação pode requisitar um recurso a um web server qualquer através de outro (um proxy). Dai, o atributo Host e a URL não apontarão para a mesma URI e o web server repassará a requisição para outro web server… Nosso httpd não é um web server completo e eu não implementaria essa re-requisição, mas é importante tratar o path estirpando a URI…

Quanto as respostas, usamos a função send() da mesma forma que usamos a função recv()… Ou seja, se mandarmos enviar n bytes, ela pode enviar menos do que isso e devemos enviar os bytes que faltam, em loop… A função devolverá -1 em caso de erro e errno setado com o motivo do erro. Um erro EINTR significa, como em recv(), que a syscall foi interrompida. Um erro ENOTCONN significa que a conexão não existe mais. Um erro EAGAIN ou EWOULDBLOCK, para sockets não bloqueáveis, significa que o buffer de saída está cheio e devemos tentar enviar os dados de novo… Consulte a documentação da função nas manpages.

Se errno for ENOTCONN, por exemplo, devemos sair do loop de session_manager() e permitir que a conexão seja fechada do nosso lado, destruindo a pilha da thread e retirando a referência da lista de sessões… Provavelmente fazemos o mesmo se o erro ECONNRESET for recebido. A mesma coisa se recebermos EPIPE (“broken pipe” normalmente é um erro de conexão quebrada!)… Diferente de recv(), se send() retorna zero, não significa que a conexão caiu…

A montagem e envio de uma resposta é sempre mais simples do que a leitura de uma requisição. Aqui nós é que temos o controle total… Se quisermos enviar um payload teremos certeza de ajustar o atributo “Content-Length” da resposta para o tamanho exato dela. Na leitura não temos essa certeza… O cliente pode nos enviar um stream de dados dizendo que ele tem 1 KiB, mas ele tem apenas 512 bytes! Por isso o esquema de timeout usando poll(). Usar poll() para enviar um stream de bytes pode ser interessante para determinar se o sistema operacional aceita o envio de dados de antemão, mas podemos confiar, até certo ponto, que ele conseguirá. O importante é tratar os erros reportados por send().

Em resumo: Um httpd é, basicamente, um processador de requisições e um fornecedor de respostas. O usuário pede algo via método “GET” ou envia algo usando os métodos “PUT” ou “POST”, a aplicação a interpreta, obtendo ou não um payload que pode existir ou não, monta uma resposta e a envia… Os demais métodos, para esse httpd simples são inócuos (HEAD, DELETE, CONNECT, …), mas é interessante oferecer uma resposta padrão para eles…

Usei essa pseudo-implementação de um httpd porque ela é complexa o suficiente para que eu pudesse escrever sobre como um protocolo do nível de aplicação pode ser tratado usando sockets. Sockets, por sua vez, são a mesma coisa que usar um file descriptor obtido via open(), só que não lidamos com dispositivos de blocos… Na realidade os “blocos” são os pacotes que chegam na sua interface de rede, que podem ter até 576 bytes de tamanho cada. Os drivers sabem como juntar pacotes recebidos corretamente e permitir a leitura de mais que 576 bytes de uma só vez, mas há um limite: Placas de rede costumam usar um buffer circular pequenininho (alguns KiB) e é por isso que recv() e send() podem tamanhos menores do que os mandamos ler/enviar… Além disso temos que ficar espertos para as condições de erro: Um arquivo está acessível, normalmente, o tempo todo, não é o caso de uma conexão de rede… Entender os códgos de erro e valores de retorno de funções que lidam com sockets é extremamente importante para o desenvolvimento de rotinas que atendam ao protocolo desejado.

Anúncios