打造前端离线日志(一): 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社区】,每周推送精品技术周刊 。

相关文章
相关标签/搜索