פרויקט קצר של שרת לקוח בשימוש ionic + node


לאחרונה, השתתפתי בהאקתון, הקמנו קבוצה ובנינו ב-24 שעות מערכת קטנה אבל מרשימה.
הרעיון היה, לבנות POC של מערכת שמדגימה הגנה ע"י smart card על מכשירי IOT (אם משהו לא מובן אפשר לשאול בתגובות).
לאחר שעות רבות של עבודה ואינספור באגים, הצלחנו להעמיד מערכת שמדגימה את זה בצורה ממש יפה ומרשימה. אני הייתי אחראי על פיתוח ה-client וה-server ולכן אתמקד בשני הנושאים האלו.
רק ניתן ספויילר שהמאמץ השתלם וזכינו במקום השני 😏.

Client

בצד של הקליינט כתבתי אפליקציית ionic ששולחת http post לשרת עם צמד של key ו-value.
התחלתי את הפיתוח עם template שנקרא sidemenu ע"י הפקודה:

ionic start myApp sidemenu

הפקודה הזו יוצרת פרויקט שמוכן לשימוש ויש בו שתי ספריות בתוך ספריית \src\pages\. ספריית home וספריית list.
ספריית home אחראית על המסך הראשון ועליה בעיקר עבדתי.
את הקובץ home.html שיניתי לקוד הבא:

home.html

 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
44
45
46
47
48
49
50
<ion-header>
  <ion-navbar>
    <button ion-button menuToggle>
      <ion-icon name="menu"></ion-icon>
    </button>
    <ion-title>Home</ion-title>
  </ion-navbar>
</ion-header>
<ion-content> <ion-list> <ion-item (click)="doorClicked()"> <ion-thumbnail item-start> <img src="assets/icon/door.jpg"> </ion-thumbnail> <h2>Door</h2> <p *ngIf="doorStatus==1">OPEN</p> <p *ngIf="doorStatus==0">CLOSE</p> </ion-item> <ion-item (click)="cameraClicked()"> <ion-thumbnail item-start> <img src="assets/icon/camera.jpg"> </ion-thumbnail> <h2>Camera</h2> <p *ngIf="cameraStatus==1">ON</p> <p *ngIf="cameraStatus==0">OFF</p> </ion-item> <ion-item (click)="safeboxClicked()"> <ion-thumbnail item-start> <img src="assets/icon/safebox.jpg"> </ion-thumbnail> <h2>Safe Box</h2> <p *ngIf="safeboxStatus==1">OPEN</p> <p *ngIf="safeboxStatus==0">CLOSE</p> </ion-item> <ion-item (click)="device4Clicked()"> <ion-thumbnail item-start> <img src="assets/icon/device4.jpg"> </ion-thumbnail> <h2>Device 4</h2> <p *ngIf="device4Status==1">ON</p> <p *ngIf="device4Status==0">OFF</p> </ion-item> </ion-list> </ion-content>

בקוד הזה יצרתי רשימה של 4 מכשירים שעליהם אני רוצה לשלוט. לכל אחד הוספתי תמונה קטנה, כותרת, ומצב (יכול להיות OPEN/CLOSE או ON/OFF). לחיצה על כל אחד מהמכשירים מפעילה פונקציה התואמת לאותו המכשיר.

להלן ה-GUI של האפליקציה:



את המימוש של הפונקציות ושל כל הלוגיקה כתבתי בקובץ home.ts.

home.ts

  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
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
import { Component } from '@angular/core';
import { NavController } from 'ionic-angular';
import { HTTP } from '@ionic-native/http';
import { Http, Headers, RequestOptions } from '@angular/http';
import 'rxjs/add/operator/catch';
import 'rxjs/add/operator/toPromise';
import { Injectable } from '@angular/core';

@Component({
  selector: 'page-home',
  templateUrl: 'home.html'
})
@Injectable()
export class HomePage {
public doorStatus = 0;
public cameraStatus = 0;
public safeboxStatus = 0;
public device4Status = 0;
  constructor(public navCtrl: NavController, private http: Http) {        
  }

  doorClicked(){
    let status = 'a0';
    let headers = new Headers(
    {
      'Content-Type' : 'application/json'
    });
    let options = new RequestOptions({ headers: headers });
    if(this.doorStatus == 0){
      this.doorStatus = 1;
      status = 'a1';
    }
    else{
      this.doorStatus = 0;
      status = 'a0';
    }
    let data = JSON.stringify({
      t: status
    });

    return new Promise((resolve, reject) => {
      this.http.post('http://localhost:3000/', data, options)
      .toPromise()
      .then((response) =>
      {
        console.log('API Response : ', response.json());
        resolve(response.json());
      })
      .catch((error) =>
      {
        console.error('API Error : ');
      });
    });    
  }

