Net-Base マガジン

13.08.2026

JSON in Delphi: System.JSONを用いた高速ストリームパーサ + 特殊文字におけるUTF-8の落とし穴

JSONペイロードがDelphi内でストリームから直接供給されると、「ちょっとパースするだけ」が瞬く間に本番の問題になります:メモリ使用量の増大、断続的なパースエラー、ウムラウトの文字化け。本稿では、System.JSONを用いて高速なストリームパーサを構築する方法を示します...

13.08.2026

雑誌のテーマからプロジェクト実践へ

該当記事に関連するサービス・技術ページ

「JSON in Delphi」は解決済みの問題のように聞こえる: System.JSONが組み込まれており、REST-Callsはテキストを返す、それで終わり。だが実務では本当の問題は、JSONが扱いやすい文字列として存在するのではなく、ストリームとして存在する場合に発生する: HTTPレスポンスストリーム、ファイルストリーム、名前付きパイプ(Named Pipe)、メッセージキュー(Message-Queue)、またはデータベースの大きなBLOB。そこでは日常的に過小評価されがちな三つの要素が同時に作用する: メモリ挙動、文字エンコーディング(特に UTF-8)、および特殊文字を巡る境界事例。

この投稿は、TStreamからJSONをパースするための、不要なコピーを発生させないクリーンで高速なアプローチを示す — 特に、ウムラウトが「壊れる」やパーサが断続的に意味不明なメッセージで停止するような典型的なUTF-8の落とし穴を回避する方法に重点を置く。フォーカスは運用とインターフェースの安定性への影響:再現可能なデバッグ、アプローチの明確な限界、そしてその工数が本当に見合うかの判断基準にある。

なぜDelphiでのJSONパースではストリームが異なる振る舞いをするのか

JSONドキュメントが小さい限り、「ストリームを読み込んでStringにし、パースする」という方法は便利だ。しかし、ある程度のペイロードサイズ(典型例:大規模なリスト、レポート、同期データ、ログエクスポート)になるとコストが高くなる:

  • メモリの重複コピー: バイトをバッファに読み込み、Unicode文字列に変換する(Delphi-String = UTF-16)、パーサが内部でさらに構造を生成する。短期間で複数のコピーが生じる可能性がある。
  • GC/ヒープ圧力: 多くの一時的なStringとJSON値が断片化と割り当てオーバーヘッドを増大させ、特に長時間稼働するプロセス(Services、Worker、Import-Jobs)で顕著になる。
  • 障害の兆候が不明瞭になる: 読み込み時点でエンコーディングの仮定が間違っていると、JSONパーサーには「妙な文字」や予期しない制御バイトとしてしか見えない。

重要なのは明確な分離だ: JSONは形式的にはUnicodeであり、伝送上ではほとんど常に UTF-8 である。Delphiは内部的にはUTF-16で動作している。バイト(ストリーム)から文字(String)への移行が、特殊文字の問題が発生する箇所であり — JSON自体ではない。

System.JSON: 得意な点と注意すべき点

System.JSONはDelphiにおけるDOMベースのJSONの標準である: オブジェクトモデル(TJSONObject, TJSONArray)を取得し、値の問い合わせ、反復、シリアライズが可能だ。典型的な業務系統合には堅牢だが、次の二つの帰結がある:

  • 真のストリーミングパーサーではない: オブジェクトモデルは完全に構築される。追加で別のStringへの読み込みを省ける場合はあるが、DOMは依然としてメモリ集約的である。
  • パーサ入力は通常テキストである: 使用するDelphiのバージョンやAPIによっては、再びStringに戻り、エンコーディング変換が発生しやすい。

もし目的が「高速なストリームパーサー」であれば、実務的には主に次の二点を指す: (1) 不要なコピーを行わないこと、(2) 損傷したペイロードに対して可能な限り早く失敗させる(fail-fast)。どちらもバイト→テキストの段階を制御できればSystem.JSONで実現可能だ。

特殊文字におけるUTF-8の落とし穴: 典型的な原因

Abstrakte Grafik zeigt UTF-8-Mehrbytezeichen über Chunk-Grenzen und korrekte Pufferung im Decoder vor dem JSON-Parsing
チャンク処理自体は問題ない — UTF-8デコーダが複数バイトシーケンスを境界を越えてバッファリングする限り。

