33
29

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

Nuxt.jsAdvent Calendar 2019

Day 5

Nuxt × Firebase × GCP周りの13のTips

Last updated at Posted at 2019-12-04

Nuxt.js Advent Calendar 2019 の 5日目の記事です。

が、Nuxt以外の要素がそれなりに含まれています、申し訳ありません🙇

タイトルの環境で本番運用をした経験はないのですが、プロトレベルのものを構築した経験をもとにいくつかのTipsを書かせて頂きます。

あくまで個人的に感じたことをまとめているものですので、「ここは違う」「ここはもっとこうしたほうが良い」等ありましたら是非コメント頂けますと幸いです🙇

前提

  • Nuxt.js: 2.10.2
  • node.js: 10.16.3
    • Cloud functionsの環境と統一させるため
  • @nuxt/typescript: 2.8.1
  • vue-property-decorator: 8.3.0

Nuxtを実装する際のTips編

1. TypeScriptはひとまず入れておく

すでに多くの方が入れられていると思いますが、TypeScriptはたとえ触ったことが無い方でも最初から入れておいて損はないと思います。(必要なところだけ型定義すれば良いので)
僕はNuxt2.4の頃からTypeScriptを入れましたが、2.6くらいまでは結構辛かったなぁという記憶があります。
が、現在最新の2.10系はものすごく簡単に入れることが出来るので、導入コストもすごく低くなっていると思います。

Nuxt×TypeScriptの記事はたくさん出ているので詳細は割愛します。

2. 環境変数はdotenvで

僕の知る限り、Nuxtで環境変数を管理する際は

  • process.env.NODE_ENVによって読み込むファイルを切り替える
  • dotenvを使う

の2択なのかなと思います。

簡単にそれぞれの例を書いてみます。

process.env.NODE_ENVによって読み込むファイルを切り替える

まずは環境変数保持用のtsファイルを作成します。(.gitignoreで除外しておきます)

.env
 ├─ development.ts
 ├─ staging.ts
 └─ production.ts

中身はこんな感じで

.env/development.ts
module.exports = {
  API_KEY: 'xxx',
  AUTH_DOMAIN: 'xxx',
  DATABASE_URL: 'xxx',
  PROJECT_ID: 'xxx',
  STORAGE_BUCKET: 'xxx',
  MESSAGING_SENDER_ID: 'xxx',
}

これをnuxt.configで読み込みます。

nuxt.config.ts
import { Configuration as NuxtConfiguration } from '@nuxt/types'  
const environment = process.env.NODE_ENV || 'development'
const envSet = require(`./.env/${environment}.ts`)

const nuxtConfig: NuxtConfiguration = {
  env: envSet,
}

あとはcross-env等を使ってNODE_ENVを指定してあげればOKです。

package.json
"scripts": {
    "dev": "cross-env NODE_ENV=development nuxt-ts",
    "build:staging": "cross-env NODE_ENV=staging nuxt-ts build",
    "build:production": "cross-env NODE_ENV=production nuxt-ts build",
}

実際に利用するはこんな感じです。

sample.ts
const apiKey = process.env.API_KEY

dotenvを使う

僕も最初の頃は↑の方法を使っていたのですが、CDツールを使う際など、リポジトリ外のファイルを配置してやる必要があって面倒だったので、今はこちらのdotenvを使う方法にしています。

まずはdotenvをインストールします。

$ yarn add dotenv

続いて.envファイルを作成します。

.env
API_KEY=xxx
AUTH_DOMAIN=xxx
DATABASE_URL=xxx
PROJECT_ID=xxx
STORAGE_BUCKET=xxx
MESSAGING_SENDER_ID=xxx

nuxt.configで読み込みます。

nuxt.config.ts
import { Configuration as NuxtConfiguration } from '@nuxt/types'
import { NuxtConfigurationEnv } from '@nuxt/types/config/env'
require('dotenv').config()
const envSet = process.env as NuxtConfigurationEnv

const nuxtConfig: NuxtConfiguration = {
  env: envSet,
}

