🔥

Next.jsで Flamelink JavaScript SDK を使ってFirestoreのデータを取得する方法

2021/05/21に公開

Next.jsでFirestoreのデータを取ってくるだけなら下記のQiitaの通りです。
https://qiita.com/centerfield77/items/49b029d4d1618dfeedb6
今回はFlamelinkを使ったため、後々のことを考えるとSDKを使ったほうがいいかと思い、情報も少なくてかなり苦戦し消耗したのでドキュメントに残します。

SDKのドキュメント

FlamelinkのSDKは2つありあます。

前提知識

Flamelinkはまだα版らしく、エラーメッセージなども原因が特定しにくかったり、ドキュメントも初心者にやさしいとはいいにくいです(Next.jsがそもそもそういうもの?)。そのため、理想を言えば以下の基礎知識を身に着けた上で挑むのが良いと思いました。

  • JavaScriptの基礎(Object/Array/JSONの違い、参照方法、変換方法)
  • TypeScriptの基礎
    • 補完やエラーがでるようにモデルの定義とか
    • Arrayのジェネリクス用いたイニシャライズ方法とか
    • async/awaitとPromiseとか
  • JSXとReact.js({}で値を渡すとか)
  • Next.jsのprops(公式チュートリアルだけでは不十分と感じた)

自分はSwiftやRailsを使ってたのですが、Next.jsは2021年のはじめ?にgetInitialPropsが非推奨となり、代わりにgetStaticPropsorgetServerSidePropsを使うようになったりと変化も激しい新しい技術なのだと感じました。

Next.jsとFirebaseの組み合わせ、またNext.jsでImageを使う方法などについては、だらさんの本やCatonoseさんのzennを先に見ておくと理解が早いかもしれません。
https://zenn.dev/dala/books/nextjs-firebase-service
https://zenn.dev/catnose99/articles/883f7dbbe21632a5254e

環境など

Schema

products
└name // string
└images // [DocumentReferrence] // 公式はimageと単数形にしているが後にmap()などするので複数形がわかりやすいと思う

package.json

// pakege.json
{
  "name": "learn-starter",
  "version": "0.1.0",
  "private": true,
  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start"
  },
  "dependencies": {
    "dotenv": "^9.0.2",
    "firebase": "^8.6.1",
    "firebase-admin": "^9.8.0",
    "flamelink": "^1.0.0-alpha.34",
    "next": "^10.0.0",
    "postcss-flexbugs-fixes": "^5.0.2",
    "postcss-preset-env": "^6.7.0",
    "react": "17.0.1",
    "react-dom": "17.0.1",
    "sass": "^1.32.13",
    "typescript": "^4.4.0-dev.20210523"
  },
  "devDependencies": {
    "@types/react": "^17.0.6",
    "autoprefixer": "^10.2.5",
    "postcss": "^8.2.15",
    "tailwindcss": "^2.1.2"
  }
}

最終的に上手く行ったコード

productsのnameとimagesを取得できたコードはこれ

lib/db.tsx
import flamelink from "flamelink/app";
import "flamelink/cf/content";
import "flamelink/cf/storage";


  let firebaseApp;

  if (typeof window === "undefined") {
    console.log('run on server-side.')
    const admin = require("firebase-admin");
    if (!admin.apps.length) {
      const serviceAccount = require("../serviceAccountKey.json");
      firebaseApp = admin.initializeApp({
        credential: admin.credential.cert(serviceAccount),
        databaseURL: process.env.FIREBASE_DATABASE_URL,
        storageBucket: process.env.FIREBASE_STORAGE_BUCKET,
      });
    } else {
      firebaseApp = admin.app();
    }
  } else {
    console.log('run on client-side')
    const firebase = require("firebase/app");
    require("firebase/firestore");
    require("firebase/storage");
    if (!firebase.apps.length) {
       firebaseApp = firebase.initializeApp({
        apiKey: process.env.FIREBASE_API_KEY,
        authDomain: process.env.FIREBASE_AUTH_DOMAIN,
        databaseURL: process.env.FIREBASE_DATABASE_URL,
        projectId: process.env.FIREBASE_PROJECT_ID,
        storageBucket: process.env.FIREBASE_STORAGE_BUCKET,
        messagingSenderId: process.env.FIREBASE_MESSAGING_SENDER_ID,
      });
    } else {
      firebaseApp = firebase.app();
    }
  }

  export const app = flamelink({ firebaseApp, dbType: "cf" });
