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。感兴趣的可以去参阅它们的文档:
- Jimp
- node-qrcode
- Node Buffer
由于生成图片步骤较多,每一步都依赖上一步的结果,并且都是异步的,如果使用回调的话就彻底陷入回调地狱了?,因此主要想说的是代码组织方式。不怕大家笑话,我的第一版代码是这样的?:
// 生成二维码Buffer
const codeBuffer = yield new Promise((resolve, reject) => {
Qrcode.toDataURL(url, {}, (err, url) => {
// 注意:这里必须把“data:image/png;base64,”这一段去掉才能转成正确的buffer
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))
})
})
})
// 将二维码Buffer包装成Jimp对象
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)
// 由于fs.createReadStream不能接受Buffer作为参数,只能将生成的图片临时保存到本地
.write(config.tempFilePath, () => {
// resolve()
reject('保存图片失败!')
})
})
}).catch((err) => {
console.log('保存图片出错:', err)
})
因为我们使用的node前后端分离框架 grace 的版本是支持 generator 语法的,所以想到了使用 yield 来将异步操作同步展示,但还是看起来太繁琐了?,必须重构!
promise 登场!
使用 promise 的链式调用语法,结构就会清晰很多,改写后代码是这样的:
// 组合多个异步I/O
const imgResult = yield generateCode(href)
// 生成二维码Buffer
.then((res) => {
codeBuffer = res;
// 包装二维码Buffer为Jimp对象
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 即可,拿该方法为例:
/**
* 包装二维码Buffer为Jimp对象
* @param {Buffer} codeBuffer [二维码Buffer对象]
* @param {Object} config [配置对象]
* @return {Promise}
*/
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上传图片时我们经常能看到请求体是这样的:
------WebKitFormBoundarywQMoN5B2ZNAD6uqN
Content-Disposition: form-data; name="file"; filename="avatar.jpeg"
Content-Type: image/jpeg
------WebKitFormBoundarywQMoN5B2ZNAD6uqN--
请求头的Content-Type是这样的:
Content-Type: multipart/form-data; boundary=----WebKitFormBoundarywQMoN5B2ZNAD6uqN
看起来挺复杂的,尤其是这个------WebKitFormBoundarywQMoN5B2ZNAD6uqN--
到底是个什么鬼?。
别急,先从我的这个上传方法讲起:
/**
* 上传图片方法
* @param {ClientRequest} request [由http.request方法返回的对象]
* @param {Object} config [配置对象]
* @param {String} cookies [用户请求时所带的所有cookie]
* @return
*/
function uploadImg(request, config, cookies = '') {
// 模拟form-data请求后端接口上传图片
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);
}
可以看到,这个方法其实就是构造了请求,拆分下来就如下几件事:
- 构造请求头
- 计算上传内容总大小
- 将文件以流的形式写入http.ClientRequest对象
先说请求头,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合成图片并上传的需求完成!过程中收获非常多!
生命不息折腾不止!