ここまでで先程のサンプルと同じように使えるのですが、僕はtypo率が高いのでtsで一度読み込んだものを使うようにしています。

env.ts
// process.envに入れて直接呼び出すとtypoがこわいので明示的に入れ直している
export const NODE_ENV = process.env.NODE_ENV
export const API_KEY = process.env.API_KEY
export const AUTH_DOMAIN = process.env.AUTH_DOMAIN
export const DATABASE_URL = process.env.DATABASE_URL
export const PROJECT_ID = process.env.PROJECT_ID
export const STORAGE_BUCKET = process.env.STORAGE_BUCKET
export const MESSAGING_SENDER_ID = process.env.MESSAGING_SENDER_ID

3. pageコンポーネントにはMixinsとベースコンポーネントを噛ませておく

こちらはComposition API導入によって変わってくると思います。
今回はvue-property-decoratorを使う場合のサンプルになります。

pageコンポーネントでは、

  • 画面読み込み時のローディング
  • 共通レイアウト

等各画面で使いまわしたいコードがちょくちょく出てくるかなと思います。

例えばローディング処理を共通化する場合、以下のようなMixinsを組み込むと多少スッキリするかなと思います。
(もちろんミドルウェアで共通化出来るものはそちらで対応したほうが良いと思います)

basePage.ts
import { Component, Vue } from 'vue-property-decorator'

@Component
export default class extends Vue {
  isInitialized: boolean = false

  startPageMounted(): void {
    this.$store.commit('loading/updateState', true)
  }

  endPageMounted(): void {
    this.isInitialized = true
    this.$store.commit('loading/updateState', false)
  }

  get isShow(): boolean {
    return this.isInitialized && !this.$store.state.loading.isLoad
  }
}
sample.vue
<template>
  <!-- mixins側のcomputedで表示制御 -->
  <div v-if="isShow">
    Component
  </div>
</template>

<script lang="ts">
import { Component, Mixins } from 'vue-property-decorator'
import BasePage from '~/mixins/basePage'

@Component
export default class PagesSample extends Mixins(BasePage) {
  async mounted(): Promise<void> {
    this.startPageMounted() // Mixins側のローディング開始処理
    await this.initialize()
    this.endPageMounted() // Mixins側のローディング終了処理
  }

  async initialize(): Promise<void> {
   // something
  }
}
</script>

次に、↓のようにヘッダーナビゲーションを共通化させたいようなケースもあるかなと思います。

スクリーンショット 2019-12-05 3.21.40.png

いくつか手段はあると思いますが、以下のようなベースコンポーネントを作成し、pageコンポーネント側でベースを利用するとスッキリするかなと思います。

basePageTemplate.vue
<template>
  <div class="base-page-template">
    <div class="sp-header-nav-container">
      <div class="sp-header-nav">
        <div class="left">
          <slot name="header-left" />
        </div>
        <div class="center">
          <slot name="header-center">
            <p>title</p>
          </slot>
        </div>
        <div class="right">
          <slot name="header-right" />
        </div>
      </div>
    </div>
    <div class="main-content-container">
      <slot name="content">
        <p>content</p>
      </slot>
    </div>
  </div>
</template>

<script lang="ts">
import { Component, Prop, Vue } from 'vue-property-decorator'

@Component
export default class BasePage extends Vue {}
</script>
pages/sample.vue
<template>
  <base-page-template v-if="isShow">
    <!-- ヘッダーNav 左 -->
    <template v-slot:header-left>
      <div class="back" @click="$router.go(-1)">
        <b-icon icon="chevron-left" />
      </div>
    </template>
    <!-- ヘッダーNav 中央 -->
    <template v-slot:header-center>
      <p>記録する</p>
    </template>
    <!-- ヘッダーNav → -->
    <template v-slot:header-right>
      <b-buttonicon-left="check">
        Done
      </b-button>
    </template>
    <!-- メイン -->
    <template v-slot:content>
      メインコンテンツ
    </template>
  </base-page-template>
