打造前端離線日誌(一): IndexedDB

前言

本文從理論和實戰的兩個方面講述前端離線日誌系統是如何構建的,由於內容比較多,我將文章分爲 3 部分來說述整個日誌系統的設計。javascript

  • 前端數據存儲設計 - IndexedDB
  • 服務端設計 - node + express/koa 以及數據壓縮 - deflate/gzip
  • (探索) WebRTC 實現日誌獲取

爲何須要離線日誌

隨着前端項目愈加複雜,前端日誌的重要性也愈加凸顯,一般咱們使用網絡請求上報的方式記錄日誌,好比 badjs,友盟的 cnzz等等。採用網絡請求上報的方式存在如下痛點:html

  1. 對弱網環境或者斷網環境支持不佳。
  2. 對服務器有較高的要求。
  3. 不斷的日誌上報可能會浪費用戶網絡資源。

在 badjs 開發中,爲了解決以上問題,咱們使用用白名單的方式上報一些非錯誤日誌(方便查詢問題),即只有知足必定條件的用戶才上報數據。白名單的方式也帶來了一些問題,好比某個用戶反饋頁面白屏了,可是咱們在 badjs 後臺沒有查到當前用戶的日誌,可能僅僅是由於用戶網絡不佳致使某個 js 加載失敗,可是你沒法給出使人信服的證據。前端

因而離線日誌應運而生,離線日誌幾乎解決了以上全部痛點,在客戶端中也早已經普遍應用。爲何前端一直沒有合適的離線應用平臺呢?一是由於以前技術存在缺陷,在 IndexedDB 以前,前端幾乎沒有好用的方式來存儲離線日誌。localstorage 雖然能夠必定程度上知足需求,可是其存在的問題也是顯而易見的。java

  1. 同步讀寫數據會帶來必定程度的阻塞。
  2. 數據大小限制。
  3. 本質上是字符串,致使不少對字符串的操做。
  4. key-value 型的存儲方式帶來更復雜的 CURD 操做。

直到 IndexedDB 的誕生加上廣大瀏覽器對其的支持,纔給了前端離線日誌帶來了成熟的時機。node

IndexedDB 簡介

簡單的說,IndexedDB 是一個基於瀏覽器實現的支持事型務的鍵值對數據庫,支持索引。IndexedDB 雖然不能使用 SQL 語句,可是存儲要求數據結構化(既能夠存文本,又能夠存文件以及blobs),經過索引產生的指針來完成查詢操做。git

IndexedDB 有如下優勢:github

  • 基於 JavaScript 對象的鍵值對存儲,簡單易用。
  • 異步 API。這點對前端來講很是重要,意味着訪問數據庫不會阻塞調用線程。
  • 很是大的存儲空間。理論上沒有最大值限制,假如超過 50 MB 會須要用戶確認請求權限。
  • 支持事務,IndexedDB 中任何操做都發生在事務中。
  • 支持 Web Workers。同步 API 必須在同 Web Workers 中使用。
  • 同源策略,保證安全。
  • 還算不錯的兼容性

基本概念

因爲 IndexedDB 是低級 API,因此想要使用 IndexedDB 還須要先理解一些基本概念。web

  • IDBFactory: window.indexedDB,提供對數據庫的訪問操做。
  • IDBOpenDBRequest: indexedDB.open() 的返回結果,表示一個打開的數據庫請求。
  • IDBDatabase: 表示 IndexedDB 數據庫鏈接,只能經過這個鏈接來拿到一個數據庫事務。
  • IDBObjectStore: 對象倉庫,一個 IDBDatabase 中能夠有多個 IDBObjectStore,相似於 table 或者 MongoDB 中的 document。
  • IDBTransaction: 表示一個事務,建立事務的時候須要指明訪問的範圍和訪問類型(讀或者寫)。
  • IDBCursor: 數據庫的索引,用來遍歷對象存儲空間。

基本操做

第一步,打開數據庫

const request = window.indexedDB.open('test', 1)
複製代碼

test 表示數據庫的名字,若是數據庫不存在則主動建立。第二個參數表示數據庫的版本,用整數表示,默認是 1。chrome

