使用 ngResource 在 AngularJS 中上传文件
在本教程中,我将讨论使用 AngularJS 的 ngResource 上传文件的方法。
引言
传统的文件上传方式是使用表单提交。这种方法与使用 Spring MVC 或 ASP.NET MVC 框架实现的应用程序配合得非常好。在本教程中,我将讨论一种不同的文件上传方法。在本教程中,文件上传可以打包在 JSON 对象中,并由 RESTful Web 服务处理。
本教程将首先概述示例应用程序的架构。然后,我将讨论如何使用 Bootstrap 创建文件上传 HTML 组件。之后,我们将探讨如何使用 AngularJS 来操作 HTML 元素。相信我,在本教程中,操作像 JQuery 这样的 HTML 元素是必要的。我们还将使用 `$scope.$apply()`。本地驱动器中的文件可以通过 JavaScript 的 `FileReader` 对象读取,该对象将二进制转换为 BASE64 编码的文本字符串。这允许我们将文件内容打包到 JSON 对象中发送。在服务器端,请求处理方法将解析文件内容并将 BASE64 字符串还原为二进制文件。
整体架构
在本教程中,我创建了一个示例程序来演示本教程中将要讨论的功能。此应用程序将基于 Spring Boot。它托管了一个 RESTFul Web 服务,该服务只能处理一种请求——我们的文件上传请求。它将处理正在上传的文件。此方法将接受一个 JSON 对象,该对象有两个属性,一个是短文件名,另一个是文件内容。此方法将简单地将文件保存到硬编码的位置,例如:*c:\temp\*。
该应用程序还包含一个索引页,该页实际上是使用 AngularJS 实现的单页 Web 应用程序。该应用程序有一个输入框,可以从磁盘加载文件。选择文件后,应用程序将加载文件内容并获取文件名(仅文件名,不含路径)。它将这两个打包到 JSON 对象中并发送到 RESTFul Web 服务。
这是示例应用程序的屏幕截图
此示例应用程序中最难的部分是
- 如何操作页面上的 HTML 元素以从本地磁盘驱动器中选择文件。
- 如何读取文件并将文件转换为 `string`,以便将其传输到后端 Web 服务。
- 在服务器端,如何读取输入文件内容并将其保存回文件。
所有这些将在接下来的几节中进行解释。当我开始编写示例应用程序时,我必须做的第一件事是设计一个可以从本地磁盘获取文件的 HTML 元素。让我们看看这是如何完成的。
文件输入组件
HTML 中有一个控件可以做到这一点。我知道。我需要的是不是这个预定义的输入控件。我需要的是一个只读文本字段和一个组合在一起的按钮。该按钮可以被点击并弹出文件打开对话框,以便用于选择文件。文本字段将显示文件名。使用 Bootstrap,这非常容易定义
<div class="input-group">
<input type="file" id="fileUploadField" style="display: none;">
<input type="text" id="fileNameDisplayField" class="form-control"
ng-model="vm.uploadFileName" placeholder="File to Upload" readonly="readonly">
<div class="input-group-addon btn btn-default"
ng-click="vm.clickSelectFile()"><i class="glyphicon glyphicon-file"></i></div>
</div>
在上面的代码片段中,有一些特定于 AngularJS 的属性。暂时可以忽略它们。外部 `
在外部 `
- 类型为 "`file`" 的输入字段。该字段将是不可见的,因为我不会显示它。它存在的原因是我可以使用它来保存所选文件的元数据。并且我使用它的功能来选择一个文件。
- 一个只读的文本输入文件,可用于显示文件名(不含文件夹路径)。我选择它为只读是因为我不想让用户修改该值。这只是一个偏好。如果您愿意,也可以使其可编辑。
- 一个带有文件图标的按钮。它可以被点击并选择一个文件。该按钮是通过另一个使用 CSS 类 "`input-group-addon`" 的 `div` 定义的。
让我们回到问题,为什么我不使用 HTML 默认的文件控制器。如果您删除 `style` 属性使其可见,您可以看到它是一个带按钮的文本字段。它类似于我刚刚创建的组件。这个默认组件的丑陋之处在于它不可自定义。而且它与其他 Bootstrap 控件不匹配。最好是创建一个看起来与其他组件一样的东西。就这样,您完成了。
现在我们有了文件上传组件。是时候实现可以选择文件并将其打包为 JSON 对象的功能了。
AngularJS 代码详解
单页应用程序定义在 `app.js` 文件中。它位于 `src\main\resources\static\assets\app\js` 文件夹下。为了让 Angular 应用程序正常工作,我必须实现几个功能
- 如何单击带文件图标的按钮以弹出文件打开对话框。
- 一旦选择了文件,如何保存文件元数据并将单个文件名提取到只读文本字段中显示。
- 如何将文件内容加载为 `BASE64 string`。
真正具有挑战性的是将 `BASE64` 文件内容 `string` 发送到后端 Web 服务。这是使用 `ngResource` 完成的。我将在本节的末尾讨论这一点。
从磁盘获取文件元数据
由于我必须定义自己的文件上传组件并且默认的文件上传输入是隐藏的,因此必须有一种方法将按钮单击链接到默认的文件上传输入。结果发现,这非常简单。默认的文件上传输入有一个按钮。我需要做的就是将我的按钮单击与文件上传按钮单击关联起来。
为了做到这一点,我必须使用类似于 JQuery 的东西。AngularJS 有一个内置函数叫做 `angular.element()`。它可用于从页面中选择元素。在我继续之前,我想说一下查询页面上的 DOM 元素是可以的。我的理由是
- 这是一个示例应用程序,用于演示一个概念。
- 它已包含,设计得相当好,并且可维护。
- 这是完成我需要完成的事情最简单的方法。
回到设计。当用户单击带文件图标的按钮时,它将触发隐藏的文件上传输入被单击。这是如何做到的
vm.clickSelectFile = function () {
angular.element("#fileUploadField").click();
};
在 `index.html` 上,带文件图标的按钮定义如下
<div class="input-group-addon btn btn-default"
ng-click="vm.clickSelectFile()"><i class="glyphicon glyphicon-file"></i></div>
如您所见,指令 `ng-click` 引用了函数 `vm.clickSelectFile()`。因此,当单击此按钮时,上面的 JavaScript 代码将对 DOM 树进行查询,找到隐藏的文件上传输入并调用其 `click()` 方法。当用户单击时,文件打开对话框将弹出。选择文件并关闭此弹出窗口后,只读文本字段不会显示任何内容。这是预期的,除非我向应用程序添加更多功能。我必须添加的下一项功能是,当隐藏的文件上传输入分配了文件时,它将通知文本字段显示文件名,下面将讨论这一点。
显示选定的文件名
如前所述,通过隐藏的文件上传输入选择文件并不会自动使文本字段显示文件名。这个问题也很容易解决。我们只需要让隐藏的文件上传输入处理选择更改事件。在事件处理期间,它会将文件名分配给绑定到文本字段的 angular 作用域变量。可能会出现的问题是,将值分配给作用域变量,但它不会自动刷新文本字段上的值显示。要解决此问题,我们必须调用 `$scope.$apply()`。这将执行刷新并使文本显示出来。
为了让文件上传输入处理选择更改事件,我必须做的是使用 `angular.element()` 查询隐藏的文件上传输入并为其分配一个事件处理方法
angular.element("#fileUploadField").bind("change", function(evt) {
if (evt) {
...
}
});
如您所见,这与 JQuery 的做法相同。通过 ID 查询元素以获取引用,然后将事件处理方法绑定到事件。在这种情况下,将处理元素的更改事件。
接下来,我需要使用所选文件的完整路径文件名,并且只返回文件名,而不是文件夹路径。为此,我只是想出了一个简单的启发式方法。文件名将是带有驱动器字母和反斜杠的 Windows 完整路径文件名;或者是一个带有斜杠的基于 UNIX 的完整路径文件名。它是一个或另一个,所以这是我提取文件名的方法
var fn = evt.target.value;
if (fn && fn.length > 0) {
var idx = fn.lastIndexOf("/");
if (idx >= 0 && idx < fn.length) {
vm.uploadFileName = fn.substring(idx+1);
} else {
idx = fn.lastIndexOf("\\");
if (idx >= 0 && idx < fn.length) {
vm.uploadFileName = fn.substring(idx+1);
}
}
}
$scope.$apply();
上面的代码片段首先查找文件名中的最后一个斜杠。如果找到,我将获取该字符之后的剩余文件名。如果找不到最后一个斜杠,那么我将尝试再次查找文件名中的最后一个反斜杠。如果找到,那么我将再次获取该字符之后的剩余文件名。如果两个字符都找不到,那么作用域变量没有任何改变。`vm.uploadFileName` 是作用域变量。一旦将文件名分配给作用域变量,我就需要调用 `$scope.$apply()` 来确保文本字段会显示文件名。为什么我要这样做?通常,AngularJS 会自动处理视图元素到模型的绑定。但是,在这种情况下,我显式地查询元素并绑定事件处理方法,数据模型到视图的自动绑定将不会设置。因此,要显式地进行模型到视图的数据刷新,只需调用 `$scope.$apply()`。
接下来是什么?这是本教程中最难的部分。我需要将文件加载为 `BASE64` 编码的 `string`。接下来将讨论。
将文件内容加载为 BASE64 编码字符串
一旦选择了文件,我们所拥有的只是文件名,也许还有一些其他与文件相关的元数据。下一步是加载数据。经过一些搜索,发现这也非常简单。JavaScript 提供了一种称为 `FileReader` 的对象类型。这种对象类型有两个很酷的地方
- 我所要做的就是将文件名传递给对象的 `readAsDataURL()` 来执行文件加载。
- 文件加载是异步完成的。加载完成后,我可以提供一个回调方法来调用 Web 服务来执行实际的上传。
这是文件加载和上传文件的整个源代码
vm.doUpload = function () {
vm.uploadSuccessful = false;
var elems = angular.element("#fileUploadField");
if (elems != null && elems.length > 0) {
if (elems[0].files && elems[0].files.length > 0) {
let fr = new FileReader();
fr.onload = function(e) {
if (fr.result && fr.result.length > 0) {
var uploadObj = {
fileName: vm.uploadFileName,
uploadData: fr.result
};
sampleUploadService.uploadImage(uploadObj).then(function(result) {
if (result && result.success === true) {
clearUploadData();
vm.uploadSuccessful = true;
}
}, function(error) {
if (error) {
console.log(error);
}
});
}
};
fr.readAsDataURL(elems[0].files[0]);
} else {
vm.uploadObj.validationSuccess = false;
vm.uploadObj.errorMsg = "No file has been selected for upload.";
}
}
};
上述代码中有几点值得提及。首先,我需要获取所选文件的文件名(完整路径)。通过查询默认上传文件输入的元素,我将获得一个引用。然后,我将使用它的 `.files` 属性来获取对该输入元素的所有选定文件的引用。默认的文件上传输入可以一次选择多个文件,这就是为什么使用数组来保存这些文件而不是一个对象引用只保存一个文件。这是代码片段
var elems = angular.element("#fileUploadField");
if (elems != null && elems.length > 0) {
if (elems[0].files && elems[0].files.length > 0) {
// This is where we can get the file name and use it for loading
...
}
}
接下来,我需要创建一个 `FileReader` 对象,并使用 `readAsDataUrl()` 方法读取文件。这是代码片段
let fr = new FileReader();
fr.onload = function(e) {
if (fr.result && fr.result.length > 0) {
// This is where we actually upload the file content to the web service.
...
}
};
fr.readAsDataURL(elems[0].files[0]);
如上所示,我创建了一个 `FileReader` 对象,并让变量 `fr` 引用它。然后,我为 `onload` 属性提供了回调方法。提供的该方法将调用我的 AngularJS 服务对象来执行文件上传。接下来,我将讨论此服务对象的工作原理。
AngularJS 文件上传服务
在我之前的文章“AngularJS ngResource 教程”中,我提到上传数据将在未来的文章中介绍。这是我承诺的未来文章。我在之前的教程中遇到的问题是我没有办法将文件加载为 `BASE64` 编码的 `string`。在这里,我做到了。其余的(上传文件)实际上非常容易。
这是服务对象定义的完整源代码
(function () {
"use strict";
var mod = angular.module("uploadServiceModule", [ "ngResource" ]);
mod.factory("sampleUploadService", [ "$resource",
function ($resource) {
var svc = {};
var restSvc = $resource(null, null, {
"uploadImage": {
url: "./uploadImage",
method: "post",
isArray: false,
data: {
fileName: "@fileName",
uploadData: "@uploadData"
}
}
});
svc.uploadImage = function (imageUpload) {
return restSvc.uploadImage(imageUpload).$promise;
};
return svc;
}
])
})();
如果您阅读了我之前的文章“AngularJS ngResource 教程”,那么理解上面的服务对象定义将会很容易。我定义了一个名为“`sampleUploadService`”的工厂对象。工厂对象返回一个充当服务的对象,其中只有一个名为 `uploadImage()` 的方法。
`uploadImage()` 方法使用 `ngResource` 对象调用后端 Web 服务。我定义的 `ngResource` 对象内部只有一个 action 方法,也称为“`uploadImage`”。该 action 方法看到 HTTP Post 用于与后端通信。`url` 属性定义了 Web 服务的 `url`。它期望一个单一对象作为响应,而不是一个对象数组。最后,发送到后端的数据将是一个 JSON 对象,它有两个属性:一个是文件名,另一个是 `BASE64` 编码的文件内容。
让我们回到上一节的结尾,当时我正要粘贴如何使用上述 AngularJS 服务(或者应该说工厂)来调用后端 Web 服务的代码片段。我停在那里是因为我认为最好先展示 AngularJS 服务是如何定义的。现在已经揭示了,是时候看看 AngularJS 服务是如何使用的了。这是它
var uploadObj = {
fileName: vm.uploadFileName,
uploadData: fr.result
};
sampleUploadService.uploadImage(uploadObj).then(function(result) {
if (result && result.success === true) {
clearUploadData();
vm.uploadSuccessful = true;
}
}, function(error) {
if (error) {
console.log(error);
}
});
文件内容存储在 `FileReader` 对象的 `result` 属性中。它仅在 `readAsDataURL()` 调用异步完成后可用。完成后,将调用该对象的 `onload()` 回调,其中包含上面的代码片段。在上面的代码片段中,我创建了一个名为 `uploadObj` 的新对象。并将没有完整路径的文件名和 `fr.result` 中的文件内容分配给新对象的属性。
最后,我使用了我的服务 `sampleUploadService` 并调用了 `uploadImage()`。仅此而已。这非常简单。如果您阅读了我的上一篇教程,您将知道如何在 HTTP 调用完成后处理事件。
我在这里想讲的最后一件事是文件内容的格式。它不仅仅是简单的 `BASE64` 编码 `string`。第一部分是媒体类型,以前缀“`data:`”开头。后面跟着一个分号。然后是编码类型。这将始终是“`base64`”,后面跟着一个逗号。最后是 `BASE64` 编码的 `string`。这是一个简短的示例
data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAZkAAAHVCAIAAACs
XRGOAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjw...
这是 JavaScript 代码逻辑的最后部分。在下一节中,我将介绍代码逻辑的 Java 端。
Spring Boot Web 服务
代码逻辑的 JavaScript 部分已经结束,现在是时候查看处理文件上传的 Spring Boot Web 服务了。首先,我想展示代表上传文件的 Java 类。这个类叫做 `UploadObject`。这是它
package org.hanbo.boot.rest.models;
public class UploadObject
{
private String fileName;
private String uploadData;
public String getUploadData()
{
return uploadData;
}
public void setUploadData(String uploadData)
{
this.uploadData = uploadData;
}
public String getFileName()
{
return fileName;
}
public void setFileName(String fileName)
{
this.fileName = fileName;
}
}
我还需要一个对象作为响应。为此,我创建了另一个名为 `GenericResponse` 的对象类型。这是它
package org.hanbo.boot.rest.models;
public class GenericResponse
{
private String id;
private boolean success;
private String detailMessage;
public String getId()
{
return id;
}
public void setId(String id)
{
this.id = id;
}
public boolean isSuccess()
{
return success;
}
public void setSuccess(boolean success)
{
this.success = success;
}
public String getDetailMessage()
{
return detailMessage;
}
public void setDetailMessage(String detailMessage)
{
this.detailMessage = detailMessage;
}
}
本教程示例程序的最后一部分是处理文件上传的 RESTFul 控制器。为了演示文件数据的解码,我创建了请求处理方法来解码文件内容,并将文件保存到磁盘。
这是完整的代码片段
@RequestMapping(value="/uploadImage", method=RequestMethod.POST)
public ResponseEntity<GenericResponse> uploadImage(
@RequestBody
UploadObject uploadObj
)
{
if (uploadObj != null && uploadObj.getUploadData() != null)
{
String uploadData = uploadObj.getUploadData();
if (uploadData.length() > 0)
{
String[] splitData = uploadData.split(";");
if (splitData != null && splitData.length == 2)
{
String mediaType = splitData[0];
System.out.println(mediaType);
if (splitData[1] != null && splitData[1].length() > 0)
{
String[] splitAgain = splitData[1].split(",");
if (splitAgain != null && splitAgain.length == 2)
{
String encodingType = splitAgain[0];
System.out.println(encodingType);
String imageValue = splitAgain[1];
byte[] imageBytes = Base64.decode(imageValue);
System.out.println("File Uploaded has " + imageBytes.length + " bytes");
System.out.println("Wrote to file " + "c:\\temp\\" + uploadObj.getFileName());
File fileToWrite = new File("c:\\temp\\" + uploadObj.getFileName());
try
{
FileUtils.writeByteArrayToFile(fileToWrite, imageBytes);
}
catch (Exception ex)
{
ex.printStackTrace();
}
}
}
}
}
}
GenericResponse resp = new GenericResponse();
UUID randomId = UUID.randomUUID();
resp.setId(randomId.toString().replace("\\-", ""));
resp.setSuccess(true);
resp.setDetailMessage("Upload file is successful.");
ResponseEntity<GenericResponse> retVal = ResponseEntity.ok(resp);
return retVal;
}
在上面的代码片段中,我所做的就是拆分 `string` 以获取 `BASE64` 编码的部分,这才是实际的文件内容。一旦我获得了 `string`,我就使用 Apache Commons 的编码库将 `string` 转换为字节数组。最后,我将字节数组保存到位于文件夹:*C:\temp* 的文件中。文件名是请求中的文件名。保存文件后,该方法会将 `GenericResponse` 类型的 JSON 对象返回给调用者。
如何测试
下载源代码后,请检查所有静态内容文件,并将所有 `*.sj` 文件重命名为 `*.js`。我不得不重命名这些文件,以便可以将它们压缩并通过电子邮件发送到 codeproject.com 进行发布。电子邮件服务器会扫描 zip 文件,并且不允许我附加文件,因为其中包含 JavaScript 文件。总之,如果您想在本地运行它们,则需要重命名这些文件。
示例应用程序是基于 Spring Boot 的应用程序,因此在将其作为服务应用程序启动之前必须对其进行构建。要构建它,您可以 `CD` 进入示例应用程序的根目录。然后运行
mvn clean intsall
构建将成功。成功后,运行以下命令并启动示例应用程序
java -jar target\hanbo-ngfileupload-sample-1.0.1.jar
应用程序将成功启动。当它启动时,您将在命令行控制台中看到以下输出
. ____ _ __ _ _
/\\ / ___'_ __ _ _(_)_ __ __ _ \ \ \ \
( ( )\___ | '_ | '_| | '_ \/ _` | \ \ \ \
\\/ ___)| |_)| | | | | || (_| | ) ) ) )
' |____| .__|_| |_|_| |_\__, | / / / /
=========|_|==============|___/=/_/_/_/
:: Spring Boot :: (v2.0.5.RELEASE)
2019-07-30 22:56:00.949 INFO 12808 --- [ main] org.hanbo.boot.rest.App
: Starting App v1.0.1 on U3DTEST-PC with PID 12808
(C:\Users\u3dadmin\workspace-mars8\ngUploadSample\target\hanbo-ngfileupload-sample-1.0.1.j
ar started by u3dadmin in C:\Users\u3dadmin\workspace-mars8\ngUploadSample)
2019-07-30 22:56:00.957 INFO 12808 --- [ main] org.hanbo.boot.rest.App
: No active profile set, falling back to default profiles: default
2019-07-30 22:56:01.095 INFO 12808 --- [ main]
ConfigServletWebServerApplicationContext : Refreshing org.springframework.boot.web.servlet.
context.AnnotationConfigServletWebServerApplicationContext@3a4afd8d: startup date
[Tue Jul 30 22:56:01 EDT 2019]; root of context hierarchy
...
...
2019-07-30 22:56:11.195 INFO 12808 --- [ main] org.hanbo.boot.rest.App
: Started App in 11.453 seconds (JVM running for 12.389)
2019-07-30 22:56:42.343 INFO 12808 --- [nio-8080-exec-3] o.a.c.c.C.[Tomcat].[localhost].[/]
: Initializing Spring FrameworkServlet 'dispatcherServlet'
2019-07-30 22:56:42.343 INFO 12808 --- [nio-8080-exec-3] o.s.web.servlet.DispatcherServlet
: FrameworkServlet 'dispatcherServlet': initialization started
2019-07-30 22:56:42.373 INFO 12808 --- [nio-8080-exec-3] o.s.web.servlet.DispatcherServlet
: FrameworkServlet 'dispatcherServlet': initialization completed in 30 ms
要在浏览器中运行 Web 应用程序,请导航到以下 URL
https://:8080
页面加载后,它将显示本教程前面显示的屏幕截图。您需要做的就是单击带有文件图标的按钮,文件打开对话框将弹出。使用它来选择一个文件并单击 **OK**。您将在文本输入框中看到文件名,不含完整文件夹路径。最后,单击蓝色的“**Upload**”按钮。上传成功后,页面顶部会显示一个绿色的状态栏,显示上传成功。
转到目标文件夹 *C:\temp*,并找到刚刚上传并保存在那里的文件。
摘要
这是又一个优秀的教程已完成。再次,我写这篇教程非常开心。对于本教程,我的重点是文件上传,特别是如何使用 AngularJS 的 `ngResource` 执行文件上传。如所示,为了执行文件上传,我必须加载文件并将内容转换为 `BASE64` 编码的 `string`(以及媒体类型和编码类型)。然后可以将它打包到一个 JSON 对象中,然后发送到 Web 服务。
Web 服务被编写为一个 Spring Boot Web 应用程序。单页应用程序也打包在同一个应用程序中。Web 服务只有一个请求处理方法,它以 JSON 对象作为输入。一旦收到请求,它将解析 `BASE64` 编码的 `string`,并将其转换为字节数组。然后将字节数组保存到文件中。尽管这个示例应用程序没有做任何有用的事情,但它演示了端到端的文件上传功能。对于需要此功能的人来说,它很有用。
希望您喜欢本教程。我肯定写得很开心。总之,尽情享受吧,希望它有所帮助。
历史
- 2019/7/31 - 初稿