yasutomogのブログ

Software Engineerの雑記

eslintとprettier

概要

  • eslintとprettierをGitコミット時に実行する

環境

  • node:14.14.0
  • eslint:7.23.0
  • eslint-config-prettier:8.1.0
  • husky:6.0.0
  • lint-staged:10.5.4
  • prettier:2.2.1

設定ファイル

prettierrc.json

{
  "singleQuote": true
}

eslintrc.json

{
  "env": {
      "es6": true
  },
  "globals": {
      "alert": false,
      "document": false,
      "$": false,
      "console": false
  },
  "extends": ["eslint:recommended", "prettier"]
}

.husky/pre-commit

#!/bin/sh
  #!/bin/sh
  . "$(dirname "$0")/_/husky.sh"

  npm run lint-staged

package.json

"scripts": {
  "lint-staged": "lint-staged"
},
"lint-staged": {
  "*.{js,ts,jsx,tsx}": [
      "npx eslint . --fix",
      "npx prettier --write ."
  ]
},

Nuxt.jsでPAY.JPの初期化エラー(既にインスタンス化されています)対策

概要

  • Nuxt.jsベースでJamstackなECサイトのサンプル実装しているときに、PAY.JPの初期化でエラーが出ることがあったのでメモ
  • PAY.JPを実装するにあたり、payjp(2.0.5)のnode moduleを利用
  • 画面側ではpayjpCardElementを生成し、トークンを作成して決済用のAPI呼び出す流れで実装

エラーとなる実装

<template>
  <div id="payjp-form"></div>
</template>
<script>
  mounted() {
    const payjp = window.Payjp(process.env.PAY_JP_PK)
    const elements = this.payjp.elements()
    const cardElement = elements.create('card')
    cardElement.mount('#payjp-form')
    cardElement.on('change', async (event) => {
      if (event.complete) {
        const res = await this.$payjp.createToken(cardElement)
        this.payjpToken = res.id
        this.isValidCard = true
      }
    })
  },
</script>
  • 画面初期表示時は問題なくカードレイアウトが表示されていたが、画面遷移後、再び画面を開くと「Error: 既にインスタンス化されています」というエラーが発生。
    複数回の初期化処理はNGとのこと。

対応策(Pluginを作成し初期化を1度に制御する)

  • plugins/payjp.jsを新規作成
import Vue from 'vue';

export default ({store, isHMR}) => {

  if (isHMR) return

  if (process.client) {

    Vue.prototype.$payjp = window.Payjp(process.env.PAY_JP_PK)
  }
}
  • nuxt.config.jsにPlugin定義追加
plugins: [
  '~/plugins/payjp.js',
],
  • エラーとなっていた画面の修正
<template>
  <div id="payjp-form"></div>
</template>
<script>
  mounted() {
    const elements = this.$payjp.elements()
    const cardElement = elements.create('card')
    cardElement.mount('#payjp-form')
    cardElement.on('change', async (event) => {
      if (event.complete) {
        const res = await this.$payjp.createToken(cardElement)
        this.payjpToken = res.id
        this.isValidCard = true
      }
    })
  },
</script>

Firestoreを使用するFirebase Cloud Functionsをファイル分割した時のエラー対応

概要

  • Firebase Cloud functionsで、Firestoreを利用している
  • 当初は、index.jsに関数定義していた
  • 複数関数定義する必要があったので、関数ごとにファイル分割した
  • 1回目の関数実行時は特に問題ないが、2回目以降の呼び出し時に以下のエラーが発生 (初期化は1回にしなさいとのこと)
The default Firebase app already exists. This means you called initializeApp() more than once without providing an app name as the second argument. In most 
cases you only need to call initializeApp() once. But if you do want to initialize multiple apps, pass a second argument to initializeApp() to give each app a unique name.

エラー発生時のコード

  • index.js
const foo = require('./foo')
const bar = require('./bar')
exports.foo = foo.hoge
exports.bar = bar.fuga
  • foo.js
const functions = require('firebase-functions');
const admin = require('firebase-admin')

