🌝

ただJSONサイズを80%削減する話

2025/01/20に公開
7

JSONファイルのサイズを 80%削減 できたお話。
実際に実験した数値も踏まえて紹介します。

モチベーション

HowToだけを知りたい方は読み飛ばしてもOKです。
モチベは次の2つです。

  1. 通信量を削減したい!
  2. 難読化したい!

1. 通信量を削減したい!

個人開発しているサイトでNetlifyを使っていました。
NetlifyにはFreeのプランがありますが、通信量の制限があります。
個人開発で潤沢にポケットマネーがあるわけではないので、できるだけ通信量のケアをしたい!

2. 難読化したい!

サーバーからブラウザへJSONを配信すると、JSONの中身が見れてしまいます。
苦労してデータ作ってるので、JSONの中身見られてパクられるのがちょっとなぁ...(※)と思ってました。
nginxのようなミドルウェア層でgzipやBrotli圧縮しても良いのですが、ブラウザのディベロッパーツールで見れちゃったり、よく知られた圧縮方式なのでデコードもしやすいです。
独自の方式で難読化したい!

(※2025/01/25)追記
主にスクレイピングの文脈です。例えば curl コマンド等でパブリックに配信している静的なJSONにアクセスされるケースです。
スクレイピング目的のアクセスを完璧に弾くのは難しいため、保険程度の難読化です。
(アクセス突破してDOM解析すれば終わりじゃん、と言われればまぁそれはそう、という感じです笑。そんなにシビアにはやってません)
あくまでも個人開発&機密情報というデータでもない&通信量削減がメインなので、本記事は「セキュリティ知識総動員してゴリゴリ難読化したい」というわけではないのでそこはご了承ください。

いざ圧縮!

次の2つのステップでJSONを圧縮しました。

  1. JSONそのものを圧縮
  2. JSON文字列を圧縮

1. JSONそのものを圧縮

JSONを圧縮するNode.jsのライブラリはいくつかありますが、実験では compressed-json を使わせていただきました。

個人開発しているサイトでいくつか圧縮したいJSONをピックアップします。

考慮なしのJSONサイズ一覧

手作り感マックスのスプレッドシートで恐縮ですが、「K」と書いてあるのはキロバイトのことです。
JSONは16個、合計804kBのファイルで試してみると次のような結果になりました。

compressed-jsonを含むJSONサイズ一覧

圧縮後は合計で464kBとなり、大体 60%ほど圧縮 できました。

2. JSON文字列を圧縮

JSONそのものを圧縮して終わりません。
最終的にはJSONを文字列化して静的ファイルに落とし込みますが、その 文字列化 の部分にも圧縮の余地があります。

JSON文字列を圧縮するためのライブラリに JSONCrush があるので、これを使ってさらに圧縮をかけてみます。

JSONCrushを含むJSONサイズ一覧

JSONCrushによる追加の圧縮後は合計ファイルサイズは160kBとなりました。元々のファイルの合計サイズが804kBだったので、 80%ほどのファイルサイズの削減 となりました。
めでたしめでたし!

(2025/01/22追記)
コード例も貼っておきます。

import fs from 'node:fs/promises';
import { compress } from 'compressed-json';
import JSONCrush from 'jsoncrush';

function heavyCompressJsonToString(value) {
  return JSONCrush.crush(JSON.stringify(compress(value)));
}

export async function writeDataToLocalFile(exportDir, data) {
  const t0 = performance.now();
  await fs.writeFile(exportDir, heavyCompressJsonToString(data));
  console.log(`Write ${performance.now() - t0} ms`);
}

めでたしめでたし...?

結果だけ見るとめでたしなのですが、いくつか注意事項があるので書いておきます。

圧縮にめちゃくちゃ時間かかる

圧縮したファイルを復元するのにはそんなに気にはならないのですが、 圧縮の方はめちゃくちゃ時間かかります
ファイルによるので一概に言えないですが、私のサイトでの経験だと 1ファイル圧縮するのに10分以上かかることもあります(※)

