作为前端开发者,我们经常遇到需要上传大文件的场景。最近我在参与一个政府项目时,遇到了一个特殊需求:在信创环境下实现4GB以上大文件的上传功能,并且要求代码完全开源可审查。这个需求看似简单,实则暗藏玄机。
信创环境指的是国产化信息技术应用创新环境,包括国产操作系统(如银河麒麟)、国产CPU(如龙芯)和国产浏览器。这些环境与常规开发环境存在诸多差异:
传统的大文件上传方案如WebUploader等组件,要么已经停止维护,要么无法适配信创环境。经过评估,我们决定基于Vue-cli自研解决方案,核心目标包括:
我们的方案采用前后端分离架构:
前端部分:
后端部分:
分片大小选择:
文件哈希算法:
并发控制:
断点续传实现:
文件分片是大文件上传的核心技术。我们使用JavaScript的Blob.slice方法实现:
javascript复制function sliceFile(file, chunkSize) {
const chunks = []
let start = 0
while (start < file.size) {
const end = Math.min(start + chunkSize, file.size)
chunks.push(file.slice(start, end))
start = end
}
return chunks
}
关键参数说明:
chunkSize: 8MB(8 * 1024 * 1024 bytes)file: 用户选择的文件对象start/end: 分片的起始和结束位置文件哈希用于唯一标识文件,支持断点续传和秒传功能。我们实现了支持国密算法的哈希计算:
javascript复制async function calculateFileHash(file) {
// 优先使用国产加密API
if (window.govCrypto) {
const reader = new FileReader()
const buffer = await new Promise((resolve) => {
reader.onload = (e) => resolve(e.target.result)
reader.readAsArrayBuffer(file.slice(0, 2 * 1024 * 1024)) // 只读前2MB
})
try {
const hash = await window.govCrypto.digest('SM3', buffer)
return 'sm3:' + hash
} catch (error) {
console.warn('国密算法计算失败,使用降级方案')
return 'mock-hash-for-audit'
}
}
// 降级方案
return 'md5:' + file.name.replace(/\./g, '') + file.size % 1000
}
注意:在实际生产环境中,应该实现完整的文件哈希计算,示例中的降级方案仅用于演示。
分片上传是整个系统的核心功能,我们使用axios实现:
javascript复制async function uploadChunk(file, chunkIndex, fileHash, chunkSize) {
const start = chunkIndex * chunkSize
const end = Math.min(file.size, start + chunkSize)
const chunk = file.slice(start, end)
const formData = new FormData()
formData.append('file', chunk)
formData.append('chunkIndex', chunkIndex)
formData.append('totalChunks', Math.ceil(file.size / chunkSize))
formData.append('fileHash', fileHash)
formData.append('fileName', file.name)
const config = {
headers: { 'Content-Type': 'multipart/form-data' },
timeout: 30000,
onUploadProgress: (progressEvent) => {
const percent = Math.round(
(progressEvent.loaded / progressEvent.total) * 100
)
console.log(`分片 ${chunkIndex} 上传进度: ${percent}%`)
}
}
try {
const response = await axios.post('/api/upload', formData, config)
return response.data
} catch (error) {
console.error(`分片 ${chunkIndex} 上传失败:`, error)
throw error
}
}
为了优化上传性能,我们实现了并发控制和断点续传:
javascript复制async function uploadFile(file, chunkSize = 8 * 1024 * 1024) {
const fileHash = await calculateFileHash(file)
const totalChunks = Math.ceil(file.size / chunkSize)
// 从本地存储获取已上传分片
const uploadedChunks = JSON.parse(
localStorage.getItem(`uploaded_${fileHash}`) || '[]'
)
// 并发控制
const MAX_CONCURRENT = navigator.userAgent.includes('Kylin') ? 2 : 5
const queue = []
const results = []
for (let i = 0; i < totalChunks; i++) {
if (uploadedChunks.includes(i)) {
console.log(`分片 ${i} 已上传,跳过`)
continue
}
if (queue.length >= MAX_CONCURRENT) {
await Promise.race(queue)
}
const task = uploadChunk(file, i, fileHash, chunkSize)
.then((result) => {
// 记录已上传分片
uploadedChunks.push(i)
localStorage.setItem(
`uploaded_${fileHash}`,
JSON.stringify(uploadedChunks)
)
return result
})
.finally(() => {
queue.splice(queue.indexOf(task), 1)
})
queue.push(task)
results.push(task)
}
await Promise.all(results)
// 所有分片上传完成,触发合并
const mergeResult = await axios.post('/api/merge', {
fileHash,
fileName: file.name,
totalChunks
})
// 清理本地存储
localStorage.removeItem(`uploaded_${fileHash}`)
return mergeResult.data
}
我们通过检测用户代理和浏览器特性来识别国产浏览器:
javascript复制function isGovBrowser() {
const ua = navigator.userAgent.toLowerCase()
return (
ua.includes('konglong') ||
ua.includes('xinxin') ||
ua.includes('loongson') ||
document.documentElement.style.hasOwnProperty('webkitTextSizeAdjust')
)
}
国产网络环境不稳定,我们增加了自动重试机制:
javascript复制async function uploadWithRetry(file, chunkIndex, retries = 3) {
let lastError
for (let i = 0; i < retries; i++) {
try {
return await uploadChunk(file, chunkIndex)
} catch (error) {
lastError = error
if (error.code === 'ECONNABORTED') {
await new Promise((resolve) => setTimeout(resolve, 3000))
continue
}
throw error
}
}
throw lastError
}
我们封装了国产加密API的调用:
javascript复制async function govCryptoDigest(data) {
if (!window.govCrypto) {
throw new Error('国产加密API不可用')
}
try {
return await window.govCrypto.digest('SM3', data)
} catch (error) {
console.error('国密算法计算失败:', error)
throw error
}
}
我们的后端提供了以下关键接口:
分片上传接口:
/api/uploadjson复制{
"success": true,
"chunkIndex": 0,
"message": "分片上传成功"
}
合并文件接口:
/api/mergejson复制{
"success": true,
"filePath": "/uploads/example.txt",
"size": 12345678
}
java复制@RestController
@RequestMapping("/api")
public class FileUploadController {
@PostMapping("/upload")
public ResponseEntity<?> uploadChunk(
@RequestParam("file") MultipartFile file,
@RequestParam("chunkIndex") int chunkIndex,
@RequestParam("totalChunks") int totalChunks,
@RequestParam("fileHash") String fileHash,
@RequestParam("fileName") String fileName
) {
// 存储分片到临时目录
String tempDir = "/tmp/uploads/" + fileHash + "/";
File dir = new File(tempDir);
if (!dir.exists()) {
dir.mkdirs();
}
String chunkPath = tempDir + chunkIndex;
try {
file.transferTo(new File(chunkPath));
return ResponseEntity.ok(Map.of(
"success", true,
"chunkIndex", chunkIndex,
"message", "分片上传成功"
));
} catch (IOException e) {
return ResponseEntity.status(500).body(Map.of(
"success", false,
"message", "分片保存失败"
));
}
}
@PostMapping("/merge")
public ResponseEntity<?> mergeChunks(
@RequestBody MergeRequest request
) throws IOException {
String fileHash = request.getFileHash();
String tempDir = "/tmp/uploads/" + fileHash + "/";
File dir = new File(tempDir);
// 检查所有分片是否已上传
for (int i = 0; i < request.getTotalChunks(); i++) {
File chunk = new File(tempDir + i);
if (!chunk.exists()) {
return ResponseEntity.badRequest().body(
Map.of("success", false, "message", "分片不完整")
);
}
}
// 合并文件
String outputPath = "/uploads/" + request.getFileName();
try (FileOutputStream fos = new FileOutputStream(outputPath)) {
for (int i = 0; i < request.getTotalChunks(); i++) {
File chunkFile = new File(tempDir + i);
Files.copy(chunkFile.toPath(), fos);
chunkFile.delete();
}
dir.delete();
}
return ResponseEntity.ok(Map.of(
"success", true,
"filePath", outputPath,
"size", new File(outputPath).length()
));
}
}
动态分片大小:
javascript复制function getDynamicChunkSize() {
const isSlowNetwork = navigator.connection
? navigator.connection.downlink < 2
: false
return isSlowNetwork ? 2 * 1024 * 1024 : 8 * 1024 * 1024
}
内存优化:
并行上传优化:
模拟慢速网络:
code复制网络速度: Fast 3G
延迟: 200ms
分片上传日志:
错误模拟:
以下是完整的Vue文件上传组件实现:
vue复制<template>
<div class="file-uploader">
<input
type="file"
@change="handleFileChange"
:disabled="uploading"
/>
<button
@click="startUpload"
:disabled="!selectedFile || uploading"
>
{{ uploading ? `上传中 (${progress}%)` : '开始上传' }}
</button>
<div v-if="uploading" class="progress-container">
<div class="progress-bar" :style="{ width: progress + '%' }"></div>
</div>
<div v-if="error" class="error-message">
{{ error }}
</div>
</div>
</template>
<script>
export default {
name: 'FileUploader',
data() {
return {
selectedFile: null,
uploading: false,
progress: 0,
error: null,
chunkSize: 8 * 1024 * 1024, // 8MB
fileHash: '',
isGovBrowser: false
}
},
created() {
this.detectGovBrowser()
},
methods: {
detectGovBrowser() {
const ua = navigator.userAgent.toLowerCase()
this.isGovBrowser = ua.includes('konglong') ||
ua.includes('xinxin') ||
ua.includes('loongson')
},
handleFileChange(event) {
this.selectedFile = event.target.files[0]
this.error = null
},
async calculateFileHash(file) {
if (this.isGovBrowser && window.govCrypto) {
try {
const buffer = await this.readFileChunk(file, 0, 2 * 1024 * 1024)
const hash = await window.govCrypto.digest('SM3', buffer)
return 'sm3:' + hash
} catch (error) {
console.warn('国密算法失败:', error)
}
}
// 降级方案
return 'md5:' + file.name.replace(/\./g, '') + file.size % 1000
},
readFileChunk(file, start, end) {
return new Promise((resolve) => {
const reader = new FileReader()
reader.onload = (e) => resolve(e.target.result)
reader.readAsArrayBuffer(file.slice(start, end))
})
},
async uploadChunk(file, chunkIndex) {
const start = chunkIndex * this.chunkSize
const end = Math.min(file.size, start + this.chunkSize)
const chunk = file.slice(start, end)
const formData = new FormData()
formData.append('file', chunk)
formData.append('chunkIndex', chunkIndex)
formData.append('totalChunks', Math.ceil(file.size / this.chunkSize))
formData.append('fileHash', this.fileHash)
formData.append('fileName', file.name)
const config = {
headers: { 'Content-Type': 'multipart/form-data' },
timeout: this.isGovBrowser ? 60000 : 30000,
onUploadProgress: (progressEvent) => {
const chunkProgress = Math.round(
(progressEvent.loaded / progressEvent.total) * 100
)
this.updateTotalProgress(chunkIndex, chunkProgress)
}
}
try {
const response = await this.$http.post('/api/upload', formData, config)
return response.data
} catch (error) {
if (error.code === 'ECONNABORTED' && this.isGovBrowser) {
console.log('网络超时,3秒后重试...')
await new Promise(resolve => setTimeout(resolve, 3000))
return this.uploadChunk(file, chunkIndex)
}
throw error
}
},
updateTotalProgress(chunkIndex, chunkProgress) {
const totalChunks = Math.ceil(this.selectedFile.size / this.chunkSize)
const chunkRatio = 100 / totalChunks
const baseProgress = chunkIndex * chunkRatio
const incrementalProgress = (chunkProgress / 100) * chunkRatio
this.progress = Math.round(baseProgress + incrementalProgress)
},
async startUpload() {
if (!this.selectedFile) return
try {
this.uploading = true
this.progress = 0
this.error = null
// 计算文件哈希
this.fileHash = await this.calculateFileHash(this.selectedFile)
// 获取已上传分片
const uploadedChunks = JSON.parse(
localStorage.getItem(`uploaded_${this.fileHash}`) || '[]'
)
// 并发控制
const MAX_CONCURRENT = this.isGovBrowser ? 2 : 5
const queue = []
const results = []
const totalChunks = Math.ceil(this.selectedFile.size / this.chunkSize)
for (let i = 0; i < totalChunks; i++) {
if (uploadedChunks.includes(i)) {
this.updateTotalProgress(i, 100)
continue
}
if (queue.length >= MAX_CONCURRENT) {
await Promise.race(queue)
}
const task = this.uploadChunk(this.selectedFile, i)
.then((result) => {
uploadedChunks.push(i)
localStorage.setItem(
`uploaded_${this.fileHash}`,
JSON.stringify(uploadedChunks)
)
return result
})
.finally(() => {
queue.splice(queue.indexOf(task), 1)
})
queue.push(task)
results.push(task)
}
await Promise.all(results)
// 合并文件
const mergeResult = await this.$http.post('/api/merge', {
fileHash: this.fileHash,
fileName: this.selectedFile.name,
totalChunks
})
// 清理本地存储
localStorage.removeItem(`uploaded_${this.fileHash}`)
this.$emit('upload-complete', mergeResult.data)
} catch (error) {
console.error('上传失败:', error)
this.error = `上传失败: ${error.message}`
} finally {
this.uploading = false
}
}
}
}
</script>
<style scoped>
.file-uploader {
max-width: 500px;
margin: 0 auto;
padding: 20px;
border: 1px solid #eee;
border-radius: 8px;
}
.progress-container {
margin-top: 10px;
height: 20px;
background: #f0f0f0;
border-radius: 4px;
overflow: hidden;
}
.progress-bar {
height: 100%;
background: #42b983;
transition: width 0.3s ease;
}
.error-message {
margin-top: 10px;
color: #ff4d4f;
}
</style>
问题现象:
解决方案:
javascript复制async function uploadWithRetry(file, chunkIndex, maxRetries = 3) {
let retries = 0
while (retries < maxRetries) {
try {
return await uploadChunk(file, chunkIndex)
} catch (error) {
retries++
if (retries >= maxRetries) throw error
await new Promise(resolve => setTimeout(resolve, 1000 * retries))
}
}
}
问题现象:
解决方案:
javascript复制async function calculateFullFileHash(file, chunkSize = 2 * 1024 * 1024) {
const hash = await window.crypto.subtle.digest(
'SHA-256',
await readFileChunk(file, 0, file.size)
)
return Array.from(new Uint8Array(hash))
.map(b => b.toString(16).padStart(2, '0'))
.join('')
}
问题现象:
解决方案:
javascript复制function isFeatureSupported(feature) {
if (this.isGovBrowser) {
// 国产浏览器特性支持表
const govSupport = {
'FileReader': true,
'Blob': true,
'crypto': false,
'Promise': true
}
return govSupport[feature] || false
}
return feature in window
}
问题现象:
解决方案:
javascript复制function processFileInChunks(file, chunkSize, processFn) {
let offset = 0
const fileSize = file.size
return new Promise((resolve) => {
const readNext = () => {
if (offset >= fileSize) {
resolve()
return
}
const chunk = file.slice(offset, offset + chunkSize)
const reader = new FileReader()
reader.onload = (e) => {
processFn(e.target.result, offset)
offset += chunkSize
readNext()
}
reader.readAsArrayBuffer(chunk)
}
readNext()
})
}
代码分割:
javascript复制const FileUploader = () => import('./components/FileUploader.vue')
性能监控:
javascript复制// 性能监控示例
const perfData = {
startTime: 0,
chunks: {},
uploadSpeed: 0
}
function startPerfMonitor() {
perfData.startTime = Date.now()
}
function recordChunkPerf(chunkIndex, size, duration) {
perfData.chunks[chunkIndex] = {
size,
duration,
speed: size / (duration / 1000)
}
updateAvgSpeed()
}
function updateAvgSpeed() {
const chunks = Object.values(perfData.chunks)
if (chunks.length === 0) return
const totalSize = chunks.reduce((sum, c) => sum + c.size, 0)
const totalTime = chunks.reduce((sum, c) => sum + c.duration, 0)
perfData.uploadSpeed = totalSize / (totalTime / 1000)
}
分片存储优化:
合并效率优化:
java复制// Java高效文件合并示例
public void mergeFiles(List<File> chunks, File output) throws IOException {
try (FileChannel outChannel = new FileOutputStream(output).getChannel()) {
for (File chunk : chunks) {
try (FileChannel inChannel = new FileInputStream(chunk).getChannel()) {
inChannel.transferTo(0, inChannel.size(), outChannel);
}
chunk.delete();
}
}
}
分片校验:
权限控制:
javascript复制// 前端添加JWT示例
axios.interceptors.request.use(config => {
const token = localStorage.getItem('jwt')
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
})
在这个项目的开发过程中,我们积累了以下几点重要经验:
国产环境适配要趁早:
分片大小需要动态调整:
错误处理要全面:
性能监控必不可少:
用户体验细节很重要:
实际开发中我们还遇到了一些有趣的挑战,比如在国产操作系统上,浏览器的File API实现与标准有细微差异,导致最初的分片计算出错。通过增加环境检测和兼容处理,我们最终解决了这些问题。
对于想要实现类似功能的开发者,我的建议是:
这个项目的完整代码已经开源,包含了更多高级功能和优化,可以作为实际项目开发的参考基础。通过这个项目,我们不仅满足了客户的需求,还积累了一套可靠的大文件上传解决方案,为后续类似项目打下了坚实基础。