以前做的是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 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的的这两种方式的区别。