上回讲到Firebase 的创建项目和用户管理。今天来说说 Firebase 的数据库的使用。

Firebase Realtime Database 是一种云托管数据库。数据库将数据存储为 JSON,并以实时方式与每个连接的客户端同步。 当使用JavaScript SDK 构建跨平台应用时,所有客户端都会分享同一个 Realtime Database 实例,并自动接收更新的最新数据。数据实时跨所有客户端同步,当应用处于离线状态时仍可使用该数据。

Firebase Realtime Database 以 JSON 格式存储数据,在 Web 端使用 WebSocket 进行数据的传输,每当数据变化时,任何连接的设备都会以毫秒速度收到更新的数据。同时Firebase Realtime Database SDK 将您的数据保留在磁盘上。 一旦重新建立连接,客户端会立即接收没有接收的任何变化,同步当前服务器状态。而最关键的一点则是:Firebase Realtime Database 可以直接从移动设备或网络浏览器访问,需要应用服务器。 安全和数据验证均通过 Firebase Realtime Database安全规则进行。

配置 Firebase Database Rules

Realtime Database 提供了声明性规则语言,可用于定义应该如何组织数据、如何将数据编入索引以及何时可以读取和写入数据。默认情况下,只有通过身份验证的用户才能拥有数据库的读写权限。可以在配置规则说明中修改。

初始化数据库

在应用创建完成之后,在“Database”选项卡中找到应用对应的实时数据库网址,一般都是https://<db-id>.firebaseio.com的形式。
然后替换config中的databaseURL

1
2
3
4
5
6
7
8
9
10
11
12
// Set the configuration for your app
// TODO: Replace with your project's config object
let config = {
apiKey: '<your-api-key>',
authDomain: '<your-auth-domain>',
databaseURL: '<your-database-url>',
storageBucket: '<your-storage-bucket>'
};
firebase.initializeApp(config);

// Get a reference to the database service
let database = firebase.database();

接下来就可以使用database来操作数据库了。

设计数据结构

在操作数据库之前,需要先了解 Firebase 实时数据库的数据体系结构的主要概念。

如何组织数据

构建一个结构合理的数据库需要思考的点很多。最重要的是,要对数据的保存和检索做好计划,尽可能简化这些过程。

所有 Firebase Realtime Database 数据都被存储为 JSON 对象。您可将该数据库视为云托管 JSON 树。 该数据库与 SQL 数据库不同,没有任何表格或记录。 当您将数据添加至 JSON 树时,它变为现有 JSON 结构中的一个节点。

例如,聊天应用允许用户存储基本个人资料和联系人列表。 通常用户个人资料位于一个诸如 /users/$uid 之类的路径中。 用户 alovelace 的数据库项看起来可能如下所示:

1
2
3
4
5
6
7
8
9
10
{
"users": {
"alovelace": {
"name": "Ada Lovelace",
"contacts": { "ghopper": true },
},
"ghopper": { ... },
"eclarke": { ... }
}
}

避免数据嵌套

JSON类型的数据结构可以多层嵌套,同时也不需要考虑数据类型。但是需要避免过多的嵌套。在获取数据库中某个位置的数据时,数据库也会检索该位置的所有子节点。另外在授权方面,在授予用户正在数据库中某个节点的读写访问权时,也会将该节点下所有数据的访问权授予该用户。前者会导致很多不必要的数据检索和传输,后者则有可能导致安全问题。

假设一个如下所示的多层嵌套结构:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
{
// This is a poorly nested data architecture, because iterating the children
// of the "chats" node to get a list of conversation titles requires
// potentially downloading hundreds of megabytes of messages
"chats": {
"one": {
"title": "Historical Tech Pioneers",
"messages": {
"m1": { "sender": "ghopper", "message": "Relay malfunction found. Cause: moth." },
"m2": { ... },
// a very long list of messages
}
},
"two": { ... }
}
}

若采用这种嵌套设计,循环访问数据就会出现问题。例如,要列出聊天对话标题,就需要将整个 chats 树(包括所有成员和消息)都下载到客户端。
Firebase 推荐平展数据结构,被拆分到不同路径,按需下载。上面给出的多层嵌套平展思路如下:

  1. 需要列出聊天的对话标题;
  2. 进入聊天室需要列出聊天室成员;
  3. 进入聊天室需要列出消息记录;

那么应该这么做:

  1. Chats 节点只需要包含聊天室的基本信息,如:标题,创建时间等。
  2. Messages 节点的数据根据 Chats节点分成不同子节点,类似这种形式: ‘messages/:chatid/:messageid’。
  3. Members 节点的数据根据 Chats节点分成不同子节点,类似这种形式: ‘members/:chatid/:memberid’。

