Firebase
FirebaseRealtimeDatabase
AngularFire2
FirebaseDay 19

Firebase Realtime DatabaseとAngularでフォロー/フォロワー機能の構築をしてみた

 はじめに

諸事情により、次のようにテーマが劣化しています。

「Firebase + Angular(AngularFire)でインスタっぽいサービスを作ってみる」

「Firebase Cloud Firestoreでフォロー/フォロワー機能の構築をしてみた」

「Firebase Realtime Databaseでフォロー/フォロワー機能の構築をしてみた」 ←現タイトル

気を取り直して進めていきます。
今回は、Firebase + Angular(AngularFire)でインスタっぽいサービスを作る前段として、TwitterやInstagramのようなユーザー同士のフォロー機能の部分の実装を行った手順を紹介します。
以前、Qiitaに投稿したAngular&Firebaseでユーザー認証してみるという記事の中で作ったAngular + AngularFireのアプリをベースに機能を追加していきたいと思います。

今回追加する機能

  • 全ユーザー一覧からユーザーのフォロー/アンフォローが出来るページ

RealtimeDatabase

RealtimeDatabaseで保持するフォロー/フォロワーの情報は次のようになります。

スクリーンショット 2017-12-18 23.31.55.png

画面上から、Followをクリックすると、followersノードにフォローしたユーザーのUIDのノードが作成され、
その子にフォロー操作を行ったユーザーのUIDとbooleanのステータスを含むオブジェクトを格納します。
同時に、followingノードにはフォロー操作を行ったユーザーのUIDのドードが作成され、
その子にフォローしたユーザーのUIDとbooleanのステータスを含むオブジェクトを格納します。

本当は...

Cloud Firestoreでやりたかった。
Cloud FirestoreのSubCollectionとQueryっていつ使うの問題という記事で紹介されているQuery型を使って、新たなフォローが行われるたびに、followersコレクションに次のようなオブジェクトが追加されていくという方法を想定していましたが、WHEREによる検索を行うと、ほんの数件の取得でも画面で見て取れるほどのもたつきが発生したのと、ドキュメント削除の方法がわからない問題があり、
RealtimeDatabaseかつ冗長な方法で実装することにしました。

{
    "followee":    <UID>,
    "follower":    <UID>,
}

手順

Angular&Firebaseでユーザー認証してみるの手順が全て済んでおり、アプリのベースができているのを前提に進めていきます。

Database(RealtimeDatabase)

今回はテスト的に構築するため、Databaseの「ルール」タブから書き込み・読み込み権限を次のように変更しておきます。

スクリーンショット 2017-12-19 1.23.48.png

FollowService

AngularFireのAPI(RealtimeDatabase)をハンドリングするためのサービスを作成します。

$ ng g service services/follow
src/app/services/follow.service.ts
import { Injectable } from '@angular/core';
import { AngularFireDatabase } from 'angularfire2/database';
import { Observable } from 'rxjs/Observable';
import { map } from 'rxjs/operators';

@Injectable()
export class FollowService {

  constructor(
    private db: AngularFireDatabase
  ) { }

  getFollowers(userId: string) {
    return this.db.object(`followers/${userId}`)
      .valueChanges();
  }

  getFollowing(followerId: string, followedId: string) {
    return this.db.object(`following/${followerId}/${followedId}`)
      .valueChanges()
      .pipe(
        map(value => {
          return value !== null ? true : false;
        })
      );
  }

  follow(followerId: string, followedId: string) {
    this.db.object(`followers/${followedId}`).update({ [followerId]: true } );
    this.db.object(`following/${followerId}`).update({ [followedId]: true } );
  }

  unfollow(followerId: string, followedId: string) {
    this.db.object(`followers/${followedId}/${followerId}`).remove();
    this.db.object(`following/${followerId}/${followedId}`).remove();
  }
}
  • getFollowers()
    • 対象のUIDを受け取ってdbから全フォローワーを返す。
  • getFollowing()
    • 対象のUIDとログイン中のユーザーのUIDを受け取って対象のユーザーをフォロー中かどうかを返す
  • follow()、unfollow()
    • その名の通り。

UsersService

AngularFireのAPI(CloudFirestore)をハンドリングするためのサービスを作成します。
ユーザー一覧ページでユーザー一覧の取得を行うために使います。

$ ng g service services/users
src/app/services/users.service.ts
import { Injectable } from '@angular/core';
import { AngularFirestore } from 'angularfire2/firestore';

@Injectable()
export class UsersService {

  constructor(
    private afStore: AngularFirestore
  ) { }

  getAllUsers() {
    const usersRef = this.afStore.collection('users');
    return usersRef.valueChanges();
  }
}
  • getAllUsers()
    • CloudFirestoreのusersコレクションを取得しています。

UserDetailComponent

1ユーザーごとの詳細情報とFollow/Unfollowボタンを配置するコンポーネントを作成します。
次のスクリーンショットの青枠の部分です。

スクリーンショット_2017-12-19_0_59_44.png

$ ng g component components/user-detail

テンプレート側

src/app/components/user-detail/user-detail.component.html
<div style="display: flex; justify-content: space-around; margin: 10px;">
  <img [src]="user.photoURL" style="width: 100px; height: 100px">
  <p>{{user.displayName}}</p>
  <p>Follower: {{followerCount}}</p>
  <p>
    <button *ngIf="!isFollowing" (click)="toggleFollow()">Follow</button>
    <button *ngIf="isFollowing" (click)="toggleFollow()">Unfollow</button>
  </p>
</div>

TypeScript側

src/app/components/user-detail/user-detail.component.ts
import { Component, OnInit, OnDestroy, Input } from '@angular/core';
import { Subscription } from 'rxjs/Subscription';
import { FollowService } from './../../services/follow.service';

