教育・医療分野向けJava大容量ファイル(100MB超)アップロード・ダウンロード実装ガイド

大規模ファイル転送システムの設計と実装

要件定義と課題分析

北京のソフトウェア企業で担当するプロジェクトでは、以下のような要件を満たす大容量ファイル転送機能が必要です:

  • 50GB超の大ファイル対応:単一ファイルおよびフォルダ構造全体のアップロード/ダウンロードをサポート
  • 中断再開機能:ブラウザリロードや終了後も進捗が保持される信頼性
  • 階層構造維持:フォルダツリー構造をそのまま保存・復元
  • 非圧縮ダウンロード:サーバー負荷を避けるためZIP化しないストリーミング配信
  • クロスプラットフォーム対応:Windows/macOS/Linux + 主要ブラウザ(IE8含む)
  • DB柔軟性:MySQLベースだがSQL Server/Oracleへの移行可能
  • デプロイ自由度:社内LAN環境とパブリッククラウド両方に対応
  • ライセンス形態:買断型希望、予算上限88万元

アーキテクチャ設計

[クライアント] → [フロントエンド(Vue2)] 
             → [APIゲートウェイ(JSP)] 
             → [ファイル処理モジュール] 
             → [Huawei Cloud OSS]
             → [データベース(MySQL)]

チャンク分割アップロード実装

// Vueコンポーネント内メソッド
methods: {
  async transmitFile(target) {
    const SEGMENT_SIZE = 5 * 1024 * 1024; // 5MBごとに分割
    const segmentCount = Math.ceil(target.size / SEGMENT_SIZE);
    const uniqueHash = await this.generateHash(target);

    // サーバー側での既存進捗確認
    const { data: progressInfo } = await axios.post('/api/transfer/checkpoint', {
      originalName: target.name,
      totalSize: target.size,
      identifier: uniqueHash,
      segments: segmentCount
    });

    if (progressInfo.completed) {
      this.$notify.success('既に存在します。即時完了');
      return;
    }

    // 再開ポイント取得
    const completedSegments = progressInfo.finished || [];

    for (let index = 0; index < segmentCount; index++) {
      if (completedSegments.includes(index)) continue;

      const beginPos = index * SEGMENT_SIZE;
      const endPos = Math.min(target.size, beginPos + SEGMENT_SIZE);
      const fragment = target.slice(beginPos, endPos);

      const payload = new FormData();
      payload.append('fragment', fragment);
      payload.append('sequence', index);
      payload.append('total', segmentCount);
      payload.append('hash', uniqueHash);
      payload.append('name', target.name);

      try {
        await axios.post('/api/transfer/partial', payload, {
          headers: { 'Content-Type': 'multipart/form-data' }
        });
        this.recordProgress(uniqueHash, index);
      } catch (err) {
        console.error(`セグメント${index}失敗:`, err);
        throw err;
      }
    }

    // 最終結合指示
    await axios.post('/api/transfer/assemble', {
      fileName: target.name,
      fileHash: uniqueHash,
      totalSegments: segmentCount
    });
  },

  recordProgress(hash, segIndex) {
    const history = JSON.parse(localStorage.getItem(hash) || '[]');
    history.push(segIndex);
    localStorage.setItem(hash, JSON.stringify(history));
  },

  generateHash(file) {
    return new Promise(resolve => {
      const fileReader = new FileReader();
      const hasher = new SparkMD5.ArrayBuffer();

      fileReader.onload = e => {
        hasher.append(e.target.result);
        resolve(hasher.end());
      };

      // IE8互換処理
      if (file.slice) {
        fileReader.readAsArrayBuffer(file.slice(0, 1048576)); // 先頭1MBのみハッシュ計算
      } else if (file.webkitSlice) {
        fileReader.readAsArrayBuffer(file.webkitSlice(0, 1048576));
      } else {
        fileReader.readAsArrayBuffer(file);
      }
    });
  }
}

サーバーサイド処理(JSPベース)

// チャンク受信サーブレット
@WebServlet("/api/transfer/partial")
public class FragmentReceiver extends HttpServlet {
    private static final String TEMP_STORAGE = "/var/tmp/transfers/";

    protected void doPost(HttpServletRequest req, HttpServletResponse resp) {
        try {
            Part fragmentPart = req.getPart("fragment");
            int sequenceNum = Integer.parseInt(req.getParameter("sequence"));
            String fileId = req.getParameter("hash");

            File storageDir = new File(TEMP_STORAGE + fileId);
            if (!storageDir.exists()) storageDir.mkdirs();

            File tempFragment = new File(storageDir, "part_" + sequenceNum);
            try (InputStream source = fragmentPart.getInputStream();
                 FileOutputStream dest = new FileOutputStream(tempFragment)) {
                byte[] buffer = new byte[8192];
                int len;
                while ((len = source.read(buffer)) != -1) {
                    dest.write(buffer, 0, len);
                }
            }

            TransferDAO.markSegmentReceived(fileId, sequenceNum);
            resp.getWriter().write("{\"status\":\"ok\"}");
        } catch (Exception ex) {
            resp.setStatus(500);
            resp.getWriter().write("{\"message\":\"" + ex.getMessage() + "\"}");
        }
    }
}

