iOS
TypeScript
Swift
Firebase
cloudfunctions
FirebaseDay 11

Firebase と Algolia Search を連携して検索機能を実装するぞ

どうも miup です。
この記事は Firebase Advent Calendar 2017 の11日目の記事です。この記事では先日弊社で行われた Firebase.Yebisu #1 での自分の発表の補足的な感じの記事です。

はじめに

Firebase には RealtimeDB と Cloud FireStore の2種類のデータベースが用意されています。
これらはどちらも NoSQL のドキュメント指向データベースであり、クエリが弱いです。
FireStore の方は多少マシなのですが、それでも2つのキーに対してレンジを指定できないなど実際のサービスで利用されるような検索を実装するには機能が足りません。
そこでいい感じの検索エンジンである Algolia にいい感じにデータを流していい感じの検索を実装してみようというのがこの記事のコンセプトです。

Algolia

本題に入る前に Algolia について簡単に説明します。
Algolia Search は所謂 Elastic Search のような検索エンジンであり、それを提供しているSaaSです。
自分はモバイルエンジニアなのでサーバーを用意して Elastic Search を立ててゴニョゴニョしたりするのは苦手なため SaaS としてクラウド上にあり、ダッシュボードまで用意されているサービスというのは非常にありがたいのです。
日本語にも対応していて、FireStore の全文検索の記事 にも記載があり、もしかしたら今後なにかしらの連携があるのかも……?

構成

使うものはこんな感じ

  • クライアント (iOSアプリケーション)
    • Salada (for RealtimeDB)
    • Algent (僕の作った Algolia iOS SDK の wrapper)
  • Firebase (外部アクセスを行うので Firebase は有料プランが必須)
    • RealtimeDB (or FireStore)
    • CloudFunctions
  • Algolia Search

大まかなアーキテクチャはスライドを見ていただければわかると思いますが、クライアントから RealtimeDB への書き込みを CloudFunctions がハンドリングして Algolia に流すといった書き込みと、クライアントから Algolia にデータを問い合わせてリアルタイムな値を取りたいもののみ RealtimeDB を Observe する読み込みで構成されます。

実装

書き込み

まずは書き込みから、クライアントから投稿されたときに作動する function を実装していきます
クライアントからの投稿は写真 (Photo) と日記 (Diary) で2種類できるようになっています。
ちなみに僕は TypeScript 有識者ではないのでもっときれいに書けるよとかあれば教えていただきたいです。

export const postCreated  = functions.database.ref('path/to/post/{postID}').onCreate(async event => {
  const firPost: firebaseModel.Post = event.data.val()
  const feedIndex = algolia.initIndex('feed') // Algolia の index をインスタンス化
  let user = await admin.database()
    .ref(`path/to/user/${firPost.userID}`)
    .once('value')
    .then(snap => snap.val())
  switch (firPost.contentType) {
  case 1: // Diary の場合
    let diary = await admin.database()
      .ref(`path/to/diary/${firPost.contentID}`)
      .once('value')
      .then(snap => snap.val())
    let diaryFeed = new Feed(event.params!.postID, firPost, user, diary, undefined)
    return feedIndex.addObject(diaryFeed)
  case 2: // Photo の場合
    let photo = await admin.database()
      .ref(`path/to/photo/${firPost.contentID}`)
      .once('value')
      .then(snap => snap.val())
    let photoFeed = new Feed(event.params!.postID, firPost, user, undefined, photo)
    return feedIndex.addObject(photoFeed)
  default: return undefined
  }
})

スライドにもありますが @star__hoshi 氏のこのへんの記事を参考に RealtimeDB の model をいい感じに定義しています。

上から説明していくと、まず post に対する変更をトリガーに function を発動して、Firebase の model をデータから取得、Algolia の index を取得。
Feed の情報に必要な user, content を取得しそれらのデータから Feed インスタンスを作成して index にオブジェクトを追加といった感じです。
投稿系のサービスの場合大体こんな感じで feed を作成できると思います。複雑なタイムラインを作るならここでその他のいろいろ設定していくことになると思います。

Algent

これは今回 Algolia iOS SDK を使っていたら少し不便な部分があったのでその辺をラップしていい感じにできないかなと思って作成したライブラリです。
Algolia iOS SDK のクライアントは JSON で Response を返してくるのでそれらを TypeSafe に扱えるようにしたのが主なこのライブラリの実装となっています。
用意されているのは大きく分けて3つで、

  • Algent (AlgoliaClient のラッパー)
  • AlgoliaRequestProtocol (検索リクエストのプロトコル)
  • AlgoliaResponse (検索結果のレスポンスのプロトコル)

これらをクライアントから利用する事になります。

読み出し

上記を踏まえて読み込みはこんな感じになります。

struct  Feed: Decodable {
    let objectID: String
    let _createdAt: TimeInterval
    let _updatedAt: TimeInterval
    let _geoloc: Algolia.GeoLocation
    let isLocationEnabled: Bool
    let contentType: Int
    let contentID: String
    let userID: String
    let userName: String
    let diary: Algolia.Diary?
    let photo: Algolia.Photo?
    let likes: Algolia.Relation
}

struct AlgoliaFeedRequest: AlgoliaRequestProtocol {
    typealias HitType = Algolia.Feed

    let page: Int
    let per: Int
    let isLocationEnabled: Bool
    let lng: Double?
    let lat: Double?
    let text: String?
    let radius: Int
    let userIDs: [String]