pages/products/list.tsx
import { app } from "../../lib/db";
import Link from "next/link";
import Image from 'next/image'

export default function Firebase({ products }) {
  return (
    <>
      <h1>製品一覧</h1>
      <ul>
        {products.map((product) => (
          <li key={product.name}>
            {product.name}
            {product.images.map(img => {
              return <Image
                key={img.id}
                src={img.url}
                height={144}
                width={144}
              />
             })}             
          </li>
          
        ))}
      </ul>
      <Link href={`/`}>
        <a>戻る</a>
      </Link>
    </>
  );
}

export async function getStaticProps() {

  const _products = await app.content
    .get({
      schemaKey: "products",
      fields: ["name", "images"],
      populate: [
        {
          field: "images",
          fields: ["id", "url"],
        },
      ],
    })
    
  const products = Object.values(_products)

  return {
    props: {
      products,
    },
  };
}

ポイント

  • TypeScriptは真っ先に導入しといた方が良い。でないと型補完が乏しくて死ぬ
  • SSR(Server Side Rendering)を使うNextやNuxtは Firebase Admin SDK じゃないと画像のダウンロードURLを取得できない
  • typeof window === "undefined"でサーバーサイドとクライアントサイドのどちらでコードが走っているのか出し分ける
  • フィールドにDocumentReferrenceを持つデータはpopulate: true, populate: ['images']などによってその参照先のデータまでまとめて取得できる
    • つまりapp.storage.getURL({fileId:})などは使わなくていい(get()1回で画像ダウンロードURLまで取得できる)
  • 参照先が画像の場合は(fl_filesコレクション配下にはないが)urlをSDKが勝手に取ってきてくれる
    • 逆に言えばFirestoreがflamelink独自のデータベース構造になっているのでSDK使わないと画像ダウンロードURLを取得しにくい

ここは公式ドキュメントにまかせます
https://flamelink.github.io/flamelink-js-sdk/#/?id=flamelink-javascript-sdk

Usage

画像の取得が不要な場合(no storage access)

lib/db.tsx
import firebase from 'firebase/app'
import 'firebase/storage'
import 'firebase/firestore'
import flamelink from 'flamelink/app'
import 'flamelink/content'
import 'flamelink/storage'

const firebaseConfig = {
  apiKey: process.env.FIREBASE_API_KEY,
  authDomain: process.env.FIREBASE_AUTH_DOMAIN,
  databaseURL: process.env.FIREBASE_DATABASE_URL,
  projectId: process.env.FIREBASE_PROJECT_ID,
  storageBucket: process.env.FIREBASE_STORAGE_BUCKET,
  messagingSenderId: process.env.FIREBASE_MESSAGING_SENDER_ID,
}

const firebaseApp = firebase.apps.length ? firebase.app() : firebase.initializeApp(firebaseConfig)

export const app = flamelink({
  firebaseApp,
  dbType: 'cf', // デフォルトがrtdbなので注意!
})

注意点はinitializeAppは1回しかやっちゃいけない(already exist と怒られます)ため、条件分岐しています。一応、firebaseAppの型はfirebase.app.Appらしいです。

またdbTypeがデフォルトでは Real Time Database (rtdb) の方になっているので Cloud Firestore を使う場合はcfに明示的に設定すること。でないとapp.content.get()の部分で「context.firebaseApp[serviceName] is not a function」とかって怒られます。

さらに別ファイルで呼び出すためにappの前にはexportを付けてます。

画像取得もする場合(storage access)

画像を取得する場合はSDKの裏側でstorageにアクセスしてるっぽく、Firebase admin SDK を使わなければならないようです(エラーが出て取得できない)。

NextやNuxtはSSRのためにサーバーサイドとクライアントサイドの両方で同じコードが走ったりするらしく、そのプロセスによって Firebase JavaScript SDK を使うか、Firebase Admin SDK を使うかを出し分けます。

Firebase Admin SDK にはサービスアカウントの秘密鍵(JSONファイル)が必要で、その準備方法や.gitignoreに追加する方法はこちらのQiitaに任せます(ただしこれはNuxt)。
https://qiita.com/yoh_zzzz/items/28683dc3fc38b0ace4ec#flamelinksdkを利用してnuxtで記事取得

本題のコードは下記です。

