Node图片处理——Jimp配合node-qrcode生成图片上传总结
上周产品那边来了一个需求,需要基于原图针对不同用户生成不同二维码以及文案,并生成新图片,让用户能够保存。接到这个需求时,心里不仅没有拒绝的意思,反而有点小兴奋 ~ 因为又能探索一下新东西。
大致效果如下,原图:
效果图:
试水canvas
刚开始打算在前端用canvas生成图片。我们都知道canvas有合成图片的功能,核心是drawImage
及toDataURL
这两个方法。
大致思路是:
- 使用
drawImage
将生成的二维码合并到原图的指定位置
- 使用
fillText
方法生成文案
- 用
toDataURL
将图片转成base64
- 使用 atob 以及 Uint8Array 将其转为Buffer进行上传。
不过最终该方案没有走通,因为不同手机尺寸比例不统一,生成的二维码的位置无法准确地定位到指定位置,因此采用了另一种方案:node层生成图片。
node搞起
在node层就无需考虑适配的问题了,因为只有一个基准,也就是原图。生成二维码及文案的尺寸、位置都可以直接写死。经过调研,node图像处理库最出名的有两个,分别是:Jimp 和 Sharp,最终选用Jimp,因为Sharp没安装上😓。二维码库倒是很多,最终决定选用 node-qrcode。
开搞!
主要步骤就两步,如下:
- 生成图片
- 读取图片并上传
下面分解这两步讲解
生成图片
生成图片是最麻烦的。步骤比较多:
- 使用qrcode生成二维码 Buffer
- 包装二维码Buffer为Jimp对象
- 生成文案
- 合成图片并保存
大部分都是调用Jimp及qrcode的api,还有一些node的原生api,如使用Buffer.from
将base64转为Buffer。感兴趣的可以去参阅它们的文档:
由于生成图片步骤较多,每一步都依赖上一步的结果,并且都是异步的,如果使用回调的话就彻底陷入回调地狱了😓,因此主要想说的是代码组织方式。不怕大家笑话,我的第一版代码是这样的🤣:
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 32 33 34 35 36 37 38 39 40
| const codeBuffer = yield new Promise((resolve, reject) => { Qrcode.toDataURL(url, {}, (err, url) => { const res = Buffer.from(url.replace(/.+,/, ''), 'base64') err ? reject(err) : resolve(res) }) }).catch(() => {})
const textJimp = yield new Promise((resolve, reject) => { new Jimp(textBgWidth, config.textBgHeight, +`0xFF${config.textBgColor}`, (err, image) => { Jimp.loadFont(config.fontPath).then((font) => { resolve(image.print(font, config.textPadding, 10, textContent, 10)) }) }) })
const codeJimp = yield new Promise((resolve, reject) => { Jimp.read(codeBuffer).then((res) => { if (res) { resolve(res.resize(config.codeWidth, config.codeWidth)) } else { reject('包装buffer失败') } }) }).catch(() => {})
yield new Promise((resolve, reject) => { Jimp.read(config.originImgPath).then(img => { img.composite(codeJimp, config.codeLeft, config.codeTop) .composite(textJimp, config.textLeft, config.textTop) .write(config.tempFilePath, () => { reject('保存图片失败!') }) }) }).catch((err) => { console.log('保存图片出错:', err) })
|
因为我们使用的node前后端分离框架 grace 的版本是支持 generator 语法的,所以想到了使用 yield 来将异步操作同步展示,但还是看起来太繁琐了😓,必须重构!
promise 登场!
使用 promise 的链式调用语法,结构就会清晰很多,改写后代码是这样的:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24
| const imgResult = yield generateCode(href) .then((res) => { codeBuffer = res; return wrapCodeBuffer(codeBuffer, imgConfig); }) .then((res) => { codeJimp = res; return generateText(textBgWidth, textContent, imgConfig); }) .then((res) => { textJimp = res; return compositeImg(imgConfig, textJimp, codeJimp); }) .then(() => true) .catch((err) => { return false; });
|
瞬间优雅的许多 ~
实现方法也很简单,就是让每个步骤的方法都返回一个 promise 即可,拿该方法为例:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
|
function wrapCodeBuffer(codeBuffer, config) { return new Promise((resolve, reject) => { Jimp.read(codeBuffer).then((res) => { if (res) { resolve(res.resize(config.codeWidth, config.codeWidth)); } else { reject('包装二维码Buffer失败'); } }); }); }
|
上传图片
接下来是使用node上传图片。由于使用的后端接口是基于FormData方式的,所以要在node层模拟一个FormData上传请求。
起初是完全懵逼的,因为对http协议的这块标准一直是一知半解。在前端使用FormData上传图片时我们经常能看到请求体是这样的:
1 2 3 4 5
| ------WebKitFormBoundarywQMoN5B2ZNAD6uqN Content-Disposition: form-data; name="file"; filename="avatar.jpeg" Content-Type: image/jpeg
------WebKitFormBoundarywQMoN5B2ZNAD6uqN--
|
请求头的Content-Type是这样的:
1
| Content-Type: multipart/form-data; boundary=----WebKitFormBoundarywQMoN5B2ZNAD6uqN
|
看起来挺复杂的,尤其是这个------WebKitFormBoundarywQMoN5B2ZNAD6uqN--
到底是个什么鬼😢。
别急,先从我的这个上传方法讲起:
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 32 33
|
function uploadImg(request, config, cookies = '') { const boundaryKey = Math.random().toString(16); const endData = '\r\n----' + boundaryKey + '--'; let contentLength = 0, content = ''; content += '\r\n----' + boundaryKey + '\r\n' + 'Content-Type: application/octet-stream\r\n' + 'Content-Disposition: form-data; name="file"; ' + 'filename="bg_invite.png"; \r\n' + 'Content-Transfer-Encoding: binary\r\n\r\n'; let contentBinary = Buffer.from(content, 'utf-8'); contentLength = fs.statSync(config.tempFilePath).size + Buffer.byteLength(contentBinary) + Buffer.byteLength(endData); request.setHeader('Content-Type', 'multipart/form-data; boundary=--' + boundaryKey); request.setHeader('Content-Length', contentLength); request.setHeader('Cookie', cookies); request.write(contentBinary); const fileStream = fs.createReadStream(config.tempFilePath, { bufferSize: 4 * 1024 }); fileStream.on('end', () => { request.end(endData); }); fileStream.pipe(request); }
|
可以看到,这个方法其实就是构造了请求,拆分下来就如下几件事:
先说请求头,FormData形式的请求Content-Type为multipart/form-data
,并且一定要提供boundary
字段。可是为什么呢?
我们都知道默认提交表单时,Content-Type是application/x-www-form-urlencoded
,并且参数都是已类似name=John&age=12
这种形式在请求体中传递的,参数是以&
分割的。这里的boundary
的作用就跟&
一样,是用来分割多个参数的,并且是可以自定义的,而在浏览器中,是浏览器为我们自动生成的,这就知道了上文中那个boundary
是怎么回事了 ~
再看每个boundary
之间的内容,也就是每个字段,其中还有Content-type及Content-Disposition字段我们很陌生。
Content-Type跟http协议的Content-Type是一样的,只不过在multipart/form-data
类型中,我们可以手动指定每个参数的Content-Type。方法中的字段值为application/octet-stream
,就是告诉Server这部分内容是字节流,因为我们需要以字节流的形式上传图片。
而Content-Disposition是每个参数必须的选项,并且值必须为form-data
。该头其实还有其他用途,可以参阅MDN的官方文档。
接下来是计算Content-Length。这里主要使用了node的fs模块,以及Buffer模块的api,都很好理解,查看文档即可。
最后是将图片写入http.ClientRequest对象中。该对象是由node的http.request方法返回,并且是一个可写流。引用node官方文档的话:
ClientRequest 实例是一个可写流。 如果需要通过 POST 请求上传一个文件,则写入到 ClientRequest 对象。
最后再调用http.ClientRequest对象的end方法,即可完成请求对象的写入,就发出请求啦 ~
至此,一个Node合成图片并上传的需求完成!过程中收获非常多!
生命不息折腾不止!