NuxtアプリをSSGでビルドしてCI/CDをお手軽に設定する【GitHub Actions × Firebase Hosting】

# はじめに
今回はNuxt.jsのSSG（静的サイトジェネレーター）モードで作成したアプリのCI/CDを設定する方法を紹介します！
GitHub ActionsとFirebase Hostingを連携させるとかなり手軽に設定できたのでNuxt/Vue初心者の方も実践できる内容だと思います。

## 対象読者
・実務でCI/CDを使った事が無い人
・CI/CDという言葉は聞いた事があるがよく分からない人
・Vue/Nuxtを触った事はあるがレンダリングの仕方までは気にした事がない (SSGってナニ?な人)
・firebaseがなんとなく分かる人

## 説明しない事
・firebase各種サービスの細かな説明
・テスト(Jest)の書き方

## SSG (静的サイトジェネレーター)とは？

個人的にはこの記事の説明が分かりやすいと思います
[Nuxt.jsを使うときに、SPA・SSR・SSGのどれがいいか迷ったら -Qiita](https://qiita.com/nishinoshake/items/f42e2f03663b00b5886d)

NuxtをSSGモードで始めると、ビルド時（nuxt generate実行時）にvueファイル内で呼ばれているAPIからデータを取得し、それに基づいたHTMLが生成されます。

ビルド時点のDBから取得した値がHTMLへ直書きされるので、DBの値が変わったとしても再度デプロイするまではHTML内の値が変わる事はありません。

#### 特徴

・サーバーサイドとデータのやりとりをする必要が無いので
　1) レスポンスが速い
　2) セキュリティ面のリスクが軽減される

・データの更新をする為には都度ビルドする必要があるので、頻繁に更新するサイトには向かない (LPやポートフォリオには向いている)

## CI/CDとは

ニフクラさんによると
> 「CI」とは「Continuous Integration（継続的インテグレーション）」の略で、ソフトウェア開発におけるビルドやテストを自動化し、継続的に行うアプローチのことです。
>「CD」とは「Continuous Delivery（継続的デリバリー）」の略で、CIによってテストされたコードのマージや、本番環境向けのビルドの作成を自動的に行い、本番環境にデプロイが可能な状態を整えるプロセスのことです。

[CI/CDとは | ニフクラ](https://pfs.nifcloud.com/navi/words/ci_cd.htm)

簡単に言えば、CI/CDとは継続的にビルド/テスト/デプロイを行う事で、いつでも本番環境を更新できるような状態に保つ為の仕組みの事です。
近年の開発手法の主流である「アジャイル開発」の中で、リリース回数が多くなっても品質を下げない様にする為の施策であるとも言えます。


#### 今回実装するCI/CDの動き

今回作成するシステムは以下の様になります
![Untitled Diagram (1).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/488042/c2483b34-02e6-4881-5c1a-a0270c65c42e.png)
mainブランチはリリース用のブランチなので、そこで作業することはありません。

**手順**
1. 機能を実装し、実装箇所のテストを書いたら、mainブランチへコードをpushします。
2. GitHub Actionsはmainブランチへのpushを検知して、ビルド、テストを実行します。
3. 2で問題がなければそのままFirebase Hostingへコードをデプロイします。
4. 問題があった場合(テスト/ビルドに失敗)は通知がされ、本番への反映はされません。


# 本編
※ Node.jsがインストールされていること、GitHubが使える事が前提

#### 環境
```
Node v12.20.0
Nuxt v2.14.6
firebase v8.1.1
```
<br>

それでは実際に環境構築していこうと思います！
まず最初にGitHubでリポジトリを作成して、cloneしておきます。

```
$ git clone［作成したリポジトリ名］
$ cd［作成したリポジトリ名］
```
<br>

### Nuxtアプリの作成

次にnuxtアプリを作成します。

```
npx create-nuxt-app [アプリ名]
```


いくつか質問に答えます。

```shell
create-nuxt-app v3.4.0
✨  Generating Nuxt.js project in '[アプリ名]'
? Project name: '[アプリ名]'
? Programming language: JavaScript
? Package manager: Npm
? UI framework: None
? Nuxt.js modules: Axios
? Linting tools: ESLint, Prettier
? Testing framework: Jest
? Rendering mode: Universal (SSR / SSG)
? Deployment target: Static (Static/JAMStack hosting)
? Development tools: jsconfig.json
? Continuous integration: None
? Version control system: None
```

create-nuxt-appが完了したら、生成したファイルをルートディレクトリに移動します。

```shell
# ファイルをルートに移動
mv [アプリ名]/{*,.*} .
# 空になったディレクトリを削除
rm -rf [アプリ名]
```

最後にfirebaseのパッケージをインストールします。
`npm i firebase`


## firebaseのセットアップ


### プロジェクトの作成
https://console.firebase.google.com

firebaseコンソールにてプロジェクトを作成してください。
初めての方はGmailのアカウントが必要になります。
無事作成できると、コンソールページに入れるようになります。

![スクリーンショット 2020-12-11 20.20.40.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/488042/d452e132-6d7f-8f03-42f3-8500d43c27f1.png)


### Cloud Firestoreの設定

次にCloud Firestoreのページへ移動し、データベースを作成します。
今回はテストモードで開始しますが、運用の際にはルールを設定してください。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/488042/0f990a01-cba5-fb49-063c-ea191edef356.png)

