Web 大文件上传方案(前后端)

1,758 阅读7分钟

前言

我们在做 web 项目的时候,基本上都会有文件上传的功能,但大部分都是比较小的图片上传, 如果直接上传比较大的文件(大于1G)时,基本上会被服务器拒绝,即使服务器调整 post body size 那 也会有连接超时的问题。比如我的服务器配置 post body 不能超过 8M,连接超时为 10s,那么基本上 是传不上去大文件的。这时我们就需要专门来处理大文件的上传问题。

总体思路

一次性上传大文件不行,我们就分成多次小文件上传,然后在合并成一个文件。

  1. 前端计算文件的 sha256 值
  2. 请求服务器创建上传任务
  3. 服务器根据 sha256 值判断文件是否已存在于服务器上,如果已存在,那么直接返回已有文件,如果不存在,那么就创建一个新的上传任务
  4. 前端根据上传任务 id 上传文件的分块
  5. 当所有分块上传完毕后,前端请求合并文件
  6. 服务器将所有的分块合并成一个大文件并保存

后端实现

我们先看实现后端的逻辑实现,我这里使用 Elixir 语言的 Phoenix 框架做说明。

文件指纹

如果用户上传了两个相同的文件,服务器也保存了两份一模一样的文件,由于是大文件,这会造成磁盘的浪费。 为了避免这种磁盘的浪费,我们应该相同的文件只保存一份,不论用户上传多少个相同的文件。

为了识别文件的唯一性,来确保文件不会冗余,我们需要计算文件的指纹。

常用的 hash 算法有 md5, sha1, sha256 等,为了尽可能减少 hash 碰撞,我在这里使用了 sha256 的算法。

数据表设计

# 创建文件表
create table(:storage_files) do
  add :sha256, :string, null: false, comment: "文件 sha256 值"
  add :size, :bigint, null: false, comment: "文件大小"
  add :content_type, :string, null: false, comment: "文件类型"

  timestamps()
end

# 唯一索引,确保 sha256 的唯一性
create unique_index(:storage_files, [:sha256])


# 上传任务表
create table(:storage_uploads) do
  add :size, :bigint, null: false, comment: "文件大小"
  add :content_type, :string, null: false, comment: "文件类型"

  timestamps()
end


# 上传分块表
create table(:storage_chunks) do
  add :number, :integer, null: false, comment: "分块序号"
  add :upload_id, references(:storage_uploads, on_delete: :delete_all), null: false

  timestamps()
end

# 外键加索引,提高查询效率
create index(:storage_chunks, [:upload_id])

# 分块序号与上传任务的复合索引,确保一个上传任务下的分块序号唯一
create unique_index(:storage_chunks, [:number, :upload_id])

上传任务

根据 sha256 查找文件,如果有就直接返回已有文件,没有的话就创建新的上传任务。

def create_upload(attrs \\ %{}) do
  sha256 = Map.get(attrs, "sha256", "")

  case Repo.get_by(StorageFile, sha256: sha256) do
    nil ->
      %Upload{}
      |> Upload.changeset(attrs)
      |> Repo.insert()

    %StorageFile{} = file ->
      {:ok, file}
  end
end

分块

保存用户上传的分块,这里将操作放到一个事务里,确保万无一失。

def create_chunk(%Upload{} = upload, attrs, %Plug.Upload{path: filepath}) do
  create_chunk_changeset =
    upload
    |> Ecto.build_assoc(:chunks)
    |> Chunk.changeset(attrs)

  update_upload_changeset =
    upload
    |> Ecto.Changeset.change(updated_at: NaiveDateTime.utc_now() |> NaiveDateTime.truncate(:second))

  Ecto.Multi.new()
  |> Ecto.Multi.insert(:create_chunk, create_chunk_changeset)
  |> Ecto.Multi.update(:update_upload, update_upload_changeset)
  |> Ecto.Multi.run(:write, fn _repo, %{create_chunk: chunk} ->
    with destpath <- build_chunk_path(upload, chunk),
          :ok <- File.rename(filepath, destpath) do
      {:ok, nil}
    end
  end)
  |> Repo.transaction()
  |> case do
    {:ok, %{create_chunk: chunk}} -> {:ok, chunk}
    {:error, :create, changeset, _} -> {:error, changeset}
    {:error, :update_upload, _, _} -> {:error, "更新上传任务时间失败"}
    {:error, _, _, _} -> {:error, "写入分块失败"}
  end
end

合并文件

def merge_upload(%Upload{} = upload) do
  upload = Repo.preload(upload, :chunks)

  with :ok <- can_merge_upload?(upload),
        {:ok, merged_filepath} <- merge_chunks(upload) do
    create_file(merged_filepath, upload.content_type)
  end
end

合并文件时,要先检查是否可合并。这里检查了分块的个数,与序号的和。