  cameraClicked(){
    let status = 'b0';
    let headers = new Headers(
    {
      'Content-Type' : 'application/json'
    });
    let options = new RequestOptions({ headers: headers });

    if(this.cameraStatus == 0){
      this.cameraStatus = 1;
      status = 'b1';
    }
    else{
      this.cameraStatus = 0;
      status = 'b0';
    }

    let data = JSON.stringify({
      t: status
    });

    return new Promise((resolve, reject) => {
      this.http.post('http://localhost:3000/', data, options)
      .toPromise()
      .then((response) =>
      {
        console.log('API Response : ', response.json());
        resolve(response.json());
      })
      .catch((error) =>
      {
        console.error('API Error : ');
      });
    });    
  }
  
  safeboxClicked(){
    let status = 'c0';
    let headers = new Headers(
    {
      'Content-Type' : 'application/json'
    });
    let options = new RequestOptions({ headers: headers });

    if(this.safeboxStatus == 0){
      this.safeboxStatus = 1;
      status = 'c1';
    }
    else{
      this.safeboxStatus = 0;
      status = 'c0';
    }

    let data = JSON.stringify({
      t: status
    });

    return new Promise((resolve, reject) => {
      this.http.post('http://localhost:3000/', data, options)
      .toPromise()
      .then((response) =>
      {
        console.log('API Response : ', response.json());
        resolve(response.json());
      })
      .catch((error) =>
      {
        console.error('API Error : ');
      });
    });    
  }

  device4Clicked(){
    let status = 'd0';
    let headers = new Headers(
    {
      'Content-Type' : 'application/json'
    });
    let options = new RequestOptions({ headers: headers });

    if(this.device4Status == 0){
      this.device4Status = 1;
      status = 'd1';
    }
    else{
      this.device4Status = 0;
      status = 'd0';
    }

    let data = JSON.stringify({
      t: status
    });

    return new Promise((resolve, reject) => {
      this.http.post('http://localhost:3000/', data, options)
      .toPromise()
      .then((response) =>
      {
        console.log('API Response : ', response.json());
        resolve(response.json());
      })
      .catch((error) =>
      {
        console.error('API Error : ');
      });
    });    
  }
}

בתחילת הקוד יש לכל מכשיר משתנה ששומר את המצב של אותו מכשיר. למשל doorStatus קובע אם הדלת פתוחה (1) או סגורה (0).
לאחר מכן יש מימוש של כל ארבעת הפונקציות שראינו בקובץ ה-HTML. כל פונקציה עושה את אותה הלוגיקה למכשיר אחר. 
הלוגיקה מאוד פשוטה, בלחיצה על הכפתור המצב של מכשיר משתנה מ-0 ל-1 (או מ-1 ל-0). בנוסף נשלח http post שמכיל צמד של key ו-value. במקרה הזה ה-key הוא תמיד t. וה-value משתנה לפי המכשיר והמצב שלו.
עבור המכשיר הראשון השתמשתי ב-a עבור השני ב-b וכן הלאה. כדי לשלוח את מצב המכשיר השתמשתי ב-0 ו-1.
כך ש-a1 אומר שהמכשיר הראשון דולק ו-a0 אומר שהוא כבוי. b0 אומר שהמכשיר השני כבוי, וכן הלאה.
אני יודע שאפשר לקצר את הקוד וליפות אותו, אבל בהאקתון אין הרבה זמן, אז לא מבזבזים זמן על קוד יפה וקצר.
שימו לב שה-ip שבו השתמשתי הוא localhost. זה נכון כשמריצים את הלקוח והשרת על אותו מחשב. אם רוצים שהשרת יהיה במחשב אחר צריך לשנות את ה-localhost ל-ip האמיתי של השרת ולשים לב שאנחנו באותה הרשת.

קובץ נוסף שקצת נגעתי בו הוא app.module.ts. 
app.module.ts


 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
import { BrowserModule } from '@angular/platform-browser';
import { ErrorHandler, NgModule } from '@angular/core';
import { IonicApp, IonicErrorHandler, IonicModule } from 'ionic-angular';

import { MyApp } from './app.component';
import { HomePage } from '../pages/home/home';
import { ListPage } from '../pages/list/list';

import { StatusBar } from '@ionic-native/status-bar';
import { SplashScreen } from '@ionic-native/splash-screen';
import { HTTP } from '@ionic-native/http';
import {HttpModule} from '@angular/http';

@NgModule({
  declarations: [
    MyApp,
    HomePage,
    ListPage
  ],
  imports: [
    BrowserModule,
    IonicModule.forRoot(MyApp),
    HttpModule
  ],
  bootstrap: [IonicApp],
  entryComponents: [
    MyApp,
    HomePage,
    ListPage
  ],
  providers: [
    StatusBar,
    SplashScreen,
    HTTP,
    
    {provide: ErrorHandler, useClass: IonicErrorHandler}
  ]
})
export class AppModule {}



מה שהוספתי מסומן בצהוב. אלו הם כמה הוספות קטנות לצורך תמיכה ב-http.

Server

