乐趣区

使用IndexedDB做前端日志持久化

问题
页面如果表现不符合预期,前端工程师在没有 javascript 日志的情况下,很难 debug。所以就需要针对必要的步骤记录日志,并上传。但是每记录一条日志就上传并不是一个合适的选择,譬如如果生成日志的操作比较密集,会频繁产生上传日志请求的情况。那么我们可以在页面做一次日志的缓存,把日志先存在本地,当缓存达到一定数量的时候一次批量上传,即节约了网络资源,对服务器也不会带来过重的负担。
选型
页面存储方案悉数下大概有这些:cookie、localStorage/sessionStorage、IndexedDB、WebSQL、FileSystem。cookie 存储量有限,显然不适合。localStorage/sessionStorage 必须自己设计及维护存储结构。WebSQL 已经是一种淘汰的标准,因为和 IndexedDB 功能重复了。FileSystem 也是比较边缘不太推荐的标准。那么 IndexedDB 容量合适,且能按条存储,不用自己维护存储结构,相较其他方案是我这次打算的选型。
实现
主要流程
这里只介绍持久化所需要的基本操作,大而全的 API 操作见 MDN 文档
第一、新建数据库及“表”
IndexedDB 几乎所有的 API 都设计成异步的形式:
const DATABASE_NAME = ‘alita’;

let db = null;

let request = window.indexedDB.open(DATABASE_NAME);
request.onerror = function(event) {
alert(‘ 打开数据库失败 ’ + event.target.error);
};
request.onsuccess = function(event) {
// 如果打开成功,把数据库对象保存下来,以后增删改查都需要用到。
db = event.target.result;
}
如果数据库已经存在,indexedDB.open 会打开数据库,如果数据库不存在,indexedDB.open 会新建并打开。IndexedDB 也有类似于表的概念,在 IndexedDB 中叫 object store。并且新建 object store 还只能在特殊的场景下进行,先看下代码再解释:
const DATABASE_NAME = ‘alita’;
const OBJECT_STORE_NAME = ‘battleangel’;

let db = null;

let request = window.indexedDB.open(DATABASE_NAME);
// 省略代码。
// request.onerror = …
// request.onsuccess = …
request.onupgradeneeded = function(event) {
let db = event.target.result;
// 新建 object store
let os = db.createObjectStore(OBJECT_STORE_NAME, {autoIncrement: true} );
// 如果想在新建完 object store 后初始化数据可以写在下面。
let initDataArray = […];
initDataArray.forEach(function(data){
os.add(data);
} );
};
db.createObjectStore 只能在 onupgradeneeded 回调函数中被调用。onupgradeneeded 什么时候触发呢?只有在你 indexedDB.open() 的数据库是新的,没有建立过的时候才会被触发。所以新建数据库和新建 object store 并不是随时随地都可以的(还有一种场景会触发,等会下面会说到)。createObjectStore 的第二个参数 {autoIncrement: true} 表示你以后添加进数据库的数据存储策略采用自增 key 的形式。
第二、添加日志数据
打开数据库后我们就可以添加数据了,我们来看下:
let transaction = db.transaction(OBJECT_STORE_NAME, ‘readwrite’); // db 就是上面第一步保存下来的数据库对象。
transaction.oncomplete = function(event) {
alert(‘ 事物关闭 ’);
};
transaction.onerror = function(event) {
// Don’t forget to handle errors!
};

