【初心者向け】Firebaseでチャットアプリを開発(codelabs)

今更感もありますが、、サーバレスアプリ開発の勉強の為にFirebaseを用いて簡単なチャットアプリを作成しました。

###Firebaseについて
Firebaseは、高品質のアプリを迅速に開発できるGoogle のmBaaSです。
今回は主要な機能である下記を使用します。

- Firebase Authentication : アプリへのログイン機能を提供
- Cloud Firestore : 構造化データを保存し、変更時に即座に通知を受け取ることが出来る
- Cloud Storage : クラウドにファイルを保存する
- Firebase Hosting : ホスティングする
- Firebase Cloud Messaging :プッシュ通知を送信する
- Firebase Performance Monitoring : アプリのパフォーマンスデータを収集する

###開発環境
- Windows10
- Docker (DockerDesktop for windows(2.2.0.5))

###完成イメージ
[ソースコード(github)](https://github.com/sinjikoro/codelab-friendlychat-web)
![完成イメージ](https://codelabs.developers.google.com/codelabs/firebase-web/img/3b1284f5144b54f6.png)



# チャットアプリ開発
### 1. 環境構築
1. codelabsの[リポジトリ](https://github.com/firebase/codelab-friendlychat-web)から、ローカルへソースをコピーしてくる
2. [Firebaseのconsole](https://console.firebase.google.com)よりwebアプリを作成
 1. [プロジェクトの作成]を行う(プロジェクト名とGoogleアナリティクス有効は任意)
 2. </>(webアプリにFirebaseを追加)を行う(アプリ名は任意、FirebaseHostingの設定はONにする)
3. 下記Dockerfileとdocker-compose.ymlをローカルソースのRootへ配置し、`docker-compose up -d --build`コマンドで開発環境コンテナを作成する

```docker:Dockerfile
Dockerfile
FROM node:10.19-alpine

WORKDIR /app

RUN npm install -g firebase-tools
RUN apk add curl

ENV HOST 0.0.0.0
EXPOSE 5000
EXPOSE 9005

```
```yml:docker-compose.yml
version: '3'

services:
  node:
    container_name: node
    build: ./
    tty: true
    volumes:
      - ./:/app
    ports: 
      - 5000:5000
      - 9005:9005
```

### 2. FirebaseCLI設定
1. 作成したコンテナ内にFirebaseCLIをインストールする
    1. `docker exec -it node ash`でコンテナ内へ
    2. `npm -g install firebase-tools`よりインストール実施
    3. `firebase --version`が返ってくればインストール成功
2. FirebaseCLIへユーザー認証する
    1. `firebase login`と打つとURL(ttps://accounts.google.com/o/oauth2/auth...)が表示されるので、ブラウザで表示されたURLへ移動
    2. Googleの認証画面が表示されるので、画面に従い認証を通す
    3. `cd /app/web-start/`でweb-startディレクトリへ移動
    4. `firebase use --add`にてアプリとFirebaseを関連付けする(Project IDは前手順のものを選択、aliasは無くてOK)
    5. `firebase serve -o "0.0.0.0" --only hosting`にてローカル上でのアプリ起動
    6. `hosting: Local server: http://localhost:5000`と表示されれば起動
    7. ブラウザより http://localhost:5000 を起動し、下記画面が表示されるか確認
    ![チャットアプリ](https://codelabs.developers.google.com/codelabs/firebase-web/img/4c23f9475228cef4.png)

### 3. 機能実装
1. [Firebaseのconsole](https://console.firebase.google.com)より、下記設定を行う
    1. 「開発>Authentication」、「sign-in method」からGoogleログインを有効にする
    2. 「開発>Database」、CloudFirestoreのCreate Databaseを行う([Start in test mode]を選択し、locationは任意選択)
    3. 「開発>Storage」、「始める」からCloud Storageを作成する(locationは任意選択)
2. `web-start/public/index.html`内にFirebaseSDKとSDK構成用のscriptの記載があるか確認
3. `web-start/public/scripts/main.js`内のfunctionを下記記載の通りに書き換える

参考）
 https://firebase.google.com/docs/reference/js/firebase.auth.Auth
 https://firebase.google.com/docs/reference/js/firebase.firestore
 https://firebase.google.com/docs/reference/js/firebase.storage
＃messagesのデータモデル図
![データモデル](https://codelabs.developers.google.com/codelabs/firebase-web/img/688d7bc5fb662b57.png)


```html:index.html
<script src="/__/firebase/X.X.X/firebase-app.js"></script>
<script src="/__/firebase/X.X.X/firebase-auth.js"></script>
<script src="/__/firebase/X.X.X/firebase-storage.js"></script>
<script src="/__/firebase/X.X.X/firebase-messaging.js"></script>
<script src="/__/firebase/X.X.X/firebase-firestore.js"></script>
<script src="/__/firebase/X.X.X/firebase-performance.js"></script>
<script src="/__/firebase/init.js"></script>

<script src="scripts/main.js"></script>
```

```javascript:main.js
// Signs-in Friendly Chat.
function signIn() {
  var provider = new firebase.auth.GoogleAuthProvider();
  firebase.auth().signInWithPopup(provider);
}

// Signs-out of Friendly Chat.
function signOut() {
  firebase.auth().signOut();
}

// Initiate firebase auth.
function initFirebaseAuth() {
  firebase.auth().onAuthStateChanged(authStateObserver);
}

// Returns the signed-in user's profile Pic URL.
function getProfilePicUrl() {
  return firebase.auth().currentUser.photoURL || '/image/profile_placeholder.png';
}

// Returns the signed-in user's display name.
function getUserName() {
  return firebase.auth().currentUser.displayName;
}

// Returns true if a user is signed-in.
function isUserSignedIn() {
  return !!firebase.auth().currentUser;
}

// Saves a new message on the Firebase DB.
function saveMessage(messageText) {
  return firebase.firestore().collection('messages').add({
    name: getUserName(), 
    text: messageText, 
    profilePicUrl: getProfilePicUrl(), 
    timestamp: firebase.firestore.FieldValue.serverTimestamp()
  }).catch(function(error) {
    console.error('Error writing new message to database', error);
  });
}

// Loads chat messages history and listens for upcoming ones.
function loadMessages() {
  // create the query to load the last 12 messages and listen for new ones.
  var query = firebase.firestore()
                    .collection('messages')
                    .orderBy('timestamp', 'desc')
                    .limit(12);
  
  // start listening to the query.
  query.onSnapshot(function(snapshot) {
    snapshot.docChanges().forEach(function(change) {
      if (change.type === 'removed') {
        deleteMessage(change.doc.id);
      } else {
        var message = change.doc.data();
        displayMessage(change.doc.id, message.timestamp, message.name, message.text, message.profilePicUrl, message.imageUrl);
      }
    })
  })
}

// Saves a new message containing an image in Firebase.
// This first saves the image in Firebase storage.
function saveImageMessage(file) {
  // 1 - We add a message with a loading icon that will get updated with the shared image.
  firebase.firestore().collection('messages').add({
    name: getUserName(), 
    imageUrl: LOADING_IMAGE_URL, 
    profilePicUrl: getProfilePicUrl(), 
    timestamp: firebase.firestore.FieldValue.serverTimestamp()
  }).then(function(messageRef) {
    // 2 - Upload the image to Cloud Storage.
    var filePath = firebase.auth().currentUser.uid + '/' + messageRef.id + '/' + file.name;
    return firebase.storage().ref(filePath).put(file).then(function(fileSnapshot) {
      // 3 - Generate a public URL for the file.
      return fileSnapshot.ref.getDownloadURL().then((url) => {
        // 4 - Update the chat message placeholder with the image's URL.
        return messageRef.update({
          imageUrl: url,
          storageUri: fileSnapshot.metadata.fullPath
        });
      });
    });
  }).catch(function(error) {
    console.error('There was as error uploading a file to Cloud Storage:', error);
  });
}
```

### 4. 通知の表示

1. `web-start/public/manifest.json`を下記記載の通りに書き換える
2. `web-start/public/firebase-messaging-sw.js`を作成し、下記内容を記載する
3. `web-start/public/scripts/main.js`のfunction`saveMessagingDeviceToken`を下記内容で実装する
4. ここまで出来たら、下記の手順にて通知が表示されることが確認できる
    1. コンテナ内で`firebase serve -o "0.0.0.0" --only hosting`よりアプリ起動
    2. http://localhost:5000 へアクセスし、通知の許可を求められるので「許可」にする
    3. ブラウザからjavascriptのconsoleを開き、`Got FCM device token`の値を控える
    4. [Firebaseのconsole](https://console.firebase.google.com)より[Cloud Messaging](https://console.firebase.google.com/project/_/settings/cloudmessaging)を開きサーバーキーを控える
    5. 下記記載のcurlコマンドの`YOUR_DEVICE_TOKEN`を上記3、`YOUR_SERVER_KEY`を上記4で控えた内容に置換る
    6. 作成したcurlコンテナ内で実行することでブラウザに表示中のチャットアプリから通知が表示される

```json:manifest.json
{
  "name": "Friendly Chat",
  "short_name": "Friendly Chat",
  "start_url": "/index.html",
  "display": "standalone",
  "orientation": "portrait",
  "gcm_sender_id": "103953800507"
}
```

```javascript:firebase-messaging-sw.js
importScripts('/__/firebase/6.0.4/firebase-app.js');
importScripts('/__/firebase/6.0.4/firebase-messaging.js');
importScripts('/__/firebase/init.js');

firebase.messaging();
```

```javascript:main.js
// Saves the messaging device token to the datastore.
function saveMessagingDeviceToken() {
  firebase.messaging().getToken().then(function(currentToken) {
    if (currentToken) {
      console.log('Got FCM device token:', currentToken);
      // Saving the Device Token to the datastore.
      firebase.firestore().collection('fcmTokens').doc(currentToken)
          .set({uid: firebase.auth().currentUser.uid});
    } else {
      // Need to request permissions to show notifications.
      requestNotificationsPermissions();
    }
  }).catch(function(error){
    console.error('Unable to get messaging token.', error);
  });
}

// Requests permissions to show notifications.
function requestNotificationsPermissions() {
  console.log('Requesting notifications permission...');
  firebase.messaging().requestPermission().then(function() {
    // Notification permission granted.
    saveMessagingDeviceToken();
  }).catch(function(error) {
    console.error('Unable to get permission to notify.', error);
  });
}
```
参考）https://firebase.google.com/docs/reference/js/firebase.messaging

```:curl
curl -H "Content-Type: application/json" \
     -H "Authorization: key=YOUR_SERVER_KEY" \
     -d '{
           "notification": {
             "title": "New chat message!",
             "body": "There is a new message in FriendlyChat",
             "icon": "/images/profile_placeholder.png",
             "click_action": "http://localhost:5000"
           },
           "to": "YOUR_DEVICE_TOKEN"
         }' \
     https://fcm.googleapis.com/fcm/send
```

### 5. セキュリティルール
1. `web-start/firestore.rules`を作成し、下記内容を記載する
2. `web-start/storage.rules`を作成し、下記内容を記載する
3. `web-start/firebase.json`へ、下記追記する
4. コンテナ内で`firebase deploy --only storage,firestore`コマンドにより作成したセキュリティルールをデプロイする

```:firestore.rules
service cloud.firestore {
  match /databases/{database}/documents {
    // Messages:
    //   - Anyone can read.
    //   - Authenticated users can add and edit messages.
    //   - Validation: Check name is same as auth token and text length below 300 char or that imageUrl is a URL.
    //   - Deletes are not allowed.
    match /messages/{messageId} {
      allow read;
      allow create, update: if request.auth != null
                    && request.resource.data.name == request.auth.token.name
                    && (request.resource.data.text is string
                      && request.resource.data.text.size() <= 300
                      || request.resource.data.imageUrl is string
                      && request.resource.data.imageUrl.matches('https?://.*'));
      allow delete: if false;
    }
    // FCM Tokens:
    //   - Anyone can write their token.
    //   - Reading list of tokens is not allowed.
    match /fcmTokens/{token} {
      allow read: if false;
      allow write;
    }
  }
}
```
参考） https://firebase.google.com/docs/firestore/security/overview

```:storage.rules
// Returns true if the uploaded file is an image and its size is below the given number of MB.
function isImageBelowMaxSize(maxSizeMB) {
  return request.resource.size < maxSizeMB * 1024 * 1024
      && request.resource.contentType.matches('image/.*');
}

service firebase.storage {
  match /b/{bucket}/o {
    match /{userId}/{messageId}/{fileName} {
      allow write: if request.auth != null && request.auth.uid == userId && isImageBelowMaxSize(5);
      allow read;
    }
  }
}
```
参考）https://firebase.google.com/docs/storage/security/start

```json:firebase.json
{
  //Add this
  "firestore": {
    "rules": "firestore.rules"
  },
  //Add this
  "storage": {
    "rules": "storage.rules"
  },
  "hosting": {
    ...
  }
}
```

### 6. パフォーマンス測定
1. `web-start/public/index.html`に下記内容があるか確認
2. `web-start/public/scripts/main.js`に下記を追記
3. FID計測する場合は、`web-start/public/index.html`へ下記追記
4. [Firebaseのconsole](https://console.firebase.google.com)からパフォーマンスを確認することが可能となる

```html:index.html
<script src="/__/firebase/X.X.X/firebase-performance.js"></script>
<script src="/__/firebase/init.js"></script>
```

```javascript:main.js
firebase.performance();
```

```html:index.html(EnableFID)
<script type="text/javascript">!function(n,e){var t,o,i,c=[],f={passive:!0,capture:!0},r=new Date,a="pointerup",u="pointercancel";function p(n,c){t||(t=c,o=n,i=new Date,w(e),s())}function s(){o>=0&&o<i-r&&(c.forEach(function(n){n(o,t)}),c=[])}function l(t){if(t.cancelable){var o=(t.timeStamp>1e12?new Date:performance.now())-t.timeStamp;"pointerdown"==t.type?function(t,o){function i(){p(t,o),r()}function c(){r()}function r(){e(a,i,f),e(u,c,f)}n(a,i,f),n(u,c,f)}(o,t):p(o,t)}}function w(n){["click","mousedown","keydown","touchstart","pointerdown"].forEach(function(e){n(e,l,f)})}w(n),self.perfMetrics=self.perfMetrics||{},self.perfMetrics.onFirstInputDelay=function(n){c.push(n),s()}}(addEventListener,removeEventListener);</script>
```


### 7. デプロイ
1. `/web-start/firebase.json`へdepoyするローカルファイルを指定する
2. `firebase deploy --except functions`コマンドにてFirebaseHostingへdeployする
3. `Deploy complete!`との表示されれば完了(URLは`Hosting URL: https://xxxx`)

 ```json:firebase.json
{
  "firestore": {
    "rules": "firestore.rules"
  },
  "storage": {
    "rules": "storage.rules"
  },
  "hosting": {
    "public": "./public"
  }
}
```

# 最後に
上記はGoogleの提供するcodelabを元に構築しました。
取り敢えずチャットアプリを作成する部分のみを抜粋して記載しております為、より深くFirebaseについて知りたい方は参考元 ([Firebase web codelab](https://codelabs.developers.google.com/codelabs/firebase-web/index.html#0)) をご覧いただければと思います。

また、色々と独学で進めている部分が多い為、上記記事でおかしな点あればやさしくコメント頂けると幸いです。