Help us understand the problem. What is going on with this article?

Admin SDK でサーバーレスな Firebase のサーバーサイドを使い倒す

Firebase Advent Calendar 2017の18日目になります。

サーバサイド

サーバーレスな Firebase のサーバサイドといえばそりゃ Firebase Cloud Functions ですが、 Cloud Functions でのプログラムにも利用することの多い Admin SDK を使えば、どこにでも Firebase のサーバサイドを立ち上げられたりして個人的に嬉しいので紹介します。

Admin SDK で出来ること、出来ないこと

簡単なので先に説明すると『Cloud Functions の外の Admin SDK』に出来ないことは、 firebase-functions npm モジュールがやってくれることすべて、つまりトリガーです。

exports.hogeFunction = functions.database.ref('feed/{feedId}').onCreate(event => {
  console.log(event.params.feedId);
  return true;
});

これが出来ません。どうしてもこれじゃないとダメだということは、ままあると思うんですが、それ以外の大抵 Cloud Functions でやってしまう次のようなことは、 Cloud Functions 上でなくても Admin SDK さえあればできます。

  • Realtime Database / Firestore / Storage のルールを無視して読み込み・書き込みをする
  • 全員の Authentication をいじる
  • SDK 経由で FCM を使う

さらに個人的な推しポイントは、

  • Node.js / Java(Kotlin) / Python / Go から選べる

です。

なぜやりたいか

Kotlin

Kotlin で書きたい!

今年 Firebase でやっていた仕事の Cloud Functions で Kotlin JS と flow を使ってきて、今 TypeScript に置き換えているんですが、今所属しているチームのようにネイティブアプリからサーバサイドまで全部やるメンバーでの仕事に使うなら、環境としての Kotlin が一番仕事しやすいと感じています(個人の感想)。

なので Kotlin で書きたい!

Cloud Functions は事実上モジュール群がひとまとまりなのがつらい

Cloud Functions は、主に「1プロジェクトに1 Cloud Functions 環境しか持てない」という制約から、よっぽど真面目に我慢強く設計して運用しないと(後述)、ユーザー向けに稼働しているモジュール(function)と、管理・計測・統計用に稼働するモジュール(function)が簡単に同居してしまいます。つまり

  • 全 function が最強権限を持った(持ちえる)状態で稼働しがち
  • 管理側のデプロイ・変更でエンドユーザー向けが影響を受けがち

になります。

Auth はしょうがないけど function 別にデプロイできるはず

$ firebase deploy --only functions:hogeFunction

ってやつですね。

Cloud Function のデプロイはよくできていて、 deploy コマンドを実行した際に依存 npm モジュール以外のファイルが function ごとに独立した領域にアップロードされ独立して稼働します。この様子は GCP 側の Cloud Functions のコンソールで確認できます。

require したちょっとしたローカルライブラリも deploy した瞬間のものが使われ続け、 function 間で共有されているわけではないので、正しく運用できれば問題ないはずです。

だが俺には正しく運用できねえ

しかしこれをよく考えずに始めると、自分はアホなので

  • 今この function はこのライブラリのどのバージョンを使ってるんだっけ?わからない!えーい全デプロイだ!
  • わーい全デプロイしたら今回直したいのとは違う function が動かなくなったよ…

ということになります。なりました。悲しいです。真面目に考えれば

  • すべての function は別の JS ファイル・モジュールとして管理し、それぞれが package.json を持つようにする。
  • package-lock.json や yarn.lock でバージョンを固定する。
  • 共通ライブラリはファイルシステム経由で共有せず、 internal な npm モジュールを使って内部配布する
  • サービスアカウントは json ファイルを使い、複数アプリケーション(複数 admin.app.App オブジェクト)をきちんと使い分ける
  • 全デプロイをほぼ禁止にする

といった運用でいけるはずですが、 Cloud Functions と JavaScript の持つ本来の手軽さから離れてしまうような…。

ここまでのまとめ

  • Cloud Functions が Admin SDK と関係なく持つ唯一の能力はプロジェクトのリソースへのトリガーである。しかし現状、トリガーできる対象リソースは、その Cloud Functions が所属する GCP/Firebase プロジェクトのものに限られる(今初めて言った)。
  • Cloud Functions はその内部のモジュール(function)ごとの独立性を保つのがなかなか難しい。