データベースが作成されたら、適当なレコードを登録しておきます。
自分はusersコレクションにnameを持ったドキュメントをいくつか作成しました。

![スクリーンショット 2020-12-11 20.25.43.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/488042/43780e41-1c2a-918c-cabb-880ab05b6a0e.png)

これが今回扱うデータになります。

###アプリにFirebaseを追加
自分のアプリ(今回はNuxt)でfirebaseを使うための設定をしていきます。
「プロジェクトを設定」から「ウェブアプリに Firebase を追加」を選択します。

手順が表示されるので、この通りに進めていきます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/488042/cee9ee7e-4c6a-5394-d9f0-c2080f298cf8.png)

　1.「このアプリのFirebase Hostingも設定します」にチェックを入れてください。
　2. モジュールバンドラを使用するので「FirebaseSDKの追加」は飛ばしてください。
　4. `firebase init`でいくつかの質問を答える事になります。以下の様に回答してください。

```shell
? Which Firebase CLI features do you want to set up for this folder?:
　> firestore/hosting
? Please select an option: 
　> Use an existing project
Select a default Firebase project for this directory:
　> [先ほど作成したプロジェクト]
? What file should be used for Firestore Rules?
　> firestore.rules
? What file should be used for Firestore indexes? 
　> firestore.indexes.json
What do you want to use as your public directory? 
　> dist
? Configure as a single-page app (rewrite all urls to /index.html)? 
　> No
? Set up automatic builds and deploys with GitHub? 
　> Yes # GitHubの認証が必要になります
? For which GitHub repository would you like to set up a GitHub workflow? (format: user/repository) 
　> [今回使っているリポジトリ]
? Set up the workflow to run a build script before every deploy? 
　> Yes
? What script should be run before every deploy?
　> npm ci & npm run build
? Set up automatic deployment to your sites live channel when a PR is merged?
　> Yes
? What is the name of the GitHub branch associated with your sites live channel?
  > main
```
後半はCI/CDの設定についてです (後述)
最後に`firebase deploy --only hosting:[アプリ名]`を実行とありますが、これはデプロイ用のコマンドであり、今のままではうまくいきません。後ほど設定をします。

### テストの確認
まずデプロイの前に、実装したコードに不備が無いかテストを実行します。
デフォルトでtestディレクトリに `Logo.spec.js` があると思います。
今回は説明の為にこのファイルのみテストします。

`Logo.vue`がvue instanceであるかのチェックです。
`npm run test` を実行すればテストが実行されます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/488042/9ae8909f-354e-b470-f6bf-7693c8e39df6.png)

`jest`を使ってテストをしています。
`1 passed, 1 total` とあるように、合計1つのテストケースのうち、1つのテストがpass(成功)しました。
コードorテストに不備があるとエラーが起きます。

