同步用户微信头像的 NodeJs 实现
对于使用微信登录的系统,在用户授权后,将其微信头像直接同步到服务器,可以省去用户上传的操作。本文最终给出一个 NodeJs 中间层的实现,并展示实现的过程和在实施过程中几个需要注意的地方。
BFF 架构
微服务架构已然成为了企业信息化架构中的主流,这种架构风格给前端带来了挑战。为了灵活应对业务需求的变化和适配不同的前端用户体验,BFF 层应运而生。
由于天然的限制或者使用场景的区别,不同的前端用户体验并不一致。拿阿迪达斯的微信小程序和其原生 APP 举例,你会看到用户体验完全不同,有些是因为微信小程序的限制(比如分享体验),有些是不同的产品运营需要。
小程序 | APP |
BFF 是 Backend for Frontend 的简称,它用来对众多后端微服务进行聚合和裁剪,以适配前端。如今,端用户体验层 -> 网关层 -> BFF 层 -> 微服务层这种分层模式已经成为了典型的现代微服务架构分层方式。
NodeJs
NodeJs 的出现使得 JavaScript 可以运行在服务器上,并且天然适合网络 IO 密集型的场景,以及不适合计算密集型场景。这使得它作为 BFF 层非常适合,因为 BFF 层通常只是联结前端与后端,做一些透传,没有密集的计算,但是重网络传输。
分析微信头像的存储方案
直接存储微信头像的 url
比如,我目前的微信头像 url 是 https://thirdwx.qlogo.cn/mmopen/vi_32/rgPgbf5XE2ancz9ibobSibZEMPOibp4LdsQEXiaQeRZ78WJgVe7xgMamYXd6eibo9rg0Wje1rnh9aLMc87DVS4vrItA/132。显然后端可以很简单的接收一个字符串,将其存储起来,这样前端下次拿到这个 url,就可以展示出来。
但是这样做有个问题,以上链接是微信的 CDN 地址。一旦用户在微信端更新了头像,那么上面的地址将不再被使用。如果某一天它被清除了,那么系统的前端展示用户头像时将是一个死链接的图片。所以方案得改成:
将微信头像 url 下载下来以图片文件格式存储
这样就需要后端实现一个文件上传的接口,然后由 BFF 层把前端传过来的 url 转成表单数据传输给后端。所以最终后端不是存储一个字符串,而是存储图片文件。
这样就没有用户更改微信头像后,系统中的头像失效的问题。至于微信头像更新后,系统中还是老的图片的不同步问题,第一种方案也不能解决。实际上这种情况下只需要再次同步即可,至于如何自动同步,不在本文讨论范围内。
结论
只需要在 BFF 层使用 NodeJs 将微信头像的 url 下载下来,再调用后端的文件上传接口即可。
代码实现
需求分析明确后,只差写代码了。经常有人问,高手写代码是不是不用百度,直接啪啪啪就能写出来?实际上,不需要搜索就能写代码的,那说明是熟练工,同样的事情干过很多回了。对于高手,也可能接到不熟悉的任务,这时他可能不用百度,而是用 Google 和 StackOverflow。
Axios
既然要使用 NodeJs 上传文件到后端,那么就需要给后端发起一个 Http 请求。通过简单搜索就能知道在 NodeJs 的世界里,Axios 是一个不错的 Http 客户端,因此再进一步搜索如何使用 Axios 发起一个文件上传的 Http 请求。
坑
搜索工具是程序员经常要使用的,虽然说如今搜索方便,但是要甄别结果的可靠性并没那么容易。被一些答案带到坑里是常有的事情。比如搜索使用 NodeJs 上传文件,多数答案如下:
var formData = new FormData();
formData.append("image", yourFile);
axios.post('upload_file', formData, {
headers: {
'Content-Type': 'multipart/form-data'
}
})
注意上面的代码显式指定了 Content-Type 这个请求头,然后实际试过后你就知道这并不工作!
Postman
Postman 是一个强大的 Http 请求监控工具,可以按需定制请求体。BFF 层要同步微信头像,无非就是要调用后端接口,发送一个 Http 请求,将用户头像存储起来。因此真正的高手对这个需求是真的不会去搜索的,而是直接使用 Postman 构造一个 Http 请求,手动上传文件,拿到后端的响应结果。
然后,点击代码,就能选择将刚才手动构造的 Http 请求,转换成可以构造同样请求的代码。我们选择 NodeJs Axios:
抄作业
从 Postman 生成的代码可以看出,第一 NodeJs 的世界里,没有原生的表单数据结构,需要引入 form-data 包;第二在请求头里不能直接写死 Content-Type = 'multipart/form-data',而是要用 form-data 生成的请求头。
题外话
如果是前端直接文件上传,那么在 Browser 的 JavaScript 世界里,是自带 FormData 数据结构的,这时候要显式不指定 Content-Type,以实现自动生成 Content-Type 请求头。对于文件上传不能显示指定 Content-Type 的原因是,构造 Http 请求时,payload 中要使用 Content-Type 请求头中的 boundary 来分割文件和其他非文件字段,而这个 boundary 需要动态生成。如果不显示指定 Content-Type,就能享受浏览器端 FormData 或者 NodeJs 端的 form-data 自动生成的 Content-Type 以及 boundary。
TDD
在写实现代码前,建议先将自动化测试代码写上,以便构建重构屏障。详细步骤参考 TDD 相关的文章。
jest/nock/TypeScript
在实际的 NodeJs 工程项目中,还是建议引入 TypeScript,以享受类型系统带来的好处。这里使用 jest 测试框架。为了控制后端的 Http 响应,可以使用 nock 将之前的 Postman 抓到的后端服务器响应作为 mock。
后端服务器的 API 可能做了 token 验证,只信任指定的客户端(BFF 层)发来的请求,因此还需要做好相关 Token 端点的 nock,最终测试代码如下(假定要将实现写在一个叫 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)
})
})
})
})
流到流
前面分析了,实现代码只需要将微信的 url 对应的图片下载下来,再上传到后端服务器即可,但是为了提高效率,可以不用等待先全部下载完毕再进行上传,而是将下载流直接对接到上传流上。这只需要对 Postman 生成的代码稍加改造。仔细观察 Postman 生成的代码,由于我们是从本地文件系统选择的文件构造出的请求,因此生成的代码创建了一个本地文件读取流,我们需要把这个本地文件读取流改造成远程文件下载流。
下载文件其实也就是向微信服务器(CDN)端构造一个 Http GET 请求,仍然采用 Axios,那么只需要设置 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
}
// 大致逻辑,实际上从统一的令牌管理类中拿可用的 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(),
}
})
}
}
总结
在实际的 BFF 开发中,可以使用 Postman 手动调用后端服务,然后生成实际的代码,这节省了搜索的工作,而且保证代码可靠。
对于微信头像的同步,一定不能只保存微信的 CDN url,而要下载后保存图片。通过使用 NodeJs Axios,下载到上传是可以很方便地流到流接上的。