大文件上傳系統開發日記
2023年5月15日 星期一 晴
今天接到了一個頗具挑戰性的外包項目,客户需要開發一個支持大文件(20G左右)上傳下載的系統,特別強調要支持文件夾上傳並保留層級結構。作為河北的一名個人開發者,我深知這個項目的難度,但也是一次很好的學習機會。
項目需求分析
- 技術要求:
- 必須使用原生JavaScript實現(不能使用jQuery等庫)
- 支持WebUploader或H5原生API
- 支持20G大文件傳輸
- 文件夾上傳下載並保留結構
- 加密傳輸(SM4/AES)和存儲
- 斷點續傳(跨瀏覽器關閉)
- 兼容IE8+等所有主流瀏覽器
- 環境要求:
- 後端:ASP.NET WebForm (C#)
- 前端:Vue3 CLI (但要求原生JS實現上傳邏輯)
- 數據庫:SQL Server
- 存儲:阿里雲OSS私有云
- 服務器:阿里雲ECS
- 特殊要求:
- 免費提供7*24技術支持
- 提供完整源代碼
- 編譯打包部署服務
- 完整開發文檔
技術方案確定
經過研究,我決定採用以下技術方案:
- 前端:
- 使用WebUploader作為基礎,但需要大量修改以支持原生JS和文件夾上傳
- 對於IE8兼容,使用Flash fallback方案
- 使用Web Crypto API和crypto-js庫實現加密
- 後端:
- ASP.NET WebForm處理上傳請求
- 使用阿里雲OSS SDK進行文件存儲
- SQL Server記錄上傳狀態用於斷點續傳
今日進展
開始搭建項目框架,先實現最基本的文件上傳功能:
前端代碼 (HTML部分)
大文件上傳系統
.uploader-container {
width: 800px;
margin: 20px auto;
}
.file-list {
margin-top: 20px;
border: 1px solid #ddd;
min-height: 300px;
padding: 10px;
}
.file-item {
padding: 5px;
border-bottom: 1px solid #eee;
}
.progress {
height: 20px;
margin-top: 5px;
background: #f5f5f5;
}
.progress-bar {
height: 100%;
background: #4CAF50;
width: 0%;
text-align: center;
line-height: 20px;
color: white;
}
大文件上傳系統
選擇文件
開始上傳
暫停
繼續
前端代碼 (JavaScript部分 - uploader.js)
// 全局變量
var uploader;
var fileChunks = {}; // 存儲文件分片信息
var encryptKey = null; // 加密密鑰
// 初始化上傳器
document.addEventListener('DOMContentLoaded', function() {
// 生成加密密鑰 (實際應用中應該從服務器獲取)
generateEncryptKey();
// 初始化WebUploader
initWebUploader();
// 綁定事件
document.getElementById('uploadBtn').addEventListener('click', startUpload);
document.getElementById('pauseBtn').addEventListener('click', pauseUpload);
document.getElementById('continueBtn').addEventListener('click', continueUpload);
});
// 生成加密密鑰 (AES示例)
function generateEncryptKey() {
// 實際應用中應該通過安全方式從服務器獲取密鑰
// 這裏只是示例,生成一個隨機密鑰
var key = CryptoJS.lib.WordArray.random(16).toString();
encryptKey = key;
console.log('加密密鑰已生成:', key);
}
// 初始化WebUploader
function initWebUploader() {
uploader = WebUploader.create({
// 自動上傳設為false,我們手動控制
auto: false,
// 使用swf上傳,兼容IE8
swf: 'https://cdn.jsdelivr.net/npm/webuploader@0.1.1/dist/Uploader.swf',
// 文件接收服務端
server: '/FileUpload.ashx',
// 選擇文件的按鈕
pick: {
id: '#filePicker',
multiple: true
},
// 分片上傳設置
chunked: true,
chunkSize: 5 * 1024 * 1024, // 5MB一片
chunkRetry: 3, // 分片上傳失敗重試次數
threads: 3, // 併發數
// 不壓縮image
compress: false,
// 允許的文件類型
accept: {
title: 'All',
extensions: '*',
mimeTypes: '*'
},
// 額外添加的數據
formData: {
userId: '123', // 實際應用中從登錄信息獲取
encryptType: 'AES' // 加密類型
}
});
// 文件加入隊列事件
uploader.on('fileQueued', function(file) {
addFileToList(file);
// 計算文件MD5用於斷點續傳
calculateFileMD5(file);
});
// 上傳進度事件
uploader.on('uploadProgress', function(file, percentage) {
updateProgress(file.id, percentage);
});
// 上傳成功事件
uploader.on('uploadSuccess', function(file, response) {
if (response.success) {
updateStatus(file.id, '上傳成功');
} else {
updateStatus(file.id, '上傳失敗: ' + response.message);
}
});
// 上傳錯誤事件
uploader.on('uploadError', function(file, reason) {
updateStatus(file.id, '上傳錯誤: ' + reason);
});
// 上傳完成事件 (不管成功失敗)
uploader.on('uploadComplete', function(file) {
// 可以在這裏做一些清理工作
});
}
// 計算文件MD5 (用於斷點續傳)
function calculateFileMD5(file) {
updateStatus(file.id, '計算文件MD5...');
uploader.md5File(file)
.then(function(md5) {
file.md5 = md5;
updateStatus(file.id, 'MD5計算完成: ' + md5);
// 檢查斷點續傳信息
checkResumeInfo(file);
})
.fail(function() {
updateStatus(file.id, 'MD5計算失敗');
});
}
// 檢查斷點續傳信息
function checkResumeInfo(file) {
// 實際應用中應該向服務器查詢上傳狀態
// 這裏模擬一個異步請求
setTimeout(function() {
// 模擬服務器返回 - 假設已經上傳了30%
var uploadedChunks = Math.floor(file.size / uploader.option('chunkSize') * 0.3);
file.uploadedChunks = uploadedChunks;
updateStatus(file.id, '已上傳 ' + Math.floor(uploadedChunks / (file.size / uploader.option('chunkSize')) * 100) + '%');
}, 500);
}
// 添加文件到列表
function addFileToList(file) {
var fileList = document.getElementById('fileList');
var fileItem = document.createElement('div');
fileItem.className = 'file-item';
fileItem.id = file.id;
fileItem.innerHTML = '' +
'' + file.name + '' +
'(' + formatFileSize(file.size) + ')' +
'' +
'' +
'' +
'' +
'等待上傳...';
fileList.appendChild(fileItem);
}
// 更新進度條
function updateProgress(fileId, percentage) {
var progressBar = document.getElementById('progress-' + fileId);
if (progressBar) {
progressBar.style.width = Math.floor(percentage * 100) + '%';
progressBar.textContent = Math.floor(percentage * 100) + '%';
}
}
// 更新狀態
function updateStatus(fileId, status) {
var statusEl = document.getElementById('status-' + fileId);
if (statusEl) {
statusEl.textContent = status;
}
}
// 格式化文件大小
function formatFileSize(bytes) {
if (bytes === 0) return '0 Bytes';
var k = 1024;
var sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
var i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}
// 開始上傳
function startUpload() {
// 加密密鑰檢查
if (!encryptKey) {
alert('加密密鑰未初始化,請刷新頁面重試');
return;
}
// 設置上傳前處理函數 - 用於加密
uploader.option('prepareNextFile', function(file) {
// 在這裏可以對文件進行加密處理
// 由於WebUploader本身不支持流式加密,我們需要在分片上傳前加密每個分片
// 這需要修改WebUploader源碼,我們稍後實現
});
// 開始上傳
uploader.upload();
updateStatus('global', '上傳中...');
}
// 暫停上傳
function pauseUpload() {
uploader.stop(true);
updateStatus('global', '已暫停');
}
// 繼續上傳
function continueUpload() {
uploader.upload();
updateStatus('global', '繼續上傳...');
}
後端代碼 (ASP.NET WebForm處理程序 - FileUpload.ashx)
<%@ WebHandler Language="C#" Class="FileUpload" %>
using System;
using System.Web;
using System.IO;
using System.Collections.Generic;
using System.Security.Cryptography;
using System.Text;
using System.Data.SqlClient;
using System.Configuration;
public class FileUpload : IHttpHandler
{
// 數據庫連接字符串
private static readonly string connStr = ConfigurationManager.ConnectionStrings["SQLServerConn"].ConnectionString;
// 阿里雲OSS配置 (應該從配置文件中讀取)
private static readonly string ossEndpoint = "your-oss-endpoint";
private static readonly string ossAccessKeyId = "your-access-key-id";
private static readonly string ossAccessKeySecret = "your-access-key-secret";
private static readonly string ossBucketName = "your-bucket-name";
public void ProcessRequest(HttpContext context)
{
context.Response.ContentType = "application/json";
try
{
// 獲取請求參數
string userId = context.Request.Form["userId"];
string encryptType = context.Request.Form["encryptType"];
string fileMd5 = context.Request.Form["md5"];
int chunk = int.Parse(context.Request.Form["chunk"] ?? "0");
int chunks = int.Parse(context.Request.Form["chunks"] ?? "1");
string fileName = context.Request.Form["name"];
long fileSize = long.Parse(context.Request.Form["size"] ?? "0");
// 驗證參數
if (string.IsNullOrEmpty(userId) || string.IsNullOrEmpty(fileName) || fileSize <= 0)
{
context.Response.Write("{\"success\":false,\"message\":\"參數錯誤\"}");
return;
}
// 獲取上傳的文件
HttpPostedFile file = context.Request.Files["file"];
if (file == null || file.ContentLength <= 0)
{
context.Response.Write("{\"success\":false,\"message\":\"未接收到文件數據\"}");
return;
}
// 處理文件分片
string tempDir = context.Server.MapPath("~/App_Data/UploadTemp/" + userId + "/" + fileMd5);
if (!Directory.Exists(tempDir))
{
Directory.CreateDirectory(tempDir);
}
string tempFilePath = Path.Combine(tempDir, chunk.ToString());
file.SaveAs(tempFilePath);
// 如果是最後一個分片,合併文件並上傳到OSS
if (chunk == chunks - 1)
{
string finalFilePath = Path.Combine(tempDir, fileName);
MergeFile(tempDir, finalFilePath, chunks);
// 加密文件 (這裏簡化處理,實際應該根據encryptType進行相應加密)
string encryptedFilePath = finalFilePath + ".enc";
EncryptFile(finalFilePath, encryptedFilePath, "your-encryption-key");
// 上傳到阿里雲OSS
string ossKey = "uploads/" + userId + "/" + DateTime.Now.ToString("yyyyMMdd") + "/" + fileName;
bool uploadSuccess = UploadToOSS(encryptedFilePath, ossKey);
// 清理臨時文件
try { File.Delete(finalFilePath); } catch { }
try { File.Delete(encryptedFilePath); } catch { }
Directory.Delete(tempDir, true);
if (!uploadSuccess)
{
context.Response.Write("{\"success\":false,\"message\":\"上傳到OSS失敗\"}");
return;
}
// 記錄到數據庫
RecordUploadToDB(userId, fileName, fileSize, ossKey, fileMd5);
}
// 記錄分片上傳狀態 (用於斷點續傳)
RecordChunkUpload(userId, fileMd5, chunk, chunks);
context.Response.Write("{\"success\":true,\"message\":\"分片上傳成功\"}");
}
catch (Exception ex)
{
context.Response.Write("{\"success\":false,\"message\":\"" + ex.Message.Replace("\"", "\\\"") + "\"}");
}
}
// 合併分片文件
private void MergeFile(string tempDir, string finalFilePath, int chunks)
{
using (FileStream fs = new FileStream(finalFilePath, FileMode.Create))
{
for (int i = 0; i < chunks; i++)
{
string chunkPath = Path.Combine(tempDir, i.ToString());
byte[] bytes = File.ReadAllBytes(chunkPath);
fs.Write(bytes, 0, bytes.Length);
File.Delete(chunkPath);
}
}
}
// 文件加密 (簡化示例)
private void EncryptFile(string inputFile, string outputFile, string key)
{
// 實際應用中應該使用AES或SM4等算法
// 這裏只是示例,實際應該使用更安全的加密方式
using (Aes aesAlg = Aes.Create())
{
// 使用固定密鑰,實際應用中應該從安全配置獲取
byte[] keyBytes = Encoding.UTF8.GetBytes(key.PadRight(32).Substring(0, 32));
byte[] iv = new byte[16]; // 使用全0 IV,實際應用中應該隨機生成
aesAlg.Key = keyBytes;
aesAlg.IV = iv;
ICryptoTransform encryptor = aesAlg.CreateEncryptor(aesAlg.Key, aesAlg.IV);
using (FileStream fsInput = new FileStream(inputFile, FileMode.Open))
using (FileStream fsOutput = new FileStream(outputFile, FileMode.Create))
using (CryptoStream csEncrypt = new CryptoStream(fsOutput, encryptor, CryptoStreamMode.Write))
{
fsInput.CopyTo(csEncrypt);
}
}
}
// 上傳到阿里雲OSS
private bool UploadToOSS(string filePath, string ossKey)
{
try
{
// 實際應用中應該使用阿里雲OSS SDK
// 這裏只是示例,實際需要引用Aliyun.OSS.SDK NuGet包
/*
var client = new OSSClient(ossEndpoint, ossAccessKeyId, ossAccessKeySecret);
using (var fs = File.Open(filePath, FileMode.Open))
{
client.PutObject(ossBucketName, ossKey, fs);
}
*/
return true;
}
catch
{
return false;
}
}
// 記錄上傳信息到數據庫
private void RecordUploadToDB(string userId, string fileName, long fileSize, string ossKey, string fileMd5)
{
using (SqlConnection conn = new SqlConnection(connStr))
{
conn.Open();
string sql = @"INSERT INTO FileUploads
(UserId, FileName, FileSize, OSSKey, FileMd5, UploadTime, Status)
VALUES
(@UserId, @FileName, @FileSize, @OSSKey, @FileMd5, GETDATE(), 'Completed')";
using (SqlCommand cmd = new SqlCommand(sql, conn))
{
cmd.Parameters.AddWithValue("@UserId", userId);
cmd.Parameters.AddWithValue("@FileName", fileName);
cmd.Parameters.AddWithValue("@FileSize", fileSize);
cmd.Parameters.AddWithValue("@OSSKey", ossKey);
cmd.Parameters.AddWithValue("@FileMd5", fileMd5);
cmd.ExecuteNonQuery();
}
}
}
// 記錄分片上傳狀態
private void RecordChunkUpload(string userId, string fileMd5, int chunk, int chunks)
{
using (SqlConnection conn = new SqlConnection(connStr))
{
conn.Open();
// 先檢查是否已有記錄
string checkSql = "SELECT COUNT(*) FROM UploadChunks WHERE UserId=@UserId AND FileMd5=@FileMd5 AND Chunk=@Chunk";
using (SqlCommand checkCmd = new SqlCommand(checkSql, conn))
{
checkCmd.Parameters.AddWithValue("@UserId", userId);
checkCmd.Parameters.AddWithValue("@FileMd5", fileMd5);
checkCmd.Parameters.AddWithValue("@Chunk", chunk);
int count = (int)checkCmd.ExecuteScalar();
if (count > 0)
{
// 已經記錄過這個分片,更新狀態
string updateSql = @"UPDATE UploadChunks SET UploadTime=GETDATE(), Status='Uploaded'
WHERE UserId=@UserId AND FileMd5=@FileMd5 AND Chunk=@Chunk";
using (SqlCommand updateCmd = new SqlCommand(updateSql, conn))
{
updateCmd.Parameters.AddWithValue("@UserId", userId);
updateCmd.Parameters.AddWithValue("@FileMd5", fileMd5);
updateCmd.Parameters.AddWithValue("@Chunk", chunk);
updateCmd.ExecuteNonQuery();
}
}
else
{
// 新記錄
string insertSql = @"INSERT INTO UploadChunks
(UserId, FileMd5, Chunk, TotalChunks, UploadTime, Status)
VALUES
(@UserId, @FileMd5, @Chunk, @TotalChunks, GETDATE(), 'Uploaded')";
using (SqlCommand insertCmd = new SqlCommand(insertSql, conn))
{
insertCmd.Parameters.AddWithValue("@UserId", userId);
insertCmd.Parameters.AddWithValue("@FileMd5", fileMd5);
insertCmd.Parameters.AddWithValue("@Chunk", chunk);
insertCmd.Parameters.AddWithValue("@TotalChunks", chunks);
insertCmd.ExecuteNonQuery();
}
}
}
}
}
public bool IsReusable
{
get
{
return false;
}
}
}
數據庫表設計
-- 文件上傳記錄表
CREATE TABLE FileUploads (
Id INT IDENTITY(1,1) PRIMARY KEY,
UserId NVARCHAR(50) NOT NULL,
FileName NVARCHAR(255) NOT NULL,
FileSize BIGINT NOT NULL,
OSSKey NVARCHAR(500) NOT NULL,
FileMd5 NVARCHAR(32) NOT NULL,
UploadTime DATETIME NOT NULL,
Status NVARCHAR(20) NOT NULL DEFAULT 'Pending',
DownloadCount INT DEFAULT 0
);
-- 上傳分片記錄表 (用於斷點續傳)
CREATE TABLE UploadChunks (
Id INT IDENTITY(1,1) PRIMARY KEY,
UserId NVARCHAR(50) NOT NULL,
FileMd5 NVARCHAR(32) NOT NULL,
Chunk INT NOT NULL,
TotalChunks INT NOT NULL,
UploadTime DATETIME NOT NULL,
Status NVARCHAR(20) NOT NULL DEFAULT 'Pending',
UNIQUE (UserId, FileMd5, Chunk)
);
今日總結
今天完成了基礎的文件上傳功能,包括:
- 前端使用WebUploader初始化
- 文件分片上傳
- MD5計算用於斷點續傳
- 基本的後端處理程序
- 數據庫表設計
明天需要解決以下問題:
- 實現文件夾上傳並保留層級結構
- 完善加密功能(前端加密每個分片)
- 實現真正的斷點續傳(從數據庫查詢已上傳分片)
- 開始實現下載功能
- 解決IE8兼容性問題
這個項目確實很有挑戰性,特別是要兼容IE8還要支持大文件上傳。我需要深入研究WebUploader的源碼,看看如何修改以支持文件夾上傳和分片加密。明天繼續加油!
(未完待續…)
設置框架
安裝.NET Framework 4.7.2
https://dotnet.microsoft.com/en-us/download/dotnet-framework/net472 框架選擇4.7.2
添加3rd引用
編譯項目
NOSQL
NOSQL無需任何配置可直接訪問頁面進行測試
SQL
使用IIS
大文件上傳測試推薦使用IIS以獲取更高性能。
使用IIS Express
小文件上傳測試可以使用IIS Express
創建數據庫
配置數據庫連接信息
檢查數據庫配置
訪問頁面進行測試
相關參考:
文件保存位置,
效果預覽
文件上傳
文件刷新續傳
支持離線保存文件進度,在關閉瀏覽器,刷新瀏覽器後進行不丟失,仍然能夠繼續上傳
文件夾上傳
支持上傳文件夾並保留層級結構,同樣支持進度信息離線保存,刷新頁面,關閉頁面,重啓系統不丟失上傳進度。
下載完整示例
下載完整示例