indexedDB.open() 返回一個 IDBOpenDBRequest 對象,經過三個事件 onerror, onsuccess, onupgradeneeded 來處理打開數據庫的操做。數據庫

let db
const request = indexedDB.open('test')
request.onerror = function(event) {
  console.error('open indexedDB error')
}
request.onsuccess = function(event) {
  db = event.target.result
  console.log('open indexedDB success')
}
request.onupgradeneeded = function(event) {
  db = event.target.result
  console.log('upgrade indexedDB success')
}
複製代碼

在建立一個新的數據庫或者增長已存在的數據庫的版本號(當打開數據庫時,指定一個比以前更大的版本號), onupgradeneeded 事件會被觸發。

onsuccessonupgradeneeded 中經過 event.target.result 來獲取數據庫的實例。

第二步,新建數據庫和表

在使用 indexedDB.open() 方法後,數據庫就已經新建了,不過裏面尚未任何內容。咱們經過 db.createObjectStore() 來建立表。

request.onupgradeneeded = function(event) {
  db = event.target.result
  console.log('upgrade indexedDB success')
  if (!db.objectStoreNames.contains('logs')) {
    const objectStore = db.createObjectStore('logs', { keyPath: 'id' })
  }
}
複製代碼

上述代碼會建立一個叫作 logs 的表,主鍵是 id,若是想要讓自動生成主鍵,也能夠這樣寫:

const objectStore = db.createObjectStore('logs', { autoIncrement: true })
複製代碼

keyPath & autoIncrement

keyPath autoIncrement 描述
No No objectStore 中能夠存儲任意類型的值,可是想要新增一個值的時候,必須提供一個單獨的鍵參數。
Yes No 只能存儲 JavaScript 對象,而且對象必須具備一個和 key path 同名的屬性。
No Yes 能夠存儲任意類型的值。鍵會自動生成。
Yes Yes 只能存儲 JavaScript 對象,一般一個鍵被生成的同時,生成的鍵的值被存儲在對象中的一個和 key path 同名的屬性中。然而,若是這樣的一個屬性已經存在的話,這個屬性的值被用做鍵而不會生成一個新的鍵。

第三步,建立索引

經過 objectStore 來建立索引:

// 建立一個索引來經過時間搜索,時間多是重複的,因此不能使用 unique 索引。
objectStore.createIndex('time_idx', 'time', { unique: false })
// 使用郵箱創建索引,爲了確保郵箱不會重複,使用 unique 索引
objectStore.createIndex("email", "email", { unique: true })
複製代碼

IDBObject.createIndex() 的三個參數分別爲「索引名稱」、「索引對應的屬性」、索引屬性(是否 unique 索引)。

第四步,插入數據

IndexedDB 中插入數據必須經過事務來完成。

// 使用事務的 oncomplete 事件確保在插入數據前對象倉庫已經建立完畢
objectStore.transaction.oncomplete = function(event) {
  // 將數據保存到新建立的對象倉庫
  const transaction = db.transaction('logs', 'readwrite')
  const store = transaction.objectStore('logs')

  store.add({
    id: 18,
    level: 20,
    time: new Date().getTime(),
    uin: 380034641,
    msg: 'xxxx',
    version: 1
  })
}
複製代碼

在初始化 IndexedDB 的時候,會觸發 onupgradeneeded 事件,而在之後的對 DB 的調用中,都只會觸發 onsuccess 事件。所以咱們將對數據庫的 CURD 操做作如下封裝。

CURD

新增數據

假如前面已經建立了一個 keyPath 爲 'id' 的名爲 logs 數據庫。

function addLog (db, data) {
  const transaction = db.transaction('logs', 'readwrite')
  const store = transaction.objectStore('logs')

  const request = store.add(data)

  request.onsuccess = function (e) {
    console.log('write log success')	
  }

  request.onerror = function (e) {
    console.error('write log fail')	
  }
}

addLog(db, {
  id: 1,
  level: 20,
  time: new Date().getTime(),
  uin: 380034641,
  msg: 'add new log',
  version: 1
})
複製代碼

