Recepty z programátorské kuchařky
Protokol HTTP

Zobraz historii verzí Skryj historii verzí

Aktuální verze kuchařky: listopad 2024

Leták 37-2listopad 2024(Martin Mareš)
* První verze

HTTP (Hypertext Transfer Protocol) je jedním z nejpoužívanějších síťových protokolů. Původně byl vymyšlen pro stahování webových stránek a odesílání webových formulářů, ale postupně si našel spoustu jiných aplikací. Povídají si po něm nejrůznější síťová API, nebo třeba servery a klienti chatovacího protokolu Matrix.

HTTP existuje ve více verzích. My si budeme povídat o nejběžnější verzi 1.1. Už existují i verze 2 a 3, které fungují podobně, ale snaží se o vyšší efektivitu, což je komplikuje.

Po HTTP si povídají dvě strany: server a klient. Server poskytuje nějakou službu (třeba webové stránky), klienti tuto službu využívají. Když se klient chce připojit k serveru, nejdříve si pomocí DNS přeloží jméno serveru na IP adresu. Pak s ní naváže spojení přes TCP typicky na portu 80. TCP spojení si můžeme představit jako obousměrnou trubku mezi serverem a klientem, která umí spolehlivě přenášet bajty – když do ní na jednom konci nějaké vložíme, za nějakou dobu vypadnou z druhého konce.

Pokud nám záleží na bezpečnosti, chceme data šifrovat a podepisovat – o to se typicky stará protokol TLS, který z TCP udělá „bezpečnou trubku.“ Skrz ni pak proudí obyčejné HTTP. Této kombinaci se říká HTTPS a místo portu 80 bydlí obvykle na 443.

HTTP je protokol košatý, takže se naše kuchařka zaměří hlavně na základy. Kdyby vás zajímaly detaily, najdete je ve standardech RFC 9110 a RFC 9112.

Objekty a URL

HTTP popisuje operace na nějakých objektech (resources). V nejjednodušším případě může být objektem soubor na disku serveru a operací „chci stáhnout obsah souboru.“ Nebo je objektem meteostanice na střeše budovy a operací „chci naměřené hodnoty ve formátu JSON.“

Síťové protokoly se obecně na objekty odkazují pomocí jejich URL (Uniform Resource Locator). Typické URL pro HTTP vypadá takto:

http://ksp.mff.cuni.cz/h/ulohy/37/reseni1.html

Skládá se ze schématu (názvu protokolu) http:, za ním následuje //adresa serveru (IP adresa nebo doménové jméno) a případně :port, pokud máme použít jiný TCP port než běžný 80. Zbytek URL je tvořen cestou v rámci serveru. Na další, méně obvyklé součástky URL narazíme později.

Někdy se stane, že do URL potřebujeme zapsat znak, který by jinak měl speciální význam – třeba mezeru. V takovém případě ho zakódujeme jako %xy, kde xy je hexadecimální kód znaku v ASCII. Takže mezera je %20 a procento %25. Nechce-li se nám studovat v RFC 3986, které znaky jsou speciální, kódujme vše kromě písmen, číslic a tečky. a - _ . ~. Ještě dodejme, že občas potkáváme i zkratku URI (Uniform Resource Identifier), což je obecnější pojem, ale v kontextu HTTP mezi nimi není potřeba rozlišovat.

Požadavek a odpověď

Podívejme se, co se děje, když si klient chce stáhnout stránku z uvedeného URL. Spojí se po TCP s ksp.mff.cuni.cz na portu 80 a pošle požadavek:

    GET /h/ulohy/37/reseni1.html HTTP/1.1
    Host: ksp.mff.cuni.cz
    Connection: close

Požadavek má tvar několika řádků textu (pozor, řádky se ukončují CR+LF). První řádek obsahuje metodu GET (to je ta operace, která se má s daným objektem provést – v tomto případě stáhnout si jeho obsah), cestu k objektu v rámci serveru a verzi HTTP, kterou se bavíme.

Následující řádky tvoří hlavičku požadavku složenou z polí tvaru klíč: hodnota. Pole Host je povinné a obsahuje doménové jméno serveru (posílá se proto, aby na jedné IP adrese mohly běžet servery pro více domén). Jelikož chceme být slušní, tak pomocí Connection: close řekneme serveru, že po tomto spojení už nechceme posílat další požadavky.

