您当前的位置:首页 > 计算机 > 编程开发 > Spring Boot

SpringBoot上传文件的断点续传实现

时间:02-08来源:作者:点击数:

前言

百度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端实现

接下来是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的。

方便获取更多学习、工作、生活信息请关注本站微信公众号城东书院 微信服务号城东书院 微信订阅号
推荐内容
相关内容
栏目更新
栏目热门