上周产品那边来了一个需求,须要基于原图针对不一样用户生成不一样二维码以及文案,并生成新图片,让用户可以保存。接到这个需求时,内心不只没有拒绝的意思,反而有点小兴奋 ~ 由于又能探索一下新东西。html
大体效果以下,原图:前端
效果图:node
刚开始打算在前端用canvas生成图片。咱们都知道canvas有合成图片的功能,核心是drawImage
及toDataURL
这两个方法。git
大体思路是:github
drawImage
将生成的二维码合并到原图的指定位置fillText
方法生成文案toDataURL
将图片转成base64不过最终该方案没有走通,由于不一样手机尺寸比例不统一,生成的二维码的位置没法准确地定位到指定位置,所以采用了另外一种方案:node层生成图片。canvas
在node层就无需考虑适配的问题了,由于只有一个基准,也就是原图。生成二维码及文案的尺寸、位置均可以直接写死。通过调研,node图像处理库最出名的有两个,分别是:Jimp 和 Sharp,最终选用Jimp,由于Sharp没安装上?。二维码库却是不少,最终决定选用 node-qrcode。后端
开搞!api
主要步骤就两步,以下:promise
下面分解这两步讲解浏览器
生成图片是最麻烦的。步骤比较多:
大部分都是调用Jimp及qrcode的api,还有一些node的原生api,如使用Buffer.from
将base64转为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); }
能够看到,这个方法其实就是构造了请求,拆分下来就以下几件事:
先说请求头,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合成图片并上传的需求完成!过程当中收获很是多!
生命不息折腾不止!