- 后端
- Web框架
- WebSocket
【免费下载链接】phoenix_live_view
Rich, real-time user experiences with server-rendered HTML
本指南承接服务端 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)} endpresign_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} endentry.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 信息:
aws_access_key_idaws_secret_access_keybucket_nameregion
服务端: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} endquery_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 版相比,差异集中在两点:
- 成功状态码:POST 版是
204,PUT 版是200; - 请求体:POST 版是
FormData(签名 fields + 文件),PUT 版直接把entry.file(原始文件对象)作为 body 发送。
除此之外,进度上报、取消、视图错误处理与 S3 版完全一致,meta.uploader同样为"S3",因此app.js中无需改动。
排查清单与关键源码索引
外部上传链路跨服务端与客户端,故障排查可按以下顺序核对:
- 服务端:
:external必须是 2 元函数(upload_config.ex),meta 必须含:uploader键(否则 update_entry_meta/3 抛错); - 服务端:回调返回值只能是
{:ok, meta, socket}或{:error, error_meta, socket},错误会变成{:external_metadata_failure, error_meta}并通过 upload_errors/2 展示; - 客户端:
liveSocket.uploaders中的键名必须与meta.uploader字符串完全一致,否则控制台出现upload.missing-uploader; - 网络层:检查签名 URL 的 403(签名过期/区域不匹配)、CORS 拦截、成功状态码(S3 POST 为 204,S3 兼容 PUT 为 200);
- 测试:用 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
相关推荐
FileCodeBox 预签名上传 API 实战指南:直传 S3 与服务器代理双模式详解
FileCodeBox 预签名上传 API 实战指南:直传 S3 与服务器代理双模式详解 导读 本文以 FileCodeBox(文件快递柜)的预签名上传接口为讲
后端突破Zappa大文件瓶颈:S3预签名URL与分块上传实战指南
突破Zappa大文件瓶颈:S3预签名URL与分块上传实战指南 你是否还在为Zappa部署的应用上传大文件时遭遇超时失败?当用户尝试上传100MB以上文件时,传统
云原生DevOps后端dotnet-starter-kit 存储与文件上传指南:IStorageService、S3/MinIO 与预签名上传全解析
dotnet starter kit 存储与文件上传指南:IStorageService、S3/MinIO 与预签名上传全解析 这篇技术指南以 .agents/
后端前端示例工程认证鉴权
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考