Požadavek je ukončen prázdným řádkem.

Server pak odpoví třeba takto:

    HTTP/1.1 200 OK
    Content-Type: text/html; charset=utf-8
    Content-Length: 38771
    Connection: close

Na prvním řádku nám server sděluje, že také hovoří verzí 1.1 a že operace byla provedena úspěšně. To říká jednak stavovým kódem 200 (určeným pro stroje), jednak zprávou OK určenou pro lidi.

Následující řádky obsahují hlavičku odpovědi:

Hlavičku opět ukončuje prázdný řádek. Za ním následuje tělo odpovědi – data, která si stahujeme.

Metody

Náš příklad používá metodu GET, ale existují i jiné. Pojďme se podívat na nejběžnější metody. Požadavky s metodou GET a HEAD nemají žádné tělo, s ostatními metodami ho mít mohou.

Kromě nich ještě existují CONNECT, OPTIONS a TRACE, které mají poměrně specifické použítí a nebudeme se jimi zabývat.

Metody GET a HEAD nesmí měnit stav objektu na serveru – prohlížeč se tedy může sám od sebe rozhodnout načíst na pozadí stránku, na níž někde viděl odkaz, a nic tím nezkazí. Takovým metodám se říká bezpečné.

Metody PUT a DELETE sice mění stav, ale jsou idempotentní – pošleme-li stejný požadavek vícekrát, dopadne to stejně jako jednou. (Všimněte si, že smazat nebo nahradit něco dvakrát má stejný efekt, jako to udělat jednou.) Pokud tedy klient zjistí, že se mu spojení se serverem rozpadlo, může GET, HEAD, DELETE nebo PUT automaticky zopakovat; s POSTem by to ovšem dělat neměl (aby vám neobjednal tři krabice zmrzliny místo jedné).

Metoda PATCH nemusí být ani bezpečná, ani idempotentní.

Data požadavku/odpovědi

Jak už víme, požadavek i odpověď mohou obsahovat tělo s daty. Jak se pozná, kde data končí? Jestliže dopředu víme, jak jsou data velká, pošleme Content-Length a problém je vyřešen. Pokud to ale nevíme, pomůžeme si nakouskováním dat. Do hlavičky přidáme Transfer-Encoding: chunked a tělo pošleme jako posloupnost kousků. Každý kousek vypadá takto: délka dat zapsaná hexadecimálně, CR+LF, data kousku, CR+LF. Poslední kousek má délku 0.

Historické verze HTTP po skončení těla zavíraly celé TCP spojení. To bylo zbytečně neefektivní, protože pro stažení každého objektu se muselo navázat spojení nové. Dnes lze poslat po stejném spojení další požadavek a přijmout další odpověď, dokud se jedna ze stran nerozhodne, že chce spojení ukončit. Pokud chceme poslat jen jeden požadavek, dá se toto chování předem zakázat pomocí Connection: close, jak už jsme viděli výše.

Formát dat je specifikován v hlavičce požadavku/odpovědi:

Kromě Content-Type nejsou tato pole povinná.

Další pole hlavičky

Uvedeme ještě pár zajímavých polí hlavičky, všechna jsou nepovinná. V požadavcích:

A v odpovědích:

Ještě dodejme, že ve jménech polí se nerozlišují velká a malá písmena – psát velká počáteční je jenom (dobrý) zvyk. Kromě standardních polí si každý může přidat svá vlastní, jejichž jména začínají na X-. A lidé někdy nepořádně říkají „hlavička“ jednomu poli a „hlavičky“ množině všech polí.

Stavové kódy

Jak už víme, server začíná odpověď řádkem se stavovým kódem. Pojďme se podívat, co kódy znamenají. Uvádíme obvyklé názvy stavů, server ovšem může být kreativní. Méně běžné kódy vynecháváme.

Všechny stavové kódy mohou být doprovázeny tělem. Často to bývá chybová zpráva v HTML, hezky zformátovaná pro pohodlí uživatele.

Použití

Dotazy a předávání argumentů

GET nemusí nutně vracet celý dlouhý dokument. Server nad ním může umět provádět dotazy – například v seznamu vyučujících na webu školy najít položku s konkrétním jménem a příjmením.

