142. Веб-сервисы на C: HTTP, REST, CGI, FastCGI, sendfile
Раздел 78 показал сокеты и первый веб-сервер; раздел 141 — как держать
тысячи соединений. Здесь — сам протокол и способы встроить программу на
C в веб: своим сервером, через CGI под чужим сервером, через FastCGI
за nginx. И то, за что nginx любят, — отдача файла без копирования
через пользователя, sendfile. Все ответы ниже получены curl от
программ из _verify/sys142/ в stm-sys:1; CGI-программу запускал
настоящий CGI-сервер.
Как это называется в документации
Оригинал Перевод request line, status line строка запроса, строка статуса header, body заголовок, тело method (GET, POST, PUT, DELETE) метод status code код ответа (200, 404, 405…) Content-Type,Content-Lengthтип и длина тела MIME type тип содержимого REST, resource, endpoint REST, ресурс, конечная точка CGI (Common Gateway Interface) шлюз: программа на каждый запрос FastCGI постоянный процесс вместо запуска на каждый запрос reverse proxy обратный прокси (nginx перед приложением) zero-copy, sendfileбез копирования через память программы WebSocket двусторонний канал поверх HTTP
142.1 HTTP — это текст
HTTP/1.1 — строки текста через \r\n: строка запроса, заголовки,
пустая строка, тело. Ответ — так же. curl -v показывает обе стороны
(> — запрос, < — ответ):
> GET /hello HTTP/1.1
> Host: 127.0.0.1:8080
>
< HTTP/1.1 200 OK
< Content-Type: text/plain
< Content-Length: 13
< Connection: close
<
Hello from C
Сервер обязан либо назвать длину тела (Content-Length), либо закрыть
соединение (Connection: close), либо слать кусками
(Transfer-Encoding: chunked) — иначе клиент не узнает, где конец.
Свой сервер собирает ответ одной функцией:
static void reply(int c, int code, const char *text, const char *type, const char *body, long len)
{
char hdr[256];
int n = snprintf(hdr, sizeof hdr, "HTTP/1.1 %d %s\r\nContent-Type: %s\r\nContent-Length: %ld\r\nConnection: close\r\n\r\n",
code, text, type, len);
write(c, hdr, n);
if (body) write(c, body, len);
}
Разбор запроса — sscanf("%7s %255s") на метод и путь и поиск
\r\n\r\n перед телом. Методы: GET — прочитать, POST — создать или
выполнить, PUT — заменить, PATCH — изменить часть, DELETE —
удалить, HEAD — только заголовки, OPTIONS — что разрешено. Коды:
2xx успех, 3xx перенаправление, 4xx ошибка клиента (400 плохой запрос,
404 нет, 405 метод не разрешён), 5xx ошибка сервера.
142.2 REST: ресурс, метод, представление
REST — не библиотека, а договор: у каждой вещи есть адрес (ресурс),
над ней выполняют стандартные методы, а состояние передаётся в теле
(обычно JSON); сервер ничего не помнит между запросами (без сеанса).
Светодиод как ресурс /api/led:
} else if (!strcmp(path, "/api/led")) { /* REST: GET читает, PUT меняет */
if (!strcmp(method, "PUT")) {
static const char err[] = "{\"error\":\"expected {\\\"on\\\":0|1}\"}\n"; /* длину считает sizeof */
int v; if (sscanf(body, "{\"on\":%d}", &v) == 1) led = v;
else { reply(c, 400, "Bad Request", "application/json", err, sizeof err - 1); return; }
} else if (strcmp(method, "GET")) { reply(c, 405, "Method Not Allowed", "text/plain", "", 0); return; }
curl http://…/api/led → {"on":0}
curl -X PUT -d '{"on":1}' http://…/api/led → {"on":1}
curl http://…/api/led → {"on":1}
POST /api/led -> 405
PUT 'garbage' -> 400 {"error":"expected {\"on\":0|1}"}
Длину тела считает sizeof, а не рука: строка ошибки — 34 байта вместе
с переводом строки, и написанная числом длина 32 обрезала бы закрывающую
скобку — клиент получил бы неполный JSON.
Так выглядит API прибора: GET для чтения датчиков, PUT для
уставок, ошибки — кодом и JSON с объяснением. JSON руками разбирают
только в таком игрушечном случае; в настоящем коде — cJSON или
jansson, а на микроконтроллере — cJSON же или jsmn. Полноценные
REST-серверы на C — libmicrohttpd, ulfius, civetweb.
В сети дальше стола разработчика такой API закрывают. Без TLS уставки
и пароли идут открытым текстом, без проверки подлинности уставку поменяет
любой, кто видит прибор в сети. На Linux TLS обычно берёт на себя nginx
перед приложением (142.4), на микроконтроллере — mbedTLS или модуль связи
(107.7); подлинность — хотя бы ключ в заголовке Authorization, по
которому сервер отличает своих (токены — 88.7). А разбор запроса в своём
сервере — место для переполнений: длину проверяют до копирования (129.6).
142.3 CGI: программа на каждый запрос
Самый старый способ подключить C к вебу: сервер запускает программу на
каждый запрос, передаёт параметры в переменных окружения, тело — в
stdin, а stdout программы отдаёт клиенту:
const char *method = getenv("REQUEST_METHOD"), *query = getenv("QUERY_STRING"), *len = getenv("CONTENT_LENGTH");
printf("Content-Type: text/plain\r\n\r\n"); /* заголовки, пустая строка, тело */
Под настоящим CGI-сервером (python3 -m http.server --cgi):
curl "http://…/cgi-bin/env.cgi?x=1&y=2"
method=GET query=x=1&y=2
GATEWAY_INTERFACE=CGI/1.1
SERVER_PROTOCOL=HTTP/1.0
SCRIPT_NAME=/cgi-bin/env.cgi
REMOTE_ADDR=127.0.0.1
HTTP_USER_AGENT=curl/7.88.1
curl -d 'hello=world' http://…/cgi-bin/env.cgi
method=POST query=
body=hello=world
Заголовки запроса приходят как HTTP_*, параметры адреса — в
QUERY_STRING, тело — CONTENT_LENGTH байт из stdin. Просто и
надёжно: программа падает — падает только этот запрос. Дорого: fork
+ exec на каждый запрос (раздел 136), никакого состояния между
запросами, соединение с базой открывается заново каждый раз.
142.4 FastCGI: постоянный процесс за nginx
FastCGI оставляет договор CGI — те же переменные и потоки, — но процесс живёт постоянно и принимает запросы по сокету от веб-сервера:
FCGX_Init();
int sock = FCGX_OpenSocket(":9000", 16); /* nginx: fastcgi_pass app:9000 */
if (sock < 0) return 1;
FCGX_Request r;
FCGX_InitRequest(&r, sock, 0);
long requests = 0;
while (FCGX_Accept_r(&r) == 0) { /* каждый запрос — без нового процесса */
const char *uri = FCGX_GetParam("REQUEST_URI", r.envp);
Перед приложением стоит nginx: он принимает HTTP, отдаёт статику сам, а запросы к приложению передаёт по FastCGI:
location /app { fastcgi_pass app:9000; include fastcgi_params; }
Проверка в двух контейнерах одной сети Docker — nginx и приложение:
три запроса подряд отвечают запрос №1, №2, №3 от одного pid —
состояние живёт между запросами, процесс один. То же самое умеет
php-fpm для PHP и uWSGI для Python; fcgiwrap превращает обычную
CGI-программу в FastCGI.
142.5 sendfile: файл в сокет без копирования
Отдать файл по сети «в лоб» — цикл read в буфер и write из него: каждый
байт проходит ядро → программа → ядро. sendfile(out, in, &off, n)
просит ядро перебросить данные напрямую; для сокета ядру даже не нужно
копировать — оно передаёт страницы файла сетевому стеку.
while ((n = read(f, buf, sizeof buf)) > 0) if (write(fd, buf, n) != n) break; /* ядро → буфер → ядро */
while (off < SIZE && sendfile(fd, f, &off, SIZE - off) > 0) ; /* без выхода в пользователя */
256 МБ через пару сокетов на одной машине:
read+write: 256 МБ за 114 мс, 2.4 ГБ/с, получено всё: да
sendfile: 256 МБ за 43 мс, 6.2 ГБ/с, получено всё: да
Быстрее — и почти без нагрузки на процессор. Свой сервер
из 142.1 так отдаёт /file/…: мегабайт случайных байт дошёл до curl
без искажений. sendfile может отправить меньше, чем просили, поэтому он
в цикле по off. Родственники: splice — между любыми дескрипторами
через канал, tee — раздвоение; TCP_CORK склеивает заголовок и файл в
один пакет. На этом стоит nginx: статика через sendfile, тысячи
соединений на epoll, 2,5 МБ памяти на 10 000 простаивающих соединений.
142.6 WebSocket и что дальше
HTTP — «запрос — ответ»; прибору часто нужно наоборот, слать данные
по своей воле. WebSocket начинается как HTTP-запрос с заголовком
Upgrade: websocket, после ответа 101 Switching Protocols соединение
становится двусторонним каналом кадров. На C его дают libwebsockets
и ulfius; для микроконтроллеров чаще MQTT (раздел 87) — он легче.
HTTP/2 и HTTP/3 — двоичные, с мультиплексированием; для прибора с
одним запросом в секунду выигрыша нет, и HTTP/1.1 остаётся нормой.
142.7 Грабли
| Симптом | Причина |
|---|---|
| клиент «висит» после ответа | нет ни Content-Length, ни Connection: close, ни chunked |
| браузер показывает мусор | нет Content-Type или неверный |
| ответ CGI — 500 «malformed header» | нет пустой строки после заголовков, или \n вместо \r\n\r\n |
| CGI не видит параметры | смотреть QUERY_STRING и CONTENT_LENGTH, а не argv |
| FastCGI отвечает «502 Bad Gateway» | приложение не слушает адрес из fastcgi_pass, или другая сеть |
sendfile отправил не всё |
возвращает меньше, чем просили — цикл по off |
sendfile возвращает EINVAL |
in_fd — сокет или канал; входом должен быть файл с mmap |
PUT с JSON вернул 400 |
тело разобрано не тем разбором — брать библиотеку JSON |
SIGPIPE убил сервер |
клиент закрыл соединение; signal(SIGPIPE, SIG_IGN) |
| один медленный клиент останавливает всех | сервер из 142.1 однопоточный; для многих — раздел 141 |
142.8 Итоги
- HTTP — текст: строка запроса, заголовки, пустая строка, тело;
границу тела задают
Content-Length, закрытием соединения илиTransfer-Encoding: chunked— иначе клиент ждёт. - REST: ресурс
/api/led,GET/PUT, JSON, 405 и 400 с объяснением. - CGI: переменные окружения,
stdin,stdout; процесс на запрос. - FastCGI: один процесс,
FCGX_Accept_rв цикле, nginx впереди. sendfile: 6,2 ГБ/с против 2,4 уread+write; в цикле поoff.
Полные исходники
http_min.c — минимальный сервер HTTP/1.1 с sendfile и REST-ресурсом /api/led.
/* Раздел 142: минимальный сервер HTTP/1.1 — разбор строки запроса и заголовков, ответ с
Content-Length, статический файл через sendfile, REST-эндпоинт /api/led с GET и PUT.
Однопоточный, одно соединение за раз: цель — показать протокол, а не производительность. */
#define _GNU_SOURCE
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <fcntl.h>
#include <signal.h>
#include <sys/socket.h>
#include <sys/sendfile.h>
#include <sys/stat.h>
#include <netinet/in.h>
static int led; /* «ресурс» для REST */
static void reply(int c, int code, const char *text, const char *type, const char *body, long len)
{
char hdr[256];
int n = snprintf(hdr, sizeof hdr, "HTTP/1.1 %d %s\r\nContent-Type: %s\r\nContent-Length: %ld\r\nConnection: close\r\n\r\n",
code, text, type, len);
write(c, hdr, n);
if (body) write(c, body, len);
}
static void handle(int c)
{
char req[4096]; ssize_t n = read(c, req, sizeof req - 1);
if (n <= 0) return;
req[n] = 0;
char method[8], path[256];
if (sscanf(req, "%7s %255s", method, path) != 2) { reply(c, 400, "Bad Request", "text/plain", "bad\n", 4); return; }
char *body = strstr(req, "\r\n\r\n"); body = body ? body + 4 : "";
if (!strcmp(path, "/hello")) {
const char *s = "Hello from C\n";
reply(c, 200, "OK", "text/plain", s, strlen(s));
} else if (!strcmp(path, "/api/led")) { /* REST: GET читает, PUT меняет */
if (!strcmp(method, "PUT")) {
static const char err[] = "{\"error\":\"expected {\\\"on\\\":0|1}\"}\n"; /* длину считает sizeof */
int v; if (sscanf(body, "{\"on\":%d}", &v) == 1) led = v;
else { reply(c, 400, "Bad Request", "application/json", err, sizeof err - 1); return; }
} else if (strcmp(method, "GET")) { reply(c, 405, "Method Not Allowed", "text/plain", "", 0); return; }
char js[32]; int l = snprintf(js, sizeof js, "{\"on\":%d}\n", led);
reply(c, 200, "OK", "application/json", js, l);
} else if (!strncmp(path, "/file/", 6) && !strstr(path, "..")) { /* статический файл через sendfile */
int f = open(path + 6, O_RDONLY);
struct stat st;
if (f < 0 || fstat(f, &st) < 0) { reply(c, 404, "Not Found", "text/plain", "no such file\n", 13); return; }
reply(c, 200, "OK", "application/octet-stream", NULL, st.st_size);
off_t off = 0;
while (off < st.st_size && sendfile(c, f, &off, st.st_size - off) > 0) ; /* ядро копирует само */
close(f);
} else reply(c, 404, "Not Found", "text/plain", "not found\n", 10);
}
int main(int argc, char **argv)
{
signal(SIGPIPE, SIG_IGN);
int port = argc > 1 ? atoi(argv[1]) : 8080;
int s = socket(AF_INET, SOCK_STREAM, 0), one = 1;
setsockopt(s, SOL_SOCKET, SO_REUSEADDR, &one, sizeof one);
struct sockaddr_in a = { .sin_family = AF_INET, .sin_port = htons(port), .sin_addr.s_addr = htonl(INADDR_ANY) };
if (bind(s, (struct sockaddr *)&a, sizeof a) < 0 || listen(s, 16) < 0) { perror("bind"); return 1; }
for (;;) { int c = accept(s, NULL, NULL); if (c < 0) continue; handle(c); close(c); }
}
fcgi_app.c — приложение FastCGI за nginx со счётчиком между запросами.
/* Раздел 142: приложение FastCGI — один процесс живёт постоянно и принимает запросы от nginx
по TCP; между запросами хранит состояние (счётчик). Сборка: -lfcgi. */
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <fcgiapp.h>
int main(void)
{
FCGX_Init();
int sock = FCGX_OpenSocket(":9000", 16); /* nginx: fastcgi_pass app:9000 */
if (sock < 0) return 1;
FCGX_Request r;
FCGX_InitRequest(&r, sock, 0);
long requests = 0;
while (FCGX_Accept_r(&r) == 0) { /* каждый запрос — без нового процесса */
const char *uri = FCGX_GetParam("REQUEST_URI", r.envp);
const char *method = FCGX_GetParam("REQUEST_METHOD", r.envp);
requests++;
FCGX_FPrintF(r.out, "Content-Type: text/plain\r\n\r\n");
FCGX_FPrintF(r.out, "FastCGI: %s %s, запрос №%ld в этом процессе, pid %d\n", method, uri ? uri : "?", requests, getpid());
FCGX_Finish_r(&r);
}
return 0;
}
Дальше: раздел 143 — статические и динамические библиотеки.