Indy'yi Genişletmek Yerine Neden Özel Bir HTTP/2 Yığını Oluşturduk

· İncelemeler

Bize en çok sorulan soru

sgcWebSockets'te ne zaman yeni bir HTTP/2 özelliği yayınlasak — server push, HPACK dinamik tablo ayarı, SETTINGS çerçeve müzakeresi — biri forumda kibarca aynı soruyu soruyor: “Neden Indy'nin TIdHTTP'sini genişletmediniz? Zaten TCP, TLS ve HTTP/1.1 yapıyor, herhalde HTTP/2 onun üzerinde yalnızca bir başka çerçeveleme biçimidir.”

Bu makul bir soru. Kısa yanıt şudur: HTTP/2, bir HTTP/1.1 istemcisinin üzerine ekleyebileceğiniz bir katman değildir — Indy'nin yaptığı neredeyse her varsayımı bozan, temelden farklı bir taşımadır. Bu yazı, HTTP/2 uygulamamızı neden sıfırdan oluşturduğumuzu, bunun ne gerektirdiğini ve sgcWebSockets'in geri kalanını nasıl etkilediğini adım adım açıklar.

HTTP/1.1 metindir, HTTP/2 ikili çerçevelerdir

Indy'nin HTTP uygulaması, her klasik HTTP/1.1 istemcisi gibi, CRLF ile sonlandırılmış bir istek satırını, ardından başlıkları, ardından bir content-length gövdesini veya bir chunked gövdesini okur. Bu, satır yönelimli metindir. Taşıma; bir istek, bir yanıt, ardından ya kapatma ya da pipeline biçimindedir (ve pratikte kimse gerçekten pipeline kullanmaz).

HTTP/2 ikili bir çerçeveleme protokolüdür. Bağlantı ön ekinden sonraki her bayt bir çerçeveye aittir: DATA, HEADERS, PRIORITY, RST_STREAM, SETTINGS, PUSH_PROMISE, PING, GOAWAY, WINDOW_UPDATE, CONTINUATION. Çerçeveler isteklerle hizalı değildir — tek bir isteğin başlıkları birkaç HEADERS / CONTINUATION çerçevesine yayılabilir, gövde birçok iç içe geçmiş DATA çerçevesi olarak gelebilir ve farklı isteklerden gelen çerçeveler aynı sokette birbirine karışır. Okuyucu bir satır ayrıştırıcısı değildir; sabit uzunluklu çerçeve başlıkları ve değişken uzunluklu yükler üzerinde çalışan bir durum makinesidir.

Bunu TIdHTTP.ReadHeaderFromStream üzerine uyarlamak, okuma döngüsünü tamamen yeniden yazmak anlamına gelirdi. Bu noktada Indy'yi genişletmiyorsunuz — yalnızca bir soket sınıfını paylaşan paralel bir uygulama yazıyorsunuz.

Çoğullama, bağlantı başına tek istek varsayımını bozar

Indy'nin HTTP istemci modeli, tek bir devam eden istek etrafında kurulmuştur. Get çağırırsınız, metot engellenir, yanıt gelir, onu işlersiniz. TCP soketi bu çağrı süresince o çağrıya aittir. HTTP/2 bunu tersine çevirir: tek bir bağlantı, her biri bir akış kimliğiyle tanımlanan birçok eşzamanlı akışı çoğullar. Tipik bir Apple Push Notification bağlantısı, aynı TCP soketi üzerinden binlerce bağımsız POST isteği gönderir ve yanıtlar sırasız gelir.

Bunu Indy'nin engelleyen modeliyle desteklemek için ya (a) HTTP/2 akışı başına bir Indy istemcisine — ki bu HTTP/2'nin tüm amacını boşa çıkarır — ya da (b) soketin sahibi olan, çerçeveleri okuyan, bunları akış başına kuyruklara yönlendiren ve çağıranlara bir futures/promises API'si sunan ayrı bir dispatcher thread'ine ihtiyacınız olur. (b) seçeneği esasen yeni bir HTTP/2 istemcisi yazmak ve Indy'yi altta ince bir soket sarmalayıcısı olarak park etmektir. Bunun yerine, bunu platform için en uygun taşımayla açıkça yapmayı seçtik.

HPACK bir durum makinesidir, bir dize değil

