上週產品那邊來了一個需求,須要基於原圖針對不一樣用戶生成不一樣二維碼以及文案,並生成新圖片,讓用戶可以保存。接到這個需求時,內心不只沒有拒絕的意思,反而有點小興奮 ~ 由於又能探索一下新東西。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合成圖片並上傳的需求完成!過程當中收穫很是多!
生命不息折騰不止!