את השרת כתבתי ב-node תוך שימוש ב-express.
כדי שהכל יעבוד כמו שצריך הייתי צריך להתקין כמה חבילות:


npm install express --save
npm install cors

ההתקנה של cors נועדה לאפשר לשרת לקבל קריאות http מדומיין אחר. ניתן לקרוא על זה בהרחבה כאן.

לקובץ קראתי listenerServer.js.
listenerServer.js


 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
var express = require('express');
var app = express();
var date = new Date();
var current_hour = date.getHours();
var cors = require('cors');
var fs = require('fs');
var bodyParser = require('body-parser');

app.use(cors());

app.listen(3000, function () {
  console.log('Example app listening on port 3000!');
});

app.use(bodyParser.urlencoded({extended: true}));
app.use(bodyParser.json());

app.post('/', function (req, res) {
                var t = req.param('t');
                console.log(req.body);
  
  console.log("post arrived "+ t + " from post ");
  res.send( t + " from post " + date );
  
  fs.appendFile('IotCommands.txt', 't = '+t+"\n", function (err) {
                  if (err) throw err;
                  console.log('Saved!');
                });
});

כפי שניתן לראות, השרת פשוט מאוד. הוא מאזין על פורט 3000. ברגע שהוא מקבל http post הוא מחלץ את הפרמטר t שנשלח אליו, מדפיס הודעה קצרה בקונסול, שולח response, והדבר החשוב לענייננו - שומר בקובץ שנקרא IotCommands.txt את המחרוזת של מה שהתקבל. לדוגמה:
t = a1
t = b0

כדי להריץ את השרת צריך פשוט לכתוב ב-cmd:


node listenerServer.js

בצורה הזו, ניתן להעביר לשרת כל מידע שנרצה.
במערכת שלנו רצה על השרת תוכנית שדוגמת את הקובץ כל שניה ולפי מה שכתוב בקובץ המערכת הדליקה וכיבתה נורות.

אני חושב שהקוד הזה יכול להוות בסיס לכל מיני יישומים של לקוח ושרת כשהלקוח הוא אפליקציה, והשרת נכתב בפשטות ומהירות.

מקווה שעזרתי למישהו. 
בהצלחה!




Firebase

הקדמה


Firebase הוא סטאטראפ (שבהתחלה נקרא Envolve) שהוקם בשנת 2011 ע"י Andrew Lee ו- James Tamplin ונרכש ע"י גוגל ב-2014. מאז הרכישה, גוגל הוסיפה ל-Firebase המון שרותים נוספים שנותנים מענה לצרכים שונים לפיתוח מובייל ו-web. בפוסט הזה נתייחס ל-data base של Firebase.
Firebase נמצא בשימוש ביישומים רבים והוכיח את עצמו בעבודה עם כמויות גדולות מאוד של משתמשים.


אפליקציות שמשתמשות ב-Firebase (מתוך האתר הרשמי)

דבר ראשון שצריך לדעת זה ש-Firebase הוא מסד נתונים מסוג NoSQL. בגדול יש 4 סוגים של NoSQL (שהם Key/Value, Document, Column, Graph), ו-Firebase הוא מסוג Key/Value אבל גם דומה ל-Document DB. למרות שלרוב היישומים מתאים מסד נתונים רלציוני (כמו SQL), ישנם לא מעט יישומים שמסד נתונים רלציוני לא אידאלי עבורם. ולכן אם אתם מכירים רק מסדי נתונים רלציונים כדאי מאוד להכיר גם מסדי נתונים שאינם רלציונים וכך להתאים לכל יישום את מסד הנתונים המתאים. 
  • היתרון של מסד נתונים מסוג NoSQL הוא ב-Scaling. הוא יכול יחסית בקלות לפצל את העבודה על כמה מכונות. המשמעות היא שמסד נתונים כזה יכול לטפל בכמות רבה יותר של נתונים מאשר מסד נתונים רלציוני. 
  • החיסרון שלו הוא במה שנקרא eventual consistency, פרוש הדבר הוא שבסופו של דבר המידע יהיה קונססיטנטי, אבל זה לא מיידי וזה עלול לקחת קצת זמן, ולכן לפעמים עלול להיות מצב שנקבל מידע שגוי כי מסד הנתונים עוד לא הספיק להתעדכן. זה מצב די נדיר אבל קורה, במיוחד שיש הרבה משתמשים שפונים בו זמנית. וצריך להכיר את זה ולהחליט אם זה מתאים למערכת שלנו. 
  • הדבר החשוב ביותר כשמשתמשים ב-Firebase זה לתכנן נכון את הדרך שבא נשמר המידע. ולצורך כך צריך להבין טוב איך Firebase עובד. 
  • Firebase הוא real time data base, כך שאם היישום שלך צריך מידע ב-real time אז Firebase יכול להיות בחירה טובה עבורך. במילים real time אני מתכוון שברגע שאני מבקש נתון מסוים אני בעצם מתחיל להאזין (listener) לאותו נתון. כך שאם הנתון משתנה Firebase ישלח לי event שהנתון הזה השתנה, ואני לא צריך לנחש מתי הנתון ישתנה ולבדוק כל פעם בעצמי. כמובן, שגם אם אני לא צריך מסד נתונים real time, עדיין Firebase יכול להתאים לי.
  • באתר של Firebase יש דוקומנטציה טובה שמאפשרת הבנה עמוקה של המוצר. 
  • ל-Firebase יש תוכנית חינמית כך שניתן להשתמש בו לכל מיני ניסויים ולמידה ללא עלות.
  • Firebase עובד עם כל סוגי ה-frontend וה-backend כך שלא משנה באיזה טכנולוגיה אתה משתמש לכתיבת היישום שלך, כיון ש-Firebase מספק REST API, קרוב לוודאי שהוא יתאים גם לשימוש שלך.
  • Firebase מאוחסן בשבילך. כך שאתה לא צריך לקנות שרת ולהתקים עליו Firebase. מצד שני צריך לשלם ל-Firebase  עבור זה אבל רק בשלב שהגעת לנקודה מסוימת שמצריכה תשלום. למזלנו, יש המון שרותים ש-Firebase  מציע בחינם והנקודה שבה Firebase דורש תשלום מספיקה להרבה אפליקציות להשתמש ב-Firebase  בחינם.
  • הנתונים ב-Firebase נגישים בעזרת URL (מה שנקרא URL oriented). ה-URL של כל נתון נגזר ישירות ממבנה הנתונים שיצרנו.