</template>

<script lang="ts">
import { Component, Mixins } from 'vue-property-decorator'
import BasePage from '~/mixins/basePage'
import BasePageTemplate from '~/components/pages/basePageTemplate.vue'

@Component({
  components: {
    BasePageTemplate,
  },
})
export default class PagesSample extends Mixins(BasePage) {
  async mounted(): Promise<void> {
    this.startPageMounted()
    await this.initialize()
    this.endPageMounted()
  }

  async initialize(): Promise<void> {
   // something
  }
}
</script>

pageコンポーネントはつくり始めと開発終盤で作り方がガラッとかわってしまうみたいなケースに出くわすことが度々あったので、これをやっておくだけでも多少は楽になるのかなと思っています。

4. scssは積極的に変数を使う

以前はstylusを使っていたのですが、こちらにあるように現在活発ではないので現在はscssを使っています。

nuxt-communityにあるstyle-resources-moduleを使うのがとてもお手軽で良いと思います。

↑にSetupがありますが、こちらでも一応載せておきます。

まずは関連パッケージをインストールします。

$ yarn add sass-loader node @nuxtjs/style-resources

続いて変数設定用のファイルを作成します。

assets/styles/variables.scss
$border-color: #cccbca;
$primary-heavy-color: #242424;
$primary-light-color: #707070;
$background-light-color: #f8f8f8;
$background-primary-color: #fff;

nuxt.configで上記ファイルを読み込みます。

nuxt.config.ts
import { Configuration as NuxtConfiguration } from '@nuxt/types'

const nuxtConfig: NuxtConfiguration = {
  modules: ['@nuxtjs/style-resources'],

  styleResources: {
    scss: ['~/assets/styles/variables.scss'],
  },
}

これで変数が使えるようになります。

<style lang="scss" scoped>
.sample {
  border: 1px solid $border-color;
}
</style>

5. Buefyをカスタマイズして使う

Vuetify使われている方が多いのかなと言う印象ですが、僕はなんとなくBuefyを使い続けています。
良いんですがデフォの紫が何ともなのでカスタマイズしています。

カスタマイズの方法はこちらにあるのですが、nuxt-buefyを使うと最新のbuefyの設定が追加されておらずコケてしまいますので少しだけ細工をします。

まずはnuxt-buefyをインストールします。

yarn add nuxt-buefy
nuxt.config.ts
import { Configuration as NuxtConfiguration } from '@nuxt/types'

const nuxtConfig: NuxtConfiguration = {
  modules: ['nuxt-buefy'],
  css: ['~/assets/styles/buefy.scss'],
}

続いて、↑で設定したbuefy.scssを作成すればOKです。
デフォルトの紫を変更するだけであれば $primary を変更してあげればOKです。

assets/styles/buefy.scss
// Import Bulma's core
@import '~bulma/sass/utilities/_all';

// この1行を入れておく!!!
$block-spacing: 1.5rem !default;

// Set your colors
$black: hsl(0, 0%, 4%) !default;
$black-bis: hsl(0, 0%, 7%) !default;
$black-ter: hsl(0, 0%, 14%) !default;

$grey-darker: hsl(0, 0%, 21%) !default;
$grey-dark: hsl(0, 0%, 29%) !default;
$grey: hsl(0, 0%, 48%) !default;
$grey-light: hsl(0, 0%, 71%) !default;
$grey-lighter: hsl(0, 0%, 86%) !default;

$white-ter: hsl(0, 0%, 96%) !default;
$white-bis: hsl(0, 0%, 98%) !default;
$white: hsl(0, 0%, 100%) !default;

$orange: hsl(14, 100%, 53%) !default;
$yellow: hsl(48, 100%, 67%) !default;
$green: hsl(141, 71%, 48%) !default;
$turquoise: hsl(171, 100%, 41%) !default;
$cyan: hsl(204, 86%, 53%) !default;
$blue: hsl(217, 71%, 53%) !default;
$purple: hsl(271, 100%, 71%) !default;
$red: hsl(348, 100%, 61%) !default;

$primary: $primary-color !default;
$info: $cyan !default;
$success: $green !default;
$warning: $yellow !default;
$danger: $red !default;

$light: $white-ter !default;
$dark: $grey-darker !default;

$orange-invert: findColorInvert($orange) !default;
$yellow-invert: findColorInvert($yellow) !default;
$green-invert: findColorInvert($green) !default;
$turquoise-invert: findColorInvert($turquoise) !default;
$cyan-invert: findColorInvert($cyan) !default;
$blue-invert: findColorInvert($blue) !default;
$purple-invert: findColorInvert($purple) !default;
$red-invert: findColorInvert($red) !default;

$primary-invert: $turquoise-invert !default;
$info-invert: $cyan-invert !default;
$success-invert: $green-invert !default;
$warning-invert: $yellow-invert !default;
$danger-invert: $red-invert !default;
$light-invert: $dark !default;
$dark-invert: $light !default;

$twitter: #4099ff;
$twitter-invert: findColorInvert($twitter);

// Setup $colors to use as bulma classes (e.g. 'is-twitter')
$colors: (
  'white': (
    $white,
    $black,
  ),
  'black': (
    $black,
    $white,
  ),
  'light': (
    $light,
    $light-invert,
  ),
  'dark': (
    $dark,
    $dark-invert,
  ),
  'primary': (
    $primary,
    $primary-invert,
  ),
  'info': (
    $info,
    $info-invert,
  ),
  'success': (
    $success,
    $success-invert,
  ),
  'warning': (
    $warning,
    $warning-invert,
  ),
  'danger': (
    $danger,
    $danger-invert,
  ),
  'twitter': (
    $twitter,
    $twitter-invert,
  ),
);

// Links
$link: $primary;
$link-invert: $primary-invert;
$link-focus-border: $primary;

// Import Bulma and Buefy styles
@import '~bulma';
@import '~buefy/src/scss/buefy';

Firestoreを使う際のTips編

6. Firestoreの更新はCloud Functions for Firebase(Httpトリガー)に統一 Firestoreの更新は極力SDK経由で、必要な場合のみCloud Functions for Firebaseで

firestoreはjavascriptのSDKが非常に優秀なので、わざわざCloud functionsを使うのは億劫なんですが、Cloud functionsを使わざるを得ない場面はそれなりにあると思います。

例えばAlgoliaを使う場合です。
検索が辛くなってきたら早々にAlgoliaやElasticsearch等の全文検索系サービスを使わざるを得なくなると思いますが、Algoliaのデータを更新するためにはADMIN_KEYが必要なため、フロントエンド側に晒してしまうと大変です。
そこでCloud functionsを使うことになると思います。

そうなるとSDKで更新を掛ける部分とCloud functionsで更新を掛ける分が混在してしまいます。
それでも大きな問題はないかなとも思いますが、個人的にはCloud functionsに寄せてしまったほうがスッキリするなぁという印象です。

導入さえしてしまえば怖がることのないCloud functionsですが、導入の敷居は多少高い気がしている(Node + TypeScriptの場合)ので、ここは後日別記事でまとめようと思います。

【2019/12/06追記】僕が以前↑の方法を取っていたのは、

  • FirestoreとAlgoliaと同期的に連携させたかった
    • Firestoreトリガーだと多少のタイムラグが出てしまうので同一トランザクション内で処理したかったためです
  • 対象の更新処理を複数プラットフォームで行う可能性があった
    • Web上、Slack、Teams等複数のプラットフォームから同一の更新処理をかける可能性があったためです

↑の理由があったのですが、Firestoreのメリットに 息を吸うようにデータを更新できる という点があると思うので、全てをHttpトリガー(API)経由で更新してしまうとメリットが失われてしまうと思います。

基本的にはSDK + Firestoreトリガーで更新しつつ、必要な場合のみHttpトリガー(API)経由で更新をかけるのが良さそうです。

7. 検索、オートコンプリートが必要になったら迷わずAlgoliaを使う

