news 2026/10/7 1:46:59

Phoenix LiveView 外部上传(External Uploads)实战指南:S3、UpChunk 与预签名直传

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Phoenix LiveView 外部上传(External Uploads)实战指南:S3、UpChunk 与预签名直传
  • 后端
  • Web框架
  • WebSocket

【免费下载链接】phoenix_live_view

Rich, real-time user experiences with server-rendered HTML

项目地址:https://gitcode.com/gh_mirrors/ph/phoenix_live_view
点击查看免费下载

本指南承接服务端 Uploads 指南 的配置基础,讲解如何通过Phoenix.LiveView.allow_upload/3的:external选项,把文件绕过 LiveView 服务器、直接上传到 Amazon S3、Google Cloud Storage 等外部云存储提供商。读完本文,你将掌握:external回调的元数据生成契约、Phoenix.LiveViewTest的模拟测试方法、基于 UpChunk 的分块 HTTP 上传,以及 Direct to S3(含 S3 兼容平台)的完整落地代码。

外部上传的工作原理

常规上传中,文件分块经 LiveView 的 UploadChannel 流式写入服务器临时文件,由应用层消费。外部上传则不同:服务器不为文件字节流买单,而是通过allow_upload/3的:external选项,注册一个2 元函数(2-arity function)。当客户端为每个上传条目发起 preflight 预检请求时,LiveView 会调用该函数生成元数据(metadata),并把这个元数据下发给客户端上一个用户自定义的 JavaScript 函数。

客户端选择文件 │ preflight 预检请求(携带文件 ref、大小、类型等) ▼ LiveView 调用 :external 回调(presign_upload/2) │ 生成预签名 URL / 上传端点等元数据(meta) ▼ 客户端收到 meta → 按 meta 中的 :uploader 名称查找 JS uploader │ 直接向云存储发起上传(S3 PUT / POST、分块 PUT 等) ▼ 上传期间 entry.progress() 回报进度 → LiveView 更新条目状态

典型场景是:回调被调用时,针对你的云存储服务商生成一条预签名 URL(pre-signed URL),给终端用户一个限时授权,使其能直接把数据写到你的云存储桶中。文件字节流完全不经过 Phoenix 应用服务器,既减轻了带宽压力,也让大文件上传不必受限于服务器超时。

在源码层面,:external选项由 upload_config.ex 中的build/3解析:

external = case Keyword.fetch(opts, :external) do {:ok, func} when is_function(func, 2) -> func {:ok, other} -> raise ArgumentError, """ invalid :external value provided to allow_upload. Only a 2-arity function receiving the upload entry and socket is supported. Got: #{inspect(other)} """ ...

只有 2 元函数被接受,任何其他值都会在编译/运行时报ArgumentError。未配置:external时该字段为false,走默认的 channel 分块上传路径。

回调契约:返回值的三种形态

:external回调接收(entry, socket)两个参数,其中entry是%Phoenix.LiveView.UploadEntry{}(结构体定义见 upload_config.ex,包含client_name、client_size、client_type、ref等客户端元数据),socket是当前 LiveView 的 socket。返回值必须是以下两种之一:

返回值含义
{:ok, meta, socket}预检成功,meta必须是 map,且必须包含:uploader键(指定客户端 JS uploader 的名字)
{:error, error_meta, socket}预检失败,error_meta必须是 map,该条目被标记为失败

返回错误时,错误会以{:external_metadata_failure, error_meta}的形式通过Phoenix.Component.upload_errors/2暴露给模板。典型用法:

{:error, %{reason: :presign_failed}, socket}

meta中的:uploader键是强约束:在 upload_config.ex 的update_entry_meta/3中,缺少该键会直接抛出ArgumentError:

def update_entry_meta(%UploadConfig{} = conf, entry_ref, %{} = meta) do case Map.fetch(meta, :uploader) do {:ok, _} -> :noop :error -> raise ArgumentError, "external uploader metadata requires an :uploader key. Got: #{inspect(meta)}" end ...

对应测试见 external_test.exs:bad_preflight返回{:ok, %{}, socket},断言抛出的错误信息正是"external uploader metadata requires an :uploader key."。

preflight 的底层处理流程

:external回调的调用发生在Phoenix.LiveView.Upload.generate_preflight_response/4(见 upload.ex):服务端把每个条目标记为preflighted后,根据external是否为函数分叉——是函数则进入external_preflight/4(upload.ex)。