したがって、 Cloud Funcions ではエンドユーザー向けの機能を、トリガー機能に依存しないその他の要件は Admin SDK を利用して Cloud Functions の外にサーバサイドを作って運用したりすると気が楽な場面もあると思います。

ここまでがきっかけで、前置きです。


ここからが実際やったこととノウハウです。

Admin SDK で Admin になる方法

サービスアカウントファイル

最初に作ってあとはあんまり触ることのないなサービスアカウントですが、 GCP コンソール側でサービスアカウントを作れば「読み込みのみ」のサービスアカウントを作れます。

RoleViewer としているところがそうです。

Google Application Default Credentials(ADC)

Google Application Default Credentials  |  Google Identity Platform  |  Google Developers

自分はすごい最近まで知らなかったんですが実は json ファイルを食わせる以外にもっと楽な方法があって、 Cloud Functions ではいつもサービスアカウントファイルを require して渡しているところを、

admin.initializeApp({
    credential: admin.credential.applicationDefault(),
    databaseURL: "https://oogattatest.firebaseio.com/",
});

と書くだけで、 SDK が実行プロジェクトから適切なサービスアカウントを選んで勝手に認証・承認を取ってくれます。

これを使うと

  • リポジトリに秘密のファイルを持たなくてよくなる
  • 1つのソースコードリポジトリを開発環境用 Firebase プロジェクトとリリース環境用の両方で使う際に、サービスアカウントファイルを2ファイル持っておいたり、それを環境変数を読み取って切り替えたりしなくてよくなる

ます。いろいろ始める春に知りたかった。

使えるところ

GCP の機能なので限られます。

  • Compute Engine
  • Kubernetes Engine
  • App Engine
  • Cloud Functions

サービスアカウントの選ばれ方

詳しくはドキュメントを見ていただくとして。 Cloud Functions では、 function の新規デプロイ時に一律で project-id@serviceaccount.google.com というサービスアカウントが割り当てられ、これが使われます。変更はできないようです。

GCP の IAM の画面で確認できますが、このサービスアカウントはプロジェクトに対する Editor ロールを持っていて、リソースへの読み込み・書き込みができます(逆に、新規 App Engine をデプロイしたりという構成変更みたいなことはできません)。

実は、 Firebase コンソールのサービスアカウント画面から生成・ json 発行できるサービスアカウントも(上の画像でちらっと見えていた firebase-adminsdk というものです)プロジェクト全体への Editor ロールです。

要するに、事実上単一サービスアカウントで運用することが多い Cloud Functions では

admin.credential.cert(require("./release/service-account.json"))

とかなんとかって書かずに

admin.credential.applicationDefault()

と書けば、機能の開発が完了してそのままリリース版プロジェクトにデプロイしても、そちらではリリース版 GCP/Firebase プロジェクトのサービスアカウントが自動的に選択されます。コード側で切り替える必要がありません。

勤務先のプロジェクトも書き換えてやろうと思って忘れてたので、今週、自分もやります。

ちなみに他のサービスでは

  • App Engine Flexible では App Engine default service account というアカウントと、なぜか Service Account の一覧に表示されない service-000000000000@gae-api-prod.google.com.iam.gserviceaccount.com みたいの両方で動く(これもサービスごとに別のものを選ぶとかはできなそう)
  • Computed Engine ならインスタンスの作成時にサービスアカウントを指定できる!( Computed Engine はマジもんのサーバなので今回は触れません)

コードサンプル

そんなこんなで、 Admin SDK をちょっと工夫して使ったコード例です。

複数のプロジェクトを扱う

Cloud Functions でなんとかやる。を考えたときに結構あるなと思うのが、複数 Firebase プロジェクトを持って、エンドユーザーに応答しているプロジェクトを中心としてデータを散らしながら運用する方法です。

Cloud Functions のためだけに複数プロジェクトを持つのは複雑ですが、ともかく簡単にできるので悪くなさそうです。

const functions = require('firebase-functions');
const admin = require('firebase-admin');

