LoginSignup
15
15

More than 3 years have passed since last update.

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

Posted at

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

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

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

実際に動いてるのはこちら

自己紹介

SESで5年間働いて、1年ニート
現在は個人事業主

アプリの仕様

  • 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/

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

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

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')

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>

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
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>

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>

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>

main.js

まとめ

firebaseの可能性を感じた
これで無料はエグい
Vuetifyはいい感じになって良い

今後追加するかもしれない機能

  • PWA対応(新規メッセージの通知)
  • 共有用のQRコード発行
  • アカウント名変更
  • 直近だけ読み込んどいて、さかのぼってくとその分読み込むみたいな機能
15
15
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
15
15