יצירת פרויקט חדש, קריאת נתונים

ניצור משתמש באתר של Firebase (זה די קל ומהיר אם אתה מחובר לחשבון גוגל שלך) ואז נלחץ על Add Project. בסרגל האפשרויות בצד שמאל נלחץ על Authentication ואז נלחץ על הכפתור בפינה הימנית העליונה שנקרא WEB SETUP. יפתח לנו חלון שבו יש את כל הקוד שנדרש לנו כדי להכניס Firebase לפרויקט שלנו. נעתיק את הקוד הזה לתוך קובץ ה-index.html שלנו.
נלחץ בסרגל האפשרויות בצד שמאל על Database ואז בסרגל העליון נלחץ על RULES. לצורך הלימוד נסיר את האבטחה ונשנה את מה שכתוב שם ל:


1
2
3
4
5
6
{
  "rules": {
    ".read": true,
    ".write": true
  }
}


נחזור בסרגל העליון ל-DATA, נעמוד על הפרויקט שלנו ונלחץ על סימן הפלוס וניצור שדה חדש בשם usrName, וניתן לו ערך של Yosi.


עכשיו נחזור לערוך את קובץ ה-HTML שלנו בצורה הבאה:


 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
<!doctype html>
<html>

<head>
  <title>Firebase learning</title>
</head>

<body>
  <h1 id="userName"></h1>

  <script src="https://www.gstatic.com/firebasejs/4.1.3/firebase.js"></script>
  <script>
    // Initialize Firebase
    var config = {
      apiKey: "AIzaSyBPx7bl1ZogSpAgnB6SgtNQ5fPl4tBPkXI",
      authDomain: "rating-project-ded62.firebaseapp.com",
      databaseURL: "https://rating-project-ded62.firebaseio.com",
      projectId: "rating-project-ded62",
      storageBucket: "rating-project-ded62.appspot.com",
      messagingSenderId: "474071235854"
    };
    firebase.initializeApp(config);

    var userName = document.getElementById("userName");
    var dbRef = firebase.database().ref().child('userName');
    dbRef.on('value',snap => userName.innerText = snap.val());
  </script>

</body>

</html>


הוספנו את השורות המסומנות. 
שורה 24 פשוטה. 
בשורה 25 יצרנו משתנה שמצביע על השדה שנקרא userName ב-DB שלנו. 
בשורה 26 השתמשנו בפקודה on שזו פקודה של Firebase. הפקודה הזו יוצרת listener על השדה userName. הפרמטר הראשון אומר שאנחנו רוצים להאזין ל-event שנקרא value. ה-event הזה נשלח בכל פעם שיש איזשהו שינוי ב-path שעליו אנחנו מאזינים.
הפרמטר השני זו פונקציית callback שמקבלת כ-input את המידע מ-Firebase שנקרא בשם snap (מלשון snapshot) והיא מכניסה את הערך של השדה userName לתוך התג h1 בקובץ html שלנו (שורה 9).
עכשיו נבדוק את מה שעשינו. נפתח את דף ה-html שלנו ונשנה ב-Firebase את הערך של השדה userName. ונראה שכל שינוי שאנחנו עושים מיד מעדכן את המידע בדפדפן.

כתיבת נתונים

עכשיו נכתוב פרויקט פשוט שמכניס נתונים ל-DB. 


 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
<!doctype html>
<html>

