以前做的是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 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, ) { }
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 17
| 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的参数,和file1
、file0
两个文件。可以看到如果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; let id = null; const images = []; while((part = await parts()) != null) { 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
。具体为:
当不指定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
|
具体为:
出现的问题是,好像以前是按照上面写的,是没问题的,但今天测试的时候忽然发现上传不了了,去掉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的的这两种方式的区别。