FJCT_ニフクラ mobile backend(mBaaS)お役立ちブログ

スマホアプリ開発にニフクラ mobile backend(mBaaS)。アプリ開発に役立つ情報をおとどけ!

NCMBでフォロー/フォロワー機能を作る(その3:ユーザ一覧とフォロー機能の実装)

f:id:mbaasdevrel:20210705185116j:plain

最近、NCMBを使ってユーザ同士がコミュニケーションできる仕組みを構築したいというニーズが出てきています。メッセージングアプリだけでなく、ゲームやフリマ、フォーラムアプリなど、多くのアプリでフォロー/フォロワーの仕組みが利用されます。

今回はコミュニケーション機能の基盤になるであろう、フォロー/フォロワー機能の実装を真剣に検討してみたので、その実装について解説します。前回はデータストアのスキーマについて解説しました。前回の認証までの実装に続けて、今回はユーザ一覧とフォロー機能の実装を、Monaca(JavaScript SDK)で行っていきます。

ユーザプロフィール一覧の表示

pages/list.html にて、ユーザプロフィールを一覧表示します。これは前回の認証チェックの後に実行します。

// 画面表示前に実行されるイベント
$on('pageBeforeIn', async (e, page) => {
  // 認証状態のチェック
  if (!(await checkAuth())) {
    // false が返ってきたらログイン画面に遷移
    return $f7router.navigate({name: 'Login'});
  }
  const Profile = ncmb.DataStore('Profile');
  const ary = await Profile.limit(100).fetchAll();
  // 取得したプロフィールデータをHTMLに描画
  showProfiles.bind(page)(ary);
  // 描画した一覧をタップした際のイベント
  page.$el.find('.profiles tbody tr').on('click', (e) => changeScreen(e, ary));
});

showProfiles 関数は次のようになります。テーブルタグの中に一覧を表示していきます。

// プロフィールデータを描画する関数
const showProfiles = function(ary) {
  this.$el.find('.profiles tbody').html(ary.map(profile => `
  <tr data-object-id="${profile.objectId}">
    <td nowrap>${profile.displayName}</td>
    <td nowrap>${profile.follows_count}</td>
    <td nowrap>${profile.followers_count}</td>
  </tr>
  `).join(''));
}

f:id:mbaasdevrel:20210705185058j:plain

一覧をタップした際のイベント

一覧されたプロフィールデータをタップすると、 changeScreen 関数が呼ばれます。これはタップされたプロフィールデータを使って pages/show.html に遷移する関数です。

// 一覧をタップした際のイベント
const changeScreen = async function(e, ary) {
  // タップしたデータのオブジェクトIDを取得
  const objectId = $(e.target).parents('tr').data('object-id');
  // 対象になるプロフィールデータを取得
  const profile = ary.filter(m => m.objectId === objectId)[0];
  $f7router.navigate(`/show/${objectId}`, {props: { profile }});
}

pages/show.html の編集

ユーザ表示を行う pages/show.html の画面はまずユーザ情報の表示があります。さらにフォロー、フォロー解除用のボタンを表示します。

<template>
  <div class="page">
    <div class="navbar">
      <div class="navbar-bg"></div>
      <div class="navbar-inner sliding">
        <div class="left">
          <a href="/list" class="link">
            <i class="f7-icons">chevron_left</i> 戻る
          </a>
        </div>
        <div class="title">${ profile.displayName }</div>
      </div>
    </div>
    <div class="page-content">
      <div class="list">
        <ul>
          <li>
            <div class="item-content">
              <div class="item-title">
                <div class="item-header">表示名</div>
                ${ profile.displayName }
              </div>
            </div>
          </li>
          <li>
            <div class="item-content">
              <div class="item-title">
                <div class="item-header">フォロワー数</div>
                ${ profile.followers_count }
              </div>
            </div>
          </li>
          <li>
            <div class="item-content">
              <div class="item-title">
                <div class="item-header">フォロー数</div>
                ${ profile.follows_count }
              </div>
            </div>
          </li>
          <li class="no-me">
            <a href="#" class="list-button follow" @click=${follow}>フォローする</a>
            <a href="#" class="list-button color-red unfollow" @click=${unfollow}>フォロー解除</a>
          </li>
        </ul>
      </div>
      <div class="block-title">フォロワー</div>
      <div class="list">
        <ul class="followers">
        </ul>
      </div>
      <div class="block-title">フォロー</div>
      <div class="list">
        <ul class="follows">
        </ul>
      </div>
    </div>
  </div>
</template>

f:id:mbaasdevrel:20210705185116j:plain

