摘要:問題頁面如果表現不符合預期,前端工程師在沒有日志的情況下,很難。所以就需要針對必要的步驟記錄日志,并上傳。只能在回調函數中被調用。事物關閉日志對象。處理異常利用的重建因為只能在中調用。
問題
頁面如果表現不符合預期,前端工程師在沒有 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?.
文章版權歸作者所有,未經允許請勿轉載,若此文章存在違規行為,您可以聯系管理員刪除。
轉載請注明本文地址:http://specialneedsforspecialkids.com/yun/109011.html
摘要:與服務器端通信每次都會攜帶在頭中,如果使用保存過多數據會帶來性能問題。但如果要存儲大量的復雜的數據,這并不是一種很好的方案。使用索引存儲數據,各種數據庫操作放在事務中執行。通過監聽正確類型的事件以等待操作完成。 cookie 生命期為只在設置的cookie過期時間之前一直有效,即使窗口或瀏覽器關閉。 存放數據大小為4K左右 。有個數限制(各瀏覽器不同),一般不能超過20個。與服務器端通...
背景 隨著前端技術日新月異地快速發展,web應用功能和體驗也逐漸發展到可以和原生應用媲美的程度,前端緩存技術的應用對這起到了不可磨滅的貢獻,因此想一探前端的緩存技術,這篇文章主要會介紹在日常開發中比較少接觸的IndexedDB IndexedDB 什么是IndexedDB IndexedDB簡單理解就是前端數據庫,提供了一種在用戶瀏覽器中持久存儲數據的方法,但是和前端關系型數據不同的是,Index...
摘要:正文開始三種本地存儲方式前言網絡早期最大的問題之一是如何管理狀態。這個特點很重要,因為這關系到什么樣的數據適合存儲在中。特點生命周期持久化的本地存儲,除非主動刪除數據,否則數據是永遠不會過期的。 最近一直在搞基礎的東西,弄了一個持續更新的github筆記,可以去看看,誠意之作(本來就是寫給自己看的……)鏈接地址:Front-End-Basics 此篇文章的地址:三種本地存儲方式 ...
摘要:在前端開發過程中,為了與服務器更方便的交互或者提升用戶體驗,我們都會在客戶端用戶本地保存一部分數據,比如。這篇文章的客戶端本地存儲,我們主要講到四種技術。回調函數傳入的事件屬性就指向該請求,即。刪除索引不會影響數據,所以沒有回調函數。 在前端開發過程中,為了與服務器更方便的交互或者提升用戶體驗,我們都會在客戶端(用戶)本地保存一部分數據,比如cookie/localStorage/se...
閱讀 590·2021-11-15 11:38
閱讀 1174·2021-10-11 10:59
閱讀 3491·2021-09-07 09:58
閱讀 478·2019-08-30 15:44
閱讀 3518·2019-08-28 18:14
閱讀 2599·2019-08-26 13:32
閱讀 3513·2019-08-26 12:23
閱讀 2413·2019-08-26 10:59