let os = transaction.objectStore(OBJECT_STORE_NAME);
let request = os.add({
// 日志对象。
} );
request.onsuccess = function(event) {
alert(‘ 添加成功 ’)
};
request.onerror = function(event) {
alert(‘ 添加失败 ’ + event.target.error);
};
第三、读取所有日志数据
在我们的场景中,添加完日志后,并不需要单独查询,只需要保存到一定数量后一次获取全部日志上传就可以了。获取表中所有数据也有新老 API 之分,先看新的 objectStore.getAll,chrome48 及以上支持。
let os = db.transaction(OBJECT_STORE_NAME, ‘read’).objectStore(OBJECT_STORE_NAME);
let request = os.getAll();
request.onsuccess = function(event) {
let logObjectArray = event.target.result;
};
如果你用户的浏览器是不支持 getAll 方法,你还可以通过游标轮询的方式来迭代出所有的数据:
let os = db.transaction(OBJECT_STORE_NAME, ‘read’).objectStore(OBJECT_STORE_NAME);
let logObjectArray = [];
let request = os.openCursor();
request.onsuccess = function(event){
let cursor = event.target.result;
if (cursor) {
logObjectArray.push(cursor.value);
cursor.continue();
}
};
当 cursor.continue() 被调用后,onsuccess 会被反复触发,当 event.target.result 返回的 cursor 为空时,表示没有更多的数据了。我们的场景有点特殊,当日志存储到一定数量时,我们除了要读出所有的数据上传外,还要把已经上传的数据删除掉,这样就不至于越存越多,把 IndexedDB 存爆掉的情况,所以我们修改代码如下(请注意 db.transaction 的第二个参数这次不同了,因为我们要删数据,所以不能是只读):
let os = db.transaction(OBJECT_STORE_NAME, ‘readwrite’).objectStore(OBJECT_STORE_NAME);
let logObjectArray = [];
if (os.getAll) {
let request = os.getAll();
request.onsuccess = function(event) {
logObjectArray = event.target.result;
// 删除所有数据
let clearRequest = os.clear();
// clearRequest.onsuccess = …
// clearRequest.onerror = …
// 上传日志
upload(logObjectArray);
};
} else {
let request = os.openCursor();
request.onsuccess = function(event){
let cursor = event.target.result;
if (cursor) {
logObjectArray.push(cursor.value);
cursor.continue();
} else {
// 删除所有数据
let clearRequest = os.clear();
// clearRequest.onsuccess = …
// clearRequest.onerror = …
// 上传日志
upload(logObjectArray);
}
};
}
以上的操作能完成我们的日志持久化的主流程了:存日志 – 获取已存日志 – 上传。
问题及解决方案
如果只有上述代码自然是没有办法完成一个健壮的持久化方案,还需要考虑如下几个点:
当存和删除冲突怎么办
我们看到代码了 IndexedDB 的操作都是异步,当我们正在获取所有日志时,又有写日志的调用怎么办?会不会在获取到所有日志和删除所有日志中间,新日志被添加进去了呢?这样新日志就会在没有被上传前就丢失了。这其实就是并发导致的问题,IndexedDB 有没有锁机制?
规范中规定 ‘readwrite’ 模式的 transaction 同时只能有一个在处理 request,其他 ‘readwrite’ 模式的 transaction 即使生成了 request 也会被锁住不会触发 onsuccess。
let request1 = db.transaction(OBJECT_STORE_NAME, ‘readwrite’).objectStore(OBJECT_STORE_NAME).add({})
let request2 = db.transaction(OBJECT_STORE_NAME, ‘readwrite’).objectStore(OBJECT_STORE_NAME).add({})
let request3 = db.transaction(OBJECT_STORE_NAME, ‘readwrite’).objectStore(OBJECT_STORE_NAME).add({})
// request1 没有处理完,request2 和 request3 就处于 pending 状态

当前一个 transaction 完成后,后一个 transaction 才能响应,所以我们无需写额外的代码,IndexedDB 内部帮我们实现了锁机制。那么你要问了,什么时候 transaction 完成呢?没有看到你上面显式调用代码结束 transaction 呀?transaction 自动完成的条件有两个:

必须有至少有一个和 transaction 关联的 request。也就是说如果你生成了一个 transaction 而没有生成对应的 request,那么这个 transaction 就成了孤儿事物,其他 transaction 没有办法继续操作数据库了,形成死锁。
当 transaction 一个关联的 request 的 onsuccess/onerror 被调用,并且同时没有其他关联的 request 时,transaction 自动 commit。用代码举个例子:

let os = db.transaction(OBJECT_STORE_NAME, ‘readwrite’).objectStore(OBJECT_STORE_NAME);
let request = os.getAll();
request.onsuccess = function(event) {
logObjectArray = event.target.result;
// 删除所有数据
let clearRequest = os.clear();
};
上述代码中 os.clear() 之所以能被成功调用,是因为 os.getAll() 生成的 request 的 onsuccess 还没有执行完,os.clear() 就又生成了一个 request。所以当前 transaction 在 os.getAll().onsuccess 时并没有结束。但是如下代码中的 os.clear() 调用就会抛异常:
let os = db.transaction(OBJECT_STORE_NAME, ‘readwrite’).objectStore(OBJECT_STORE_NAME);
let request = os.getAll();
request.onsuccess = function(event) {
logObjectArray = event.target.result;
// 删除所有数据
setTimeout(function(){
let clearRequest = os.clear(); // 这里会抛异常说 os 对应的 transaction 已经被关闭了。
}, 10 );

};
怎么来判断数据库中存了多少数据
我们解决了并发问题,那么我们如何来判断什么时候该上传日志了呢?有两个方案:1 基于数据库所存数据条数;2 基于数据库所存数据的大小。因为每条日志的数据或多或少都不一样,用条数来判断会出现同样 30 条数据,这次数据只占 10k,下次可能有 30k。所以相对理想的,我们应该以所存数据大小并设定一个阈值。这样每次上传量比较稳定。不过告诉大家一个悲伤的消息,IndexedDB 提供了查询条数的 API:objectStore.count,但是并没有提供查询容量的 API。所以我们采取了预估的方式先把查出来的所有数据转成 string,然后按 utf-8 的编码规则,逐个 char 累加,大致的代码如下:
/**
* UTF-8 是一种可变长度的 Unicode 编码格式,使用一至四个字节为每个字符编码
*
* 000000 – 00007F(128 个代码) 0zzzzzzz(00-7F) 一个字节
* 000080 – 0007FF(1920 个代码) 110yyyyy(C0-DF) 10zzzzzz(80-BF) 两个字节
* 000800 – 00D7FF
00E000 – 00FFFF(61440 个代码) 1110xxxx(E0-EF) 10yyyyyy 10zzzzzz 三个字节
* 010000 – 10FFFF(1048576 个代码) 11110www(F0-F7) 10xxxxxx 10yyyyyy 10zzzzzz 四个字节
*/
function sizeOf(str) {
let size = 0;
if (typeof str===’string’) {
let len = str.length;
for(let i = 0; i < len; i++) {
let charCode = str.charCodeAt(i);
if (charCode<=0x007f) {
size += 1;
} else if (charCode<= 0x07ff) {
size += 2;
} else if (charCode<=0xffff) {
size += 3;
} else {
size += 4;
}
}
}
return size;
}

所以我们添加日志的代码可以进一步完善成如下:
function writeLog(logObj) {
let os = db.transaction(OBJECT_STORE_NAME, ‘readwrite’).objectStore(OBJECT_STORE_NAME);
let request = os.getAll();
request.onsuccess = function(event) {
let logObjectArray = event.target.result;
logObjectArray.push(logObj);
let allDataStr = logObjectArray.map(l=>JSON.string(l) ).join(` 分隔符 `);
let allDataSize = sizeOf(allDataStr);
// 如果已存日志加上此次要添加的日志数据总和超过阈值,则上传并清空数据库
if (allDataSize > ` 预设阈值 `) {
os.clear();
upload(allDataStr);
} else {
// 如果还没有达到阈值,则把日志添加进数据库
os.add(logObj);
}
}
}
隐式问题:自增 key
到上面为止正常的日志持久化方案已经较为完整了,上线也能够跑了(当然我示例代码里面省略了异常处理的代码)。但是这其中有一个隐形的问题存在,我们新建 object store 的时候存储结构使用的是自增 key。每个 object store 的自增 key 会随着新加入的数据不断的增加,删除和 clear 数据也不会重置这个 key。key 的最大值是 2 的 53 次方(9007199254740992)。当达到这个数值时,再 add 就会 add 不进数据了。此时 request.onerror 会得到一个 ConstraintError。我们可以通过显式得把 key 设置成最大的来模拟下:
let os = db.transaction(OBJECT_STORE_NAME, ‘readwrite’).objectStore(OBJECT_STORE_NAME);
let request = os.add({}, 9007199254740992 );