このフォロー、フォロー解除用のボタンはユーザの状態によって表示または非表示になります。

  • 自分のプロフィールだった場合
    ボタンを両方とも非表示
  • まだフォローしていない場合
    フォロー解除ボタンは非表示
  • すでにフォローしている場合
    フォローするボタンを非表示

これを実装すると、次のようになります。

const { profile } = props;
const user = ncmb.User.getCurrentUser();
// 画面表示前に実行されるイベント
$on('pageBeforeIn', async (e, page) => {
  const user = ncmb.User.getCurrentUser();
  // 自分のプロフィールだったらボタンを非表示に
  if (profile.user.objectId === user.objectId) {
    $('.no-me').hide();
  }
  // フォロー済みかチェック
  const Follow = ncmb.DataStore('Follow')
  const isFollow = await Follow
    .equalTo('follows', profile.objectId)
    .fetch();
  if (isFollow.objectId) {
    // フォローしている
    page.$el.find('.follow').hide();
  } else {
    // フォローしていない
    page.$el.find('.unfollow').hide();
  }
  // フォロー/フォロワー一覧を取得
  const { followers, follows } = await getFollow(profile.user);
  // フォロワーを表示
  showUser.bind(page)(followers, 'followers');
  // フォローしているユーザを表示
  showUser.bind(page)(follows, 'follows')
});

指定したユーザのフォロー/フォロワーを取得する getFollow 関数は次のようになります。Followクラスを特定ユーザで絞り込む場合は、次のように行います。ポインターとしてユーザが保存されていますので、その形式にした上で検索を実行します。

// 指定したユーザのフォロー/フォロワーを取得する
const getFollow = async (user) => {
  // ポインターの作成
  const pointer = {
    __type: 'Pointer',
    className: 'user',
    objectId: user.objectId
  };
  const Follow = ncmb.DataStore('Follow');
  return await Follow
    .equalTo('user', pointer)  // ポインターで検索
    .fetch();
}

フォロー(フォロワー)ユーザを一覧表示する showUser 関数は次のようになります。

// フォロー/フォロワーを一覧表示する関数
const showUser = async function(ids, name) {
  // 表示対象がなければ何もしない
  if (ids.length === 0) return;
  // 表示対象のobjectIdでProfileクラスを検索します
  const Profile = ncmb.DataStore('Profile');
  const profiles = await Profile
    .in('objectId', ids)
    .limit(1000)
    .fetchAll();
  // 返ってきたプロフィールデータを指定されたクラスに描画します
  this.$el.find(`.${name}`).html(profiles.map(profile => `
    <li>
      <div class="item-content">
        <a data-object-id="${profile.objectId}" href="#">${profile.displayName}</a>
      </div>
    </li>
  `).join(''));
  // リストをタップした際の処理
  this.$el.find(`.${name} li`).on('click', e => {
    // タップされたデータのobjectIdを特定します
    const objectId = $(e.target).data('object-id');
    // タップされたプロフィールデータを特定します
    const profile = profiles.filter(p => p.objectId === objectId)[0];
    // 画面遷移します
    return $f7router.navigate(`/show/${objectId}`, {props: { profile }});
  });
}

フォロー処理

新しくユーザをフォローする処理です。これはフォロー対象(プロフィールデータ)のobjectIdをFollowクラスに保存します。と、同時にフォロー対象はフォローされたという処理が必要になります。これはACL的に処理ができないので、スクリプト機能で実行します。

// フォロー処理
const follow = async () => {
  // 自分のフォローデータを取得
  let follow = await getFollow(user);
  // フォロー対象を follows フィールドに追加
  await follow
    .addUnique('follows', profile.objectId)
    .update();
  // データを更新(再度取得)
  follow = await getFollow(user);
  // 自分のフォロー数をアップデート
  const myProfile = await getProfile();
  await myProfile
    .set('follows_count', follow.follows.length)
    .update();
  // フォロー対象のデータ更新はスクリプトで実行
  await ncmb.Script
    .data({
      follow_id: profile.objectId,
      follower_id: myProfile.objectId,
      action: 'add'
    })
    .exec('POST', 'follower.js');
  $f7.dialog.alert(`${profile.displayName}をフォローしました`);
}

ここでの注意点として、新しくフォローしたユーザのデータについては addUnique で追加しています。これにより多重処理になったとしても、複数回同じIDが追加されることはありません。そして自分のプロフィールデータに対してフォローカウント数をアップデートします。addUnique関数は特殊なオペレーションになるので、followsフィールドは配列データではなくなってしまいます。そのため、一度データを取得し直しています。

