百度SpringBoot上传文件的断点续传,千篇一律的都是分片,即把大文件分割成许多小文件,然后上传所有的小文件到服务器,服务器再把所有的小文件合并为一个大文件。这根本就不是断点续传。断点续传应该是传到哪里断了,下次就在那个断开的位置接着继续传,这代码一想也很简单啊,服务器通过输出流不停的写文件,如果被中断了链接了,则客户端下次上传之前先给服务器发送一个请求,让服务器告诉我们之前传到什么位置了,这样客户端就可以从指定的位置开始继续传了。
网上的做法都是分片,是因为SpringBoot会自动帮我们接收文件,存到一个临时的位置,文件接受完了才给我们去复制文件,如果文件没接收完断开了,Spring默认是没有帮我们做断点续传的,则文件就白传了,需要从头再来,所以分片是为了如果中断也是中断某一片,已传完的片就不需要重传,重传没传完的片即可。
为什么要等SpringBoot接收完文件才给我们复制文件,难道SpringBoot做不到一边接收就一边让我们写文件保存吗?What?SprintBoot这么强大的框架,怎么可能实现不了这样的简单功能,肯定是可以的嘛,既然SpringBoog自动帮我们接收文件,接收完再交给我们复制,那我们就告诉SpringBoot不要自动帮我们接收文件了,我们自己来接收,这样就很容易就实现断点续传了。
在application.properties中添加如下属性来告诉Spring不要自动帮我们接收文件:
spring.servlet.multipart.enabled=false
在解析上传参数的时候,用到了Apache的Commons FileUpload组件,是用来帮我们解析上传参数的,官网在此,在使用方式上有多种,推荐使用流式API,官方教程在此。因为要使用Commons FileUpload组件,所以需要先添加该组件的依赖,如下:
implementation("commons-fileupload:commons-fileupload:1.4")
上传类完整代码如下:
object FileUtil {
fun getFileMd5(file: File): String {
val messageDigest = MessageDigest.getInstance("MD5")
FileInputStream(file).use { fis ->
val buf = ByteArray(8192)
var length: Int
while (fis.read(buf, 0, 8192).also { length = it } != -1) {
messageDigest.update(buf, 0, length)
}
}
// 获取md5签名(16个字节)
val md5Bytes = messageDigest.digest()
// 将byte数组的签名转换为16进制的字符串
val bigInteger = BigInteger(1, md5Bytes) // 把16个字节当成一个无符号的大整数
var md5String = bigInteger.toString(16) // 把大整数转换为16进制的字符串形式
repeat(32 - md5String.length) { // 预防字符串不够32位。1个byte需要两位16进制数,而md5Bytes的长度为16,所以需要32位的16进制数来表示。
md5String = "0$md5String"
}
return md5String
}
}
@RestController
@RequestMapping("/dazhou")
class MainController {
private var uploadedSize = 0L
/** 获取文件已上传的大小,如果文件已经完全上传,则返回文件在服务器的路径 */
@GetMapping("/getFileUploadedSize")
fun getFileUploadedSize(fileMd5: String, fileName: String): String {
println("收到获取文件已上传大小请求")
val file = File("C:\\Users\\Even\\Videos\\${fileMd5}\\$fileName")
if (file.exists()) {
val start = System.currentTimeMillis()
val md5 = FileUtil.getFileMd5(file)
println("md5计算完成,所花时间:${System.currentTimeMillis() - start}")
if (fileMd5 == md5) {
return file.absolutePath // 服务器中的文件md5与客户端的相同,则文件已经完全上传,返回服务器的文件路径
} else {
return "${file.length()}" // md5不相同则文件未完全上传,返回文件已上传的大小
}
} else {
return "0" // 文件不存在,则上传大小为0
}
}
private var isFirst = true
/** 文件上传请求 */
@PostMapping("/upload")
fun upload(request: HttpServletRequest): String {
println("收到文件上传请求,当前线程:${Thread.currentThread().name}")
val start = System.currentTimeMillis()
val isMultipart = ServletFileUpload.isMultipartContent(request)
if (!isMultipart) {
return "isNotMultipart"
}
var destFile: File? = null
var fileSaveDir: File? = null
try {
var fileMd5 = ""
val servletFileUpload = ServletFileUpload()
var progressStart = System.currentTimeMillis()
servletFileUpload.setProgressListener { pBytesRead, pContentLength, _ ->
// 注:这里的进度是整个请求的进度,包含一些普通表单数据的传输,不仅仅是文件传输的进度
if (isFirst || System.currentTimeMillis() - progressStart >= 1000 || pBytesRead == pContentLength) {
isFirst = false
progressStart = System.currentTimeMillis()
val progress = (pBytesRead + uploadedSize).toFloat() / (pContentLength + uploadedSize) * 100
println("上传进度:${progress.toInt()}%")
}
}
val itemIterator = servletFileUpload.getItemIterator(request)
// 遍历所有的上传请求参数
while (itemIterator.hasNext()) {
val fileItemStream = itemIterator.next() // 得到一个参数对象
val fieldName = fileItemStream.fieldName // 得到参数名
val inputStream = fileItemStream.openStream()
if (fileItemStream.isFormField) {
// 普通表单参数
val formFieldValue = Streams.asString(inputStream) // 得到参数值
println("${fieldName}是普通表单参数, value为$formFieldValue")
if (fieldName == "fileMd5") {
fileMd5 = formFieldValue
}
} else {
// 文件参数
val fileName = fileItemStream.name // 得到文件名
println("${fieldName}是文件参数,文件名为:$fileName")
fileSaveDir = File("C:\\Users\\Even\\Videos\\${fileMd5}") // 文件保存目录
if (!fileSaveDir.exists()) { // 如果文件保存目录不存在,则创建出来
val result = fileSaveDir.mkdirs()
println("文件保存目录创建结果:$result")
}
destFile = File(fileSaveDir,fileName)
uploadedSize = destFile.length()
// 把网络中的文件数据写到服务器本地
FileOutputStream(destFile, true).use { fos ->
val size = 1024
val buf = ByteArray(size)
var length: Int
while (inputStream.read(buf, 0, size).also { length = it } != -1) {
fos.write(buf, 0, length)
}
println("文件全部写入到本地了!")
}
}
}
val calcMd5Start = System.currentTimeMillis()
val md5 = FileUtil.getFileMd5(destFile!!)
println("计算md5使用时间:${System.currentTimeMillis() - calcMd5Start}")
if (md5 == fileMd5) { // md5相同则文件上传成功
val success = "文件上传成功,用时:${System.currentTimeMillis() - start},文件路径:${destFile.absoluteFile}"
println(success)
return success
} else {
fileSaveDir!!.deleteRecursively() // 写到服务器端的文件md5与客户端不一致,留着也没用了,删除md5目录和文件
val error = "文件上传失败,服务器计算文件的md5与客户端不一致, 客户端md5 = $fileMd5,服务器端md5 = $md5"
println(error)
return error
}
} catch (e: Exception) {
val error = "文件上传失败,用时:${System.currentTimeMillis() - start},已传大小:${destFile?.length()},异常为:${e.javaClass.simpleName}: ${e.message},当前线程:${Thread.currentThread().name}"
println(error)
return error
}
}
}
OK,上传文件的断点续传就是这么简单,这里的代码不涉及任何的项目业务逻辑,还是非常简单的,而且本人是做Android开发的,所以代码肯定也有写的不好的地方,比如我的文件是直接保存在C盘的。在获取文件上传大小的接口中,判断文件是否已经上传,我是通过比较文件md5来实现的,如果文件已经上传,而且是大文件,则计算md5也是需要花费好几秒种的,这里我就使用了以时间换空间的决策,我就假设大多数文件是不会上传失败的,如果大文件上传失败了要重传,本身就需要很多的上传时间,也就不在乎再多这几秒了。当然,如果服务器磁盘空间充足,可以在保存文件完成的时候,以文件md5为key记录下该文件是否上传成功了,在获取文件已上传大小的接口中,就可以直接查询记录,而不需要再计算md5了,这样可提升速度。
这里还需要注意的是,客户端在调用上传接口时,文件参数要放在最后,因为服务器解析的时候就是从前面到后面的顺序进行解析的。
接下来是Android客户端的代码,使用的是OkHttp,上传的断点续传在Android上的操作简直不要太简单,比分片不知道要简单多少倍。无非就是要在上传文件之前先获取一下之前已上传的大小,如果之前已经上传过,则返回之前已上传的大小,如果之前已经完全上传完成了,则效果为秒传,直接返回文件路径。当然,多一个请求也是一些花销,如果你知道你要上传的文件是刚刚产生的,之前决定没上传过,则在第一次上传时就不需要调用获取之前已上传大小的接口,而上传失败时才需要调用,Anroid端完整代码如下:
界面效果如下:
就一个上传按钮,然后下面有两个TextView,因为我们有两个请求,所以使用两个TextView分别显示对应的请求结果,布局xml如下:
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity">
<Button
android:id="@+id/button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:onClick="uploadFile"
android:text="上传文件"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/uploadedSizeText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:gravity="center_horizontal"
android:text="Text_01"
android:layout_marginTop="9dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.498"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/button"
app:layout_constraintVertical_bias="0.054" />
<TextView
android:id="@+id/uploadResultText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:gravity="center_horizontal"
android:text="Text_02"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.498"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/uploadedSizeText"
app:layout_constraintVertical_bias="0.079" />
</androidx.constraintlayout.widget.ConstraintLayout>
因为使用了OkHttp,所以需要添加如下依赖:
implementation 'com.squareup.okhttp3:okhttp:4.8.1'
还有我们用到了存储权限和网络权限,需要在清单文件中添加上,为了预防高版本手机的兼容问题,我们还加了requestLegacyExternalStorage="true"和usesCleartextTraffic="true"属性,权限的动态申请我没写,所以在运行的时候记得手动到设置中设置一下存储权限,完整清单文件:
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
package="cn.android666.uploaddemo">
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
tools:ignore="ScopedStorage" />
<uses-permission android:name="android.permission.INTERNET"/>
<application
android:allowBackup="false"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.UploadDemo"
android:requestLegacyExternalStorage="true"
android:usesCleartextTraffic="true">
<activity
android:name=".MainActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>
MainActivity以及相关类完整代码如下:
object FileUtil {
fun getFileMd5(file: File, callback: (String) -> Unit) {
thread {
val messageDigest = MessageDigest.getInstance("MD5")
FileInputStream(file).use { fis ->
val buf = ByteArray(8192)
var length: Int
while (fis.read(buf, 0, 8192).also { length = it } != -1) {
messageDigest.update(buf, 0, length)
}
}
// 获取md5签名(16个字节)
val md5Bytes = messageDigest.digest()
// 将byte数组的签名转换为16进制的字符串
val bigInteger = BigInteger(1, md5Bytes) // 把16个字节当成一个无符号的大整数
var md5String = bigInteger.toString(16) // 把大整数转换为16进制的字符串形式
repeat(32 - md5String.length) { // 预防字符串不够32位。1个byte需要两位16进制数,而md5Bytes的长度为16,所以需要32位的16进制数来表示。
md5String = "0$md5String"
}
callback(md5String)
}
}
}
class UploadRequestBody(private val file: File, private val uploadedSize: Long, private val contentType: MediaType? = null) : RequestBody() {
override fun contentType() = contentType
override fun contentLength() = file.length() - uploadedSize
override fun writeTo(sink: BufferedSink) {
RandomAccessFile(file, "r").use { raf ->
raf.seek(uploadedSize)
val buf = ByteArray(8192)
var length: Int
while (raf.read(buf, 0, 8192).also { length = it } != -1) {
sink.write(buf, 0, length)
}
}
}
}
class MainActivity : AppCompatActivity() {
private lateinit var uploadResultText: TextView
private lateinit var uploadedSizeText: TextView
private val okHttpClient = OkHttpClient()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
uploadResultText = findViewById(R.id.uploadResultText)
uploadedSizeText = findViewById(R.id.uploadedSizeText)
}
fun uploadFile(view: View) {
uploadedSizeText.text = ""
uploadResultText.text = ""
val file = File("/sdcard/龙猫.mkv")
FileUtil.getFileMd5(file) { fileMd5 ->
getFileUploadedSize(file, fileMd5)
}
}
private fun getFileUploadedSize(file: File, fileMd5: String) {
println("开始获取已上传文件大小")
val start = System.currentTimeMillis()
val url = "http://192.168.1.39:8080/dazhou/getFileUploadedSize".toHttpUrl()
val newUrl = url.newBuilder()
.addQueryParameter("fileMd5", fileMd5)
.addQueryParameter("fileName", file.name)
.build()
val request = Request.Builder()
.get()
.url(newUrl)
.build()
okHttpClient.newCall(request).enqueue(object : Callback {
override fun onFailure(call: Call, e: IOException) {
showText1("请求失败,${e.javaClass.simpleName}: ${e.message}")
}
override fun onResponse(call: Call, response: Response) {
val result = response.body!!.string()
println("获取文件大小成功:${result},用时:${System.currentTimeMillis() - start}")
try {
val uploadedSize = result.toLong()
showText1("已上传的文件大小: $uploadedSize")
upload(file, fileMd5, uploadedSize)
} catch (e: Exception) {
showText1("文件上传成功(秒传),文件路径:$result")
}
}
})
}
private fun upload(file: File, fileMd5: String, uploadedSize: Long) {
val url = "http://192.168.1.39:8080/dazhou/upload"
val mime = "mkv-application/octet-stream"
val body = UploadRequestBody(file, uploadedSize, mime.toMediaType())
val requestBody = MultipartBody.Builder()
.setType(MultipartBody.FORM)
.addFormDataPart("fileMd5", fileMd5)
.addFormDataPart("file", file.name, body)
.build()
val request = Request.Builder()
.post(requestBody)
.url(url)
.build()
okHttpClient.newCall(request).enqueue(object : Callback {
override fun onFailure(call: Call, e: IOException) {
showText2("请求失败,${e.javaClass.simpleName}: ${e.message}")
}
override fun onResponse(call: Call, response: Response) {
val result = response.body?.string()
showText2("请求成功: $result")
}
})
}
fun showText1(text: String) {
runOnUiThread { uploadedSizeText.text = text }
}
fun showText2(text: String) {
runOnUiThread { uploadResultText.text = text }
}
}
实验也很简单,在文件没上传完的时候,直接杀进程把App杀死,然后再打开App执行上传操作,等上传完成后在服务器端打开文件看看是否是OK的。