当代后端格尼兹渐趋繁杂,在均衡操控性和统计数据主动性各方面,后端也渐渐已经开始分担许多职责。
前段天数他们在工程项目中就遇到了这种两个情景。他们的工程项目而已两个十分现代的统计数据看版类工程项目,使用者关上网页,透过如上所述化API读取统计数据,图形网页,顺利完成各项任务。
但那个工程项目有两个特征,我须要不光表明呵呵:
子公司高管们每晚单厢采用,因此十分高度关注高管们在智能手机上采用,互联网前提亦然高管们查阅统计数据时一般来说都较为不安甚至于,两个原本貌似单纯的工程项目,就逐渐变为操控性强化的要角。
version alpha
最已经开始他们的思路较为单纯,是给把统计数据储存到indexedDB中,并增设两个已过期天数。总体业务流程如下表所示:
有关indexedDB的如上所述版标识符大体包涵如下表所示几个部份
const TABLE_NAME = xhr_cache export const getDBConnection = () => { const request = window.indexedDB.open(DB_NAME) request.onupgradeneeded =function (event) { const db = event.target.result if(!db.objectStoreNames.contains(TABLE_NAME)) {const table = db.createObjectStore(TABLE_NAME, { keyPath: uid}) table.createIndex(uid, uid, { unique: true }) } } return new Promise((resolve, reject) =>{ request.onsuccess =function () { resolve(request.result) } }) } const dbConn = awaitgetDBConnection()根据request生成唯一key
import MD5 from crypto-js/md5 getKey(config) { const hashedKey = MD5( `${config.url}_${JSON.stringify(config.payload)}` ).toString() return hashedKey }根据两个请求的URL + payload,他们可以识别两个唯一的请求。
对其值进行md5哈希之后得到两个唯一的键,代表两个请求,并将其作为储存在indexedDB中的主键。
读取统计数据和写入统计数据
/* 写入API response统计数据 */
constresponse = {uid: key, content: axiosRequest.response.data, created_at: new Date().getTime(), expired: expired_at }const addResponseToIndexedDB = function (response) { dbConn .transaction([TABLE_NAME], readwrite) .objectStore(TABLE_NAME) .put(response) } /* 读取内存 */ constrequest = dbConn .transaction([TABLE_NAME],readonly) .objectStore(TABLE_NAME) .index(uid) .get(key) constresult =await new Promise((resolve => { request.onsuccess = function () { resolve(request.result) } })清除已过期内存
虽然indexedDB可以储存远大于localStorage的统计数据,但他们也不希望indexedDB随着使用者不断访问储存大量冗余统计数据。因此,会在每次应用加载的已经开始对于已过期统计数据统一进行一次清理:
const isExpireded = (result, expired = 60000) => { const now = new Date().getTime() constcreated_at = result.created_atreturn !created_at || (now – created_at > expired) ? true : false } constdelCacheByExpireded =() => { var request = dbConn .transaction([TABLE_NAME], readwrite) .objectStore(TABLE_NAME) .openCursor(); request.onsuccess =function (e) { var cursor = e.target.result; if(cursor && cursor !==null) { const key = cursor.key const expireded = isExpireded(cursor.value) if(expireded) { that.delCacheByKey(key) } cursor.continue(); } } }Axios Request / Response Interceptor
有了上述这些能力,他们就可以在自己的Axios拦截器中采用indexedDB的内存统计数据。
axios request 拦截器
… const CACHED_URL_REGEX = [somepath/data/version/123, user/info/name, … ] Axios.interceptors.request.use(async function (config) { const r = new Regex(`${CACHED_URL_REGEX.join(|)}$`) if (r.test(config.url)) { constkey = getKey()const request = dbConn .transaction([TABLE_NAME], readonly) .objectStore(TABLE_NAME) .index(uid) .get(key) const result = await new Promise((resolve) => { request.onsuccess = function (event) { resolve(request.result) } request.onerror = function (event) { resolve() } })if (result && isExpired(result)) { config.adapter = function (config) { return new Promise((resolve) => { const res = { data: result.content, status: 200, statusText: OK, headers: { content-type: text/plain; charset=utf-8 }, config, request: {} } return resolve(res) }) } } returnconfig } }) …可以看到,他们在request 拦截器中进行了以下操作:
axios request interceptor的参数中包涵URL和payload属性根据URL判断当前资源是否须要内存如须要内存,则根据URL和payload信息生成唯一的key根据此key去indexedDB中查找是否已有内存如有则直接构筑两个response并返回如没有则返回原始config,继续进行axios默认行为注意下面这段标识符
const result = await new Promise((resolve) => { request.onsuccess = function (event) { resolve(request.result) } request.onerror =function (event) { resolve() } })这里的标识符使用了await,以此等待indexedDB的异步查询结束。异步查询结束之后才能根据其结果判断是否要直接返回还是继续axios默认行为。
axios response 拦截器
Axios.interceptors.response.use(function (response) { … let success = response.status < 400 const key= getKey(response.config) dbConn .transaction([TABLE_NAME],readwrite) .objectStore(TABLE_NAME) .put({ uid:key, content: response.data, created_at: new Date().getTime() }) … returnresponse }在response拦截器中,无需等待indexedDB的异步写入过程,因此不须要采用await。
截至目前,基于Axios + indexedDB的内存方案已经大体可用,当然以上标识符并不完全,如需采用还得根据自己的工程项目做许多修改。
IndexedDB不够快?
上述设计方案实现之后,他们发现在读取indexedDB的时候有时会很快,但有些时候却十分慢。根据观测,在某些智能手机上,读取一小段不超过100K的统计数据,有时候须要400ms以上。根据经验这是无法理解的。
进一步调查发现,在主线程繁忙时,如上所述化indexedDB事务到indexedDB返回统计数据就会较为慢;反之,在主线程空闲时,经过测量,同一过程耗时大约在5ms以下,这才在统计数据库读取速度的正常认知范围之内。
但众所周知,基于react + antd的后端应用,DOM结构繁杂,主线程在图形时会十分繁忙,这就造成了他们观察到的读取indexedDB耗时较长。
说到这里,还记得上边在Axios Request Interceptor中须要先等待读取到indexedDB统计数据,根据结果判断是否要请求API的标识符吗?
于是尴尬的一幕出现了。假设一次请求叠加了如下表所示因素:
主线程正在进行大范围的DOM图形,造成CPU繁忙indexedDB读取耗时从若干毫秒跳级到几百毫秒读取到的统计数据已过期,经过判断须要请求API请求API耗时200ms以上原本也可能高达无法理解的一秒左右。这种结果表示,此情景下的内存方式显然是得不偿失的。
下表为他们针对alpha版内存方案在Chrome浏览器上的操控性做出的统计。其中每一列分别表示在React进行如上所述化图形阶段的indexedDB请求耗时。
API 1
API 2
API 3
180ms
82ms
51ms
如果将Chrome的CPU throttle调低到1/4的效率,统计数据则更加无法理解
API 1
API 2
API 3
956ms
183ms
253ms
与之对应的,在CPU空闲的时候,也是如上所述化图形完毕之后的indexedDB请求耗时分别为:
API 1
API 2
API 3
13ms
12ms
13ms
Version BETA
由于上一节的结论,这种的内存思路显然无法达到原本的目的。因此他们又设计了两个方案进行对比:
利用serviceWorker进行统计数据内存在应用已经开始之初将indexedDB统计数据dump到内存,之后的取用直接透过内存内存。根据dump的天数点,又细分为在react app如上所述化时进行dump在html script标签中采用主线程执行dump标识符在html script标签中采用web worker执行dump标识符其中dump统计数据到内存中进行内存取用的三种细分,他们分别命名为:
ReactAPP如上所述化MemCache的方案HTML加载时如上所述化MemCache的方案webWorker如上所述化MemCache的方案思路的对比如下表所示:
方案
对比
ReactAPP如上所述化MemCache
为了避免API如上所述化在dump统计数据到内存顺利完成之前,须要等待如上所述化MemCache之后再如上所述化react app的render方法 由于这种顺序执行,会牺牲一部份APP图形的耗时
HTML加载时如上所述化MemCache
HTML加载时如上所述化,CPU相对较为空闲,进行dump操作效率较高,但也取决于当时是否正在对加载的JS资源进行script evaluate 如浏览器正在进行脚本文件的执行和编译,dump时长仍然较为长
webWorker如上所述化MemCache
利用webworker在主线程之外进行indexedDB的dump操作,可以避免主图形线程繁忙与否对于indexedDB读取耗时的影响 但如上所述化webworker本身仍然须要额外耗时
由于以上方案相对于上一节中单次indexedDB如上所述化增加了前置dump统计数据到内存的操作耗时,所以我们这次对测量方案增加了TOTAL一栏,表示从html网页载入到react app完全图形完毕的耗时。
下表中包涵共5种方案的操控性对比:alpha版,serviceWorker方案,以及MemCache的三种方案。每种方案测试十次,取四个阶段以及TOTAL耗时的平均值:
HTML已经开始加载到静态资源加载顺利完成的耗时如上所述化图形过程中的三次indexedDB如上所述化耗时TOTAL耗时评测统计数据见下表(细字体的部份为正常CPU负载情况下,粗体字的部份表示CPU效率降级为1/4时的情况):
方案
静态资源
API 1
API 2
API 3
TOTAL
静态资源
API 1
API 2
API 3
TOTAL
alpha版
525.4
180.4
82.2
51.3
1544.7
2500.5
956.5
183.6
253
6562.5
service worker方案
827.5
60.9
208.4
351.6
1777.2
4053.7
138.4
991.4
546.3
7357.3
ReactAPP如上所述化MemCache
1042.9
1.8
26
9.8
1659.5
4512.9
7.5
31.3
35.5
6410.1
HTML加载时如上所述化MemCache
1021
2.4
10.3
9.7
1564.7
5273.1
7.2
31.6
34.5
7178.3
webWorker如上所述化MemCache
797.9
0.9
8.9
7.1
1299.6
3853.7
5.6
31.2
45
5975
Finally!! We have a winner
根据统计数据显示,对于他们的情景来说,采用webworker启动MemCache的方案是最经济的。方案设计如下表所示图所示:
WebWorker 脚本 / APP内部的dump统计数据脚本
由于dump统计数据的操作基本一致,因此WebWorker脚本和APP内部用于dump统计数据的lib文件内容基本一致。大体标识符可见:
const DB_NAME = db_name const TABLE_NAME = xhr_cache export const getDBConnection = () => { const request = window.indexedDB.open(DB_NAME) request.onupgradeneeded = function (event) { const db = event.target.result if (!db.objectStoreNames.contains(TABLE_NAME)) { consttable = db.createObjectStore(TABLE_NAME, {keyPath: uid }) table.createIndex(uid, uid, { unique: true}) } }return new Promise((resolve, reject) => { let completed = false request.onsuccess = function () { if(completed ===false) { completed = true resolve(request.result) } else{ request.result.close() } } request.onerror =function (err) { if (completed === false) { completed = truereject(err) } } setTimeout(() => { if (completed === false) { completed = true reject(new Error(getDBConnection timeout after app rendered)) } }, 1000) }) } export const dump2Memory = async(db) => {const transaction = db.transaction([TABLE_NAME], readonly) consttable = transaction.objectStore(TABLE_NAME)const request = table.index(uid).getAll() const records = await new Promise((resolve, reject) => { request.onsuccess = function () { resolve(request.result) } request.onerror = function () { console.log(dump2Memory error) resolve() } }) return records } export constdelCacheByExpireded =async (records) => { const validRecords = records.filter((record) =>!getExpireded(record))const objectStore = DBCache.conn .transaction([xhr_cache], readwrite) .objectStore(xhr_cache) const clearRequest = objectStore.clear() clearRequest.onsuccess = function () { validRecords.forEach((record) => { objectStore.add(record) }) } return validRecords }在这里定义了三个函数
DB链接的函数从indexedDB中dump所有统计数据到内存的函数对内存中的全量统计数据进行已过期筛查的函数,其中筛查出已已过期的统计数据进行删除操作,留下来的有效内存再次存回到indexedDBxedDB链接的函数中,相对alpha版增加了容错处理。如果两个浏览器多个tab同时关上同两个indexedDB的链接,可能会导致后面关上的indexedDB链接被block住。因此在这里做了超时处理。
如果新的链接关上超时则不如上所述化内存内存,作为降级处理方案。
于此同时,MemCache类也须要对这种降级做出兼容。
MemCache类
DBCache.conn = nullDBCache.memCache = {__memCache: null, initialize: function (records) { this.__memCache = new Map(records.map((record) =>[record.uid, record])) },get: function (key) { const result = this.__memCache.get(key) if (result) { returncloneDeep(result) }else { return null } }, add: function (record) { this.__memCache.set(record.uid, record) } } DBCache.prepare =async function () { try { DBCache.conn = awaitgetDBConnection()let dbRecordList = [] if (window.__db_cache_prepared_records__.length) { dbRecordList = cloneDeep(window.__db_cache_prepared_records__) } else { console.time(dump) dbRecordList = awaitdump2Memory(DBCache.conn)console.timeEnd(dump) } const validRecords = awaitdelCacheByExpireded(dbRecordList) DBCache.memCache.initialize(validRecords || []) }catch(err) { DBCache.memCache.initialize([])console.error(err) } } DBCache.updateRecord = (record) => { if(DBCache.conn) { DBCache.memCache.add(record) DBCache.conn .transaction([xhr_cache], readwrite) .objectStore(xhr_cache) .put(record) } }请注意,DBCache对象的prepare静态方法中:
法外围添加了try{}catch{}块。
于是所有Axios请求全部降级为API如上所述化。
另外两个须要注意的点是,DBCache.memCache.get的方法实现中对于内存中的统计数据进行深拷贝的操作。原因在于,如果直接向react业务标识符传递该内存块的引用,很显然业务标识符会对该内存引用的对象进行修改。那么下次再采用命中的内存时,就会因为内存统计数据与API返回的统计数据结构不一致导致报错。
如上所述化WebWorker
到现在为止,几乎所有必须模块的标识符都已经实现了。整个业务流程只剩下最后一块砖:HTML里script标签内用于启动WebWorker以及WebWorker中通知主线程的标识符。
<script> window.__db_cache_prepared_records__ = []if (window.Worker) { console.time(dump in html) const dbWorker = new Worker(./webworker.dump.prepare.js); dbWorker.onmessage = function(e) { if (e.data.eventName = onDBDump) { if (window.__db_cache_prepared_records__.length ===0) window.__db_cache_prepared_records__ = e.data.data console.timeEnd(dump in html) } } } </script>PostMessage
// other codes in dump script section. // Im not gonna repeat those. see it yourself please … if (indexedDB) { console.time(dump2Memory) getDBConnection().then(conn => { dump2Memory(conn).then(records => { console.timeEnd(dump2Memory) postMessage({ eventName: onDBDump, data: records }) }) }).catch(err => { console.error(err) }) }结论
截至目前,他们采用Axios + indexedDB + WebWorker实现的最高效的后端API内存方案就到此为止了。
实话实说,现在还而已搭建了两个高效内存的框架,至于各种适合不同应用情景的内存思路还没有实现。
如果你有有意思的内存情景或须要何种内存思路,欢迎留言。