HTTP環境におけるJava分塊アップロードとクロスブラウザ対応の実装戦略

大規模ファイル転送システムの技術的課題

20GB超の大容量ファイルをHTTPプロトコル経由で安定して転送するには、単純なストリーム送信ではタイムアウトやメモリ枯渇のリスクが伴います。特に、フォルダ階層の維持、クライアント側の暗号化(SM4/AES)、ブラウザ再起動やネットワーク切断からの自動再開、そしてInternet Explorer 9を含むレガシー環境との互換性を満たす実装は、アーキテクチャの設計段階から慎重な検討を要求します。

クライアント側の分塊処理と暗号化戦略

大規模ファイルを扱う場合、フロントエンドではファイルを所定のサイズに分割し、各チャンクに一意の識別子と順序番号を付与して送信します。暗号化処理はクライアント側で実施することで、サーバーの負荷を軽減し、中間者攻撃からの保護を強化できます。レガシーブラウザ対策としては、ES2015以降の構文をトランスパイルし、Blob APIやFetch APIのフォールバック実装を準備することが不可欠です。

フロントエンド実装例(TypeScript / 非同期処理最適化)

const CHUNK_THRESHOLD = 2 * 1024 * 1024; // 2MB
const MAX_CONCURRENT_REQUESTS = 3;

async function initializeChunkedTransfer(file, cryptoKey) {
  const fileHash = await generateFileHash(file);
  const chunkCount = Math.ceil(file.size / CHUNK_THRESHOLD);
  const chunkPromises = [];

  for (let i = 0; i < chunkCount; i++) {
    const start = i * CHUNK_THRESHOLD;
    const end = Math.min(start + CHUNK_THRESHOLD, file.size);
    const chunkBlob = file.slice(start, end);

    chunkPromises.push(encryptAndSendChunk({
      blob: chunkBlob,
      index: i,
      total: chunkCount,
      identifier: fileHash,
      cryptoKey: cryptoKey,
      fileName: file.name,
      filePath: file.webkitRelativePath || file.name
    }));
  }

  await executeWithConcurrency(chunkPromises, MAX_CONCURRENT_REQUESTS);
}

async function encryptAndSendChunk(params) {
  const iv = window.crypto.getRandomValues(new Uint8Array(12));
  const encryptedBuffer = await window.crypto.subtle.encrypt(
    { name: "AES-GCM", iv: iv },
    params.cryptoKey,
    params.blob
  );

  const formData = new FormData();
  formData.append("part", new Blob([encryptedBuffer]), "part_" + params.index);
  formData.append("metadata", JSON.stringify({
    hash: params.identifier,
    sequence: params.index,
    totalParts: params.total,
    originalName: params.fileName,
    directoryPath: params.filePath,
    iv: Array.from(iv).map(b => b.toString(16)).join("")
  }));

  try {
    const response = await fetch("/api/v1/upload/chunk", {
      method: "POST",
      body: formData
    });
    if (!response.ok) throw new Error("Chunk upload failed");
  } catch (err) {
    console.warn("Retry scheduled for chunk:", params.index);
  }
}

サーバーサイドの整合性検証とマージ処理

Java(Spring Boot)側では、各チャンクを受信し、一時的なワークディレクトリに格納します。すべてのチャンクが到着し、合計サイズとハッシュ値が一致した時点で、スレッドセーフなロック機構を用いてファイルをマージし、最終的なストレージパスへ移動させます。フォルダ構造の復元は、メタデータに含まれる相対パス情報を基に行います。

バックエンド実装例(Spring Boot / Java 17)

@RestController
@RequestMapping("/api/v1/upload")
@RequiredArgsConstructor
public class TransferController {

    private static final Path TEMP_WORKSPACE = Paths.get("var/upload-staging");
    private static final String CIPHER_TRANSFORMATION = "AES/GCM/NoPadding";

    @PostMapping("/chunk")
    public ResponseEntity<Map<String, Object>> handleChunk(
            @RequestParam("part") InputStream chunkStream,
            @RequestParam("metadata") String metaPayload) {

        try {
            TransferMetadata meta = JsonUtils.parse(metaPayload, TransferMetadata.class);
            validateChunkIntegrity(meta);

            Path targetChunkPath = TEMP_WORKSPACE
                    .resolve(meta.getHash())
                    .resolve("part_" + meta.getSequence());

            Files.createDirectories(targetChunkPath.getParent());
            Files.copy(chunkStream, targetChunkPath, StandardCopyOption.REPLACE_EXISTING);

            if (isAllChunksReceived(meta.getHash(), meta.getTotalParts())) {
                consolidateAndDecrypt(meta);
            }

            return ResponseEntity.ok(Map.of("status", "accepted", "sequence", meta.getSequence()));
        } catch (Exception e) {
            return ResponseEntity.status(500).build();
        }
    }

    private void consolidateAndDecrypt(TransferMetadata meta) throws IOException {
        Path finalDir = Paths.get("var/archive")
                .resolve(YearMonth.now().toString())
                .resolve(meta.getDirectoryPath());
        Files.createDirectories(finalDir);

        Path finalFile = finalDir.resolve(meta.getOriginalName());
        try (OutputStream out = Files.newOutputStream(finalFile, StandardOpenOption.CREATE)) {
            for (int i = 0; i < meta.getTotalParts(); i++) {
                Path chunkPath = TEMP_WORKSPACE.resolve(meta.getHash()).resolve("part_" + i);
                decryptStream(Files.newInputStream(chunkPath), out, meta.getIvForPart(i));
                Files.deleteIfExists(chunkPath);
            }
        }
        Files.deleteIfExists(TEMP_WORKSPACE.resolve(meta.getHash()));
    }
}

ストレージパス設計と状態管理

ファイルの永続化パスは、日付ベースのディレクトリ構造と一意の識別子を組み合わせることで、ディスクのフラグメンテーションを抑制し、バックアップポリシーの実施を容易にします。例:var/archive/2024-05/12/<unique-id>/<original-filename>

断点續伝(レジューム)機能を実現するには、クライアント側のIndexedDBまたはLocalStorageに、ファイルハッシュ、送信済みチャンク番号、ネットワーク状態を記録します。セッションが復元された際、ローカルストレージとの比較により未送信部分のみを特定し、HTTPリクエストを再構築します。IE9環境ではIndexedDBが非対応であるため、LocalStorageへのJSONシリアライズと、XMLHttpRequest Level 1 ベースの送信ロジックへのダウングレード処理が必須となります。

暗号化キーの管理については、クライアント生成の一時セッションキーと、サーバー保持のマスターキーを用いた鍵交換プロトコルを採用し、各チャンクごとに独立した初期化ベクトル(IV)を付与することで、パターン解析を回避します。これにより、20GB規模のファイルを分割・暗号化・送信・再構築する一連のパイプラインが、リソース制約のある環境でも安定して動作する基盤が構築できます。

タグ: Java Spring Boot HTTP クライアント側暗号化 レガシーブラウザ対応

6月2日 20:32 投稿