そしてスクリプトに対してフォロー対象とフォロワー(自分)のobjectIdを送っています。また、追加と削除の2つの処理が考えられるので、追加のアクションである指定して add を送っています。スクリプトの実装は後述します。

フォロー解除

同様にフォロー解除の処理です。フォローの解除は remove 関数で行います。これも配列操作用の特殊なオペレーションです。後は基本的にフォロー追加の時と同じ流れになりますが、スクリプトを呼び出す際のアクションが remove としているのが違いになります。

// フォロー解除処理
const unfollow = async () => {
  // 自分のフォローデータを取得
  let follow = await getFollow(user);
  // フォロー解除対象を follows フィールドに追加
  await follow
    .remove('follows', profile.objectId)
    .update();
  // データを更新(再度取得)
  follow = await getFollow(user);
  // 自分のフォロー数をアップデート
  const myProfile = await getProfile();
  await myProfile
    .set('follows_count', follow.follows.length)
    .update();
  // フォロー解除対象のデータ更新はスクリプトで実行
  const res = await ncmb.Script
    .data({
      follow_id: profile.objectId,
      follower_id: myProfile.objectId,
      action: 'remove'
    })
    .exec('POST', 'follower.js');
  $f7.dialog.alert(`${profile.displayName}のフォローを外しました`);
}

スクリプトの実装

では follower.js というスクリプトの実装です。内容は次の通りになります。指定したフォローデータに対して、新しくフォロワーのIDを追加、または削除します。その上で、プロフィールデータを最新のものにアップデートしておきます。

const NCMB = require('ncmb');
const applicationKey = 'YOUR_CLIENT_KEY';
const clientKey = 'YOUR_APPLICATION_KEY';
const userName = 'ADMIN_USER_NAME';
const password = 'ADMIN_PASSWORD';
const ncmb = new NCMB(applicationKey, clientKey);
module.exports = async (req, res) => {
  await ncmb.User.login(userName, password);
  // 更新対象のフォローデータを取得
  let follow = await getFollow(req.body.follow_id);
  // フォローデータがなければ終了
  if (!follow.objectId) return res.send({
    error: `No follow ${req.body.follow_id}`
  });
  // アクションによって処理分け
  switch (req.body.action) {
  case 'add':     // フォローされた際の処理
    follow.addUnique('followers', req.body.follower_id)
    break;
  case 'remove':  // アンフォローされた時の処理
    follow.remove('followers', req.body.follower_id)
    break;
  }
  // 更新
  await follow.update();
  // データ更新(再取得)
  follow = await getFollow(req.body.follow_id);
  // プロフィールを更新
  const Profile = ncmb.DataStore('Profile');
  const profile = new Profile;
  await profile
    .set('objectId', follow.profile.objectId)        // 更新対象のobjectIdを設定
    .set('followers_count', follow.followers.length) // フォロワー数を更新
    .set('follows_count', follow.follows.length)     // フォロー数を更新
    .update();
  res.send({ follow });
}

// フォローデータを返す関数
function getFollow(objectId) {
  const Follow = ncmb.DataStore('Follow');
  return Follow
    .equalTo('profile', {
      __type: 'Pointer',
      className: 'Profile',
      objectId
    }).fetch();
}

コード

コードはNCMBMania/follower_app_monaca: Monacaで実装したフォロー/フォロワー機能ですにアップしてあります。実装時の参考にしてください。

まとめ

これでMonacaアプリにおけるフォロー/フォロワー機能の実装が完了です。この仕組みは汎用的に利用できますので、皆さんのアプリにおいてコミュニケーション機能が必要になった際に参考にしてください。

現状のスクリプトは誰でも実行できる状態になっています。これは、仕組みが分かっていて、かつobjectIdが分かっている場合にフォロー操作ができることを意味します。もしセキュアにしたい場合には、次のような工夫が考えられます。

  • ログインユーザのセッショントークンをスクリプトに送る
  • スクリプトでセッショントークンを利用してユーザ情報が取得できるか確認
  • 取得できる場合は、リクエストされたフォローデータのユーザ情報と比較
  • フォローデータのユーザ情報と一致した場合は、正しいリクエストであると判定して処理を行う

このような処理を追加すれば、よりセキュアにスクリプトが実行できるでしょう。実装時の参考にしてください。

中津川 篤司

中津川 篤司

NCMBエヴァンジェリスト。プログラマ、エンジニアとしていくつかの企業で働き、28歳のときに独立。 2004年、まだ情報が少なかったオープンソースソフトの技術ブログ「MOONGIFT」を開設し、毎日情報を発信している。2013年に法人化、ビジネスとエンジニアを結ぶDXエージェンシー「DevRel」活動をスタート。