参考如下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
{
// Chats contains only meta info about each conversation
// stored under the chats's unique ID
"chats": {
"one": {
"title": "Historical Tech Pioneers",
"lastMessage": "ghopper: Relay malfunction found. Cause: moth.",
"timestamp": 1459361875666
},
"two": { ... },
"three": { ... }
},

// Conversation members are easily accessible
// and stored by chat conversation ID
"members": {
// we'll talk about indices like this below
"one": {
"ghopper": true,
"alovelace": true,
"eclarke": true
},
"two": { ... },
"three": { ... }
},

// Messages are separate from data we may want to iterate quickly
// but still easily paginated and queried, and organized by chat
// converation ID
"messages": {
"one": {
"m1": {
"name": "eclarke",
"message": "The relay seems to be malfunctioning.",
"timestamp": 1459361875337
},
"m2": { ... },
"m3": { ... }
},
"two": { ... },
"three": { ... }
}
}

现在每个对话只需下载几个字节即可循环访问房间列表,快速在 UI 中列出或显示房间。在消息到达时,可单独提取和显示,确保 UI 的及时响应和速度。似乎一切都很好,但是还是存在问题:假设用户与群组之间存在双向关系。 用户可属于一个群组,且群组包含一个用户列表。 当需要决定用户属于哪些群组时,情况就会比较复杂。文档中给出了一个称之为“索引”的方式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// An index to track Ada's memberships
{
"users": {
"alovelace": {
"name": "Ada Lovelace",
// Index Ada's groups in her profile
"groups": {
// the value here doesn't matter, just that the key exists
"techpioneers": true,
"womentechmakers": true
}
},
...
},
"groups": {
"techpioneers": {
"name": "Historical Tech Pioneers",
"members": {
"alovelace": true,
"ghopper": true,
"eclarke": true
}
},
...
}
}

通过存储 Ada 记录及该群组下的关系来重复某些数据的。 现在,alovelace 在一个群组下进行索引,而 techpioneers 则列在 Ada 的个人资料中。 所以要从该群组删除 Ada,则必须在两个地方更新。

存在双向关系的两个元组,将对方的元素作为”索引“来使用,这样可以快速、高效地提取 Ada 的成员身份,将 ID 列为密钥并将值设为 true 来颠倒数据,使密钥检查变得像读取 /users/$uid/groups/$group_id 和检查是否 null 一样简单。与查询或扫描数据相比,索引的速度更快,效率更高。
虽然数据有点冗余。

数据的保存操作

基本写入操作

使用 set() 将数据保存至特定引用,替换该路径的任何现有数据,注意是替换。 例如,社交博客应用可以使用 set() 添加用户,如下所示:

function writeUserData(userId, name, email, imageUrl) {
firebase.database().ref(‘users/‘ + userId).set({
username: name,
email: email,
profile_picture : imageUrl
});
}

使用 set() 覆盖位于指定位置的数据,包括所有子节点。

追加到数据列表

使用 push() 方法可将数据追加到节点的列表。每次调用push方法时,push 方法均会生成唯一 ID。通过使用自动生成的键,多个客户端可以同时向同一位置添加子节点,而不存在写入冲突。push() 生成的唯一 ID 基于时间戳,因此列表项目会自动按时间顺序排列。

你可以 push() 方法返回的引用的键作为子节点来设置数据。 push() 引用的 .key 属性包含自动生成的键的值。

更新特定字段

使用 update() 方法,同时向一个节点的特定子节点写入数据,而不覆盖其他子节点。
调用 update() 时,可以通过为键指定路径来更新较低级别的子值。 如果将数据存储在多个位置中以便更好地进行缩放,则可使用数据扇出更新这些数据的所有实例。例如,社交博客应用可使用如下所示的代码,创建一篇博文,同时将其更新至最新的 Activity 源和发布用户的 Activity 源:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
function writeNewPost(uid, username, picture, title, body) {
// A post entry.
var postData = {
author: username,
uid: uid,
body: body,
title: title,
starCount: 0,
authorPic: picture
};

// Get a key for a new Post.
var newPostKey = firebase.database().ref().child('posts').push().key;

// Write the new post's data simultaneously in the posts list and the user's post list.
var updates = {};
updates['/posts/' + newPostKey] = postData;
updates['/user-posts/' + uid + '/' + newPostKey] = postData;

return firebase.database().ref().update(updates);
}

在这个例子中,使用 push() 在节点(其中包含 /posts/$postid 内所有用户博文)中创建一篇博文,同时检索相应键。 然后,可以使用该键在用户博文(位于 /user-posts/$userid/$postid 内)中创建第二个条目。

通过使用这些路径,只需调用 update() 一次即可同步更新 JSON 树中的多个位置。通过这种方式同步更新具有原子性:要么所有更新全部成功,要么全部失败。

删除数据

删除数据最简单的方法是在引用上对这些数据所处的位置调用 remove()。

此外,还可以通过将 null 指定为另一个写入操作(例如,set() 或 update())的值来删除数据。 可以结合使用此方法与 update(),在单一 API 调用中删除多个子节点。

接收 Promise

要知道将数据提交到 Firebase Realtime Database 服务器的时间,您可以使用 Promise。set() 和 update() 均能够返回 Promise,可以使用它了解将写入的数据提交到数据库的时间。