<head>
  <title>Firebase learning</title>
</head>

<body>

  <script src="https://www.gstatic.com/firebasejs/4.1.3/firebase.js"></script>
  <script>
    // Initialize Firebase
    var config = {
      apiKey: "AIzaSyBPx7bl1ZogSpAgnB6SgtNQ5fPl4tBPkXI",      
      authDomain: "rating-project-ded62.firebaseapp.com",
      databaseURL: "https://rating-project-ded62.firebaseio.com",
      projectId: "rating-project-ded62",
      storageBucket: "rating-project-ded62.appspot.com",
      messagingSenderId: "474071235854"    };
    firebase.initializeApp(config);

    var dbRef = firebase.database().ref().child('userName');
    
    function addUser(newUser){
      dbRef.push({
        userName: newUser
      });
    }
  </script>
<input id="newUser">
<button onclick="document.getElementById('userName').innerText = 
                 addUser(document.getElementById('newUser').value)">add user</button>

</body>

</html>


בקוד הזה השתמשנו בפקודה של Firebase שנקראת push. וכל פעם הכנסנו עוד מידע לתוך השדה newUser. שם המשתמש החדש נלקח מתוך תיבת הטקסט.
נלחץ על קובץ ה-html שלנו ונקבל דף כזה:

נכניס בתיבת הטקסט שם ונלחץ על הכפתור add user. נוכל לראות שכל לחיצה על הכפתור מוסיפה עוד משתמש ב-DB.

ניתן לראות שכל ערך שהכנסתי לשדה userName קיבל אוטומטית ID מ-Firebase.

הבנת מבנה הנתונים

מערכים

קודם כל צריך להבין שהמערכים ב-Firebase לא דומים למערכים ב-JavaScript. ב-JavaScript לכל איבר במערך יש אינדקס לפי מספר סידורי עולה (0,1,2,3). ב-Firebase כל איבר במערך מקבל ID ייחודי אוטומטי מ-Firebase ודרך ה-ID הזה ניתן לגשת לאותו איבר.

גישה לנתון שלא קיים

במקרה שמנסים לגשת למידע (כמובן דרך כתובת URL) שלא קיים נקבל בחזרה את הערך null.
לדוגמה, במסד נתונים שיצרתי לעיל יש רק שדה שנקרא userName וה-URL שלו הוא:

https://console.firebase.google.com/project/rating-project-ded62/database/data/userName

אם אני אנסה לגשת לשדה שלא קיים ונקרא userPhone דרך אותו URL רק שאשנה את הסיומת ל-userPhone בצורה הבאה:

https://console.firebase.google.com/project/rating-project-ded62/database/data/userPhone

Firebase יחזיר לי null. כמו שרואים בתמונה הבאה:

גישה דרך רפרנס (והפונקציות child, parent, root)

ב-Firebase הגישה לנתונים מתבצעת ע"י רפרנס שמקושר ל-URL מסוים. למשל במקרה שלנו אני יכול ליצור 2 רפרנסים:

1
2
var dbRef = firebase.database().ref();
var userRef = firebase.database().ref().child('userName');

הראשון מפנה לנקודה ההתחלתית של ה-DB (ה-root), והשני מפנה לשדה userName. עשינו שימוש בפונקציה child כדי לקבל רפרנס לשדה userName שנמצא מתחת לנקודה ההתחלתית של ה-DB.
ניתן להשתמש גם בפונקציה parent שמחזירה רפרנס של שדה אחד מעל השדה של הרפרנס הנוכחי. לדוגמה:



1
2
var userRef = firebase.database().ref().child('userName');
var dbRef = userRef.parent;

ויש אפשרות להגיע ישירות לנקודה ההתחלתית מכל מקום ב-DB ע"י שימוש בפונקציה root.


1
2
var userRef = firebase.database().ref().child('userName');
var dbRef = userRef.root;

מבנה שטוח ולא מבנה עמוק

כשמתכננים את מבנה הנתונים צריך לתכנן מבנה שטוח ולא מבנה עמוק. מבנה עמוק הכוונה למבנה שמכיל הרבה childs. הסיבה שמבנה עמוק לא טוב, היא שכשנבקש מ-Firebase נתון במיקום מסוים, הוא יחזיר לנו אותו ואת כל מה שמתחתיו. ואם נתכנן מבנה עמוק זה עלול להיות הרבה מידע שנשלח בכל בקשה.
ב-Firebase אין דרך לפלטר מה יחזור אלינו. כל מה שנמצא תחת אותו ה-URL שביקשנו ישלח אלינו.

