【Java】大量のUUID配列をJSONでやり取りする状況で、UUIDをBase64化する影響を比較する

# やりたいこと
以下のような`JSON`について、`ids`（`UUID`の配列）が数万 ~ 数十万件単位になる状況で、`UUID`を`Base64`にした場合性能に対してどのような影響が有るか確認します。

```json
{
  "ids": [
    "6c9352f3-77b3-4f0e-a26f-5e496f00496b",
    "76dfbbe7-5ebd-437d-97a2-708ae57416d1",
    "12cde1cf-5711-4301-a17e-16fcc6d15461"
  ]
}
```

## 結論
`UUID`が5万件の場合で、最大限頑張る（後述する3のパターンを採用する）と、以下のような改善が見られました。

- `JSON`の容量が55%まで削減される(1.95MB -> 1.07MB)
- `JSON`処理のスループットは1.35倍に向上する(8ms程度短縮)

`JSON`処理の差は小さく、容量削減もネットワーク帯域が1gbpsで8msしか差が出ないようなので、余程ネットワーク帯域が小さくなければあまり影響も無いような気がします。

# 比較
`JSON`関係の処理には`Jackson`を利用します。
以下3パターンについて、`JSON`化した際の容量面と性能面で比較します。

1. 生`UUID`文字列の配列
2. `UUID`の`Base64`文字列の配列
3. `UUID`配列全体を`Base64`化した文字列

1 ~ 3を表す`DTO`は以下を用います。

```java
import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonProperty;

public static class RawIdList {
    private final List<UUID> ids;
    @JsonCreator public RawIdList(@JsonProperty("ids") List<UUID> ids) { this.ids = ids; }
    public Object getIds() { return ids; }
}

public static class Base64IdList {
    private final List<String> ids;
    @JsonCreator public Base64IdList(@JsonProperty("ids") List<String> ids) { this.ids = ids;}
    public Object getIds() { return ids; }
}

public static class Base64String {
    private final String ids;
    @JsonCreator public Base64String(@JsonProperty("ids") String ids) { this.ids = ids; }
    public Object getIds() { return ids; }
}
```

## `Base64`エンコード・デコードについて
まず、2と3で用いる変換処理について説明します。
以下がソースコードです。

```java
import java.nio.ByteBuffer;
import java.util.*;

private static final int UUID_SIZE_BYTES = 16;
private static final Base64.Encoder ENC = Base64.getEncoder().withoutPadding();
private static final Base64.Decoder DEC = Base64.getDecoder();

public static String encode(UUID id) {
    ByteBuffer buf = ByteBuffer.allocate(UUID_SIZE_BYTES)
            .putLong(id.getMostSignificantBits())
            .putLong(id.getLeastSignificantBits());

    return new String(ENC.encode(buf.array()));
}

public static UUID decode(String id) {
    ByteBuffer buf = ByteBuffer.wrap(DEC.decode(id));
    return new UUID(buf.getLong(), buf.getLong());
}

public static String encodeAll(List<UUID> ids) {
    if (ids.isEmpty()) return "";

    ByteBuffer buf = ByteBuffer.allocate(UUID_SIZE_BYTES * ids.size());
    for (UUID id : ids) {
        buf.putLong(id.getMostSignificantBits()).putLong(id.getLeastSignificantBits());
    }

    return new String(ENC.encode(buf.array()));
}

public static List<UUID> decodeAll(String ids) {
    List<UUID> result = new ArrayList<>();
    if (ids.isEmpty()) return result;

    ByteBuffer buf = ByteBuffer.wrap(DEC.decode(ids));

    while (buf.hasRemaining()) {
        result.add(new UUID(buf.getLong(), buf.getLong()));
    }

    return result;
}
```

`encode`・`decode`関数は`UUID`単体の`Base64`変換処理です。
これは`UUID`を2つの`long`として`Base64`変換しています。
この変換処理を使った場合、1つの`UUID`が36文字 -> 22文字に短縮されます。

`encodeAll`・`decodeAll`関数は、`UUID`配列を1つの`Base64`文字列に変換する処理です。
これは`UUID`配列を`long`の配列のように扱い、1つの`Base64`文字列に変換しています。
このやり方をすることで、文字列を囲む`"`や区切りの`,`が無くなるため、全体で見れば1`UUID`当たり単体変換よりも更に3文字短縮されます。
また、`Base64`変換は6bitずつ行われるため、詰めて変換できる分`UUID`を1つずつ`Base64`変換するよりも利用効率が高くなります。

## 容量面の比較
件数が5万件だった場合の`JSON`の文字数を比較すると以下のようになります。
カッコ内は、`Base64`変換しなかった場合と比較したサイズです。

1. 1950009(100%)
1. 1250009(64.1%)
1. 1066677(54.7%)

計測は以下のように行いました。

```java
private static final ObjectMapper MAPPER = new ObjectMapper();

List<UUID> sourceIds = new ArrayList<>();
for (int i = 0; i < 50000; i++) sourceIds.add(UUID.randomUUID());

String raw = MAPPER.writeValueAsString(new RawIdList(sourceIds));
String base64List = MAPPER.writeValueAsString(new Base64IdList(sourceIds.stream().map(JsonBenchmark::encode).toList()));
String base64String = MAPPER.writeValueAsString(new Base64String(encodeAll(sourceIds)));

System.out.println(raw.length());
System.out.println(base64List.length());
System.out.println(base64String.length());
```