exports.hoge = functions.region('asia-northeast1').https.onCall(async (data, context) => {

  admin.initializeApp()
  const fireStore = admin.firestore()

  // 以下、firestoreを使った処理

})
  • bar.js
const functions = require('firebase-functions');
const admin = require('firebase-admin')

exports.fuga = functions.region('asia-northeast1').https.onCall(async (data, context) => {

  admin.initializeApp()
  const fireStore = admin.firestore()

  // 以下、firestoreを使った処理

})

エラー解決用に修正したコード

  • index.js(変更なし)
const foo = require('./foo')
const bar = require('./bar')
exports.foo = foo.hoge
exports.bar = bar.fuga
  • firestore.js(新規追加)
const admin = require('firebase-admin');

admin.initializeApp();
const fireStore = admin.firestore();

module.exports = {
  fireStore,
};
  • foo.js(変更)
const functions = require('firebase-functions');
const { fireStore } = require('./firestore')

exports.hoge = functions.region('asia-northeast1').https.onCall(async (data, context) => {

  // 以下、firestoreを使った処理

})
  • bar.js(変更)
const functions = require('firebase-functions');
const { fireStore } = require('./firestore')

exports.fuga = functions.region('asia-northeast1').https.onCall(async (data, context) => {

  // 以下、firestoreを使った処理

})

JamstackなECサイトの考察

概要

  • Jamstack Advent Calendar 2020 - Qiita の22日目の記事です。
  • 株式会社トライビート| TRIBEAT CO., LTD. という会社でBtoCや業務システムのWebアプリケーション構築や運用・保守をしています。
  • これまでJamstackという言葉が出てくる前から、社内独自のSSGの仕組みで試行錯誤して対応することがありました。
  • Jamstack関係の環境やサービスが充実してくる中で、ECサイト構築が現実的に良い選択になりそうかを実際に試してみたので、考察やハマったポイントを備忘録としてまとめる。(まだ検証中)

アーキテクチャの構成

  • microCMS
  • Firebase hosting
  • Firestore
  • Firebase functions
  • Firebase Authentication
  • Nuxt.js
  • Algolia
  • Stripe
  • GitHub Actions

※Next.jsとVercelへの移行も試したいが、データ層の設計がまとまったタイミングで検討する。

画面構成

ECサイト

  • 商品一覧
    • サイトのtopページ
    • algoliaから商品一覧を取得して表示
    • 検索を可能にするため、商品一覧情報を組み込んだ静的ページ化はせずSPAとする
    • 商品をクリックすることで、商品詳細画面へ遷移
  • 商品詳細画面
    • SSGで静的ファイル化したページ
    • 数量変更とカート追加が可能
    • カート追加ボタンをクリックすることで、カート画面へ遷移
  • カート画面
    • カートに追加された商品一覧を表示
    • 商品の数量を変更することが可能
    • 買い物を続けるボタンをクリックすることで商品一覧画面へ遷移
    • 購入者情報とカード情報を入力すると購入ボタンが活性化
    • 購入ボタンクリックすると、在庫チェック後、決済用のCloud Functionsをリクエス
    • カード情報入力部分はStripeのvueコンポーネントを使用
  • アカウント画面(未実装)
    • いつでも作成できるので、後回しにして進めていた画面
    • 取り急ぎは、Firebase Authentication上でユーザー作成し、簡易画面からログイン処理とログアウト処理を実装
    • アカウント情報の作成や更新できるようにする
    • ログイン、ログアウトの組み込み
    • 認証はあくまでも便利機能として考え、未認証でも購入できる想定

管理者サイト

  • プレビュー画面
    • microCMS管理画面の画面プレビューからリクエストされる想定
    • リクエストURLからmicroCMSのAPIを叩き、動的にページ作成
  • 在庫管理画面
    • 商品データに在庫数を付与したリスト画面
    • 商品データの一覧はSSGビルドで静的ファイルとして作成
    • 在庫数のみ、描画時にFirestroeから取得して当て込む
    • 在庫数の変更が可能(Firestoreを更新する)
  • 販売管理画面
    • 販売データ(日付、合計金額)とステータスを表示したリスト画面
    • FireStoreから動的にデータを取得するので、静的化せずSPAとする
    • 詳細ボタンをクリックすると、購入者情報や購入商品一覧をオーバーレイで表示

