我們一起聊聊前端接口容災(zāi)
開(kāi)篇
你說(shuō),萬(wàn)一接口掛了會(huì)怎么樣?
還能咋樣,白屏唄。
有沒(méi)有不白屏的方案?
有啊,還挺簡(jiǎn)單的。
容我細(xì)細(xì)細(xì)細(xì)分析。
原因就是接口掛了,拿不到數(shù)據(jù)了。那把數(shù)據(jù)儲(chǔ)存起來(lái)就可以解決問(wèn)題。
思考
存哪里?
第一時(shí)間反應(yīng)瀏覽器本地存儲(chǔ),想起了四兄弟。
選型對(duì)比
特性 | cookie | localStorage | sessionStorage | indexDB |
數(shù)據(jù)生命周期 | 服務(wù)器或者客戶(hù)端都可以設(shè)置、有過(guò)期時(shí)間 | 一直存在 | 關(guān)閉頁(yè)面就清空 | 一直存在 |
數(shù)據(jù)儲(chǔ)存大小 | 4KB | 5MB | 5MB | 動(dòng)態(tài),很大 大于250MB |
與服務(wù)器通信 | 每次都帶在header中 | 不帶 | 不帶 | 不帶 |
兼容性 | 都支持 | 都支持 | 都支持 | IE不支持,其他主流都支持 |
考慮到需要存儲(chǔ)的數(shù)據(jù)量,5MB 一定不夠的,所以選擇了 IndexDB。
考慮新用戶(hù)或者長(zhǎng)時(shí)間未訪問(wèn)老用戶(hù),會(huì)取不到緩存數(shù)據(jù)與陳舊的數(shù)據(jù)。
因此準(zhǔn)備上云,用阿里云存儲(chǔ),用 CDN 來(lái)保障。
總結(jié)下:線上 CDN、線下 IndexDB。
整體方案
整體流程圖
圖片
CDN
先講講線上 CDN。
通常情況下可以讓后端支撐,本質(zhì)就是更新策略問(wèn)題,這里不細(xì)說(shuō)。
我們講講另外一種方案,單獨(dú)啟個(gè) Node 服務(wù)更新 CDN 數(shù)據(jù)。
流程圖
圖片
劫持邏輯
劫持所有接口,判斷接口狀態(tài)與緩存標(biāo)識(shí)。從而進(jìn)行更新數(shù)據(jù)、獲取數(shù)據(jù)、緩存策略三種操作
通過(guò)配置白名單來(lái)控制接口存與取
axios.interceptors.response.use(
async (resp) => {
const { config } = resp
const { url } = config
// 是否有緩存tag,用于更新CDN數(shù)據(jù)。目前是定時(shí)服務(wù)在跑,訪問(wèn)頁(yè)面帶上tag
if (this.hasCdnTag() && this.isWhiteApi(url)) {
this.updateCDN(config, resp)
}
return resp;
},
async (err) => {
const { config } = err
const { url } = config
// 是否命中緩存策略
if (this.isWhiteApi(url) && this.useCache()) {
return this.fetchCDN(config).then(res => {
pushLog(`cdn緩存數(shù)據(jù)已命中,請(qǐng)?zhí)幚韅, SentryTypeEnum.error)
return res
}).catch(()=>{
pushLog(`cdn緩存數(shù)據(jù)未同步,請(qǐng)?zhí)幚韅, SentryTypeEnum.error)
})
}
}
);
緩存策略
累計(jì)接口異常發(fā)生 maxCount 次,打開(kāi)緩存開(kāi)關(guān),expiresSeconds 秒后關(guān)閉。
緩存開(kāi)關(guān)用避免網(wǎng)絡(luò)波動(dòng)導(dǎo)致命中緩存,設(shè)置了閥值。
/*
* 緩存策略
*/
useCache = () => {
if (this.expiresStamp > +new Date()) {
const d = new Date(this.expiresStamp)
console.warn(`
---------------------------------------
---------------------------------------
啟用緩存中
關(guān)閉時(shí)間:${d.getHours()}:${d.getMinutes()}:${d.getSeconds()}
---------------------------------------
---------------------------------------
`)
return true
}
this.errorCount += 1
localStorage.setItem(CACHE_ERROR_COUNT_KEY, `${this.errorCount}`)
if (this.errorCount > this.maxCount) {
this.expiresStamp = +new Date() + this.expiresSeconds * 1000
this.errorCount = 0
localStorage.setItem(CACHE_EXPIRES_KEY, `${this.expiresStamp}`)
localStorage.removeItem(CACHE_ERROR_COUNT_KEY)
return true
}
return false
}
唯一標(biāo)識(shí)
根據(jù) method、url、data 三者來(lái)標(biāo)識(shí)接口,保證接口的唯一性
帶動(dòng)態(tài)標(biāo)識(shí),譬如時(shí)間戳等可以手動(dòng)過(guò)濾
/**
* 生成接口唯一鍵值
*/
generateCacheKey = (config) => {
// 請(qǐng)求方式,參數(shù),請(qǐng)求地址,
const { method, url, data, params } = config;
let rawData = ''
if (method === 'get') {
rawData = params
}
if (method === 'post') {
rawData = JSON.parse(data)
}
// 返回拼接key
return `${encodeURIComponent([method, url, stringify(rawData)].join('_'))}.json`;
};
更新數(shù)據(jù)
/**
* 更新cdn緩存數(shù)據(jù)
*/
updateCDN = (config, data) => {
const fileName = this.generateCacheKey(config)
const cdnUrl = `${this.prefix}/${fileName}`
axios.post(`${this.nodeDomain}/cdn/update`, {
cdnUrl,
data
})
}
Node定時(shí)任務(wù)
構(gòu)建定時(shí)任務(wù),用 puppeteer 去訪問(wèn)、帶上緩存標(biāo)識(shí),去更新 CDN 數(shù)據(jù)
import schedule from 'node-schedule';
const scheduleJob = {};
export const xxxJob = (ctx) => {
const { xxx } = ctx.config;
ctx.logger.info(xxx, 'xxx');
const { key, url, rule } = xxx;
if (scheduleJob[key]) {
scheduleJob[key].cancel();
}
scheduleJob[key] = schedule.scheduleJob(rule, async () => {
ctx.logger.info(url, new Date());
await browserIndex(ctx, url);
});
};
export const browserIndex = async (ctx, domain) => {
ctx.logger.info('browser --start', domain);
if (!domain) {
ctx.logger.error('domain為空');
return false;
}
const browser = await puppeteer.launch({
args: [
'--use-gl=egl',
'--disable-gpu',
'--no-sandbox',
'--disable-setuid-sandbox',
],
executablePath: process.env.CHROMIUM_PATH,
headless: true,
timeout: 0,
});
const page = await browser.newPage();
await page.goto(`${domain}?${URL_CACHE_KEY}`);
await sleep(10000);
// 訪問(wèn)首頁(yè)所有查詢(xún)接口
const list = await page.$$('.po-tabs__item');
if (list?.length) {
for (let i = 0; i < list.length; i++) {
await list[i].click();
}
}
await browser.close();
ctx.logger.info('browser --finish', domain);
return true;
};
效果
手動(dòng) block 整個(gè) domain,整個(gè)頁(yè)面正常展示
圖片
IndexDB
線上有 CDN 保證了,線下就輪到 IndexDB 了,基于業(yè)務(wù)簡(jiǎn)單的增刪改查,選用 localForage 三方庫(kù)足矣。
axios.interceptors.response.use(
async (resp) => {
const { config } = resp
const { url } = config
// 是否有緩存tag,用于更新CDN數(shù)據(jù)。目前是定時(shí)服務(wù)在跑,訪問(wèn)頁(yè)面帶上tag
if (this.hasCdnTag() && this.isWhiteApi(url)) {
this.updateCDN(config, resp)
}
if(this.isIndexDBWhiteApi(url)){
this.updateIndexDB(config, resp)
}
return resp;
},
async (err) => {
const { config } = err
const { url } = config
// 是否命中緩存策略
if (this.isWhiteApi(url) && this.useCache()) {
return this.fetchCDN(config).then(res => {
pushLog(`cdn緩存數(shù)據(jù)已命中,請(qǐng)?zhí)幚韅, SentryTypeEnum.error)
return res
}).catch(()=>{
pushLog(`cdn緩存數(shù)據(jù)未同步,請(qǐng)?zhí)幚韅, SentryTypeEnum.error)
if(this.isIndexDBWhiteApi(url)){
return this.fetchIndexDB(config).then(res => {
pushLog(`IndexDB緩存數(shù)據(jù)已命中,請(qǐng)?zhí)幚韅, SentryTypeEnum.error)
return res
}).catch(()=>{
pushLog(`IndexDB緩存數(shù)據(jù)未同步,請(qǐng)?zhí)幚韅, SentryTypeEnum.error)
})
}
})
}
}
);
總結(jié)
總結(jié)下,優(yōu)點(diǎn)包括不入侵業(yè)務(wù)代碼,不影響現(xiàn)有業(yè)務(wù),隨上隨用,盡可能避免前端純白屏的場(chǎng)景,成本低。劣勢(shì)包括使用局限,不適合對(duì)數(shù)據(jù)實(shí)效性比較高的業(yè)務(wù)場(chǎng)景,不支持 IE 瀏覽器。
接口容災(zāi)我們也是剛弄不久,有許多細(xì)節(jié)與不足,歡迎溝通交流。
接口容災(zāi)本意是預(yù)防發(fā)生接口服務(wù)掛了的場(chǎng)景,我們不會(huì)很被動(dòng)。原來(lái)是P0的故障,能被它降低為 P2、P3,甚至在某些場(chǎng)景下都不會(huì)有用戶(hù)反饋。