lib/db.tsx
import flamelink from "flamelink/app";
import "flamelink/cf/content";
import "flamelink/cf/storage";


  let firebaseApp;

  if (typeof window === "undefined") { // これでサーバーサイドで走るときだけfirebase-adminを使う
    console.log('run on server-side.')
    const admin = require("firebase-admin");
    if (!admin.apps.length) {
      const serviceAccount = require("../serviceAccountKey.json"); // ここはルートにサービスアカウントの秘密鍵を配置しているのでこのPath
      firebaseApp = admin.initializeApp({
        credential: admin.credential.cert(serviceAccount),
        databaseURL: process.env.FIREBASE_DATABASE_URL,
        storageBucket: process.env.FIREBASE_STORAGE_BUCKET,
      });
    } else {
      firebaseApp = admin.app();
    }
  } else {
    console.log('run on client-side')
    const firebase = require("firebase/app");
    require("firebase/firestore");
    require("firebase/storage");
    if (!firebase.apps.length) {
       firebaseApp = firebase.initializeApp({
        apiKey: process.env.FIREBASE_API_KEY,
        authDomain: process.env.FIREBASE_AUTH_DOMAIN,
        databaseURL: process.env.FIREBASE_DATABASE_URL,
        projectId: process.env.FIREBASE_PROJECT_ID,
        storageBucket: process.env.FIREBASE_STORAGE_BUCKET,
        messagingSenderId: process.env.FIREBASE_MESSAGING_SENDER_ID,
      });
    } else {
      firebaseApp = firebase.app();
    }
  }

  export const app = flamelink({ firebaseApp, dbType: "cf" }); // 旧SDKではここがapp.flamelink = になっててややこしい

くれぐれもserviceAccountKey.jsonをGitコミットに含めたり、安易に公開しないように気をつけてください。

サーバーサイドとクライアントサイドの判別は、従来は process.browserで判定していまいたが、これは非推奨になって現在はtypeof window === "undefined"で判別するのが推奨になったとのことです。
https://stackoverflow.com/questions/49411796/how-do-i-detect-whether-i-am-on-server-on-client-in-next-js

データ取得の基本

Flamelinkでget()して取得できるのはObject(連想配列)である。一般のFireStoreのdb.collection('hoge').get()で返ってくるのはsnapshotでありsnapshot.docs.map(doc => { const data = doc.data() })のように加工できるのだが、このObjectの変換ですごい苦戦した(JavaScriptの基礎をすっ飛ばしてるツケがきた)。

pages/products/list.tsx
import { app } from '../lib/db';
import Link from 'next/link';

export default function Firebase({ products }) {
  return (
    <>
      <h1>製品一覧</h1>
      <ul>
        {products.map((product) => (
          <li key={product.name}>{product.name}</li>
        ))}
      </ul>
      <Link href={`/`}>
        <a>戻る</a>
      </Link>
    </>
  );
}

export async function getStaticProps() {
  const _products = await app.content.get({ // この_productsはObjectです
    schemaKey: 'products',
    fields: ['name']
  })
  console.log(_products)

  const products = Object.values(_products)
  console.log(products);

  return {
    props: {
      products // ここは上で使う変数名と一致してないといけない(productsArrayとか返しちゃダメ)
    },
  };
}

console.log の出力結果

Object=連想配列=Swiftで言う辞書であり、keyとvalueを持つ?

// 加工前(ValueにOjbectを持つObject?)
{ aaa: { name: 'Anker PowerCore Ⅲ 19200 60W' } }  // aaaはドキュメントID

// 加工後(Objectの配列?)
[ { name: 'Anker PowerCore Ⅲ 19200 60W' } ]

加工しないままgetStaticPropsの返り値としてぶっこむと「products.map is not a function」のように怒られます。理由はmap()はArrayにしか使えずObjectには使えないためです。

ただしpropsとして渡すのはObjectのままでも渡せてしまうようですし、実際に{product.name}のように使うときのこのproductはObjectです(だからドットによる値参照が使える?)。

ここではドキュメントIDが不要だったのでObject.value()を使いましたが、

