リアルタイムDB「Firestore」を使ってお絵かきチャットを作ってみる
はじめに
Firebaseのデータベースサービス「Firestore」を使って、お絵かきチャットを作ってみました。この記事では、お絵かきチャットの実装について、ソースコードを交えて紹介したいと思います。
対象の読者
「チャットのような、リアルタイムにデータ連携するアプリを作ってみたいけど、実装が難しそう。。。」
こんな思いを持っている方にぜひご覧いただきたい内容になっています。読んでいただいた後には、
「Firestoreを使えば、とても簡単に実現できるんだ!」
と思っていただけるはずです。
Firebaseについての前提知識は不要です。実装紹介はVue.jsのコードですが、html, javascriptの基礎知識があれば概要は理解できるように説明したいと思います。
作ったアプリ
まずは、作成したアプリを紹介します。
複数人でボードを共有し、手書きで書いた線が全員に共有されるアプリです。離れた場所にいる相手と一緒に、ライブお絵かきを楽しんだり、言葉で伝えるのが難しいことを図で説明したいときなどに役立ちます。
最初に誰かが部屋を作成し、開いた部屋のURLを他のメンバーに配布して入ってもらえば、メンバー間でボードを共有することができます。
実装紹介 - Firestore編
ここからは、お絵かきチャットを作成するためにやったことを紹介します。
まずはFirestore編ということで、Firebaseの登録から、同コンソール上での操作を解説してゆきます。
Firebase登録、Firestore初期化
このあたりは公式サイトに手順が書いてあるので簡単に流します。
- Firebaseの公式サイトでユーザ登録
- Firebaseプロジェクトを作成
- メニューの「Firestore」 から、ナビゲーションに従いFirestoreを初期化
Firestoreについて簡単に解説
Firestoreは、以下の特徴を持つデータベースです。
- スキーマを持たず、JSONオブジェクトをKey-Valueで保管するタイプ(NoSQL)のデータベース。
- データの変更をクライアントにpush通知する仕組みがあり、リアルタイムに同期させることができる。
- read/writeのアクセスルールを設定することができ、Authenticationと連携すればユーザ認証も可能。
- オフライン状態でも動作する。ブラウザの内蔵ストレージ(IndexedDB)を介してデータのI/Oをキャッシュするため。
お絵かきチャットを作成するうえで大事な部分は、データをクライアントとリアルタイムで同期できる点です。認証とオフラインの機能は、今回は利用しません。
データ構造を考える
お絵かきチャットを実現するために必要なデータ構造について、構想を立てていきます。
Firestoreでは、コレクション、ドキュメントという概念があります。
- コレクション:ドキュメントを収納する入れ物
- ドキュメント:コレクションに収納される1つ1つのデータ
また、ドキュメントはその下に更にコレクションを持つことができます。これはサブコレクションと呼ばれ、データ間に従属関係がある場合に利用されます。
お絵かきチャットでは、絵を共有する場である「部屋」と、部屋の中でユーザが描く「線」のデータ管理が必要になりますので、それぞれ、「rooms」「elements」というコレクションに収納しようと思います。線は各部屋ごとに扱うので、elementsはroomsのサブコレクションとします。イメージは下図のようになります。
- room1
- element1
- element2
- ...
- room2
- element1
- element2
- ...
- ...
※各ドキュメントの名称は、実際には連番ではなくハッシュ文字列になります。
線(elements)の各ドキュメントには、以下のような色と座標列のデータを持たせます。このデータにより、一本一本の線が表現されます。
{
points: [{x: 100, y: 100}, {x: 200, y: 100}, {x: 200, y: 200}],
color: "black"
}
なお、Firestoreはドキュメントを追加した時にコレクションがなければ自動的に作成される仕組みなので、コレクションをあらかじめ作っておく必要はありません。
アクセスルールの設定
FireStore管理画面の「ルール」タブから、各ドキュメントへのアクセス権限を設定してゆきます。以下の5種類のアクセスに対して、許可する条件を設定してゆきます。
- get : ドキュメントIDを指定して単一のドキュメントを取得する
- list : 複数のドキュメントを一覧取得する
- create : 新しいドキュメントを作成する
- update : 既存のドキュメントを更新する
- delete : 既存のドキュメントを削除する
※get,listは合わせてread、create,update,deleteは合わせてwriteとも表記されます。
参考までに、以下のルールにすると、すべてのユーザからのすべてドキュメントに対して、書き込み、読み込みを許可します。一応、この設定でアプリは動作します。
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
match /{document=**} {
allow read, write: if true;
}
}
}
しかし、この設定だと、roomsコレクションに対してlistをすることにより、他の人が建てた部屋のIDが分かってしまい、勝手に入ることができてしまいます。
そこで、roomsコレクションのlistを禁止することで、部屋の一覧を取得できなくします。これで、部屋の作成者と、作成者からURLを受け取ったメンバー以外は部屋のIDを知ることができなくなるはずです。また、update, deleteも、現バージョンでは使用しないので禁止しておきます。
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
match /rooms/{doc} {
allow get, create: if true;
allow list, update, delete: if false;
match /elements/{doc} {
allow read, write: if true;
}
}
}
}
roomsの下にあるelementsコレクションは、すべてのアクセスを許可しました。部屋のIDを知っている時点で正規のメンバーとみなすため、特に制限は必要ないからです。
なお、今回のアプリでは認証を行わないため、条件は大雑把に true/false
で指定していますが、Authenticationサービスと連携してユーザ認証をすることもできます。認証を使う場合のアクセスルールの記法については、この記事の対象外とします。
接続情報の取得
クライアントアプリケーションからFirestoreへ接続するためには、APIキーなどの接続情報が必要です。Firebase管理画面の「設定」から、以下のようなjson形式の接続情報が取得できるので、コピーして控えておきます。
{
apiKey: "xxxx",
authDomain: "xxxx.firebaseapp.com",
databaseURL: "https://xxxx.firebaseio.com",
projectId: "xxxx",
storageBucket: "xxxx.appspot.com",
messagingSenderId: "xxxx",
appId: "xxxx",
measurementId: "xxxx"
}
実装紹介 - Vue.js編
いよいよ、Vue.jsを使った画面の実装に進みます。ここでは、お絵かきの画面(WhiteBoard.vue)に絞り、かつエッセンスとなる部分だけをかいつまんで紹介します。コード全体が見たい方は、GitHubで公開していますので、ご覧ください。
プロジェクト作成と実装の下準備
まず、以下の準備を行います。ここでは説明を割愛させていただきます。
- Node.jsのインストール
- VueCLIのインストール
- VueCLIで新規プロジェクト作成
作成したプロジェクトにfirebaseパッケージをインストールしておきます。
> npm install firebase
さきほど取得した接続情報を使って、firebaseの初期設定をします。これにより、アプリはあなたのFirebaseを識別して接続できるようになります。この設定は、Vue.jsで共通のエントリポイントとなる main.js
に記述しておきます。
import firebase from "firebase";
const firebaseConfig = {
apiKey: "xxxx",
authDomain: "xxxx.firebaseapp.com",
databaseURL: "https://xxxx.firebaseio.com",
projectId: "xxxx",
storageBucket: "xxxx.appspot.com",
messagingSenderId: "xxxx",
appId: "xxxx",
measurementId: "xxxx"
};
firebase.initializeApp(firebaseConfig);
初期処理:Firestoreへの接続とデータの同期
ここからは、お絵かきの画面(WhiteBoard.vue)のコードを紹介してゆきます。
まずはfirebaseパッケージをインポート。
import firebase from "firebase";
ページを開いたときに実行される初期処理から作ってゆきます。Vue.jsでは、初期処理をコンポーネントの関数 created()
に記述してゆきます。
まず、firestoreのインスタンスを取得。
this.db = firebase.firestore();
次に、コレクションへの参照を取得します。今回必要なのは、現在の部屋における線のコレクション(elements)なので、以下のように記述します。なお、this.$route.params.roomId
は、URLに埋め込まれたルームIDが収納されていますので、これにより今自分がいる部屋を特定できます。
this.elementsCollectionRef = this.db
.collection("rooms")
.doc(this.$route.params.roomId)
.collection("elements");
続いて、ここが大事なポイントです。取得したコレクション参照に対して、onSnapshot
でコールバック関数を設定してあげます。このコールバック関数は、自分または他の誰かがコレクションへ変更を加えたときに呼び出されるものです。
this.elementsCollectionRef.onSnapshot((querySnapshot) => {
this.elements = [];
querySnapshot.forEach((doc) => {
const element = doc.data();
element.id = doc.id;
this.elements.push(element);
});
});
コールバック関数の中身を見ると、変更された後の最新の線のデータを this.elements
という配列にすべてpushしています。結果として this.elements
が常にDBの最新の状態と同期することになります。
表示処理:SVG要素テンプレートの構築
次に、this.elements
を画面(html)に反映させる処理を作っていきます。
今回、お絵かきキャンバスの要素としてはSVGを利用しようと思います。SVGとはベクター形式の画像であり、タグ要素で描画を表すため、htmlの一部として表現できます。例えば、多角線を表す要素であるpolylineは下例のように stroke
属性で色を、points
属性で繋げる座標列(px)を表現します。
<polyline
stroke="black"
points="100,100 200,100 200,200"
fill="none"
stroke-linecap="round"
stroke-width="5"
/>
Vue.jsでは、htmlの骨組み(template)をあらかじめ定義し、そこに変数をバインドしておくことで、変数が変化したとき自動的にhtmlも変化するようになります(リアクティブ)。以下のように、this.elements
をバインドしたSVG要素のテンプレートを作成します。
<svg
ref="svgElement"
class="canvas"
version="1.1"
xmlns="http://www.w3.org/2000/svg"
@mousedown.prevent="dragStart"
@mousemove.prevent="dragMove"
@mouseup.prevent="dragEnd"
@touchstart.prevent="dragStart"
@touchmove.prevent="dragMove"
@touchend.prevent="dragEnd"
>
<polyline
v-for="element in elements"
:key="element.id"
fill="none"
:stroke="element.color"
stroke-linecap="round"
stroke-width="5"
:points="pointsAttr(element.points)"
/>
</svg>
svg要素の下にpolyline要素を配置していますが、Vue.jsのテンプレート記法 v-for
を使うことで、配列 this.elements
の各要素に対してpolyline要素を繰り返し配置します。なお、:stroke
などのコロンで始まる属性は変数の値をバインドしています。
変数をバインドするとき、stroke
属性は element.color
に入っている色の文字列をそのままバインドすればよいですが、points
属性は element.points
が配列であるため、そのままバインドしても正しく表示されません。そのため、関数 pointsAttr
を通して、カンマ・スペースつなぎの文字列に変換してからバインドします。
以上で、DBと同期した this.elements
をバインドしたこのsvg要素も、DBとリアルタイムに同期するようになりました。
ユーザ操作による線の描画処理
次に、ユーザがマウスで画面に線を描く処理を作ります。さきほどtemplateのsvg要素に @mousedown.prevent
等のアットマークで始まる属性がありましたが、これはイベント関数のバインドを表します。バインドされた dragStart
, dragMove
, dragEnd
の関数の中身は以下のとおりです。
dragStart() {
const newElement = { points: [], color: this.selectedColor };
this.elements.push(newElement);
const rect = this.$refs.svgElement.getBoundingClientRect();
this.dragMoveHandler = () => {
if (event.touches) {
event.clientX = event.touches[0].clientX;
event.clientY = event.touches[0].clientY;
}
newElement.points.push({
x: event.clientX - rect.x,
y: event.clientY - rect.y,
});
};
this.dragEndHandler = () => {
if (newElement.points.length === 0) return;
this.elementsCollectionRef.add(newElement);
};
},
dragMove() {
if (this.dragMoveHandler) {
this.dragMoveHandler();
}
},
dragEnd() {
if (this.dragEndHandler) {
this.dragEndHandler();
this.dragMoveHandler = null;
this.dragEndHandler = null;
}
}
各関数の概要は以下の通りです。
dragStart
:新しい線をthis.elements
に追加dragMove
:新しい線に座標をpushして伸ばすdragEnd
:描画を確定し、DBに新しい線を追加する
コードではイベントハンドラ関数を着脱したり、svg要素上のポインタの相対座標を求めたりしていますが、本題から逸れるため説明は割愛させていただきます。
最終的に、dragEnd
でFirestoreにデータが追加されるので、その瞬間に、同じ部屋に入っている全クライアント(自分含む)では onSnapshot
で設定したコールバック関数が呼び出され、あなたが描いた線が自動的に表示されます。
以上のようにして、お絵かきのリアルタイム同期を実装することができます。
さいごに
実装の紹介は以上になります。かいつまんでの説明となったため、ここで紹介しきれていない部分もありますが、リアルタイムアプリを作るイメージは掴めたと思います。
お絵かきチャットに限らず、Firestoreを利用すれば様々なリアルタイムアプリを簡単に作成できるので、ぜひ、あなたのアイデアを形にしてみてください。