setTimeout(function(){
let os = db.transaction(OBJECT_STORE_NAME, ‘readwrite’).objectStore(OBJECT_STORE_NAME);
let request = os.add({} );
request.onerror = function(event) {
console.log(event.target.error.name); // ConstraintError
}
}, 2000 );
这里有个一个问题,ConstraintError 并不是一个特定的 error 表示数据库“写满”了,其他场景也会触发抛出 ConstraintError,譬如添加 index 时候重复了。规范中也没有特定的 error 给到这种场景,所以这里要特别注意下。当然这个最大值是很大的,我们 5 秒钟写一次日志也需要 14 亿年写满。不过我比较任性,为了代码完备性,我给理论上兜个底。那么怎么才能重置 key 呢?很直接,就是删了当前的 object store,再建一个。这个时候坑爹的事又出现了。就像上面提到的 db.createObjectStore 只能在 onupgradeneeded 回调函数中被调用一样。db.deleteObjectStore 也只能在 onupgradeneeded 回调函数中被调用。那么我们上面提到了只有在新建的 db 的时候才能触发这个回调,怎么办?这个时候轮到 window.indexedDB.open 的第二个参数出场了。我们如果需要更新当前 db,那么就可以在第二个参数上传入一个比当前版本高的版本,就会触发 upgradeneeded 事件 (第一次不传默认新建数据库的 version 就是 1),代码如下:
let nextVersion = 1;
if (db) {
nextVersion = db.version + 1;
db.close(); // 这里一定要注意,一定要关闭当前 db 再做 open,要不然代码往下执行在 chrome 上根本不 work(其他浏览器没有测)。
db = null;
}
let request = window.indexedDB.open(DATABASE_NAME, nextVersion);
request.onerror = function() {
// 处理异常
};
request.onsuccess = (event)=>{
db = event.target.result;
};
// 利用 open version+1 的 db 重建 object store, 因为 deleteObjectStore 只能在 onupgradeneeded 中调用。
request.onupgradeneeded = function(event) {
let currentDB = event.target.result;
currentDB.deleteObjectStore(OBJECT_STORE_NAME);
currentDB.createObjectStore(OBJECT_STORE_NAME, {
autoIncrement: true
} );
}
所以添加日志的代码最终形态是:
function recreateObjectStore(success) {
let nextVersion = 1;
if (db) {
nextVersion = db.version + 1;
db.close(); // 这里一定要注意,一定要关闭当前 db 再做 open,要不然代码往下执行在 chrome 上根本不 work(其他浏览器没有测)。
db = null;
}
let request = self.indexedDB.open(DATABASE_NAME, nextVersion);
request.onerror = function() {
// 处理异常
};
request.onsuccess = (event)=>{
db = event.target.result;
success && success();
};
// 利用 open version+1 的 db 重建 object store, 因为 deleteObjectStore 只能在 onupgradeneeded 中调用。
request.onupgradeneeded = function(event) {
let currentDB = event.target.result;
currentDB.deleteObjectStore(OBJECT_STORE_NAME);
currentDB.createObjectStore(OBJECT_STORE_NAME, {
autoIncrement: true
} );
}
}

let recreating = false; // 标志位,为了在没有重新建立 object store 前不要重复触发 recreate

function writeLog(logObj) {
let os = db.transaction(OBJECT_STORE_NAME, ‘readwrite’).objectStore(OBJECT_STORE_NAME);
let request = os.getAll();
request.onsuccess = function(event) {
let logObjectArray = event.target.result;
logObjectArray.push(logObj);
let allDataStr = logObjectArray.map(l=>JSON.string(l) ).join(` 分隔符 `);
let allDataSize = sizeOf(allDataStr);
// 如果已存日志加上此次要添加的日志数据总和超过阈值,则上传并清空数据库
if (allDataSize > ` 预设阈值 `) {
os.clear();
upload(allDataStr);
} else {
// 如果还没有达到阈值,则把日志添加进数据库
let addRequest = os.add(logObj);
addRequest.onerror = function(e) {
// 如果添加新数据失败了
if (error.name===’ConstraintError’) {
// 1. 先把已有数据上传
uploadAllDbDate();
// 2. 看看是否已经在重置了
if (!recreating) {
recreating = true;
// 3. 如果没有重置,就重置 object store
recreateObjectStore(function(){
// 4. 重置完成,再添加一遍数据
recreating = false;
writeLog(logObj);
} )
}
}
}
}
}
}
好了到现在为止,整个日志持久化方案的流程就闭环了,当然实际代码肯定要更精细,结构更好。因为并发锁问题,数据大小问题,重置 object store 问题都不是很容易查到解决方案,网上大多数只有一些基本操作,所以这里记录下,方便有需要的人。
参考文档:

Using IndexedDB.

Locking model for IndexedDB?.

How do you keep an indexeddb transaction alive?.

退出移动版