圧縮時間の内訳ですが、ほぼ「1. JSONそのものを圧縮」に時間かかります。
なお、この後お話する compress-json にライブラリを置き換えても同様です。

fetch のような非同期IOとは違い、このJSONの圧縮処理は同期的な処理なので、圧縮が始まると他の処理が止まります。
マルチスレッドで処理するなどの一工夫が必要です。

また、ユーザーからリクエストを受け取ってから動的にJSONを生成して返却するケースも難しいでしょう。私のサイトのようにアプリケーションビルド時などあらかじめ静的にJSONを生成するようなケースで有効です。

(※2025/01/22追記)
Cloud Buildのデフォルトの machineType で実行した結果なので、かかる時間は実行環境によります。その他詳細な前提はこちらにコメントしてます。

JSON圧縮には別ライブラリを使うのがおすすめ

使わせてもらってる身ですみません、という感じなのですが、今回の実験で使った compressed-json よりも代替のライブラリを使うことをおすすめします。
というのも、この記事の執筆時点ではあまりメンテされていなかったり、TypeScriptに対応していない、他に良いライブラリがある、といったのが理由です。

代替の候補としては compress-json があります。個人的な推奨理由としては次のようなものがあります。

  • 記事執筆時点でも直近でメンテされていそう
  • TypeScriptもサポート
  • ベンチマークをみた限りはcompressed-jsonよりも圧縮率が良い

ただし、圧縮や解凍時間はcompressed-jsonに分がありそう(私は最初これを理由にcompressed-jsonを選んだ)なので、あくまで個人的な意見と捉えていただければと思います。

まとめ

compressed-jsonJSONCrush でJSONのサイズを80%削減したぞ!

昔書いた記事ではありますが、もしパフォーマンスチューニングに興味があればこちらの記事も参考にしてみてください。

https://qiita.com/nuko-suke/items/50ba4e35289e98d95753

こちらもちょっと古い記事で恐縮ですが、私のサイトのアーキテクチャなどを紹介した記事があるのでご興味あればこちらもぜひ。

https://qiita.com/nuko-suke/items/e191bda476724905245e

(一部現在のアーキテクチャとは違う部分はあるのであしからず)

ここまでご覧いただきありがとうございました!

Discussion

あいや - aiya000あいや - aiya000

このJSONの圧縮処理は同期的な処理なので

async functionやPromiseの中で実行をすればいい、という単純な話ではない感じですかね?

mpywmpyw

前提として

  • JavaScript 実行環境は(クライアント側・サーバ側ともに)原則的にはシングルスレッド動作です。つまり単一の CPU コアしか利用できません。
  • Promise は IO バウンドな処理の多重化・平行化(並列化ではありません) に使われるだけです。複数 CPU コアを別々のタスクに割り当てるということはできず、単一 CPU コア上であるタスクの IO の待ち時間に別のことをやってもらう ということしか出来ません。

そのため、今回のような CPU バウンドな処理は Promise ベースの提供になっていないことが殆どで、そういった処理は明示的にプロセスやスレッドを起こさないと複数の CPU コアを利用した真の並列化は実現できません。

あいや - aiya000あいや - aiya000

つまり単一の CPU コアしか利用できません。

この記事ではスクレイピングの文脈とのことで、少なくともNode.jsではデフォルトで(必要に応じて)マルチスレッドが使用されるはずと思っています👀

なんとなくわかりやすそうなページ↓
https://qiita.com/darai0512/items/568ea7d49d2c522b7c45

マルチスレッドはOSによって複数のCPUコアが使われる場合がある認識ですが、ここが間違っているのでしょうか?

Promise は IO バウンドな処理の多重化・平行化(並列化ではありません) に使われるだけです

僕が引用した内容は「JSON圧縮が同期処理である」というもので、多重化・並行並列処理に関しては言及していないつもりです🙋‍♂️

またPromiseで行う処理が同期処理であるか非同期処理か、については非同期処理である、と考えているのですが、そこが間違っていますか?

mpywmpyw

少々語弊があったみたいですね,すみません。

