HTTP/1.1 клієнт і сервер на risp — Lisp на Rust без жодної залежності

risp — Lisp на Rust без жодних залежностей, не туторіал 2019 — отримує HTTP/1.1 клієнт і сервер, написаний майже повністю на Lisp, без зовнішніх крейтів.

Джерело: github.com/kissishka/risp

Щоб одразу пояснити, про який саме Lisp ідеться: risp — це невеликий гомоіконічний Lisp-інтерпретатор на Rust, що спирається лише на std. Це не іграшковий калькулятор «Risp» із допису 2019 року й не якийсь там Common Lisp. У ньому є єдиний enum Value, який водночас є і AST, і значенням під час виконання, три рушії обчислення та правило, що типова cargo build не тягне жодного зовнішнього крейта (вихідний код на GitHub). Цей допис — про тиждень, коли risp навчився говорити HTTP: справжній HTTP/1.1 клієнт і сервер, із декодуванням chunked та маршрутизатором, написаний майже повністю на самому risp — і про невеликий, свідомо дозований обсяг Rust, що зробив це можливим, не порушуючи обіцянки про нульові залежності.

Дві стіни: ні транспорту, ні тексту

Чесна перша відповідь на запитання «а чи можна збудувати HTTP на цьому Lisp?» була ні, і не з однієї причини, а з двох. Уся сполука risp із зовнішнім світом — це три вбудовані функції: print, display, newline. Ні файлового вводу-виводу, ні read-line, ні сокетів, ні FFI, щоб прив’язати C-бібліотеку, ні запуску процесу, щоб гукнути curl. Інтерпретатор міг чудово обчислювати й повідомити вам відповідь, але він не міг ні до чого дотягнутися. Це стіна номер один: немає транспорту. Не можна говорити HTTP, якщо не можеш відкрити TCP-з’єднання, а тут не було ні примітиву, ні лазівки, щоб його відкрити.

Стіна номер два була тоншою. Припустімо, байти таки почали надходити. Рядки в risp були Rc<str> — незмінні й непрозорі: можна було надрукувати один або порівняти два на рівність, і це був увесь API. Ні substring, ні string-index, ні способу перетворити рядок на байти й назад, та й жодного типу байт-буфера взагалі. Тож навіть отримавши наповнений сокет, ти не міг ні зібрати рядок запиту, ні розібрати відповідь на частини. Дві стіни, кожна сама по собі достатня, щоб заблокувати HTTP. Бракувало не функції — бракувало підмурівка.

Лінива стратегія: найменша поверхня на Rust, протокол на Lisp

Хід, що зробив HTTP здійсненним, — той самий інстинкт, що кладе map і filter у прелюдію risp, а не в Rust: додай якнайменшу нативну поверхню, а потім напиши все, що над нею, самою мовою. Новим підмурівком стали два варіанти ValueBytes(Rc<[u8]>) і Tcp — плюс близько двадцяти простих вбудованих функцій fn(&[Value]) -> RispResult: шість сокетів (tcp-connect, tcp-read, tcp-write, tcp-close, tcp-listen, tcp-accept), кілька операцій над байтами (bytes-ref, subbytes, bytes-concat, bytes-index, bytes->string, string->bytes) та рядкові операції, які парсеру справді потрібні (substring, string-index, string->number, string-downcase).

Байт-буфер — це фундамент, і Rc<[u8]> обрано з тієї самої причини, з якої кожен інший вантаж лежить за Rc: одна непрямість і клонування за O(1).

pub enum Value {
    // ... Nil, Bool, Int, Float, Str, Symbol, Pair, Builtin, Closure, Macro, Compiled ...

    /// An immutable byte buffer (`Rc<[u8]>` gives one indirection and O(1) clone).
    Bytes(Rc<[u8]>),
    /// A TCP socket handle (stream or listener), opaque and self-evaluating.
    Tcp(Rc<TcpKind>),
}

