Open32

Next.jsとFirebaseでLIFFモバイルオーダーアプリを作る

edegpedegp

ベースのバックエンドはAWSを使っていてdockerでデプロイしているがFirebaseではどうやってやるのか?

edegpedegp

Firebaseのベンダーロックインは問題かも

→でもfirebase alternative のSupabaseがあるぜ!!

edegpedegp

意外と簡単に行けるんちゃう?(純東京人)

dockerいらないし
スキーマレスだから小さいDB立てるにはもってこい

edegpedegp

realtime database vs Cloud Firestore

https://techblog.kayac.com/rtdb-vs-firestore

そんなに規模が大きくなくて、変更がないなら後発のCloud Firestoreがいいっぽい

firestoreの優位性

  • 参照するデータの範囲をコントロールしやすい
  • 複雑なクエリを使ったデータの検索がかけられる

DB TableOrderItemListPaymentOrderInfoの二つだけだしCloud Firestoreでいいかな~

edegpedegp

ロケーション選択があるけど、具体的に地名が書いてないからどこが近いかわからん 多分asia-east2


はずれ~

asia-northeast1(東京)
asia-northeast2(大阪)が近かった

https://firebase.google.com/docs/functions/locations?hl=ja

Tier 1 の料金設定って何ぞや

Tier 1 が高いと思ったらTier 1 が安いんじゃ

東京にするぜ!

edegpedegp

backendディレクトリはいらないかも

sdkインストール

/front
yarn add firebase

余ってline-use-caseってvue.jsじゃんvue.jsビルド遅くてtemplateトjs別で書かなきゃいけないからなんだよね(vueファンの方ごめんなさい)
なので最初から作っちゃいます!
delete

edegpedegp
yarn create next-app --typescript

みんな大好きnextjstypescript

yarn add tailwind sass postcss autoprefixer firebase

tailwindsassfirebase追加!
tsconfigもあるし、eslintもあるしNext.js

edegpedegp

とりあえずページとコンポーネントだけ作ってrfceスぺニットで型だけ作った

edegpedegp

アプリ追加してなかった

firebase初期化

/utils/firebase-client.ts
import { initializeApp } from "firebase/app";
import { getAnalytics } from "firebase/analytics";

// TODO: Replace the following with your app's Firebase project configuration
const firebaseConfig = {
  apiKey: "API_KEY",
  authDomain: "PROJECT_ID.firebaseapp.com",
  // The value of `databaseURL` depends on the location of the database
  databaseURL: "https://DATABASE_NAME.firebaseio.com",
  projectId: "PROJECT_ID",
  storageBucket: "PROJECT_ID.appspot.com",
  messagingSenderId: "SENDER_ID",
  appId: "APP_ID",
  // For Firebase JavaScript SDK v7.20.0 and later, `measurementId` is an optional field
  measurementId: "G-MEASUREMENT_ID",
};

const app = initializeApp(firebaseConfig);

DATABASE_NAMEがよくわからんが取り合えずなくても大丈夫そう

edegpedegp

8/10 23時とりあえず今日は眠いからここまで

edegpedegp

アプリで Firebase にアクセスする

utils/fiirebase-client.ts
import { initializeApp } from "firebase/app";
import { getFirestore, collection, getDocs } from "firebase/firestore/lite";

// TODO: Replace the following with your app's Firebase project configuration
const firebaseConfig = {
  apiKey: process.env.FIREBASE_API,
  authDomain: `${process.env.PROJECT_ID}.firebaseapp.com`,
  databaseURL: `DATABASE_NAME{process.env.REGION}.firebaseio.com`,
  projectId: process.env.PROJECT_ID,
  storageBucket: `${process.env.PROJECT_ID}.appspot.com`,
  messagingSenderId: process.env.MESSAGEING_SENDER_ID,
  appId: process.env.APP_ID,
  measurementId: process.env.G_MEASUREMENT_ID,
};

const app = initializeApp(firebaseConfig);

const db = getFirestore(app)
edegpedegp

コレクションを定義しようとおもったけど、データ追加するだけでいいっぽい

edegpedegp

ベースの商品情報を配列にしてjsファイルとして作成

order-item/table-order-items.js
export const tableOerderItems = [
  {
    categoryId: 0,
    categoryName: "オススメ",
    items: [
      {
        categoryId: 0,
        categoryName: "オススメ",
        discountRate: 20,
        discountWay: 1,
        imageUrl:
          "https://media.istockphoto.com/photos/french-fries-on-white-background-picture-id604378894?s=2048x2048",
        itemDespription: "国産じゃがいも使用。ケチャップでどうぞ。",
        itemId: 1,
        itemName: "ポテトフライ",
        orderNo: 1,
        price: 190,
        stockoutFlg: false,
      },
      {
        categoryId: 0,
        categoryName: "オススメ",
        discountRate: 10,
        discountWay: 2,
        imageUrl:
          "https://media.istockphoto.com/photos/japanese-fried-chicken-picture-id1150109573?s=2048x2048",
        itemDespription: "サクサク、ジューシーな味をお楽しみください。",
        itemId: 2,
        itemName: "特製唐揚げ",
        orderNo: 1,
        price: 500,
        stockoutFlg: false,
      },
      {
        categoryId: 0,
        categoryName: "オススメ",
        discountRate: 0,
        discountWay: 0,
        imageUrl:
          "https://media.istockphoto.com/photos/green-salad-with-fresh-vegetables-picture-id953810510?s=2048x2048",
        itemDespription: "人気のサラダです。",
        itemId: 3,
        itemName: "グリーンサラダ",
        orderNo: 1,
        price: 390,
        stockoutFlg: false,
      },
    ],
    orderNo: 1,
  },
  {
    categoryId: 1,
    categoryName: "ドリンク",
    items: [
      {
        categoryId: 1,
        categoryName: "ドリンク",
        discountRate: 0,
        discountWay: 0,
        imageUrl:
          "https://media.istockphoto.com/photos/glass-of-beer-isolated-on-white-background-picture-id862774556?s=2048x2048",
        itemDespription: "まずはビールから!",
        itemId: 1001,
        itemName: "生ビール",
        orderNo: 1,
        price: 300,
        stockoutFlg: false,
      },
      {
        categoryId: 1,
        categoryName: "ドリンク",
        discountRate: 0,
        discountWay: 0,
        imageUrl:
          "https://media.istockphoto.com/photos/glass-of-gin-tonic-picture-id803905662?s=2048x2048",
        itemDespription: "フルーティーで優しい口当たり。",
        itemId: 1002,
        itemName: "特製カクテル",
        orderNo: 2,
        price: 350,
        stockoutFlg: false,
      },
      {
        categoryId: 1,
        categoryName: "ドリンク",
        discountRate: 0,
        discountWay: 0,
        imageUrl:
          "https://media.istockphoto.com/photos/cola-with-crushed-ice-and-straw-in-tall-glass-picture-id681018122?s=2048x2048",
        itemDespription: "",
        itemId: 1003,
        itemName: "コーラ",
        orderNo: 3,
        price: 200,
        stockoutFlg: false,
      },
    ],
    orderNo: 2,
  },
  {
    categoryId: 2,
    categoryName: "スピードメニュー",
    items: [
      {
        categoryId: 2,
        categoryName: "スピードメニュー",
        discountRate: 0,
        discountWay: 0,
        imageUrl:
          "https://media.istockphoto.com/photos/edamame-japanese-foodboiled-green-soybeans-picture-id1066735192?s=2048x2048",
        itemDespription: "甘み引き立つうす塩味に仕上げました。",
        itemId: 2005,
        itemName: "枝豆",
        orderNo: 6,
        price: 190,
        stockoutFlg: false,
      },
      {
        categoryId: 2,
        categoryName: "スピードメニュー",
        discountRate: 20,
        discountWay: 1,
        imageUrl:
          "https://media.istockphoto.com/photos/japanese-food-japanese-soft-cold-tofu-picture-id1170639696?s=2048x2048",
        itemDespription: "",
        itemId: 2006,
        itemName: "冷ややっこ",
        orderNo: 6,
        price: 200,
        stockoutFlg: false,
      },
    ],
    orderNo: 3,
  },
  {
    categoryId: 3,
    categoryName: "料理",
    items: [
      {
        categoryId: 3,
        categoryName: "料理",
        discountRate: 10,
        discountWay: 2,
        imageUrl:
          "https://media.istockphoto.com/photos/japanese-fried-chicken-picture-id1150109573?s=2048x2048",
        itemDespription: "サクサク、ジューシーな味をお楽しみください。",
        itemId: 3001,
        itemName: "特製唐揚げ",
        orderNo: 1,
        price: 500,
        stockoutFlg: false,
      },
      {
        categoryId: 3,
        categoryName: "料理",
        discountRate: 20,
        discountWay: 1,
        imageUrl:
          "https://media.istockphoto.com/photos/french-fries-on-white-background-picture-id604378894?s=2048x2048",
        itemDespription: "国産じゃがいも使用。ケチャップでどうぞ。",
        itemId: 3002,
        itemName: "ポテトフライ",
        orderNo: 1,
        price: 190,
        stockoutFlg: false,
      },
    ],
    orderNo: 4,
  },
  {
    categoryId: 5,
    categoryName: "本日限定品",
    items: [
      {
        categoryId: 5,
        categoryName: "本日限定品",
        discountRate: 0,
        discountWay: 0,
        imageUrl:
          "https://media.istockphoto.com/photos/isolated-top-view-of-sukiyaki-hot-pot-with-boiling-vegetables-picture-id1090355486?s=2048x2048",
        itemDespription: "",
        itemId: 5001,
        itemName: "寄せ鍋",
        orderNo: 1,
        price: 1200,
        stockoutFlg: false,
      },
    ],
    orderNo: 5,
  },
];

