目录
分享
说实话,我也不是很理解这个熔断机制但是不影响大佬的这篇文章的优秀,虽然这个也是经常听说,但是没用过,所以也不是很了解。有其他见解的同学也可以在评论区留言或者私信我。
【作者】蔡小真
【链接】https://juejin.cn/post/6933082931653148680
1. 背景
微信官方提供了两种标识:
OpenId
是一个用户对于一个/的标识,开发者可以通过这个标识识别出用户。UnionId
是一个用户对于同主体微信//APP 的标识,开发者需要在微信开放平台下绑定相同账号的主体。开发者可通过UnionId
,实现多个、、甚至 APP 之间的数据互通。
开发者在首页直接调用
wx.getUserInfo
进行授权,弹框获取用户信息,会使得一部分用户“拒绝”按钮。在开发者没有处理用户拒绝弹框的情况下,用户必须授权头像昵称等信息才能继续使用,会导致某些用户放弃使用该。
用户没有很好的方式重新授权,尽管微信官方增加了设置页面,可以让用户选择重新授权,但很多用户并不知道可以这么操作。
微信官方也意识到了这个问题,针对获取用户信息更新了三个能力:
使用组件来获取用户信息。
若用户满足一定条件,则可以用
wx.login
获取到的 code 直接换到unionId
。wx.getUserInfo
不需要依赖wx.login
就能调用得到数据。
本文主要讲述的是第二点能力,微信官方鼓励开发者在不骚扰用户的情况下合理获得unionid
,而仅在必要时才向用户弹窗申请使用昵称头像,从而衍生出「静默登录」和「用户登录」两种概念。
2. 什么是静默登录?
很多开发者会把 wx.login
和 wx.getUserInfo
捆绑调用当成登录使用,其实 wx.login
已经可以完成登录,wx.getUserInfo
只是获取额外的用户信息。
在 wx.login
获取到 code
后,会发送到开发者后端,开发者后端通过接口去微信后端换取到 openid
和 sessionKey
(现在会将 unionid
也一并返回)后,把自定义登录态 3rd_session
(本业务命名为auth-token
) 返回给前端,就已经完成登录行为了。wx.login
行为是静默,不必授权的,用户不会察觉。
2.1 静默登录流程时序
官方给出了 wx.login
的最佳实践如下:
静默登录英文简称为silentLogin
,代码如下所示:
private async silentLogin(): Promise<void> {
try {
this.status.silentLogin.ing();
// 获取临时登录凭证code
const code = await getWxLoginCode();
// 将code发送给服务端
const res = await API.login(code);
// 保存登录信息,如auth-token
storage.setSync(constant.STORAGE_SESSION_KEY, res.data);
this.status.silentLogin.success();
} catch (error) {
logger.error('静默登录失败', error);
this.status.silentLogin.fail(error);
throw error;
}
}
总结为以下三步:
端调用
wx.login()
获取 临时登录凭证code
,并回传到开发者服务器。服务器端调用
auth.code2Session
接口,换取 用户唯一标识OpenID
和 会话密钥session_key
。开发者服务器可以根据用户标识来生成自定义登录态(例如:
auth-token
),用于后续业务逻辑中前后端交互时识别用户身份。
2.2 开发者后台校验与解密开放数据
静默登录成功后,微信服务器端会下发一个session_key
给服务端,而这个会在需要获取微信开放数据的时候会用到。
为了确保开放接口返回用户数据的安全性,微信会对明文数据进行签名。开发者可以根据业务需要对数据包进行签名校验,确保数据的完整性。
通过调用接口(如
wx.getUserInfo
)获取数据时,如果用户已经授权,接口会同时返回以下几个字段。如用户未授权,会先弹出用户弹窗,用户同意授权,接口会同时返回以下几个字段。相反如果用户拒绝授权,将调用失败。
属性 | 类型 | 说明 |
---|---|---|
userInfo |
UserInfo | 用户信息对象,不包含 openid 等敏感信息 |
rawData |
string | 不包括敏感信息的原始数据字符串,用于计算签名 |
signature |
string | 使用 sha1( rawData + sessionkey ) 得到字符串,用于校验用户信息 |
encryptedData |
string | 包括敏感数据在内的完整用户信息的加密数据 |
iv |
string | 加密算法的初始向量 |
cloudID |
string | 敏感数据对应的云 ID,开通云开发的才会返回,可通过云调用直接获取开放数据 |
开发者将
signature
、rawData
发送到开发者服务器进行校验。服务器利用用户对应的session_key
使用相同的算法计算出签名signature2
,比对signature
与signature2
即可校验数据的完整性。开发者服务器告诉前端开发者数据可信,即可安全使用用户信息数据。如果开发者想要获取敏感数据(如 openid,unionID),则将
encryptedData
和iv
发送到开发者服务器,由服务器使用session_key
(对称解密密钥)进行对称解密,获取敏感数据进行存储并返回给前端开发者。
2021 年 2 月 23 日起,通过
wx.login
接口获取的登录凭证可直接换取unionID
。2021 年 4 月 13 日后发布新版本的,无法通过
wx.getUserInfo
接口获取用户个人信息(头像、昵称、性别与地区),将直接获取匿名数据。getUserInfo
接口获取加密后的openID
与unionID
数据的能力不做调整。新增
getUserProfile
接口(基础库 2.10.4 版本开始支持),可获取用户头像、昵称、性别及地区信息,开发者每次通过该接口获取用户个人信息均需用户确认。
即开发者通过组件调用wx.getUserInfo
将不再弹出弹窗,直接返回匿名的用户个人信息。如果要获取用户头像、昵称、性别及地区信息,需要改造成wx.getUserProfile
接口。
2.3 session_key 的有效期
wx.login
调用时,用户的session_key
可能会被更新而致使旧session_key
失效(刷新机制存在最短周期,如果同一个用户短时间内多次调用wx.login
,并非每次调用都导致session_key
刷新)。开发者应该在明确需要重新登录时才调用wx.login
,及时通过auth.code2Session
接口更新服务器存储的session_key
。微信不会把
session_key
的有效期告知开发者。我们会根据用户使用的行为对session_key
进行续期。用户越频繁使用,session_key
有效期越长。开发者在
session_key
失效时,可以通过重新执行登录流程获取有效的session_key
。使用接口wx.checkSession
可以校验session_key
是否有效,从而避免反复执行登录流程。当开发者在实现自定义登录态时,可以考虑以
session_key
有效期作为自身登录态有效期,也可以实现自定义的时效性策略。
3 「登录」架构
用户登录架构
3.1 libs
- 提供登录相关的类方法供「业务层」调用
封装
session
类,提供类方法供「业务层」调用。主要有以下几种方法:
方法名 | 功能 | 使用场景 |
---|---|---|
silentLogin |
发起静默登录 | - |
login |
登录,silentLogin 方法的一层封装 |
用于启动时发起静默登录 |
refreshLogin |
刷新登录态,silentLogin 方法的一层封装 |
用于登录态过期时发起静默登录 |
ensureSessionKey |
验证 sessionKey 是否过期,过期则刷新登录态 |
绑定微信授权手机号时验证是否过期,过期则得重新弹窗授权 |
装饰器:
fuse-line
:熔断机制,如果短时间内多次调用,则停止响应一段时间,类似于 TCP 慢启动。用于解决refreshLogin
、login
等方法的并发处理问题。single-queue
:单队列模式,同一时间,只允许一个正在过程中的网络请求。请求被锁定之后,同样的请求都会被推入队列,等待进行中的请求返回后,消费同一个结果。用于解决refreshLogin
、login
等方法的并发处理问题。
4. 静默登录的调用时机
public async login(): Promise<void> {
// 调用wx.checkSession判断session_key是否过期
const hasSession = await checkSession();
// 本地已有可用登录态且session_key未过期,resolve。
if (this.getAuthToken() && hasSession) return Promise.resolve();
// 否则,发起静默登录
await this.silentLogin();
}
4.2 接口请求发起时调用
保险起见,如果某些接口需要携带自定义登录态进行鉴权,则需要在请求发起时进行拦截,校验登录态,并刷新登录。刷新登录代码如下所示:
public async refreshLogin(): Promise<void> {
try {
// 清除 Session
this.clearSession();
// 发起静默登录
await this.silentLogin();
} catch (error) {
throw error;
}
}
整个流程如下图所示:
拦截 request:
判断是否需要鉴权:请求发起时,拦截请求,判断请求是否需要添加
auth-token
,如若不需要,直接发起请求。如若需要,执行第二步。判断是否需要发起静默登录:判断
storage
中是否存在auth-token
,如若不存在,发起「刷新登录」。请求头部添加
auth-token
:添加auth-token
,发起请求。
与服务端通信:发起请求,服务端处理请求返回结果。
拦截 response: 解析状态码
状态码为
AUTH_FAIL
:服务端返回code
为“鉴权失败”,触发这种情景的原因有两个,一是接口需要鉴权,但是发起请求时未携带auth-token
,二是auth-token
过期。这时将上一次请求携带的auth-token
与本地存储的auth-token
比较,如果不一致,表示登录态已经刷新过了,那么就直接重新发起请求。如果一致,发起刷新登录,拿到新的auth-token
后重新发起请求,这个动作对用户来说是无感知的。状态码为
USER_WX_SESSIONKEY_EXPIRE
:服务器返回code
为“用户登录态过期”,这是针对用户授权手机号登录失败定制的状态码,如果登录态已过期,表示存储在服务端的session_key
也是过期的,那么授权手机号获取的加密数据发送到服务端进行对称解密,由于session_key
失效,无法解密出真正的手机号。因此需要重新发起静默登录,等待用户重新授权按钮获取新的加密数据,然后发起新的解密请求状态码为其它:比如
Success
或者其他业务请求错误的情况,不进行拦截,返回 response 让业务代码解析。
4.3 wx.checkSession 罢工之谜
基于上述接口请求发起时调用的流程,很多人会有疑问,既然服务端会返回auth-token
过期的状态码,为啥不在请求发送前进行拦截,使用wx.checkSession
接口校验登录态是否过期(如下图所示,增加红框内的步骤)?
这是因为,我们通过实验发现,在 session_key
已过期的情况下,wx.checkSession
有一定的几率返回true
。即增加wx.checkSession
步骤并不能百分百保证登录态不会过期,后续仍然需要对不同的状态码进行处理。
社区也有相关的反馈未得到解决:
解密手机号,隔一小段时间后,checksession:ok,但是解密失败
wx.checkSession 有效,但是解密数据失败
checkSession 判断 session_key 未失效,但是解密手机号失败
所以结论是:wx.checkSession
可靠性是不达 100% 的。
基于以上,我们需要对 session_key
的过期做一些容错处理:
发起需要使用
session_key
的请求前,做一次wx.checkSession
操作,如果失败了刷新登录态。后端使用
session_key
解密开放数据失败之后,返回特定错误码(如:USER_WX_SESSIONKEY_EXPIRE
),前端刷新登录态。
4.4 并发处理
基于此,我们设计了如下方案:
单队列模式:
请求锁:同一时间,只允许一个正在过程中的网络请求。
等待队列:请求被锁定之后,同样的请求都会被推入队列,等待进行中的请求返回后,消费同一个结果。
熔断机制:如果短时间内多次调用,则停止响应一段时间,类似于 TCP 慢启动。
如上图所示,首先refreshLogin
请求入队,队列中只有一个请求,发送该请求,同时保险丝计入次数 1,服务端返回请求结果,消费结果。接着又发起一个refreshLogin
请求,队列中只有一个请求,发送该请求,同时保险丝计入次数 2。然后又连续发起三个请求,由于上一个请求还没有执行完成,将这三个请求入队,等待上一个请求结果返回,队列中的四个请求消费同一个结果。由于触发自动冷却阈值,保险丝重置。
@singleQueue({ name: 'refreshLogin' })
@fuseLine({ name: 'refreshLogin' })
public async refreshLogin(): Promise<void> {
try {
// 清除 Session
this.clearSession();
await this.silentLogin();
} catch (error) {
throw error;
}
}
到此,很多读者可能对熔断机制还不甚理解,熔断的目的是为一个函数提供保险丝保障,短时间内多次调用,会熔断一段时间,这段时间内拒绝所有请求。如果在自动冷却阈值内,没有请求通过,则重置保险丝。代码如下所示:
export default function fuseLine({
// 一次熔断前重试次数
tryTimes = 3,
// 重试间隔,单位 ms
restoreTime = 5000,
// 自动冷却阈值,单位 ms
coolDownThreshold = 1000,
// 名称
name = 'unnamed',
}: {
tryTimes?: number;
restoreTime?: number;
name?: string;
coolDownThreshold?: number;
} = {}) {
// 请求锁
let fuseLocked = false;
// 当前重试次数
let fuseTryTimes = tryTimes;
// 自动冷却
let coolDownTimer;
// 重置保险丝
const reset = () => {
fuseLocked = false;
fuseTryTimes = tryTimes;
logger.info(`${name}-保险丝重置`);
};
const request = async () => {
if (fuseLocked) throw new Error(`${name}-保险丝已熔断,请稍后重试`);
// 已达最大重试次数
if (fuseTryTimes <= 0) {
fuseLocked = true;
// 重置保险丝
setTimeout(() => reset(), restoreTime);
throw new Error(`${name}-保险丝熔断!!`);
}
// 自动冷却系统
if (coolDownTimer) clearTimeout(coolDownTimer);
coolDownTimer = setTimeout(() => reset(), coolDownThreshold);
// 允许当前请求通过保险丝,记录 +1
fuseTryTimes = fuseTryTimes - 1;
logger.info(`${name}-通过保险丝(${tryTimes - fuseTryTimes}/${tryTimes})`);
return Promise.resolve();
};
return function(
_target: Record<string, any>,
_propertyName: string,
descriptor: TypedPropertyDescriptor<(...args: any[]) => any>,
) {
const method = descriptor.value;
descriptor.value = async function(...args: any[]) {
await request();
if (method) return method.apply(this, args);
};
};
}