Next.jsとFirebaseで更新の手間がかからないポートフォリオサイトを作ってみた

## はじめに
ソフトウェアエンジニアとして就職して、2年半ほど経ちました。開発業務で得た知見を生かして、プライベートで簡単なツール開発、勉強会でのLT、Qiitaやブログへの記事執筆など様々な取り組みを行ってきたのですが、成果物が複数に分散する問題を抱えていました。

成果物を1箇所に集約することができれば、自分の得意なことや興味のあることが可視化できると考え、今回ポートフォリオサイトを作成したので、技術的な話をまとめたいと思います。

## 制作物
* ポートフォリオサイトURL
    * [Kentaro Matsushita - @kentaro-m](https://kentarom.com/)
* リポジトリ
    * [kentaro-m/portfolio: A my portfolio site that fetch data and display from various services such as GitHub, Qiita, SpeakerDeck.](https://github.com/kentaro-m/portfolio)
* 制作期間
    * 8日間
* 運用費用
    * 0円/月 (Firebase無料枠)

README.mdにビルドとデプロイ方法が記載しており、手順に従うことで同様のポートフォリオサイトが作れます。ご興味があれば、お試しください。

機能は以下の通りです。

* 簡単なプロフィール表示
* スキル表示
* Qiita記事表示
* GitHubリポジトリ表示
* SpeakerDeckスライド表示

### スキル表示
<img width="1276" alt="Screen Shot 2018-10-14 at 16.16.08.png" src="https://qiita-image-store.s3.amazonaws.com/0/38478/301753ad-d354-ab62-3386-90061a662c2b.png">

### Qiita記事表示
<img width="1275" alt="Screen Shot 2018-10-14 at 16.16.51.png" src="https://qiita-image-store.s3.amazonaws.com/0/38478/66144329-6576-eeaa-306f-17d29101a83d.png">

### SpeakerDeckスライド表示
<img width="1279" alt="Screen Shot 2018-10-14 at 16.17.41.png" src="https://qiita-image-store.s3.amazonaws.com/0/38478/4d51b002-4783-c521-26ac-aaff19d88a14.png">


## コンセプト
ポートフォリオサイトを開発するにあたり、以下の2点の実現を目指しました。

* 成果物を一覧性の高い形式で表示できる
* 更新の手間がかからない

### 成果物を一覧性の高い形式で表示する
現状ではQiitaやはてなブログ、GitHub、SpeakerDeckなど複数サイトに成果物が分散しているので、ポートフォリオサイトから参照できることを目指しました。

### 更新の手間がかからない
新しい成果物を作成する度にポートフォリオを更新するのは面倒だと考えたので、APIを利用して、情報はなるべく外部から取得して、自動的に反映させることを意識しました。

また、ポートフォリオサイト側の更新が必要な項目もマークダウンで編集できるようにして、手間なく運用できるようにしました。

## 使用技術
最近気になっている技術を採用しました。プライベート開発の醍醐味ですね。

* Next.js
    * Reactでサーバーサイドレンダリングするアプリケーションを構築するためのフレームワーク
* Firebase Hosting
    * 静的コンテンツをホスティングするサービス
* Cloud Functions for Firebase
    * イベントドリブンで関数を実行するサービス

## アーキテクチャ
<img width="1920" alt="portfolio_architecture.png" src="https://qiita-image-store.s3.amazonaws.com/0/38478/f376cb9e-7882-dbe5-d59f-12e881ce3205.png">

ユーザーのポートフォリオサイトへのリクエストをトリガーに、Cloud Functions for Firebaseが起動し、外部APIへの情報取得を行い、Next.jsがサーバーサイドレンダリングを行い、サイトを表示します。

実装にあたっては以下のソースコード及び記事を参考にしました。

* [next.js/examples/with-firebase-hosting at canary · zeit/next.js](https://github.com/zeit/next.js/tree/canary/examples/with-firebase-hosting)
* [Next.js on Cloud Functions for Firebase with Firebase Hosting](https://codeburst.io/next-js-on-cloud-functions-for-firebase-with-firebase-hosting-7911465298f2)

## 工夫した点

### HTML5UP!のテンプレートをReactコンポーネント化
「デザインはできないけど、綺麗なサイトを作りたい」を実現するために既存のHTMLテンプレートを利用。[HTML5 UP!](https://html5up.net/)ではレスポンシブデザインを施したHTML5テンプレートをCreative Commonsライセンスの下で提供しています。

作成したポートフォリオは[Strata](https://html5up.net/strata)をベースにしており、ヘッダーやフッター、セクション等のパーツのReactコンポーネントを順に作成しました。

また、テンプレートに含まれるsassファイルでデザインを適用するためにNext.jsのSassプラグインを設定しました。

```
$ yarn add node-sass @zeit/next-sass
```

```js:next.config.js
const withSass = require('@zeit/next-sass')

module.exports = withSass()
```

```js:src/app/pages/index.js
import Layout from '../components/Layout'

// sassファイルの読み込み
import '../styles/main.scss'

export default () => (
  <Layout/>
)
```

```jsx:src/app/pages/_document.js
import Document, { Head, Main, NextScript } from 'next/document'

export default class MyDocument extends Document {
  render() {
    return (
      <html>
        <Head>
          { /*
            Sassをビルドして出力されたcssファイルを読み込み
          /* }
          <link rel="stylesheet" href="/_next/static/style.css" />
          <link rel="stylesheet" href="/static/css/font-awesome.min.css" />
          <script src="/static/js/jquery.min.js"/>
          <script src="/static/js/jquery.poptrox.min.js"/>
          <script src="/static/js/browser.min.js"/>
          <script src="/static/js/breakpoints.min.js"/>
          <script src="/static/js/util.js"/>
          <script src="/static/js/main.js"/>
        </Head>
        <body className="is-preload">
          <Main/>
          <NextScript/>
        </body>
      </html>
    )
  }
}
```

サンプルコード：[next.js/examples/with-next-sass at canary · zeit/next.js](https://github.com/zeit/next.js/tree/canary/examples/with-next-sass)

### マークダウンを利用した編集
サイトの一部をマークダウンで編集可能にしています。GitHubではWeb UI上でマークダウンの編集ができるので、ちょっとした更新はブラウザだけで完結します。

<img width="1278" alt="Screen_Shot_2018-10-14_at_15_02_36.png" src="https://qiita-image-store.s3.amazonaws.com/0/38478/36140a97-2635-e25a-3f24-8d0ea5e0dd69.png">

実際に読み込んでいるマークダウンファイル

```md:src/app/content/site_description.md
### Kentaro Matsushita
I'm working as a software engineer in Kanazawa, Japan.
```

マークダウンの利用は`@zeit/next-mdx`で実現しています。

```
$ yarn add @zeit/next-mdx
```

`next.config.js`にプラグインの設定を記述します。

```js:src/app/next/config.js
const withMDX = require('@zeit/next-mdx')({
  extension: /\.md?$/
})

module.exports = withMDX()
```

他のjsファイルと同様にimportで読み込むと表示可能です。

```js:src/app/components/Header.js
import React from 'react'

// マークダウンファイルを読み込み
import SiteDescription from '../content/site_description.md'

const Header = props => (
  <SiteDescription/>
)

export default Header
```

サンプルコード：[next.js/examples/with-markdown at canary · zeit/next.js](https://github.com/zeit/next.js/tree/canary/examples/with-markdown)

### 外部APIから成果物の情報を取得

#### 外部APIのコール
`getInitialProps`と呼ばれるメソッドで外部APIをコールします。このメソッドは子コンポーネントでは動作しないので、親コンポーネントでデータを取得して、propsで子コンポーネントにデータを渡します。

```jsx:src/app/pages/index.js
import React, { Component } from 'react'
import config from '../lib/config'
import Layout from '../components/Layout'
import { getRepos, getSlides, getQiitaItems, getArticles } from '../lib/utils'
import '../styles/main.scss'

export default class Index extends Component {

  // データを取得
  static async getInitialProps() {
    const repos = await getRepos(config.user.github, config.github.topic)
    const slides = await getSlides(
      config.user.speaker_deck,
      config.speaker_deck.slides_count
    )
    const qiitaItems = await getQiitaItems(config.qiita.item_count)
    const articles = await getArticles(
      config.blog.feed_url,
      config.blog.article_count
    )
    return {
      repos: repos,
      slides: slides,
      qiitaItems: qiitaItems,
      articles: articles
    }
  }

  render() {
    const { repos, slides, qiitaItems, articles } = this.props

    // 子コンポーネントに取得したデータを渡す
    return (
      <Layout
        repos={repos}
        slides={slides}
        qiitaItems={qiitaItems}
        articles={articles}
      />
    )
  }
}
```

サンプルコード：[next.js/examples/data-fetch at canary · zeit/next.js](https://github.com/zeit/next.js/tree/canary/examples/data-fetch)

#### GitHubリポジトリの取得
[Search | GitHub Developer Guide](https://developer.github.com/v3/search/#search-repositories)

GitHubリポジトリの取得はSearch Repositories APIを利用しています。GitHubのリポジトリにトピックと呼ばれるタグ付け機能が提供されており、ポートフォリオサイトに表示したいリポジトリには「my-portfolio」というトピックを付与しています。

Search Repositories APIをコールするときは、自分のユーザー名とmy-portfolioトピックでフィルタリングをかけて、ポートフォリオに表示するリポジトリを制御しています。

<img width="1011" alt="Screen Shot 2018-10-14 at 1.41.48.png" src="https://qiita-image-store.s3.amazonaws.com/0/38478/b42e69ea-08ea-4c30-0e7b-8570f0b43f62.png">

#### Qiita記事の取得
Qiitaの記事取得はQiita APIを利用しています。認証中のユーザーの投稿をした記事を取得するAPIで件数を指定して、取得しています。

[Qiita API v2ドキュメント - Qiita:Developer](https://qiita.com/api/v2/docs#%E6%8A%95%E7%A8%BF)

#### SpeakerDeckスライドの取得 / はてなブログの記事取得
SpeakerDeckのスライドの取得はFeedと[YQL](https://developer.yahoo.com/yql/)というサービスを組み合わせて実現しています。SpeakerDeckはAPIが提供されていないので、代替方法を調査した結果、この方法に辿り着きました。

YQLはYahoo!が提供しているWebサービスでXMLをJSONに変換する機能があります。今回はSpeakerDeckのFeedをパースして、JSONで情報取得し、ポートフォリオサイトで表示しています。

YQLの利用は専用構文をパラメータに与えて、エンドポイントにリクエストするだけで非常に簡単です。動作確認は[YQL - Yahoo Developer Network](https://developer.yahoo.com/yql/)のサイトから可能です。

<img width="1278" alt="Screen Shot 2018-10-14 at 2.02.53.png" src="https://qiita-image-store.s3.amazonaws.com/0/38478/a3eecd71-9b78-504c-d097-2eeb824451a4.png">

スライドを2件取得するYQLのクエリ

```sql
select * from feed where url = 'https://speakerdeck.com/kentarom.atom' limit 2
```

結果は以下のような、JSONで返却されます。

```json
{
 "query": {
  "count": 2,
  "created": "2018-10-13T17:08:22Z",
  "lang": "ja",
  "results": {
   "entry": [
    {
     "id": "tag:speakerdeck.com,2005:Talk/467924",
     "published": "2018-10-04T02:00:05-04:00",
     "updated": "2018-10-04T10:36:20-04:00",
     "link": {
      "href": "https://speakerdeck.com/kentarom/make-a-portfolio-site-with-firebase",
      "rel": "alternate",
      "type": "text/html"
     },
     "title": "Firebaseで ポートフォリオサイトを作ってみた / Make a portfolio site with Firebase",
     "content": {
      "type": "html",
      "content": "<img src=\"https://speakerd.s3.amazonaws.com/presentations/cd5930861b2a43e0aa93951a6be09068/preview_slide_0.jpg?467668\" alt=\"Preview slide 0\" /><div><p>社内LT</p></div>"
     },
     "author": {
      "name": "Kentaro  Matsushita"
     }
    },
    {
     "id": "tag:speakerdeck.com,2005:Talk/459561",
     "published": "2018-08-17T13:26:53-04:00",
     "updated": "2018-08-18T00:14:27-04:00",
     "link": {
      "href": "https://speakerdeck.com/kentarom/lets-challenge-automating-your-github-workflow-with-probot",
      "rel": "alternate",
      "type": "text/html"
     },
     "title": "ProbotでGitHubワークフローの 自動化に挑戦しよう / Let&#x27;s challenge automating your GitHub workflow with Probot",
     "content": {
      "type": "html",
      "content": "<img src=\"https://speakerd.s3.amazonaws.com/presentations/c2fef73a15c34b239e11ad74d52d067e/preview_slide_0.jpg?459312\" alt=\"Preview slide 0\" /><div><p>2018/08/18 kanazawa.rb meetup #72 Lightning Talk</p></div>"
     },
     "author": {
      "name": "Kentaro  Matsushita"
     }
    }
   ]
  }
 }
}
```

はてなブログの記事取得も同様の方法で行っています。

### APIアクセストークンの管理
GitHubやQiitaからデータ取得するためのAPIのアクセストークンの管理はローカル開発環境とCI環境のデプロイ実行環境によって、別の方法を使用してます。アクセストークンを安全に管理するために使い分けています。

#### dotenvの利用
ローカル開発環境ではアプリケーション起動やデプロイでdotenvを使用しています。Next.jsプラグインである`with-dotenv`では、`.env`ファイルにアクセストークンを記載することで、ソースコード上で環境変数としてアクセス可能です。

```yml:.env
GITHUB_API_TOKEN=
QIITA_API_TOKEN=
```

```js
const githubToken = process.env.GITHUB_API_TOKEN
const qiitaToken = process.env.QIITA_API_TOKEN
```

[next.js/examples/with-dotenv at canary · zeit/next.js](https://github.com/zeit/next.js/tree/canary/examples/with-dotenv)

#### Cloud Functions for Firebaseの環境変数設定の利用
CI環境ではCloud Functions for Firebaseの環境変数設定を利用しています。`.env`ファイルをGitHubのリモートリポジトリにコミットするのは望ましくないので、代替方法です。

Circle CI上であらかじめ環境変数を設定し、Cloud Functionsをデプロイするときに環境変数を読み込んでセットしています。

環境変数名は大文字が利用できないので、名前の差異をソースコードで吸収して、どちらでも動作するようにしています。

* Circle CIでの環境変数設定
    * [Using Environment Variables - CircleCI](https://circleci.com/docs/2.0/env-vars/)
* Cloud Functions for Firebaseでの環境変数利用方法
    * [環境の構成  |  Firebase](https://firebase.google.com/docs/functions/config-env?hl=ja)

```
# デプロイ時のCloud Functionsへの環境変数設定
firebase functions:config:set token.github="$GITHUB_API_TOKEN" token.qiita="$QIITA_API_TOKEN"
```

### サイト設定を集約
サイト設定は設定ファイルを用意し、そちらで一元管理しています。各種SNS情報を変更しても、1箇所変えれば、変更が全適用されます。

```js:src/app/lib/config.js

module.exports = {
  site: {
    url: prod ? 'https://kentarom.com' : 'http://localhost:3000',
    title: 'Kentaro Matsushita - @kentaro-m',
    description: '',
    image: 'ogp_image.png'
  },
  user: {
    name: 'Kentaro Matsushita',
    github: 'kentaro-m',
    qiita: 'kentaro-m',
    speaker_deck: 'kentarom',
    twitter: '_kentaro_m',
    facebook: 'kentaro.m9',
    linkedin: 'kentarom'
  },
  skills: [
    { type: 'JavaScript (ES2015)', level: 80 },
    { type: 'Node.js', level: 70 },
    { type: 'AWS', level: 70 },
    { type: 'React.js', level: 60 },
    { type: 'HTML', level: 60 },
    { type: 'CSS', level: 60 },
    { type: 'Java', level: 50 },
    { type: 'Go', level: 40 },
    { type: 'TypeScript', level: 40 }
  ],
  github: {
    topic: 'my-portfolio'
  },
  qiita: {
    item_count: '5'
  },
  blog: {
    url: 'https://kentarom.hatenablog.com/',
    feed_url: 'https://kentarom.hatenablog.com/feed',
    article_count: '5'
  },
  speaker_deck: {
    slides_count: '6'
  }
}
```

### CDパイプラインを整備
GitHubとCircle CIとFirebaseを連携させて、リモートのmasterブランチにコミットすると、デプロイされる環境を整備しました。

```yml:.circleci/config.yml
version: 2

jobs:
  build:
    docker:
      - image: circleci/node:8.12.0
    working_directory: ~/workspace

    steps:
      - checkout
      - restore_cache:
          key: yarn-{{ .Branch }}-{{ checksum "yarn.lock" }}
      - run:
          name: install dependencies
          command: yarn install
      - run:
          # NOTE: Added a command to avoid build failures
          # Node Sass could not find a binding for your current environment: Linux 64-bit with Node.js 8.x
          name: rebuild node-sass
          command: yarn add node-sass --force
      # 以前ビルドした結果を削除 (デプロイごとに綺麗にする)
      - run:
          name: clean dist dir
          command: yarn clean
      # ビルド実行
      - run:
          name: build
          command: yarn predeploy
      - save_cache:
          key: yarn-{{ .Branch }}-{{ checksum "yarn.lock" }}
          paths:
            - ~/workspace/node_modules
      # ビルドした結果をdeployジョブでも利用するために一時保存
      - persist_to_workspace:
          root: .
          paths:
            - .

  deploy:
    docker:
      - image: circleci/node:8.12.0
    working_directory: ~/workspace

    steps:
      # buildジョブで一時保存されたビルド結果を読み込む
      - attach_workspace:
          at: .
      - run:
          # 環境変数の設定
          name: set environment variables
          command: yarn set-env
      - run:
          # Firebaseへデプロイ
          name: deploy to firebase
          command: yarn deploy:ci

workflows:
  version: 2
  build_and_deploy:
    jobs:
      - build
      - deploy:
          requires:
            - build
          # masterブランチのコミットのみデプロイ
          filters:
            branches:
              only: master
```

Firebase CLIをCI環境で利用するためには`firebase login:ci`コマンドで認証トークンを取得し、CIの環境変数として設定し、デプロイ実行時に`token`オプションで指定する必要があります。

```
$ firebase deploy --token="$FIREBASE_TOKEN"
```

`package.json`のnpm-scriptsにビルド・デプロイに関するスクリプトを定義し、Circle CIの設定ファイルでは定義したものを呼び出すだけにしています。

```json
{
  "name": "portfolio",
  ...
  "scripts": {
    "dev": "next src/app",
    "build": "next build",
    "start": "next start",
    "lint": "eslint src/app/**/*.js",
    "preserve": "yarn build-public && yarn build-funcs && yarn build-app && yarn copy-deps && yarn install-deps",
    "serve": "NODE_ENV=production firebase serve",
    "predeploy": "yarn build-public && yarn build-funcs && yarn build-app && yarn copy-deps",
    "deploy": "firebase deploy",
    "deploy:ci": "firebase deploy --token=\"$FIREBASE_TOKEN\"",
    "set-env": "firebase functions:config:set token.github=\"$GITHUB_API_TOKEN\" token.qiita=\"$QIITA_API_TOKEN\" token.sentry=\"$SENTRY_PUBLIC_DSN\"",
    "copy-deps": "cpx \"*{package.json,yarn.lock}\" \"dist/functions\"",
    "install-deps": "cd \"dist/functions\" && yarn",
    "build-public": "cpx \"src/public/**/*.*\" \"dist/public\" -C && cpx \"src/app/static/**/*.*\" \"dist/public/static\"",
    "build-funcs": "cpx \"src/functions/**/*.*\" \"dist/functions\"",
    "build-app": "NODE_ENV=production next build \"src/app\"",
    "clean": "rimraf \"dist/functions/**\" && rimraf \"dist/public\"",
    "firebase-login": "firebase login"
  },
  ...
}
```

## 改善箇所

### ページ表示速度が遅い
現状ではページの表示速度が遅いので改善が必要です。

<img width="1080" alt="Screen Shot 2018-10-14 at 16.30.28.png" src="https://qiita-image-store.s3.amazonaws.com/0/38478/c0de6e87-a3c7-19fc-b68a-3823c6818dd0.png">

遅い理由は以下が考えられます。

* Cloud Functions for Firebaseのデプロイリージョンが`us-central1` (アイオワ)
    * `asia-northeast1` (東京) に変更する
* リクエストの度に外部APIをコールしている
    * 結果は頻繁に変わらないので、API実行結果をキャッシュする
* Firebase Hostingのキャッシュ管理を利用できていない
    * [Cloud Functions による動的コンテンツの配信  |  Firebase](https://firebase.google.com/docs/hosting/functions?hl=ja#manage_cache_behavior)

## 機能追加

### 職務経歴を追加したい
経験したプロジェクトをタイムライン形式で表示できたら良いと考えています。

### 問い合わせフォームを追加したい
他の方のポートフォリオサイトを見ると、問い合わせフォームがあるところが多いので追加したいです。Netlify Formsのように簡単にフォームを導入できるサービスを探しています。

[Forms | Netlify](https://www.netlify.com/docs/form-handling/)

## さいごに
FirebaseとNext.js共に触れるのは初めてだったのですが、ドキュメントとサンプルコードが多いおかげでサクッと作れました。

今回、ポートフォリオサイトを実際に作ってみて、自分の成果物を1ページにまとめることができて、非常に満足しています。

ツイートやブログ等の発信も大切ですが、フロー型なので時間の経過とともに忘れ去られてしまいます。1ソフトウェアエンジニアとしての得意分野や興味のある分野をアピールするためには成果物を積み上げていくストック型のポートフォリオサイトの作成は有効だと感じました。