查看源代码 文件上传

Web 应用程序的常见任务之一是上传文件。这些文件可能是图像、视频、PDF 或任何其他类型的文件。为了通过 HTML 界面上传文件,我们需要在多部分表单中使用 file 输入标签。

正在寻找 LiveView 上传指南?

本指南介绍了通过 Plug.Upload 进行多部分 HTTP 文件上传。有关 LiveView 文件上传的更多信息,包括在客户端进行直接到云的外部上传,请参阅 LiveView 上传指南

Plug 提供了一个 Plug.Upload 结构体来保存 file 输入的数据。如果用户在提交表单时选择了文件,则 Plug.Upload 结构体将自动出现在您的请求参数中。

在本指南中,您将执行以下操作

  1. 配置多部分表单

  2. 在表单中添加文件输入元素

  3. 验证您的上传参数

  4. 管理上传的文件

Contexts 指南 中,我们为产品生成了一个 HTML 资源。我们可以重复使用在那里生成的表单来演示文件上传在 Phoenix 中的工作方式。请参阅该指南以获取有关生成将在此处使用的产品资源的说明。

配置多部分表单

您需要做的第一件事是将您的表单更改为多部分表单。HelloWeb.CoreComponents simple_form/1 组件接受一个 multipart 属性,您可以在其中指定此属性。

以下是来自 lib/hello_web/controllers/product_html/product_form.html.heex 的表单,其中包含该更改

<.simple_form :let={f} for={@changeset} action={@action} multipart>
. . .

添加文件输入

拥有多部分表单后,您需要一个 file 输入。以下是如何在 product_form.html.heex 中执行此操作

. . .
  <.input field={f[:photo]} type="file" label="Photo" />

  <:actions>
    <.button>Save Product</.button>
  </:actions>
</.simple_form>

渲染后,以下是默认 HelloWeb.CoreComponents input/1 组件的 HTML

<div>
  <label for="product_photo" class="block text-sm...">Photo</label>
  <input type="file" name="product[photo]" id="product_photo" class="mt-2 block w-full...">
</div>

请注意您的 file 输入的 name 属性。这将在 product_params 地图中创建 "photo" 键,该键将在您的控制器操作中可用。

这都是从表单方面完成的。现在,当用户提交表单时,POST 请求将路由到您的 HelloWeb.ProductController create/2 操作。

我应该将照片添加到我的 Ecto 架构中吗?

照片输入不需要成为您架构的一部分,它就会出现在 product_params 中。但是,如果您想在数据库中持久保存照片的任何属性,则需要将其添加到您的 Hello.Product 架构中。

验证您的上传参数

由于您生成了一个 HTML 资源,因此您现在可以使用 mix phx.server 启动服务器,访问 http://localhost:4000/products/new,并使用照片创建新产品。

在您开始之前,将 IO.inspect product_params 添加到 lib/hello_web/controllers/product_controller.ex 中的 ProductController.create/2 操作的顶部。这将在您的开发日志中显示 product_params,这样您就可以更好地了解发生了什么。

. . .
  def create(conn, %{"product" => product_params}) do
    IO.inspect product_params
. . .

当您这样做时,这是您的 product_params 在日志中输出的内容

%{"title" => "Metaprogramming Elixir", "description" => "Write Less Code, Get More Done (and Have Fun!)", "price" => "15.000000", "views" => "0",
"photo" => %Plug.Upload{content_type: "image/png", filename: "meta-cover.png", path: "/var/folders/_6/xbsnn7tx6g9dblyx149nrvbw0000gn/T//plug-1434/multipart-558399-917557-1"}}

您有一个 "photo" 键,它映射到预先填充的 Plug.Upload 结构体,表示您上传的照片。

为了使此内容更易于阅读,请关注结构体本身

%Plug.Upload{content_type: "image/png", filename: "meta-cover.png", path: "/var/folders/_6/xbsnn7tx6g9dblyx149nrvbw0000gn/T//plug-1434/multipart-558399-917557-1"}

Plug.Upload 提供了文件的 MIME 类型、原始文件名以及 Plug 为您创建的临时文件的路径。在本例中,"/var/folders/_6/xbsnn7tx6g9dblyx149nrvbw0000gn/T//plug-1434/" 是 Plug 创建用于放置上传文件的目录。该目录将在请求之间持续存在。"multipart-558399-917557-1" 是 Plug 给您上传的文件起的名字。如果您有多个 file 输入,并且如果用户为所有输入都选择了照片,那么您将拥有散布在临时目录中的多个文件。Plug 将确保所有文件名都是唯一的。

Plug.Upload 文件是临时的

当请求完成时,Plug 会从其目录中删除上传。如果您需要对该文件执行任何操作,则需要在该时间之前执行(或者 将其转让,但这超出了本指南的范围)。

管理上传的文件

在您的控制器中获得 Plug.Upload 结构体后,您可以对其执行任何您想要的运算。例如,您可能想要执行以下一项或多项操作

在生产系统中,您可能希望将文件复制到根目录,例如 /media。这样做时,确保名称唯一非常重要。例如,如果您允许用户上传产品封面图像,您可以使用产品 ID 生成唯一的名称

if upload = product_params["photo"] do
  extension = Path.extname(upload.filename)
  File.cp(upload.path, "/media/#{product.id}-cover#{extension}")
end

然后,可以在 lib/my_app_web/endpoint.ex 中添加一个 Plug.Static 插件来在 "/media" 处提供这些文件

plug Plug.Static, at: "/uploads", from: "/media"

现在可以使用类似 "/uploads/1-cover.jpg" 的路径从浏览器访问上传的文件。在实践中,在上传文件时,还需要处理其他问题,例如验证扩展名、编码名称等等。很多时候,使用已经处理了这些情况的库是比较好的选择。

最后,请注意,当 file 输入没有数据时,您既不会得到 "photo" 键,也不会得到 Plug.Upload 结构体。以下是日志中的 product_params

%{"title" => "Metaprogramming Elixir", "description" => "Write Less Code, Get More Done (and Have Fun!)", "price" => "15.000000", "views" => "0"}

配置上传限制

从表单发送的数据转换为实际的 Plug.Upload 的操作是由 Plug.Parsers 插件完成的,您可以在 HelloWeb.Endpoint 中找到它

# lib/hello_web/endpoint.ex
plug Plug.Parsers,
  parsers: [:urlencoded, :multipart, :json],
  pass: ["*/*"],
  json_decoder: Phoenix.json_library()

除了上述选项之外,Plug.Parsers 接受其他选项来控制数据上传

  • :length - 设置要读取的最大主体长度,默认为 8_000_000 字节
  • :read_length - 设置每次读取的字节数,默认为 1_000_000 字节
  • :read_timeout - 设置接收每个数据块的超时时间,默认为 15_000 毫秒

第一个选项配置允许的最大数据量。其余选项配置我们预期读取的数据量及其频率。如果客户端无法足够快地推送数据,则连接将被终止。Phoenix 附带了合理的默认值,但您可能希望在特殊情况下对其进行自定义,例如,如果您期望非常慢的客户端发送大量数据块。

还值得指出的是,这些限制作为一种安全机制非常重要。例如,如果您没有设置数据上传限制,攻击者就可以打开数千个与您的应用程序的连接,并每 2 分钟发送一个字节,这将需要很长时间才能完成,同时会占用您服务器的所有连接。上面的限制至少期望有合理的进度,这会让攻击者的行动变得更加困难。