Web应用程序开发过程中, 文件上传功能的开发经常困扰着新同学.
这主要是因为文件从客户端来到服务器端的方式, 以及服务器端对文件的处理方式与普通数据字段不同, 所以常常让同学们摸不着头脑.
今天, 我们就以此为主题, 重点聊聊…
这个教程越写越长, 考虑再三, 还是把它分成了上 / 下 两部分, 本文是上部, 主要涉及基本理论和客户端实现, 服务器端的实现在 文件上传怎么做 (下) 中讲解.
这样同学可以同时打开两个窗口, 对照着看, 也方便不是吗 ?
1. 前言
1.1 服务器端如何存储文件 ?
很多同学常会走入一个误区, 认为客户上传的文件将会被存储到数据库中. 于是开始研究如何设计数据库的表结构, 以存储文件.
其实, 并非如此, 在大多数情况下, 文件都不是直接存储在数据库中的, 而是直接保存在文件系统 (磁盘) 里, 数据库中只是记录了文件的信息 ( 比如: 存储路径, 文件名 ). 看下图:
如图所示, 客户选择的文件从客户端来到服务器端后, 一边将文件信息存储到了数据中, 另一边则将文件本身存储到文件系统 ( 磁盘 ).
在一些特殊场景下, 如果客户端上传的是一些尺寸较小的图片, 偶尔我们也会把图片数据进行BASE64编码, 然后直接存储到数据库里. 但这是非主流的做法.
1.2 文件怎么从客户端来到服务器端 ?
谈到这个问题, 我们就得说说 HTTP协议和RFC1867协议.
最初的 HTTP 协议并没有上传文件方面的功能. RFC1867 为 HTTP 协议添加了这个功能.
客户端的浏览器按照此规范将用户指定的文件发送到服务器, 服务器端程序则按照此规范, 解析出客户端发送来的文件数据.
RFC1867 协议在 HTTP 协议的基础上为 input 标签增加了 file 属性, 同时限定了表单 ( form ) 的 method 属性必须为 POST ( 即: 必须以POST方式提交表单 ),enctype 必须为 multipart/form-data.
在一般的 Web 应用程序中, 我们通常使用 <input type="file">
标签, 由浏览器解析后它会产生一个文件浏览按钮, 单击此按钮将会出现操作系统的文件选择对话框.
如果我们在 HTML 文档中有如下代码:
1 | <form action="..." method="POST"> |
那么, 在提交此表单后 ( POST 方式 ), 输入框中的数据将被编码成 userName=zhangsan&userAge=18 的样子, 放在 HTTP 数据包的主体 ( Body ) 部分传输到服务器端.
现在, 我们再多加一个文件选择控件 ( 第4行 ):
1 | <form action="..." method="POST" enctype="multipart/form-data"> |
这样, 用户就可以点击文件浏览按钮, 选择一张图片作为自己的头像, 随同姓名和年龄信息一起传输到服务器端了.
现在我们来看看, 加入了这个<input type="file">
后 , 用户若填写并提交此表单, HTTP 数据包会发生什么变化 ?
(1) HTTP 数据包的头 ( Head ) 部分会由原来的:
1 | content-type: application/x-www-form-urlencoded |
变为
1 | content-type: multipart/form-data; + + |
content-type 的值变了. 同时增加了 boundary , 它其实就是华丽丽的分割线, 下文可看到 RFC1867 利用 boundary 分割 HTTP 数据包中的数据. boundary 中数字字符区是随机生成的.
(2) HTTP 数据包的主体部分
1 | -----------------------------7d52b133509e2 |
可以看到, 用户填的 姓名 / 年龄 会和头像文件的数据一起打包在 HTTP 数据包中, 只是其中使用了 boundary 进行分隔.
这样, 服务器端的技术 ( ASP / ASP.NET / PHP / J2EE …) 若也遵循相同的规范 ( RFC1867 ), 那么就可以解析出相应的数据了.
好了, 扯了这么多, 只是为了大概了解底层的工作原理. 其实, 还有很多细节, 我们就不再深究了…
下面, 我们直接来看简单例子, 这样你大概就可以依葫芦画瓢了…
对于 Web 应用程序来说, 只要 “接口” 对上了, 客户端和服务器端所使用的技术/方法是可以 “混搭” 的.
所以, 下文我们分为客户端程序 和 服务器端程序 两个部分来说, 你可以自由混搭.
2. 客户端实现
根据实际需要, 客户端可以是 HTML 表单提交, 也可以是 Ajax 方式提交, 当然也包括 Hybrid App, 微信小程序等.
下面我分别举例说明…
对于 2.1, 2.2 节的示例, 首先你需要使用喜欢的 IDE ( IntelliJ IDEA / Eclipse / … ) 创建一个动态网站项目, 客户端代码和服务器端代码要放在同一个项目下.
如果不明白上面这句话是什么意思, 那估计你得先补补课, 看一下这几个基础教程:
IntelliJ IDEA + Tomcat 搭建 Web 应用程序开发环境
2.1 HTML 表单
创建一个动态网站项目, 新建一个前端 HTML 页面, 代码如下:
Code 2.1: index.html
1 |
|
很简单, 以上代码就是在网页中放置了一个表单, 其中有 2 个输入框 ( userName 和 userAge ), 以及一个文件浏览控件 ( portrait ), 再加一个 “提交” 按钮.
注意 <form>
标签的 3 个属性:
action="/upload"
表明表单提交后由服务器端的谁来处理
method="POST"
, enctype="multipart/form-data"
为什么要这样写? 思考一下, 不明白的话, 回头看下 1.2 节.
为了测试方便, userName 和 userAge 输入框中我们已经预先填入了 zhangsan 和 18, 省得测试的时候还要手动填写.
接下来, 就是服务器端的实现了, 可以直接跳到第 3 节继续观看…
2.2 Ajax 方式
创建一个动态网站项目, 新建一个前端 HTML 页面, 代码如下:
Code 2.1-1: index.html
1 |
|
第 6 行, 引入 jQuery 库, 这里使用的是百度提供的源, 所以你必须连接上 Internet .
第 7 行, 引入我们自己的 js 文件 ( 见下文 )
第 10 ~ 13 行的表单很简单. 我们这里使用 Ajax 方式提交, 所以 form 标签的 action, method, enctype 属性都用不着设置 ( 因为我们不是直接提交表单 ) . 注意 10, 13, 14 行这几个元素我们都加了 id 属性, 以便在 js 文件中引用.
OK, 接下来看 JavaScript 代码. 新建一个 js 文件: index.js , 代码如下:
Code 2.1-2: index.js
1 | // 当网页就绪后, 注册 btnAjaxSubmit 按钮的点击(click)事件监听, 以触发后续的逻辑 |
代码中大量的注释应该可以帮助你理解个大概了, 下面补充几点要注意的地方:
(1) 如第 1.2 节所述, 带文件的数据提交需要遵循 RFC1867 协议, 所以这里我们借助浏览器提供的 FormData
工具类来封装数据包 ( 参看第 14 行 ), 可以把它看作是一个容器.
(2) 最后就是第 22 ~ 35 行, 使用 Ajax 方法提交到服务器端了.
有一定基础的同学应该可以基本看懂了, 如果不明白, 可回炉重造, 传送门: 前后端通信简明教程
划重点:
- 上行服务器端的所有数据 ( 含普通表单字段和文件 ) 都被封装成了一个 FormData 对象, 所以第 25 行这里只能是
data: formData
, 不能再把数据散放. - RFC1867 协议规定, 前端上行数据包的 content-type 应为 multipart/form-data, 所以, 第 34 行这里, 要么写作 false , 表示由 jQuery 自行判断填充, 要么明确写明
'content-type: multipart/form-data'
- 同样, 因为我们已经使用 FormData 对数据进行了封装, 所以要在第 28 行那里告诉 jQuery 不要再画蛇添足, 再对数据进行处理.
老师再次敲黑板了~ 上面 3 点要特别关注.
好了, 如果你是乖娃娃, 已经跟着把代码写好了, 那你现在可以直接飞到第 3 节, 给你的代码配个服务器端实现, 就可以跑起来了.
本节 ( 2.2 ) 和 2.1 节只是使用了不同的方法来实现客户端, 其中的代码配上相同的服务器端实现, 应会产生相同的结果. 本文最后部分(2.5节)补充了 vue + axios 实现客户端的做法。
可能有同学会想, 既然我们已经可以使用 Ajax 方式上传文件了, 能否不在页面上放置那个丑陋的 <input type=”file” > 标签? 而是直接让用户填写一个本地的路径 ( 或我们直接指定一个本地路径 ), 然后使用 JavaScript 代码从用户电脑的磁盘上读取文件数据, 上传服务器端 ?
答案是: NO!
浏览器出现安全考虑, 是不允许 JavaScript 直接去访问客户端的文件系统或执行客户端程序的 ( 专业点说, 你的 JavaScript 代码是运行于浏览器的安全沙箱 ( sandbox ) 中的, 没有那么大的权利 ). 所以只能说上面的想法太天真!
所以, 至少在浏览器环境下, 你是绕不开那个丑陋的文件浏览按钮的. 不过… 你可以把它隐藏, 然后使用一个漂亮的图片或是别的什么东东来代替, 然后监听你这个漂亮东东的 click 事件, 随后再触发文件浏览按钮的 click 事件. 比如这样: document.getElementById(“filePortrait”).click(); 嘿嘿, 我就不举例了, 如果不明白, 就再看一遍这段话, 还不明白的话… 那就算了.
至于在 Hybrid App 或 微信小程序中如何直接读取用户手机相册文件, 请看下节分解…
2.3 使用 HBuilderX 开发的 5+ App
如果你正在 HBuilderX 在开发所谓的 5+ App ( 其实就是 HyBrid App ) , 本节的小例子可供你参考如何从相册选择 ( 或相机拍摄) 一张图片上传到服务器端.
HBuilderX 是什么鬼? HBuilderX 的安装和使用方法, 以及所谓 5+ App 的基本概念和开发方法不在本文讨论范围, 可直接看这里: https://dcloud.io/
OK, 开始吧…
选择菜单 文件 → 新建 → 项目, 选择 5+ App, 填写项目名称, 创建一个新项目.
新建一个 HTML 文件 ( 本例直接使用自动创建的 index.html ) , 敲入如下代码:
Code 2.3: index.html
1 |
|
呵呵, 这种 HTML 和 JavaScript 混在一起的代码风格确实有点不符合有洁癖的程序员的审美 … 你可以像普通网页设计一样, 把 HTML, ,CSS, JavaScript 分离到不同的文件中.
我们倒着来看以上这段代码.
第 53 ~ 56 行, 页面展示部分, 很简单, 就是在页面上放了两个按钮, 点击后分别触发 使用相机拍照上传 ( uploadByCamera ) 和 从相册选择图片 ( uploadByGallery ) 上传.
第 39 ~ 43 行, 使用相机拍照上传, plus.camera.getCamera().captureImage() 是运行时环境提供的 API, 在真机调试环境下会打开手机摄像头让你拍一张照片. 我们在调用 captureImage() 时传入了一个回调函数 , 当用户拍照后会自动回调 , 同时将照片在客户端 ( 手机 ) 中的存储路径注入 ( path ). 然后, 调用 ( 触发) 第 13 ~ 36 行定义的 uploadFile() 函数, 完成图片上传.
第 46 ~ 50 行, 从相册选择图片上传, 代码逻辑与拍照上传类似 ( 参看上一段 ). 只是这里使用 plus.gallery.pick() 来触发从相册选择图片的动作.
plus.camera.getCamera().captureImage() 参见: https://www.html5plus.org/doc/zh_cn/camera.html
plus.gallery.pick() 参见: https://www.html5plus.org/doc/zh_cn/gallery.html
以上两个 API 回调时将带回图片在手机端的存储路径 ( path ), 形如: file:///…/1522437259-IMG_0006.jpg , 注意 path 中最后一个 “/“ 后的部分是图片在手机端的名称. 上述代码中第 30 行即从 path 中提取文件名部分.
若使用真机调试, 路径格式稍有不同, 但不影响代码实现逻辑.
OK, 来看核心部分, 第 13 ~ 36 行的 uploadFile() 函数:
首先, 使用 plus.uploader.createUpload() 创建一个文件上传任务 ( task ).
plus.uploader.createUpload() 参见: https://www.html5plus.org/doc/zh_cn/uploader.html
调用 createUpload() 函数需要传 3 个参数:
第 1 个参数, 服务器端接口的 URL, 上述代码中我们将其在第 10 行暂存于变量 url, 以便修改.
特别注意: 现在我们程序的客户端是运行于手机/模拟器中, 而服务器端程序运行于你的电脑, 也就是说, 客户端程序 和 服务器端程序并不是运行于同一个设备上, 也并非同一个域. 所以, 这里的服务器端接口的 url 必须使用完整路径 ( 如: http://192.168.0.1:8080/upload , 带主机头) , 而不能像一般的动态网站那样使用相对路路径 /upload. 也不能使用 http://localhost:8080/upload, 因为此时的 localhost 其实是手机/模拟器, 而不是服务器端程序所在的电脑.
若使用模拟器调试程序, 可以查看一下你服务器端电脑的 IP 地址, 用服务器端电脑 IP 地址替换上述代码的 192.168.0.1 部分.
若使用手机真机调试, 同样需要把上述代码的 192.168.0.1 部分替换成服务器端电脑 IP 地址.
更重要的是, 要确保你的手机能正常访问你的服务器端.
可以考虑两种方式:
方式 1: 若你有无线路由器, 手机通过 WIFI 连接无线路由器, 电脑通过有线/无线方式连接同一个路由器, 让手机和电脑处于同一个通信子网. ( 可能还需要根据你的局域网建构方式做相应的配置 )
方式 2: 打开手机热点, 电脑连接手机热点.
注意你电脑的防火墙可能会阻止手机端访问.
第 2 个参数, { method:’POST’ } 部分是一些配置信息, 这里我们只设置了提交方式. 其它的配置参数参见: https://www.html5plus.org/doc/zh_cn/uploader.html
第 3 个参数是一个回调函数 ( 16 ~ 22 行 ). 文件上传任务完成后将回调此函数 ( 类似 jQuery 中 Ajax 请求的 complete 回调函数 ). 无论上传成功或失败均会回调此函数, 可以通过回调函数的第 2 个参数获得 HTTP 请求的状态码 ( 200 表示成功 ), 而其第 1 个参数则是服务器端返回的数据.
第 25 ~ 32 行, 将普通数据 ( userName, userAge ) 和 文件信息添加到上传任务中.
这段代码是不是似曾相识 ? 回头看一下 2.2 节 Code 2.1-2: index.js 中第 11 ~ 26 行的部分~
第 32 行, { key: ‘portrait’, name: fileName } 部分是随文件一起带到服务器端的关于文件的信息 ( 程序运行起来后, 对照看一下服务器端的控制台输出就能明白 )
第 35 行, 启动任务, 开始上传… ( 前面只是做准备, 此时才真正开始上传 )
好了, 跳到第 3 节, 给你的代码配个服务器端实现, 实测一下吧~
温馨提示:
(1) 调试时若提示权限不够, 应在 manifest.json 中进行相应设置
(2) 若真机调试, 手机询问是否允许使用网络时要记得”允许”哦
(3) 因客户端程序和服务器端处于不同的 “域”, 可能会出现跨域访问问题 ( CORS ). 若不幸遇到, 对服务器端作相应配置.
(4) JavaScript 代码是运行于浏览器的安全沙箱 ( sandbox ) 中的, 它无权直接去读取/操作手机磁盘上的文件. 必须通过运行环境提供的 API 进行处理 ( 如: plus.gallery.pick() ).
(5) Android / iOS 与 Windows 不同, 注意移风易俗. 虽然, 本质上来说, Hybrid App 大部分功能是使用网页开发技术实现的, 但涉及到与手机操作系统/硬件系统交互的部分, 应使用运行时环境提供的 API ( 本例即: HTML 5+ API ) 来实现.
(6) 音频的录制和上传可参考 https://www.html5plus.org/doc/zh_cn/audio.html ( plus.audio.getRecorder() ), 结合本例, 稍作改造即可.
2.4 微信小程序
微信小程序开发环境的搭建不在本文讨论范围, 若不了解, 可参看: https://mp.weixin.qq.com/cgi-bin/wx
在 “微信开发者工具” 中新建微信小程序项目.
开发者工具应会自动创建一个 “首页”, 下面我们直接修改其首页代码, 演示在微信小程序中如何选择图片 ( 拍照 ) 上传.
修改 index.wxml 代码如下:
Code 2.4-1: index.wxml
1 | <view class="container"> |
非常简单的页面 ( 如下图 ). 注意第 2 行绑定了 “选择图片” 按钮的 tap
事件处理函数为 chooseImage
, 见 Code 2.4-2: index.js
修改 index.js 代码如下:
Code 2.4-2: index.js
1 | Page({ |
第 3 ~ 16 行的 chooseImage() 函数即为 “选择图片” 按钮的 tap 事件回调函数, 负责唤起拍照/从相册选取图片功能.
第 5 ~ 15 行, wx.chooseImage()
是微信提供的 API , 参看代码中的注释 和 微信官方文档 ( chooseImage ) .
第 9 ~ 14 行为用户选择/拍照后的回调函数, 其参数 res 携带了关于用户图片的信息, res.tempFilePaths 中即存放了用户选中的图片文件在用户手机端的临时存储路径. 微信小程序中用户可一次从相册中最多选中 9 张图片, 因此, res.tempFilePaths 为数组. 接下来的第 11 行判定用户确实选中了图片后, 在第 12 行调用 uploadImage 函数开始上传图片文件.
wx.chooseImage() 的 success 函数是由微信平台触发的异步回调函数, 在此函数中的 this 并非指向当前 Page , 所以不能使用 this.uploadImage( … ) 来调用上传图片函数 ( uploadImage ) . 因此, 在第 4 行暂存 this 于 _this, 以便在 success 函数中引用. 微信小程序开发中, 异步回调常使用此方法来解决 scope 问题.
第 19 ~ 37 行完成图片上传工作. 使用了微信平台提供的上传文件 API: wx.uploadFile()
, 参看代码中的注释 和 微信官方文档 ( uploadFile ) .
注意几个地方:
第 21 行的 url 为服务器端的接口地址, 不能像一般的动态网站那样使用相对路径 /upload , 也不可用 http://localhost:8080/upload . 小程序运行于手机, 并非服务器端, 因此, localhost 并不会指向服务器端. 小程序也并不与服务器端在同一个”域”, 因此不可直接使用相对路径. 而必须使用完整路径 (例如: http://192.168.0.1:8080/upload , 带主机头)
若使用模拟器调试程序, 可以查看一下你服务器端电脑的 IP 地址, 用服务器端电脑 IP 地址替换上述代码的 192.168.0.1 部分.
若使用手机真机调试, 同样需要把上述代码的 192.168.0.1 部分替换成服务器端电脑 IP 地址.
**更重要的是, 要确保你的手机能正常访问你的服务器端 **
可以考虑两种方式:
方式 1: 若你有无线路由器, 手机通过 WIFI 连接无线路由器, 电脑通过有线/无线方式连接同一个路由器, 让手机和电脑处于同一个通信子网. ( 可能还需要根据你的局域网建构方式做相应的配置 )
方式 2: 打开手机热点, 电脑连接手机热点.
注意你电脑的防火墙可能会阻止手机端访问.
第 24 行, 可使用 formData 属性携带额外的信息至服务器端 ( 参看服务器端控制台输出 )
第 26 行的 success 函数是在调用微信小程序 API 成功后的回调, 并不代表整个通信过程成功. 须在回调函数中判断状态码是否 200 才能确定通信过程是否成功.
微信小程序默认使用的是 HTTPS 协议, 但在开发阶段可使用 HTTP 协议: 点击微信开发者工具右上角的 详情 → 本地设置 → 选中 “不检验合法域名、web-view 版本、TLS 版本以及 HTTPS 证书”, 如下图.
微信小程序正式发布版必须使用 HTTPS. 此外, 服务器端程序亦须部署于公网服务器, 并进入腾讯的微信小程序管理平台配置服务器合法域名.
2.5 Vue + Axios
与时俱进,补充 Vue + Axios 实现客户端
Code 2.5-1: index.html
1 |
|
Code 2.5-2: index.js
1 | new Vue({ |
好了, 跳到下一部分, 搭配一个服务器端实现, 实测一下吧~
Revised on 2022/05/06 03:33:12 by Bailey
-
Next Post前后端通信简明教程
-
Previous Post文件上传怎么做 (下)