// 第2引数なしで initializeApp を呼び出すとデフォルトアプリが初期化される
// デフォルトアプリは各種 API から暗黙に利用される
const mainApp = admin.initializeApp({
  credential: admin.credential.applicationDefault(),
  databaseURL: "https://oogattatestmainapp.firebaseio.com/",
});

// 第2引数つきで initializeApp を呼び出すとデフォルトではないアプリが初期化される
const subApp = admin.initializeApp({
    credential: admin.credential.cert(require("./sub-app-service-account.json")),
    databaseURL: "https://oogattatestsubapp.firebaseio.com/",
  },
  "subApp"
);

// functions は暗黙にデフォルトアプリのみを取り扱う(今のところ、このふるまいは変更できない)
exports.hogeFunction = functions.database.ref('feed/{feedId}').onCreate(event => {
  // 管理アプリの方に必要な情報を流す
  subApp.database().ref('admin/feed').push({[`${event.params.feedId}`]: true});

  return true;
});

メインのアプリには前述した Application Default Credentials を使い、管理業務に使うプロジェクトにはサービスアカウントファイルを設定て admin.app.App のインスタンスを複数初期化して使います。

良い

  • Firebase のサーバ側を Clooud Functions に集約できる
  • 書くのはそんなに大変ではない

重い

  • トリガー(効率的でリアルタイムな更新の取得)は主プロジェクトのみ
  • サブシステムのサービスアカウントファイルの取り扱い
  • コードレビューしっかり

ローカルでスクリプトを実行する

Admin SDK を使ってスクリプトを書くのって、管理画面作り込む前にいろいろ試すフェーズで便利でいいですよね。ローカルなのでさすがに ADC は使えませんが( gcloud コマンドを工夫すればできるんだろうか…)、リードオンリーのサービスアカウントを作っておけば何か間違いがあっても書き込み出来ないので安心です。

実際に、 GCP コンソールで Viewer ロールのサービスアカウントを作って Realtime Database に書き込んでみましたが、無事弾かれました。

しかし Kotlin で書きたい! 例えばこんな感じ、ユーザー向けに稼働している Realtime Database に新しい投稿があったら Slack に投げるスクリプトです( import とか省略してます)。

main.kt
val serviceAccountPath = "src/main/resources/readonly-service-account.json"
val databaseUrl = "https://oogattatest.firebaseio.com/"
val webhookUrl = "https://hooks.slack.com/services/~~~~~~~~~"

var loop = true

fun main(args: Array<String>) {
    val serviceAccount = FileInputStream(serviceAccountPath)

    val options = FirebaseOptions.Builder()
        .setCredentials(GoogleCredentials.fromStream(serviceAccount))
        .setDatabaseUrl(databaseUrl)
        .build()

    FirebaseApp.initializeApp(options)

    run()

    while (loop) {
        loop = handleMenuInput()
    }
}

private fun run() {
    FirebaseDatabase.getInstance().reference.child("feed").addListenerForSingleValueEvent(object: ValueEventListener {
        override fun onCancelled(error: DatabaseError?) {}

        override fun onDataChange(snapshot: DataSnapshot?) {
            snapshot ?: return

            val count = snapshot.childrenCount.toInt()
            var addedCount = 0

            FirebaseDatabase.getInstance().reference.child("feed").addChildEventListener(object: ChildEventListener {
                override fun onCancelled(error: DatabaseError?) {}
                override fun onChildMoved(snapshot: DataSnapshot?, previousChildName: String?) {}
                override fun onChildChanged(snapshot: DataSnapshot?, previousChildName: String?) {}

                override fun onChildAdded(snapshot: DataSnapshot?, previousChildName: String?) {
                    snapshot ?: return

                    if (++addedCount <= count) { return }

                    val post = snapshot.getValue(Post::class.java)

                    val api = SlackApi(webhookUrl)
                    val message = SlackMessage("")
                    val attachment = SlackAttachment(post.text)

                    attachment.addFields(SlackField().apply {
                        isShorten = false
                        setTitle("text")
                        setValue(post.text)
                    })
                    attachment.addFields(SlackField().apply {
                        isShorten = true
                        setTitle("userId")
                        setValue(post.userId)
                    })

                    message.addAttachments(attachment)

                    api.call(message)
                }

                override fun onChildRemoved(snapshot: DataSnapshot?) {}
            })
        }
    })
}