Promise は IO バウンドな処理の多重化・平行化(並列化ではありません) に使われるだけです。

こちらは語弊があったので無視してください。これが正しいかどうかは内包する処理に依存する,というのが正しい説明でした。


以下,仕切り直して説明します。具体例を挙げて,各ケースにおける「並行性」と「並列性」について説明します。 JSON.stringify で大きなペイロードを文字列化することを想定してください。

A. JSON.stringify をラップした JavaScript の同期関数

コード例

const syncStringify = (data) => JSON.stringify(data);

説明

  • 並行性: ❌
  • 並列性: ❌

B. JSON.stringify をラップした JavaScript の Promise 関数

コード例

const concurrentStringify = async () => JSON.stringify(data);
const concurrentStringify = (data) => new Promise((resolve) => {
    resolve(JSON.stringify(data));
});

説明

  • 並行性: 🔺
    • セマンティック的にはありに分類されるが,実際のところは無しに近い。 内部の処理が全て同期実行であるため,一度 concurrentStringify の実行に入ると全ての処理が終わるまでこの関数を抜けることはない。
  • 並列性: ❌

C. JSON.stringifychild_process 上で実行するようにラップした Node.js の Promise 関数

コード例

import { fork } from 'node:child_process';
import path from 'node:path';

const parallelStringify = (data) => new Promise((resolve, reject) => {
    const child = fork(path.resolve(__dirname, 'stringify-worker.js'));
    child.on('message', (result) => resolve(result));
    child.on('error', reject);
    child.send(data);
});

stringify-worker.js:

process.on('message', (data) => {
    const result = JSON.stringify(data);
    process.send(result);
});

説明

  • 並行性: ✅
  • 並列性: ✅
    • child_process を使用することで別のプロセスで処理され,CPUのコアを活用した並列実行が可能

D. fs.readFile をラップした Node.js の Promise 関数

コード例

import * as fs from 'node:fs/promises';

const asyncReadFile = async (filePath) => {
    return await fs.readFile(filePath, 'utf8');
}
import * as fs from 'node:fs';

const asyncReadFile = (filePath) => new Promise((resolve, reject) => {
    fs.readFile(filePath, 'utf8', (err, data) => {
        err ? reject(err) : resolve(data);
    });
});

説明

  • 並行性: ✅
  • 並列性: ✅
    • fs.readFilelibuvのスレッドプールを利用するため,複数の IO 操作を並列で処理可能

もとの話に戻りますが,A から B のように書き換えたところで, 「同期処理として提供されているものを Promise でラップしただけでは何も解決しないよ」 が私の一番いいたいところでした。 Promise でラップするだけで OK というのは, D のように元から並列性が実現されているものを取り扱う場合のみであり,そうなっていない処理の場合は基本的には C のような対応が必要ということです。

  • Node.js であれば child_process など(他にも選択肢あり)を使用します。
  • Web ブラウザであれば WebWorker を使用します。
nawadanawada

1ファイル圧縮するのに10分以上かかる

単純に気になったのですが、10秒じゃなく10分ですか…?ファイルがすごく大きいのでしょうか?

nuko_suke_devnuko_suke_dev

あー、すみません、10分なのはCloud Buildのデフォルトの machineType で実行した結果です!ので実行する環境によってはもっと速くなるかと思います〜
ちなみに10分くらいかかってるファイルのサイズは大体100kB~200kBです。
なお、JSONで記述されている各オブジェクトは、オブジェクトや配列がネストされたような複雑なものでもなく、valueが文字列や数値の単純な形式のオブジェクトです。

簡易的ですがコード例も貼っておきます。

import fs from 'node:fs/promises';
import { compress } from 'compressed-json';
import JSONCrush from 'jsoncrush';

function heavyCompressJsonToString(value) {
  return JSONCrush.crush(JSON.stringify(compress(value)));
}

export async function writeDataToLocalFile(exportDir, data) {
  const t0 = performance.now();
  await fs.writeFile(exportDir, heavyCompressJsonToString(data));
  console.log(`Write ${performance.now() - t0} ms`);
}