对于使用微信登录的系统,在用户授权后,将其微信头像直接同步到服务器,可以省去用户上传的操作。本文最终给出一个 NodeJs 中间层的实现,并展示实现的过程和在实施过程中几个需要注意的地方。

image.png

BFF 架构


微服务架构已然成为了企业信息化架构中的主流,这种架构风格给前端带来了挑战。为了灵活应对业务需求的变化和适配不同的前端用户体验,BFF 层应运而生。

由于天然的限制或者使用场景的区别,不同的前端用户体验并不一致。拿阿迪达斯的微信小程序和其原生 APP 举例,你会看到用户体验完全不同,有些是因为微信小程序的限制(比如分享体验),有些是不同的产品运营需要。

小程序 APP
image.png image.png


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 上传文件,多数答案如下:
image.png​ javascript 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 请求,手动上传文件,拿到后端的响应结果。
image.png
然后,点击代码,就能选择将刚才手动构造的 Http 请求,转换成可以构造同样请求的代码。我们选择 NodeJs Axios:
image.png

抄作业


从 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 的类中):

typescript 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 users 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,就能得到文件下载流:

typescript 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,下载到上传是可以很方便地流到流接上的。