此处以下载本地文件为示例,
业务逻辑
- 参数解析
- 根据文件id获取文件内容
- 根据文件来源确定文件下载方式
- 本地文件
- 获取参数
- 获取 encode 参数
- 获取 range 参数
- 压缩文件
- 如果 encode 参数为 identity
直接传输软件,支持断点续传 - 如果 encode 参数为支持的压缩算法
压缩文件,不支持断点续传
- 如果 encode 参数为 identity
- 流式传输
- 获取参数
- 远程文件
- 重新定向到远程文件,具体方案待定
- 本地文件
Code
此处不包括鉴权的部分,鉴权用中间件另外注入
package file
import (
"GoCloud/pkg/log"
"GoCloud/pkg/serializer"
"compress/flate"
"compress/gzip"
"compress/lzw"
"github.com/gin-gonic/gin"
"github.com/pkg/errors"
"io"
"net/http"
"os"
"strconv"
"strings"
)
// Download 下载文件
// @Summary 下载文件
// @Description 下载文件
// @Tags Space
// @Accept Application/json
// @Produce
// @Param id path int true "文件ID"
// @Param Authentication header string true "用户令牌"
// @Param Accept-Encoding header string false "压缩格式,支持 gzip,compress,deflate 不填写默认为不压缩"
// @Param Range header string false "断点续传,不填默认传输整个文件,比如 bytes=0-1023,注意断点续传只支持非压缩版本"
// @Success 200 {object} serializer.Response "具体文件"
// @Success 206 application/octet-stream "文件流"
// @Failed 400 {object} serializer.Response "参数错误"
// @Router /files/download/{id} [get]
func Download(c *gin.Context) {
entry := log.NewEntry("controller.file.download")
//0. 获取参数
//0.1 获取 id
id := c.Param("id")
if id == "" {
res := serializer.NewResponse(entry, 400, serializer.WithMsg("参数错误"), serializer.WithErr(errors.New("id 参数为空")))
c.JSON(res.Code, res)
return
}
//1. 根据文件id获取文件内容
file, rUrl, err, local := fetchFile(id)
//2. 根据文件来源判断下载方式
if !local {
//todo 这是一个难点,如何实现跳转到远程文件
c.Redirect(http.StatusFound, rUrl)
return
} else {
if err != nil {
res := serializer.NewResponse(entry, http.StatusInternalServerError, serializer.WithMsg("文件错误"), serializer.WithErr(errors.Wrap(err, "获取文件内容失败")))
c.JSON(res.Code, res)
return
}
if file == nil {
res := serializer.NewResponse(entry, http.StatusNotFound, serializer.WithMsg("文件不存在"))
c.JSON(res.Code, res)
return
}
LocalDownload(entry, c, file)
}
//todo
//7. 根据文件id更新文件下载次数
//8. 根据文件id更新文件下载流量
//9. 根据文件id更新文件下载速度
//10. 根据文件id记录文件下载日志
}
// 解析 Range 头,返回需要传输的部分
func parseRange(rangeHeader string) (int64, int64) {
if rangeHeader == "" {
return 0, 0
}
// 实际应用中应正确解析 Range 头并处理错误,目前处理格式 bytes=0-1023
parts := strings.Split(rangeHeader, "=")
byteRange := parts[1]
rangeParts := strings.Split(byteRange, "-")
start, err := strconv.Atoi(rangeParts[0])
if err != nil {
return -1, -1
}
end, err := strconv.Atoi(rangeParts[1])
if err != nil {
return -1, -1
}
return int64(start), int64(end)
}
// LocalDownload 下载本地文件
func LocalDownload(entry log.IEntry, c *gin.Context, file *os.File) {
//1. 获取参数
//1.1 获取 encode
// acceptEncoding 非必须参数,可以为空
acceptEncoding := c.GetHeader("Accept-Encoding")
if acceptEncoding == "" {
acceptEncoding = "identity"
}
// 以 acceptEncoding 支持的第一个压缩格式为准
encode := strings.Split(acceptEncoding, ",")[0]
encode = strings.TrimSpace(encode)
encode = strings.ToLower(encode)
//1.2 获取 range
start, end := parseRange(c.GetHeader("Range"))
if start == -1 && start == end {
res := serializer.NewResponse(entry, 400, serializer.WithMsg("参数错误"), serializer.WithErr(errors.New("range 解析失败")))
c.JSON(res.Code, res)
return
}
info, err := file.Stat()
// 预处理,文件关闭
defer func(file *os.File) {
err := file.Close()
if err != nil {
entry.Error("文件关闭失败", log.Field{
Key: "err",
Value: err,
})
}
}(file)
if err != nil {
res := serializer.NewResponse(entry, http.StatusInternalServerError, serializer.WithMsg("文件错误"), serializer.WithErr(errors.Wrap(err, "获取文件信息失败")))
c.JSON(res.Code, res)
return
}
fileName := info.Name()
//2. 根据文件内容压缩文件
switch encode {
case "identity":
{
// 进行 Range 的判断
if start >= info.Size() || end >= info.Size() {
c.Status(http.StatusRequestedRangeNotSatisfiable)
c.Header("Content-Range", "bytes */"+strconv.FormatInt(info.Size(), 10))
return
}
// 非压缩版本支持断点续传
c.Writer.Header().Set("Content-Type", "application/octet-stream")
c.Writer.Header().Set("Content-Disposition", "attachment; filename="+fileName)
c.Writer.Header().Set("Content-Length", strconv.Itoa(int(info.Size())))
if start == 0 && start == end {
// 传输整个文件
_, err = io.Copy(c.Writer, file)
if err != nil {
_ = c.AbortWithError(http.StatusInternalServerError, errors.Wrap(err, "文件流复制失败"))
return
}
c.Status(http.StatusOK)
} else if start >= 0 && end >= 0 && start <= end {
// 断点续传
_, err = file.Seek(start, 0)
if err != nil {
res := serializer.NewResponse(entry, http.StatusInternalServerError, serializer.WithMsg("文件错误"), serializer.WithErr(errors.Wrap(err, "文件 seek 失败")))
c.JSON(res.Code, res)
return
}
_, err = io.CopyN(c.Writer, file, end-start+1)
if err != nil {
_ = c.AbortWithError(http.StatusInternalServerError, errors.Wrap(err, "文件流复制失败"))
return
}
c.Status(http.StatusPartialContent)
}
return
}
case "gzip":
{
// 创建gzip写入器
gw := gzip.NewWriter(c.Writer)
defer func(gw *gzip.Writer) {
err := gw.Close()
if err != nil {
entry.Error("gzip writer 关闭失败", log.Field{
Key: "err",
Value: err,
})
}
}(gw)
// 将文件内容写入gzip writer
gw.Name = info.Name()
gw.Header = gzip.Header{
Comment: "",
Extra: nil,
ModTime: info.ModTime(),
Name: info.Name(),
OS: 0,
}
// 将文件内容复制到gzip writer
if _, err = io.Copy(gw, file); err != nil {
_ = c.AbortWithError(http.StatusInternalServerError, errors.Wrap(err, "文件流复制失败"))
return
}
// 设置文件类型
fileName = fileName + ".gz"
c.Writer.Header().Set("Content-Type", "application/gzip")
c.Writer.Header().Set("Content-Disposition", "attachment; filename="+fileName)
c.Status(http.StatusOK)
return
}
case "compress":
{
lzww := lzw.NewWriter(c.Writer, lzw.LSB, 8)
defer func(lzww io.WriteCloser) {
err := lzww.Close()
if err != nil {
entry.Error("lzw writer 关闭失败", log.Field{
Key: "err",
Value: err,
})
}
}(lzww)
// 将文件内容写入 lzww writer
_, err = io.Copy(lzww, file)
if err != nil {
_ = c.AbortWithError(http.StatusInternalServerError, errors.Wrap(err, "文件流复制失败"))
return
}
// 设置文件类型
fileName = fileName + ".gz"
c.Writer.Header().Set("Content-Type", "application/x-compress")
c.Writer.Header().Set("Content-Disposition", "attachment; filename="+fileName)
c.Status(http.StatusOK)
return
}
case "deflate":
{
// 创建 deflate 写入器
deflateWriter, err := flate.NewWriter(c.Writer, flate.BestCompression)
if err != nil {
_ = c.AbortWithError(http.StatusInternalServerError, errors.Wrap(err, "deflate writer 创建失败"))
return
}
defer func(deflateWriter *flate.Writer) {
err := deflateWriter.Close()
if err != nil {
entry.Error("deflate writer 关闭失败", log.Field{
Key: "err",
Value: err,
})
}
}(deflateWriter)
// 将文件内容写入 deflate writer
_, err = io.Copy(deflateWriter, file)
if err != nil {
_ = c.AbortWithError(http.StatusInternalServerError, errors.Wrap(err, "文件流复制失败"))
return
}
// 设置文件类型
fileName = fileName + ".gz"
c.Writer.Header().Set("Content-Type", "application/x-deflate")
c.Writer.Header().Set("Content-Disposition", "attachment; filename="+fileName)
c.Status(http.StatusOK)
return
}
// todo 其他压缩格式
default:
{
res := serializer.NewResponse(entry, http.StatusBadRequest, serializer.WithMsg("不支持的压缩格式"))
c.JSON(res.Code, res)
return
}
}
}
// 根据文件id 获取文件内容
func fetchFile(id string) (file *os.File, rUrl string, err error, local bool) {
//2. 根据文件id获取文件信息
//3. 根据文件信息获取文件路径
//4. 根据文件路径获取文件内容
//todo 此处目前只是测试,后续需要修改
file, err = os.Open("D:\\Videos\\[Haruhana&CoolComic404] Suzume no Tojimari [WEBRip][AVC-8bit 1080p][CHT_JPN].mp4")
if err != nil {
return nil, "", err, true
}
return file, "", nil, true
}