Od tematu magazynowego do praktyki projektowej
Pasujące strony usługowe i techniczne do artykułu
„JSON w Delphi” brzmi jak rozwiązany problem: System.JSON jest dostępny, wywołania REST zwracają tekst, i po sprawie. W praktyce prawdziwe błędy pojawiają się jednak tam, gdzie JSON nie występuje jako wygodny ciąg znaków, lecz jako strumień: HTTP-Response-Stream, plikowy strumień, Named Pipe, Message-Queue lub duży BLOB z bazy danych. Wtedy spotykają się trzy kwestie, które w codziennej pracy bywają lekceważone: zachowanie pamięci, kodowanie znaków (w szczególności UTF-8) oraz przypadki brzegowe związane ze znakami specjalnymi.
Ten artykuł pokazuje czyste, wydajne podejście do parsowania JSON z TStream bez generowania zbędnych kopii — i przede wszystkim bez typowych pułapek UTF-8, przy których znaki diakrytyczne ulegają „zniekształceniu” lub parsery sporadycznie przerywają pracę z krypticznymi komunikatami. Nacisk położony jest na konsekwencje dla eksploatacji i stabilności interfejsów: powtarzalne debugowanie, jasne granice podejścia oraz kryteria, kiedy nakład pracy naprawdę się opłaca.
Dlaczego strumienie zachowują się inaczej przy parsowaniu JSON w Delphi
Dopóki dokument JSON jest mały, droga „odczytaj strumień do Stringa, potem parsuj” jest wygodna. Przy pewnej wielkości payloadu (typowo: duże listy, raporty, dane synchronizacyjne, eksporty logów) staje się to jednak kosztowne:
- Duplikaty pamięci: Odczytujesz bajty do bufora, konwertujesz je do Unicode-Stringa (String w Delphi = UTF-16), parser wewnętrznie tworzy dodatkowe struktury. To może tymczasowo oznaczać kilka kopii.
- Obciążenie GC/ster y (Heap): Wiele tymczasowych stringów i wartości JSON zwiększa fragmentację i narzut alokacji, szczególnie w procesach długotrwałych (usługi, workerzy, zadania importu).
- Obraz błędu staje się niejasny: Jeśli przy odczycie dokonano błędnych założeń co do kodowania, parser JSON zobaczy jedynie „dziwne znaki” lub nieoczekiwane bajty sterujące.
Ważne jest wyraźne rozdzielenie: JSON formalnie jest Unicode, na łączu prawie zawsze występuje jako UTF-8. Delphi działa wewnętrznie jednak na UTF-16. Przejście od bajtów (Stream) do znaków (String) to miejsce, w którym pojawiają się problemy ze znakami specjalnymi — nie w samym JSON.
System.JSON: Co potrafi dobrze — i na co musisz uważać
System.JSON jest w Delphi standardem dla JSON opartego na DOM: otrzymujesz model obiektowy (TJSONObject, TJSONArray), możesz pobierać wartości, iterować, serializować. To jest solidne dla typowych integracji biznesowych, ale ma dwie konsekwencje:
- To nie jest prawdziwy parser strumieniowy: Model obiektowy jest budowany w całości. Co prawda możesz czasem zaoszczędzić odczyt do osobnego Stringa, ale DOM nadal jest pamięciochłonny.
- Wejście parsera to zazwyczaj tekst: W zależności od wersji Delphi i używanej API szybko wracasz do Stringa, łącznie z konwersją kodowania.
Jeśli twoim celem jest „szybki parser strumieniowy”, w praktyce chodzi zwykle o jedno z dwóch: (1) brak zbędnych kopii i (2) możliwie wczesne fail-fast przy uszkodzonych payloadach. Oba cele są osiągalne z System.JSON, o ile kontrolujesz etap zamiany bajtów na tekst.
Pułapki UTF-8 przy znakach specjalnych: typowe przyczyny
Jeśli umlauty (ä/ö/ü/ß) lub inne znaki specjalne w wyniku wyglądają nieprawidłowo (ä, – itd.), to niemal zawsze jest to niezgodność kodowania. W środowisku Delphi przyczyny te występują szczególnie często:
1) ANSI-Fallback durch „bequeme“ Helper
Niektóre ścieżki odczytu domyślnie przyjmują systemowe kodowanie ANSI (codepage systemu Windows), jeśli nie podano jawnie kodowania. Widać to dopiero, gdy payload zawiera coś więcej niż ASCII. W danych testowych często przypadkowo „działa”, w produkcji wychodzi to przy prawdziwych nazwach, miejscach, tekstach wolnego formatu.
2) BOM-Verwirrung (Byte Order Mark)
UTF-8 może zaczynać się od BOM (bajty EF BB BF). W kontekście webowym BOM jest raczej rzadki, w plikach się zdarza. Niektóre czytniki rozpoznają BOM i dostosowują kodowanie, inne tego nie robią lub tylko w określonych trybach. Jeśli BOM trafi do łańcucha jako zwykły znak, często zobaczysz niewidoczny „Zero Width No-Break Space” na początku albo parser JSON zakończy się niepowodzeniem już przy pierwszym tokenie.
3) Doppel-Konvertierung (UTF-8 wird „nochmal“ interpretiert)
Typowy obraz błędu „ä“ zamiast „ä“ powstaje, gdy bajty UTF-8 najpierw zostały poprawnie zdekodowane do Unicode, ale później ponownie błędnie zinterpretowane jako bajty ANSI/UTF-8 (lub odwrotnie). W środowisku Delphi dzieje się to chętnie, gdy konwersje między TBytes, RawByteString i string są niejasne.
4) Trunkierung mitten in einem Multibyte-Zeichen
UTF-8 koduje znaki specjalne w 2–4 bajtach. Jeśli czytasz w chunkach (np. 8 KB) i granica chunka przypada w środku znaku, dekoder musi to poprawnie buforować. Naiwne podejście, które każdy chunk konwertuje osobno na string i skleja, generuje nieprawidłowe sekwencje. Może to wyglądać jak „sporadyczny” błąd, zależny od granic pakietów, zachowania proxy lub HTTP-Chunkingu.
5) Falsche Annahmen aus HTTP-Headern
W przypadku REST źródłem często jest Content-Type: application/json; charset=utf-8. Niektóre serwery jednak nie podają charset, inne podają błędne informacje. Jeśli ślepo korzystasz z nagłówka, może to zmieniać się w zależności od wersji backendu. Dla działu operacyjnego i wsparcia pomocne jest sprawdzenie rzeczywistego strumienia bajtów i logowanie go w przypadku błędu.
Ein sauberer Ansatz: Stream → UTF-8-Decoder → JSON-Parser
Robustna pipeline składa się z trzech wyraźnych etapów:
- Czytać bajty ze strumienia (kontrolowane, ewentualnie z limitem/timeoutem po stronie klienta HTTP).
- Dekodowanie do Unicode z wyraźnie określonym UTF-8 (opcjonalne tolerowanie BOM).
- Parsowanie mit System.JSON do modelu obiektowego lub do selektywnej ekstrakcji.
Najważniejszym dźwignią jest etap 2: nie chcesz, aby gdziekolwiek decydowało „Default Encoding”. W środowisku Delphi oznacza to: jawne ustawienie TEncoding.UTF8 i nieufność wobec implicitnych konwersji.
Co dokładnie oznacza tutaj „szybko”
Dzięki System.JSON nie „wyeliminujesz” DOM-u. Możesz jednak uniknąć:
- dodatkowej kopii całej payload jako pośredniego łańcucha znaków, jeśli wewnętrznie i tak potrzebujesz tylko kilku wartości (w takim przypadku lepszy będzie inny parser; o tym później),
- wielokrotnego ponownego kodowania,
- i możesz kontrolowanie wczytywać bardzo duże payloady (z limitem rozmiaru i czytelnym komunikatem o błędzie), zamiast kończyć na Out-of-Memory lub Access Violations.
Konkretna sytuacja brzegowa: znaki specjalne uszkodzone, lecz tylko czasami
Jeden z praktycznych przypadków jest szczególnie podstępny: payload jest zasadniczo poprawnym UTF-8-JSON, ale odczytujesz go w kawałkach (Chunks) i konwertujesz każdy kawałek na string. Dopóki występuje tylko ASCII, nic nie zauważysz. Gdy znak diakrytyczny znajduje się dokładnie na granicy kawałka, powstają nieprawidłowe sekwencje UTF-8. Rezultat: albo uszkodzone znaki, albo błąd parsowania w miejscu, które nie odpowiada rzeczywistej zawartości.
Jak to rozpoznać:
- Błędy parsowania występują „losowo” przy dużych odpowiedziach, a nie przy małych.
- To samo żądanie czasem przechodzi, czasem nie (w zależności od Chunking/Transport).
- Heksdump bajtów pokazuje prawidłowe UTF-8, ale zalogowany przez ciebie string zawiera znaki zastępcze (�) lub klasyczne Mojibake.
Rozwiązaniem nie jest używanie „mehr zu readln” ani „większych Buffer”, lecz użycie dekodera, który poprawnie buforuje sekwencje wielobajtowe przez granice Chunk-ów. Dokładnie tutaj TStreamReader w połączeniu z kodowaniem UTF-8 może być pomocny — pod warunkiem prawidłowej inicjalizacji.
Przewodnik praktyczny: powtarzalne sprawdzenie UTF-8 w Delphi
Zanim zaczniesz dłubać przy parserze, potrzebujesz konfigurację do debugowania, która ujawni faktyczną sekwencję bajtów. Dla wsparcia i eksploatacji to bezcenna wartość, ponieważ później będziesz mógł jasno stwierdzić, czy druga strona wysyła błędne dane, czy twoja pipeline źle dekoduje.
1) Sprawdzić pierwsze bajty (BOM, początek JSON)
Jeśli JSON przychodzi z BOM, na początku strumienia zobaczysz EF BB BF. Bezpośrednio po tym powinien zazwyczaj wystąpić „{“ lub „[“. Jeśli w stringu widzisz już „“, oznacza to, że BOM nie został potraktowany jako BOM, lecz zdekodowany jako tekst.
2) Logowanie surowych bajtów w Hex – aber begrenzt
Nie loguj kompletnych payloadów w produkcji (ochrona danych, koszty, objętość logów). Sprawdzają się:
- Prefiks (np. pierwsze 256 lub 1024 bajty),
- Sufiks (ostatnie 256 bajtów),
- oraz hash (SHA-256) do korelacji, jeśli musisz porównywać payloady.
Dzięki temu często w ciągu minut możesz przyporządkować problemy ze znakami specjalnymi: Czy sekwencja bajtów dla „ä” jest poprawna (C3 A4)? Czy występuje trunkacja? Pojawia się nieoczekiwane 0x00 (Nullbyte), np. w wyniku błędnego założenia UTF-16?
3) Logowanie Content-Type i Charset
Przy HTTP/REST: Zaloguj Content-Type i zadeklarowany charset. Jeśli bajty wyraźnie są w UTF-8, ale charset twierdzi coś innego, klient nie powinien ślepo ufać. Dla JSON UTF-8 jest de-facto standardem. W razie wątpliwości: analiza bajtów ma pierwszeństwo przed nagłówkiem.
Szybki parser strumieniowy z System.JSON: projekt bez zbędnych kopii
Praktyczny wzorzec to: czytasz ze strumienia do bufora bajtów, tworzysz z niego String dokładnie raz z użyciem UTF-8 i przekazujesz ten String do parsera JSON. To nie jest „streaming” w sensie SAX, ale jest to kontrolowany, wydajny potok bez niespodziewanych zmian kodowania.
Ważne dla architektury: zaprojektuj funkcję tak, by centralnie w jednym miejscu decydowała o:
- Jakie zasady kodowania obowiązują (zwykle UTF-8, BOM opcjonalnie)?
- Jak duża może być maksymalnie payload (ochrona przed DoS, granica operacyjna)?
- Jak wyglądają komunikaty o błędach (z kontekstem, ale bez wycieku danych)?
Strategia odczytu: ograniczone buforowanie zamiast „StreamToString” bez limitu
Jeśli przyjmujesz JSON z zewnętrznych źródeł (partnerzy, klienci mobilni, dostawcy zewnętrzni), limit rozmiaru jest obowiązkowy. Bez limitu pojedyncze pechowe żądanie może sprowokować presję pamięci w serwisie. W praktyce oznacza to: podczas czytania zliczać sumę bajtów i przerwać po przekroczeniu granicy — z jasnym wyjątkiem, który będzie zrozumiały w monitoringu.
Dlaczego często oczekuję UTF-8 „bez BOM“, ale BOM toleruję
W payloadach REST BOM występuje rzadko. W plikach (eksportach, edycji ręcznej) zdarza się częściej. Dla solidnych ścieżek importu warto tolerować BOM, ale rejestrować go w logu, ponieważ może wskazywać na „świat plików” zamiast „świat API”.
Cisi zabójcy: TStreamReader-Defaults i mieszane użycie czytników tekstu
TStreamReader jest wygodny, ale musisz mieć solidnie opanowane dwie kwestie:
- Ustawić encoding jawnie: Nie polegać na tym, że on to „rozpozna”.
- Zrozumieć buforowanie: Reader buforuje wewnętrznie. Jeśli później ten sam strumień czytasz gdzie indziej, pozycja ma znaczenie. Brzmi trywialnie, ale w większych pipeline’ach importowych szybko staje się źródłem błędów.
Szczególnie kłopotliwy jest tryb mieszany: najpierw czytać część jako bajty (np. do logowania lub sprawdzenia magic-Bytes), potem kontynuować z TStreamReader. Jeśli nie przewiniesz jasno albo nie zainicjalizujesz readera na właściwej pozycji, zaczniesz czytać od bajtu 257 zamiast od 0. Parser JSON zgłosi wtedy „Invalid character at position …”, choć sama payload jest poprawna.
Gdy znaki specjalne mimo UTF-8 pozostają „zepsute”: Escaping kontra prawdziwy Unicode
JSON może zawierać znaki specjalne na dwa sposoby:
- Jako prawdziwe znaki UTF-8 (np. „München” jako bajty C3 BC …).
- Jako sekwencja ucieczki (np. „Mu00fcnchen“).
Obie wersje są poprawne. W praktyce ważne jest: sekwencje ucieczki omijają wiele problemów transportowych, ale tylko pozornie maskują błędy kodowania. Jeśli w którymś miejscu system błędnie interpretuje bajty, to jest to ryzyko operacyjne, nie tylko kosmetyczny błąd. Dodatkowo sekwencje ucieczki mogą mylić przy logowaniu/monitoringu, jeśli zespoły oczekują „czytelnego tekstu”.
System.JSON zwróci ci w obu przypadkach na końcu normalne Delphi-ciągi znaków (UTF-16), o ile ścieżka do tego punktu była poprawna.
Realistyczna ocena wydajności: koszty DOM, duże Arrays i selektywność
Największym dźwignią wydajności często nie jest „przyspieszenie parsera”, lecz mniej parsowania. Z System.JSON trudno to osiągnąć, ponieważ otrzymujesz DOM. Trzy typowe sytuacje:
- Duże Arrays (10.000+ Elemente): budowa DOM kosztuje czas i RAM. Jeśli potrzebujesz tylko 2 pola na element, często sensowny jest parser obsługujący streaming (SAX/Tokenizer). System.JSON nie jest do tego przeznaczony.
- Pojedyncze obiekty z wieloma polami: jeśli potrzebujesz tylko kilku pól, możesz nadal używać DOM, ale unikaj wielokrotnego przeszukiwania. Pobierz wartości raz i zmapuj je do swoich struktur.
- Wiele dużych payloadów jeden po drugim: w zadaniach importu lub workerach synchronizujących warto wydzielić parsowanie do wyraźnego kroku i po każdym dokumencie zwalniać wszystkie referencje, tak aby zarządca pamięci mógł posprzątać. To banał, ale w usługach często jest robione „przy okazji”.
„Szybki parser strumieniowy” w kontekście System.JSON bywa więc dobrym kompromisem: odczyt efektywny i poprawny, świadome korzystanie z DOM, jasno określone granice. Jeśli potrzebujesz prawdziwej semantyki streamingu (np. przetwarzanie elementów tablicy po kolei bez trzymania wszystkiego w pamięci), System.JSON nie jest właściwą bazą.
Odporność w produkcji: obrazy błędów i jak je natychmiast namierzyć
Błędy parsowania JSON w logach często są mało pomocne, bo podają tylko pozycję. Dla operacji i wsparcia potrzebujesz kontekstu:
- Pozycja bajtowa vs. pozycja znaku: w UTF-8 to nie to samo. Jeśli parser raportuje pozycję znaku, pozycja bajtowa może się różnić. Dla zrzutów bajtów kluczowa jest pozycja bajtowa.
- Wycinek wokół miejsca błędu: zaloguj w przypadku błędu małe okno wokół pozycji (np. 40 znaków przed/po), ale tylko jeśli nie zawiera to danych wrażliwych. Alternatywnie: loguj tylko w formie szesnastkowej.
- Korelacja: Request-ID, Endpoint, Partner-ID, Payload-Hash. Inaczej nigdy nie odnajdziesz „tego jednego błędu” ponownie.
Celem jest, żebyś po incydencie produkcyjnym w ciągu kilku minut mógł odpowiedzieć: „kodowanie źle zinterpretowane”, „payload obcięty”, „serwer zwraca niepoprawny JSON” albo „mamy problem z mapowaniem”.
Unikać pułapek UTF-8 celowo: checklist
- Zawsze określaj kodowanie: przy odczycie ze streamu i przy zapisie do logów/plików nie polegaj na ustawieniach domyślnych.
- Brak konwersji chunk->string: jeśli czytasz w trybie chunked, zbieraj bajty lub używaj dekodera, który buforuje sekwencje wielobajtowe.
- Toleruj BOM, ale widoczny: akceptuj, ale miej go wykrywalnym podczas debugowania.
- Ustal limity: maks. rozmiar payloadu, maks. głębokość obiektu/tablicy (o ile możesz to kontrolować), timeouty w kliencie HTTP.
Kiedy naprawdę opłaca się wysiłek związany z parserem strumieniowym?
Nie musisz optymalizować każdego fragmentu JSON. Podejście zazwyczaj ma sens, gdy spełnia się przynajmniej jeden z poniższych warunków:
- Duże payloady (kilka MB) występują regularnie lub mogą wystąpić.
- Procesy longrunning (usługa, worker) przetwarzają wiele payloadów i obserwujesz skoki użycia pamięci lub fragmentację.
- Interoperacja z heterogenicznymi systemami: wielu partnerów, różne platformy, sporadycznie wadliwe kodowania.
- Historia incydentów: występowały już „zepsute znaki diakrytyczne (Umlaute)“, sporadyczne błędy parsowania lub trudno odtworzalne przerwy importu.
Jeśli twoje payloady są małe i pochodzą ze źródła kontrolowanego, często wystarczy prostsze rozwiązanie – ale nawet wtedy: jawne ustawienie UTF-8 niemal nic nie kosztuje i zapobiega późniejszym niespodziankom.
Rozgraniczenie: kiedy potrzebujesz rzeczywistego streamingu
System.JSON jest zorientowany na DOM. Jeśli chcesz przetwarzać dane naprawdę „w locie“, np. duże tablice element po elemencie, bez trzymania ich w całości, potrzebujesz innego podejścia do parsera (Tokenizer/SAX). To nie jest ocena wartości, lecz decyzja architektoniczna:
- DOM (System.JSON): wygodny, dobry dla typowych obiektów biznesowych, ale pamięciożerny.
- Streaming/SAX: mniejsze zużycie pamięci, dobre dla bardzo dużych danych, ale większy nakład implementacyjny i wymaga bardziej starannego obsługi błędów.
Często sensowny kompromis to: solidne zaimplementowanie obsługi strumienia, kodowania i limitów, i dopiero potem zdecydować, czy DOM nadal pasuje. W wielu projektach samo to znacznie stabilizuje działanie.
Wniosek: JSON w Delphi będzie niezawodny, jeśli potraktujesz kodowanie i strumienie jako odrębną warstwę
Większość problemów związanych z „JSON w Delphi” nie wynika z samego parsera JSON, lecz z niepozornego odcinka przed nim: bajty ze strumienia są zamieniane na tekst. Jeśli tam jawnie obsłużysz UTF-8, rozpoznasz przypadki BOM, nie zignorujesz granic chunków i ustawisz jasne limity rozmiaru, typowe błędy ze znakami specjalnymi znikają – a sporadyczne problemy z parsowaniem stają się odtwarzalne.
System.JSON pozostaje przy tym pragmatycznym standardem: nie najszybszy parser strumieniowy, ale solidny, jeśli kontrolujesz input i świadomie akceptujesz koszty DOM. Jeśli chcesz, możemy wspólnie przejrzeć twój konkretny import-/REST-ścieżkę i zidentyfikować miejsce, w którym kodowanie lub chunking zawodzą: Skontaktuj się z nami.
W tym temacie ważne są także parsery JSON strumieniowe. Artykuł porządkuje te aspekty w zrozumiały sposób i pokazuje, na co zwracać uwagę w praktyce.
Następny krok
Jeżeli temat stanie się rzeczywistym projektem, architektura, stan istniejący i eksploatacja powinny być rozpatrywane razem na wczesnym etapie.
Wspieramy nie tylko w pojedynczych zagadnieniach, lecz także wtedy, gdy z fragmentów kodu źródłowego, kwestii związanych z systemami legacy lub koncepcji portalu ma powstać solidny projekt dla przedsiębiorstwa.
- Stan istniejący, obraz docelowy i ryzyka techniczne są oceniane łącznie.
- REST, dostęp do danych, portale i Rollout nie będą przesuwane na później.
- Wcześnie widzą Państwo, która droga jest ekonomicznie i operacyjnie wykonalna.