pub enum TcpKind {
    Stream(std::net::TcpStream),
    Listener(std::net::TcpListener),
}

Усе правило тримається на одному факті: std::net є частиною std. Сокети коштують нуль залежностей — та сама офлайн cargo build, що не тягнула крейтів раніше, не тягне їх і досі. (TLS — це єдина можливість, яка справді не може бути лише std-овою, бо немає std::tls; вона живе за прапорцем --features tls, точно віддзеркалюючи те, як ізольовано Cranelift JIT у risp, тож типовий артефакт лишається чистим. Цю історію — і ширшу роботу зі зміцнення, якої потребував HTTP-стек, — описано в зміцненні HTTP-стека хобі-інтерпретатора.)

Додавання варіанта Value — це скінченне, перевірене компілятором правлення

Додати варіант до центрального enum Value звучить жахливо, коли мова про три рушії — деревообхідний інтерпретатор, байткод-VM із регістровим кешем та необов’язковий JIT. На практиці це дисципліноване, обмежене правлення. У value.rs є рівно чотири місця, які треба зачепити вручну: сам enum, type_name, функцію поверхневої рівності risp_eq та write_atom (друкувальник).

fn type_name(&self) -> &'static str {
    match self {
        // ...
        Value::Bytes(_) => "bytes",
        Value::Tcp(_) => "tcp",
    }
}

Усе інше — клопіт компілятора. Це недооцінена радість вичерпного match у Rust: тобі не треба пам’ятати, де новий варіант потребує обробки. Ти додаєш його до enum, запускаєш cargo build, і перевірка на вичерпність вручає тобі перелік усіх місць у деревообхіднику, VM, байткод-шарі та JIT, де тепер потрібне рішення. «Збирати до зеленого» перестає бути надією і стає жорсткою брамою коректності. Ітеративний Drop, яким risp звільняє структури глибиною в мільйон, не потребував жодних змін, бо Bytes і Tcp не тримають вкладених Value — наявний загальний випадок звільняє їх як листки.

Увесь протокол живе в http.lisp

Коли підмурівок був на місці, по-справжньому марудну частину — формування запиту, розбір рядка стану, alist заголовків, декодування Content-Length та chunked — було написано на risp, у файлі під назвою http.lisp, який вставляється через include_str! й обчислюється під час запуску інтерпретатора одразу після prelude.lisp:

const PRELUDE: &str = include_str!("prelude.lisp");
self.run_source(PRELUDE).expect("prelude.lisp must evaluate cleanly");
const HTTP: &str = include_str!("http.lisp");
self.run_source(HTTP).expect("http.lisp must evaluate cleanly");

Порядок має значення: прелюдія встановлює fold/map/filter, а http.lisp збудовано поверх них. Виграш від цього поділу в тому, що бік на Rust лишається крихітним і придатним до аудиту, а логіка протоколу живе у високорівневій мові, де її легко читати і, що головне, змінювати — і швидше, ніж у Lisp із нативною компіляцією за реальними вимірюваннями. Кожен рядок протоколу, винесений за межі Rust, — це рядок, який не потребує перекомпіляції, щоб його полагодити. Розбір URL, пошук заголовка без урахування регістру, цикл читання до роздільника — усе це читабельний Lisp.

GET-запит, що приємно, — це просто конкатенація рядків через string-append, а потім запис байтів у сокет. Ось справжній будівник запиту з http.lisp:

(def http--build-request
  (lambda (method path host extra body)
    (let ((header-lines
            (map (lambda (kv) (string-append (car kv) ": " (cdr kv) "\r\n")) extra)))
      (let ((clen (if (> (string-length body) 0)
                      (string-append "Content-Length: "
                                     (number->string (bytes-length (string->bytes body)))
                                     "\r\n")
                      "")))
        (string-append
          method " " path " HTTP/1.1\r\n"
          "Host: " host "\r\n"
          "Connection: close\r\n"
          (http--apply-concat header-lines)
          clen
          "\r\n"
          body)))))