ウムラウト(ä/ö/ü/ß)やその他の特殊文字が結果で正しく表示されない(ä、– 等)のは、ほとんどの場合エンコーディング不一致です。Delphi 環境では以下の原因が特に多く見られます:

1) 「便宜的」ヘルパーによる ANSI フォールバック

いくつかの読み取り経路は、明示的なエンコーディングが渡されなければ暗黙的にシステムの ANSI エンコーディング(Windows システムのコードページ)を前提とします。これはペイロードが ASCII のみでない場合に初めて顕在化します。テストデータでは偶発的に問題にならないことが多いですが、本番では実際の氏名、地名、自由入力テキストで障害が発生します。

2) BOM-Verwirrung (Byte Order Mark)

UTF-8 は BOM(バイト列 EF BB BF)で始まることがあります。Web コンテキストでは BOM は比較的稀ですが、ファイルでは起こります。Reader によっては BOM を検出してエンコーディングを調整するものもあれば、特定のモードでしか認識しないものもあります。BOM が文字列内に通常の文字として混入すると、先頭に不可視のゼロ幅ノーブレークスペース(Zero Width No-Break Space)が入ることが多く、あるいは JSON パーサが最初のトークンで失敗することがあります。

3) 二重変換(UTF-8 が「もう一度」解釈される)

典型的な誤りのケースとして「ä」の代わりに別の文字が出る現象は、UTF-8 バイト列が一度正しく Unicode にデコードされた後で、さらに別の段階で ANSI/UTF-8 バイト列として誤解釈される(またはその逆)ときに発生します。Delphi では、TBytes, RawByteString, string の間での変換が不明確な場合にこの問題が生じやすいです。

4) マルチバイト文字の途中での切断

UTF-8 は特殊文字を 2〜4 バイトでエンコードします。チャンク(例:8 KB)で読み取る場合にチャンク境界が文字の途中に来ると、デコーダはそれらを適切にバッファリングする必要があります。各チャンクを個別に文字列に変換してから結合するという素朴な実装は無効なシーケンスを生み出します。これはパケット境界、プロキシの挙動、あるいは HTTP チャンクングに依存して「断続的な」エラーのように見えることがあります。

5) HTTP ヘッダからの誤った想定

REST ではソースが Content-Type: application/json; charset=utf-8 であることが多いです。しかしながら、サーバによっては charset を返さない場合や誤った値を返す場合があります。ヘッダを盲目的に信頼すると、バックエンドのバージョンによって挙動が変わることがあります。運用・サポートのためには実際のバイトストリームを確認し、障害時にログを残すことが有益です。

正しいアプローチ:Stream → UTF-8-Decoder → JSON-Parser

堅牢なパイプラインは三つの明確な段階で構成されます:

  1. ストリームからバイトを読む(制御された方法で、必要に応じて HTTP クライアント側で Limit/Timeout を設定する)。
  2. 明示的な UTF-8 で Unicode へデコードする(BOM は任意で許容する)。
  3. System.JSON でパースしてオブジェクトモデルへ、または目的に応じた抽出を行う

最も重要なのは第2段階です:どこかで「Default Encoding」に任せてはいけません。Delphi ではつまり、TEncoding.UTF8 を明示的に設定することであり、暗黙の変換に依存しないことです。

ここでの「schnell」が具体的に意味するもの

System.JSONを使ってDOMを「取り除ける」わけではありません。しかし、次のことは回避できます:

  • 内部でごく少数の値しか必要としない場合に、全ペイロードの追加コピーを中間文字列として作ること(その場合は別のパーサの方が適している;後述)、
  • 複数回の再エンコード、
  • そして、Out-of-Memoryやアクセス違反(Access Violation)で終了する代わりに、サイズ制限と明確なエラーメッセージを伴って非常に大きなペイロードを制御して読み込めます。

具体的な端ケース:特殊文字が破損するが、発生は断続的

実務で遭遇する厄介な端ケースがあります:ペイロード自体は有効なUTF-8 JSONですが、チャンク単位で読み取り、各チャンクを文字列に変換していると、ASCIIのみなら問題が出ません。しかしウムラウトなどがちょうどチャンク境界にかかると、無効なUTF-8シーケンスが発生します。結果として、文字が壊れるか、実際の内容に合わない箇所でパースエラーが発生します。

これが起きているかの見分け方:

  • パースエラーは小さい応答では発生せず、大きい応答で“ランダムに”発生する。
  • 同一のリクエストが場合によって成功したり失敗したりする(チャンク分割や伝送による)。
  • バイトのヘックスダンプは有効なUTF-8を示すが、ログに記録された文字列は置換文字(�)や従来型の文字化けを含んでいる。

