同步用戶微信頭像的 NodeJs 實(shí)現(xiàn)
對(duì)于使用微信登錄的系統(tǒng),在用戶授權(quán)后,將其微信頭像直接同步到服務(wù)器,可以省去用戶上傳的操作。本文最終給出一個(gè) NodeJs 中間層的實(shí)現(xiàn),并展示實(shí)現(xiàn)的過程和在實(shí)施過程中幾個(gè)需要注意的地方。

BFF 架構(gòu)
微服務(wù)架構(gòu)已然成為了企業(yè)信息化架構(gòu)中的主流,這種架構(gòu)風(fēng)格給前端帶來了挑戰(zhàn)。為了靈活應(yīng)對(duì)業(yè)務(wù)需求的變化和適配不同的前端用戶體驗(yàn),BFF 層應(yīng)運(yùn)而生。
由于天然的限制或者使用場(chǎng)景的區(qū)別,不同的前端用戶體驗(yàn)并不一致。拿阿迪達(dá)斯的微信小程序和其原生 APP 舉例,你會(huì)看到用戶體驗(yàn)完全不同,有些是因?yàn)槲⑿判〕绦虻南拗疲ū热绶窒眢w驗(yàn)),有些是不同的產(chǎn)品運(yùn)營需要。
小程序 | APP |
|
|
BFF 是 Backend for Frontend 的簡稱,它用來對(duì)眾多后端微服務(wù)進(jìn)行聚合和裁剪,以適配前端。如今,端用戶體驗(yàn)層 -> 網(wǎng)關(guān)層 -> BFF 層 -> 微服務(wù)層這種分層模式已經(jīng)成為了典型的現(xiàn)代微服務(wù)架構(gòu)分層方式。
NodeJs
NodeJs 的出現(xiàn)使得 JavaScript 可以運(yùn)行在服務(wù)器上,并且天然適合網(wǎng)絡(luò) IO 密集型的場(chǎng)景,以及不適合計(jì)算密集型場(chǎng)景。這使得它作為 BFF 層非常適合,因?yàn)?BFF 層通常只是聯(lián)結(jié)前端與后端,做一些透?jìng)鳎瑳]有密集的計(jì)算,但是重網(wǎng)絡(luò)傳輸。
分析微信頭像的存儲(chǔ)方案
直接存儲(chǔ)微信頭像的 url
比如,我目前的微信頭像 url 是 https://thirdwx.qlogo.cn/mmopen/vi_32/rgPgbf5XE2ancz9ibobSibZEMPOibp4LdsQEXiaQeRZ78WJgVe7xgMamYXd6eibo9rg0Wje1rnh9aLMc87DVS4vrItA/132。顯然后端可以很簡單的接收一個(gè)字符串,將其存儲(chǔ)起來,這樣前端下次拿到這個(gè) url,就可以展示出來。
但是這樣做有個(gè)問題,以上鏈接是微信的 CDN 地址。一旦用戶在微信端更新了頭像,那么上面的地址將不再被使用。如果某一天它被清除了,那么系統(tǒng)的前端展示用戶頭像時(shí)將是一個(gè)死鏈接的圖片。所以方案得改成:
將微信頭像 url 下載下來以圖片文件格式存儲(chǔ)
這樣就需要后端實(shí)現(xiàn)一個(gè)文件上傳的接口,然后由 BFF 層把前端傳過來的 url 轉(zhuǎn)成表單數(shù)據(jù)傳輸給后端。所以最終后端不是存儲(chǔ)一個(gè)字符串,而是存儲(chǔ)圖片文件。
這樣就沒有用戶更改微信頭像后,系統(tǒng)中的頭像失效的問題。至于微信頭像更新后,系統(tǒng)中還是老的圖片的不同步問題,第一種方案也不能解決。實(shí)際上這種情況下只需要再次同步即可,至于如何自動(dòng)同步,不在本文討論范圍內(nèi)。
結(jié)論
只需要在 BFF 層使用 NodeJs 將微信頭像的 url 下載下來,再調(diào)用后端的文件上傳接口即可。
代碼實(shí)現(xiàn)
需求分析明確后,只差寫代碼了。經(jīng)常有人問,高手寫代碼是不是不用百度,直接啪啪啪就能寫出來?實(shí)際上,不需要搜索就能寫代碼的,那說明是熟練工,同樣的事情干過很多回了。對(duì)于高手,也可能接到不熟悉的任務(wù),這時(shí)他可能不用百度,而是用 Google 和 StackOverflow。
Axios
既然要使用 NodeJs 上傳文件到后端,那么就需要給后端發(fā)起一個(gè) Http 請(qǐng)求。通過簡單搜索就能知道在 NodeJs 的世界里,Axios 是一個(gè)不錯(cuò)的 Http 客戶端,因此再進(jìn)一步搜索如何使用 Axios 發(fā)起一個(gè)文件上傳的 Http 請(qǐng)求。
坑
搜索工具是程序員經(jīng)常要使用的,雖然說如今搜索方便,但是要甄別結(jié)果的可靠性并沒那么容易。被一些答案帶到坑里是常有的事情。比如搜索使用 NodeJs 上傳文件,多數(shù)答案如下:

var formData = new FormData();
formData.append("image", yourFile);
axios.post('upload_file', formData, {
headers: {
'Content-Type': 'multipart/form-data'
}
})
注意上面的代碼顯式指定了 Content-Type 這個(gè)請(qǐng)求頭,然后實(shí)際試過后你就知道這并不工作!
Postman
Postman 是一個(gè)強(qiáng)大的 Http 請(qǐng)求監(jiān)控工具,可以按需定制請(qǐng)求體。BFF 層要同步微信頭像,無非就是要調(diào)用后端接口,發(fā)送一個(gè) Http 請(qǐng)求,將用戶頭像存儲(chǔ)起來。因此真正的高手對(duì)這個(gè)需求是真的不會(huì)去搜索的,而是直接使用 Postman 構(gòu)造一個(gè) Http 請(qǐng)求,手動(dòng)上傳文件,拿到后端的響應(yīng)結(jié)果。

然后,點(diǎn)擊代碼,就能選擇將剛才手動(dòng)構(gòu)造的 Http 請(qǐng)求,轉(zhuǎn)換成可以構(gòu)造同樣請(qǐng)求的代碼。我們選擇 NodeJs Axios:

抄作業(yè)
從 Postman 生成的代碼可以看出,第一 NodeJs 的世界里,沒有原生的表單數(shù)據(jù)結(jié)構(gòu),需要引入 form-data 包;第二在請(qǐng)求頭里不能直接寫死 Content-Type = 'multipart/form-data',而是要用 form-data 生成的請(qǐng)求頭。
題外話
如果是前端直接文件上傳,那么在 Browser 的 JavaScript 世界里,是自帶 FormData 數(shù)據(jù)結(jié)構(gòu)的,這時(shí)候要顯式不指定 Content-Type,以實(shí)現(xiàn)自動(dòng)生成 Content-Type 請(qǐng)求頭。對(duì)于文件上傳不能顯示指定 Content-Type 的原因是,構(gòu)造 Http 請(qǐng)求時(shí),payload 中要使用 Content-Type 請(qǐng)求頭中的 boundary 來分割文件和其他非文件字段,而這個(gè) boundary 需要?jiǎng)討B(tài)生成。如果不顯示指定 Content-Type,就能享受瀏覽器端 FormData 或者 NodeJs 端的 form-data 自動(dòng)生成的 Content-Type 以及 boundary。
TDD
在寫實(shí)現(xiàn)代碼前,建議先將自動(dòng)化測(cè)試代碼寫上,以便構(gòu)建重構(gòu)屏障。詳細(xì)步驟參考 TDD 相關(guān)的文章。
jest/nock/TypeScript
在實(shí)際的 NodeJs 工程項(xiàng)目中,還是建議引入 TypeScript,以享受類型系統(tǒng)帶來的好處。這里使用 jest 測(cè)試框架。為了控制后端的 Http 響應(yīng),可以使用 nock 將之前的 Postman 抓到的后端服務(wù)器響應(yīng)作為 mock。
后端服務(wù)器的 API 可能做了 token 驗(yàn)證,只信任指定的客戶端(BFF 層)發(fā)來的請(qǐng)求,因此還需要做好相關(guān) Token 端點(diǎn)的 nock,最終測(cè)試代碼如下(假定要將實(shí)現(xiàn)寫在一個(gè)叫 MemberService 的類中):
import { MemberService } from './member.service'
import * as nock from 'nock'
describe('MemberService', () => {
beforeEach(async () => {
const mockConfig = {
backend: {
url: 'https://your.back.end',
auth: {
url: 'https://your.back.end/auth/token',
clientId: 'fakeId',
clientSecret: 'fakeSecret',
clientKey: 'fakeKey'
}
}
}
describe('update user\'s head image', () => {
it('pipe weixin head img to back end', async () => {
const mockRes = {
code: 200,
message: '操作成功',
success: true,
data: 'https://upload.image.url',
time: '2021-06-29 11:20:30'
}
nock(mockConfig.backend.url).post('/auth/token').reply(200, {status: 'SUCCESS', data: {access_token: 'xxx', expires_in: 3600, refresh_token: 'yyy'}})
nock(mockConfig.backend.url).put('/upload/image/head/abcdefg').reply(200, mockRes)
const sut = new MemberService(nockConfig)
const res = await sut.updateAvatar('abcdefg', 'https://thirdwx.qlogo.cn/mmopen/vi_32/rgPgbf5XE2ancz9ibobSibZEMPOibp4LdsQEXiaQeRZ78WJgVe7xgMamYXd6eibo9rg0Wje1rnh9aLMc87DVS4vrItA/132')
expect(res).toStrictEqual(mockRes)
})
})
})
})
流到流
前面分析了,實(shí)現(xiàn)代碼只需要將微信的 url 對(duì)應(yīng)的圖片下載下來,再上傳到后端服務(wù)器即可,但是為了提高效率,可以不用等待先全部下載完畢再進(jìn)行上傳,而是將下載流直接對(duì)接到上傳流上。這只需要對(duì) Postman 生成的代碼稍加改造。仔細(xì)觀察 Postman 生成的代碼,由于我們是從本地文件系統(tǒng)選擇的文件構(gòu)造出的請(qǐng)求,因此生成的代碼創(chuàng)建了一個(gè)本地文件讀取流,我們需要把這個(gè)本地文件讀取流改造成遠(yuǎn)程文件下載流。
下載文件其實(shí)也就是向微信服務(wù)器(CDN)端構(gòu)造一個(gè) Http GET 請(qǐng)求,仍然采用 Axios,那么只需要設(shè)置 responseType 為 stream,就能得到文件下載流:
import axios from 'axios'
import * as FormData from 'form-data'
export class MemberService {
constructor(private readonly config: Config) {}
async updateAvatar(userId: string, avatar: string | undefined) {
if (!avatar) {
return undefined
}
// 大致邏輯,實(shí)際上從統(tǒng)一的令牌管理類中拿可用的 token
const {data: {access_token}} = await axios.post(this.config.backend.auth.url, {clientId, clientSecret, ...})
const formData = new FormData()
formData.append('headImg', (await axios.get(avatar, { responseType: 'stream' })).data, 'headImage.jpg')
return axios.put(`${this.config.backend.url}/upload/image/head/${userId}`, {
data: formData,
headers: {
'Authorization': `Bearer ${access_token}`,
...formData.getHeaders(),
}
})
}
}
總結(jié)
在實(shí)際的 BFF 開發(fā)中,可以使用 Postman 手動(dòng)調(diào)用后端服務(wù),然后生成實(shí)際的代碼,這節(jié)省了搜索的工作,而且保證代碼可靠。
對(duì)于微信頭像的同步,一定不能只保存微信的 CDN url,而要下載后保存圖片。通過使用 NodeJs Axios,下載到上傳是可以很方便地流到流接上的。


