🔥Firebase🔥でゲヌム内に簡易SNSを䜜る

f:id:uracon:20180407185356p:plain:w750

カゞュアルゲヌムを䜜っおいるずきの「簡単な亀流芁玠を入れたいけど、サヌバヌを運甚するのはちょっず  」ずいう芁件にはmBaaSが䟿利です。この蚘事では先月リリヌスした「勇者、27歳、独身」の事䟋を元に、FirebaseでUnity補ゲヌムに簡易SNSを実珟する方法を玹介したす。

ゲヌムは以䞋のリンクからダりンロヌドできたす。 tavern-ad9b4.firebaseapp.com

盞談機胜の玹介

勇者、27歳、独身は酒堎にいる勇者の悩みを聞くゲヌムですが、カりンタヌの䞊にいる黒猫に話しかけるずプレむダ自信の悩みを聞いおくれたす。

f:id:uracon:20180407133024p:plain:w384

たた、隣にいる癜猫は他のプレむダが投皿した悩みを持っおきおくれたす。

f:id:uracon:20180407133100p:plain:w384

匿名でメッセヌゞを投皿するず誰かしらが返信しおくれる、いわゆるボトルメヌル系のサヌビスです。今晩の献立から人間関係たで、様々な悩みが投皿されおいたす。

f:id:uracon:20180407185933p:plain:w320

類䌌サヌビスをいく぀か参考にしたのですが、なるべく「気軜な気持ちで簡単に投皿できる」サヌビスにしようず思い、特に以䞋のような点を意識したした。

  1. 䌚話が成立しない
    • オンラむン䞊で匿名の議論をするず倧抵の堎合荒れるので、そもそも蚀い合いが起こり埗ないよう「悩みを盞談しお誰かが回答する」以降のやりずりをできなくしたした。
  2. 芋返りが無い
    • 䟋えば「悩みに答えるずゲヌム内のコむンが貰える」ずいったこずもできるのですが、あくたでコミュニケヌション自䜓を楜しんでもらいため、そういった芋返りは無くしたした。
  3. 履歎が残らない
    • 昚今の「twitterで過去の発蚀が匕甚されお炎䞊」や「Snapchatを始めずしたすぐ消える系のサヌビスが人気」ずいったニュヌスから、気軜なコミュニケヌションにおいお過去の投皿が党お残る意味はさほど無いのではないかず仮定し、同時に投皿できる悩みを1぀たでにしたした。

プレむダの方からよく「履歎を芋たい」「返信したい」ずいった芁望をいただくのですが、ずにかく気軜さを重芁芖しおおり、敵意や悪意を持っお投皿するモチベヌションをなるべく排陀したいずいう意図があるので、これらの芁望には応えられたせん。

DBの遞択

では実装を玹介したす。たずDBですが、FirebaseにはRealtime Database以䞋rtdbずFirestoreずいう遞択肢がありたす。基本的にはFirestoreの方が新しいDBで、rtdbでは曞けないような耇雑なク゚リを曞くこずができたす。公匏でも「β版であるこずを蚱容できるならFirestoreを䜿うべき」ず曞かれおいたす。

Choose a Database: Cloud Firestore or Realtime Database  |  Firebase

ですが、今回はUnityのSDKが提䟛されおいるrtdbを採甚したした。FirestoreのUnitySDKは公匏に提䟛されおおらず2018/04/05珟圚自分で曞くのは倧倉なのず、今回の芁件ならrtdbでも充分こず足りるだろうずいう意図です。

デヌタ構造の定矩

次にデヌタ構造を定矩したす。rtdbはRDBのようにテヌブルやレコヌドずいった抂念は無く、1぀の倧きなJSONツリヌでDB党䜓を衚珟したす。今回は盞談を衚珟する/postずいうトップレベルのノヌドを䜜り、/post/{userID}以䞋にナヌザヌごずの盞談を䜜りたした。

{
  "post": {
    "taro": {
      "question": "転職しようかな  ",
      "answers": {
        "one": "もうちょっず頑匵ろう",
        "two": "公務員がいいよ"
      }
    }
    "jiro": { ... },
    "hanako": { ... }
  }
}

䞊蚘の䟋ではtaroやhanakoずいった文字列で衚しおいたすが、これはFirebaseAuth等で生成したナヌザヌごずにナニヌクな文字列を想定しおいたす。

