Salta al contenuto
Note per Studenti Esercizio - Client HTTP 1.1 con Content-Length e connessione persistente (slide HTTP 1.1)

Esercizio - Client HTTP 1.1 con Content-Length e connessione persistente (slide HTTP 1.1)

In questa pagina 6

Testo (slide HTTP 1.1 del corso di Reti di Calcolatori, Ing. Informatica UniPD: «Client HTTP/1.1: Content-Length»).

Modificare il client HTTP/1.0 (Esercizio - Client HTTP 0.9 e 1.0 che scarica una pagina (slide HTTP 0.9 e 1.0)) in modo che:

  1. invii richieste HTTP/1.1 con l'header Host;
  2. invii più richieste sulla stessa connessione TCP (persistente) e legga ogni risposta senza aspettare la chiusura della connessione, usando Content-Length per sapere dove finisce il corpo;
  3. rispetti le regole sulla lunghezza del corpo: nessun corpo per la risposta a HEAD e per gli stati 1xx, 204, 304, anche se c'è Content-Length;
  4. dichiari Connection: close nell'ultima richiesta;
  5. se il server chiude la connessione (header Connection: close nella risposta, o risposta HTTP/1.0, o corpo terminato dalla chiusura), riapra la connessione per le richieste seguenti.

Teoria: HTTP 1.1 - connessioni persistenti, Content-Length e chunked transfer encodingHTTP/1.1 (oggi RFC 9110 e 9112) rende la connessione persistente di default (si chiude solo con "Connection: close"), rende obbligatorio l'header Host (virtual hosting) e introduce i nuovi metodi PUT, DELETE, OPTIONS, TRACE, Expect: 100-continue, richieste di intervalli (206) e Transfer-Encoding: chunked; con la connessione persistente il client deve sapere dove finisce ogni risposta: lunghezza del corpo nell'ordine HEAD/1xx/204/304 senza corpo, Transfer-Encoding chunked, Content-Length, altrimenti fino alla chiusura; il chunked divide il corpo in blocchi preceduti dalla lunghezza in esadecimale, termina con un chunk 0 e un trailer facoltativo, e si decodifica contando i byte dichiarati (non cercando CRLF).HTTP 1.1 - connessioni persistenti, Content-Length e chunked transfer encoding →, HTTP 0.9 e 1.0 - struttura dei messaggi, header e parsingHTTP/0.9 (1991) ha una sola riga di richiesta "GET /percorso" e la risposta e' solo il documento HTML, senza codici di stato ne' header, e la connessione si chiude alla fine; HTTP/1.0 (RFC 1945) aggiunge Request-Line con versione, Status-Line con codice, header (generali, di richiesta, di risposta, di entita'), i metodi HEAD e POST, tipi di contenuto diversi dall'HTML; un messaggio e' riga iniziale, header "Nome: valore" a righe CRLF, riga vuota, corpo; in HTTP/1.0 il corpo della risposta finisce quando il server chiude la connessione, mentre per POST serve Content-Length; il parsing legge la prima riga, poi gli header fino alla riga vuota, poi il corpo; i nomi degli header non distinguono maiuscole e minuscole, e un client robusto accetta LF semplice e spazi variabili.HTTP 0.9 e 1.0 - struttura dei messaggi, header e parsing →, System call POSIX, file descriptor e API delle socketLe system call sono le funzioni con cui un programma in user space chiede servizi al kernel (open, read, write, close, fork, pipe, dup2, socket, bind, listen, accept, connect); restituiscono -1 e impostano errno in caso di errore; un file descriptor e' un intero che indicizza la tabella dei file aperti del processo (0 stdin, 1 stdout, 2 stderr) e vale per file, pipe e socket; read e write possono trasferire MENO byte del richiesto (e sui socket TCP non c'e' nessun confine fra i messaggi), quindi servono cicli write_all e read_exact; l'API delle socket crea un punto finale di comunicazione (socket), lo lega a indirizzo e porta (bind), lo rende passivo (listen), accetta connessioni (accept) o si connette (connect); getaddrinfo traduce nomi e porte in indirizzi (IPv4 e IPv6); UDP usa sendto e recvfrom e conserva i confini dei datagrammi, TCP e' uno stream affidabile.System call POSIX, file descriptor e API delle socket →.


Idea

Con HTTP/1.0 il client sapeva che la risposta era finita perché il server chiudeva. Con la connessione persistente la chiusura non arriva: il client deve contare. La regola di RFC 9112 par. 6.3 (nell'ordine, per le risposte che interessano qui):

ordine condizione corpo
1 risposta a HEAD, stato 1xx, 204, 304 nessuno
2 Transfer-Encoding: chunked decodifica dei chunk (Esercizio - Client HTTP 1.1 con decodifica del chunked (slide HTTP 1.1))
3 Content-Length: n esattamente n byte
4 nessuno dei due fino alla chiusura della connessione (e la connessione non si riusa)

Il programma implementa 1, 3, 4 e segnala chiaramente il caso 2. Un'altra novità rispetto a HTTP/1.0 è la decisione di riuso della connessione dopo ogni risposta:

  • risposta HTTP/1.1: riusabile salvo Connection: close;
  • risposta HTTP/1.0: riusabile solo con Connection: keep-alive;
  • mai se il corpo è stato delimitato dalla chiusura del socket.

Se non è riusabile e restano richieste da fare, il client chiude e riapre.

La lettura continua a essere un byte alla volta per le righe (read_line) e a blocchi per il corpo (read con al più i byte mancanti), così il client non legge mai un byte oltre la risposta corrente: i byte successivi, se ci sono, sono l'inizio della risposta seguente.

Codice

c
/* client11_cl.c - client HTTP/1.1 con connessione persistente e corpo delimitato da Content-Length.
 * Manda piu' richieste sulla STESSA connessione TCP; se il server chiude, riapre.
 *
 * Compilare:  gcc -Wall -Wextra -o client11_cl client11_cl.c
 * Usare:      ./client11_cl host porta percorso1 [percorso2 ...]
 *             un percorso preceduto da "HEAD:" usa il metodo HEAD (es. HEAD:/index.html)
 *             es. ./client11_cl 127.0.0.1 8000 /index.html HEAD:/index.html /a.txt
 * Un corpo chunked NON e' gestito qui: vedi il client dell'esercizio sul chunked.
 */
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <strings.h>
#include <errno.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netdb.h>

#define MAX_LINE 4096

struct response {
    int major, minor;       /* versione della risposta */
    int status;
    long content_length;    /* -1 se assente */
    int chunked;
    int conn_close;         /* "Connection: close" presente */
    int conn_keepalive;     /* "Connection: keep-alive" presente */
};

static int connect_to(const char *host, const char *port)
{
    struct addrinfo hints, *res, *p;
    memset(&hints, 0, sizeof hints);
    hints.ai_family = AF_UNSPEC;
    hints.ai_socktype = SOCK_STREAM;
    if (getaddrinfo(host, port, &hints, &res) != 0)
        return -1;
    int fd = -1;
    for (p = res; p != NULL; p = p->ai_next) {
        fd = socket(p->ai_family, p->ai_socktype, p->ai_protocol);
        if (fd < 0)
            continue;
        if (connect(fd, p->ai_addr, p->ai_addrlen) == 0)
            break;
        close(fd);
        fd = -1;
    }
    freeaddrinfo(res);
    return fd;
}

static int write_all(int fd, const char *buf, size_t n)
{
    while (n > 0) {
        ssize_t w = write(fd, buf, n);
        if (w < 0) {
            if (errno == EINTR)
                continue;
            return -1;
        }
        buf += w;
        n -= (size_t)w;
    }
    return 0;
}

/* Una riga alla volta, un byte per read: non si legge mai un byte oltre la riga, quindi il corpo
 * (e la risposta successiva su una connessione persistente) resta intatto nel socket. */
static int read_line(int fd, char *line, size_t max)
{
    size_t n = 0;
    char c;
    while (n + 1 < max) {
        if (read(fd, &c, 1) <= 0)
            return -1;
        if (c == '\n') {
            if (n > 0 && line[n - 1] == '\r')
                n--;
            line[n] = '\0';
            return (int)n;
        }
        line[n++] = c;
    }
    return -1;
}

/* Vero se 'hay' contiene 'needle' senza distinguere maiuscole e minuscole. */
static int ci_contains(const char *hay, const char *needle)
{
    size_t k = strlen(needle);
    for (; *hay; hay++)
        if (strncasecmp(hay, needle, k) == 0)
            return 1;
    return 0;
}

/* Legge status-line e header. Torna 0 se tutto bene. */
static int read_head(int fd, struct response *rs)
{
    char line[MAX_LINE];
    memset(rs, 0, sizeof *rs);
    rs->content_length = -1;
    if (read_line(fd, line, sizeof line) < 0 || sscanf(line, "HTTP/%d.%d %d", &rs->major, &rs->minor, &rs->status) != 3)
        return -1;
    for (;;) {
        int len = read_line(fd, line, sizeof line);
        if (len < 0)
            return -1;
        if (len == 0)
            return 0;                               /* riga vuota: fine degli header */
        char *colon = strchr(line, ':');
        if (colon == NULL)
            continue;
        *colon = '\0';
        const char *v = colon + 1;
        while (*v == ' ' || *v == '\t')
            v++;
        if (strcasecmp(line, "Content-Length") == 0)
            rs->content_length = atol(v);
        else if (strcasecmp(line, "Transfer-Encoding") == 0 && ci_contains(v, "chunked"))
            rs->chunked = 1;
        else if (strcasecmp(line, "Connection") == 0) {
            if (ci_contains(v, "close"))
                rs->conn_close = 1;
            if (ci_contains(v, "keep-alive"))
                rs->conn_keepalive = 1;
        }
    }
}

/* Quanti byte ha il corpo? Si seguono le regole di RFC 9112 par. 6.3 (nell'ordine). Il corpo viene
 * stampato in anteprima e scartato. *close_delimited vale 1 se il corpo e' finito con la chiusura del socket.
 * Torna la lunghezza del corpo, -1 su errore, -2 se e' chunked. */
static long read_body(int fd, const struct response *rs, int head_request, int *close_delimited)
{
    *close_delimited = 0;
    /* 1) HEAD, 1xx, 204, 304: MAI un corpo, anche se c'e' Content-Length (e' la lunghezza che avrebbe avuto il GET) */
    if (head_request || rs->status / 100 == 1 || rs->status == 204 || rs->status == 304)
        return 0;
    if (rs->chunked)
        return -2;
    char buf[4096];
    long total = 0;
    if (rs->content_length >= 0) {
        /* 2) Content-Length: si leggono esattamente quei byte, ne' uno di piu' ne' uno di meno */
        long left = rs->content_length;
        while (left > 0) {
            size_t want = left < (long)sizeof buf ? (size_t)left : sizeof buf;
            ssize_t r = read(fd, buf, want);
            if (r < 0 && errno == EINTR)
                continue;
            if (r <= 0)
                return -1;                          /* il server ha chiuso prima: risposta incompleta */
            if (total == 0)
                printf("    anteprima: %.*s\n", r < 60 ? (int)r : 60, buf);   /* buf non e' terminato da '\0' */
            total += r;
            left -= r;
        }
        return total;
    }
    /* 3) Niente lunghezza: il corpo finisce quando il server chiude la connessione (stile HTTP/1.0) */
    ssize_t r;
    while ((r = read(fd, buf, sizeof buf)) > 0)
        total += r;
    *close_delimited = 1;
    return total;
}

int main(int argc, char **argv)
{
    if (argc < 4) {
        fprintf(stderr, "uso: %s host porta percorso1 [percorso2 ...]\n", argv[0]);
        return 1;
    }
    const char *host = argv[1], *port = argv[2];
    int fd = connect_to(host, port);
    if (fd < 0) {
        fprintf(stderr, "connessione fallita\n");
        return 1;
    }
    int nconn = 1;

    for (int i = 3; i < argc; i++) {
        int last = (i == argc - 1);
        const char *method = "GET", *path = argv[i];
        if (strncmp(path, "HEAD:", 5) == 0) {
            method = "HEAD";
            path += 5;
        }
        /* In HTTP/1.1 la connessione e' persistente di default: Connection: close solo nell'ultima richiesta. */
        char req[2048];
        int n = snprintf(req, sizeof req,
                         "%s %s HTTP/1.1\r\n"
                         "Host: %s:%s\r\n"
                         "User-Agent: client11_cl/1.0\r\n"
                         "%s"
                         "\r\n",
                         method, path, host, port, last ? "Connection: close\r\n" : "");
        if (write_all(fd, req, (size_t)n) < 0) {
            perror("write");
            return 1;
        }

        struct response rs;
        if (read_head(fd, &rs) < 0) {
            fprintf(stderr, "risposta non valida o connessione chiusa\n");
            return 1;
        }
        int cd;
        long len = read_body(fd, &rs, strcmp(method, "HEAD") == 0, &cd);
        if (len == -2) {
            fprintf(stderr, "corpo chunked: non gestito da questo client\n");
            return 1;
        }
        if (len < 0) {
            fprintf(stderr, "corpo incompleto\n");
            return 1;
        }
        printf("%s %s -> %d, Content-Length %ld, corpo letto %ld byte (connessione n. %d)\n",
               method, path, rs.status, rs.content_length, len, nconn);

        /* La connessione si puo' riusare? 1.1: si', salvo "Connection: close"; 1.0: solo con "keep-alive";
         * e mai se il corpo e' finito con la chiusura del socket. */
        int reusable = (rs.major == 1 && rs.minor >= 1) ? !rs.conn_close : rs.conn_keepalive;
        if (cd)
            reusable = 0;
        if (!reusable && !last) {
            printf("    il server chiude la connessione: ne apro una nuova\n");
            close(fd);
            fd = connect_to(host, port);
            if (fd < 0) {
                fprintf(stderr, "riconnessione fallita\n");
                return 1;
            }
            nconn++;
        }
    }
    close(fd);
    return 0;
}

Compilare e provare

Un server HTTP/1.1 persistente di prova (il server di Python con il protocollo 1.1):

$ python3 -c "import http.server as h; h.test(h.SimpleHTTPRequestHandler, protocol='HTTP/1.1', port=8000, bind='127.0.0.1')" &
$ gcc -Wall -Wextra -o client11_cl client11_cl.c
$ ./client11_cl 127.0.0.1 8000 /index.html HEAD:/index.html /a.txt
    anteprima: <html><body><h1>Ciao</h1></body></html>
GET /index.html -> 200, Content-Length 40, corpo letto 40 byte (connessione n. 1)
HEAD /index.html -> 200, Content-Length 40, corpo letto 0 byte (connessione n. 1)
    anteprima: riga1
riga2
GET /a.txt -> 200, Content-Length 12, corpo letto 12 byte (connessione n. 1)

Tutte e tre le richieste usano la connessione n. 1. Controllare con ss -tan | grep 8000 durante l'esecuzione, o con strace -e trace=connect ./client11_cl ... (una sola connect). Con il server dell'esercizio Esercizio - Server HTTP iterativo con GET, HEAD e codici di errore (sul modello della prova pratica), che manda Connection: close a ogni risposta, il client riapre la connessione:

$ ./client11_cl 127.0.0.1 8080 /index.html /a.txt
    anteprima: <html><body><h1>Ciao</h1></body></html>
GET /index.html -> 200, Content-Length 40, corpo letto 40 byte (connessione n. 1)
    il server chiude la connessione: ne apro una nuova
    anteprima: riga1
riga2
GET /a.txt -> 200, Content-Length 12, corpo letto 12 byte (connessione n. 2)

Con HEAD: davanti al percorso si prova il caso 1: senza la regola sulla HEAD il client resterebbe bloccato in attesa dei 40 byte che il server non invia.

Spiegazione dei punti chiave

La richiesta.

GET /index.html HTTP/1.1\r\n
Host: 127.0.0.1:8000\r\n
User-Agent: client11_cl/1.0\r\n
\r\n                                 (nell'ultima richiesta anche  Connection: close\r\n)
  • Host è obbligatorio in HTTP/1.1: un server risponde 400 se manca.
  • Nessun Connection: keep-alive: in HTTP/1.1 la persistenza è il comportamento predefinito. Solo l'ultima richiesta dice Connection: close, così il server chiude dopo la risposta e il client non resta con una connessione aperta.

struct response. Dopo read_head contiene: versione (major, minor), status, content_length (-1 se assente), chunked, conn_close e conn_keepalive. L'header Connection può contenere più parole (keep-alive, Upgrade): ci_contains cerca la sottostringa senza distinguere maiuscole.

read_head. Come nel client 1.0 (read_line byte per byte, status-line con sscanf, header spezzati al primo :), ma raccoglie solo i quattro header che servono. I nomi si confrontano con strcasecmp: content-length e Content-Length sono lo stesso header (il server di Python manda Content-type).

read_body.

  • Caso 1 (head_request, status/100 == 1, 204, 304): restituisce 0 senza leggere. È il punto più delicato: la risposta a HEAD ha Content-Length: 40 ma nessun corpo.
  • Caso 2 (chunked): il client segnala e termina (nel prossimo esercizio lo si decodifica).
  • Caso 3 (Content-Length): left conta i byte mancanti; ogni read chiede al più min(left, sizeof buf); r <= 0 prima della fine significa che il server ha chiuso: corpo incompleto (errore, non una risposta valida). Chiedere solo i byte mancanti è ciò che impedisce di consumare l'inizio della risposta successiva.
  • Caso 4 (niente lunghezza): read fino a 0; *close_delimited = 1 avvisa il chiamante che la connessione non è riusabile.

La stampa dell'anteprima usa %.*s con la lunghezza letta: il buffer non è terminato da '\0' e printf("%s") leggerebbe oltre i dati ricevuti.

La decisione di riuso (nel main).

c
int reusable = (rs.major == 1 && rs.minor >= 1) ? !rs.conn_close : rs.conn_keepalive;
if (cd) reusable = 0;                 /* corpo delimitato dalla chiusura */
if (!reusable && !last) { close(fd); fd = connect_to(host, port); nconn++; }

Se la risposta è HTTP/1.1 la connessione resta valida finché non c'è Connection: close; se è 1.0 serve keep-alive esplicito. Ogni riapertura incrementa nconn, che il programma stampa a ogni richiesta.

Quello che il programma non fa (e un client vero deve fare): ritentare automaticamente una richiesta se il server chiude una connessione inattiva (solo per richieste idempotenti), gestire il pipelining, gestire 100 Continue, i timeout, e il chunked.

Errori tipici

  • Aspettare il corpo dopo una risposta a HEAD, 204 o 304: il programma si blocca.
  • Leggere "fino alla chiusura" con una connessione persistente: read non restituisce mai 0 finché il server non chiude.
  • Leggere con un buffer fisso più grande dei byte mancanti: si consumano byte della risposta successiva e si perde la sincronizzazione (con read di blocchi si limita want a left).
  • Non distinguere il fine corpo (Content-Length) dalla fine della risposta quando il server chiude prima: corpo incompleto.
  • Dimenticare Host in HTTP/1.1, o mandare Connection: keep-alive credendolo necessario.
  • Non controllare Connection: close nella risposta: la richiesta successiva va su un socket chiuso (errore EPIPE o risposta vuota).
  • Confrontare Content-Length con strcmp invece di strcasecmp.
  • Stampare il buffer con %s senza terminatore.

Varianti per esercitarsi

  • Aggiungere la ripetizione automatica di una GET se la connessione è stata chiusa dal server prima di ricevere qualunque byte della risposta.
  • Salvare ogni corpo in un file risposta-N.bin.
  • Implementare il pipelining: inviare tutte le richieste insieme e leggere poi le risposte nello stesso ordine.
  • Unificare con il client del chunked: Esercizio - Client HTTP 1.1 con decodifica del chunked (slide HTTP 1.1).

Versione ripasso

  • Testo. Client HTTP/1.1 con Host; più richieste sulla stessa connessione, risposte delimitate da Content-Length; nessun corpo per HEAD e per 1xx/204/304; Connection: close nell'ultima richiesta; riapertura se il server chiude.
  • Lunghezza del corpo (RFC 9112 par. 6.3). (1) HEAD, 1xx, 204, 304: nessun corpo; (2) Transfer-Encoding: chunked: chunk; (3) Content-Length: n: esattamente n byte; (4) altrimenti fino alla chiusura (connessione non riusabile).
  • Riuso della connessione. 1.1: riusabile salvo Connection: close; 1.0: solo con keep-alive; mai se il corpo è finito con la chiusura. Se non riusabile e restano richieste: close + connect_to, nconn++.
  • Richiesta. GET path HTTP/1.1\r\nHost: host:porta\r\nUser-Agent: ...\r\n + (solo l'ultima) Connection: close\r\n + \r\n. Host obbligatorio; niente keep-alive.
  • read_head. read_line a un byte alla volta; sscanf("HTTP/%d.%d %d"); header: Content-Length (atol), Transfer-Encoding (contiene chunked), Connection (lista: close, keep-alive) con strcasecmp e ci_contains.
  • read_body.
c
if (head_request || status / 100 == 1 || status == 204 || status == 304) return 0;   /* nessun corpo */
if (chunked) return -2;
if (content_length >= 0) { long left = content_length;
    while (left > 0) { size_t want = left < sizeof buf ? left : sizeof buf;       /* al più i byte MANCANTI */
        ssize_t r = read(fd, buf, want);
        if (r <= 0) return -1;                                                    /* chiuso prima: incompleto */
        left -= r; } }
else  /* fino alla chiusura */ { while (read(fd, buf, sizeof buf) > 0) ...; *close_delimited = 1; }
  • Prove. Server di prova: python3 -c "import http.server as h; h.test(h.SimpleHTTPRequestHandler, protocol='HTTP/1.1', port=8000, bind='127.0.0.1')": ./client11_cl 127.0.0.1 8000 /index.html HEAD:/index.html /a.txt -> tutte e tre sulla connessione n. 1. Con il server dell'esercizio (Connection: close) il client riapre (connessione n. 2). HEAD: prova la regola della HEAD.
  • Anteprima. %.*s con la lunghezza letta: il buffer non è terminato da '\0'.
  • Non gestito. Ritentativo automatico, pipelining, 100 Continue, timeout, chunked.
  • Codice essenziale (le funzioni centrali, senza commenti):
c
static int read_head(int fd, struct response *rs)
{
    char line[MAX_LINE];
    memset(rs, 0, sizeof *rs);
    rs->content_length = -1;
    if (read_line(fd, line, sizeof line) < 0 || sscanf(line, "HTTP/%d.%d %d", &rs->major, &rs->minor, &rs->status) != 3)
        return -1;
    for (;;) {
        int len = read_line(fd, line, sizeof line);
        if (len < 0)
            return -1;
        if (len == 0)
            return 0;
        char *colon = strchr(line, ':');
        if (colon == NULL)
            continue;
        *colon = '\0';
        const char *v = colon + 1;
        while (*v == ' ' || *v == '\t')
            v++;
        if (strcasecmp(line, "Content-Length") == 0)
            rs->content_length = atol(v);
        else if (strcasecmp(line, "Transfer-Encoding") == 0 && ci_contains(v, "chunked"))
            rs->chunked = 1;
        else if (strcasecmp(line, "Connection") == 0) {
            if (ci_contains(v, "close"))
                rs->conn_close = 1;
            if (ci_contains(v, "keep-alive"))
                rs->conn_keepalive = 1;
        }
    }
}

static long read_body(int fd, const struct response *rs, int head_request, int *close_delimited)
{
    *close_delimited = 0;
    if (head_request || rs->status / 100 == 1 || rs->status == 204 || rs->status == 304)
        return 0;
    if (rs->chunked)
        return -2;
    char buf[4096];
    long total = 0;
    if (rs->content_length >= 0) {
        long left = rs->content_length;
        while (left > 0) {
            size_t want = left < (long)sizeof buf ? (size_t)left : sizeof buf;
            ssize_t r = read(fd, buf, want);
            if (r < 0 && errno == EINTR)
                continue;
            if (r <= 0)
                return -1;
            if (total == 0)
                printf("    anteprima: %.*s\n", r < 60 ? (int)r : 60, buf);
            total += r;
            left -= r;
        }
        return total;
    }
    ssize_t r;
    while ((r = read(fd, buf, sizeof buf)) > 0)
        total += r;
    *close_delimited = 1;
    return total;
}
  • Errori tipici: corpo atteso dopo HEAD/204/304; "fino alla chiusura" con connessione persistente; read più grande dei byte mancanti (si perde la sincronizzazione); chiusura prima della fine non rilevata; Host mancante; Connection: close della risposta ignorato; strcmp sui nomi; %s su buffer non terminato.

Esercizi su questo argomento

Teoria collegata