解決策は「readlnを多く実行する」や「大きなバッファを使う」ことではなく、チャンク境界をまたいだマルチバイトシーケンスを正しくバッファリングするデコーダを使うことです。正しく初期化すれば、TStreamReaderとUTF-8-Encodingの組み合わせがまさにその用途に役立ちます。

実践ガイド:UTF-8を Delphi で再現可能に検証する

Debugging-Setup mit Byte-Dump-Ausdrucken und Arbeitsmaterial, um UTF-8-Bytes und BOM in JSON-Payloads zu prüfen
適切なデバッグではまずバイトストリームを確認することが重要です。BOM、切断(トランケーション)、および無効なシーケンスが迅速に明らかになります。

パーサに手を入れる前に、実際のバイト列を可視化するデバッグセットアップが必要です。サポートや運用にとって価値が高く、あとで相手側が不正なデータを出しているのか、自分のパイプラインが誤ってデコードしているのかを明確に伝えられます。

1) Die ersten Bytes prüfen (BOM, JSON-Start)

JSONにBOMが付いている場合、ストリーム先頭で EF BB BF が見えます。その直後には通常「{」か「[」が来るはずです。文字列に既に「」が含まれているなら、BOMはBOMとして扱われずテキストとしてデコードされています。

2) Rohbytes in Hex loggen – aber begrenzt

本番で完全なペイロードをログに残さないこと(データ保護、コスト、ログ量)。有効な方法は次の通りです:

  • プレフィックス(例:最初の256または1024バイト)、
  • サフィックス(最後の256バイト)、
  • そして、ペイロードを比較する必要がある場合の相関用ハッシュ(SHA-256)。

これにより特殊文字の問題を多くの場合数分で分類できます:“ä“のバイト列は正しいか(C3 A4)?切断はあるか?例えばUTF-16と誤認されて0x00(ヌルバイト)が混入していないか?

3) Content-Type und Charset mitloggen

Bei HTTP/REST: Logge den Content-Type und das deklarierte charset. Wenn die Bytes klar UTF-8 sind, aber charset etwas anderes behauptet, solltest du im Client nicht blind folgen. Für JSON ist UTF-8 der De-facto-Standard. Im Zweifel: Byte-Analyse gewinnt gegen Header.

Schneller Stream-Parser mit System.JSON: Design ohne unnötige Kopien

Schemahafte Darstellung einer Pipeline aus Stream, UTF-8-Decoding, Größenlimit und JSON-DOM-Parsing in Delphi
Eine klare Pipeline mit Limit und explizitem UTF-8 trennt Transport, Decoding und Parsing sauber.

Ein praxistaugliches Pattern ist: Du liest aus dem Stream in einen Byte-Puffer, baust daraus einen String genau einmal mit UTF-8, und gibst diesen String an den JSON-Parser. Das ist nicht „Streaming“ im Sinne von SAX, aber es ist eine kontrollierte, performante Pipeline ohne überraschende Encoding-Wechsel.

Wichtig für die Architektur: Baue die Funktion so, dass sie an einer Stelle zentral entscheidet:

  • Welche Encoding-Regel gilt (in der Regel UTF-8, BOM optional)?
  • Wie groß darf die Payload maximal sein (DoS-Schutz, Betriebsgrenze)?
  • Wie sehen Fehlermeldungen aus (mit Kontext, aber ohne Datenleak)?

Einlese-Strategie: Begrenztes Puffern statt „StreamToString“ ohne Limit

Wenn du JSON aus externen Quellen annimmst (Partner, mobile Clients, Drittanbieter), ist ein Größenlimit Pflicht. Ohne Limit reicht ein einzelner unglücklicher Request, um einen Service in Memory Pressure zu bringen. Praktisch heißt das: beim Lesen die Summe der Bytes zählen und ab einer Grenze abbrechen – mit einer klaren Exception, die im Monitoring verständlich ist.

Warum ich bei UTF-8 oft „ohne BOM“ erwarte, aber BOM tolerieren würde

Bei REST-Payloads kommt BOM selten vor. Bei Dateien (Exports, manuelle Bearbeitung) dagegen öfter. Für robuste Importstrecken ist es sinnvoll, BOM zu tolerieren, aber im Log sichtbar zu machen, weil es ein Hinweis auf „Datei-Welt“ statt „API-Welt“ sein kann.

