基于 gin 实现文件下载业务


此处以下载本地文件为示例,

业务逻辑

  1. 参数解析
  2. 根据文件id获取文件内容
  3. 根据文件来源确定文件下载方式
    1. 本地文件
      1. 获取参数
        1. 获取 encode 参数
        2. 获取 range 参数
      2. 压缩文件
        1. 如果 encode 参数为 identity
          直接传输软件,支持断点续传
        2. 如果 encode 参数为支持的压缩算法
          压缩文件,不支持断点续传
      3. 流式传输
    2. 远程文件
      1. 重新定向到远程文件,具体方案待定

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
}

如果本文帮助到了你,帮我点个广告可以咩(o′┏▽┓`o)


评论
  目录