在Phoenix应用里接收用户上传的XML文件,看似只是一个简单的表单提交,实际却牵扯到Plug的multipart解析、临时文件机制以及XML解析库的选择。不少人在控制器里拿到参数后,发现文件内容读不出来,或者文件被提前清理掉了,本质上都是对Plug.Upload这个结构体的生命周期不够了解。本文把整个流程从头到尾梳理一遍,并给出可以直接落地的代码。

一、multipart上传与Plug.Upload的内部结构
当客户端以multipart/form-data方式提交表单时,Cowboy会把请求体交给Plug解析。Plug在解析multipart请求时,并不会把文件内容直接读进内存(除非文件很小),而是把数据写入一个临时文件,然后在参数里塞进一个%Plug.Upload{}结构体。这个结构体只有三个字段,但每一个都很关键。
第一个字段是filename,即客户端上传时的原始文件名。注意这个值完全由客户端控制,绝不能直接拿它来拼接存储路径,否则一个构造好的文件名(比如包含../)就可能造成路径穿越。第二个字段是content_type,同样由客户端声明,只作为参考,不能作为安全校验的唯一依据。第三个字段是path,指向Plug写入的临时文件的真实路径,通常位于系统临时目录下,例如/tmp/plug-1648/multipart-1698234。
理解了这三个字段,你就明白为什么不能把params["file"]当成字符串来处理。在控制器里,它是一个结构体,你需要通过File.read(upload.path)来读取真实内容,或者直接把upload.path交给XML解析器处理。下面是一个典型的控制器骨架:
defmodule MyAppWeb.XmlController do
use MyAppWeb, :controller
def upload(conn, %{"file" => upload}) when is_map(upload) do
case File.read(upload.path) do
{:ok, xml_binary} ->
json(conn, %{status: "ok", size: byte_size(xml_binary)})
{:error, reason} ->
conn
|> put_status(:bad_request)
|> json(%{error: "读取文件失败: #{inspect(reason)}"})
end
end
end二、临时文件的生命周期与自动清理
Plug.Upload最容易被误解的一点是临时文件的有效期。这些文件是Plug在解析请求时创建的,随着请求结束会被自动删除。换句话说,如果你在请求处理过程中只是把path存到了数据库,而没有把文件复制到持久化目录,那么等响应返回后,那个路径指向的文件就不存在了。
正确的做法是在控制器里立即处理文件:要么当场解析XML提取数据,要么用File.cp/2把文件复制到应用控制的存储目录,或者上传到对象存储。如果选择复制到本地,建议不要沿用客户端提供的文件名,而是自己生成一个安全的名字,比如用Erlang的System.unique_integer/1加上UUID组合:
defp persist_file(%Plug.Upload{} = upload) do
ext = Path.extname(upload.filename) |> String.downcase()
safe_name = "#{System.unique_integer([:positive])}#{ext}"
dest = Path.join(Application.app_dir(:my_app, "priv/uploads"), safe_name)
case File.cp(upload.path, dest) do
:ok -> {:ok, dest}
{:error, reason} -> {:error, reason}
end
end另外要留意磁盘空间问题。如果接口允许上传较大文件,恶意用户可以通过高频提交multipart请求快速填满临时目录。可以在应用层面限制请求体大小,比如在Endpoint里配置plug Plug.Parsers, length: 20_000_000,把单次请求限制在20MB以内,超过就直接返回413状态码,避免临时文件被无限写入。
三、解析XML内容:SweetXml实战
拿到XML的二进制内容后,下一步就是解析。Erlang自带的Xmerl功能完整但API偏底层,直接在Elixir里用不太顺手,社区里更常用的做法是引入SweetXml这个封装库。它基于Xmerl实现,提供了一套声明式的sigil语法~x,写起来非常简洁。
先在mix.exs中添加依赖:{:sweet_xml, "~> 0.7"},然后执行mix deps.get。假设客户端上传的是一个订单导出的XML,我们可以这样提取字段:
defmodule MyAppWeb.XmlParser do
import SweetXml
def parse_orders(xml_binary) do
xml_binary
|> parse(namespace_conformant: true)
|> xpath(
~x"//order"l,
order_id: ~x"./id/text()"s,
amount: ~x"./amount/text()"f,
customer: ~x"./customer/name/text()"s
)
end
end这段代码里,~x"//order"l中的小写字母l表示返回列表,s表示把结果转成字符串,f转成浮点数。解析结果是一个map列表,可以直接丢给Changeset做批量入库。需要注意的是,Xmerl解析时会默认开启DTD处理,如果XML里引用了外部实体,存在XXE(XML外部实体注入)的风险。虽然SweetXml默认不允许网络加载外部实体,但保险起见,对来源不可信的文件,建议在解析前用正则做一层基础检查,或者限制文件大小,避免超大实体扩展攻击(俗称billion laughs)。
把解析逻辑整合进控制器,完整的上传接口大致如下:
def upload(conn, %{"file" => %Plug.Upload{} = upload}) do
with {:ok, xml} <- File.read(upload.path),
{:ok, _ext} <- validate_extension(upload.filename),
orders when is_list(orders) <- MyAppWeb.XmlParser.parse_orders(xml) do
{:ok, count} = MyApp.Orders.bulk_insert(orders)
json(conn, %{imported: count})
else
{:error, %XmerlFatalError{} = err} ->
conn |> put_status(:unprocessable_entity) |> json(%{error: "XML格式非法"})
{:error, reason} ->
conn |> put_status(:bad_request) |> json(%{error: inspect(reason)})
end
end
defp validate_extension(name) do
if Path.extname(name) =~ ~r/\.xml$/i, do: {:ok, ".xml"}, else: {:error, "仅支持XML文件"}
end四、校验、安全与常见坑
除了前面提到的文件名校验和请求体限制,还有几个细节值得注意。首先是Content-Type校验:虽然upload.content_type不可全信,但可以作为第一道过滤,凡是声明不是text/xml或application/xml的直接拒绝,剩下的再用实际解析结果来兜底。其次是并发场景:如果上传处理涉及耗时操作,比如超大的XML解析,不要在控制器进程里同步做完再返回,可以把任务丢给Task.Supervisor或Oban这样的后台任务库,但前提是先完成文件复制,因为请求一结束临时文件就没了。
还有一个经典坑是测试。写ExUnit测试时,很多人不知道如何构造multipart请求。其实Plug.Test配合multipart_conn相关工具可以很方便地模拟,或者直接用Phoenix.ConnTest.build_conn()加上手动构造Plug.Multipart body。最简单的做法是在测试里自己创建一个临时文件作为%Plug.Upload{}的path传入,这样不需要真的走一遍multipart编码。
最后总结一下核心要点:把Plug.Upload理解成"指向临时文件的引用",在请求生命周期内完成读取、解析或复制;文件名和MIME类型都来自客户端,必须自行校验;解析XML优先用SweetXml,同时警惕XXE和实体扩展攻击;对大文件做好大小限制并考虑异步处理。把这些环节都处理好,一个健壮的XML上传接口就基本成型了。
Elixir PhoenixPlug.UploadXML上传修改时间:2026-09-14 07:30:39