Dotaz se specifikuje přidáním argumentů na konec URL (za cestu) ve tvaru ?jmeno=Tim&prijmeni=Berners-Lee. Dotaz začíná otazníkem a pak následují dvojice klíč=hodnota oddělené ampersandy. Pokud hodnoty obsahují nějaké „divné“ znaky, kódují se už známým %xy; místo mezery můžeme poslat +.

Jak přesně přítomnost dotazu ovlivní operaci, je definované serverem. Stejně jako co se stane, když dotaz přidáme k jiné metodě než GET.

Webové formuláře

Součástí webové stránky může být formulář (<form>). Ten nechá uživatele zadat data do pojmenovaných políček a pak tato data odešle serveru na určené URL buď metodou GET, nebo POST. Výběr metody také určí, jakým způsobem se data formuláře odešlou.

Metoda GET je jednodušší. Klient využije syntaxi URL s dotazem a jako argumenty vyplní data formuláře. To se hodí třeba pro vyhledávací okénka nebo stránkování dlouhých seznamů. Server na GET z daného URL může poslat dynamicky generovanou odpověď (třeba podle zadaného vyhledávacího dotazu). Pozor na to, že GET je z definice bezpečná metoda, takže takto odesílané formuláře nesmí měnit stav světa.

Pokud odeslání formuláře má měnit stav (třeba někomu poslat zprávu), nebo pokud je dat tolik, že se do URL prakticky nevejdou, použijeme metodu POST. Tou pošleme tělo s Content-Type: application/x-www-form-urlencoded obsahující data zakódovaná stejně jako dotaz na konci URL. (Pokud by byl součástí formuláře upload souboru, posílá se jiný formát multipart/form-data, který nebudeme rozebírat.)

Autentikace

Pokud chcete po HTTP řídit svou vzducholoď, jistě nestojíte o to, aby jí mohl posílat příkazy každý. HTTP nabízí autentikační mechanismy (kterými může klient dokázat, kdo je) a servery pak na jejich základě klienty autorizují k provedení konkrétní operace. (Přestože HTTP takové věci umí, dnešní weby si obvykle přihlašování řeší samy přes formuláře a kryptograficky podepsané cookies. Ale u různých API je autentikace na úrovni HTTP naprosto běžná.)

Když se klient pokusí přistoupit na stránku vyžadující autorizaci, dostane stav 401 Unauthorized a v hlavičce něco jako WWW-Authenticate: Basic realm="Hrochovo". Z toho se dozvěděl, že má použít autentikační metodu jménem Basic v „říši“ Hrochovo (říše je nápověda pro uživatele, jaký druh účtu potřebuje).

Požadavek pak pošle znovu a do hlavičky přidá něco jako Authorization: Basic aHJvY2g6aHVtcGY=. Tutéž hlavičku pak posílá s dalšími požadavky na totéž URL a často i na URL „pod ním v hierarchii“.

Konkrétní podoba autorizačního řetězce je definovaná autentikační metodou. Pro Basic se řídíme RFC 7617, které nám řekne, že máme vzít řetězec jmeno:heslo a zakódovat ho do Base64. To není šifra, ale usnadní to přenášení divných znaků a také si nedopatřením nepřečtete cizí heslo. (V praxi samozřejmě chcete jakákoliv citlivá data přenášet přes HTTPS místo obyčejného HTTP.)

Server může klientovi nabídnout víc autentikačních metod (oddělených čárkou), klient si z nich jednu vybere.

Syntaxe URL dovoluje uvést jméno a heslo, která se mají použít, jako http://jméno:heslo@doména/… Jelikož aplikace málokdy předpokládají, že URL je citlivý údaj, nebývá to moudré používat.

Optimalizace

Domlouvání na reprezentaci dat

Server může data poskytovat ve více reprezentacích, které se hodí v různých situacích. Například u obrázku může nabízet běžný JPEG, pak JPEG XL (který je menší, ale zatím ho málokdo zná) a PNG (bezztrátové, takže kvalitnější, leč mnohem větší). Jak server pozná, o kterou reprezentaci má klient zájem?

Jedna možnost je využít stavový kód 300 Multiple Choices a poslat klientovi seznam všech variant. Klient, který si vybírat neumí, si prostě z Location přečte default a následuje ho, jako u každého jiného přesměrování. Každý výběr varianty nás ale stojí poslání dalšího požadavku. A navíc jsou varianty popsané odkazy v HTML, takže se těžko zpracovávají strojově.