utilsファイルからfirebaseファイルに移動

utils\firebase-client.ts → firebase\firebase-client.ts

forEachでぶん回す

firebase/db.ts
import { tableOerderItems } from "order-item/table_order_items";
import { collection, addDoc } from "firebase/firestore";
import { db } from "./firebase-client";

tableOerderItems.forEach(async (item: any) => {
  try {
    const docRef = await addDoc(collection(db, "TableOrderItemList"), item);
    console.log("Document written with ID: ", docRef.id);
  } catch (e) {
    console.error("Error adding document: ", e);
  }
});
edegpedegp

ちょっと頭が混乱してきた

商品情報のデータベース構築はどこで実行すればいのか?
実行されるごとに上書きされるみたい
getStaticPropsで実行すればビルド時に毎回商品情報を更新できるのでは?
商品情報使うページでgetStaticPropsだ!
とりあえずフロントページにおいておく

pages/index.tsx
export async function getStaticProps() {
  const items = await InitTableOerderItems();
  return{
    props: {
      items
    }
  }
}
edegpedegp

これで商品情報のデータベースはできた

いやまてインデックス作ってない
コンソールでも追加できるが
firebase CLIを使った方が再現性が高そう

firebase CLI install

npm install -g firebase-tools

ログイン

firebase login

プロジェクト確認

firebase projects:list

┌──────────────────────┬─────────────────┬────────────────┬──────────────────────┐
│ Project Display Name │ Project ID      │ Project Number │ Resource Location ID │
├──────────────────────┼─────────────────┼────────────────┼──────────────────────┤
│ LineMobileOrder      │ linemobileorder │  ------------  │ asia-northeast1      │
└──────────────────────┴─────────────────┴────────────────┴──────────────────────┘

初期化

firebase init

いや待て、自動インデックスっていう機能あるから商品情報のインデックスの設定の必要ない
無駄骨だったが、注文情報で使うからいいか

edegpedegp

注文情報データベースはベースの設定だとAWSのグローバルセカンダリインデックスを使ってる

firebaseだと複合インデックスト、単一インデックスがあって複合インデックスは2つの条件でクエリを実行するときに必要

citiesRef.where("country", "==", "USA").orderBy("population", "asc")
citiesRef.where("country", "==", "USA").where("population", "<", 3800000)
citiesRef.where("country", "==", "USA").where("population", ">", 690000)
// in and == clauses use the same index
citiesRef.where("country", "in", ["USA", "Japan", "China"])
         .where("population", ">", 690000)

でも複数条件でも、値を指定してクエリする場合は単一インデックスでも行ける

citiesRef.where('country', 'in', ["USA", "Japan", "China"])

// Compound equality queries
citiesRef.where("state", "==", "CO").where("name", "==", "Denver")
citiesRef.where("country", "==", "USA")
         .where("capital", "==", false)
         .where("state", "==", "CA")
         .where("population", "==", 860000)

単一インデックスにする
あれ自動インデックスってすべてのフィールドについているっぽい

いらねーじゃん(笑)
てかfirebase AWSより圧倒的に使いやすいんだが

edegpedegp

ちょっと行き詰ってきたけど注文情報はフロント作るときに一緒に作っちゃお

とりあえずfunctionsを作る

ベースがpythonなのでpythonでデプロイしようと思ったら
javascriptとtypescriptだけっぽい、一応GCPのfunctionsを使えばpythonでも行けるみたいだけど
デプロイの仕方がいまいちわからない
1つのファイルに関数をまとめないといけないのか?

https://qiita.com/tdkn/items/2ed2b01f2656fc50da8c

edegpedegp

pythonでやるのにもGCPを使って、ファイルに関数入れなきゃいけないみたいだから
ベースを参考にtypescriptでfunctions作ります。

edegpedegp

getItems