## 性能面の比較
「`JSON`シリアライズ -> デシリアライズして`UUID`を返却」という手順の`JMH`ベンチマークを作成し、性能面の比較を行いました。
結果は以下の通りです（高いほど良い、また1 ~ 3の順に並べ替えて整形済み）。

```
Benchmark                    Mode  Cnt   Score   Error  Units
JsonBenchmark.raw           thrpt    4  32.791 ± 3.149  ops/s
JsonBenchmark.base64List    thrpt    4  36.995 ± 3.387  ops/s
JsonBenchmark.base64String  thrpt    4  44.262 ± 5.383  ops/s
```

ベンチマークのコードは以下の通りです。

```java
package org.wrongwrong;

import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.openjdk.jmh.annotations.*;

import java.nio.ByteBuffer;
import java.util.*;

@State(Scope.Benchmark)
public class JsonBenchmark {
    private static final int UUID_SIZE_BYTES = 16;
    private static final Base64.Encoder ENC = Base64.getEncoder().withoutPadding();
    private static final Base64.Decoder DEC = Base64.getDecoder();

    public static String encode(UUID id) {
        ByteBuffer buf = ByteBuffer.allocate(UUID_SIZE_BYTES)
                .putLong(id.getMostSignificantBits())
                .putLong(id.getLeastSignificantBits());

        return new String(ENC.encode(buf.array()));
    }

    public static UUID decode(String id) {
        ByteBuffer buf = ByteBuffer.wrap(DEC.decode(id));
        return new UUID(buf.getLong(), buf.getLong());
    }

    public static String encodeAll(List<UUID> ids) {
        if (ids.isEmpty()) return "";

        ByteBuffer buf = ByteBuffer.allocate(UUID_SIZE_BYTES * ids.size());
        for (UUID id : ids) {
            buf.putLong(id.getMostSignificantBits()).putLong(id.getLeastSignificantBits());
        }

        return new String(ENC.encode(buf.array()));
    }

    public static List<UUID> decodeAll(String ids) {
        List<UUID> result = new ArrayList<>();
        if (ids.isEmpty()) return result;

        ByteBuffer buf = ByteBuffer.wrap(DEC.decode(ids));

        while (buf.hasRemaining()) {
            result.add(new UUID(buf.getLong(), buf.getLong()));
        }

        return result;
    }

    public static class RawIdList {
        private final List<UUID> ids;
        @JsonCreator public RawIdList(@JsonProperty("ids") List<UUID> ids) { this.ids = ids; }
        public Object getIds() { return ids; }
    }

    public static class Base64IdList {
        private final List<String> ids;
        @JsonCreator public Base64IdList(@JsonProperty("ids") List<String> ids) { this.ids = ids;}
        public Object getIds() { return ids; }
    }

    public static class Base64String {
        private final String ids;
        @JsonCreator public Base64String(@JsonProperty("ids") String ids) { this.ids = ids; }
        public Object getIds() { return ids; }
    }

    private static final ObjectMapper MAPPER = new ObjectMapper();
    public List<UUID> sourceIds;

    @Setup(Level.Trial)
    public void setUp() {
        sourceIds = new ArrayList<>();
        for (int i = 0; i < 50000; i++) sourceIds.add(UUID.randomUUID());
    }

    @Benchmark
    public List<UUID> raw() throws JsonProcessingException {
        RawIdList src = new RawIdList(sourceIds);

        String json = MAPPER.writeValueAsString(src);
        RawIdList res = MAPPER.readValue(json, RawIdList.class);

        return res.ids;
    }

    @Benchmark
    public List<UUID> base64List() throws JsonProcessingException {
        Base64IdList src = new Base64IdList(sourceIds.stream().map(JsonBenchmark::encode).toList());

        String json = MAPPER.writeValueAsString(src);
        Base64IdList res = MAPPER.readValue(json, Base64IdList.class);

        return res.ids.stream().map(JsonBenchmark::decode).toList();
    }

    @Benchmark
    public List<UUID> base64String() throws JsonProcessingException {
        Base64String src = new Base64String(encodeAll(sourceIds));

        String json = MAPPER.writeValueAsString(src);
        Base64String res = MAPPER.readValue(json, Base64String.class);

        return decodeAll(res.ids);
    }
}
```

`build.gradle.kts`は以下の通りです。

```kotlin
plugins {
    id("java")
    id("me.champeau.jmh") version "0.7.2"
}

group = "org.wrongwrong"
version = "1.0-SNAPSHOT"

repositories {
    mavenCentral()
}

dependencies {
    // https://mvnrepository.com/artifact/com.fasterxml.jackson.core/jackson-databind
    implementation("com.fasterxml.jackson.core:jackson-databind:2.17.1")

    testImplementation(platform("org.junit:junit-bom:5.10.0"))
    testImplementation("org.junit.jupiter:junit-jupiter")
}

tasks.test {
    useJUnitPlatform()
}

jmh {
    warmupForks = 2
    warmupBatchSize = 3
    warmupIterations = 3
    warmup = "1s"

    fork = 2
    batchSize = 3
    iterations = 2
    timeOnIteration = "1500ms"

    failOnError = true
}
```