La réponse d'une requête HTTP est constituée d'un en-tête (de longueur variable) et d'un corps (de longueur variable également). Dans le cadre d'un programme C, cela cause pas mal de souci, en particulier parce que les longueurs respectives ne sont pas nécessairement connues a priori.
En outre, il existe pas moins de 3 manières dont un serveur http peut annoncer "prévenir" que le corps est terminé:
Au passage, notons que les appels recv() retourne une quantité de données généralement peu prévisible. On notera aussi l'existence de realloc() qui nous permet en une simple commande d'agrandir un tampon, ou plus précisément de réaliser
- par l'intermédiaire d'une option content-length dans les en-têtes
- par un chunk de taille nulle à condition d'avoir annoncé transfer-encoding: chunked dans les en-têtes
- en fermant la connexion TCP (principalement pour HTTP/1.0)
{
char *temp_buffer=malloc(new_size);
memcpy(new_buffer, old_buffer, old_size);
free(old_buffer);
}
L'emploi de realloc() dans un programme demande pas mal de prudence de la part de son utilisateur, notamment parce que rien ne garantit que le pointeur qui lui est transmis sera toujours valide après l'appel. Il faudra utiliser à la place le nouveau pointeur fourni par realloc().
Une première solution consiste à transférer la réponse au vol vers un fichier (ou un autre socket dans le cadre d'un proxy) sans chercher à la comprendre. Il sera toujours temps d'ouvrir le fichier plus tard une fois sa taille connue, etc. L'idéal dans ce cas est de s'être assuré que la requête envoyée contenait bien un Connection: close qui garantit que le serveur fermera la connexion une fois la réponse transmise.
| void http_forward(int sock, FILE* target) { char buffer[ANY_BUFLEN]; int received=1; while(received>0) { received=recv(sock,buffer,BUFLEN,0); fwrite(buffer,1,received,target); } } |
Le principal défaut de cette approche est évidemment de nécessiter un nom de fichier qui ne risquera pas d'entrer en conflit avec "autre chose". En particulier, pour un programme multi-processus ou multi-thread, il y a risque d'utilisation du même fichier par plusieurs tâches, conduisant dès lors à des résulats incohérents.
On remarquera aussi qu'une part importante de la difficulté a été simplement contournée: on ne sait toujours pas où se terminent les en-tête, ni quelle est la taille des données dans ce fichier. En outre, si le fichier était encodé, il l'est toujours.
L'avantage est que n'importe quelle valeur de ANY_BUFLEN fait l'affaire.
Le nom barbare de cette technique reflète assez bien son comportement. Tout en recev()ant la réponse depuis le socket, on applique la fonction strstr (recherche d'une chaîne à l'intérieur d'une autre) sur les données reçues pour identifier la fin des en-têtes (à l'aide de la chaîne "\r\n\r\n"). Tout ce qui précède cette chaîne peut alors être identifié comme appartenant aux en-tête, et tout ce qui suit au corps de la réponse.
void http_split(int sock, char* headers_here, char* data_here) {
char buffer[LARGE_ENOUGH];
char *end_of_hdr;
int buflen=0;
int received=1;
while(!end_of_hdr && received>0 && buflen<LARGE_ENOUGH) {
received=recv(sock,buffer+buflen,LARGE_ENOUGH-buflen,0);
end_of_hdr=strstr(buffer, "\r\n\r\n");
}
if (end_of_hdr) {
memcpy(headers_here, buffer, end_of_hdr - buffer);
memcpy(data_here, end_of_hdr+4, buflen - (end_of_hdr-buffer)-4);
}
}
On notera ici l'importance d'avoir défini la constante LARGE_ENOUGH de manière à ce qu'elle puisse contenir les en-têtes de n'importe quelle réponse. Malheureusement, dans le cas de sites complexes faisant intervenir des cookies, etc. trouver la bonne valeur de LARGE_ENOUGH n'est pas une mince affaire... Il faudra donc avoir recours à des buffers extensibles à coup de realloc qu'on devra libérer plus tard.
Un autre inconvénient de cette technique est que l'on prélève lors du traitement des headers une partie des données, ce qui risque d'en compliquer sensiblement le traitement par après...
En résumé, cette technique, si elle évite l'inconvénient d'un fichier intermédiaire, est particulièrement risquée, à moins d'avoir un style de programmation parfaitement propre. En outre, elle ne nous offre que la séparation header/données, mais aucune information sur la taille des données (il faudra encore parcourir les headers à la recherche de cette information, etc).
L'arme ultime: la bibliothèque standard. Comme présenté dans les Trucs et astuces pour la programmation réseau, il est facile de construire un flux géré par la bibliothèque par dessus un socket à l'aide de la fonction fdopen(). On perd alors définitivement l'usage de recv() ou send() sur ce socket, mais le plus souvent au bénéfice de fonctions bien plus performantes. On peut alors revoir le problème de la manière suivante:
La lecture ligne par ligne, de même que la lecture de la taille du bloc, peuvent se faire à l'aide de la fonction fscanf. Dans la bibliothèque glib, celle-ci possède quelques options particulièrement intéressantes:
- lire ligne par ligne tant qu'on a pas une ligne vide
- pendant ces lignes, repérer "content-length" ou "transfer-encoding"
- si content-length:
- allouer un bloc de la taille indiquée
- fread() pour tout mettre dans le bloc
- si transfer-encoding: chunked
- allouer un bloc
- "lire la taille du chunk suivant"
- "redimensionner le bloc si nécessaire"
- fread() pour lire le chunk à la suite du bloc alloué
%*s, %*d, ...
consomme un élément sur le flux mais ne stocke sa valeur nulle part. Aisni, "HTTP/1.%*d" nous permet de nous assuré que ce qui suit "." est bien un nombre, mais dont la valeur ne nous intéresse pas.
%as, %a[...]
force la fonction scanf à allouer elle-même la mémoire dans laquelle elle va retourner un tableau de caractères. Ce tableau aura d'office la bonne taille et sera terminé par un caractère nul. Il faut évidemment appeler free pour restituer cette mémoire lorsqu'on en a plus besoin.
%[anych] et %[^exclch]
permet de récupérer n'importe quelle séquence de caractères (soit donnés explicitements, soit tous ceux qui ne sont pas donnés). Par exemple %[0123456789] permettrait de récupérer un nombre décimal sous forme de chaîne. Dans notre cas, nous sommes surtout intéressés à récupérer des lignes, ce qui se traduit par %[^\n] (tout jusqu'à la fin de ligne)
Extraire la ligne d'état revient alors à faire:
int http_getcode(FILE* fstar)
{
int code,na;
na=fscanf(fstar,"HTTP/1.%*d %d %*[^\n]\n",&code);
if (na!=1)
DIE("unexpected response (%i)",na);
return code;
}
Il faut être prudent lorsqu'on utilise fscanf pour lire ligne par ligne. Un caractère d'espacement (ou fin de ligne, etc) dans la chaîne de formattage absorbe n'importe quel nombre de caractères d'espacement/fin de ligne/tabulation, etc. sur l'entrée. En utilisant quelque chose comme ...
int empty_line=0;
char *line;
while(!emtpy_line) {
fscanf(fstar,"%a[^\n]\n",&line);
empty_line=(!line) || (strlen(line)<1);
// traiter la ligne
free(line);
}
... on absorbe dans une ligne toutes les lignes vides qui la suivent... Il sera donc impossible de détecter la fin des en-têtes HTTP.
La solution consiste à utiliser fgetc/ungetc pour tester si on a une ligne vide avant de faire le fscanf. On retire alors un cartactère du flux d'entrée pour l'y remettre immédiatement. S'il s'agissait de la fin de ligne ("\r" ou "\n"), on sait alors que l'on a affaire à la fin de la ligne.
int isemptyline(FILE* f)
{
char c=ungetc(fgetc(f),f);
return c=='\r' || c=='\n';
}
void chompline(FILE* f)
{
while (fgetc(f)!='\n');
}
void eatup_headers(FILE *f)
{
char *line;
while(!isemtpyline(f)) {
fscanf(fstar,"%a[^\n]",&line);
// traiter la ligne
free(line);
chompline(f);
}
chompline(f);
}
Les fonctions isemptyline() et chompline() permettront de manipuler plus facilement des lignes terminées par "\n" ou "\r\n"...
Ici, plus vraiment de choix possibles: pour un support un peu propre du chunked encoding lors de la réception, il est presqu'indispensable d'avoir la bibliothèque stdio derrière soi ...
Pour rappel, le RFC2616 définit la structure du chunk encoding (section 3.6.1):
chunk = chunk-size [ chunk-extension ] CRLF
chunk-data CRLF
où "chunk-size" est un nombre de byte en hexadécimal. La fin de l'encodage est signalé par un chunk dont la taille vaut zéro (donc 0CRLFCRLF). On peut donc simplement écrire:
int chunksize(FILE* f)
{
int size;
if (fscanf(f,"%x",&size)!=1)
DIE("invalid chunk size");
chompline(f);
return size;
}
void chunkdata(FILE* f, void* where, int size)
{
if (fread(where,1,size,f)!=where)
DIE("cannot read whole chunk");
chompline(f);
}
void *readchunked(FILE *f)
{
int clen, totlen;
void *buffer=NULL;
int bufsize=0;
while((clen=chunksize(f))!=0) {
resize_if_needed(&buffer, &bufsize, totlen, clen);
chunkdata(f,buffer+totlen,clen);
totlen+=clen;
}
chompline(f);
return buffer;
}
reste évidemment le problème de la gestion de la mémoire pour le buffer (jusqu'ici si bien caché dans resize_if_needed()). Chaque redimensionnement risque de donner lieu à une copie de la mémoire vers le nouvel emplacement, on va donc essayer d'éviter les redimensionnements inutiles (en particuliers suite à de nombreux chunks de petite taille). Ayant bufsize, l'espace total dans buffer, totlen, l'espace consommé dans le buffer et clen, la taille du chunk à ajouter, l'idée est la suivante:
Ce qui peut se traduire par
- allouer initialement assez pour le 1° chunk, mais au moins DEFAULT_LEN bytes
- ne pas réallouer s'il reste clen bytes disponibles
- sinon, réallouer assez de place pour le chunk, mais on va au moins doubler la taille
void resize_if_needed(void** buf, int* bsize, int tl, int cl)
{
if (*bsize - tl > cl) return;
if (!*buf) {
*bsize=MAX(DEFAULT_LEN,cl);
*buf=malloc(*bsize);
} else {
*bsize=MAX(2*(*bsize),*bsize+cl);
*buf=realloc(*buf,*bsize);
}
}
Bien sûr, il faudra libérer ce buffer une fois les opérations terminées ...