このようにしてデプロイ(本番反映)前にテストを実行する事でバグを未然に防げます。ただし、テストを実行し忘れてデプロイしてしまう可能性もあるので、CI/CDを使ってデプロイの度に自動テストをする仕組みを作成します。

<br>

### 本番環境へデプロイ

テストが成功した事を確認したら、本番環境へデプロイしていきます。

まずビルド用のコマンド、

```shell
npm run generate
```
を実行します。

すると`dist`配下に静的ファイルが作成されます。
この`dist`の中身が、今回Firebase Hostingによってホスティングされる静的ファイル群です。

ビルドが成功したら、デプロイ用のコマンド、

```
firebase deploy --only hosting
```

を実行してみてください。

完了すると`Hosting URL:[URL]`と最後に表示されるので、アクセスしてみます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/488042/50a3cb56-9d16-2d49-2605-83a7f0634c42.png)

無事アクセスできました！


## Nuxt × firestoreでSSGを試す

それではNuxt側でfirestoreにアクセスし、データを取得/表示してみます。

### firebaseSDKを使う
まずはfirebaseSDKをNuxtで使うために、firebaseを初期化するためのpluginファイルを作成します。

plugins配下にfirebase.jsを作成します。
`firebaseConfig`の部分は自分のものに変えてください。

<details>
<summary>firebaseConfigの取得方法</summary>
コンソールにログインして、
「プロジェクトを設定」→ 「マイアプリ」 →　「Firebase SDK snippet」
と進み、「CDN」を選択するとコードが表示されます。
`firebaseConfig`の部分だけコピーします。
![スクリーンショット 2020-12-11 21.42.23.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/488042/753efe0c-751f-83d1-482b-e403c27b809f.png)
</detail>

<br>

```javascript:plugins/firebase.js
import firebase from 'firebase'

const firebaseConfig = {
  apiKey: 'Your apiKey',
  authDomain: 'Your authDomain',
  projectId: 'Your projectId',
  storageBucket: 'Your　storageBucket',
  messagingSenderId: 'Your messagingSenderId',
  appId: 'Your appId',
}

if (!firebase.apps.length) {
  firebase.initializeApp(firebaseConfig)
}

export default firebase
```
<br>
これ以降はNuxt側で

```javascript
import firebase from '~/plugins/firebase'
```

と書く事で、先ほど作成したfirebaseプロジェクトを利用できるようになります！


### asyncDataとmountedを比較

SSGでレンダリングする場合には、`asyncData`内でAPIへリクエストを送り、レスポンスのPromiseを`{key:value}`形式で返します。
keyにはtemplate内で使いたい名前を、valueにはPromiseを入れます。

実際にindex.vueを書き換えて、SSGとSPAでの挙動の違いを確認してみます。

```vue:index.vue
<template>
  <div class="container">
    <div>
      <h2>Users with asyncData (SSG)</h2>
      <p v-for="(user, i) in asyncUsers" :key="'user-async' + i">
        {{ user.name }}
      </p>

      <h2>Users with mounted</h2>
      <p v-for="(user, i) in mountedUsers" :key="'user-mounted' + i">
        {{ user.name }}
      </p>
    </div>
  </div>
</template>

<script>
import firebase from '~/plugins/firebase'
export default {
  data() {
    return {
      mountedUsers: [],
    }
  },
  async asyncData() {
    const res = await firebase
      .firestore()
      .collection('users')
      .get()
      .then((querySnapshot) => {
        const array = []
        querySnapshot.forEach((doc) => {
          array.push(doc.data())
        })
        return array
      })
      .catch((e) => console.log(e))
    return { asyncUsers: res }
  },
  async mounted() {
    await firebase
      .firestore()
      .collection('users')
      .get()
      .then((querySnapshot) => {
        querySnapshot.forEach((doc) => {
          this.mountedUsers.push(doc.data())
        })
      })
      .catch((e) => console.log(e))
  },
}
</script>

<style>
.container {
  margin: 0 auto;
  min-height: 100vh;
  display: flex;
  justify-content: center;
  align-items: center;
  text-align: center;
}

h2 {
  margin: 16px 5px;
}
p {
  margin: 5px 0px;
}
</style>
```
<br>
`npm run dev`で確認してみます。

