Next.jsで Flamelink JavaScript SDK を使ってFirestoreのデータを取得する方法
Next.jsでFirestoreのデータを取ってくるだけなら下記のQiitaの通りです。
今回はFlamelinkを使ったため、後々のことを考えるとSDKを使ったほうがいいかと思い、情報も少なくてかなり苦戦し消耗したのでドキュメントに残します。SDKのドキュメント
FlamelinkのSDKは2つありあます。
-
旧:Flamelink SDK
https://flamelink.github.io/flamelink/#/?id=flamelink-sdk -
新:Flamelink JavaScript SDK
https://flamelink.github.io/flamelink-js-sdk/#/migration-guide
前提知識
Flamelinkはまだα版らしく、エラーメッセージなども原因が特定しにくかったり、ドキュメントも初心者にやさしいとはいいにくいです(Next.jsがそもそもそういうもの?)。そのため、理想を言えば以下の基礎知識を身に着けた上で挑むのが良いと思いました。
- JavaScriptの基礎(Object/Array/JSONの違い、参照方法、変換方法)
- TypeScriptの基礎
- 補完やエラーがでるようにモデルの定義とか
- Arrayのジェネリクス用いたイニシャライズ方法とか
- async/awaitとPromiseとか
- JSXとReact.js(
{}
で値を渡すとか) - Next.jsのprops(公式チュートリアルだけでは不十分と感じた)
自分はSwiftやRailsを使ってたのですが、Next.jsは2021年のはじめ?にgetInitialProps
が非推奨となり、代わりにgetStaticProps
orgetServerSideProps
を使うようになったりと変化も激しい新しい技術なのだと感じました。
Next.jsとFirebaseの組み合わせ、またNext.jsでImageを使う方法などについては、だらさんの本やCatonoseさんのzennを先に見ておくと理解が早いかもしれません。
環境など
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を取得できたコードはこれ
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" });
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を取得しにくい
Flamelink JavaScript SDK のinstall
ここは公式ドキュメントにまかせます
Usage
画像の取得が不要な場合(no storage access)
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)。
本題のコードは下記です。
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"
で判別するのが推奨になったとのことです。
データ取得の基本
Flamelinkでget()して取得できるのはObject(連想配列)である。一般のFireStoreのdb.collection('hoge').get()
で返ってくるのはsnapshotでありsnapshot.docs.map(doc => { const data = doc.data() })
のように加工できるのだが、このObjectの変換ですごい苦戦した(JavaScriptの基礎をすっ飛ばしてるツケがきた)。
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()
の引数populate
をtrue
か['image']
のようにしてやると、その参照先のデータも取ってこれると色んなところで書かれていますが(populateはデータなどを入力するという意味もあるそうです)、Next.jsのSSRの仕組みのせいか、非常にエラーが起きやすいです。
まずはnext.config.jsにドメイン追加
Next.jsは外部から画像を読み込むときに、そのドメインを許可するためにnext.config.jsファイルで明示的に指定しないと行けない。このときの画像最適化方法の設定などもここでやるらしい(詳しくは冒頭のCatonoseさんのZennを参照)。
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 (これは上手くいかない)
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に取得フィールドを細かく指定(これが上手くいく)
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()してみた例
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ですが同じようなデータ加工をされています。
Discussion