Hlavní navigace

Minimalistický http server v C++

29. 9. 2018 15:39 (aktualizováno) Ondřej Novák

Každý určitě má nějaký koníček. A v rámci svých koníčků člověk často dělá věci, které zdánlivě nemají smysl, nebo v dané době už jsou překonané. Prostě vynalézáme poněkolikáté kolo. Přesto to může být zábava

V tomto článku popíšu, jak jsem naprogramoval minimalistický http server v C++ 17. Začalo to přitom nevině, na začátku byla snaha zobrazit obsah adresáře v prohlížeči. Tím samozřejmě myslím stránky, které v tom adresáři byly uložené jako sada html souborů a přidržených stylů, obrázků a skriptů. Za normálních okolností si vystačíme s protokolem file:/// který drtivá většina prohlížečů zvládne, takže stačí otevřít zpravidla index.html přímo z adresáře a vše je vidět. Ne vždy ale stránky fungují, což má logiku, pokud je tam vyžadována nějaká komunikace se vzdáleným serverem, ale bohužel často se stává, že nefungují ani, když komunikaci nevyžadují. Na vině je delší dobu utlačovaný význam protokolu file:///, díky kterému prohlížeč mnoho věcí blokuje. Zejména různé moderní funkce jako média, poloha, XHR, atd, prostě přes file:/// nerozeběhnete. (ano XHR, třeba na stažení dalších souborů javascriptem)

Hledal jsem řešení, jak co nejjednodušeji přenést adresář do http prostředí. A místo abych jednoduše vzal nginx/apache a do jejich document_root strčil symlink na ten adresář, což by asi udělal každý, tak jsem otevřel stránky Google a našel… docela zajímavý github s implementací miniaturního http serveru v C. Vzpomněl jsem si na legendární slova Linuse Torvaldse o tom, že „C++ is a horrible language“ a rozhodl jsem se, že ukážu, že to tak není a že lze napsat cosi základního v C++ a to s elegancí. Výzva byla na světě.

Projekt minihttp server najdete na Githubu. Jedná se o minimalizovaný http server (pouze http a pouze verze 1.1 a to ještě limitované) napsaný v C++17. Snažil jsem se maximálně používat C++ a do čistého C přecházet jen v případě, kdy to bylo nutné – typicky kvůli Posixu, protože C++ ani ve verzi 17 neumí v základu například práci se sokety, a protože bylo podmínkou čistá implementace bez knihoven třetích stran, vyloučil jsem i použití boostu. 

Samotný program se po přeložení ovládá jednoduše

minihttp localhost:8800 ./www

Ukázka čistá instalace

Ukázka webu

Co je uvnitř?

Program minihttp je naprogramován jako jeden jediný cpp soubor – obsahuje vše, co potřebuje a pro překlad si vystačí s gcc 8 a výše. Původní C implementace používá fork() pro vytváření workerů, moje implementace s výhodou používá standardní std::thread. Díky C++17 se konečně do normy dostává std::string_view a tento objekt se v programu hojně využívá zejména při parsování požadavků. Pokud program pracuje s descriptory a sockety, nepracuje s nimi nikdy přímo. Místo toho tam naleznete šablonu RAII, která obaluje tyto číselné deskriptory do třídy, která pak sleduje vlastnictví deskriptoru a je schopna jednotlivý deskriptor automaticky uzavřít jakmile ztratí platnost. Z výhodou se tady používá r-value reference a std::move()

Programátor začátečník by měl v kódu najít několik postupů jak docílit

  • otevřít port na rozhraní a proč k tomu použít getaddrinfo
  • pracovat s IPv6 (zjistí, že s tím není žádná práce)
  • jak ukončit čekání na accept a že to jde i ze signal handleru
  • jak číst data ze streamu a neztratit ani bajt

U toho posledního se maličko zastavím. Viděl jsem mnoho implementací čtení klasického socketu a málokterá byla správně. Základní problém většiny implementací je ten, že funkce recv (nebo read) nemusí vždy přečíst tolik, kolik se od ní žádá. Pokud načte méně, pak je třeba čtení opakovat. Viděl jsem ale kód, kde se s tím nepočítalo a ten kód fungoval z 99%. V tom 1% se paket náhodou rozdělil přesně v tom místě, kde se očekával souvislý blok a výsledkem byl prostě crash.

Největší problém vždycky představuje čtení dat, které končí nějakým znakem. Většina čtecích funkcí totiž vyžaduje znát délku čtených dat a …pokud data končí nějakým znakem, jakou délku mám zadat, když to nevím? Pokud už načtu nějaký buffer a zjistím, že data, která parsuji, končí „kdesi uprostřed“, co udělat se zbytkem dat, aby se neztratila?