Цей рядок потім вирушає назовні як (tcp-write sock (string->bytes req)). Зверніть увагу на \r\n. HTTP — це протокол із обрамленням CRLF, і доправити ці символи повернення каретки на провід виявилося єдиною справжньою бойовою історією проєкту: лексер не розпізнавав \r як рядковий escape і мовчки лексив "\r" у літеру r — дрімотна вада, яку жоден тест ніколи не зачіпав, бо доки ти не говориш протоколом із CRLF, хто взагалі набирає \r у рядку? Інтеграційний тест із повним циклом за секунди виявив те, про що роки модульних тестів так і не здогадалися перевірити.

Відповідь — це alist; декодування chunked — це Lisp-цикл

На зворотному шляху відповідь розбирається в alist і читається через п’ять аксесорів — http-status, http-reason, http-body, http-header та http-final-url:

(def resp (http-get "http://example.com/"))
(http-status resp)                   ; => 200
(http-reason resp)                   ; => "OK"
(http-body resp)                     ; => "<!doctype html>…"
(http-header resp "content-type")    ; => "text/html; charset=UTF-8"  (case-insensitive)

Цікавий випадок обрамлення — це Transfer-Encoding: chunked. Кожен фрагмент (chunk) — це рядок із шістнадцятковим розміром, потім стільки ж байтів даних, потім CRLF, і так повторюється, доки фрагмент нульового розміру не завершить тіло. http--decode-chunked розбирає розміри через parse-hex і збирає тіло поверх сокетних примітивів зі складністю O(n), ігноруючи трейлери після останнього фрагмента:

(def http--decode-chunked
  (lambda (sock buf max out total)
    (let ((b (http--ensure-crlf sock buf)))
      (let ((nl (bytes-index b "\r\n")))
        (if (< nl 0)
            (error "malformed chunked body: no size line") ; truncated
            (let ((size (parse-hex (bytes->string (subbytes b 0 nl)))))
              (if (= size 0)
                  (bytes-concat (reverse out)) ; last chunk; done
                  ;; ... read `size` data bytes + CRLF, recurse with a bigger `out`
                  )))))))

Дві речі роблять це безпечним, а не наївним. Розбір рядка розміру спирається на parse-hex, який зупиняється на першому нешістнадцятковому символі, тож рядок розміру фрагмента, що несе параметр ;ext або хвостовий \r, усе одно розбирається коректно. А декодер протягує поточне значення total проти max, здіймаючи "response body exceeded max-body" тієї ж миті, коли розміри фрагментів ворожого сервера штовхнули б зібране тіло за межу. Сканування заголовків зумисне наївні рядкові пошуки O(n·m) — але вони завжди працюють лише над даними розміру заголовка; обрамлення тіла — це та частина, що мусить бути O(n), і вона така, бо самі цикли читання (tcp-read-until, tcp-read-exact) живуть у Rust, де вони сканують лише щойно прибулі байти замість того, щоб переконкатеновувати весь буфер на кожен фрагмент.

Сервер однопотоковий за самою природою інтерпретатора

Серверний бік — http-serve, http-serve-once та make-router — повністю сформований тим фактом, що risp однопотоковий. Немає пулу потоків, якому можна передати з’єднання, тож цикл accept обслуговує рівно одне з’єднання за раз, і кожна відповідь несе Connection: close; keep-alive немає. Це жорстка стеля однопотокового інтерпретатора, і документація називає її проєктним рішенням, а не недоглядом. http-serve — це буквально прив’язка, а потім хвостоворекурсивний цикл accept-and-serve:

(def http-serve
  (lambda (port handler)
    (let ((listener (tcp-listen "127.0.0.1" port)))
      (def loop
        (lambda ()
          (let ((sock (tcp-accept listener)))
            (http--serve-connection sock handler)
            (loop))))
      (loop))))

