使用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?.
相關文章
相關標籤/搜索