↑で記載しましたが、Firestoreを使う限り全文検索サービスは遅かれ早かれ導入せざるを得ないケースが来ることが多いと思いますので、

  • キーワード検索をしたい
  • Firestoreのフィルターが弱くて困っている

等の課題を抱えている方は導入を検討したほうが良いと思います。
(こちらもCloud functions導入記事で合わせて記載しようと思います)

8. Firestoreのデータ分析はExport Collections to BigQueryを使ってBigQueryで

以前はCloud functionsを使って日次等でFirestoreからBigQueryにデータを流し込む方法が一般的だったと思いますが、Export Collections to BigQueryが出たので、非常に楽になりました。
従量課金制のプロジェクトでないと使えない ので万人が救われるわけではありませんが、それでも労力をお金で解決出来る良い選択肢だと思いますので検討の価値はあると思います。

こちらExport Collections to BigQueryの詳細な使い方は、現在同じチームで働いている @wata01Firebase #2 Advent Calendar 2019の10日目で書く予定ですのでそちらをご覧頂ければと思います。

9. FirestoreにはobjectIDを常に持たせておく

Firestoreのidは取得方法が他のフィールドと異なるため、個人的にはちょっとモヤモヤしていました。

const doc = await db.collection('users').doc(xxx).get()

// idを取得する場合
const id = doc.id

// その他フィールドを取得する場合
const data = doc.data()
console.log(data.accountName)

また、Algoliaにデータを突っ込む際もFirestoreの値をそのまま突っ込むとidの情報がないためFirestoreとAlgoliaでデータを紐付けることができなくなってしまいます。

そのため、僕はFirestoreにデータを追加する際、idをobjectIDという名前(Algolia側のキー名に合わせて)に入れるようにしています。

export async function add<D, T>(collection: CollectionReference, draft: D): Promise<T> {
  const ref: DocumentReference = await collection.add(draft) // まずは一度データを登録して、
  const object: T = ({ ...draft, objectID: ref.id } as unknown) as T // objectIDというキーにidを入れて、
  await collection.doc(ref.id).set(object) // 再度更新を掛ける
  return object
}

【2020/01/21追記】
↑、もっと簡単に出来ました🙇

export async function add<D, T>(collection: CollectionReference, draft: D): Promise<T> {
  const ref: DocumentReference = collection.doc() // これで一意のidを付与してくれる
  const object: T = ({ ...draft, objectID: ref.id } as unknown) as T // ↑で付与されたidをobjectIDにも設定する
  await ref.set(object)
  return object
}

データ更新回数が増えてしまうデメリットはありますが、扱いやすさを優先して全データをこの方法で登録するようにしています。

10. Firestoreはある程度の規模になるまでは正規化に近くてもそこまで問題はなさそう

NoSQLで冗長化を恐れたら何も出来ないと言われたそれまでなんですが、それでもやっぱり僕はRDB脳が抜けず、冗長化をする際はそれなりに悩んでいます。

Firebaseでアプリを開発するならClient Side Joinを前提にすることを読ませて頂いて、FirestoreはそこまでN+1を気にしすぎる必要はなさそうなので、特に初期構築時は正規化に近い状態で都度データを取得する実装で進んでしまって良いのではないかなと思います。

例えば僕は以下のようなコードをよく書いています。

const user = await user(xxx) // 一度ユーザー情報を取得してから、
user.comments = await commentsOfUser(user.objectID) // ユーザーに紐づくComment一覧を取得する

export async function user(objectID: string): Promise<FirebaseUser> {
  const usersCollection = db.collection('users')
  const userDoc: DocumentSnapshot = await usersCollection.doc(objectID).get()
  return userDoc.data() as FirebaseUser
}

export async function commentsOfUser(userId: string): Promise<FirebaseComment[]> {
  const comments: types.Comment[] = []
  await commentsCollection.where('userId', '==', userId).get()
    .then(query => {
      query.forEach(doc => comments.push(doc.data() as FirebaseComment))
    })

  return comments
}

