over 1 year ago

Firebase提供Realtime DB雲端資料庫服務,資料儲存在JSON Tree當中並且將資料即時同步給所有連結中的使用者!其資料庫底層為NoSQL,所以在設計資料庫架構時必須特別留意
本章大致會講解CRUD資料使用、資料權限(Security)以及基本的資料架構設計,可以搭配官網教學一同觀賞
以下是編寫的程式碼,操作時記得先註冊,才能使用後續的功能

首先看主控台的部分,點擊"Realtime Databse"可以看到右方出現四個子分頁選項,分別是資料:也就是所有儲存的資料顯示處、規則:定義每個資料節點的讀寫權限、用量:觀察使用流量、備份:因為是免費專案所以沒有備份服務
一開始為了開發,可以將“規則”改為

{
  "rules": {
    ".read":true,
    ".write":true,
  }
}

這表示 讀寫資料完全不受限,到後面我們會修改此規則

在開始寫程式之前,必須先規劃好基本的資料庫架構,這部分參考官方文件Structure Your Database,得出幾個要領
1. Data Node資料節點:
在NoSQL中,不像是RDBMS有強制的預設Data schema、複雜的Table與交錯的關係,資料採用JSON格式儲存,所以沒有固定格式與參數規範;在Realtime DB中,資料是放在JSON Tree當中,而資料中每個參數都是個節點。
舉例來說,"userA"代表為一個節點;而"name"是"userA"的child,同時也是一個節點

{
      "userA":{
        "name":"hello"
      }
}

資料儲存的方式與節點息息相關,這點必須要留意

2. 避免層狀結構(Avoid nesting data):
在DB中,Firebase預設資料最多可以到32層深,但是如果資料層數太多,當你想要取得某節點資料,DB會自動將該節點與其子節點資料全數加載,這會造成效能與流量的浪費;
所以應該要採用扁平化設計(Flatten data structures),也就是反范式denormalization;

相對應的范式(normalization)設計是代表說 將資料分散在各個Table,彼此有相關聯就採用foreign key和join查詢,如user與group關係(user在多個group中且group有多個user),在這種情況下會有三個Table- User / Group和一個放置user.id <--> group.id的Mapping Table,好處是如果要單獨修改User或Group屬性十分方便,但缺點是資料破碎化以及消耗性能在join上,詳細可以查詢資料庫正規化的部分。

在NoSQL中,因為捨棄了RDBMS的資料關聯性也沒有強大的SQL可以做複雜的join,所以改用反范式設計,寧可將資料重複放在多個地方,如果讀的次數遠大於寫,這樣的方式就很適合(這也是為什麼NoSQL更適合在現在數據氾濫的時代,ex. 社群網站)
以下範例取自官網,可以看到user方儲存了所參加group,同時group也儲存了所有的user

{
      "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
          }
        },
        ...
      }
}

接著就要進入程式碼的部分,最一開始要放"網路設定"的初始化code

一.創建帳號

使用firebase.database().ref('users/' + loginUser.uid).set(資料),firebase.database()和上一篇的firebase.auth()用意相同,先取得database的實例化,接著看到ref,ref指的是資料節點的路徑,這裡我預設所有使用者都儲存在"/users/"加上每個user的獨特uid,set則是寫入資料的方式!

firebase.auth().createUserWithEmailAndPassword(account.value, pwd.value).then(function(){
        //登入成功後,取得登入使用者資訊

        loginUser = firebase.auth().currentUser;
      console.log("登入使用者為",loginUser);
      firebase.database().ref('users/' + loginUser.uid).set({
        email: loginUser.email,
        name: name.value,
        age : age.value
      }).catch(function(error){
        console.error("寫入使用者資訊錯誤",error);
      });
    })

成功創建後,可以進入主控台查看資料生成的樣式

二.取得資料

如果要取得剛才創建的資料,可以透過firebase.database().ref('/users/' + loginUser.uid).once('value').then(function(snapshot) {}),同樣先透過ref指到資料的位置,接著呼叫once 代表我只取一次資料而非持續關注,接著在snapshot中可以得到所有的資料

//取得目前使用者資訊

var userInfoBtn = document.getElementById("userInfoBtn");
var userInfo = document.getElementById("userInfo");
userInfoBtn.addEventListener("click",function(){
    //資料讀取一次後就不再理會

  firebase.database().ref('/users/' + loginUser.uid).once('value').then(function(snapshot) {
    var userInfoText = "使用者姓名:"+snapshot.val().name+", 使用者年齡:"+snapshot.val().age;
    console.log(userInfoText);
    userInfo.innerHTML = userInfoText;
  });
},false);

如果希望關注節點資料變化可以透過userRef.on('value',function(snapshot){~})