数据的检索

检索数据的基础知识以及如何对 Firebase 数据进行排序和过滤。

添加事件监听器

firabase的数据检索不需要主动发出请求,只需要监听事件即可。调用firebase.database.Reference 提供的on()将监听器绑定在对应的数据节点上,收到不同事件时做出对应的处理即可。可以监听的事件如下:

  • value 读取和监听对路径的全部内容所做的更改。
  • child_added 检索项目列表,或监听项目列表中是否添加了新项目。 建议与 child_changed 和 child_removed 配合使用,从而监控对列表所做的更改。
  • child_changed 监听对列表中的项目所做的更改。与 child_added 和 child_removed 结合使用,从而监控对列表所做的更改。
  • child_removed 监听正从列表中删除的项目。与 child_added 和 child_changed 结合使用, 从而监控对列表所做的更改。
  • child_moved 与经过排序的数据配合使用,从而监听对项目优先级所做的更改。
value 事件

使用 value 事件来读取事件发生时某个给定路径下存在的内容的静态快照。此方法将在绑定监听器时触发一次,并在数据(包括子节点)发生任何更改时再次触发。系统将为事件回调传递一个包含该位置中所有数据(包括子节点数据)的快照。如果没有任何数据,则返回的快照为 null。
每当指定的数据库引用中的数据发生更改(包括对子数据进行更改)时,系统就会调用 value 事件,为了限制快照的大小,应该尽可能在最低级别的节点上绑定侦听器。例如,建议不要在数据库的根目录附加侦听器。

下面的示例演示了一个社交博客应用如何从数据库中检索某篇博文的星级数:

1
2
3
4
var starCountRef = firebase.database().ref('posts/' + postId + '/starCount');
starCountRef.on('value', function(snapshot) {
updateStarCount(postElement, snapshot.val());
});

回调函数的参数是一个 snapshot,其中包含事件发生时数据库中指定位置的数据。 通过snapshot.val() 获取该 snapshot 中的数据。

Child 事件

当某个节点的子节点发生更改时,就会触发子事件,例如通过 push() 方法添加新的子节点,或通过 update() 方法更新子节点。

  • child_added 事件通常用于在 Firebase 数据库中检索项目列表。child_added 事件将针对每个现有的子节点触发一次,并在每次向指定的路径添加新的子节点时再次触发。

  • 每次修改子节点或者子节点的后代时,均会触发 child_changed 事件。通常与 child_added 和 child_removed 事件一起使用。

  • 删除直接子节点时,会触发 child_removed 事件。该事件通常与 child_added 和 child_changed 事件一起使用。传递给事件侦听器的快照中包含被移除的子节点的数据。

  • 每当因更新(导致子节点重新排序)而触发 child_changed 事件时,系统就会触发 child_moved 事件。该事件用于通过 orderByChild、orderByValue 或 orderByPriority 中的任何一种进行排序的数据。

同时使用所有这些事件对于侦听数据库中某个特定节点的更改将会非常有用。 例如,社交博客应用可以结合使用这些方法来监控博文评论中的活动,如下所示:

1
2
3
4
var commentsRef = firebase.database().ref('post-comments/' + postId);
commentsRef.on('child_added', function(data) {
addCommentElement(postElement, data.key, data.val().text, data.val().author);
});

取消事件监听器

调用 Firebase 数据库引用的 off() 方法即可删除回调。可以将单个监听器作为参数传递给 off(),移除单个该侦听器。如果在不带任何参数的情况下在该节点位置调用 off(),则将移除该节点位置的所有侦听器。
在父监听器上上调用 off() 时不会自动删除在其子节点上注册的侦听器;还必须在任何子侦听器上调用 off() 才能删除回调。

1
2
3
4
5
6
7
commentsRef.on('child_changed', function(data) {
setCommentValues(postElement, data.key, data.val().text, data.val().author);
});

commentsRef.on('child_removed', function(data) {
deleteComment(postElement, data.key);
});

绑定一次

在某些情况下,你可能希望获得数据的快照,但不需要监听其变化,可以使用 once() 方法来简化这一场景:它只会触发一次,以后就再也不会触发。对于只需加载一次且预计不会频繁变化或需要主动监听的数据,这非常有用。 例如,上述示例中的博客应用使用此方法在用户开始撰写新博文时加载其个人资料:

1
2
3
4
5
var userId = firebase.auth().currentUser.uid;
return firebase.database().ref('/users/' + userId).once('value').then(function(snapshot) {
var username = snapshot.val().username;
// ...
});

结束语

上述简单的阐明了Firebase 实时数据库的基本使用。所有的点都围绕在数据节点上展开。确定了节点路径之后,通过节点路径获取对应节点的引用,在引用上进行一系列的操作,说明白之后其实很简单。可能稍微复杂一点点的就是数据结构扁平化的处理。

结合上回讲到的Firebase 项目的创建和用户管理,现在已经可以很轻松的构建出一个简单的应用逻辑了。下回:Firebase中的Storage,敬请期待