`asyncData`はビルド時に実行されていて、DBから取得した値がHTMLに直書きされている状態なので、その都度取得するmountedより表示速度が速い事が分かります。

![test666.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/488042/15fc05dd-7fd8-7430-6c1b-4e156c54384f.gif)



## SSGを利用している部分をデプロイ

今回新しい実装をしたので、再度本番環境へ反映させます。

手順としてはざっくり、

・**テストの実行** (本来なら変更箇所のテストを追加作成) →  `npm run generate`
・**ビルドの実行** → `npm run generate`
・**デプロイの実行** →  `firebase deploy --only hosting`

となると思います。

ただし、
毎回これらの手順を人の手で実行するのは面倒である事はもちろん、テストを実行し忘れたままデプロイしてしまう等、本番環境での不具合にも繋がります。

本番環境への反映をシステム化する為にもCI/CDを導入します。

以上の点を踏まえて、CI/CDを設定して変更内容をデプロイします！

## CI/CDで自動テスト&自動デプロイをする

デプロイの設定は既に完了しています。

```
firebase init
```

を実行した時にいくつか質問に答えたと思います。
この質問の後半での回答がCI/CDの設定になります。

``` shell
? Set up automatic builds and deploys with GitHub? 
　> Yes
? For which GitHub repository would you like to set up a GitHub workflow? (format: user/repository) 
　> [今回使っているリポジトリ] #GitHub Actionsを設定するリポジトリ
? Set up the workflow to run a build script before every deploy? 
　> Yes
? What script should be run before every deploy?
　> npm ci & npm run build #デプロイ時に実行するコマンドを指定
? Set up automatic deployment to your sites live channel when a PR is merged?
　> Yes # マージされた時も自動デプロイを有効にするかどうか
? What is the name of the GitHub branch associated with your sites live channel?
  > main #ここで指定したブランチへpushした時にGitHub Actionsが走ります
```

mainブランチへのpushをGitHub Actionsが検知し、指定したJobを実行してくれるようになります。

ビルド、テストの設定についてはもう少し手を加える必要があります。

では、実行するJobを編集していきます。
CI/CDの定義はymlファイルに記述します。

既存の`npm ci & npm run build`を削除して、以下のように編集します。

```diff:.github/workflows/firebase-hosting-merge.yml
-      - run: npm ci & npm run build
+      - name: Cache dependencies
+        uses: actions/cache@v1
+        with:
+          path: ~/.npm
+          key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
+          restore-keys: |
+            ${{ runner.os }}-node-
+
+      - name: Install dependencies
+        run: npm ci
+        
+      - name: Run test
+        run: npm test
+        
+      - run: npm run generate
```

それでは、変更内容を`main`ブラントへpushしてみましょう！

pushしたらGitHub Actionsをみてみます。
リポジトリの`Actions`タブにワークフローが表示されていると思います。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/488042/7abc3350-b0f0-2246-7d1d-1554fff79321.png)
<br>
詳細をみてみると、先ほど定義したjobが実行されている事が分かります。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/488042/6542df15-bb7b-e6df-91f8-20c636f77f6a.png)

<br>
ステータスが緑のチェックになっていれば成功です！
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/488042/cc8164ac-6de3-2640-d62f-5bee75e5bce1.png)
これ以降はmainブランチに変更内容をpushするだけで、本番環境へ反映されるようになります。

<br>
ここでテストが通らなければデプロイに失敗します。

例えば以下のように変更すればテストが通らず、デプロイにも失敗します。

```diff
- expect(wrapper.vm).toBeTruthy()
+ expect(wrapper.vm).toBeFalsy()
```

# まとめ

今回扱った技術については理解が曖昧な部分があったので、自分としてもまとめられて良かったです！
何よりSSGが使いやすくて好きになりました！
ポートフォリオサイトに最適だと思ったので、今回の構成で作り直してみようと思います。
SSR/ISRも試してみよう。

---
22卒の学生エンジニアです。
Twitterもやってるのでフォローお願いします！
https://twitter.com/1keiuu