// 最終結合処理
@WebServlet("/api/transfer/assemble")
public class AssemblerServlet extends HttpServlet {
    protected void doPost(HttpServletRequest req, HttpServletResponse resp) {
        String fileId = req.getParameter("fileHash");
        String originalName = req.getParameter("fileName");
        int totalParts = Integer.parseInt(req.getParameter("totalSegments"));

        File workDir = new File(TEMP_STORAGE + fileId);
        File finalFile = new File(workDir, originalName);

        try (FileOutputStream merger = new FileOutputStream(finalFile)) {
            for (int i = 0; i < totalParts; i++) {
                File partFile = new File(workDir, "part_" + i);
                try (FileInputStream partStream = new FileInputStream(partFile)) {
                    byte[] buf = new byte[8192];
                    int readBytes;
                    while ((readBytes = partStream.read(buf)) != -1) {
                        merger.write(buf, 0, readBytes);
                    }
                }
                partFile.delete(); // 結合後は削除
            }

            // Huawei OSSへ転送
            OSSClient client = new OSSClient(...);
            client.putObject("bucket-id", "archives/" + originalName, finalFile);

            TransferDAO.finalizeUpload(fileId, originalName);
            resp.getWriter().write("{\"status\":\"completed\"}");
        } catch (Exception ex) {
            resp.setStatus(500);
            resp.getWriter().write("{\"message\":\"" + ex.getMessage() + "\"}");
        }
    }
}

ディレクトリ構造維持アップロード

// フォルダ構造解析とアップロード
methods: {
  async transmitDirectory(dirEntry) {
    const items = await this.scanDirectory(dirEntry);
    const structureMap = {};

    items.forEach(item => {
      const pathKey = item.webkitRelativePath || this.computePath(item, dirEntry);
      structureMap[pathKey] = item;
    });

    const { data: { sessionId } } = await axios.post('/api/directory/initiate', {
      rootName: dirEntry.name,
      paths: Object.keys(structureMap)
    });

    for (const [path, fileObj] of Object.entries(structureMap)) {
      await this.transmitFile(fileObj, {
        session: sessionId,
        relativePath: path
      });
    }

    await axios.post('/api/directory/finalize', { sessionId });
  },

  scanDirectory(container) {
    return new Promise(resolve => {
      if (container.items) {
        const collected = [];
        const scanner = container.createReader();
        
        const fetchMore = () => {
          scanner.readEntries(results => {
            if (results.length > 0) {
              collected.push(...results);
              fetchMore();
            } else {
              resolve(collected);
            }
          });
        };
        fetchMore();
      } else if (container.files) {
        resolve(Array.from(container.files));
      } else {
        resolve([]);
      }
    });
  }
}

データベース管理クラス

public class DirectoryStructureManager {
    public static String initiateSession(String rootFolder, String[] filePaths) {
        String sessionId = UUID.randomUUID().toString();

        try (Connection db = DBConnector.obtain()) {
            String insertFolder = "INSERT INTO transfer_sessions (session_id, folder_name, state) VALUES (?, ?, 'active')";
            try (PreparedStatement ps1 = db.prepareStatement(insertFolder)) {
                ps1.setString(1, sessionId);
                ps1.setString(2, rootFolder);
                ps1.executeUpdate();
            }

            String insertPaths = "INSERT INTO session_paths (session_id, file_path, status) VALUES (?, ?, 'queued')";
            try (PreparedStatement ps2 = db.prepareStatement(insertPaths)) {
                for (String path : filePaths) {
                    ps2.setString(1, sessionId);
                    ps2.setString(2, path);
                    ps2.addBatch();
                }
                ps2.executeBatch();
            }
        } catch (SQLException sqle) {
            throw new RuntimeException("セッション初期化失敗", sqle);
        }
        return sessionId;
    }

    public static void markPathCompleted(String sessionId, String filePath) {
        // 完了状態更新処理
    }

    public static void closeSession(String sessionId) {
        // セッション完了処理
    }
}

特殊対応技術

  • IE8互換:Flash/ActiveXフォールバック、機能制限による代替案提示
  • 進捗永続化:localStorage + IndexedDB併用、定期的なサーバー同期
  • 大容量フォルダダウンロード:オンデマンドストリーミング、動的構造生成
  • 負荷分散:同時接続数制限、Huawei OSS直接転送によるサーバー負担軽減

導入手順

  1. Eclipse/IDEAプロジェクトインポート
  2. データベーステーブル作成(SQLスクリプト適用)
  3. DB接続設定ファイル修正
  4. テストページアクセスで動作確認

ファイル保存パス構造

up6/upload/{年}/{月}/{日}/{UUID}/{元ファイル名}

動作イメージ

  • 大ファイル分割アップロード
  • ブラウザ再起動後も継続可能な中断再開
  • 階層構造を保持したフォルダ一括アップロード

タグ: Java Vue2 JSP HuaweiOSS 大容量ファイル転送

6月18日 18:49 投稿