defp can_merge_upload?(%Upload{} = upload) do
  target_count = ceil(upload.size / @storage_config[:chunk_size])
  target_sum = Enum.sum(1..target_count)

  chunks_count = length(upload.chunks)
  chunks_sum = Enum.map(upload.chunks, &(&1.number)) |> Enum.sum()

  if chunks_count == target_count && chunks_sum == target_sum do
    :ok
  else
    {:error, "分块异常, 不能合并"}
  end
end

合并所有分块,这里使用文件流的方式,减少内存占用率。 合并后的文件作为临时文件,先不保存,然后删掉本次上传任务及分块文件。

defp merge_chunks(%Upload{} = upload) do
  merged_filepath = Path.join(@storage_config[:path], "upload_#{upload.id}.tmp")

  upload.chunks
  |> Enum.sort_by(&(&1.number))
  |> Enum.map(&File.stream!(build_chunk_path(upload, &1), [], 8 * 1024))
  |> Stream.concat()
  |> Stream.into(File.stream!(merged_filepath, [:write], 8 * 1024))
  |> Stream.run()

  case delete_upload(upload) do
    {:ok, _} ->
      {:ok, merged_filepath}

    {:error, _} ->
      File.rm(merged_filepath)
      {:error, "删除分块任务失败"}
  end
end

def delete_upload(%Upload{} = upload) do
  # 删除所有分块文件
  Enum.each(upload.chunks, fn chunk ->
    build_chunk_path(upload, chunk)
    |> File.rm()
  end)

  # 删除上传任务及所有分块,有外键约束的级联删除,这里只删除上传任务即可
  Repo.delete(upload)
end

创建并保存文件,本着 永远不要相信用户输入 的原则,这里还需要在服务端计算一遍 sha256 值。 保存操作同样放在事务里,确保数据一致性。

defp create_file(filepath, content_type) do
  sha256 =
    File.stream!(filepath, [], 8 * 1024)
    |> Enum.reduce(:crypto.hash_init(:sha256), &(:crypto.hash_update(&2, &1)))
    |> :crypto.hash_final()
    |> Base.encode16(case: :lower)

  case File.stat(filepath) do
    {:ok, %File.Stat{size: size}} ->
      save_file(filepath, %{sha256: sha256, size: size, content_type: content_type})

    {:error, _} ->
      File.rm(filepath)
      {:error, "获取文件大小失败"}
  end
end

defp save_file(filepath, attrs) do
  Ecto.Multi.new()
  |> Ecto.Multi.insert(:create, StorageFile.changeset(%StorageFile{}, attrs))
  |> Ecto.Multi.run(:write, fn _repo, %{create: file} ->
    with destpath <- Path.join(@storage_config[:path], file.sha256),
          :ok <- File.rename(filepath, destpath) do
      {:ok, nil}
    end
  end)
  |> Repo.transaction()
  |> case do
    {:ok, %{create: file}} ->
      {:ok, file}

    {:error, _, _, _} ->
      File.rm(filepath)
      {:error, "保存文件失败"}
  end
end

前端实现

由于上传操作步骤较多,这里使用 Vue 里实现前端的上传组件。 前端的代码较繁杂,这里就分享一下关键代码。

import sha256 from 'fast-sha256'
import { floor as _floor } from 'lodash'

/**
 * 计算 Blob 的 sha256
 * @param {Blob} blob
 * @param {function} on_progress 进度回调
 */
export async function blob_sha256 (blob, on_progress) {
  const chunk_size = window.__CONFIG__.storage.chunk_size
  const chunk_count = Math.ceil(blob.size / chunk_size)
  const hasher = new sha256.Hash()

  for (let idx = 0; idx < chunk_count; idx++) {
    const start = idx * chunk_size
    const end = (start + chunk_size) > blob.size ? blob.size : start + chunk_size
    const buffer = new Uint8Array(await read_blob(blob.slice(start, end)))

    if (on_progress) {
      on_progress(chunk_count > 1 ? _floor(idx / (chunk_count - 1) * 100, 2) : 100)
    }

    hasher.update(buffer)
  }

  hasher.digest()

  return Array.prototype.map.call(hasher.digest(), x => ('00' + x.toString(16)).slice(-2)).join('')
}

function read_blob (blob) {
  return new Promise(function (resolve, reject) {
    const file_reader = new FileReader()

    file_reader.onload = function (ev) {
      resolve(ev.target.result)
    }

    file_reader.onerror = function () {
      reject(new Error('读取 Blob 失败'))
    }

    file_reader.readAsArrayBuffer(blob)
  })
}