寫數據的時候須要制定表名,而後建立事務,經過 objectStore 獲取 IDBObjectStore 對象,再經過 add 方法進行插入。

更新數據

經過 IDBObjectStore 對象的 put 方法,能夠完成對數據的更新操做。

function updateLog (db, data) {
  const transaction = db.transaction('logs', 'readwrite')
  const store = transaction.objectStore('logs')

  const request = store.put(data)

  request.onsuccess = function (e) {
    console.log('update log success')	
  }

  request.onerror = function (e) {
    console.error('update log fail')
  }	
}

updateLog(db, {
  id: 1,
  level: 20,
  time: new Date().getTime(),
  uin: 380034641,
  msg: 'this is new log',
  version: 1
})
複製代碼

IndexeDB 使用 put 方法更新數據,不過 put 的前提是必須有 unique 索引,IndexeDB 根據 unique 索引做爲 key 更新數據。put 方法相似於 upsert,若是 unique 索引對應的值不存在,則直接插入新的數據。

讀取數據

經過 IDBObjectStore 對象的 get 方法,能夠完成對數據的讀取操做。與更新數據相同,經過 get 方法讀取數據也須要 unique 索引。讀取的數據在 onsuccess 事件中查看。

function getLog (db, key) {
  const transaction = db.transaction('logs', 'readwrite')
  const store = transaction.objectStore('logs')

  const request = store.get(key)
  request.onsuccess = function (e) {
    console.log('get log success')
    console.log(e.target.result)
  }

  request.onerror = function (e) {
    console.error('get log fail')	
  }	
}

getLog(db, 1)
複製代碼

刪除數據

function deleteLog (db, key) {
  const transaction = db.transaction('logs', 'readwrite')
  const store = transaction.objectStore('logs')

  const request = store.delete(key)
  request.onsuccess = function (e) {
    console.log('delete log success')
  }

  request.onerror = function (e) {
    console.error('delete log fail')
  }	
}
複製代碼

刪除數據的時候即便數據不存在也會正常進入 onsuccess 事件中。

使用遊標

因爲 IndexedDB 中並無提供 SQL 的能力,因此不少時候咱們想要查找一些數據,只能經過遍歷的方式。

function getAllLogs (db) {
  const transaction = db.transaction('logs', 'readwrite')
  const store = transaction.objectStore('logs')

  const request = store.openCursor()

  request.onsuccess = function (e) {
    console.log('open cursor success')
    const cursor = event.target.result
    if (cursor && cursor.value) {
      console.log(cursor.value)	
      cursor.continue()
    }
  }

  request.onerror = function (e) {
    console.error('oepn cursor fail')
  }	
}
複製代碼

cursor 使用相似遞歸的方式對錶進行遍歷,經過 cursor.continue() 方法進入下一次循環。

使用索引

在前面的例子中,咱們都是經過主鍵去獲取數據的,經過索引的方式,可讓咱們用別的屬性查找數據。

假設在新建表的時候就建立了 uin 索引。

objectStore.createIndex('uin_index', 'uin', { unique: false })
複製代碼

在查詢數據的時候就能夠經過 uin 索引的方式:

function getLogByIndex (db) {
  const transaction = db.transaction('logs', 'readonly')
  const store = transaction.objectStore('logs')

  const index = store.index('uin_index')
  const request = index.get(380034641) // 注意這裏數據類型要一致

  request.onsuccess = function (e) {
    const result = e.target.result
    console.log(result)
  }
}
複製代碼

使用上述索引查詢的方式,只能查到第一個知足條件的數據,若是要查到更多的數據,還須要結合 cursor 來操做。