Trošku odlišný přístup je právě prezentován v třídě Conn. Tam najdete funkci, která se jmenuje jednoduše read(). Nemá žádné argumenty, pouze vrací std::string_view, který nese část načtených dat. Přijde mi totiž zbytečné, abych dopředu hlásil, kolik toho budu chtít. Přece dokud jsou data, tak čti. Volající pak dostává úseky dat tak jak přímo lezou ze streamu a které, mezitím než přilezou další, může nějak zpracovat. A když je nalezen konec, co udělat se zbytkem bufferu? K tomu tam existuje funkce put_back, která část bufferu vrátí a tak se žádná data neztratí, budou načtena další funkcí read(). 

Zmiňuji se o tom proto, že málokdy vidím API, které by takto bylo navrženo. Spíš častěji vidíme prototyp: size_t read(void *buffer, si­ze_t velikost), což je velice nepohodlné. 

VSCGI a proxy

Zjistil jsem, že po dopsání serveru jej i používám (!). Jasně, pořád jsem dost línej na to vytvořit si ten symlink. Ale protože mám víc vývojových prostředí a některé i ve virtualech, tak nemám na všech instalaci nginx/apache, a tak se ukázalo pohodlnější stáhnout a přeložit program z githubu, než se prokousávat nastavení webserveru. Chyběla mi tam možnost na určitou cestu v rámci stránek namapovat jinou URL, třeba proxy k serverovému API, zejména proto, abych se vyhnul nutností zapínat CORS na serveru (ne vždy je to možné). 

Cílem tedy bylo mít možnost přeposlat určité requesty na jiný server. Zdánlivě jednoduchá úloha není jednoduchá jak se zdá. A nezápasím s tím jen já. Kolega z mého týmu je html/js programátor, který, protože trvale pracuje s javascriptem, je také vlastníkem plnohodnotné instalace node.js. Ten moje koníčky neocení, protože jeho nástroje tohle všechno umí v základu. Přesto nejednou zápasíme s konfigurací jeho developerského webového serveru tak, aby se domluvil s našimi servery poskytující API. Při každé příležitosti se vynořují jiné problémy. Tu nefunguje POST, tu si webserver při proxování nerozumí s certifikátem, onehdy to prostě nefungovalo vůbec a jak se nám to naposled podařilo rozjet se mne ani neptejte. 

Když už jsem zmínil certifikáty, tak potřeba připojovat se k https mi nejprve vzala vítr z plachet. V rámci totální minimalizace bych nechtěl řešit openssl…

Nakonec jsem s k tomu vrátil s tím, že by http klient mohl být realizován externě, pomocí programu curl, ten přece zvládne vygenerovat libovolný request a na standardní výstup předat odpověď včetně hlaviček. Zbývalo jen vymyslet, jak ho napojit na minihttp. A tak vznikl protokol VSCGI, který je inspirován CGI protokolem. Vadilo mi, že CGI je velice „těžký“ a proto mým řešením je výrazně osekaný CGI protokol, jehož zkratka znamená: Very Stupid CGI protokol (nebo Very Simple?)

VSCGI je definován následovně: Zavádí se soubor s příponou .vscgi, který, pokud se nachází v cestě požadavku, a přitom může být uprostřed cesty, je spuštěn jako proces, přičemž obdrží parametry: Metodu (POST, GET, PUT),  zbytek cesty (pokud se soubor nachází uprostřed) a verzi protokolu (HTTP/1.1). Zbytek hlaviček a tělo requestu je odeslán na standardní vstup. Spuštěný process se také může spolehnout, že request končí zavřením standardního vstupu, takže nemusí složitě odpočítávat bajty s Content-Lenght. Jakmile je request načten, očekává se, že process vygeneruje odpověď. Odpověď tentokrát musí být kompletní včetně hlaviček a tělíčka. Odpověď také končí zavřením standardního výstupu. 

Zbytek proxování zařídí skript minihttp_proxy, který je v rámci zpracování požadavku zavolán. Mělo by to fungovat na první dobrou. Od toho okamžiku máte v rámci statických stránek v rámci domény localhost možnost posílat requesty na externí URL aniž by se jednalo o CORS requesty.

FFFilm.name proxován na localhost přes vscgi proxy

Pro koho je to určeno?

Jak jsem psal v úvodu, vzniklo to jako koníček, něco, co v zásadě nemá žádný velký význam. Přesto si myslím, že to někomu může připadnout užitečné, například začínajícím vývojářům. A protože necílím na zahraniční vývojáře, najdete v kódu všechny komentáře česky. 

Jinak si myslím, že na adhoc web server to také může být užitečné, zvlášť pokud nemáte k dispozici webserver, pracujete na omezeném účtu (webserver potřebuje root práva k instalaci) a chcete si zobrazit obsah adresáře jako http stránky.

Stránky projektu: https://github.com/ondra-novak/minihttp