Dnes je běžnější, že klient pošle serveru své preference a server podle toho sám vybere vhodnou reprezentaci. Preference na MIME-typ dat můžeme poslat třeba takto:

    Accept: image/jxl, image/*; q=0.5, */*; q=0.1

Nabízíme několik variant oddělených čárkami. Varianty můžeme ohodnotit kvalitou mezi 0 a 1 (s přesností na tisíciny). Neuvedená kvalita je rovna 1. Uvedeme-li q=0, explicitně říkáme, že o takový typ nestojíme. Náš příklad tedy říká, že preferujeme JPEG XL, spokojíme se s libovolným jiným obrázkovým formátem, a když není ani ten, přežijeme s čímkoliv.

Server nám pak pošle tu variantu, které vyjde nejvyšší kvalita, případně chybu 406 Not Acceptable, pokud žádná dostupná varianta nesplňuje naše požadavky.

Podobně existují pole Accept-Charset, Accept-Encoding a Accept-Language pro domlouvání se na dalších vlastnostech reprezentace dat. Pokud preference neudáme, server si zvolí po svém.

Kromě toho má HTTP hlavičku požadavku s lehce obskurním názvem TE, která uvádí klientem podporované hodnoty Transfer-Encoding. Ty se ale nehodnotí kvalitou.

Server tedy může pro jedno URL posílat různým klientům různý obsah podle toho, jakou hlavičku požadavku pošlou. Aby bylo jasné, že se něco takového děje, server do odpovědi připíše pole Vary, kde vyjmenuje všechna pole hlavičky požadavku, kterými se řídila volba reprezentace. Třeba Vary: Accept, Accept-Encoding.

Podmíněné operace

Představte si, že máme stažený ohromný soubor a chceme zjistit, zda se mezitím na serveru změnil. K tomu můžeme použít metodu HEAD, jež nám v poli Last-Modified řekne datum a čas poslední modifikace souboru (pokud reprezentace není soubor, ale něco dynamicky generovaného, tak toto pole buď bude rovno aktuálnímu času, nebo bude úplně chybět). Tento čas pak můžeme porovnat s hodnotou z předchozího stažení.

Pozor na to, že hodiny serveru nemusí být synchronizované s našimi, takže nedává smysl porovnávat Last-Modified s naším časem modifikace souboru, do nějž jsme si data uložili. Server nám ovšem v poli Date řekne svůj čas, kdy odpověď vytvořil (takže umíme dopočítat stáří dat).

Časy bývají dost nepraktické identifikátory verzí – data mohou vznikat dynamicky kombinací různých zdrojů, data se mohou měnit vícekrát za sekundu atd. Proto server může verzi dat identifikovat také polem ETag (entity tag). To je nějaký řetězec, který si každý server může vytvořit po svém. Jediná povinnost je, aby jakákoliv změna reprezentace dat vyvolala změnu ETagu. (To striktně vzato není pravda, protože existují i slabé ETagy začínající na W/, které mohou zůstat stejné, pokud se nezměnil význam dat. Ale ty potkáme málokdy.)

Kombinace „zjistím, jestli se data změnila, a pokud ano, stáhnu si novou verzi“ je ovšem natolik častá, že pro ni existuje zkratka. Libovolný požadavek jde podmínit. Pokud například do hlavičky GETu připíšeme pole If-Modified-Since s nějakým časem, dostaneme data pouze tehdy, pokud je jejich Last-Modified pozdější než tento čas. V opačném případě server odpoví stavem 304 Not Modified.

Podobně existuje:

Dodejme, že server se může rozhodnout podmínku ignorovat.

Intervalové dotazy

Teď si představte, že si prohlížíme obrovský videozáznam. Tehdy se hodí stáhnout si jen prvních pár megabytů, abychom nemuseli čekat, až se přenese všechno. A pak postupně stahovat další data, třeba i na přeskáčku, pokud po videu chceme skákat.

HTTP tuto situaci řeší intervalovými dotazy. Nejdříve pošleme HEAD a server odpoví polem Accept-Ranges: bytes. Tím říká, že podporuje intervalové dotazy a že intervaly se uvádí v bajtech (specifikace připouští jiné jednotky, ale zatím žádné nedefinuje).