נדגים את העניין עם דוגמה קטנה. אם אנחנו רוצים לבנות DB לצורך ביקורות על ערים בישראל, ונבנה את ה-DB בצורה הבאה:
אם במערכת שלנו יש צורך לקבל רשימה של כל הערים שעליהם נכתבה ביקורת, אז נצטרך לקחת אותם מה-root של ה-DB ויחד איתם ישלח כל שאר המידע שמתחת להם. כרגע אין הרבה מידע אבל זה עלול לגדול מאוד ולהגיע לכמות עצומה. ולכן אם המערכת שלנו צריכה הרבה פעמים לבקש מה-DB את רשימת הערים, נצטרך כנראה ליצור שדה נפרד לרשימת הערים. נכון שיש פה כפילות של מידע, אבל כפילות מידע זה דבר מקובל ב-Firebase וכשיש צורך משתמשים בזה.
בתכנון ה-DB הרבה פעמים נשאלת השאלה איזה מידע כדאי לכפול (לשים ביותר ממקום אחד)? והתשובה היא לא חד משמעית, ופה הניסיון עם Firebase משחק תפקיד גדול. ככל שנצבור יותר ניסיון עם Firebase נוכל יותר בקלות להחליט מתי כדאי לכפול מידע ומתי לא. צריך לזכור שכשכופלים מידע, צריך גם לעדכן אותו במספר מקומות כשהוא מתעדכן. יש פה tradeoff בין גישה מהירה לנתונים לבין הגדלת ה-DB, ולכן לכל מערכת יש שיקולים שונים כתלות בכמה פעמים נצטרך לקרוא את המידע וכמה פעמים נצטרך לכתוב לשם. ומה גודל המידע.
בגדול אפשר לסכם שהכללים לתכנון ה-DB הם:
  1. תכנון מבנה המידע קשור ישירות לצורת השימוש במערכת (תדירות הקריאות, תדירות הכתיבות, ומה גודל המידע)
  2. לתכנן מבנה שטוח ולהימנע ממבנה עמוק 
  3. לא לחשוש להכפיל מידע כשיש צורך בכך

קריאת נתונים

ניתן לקרוא נתונים מ-Firebase ע"י שימוש בפונקציה on או בפונקציה once. ההבדל ביניהם הוא שהפונקציה on מאזינה לנתונים ומקבלת event על כל שינוי בנתונים. ואילו הפונקציה once קוראת את הנתונים בצורה חד פעמית.


קריאה בעזרת הפונקציה on

כפי שכבר ראינו לעיל, צורת השימוש בפונקציה on היא:



1
2
var userRef = firebase.database().ref().child('userName');
userRef.on('value',snap => userName.innerText = snap.val());

הפונקציה on מקבלת 2 פרמטרים: 
הפרמטר הראשון, זה על איזה event להאזין, ברוב המקרים נשתמש ב-'value' כי אנחנו רוצים לקבל event על כל שינוי במידע שעליו אנחנו מאזינים.
הפרמטר השני, זו פונקציית callback שמקבלת כ-input את המידע שהגיע מ-Firebase (מקובל לקרוא למידע הזה snap כי זה snapshot של המידע ברגע הנוכחי) ועושה מה שאנחנו צריכים עם המידע שהתקבל.

קריאה בעזרת הפונקציה once

השימוש בפונקציה once דומה לשימוש בפונקציה on. 

1
2
var userRef = firebase.database().ref().child('userName');
userRef.once('value',snap => userName.innerText = snap.val());

ההבדל הוא שה-event יגיע אלינו רק פעם אחת. צריך לדעת שה-event הזה שנקרא value לא נשלח אלינו רק כשיש שינוי אלא גם בהתחלה הוא ישלח אלינו עם הערך ההתחלתי.

הפסקת ההאזנה לנתונים

כפי שכתבתנו לעיל, הפונקציה on מאזינה לכל שינוי בנתונים לפי הרפרנס שהיא קיבלה. במקרה שאנחנו רוצים להפסיק את ההאזנה לנתונים נצטרך לקרוא לפונקציה off. כדי שנוכל לקרוא לפונקציה הזו צריך לשמור את ה-handle שחוזר מהפונקציה on. להלן קוד לדוגמה:


1
2
3
4
5
6
7
8
var userRef = firebase.database().ref().child('userName');
userHandle = userRef.on('value',snap => userName.innerText = snap.val());
...
...
...
if(typeof userRef === 'object' && typeof userHandle) {
   userRef.off('value', userHandle);
}

כתיבת נתונים

כפי שכבר ראינו לעיל, כתיבת נתונים ל-Firebase מתבצעת ע"י הפונקציה push
את הפונקציה push צריך לקרוא במיקום הנכון שאליו רוצים לכתוב. 
נמשיך את הדוגמה של מערכת הביקורות על הערים שלנו בארץ ישראל. נכתוב דוגמה למקרה שבו משתמש רוצה להכניס ביקורת חדשה על ירושלים לדוגמה. כרגע יש לנו 3 ביקורות על ירושלים ו-3 על תל אביב. 
נגיד שיש משתמש בשם צביקה שכתב ביקורת על ירושלים. לצורך העניין יצרת דף הפשוט והכנסתי לו את הנתונים הבאים:

ואת הנתונים האלו שלחתי ל-Firebase. 
להלן הקוד השלם:



 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
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
<!doctype html>
<html>

<head>
  <title>Firebase learning</title>
</head>

<body>

  <script src="https://www.gstatic.com/firebasejs/4.1.3/firebase.js"></script>
  <script>
    // Initialize Firebase
    var config = {
      apiKey: "AIzaSyBPx7bl1ZogSpAgnB6SgtNQ5fPl4tBPkXI",      
      authDomain: "rating-project-ded62.firebaseapp.com",
      databaseURL: "https://rating-project-ded62.firebaseio.com",
      projectId: "rating-project-ded62",
      storageBucket: "rating-project-ded62.appspot.com",
      messagingSenderId: "474071235854"    };
    firebase.initializeApp(config);
   
    function addReview(userName, cityName, reviewText, numberOfStars) {
      var userRef = firebase.database().ref().child(cityName)
                     .child('review').child('users').child(userName);
      userRef.push({
        description: reviewText,
        stars: numberOfStars      
      });
    }

  </script>
  <div dir="rtl">
    <div>
      שם:
      <input id="userName">
    </div>
    <div>עיר:
      <input id="cityName">
    </div>
    <div>ביקורת:
      <input id="reviewText">
    </div>
    <div>דרוג:
      <input id="numberOfStars">
    </div>

    <button onclick="addReview(document.getElementById('userName').value, 
                         document.getElementById('cityName').value,
                         document.getElementById('reviewText').value,
                         document.getElementById('numberOfStars').value)">שלח</button>
  </div>

</body>

</html>

הפונקציה העיקרית מסומנת בצהוב.
בשורות 25, 26 לקחנו רפרנס לפי העיר שעליה נכתבה הביקורת ולפי המשתמש שכותב את הביקורת. 
אם המשתמש קיים, Firebase ימצא אותו ויכניס את הביקורת החדשה תחת אותו משתמש. 
אם המשתמש לא קיים Firebase ייצור מתשתמש חדש לפי השם שנתנו לו ויכניס תחתיו את הביקורת החדשה.


בדוגמה לעיל, כותב הביקורת הוא משתמש בשם צביקה, וכרגע אין משתמש כזה במערכת, לכן Firebase ייצור משתמש חדש בשם צביקה, והתוצאה תהיה:

במקרה שמכניס הביקורת הוא משתמש קיים, כמו דוד:




Firebase יכניס את הביקורת החדשה תחת המשתמש הקיים:

בשני המקרים ניתן לראות ש-Firebase יצר לכל ביקורת ID ייחודי משלו. ואילו בשאר הביקורות שייצרתי באופן ידני אין ID לכל ביקורת.
הרעיון הוא, שבהתחלה אנחנו בונים את השלד של ה-DB שלנו באופן ידני ואין חשש להתנגשויות עם מישהו אחר שמכניס נתונים ל-DB. אבל ברגע שמשתמשים מכניסים מידע ע"י הפונקציה push יכול להיות שיהיו הרבה משתמשים שיכניסו ביחד מידע לאותו המקום ואז עלולים להיות התנגשויות ולכן Firebase מכניס ID ייחודי לכל הכנסת נתונים, וכך אין חשש להתנגשויות.

מה יקרה אם משתמש יכניס ביקורת על עיר שלא קיימת עדיין ב-DB שלנו, למשל חיפה:



גם כאן, Firebase יתנהג בצורה די אינטואיטיבית, וייצור את העיר החדשה הזו ב-DB שלנו, עם המבנה הנכון מתחת לשם העיר, ויכניס את הביקורת החדשה לשם:

שימו לב שנוצר ID רק למידע הסופי, ולא לשאר השרשרת שמעליו.

במקרה שהפרויקט שלנו צריך מדי פעם לבקש מה-DB את רשימת הערים שיש עליהם ביקורת, כמו שהסברנו לעיל, כדאי מאוד ליצור עוד צומת ב-DB שנקרא לו ReviewedCities לדוגמה, ובתוכו יהיו שמות כל הערים שיש עליהם ביקורת. כך נוכל לקבל רק את שמות הערים כשנצטרך ולא את כל המידע וכל הביקורות, כשמה שאנחנו רוצים זה רק שמות הערים.
במקרה כזה, ברגע שיש ביקורת על עיר חדשה שעדיין לא קיימת אצלנו, צריך לעדכן גם את הצומת של ReviewedCities במידע החדש. בתור הדגמה נתחיל במצב שיש לנו ביקורות רק על ירושלים ותל אביב:



ועכשיו מגיע אהרון ומכניס ביקורת על חיפה:


נעדכן את הפונקציה  addReview בקוד הבא:


 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
    var dbRef = firebase.database().ref();

    function addReview(userName, cityName, reviewText, numberOfStars) {
      var usersRef = firebase.database().ref().child(cityName).child('review')
                     .child('users').child(userName);
      var newCity = true;

      usersRef.push({
        description: reviewText,
        stars: numberOfStars
      }, function (err) {
        if (err) {
          console.warn('error: ', err);
        } else {
          dbRef.child('ReviewedCities').once('value', function (snap) {
            var cities = snap.val();
            Object.keys(cities).forEach(function (key, index) {
              if (cities[key].name === cityName) {
                newCity = false;
                return;
              }
            });
            if (newCity == true)
              dbRef.child('ReviewedCities').push({ name: cityName });
          });
        }
      });
    }

הסבר:
הפונקציה push יכולה לקבל פרמטר שני. הפרמטר הזה הוא פונקציית callback למקרה שיש error. 
בשורה 11 הוספנו את פונקציית ה-callback ובדקנו אם קיים error. 
במקרה שאין error הגענו לשורה 15 ושם קראנו באופן חד פעמי את הצומת ReviewedCities. בשורה 17 הכנסנו לולאה שעוברת על כל השדות של ReviewedCities ובודקת אם יש שם אובייקט שמכיל בשדה 'name' את השם של העיר החדשה (במקרה שלנו - חיפה). במקרה שמצאנו אובייקט כזה שמכיל את חיפה אנחנו מפסיקים את הלולאה ע"י return ומכניסים למשתנה newCity את הערך true.
בשורה 23, לאחר הלולאה, אנחנו בודקים את הערך של newCity במקרה שהוא true אנחנו מכניסים את שם העיר החדשה לתוך ReviewedCities. 

והתוצאה שנקבל היא:



עדכון שדה ע"י הפונקציה update

כפי שראינו, יש לנו שדה של ממוצע הדרוג שהעיר קיבלה:




אנחנו רוצים שכל ביקורת חדשה תשפיע על השדה הזה, כך שהשדה הזה יהיה ממוצע של מספר הכוכבים (1 עד 5) שהעיר קיבלה. לצורך כך נשתמש בפונקציה update שמעדכנת שדה קיים.

נניח שמגיע משתמש בשם רפאל ומכניס את הביקורת הבאה:

הביקורת הזו תשנה את הממוצע מ-3.5 ל-4. כדי לגרום לשדה avarage להתעדכן, נעדכן את הפונקציה addReview בצורה הבאה:


 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
var dbRef = firebase.database().ref();

function addReview(userName, cityName, reviewText, numberOfStars) {
  var usersRef = dbRef.child(cityName).child('review').child('users').child(userName);
  var cityReviewRef = dbRef.child(cityName).child('review');
  var newCity = true;
  usersRef.push( {
 description: reviewText,
 stars: numberOfStars
  }, function (err) {
 if (err) {
   console.warn('error: ', err);
 } else {
   dbRef.child('ReviewedCities').once('value', function (snap) {
  var cities = snap.val();
  Object.keys(cities).forEach(function (key, index) {
    if (cities[key].name === cityName) {
   newCity = false;
   return;
    }
  });
  if (newCity == true)
    dbRef.child('ReviewedCities').push({ name: cityName });
   });

   cityReviewRef.once('value', snap => {
  numOfReviewers = snap.child('users').numChildren();
  startsAverage = ( (numOfReviewers-1) * snap.child('avarage').val() 
    + Number(numberOfStars)) / numOfReviewers;
  cityReviewRef.update({avarage: startsAverage});
   });
   
 }
  });
}


הסבר על תוספת הקוד המודגש:
כדי לעדכן את ממוצע הדרוג צריך לקחת את הממוצע הנוכחי ולהכפיל אותו במספר המדרגים (לא כולל את המדרג החדש), במקרה שלנו ממוצע הדרוג היה 3.5 ומספר המדרגים 2. המכפלה היא 7. למספר הזה נוסיף את הדרוג החדש (5) ונחלק במספר המדרגים החדש (3) כדי לקבל ממוצע. במקרה שלנו
 ( 7 + 5 ) / 3 = 4
לכן, הקוד שלנו מתחיל בשורה 26 שבה אנחנו קוראים מה-DB את שדה ה-review של אותה העיר שקיבלה עכשיו ביקורת חדשה, כדי לקחת משם את הערך של השדה avarage ואת מספר המדרגים.
כדי לדעת את מספר המדרגים השתמשנו בפונקציה numCildren. זו פונקציה של Firebase שמחזירה את מספר הילדים שיש לאותה צומת שעליה הפעלנו את הפונקציה.
המספר הזה כבר כולל את הביקורת החדשה, ולכן בשורה 28 הפחתנו ממנו 1 כדי לממש את החישוב של הממוצע החדש כפי שהסברנו לעיל. 
המשתנה numberOfStars מכיל את הדרוג החדש שהמדרג נתן. והפעלנו עליו את פונקציית Number כדי להמיר אותו ממחרוזת למספר.
בשורה 30, מעדכנים את השדה avarage בממוצע הדרוג שכולל את הביקורת החדשה, ע"י פונקציית update.
התוצאה שתתקבל היא: