‘JavaScript’ カテゴリーのアーカイブ

suganuma

Firebase Genkitを試してみた

Firebase Genkit は、本番環境に対応した AI 搭載アプリの構築、デプロイ、モニタリングに役立つオープンソース フレームワークです。

今回、以下を試してみます。

  • ブラウザからFirebase Functionを呼び出し実行
  • Firebase Local Emulator Suiteを使ってローカルで開発
  • Cloud Firestore vector storeを使ってRAG(Retrieval Augmented Generation)のインデックス作成と検索を行う
  • モデルにGoogle AIのGemini ProとOpenAIのGPT-4oを使う

セットアップ

ここを参考にしています。
Node.jsのバージョン20以降が必要です。
Google AIのGeminiを使うため、Generative Language APIを有効にし、APIキーを作成しておきます。

空のディレクトリを作成し、そこで以下のコマンドを実行し、いくつかの質問に答えるとFirebaseプロジェクトが作成されます。

firebase init genkit
質問の中でサンプルフローを生成するか聞かれますので、「Y」を選択します。

Gemini APIキーを環境変数にセットします。

export GOOGLE_GENAI_API_KEY=*********
functionsディレクトリでnpm run buildでビルドした後、以下を実行しエミュレータを起動します。

GENKIT_ENV=dev firebase emulators:start --inspect-functions
さらに以下を実行しGenkit Dev UIを起動します。

genkit start --attach http://localhost:3100 --port 4001
ブラウザでhttp://localhost:4001/ にアクセスすると、サンプルフローのmenuSuggestionFlowがあります。試しに、Auth JSONに{"uid": 0,"email_verified": true}を入力し(サンプルコードでメール確認済ユーザのみとなっている)、input JSONに「牛丼」と入れてみました。
結果、以下のような 汁だく特盛牛丼を提案されました。

**"Gyu-don Grande"**

A super-sized portion of traditional gyudon, featuring:

* Double the amount of tender and juicy beef
* Extra fluffy rice with a hint of soy sauce and mirin glaze
* Ample servings of savory onions
* Topped with a generous drizzle of umami-rich sauce
* Served in a large bowl taller than a skyscraper

Pairs well with:

* Miso soup
* Cold soba noodles
* Pickled side dishes
インデックス作成Cloud Firestore vector storeを使ったインデックス作成します。
ここを参考にしています。

エミュレータでFirestoreを使うので、firebase init emulatorsを実行し、Firestore Emulatorを使えるように設定します。ついでに後で認証とホスティングも使うのでAuthentication emulatorとHosting Emulatorも使えるように設定しておきます。
あと、firebase init hostingでホスティング用の設定ファイルを作成しておきます。

以下の内容のファイルをfunctions/src/populate_collection.tsに作成します。
処理内容は以下です。

  • テキストファイルfunctions/company.txtを読んで、改行2つを区切りとしてチャンクに分ける

import { configureGenkit } from "@genkit-ai/core";
import { embed } from "@genkit-ai/ai/embedder";
import { defineFlow, run } from "@genkit-ai/flow";
import { textEmbeddingGecko001, googleAI } from "@genkit-ai/googleai";

import { FieldValue, getFirestore } from "firebase-admin/firestore";

import { chunk } from "llm-chunk";
import * as z from "zod";

import { readFile } from "fs/promises";
import path from "path";

const indexConfig = {
collection: "company",
contentField: "text",
vectorField: "embedding",
embedder: textEmbeddingGecko001,
};

configureGenkit({
plugins: [googleAI()],
enableTracingAndMetrics: false,
});

const firestore = getFirestore();

// Firebase functionにしないのでonFlowではなくdefineFlow関数を使う
export const indexFlow = defineFlow(
{
name: "indexFlow",
inputSchema: z.void(),
outputSchema: z.void(),
},
async () => {
const filePath = path.resolve('./company.txt');

const txt = await run("extract-text", () =>
extractText(filePath)
);

// 改行2つを区切りとしてチャンク作成
const chunks = await run("chunk-it", async () => chunk(txt, {delimiters: '\n\n' }));

// Add chunks to the index.
await run("index-chunks", async () => indexToFirestore(chunks));
}
);

async function indexToFirestore(data: string[]) {
for (const text of data) {
const embedding = await embed({
embedder: indexConfig.embedder,
content: text,
});
await firestore.collection(indexConfig.collection).add({
[indexConfig.vectorField]: FieldValue.vector(embedding),
[indexConfig.contentField]: text,
});
}
}

async function extractText (filePath: string) {
const f = path.resolve(filePath);
return await readFile(f, 'utf-8');
}
上記を実行するため、functions/src/index.tsに以下を追加します。

export { indexFlow } from './populate_collection';
結果、Genkit Dev UIのフローに「indexFlow」が追加されました。
input JSONに何も入力せず、「RUN」ボタンを押します。
成功すると、Firestoreにcompanyコレクションが作成されます。
エミュレータで確認してみると、embeddingとtextフィールドを持つドキュメントが複数あり、embedding.valueにはベクター情報があります。

RAGのプロンプト生成

functions/src/index.tsを以下の内容に置き換えます。

import {retrieve} from "@genkit-ai/ai";
import {configureGenkit} from "@genkit-ai/core";
import {firebaseAuth} from "@genkit-ai/firebase/auth";
import {onFlow} from "@genkit-ai/firebase/functions";
import {geminiPro, textEmbeddingGecko001} from "@genkit-ai/googleai";
import * as z from "zod";
import {defineFirestoreRetriever, firebase} from "@genkit-ai/firebase";
import {googleAI} from "@genkit-ai/googleai";
import { getFirestore } from "firebase-admin/firestore";
import { defineDotprompt } from '@genkit-ai/dotprompt';
import { initializeApp } from "firebase-admin/app";
import { dotprompt } from '@genkit-ai/dotprompt';

initializeApp()

configureGenkit({
plugins: [
dotprompt(),
firebase(),
googleAI(),
],
flowStateStore: 'firebase',
logLevel: 'debug',
traceStore: 'firebase',
enableTracingAndMetrics: true,
});

const firestore = getFirestore();

const retrieverRef = defineFirestoreRetriever({
name: "companyRetriever",
firestore,
collection: "company",
contentField: "text",
vectorField: "embedding",
embedder: textEmbeddingGecko001,
distanceMeasure: "COSINE", // "EUCLIDEAN", "DOT_PRODUCT", or "COSINE" (default)
});


const CompanyQuestionInputSchema = z.object({
data: z.array(z.string()),
question: z.string(),
});

const companyPrompt = defineDotprompt(
{
name: 'companyPrompt',
model: geminiPro,
input: { schema: CompanyQuestionInputSchema },
output: { format: 'text' },
config: { temperature: 0.3 },
},
`
あなたは有限会社ランカードコムの広報担当者です。
以下は有限会社ランカードコムの会社概要です。
{{#each data~}}
- {{this}}
{{~/each}}

会社概要を元に以下のカスタマーの質問に回答してください:
{{question}}?
`
);

export const companyFlow = onFlow(
{
name: "companyFlow",
inputSchema: z.string(),
outputSchema: z.string(),
authPolicy: firebaseAuth((user) => {
if (!user.email_verified) {
throw new Error("Verified email required to run flow");
}
}),
},
async (question) => {
const docs = await retrieve({
retriever: retrieverRef,
query: question,
options: {limit: 5},
});
const llmResponse = await companyPrompt.generate({
input: {
data: docs.map(doc => doc.content[0].text || ''),
question,
},
});

return llmResponse.text();
}
);

export { indexFlow } from './populate_collection';
Genkit Dev UIのフローcompanyFlowでAuth JSONに{"uid": 0,"email_verified": true}を入力し、適当な質問をinput JSONに入力し実行すると、正しく回答が返ってきました。

モデルをOpenAIのGPT-4oに変更

genkitx-openaiを使います。

cd functions
npm i genkitx-openai
- import {geminiPro, textEmbeddingGecko001} from "@genkit-ai/googleai";
- import {googleAI} from "@genkit-ai/googleai";
+ import { gpt4o, openAI, textEmbedding3Small } from "genkitx-openai";
googleAI
をopenAIに、geminiProをgpt4o、textEmbeddingGecko001をtextEmbedding3Smallに置換します。

OpenAIの環境変数をセットし、エミュレータを起動します。

export OPENAI_API_KEY=*************
GENKIT_ENV=dev firebase emulators:start --inspect-functions
indexFlowでインデックス作成し、companyFlowで正しく回答されるか確認します。

ブラウザからFirebase Functionを呼び出し実行

public/index.htmlを以下の内容に書き換えます。
ここの4.Replace public/index.html with the following:を、エミュレータで動作するように修正しました。

<!doctype html>
<html>
<head>
<title>Genkit demo</title>
</head>
<body>
<div id="signin" hidden>
<button id="signinBtn">Sign in with Google</button>
</div>
<div id="callGenkit" hidden>
<div>有限会社ランカードコムについてお聞きください。</div>
<textarea id="question" rows="5" cols="33"></textarea>
<div>
<button id="submit">送信</button>
</div>
<p id="answer"></p>
</div>
<script type="module">
import { initializeApp } from 'https://www.gstatic.com/firebasejs/10.10.0/firebase-app.js';
import {
getAuth,
connectAuthEmulator,
onAuthStateChanged,
GoogleAuthProvider,
signInWithPopup,
} from 'https://www.gstatic.com/firebasejs/10.10.0/firebase-auth.js';
import {
getFunctions,
httpsCallable,
connectFunctionsEmulator
} from 'https://www.gstatic.com/firebasejs/10.10.0/firebase-functions.js';
import {
getFirestore,
connectFirestoreEmulator
} from 'https://www.gstatic.com/firebasejs/10.10.0/firebase-firestore.js';
const firebaseConfig = await fetch('/__/firebase/init.json');
const firebaseApp = initializeApp(await firebaseConfig.json());

if (location.hostname === 'localhost') {
const auth = getAuth(firebaseApp)
const firestore = getFirestore(firebaseApp)
const functions = getFunctions(firebaseApp)

connectFirestoreEmulator(firestore, location.hostname, 8080, { ssl: false })
connectFunctionsEmulator(functions, location.hostname, 5001)
connectAuthEmulator(auth, `http://${location.hostname}:9099`)
}



async function generateAnswer() {
const companyFlow = httpsCallable(
getFunctions(),
'companyFlow'
);
const question = document.querySelector('#question').value;
const response = await companyFlow(question);
document.querySelector('#answer').innerText = response.data;
}

function signIn() {
signInWithPopup(getAuth(), new GoogleAuthProvider());
}

