前些日子对接过联盟一个帮派的 Java web 项目,对接人王道友提过好几次,说该项目的用户登录校验经常失灵,明明登录过了,刷新页面后又时常会跳去登录页,一直未找到原因,甚是困扰。
话已至此,一向行侠仗义的我决定趟一趟这浑水。
问题分析
由于不熟悉项目,首先让王道友介绍了项目登录的主流程,得到如下流程图。
随后花了点时间在问题复现上,最终定位到了核心代码,321上代码
let userCheck = {
isRunning: false,
interval: 30,
checkSessionUrl: "https://xxx/user/checkSession",
returnUrl: null,
userGuid: null,
timer: null,
close: function () {
clearTimeout(timer);
this.isRunning = false;
},
open: function () {
if (this.isRunning === true) {
return;
}
this.isRunning = true;
let container = document.getElementById("userCheckContainer");
if (!container) {
container = document.createElement("div");
}
container.id = "userCheckContainer";
container.style.display = "none";
document.body.appendChild(container);
this.returnUrl = this.removeQueries(window.location.href);
// 问题所在
window.addEventListener(
"message",
function (event) {
this.userGuid = event.data.userGuid;
},
false
);
this.refreshFrame();
},
// 获取当前登录用户guid,当前未登录用户则返回空字符串
getUserGuid: function () {
this.close();
this.open();
return this.userGuid;
},
refreshFrame: function () {
let frame = document.createElement("iframe");
frame.id = "userCheckFrame";
frame.src = `${this.checkSessionUrl}?returnUrl=${encodeURIComponent(
this.returnUrl
)}`;
frame.sandbox = "allow-same-origin allow-scripts allow-forms";
let container = document.getElementById("userCheckContainer");
if (container) {
container.innerHTML = "";
container.appendChild(frame);
}
if (this.isRunning) {
this.timer = setTimeout(
() => this.refreshFrame(),
this.interval * 1000
);
}
},
removeQueries: function (url = "") {
let idx = url.indexOf("?");
if (idx < 1) {
return url;
}
return url.substring(0, idx - 1);
},
};
getUserInfo().then((userInfo) => {
const newGuid = userUtils.getUserGuid();
if (userInfo.guid !== newGuid) {
// 跳去登录页
}
});
眼尖的道友估计一眼就看出了端倪,核心问题在于用户一致性校验环节的异步流程错乱。
userCheck 行 28-34 的 message 事件回调是异步给 userGuid 赋值的,而行 39-42 getUserGuid 却以同步的方式返回结果。
行 75 执行getUserGuid时,只有当 message 回调在行 76 执行前返回结果,登录校验流程才能正常,否则校验异常跳去登录页。
妙啊!原来是玄学编程,有缘者登录之。
话不多说,开始设计解决方案吧。
解决方案
方案设计方向是把上述用户一致性校验环节的异步流程理顺。
源代码流程是动态创建 iframe 并监听其 message 回调,并以轮询方式重试,存在的问题有:
- 同步异步时序错乱(核心问题,上文分析过)
- 未考虑 iframe 加载异常场景、未设置 refreshFrame 轮询上限,可能进入死循环
- 未校验 message 消息合法性,可能拿到错误信息
- 未正常处理 getUserId 多次调用场景
现在以异步编程的方式重新设计下流程:
编码实战
基于上述流程上代码
// 执行程序单例
let processPromise: Promise<string> | null = null;
// 副作用池
const sideEffectPools: Function[] = [];
function log(...args: any[]) {
console.log(`[AuthCheck]`, ...args);
}
// 创建iframe容器
function createCheckFrame(url: string, returnUrl = window.location.href) {
const frameId = "userCheckFrame";
let frame = document.createElement("iframe");
const frameAttrs = {
id: frameId,
src: `${url}?returnUrl=${encodeURIComponent(returnUrl)}`,
sandbox: "allow-same-origin allow-scripts allow-forms",
style: "display: none;position: absolute;",
};
Object.entries(frameAttrs).forEach(([key, val]) => {
frame.setAttribute(key, val);
});
// 销毁iframe容器
sideEffectPools.push(() => {
frame.parentNode?.removeChild(frame);
frame = null; // 解除引用
});
return frame;
}
// iframe 加载异常
function frameErrorPromise(frame: HTMLIFrameElement) {
return new Promise((_, reject) => {
frame.onerror = () => {
reject(new Error("iframe加载错误"));
};
});
}
// iframe 消息回调
function frameMessagePromise<T>(
validator?: (evt: MessageEvent, next: Function) => void
) {
return new Promise<T>((resolve) => {
const messageCallback = (event: MessageEvent) => {
if (typeof validator === "function") {
validator(event, resolve);
return;
}
resolve(event.data);
};
window.addEventListener("message", messageCallback, false);
// 清理事件监听
sideEffectPools.push(() => {
window.removeEventListener("message", messageCallback);
});
});
}
// 超时 Promise
function frameTimeoutPromise(ms = 1000, err?: string) {
return new Promise((_, reject) => {
let timer = setTimeout(() => reject(new Error(err)), ms);
// 清理计时器
sideEffectPools.push(() => {
clearTimeout(timer);
timer = null;
});
});
}
// 清理副作用
function cleanSideEffects() {
while (sideEffectPools.length) {
const sideEffect = sideEffectPools.pop();
sideEffect?.();
}
}
// 获取用户基本信息
function _getCurrentUser(url: string, timeout = 5 * 1000) {
cleanSideEffects();
const frame = createCheckFrame(url);
const promise = Promise.race([
frameMessagePromise((event: MessageEvent, next: Function) => {
log("messageCallback:", event.origin, event.data);
const host = new URL(url).origin;
// 只接受同源消息
if (evt.origin === host) {
next(evt.data?.userGuid);
}
}),
frameErrorPromise(frame),
frameTimeoutPromise(timeout, "获取用户信息超时"),
]).finally(cleanSideEffects);
document.body.appendChild(frame);
return promise;
}
// 获取当前登录用户guid
async function getUserGuid(args?: {
retryCount?: number;
checkSessionUrl: string;
timeout?: number;
}) {
let {
retryCount = 1,
checkSessionUrl = "https://xxx/user/checkSession",
timeout,
} = args || {};
if (processPromise) return processPromise;
const execute = async () => {
try {
const res = (await _getCurrentUser(checkSessionUrl, timeout)) as string;
processPromise = null;
return res;
} catch (err) {
log(err?.message);
if (retryCount > 0) {
log(`开始重新获取,剩余重试次数${retryCount}次`);
retryCount--;
return execute();
}
processPromise = null;
throw new Error(`获取用户信息失败`);
}
};
processPromise = execute();
return processPromise;
}
完成代码,接入测试下:
正常场景
超时场景
竞态场景
完美,项目交由王道友测试后,其嘴角微微上扬,内心亦起了一丝丝敬意。
小结
用户登录校验经常失灵,根本原因在于用户一致性校验环节的异步流程错乱。
本文通过梳理原有检验流程,结合promise、async/await 异步函数重构了代码,并考虑到了多重边界场景,最终道友们的登录缘分,终可不必再靠玄学。
不多说了,王道友已经买好咖啡在门口了,我去去就回……