HTTP/1.1 başlıkları ad-değer metnidir. HTTP/2 başlıkları, yaygın başlıkların statik bir tablosunu, bağlantı ilerledikçe büyüyen dinamik bir tabloyu ve Huffman ile kodlanmış literalleri birleştiren HPACK (RFC 7541) ile sıkıştırılır. Bir başlık kümesini kodlamak WriteLn(Stream, 'Content-Type: application/json') değildir — iki tabloya karşı bir arama, indexed/literal-with-indexing/literal-without-indexing/literal-never-indexed gösterimleri arasında bir seçim, isteğe bağlı Huffman sıkıştırması ve her iki uç noktanın senkronize tutması gereken dinamik tabloda bir güncellemedir.

Tablo boyutunu yanlış ayarlayın ve bağlantı bir COMPRESSION_ERROR ile ölür. Maksimum başlık tablosu boyutu için SETTINGS güncellemesini uygulamayı unutun ve daha önce çalışan bir sunucu isteklerinizi reddetmeye başlar. Kısayol yoktur — ya HPACK'i tam olarak uygularsınız ya da HTTP/2'niz olmaz. Statik tabloyu, eviction'lı dinamik tabloyu, Huffman kodlama/kod çözmeyi ve dört gösterim biçimini kabaca 1.200 satır Pascal ile uyguladık. Bu kod Indy'de hiçbir yerde yoktur ve orada doğal bir yeri olmazdı.

Akış denetimi akış başına ve bağlantı başınadır

HTTP/2'nin iki katmanlı penceresi vardır: bir bağlantı düzeyi penceresi ve akış başına bir pencere. Her ikisi de 65.535 baytta başlar ve yalnızca karşı taraf WINDOW_UPDATE çerçeveleri gönderdiğinde büyür. Pencereyi denetlemeden DATA yazan saf bir uygulama, ilk 64 KB'den sonra bağlantıyı askıya alır. Doğru bir uygulama; pencere tükendiğinde yazıcıları duraklatan, büyük gövdeleri pencere boyutunda parçalara bölen ve güncellemeler geldiğinde devam eden bir olay döngüsüne ihtiyaç duyar.

Bunların hiçbiri HTTP/1.1'de yoktur ve dolayısıyla hiçbiri Indy'de yoktur. Bir yerde bulunması gereken gerçek bir iş parçasıdır — biz bunu çerçeve okuyucusu ve HPACK codec'iyle aynı yere koyduk.

Yazıcı döngümüz nasıl görünüyor

İşte çerçeve yazıcısının, okunabilirlik için basitleştirilmiş küçük bir bölümü. Açık akış denetimi kontrolüne ve yükü pencere sınırlı parçalara bölen döngüye dikkat edin:

procedure TsgcHTTP2Stream.WriteData(const aData: TBytes; aEndStream: Boolean);
var
  vOffset, vChunk, vMax: Integer;
  vFlags: Byte;
begin
  vOffset := 0;
  while vOffset < Length(aData) do
  begin
    // Respect both connection and stream flow-control windows.
    vMax := Min(FConnection.SendWindow, Self.SendWindow);
    vMax := Min(vMax, FConnection.PeerSettings.MaxFrameSize);
    if vMax <= 0 then
    begin
      // Block until a WINDOW_UPDATE wakes us.
      FWindowEvent.WaitFor(INFINITE);
      Continue;
    end;
    vChunk := Min(Length(aData) - vOffset, vMax);
    vFlags := 0;
    if aEndStream and ((vOffset + vChunk) = Length(aData)) then
      vFlags := FLAG_END_STREAM;
    FConnection.SendFrame(FRAME_DATA, vFlags, Self.StreamId,
      Copy(aData, vOffset, vChunk));
    Dec(FConnection.SendWindow, vChunk);
    Dec(Self.SendWindow, vChunk);
    Inc(vOffset, vChunk);
  end;
end;

Bu birkaç düzine satırdır, ancak HTTP/2'nin neden ince bir eklenti olamayacağının özünü yakalar. Herhangi bir HTTP/1.1 istemcisinde eşdeğer bir kod yolu yoktur, çünkü kavram basitçe mevcut değildir. Bunu TIdHTTP.Get üzerine ekleyin ve tüm çağırma kuralının — istek başına bir engelleyen çağrı — yanlış biçimde olduğunu hemen keşfedersiniz.