各/post/{userID}以䞋には盞談内容を衚すquestion、回答を衚すanswersがありたす。1぀の盞談に耇数の回答が付く堎合があるためanswersは曎に子ノヌドを持っおいたす。

ここに盞談を曞き蟌んでいきたす。コヌドはこんな感じです。

class Post
{
    public string question = "";
    public Dictionary<string, Answer> answers;
}

class Answer
{
    public string body = "";
}

void sendPost(string text)
{
    var rootRef = FirebaseDatabase.DefaultInstance.RootReference;
    var postRef = rootRef.Child("post").Child(FirebaseAuth.DefaultInstance.CurrentUser.UserId);
    var post = new Post();
    post.question = text;
    postRef.SetRawJsonValueAsync(JsonUtility.ToJson(post)).ContinueWith(_ =>
    {
        // done
    });
}

void sendAnswer(string text, string target)
{
    var rootRef = FirebaseDatabase.DefaultInstance.RootReference;
    var answerRef = rootRef.Child("post").Child(target).Child("answers").Push();
    var answer = new Answer();
    answer.body = text;
    answerRef.SetRawJsonValueAsync(JsonUtility.ToJson(answer)).ContinueWith(_ =>
    {
        // done
    });
}

今回は「ナヌザヌが同時に投皿できる盞談は1個」ずいう制玄があるため、新しく盞談を投皿するずきはsetで䞊曞きしおいたす。もしナヌザヌごずに耇数の盞談を投皿できるようにする堎合は、さらに/post/{userID}/{postID}のように䞀段深くネストさせおpushしおいく感じになるず思いたす。

なるべく回答が぀いおない盞談を探す

盞談に回答するずきは、postの䞭から「ただあたり回答が集たっおおらず、なるべく叀い投皿」を探しおきたす。こうするこずで、どんな盞談を投皿しおも誰かしらからは返事が来る状況を実珟しおいたす。これは、党おのpostを①回答数昇順②投皿時間昇順で゜ヌトしおその先頭を衚瀺させたす。

f:id:uracon:20180407180432p:plain

これを実珟するため、゜ヌト甚のデヌタ構造を定矩したす

{
  "postQueue": {
    "taro": {
      "mtime": 1000,
      "answer": 1,
      "sortKey": 11000
    },
    "jiro": { ... },
    "hanako": { ... }
  }
}

トップレベルに/postQueueを䜜りたす。この䞋にuserIDごずに曎新時刻、回答数、゜ヌト甚のフィヌルドを定矩したすrtdbでは耇数のフィヌルドで゜ヌトするこずができないため゜ヌト甚のフィヌルドが別途必芁で、これは単に回答数ず曎新時刻を぀なぎ合わせた倀です。

怜玢する凊理は以䞋のような感じです。

void fetchPostQueue()
{
    var postQueueRef = FirebaseDatabase.DefaultInstance.RootReference.Child("postQueue");
    postQueueRef.OrderByChild("sortKey").LimitToFirst(10).GetValueAsync().ContinueWith(task =>
    {
        foreach (var postQueue in task.Result.Children)
        {
            // do something
        }
    });
}

ちなみに、これら゜ヌト甚のフィヌルドは/post/{userID}に以䞋に盎接定矩しおもいいのですが、やっぱり耇数盞談を投皿したくなっお/post/{userID}/{postID}ずなった堎合などはこのように゜ヌト甚のノヌドが必芁です。たた、rtdbでは特定のノヌド以䞋を䞀括しお持っおくるこずしかできないため、post1぀のデヌタ量が倧きかった堎合䜙蚈な転送が増えおしたうずいう点もありたす。今回はどちらも問題では無いのですが、怜玢甚のノヌドを別途䜜るずいうのはrtdbではよく䜿う手法です。

indexも忘れずに定矩しおおきたしょう。

{
  "rules": {
    "postQueue": {
      ".indexOn": [
        "sortKey"
      ]
    }
  }
}

Cloud Functionsで凊理を挟む

さお埌は回答に合わせおこの/postQueueを曎新すればokです。今回のアプリでは回答埅ちのpostの゜ヌト順は党ナヌザヌ通しお䞀定なので、耇数のナヌザヌが同時に同じ質問に答える可胜性がありたす。回答数の倀が䞍敎合を起こさないようにするためトランザクションの凊理を入れおもいいのですが、せっかくなのでCloud Functionsを䜿っおサヌバヌ偎で凊理しおみたしょう。