Die stillen Killer: TStreamReader-Defaults und Text-Reader im Mischbetrieb

TStreamReader ist bequem, aber du musst zwei Dinge sauber im Griff haben:

  • Encoding explizit setzen: Nicht hoffen, dass er es „erkennt“.
  • Buffering verstehen: Der Reader puffert intern. Wenn du denselben Stream später nochmal woanders liest, ist die Position relevant. Das klingt trivial, wird aber in größeren Importpipelines schnell eine Fehlerquelle.

Besonders unangenehm ist Mischbetrieb: Erst ein Teil als Bytes lesen (z. B. für Logging oder Magic-Bytes), dann mit TStreamReader weiter. Wenn du dabei nicht sauber zurückspulst oder die Reader-Initialisierung an der richtigen Position machst, liest du ab Byte 257 statt ab 0. Der JSON-Parser meldet dann „Invalid character at position …“, obwohl die Payload an sich korrekt ist.

Wenn Sonderzeichen trotz UTF-8 „kaputt“ bleiben: Escaping vs. echtes Unicode

JSON kann Sonderzeichen auf zwei Arten enthalten:

  • Als echte UTF-8-Zeichen (z. B. „München“ als Bytes C3 BC …).
  • エスケープシーケンスとして(例: „Mu00fcnchen“)。

どちらも有効です。実務上重要なのは、エスケープシーケンスは多くの輸送上の問題を回避しますが、エンコーディングの誤りを覆い隠すだけである点です。システムのどこかでバイトを誤解釈していると、それは単なる見かけのバグではなく運用上のリスクになります。さらに、チームが「可読なテキスト」を期待している場合、エスケープシーケンスはログやモニタリングで混乱を招くことがあります。

System.JSONは、いずれの場合でも、そこまでの処理が正しければ最終的に通常のDelphi文字列(UTF-16)を返します。

パフォーマンスを現実的に評価する:DOMコスト、大きな配列、選択性

パフォーマンスで最も効果があるのは多くの場合「パーサを速くする」ことではなく、パース量を減らすことです。System.JSONではDOMが生成されるためこれが難しくなります。典型的な三つの状況:

  • 大きな配列(10,000件以上): DOMの構築は時間とメモリを消費します。要素ごとに2つのフィールドしか必要ない場合は、ストリーミング対応のパーサ(SAX/Tokenizer)の方が適していることが多いです。System.JSONはその用途に向いていません。
  • フィールドの多い単一オブジェクト: 必要なフィールドが少ない場合でもDOMを使うことはできますが、何度もトラバースするのは避けてください。値は一度だけ取得し、自分の構造にマッピングしてください。
  • 連続する複数の大きなペイロード: インポートジョブや同期ワーカーでは、パースを明確なステップに切り出し、各ドキュメントの後に全ての参照を開放してメモリマネージャが回収できるようにするのが有効です。自明な対策ですが、サービス実装では「ついでに」行われがちです。

したがって、System.JSONを用いた「高速なストリームパーサ」は有効な妥協になることが多いです:効率的かつ正しく読み込み、DOMは必要に応じて意図的に使い、境界を定める。もし配列要素を逐次処理して全てを保持しないといった本格的なストリーミングセマンティクスが必要なら、System.JSONは適切な基盤ではありません。

運用時の堅牢性:エラーの類型と即座に把握する方法

JSONのパースエラーは、ログに位置だけが記されることが多く、役に立たない場合があります。運用・サポートには次のようなコンテキストが必要です:

  • バイト位置 vs 文字位置:UTF-8では同一ではありません。パーサが文字位置を報告する場合、バイト位置は異なる可能性があります。バイトダンプを取る際はバイト位置が重要です。
  • エラー箇所付近のスニペット:エラー時に位置の前後(例:前後40文字)の小さな範囲をログに残してください。ただし機微なデータが含まれていない場合に限ります。代替手段としては16進でのみログする方法があります。
  • 相関情報:Request-ID、Endpoint、Partner-ID、Payload-Hash。さもないと「その一つのエラー」を再発見できません。

目標は、本番インシデント後に数分以内に次のように答えられることです:「エンコーディングが誤解釈された」「ペイロードが切断された」「サーバーが無効なJSONを返している」「マッピングの問題がある」。