デプロイする際のTips編

11. Nuxt(SSR)のデプロイはGAE(Google App Engine)で

SSRのNuxtをGCP資産でデプロイする場合、

  • firestore hostingでホスティングしつつ、SSRが必要なページのみCloud functions経由にする
  • GAEに乗せる

の2択が多いのかなと思いますが、前者はとにかく実装が辛すぎてメリットが見いだせませんでした。。
一方後者のGAEは公式にもしっかりと記載されていて非常に簡単にデプロイ出来ます。

デプロイ方法は公式にある通り、app.yamlを作成して gcloud app deploy app.yaml --project [project-id] するだけです。
app.yamlをカスタマイズせずともデプロイ出来るので、前者の環境からはじめてGAEに移した時は一人で爆笑してしまいました。

12. デプロイはCloud Buildで

地雷を踏んだので要注意です

Firebase, GCPを使っている環境でCDをどこに任せるか考えた際、手軽に行うならCloud Buildが良いかなと思います。
GCP内の資産で、かつ設定も非常に簡単です。

まずはコンソール側で設定を行います。

スクリーンショット 2019-12-05 2.34.29.png

トリガーを選択できて、対象のブランチもフィルターできて、除外、もしくは対象ディレクトリも指定できるのでさくっとCD環境を作成できます。

続いてnpm scriptとcloudbuild.yamlを設定します。
以下はNuxtをデプロイする際の設定になります。

package.json
"scripts": {
  "build": "nuxt-ts build",
  "deploy": "yarn build && gcloud app deploy -q",
  "deploy:gcp:staging": "cross-env NODE_ENV=staging yarn deploy:gcp",
  "deploy:gcp:production": "cross-env NODE_ENV=production yarn deploy:gcp",
}
cloudbuild.yaml
steps:
  - name: 'alpine'
    args: ['echo', '${_ENVIRONMENT}']
  - name: 'gcr.io/cloud-builders/yarn'
    args: ['install']
  - name: 'gcr.io/cloud-builders/gcloud'
    args: ['config', 'set', 'project', '${_PROJECT_ID}']
  - name: 'gcr.io/cloud-builders/yarn'
    args: ['deploy:gcp:${_ENVIROMENT}']
    env:
      - 'ENV_VALUE=${_ENV_VALUE}'
  - name: 'gcr.io/cloud-builders/gcloud'
    args: ['app', 'deploy', '-q']

これでOKです。
環境変数でセキュアにしておく必要があるものはこちらでなくGCPのKVMで管理したほうが良さそうですが、 コンソールからの設定だと何が危ないの? という点をまだ把握出来ていません。。

で、Nuxtが出来たのでCloud functionsもCloud Buildでデプロイするぞ!と意気込んだものの、原因不明のエラーが出てしまいました。

こちらで質問をし、Googleのイシュートラッカーに登録をしたのですが、サポートから

I looked at the logs for the Cloud Build Id you provided and was able to confirm a common issue that is affecting our clients. The fix is on its way and the Engineering team is working on it.

Although there is no ETA, you may follow the progress in this thread.

との回答があり、その後まだ進展はない状況です。

その他のTips編

13. ベータ版の意味をきちんと理解する

↑に地雷を一つ書かせて頂きましたが、Cloud functionsの手動デプロイでも地雷を踏みました。
(状況はこちらに近いです)
こちらもサポートへ問い合わせ、 エンジニアチームが対応してるから待ってね という回答を最後に止まっていますが、僕の環境の場合数日置いて再度実行したらうまく行き、再発もしなくなりました。

GCPでは続々と素晴らしい新機能がリリースされていますが、ベータ版はあくまでベータ版であるということを再認識しました。

さいごに

とんでもなく脈略のない投稿になってしまい申し訳ありません。。。🙇

Nuxt × Firebase × GCPの組み合わせはもっともっとシンプルになっていくだろうと考えているので、これからも実際に触りながらレベルアップしていきたいと思います💪

33
29
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
33
29

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?