爆速でVue, firebase, vuetifyでLineのオープンチャットみたいなサービスを作った１

firebase使ってみたかったので
firebaseでチャット作ってみた的なのはたくさんあったので、ちょうどリリースされて話題になってたLineのオープンチャット的なのものを作ることにした

かかった期間は３週間くらい
正確な時間はわからないけど、１日あたり２時間もやってないと思う

vueもfirebaseも業務経験はなし
チュートリアルやってみたくらい

[実際に動いてるのはこちら](https://openchat-a2013.firebaseapp.com/)

# 自己紹介
SESで５年間働いて、１年ニート
現在は個人事業主

# アプリの仕様
- Googleで認証する
- 認証してないと閲覧も投稿もできない
- チャットルームを作ることができる
- チャットルームの名前を設定できる
- チャットルーム毎に自分のアカウント名を設定できる
- チャットルームはURLを知っていれば参加できる
- 自分が参加しているチャットルーム一覧が見れる

# 画面遷移
- 共通ヘッダ
    - マイページ・チャットルーム作成ページへのリンク
    - ログイン・ログアウトボタン
- マイページ
    - 自分が参加しているチャットルーム一覧
- チャットページ
    - 未参加ならアカウント名設定フォーム表示
    - 参加済みならチャットとメッセージ入力フォーム表示
- チャットルーム作成ページ
    - ルーム名、アカウント名の入力フォーム

# 使用技術
- Firebase Hosting
- Firestore
- Firebase Auth
- Vue.js
- Vue-router
- Vuetify

firebaseでチャットアプリ作ってみた系はなぜかRealtime Database使ってるのばかりだったけど、
firebase的には新しいfirestore推しっぽいのでfirestoreを選択した

Vuexは今回使用してない

デザインはほぼvuetify任せ
css殆ど書いてません

# ソース
## firestore
### データ構造
- rooms(コレクション)
    - id
        - created
        - name
        - owner
        - messages(サブコレクション)
            - id
                - created
                - text
                - name
                - author
        - users(サブコレクション)
            - id
                - name

- users
    - id
        - created
        - rooms(サブコレクション)
            - id
                - name

roomsにチャットルームの情報を保持
rooms/{roomid}/usersにチャットルームに参加しているユーザ情報とアカウント名を保持
rooms/{roomid}/messagesにメッセージを保持

usersにユーザ情報を保持
users/{userid}/roomsにユーザが参加しているチャットルーム情報を保持

はじめはサブコレクションではなく配列でデータをもたせようとしていたが、データ取得とかセキュリティルールの記述がしやすそうなのでサブコレクションに変更した
RDBしか使ったことなかったので、情報が重複してるのとか気持ち悪いけどNoSQLはこんなもんなんだよね？
この構成が正解かどうかは全く自信がないので、指摘あったらガンガンください！

### rules
基本的には公式ドキュメント見れば全部書いてある
https://firebase.google.com/docs/firestore/security/get-started?hl=ja
具体例とかは以下のサイトが参考になりました。
https://tech-blog.sgr-ksmt.org/2018/12/11/194022/

書き込むデータのバリデーションもここで記述できる

```javascript:firestore.rules
rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
  	match /users/{userID}{
    	allow get: if isUserAuthenticated(userID)
      allow write: if isUserAuthenticated(userID)
      match /rooms/{roomID}{
        allow create: if isAuthenticated()
                        && isUserAuthenticated(userID)
        allow get,list: if isAuthenticated()
                        && isUserAuthenticated(userID)
      }
    }
    match /rooms/{roomID}{
    	allow get: if isAuthenticated()
      allow write: if isAuthenticated()
                    && request.resource.data.name is string
                    && request.resource.data.name != ""
                    && request.resource.data.owner is string
                    && request.resource.data.owner == request.auth.uid
      match /messages/{messageID}{
      	allow list: if isAuthenticated();
      	allow create: if isAuthenticated() && validateMessage()
      }
      match /users/{userID}{
      	allow write: if isAuthenticated() 
                      && isUserAuthenticated(userID) 
                      && request.resource.data.name is string
                      && request.resource.data.name != ""
        allow get,list: if isAuthenticated() && exists((/databases/$(database)/documents/rooms/$(roomID)/users/$(request.auth.uid)))
      }
    }
    
    function isAuthenticated() {
      return request.auth != null
    }
    function isUserAuthenticated(userID) {
      return request.auth.uid == userID
    }

    function validateMessage(){
      return
        request.resource.data.name is string &&
        request.resource.data.author is string &&
        request.resource.data.text is string &&
        request.resource.data.created is timestamp &&
        request.resource.data.author == request.auth.uid && 
        request.resource.data.name != "" &&
        request.resource.data.text != ""
    }
  }
}
```

## vue

```javascript:main.js
import Vue from 'vue'
import App from './App.vue'
import firebase from 'firebase'
import router from './router'
import 'firebase/firestore';
import vuetify from './plugins/vuetify';

Vue.config.productionTip = false

// Initialize Firebase
var config = {
// firebaseのコンソールからコピーしてきたキーを貼る
}
const firebaseApp = firebase.initializeApp(config)
export const db = firebaseApp.firestore();

new Vue({
  render: h => h(App),
  vuetify,
  router,
}).$mount('#app')

```

```javascript:App.vue
<template>
  <v-app>
    <v-navigation-drawer temporary app v-model="drawer">
      <v-list>
        <v-list-item>
          <v-list-item-content>
            <v-btn to="/">Home</v-btn>
          </v-list-item-content>
        </v-list-item>
        <v-list-item>
          <v-list-item-content>
            <v-btn to="/mypage">マイページ</v-btn>
          </v-list-item-content>
        </v-list-item>
        <v-list-item>
          <v-list-item-content>
            <v-btn to="/newroom">ルーム作成</v-btn>
          </v-list-item-content>
        </v-list-item>
        <v-list-item>
          <v-list-item-content>
            <div v-if="isSignedIn">
              <p>Login as: {{ user.email }}</p>
              <v-btn @click="signOut">ログアウト</v-btn>
            </div>
            <div v-else>
              <v-btn @click="signIn">ログイン</v-btn>
            </div>
          </v-list-item-content>
        </v-list-item>
      </v-list>
    </v-navigation-drawer>
    <v-app-bar app>
      <v-app-bar-nav-icon @click.stop="drawer = !drawer"></v-app-bar-nav-icon>
      <h1>しょぼチャ</h1>
    </v-app-bar>
    <!-- Sizes your content based upon application components -->
    <v-content>
      <!-- Provides the application the proper gutter -->
      <v-container fluid>
        <div v-if="isSignedIn">
          <router-view/>
        </div>
        <div v-else>
          <Discription></Discription>
          <v-btn @click="signIn">ログイン</v-btn>
        </div>
      </v-container>
    </v-content>
    <v-footer app>
      <!-- -->
    </v-footer>
  </v-app>
</template>

<script>
import firebase from 'firebase'
import Discription from './components/Discription.vue'

export default {
  name: 'app',
  components: { Discription },
  data () {
    return {
      user: null,
      isSignedIn: null,
      drawer: false,
    }
  },
  created () {
    this.onAuthStateChanged()
  },
  methods: {
    // ログイン状況が変更されたら呼ばれる
    onAuthStateChanged () {
      firebase.auth().onAuthStateChanged( user => {
        this.user = user;
        this.isSignedIn = user ?
          true : false;
      })
    },
    // ログインしてるか
    isUserSignedIn () {
      return !!firebase.auth().currentUser || false;
    },
    // Google認証でログイン
    signIn () {
      const provider = new firebase.auth.GoogleAuthProvider()
      firebase.auth().signInWithRedirect(provider)
    },
    // ログアウト
    signOut () {
      firebase.auth().signOut()
    },
  }
}
</script>

<style>
#app {
  font-family: 'Avenir', Helvetica, Arial, sans-serif;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  text-align: center;
  color: #2c3e50;
  margin-top: 60px;
}
</style>

```

```javascript:router/index.js
import Vue from 'vue'
import Router from 'vue-router'

import Home from '@/components/Home'
import ChatRoom from '@/components/Chat'
import RoomForm from '@/components/RoomForm'
import MyPage from '@/components/MyPage'

Vue.use(Router)

let router = new Router({
  routes: [
    { path: '/', component: Home },
    { path: '/room/:id', name: "room", component: ChatRoom },
    { path: '/newroom', component: RoomForm },
    { path: '/mypage', component: MyPage },
  ]
})

export default router
```

```javascript:components/Chat.vue
<template>
    <div id="chat">
        <h1>{{ roomname }}</h1>
        <v-alert type="error" v-model="hasError" dismissible>{{ errorMessage }}</v-alert>
        <div v-if="!registerd">
            <v-form>
                <v-text-field label="チャットルームでのアカウント名" v-model="name" :rules="[rules.required]"></v-text-field>
                <v-btn @click="registerName">OK</v-btn>
            </v-form>
        </div>
        <div class="message" v-for="(message, index) in messages" :key="index" v-bind:class="{ 'mine': message.isMine }">
            <div class="username" v-if="index>0 && messages[index-1].author != message.author">{{message.name}}</div>
            <div class="username" v-if="index == 0">{{message.name}}</div>
            <v-chip outlined>{{ message.text }}</v-chip>
        </div>
        <v-form v-if="registerd" class="d-flex align-center">
            <v-textarea class="pa-10" v-model="input" rows="1"></v-textarea>
            <v-btn class="pa-2" @click="sendmessage">送信</v-btn>
        </v-form>
    </div>
</template>
<script>
import firebase from 'firebase'
export default {
    data () {
        return {
            registerd: false,
            roomname: "",
            name: "",
            input: "",
            messages: [],
            rules: {
                required: value => !!value || 'Required.',
            },
            hasError: false,
            errorMessage: "",
        }
    },
    created () {
        this.getData();
    },
    methods: {
        registerName: function(){
            if(!this.validateName()){
                this.hasError = true
                this.errorMessage = "表示名を入力してください"
                return
            }
            const roomid = this.$route.params.id;
            const db = firebase.firestore();
            const user = firebase.auth().currentUser;
            const userRef = db.collection("rooms").doc(roomid).collection("users").doc(user.uid)
            try {
                userRef.set({name: this.name})
            } catch (error) {
                this.hasError = true
                this.errorMessage = "表示名登録に失敗しました"
                return
            }
            this.registerd = true;
        },
        getData: async function(){
            const self = this;
            const roomid = this.$route.params.id;
            const db = firebase.firestore();
            const user = firebase.auth().currentUser;

            // get room info
            const roomRef = db.collection("rooms").doc(roomid)
            const roomSnap = await roomRef.get()
            if(roomSnap.exists){
                this.roomname = roomSnap.data().name
            }else{
                this.hasError = true
                this.errorMessage = "ルーム情報取得に失敗しました"
                return
            }

            // get user disp name
            const userRef = db.collection("rooms").doc(roomid).collection("users").doc(user.uid);
            try {
                const userSnapshot = await userRef.get()
                if(userSnapshot.exists){
                    this.registerd = true
                    this.name = userSnapshot.data().name
                }
            } catch (error) {
                this.hasError = true
                this.errorMessage = "表示名取得に失敗しました"
                return
            }

            // get messages
            const messagesRef = db.collection("rooms").doc(roomid).collection("messages");
            messagesRef.orderBy("created").onSnapshot({next: function(querySnapshot){
                self.messages = [];
                querySnapshot.docs.forEach(function(doc) {
                    let data = doc.data()
                    data.isMine = data.author == user.uid? 1: 0
                    self.messages.push(data)
                });
            }, error: function(error){
                this.hasError = true
                this.errorMessage = "メッセージ取得に失敗しました"
            }});
        },
        sendmessage: async function(){
            if(!this.validateMessage()){
                this.hasError = true
                this.errorMessage = "メッセージを入力してください"
                return
            }
            const roomid = this.$route.params.id;
            const db = firebase.firestore();
            const messegesRef = db.collection("rooms").doc(roomid).collection("messages");
            const user = firebase.auth().currentUser;
            try {
                messegesRef.add({name: this.name, text: this.input, author: user.uid, created: new Date()});
            } catch (error) {
                this.hasError = true
                this.errorMessage = "メッセージ送信に失敗しました"
                return
            }
            this.input = ""
        },
        validateMessage: function(){
            return !(this.input == "")
        },
        validateName: function(){
            return !(this.name == "")
        }
    }
}
</script>
<style>
.message{
    text-align: left;
}
.message.mine{
    text-align: right;
}
</style>

```

```javascript:components/RoomForm.vue
<template>
    <div>
        <v-alert type="error" v-model="hasError" dismissible>{{ errorMessage }}</v-alert>
        <v-form>
            <v-text-field label="チャットルーム名" v-model="roomname" :rules="[rules.required]"></v-text-field>
            <v-text-field label="チャットルームでのアカウント名" v-model="nickname" :rules="[rules.required]"></v-text-field>
            <v-btn @click="createroom">作成</v-btn>
        </v-form>
    </div>
</template>
<script>
import firebase from 'firebase'
export default {
    data () {
        return {
            roomname: "",
            nickname: "",
            rules: {
                required: value => !!value || 'Required.',
                counter: value => value.length <= 20 || 'Max 20 characters',
                email: value => {
                    const pattern = /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/
                    return pattern.test(value) || 'Invalid e-mail.'
                },
            },
            hasError: false,
            errorMessage: "",
        }
    },
    created () {
    },
    methods: {
        createroom: async function(){
            if(!this.validateRoom()){
                this.hasError = true
                this.errorMessage = "チャットルーム名を入力してください"
                return
            }
            if(!this.validateNickname()){
                this.hasError = true
                this.errorMessage = "チャットルームでのアカウント名を入力してください"
                return
            }
            const self = this
            const user = firebase.auth().currentUser;

            // DBにルーム作成
            const db = firebase.firestore();
            const batch = db.batch();
            
            // create room
            const roomRef = db.collection("rooms").doc()
            batch.set(roomRef, { name: this.roomname, owner: user.uid })
            // add user
            const userRef = roomRef.collection("users").doc(user.uid)
            batch.set(userRef, {name: this.nickname})
            // add room to user info
            const userInfoRef = db.collection("users").doc(user.uid).collection("rooms").doc(roomRef.id)
            batch.set(userInfoRef, {name: this.roomname})
            batch.commit().then(function(){
                // 作成したルームへ移動
                self.$router.push({ name: 'room', params: { id: roomRef.id } })
            }).catch(function(error){
                console.log(error)
                self.hasError = true
                self.errorMessage = "チャットルーム作成に失敗しました"
                return
            })
        },
        validateRoom(){
            return !(this.roomname == "")
        },
        validateNickname(){
            return !(this.nickname == "")
        }
    }
}
</script>
<style>

</style>

```

```javascript:components/MyPage.vue
<template>
    <div id="home">
        <h1>MyPage</h1>
        <v-alert type="error" v-model="hasError" dismissible>{{ errorMessage }}</v-alert>
        <v-list>
            <v-list-item v-for="(room, index) in rooms" :key="index">
                <v-list-item-content>
                    <v-list-item-title>
                        <router-link v-bind:to="{ name: 'room', params: {id: room.id}}">{{ room.name }}</router-link>
                    </v-list-item-title>
                </v-list-item-content>
            </v-list-item>
        </v-list>
    </div>
</template>
<script>
import firebase from 'firebase'
export default {
    data () {
    return {
        rooms: [],
        hasError: false,
        errorMessage: "",
    }
  },
  async created () {
      await this.getRooms()
  },
  methods: {
    // ルーム一覧取得
    getRooms: async function(){
        console.log("getRooms")
        const self = this
        const db = firebase.firestore();
        const user = firebase.auth().currentUser;
        const roomsRef = db.collection("users").doc(user.uid).collection("rooms");
        const querySnapshot = await roomsRef.get()
        if(querySnapshot){
            querySnapshot.forEach((roomSnapshot)=>{
                if(roomSnapshot.exists){
                    self.rooms.push({id: roomSnapshot.id, name: roomSnapshot.data().name})
                }
            })
        }else{
            //this.hasError = true
            //this.errorMessage = "ルーム一覧の取得に失敗しました"
        }
    }
  }
}
</script>
<style>

</style>

```

```javascript:main.js
```
# まとめ
firebaseの可能性を感じた
これで無料はエグい
Vuetifyはいい感じになって良い

# 今後追加するかもしれない機能
- PWA対応（新規メッセージの通知）
- 共有用のQRコード発行
- アカウント名変更
- 直近だけ読み込んどいて、さかのぼってくとその分読み込むみたいな機能