Intervalový GET pak v hlavičce specifikuje například Range: bytes=1000-1999. Tím si řekne o 1000 bajtů, na což server odpoví stavem 206 Partial Content a v hlavičce dodá Content-Range: bytes=1000-1999/8888, tedy že posílá bajty 1000–1999 z celkem 8888 (pokud není celková délka známá, uvede se /*).

Kdybychom se zeptali na interval -100, dostaneme posledních 100 bajtů. Můžeme uvést více intervalů, ale pak odpověď dostaneme v poměrně komplikovaném formátu. Pokud se zeptáme na interval, který zčásti leží mimo soubor, dostaneme existující podinterval. A pokud leží celý mimo, dostaneme chybu 416 Range Not Satisfiable.

Nezapomeňme, že mezi intervalovými dotazy by se soubor mohl změnit. Často tedy přidáváme If-Match, případně If-Range, s nímž server otestuje, zda objekt odpovídá danému ETagu, a pokud nikoliv, ignoruje Range a pošle celý soubor.

Chování intervalů u jiných metod než GET není zatím definováno.

Proxies

Server se smí rozhodnout, že požadavek nevyřídí sám, ale předá ho někomu jinému. Předávajícímu serveru se říká proxy (zmocněnec). Proxies existují dvou základních druhů: dopředné (ty si vybírá klient) a reverzní (na klienta se tváří jako cílový server, ale požadavky předávají back-endovým serverům, třeba kvůli rozdělování zátěže mezi více strojů).

Pro proxies platí speciální pravidla, které nebudeme rozebírat. Alespoň poznamenejme, že kdykoliv požadavek projde přes proxy, ta by se měla podepsat do pole Via a uvést verzi HTTP, kterou mluvila. U reverzní proxy se navíc hodí, aby předala adresu klienta. Pro to existuje nestandardní, ale běžné pole X-Forwarded-For.

Kešování

Proxy může kromě předávání požadavků i kešovat odpovědi – pamatovat si u požadavků, které přes ni prošly, jaká byla odpověď, a pokud se nějaký klient zeptá na totéž, rovnou mu poskytnout zapamatovanou odpověď. Keše mohou být sdílené (pro více klientů) nebo soukromé (pro jednoho, třeba zabudovaná keš webového prohlížeče).

Keš si ovšem musí dávat pozor, aby vždy poskytovala relevantní data. Proto pro chování keší existují poměrně přísná pravidla standardizovaná v RFC 9111 (interní keše v prohlížečích se jimi bohužel ne vždy řídí, což je častým zdrojem potíží). Především se obvykle kešují jenom odpovědi na GET a HEAD (přičemž negativní odpovědi jen velmi opatrně). Sdílená keš neukládá odpovědi na požadavky, které obsahovaly autorizaci. Aby se nerozbíjel mechanismus domlouvání na reprezentaci, musí keš při objevení pole Vary v zakešované odpovědi ověřit, že se nový požadavek shoduje s původním ve všech polích, jež byla ve Vary uvedena.

Keš si pro každou odpověď spočítá, jak dlouho ji může poskytovat dalším klientům. Po uplynutí této doby je odpověď prošlá. Prošlou odpověď může keš revalidovat – poslat serveru HEAD nebo podmíněný požadavek, aby si ověřila, že uložená odpověď je stále platná. Keše smí v některých případech revalidaci vynechat a dávat klientům prošlé odpovědi (například není-li server zrovna dostupný).

Server může v poli Cache-Control sdělit instrukce pro kešování (oddělené čárkami):

Kromě max-age může server poslat též pole Expires, kterým uvádí čas (serverový), do kdy je odpověď platná. Pokud nepošle ani jedno, smí keš dobu platnosti odhadnout.

Klient může také instruovat keše pomocí Cache-Control, konkrétně:

Ještě doplníme, že odpověď z keše se doprovází polem Age, jehož hodnota říká, kolik sekund uplynulo od zakešování nebo revalidace odpovědi.

Knihovny

HTTP je docela rozsáhlé a implementovat vlastní server nebo klienta dá dost práce a znamená to strávit pár dní detailním studiem specifikací. Proto nejspíš chcete použít nějakou existující knihovnu. Můžeme doporučit například:

Také vás mohou inspirovat vývojářské nástroje webových prohlížečů, které umí ukazovat hlavičky HTTP požadavků odeslaných prohlížečem i odpovědí na ně.

Dnešní menu servíroval

Martin Mareš