document
.querySelector('#signinBtn')
.addEventListener('click', signIn);
document
.querySelector('#submit')
.addEventListener('click', generateAnswer);

const signinEl = document.querySelector('#signin');
const genkitEl = document.querySelector('#callGenkit');

onAuthStateChanged(getAuth(), (user) => {
if (!user) {
signinEl.hidden = false;
genkitEl.hidden = true;
} else {
signinEl.hidden = true;
genkitEl.hidden = false;
}
});
</script>
</body>
</html>
http://localhost:5002/にアクセスし、「Sign in with Google」ボタンを押すとエミュレータのAuthenticationにアカウントを作成できます。テキストエリアに質問入力し送信し、回答が返ってくれば成功です。

回答例

課題

ストリームで回答出力

Genkit側ではgenerateStreamでストリーム出力できますが、onFlow(https.onCallのプロトコルに準拠)を使って、ブラウザにどうやって逐次データを返せばいいのか、分かりませんでした。Firestoreにチャンクデータを逐次保存すれば、onSnapshotでストリーミングでデータを返すことはできますが、課金および速度的にいい方法とは思えません。
Firebase Genkitはまだベータ版なので、今後Firebase functionsでストリーミングで返せるようになることを期待しています。
2024/08/01追記
onFlowを使わず、onRequestを使えば、ストリームで回答出力できました。onRequestはHTTPプロトコルなので、サーバー送信イベント (Server-Sent Events、SSE) を使うことができ、これを使いました。ただ作成したdefineFlowをonRequestに書き直すのは面倒です。
onFlowのような感じでonRequestのプロトコルを持つフローを作成するGenkitのプラグインがあれば良いのですが。

murave

Framework7 Vue を調査する

以前の記事『Framework7のrouter.backで戻っても最新データに更新したい』でいろいろと調べてたときの手法をまとめてみるなど。

情報収集からデバッグ手法まで雑多なので曖昧な「調査する」というタイトルにしてみました。

