---
title: 集合
slug: collections
date: 0004/01/01
number: 4
points: 5
photoUrl: http://www.flickr.com/photos/73449134@N04/8270793784/
photoAuthor: Mike Lewinski
contents: 學習 Meteor 的核心功能, 實時集合。|理解 Meteor 的數據同步工作方式|將集合與模板結合。|把簡陋的原型變成完整的實時應用程序!
paragraphs: 72
---
在第一章我們提到了 Meteor 的核心功能, 那就是服務器端和客戶端的自動數據同步。
在這一章我們要仔細了解一下它是如何運作的,以及研究那個讓它得以運行的關鍵技術: Meteor **集合(Collection)**。
集合是一個特殊的數據結構,它將你的數據存儲到持久的、服務器端的 MongoDB 數據庫中,并且與每一個連接的用戶瀏覽器進行實時地同步。
我們想讓我們的 post 永久保存并且要在用戶之間共享,所以我們一開始要新建一個叫做 `Posts` 的 collection 來保存它們。
我們現在做一個社交新聞應用, 所以第一件事兒就是做一個人們貼上來的帖子的連接列表。 我們叫它 'post'
很自然, 我們需要把它們存起來。 Meteor 捆綁了 MongoDB 運行在服務器上作為*持久化*存儲。
因此,盡管一個用戶在瀏覽器上有各種狀態(比如他們正在閱讀哪一頁, 或者正在輸入那一條評論), 而服務器上,尤其是 Mongo,保存的是永久保留的*一致*數據。 說到*一致*, 我們是指對于所有用戶來說都是一樣的數據: 每個用戶也許在看不同的頁面, 但是帖子 Post 的主列表對所有用戶來說卻始終是一樣的。
這些數據在Meteor中被存儲在集合(**Collection**)中。 集合是一種特殊的數據結構, 通過發布(publications)和訂閱(subscriptions)機制把數據實時同步上行或者下行到連接著的各個用戶的瀏覽器或者Mongo數據庫中。
讓我們看看如何做到的。
我們希望我們的帖子Post可以持久存儲并分享給用戶們, 所以我們一開始就要建立一個叫 `Posts` 的集合來存儲他們。 如果你還沒有在根文件夾建立一個叫做 `collections/` 的文件夾, 并在里面放一個 `posts.js` 的文件的話,那現在就加上。
~~~js
Posts = new Mongo.Collection('posts');
~~~
<%= caption "lib/collections/posts.js" %>
<%= commit "4-1", "增加一個 post 集合" %>
代碼所在的目錄既不是 `client/` 也不是 `server/` 所以 `Posts` 會共同存在運行在服務器和客戶端。 然而,這個集合的使用在兩種環境下十分不同。
<% note do %>
### 要 Var 還是不要 Var?
在 Meteor 中,關鍵字 `var` 限制對象的作用域在文件范圍內。 我們想要 `Posts` 作用于整個應用范圍內,因此我們在這里不要 Var 這個關鍵字。
<% end %>
### 存儲數據
網絡應用有三種基本方式保存數據,各種方式有不同的角色:
- **瀏覽器內存**:像 JavaScript 變量的這些數據會保存在瀏覽器內存中,意味著他們不是永久性的:它們存在于當前瀏覽器標簽中,當標簽關閉后它們會消失。
- **瀏覽器存儲**:瀏覽器也可存儲較為永久性的數據,使用 cookies 或[本地存儲 Local Storage](http://diveintohtml5.info/storage.html)。雖然數據會在不同 session 間保持,但是只是針對于當前用戶(包括標簽之間)但不能輕易地共享給其他用戶。
- **服務器端數據庫**:你想永久保存數據并且提供給多個用戶的最好方法是數據庫(MongoDB 是 Meteor 應用默認的方案)。
Meteor 使用所有三種方式,有時會從一個地方同步數據到另一個地方(我們會馬上看到)。話雖如此,數據庫仍然是包含數據主副本的“規范化的”數據源。
### 客戶端與服務器
不在 `client/` 或 `server/` 文件夾中代碼會在**客戶端和服務器端**運行。所以 `Posts` 集合在**客戶端和服務器端**都可用。但是,在各自環境下所起的作用有很大不同。
在服務器,集合有一個任務就是和 Mongo 數據庫聯絡,讀取任何數據變化。 在這種情況下,它可以比對標準的數據庫。
在客戶端,集合是一個*安全*拷貝來自于實時一致的數據*子集*。客戶端的集合總是(通常)透明地實時更新數據子集。
<% note do %>
### Console,Console 與 Console
在這一章,我們開始使用**瀏覽器控制臺**,不過不要和**終端**、**Meteor Shell** 或者 **Mongo Shell** 搞混了。 現在對它們做個比對。
#### 終端命令行(Terminal)
<%= screenshot "terminal", "終端命令行" %>
- 由操作系統啟動
- **服務器端** `console.log()` 會輸出到這里
- 有 `$` 提示符
- 通常也被成為外殼程序 Shell,Bash
#### 瀏覽器控制臺(Browser Console)
<%= screenshot "browser-console", "The Browser Console" %>
- 在瀏覽器內啟動,執行 Javascript 代碼
- **客戶端**的 `console.log()` 會輸出到這里
- 提示符是 `?`
- 也通常被稱作 Javascript 控制臺或者開發工具控制臺(DevTools Console)
#### Meteor Shell
<%= screenshot "meteor-shell", "Meteor Shell" %>
- 在 Terminal 用 `meteor shell` 調用。
- 使你直接接觸到應用的服務器端代碼。
- 提示符:`>`。
#### Mongo 外殼程序 (Mongo Shell)
<%= screenshot "mongo-shell", "Mongo 外殼" %>
- 從終端由 `meteor mongo` 或者 `mrt mongo` 來啟動
- 你可以在這里直接操作 App 的數據庫
- 提示符 `>`
- 也被稱作 Mongo 控制臺 (Mongo Console)
注意在各種情況下你都不需要敲提示符(`$` `?` 或 `>`)在命令前面。而且你可以認定任何不是用提示符起始的行都是前一個命令的輸出結果。
<% end %>
### 服務器端的集合
在服務器端,集合可以像 API 一樣操作 Mongo 數據庫。在服務器端的代碼,你可以寫像 `Posts.insert()` 或 `Posts.update()` 這樣的 Mongo 命令,來對 Mongo 數據庫中的 `posts` 集合進行操作。
如果想直接看看 MongoDB 數據庫,可以打開第二個終端窗口(這時候 Meteor 還在第一個終端窗口繼續運行呢),在你應用的目錄,輸入命令 `meteor mongo` 啟動 Mongo Shell 外殼程序。現在你可以輸入標準的 Mongo 命令(如同以往,你可以敲 `ctrl+c` 快捷鍵退出)。比如讓我們插入一個新的 post:
~~~bash
meteor mongo
> db.posts.insert({title: "A new post"});
> db.posts.find();
{ "_id": ObjectId(".."), "title" : "A new post"};
~~~
<%= caption "Mongo Shell" %>
<% note do %>
### Meteor.com 上的 Mongo
注意如果你把應用部署在 *.meteor.com 上,你一樣可以通過 `meteor mongo myApp` 的方式進入你應用的 Mongo shell 進行操作。
而且你還可以輸入 `meteor logs myApp` 得到你應用的 log 日志。
<% end %>
Mongo 的語法由于借鑒了 Javascript 的語法所以十分熟悉。我們現在在 Mongo 外殼里不做過多的數據操作,不過我們可以隨時來這里檢查數據確保他們正常存在。
### 客戶端集合
客戶端的集合更加有趣。當你在客戶端申明 `Posts = new Mongo.Collection('posts');` 你實際上是創建了一個本地的,在瀏覽器緩存中的真實的 Mongo 集合。 當我們說客戶端集合被"緩存"是指它保存了你數據的一個*子集*,而且對這些數據提供了十分*快速*的訪問。
有一點我們必須要明白,因為這是 Meteor 工作的一個基礎: 通常說來,客戶端的集合的數據是你 Mongo 數據庫的所有數據的一個子集(畢竟我們不會想把*整個*數據庫的數據全傳到客戶端來)。
第二,那些數據是被存儲在*瀏覽器內存*中的,也就是說訪問這些數據幾乎不需要時間,不像去服務器訪問 `Posts.find()` 那樣需要等待,因為數據事實上已經載入了。
<% note do %>
### 介紹 MiniMongo
Meteor 的客戶端 Mongo 的技術實現被成為 MiniMongo。它目前還不是一個完美的實現,而且你會發現偶爾 Mongo 的功能在這里不能實現。不過本書中涉及到的功能都是可以在 Mongo 和 MiniMongo 中實現的。
<% end %>
### 客戶端-服務器通訊
這一切最關鍵的是如何讓客戶端的集合數據與服務器端同名的集合數據同步(以我們現在這個例子來說是 `posts`)。
與其現在就解釋細節不如讓我們先來看看發生了什么。
現在我們打開兩個瀏覽器窗口,分別打開他們的瀏覽器控制臺。然后在終端命令行打開 Mongo 外殼程序。
現在可以在這三個地方看到我們早前時候建立的那個文檔。(注意,我們應用的*用戶界面*依然顯示著我們之前的三個演示 post,請忽略它們。)
~~~bash
> db.posts.find();
{title: "A new post", _id: ObjectId("..")};
~~~
<%= caption " Mongo 外殼" %>
~~~js
? Posts.findOne();
{title: "A new post", _id: LocalCollection._ObjectID};
~~~
<%= caption "第一個瀏覽器控制臺" %>
讓我們來創建一個帖子。在其中一個瀏覽器窗口中運行這個插入命令:
~~~js
? Posts.find().count();
1
? Posts.insert({title: "A second post"});
'xxx'
? Posts.find().count();
2
~~~
<%= caption "第一個瀏覽器控制臺" %>
毫無疑問,這個帖子被加入到本地集合中。現在讓我們查看一下 Mongo:
~~~bash
? db.posts.find();
{title: "A new post", _id: ObjectId("..")};
{title: "A second post", _id: 'yyy'};
~~~
<%= caption "Mongo 外殼" %>
如同你所看見的那樣,這個帖子一路上行一直到 Mongo 數據庫中,而我們卻沒有為這個連接客戶端和服務器的過程寫任何一行代碼。(嚴格地說,我們的確寫了_一行_代碼:`new Mongo.Collection('posts')`)。但是這沒關系!
現在到第二個瀏覽器窗口的控制臺中輸入這個命令:
~~~js
? Posts.find().count();
2
~~~
<%= caption "第二個瀏覽器的控制臺" %>
這個帖子居然也在這兒!甚至于我們連刷新都沒有在第二個瀏覽器做過,更何況我們也沒有寫任何代碼來推送更新。這一切像魔術一般 - 而且是即時的,盡管這一切以后看起來都很顯而易見。
實際情況是服務器端的集合被客戶端的集合通知說有一個新帖子,然后執行了一個任務把這個帖子放入 Mongo 數據庫,進而會送到所有連接著的 `post` 即可。
在瀏覽器的控制臺取出所有的帖子沒什么用處。我們以后會學習如何把這些數據顯示在模板中,并把這個簡單的 HTML 原型變成一個有用的實時 Web 應用。
### 保持實時
從瀏覽器控制臺看到集合算是一件事兒,我們更應該關注的是能在屏幕上顯示數據和數據的變化。要做到這一點,我們需要把我們的應用從一個單一顯示靜態數據的`頁面`變成可以實時動態數據的`應用`。
讓我們看怎么做。
### 從數據庫提取數據
首先我們先放點數據在數據庫里。我們要做的是讓服務器第一次初始啟動的時候從一個數據文件中讀取數據結構存在`Posts` 集合中。
首先我們要確保數據庫中沒有數據。我們使用 `meteor reset` 命令清空數據庫初始化我們的項目。當然,如果在真實的正在運行的正式項目上請務必十分小心。
停止 Meteor 服務(通過鍵入 `ctrl-c`) 然后在命令行輸入:
~~~bash
meteor reset
~~~
這個 reset 命令徹底地把 Mongo 數據庫清空了。在開發的時候這個命令很有用,尤其當我們的數據庫發生數據混亂的時候。
現在重啟我們的 Meteor 應用:
~~~bash
meteor
~~~
現在數據庫已經清空,我們可以增加下面的代碼以便在服務器啟動時候檢查數據庫 `Posts` 集合,如果為空則載入三條帖子。
~~~js
if (Posts.find().count() === 0) {
Posts.insert({
title: 'Introducing Telescope',
url: 'http://sachagreif.com/introducing-telescope/'
});
Posts.insert({
title: 'Meteor',
url: 'http://meteor.com'
});
Posts.insert({
title: 'The Meteor Book',
url: 'http://themeteorbook.com'
});
}
~~~
<%= caption "server/fixtures.js" %>
<%= commit "4-2", "在 posts 集合中加入數據." %>
我們把這個文件放到了 `server/`目錄中,因此永遠不會被加載到任何用戶的瀏覽器中。這段代碼在服務器啟動的時候會立即運行,然后調用`插入`功能在數據庫的 `posts` 集合中插入三條簡單的帖子。因為我們還沒有加入任何數據安全功能,所以無論在服務器還是在客戶端運行這個文件都事實上沒有區別的。
現在我們用 `meteor` 命令啟動服務,這三條帖子會被裝在到數據庫中。
### 動態數據
現在如果我們打開一個瀏覽器的控制臺,我們可以看到這三個帖子都被轉載到 MiniMongo 中了:
~~~js
? Posts.find().fetch();
~~~
<%= caption "瀏覽器控制臺" %>
要把這些 post 渲染到 HTML 中,我們需要用模板 helper。
在第三章中,我們看到 Meteor 允許我們把 *數據上下文* 捆綁到我們的 Spacebars 模板上,從而用 HTML 視圖顯示這些簡單的數據結構。 我們可以同樣把我們的集合數據捆綁起來。我們馬上就替換掉靜態的 `postsData` Javascript 對象成為一個動態地集合。
現在請隨手刪掉`postsData` 代碼。下面是 `posts_list.js` 修改后的樣子:
~~~js
Template.postsList.helpers({
posts: function() {
return Posts.find();
}
});
~~~
<%= caption "client/templates/posts/posts_list.js" %>
<%= highlight "2~4" %>
<%= commit "4-3", "把集合連接到 `postsList` 模板上。" %>
<% note do %>
### 查找與提取
在 Meteor 中,`find()` 返回值是一個*游標*。游標是一種[從動數據源](http://docs.meteor.com/#find)。如果你想輸出內容,你可以對游標使用 `fetch()` 來把游標轉換成數組。
Meteor 十分智能地在應用中保持游標狀態而避免動不動就把游標變成數組。這就造成了你不會經常在 Meteor 代碼中看到 `fetch()` 被調用(基于同樣原因,我們在上述例子中也沒有使用 fetch )。
<% end %>
現在,與其把帖子們變成靜態的數組,不如直接把游標賦給 `posts` 幫助方法。但是如何做得到呢?如果我們回到瀏覽器上,我們可以看到:
<%= screenshot "4-3", "使用活數據" %>
我們可以清晰地看到 `{{#each}}` 幫助方法已經枚舉了 `Posts` 中的所有帖子,而且顯示到屏幕上。服務器端的集合從 Mongo 數據庫中取出貼子數據,通過網絡傳到客戶端的集合中,進而 handlers 的幫助方法 把這些數據加載到模板中。
現在我們只需要再走一步;讓我們通過控制臺增加另一個帖子:
~~~js
Posts.insert({
title: 'Meteor Docs',
author: 'Tom Coleman',
url: 'http://docs.meteor.com'
});
~~~
<%= caption "瀏覽器控制臺" %>
再看瀏覽器 - 你會看到這些:
<%= screenshot "4-4", "通過控制臺增加帖子" %>
你剛才第一次看到從動功能生效了。當我們告訴 handlebars 去枚舉 `Posts.find()` 游標的時候,它自己知道如何發現游標的變動,從而用最簡單的方式將變化后的正確數據顯示到屏幕上。
<% note do %>
### 檢查 DOM 變動
在目前的情況下,最簡單的變動應該就是增加一個 `<div class="post">...</div>`。 如果你想看看是否的確如此,你可以打開 DOM 檢查器然后選擇某個已經存在的帖子的 `<div>` 。
現在在 Javascript 控制臺,插入另外一個帖子。當你回到檢查器,會發現一條新的 `<div>` 對應了新增的那個帖子。同時,原先選中的*那個*舊的 `<div>` 仍然存在。這是一種判斷元素是否被重新渲染的有效方式。
<% end %>
### 連接集合: 發布與訂閱
到此為止,我們仍然用著 `autopublish` 這個包,這個包并不是為正式產品化的應用程序準備的。正如它的名字陳述的那樣,它簡單地把整個集合分享給所有連接的客戶端。這個可不是我們期望的樣子,所以讓我們去掉它。
打開一個終端窗口,輸入:
~~~bash
meteor remove autopublish
~~~
這個操作有了立即的反應。當你打開瀏覽器,你會發現所有的帖子都不見了!這是因為我們一直依賴于 `autopublish` 來讓我們的客戶端可以鏡像般地得到數據庫中的所有帖子。
最終我們需要做得到我們僅僅把我們客戶端需要看到的帖子傳輸過來(需要考慮分頁的情況)。不過暫時我們可以先設置把 `Posts` 所有帖子都發布出來。
為達到這個目的,我們建立一個簡單的 `Publish()` 函數,它僅僅返回一個反映所有帖子的游標。
~~~js
Meteor.publish('posts', function() {
return Posts.find();
});
~~~
<%= caption "server/publications.js" %>
在客戶端我們需要*訂閱*這個發布。我們僅僅需要增加這樣一行到 `main.js` 文件中:
~~~js
Meteor.subscribe('posts');
~~~
<%= caption "client/main.js" %>
<%= commit "4-4", "刪除 `autopublish` 并建立基本的發布功能" %>
如果你現在看一眼瀏覽器,發現帖子都回來了。哇!好險啊!
### 總結
我們都做了什么?盡管我們還沒有用戶界面,至少我們已經有了一個能用的應用。我們可以把這個應用部署到網絡上,(使用瀏覽器的控制臺)發帖子,并看到帖子顯示在其他用戶的瀏覽器上。