    var indexName: String {
        return "feed"
    }

    var query: AlgentQuery {
        let query = AlgentQuery(query: text)
        query.page = UInt(page)
        query.hitsPerPage = UInt(per)

        if isLocationEnabled {
            if let lat = lat, let lng = lng {
                query.aroundLatLng = LatLng(lat: lat, lng: lng)
                query.aroundRadius = Query.AroundRadius.explicit(UInt(radius))
            }
        }

        if !userIDs.isEmpty {
            query.facets = ["userID"]
            query.facetFilters = [userIDs.map { "userID:\($0)" }]
        }
        return query
    }

    init(page: Int, per: Int, isLocationEnabled: Bool = false, lng: Double? = nil, lat: Double? = nil, text: String? = nil, radius: Int = 10000, userIDs: [String] = []) {
        self.page = page
        self.per = per
        self.isLocationEnabled = isLocationEnabled
        self.text = text
        self.radius = radius
        self.lat = lat
        self.lng = lng
        self.userIDs = userIDs
    }
}

func fetchFeed(page: Int, per: Int = 30, userIDs: [String] = []) -> Single<AlgoliaResponse<Algolia.Feed>> {
    return .create { observer in
        Algent.shared.search(request: AlgoliaFeedRequest(page: page, per: per, userIDs: userIDs)) { result in
            switch result {
            case .success(let response):
                observer(.success(response))
            case .failure(let error):
                observer(.error(error))
            }
        }
        return Disposables.create()
    }
}

ちょっとコードが長いですがやってることは簡単で、Algolia から返される JSON をパースする型、RequestProtocol に準拠したリクエストの型を定義して Algent に渡すだけです。
これを View まで渡してあげて、View では Realtime に取得したい値 (いいねとか) を Observe してあげるようにします。

class FeedCell: UITableViewCell, Reusable, NibType {
    var likeCountString: Variable<String> = Variable<String>("0")
    private var likecountHandle: UInt?
    private var postID: String?
    private let disposeBag = DisposeBag()

    override func awakeFromNib() {
        super.awakeFromNib()
        likeCountString.asDriver()
            .drive(actionView.likeCountLabel.rx.text)
            .disposed(by: disposeBag)
    }

    func configure(_ feed: Algolia.Feed) {
        likecountHandle = Firebase.Post.databaseRef
            .child(feed.objectID)
            .child("likes/count")
            .observe(.value) { [weak self] snapshot in
                if snapshot.exists() {
                    self?.likeCountString.value = "\((snapshot.value as? Int) ?? 0)"
                } else {
                    self?.likeCountString.value = "0"
                }
            }
    }

    override func prepareForReuse() {
        super.prepareForReuse()
        likeCountString.value = "0"
        if let postID = postID, let likecountHandle = likecountHandle {
            Firebase.Post.databaseRef
                .child(postID)
                .child("likes/count")
                .removeObserver(withHandle: likecountHandle)
        }
    }
}

View の情報とか色々省略していますが、いいねの数を Observe して View に bind する処理はこんな感じで書けるかと思います。

エラー処理

エラー処理に関しては Firebase.Yebisu の発表でもあまり触れられてなくて懇親会での質問も多かった部分です。
やり方は2種類くらい思いついていて、実装コストの軽い順 (正確さの低い順) で書くと

  • Firebase Cloud Messaging を使う方法
  • データベースのモデルにエラー状態を表すオブジェクトを生やす方法

と言った感じです。他になにか良さそうなアイデアがあれば教えていただきたいです。

Firebase Cloud Messaging を使う方法

これは簡単で、 Algolia に追加するデータがおかしかったとき、 Algolia にデータが上手く追加できなかったとき、その投稿をしたユーザーに対してサイレントプッシュを送信してエラー内容を通知するやり方です。
この場合、何かしらの問題がおきてプッシュ通知がユーザーに届かなかった場合、ユーザーはエラー発生を知ることができないので安定性に欠けます。が、実装はかなり楽だと思います。

データベースのモデルにエラー状態を表すオブジェクトを生やす方法

このやり方では、エラーが発生した場合、その Firebase 上のモデルに以下のようなオブジェクトを生やします。

Screen Shot 2017-12-10 at 14.25.30.png

クライアントでは投稿後にこの path/to/post/${postID}/error を Observe してエラーが発生したら何かしらのアクションを起こすようにします。
ちなみに description を desc としているのは Swift の description と被るのを防ぐためです。
この方法ではこのエラーの定義を拡張していくことでリトライの処理を書くことができたり、モデルの値のミスで外部サービスへのデータの受け渡しがうまく行っていなかった場合にモデルのアップデート時に外部サービスへとの連携を再開するといったこともできるようになります。
かなり柔軟に処理できるのでこの方法を推奨します。

以上がデータを書き込んで読み込んでエラー処理をする一連の流れになります。

まとめ

この記事ではちょっと不便な Firebase のデータベースのクエリをデータベースの構造を無理変えて対応したりせずに外部サービスを用いて解決する方法を実装してみました。 Firebase もまだまだ発展していくでしょうし将来はこんなことで悩まなくても済むようになっていくとは思いますが、このような方法で現状の問題を解決できるので、検索、ライムライン型サービスの実装や、外部サービスとの連携を考えていて実装で悩んでいた方は参考にしていただければと思います。