0%

midway 结合angular 上传图片到服务器

以前做的是angular上传图片直接到七牛云,只需要自己的服务器把七牛云需要的东西比如token带过来即可,然后前端用angular上传,自己的服务器不用管图片的处理,全交给七牛云就完事了,简单可靠。但是,七牛云总归是别人家的东西,有些限制我们只能接受,没法子适应你特殊需求,那么我们就需要将图片上传到自己的服务器上,反正服务器存储空间还挺大,闲着也是闲着。

我是用midway来做api的,所以来看下使用midway来处理图片的上传。

前端,angular发起携带图片的上传请求

我们先不管后端,先来看前端使用angular来发起上传图片的请求。

一个请求要想携带文件,那么他的Header字段Content-Type必须为multipart/form-data

那么为什么需要设置这个Content-Type?它的作用是什么?

Content-Type

Content-Type用来标记这个请求的内容的类型,语法:

1
2
Content-Type: text/html; charset=utf-8
Content-Type: multipart/form-data; boundary=something

例如,在通过Html Form提交生成的POST请求中,请求头的Content-Type由<form>元素上的enctype来指定:

1
2
3
4
5
<form action="/" method="post" enctype="multipart/form-data">
<input type="text" name="des" value="some text"/>
<input type="file" name="myFile"/>
<button type="submit">submit</button>
</form>

POST请求发送数据给服务器,服务器解析成功才能得到对应的数据,服务器就是通过请求头中的Content-Type来知道请求的消息主体是用哪种方式的编码的数据,然后才能正确解析。

常见的有四种编码方式:

  • application/x-www-form-urlencoded 请求主体都进行url转码
  • multipart/form-data 请求主体包含二进制文件
  • application/json 请求主体以json形式发送
  • text/xml 请求主体以xml的形式发送

具体介绍这四种的已经写了一篇文章,这里不再赘述,具体参见:提交post请求的四种方法

angular发起带文件的POST请求

先通过Angular Cli创建一个上传文件的组件:

1
ng g c upload

然后我们需要一个隐藏的表单域:

1
2
3
4
5
6
7
8
9
10
11
<input
#fileUpload
type="file"
id="file-upload"
name="fileUpload"
multiple
[accept]="accept"
(change)="fileUploadChange()"
style="display: none"
/>
<ng-content></ng-content>

至于ng-content,是为了方便使用这个组件,我们可以在使用组件的时候,自定义一些内容。比如包含一个上传按钮:

1
2
3
<app-upload>
<button mat-button>上传</button>
</app-upload>

再看ts文件。

我们在html中定义了一个模板变量:fileUpload,为了方便在ts中来获取这个input的dom:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 和模板变量建立联系
@ViewChild('fileUpload') fileUpload: ElementRef;

private _fileUpload: HTMLInputElement;

constructor(
private _http: HttpClient,
) {
}

// 获得原生dom
ngAfterViewInit(): void {
this._fileUpload = this.fileUpload.nativeElement;
}

// 当选择文件后就处理上传
fileUploadChange() {
if (this._fileUpload.files && this._fileUpload.files.length) {
this.handleImage();
}
}

最后看处理上传的方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
handleImage() {
const formData = new FormData();
formData.append('id', 'test');
for (let i = 0; i < this._fileUpload.files.length; i++) {
formData.append(`file${i}`, this._fileUpload.files[i]);
}

this._http.post(environment.apiUrl + '/upload', formData, {
headers: {
'Content-Type': 'multipart/form-data',
},

}).subscribe(res => {
console.log(res);
});
}

我们给请求主体中塞了一个id字段,然后带着所有选中的文件,发起了请求。

在浏览器的控制台看看请求体:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
------WebKitFormBoundaryIlSKiYRXCV64BXGf
Content-Disposition: form-data; name="id"

test
------WebKitFormBoundaryIlSKiYRXCV64BXGf
Content-Disposition: form-data; name="file0"; filename="dhpkpldndv.jpeg"
Content-Type: image/jpeg


------WebKitFormBoundaryIlSKiYRXCV64BXGf
Content-Disposition: form-data; name="file1"; filename="dhpkpldnlv.jpeg"
Content-Type: image/jpeg


------WebKitFormBoundaryIlSKiYRXCV64BXGf--

在multipart/form-data消息体中:

  • boundary 用来分割不同的字段。
  • Content-Disposition 用来给出其对应字段的相关信息。
  • Content-Type 描述二进制文件的类型

上面我发起请求,在form-data中包含了一个名字叫id的参数,和file1file0两个文件。可以看到如果multipart中子部分是二进制文件的话,还会包含文件名称filename

到此为止,我们前段准备完毕,接下来就看midway中的处理了。

后台,midway处理上传

在开始midway之前,我们先了解下Node的流处理。

Node Stream 流

Stream是Node.js中的基础概念,类似于EventEmitter,专注于IO管道中事件驱动的数据处理方法。Node.js提供了多种流对象,例如,Http服务器的请求和process.stdout都是流的实例。

流是可读、可写、或者可读可写的。所有流都是EventEmitter的实例。

Node.js中的四种基本流:

  • Writable 可写入数据的流(例如:fs.createWriteStream())。
  • Readable 可读取的数据流(例如:fs.createReadStream())。
  • Duplex 可读可写的流(例如:net.Socket)。
  • Transform 在读写过程中可以修改或转换数据的Duplex流(例如:zlib.createDeflate())。

