使用IndexedDB作前端日志持久化

问题

页面若是表现不符合预期,前端工程师在没有 javascript 日志的状况下,很难 debug。因此就须要针对必要的步骤记录日志,并上传。可是每记录一条日志就上传并非一个合适的选择,譬如若是生成日志的操做比较密集,会频繁产生上传日志请求的状况。那么咱们能够在页面作一第二天志的缓存,把日志先存在本地,当缓存达到必定数量的时候一次批量上传,即节约了网络资源,对服务器也不会带来太重的负担。javascript

选型

页面存储方案悉数下大概有这些:cookie、localStorage/sessionStorage、IndexedDB、WebSQL、FileSystem。cookie 存储量有限,显然不适合。localStorage/sessionStorage 必须本身设计及维护存储结构。WebSQL 已是一种淘汰的标准,由于和 IndexedDB 功能重复了。FileSystem 也是比较边缘不太推荐的标准。那么 IndexedDB 容量合适,且能按条存储,不用本身维护存储结构,相较其余方案是我此次打算的选型。前端

实现

主要流程

这里只介绍持久化所须要的基本操做,大而全的 API 操做见MDN文档java

第1、新建数据库及“表”chrome

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 的形式。缓存

第2、添加日志数据服务器

打开数据库后咱们就能够添加数据了,咱们来看下:cookie

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 );
};

第3、读取全部日志数据网络

在咱们的场景中,添加完日志后,并不须要单独查询,只须要保存到必定数量后一次获取所有日志上传就能够了。获取表中全部数据也有新老 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 自动完成的条件有两个:

  1. 必须有至少有一个和 transaction 关联的 request。也就是说若是你生成了一个 transaction 而没有生成对应的 request,那么这个 transaction 就成了孤儿事物,其余 transaction 没有办法继续操做数据库了,造成死锁。
  2. 当 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 问题都不是很容易查到解决方案,网上大多数只有一些基本操做,因此这里记录下,方便有须要的人。

参考文档:

  1. Using IndexedDB.
  2. Locking model for IndexedDB?.
  3. How do you keep an indexeddb transaction alive?.
相关文章
相关标签/搜索