function getAllLogsByIndex (db) {
  const transaction = db.transaction('logs', 'readonly')
  const store = transaction.objectStore('logs')

  const index = store.index('uin_index')
  const request = index.openCursor(IDBKeyRange.only(380034641)) // 這裏能夠直接寫值

  request.onsuccess = function (e) {
    const cursor = event.target.result
    if (cursor && cursor.value) {
      console.log(cursor.value)	
      cursor.continue()
    }	
  }
}
複製代碼
  • IDBKeyRange.only(val) 只獲取指定數據
  • IDBKeyRange.lowerBuund(val, isOpened) 獲取在 val 之前或者小的數據,isOpened 是開閉區間,false 是包含 val(閉區間),true 是不包含 val(開區間)
  • IDBKeyRange.upperBuund(val, isOpened) 獲取在 val 之後或者大的數據,isOpened 是值開閉區間,false 是包含 val(閉區間),true 是不包含 val(開區間)
  • IDBKeyRange.buund(val1, val2, isOpened1, isOpened2) 獲取在 value1 與 value2 之間的數據,isOpened1 和 isOpened2 分別是左右開閉區間

通常經過 IDBKeyRange 對象上的上述幾個方法來進行多模式的查詢操做。

如何設計前端離線數據庫

在前面的例子中已經能夠看到數據庫的雛形了,表結構以下:

  • from - 日誌來源
  • id - 上報 id
  • level - 日誌等級
  • msg - 日誌信息
  • time - 日誌產生時間
  • uin - 用戶惟一標識
  • version - 日誌版本

這些是上報內容,那接口如何設計呢?

在一個前端離線日誌系統中,至少要提供如下五個接口:

  1. 清除日誌接口。因爲用戶的日誌不斷產生,不能讓數據無限積累,因此通常設定固定的天數,經過每次系統啓動的時候對日誌進行檢查來清理過時的日誌。
  2. 寫入日誌接口。經過異步寫日誌的方式容許系統不斷寫入新的日誌。
  3. 搜索相關接口。包括搜索當前用戶的日誌,固定時間段日誌,以及固定等級日誌等。方便上報收集端獲得合適的日誌信息。
  4. 數據整理壓縮接口。因爲用戶的日誌量可能很是大, 因此經過對數據進行整理和壓縮,可能有效減小上報數據大小。
  5. 數據上報接口。

具體能夠查看 wardjs-report 項目中的 offline 模塊。

小程序中由於有 wx.getStorage(Object object) 接口,所以也能夠模擬離線日誌的存儲功能。 這個分支 feat_miniprogram 是咱們小程序離線上報的解決方案。

IndexedDB 性能測試

IndexedDB 的性能很是好,並且基本都是異步操做,因此雖然做用於瀏覽器,可是常規的讀寫操做基本不會對產品有額外的影響。

測試環境

iMac 4GHz i7/16GB DDR3 macOS Majave 10.14.2 Chrome 72.0

數據準備

插入 1w 條日誌數據,每條日誌長度爲500。

測試耗時

鏈接DB耗時(10次求平均):3.5ms 插入1條數據(10次求平均):不到 1 ms 鏈接DB -> 插入數據 -> 釋放鏈接(10次求平均):4.3ms 同時插入10條數據(10次求平均):不到 1 ms

手機端測試

iPhone 6sp iOS 12.1.4 safari

測試耗時

鏈接DB耗時(10次求平均):2.3ms 插入1條數據(10次求平均):不到 1 ms 鏈接DB插入數據釋放鏈接(10次求平均):2.3ms 同時插入10條數據(10次求平均):不到 1 ms

測試結果比較奇怪,居然手機端的成績要優於 PC 端的成績。可能跟瀏覽器有關吧,還有當時測試的時候,電腦中有不少 app 和 chrome tab 沒有關,這個可能也有影響吧。

不過我沒有再去作測試了,由於上面的數據已經足夠驚豔了,你能夠理解爲,咱們簡單地插入數據是基本不耗時的。沒錯,IndexedDB 就是這麼強悍。

結語

因爲內容太多,將文章分爲多篇寫。本篇簡單介紹 IndexedDB 的用法以及性能測試。在前端離線日誌系統的構建中,這是最關鍵的一環,數據存儲,既要保證數據的可靠性,又要保持必定的性能,同時不能對用戶正常的操做產生反作用,經過咱們簡單的測試,我以爲 IndexedDB 徹底有能力勝任這份工做。

關注公衆號:【IVWEB社區】,每週推送精品技術週刊 。

相關文章
相關標籤/搜索