大規模ファイル転送システムの設計と実装
要件定義と課題分析
北京のソフトウェア企業で担当するプロジェクトでは、以下のような要件を満たす大容量ファイル転送機能が必要です:
- 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直接転送によるサーバー負担軽減
導入手順
- Eclipse/IDEAプロジェクトインポート
- データベーステーブル作成(SQLスクリプト適用)
- DB接続設定ファイル修正
- テストページアクセスで動作確認
ファイル保存パス構造
up6/upload/{年}/{月}/{日}/{UUID}/{元ファイル名}
動作イメージ
- 大ファイル分割アップロード
- ブラウザ再起動後も継続可能な中断再開
- 階層構造を保持したフォルダ一括アップロード