前言
我们在做 web 项目的时候,基本上都会有文件上传的功能,但大部分都是比较小的图片上传, 如果直接上传比较大的文件(大于1G)时,基本上会被服务器拒绝,即使服务器调整 post body size 那 也会有连接超时的问题。比如我的服务器配置 post body 不能超过 8M,连接超时为 10s,那么基本上 是传不上去大文件的。这时我们就需要专门来处理大文件的上传问题。
总体思路
一次性上传大文件不行,我们就分成多次小文件上传,然后在合并成一个文件。
- 前端计算文件的 sha256 值
- 请求服务器创建上传任务
- 服务器根据 sha256 值判断文件是否已存在于服务器上,如果已存在,那么直接返回已有文件,如果不存在,那么就创建一个新的上传任务
- 前端根据上传任务 id 上传文件的分块
- 当所有分块上传完毕后,前端请求合并文件
- 服务器将所有的分块合并成一个大文件并保存
后端实现
我们先看实现后端的逻辑实现,我这里使用 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>