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
- id
- users(サブコレクション)
- id
- name
- id
- id
-
users
- id
- created
- rooms(サブコレクション)
- id
- name
- id
- id
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/
書き込むデータのバリデーションもここで記述できる
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
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')
<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>
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
<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>
<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>
<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>
まとめ
firebaseの可能性を感じた
これで無料はエグい
Vuetifyはいい感じになって良い
今後追加するかもしれない機能
- PWA対応(新規メッセージの通知)
- 共有用のQRコード発行
- アカウント名変更
- 直近だけ読み込んどいて、さかのぼってくとその分読み込むみたいな機能