Object.keys(_products).map(([k, v]) => {
    console.log(_products[k])
    console.log(v)
    return v
}

Object.entries(_products)

のような操作を行っている例もありました。ただentries()はkeyとvalueをどっちもArrayの1要素としてゴチャ混ぜにしちゃう感じだったので使わないかも。

画像ダウンロードURLの取得方法

ブログなどに画像のフィールドを追加するのは当然すぎるユースケースと思うのですが、この画像のダウンロードURL取得がNext.jsの場合は特に上手くいかず難解でした。

get()の引数populatetrue['image']のようにしてやると、その参照先のデータも取ってこれると色んなところで書かれていますが(populateはデータなどを入力するという意味もあるそうです)、Next.jsのSSRの仕組みのせいか、非常にエラーが起きやすいです。

まずはnext.config.jsにドメイン追加

Next.jsは外部から画像を読み込むときに、そのドメインを許可するためにnext.config.jsファイルで明示的に指定しないと行けない。このときの画像最適化方法の設定などもここでやるらしい(詳しくは冒頭のCatonoseさんのZennを参照)。

next.config.js
module.exports = {
  images: {
    domains: ['storage.googleapis.com'] // これを追加しないといけない
  },
  webpack: config => {
    const env = Object.keys(process.env).reduce((acc, curr) => {
      acc[`process.env.${curr}`] = JSON.stringify(process.env[curr]);
      return acc;
    }, {});

    config.plugins.push(new webpack.DefinePlugin(env));

    return config;
  }
};

populate: true (これは上手くいかない)

pages/products/list.tsx
export async function getStaticProps() {
  const _products = await app.content
    .get({
      schemaKey: "products",
      populate: true,
    })    

  return {
    props: {
      products,
    },
  };
}

これだと以下のように_fl_meta_のフィールド値がシリアライズできませんとエラーがでる。

SerializableError: Error serializing .products[0].image[0].fl_meta.createdDate returned from getStaticProps in "/products/list".
Reason: object ("[object Object]") cannot be serialized as JSON. Please only return JSON serializable data types.

populateに取得フィールドを細かく指定(これが上手くいく)

pages/products/list.tsx
export async function getStaticProps() {
  const _products = await app.content
    .get({
      schemaKey: "products",
      fields: ["name", "images"],
      populate: [
        {
          field: "images",
          fields: ["id", "url"],
        },
      ],
    })
    
  const products = Object.values(_products)
  console.log(products[0].images) // ここはエディターがエラーを示すが一応動くしimagesの中に何が入っているかこうやって確かめるとわかりやすい

  return {
    props: {
      products,
    },
  };
}

productsのフィールドと、その子要素?のimagesのフィールドまで、必要なものだけを明示的に指定してやる必要がある。これをしないとすぐにエラーとなって動かない。

ポイントはFirestore側のimagesの参照先であるfl_filesコレクションの各ドキュメントにはurlというフィールドがないことです。

本来はDeveloperとしてここにダウンロードURLの絶対パスが格納されていることを期待しますが無くて、実際にFlamelink SDK経由でpopulateを使って取得したときに初めてurlが現れ、かつconsole.log()したときにimagesは[ [Object] ]のようにしか出力されないためurlが生成されてることに気づきにくいという罠‥。

しかもpopulateでurlを明示的に指定してやらないと(trueだけにしてると)Next.jsさんはエラーを吐くのでこんなに苦戦したわけです。

imageのObjectをconsole.log()してみた例

pages/products/list.tsx
export async function getStaticProps() {
  const products = [];
  const _products = await app.content
    .get({
      schemaKey: "products",
      fields: ["name", "images"],
      populate: [
        {
          field: "images",
	  // ここで fields: ["id", "url"] を明示的に指定してないとNext.jsではエラーになるけど
        },
      ],
    })
console.log(_products)
console.log(_products.xxx.images) // xxxはFirestoreの任意のドキュメントID

  return {
    props: {
      products,
    },
  };
}
// console.log(_products)
_products:  {
  xxx: { name: 'Anker PowerCore III 19200 60W', images: [ [Object] ] }
}

// console.log(_products.xxx.images) // xxxはFirestoreの任意のドキュメントID
 [
  {
    sizes: [ [Object], [Object], [Object], [Object], [Object], [Object] ],
    file: 'abc.jpg',
    _fl_meta_: {
      createdDate: [Timestamp],
      docId: 'yyy',
      createdBy: 'zzz'
    },
    folderId: DocumentReference {
      _firestore: [Firestore],
      _path: [ResourcePath],
      _converter: [Object]
    },
    type: 'images',
    contentType: 'image/jpeg',
    id: 'yyy',
    url: 'https://storage.googleapis.com/my-project-name.appspot.com/flamelink/media/abc.jpg?GoogleAccessId=.....'
  }
]

参考

Nuxt.jsですが同じようなデータ加工をされています。
https://qiita.com/yoh_zzzz/items/28683dc3fc38b0ace4ec

https://www.sitepoint.com/community/t/typeerror-map-is-not-a-function-react-js/344755

Discussion