在external_preflight/4中值得注意的是auto upload(自动上传)模式下的错误宽容策略:

  • 回调返回{:ok, meta, new_socket}:调用update_upload_entry_meta/3记录 meta,继续下一个条目;
  • 回调返回{:error, error_meta, new_socket}:若auto_upload?为真,则通过put_upload_error/4记录{:external_metadata_failure, error_meta},其余仍有效的条目继续上传(不整体中断);若为非自动上传模式,则直接 halt 并返回错误响应。

preflight 响应会打包client_meta(max_file_size、max_entries、chunk_size、chunk_timeout)与每个条目对应的 meta 一起下发给客户端。客户端侧,UploadEntry.zipPostFlight/1(upload_entry.js)把resp.entries[this.ref]写入entry.meta,随后uploader/1(upload_entry.js)根据meta.uploader从liveSocket.uploaders中查找对应的 JS 回调:

uploader(uploaders) { if (this.meta.uploader) { const callback = uploaders[this.meta.uploader] || this.view.logError( "upload.missing-uploader", `no uploader configured for ${this.meta.uploader}`, { uploader: this.meta.uploader, uploaders }, ); return { name: this.meta.uploader, callback: callback }; } else { return { name: "channel", callback: channelUploader }; } }

没有:uploader键时退化为默认的 channel 上传器;有该键但liveSocket.uploaders中找不到对应实现时,会在控制台记录upload.missing-uploader错误——这也是排查外部上传"客户端无反应"的第一检查点。分组后的条目由 live_uploader.js 的initAdapterUpload/3按 uploader 名分组,再逐个调用callback(entries, onError, resp, liveSocket)。

测试外部上传

测试服务端的外部上传流程,使用Phoenix.LiveViewTest.render_upload/3。它会自动执行 preflight 预检请求、调用:external配置的函数,并模拟客户端上报上传进度:

avatar = file_input(view, "#upload-form", :avatar, [ %{name: "avatar.png", content: "file contents", type: "image/png"} ]) assert render_upload(avatar, "avatar.png") =~ "100%" assert view |> form("#upload-form") |> render_submit() =~ "uploaded"

这里file_input/4构造文件输入,render_upload/3默认一次性把整个文件"上传"到 100%,再通过render_submit/1触发表单提交,断言服务端消费结果。render_upload/3的实现见 live_view_test.ex:它先检查模拟客户端是否已 acknowledge preflight,若没有则自动调用preflight_upload/1,因此不要在调用render_upload/3之前手动调用preflight_upload/1(render_upload/3会自己做一次 preflight,重复调用会得到过期/冲突的状态)。

render_upload/3支持第三个可选参数——按百分比分块模拟上传:

assert render_upload(avatar, "myfile.jpeg", 49) =~ "49%" assert render_upload(avatar, "myfile.jpeg", 51) =~ "100%"

该参数的底层行为由测试端 UploadClient 的progress_stats/2与with_chunk_boundaries/1(upload_client.ex)驱动:按文件大小计算 1%~100% 的字节边界,当目标百分比无法整除时给出 warning 并按最接近边界执行。

重要边界:render_upload/3不会运行你配置的 JavaScript uploader,也不会把文件真正发给外部服务。它只模拟客户端进度上报与服务端状态更新。JS uploader 及其 HTTP 集成需要单独测试(例如用浏览器端测试套件,仓库中的 Playwright e2e 测试 即属此类)。

如果只想检查 preflight 返回的元数据,用Phoenix.LiveViewTest.preflight_upload/1单独测试:

assert {:ok, %{entries: entries}} = preflight_upload(avatar) assert [%{uploader: "S3", url: url}] = Map.values(entries)

preflight_upload/1只返回 preflight 响应,不会在模拟上传客户端中 acknowledge 该响应,因此拿到结果后不要再用同一个 upload 调用render_upload/3(它内部会再发起一次 preflight)。其实现见 live_view_test.ex,本质是向测试 proxy 发送:allow_upload事件。

仓库的 external_test.exs 覆盖了外部上传的主要分支:每个条目都会触发一次 preflight 回调("external upload invokes preflight per entry")、max_entries超限、auto upload 下的超限与超大文件、缺失:uploader键报错、:error返回值映射为{:external_metadata_failure, reason}等,可作为你编写自己测试的对照清单。

分块 HTTP 上传(Chunked HTTP Uploads,UpChunk)