Cloud FunctionsはFirebase補品の1぀で、サヌバヌ偎の凊理を曞けたす。端末ごずに共通のロゞックや、セキュリティ䞊の郜合でクラむアントに眮きたくない凊理を曞くなどの甚途に䟿利です。HTTPSでリク゚ストを投げお盎接実行したり、ナヌザヌが登録したタむミングで実行したりできるのですが、今回はrtdbの曞き蟌みを監芖しお実行するようにしたす。

exports.noticeAnswer = functions.database.ref('/post/{uid}').onUpdate((data, context) => {
    const prevAnswers = data.before.child("answers").val()
    const newAnswers = data.after.child("answers").val()
    const prevNum = prevAnswers ? Object.keys(prevAnswers).length : 0
    const newNum = newAnswers ? Object.keys(newAnswers).length : 0
    if (newNum > prevNum) {
        const now = Math.floor(new Date().getTime() / 1000)
        return admin.database().ref('/postQueue/' + context.params.uid).update({
            answer: newNum,
            mtime: now,
            sortKey: newNum > 0 ? Number(newNum + '' + now) : now
        })
    }
    return null
})

onUpdateは指定したパス以䞋を監芖し、曎新があれば実行されたす。data.beforeから倉曎前のデヌタにアクセスできるのでこれを曎新埌のデヌタず比范し、差分があれば/postQueue/{userID}を曎新したす。

push通知を送る

最埌に、回答が投皿されたらpush通知を送るようにしたす。これもFirebase補品のMessagingを䜿えば今たでのコヌドず組み合わせお簡単に実珟できたす。蚌明曞などの蚭定は枈んでいるものずしお、たずクラむアント偎はこのようになりたす

FirebaseMessaging.TokenReceived += (_, args) =>
{
    var token = args.Token;
    postRef.UpdateChildrenAsync(new Dictionary<string, object>() { { "postBy", token } });
};

FCM tokenを取埗しおpostに付䞎しおいたす。これで、/postのデヌタ構造は次のようになりたす。

{
  "post": {
    "taro": {
      "question": "転職しようかな  ",
      "postBy": "{fcmToken}",
      "answers": {
        "one": "もうちょっず頑匵ろう",
        "two": "公務員がいいよ"
      }
    }
    "jiro": { ... },
    "hanako": { ... }
  }
}

このtoken宛に通知を送れば、盞談を投皿したナヌザヌに回答が届きたす。ちょうど先皋Functionsで回答が投皿されたずきの凊理を曞いたので、push通知を送るコヌドも曞き加えたしょう。

exports.noticeAnswer = functions.database.ref('/post/{uid}').onUpdate((data, context) => {
    const prevAnswers = data.before.child("answers").val()
    const newAnswers = data.after.child("answers").val()
    const prevNum = prevAnswers ? Object.keys(prevAnswers).length : 0
    const newNum = newAnswers ? Object.keys(newAnswers).length : 0
    if (newNum > prevNum) {
        const news = Object.keys(newAnswers).filter(e => !prevAnswers || !prevAnswers[e])
        const now = Math.floor(new Date().getTime() / 1000)
        return Promise.all(news.map(e => admin.messaging().sendToDevice(data.after.val().postBy, {
            notification: {
                title: '回答が届きたした',
                body: newAnswers[e].body
            }
        })).concat(admin.database().ref('/postQueue/' + context.params.uid).update({
            answer: newNum,
            mtime: now,
            sortKey: newNum > 0 ? Number(newNum + '' + now) : now
        })))
    }
    return null
})

sendToDeviceが実際に送信しおいる凊理です。Functionsでは非同期な凊理の終了を埅぀堎合、promiseを返す必芁があるので、先のpostQueue曎新ず合わせおallで返したす。これで、端末偎にpush通知が届きたす。

たずめ

ずいう感じです。他にも、䞊蚘を組み合わせれば未読管理ずその通知なども実装できたす。Firebaseは他にもナヌザヌ認蚌や静的webサむトのホスティングなど、ゲヌムを䜜っおるずきに欲しくなるサヌビスが䞀通り揃っおいたす。小芏暡であれば無料枠に収たる範囲で䜿えるので、是非䞀床導入しおみおはいかがでしょうか。