使用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 問題都不是很容易查到解決方案,網上大多數只有一些基本操作,所以這裡記錄下,方便有需要的人。