对于任何支持通过带Content-Range头的分块 HTTP 请求上传大文件的服务,可以使用 Mux 的UpChunkJS 库接管上传的繁重工作,LiveView 负责条目回调与状态同步。如果只是小文件或想快速上手,建议直接用下面的 Direct to S3 方案。

安装 UpChunk

把 UpChunk 保存到assets/vendor/upchunk.js,或用 npm 安装:

$ npm install --prefix assets --save @mux/upchunk

服务端配置

在mount/3中为上传配置:external:

def mount(_params, _session, socket) do {:ok, socket |> assign(:uploaded_files, []) |> allow_upload(:avatar, accept: :any, max_entries: 3, external: &presign_upload/2)} end

presign_upload/2生成客户端将要推送字节的签名 URL。以 Google 的 resumable upload 协议为例(start_session参考 Google 开发者文档):

defp presign_upload(entry, socket) do {:ok, %{"Location" => link}} = SomeTube.start_session(%{ "uploadType" => "resumable", "x-upload-content-length" => entry.client_size }) {:ok, %{uploader: "UpChunk", entrypoint: link}, socket} end

entry.client_size来自客户端 preflight 上报的文件字节数,entrypoint是 UpChunk 将要上传到的临时端点。注意这里meta的:uploader是"UpChunk",必须与客户端 uploader 键严格一致。

客户端接线

客户端用 UpChunk 从服务器生成的临时 URL 创建上传,并把其事件绑定到条目的回调上:

import * as UpChunk from "@mux/upchunk" let Uploaders = {} Uploaders.UpChunk = function(entries, onViewError){ entries.forEach(entry => { // create the upload session with UpChunk let { file, meta: { entrypoint } } = entry let upload = UpChunk.createUpload({ endpoint: entrypoint, file }) // stop uploading in the event of a view error onViewError(() => upload.pause()) // abort the upload if the user cancels it entry.onCancel(() => upload.abort()) // upload error triggers LiveView error upload.on("error", (e) => entry.error(e.detail.message)) // notify progress events to LiveView upload.on("progress", (e) => { if(e.detail < 100){ entry.progress(e.detail) } }) // success completes the UploadEntry upload.on("success", () => entry.progress(100)) }) } // Don't forget to assign Uploaders to the liveSocket let liveSocket = new LiveSocket("/live", Socket, { uploaders: Uploaders, params: {_csrf_token: csrfToken} })

这段代码里四个回调与 LiveView 的契约一一对应(接口定义见 upload_entry.js):

  • onViewError(fn):视图出错时暂停上传(对应view崩溃保护);
  • entry.onCancel(fn):用户取消时中止上传(对应cancel_upload/3服务端取消);
  • entry.error(reason):把错误推给服务端,服务端将条目标记为失败并触发upload_errors;
  • entry.progress(percent):上报 0~100 进度,progress(100)会把条目标记为 done 并触发pushFileProgress完成回调。

客户端进度推送走view.pushFileProgress(fileEl, ref, percent)(见 upload_entry.js),服务端在 upload.ex 的update_progress/3中处理:整数进度更新百分比,而带"error"键的 map 在外部上传模式下会记录为:external_client_failure错误。

Direct to S3

据 S3 FAQ,S3 单次 PUT 可上传的最大对象为5 GB;更大文件请使用上面的分块方案。

本节假定你已有一个配置好 CORS、允许客户端直传的 S3 桶。客户端直传的 CORS 配置示例:

[ { "AllowedHeaders": [ "*" ], "AllowedMethods": [ "PUT", "POST" ], "AllowedOrigins": [ "https://web.myapp.com", // Add any other domains desired, or * for wildcard. ], "ExposeHeaders": [] } ]

AllowedOrigins可换成任何你的域名,或用*通配;更多 S3 桶 CORS 配置参见 AWS 官方文档。注意:客户端直传时不使用 LiveView 的 channel 分块与max_file_size等服务端约束,为了强制所有文件约束生效,必须采用multipart form POST携带文件数据。

开始前准备好四项 S3 信息:

  1. aws_access_key_id
  2. aws_secret_access_key
  3. bucket_name
  4. region

服务端:presign_upload/2

def mount(_params, _session, socket) do {:ok, socket |> assign(:uploaded_files, []) |> allow_upload(:avatar, accept: :any, max_entries: 3, external: &presign_upload/2)} end defp presign_upload(entry, socket) do uploads = socket.assigns.uploads bucket = "phx-upload-example" key = "public/#{entry.client_name}" config = %{ region: "us-east-1", access_key_id: System.fetch_env!("AWS_ACCESS_KEY_ID"), secret_access_key: System.fetch_env!("AWS_SECRET_ACCESS_KEY") } {:ok, fields} = SimpleS3Upload.sign_form_upload(config, bucket, key: key, content_type: entry.client_type, max_file_size: uploads[entry.upload_config].max_file_size, expires_in: :timer.hours(1) ) meta = %{uploader: "S3", key: key, url: "http://#{bucket}.s3-#{config.region}.amazonaws.com", fields: fields} {:ok, meta, socket} end

这里把presign_upload/2以捕获匿名函数的形式传给:external。要点:

  • entry.client_name决定 S3 上的对象键key,entry.client_type用于 content type;
  • uploads[entry.upload_config].max_file_size从当前上传配置里读取你allow_upload/3设定的文件大小上限,用于生成签名表单中的约束字段——这是把服务端约束"带进"直传的关键;
  • expires_in: :timer.hours(1)让签名 1 小时后过期;
  • 返回的meta包含:uploader(客户端 uploader 名)、key、url与签名fields。

SimpleS3Upload 模块

指南要求新增一个SimpleS3Upload模块来生成 S3 预签名 URL。创建simple_s3_upload.ex,内容取自 Chris McCord 编写的零依赖模块 SimpleS3Upload。

提示:如果遇到:crypto模块报错,或 S3 因 ACL 拦截报错,请阅读上述 gist 的评论区寻找解决方案。

客户端:S3 uploader

客户端 uploader 的名字必须与服务端 meta 的:uploader一致(此处为"S3")。在assets/js/目录(与app.js同级)新建uploaders.js:

let Uploaders = {} Uploaders.S3 = function(entries, onViewError){ entries.forEach(entry => { let formData = new FormData() let {url, fields} = entry.meta Object.entries(fields).forEach(([key, val]) => formData.append(key, val)) formData.append("file", entry.file) let xhr = new XMLHttpRequest() onViewError(() => xhr.abort()) entry.onCancel(() => xhr.abort()) xhr.onload = () => xhr.status === 204 ? entry.progress(100) : entry.error() xhr.onerror = () => entry.error() xhr.upload.addEventListener("progress", (event) => { if(event.lengthComputable){ let percent = Math.round((event.loaded / event.total) * 100) if(percent < 100){ entry.progress(percent) } } }) xhr.open("POST", url, true) xhr.send(formData) }) } export default Uploaders;

该函数对每个条目发起一次 AJAX 请求:把签名fields与文件本身一起 append 进FormData,以 POST 形式提交到预签名url;用entry.progress()与entry.error()把上传事件回报给 LiveView。成功判定:S3 的 multipart POST 成功后返回204 No Content,因此xhr.status === 204才调用entry.progress(100)完成条目;entry.onCancel与onViewError都绑定到xhr.abort()以中止请求。

接入 app.js

最后在app.js中把uploaders: Uploaders传给LiveSocket构造器,告诉 Phoenix 到哪里找外部元数据中返回的 uploader:

// for uploading to S3 import Uploaders from "./uploaders" let liveSocket = new LiveSocket("/live", Socket, { params: {_csrf_token: csrfToken}, uploaders: Uploaders } )

至此,服务端返回的"S3"与客户端定义的Uploaders.S3匹配成功。若上传遇到问题,打开浏览器开发者工具,检查Console(JS 错误日志)与Network(网络请求状态、签名 URL 返回码)即可定位:例如签名过期(403)、CORS 拦截、upload.missing-uploader等都会在这里露出端倪。

Direct to S3-Compatible(如 Cloudflare R2)

本节假定你已在项目中正确安装并配置好 ExAws 与 ExAws.S3,且能无错执行示例代码。

大部分 S3 兼容平台(如 Cloudflare R2)不支持 POST 上传,因此需要改用带签名的 URL 以PUT方式把文件直传过去。为此要同时改动presign_upload/2和Uploaders.S3。

新的presign_upload/2(用 ExAws 生成 PUT 预签名 URL):

def presign_upload(entry, socket) do config = ExAws.Config.new(:s3) bucket = "bucket" key = "public/#{entry.client_name}" {:ok, url} = ExAws.S3.presigned_url(config, :put, bucket, key, expires_in: 3600, query_params: [{"Content-Type", entry.client_type}] ) {:ok, %{uploader: "S3", key: key, url: url}, socket} end

query_params把Content-Type以签名查询参数的形式携带,expires_in: 3600表示签名有效期为 1 小时。

新的Uploaders.S3(改为 PUT 裸文件字节):

Uploaders.S3 = function (entries, onViewError) { entries.forEach(entry => { let xhr = new XMLHttpRequest() onViewError(() => xhr.abort()) entry.onCancel(() => xhr.abort()) xhr.onload = () => xhr.status === 200 ? entry.progress(100) : entry.error() xhr.onerror = () => entry.error() xhr.upload.addEventListener("progress", (event) => { if(event.lengthComputable){ let percent = Math.round((event.loaded / event.total) * 100) if(percent < 100){ entry.progress(percent) } } }) let url = entry.meta.url xhr.open("PUT", url, true) xhr.send(entry.file) }) }

与 S3 POST 版相比,差异集中在两点:

  1. 成功状态码:POST 版是204,PUT 版是200;
  2. 请求体:POST 版是FormData(签名 fields + 文件),PUT 版直接把entry.file(原始文件对象)作为 body 发送。

除此之外,进度上报、取消、视图错误处理与 S3 版完全一致,meta.uploader同样为"S3",因此app.js中无需改动。

排查清单与关键源码索引

外部上传链路跨服务端与客户端,故障排查可按以下顺序核对:

  1. 服务端::external必须是 2 元函数(upload_config.ex),meta 必须含:uploader键(否则 update_entry_meta/3 抛错);
  2. 服务端:回调返回值只能是{:ok, meta, socket}或{:error, error_meta, socket},错误会变成{:external_metadata_failure, error_meta}并通过 upload_errors/2 展示;
  3. 客户端:liveSocket.uploaders中的键名必须与meta.uploader字符串完全一致,否则控制台出现upload.missing-uploader;
  4. 网络层:检查签名 URL 的 403(签名过期/区域不匹配)、CORS 拦截、成功状态码(S3 POST 为 204,S3 兼容 PUT 为 200);
  5. 测试:用 render_upload/3 覆盖服务端流程,用 preflight_upload/1 单独检查元数据,参考 external_test.exs 的用例矩阵。

相关核心源码路径一览:

  • upload.ex:preflight 响应生成与:external回调调用(generate_preflight_response/4、external_preflight/4);
  • upload_config.ex::external选项校验;upload_config.ex::uploader键强制校验;
  • live_uploader.js:客户端按 uploader 分组分发;
  • upload_entry.js:meta.uploader查找与 zipPostFlight;
  • live_view_test.ex:render_upload/3与preflight_upload/1;
  • external_test.exs:外部上传测试用例(preflight 逐条目调用、错误映射、:uploader缺失报错、auto upload 容错等)。
  • 后端
  • Web框架
  • WebSocket

【免费下载链接】phoenix_live_view

Rich, real-time user experiences with server-rendered HTML

项目地址:https://gitcode.com/gh_mirrors/ph/phoenix_live_view
点击查看免费下载

相关推荐

上一篇:10个Quart高级技巧:中间件、信号与配置管理
下一篇:palera1n越狱实战指南:解锁iOS设备完整解决方案

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/7 1:45:22

彻底拆解SystemVerilog DPI-C:原理、实操与避坑指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/7 1:45:20

从搜索框到Agent:联网搜索的核心技术与实操搭建

1. 从搜索框到 Agent 的演进逻辑1.1 为什么传统搜索框模式走到了瓶颈做过 Chatbot 的人都有一个共同体会&#xff1a;用户问“今天有什么值得关注的科技新闻”&#xff0c;如果机器人只能从训练数据里翻答案&#xff0c;那它给出的内容大概率停留在知识截止日期之前&#xff0c…

作者头像 李华
网站建设 2026/10/7 1:45:12

DUIX开源数字人框架:从零搭建本地交互闭环与性能调优实战

1. 为什么我会盯上 DUIX 这个开源数字人项目第一次看到 DUIX 这个项目&#xff0c;是在一个做智能客服的朋友那里。他当时正为了一套数字人交互方案焦头烂额——商业 API 按调用量计费&#xff0c;一个月下来成本压不住&#xff0c;而且数据要往外传&#xff0c;合规那边一直卡…

作者头像 李华
网站建设 2026/10/7 1:44:46

线性DP工程实战:从算法公式到物流调度流水线

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/7 1:44:44

AI代理+国产MCU:用OpenClaw重塑CW32开发工具链

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华