そうそう、せっかくなので本家(https://framework7.io/)のスポンサーのところをキャプチャして貼っときますね。弊社が載ってます。

この記事にもスポンサーが更新されてない日本語サイト(https://framework7.jp/)しかでてこなかったりしますし、少しは主張を。

Framework7 Vue の事情

Framework7は決して薄くないフレームワークなのでFramework7側を調べたほうが良いことが多いと思います。もちろんVueの知識も必要になりますが(ちなみに今回はVueといったらVue3を指していると思ってください)

Framework7側の事情でVueでの一般的な書式と少し違う書き方を求められる事などもありますので注意しましょう。

なにはともあれ公式ドキュメントを読もう

Framework7もVueも日本語化された公式ドキュメントが充実しているのでまずはこれらをみましょう。必要情報を探すのもそうですが定期的に繰り返し全体を見返す位がいいと思います。よく「以前探してて見つからなかったヤツだ!出来たんかい!」とかなります。

Framework7の日本語化された公式ドキュメントは 日本語サイトのトップ(https://framework7.jp/)からたどることができます。

Framework7 Vue (https://framework7.jp/vue/)

前述の「Framework7側の事情でちょっと変わった書き方を求められる」などの情報は Framework7 Vue のドキュメントに情報があります。逆に「Framework7では簡単にできてたのにVueと組み合わせた場合にどうやったらいいかわからん!」といった場合もここのどこかに情報が埋まっていることが多いです。例えば

「あれ? v-model じゃだめなん?」

入力/フォーム要素 Vueコンポーネント(https://framework7.jp/vue/inputs.html)に以下の記述があります。気づかないとハマります。

f7-list-inputとf7-inputのVueコンポーネントは、valueのpropにv-modelをサポートしています。

<template>
<p>Name is {{ name }}</p>
<p>Email is {{ email }}</p>
...
<f7-list-input
label="Name"
type="text"
placeholder="Your name"
clear-button
v-model:value="name"
/>

<f7-input
type="text"
placeholder="Your email"
clear-button
v-model:value="email"
/>
...
</template>
<script>
export default {
data() {
name: '',
email: '',
},
...
};
</script>
「Framework7のrouter、簡単に取得できないん?」Vueコンポーネントの拡張機能(https://framework7.jp/vue/vue-component-extensions.html)に情報がありました。

ルーターのインスタンスと現在のルートは、ルーターコンポーネントにpropsを介して渡されます。

<template>
...
</template>
<script>
export default {
// コンポーネントが受け取れるようにpropsを定義します。
props: {
f7route: Object,
f7router: Object,
},
...
mounted() {
console.log(this.f7route.url)
},
methods: {
navigate() {
this.f7router.navigate('/some-page/')
}
}
};
</script>
これ、気づくまでかなり困ってました。

Framework7 Core (https://framework7.jp/docs/)

Framework7自体のドキュメントです。Framework7 Vue のドキュメントだけでなくこちらについても読んだり情報を探したりすると良いです。以前の記事に書いたpage:reinit(pageReinit)イベントはこちらで見つけました。

まぁ、後で確認したらFramework7 Vueの方にも載ってたんですが(https://framework7.jp/vue/page.html)

どちらかというとpageのイベントということでなかなか気づかなかったんですよね。router調べてたので。

Vue.js devtools が便利すぎる

以前はVue3の場合beta版を使用する必要がありましたが、現在のバージョン(6.1.4)はVue3に対応しています。
ともかく便利なので使おう! というか、ないとやってられない。

私はChrome拡張のを使ってます。
https://chrome.google.com/webstore/detail/vuejs-devtools/nhdogjmejiglipccpnnnanhbledajbpd?hl=ja

前述のpropsで受け取ったf7routerを覗いて構造やヒストリーを確認したりもできます。最高!

ソースコードを読んだりもする

router.backとrouter.navigateの動作を調べていた際はソースコードを読んでrouter.backがrouter.navigateにわたすオプションと追加処理の組み合わせで実現されているのを確認して「ヒストリー直接弄れそうなのでどうにかなるな」と思ったりしました。

ちょっと試して「これ全部navigateで書き直すのと変わらんのでは」と思い直して別の手段がないか調査に戻りましたが。

なんだかんだで console.log

「本当にこの処理通ってる?」とか「処理の順番どうなってる?」とか、なんだかんだでconsole.logしちゃうのが手っ取り早いですね。

結局は?

いろいろと試した末に「こういうイベントがあるといいんだけどなぁ」と思いつつ公式ドキュメントを頭から読み直してたら使えそうなイベントが見つかったのでした。

そんで console.log で動きを確かめて「使える! ひゃっほー!」となりましたとさ。

murave

Framework7のrouter.backで戻っても最新データに更新したい

関係の技術記事書こうかと思ったんだけど、リタイアしたのでこれだけ書いとこうかと
— murave (@murave) March 30, 2022

昨日はリタイアしたけど、今日はがんばる。

日本語サイト(https://framework7.jp/)から引用すると

Framework7 – は、ネイティブなルック&フィールを持つモバイル、デスクトップ、またはウェブアプリケーションを開発するためのフリーでオープンソースのフレームワークです。また、必要に応じてアプリのプロトタイプをすぐに表示できる、不可欠なプロトタイピングツールでもあります。

といったものです。

弊社の場合、まずはプロトタイプ作成に使用され「Vue3対応してるしアプリ開発もそのままいこうぜ」といった流れだった気がします(確かそのタイミングでスポンサーになった)

使ってみた感じフルスタックなフレームワークという印象で、Vue.js、React、Svelteのコンポーネントもあってエライです。前述のとおり弊社では Framework7 Vue + Vue3 を採用しています。

さて、本題。Framework7は独自のrouterをもっています。今回は関係ないですが独自のstoreも持ってます。あふれるフルスタック感。

routerに話を戻すと、特定条件下ではヒストリーをもたせることができ、router.backでヒストリーにキャッシュされた前のページに戻るとことができます。便利な機能です。特にモック段階では。

モックを元に本番実装を進めていたある日、はたと困りました。

「router.backして戻った先の表示データを最新に更新したいけど、どうしよう」

もちろん普通に遷移(router.navigate)させてサーバーからデータを再取得させるのは可能です。しかしそれをやると戻ったつもりがヒストリーに積まれてしまい戻り先でさらに「戻る」(router.back)させたときに挙動が想定からはずれてしまいます。

『トップ』から『リスト画面』を開き『詳細画面』に遷移したのちに戻ったとすると

『トップ』 -navigate-> 『リスト画面』 -navigate-> 『詳細画面』 –back-> 『リスト画面』 -back-> 『トップ』

となってほしいところが

『トップ』 -navigate-> 『リスト画面』 -navigate-> 『詳細画面』 –navigate-> 『リスト画面』 -back-> 『詳細画面』

となってしまうわけです(変更したbackとnavigateに下線つけてます)

すべてnavigateにしてアプリ側でコントロールする、というのは避けたいところなのでいろいろと調査・検討しました。

ヒストリー弄ってみたりといろいろと試してたんですがありましたよ「これだ!」というイベントが。それはpage:reinit(pageReinit)。日本語サイトのページ(https://framework7.jp/docs/page.html)から引用します。

このイベントは、すでに初期化されたページに移動した場合に発生します。

前に挙げた遷移の例だと、『リスト画面』から『詳細画面』への遷移(navigate)する際にこのイベントでサーバーからのデータ取得を行うように仕込んでおけば『詳細画面』から戻る(back)した際にデータの再取得がおこなわれます。

router.onceで一度だけイベント処理を行うという設定が可能なので疑似コードは以下のようになります(Framework7 Vue + Vue3の場合)

const props = defineProps({
f7router: Object,
});
const { f7router } = props;

const 詳細画面に遷移 = () => {
f7router.once('pageReinit', async (pageData) => {
await データ取得();
});
f7router.navigate('/詳細画面/');
}
リスト画面でデータ取得のために指定されたパラメータは残っていますので利用者が意図した条件のまま最新データに更新されます。

murave

SPAでのキャッシュの闇

というか自分の失敗なんですけど。
今回の話は具体的にはNuxt2系でクライアント側でキャッシュしてみてのやらかしです。ひとことで言うと

「自分でちゃんと後始末しなきゃだめ」

仕組みはこうです。

  • Vuexにキャッシュ用のstateを用意。
  • キャッシュ用stateが空の場合はサーバーからデータを取得してセット。
  • キャッシュ用stateに値がセットされていればそれを返す。

(ほぼ)変更が無い値の場合、これで無駄なサーバーからのデータ取得をなくすことができます。
「翌日に反映されれば問題ない」位のものまでこの方式で扱っていました。

ある朝、トラブル発生の連絡がきました。前日に設定した値について反映されている端末とされていない端末があるとのこと。

こういう場合はだいたいキャッシュが原因です。

キャッシュってのはそもそもトラブルの元です。

でもパフォーマンスのためにはやっちゃうんだ。

お気づきですね。キャッシュの後始末をするのをすっかり忘れてました。毎日ブラウザが終了・起動されていると思ってはいけないのです。

業務で使用されているPCについては終業時にスリープさせて帰るということはあまりないので反映されていたのですが、タブレットの場合は基本的にブラウザ上がりっぱなしですので古いキャッシュがずっと残りっぱなしになっていたのです。

Webアプリの開発中は習慣で頻繁にブラウザをリロードするのでそれで消えるんですよね(言い訳)

んで後始末を忘れてるのに気づかずにリリースして事故ったり(闇)

以上です。

murave

Nuxt.js 開発サーバーでのSSEがランダムポートになって困ってた

説明しよう!

  • Nuxt.js で開発やってると開発サーバーを使うでしょ?
  • プログラム修正したら自動的にビルドしてくれて素敵!
  • そして画面を自動リロードしてくれて親切!

なんです。

ところで、Nuxt Screens Module(https://github.com/nuxt/screens)がローディング表示のためにSSE(Server-Sent Events)を使ってまして、最近、使用するポートがランダムになりました。

この変更は開発中のパフォーマンス問題への対策らしいです。良いことです。
しかし特定ポートをフォーワードして開発している人なんかは困ったことになります。
僕とか。

Nuxt.jsのdevでのsseがランダムなポート使うようになってるようなんだけど情報が見当たらない。ポートフォワードしてるので困る。— murave (@murave) June 19, 2020

とあるプロジェクトで

[Firebase] Cloud Functionsで消耗したくない人のために、開発環境のベストプラクティスをまとめていったらDockerに行き着いた話(随時更新)
https://qiita.com/pannpers/items/244a7e3c18d8c8422e4f

を参考にDockerベースで開発環境をつくってまして、必要なポートをフォワードしてゴニョゴニョやってるのでSSEのポートをフォワードしてないと

というふうにコンソールにエラーが出続けることになります。文字起こししときましょう。

GET http://localhost:45377/sse net::ERR_CONNECTION_REFUSED
このときのポートは45377ですが、ランダムに変わります(頻繁に、ではないですが)

つらい。

固定ならフォワーディングを増やせばいいだけなのですがランダムはつらい。
範囲指定でのフォワーディングも試しましたがリソース不足でコンテナが死んだりで断念。

本質的じゃないところでひっかかって半日を費やしたのちにメモを残して諦めることにしたのだった— murave (@murave) June 19, 2020

と敗北宣言したのですが、時間ができたので再調査したところ同じ目にあってる方(JasonCubicさん)を発見しました。

https://github.com/nuxt/nuxt.js/pull/7286#issuecomment-646853851

I am running nuxt dev in a docker container because I have a driver that needs some special setup. Using this different port spams cors errors into my console. How can I disable this?

ですよねー。そしてこの方、自己解決されたようです。

Update: If anyone else is trying to turn off the loading screen its in nuxt.config.js build.loadingScreen: false

なるほど。loadingScreen自体を無効化したのか。
自動リロードしてくれても変更が反映されないことがたまにあって手動リロード癖がついてるし、いい気がする(敗北感はあるけど)

nuxt.config.jsから抜粋

module.exports = {
// 略
build: {
// 略
loadingScreen: false
}
}
こうするとエラー表示はでなくなりました。そして画面の自動リロード自体は動きました。
本当にローディング表示のためだけだったのね。自動リロードにも影響すると思ってた。

ローディング表示、なくてもこまらないので無効化でOK。

murave

CORSに@nuxtjs/proxy

Nuxt.js でspaの開発やってたら普通は開発サーバーを使いますよね。
例えばyarnをパッケージマネージャーに採用している場合だと

$ yarn run dev
yarn run v1.21.1
$ nuxt

╭───────────────────────────────────────────────╮
│ │
│ Nuxt.js v2.12.2 │
│ Running in development mode (spa) │
│ │
│ Listening on: http://127.0.0.1:3000/ │
│ │
╰───────────────────────────────────────────────╯

ℹ Preparing project for development 13:54:32
ℹ Initial build may take a while 13:54:32
✔ Builder initialized 13:54:32
✔ Nuxt files generated 13:54:32

✔ Client
Compiled successfully in 25.76s

ℹ Waiting for file changes 13:55:00
ℹ Memory usage: 241 MB (RSS: 451 MB) 13:55:00
ℹ Listening on: http://127.0.0.1:3000/
といった感じ。通常だとポートは3000になります。

サーバー側APIは別のポートで提供することになるので別オリジンになり、そのままだと開発中にAPIアクセスができません。

セキュリティ上の理由から、ブラウザーは、スクリプトによって開始されるオリジン間 HTTP リクエストを制限しています。例えば、 XMLHttpRequestや Fetch API は同一オリジンポリシーsame-origin policyに従います。つまり、これらの API を使用するウェブアプリケーションは、そのアプリケーションが読み込まれたのと同じオリジンに対してのみリソースのリクエストを行うことができ、それ以外のオリジンの場合は正しい CORS ヘッダーを含んでいることが必要です。

「オリジン間リソース共有 (CORS)」(https://developer.mozilla.org/ja/docs/Web/HTTP/CORS)

からの引用です。

運用時には必要なくても開発時にはCORS対応が必要になる、ということが発生するわけです。
どうしたら?

「そうだね @nuxtjs/proxy だね」と公式さんは言いました(適当)

「クロスオリジンリソースを共有するには?」(https://ja.nuxtjs.org/faq/http-proxy/)

から導入や詳細については辿っていただくとして、自分の設定などをご紹介します。

前提条件として、cross-envで環境毎に設定を切り替えています。
この件についてはNuxt.js 悩み解決の小ネタ集をご参照ください。

nuxt.config.js から関係箇所抜粋

const environment = process.env.NODE_ENV || 'development'
const envSet = require(`./.env.${environment}.js`)

export default {
// (略)
axios: {
proxy: true
},
proxy: {
// 開発中はAPIサーバーとポートが異なるのでその対処のため(CORS対策)
'/api/': {
target: envSet.server.domain,
pathRewrite: { '^/api/': envSet.server.apiDir }
}
},
// (略)
開発環境用の設定ファイル .env.development.js

module.exports = {
server: {
domain: 'http://127.0.0.1',
apiDir: '/api/'
}
}
てな設定で開発時は http://127.0.0.1/api/ の下にAPIアクセスしCORSについてはよろしくやってくれます。

使用方法は /api/all というパスのAPIへのアクセスだとこんな感じ

const res = await this.$axios.get('/api/all')
おまけ
前の例ではややこしくなるのが嫌でpathRewriteで変に書き換えが起きないようにしていますが、APIが実は /api/rev2/ の下で提供されていた場合には設定ファイルを以下のよう修正すれば対応できます。

module.exports = {
server: {
domain: 'http://127.0.0.1',
apiDir: '/api/rev2/'
}
}
いい感じですね。

murave

Firebase Cloud Functions のNode.js 10更新、わたしの場合

2020年5月某日、

「Node.js 8のFunctionは来年3/15には止まるから10にしてね。あと、10は無料プランじゃ使えないからそこんとこヨロシク」(要約)

というメールがGoogleさんから来ました。

詳しくはその内容を整理してくださってるステキな記事をどうぞ。

【Firebase】Cloud Functionsが従量課金プランでしか使えなくなるらしい
(https://qiita.com/azukiazusa/items/edd6ca9cba2d48c4c3e2)

1年くらい前(2019年3月末)にリリースしたシステムが該当しており放置すると来年の3月には死。
あの時はまだNode.js 8すらベータ版だったやんか。1年でこんなことになるとは…


まずお客様へご説明し、更新作業のコストや時期について調整しなければなりません。

本番プロジェクトは元からBlaze(従量課金)プランですのでその点は問題ありません。
基盤更新への対応作業の発生は想定していましたが発生頻度が初期の想定を超えそうです。

更新コストについては、今回はサービス、以降は作業料をいただく方向でまとまりました。
実施時期については6月中に行うことに(COVID-19の影響で7月になるまで当該システム稼働なし確定のため)

ということでリリース完了して余裕ができたのでこれを書いてるってわけです。

更新作業どうだった?

Functions関係の更新は、最初に驚きのつまづきがありましたが、順調だったと言えます。

開発用プロジェクトをBlazeプランにする

開発用プロジェクトはSpark(無料)プランでしたので変更が必要でした。
今回のシステムの場合、開発プロジェクトでBlazeの無料枠を超えることはまず無いのでとくに気にせず変えちゃいます。

が、この変更を行う場所が(Google Cloud Platformのコンソールで)見つからなくて「あれ〜?」となりました。

FIrebaseコンソールの存在を忘れてた。変だと思った。— murave (@murave) June 11, 2020

最初のつまづきとはコレです。驚きましたね、Firebaseコンソールの存在を忘れていた自分に。

Google Cloud Platformのコンソールでもプロジェクトは見えてますしCloud FunctionsやFirestoreも見えるのですぐには気づかなかったのでした。専用のコンソールがあるFirebaseがイレギュラーですし〜

念の為書いておくと、Firebaseコンソールには以下のURLからアクセスできます。
https://console.firebase.google.com/

Node.js 10 を指定してデプロイする

Node.js 8の関数がデプロイされていると以下のような表示が出るようになってました。

Node.js 10への変更はFunctionsのpackage.json内で “engines”: {“node”: “10”} を指定します。

変更前のpackage.json

{
"name": "functions",
"description": "Cloud Functions for Firebase",
"scripts": {
"serve": "firebase serve --only functions -p 5000 -o 0.0.0.0",
"shell": "firebase functions:shell",
"start": "npm run shell",
"deploy": "firebase deploy --only functions",
"logs": "firebase functions:log"
},
"dependencies": {
"firebase-admin": "~7.0.0",
"firebase-functions": "^2.2.0"
},
"private": true,
"engines": {"node": "8"}
}
変更後のpackage.json

{
"name": "functions",
"description": "Cloud Functions for Firebase",
"scripts": {
"serve": "firebase serve --only functions -p 5000 -o 0.0.0.0",
"shell": "firebase functions:shell",
"start": "npm run shell",
"deploy": "firebase deploy --only functions",
"logs": "firebase functions:log"
},
"dependencies": {
"firebase-admin": "~7.0.0",
"firebase-functions": "^2.2.0"
},
"private": true,
"engines": {"node": "10"}
}
とりいそぎコレでデプロイしてみる。


ベータ版じゃねーか!(2020/06/24現在、ベータ版表示は消えたようです)

動作確認したところエラーとなりました。残念。

依存パッケージを更新する

前記package.jsonにありますようにfirebase-adminとfirebase-functionsに依存していますのでこれらを更新してみます。

$ npm install firebase-admin@latest
(出力略)
$ npm install firebase-functions@latest
(出力略)
更新後のpackage.json

{
"name": "functions",
"description": "Cloud Functions for Firebase",
"scripts": {
"serve": "firebase serve --only functions -p 5000 -o 0.0.0.0",
"shell": "firebase functions:shell",
"start": "npm run shell",
"deploy": "firebase deploy --only functions",
"logs": "firebase functions:log"
},
"dependencies": {
"firebase-admin": "^8.12.1",
"firebase-functions": "^3.7.0"
},
"private": true,
"engines": {"node": "10"}
}
デプロイすると正常に動作しました!

最後に

「メンテナンスコストをさげたくてサーバレスにしたのになぁ」
まぁ、忘れるくらいFirebaseコンソールを使ってなかったんだから成功といえるかな。

しかし

これから更新作業を行うプラットホームのEnd of Lifeが来年4月末だと知ったときの顔をしている— murave (@murave) June 16, 2020

あと、実はついでにクライアントの(一部)依存パッケージ更新やったらハマりまくりでそちらで時間がくわれまくったんですよね(カオスすぎて記事にする気もおきない)

murave

FirestoreのServerTimestampさん

有能!

サーバー側で採時するので集計対象の抽出などに安心して使用できます。
これのおかげで必要なデータ取得数を大幅に削減できています。
Firestoreだとデータ取得数は速度や効率だけでなく課金に直結しますしね。

管理画面でデータを時系列に並べたりするのにも便利なのでドキュメント登録時には必ず入れておいたほうが良いくらいの有能さです。

Webでは

import firebase from '~/plugins/firebase'

payload.createdAt = firebase.firestore.FieldValue.serverTimestamp()
await db.collection('コレクション名').add(payload)

(Functionsなど)node.jsでfirebase-adminを使用する場合は

const admin = require("firebase-admin");

const serviceAccount = require("サービスアカウントの鍵.json");
admin.initializeApp({
credential: admin.credential.cert(serviceAccount)
});

admin.firestore().collection("コレクション名").doc("ドキュメントのキー").set({
updatedAt: admin.firestore.FieldValue.serverTimestamp()
});
といった感じでサーバー側でTimestampを投入するように指示できます。

でも注意点が。

登録直後にドキュメントを取得するとTimestampが入ってない状態で返ってきます(2019年3月)。await等で完了をまっても、です。
ただしこれはフロントエンドからの取得の話でonCreateで起動されたFunctionsではさすがにちゃんと入っているようです。

フロントエンドで登録直後にデータを取得し(Server)Timestampを利用するには採時されるのを待つ必要があります。タイミングの問題ってことです(;´Д`)

私の場合は操作ログ記録のための変更後のドキュメント取得でしたので、ログ登録という処理の性質上ユーザーから見える必要はないので非同期に500msディレイして取得・登録処理を行うようにしました(lodashのdelayを利用)

なんか記事書いててもホントかよって感じですが。

murave

Firestoreのセキュリティルールのこと

バックエンドをFirebaseでSPAのシステムを作る場合、データを守る砦はデータベース(今回はFirestore)のセキュリティルールです。
Authenticationのログイン情報とFirestore上のユーザー情報を紐付けし権限によりドキュメントへのアクセスを制限するなど、適切な設定しなければなりません。

service cloud.firestore {
match /databases/{database}/documents {
match /{document=**} {
allow read, write: if request.auth != null;
}
}
}
これは「ログインしているユーザーはフルアクセス」という最低限設定しておくべきルールです。開発中はお世話になりました。
しかし最終的にはもっとこまかく制限をかける必要がありますから、それを前提にデータ構造を設計しておいたほうが良いです。

例として今回作ったシステムのデータ構造とセキュリティルールの一部について解説します。

今回のシステムでは権限による制限が必要だったのでFirestoreにemailをキーとするコレクション、usersを作成し権限情報roleをもたせました。セキュリティルール中でAuthenticationのログイン情報からemailを取得できますので紐づけが可能です。

usersのドキュメント例(権限に関するroleのみ)

murave@hoge.var: {
role: "admin"
}
これを利用してアクセス制限を行います。一部ルールを例示します。

firestore.rules

service cloud.firestore {
match /databases/{database}/documents {
function getUser() {
return get(/databases/$(database)/documents/users/$(request.auth.token.email));
}

match /users/{email} {
allow get: if request.auth != null;
allow read, write: if getUser().data.role == 'admin';
}

match /journals/{journal} {
allow read: if getUser().data.role in ['viewer', 'admin'];
allow create: if request.auth != null;
}

match /summaries/{summary} {
allow read: if request.auth != null;
allow write: if getUser().data.role == 'admin';
}
}
}
この設定は以下の制限・意図となっています。

  • usersはログインしていればドキュメントの(個別)取得は可能。一覧取得、書き込みは管理者(admin)のみ可能。
    ログインしたユーザーが自身の権限情報を取得できる必要がある。
  • ユーザー情報は管理者が管理する。

jornalsへの新規登録はログインしていれば可能。読み出しは閲覧者(viewer)と管理者(admin)が可能。

  • これは集計前の生データなので、ログインしたユーザーすべてが入力する可能性がある。
  • 閲覧者と管理者は登録された生データの分析を行うことができる。

summariesはログインしていれば読み出しは可能。書き込みは管理者(admin)のみが可能。

  • jornalsを集計したデータでユーザーは基本的にはこの集計されたデータを見る。
  • 集計処理はjornalsへのドキュメント新規登録をトリガーとして実行されるFirebase側のFunctionで行われる。
  • 管理者は集計を修正できる。

集計処理はFirebaseのFunctionsを利用しサーバー側に委譲しており、一般・閲覧ユーザーの権限を絞ることが可能となっているのもミソです。

getUser()はAuthenticationのログイン情報のemail(request.auth.token.email)からusersのドキュメントを取得する関数です。内部で使用しているget()等、組み込みの関数には使用回数制限があることに注意してください。同じドキュメントの取得では使用回数は増えないなど独特のルールがあるので意識して、制限を超えないような構造にしなければなりません。

適切なセキュリティルールで安全なFirestoreライフを!

murave

Nuxt.js 悩み解決の小ネタ集

ここ1ヶ月程Nuxt.js + FirebaseでSPAのシステムを作っていました。

短期構築がご要望でリアルタイム性が求められる内容。Firebaseに向いていると判断、提案したところ技術構成は自由にやらせていただけることになりました。
別件で一昨年末あたりからずっとバックエンドはLaravel、フロントエンドは素のVue.js + Vuex + Vue Routerで開発やってるんですが、PWA化したほうがよさそうだったこともあってNuxt.js(以降Nuxt)使ってみようかなってことで採用。

Nuxt、便利ですね。特にプラグインとミドルウェアが素敵!

Firebase(今回使用したのはAuthentication、Firestore、Hosting、Functions)との相性もバッチリでした。

ですが初めてということもありいろいろと悩んだりもするわけで…
それらをどう解決したか?の小ネタ集です(というか今回の落とし所、かな)

cross-envで環境を切り替える

設定ファイルの扱いをどうするか?という悩みです。

バックエンドにFirebaseを使用していることもあり、クライアント側にapiKey等の設定をもたせる必要がありました。当然ながら開発用の設定と本番用の設定は異なります。
開発者の勘を働かせて(大げさ)envというキーワードでモジュールを調査。結果、cross-envを採用しました。

Qiitaにきっちりまとめてくださっている方がいらっしゃったので助かりました

Nuxtでcross-envを使い環境ごとに環境変数を分ける

今回のpackege.jsonのscriptsは以下のように開発・動作確認用Firebaseプロジェクトへのデプロイ用のbuild:staging、顧客練習用のbuild:trainingを追加しています。

"scripts": {
"dev": "nuxt",
"build": "nuxt build",
"build:staging": "cross-env NODE_ENV=\"staging\" nuxt build",
"build:training": "cross-env NODE_ENV=\"training\" nuxt build",
"start": "nuxt start",
"generate": "nuxt generate",
"lint": "eslint --ext .js,.vue --ignore-path .gitignore .",
"precommit": "npm run lint"
},

Nuxtでは通常のnuxt buildを行うとNODE_ENVはproductionになりますのでbuildをそのまま本番用としています。

インスタンスをプラグインで初期化する

ライブラリインスタンスの初期化をどこで行うか?という悩みです。

例えばFirebaseを利用するにはFirebaseプロジェクト用設定での初期化が必要ですが、プラグインの仕組みを利用して初期化するのが常道のようです。

確かに実行タイミング的に適していますね。

plugins/firebase.js

import firebase from 'firebase/app'
import 'firebase/auth'
import 'firebase/firestore'

if (!firebase.apps.length) {
firebase.initializeApp(process.env.firebase)
}
export default firebase
利用側では初期化したインスタンスをimportして利用します。

store/index.js

import firebase from '~/plugins/firebase'

export const strict = false

const db = firebase.firestore()
// 以下略
Vuexの厳格モードをオフにするせっかく用意されている厳格モード、ご指導いただくべき?という悩みです。

Vuexの厳格モードは人類には厳しすぎる!というのが私の感想です。

前掲の利用側コードにありますがVuexの厳格モードは export const strict = false でオフにしました(開発中も)

ドキュメントに「本番環境で厳格モードを有効にしてデプロイしてはいけません! 」とあることですし、気にしない。

問題なく開発できる方は厳格モードを活用してください。

共通処理をプラグイン化する

同じ処理をバラバラ書くのは避けたいもの。どこにまとめるべき?という悩みです。

プラグインに落ち着きました。さまざまなインジェクションも可能ですし、いろいろな場所で使う処理をあつかうのに便利です。他で再利用することは無いようなプロジェクト独自の処理もプラグインに書いています。

plugins/forApp.js

import Vue from 'vue'
import { cloneDeep } from 'lodash'
import firebase from '~/plugins/firebase'

const forApp = {
testA: function(a) {
return a
},
testB: function() {
return this.testA('b')
}
}
Vue.prototype.$forApp = forApp
export default forApp
のような感じでインジェクションとエクスポートを行えばvueファイル内ではthis.$forApp.testA(‘ええじゃないか’)みたいに使えますし、前掲のfirebase.jsのようにimportして使用することもできます。

こんなもんかな。
もっと良い方法があったら教えてくださいね。