export default functions.https.onRequest(async function ItemListGet(req, res) {
  const getItemList = async (params: unknown) => {
    if (typeof params === "string") params = parseInt(params, 10);
    const items = await admin
        .firestore()
        .collection("TableOrderItemList")
        .where("categoryId", "==", params);
    functions.logger.log("items %s", items);
    return items;
  };
  functions.logger.log(req.query);
  const items = await getItemList(req.query);
  if (items) {
    functions.logger.log("Occur Exception: %s", items);
  }
  return res.json(items);
});
edegpedegp

ベースのレイヤディレクトリの部分を作るのが予想以上にしんどい気がする

やはりcloud functions にgcp CLIでデプロイするのがよきかも
いや、そもそもdockerでコンテナ化してないので、pythonでやっても書き直しが結構多いことに気づいた
firebaseじゃなくてAWSをシンプルに使った方がよかったのか…
でも、AWSの使いにくさは嫌いなんだよね
GCPは無料期間が短いのでAzureがいいのかもしれない

edegpedegp

めちゃ簡単

import { format } from "date-fns";

export const check_year_month = (columns, column_name) => {
  const columnsReplaced = columns.replace("-", "").replace("/", "");
  try {
    format(new Date(columnsReplaced), "yyyyMM");
  } catch {
    return `年月形式エラー : ${column_name}(${columns})`;
  }
};
export const check_year_month_day = (columns, column_name) => {
  const columnsReplaced = columns.replace("-", "").replace("/", "");
  try {
    format(new Date(columnsReplaced), "yyyyMMdd");
  } catch {
    return `年月日形式エラー : ${column_name}(${columns})`;
  }
};
export const check_time_format = (columns, column_name, time_format) => {
  const columnsReplaced = columns.replace("-", "").replace("/", "");
  try {
    format(new Date(columnsReplaced), time_format.toString());
  } catch {
    return `年月日形式エラー : ${column_name}(${columns})`;
  }
};

edegpedegp

pythonの__init__って何なの?

コンストラクタっていうらしい(前にやったなー)
機能的にはクラスを初期化するらしい

class TableOrderParamCheck(ParamCheck):
    def __init__(self, params):
        self.category_id = params['categoryId'] if 'categoryId' in params else None  # noqa:E501
        self.table_id = params['tableId'] if 'tableId' in params else None
        self.payment_id = params['paymentId'] if 'paymentId' in params else None  # noqa:E501
        self.transaction_id = params['transactionId'] if 'transactionId' in params else None  # noqa:E501

        self.item = params['item'] if 'item' in params else None  # noqa:E501

        self.error_msg = []

    def check_api_order_put(self):
        self.check_table_id()

        self.check_item()

        return self.error_msg

普通にjavascriptならinit()みたいにしてファイルの上の方に書いておけばOKかな?

https://stackoverflow.com/questions/43812514/javascript-equivalent-to-python-init-py

edegpedegp

functions ディレクトリ内でファイル分割して関数を定義して、exportしていたら
Parsing error: Cannot read file '.../tsconfig.json'.eslint (...の部分は自分のプロジェクトフォルダまでのパス)というエラーが表示された。

https://zenn.dev/taichifukumoto/scraps/45be5ffdfa8457

.vscode/setting.json
{
  "eslint.workingDirectories": ["./front", "./functions"]
}

結果的にこれで解決

edegpedegp

lineの関数と日付け調整の関数が完成

functions/src/line
/* eslint-disable linebreak-style */
import * as functions from "firebase-functions";
import line from "@line/bot-sdk";
import axios from "axios";

export const sendPushMessage = (
  channelAccessToken: any,
  flexObj: any,
  userId: string
) => {
  const client = new line.Client({
    channelAccessToken,
    channelSecret: process.env.CHANNEL_SECRET || "",
  });
  let response;
  try {
    response = client.pushMessage(userId, {
      type: "flex",
      altText: "This is a Flex Message",
      content: flexObj,
    });
  } catch (e) {
    // functions.logger.error(
    //   "Got exception from LINE Messaging API: %s\n" % e.message
    // );
    functions.logger.error("Occur Exception: %s", e);
  }
  return response;
};

export const getProfile = (idToken: any, channelId: any) => {
  const headers = { "Content-Type": "application/x-www-form-urlencoded" };
  const body = {
    id_token: idToken,
    client_id: channelId,
  };

  const response = axios.post(process.env.API_USER_ID_URL, {
    headers,
    data: body,
  });

  const resBody = JSON.parse(response.text);
  return resBody;
};


もう遅いから寝る
明日は用事あるから休み