还有我们需要处理文件流的两个模块:

  • await-stream-ready 用于异步操作流文件
  • stream-wormhole 关闭流文件(将上传的文件的流消耗掉,要不然浏览器会卡死)

midway 接受请求里面的流文件

我们先构造一个控制器,绑定我们的请求地址:http://localhost:7001/upload

1
2
3
4
5
6
7
export class ImageController {

@post('/upload')
async upload(ctx: Context) {
ctx.body = {code: 0, msg: 'success'};
}
}

现在访问应该可以看见是正常的返回。

我们需要从请求中获取文件流,可以看官网介绍:egg 控制器 获取上传文件 流模式

我们这里是多个文件,所以直接使用ctx.multipart()获取。

这里有个名词需要解释下busboy,busboy是一个node.js模块,用户解析传入的html表单的数据。

它会解析http的请求流,解析form-data消息体携带的数据,如果是非文件的字段,它会解析为一个数组,分别为:

  • part[0] 字段名称
  • part[1] 字段的值
  • part[2] valueTruncated 截断的值
  • part[3] fieldnameTruncated 截断的字段名称

接下来我们可以处理请求了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
@post('/upload')
async upload(ctx: Context) {
const parts = ctx.multipart();
let part = null;
// 从formData中也要获取id字段。
let id = null;
const images = [];
while((part = await parts()) != null) {
// 如果part是个数组,说明是非文件字段
if (part.length) {
if (part[0] === 'id') {
id = part[1];
}
} else {
// 文件,没有文件名就不用处理
if (!part.filename) {
continue;
}
// 处理上传
const res = await this.imageService.saveImage(part, this.uploadConfig.jarvis.path);
if (res.type) {
images.push(res.name);
} else {
return;
}
}
}
ctx.body = Response(CodeEnum.SUCCESS);
}

midway 处理上传

为了方便扩展,我们将上传的方法封装到一个service中,在这里处理上传,上传成功后返回文件的名字和目录给业务即可。

首先我们需要一个辅助函数,创建文件夹的函数:

1
2
3
4
5
6
7
8
9
10
11
mkdirSync(dirname: string): boolean {
if (fs.existsSync(dirname)) {
return true;
}
try {
fs.mkdirSync(dirname);
return true;
} catch(err) {
return false;
}
}

然后我们的上传方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
import * as fs from 'fs';
import * as path from 'path';
import * as awaitStream from 'await-stream-ready';
import * as sendToWormHole from 'stream-wormhole';
import * as moment from 'moment';
import {FileStream} from "egg";
async saveImage(
stream: FileStream,
uploadPath: string,
) : Promise<{type: boolean, msg?: string, name?: string}> {
const fileName = `${Date.now()}${Math.floor(Math.random() * 1000)}${path.extname(stream.filename).toLocaleLowerCase()}`;
// 文件夹的名字
const dirname = moment().format('YYYYMMDD');
const mkdirResult = this.mkdirSync(path.join(uploadPath, dirname));
if (!mkdirResult) {
return {type: false, msg: '创建文件夹错误'};
}

const target = path.join(uploadPath, dirname, fileName);

const writeStream = fs.createWriteStream(target);
try {
await awaitStream.write(stream.pipe(writeStream));
const p = uploadPath.split('/')[-1];
return {type: true, name: `/${p}/` + dirname + '/' + fileName};
} catch (err) {
// 如果出错就关闭管道
await sendToWormHole(stream);
return {type: false, msg: '文件错误'};
}
}

最后,我们在调用接口的时候,会发现文件已经被上传到我们指定的目录。


更新2020-04-04 22:20:22

在Angular中如果body是FormData类型的时候,header中指定Content-Type和不指定是有区别的。

当指定Content-Type时,就类似于上面的例子中所示,body里面是下面这个样子的:

1
2
3
4
5
6
7
8
9
10
------WebKitFormBoundaryIlSKiYRXCV64BXGf
Content-Disposition: form-data; name="id"

test
------WebKitFormBoundaryIlSKiYRXCV64BXGf
Content-Disposition: form-data; name="file0"; filename="dhpkpldndv.jpeg"
Content-Type: image/jpeg


------WebKitFormBoundaryIlSKiYRXCV64BXGf

body的名字会变为:Request Payload。具体为:
no content-type

当不指定Content-Type时,发起请求的时候会在Request Header里面自动识别Content-Type,这时候body会变成:

1
2
3
file0: (binary)
file1: (binary)
id: test

body的名字也是Form Data

然后boundary会放在自动生成的Content-Type中。类似于:

1
Content-Type: multipart/form-data; boundary=----WebKitFormBoundary85LayBzfocjBIQa1

具体为:
have content-type

出现的问题是,好像以前是按照上面写的,是没问题的,但今天测试的时候忽然发现上传不了了,去掉Angular中指定的Content-Type反而就好了:

1
2
3
4
5
6
7
8
9
const formData = new FormData();
for (let i = 0; i < this._fileUpload.files.length; i++) {
formData.append(`file${i}`, this._fileUpload.files[i]);
}
formData.append('id', 'test');

this._http.post(environment.apiUrl + '/photos/upload', formData).subscribe(res => {
console.log(res);
});

所以就很奇怪,到底是Angular变了心,还是Midway劈了腿???

后面需要特别去看下关于Form Data的的这两种方式的区别。

码字辛苦,打赏个咖啡☕️可好?💘