Firebaseで古風な掲示板(BBS)を作る

## はじめに

<img src="https://qiita-image-store.s3.amazonaws.com/0/152179/f29c6c75-8075-60d3-62ef-a3e2ada38c1c.png" height="400">


Firebaseを用いたサービスを作る演習として、導入から昔ながらの掲示板(BBS)を実装するまでをちゃちゃっと行ってみます。
最短の実装のために色々と端折っているのでご容赦ください。

### BBSの仕様
- 名前欄と本文と削除キーを入力、投稿ボタンを押すことで投稿
- 今まで投稿された内容のうち直近10件がリスト表示される
- それぞれの投稿の削除ボタンから投稿の削除ができる。その際削除キーが尋ねられ、本来のキーと一致する場合にのみ削除が行われる

ユーザー認証とかもない古き良き電子掲示板ですね。


## Node.jsのインストール

Firebase CLI の使用にあたって必須なので[公式サイト](https://nodejs.org/)からダウンロードしてきてインストールします。

### Firebase CLI のインストール

Node.jsがインストールできたら、今度はFirebase CLIをインストールします。コマンドラインで`npm install -g firebase-tools`と入力：

```bash
$ npm install -g firebase-tools
```

無事インストールできたら`firebase`コマンドが有効になっている筈です：

```bash
$ firebase

# (何か色々メッセージが出てくる)
```



## Firebaseプロジェクトを作る

基本的な部分は [Firebaseの始め方 - Qiita の *Firebaseを初めてみる*](https://qiita.com/kohashi/items/43ea22f61ade45972881#firebase%E3%82%92%E5%88%9D%E3%82%81%E3%81%A6%E3%81%BF%E3%82%8B)  の項と同じです。

- https://console.firebase.google.com/ にアクセス、Googleアカウント(無かったら作る)でログイン。

- `プロジェクトを追加`→適当にプロジェクト名を入力。

- `firebase login`コマンド→ブラウザでログイン画面が開くので先のGoogleアカウントを選択。許可ボタンを押してFirebase CLIでのログイン完了

- 適当にプロジェクト用のディレクトリを新規作成し、そこに移動。 
  `firebase init`コマンドでFirebaseプロジェクトの初期化を開始：

```bash
$ cd your_firebase_project_dir
$ firebase init

? Are you ready to proceed?
# => Yes

? Which Firebase CLI features do you want to setup for this folder?
# => 今回は `Database`, `Functions`, `Hosting` を選択

? Select a default Firebase project for this directory:
# => 先に作成したFirebaseプロジェクトを選択

? What file should be used for Database Rules?
# => そのままエンター(デフォルト名を使用)

? What language would you like to use to write Cloud Functions?
# => JavaScriptを選択

? Do you want to use ESLint to catch probable bugs and enforce style?
# => No

? Do you want to install dependencies with npm now?
# => Yes

? What do you want to use as your public directory?
? Configure as a single-page app (rewrite all urls to /index.html)?
# => どちらもそのままエンター(デフォルト)

...
+  Firebase initialization complete!
```

これでプロジェクトの初期化は完了です。
上のとおりに初期化を行った後では、プロジェクトのルートディレクトリ下には

- ファイル `database.rules.json`, `firebase.json`
- Hosting用の`public`ディレクトリ
- Cloud Functions用の`functions`ディレクトリ

が生成されている筈です。

---

## コードを書く

### Hosting側

publicディレクトリ下に配置するファイルの内訳は`index.html`, `main.js`, `main.css`の3つとします。
`index.html`は次の通り：

```public/index.html
<html>
<head>
  <title>Firebase Simple BBS</title>
  <script defer src="/__/firebase/4.9.1/firebase-app.js"></script>
  <script defer src="/__/firebase/4.9.1/firebase-database.js"></script>
  <script defer src="/__/firebase/init.js"></script>
  <script src="./main.js"></script>
  <link rel="stylesheet" type="text/css" href="./main.css"></link>
</head>
<body>
  <div id="reply-form">
    <div class="l">Name:</div> <input type="text" id="reply-name">
    <div class="l">Body:</div> <textarea id="reply-body"></textarea>
    <div class="l">Key:</div> <input type="text" id="reply-key">
    <div class="l"></div> <button id="reply-submit" onclick="postReply()">Post</button>
  </div>
  <div id="replies"></div>
</body>
</html>
```

`public/main.js`はこう：

```public/main.js
const main = ()=>
  firebase.database().ref('/simplebbs/posts').limitToLast(10).on('value', snapshot=>{
    const posts = snapshot.exists() ? snapshot.val() : {}
    let html = ''
    for(const [id, {name, content, date}] of Object.entries(posts).reverse())
      html += makeReply(id, name, content, date)
    document.querySelector('#replies').innerHTML = html
  })

const makeReply = (id, name, content, date) => `<div class="reply">
  <div class="head">Name: ${name} <span class="date">${date}</span></div>
  <div class="content">${content}</div>
  <button class="delete" onclick="deleteReply('${id}')">delete</button> </div>`

const postReply = ()=> post('/api/post', {
    name: document.querySelector('#reply-name').value,
    content: document.querySelector('#reply-body').value,
    key: document.querySelector('#reply-key').value,
  }).then(e=>{ document.querySelector('#reply-body').value='' })

const deleteReply = id => post('/api/delete', {id, key: prompt('key?') || ''})

const post = (path, jsonData) => fetch(path, {
    method: 'POST', body: JSON.stringify(jsonData),
    headers: {'Accept': 'application/json', 'Content-Type': 'application/json'},
  })

document.addEventListener('DOMContentLoaded', main)
```

`public/main.css`は[適当に](https://gist.github.com/a-nakanosora/5ad3622e0ddb0d560c2328fcd62e2923)

---

### Cloud Functions側

Cloud Functions 側のファイル`functions/index.js`を下記の内容に書き換えます：

```functions/index.js
const functions = require('firebase-functions')
const admin = require('firebase-admin')
admin.initializeApp(functions.config().firebase)
const db = admin.database()
const rep = s=> s.replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/\n/g,'<br>')

exports.post = functions.https.onRequest((request, response)=>{
  const {name, content, key} = request.body
  const date = new Date().toLocaleDateString()
  db.ref('/simplebbs/posts').push({name:rep(name), content:rep(content), date})
    .then(e=> db.ref(`/simplebbs/keys/${e.key}`).set(key))
    .then(e=> response.status(200).end())
})

exports.delete = functions.https.onRequest((request, response)=>{
  const {id, key} = request.body
  db.ref(`/simplebbs/keys/${id}`).once('value').then(sKey=>{
    if(!sKey.exists() || sKey.val() !== key)
      return response.status(400).send('invalid id or incorrect key').end()
    db.ref(`/simplebbs/posts/${id}`).remove()
      .then(e=> sKey.ref.remove())
      .then(e=> response.status(200).end())
  })
})
```

### 設定ファイル

`firebase.json`にFunctionsのAPIをHosting上のドメインで呼ぶために`rewrites`の項目を書き加えます：

```firebase.json
{
  "database": {
    "rules": "database.rules.json"
  },
  "hosting": {
    "public": "public",
    "ignore": [
      "firebase.json",
      "**/.*",
      "**/node_modules/**"
    ],
    "rewrites": [
      {"source": "/api/post", "function": "post"},
      {"source": "/api/delete", "function": "delete"}
    ]
  }
}

```

また Realtime Database のセキュリティルールを設定するために`database.rules.json`を編集：

```database.rules.json
{
  "rules": {
    ".read": "false",
    ".write": "false",

    "simplebbs":{
      "posts":{
        ".read": "true",
      },
    },
  }
}
```

### デプロイ

```
$ firebase deploy
```

完了するまでしばらく待つ

### 動作確認

デプロイが完了した時に表示されるHosting URL (`https://～～.firebaseapp.com`)にアクセス。正しく動作していることを確認します。

<img src="https://qiita-image-store.s3.amazonaws.com/0/152179/b0af01e0-d14f-aa8f-06e5-4c886f00f6eb.gif" height="300">

これでFirebaseを用いたBBSが設置できました。やったね！
しかもRealtime Databaseを利用しているため無駄にリアルタイムで更新が反映されます。





---

## 解説

### ページ側 (Hosting側)

- ページ読み込み時に実行される`main()`関数ではFirebase の Realtime Database からパス `/simplebbs/posts` に配置されてある各投稿内容の内、直近10件を取得してページ内に描画しています。

- 投稿(`Post`)ボタンを押すと`postReply()`関数が実行され、その中でCloud Functions側のAPIとして用意した`post`関数をPOSTメソッドのHTTP通信を通して呼んでいます。送信するデータとして、投稿フォームの名前欄・本文・削除キーそれぞれを与えています。

- 同様にそれぞれの投稿の削除(`delete`)ボタンを押すとページ側の`deleteReply()`関数を通してFunctions側の`delete`関数が呼ばれます。


### サーバー側 (Cloud Functions側)

- Cloud Functions側で用意しているAPIは`post`, `delete`の2つです。
- `post`関数 ： POSTメソッドで投稿内容のデータを受け取り、それをRealtime Databaseに保存。データベース中のパス`/simplebbs/posts/{id}`と、`/simplebbs/keys/{id}`とで別々の箇所に分割してデータを保存しています。データベースのセキュリティルールより、ユーザー側から(ページ側から)中身を覗けるのは前者の内容のみとなっています。

- `delete`関数 ： 同様にPOSTメソッドでデータを受け取り、与えられたIDの投稿の削除キーが入力値と一致する場合にデータベースからその投稿を削除する処理を行っています。

`database.rules.json`で書いたデータベースのセキュリティルールにより、ページ側から内容を読み取れるのは一部だけ、また内容の書き込みは全部禁止としています。
サーバー側からは読み書きが行えるので、データの追加やプライベートなデータの扱いはサーバー側に任せています。




### 注意点

注意点として、上の内容ではコードの簡略化のために尽くエラー処理を省いています。[^1]

[^1]: 例えばCloud Functions側のAPIでは、本来ならばリクエストがPOSTメソッドであることを確認するといった処理が必要です。他にもデータベースの変更の際にエラーが生じた場合への対応や、投稿ボタンを押す際にユーザーが間違ってボタンを連打してしまわないようにする方策等々キチンとしようとするとキリがありませんが、最低限の機能を示す雛形としてはこれで十分でしょう。

---

## 所感

- 一昔前のCGIでやっていたようなこともFirebaseなら簡単に実現できてしまいますね。  
  サーバーの存在が抽象化されている分、下手するとCGIよりも敷居が低いかも 
- はじめRealtime Databaseばかり取り沙汰されるFirebaseでユーザー側から隠蔽したい処理を含んだサービスはどう記述したらいいものかと疑問に思っていましたが、Cloud Functions for Firebaseを利用すればよかったんですね。素直な形でサーバー側の処理を書けるのはとても楽。
- 過疎・内輪用サービスなら無料プランの範囲内で余裕で遊べる感じです。皆もウェブサービスやコミュニティつくろう

<br><br><br>

---

### 代案あれこれ

- Cloud Functionsを使用せずRealtime Databaseだけで何とかする
  - Realtime Databaseのセキュリティルールを駆使すれば、今回のようなプライベートなデータの扱い・削除キーによる投稿の削除といったものもCloud Functions無しで実現できると思います。
  - ただサーバー側での投稿内容のサニタイズができないというデメリットも。
- Firebase Storage を使う
  - 画像の投稿機能を追加したい時にでも
- クライアントからのDatabase呼び出しは用いずCloud FunctionsのみでSSRする
  - より古風なCGIに近づきますね
  - ただAPI呼び出し制限の面からあまり経済的ではなさそう


---

### 参考

- [Firebaseの始め方 - Qiita](https://qiita.com/kohashi/items/43ea22f61ade45972881)

- [まだ間に合う！着々と進化しているFirebaseをまとめてみるよ #Firebase #FJUG - Qiita](
https://qiita.com/Yatima/items/23797ca868ee988c824b)

### 関連

- [Cloud Functions for Firebase 覚書き - Qiita](https://qiita.com/nacasora/items/7468892fa8a8757acc50)

<br><br><br>