Embedded HandbookПриложения
Все разделы › XVII. Системное программирование на C
· уровень средний · 12 мин чтения
Открыть в интерактивном справочнике

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 ошибка сервера.

Три способа встроить C в веб: свой HTTP-сервер, CGI-процесс на запрос, FastCGI за nginx


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), никакого состояния между запросами, соединение с базой открывается заново каждый раз.

Путь CGI-запроса: сервер запускает программу, запрос приходит в окружении и stdin, ответ уходит из stdout


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_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 — статические и динамические библиотеки.

Открыть в интерактивном справочнике