ALPN, ayar müzakeresi ve bağlantı yaşam döngüsü

TLS üzerinden HTTP/2, el sıkışmadan önce TLS bağlamında yapılandırılması gereken ALPN (Application-Layer Protocol Negotiation) aracılığıyla seçilir. Indy'nin OpenSSL IO işleyicisi yıllarca ALPN'yi açığa çıkarmadı — sonunda eklendi — ancak ALPN yalnızca ilk adımdır. TLS el sıkışmasından sonra istemci, 24 baytlık bağlantı ön ekini, ardından bir SETTINGS çerçevesini göndermeli, ardından sunucunun SETTINGS çerçevesini beklemeli ve onaylamalıdır. Ancak o zaman bağlantı istekler için hazır olur. Windows'taki SChannel, OpenSSL'den farklı bir ALPN kurulum yolu gerektirir. msquic'in ise bir başkası vardır. Bunları düzgün bir şekilde soyutlamak, Indy'nin mevcut TLS el sıkışma kodu içinden geçirmektense temiz bir modülde daha kolaydı.

Performans: rakamlar

Tüm bu işi yapmanın getirisi ölçülebilir. Temsili bir iş yükünde — modern bir Windows sunucusunda APNs'ye (HTTP/2 modunda apns2) bir milyon Apple Push Notification gönderen bir Delphi istemcisi:

Aynı donanımda ve aynı ağda, uygulama mantığını değiştirmeden kabaca 10 kat iyileştirme. Bu, yığını paralel HTTP/1.1 bağlantılarıyla taklit etmeye çalışmak yerine yerel olarak oluşturduğunuzda HTTP/2 çoğullamasının sunduğu değerdir.

Indy'den neyi yeniden kullandık

Her şey yeniden yazılmadı. Temel TLS sağlayıcılarından biri olarak Indy'nin OpenSSL TIdSSLIOHandlerSocketOpenSSL bileşenini (kendi ALPN yamamızla), varsayılan arka uçtaki ham soket katmanı için Indy'nin TIdTCPClient bileşenini kullanıyoruz ve Indy'nin TIdHTTPServer bileşeni HTTP/1.1 yedeği için resimde kalıyor. HTTP/2 katmanı; okuma döngüsünün, çerçeve ayrıştırıcısının, HPACK'in, akış denetiminin ve akış yaşam döngüsünün sahibi olarak üstte yer alır. Bu ayrım, HTTP/2 semantiğini Indy'nin engelleyen çağrı modelinden bağımsız tutarken TLS altyapısını paylaşmamıza olanak tanır.

Diğer protokoller için dersler

Aynı desen sgcWebSockets boyunca tekrar eder. WebSocket, bir HTTP yükseltmesine eklenmiş ikili bir çerçeveleme protokolüdür — çerçeveleyiciyi sıfırdan yazdık. MQTT 5'in kendi değişken uzunluklu tamsayı kodlaması, özellikler tablosu ve akış denetimi vardır — sıfırdan yazıldı. AMQP 1.0'ın tam bir tip sistemi ve link credit akışı vardır — sıfırdan yazıldı. HTTP/3 QUIC üzerindedir ve hiç TCP değildir — msquic / ngtcp2 sarmalıyoruz. Ortak nokta şudur: her modern protokolün kendi durum makinesi vardır ve onu “yalnızca HTTP/1.1'in başka bir modu” gibi göstermek kırılgan, yavaş koda yol açar.

Kapanış düşünceleri

Indy'yi genişletmek bariz ilk içgüdüdür — her Delphi geliştiricisinin zaten güvendiği kütüphanedir. Ancak HTTP/2, HTTP/1.1'in bir uzantısı değildir, bir yeniden tasarımdır. Çerçeve çoğullama, HPACK, akış denetimi ve sırasız yanıtların her biri, engelleyen bir HTTP/1.1 istemcisinde basitçe var olmayan altyapı gerektirir. Onu yerel olarak oluşturmak gerçek bir mühendislik çabasına mal oldu, ancak HTTP/2'nin vaat ettiği büyüklük mertebesindeki performansı sunmanın tek yolu budur — ve sgcWebSockets'in o zamandan beri sunduğu diğer her modern protokol için şablonu belirledi.