@Component({
  selector: 'app-user-detail',
  templateUrl: './user-detail.component.html',
  styleUrls: ['./user-detail.component.css']
})
export class UserDetailComponent implements OnInit, OnDestroy {

  @Input() user;
  @Input() currentUser;

  public followerCount: number;
  public isFollowing: boolean;
  followers: Subscription;
  following: Subscription;

  constructor(
    private followService: FollowService
  ) { }

  ngOnInit() {
    const userId = this.user.uid;
    const currentUserId = this.currentUser.uid;

    this.following = this.followService.getFollowing(currentUserId, userId)
      .subscribe(following => {
        this.isFollowing = following;
      });

    this.followers = this.followService.getFollowers(userId)
      .subscribe(followers => {
        if (followers === null) {
          this.followerCount = 0;
        } else {
          this.followerCount = Object.keys(followers).length;
        }
      });
  }

  ngOnDestroy() {
    this.followers.unsubscribe();
    this.following.unsubscribe();
  }

  toggleFollow() {
    const userId = this.user.uid;
    const currentUserId = this.currentUser.uid;
    if (this.isFollowing) {
      this.followService.unfollow(currentUserId, userId);
    } else {
      this.followService.follow(currentUserId, userId);
    }
  }
}

UserListComponent

ユーザーリストを表示するページコンポーネントをを作成します。
UserDetailComponentの項目にあるスクリーンショットの赤枠の部分です。

$ ng g component components/user-list

テンプレート側

src/app/components/user-list/user-list.component.html
<p>ユーザー一覧</p>
<div *ngIf="auth.user | async as currentUser">
  <div *ngIf="users | async as users">
    <div *ngFor="let user of users">
      <app-user-detail [user]="user" [currentUser]="currentUser"></app-user-detail>
    </div>
  </div>
</div>

TypeScript側

src/app/components/user-list/user-list.component.ts
import { Component, OnInit } from '@angular/core';
import { AuthService } from './../../services/auth.service';
import { UsersService } from './../../services/users.service';
import { Observable } from 'rxjs/Observable';

@Component({
  selector: 'app-user-list',
  templateUrl: './user-list.component.html',
  styleUrls: ['./user-list.component.css']
})
export class UserListComponent implements OnInit {
  public users: Observable<any>;

  constructor(
    public auth: AuthService,
    public usersService: UsersService
  ) { }

  ngOnInit() {
    this.users = this.usersService.getAllUsers();
  }
}

AppModuleとAppRoutingModule

上記までの実装に伴って、AppModuleと、AppRoutingModuleにも変更が入ります。

AppModule

src/app/app.module.ts
import { BrowserModule } from '@angular/platform-browser';
import { NgModule } from '@angular/core';
import { AppRoutingModule } from './app-routing.module';
import { AppComponent } from './app.component';

import { ReactiveFormsModule } from '@angular/forms';
import { AngularFireModule } from 'angularfire2';
import { AngularFireAuthModule } from 'angularfire2/auth';
import { AngularFirestoreModule } from 'angularfire2/firestore';
import { AngularFireDatabaseModule } from 'angularfire2/database';
import { UserLoginComponent } from './pages/user-login/user-login.component';
import { UserProfileComponent } from './pages/user-profile/user-profile.component';
import { UserSignupComponent } from './pages/user-signup/user-signup.component';
import { UserListComponent } from './pages/user-list/user-list.component';
import { AuthService } from './services/auth.service';
import { AuthGuard } from './guard/auth.guard';
import { UsersService } from './services/users.service';
import { FollowService } from './services/follow.service';
import { UserDetailComponent } from './components/user-detail/user-detail.component';
import { environment } from './../environments/environment';

@NgModule({
  declarations: [
    AppComponent,
    UserLoginComponent,
    UserProfileComponent,
    UserSignupComponent,
    UserListComponent,
    UserDetailComponent
  ],
  imports: [
    BrowserModule,
    AppRoutingModule,
    ReactiveFormsModule,
    AngularFireModule.initializeApp(environment.firebase),
    AngularFireAuthModule,
    AngularFirestoreModule,
    AngularFireDatabaseModule
  ],
  providers: [
    AuthService,
    AuthGuard,
    UsersService,
    FollowService
  ],
  bootstrap: [AppComponent]
})
export class AppModule { }

AppRoutingModule

src/app/app-routing.module.ts
import { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';
import { UserLoginComponent } from './pages/user-login/user-login.component';
import { UserSignupComponent } from './pages/user-signup/user-signup.component';
import { UserProfileComponent } from './pages/user-profile/user-profile.component';
import { UserListComponent } from './pages/user-list/user-list.component';
import { AuthService } from './services/auth.service';
import { AuthGuard } from './guard/auth.guard';

const routes: Routes = [
  { path: '', redirectTo: '/profile', pathMatch: 'full' },
  { path: 'profile', component: UserProfileComponent, canActivate: [AuthGuard] },
  { path: 'userlist', component: UserListComponent, canActivate: [AuthGuard] },
  { path: 'login', component: UserLoginComponent },
  { path: 'signup', component: UserSignupComponent }
];

@NgModule({
  imports: [RouterModule.forRoot(routes)],
  exports: [RouterModule]
})
export class AppRoutingModule { }
  • userlist
    • 今回追加したuserlistのルーティング定義を追加しています。

上記までで、userlistユーザーのフォロー/アンフォロー/フォロワー数の確認が出来るようになりました。

終わりに

ユーザー情報の保存のCloudFirestoreを使いつつも、フォロー機能ではRealtimeDatabaseを使うという統一感の内向性になってしまっています。次はCloudFirestoreでリベンジしてみたいと思います。

参考