//關注使用者清單

var userRef = firebase.database().ref('users');
userRef.on('value', function(snapshot) {
  console.log("目前所有使用者:",snapshot.val());
});
三.刪除資料

對節點使用remove()即可,等同於set(null);這裡我刪除的不是該使用者資料,而僅是底下的"name",刪除後name節點就消失了

//刪除使用者資料

var delUserInfoBtn = document.getElementById("delUserInfoBtn");
delUserInfoBtn.addEventListener("click", function(){
    firebase.database().ref('/users/' + loginUser.uid + "/name").remove().then(function(){
    console.log("成功刪除")
  });
}, false);
四.在清單中新增資料

先前新增user時,是透過user id建立每個使用者一個專屬的節點;但是在user底下,我希望建立屬於User的Post,但是多個Post沒有專屬的id可以辨別,這時候可以使用push()
一開始我先指到節點var postRef = firebase.database().ref('/posts/' + loginUser.uid);,接著使用postRef.push(),這個時候DB會回傳一個基於時間雜湊的Unique Key,接著使用set()方法創建資料

//新增Post

var postSmtBtn = document.getElementById("postSmtBtn");
var postTitle = document.getElementById("postTitle");
var postContent = document.getElementById("postContent");
var postLimitAge = document.getElementById("postLimitAge");
postSmtBtn.addEventListener("click", function(){
    var postRef = firebase.database().ref('/posts/' + loginUser.uid);
    postRef.push().set({
    uid: loginUser.uid,
    title: postTitle.value,
    content:postContent.value,
    age:parseInt(postLimitAge.value)
  }).then(function(){
    console.log("新增Post成功");
  }).catch(function(err){
    console.error("新增Post錯誤:",err);
  })
})

如果要關注子節點是否被新增、刪除等,可以透過on綁定事件,事件包含child_addedchild_changed等,相對應子節點新增、修改、刪除,其中data.key就是剛才所說透過push()取得的Unique Key!

var postRef = firebase.database().ref('/posts/' + loginUser.uid);
postRef.on('child_added', function(data) {
  addCommentElement(postElement, data.key, data.val().text, data.val().author);
});

postRef.on('child_changed', function(data) {
  setCommentValues(postElement, data.key, data.val().text, data.val().author);
});

postRef.on('child_removed', function(data) {
  deleteComment(postElement, data.key);
});
五.加入資料權限

目前的資料權限是任何人都可以讀寫User和Post的資料,現在要來修改這部分
首先看到Firebase針對每個節點可以設定的四種規則型態

Title 說明
.read 針對讀的權限,淺層規則覆蓋深層
.write 針對寫的權限,淺層規則覆蓋深層
.validate 資料驗證,不會影響子節點
.indexOn 節點的索引設定,輔助排序與過濾功能

接著Firebase有提供基本的參數可以使用

Title 說明
now 主要用來取得目前時間,格式為從1970-1-1到目前時間點的millisecond表示
驗證:創建的資料時間必須小於當今時間
.validate: newData.val() {小於} now
root 指向資料庫的最頂層,用來取得其他節點資料相當方便
查看user欄位中的特定id是否存在
root.child('/users/'+$uid).exists()
newData 套用於write和valite上,取得Request挾帶的資料物件
驗證要新增的age欄位是否大於10
newData.child('age').val()>10
data 相對應於newData,data表示原本存在於資料庫中的現有資料
如果要讀取,檢查資料是否為公開
".read": "data.child('public').val() == true"
$variable 用來表示動態的子節點路徑,相當於wildcard
auth 取得通過驗證的使用者資料
檢查資料的uid和驗證者的uid是否一致
data.child('uid').val()==auth.uid

編寫規則方式是先在資料節點設定"規則型態":"運用基本參數與判斷是"
接著透過幾個例子,來實際體驗一下如何設計自己的資料權限規則

  1. 讀與寫:淺層覆蓋深層
    假設使用者要讀取"/foo/bar",在"foo"節點時假設已經通過.read權限,即使"bar"節點.read設為false,使用者依然可以讀取!
    所以盡量不要再上層宣告.read和.write權限,而是在靠底層的子節點宣告以免全局污染
    另外 如果該節點沒有宣告讀寫權限則自動視為false,這點也要特別注意
    {
      "rules": {
         "foo": {
            // allows read to /foo/
            ".read": "data.child('baz').val() === true",
            "bar": {
              / ignored, since read was allowed already */
              ".read": false
            }
         }
      }
    }
    
  2. 資料驗證
    validate則沒有全局污染的問題
    另外不可以拿validate當作filter,因為DB操作的原子性,如果驗證失敗會直接返回Error並取消此次write操作
    {
      "rules": {
        // 大家都可以write
        ".write": true,
        "widget": {
          // 要新增的資料必須包含這兩個欄位
          ".validate": "newData.hasChildren(['color', 'size'])",
          "size": {
            // “”必須是數字且值在零到九九之間
            ".validate": "newData.isNumber() &&
                          newData.val() >= 0 &&
                          newData.val() <= 99"
          },
          "color": {
            // 顏色的值必須包含在"/valid_colors/"的子節點
            ".validate": "root.child('valid_colors/' + newData.val()).exists()"
          }
        }
      }
    }
    
    //寫入
    
    db.ref("/widget").set({size: 22}) //失敗,少color欄位
    
    db.ref("/widget").set({ size: 'foo', color: 'red' });//失敗,假設red不存在於/valid_colors/red
    
    db.ref("/widget").set({ size: 21, color: 'blue'});//成功
    
    這裡可以觀察到 newData在不同資料層代表的數值是不相同的!例如在"widget"層代表{color:blue,size:20},但是到了"size層",newData變成20,Firebase會自動將值取出對應不同的資料層!