Functions(API

  • 決済用関数
    • 在庫チェックと金額改ざんチェック後、StripeのAPI呼び出し
    • 在庫数変更(Firestore)
    • 販売情報の登録(Firestore)

現状の簡易フロー図

f:id:yasug:20201218003238j:plain
jamstack_ec_flow

実装時の考察

ECサイトの機能について

  • 当初、商品一覧画面はページング付きですべてSSGしたもので進めていたが、やはり検索したいということからAlgoliaを利用したSPAとした
  • ログイン情報の状態管理はVuexとlocalstorageを同期させた管理方法で実装(詳細は下に別途書く)
  • ブラウザから直接Firestoreへwriteすることはやめ、Cloud Functionsを間に挟むように実装(ReadはOK)
  • カートの状態管理も認証と同様にVuexで管理
  • カート画面から購入時の在庫チェックは、その後のCloud Functions内でもするが一応フロント側でも見るようにした(要検討)

管理者サイトの機能について

  • リアルタイム性が必要になるデータを扱うことが多く利用ユーザも限定されるため、SSGに意固地になりすぎずSPAでの設計も積極的に検討する
  • Firestoreへのwrite系処理もCloud Functionsが間に挟めなくて良しとする(要検討)
  • プレビュー画面ではmicroCMSのAPI_KEYがソースから見えるが良しとする(要検討)

データ層について

  • ここが一番迷っていて検討要素が多い(Stripeでどこまでデータ管理すべきか見えていない)
  • 在庫データ、商品データ、販売データをFirestoreで管理して作成を進めた(要検討)
    • 在庫データと販売データはStripe側で持たせるほうがいいのか?
    • 商品データはStripeに連携してそちらでも管理する方がいいのか?
  • 商品データはmicroCMSからAPIで取得もでき2重管理になるがFirestoreにも入れた(要検討)
    • 決済時のCloud Functionsで金額チェックをしたい 具体的には、商品マスタ情報の金額と画面から渡された数量が、画面から渡された合計金額に一致するかチェックしたい
    • 上記チェック時の金額判断するためには、microCMSから更新された商品データの履歴が必要になるため、履歴管理をどこかで持たせたい
    • 商品データもStripeでもたせる方がいいのか?
  • 顧客データ(要検討)
    • 認証周りは簡易実装しかしていないので、現状はFirebase Authenticationに固定ユーザー作って試している ユーザー情報が登録されたときに、StripeとFirestoreどちらで管理すべきか?

SSG(ビルド)について

  • 各商品ページをSSG
  • 上記で、microCMSのAPIから商品データ一式取得するので、AlgoliaとFirestoreへ必要なデータ更新するように実装
    • Firestoreへの更新をトリガーにしたCloud Functionsを用意してAlgoliaのデータと同期するようにした方がいいか迷っている。
    • microCMSから商品データ更新(追加・削除)された場合に、Firestoreのデータを全て変更する必要はないので、microCMSで設定するカスタムのWebhookでやってもいいものか迷っている(詳細は下に別途書く)

ハマったポイントと解決案

1. GitHub Actionsでデプロイ時にfirebaseコマンドがないと言われた

背景

  • GitHub ActionsのFirebaseデプロイでは、よく以下を利用したサンプルを見かけるが、まずは理解のためにも自分でfirebaseコマンドを叩くように書いてみた

    github.com

  • デプロイ用のymlでは以下のように、stripeのシークレットKEYを設定し、デプロイコマンドを叩いていた

- name: deploy to Firebase Hosting
      run: |
        firebase functions:config:set stripe.secretkey=${{secrets.STRIPE_SECRET_KEY}}
        firebase deploy
      env:
        FIREBASE_TOKEN: ${{secrets.FIREBASE_TOKEN}}
  • GitHub Actions内のデプロイ処理で、firebase command not foundエラーが発生していた

解決案

  • デプロイの前処理でビルド処理が実行されるので、そこの npm install 時にfirebase-toolsを、-gでインストール
npm install -g firebase-tools

2. ある日突然、firebaseのデプロイがコケるようになった

背景

  • 12/16になったらデプロイがコケるようになった。
  • GitHub Actionsのエラー内容は以下
Error: An unexpected error has occurred.
Error: Process completed with exit code 2.

解決案

  • firebase-toolsのバージョンチェック

github.com

  • プロジェクト生成時は8系で進めていたが、12/16に9系に上がっていた!
  • とりあえずバージョンを8系にするように変更して正常動作することを確認できた
npm install -g firebase-tools@^8.0.0
  • 9系になったことで設定ファイル系のフォーマットなど変わったのかも、まだ詳細調査はできていない。後日する予定。

3. Firebase Auth使った認証状態の保持

背景

  • 認証自体はFirebase Authenticationを利用して簡易実装していたが、どう状態保持するか悩んだ
  • 最初、middlewareにauth処理を実装してvuexで状態保持を検討した
    • npm run devで確認しているとうまく動くように見えるが、npm run generate & npm run startだとうまくいかない
    • middlewareはF5(page refresh)時には呼ばれないため、Jamstack構成では使えないと判断
  • 次に、共通のヘッダコンポーネントを用意していたので、そこのbeforeCreateでfirebaseの認証チェックするようにした
    • 商品ページの切り替え時に、その都度認証確認リクエストが飛ぶことにモヤモヤ
    • 今後ページが増えていく中で、ヘッダが全て共通になるのかモヤモヤ
    • カートの状態も何かしらの方法で保持しなくてはいけないことに気づき、使えないと判断

解決案

  • 最終的には、以下のpluginを導入しlocalstorage管理することで対応

github.com

4. GitHub Actionsのパフォーマンス検討

背景

  • GitHub Actionsの処理全体で約4〜5分掛かっている
  • 内訳としては、ビルド(npm install含む)とfirebaseのデプロイでそれぞれ2分くらい
  • ビルド処理は常に1からインストールしているので、対策ないか調査

解決案

  • 探すとすぐに以下が見つかった。

github.com

  • ただ、使ってみたけど特に大きくパフォーマンスが改善されることはなく、ここについては後日調査

5. nuxt run generate 時にFirestoreへデータ入れようとして発生したワーニング

背景

  • build処理時、nuxt.config.jsのgenerateで以下のような感じでFirestoreにデータ更新していた
  generate: {
    async routes() {

      const { data } = await axios.get(
        `https://xxx.microcms.io/api/v1/items?limit=100`,
        { headers: { 'X-API-KEY': API_KEY } }
      )

      const fsStocks = firestore.collection('items')
      const batch = firestore.batch()

      const pages = []
      data.contents.forEach((content) => {
        pages.push({
          route: `/${content.id}`,
          payload: content
        })

        const nycRef = fsStocks.doc(content.id)
        batch.set(nycRef, content)
      })
      await batch.commit()

      return pages
    }
  },
  • npm run generate すると一応SSGは正常完了するものの、以下のようなワーニングが表示される
⚠ Nuxt Warning

The command 'nuxt generate' finished but did not exit after 5s
This is most likely not caused by a bug in Nuxt
Make sure to cleanup all timers and listeners you or your plugins/modules start. 
Nuxt will now force exit

DeprecationWarning: Starting with Nuxt version 3 this will be a fatal error

解決案

  • generate完了時に明示的にFirestoreを終了させてやる必要がある
  • nuxt.config.jsのhooksに以下を追記
  hooks: {
    generate: {
      done(builder) {
        firestore.terminate().then()
      }
    }
  },

6. Firestoreへデータ登録かmicroCMSのWebhookで迷った

背景

  • ビルド時にmicroCMSから全データ取得しているので、その流れでFirestoreも一律全データ更新するようにしていた
  • microCMSで商品データ更新(追加、削除)された場合に、全データ更新ではなく、更新データのみFirestoreに反映した方が効率よいと思った
  • microCMSではWebhookにカスタム通知が設定できるので、それを利用しCloud Functionsを呼び出せば、1件ごとのFirestore更新ができるのではないかと思った

解決案

  • 以下のようなCloud Functionsを用意し、カスタム通知にURL設定することでWebhook連携の接続までは簡単にできることを確認
exports.updateCms = functions.region('asia-northeast1').https.onCall(async (data, context) => {
  functions.logger.log("called updateCms.")
})

exports.updateCms2 = functions.region('asia-northeast1').https.onRequest((request, response) => {
  functions.logger.log("called updateCms2.")
})
  • 関数内でリクエストパラメータを取得しFirestore更新まではいけるものの、セキュリティが心配に。
  • microCMSのカスタム通知で渡せるパラメータは決まっているため、認証用トークンなどは含めれないはず?
  • カスタム通知に設定するURLにクエリストリングを付与して、それをトークンとして扱うことも検討したが、一旦はそのままビルド時に全データ更新で落ち着いた。

結論

  • 細かい部分、特にデータ層の役割分担については検討も作り込みも必要だが、実際に書いてみた感触的には中小規模のECサイトであれば可能。
  • Jamstackな構成にすることで、様々なSaaSを利用することになるため、それについても少し考えてみた。
    • SaaS利用について、慎重に選択する必要はあるが利用そのものに消極的になりすぎる必要はない
    • パフォーマンス、スケーラビリティ、アベイラビリティ等の非機能要件への対策を、SaaS側に移譲することは合理的に感じている
      • SaaS提供企業側としては、如何にパフォーマンスよく安定したサービスを提供するかがビジネスに直結している
      • SaaS利用企業側からすると、対策にコストが掛かる非機能要件を移譲することで本来の目的となるビジネスロジックに集中することが可能

iOSバージョンとMacBookのSafari開発者ツールについて

概要

  • iOS実機とMacbookを接続して実機デバッグする中で、開発者ツールがうまく動かない事象が発生
  • iOS実機のバージョンにより再現が異なることを確認
    • iOS 12.4.9
      • 問題なし
    • iOS 14.2
      • MacbookSafariから実機接続は可能
      • 開発者ツールの[要素]タブから、適当なhtml要素を選択すると、「スタイル」にCSSクラスのスタイルが表示されない html要素に直接指定されているスタイルのみ表示されている状態
    • iOSシュミレーター
      • MacbookSafariの[開発] メニューにシュミレーターが表示されない
  • iOS実機とMacbookの接続方法や設定については、たくさん記事が上がっているので割愛

解決方法

  • Safari Technology Preview をインストールして利用することで、開発者ツールが問題なく使えることを確認。

developer.apple.com

  • Safari Technology Preview はCatalinaとBug Surに対応している
  • Catalinaの10.15.5に入れようとしたところ、10.15.6からインストール可能というエラーになったので、 10.15.7を入れて問題なく動作できることを確認。

PHPのログライブラリ(monolog)のPull Requestがマージされた

yasutomo.hatenablog.com

2019年に上記ブログで書いてたとおり、monologを使った日付のログローテート処理でうまくいかないことがありPRを投げていた。

PR作成してから2週間程は気にして見てたけど、特に音沙汰なかったため、自分の拙い英語ではやはり相手にされないのかと勝手に納得していた。自分の関わっているプロジェクトでは必要な修正だったためライブラリのクラスをoverrideして運用していた。

月日は経ち、プロジェクトで使っているフレームワークのバージョンを上げるタイミングがやってきた。それに伴いライブラリ等のバージョンも上げることになった。

バージョンを上げるための調査は僕とは別の人が進めいて、今朝、その人から僕のPRがマージされてると教えてもらった。寝耳に水で、最初何のことかもわからないくらいだった。

確認してみるとPRから3ヶ月後くらいにマージされていて、当たり前だがメール通知もちゃんと届いていた。

PRは普段から業務でPrivateなリポジトリに対して作成しているが、一般的によく使われているオープンソースに対しては初めてだったのでなんだかとても嬉しかった。

今後も続けていきたい。

github.com

いつも昼はカップラーメンや柿ピーで済ませてしまうけど、今日は自分へのご褒美にチキンカツのサンドイッチを食べた。

Google Cloud Platform(Cloud Functions, Cloud PubSub, Cloud Storage, Speech-to-Text, Secret Manager)を使ってPodcast音声データの文字起こし

概要

  • 2020年のコロナ時期から社内コミュニケーションを活性化させたく、社内環境でPodcast配信を開始
  • Podcastの収録はzoomで録音し、編集などはせずに社内のGoogleサイトへアップする流れ(継続させることに重き置いて、極力手間を掛けないことを意識している)
  • 収録はいつも1〜1.5hほどになっている
  • 社内から聞く前にある程度話の内容などがわかると嬉しいという意見をもらったので、Google Cloud APIのSpeech-to-Textを利用して文字起こしを試してみた

ゴール

  • 当初は、自分のローカルマシンでやっていたが色々運用がめんどくさくなってきたのと、GCPのサービス連携を試してみたかったので、Storageに音声ファイル(wav)をアップロードしたら自動で文字起こしをしてくれるものをゴールにした。
  • 今回、Secret ManagerやPubSubは利用しなくても構築可能だったが、色々な連携を試してみたかったので利用することに決めた。(サービスの最適化よりかは、色々な素振りをするために利用サービスを検討した。)
  • 言語はNode.jsで書く

GCPのサービス関連図と処理内容

Podcast音声データ文字起こしGCPの関連図
Podcast音声データ文字起こしGCPの関連図

  1. wavファイルをローカルPCからCloud Storageへアップロード(zoom収録時はm4a形式になっているので、それをwavに変換するところまではローカルで実施)
  2. アップロード用のCloud Storageにファイル配置(上書き)されたことを契機にCloud Functions実行。
  3. 関数内ではシークレットマネージャーからCloud Storageアクセス情報(Bucket名)を取得(特に不要そうではあるが、試しに使ってみたかった)
  4. Speech-to-Text APIを利用して、音声(wav)データから話している内容を取得(結構、処理に時間が掛かる)
  5. アップロードとは別のCloud Storageへjsonファイルを書き出し
  6. Cloud PubSubにjsonファイル名をメッセージとしてパブリッシュ
  7. メッセージのパブリッシュを契機にCloud Functions実行
  8. 関数内ではシークレットマネージャーからCloud Storageアクセス情報(Bucket名)を取得(特に不要そうではあるが、試しに使ってみたかった)
  9. Cloud Storageからjsonデータを取得
  10. 取得したjsonデータを解析し、時間ごとの会話内容として整形したファイルを別なCloud Storageへ書き出し

ハマったポイント

  • Cloud FunctionsからStorageやシークレットマネージャーにアクセスしたときに権限エラーが発生。
    単純にStorageやシークレットマネージャー側で権限を絞っていたので、適宜権限を変更することで解決した
  • Speech-to-Text APIの利用でタイムアウトエラー
    Cloud Functionsのタイムアウト最大値が540秒なため、タイムアウトや使用メモリサイズにMAX値を設定していたが540秒以内に完了せずタイムアウトエラーとなっていた。
    当初、Storageへのファイル配置をトリガーとして呼ばれる関数には、asyncをつけて同期的な処理実行をするようにしていたが、それをやめ非同期処理させるようにして解決。

今後の課題

  • デプロイ方法
    gcloudコマンドを利用してローカルからデプロイしているが、GitHubと連携させたい
  • エラーハンドリング
    音声ファイルのテキスト化部分は非同期処理としただけなので、エラー時の処理を検討したい
  • GoogleのSpeech-to-Text APIの精度
    雑な日本語で話しているせいかもしれないが、結構話している内容とテキストで差が生じる。
    マイクやレコーディング後の編集などで音質を上げることで、精度向上につながるものなのか時間があれば試してみたい。

GitHub

github.com

※helloWorldのソースは、Cloud Functionsの関数内でsetTimeoutした場合に、大元の関数処理自体は完了しても非同期処理される関数はその後実行されることを確認するためのもの。