UTF-8の落とし穴を意図的に回避する:チェックリスト

  • エンコーディングは常に明示する:ストリームから読み取る際やログ/ファイルへ書き出す際にデフォルトに依存しないこと。
  • チャンク→文字列変換を行わない:チャンク読みをする場合はバイトを蓄積するか、マルチバイトシーケンスをバッファするデコーダを使用してください。
  • BOMは許容するが可視化する:受け入れはするがデバッグ時に識別できるようにすること。
  • 上限を設定する:最大ペイロードサイズ、最大オブジェクト/配列の深さ(制御可能な場合)、HTTPクライアントのタイムアウトなど。
  • 責務の分離:「トランスポートの読み取り」と「JSONのパース」を別々にカプセル化する。そうすることでデバッグが速くなり、後からパーサを差し替えられる。
  • いつストリームパーサへの投資が本当に見合うか?

    すべてのJSON箇所を最適化する必要はありません。次のいずれかが当てはまる場合に、このアプローチは通常有効です:

    • 大きなペイロード(数MB以上)が定常的に発生する、または発生し得る。
    • 長時間実行プロセス(サービス、ワーカー)が多数のペイロードを処理しており、メモリのスパイクや断片化が観測される。
    • 異種システムとの相互運用:複数のパートナー、異なるプラットフォーム、時折発生するエンコーディング不良。
    • インシデント履歴:既に「ウムラウトの文字化け」や断続的なパースエラー、再現が難しいインポート中断が発生している。

    もしペイロードが小さく、管理されたソースから来るなら、単純な方法で十分なことが多い――しかしその場合でも:UTF-8を明示的に指定するのはほとんどコストがかからず、後の不意の問題を防げる。

    区別:本当にストリーミングが必要な場合

    System.JSONはDOM指向です。データを文字通り「通過しながら」処理したい、例えば大きな配列を全体を保持せずに要素ごとに処理したい場合は、別のパーサ方式(Tokenizer/SAX)が必要です。これは価値判断ではなくアーキテクチャ上の選択です:

    • DOM (System.JSON): 使いやすく典型的なビジネスオブジェクトに向くが、メモリを多く消費する。
    • Streaming/SAX: メモリ消費が低く非常に大きなデータに適するが、実装工数が増え、より厳密なエラーハンドリングが求められる。

    よくある妥協点は次の通り:まずストリームの扱い、エンコーディング、サイズ上限をきちんと作り、それからDOMが適切かどうか判断する。多くのプロジェクトでは、それだけで運用が大幅に安定する。

    結論:JSON in Delphi は、エンコーディングとストリームを独立した層として扱えば信頼性が高まる

    「JSON in Delphi」を巡る問題の多くは、JSONパーサ自体の問題ではなく、その前段の目立たない経路にある:ストリームから来るバイト列がテキストに変換される部分だ。そこでUTF-8を明示的に扱い、BOMケースを検出し、チャンク境界を無視せず、明確なサイズ制限を設ければ、典型的な特殊文字の問題は消え、断続的なパース問題も再現可能になる。

    System.JSONは実務的な標準として残る:最速のストリーミングパーサではないが、入力を管理しDOMのコストを理解した上で使えば堅実だ。ご希望であれば、あなたの具体的なインポート/REST パスを一緒に確認し、エンコーディングやチャンク処理が破綻する箇所を特定しましょう。ご連絡ください。

    このテーマではJSONストリームパーサも重要です。本稿はこれらの点を整理し、日常運用で何に注意すべきかを示しています。

    Net-Base を使ったプロジェクトや近代化案件について相談する.

    次のステップ

    テーマが実際のプロジェクトになる場合、アーキテクチャ、既存資産、運用は早期に一体として検討する必要があります。

    私たちは単なる個別の問い合わせへの対応にとどまらず、ソースの断片やレガシー課題、ポータルの構想が堅牢な企業向けプロジェクトへと成長する段階まで支援します。

    • 既存環境、目標像、技術的リスクを一体として評価します。
    • REST、データアクセス、ポータル、およびロールアウトは後回しにされません。
    • 早い段階で、どの選択肢が経済的かつ運用上実行可能であるかを見極められます。

    投稿を共有

    この投稿を直接共有する

    LinkedIn、X、XING、Facebook、WhatsApp、Eメールはすぐに利用可能です。Instagramについては、リンクと簡潔なテキストを直ちに準備します。

    Eメール

    Instagramは新しいタブで開きます。リンクと短文は事前にクリップボードにコピーされます。