最後分享一下這個範例設定的資料驗證規則
我的資料路徑為 root/posts/ $uid:創建的使用者id/ $postId:自動產生的Key
root/posts/$uid,我設定讀的權限為必須登入("auth!=null)且該使用者成功創建(root.child('/users/'+$uid).exists())且只能寫入自己的路徑下($uid==auth.uid)
root/posts/$uid/$postID,也就是我存放post的路徑,驗證資料的年齡是否為數字(isNumber)、讀寫則驗證合法使用者而已

{
      "rules": {
          "posts":{
            "$uid":{
              ".read": "auth!=null
                        && root.child('/users/'+$uid).exists() 
                        && $uid==auth.uid",
              ".indexOn": ["age"],
              "$postId":{
                ".validate":"newData.hasChildren(['age']) 
                        && newData.child('age').isNumber() 
                        && newData.child('age').val()>10",
                ".read": "auth!=null 
                        && root.child('/users/'+$uid).exists() 
                        && data.child('uid').val()==auth.uid",
                ".write": "auth!=null 
                        && root.child('/users/'+$uid).exists() 
                        && newData.child('uid').val()==auth.uid",
              }
            }
          },
        "users":{
          "$uid":{
            ".read":"$uid==auth.uid",
            ".write":"$uid==auth.uid"
          }
        }
      }
}

至於想知道newDataauth等有什麼方法可以呼叫,就必須查看官方文件 Firebase Database Security Rules API,常用的是透過child()/parent()先指到路徑,並透過val()將值取出比對,auth我只用到auth.uid。

六.資料排序、過濾與索引

Firebase提供資料排序與過濾的方法,在原本節點後方加上
排序:
三種方法,orderByChild(特定欄位):針對特定的子欄位、orderByKey(無參數):依據資料的Key(如果是用push()新增的話等同於創建順序)、orderByValue(無參數):排列順序等同於orderByChild(“欄位”)
過濾:
過濾必須跟著排序,提供五種方法,limitStartAt(數字):取資料序列前幾位<--> limitToLast(數字)startAt(值):大於等於值<-->endAt(值):小於等於值<-->equalTo(值):等於值

postListBtn.addEventListener("click", function(){
  var postsRef = firebase.database().ref('posts/' + loginUser.uid).orderByChild("age").startAt(12);
  console.log("取得使用者所有超過12歲的Post")
  postsRef.once('value').then(function(snapshot){
    snapshot.forEach(function(childSnapshot) {
      console.log(childSnapshot.val());
    });
  })
}, false);

firebase.database().ref('posts/' + loginUser.uid).orderByChild("age").startAt(12)代表我選取該使用者所有post並且依照age欄位排序取出大於12的資料

另外如果資料越加龐大時,適當的加入索引可以大幅提升讀寫速度,因為我設計是用"age"排序,所以我在資料規則中加入了".indexOn": ["age"],可以針對常用欄位做索引。

最後總結:
新增可以透過Ref.set()或是Ref.push().set()、更新Ref.update()(這篇沒提但是不難理解)、刪除Ref.remove()、讀值可分成一次性Ref.once(value).then((snapshot)=>{snapshot.val()~})或是持續關注Ref.on('value', function(snapshot){snapshot.val()~}
接著是資料權限規則處理,.read.write.validate.indexOn,不同資料層有不同的定義,記得讀寫會由淺層覆蓋!
最後是資料的過濾與排序,order和filter,記得使用時要先在 rule中加入.indexOn["${欄位}"]喔
剩下的多看官網例子並動手寫code就能夠加深理解囉

← Firebase網頁教學[二] - 驗證篇 Firebase網頁教學[四]-Storage篇 →
 
comments powered by Disqus