private fun handleMenuInput(): Boolean {
    return when (readLine()) {
        "x" -> false
        else -> true
    }
}

class Post(var text: String = "", var userId: String = "")

この記事と仕事の両方で使いたくてさっき書いたすごく雑なものですが、案外ちゃんと動きます。

良い

  • 主システムとは完全に切り離されていて読み込みのみの権限で動かしたりできるので、雑に書いて実行しても安心
  • クライアント SDK を使うのと違って、運営さんたちがアプリを触る目的で Authentication 領域を汚さない

重い

  • Realtime Database の接続数を1つ消費してしまう
  • サービスアカウントファイルの取り扱い

Webサーバで実行する

リソースの常時監視ではなく、管理ユーザーのリクエストに応えて処理を行うシステムです。

せっかくなので JetBrains 製の Kotlin Web Framework 、 Ktor でやってみます。

ユーザIDを受け取って、ユーザ情報(ここでは名前だけですが)を返してみます。

App.kt
fun Application.main() {
    val readOnlyServiceAccount = FileInputStream("src/main/blog/readonly-service-account.json")
    val options = FirebaseOptions.Builder()
        .setCredentials(GoogleCredentials.fromStream(readOnlyServiceAccount))
        .setDatabaseUrl("https://oogattatest.firebaseio.com/")
        .build()

    FirebaseApp.initializeApp(options)

    install(DefaultHeaders)
    install(CallLogging)
    install(Routing) {
        get("/user/{userId}") {
            val userId = call.parameters["userId"] ?: call.respondText("aho")

            if (userId as? String != null) {
                val name = call.getName(userId).await()
                call.respondText(name, ContentType.Text.Html)
            }
        }
    }
}

private fun ApplicationCall.getName(userId: String) = Single.create<String> {
    FirebaseDatabase.getInstance().reference.child("users").addListenerForSingleValueEvent(object : ValueEventListener {
        override fun onCancelled(error: DatabaseError?) {
            it.onError(Error("onCancelled"))
        }

        override fun onDataChange(dataSnapshot: DataSnapshot?) {
            if (dataSnapshot == null) {
                it.onError(Error("dataSnapshot is null"))
                return
            }

            val user = dataSnapshot.child(userId).getValue(User::class.java)

            if (user == null) {
                it.onError(Error("user is null"))
                return
            }

            it.onSuccess(user.name)
        }
    })
}

class User(var name: String = "")

さらに

build.gradle
dependencies {
    // ...

    compile "io.ktor:ktor-server-core:$ktor_version"
    compile "io.ktor:ktor-server-jetty:$ktor_version"
    compile "io.ktor:ktor-html-builder:$ktor_version"

    compile "org.jetbrains.kotlinx:kotlinx-coroutines-rx2:0.20"
    compile "io.reactivex.rxjava2:rxjava:2.1.7"
    compile 'com.google.firebase:firebase-admin:5.5.0'
}

kotlin {
    experimental {
        coroutines "enable"
    }
}

こんな感じに org.jetbrains.kotlinx:kotlinx-coroutines-rx2 と RxJava2 を使えば、上のコードのように RxJava2 の Observable たちが Kotlin の coroutine のネイティブオブジェクトのように振る舞います。 RxJava2 の Single を受けて、それを await して非同期処理を解決しているところなんか最高です。 JS の async/await と景色が一緒になります。

Ktor も Ktor でコントローラメソッドの中に非同期処理が入って当然という作りになっていてとても楽でした。

良い

  • トリガーがいらないなら Admin SDK で十分 Firebase 用のアプリケーションサーバを立てられる
  • 特に Java/Kotlin なら、クライアントアプリケーションと同じライブラリで書ける
  • App Engine に上げれば請求は Firebase と一緒
  • App Engine の cron を使えば定期実行するジョブも書ける

重い

  • が、実は今これが App Engine flexible にデプロイできない(Firebaseのinitializeが怪しい、できるようになったら書きます…)
  • Android と両方やってないとおいしさが少ないかも
  • やっぱりサービスアカウントファイルの取り扱い

というわけで、今後もどんどん Admin SDK を活用していきたいと思います。

Ktor & Firebase のサーバーアプリを App Engine flexible で動かせたらまた書きます。

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away