// 上传所有分块
async upload_chunks (upload, file) {
  const chunk_size = window.__CONFIG__.storage.chunk_size
  const chunk_count = Math.ceil(upload.size / chunk_size)
  const chunk_list = []

  for (let idx = 1; idx <= chunk_count; idx++) {
    chunk_list.push({
      number: idx,
      ok: false,
      error_count: 0
    })
  }

  let ok_count = 0

  while (true) {
    const chunk = _find(chunk_list, c => !c.ok && c.error_count < 3)

    if (!chunk) { break }

    const start = (chunk.number - 1) * chunk_size
    const end = (start + chunk_size) > upload.size ? upload.size : start + chunk_size

    const form_data = new FormData()

    form_data.append('chunk[number]', chunk.number)
    form_data.append('file', file.slice(start, end))

    try {
      const { data } = await axios.post(`/storage/uploads/${upload.id}/chunks`, form_data)

      if (data.error) {
        // 服务端错误,直接结束
        return
      }

      chunk.ok = true
      ok_count += 1
      this.uploading.progress = _floor(ok_count / chunk_count * 100, 2)
    } catch (e) {
      // 网络异常,需要重试
      chunk.error_count += 1
    }
  }
}

静态伺服

我们上传的文件需要有权限才可以下载,所以不能直接放在静态目录。

所以上传的文件需要单独做静态伺服,刚开始我是直接返回文件。

def serve_file(%Plug.Conn{} = conn, %StorageFile{} = file) do
  conn
  |> Plug.Conn.put_resp_header("content-type", file.content_type)
  |> Plug.Conn.send_file(200, Path.join(@storage_config[:path], file.sha256))
end

但是在 <video></video> 中没法做 seek 操作,这里的关键点是我们要处理 Range 请求头,并设置 Content-Range 响应头。

参考(抄袭)Plug.Static 实现 partial content。

defp serve_range(conn, file) do
  with [range] <- Plug.Conn.get_req_header(conn, "range"),
      %{"bytes" => bytes} <- Plug.Conn.Utils.params(range),
      {range_start, range_end} <- start_and_end(bytes, file.size) do
    send_range(conn, file, range_start, range_end)
  else
    _ -> send_entire_file(conn, file)
  end
end

defp start_and_end("-" <> rest, file_size) do
  case Integer.parse(rest) do
    {last, ""} when last > 0 and last <= file_size -> {file_size - last, file_size - 1}
    _ -> :error
  end
end
defp start_and_end(range, file_size) do
  case Integer.parse(range) do
    {first, "-"} when first >= 0 ->
      {first, file_size - 1}

    {first, "-" <> rest} when first >= 0 ->
      case Integer.parse(rest) do
        {last, ""} when last >= first -> {first, min(last, file_size - 1)}
        _ -> :error
      end

    _ ->
      :error
  end
end

defp send_range(conn, %StorageFile{size: file_size} = file, 0, range_end) when range_end == file_size - 1 do
  send_entire_file(conn, file)
end
defp send_range(conn, file, range_start, range_end) do
  length = range_end - range_start + 1

  conn
  |> Plug.Conn.put_resp_header("content-range", "bytes #{range_start}-#{range_end}/#{file.size}")
  |> Plug.Conn.send_file(206, Path.join(@storage_config[:path], file.sha256), range_start, length)
  |> Plug.Conn.halt()
end

defp send_entire_file(conn, file) do
  conn
  |> Plug.Conn.send_file(200, Path.join(@storage_config[:path], file.sha256))
  |> Plug.Conn.halt()
end

数据一致性

使用数据库的外键约束,事务可以确保文件信息的一致性。

但是如果用户创建了一个上传任务,但是没有把分块传完,就刷新页面或离开了,这种情况就会有上传任务永远完不成了。 所以我们需要一个清扫器来周期性的清扫这些废弃的上传任务。

这里使用 gen_server 可以方便的实现周期性工作。

defmodule Tower.Storage.Cleaner do
  @moduledoc """
  清扫器, 周期性清除过期的分块与上传任务, 释放磁盘空间
  """

  use GenServer

  import Ecto.Query, warn: false

  alias Tower.Repo
  alias Tower.Storage
  alias Tower.Storage.Upload

  @storage_config Application.get_env(:tower, Tower.Storage)


  def start_link(args) do
    GenServer.start_link(__MODULE__, args, name: __MODULE__)
  end

  @impl true
  def init(_args) do
    clean()
    schedule_work()

    {:ok, :no_state}
  end

  @impl true
  def handle_info(:work, state) do
    clean()
    schedule_work()

    {:noreply, state}
  end

  defp schedule_work() do
    Process.send_after(self(), :work, @storage_config[:clean_interval] * 1000)
  end

  defp clean() do
    time =
      NaiveDateTime.utc_now()
      |> NaiveDateTime.add(-@storage_config[:clean_interval])

    query = from u in Upload, where: u.updated_at < ^time

    query
    |> Repo.all()
    |> Repo.preload(:chunks)
    |> Enum.each(&Storage.delete_upload/1)
  end
end

最后

至此,大文件上传功能基本实现。

Authored by <duanpengyuan#qq.com>