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

#　はじめに

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

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

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

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

# 今回追加する機能

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

# RealtimeDatabase

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

<img width="512" alt="スクリーンショット 2017-12-18 23.31.55.png" src="https://qiita-image-store.s3.amazonaws.com/0/79414/ab7a82e7-f791-39d4-c6ae-4610eaadd6ff.png">


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

## 本当は...

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

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

# 手順

[Angular&Firebaseでユーザー認証してみる](https://qiita.com/daikiojm/items/65b4f27a1dc82f449666)の手順が全て済んでおり、アプリのベースができているのを前提に進めていきます。

## Database(RealtimeDatabase)

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

<img width="660" alt="スクリーンショット 2017-12-19 1.23.48.png" src="https://qiita-image-store.s3.amazonaws.com/0/79414/8afada08-713d-9860-6654-5e54e0daae95.png">


## FollowService

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

```
$ ng g service services/follow
```

```ts: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
```

```ts: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](https://qiita-image-store.s3.amazonaws.com/0/79414/7586bef2-f9e3-268f-666d-f6b4fd7c2954.png)


```
$ ng g component components/user-detail
```

テンプレート側

```html: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側

```ts: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
```

テンプレート側

```html: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側

```ts: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

```ts: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

```ts: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でリベンジしてみたいと思います。

# 参考

* [angularfire2 -GitHub](https://github.com/angular/angularfire2)
    * AngularでFirebaseを扱うためのライブラリ
* [Cloud FirestoreのSubCollectionとQueryっていつ使うの問題](https://qiita.com/1amageek/items/d2ef7a49bccf5b4ea78e)
    * 元々Cloud Firestoreを使おうと思っていたで、こちらの記事でfollowerコレクションの実現方法に関して言及されている部分が大変参考になりました。