Маршрутизація так само невелика. make-router бере список трійок (method path handler) і повертає обробник, що зіставляє за методом плюс точним шляхом, з автоматичним 404 для всього, що не зіставилося:

(def make-router
  (lambda (routes)
    (lambda (req)
      (let ((method (http--get req 'method ""))
            (path (http--get req 'path "/")))
        (let ((route (http--match-route routes method path)))
          (if (null? route)
              (list (cons 'status 404)
                    (cons 'headers (list (cons "Content-Type" "text/plain")))
                    (cons 'body "Not Found"))
              ;; route is (method path handler); call the handler with the req.
              ((car (cdr (cdr route))) req)))))))

Лише точне зіставлення — без параметрів /users/:id, без шаблонів, без нормалізації хвостового слеша у v1. Обробник повертає або звичайний рядок (який стає 200 text/plain), або повний alist відповіді з status, headers та body для будь-якого коду стану. Увесь шлях на одне з’єднання загорнуто в error-guard, тож зіпсований клієнт дає за найкращого зусилля 400 чи 500 замість того, щоб вилетіти з циклу accept і вбити сервер.

Тестування без мережі

Якщо нутрощі інтерпретатора для вас новина, вивчайте Rust, читаючи Lisp-інтерпретатор — гарна вступна стаття про те, як влаштовано рушій, перш ніж занурюватися в мережевий шар.

Мережева бібліотека, яку можна тестувати лише проти живого інтернету, — нестабільна, тож тести герметичні — без зовнішніх хостів, детерміновані, безпечні в CI. Для клієнта це легко: піднімаєш заздалегідь заготовлений сервер у фоновому std::thread, прив’язаний до 127.0.0.1:0 (щоб ОС видала тобі вільний порт), змушуєш його прийняти одне з’єднання й записати фіксоване HTTP/1.1 200 OK\r\nContent-Length: 5\r\n\r\nhello, а потім націлюєш на нього http-get із risp і перевіряєш розібрані статус і тіло.

Сервер — складніший напрям, бо він напряму впирається в однопотокову межу: не можна змусити http-serve-once з risp блокуватися на accept, водночас керуючи клієнтом із того ж інтерпретатора. Хитрість у тому, щоб обрати вільний порт, прив’язавши пробний слухач і одразу його відкинувши, породити клієнта в Rust-потоці, що під’єднується з повторними спробами — коротко крутиться в циклі, доки під’єднання не вдасться, — і дати risp прив’язати той самий порт і обслуговувати. Цикл повторного під’єднання закриває крихітну гонку між відкиданням пробного слухача та повторною прив’язкою risp. Це тримає всю справу в межах одного інтерпретатора й детермінованою.

Часті запитання

Чи можна написати HTTP-сервер без жодних залежностей?
Так. HTTP/1.1 клієнт і сервер risp написані майже повністю на Lisp, поверх лише типу-значення Bytes та приблизно 20 вбудованих функцій для сокетів і рядків, доданих до ядра на Rust. Оскільки std::net є частиною стандартної бібліотеки Rust, типова cargo build досі не тягне жодного зовнішнього крейта.
Скільки Rust довелося додати в risp, щоб підтримати HTTP?
Два варіанти enum Value (Bytes і Tcp), що зачіпають рівно чотири місця у value.rs, плюс близько 20 простих вбудованих функцій fn(&[Value]) -> RispResult: шість TCP-сокетів, кілька операцій над байтами та рядкові операції, потрібні парсеру. Уся логіка протоколу — формування запиту, розбір заголовків, декодування chunked — живе в http.lisp, а не в Rust.
Чи багатопотоковий HTTP-сервер risp?
Ні. Сервер однопотоковий і обслуговує рівно одне з'єднання за раз, надсилаючи Connection: close в кожній відповіді без keep-alive. Це жорстка стеля однопотокового інтерпретатора, визнана як проєктне рішення, а не недогляд.

← Усі дописи