会社のテックブログを勝手に爆速にしてみた

こんにちは GMOグローバルサイン・ホールディングス株式会社 GSP事業部 PlayCanvas推進室室長の津田@utautattaroです。
普段はPlayCanvasの事業責任者として普及促進活動をしたりWebGLのサンプルを作ったりしています。
会社では珍しく(?)フロント系のエンジニアなので、今回はフロントエンドな話題を取り上げてみました。

このテックブログは遅すぎる

ついに会社のテックブログができたという話を聞いて見に行ったところ、いろいろ気になってしまったので、勝手に爆速にしたエントリです。今回はWordPress製のこのテックブログをゲリラ的にJAMStack実装して爆速にしたのでその結果と過程を残したいと思います。

現在の実装とパフォーマンス

Wappalyzerで確認したらこのような実装になっていました。

ふむふむ、まあ会社としてPleskを結構押し出しているし、WordPressなら拡張性も高いしいいと思います。
でもこれじゃパフォーマンスどうなんだろうと思ってLighthouseをかけてみました。

ふむ、微妙です。これは会社の技術をアピールするブログとしていかがな数値でしょう。
テックブログたるもの、Webパフォーマンスも完璧を目指すべき!ということで最近フロントエンド界隈で流行ってきているJAMStackアーキテクチャで勝手に作り直して計測比較してみたいと思います。

JAMStackとは

JAMstackとは2016年ごろにNetrifyの創設者Matt Biilmann氏が提唱した新しいフロントエンドスタックです。 リクエストを受けてからサーバー上で処理を行いレスポンスを返す従来のWebサイトの配信方法とは異なり、JAMstackでは事前にビルドされたHTMLをCDN上で配信します。[1]

旧来のWebアプリはこのようにWebページにアクセス後DBへデータを取得しに行っていました。

それがJAMStackだと以下のようになります。

エンドユーザー(※下列)はデプロイされた静的データを返却するWebサーバーにアクセスするだけなので爆速です。またDBとエンドユーザーの経路がつながっていないため、セキュリティも強固です。

ただこれだとDBが更新されてもWebサイトが古いままなのでエンドユーザーは古いデータにアクセスしてしまう恐れがあります。
ですがJAMStackアーキテクチャでは、DBが更新されるタイミングで都度デプロイをするため、Webサーバーには最新の状態を保持し続けることができるのです。[2]

つまるところユーザーが見るコンテンツは従来のようにクライアントサーバー間でアクセス時にやり取りするのではなく、事前にCI/CDでビルドして静的にホストしておこうぜという感じです。静的サイトとしてホストされるのでパフォーマンス的にもSEO的にもセキュリティ的にもばっちりとのこと。CI/CDが当たり前になってきたからこそ実現可能なアーキテクチャです。

JAMStackを構成するうえで必要な要素

JAMStackでは、レガシーなWebサイトと違い、継続的にCI/CDを行いデプロイを繰り返すため、以下の要素がそれぞれ必要となります。

名称 サービス例
Static Site Generator next.js, nuxt.js, Gatsby, Gridsomeなどが有名
CI/CD CircleCI, Github Actionsなど
静的ホスティング 共用サーバー、無料ではGithub pages, Netlifyなど
APIs headless CMSではContentful, microCMSなど。WordPress REST APIでもOK

今回の成果物

ということで、まず結論から、今回はこのテックブログのJAMStack版コピーブログを作成しました。
https://gmocloud-adp.github.io/tech.gmogshd.com/

機能

今回はJAMStackで移行可能な範囲のみを再構築してコピーを作成しました。

名称 パス
トップページ /
トップページ ページネーション /page/:pagenumber
検索 /?s=[search word]
記事ページ /:id
カテゴリーページ /category/:id

また、以下の機能は再現していません

名称 理由
コメント 本家ブログにまだコメントが登録されていなかったため
閲覧数表示 WP REST APIで取得できなかったため
タグ /tag/:id 本家ではタグ機能は動いていたが検索機能と混同されていたため
アーカイブ /:year/:month 実装されているがブログ内に導線が存在しないため

JAMStack版の実装とパフォーマンス

今回は先ほど挙げた必要な要素としてそれぞれ

名称 選定サービス
Static Site Generator nuxt.js
CI/CD Github Actions
静的ホスティング Github pages
APIs WordPress Rest API v2

を選定しました。

選定基準

WordPressからのJAMStack化を実現するために、まずWordPress側でWP REST APIに対応してくれていないと話になりません。
試しにWP REST APIのエンドポイントである(https://tech.gmogshd.com/wp-json/wp/v2/posts)[https://tech.gmogshd.com/wp-json/wp/v2/posts] へアクセスしたところjsonが返ってきたので、実装を開始しました。

Static Site Generator

今回はパフォーマンスチューニングが目的だったので使い慣れたnuxt.jsを選びました。GatsbyやGridsomeを利用するとdata-sourceとしてWordPressが選べるため、ただWordPress -> JAMStack化だけしたい方はそちらの方が手軽です。

静的ホスティング

手軽さ重視で今回はGithub Pagesを選定しました。またPerformanceの項目ではキャッシュ周りやサーバー周りの数値が割と細かくチェックされるため、一度素の状態でGithub Pagesをlighthouseで計測した際にサーバー周りの項目が指摘されていなかったのも決め手です。

CI/CD

今回はGithub pagesにデプロイすることが決まっているためCI/CDは自ずとGithub Actionsになりました。

パフォーマンス

lighthouseはこのようになりました。圧倒的です。

記事ページはこんな感じです。

Performanceのスコアは環境によって左右されるのですが、これだけは満点行きませんでした。確認したところWordPress側で画像をWebp対応してくれれば行けそうです。
記事ページのAccessibilityはaタグの色が背景色の白とコントラスト比が少ないようで怒られました。

実装

JAMStackサイトの作り方は、基本的に通常のStatic Site Generatorを利用した開発手法と大きく変わりません。

リポジトリの作成

今回はGithubにべったり依存した形で構築するので、まずリポジトリを作ります。
リポジトリは gmocloud-adp/tech.gmogshd.com としました。

create-nuxt-app

できたらcreate-nuxt-appしていきます。このような設定でnuxt.jsアプリを作成しました。

# npx create-nuxt-app tech.gmogshd.com
create-nuxt-app v3.4.0
✨  Generating Nuxt.js project in tech.gmogshd.com
? Project name: tech.gmogshd.com
? Programming language: JavaScript
? Package manager: Npm
? UI framework: Bootstrap Vue
? Nuxt.js modules: Axios
? Linting tools: (Press <space> to select, <a> to toggle all, <i> to invert selection)
? Testing framework: None
? Rendering mode: Universal (SSR / SSG)
? Deployment target: Static (Static/JAMStack hosting)
? Development tools: jsconfig.json (Recommended for VS Code if you're not using typescript)
? What is your GitHub username? utautattaro
? Version control system: Git

個人的にBootStrapが好きなのでBootStrapを選択しています。
WP REST APIを参照する際にaxiosが必要なのでこの場でインストールします。Rendering mode, Deploy targetはそれぞれSSR、Staticを選択します。

アプリケーションができたらgit initしておきましょう。

git init
git add .
git commit -m "first commit"
git remote add origin https://github.com/gmocloud-adp/tech.gmogshd.com.git
git push -u origin master

ルーティング

今回JAMStackで実装するページのルーティングは以下の通りとなります。

名称 パス
トップページ /
記事ページ /:id
トップページ ページネーション /page/:pagenumber
カテゴリーページ /category/:id

これをnuxt.jsのpagesディレクトリで以下のように再現しました。

> tree /f
pages
│  index.vue
│  _article.vue
│
├─category
│      _page.vue
│
└─page
        _page.vue

nuxt.config.jsではこのように動的ルーティングを生成しています。

nuxt.config.js

generate: {
    async routes() {
        const generates = [];
        let categoryarray = [];

        /*/postsではカテゴリーはIDで入ってくるため先にカテゴリー一覧を取得する*/
        await axios.get("https://tech.gmogshd.com/wp-json/wp/v2/categories")
        .then((res)=>
        {
            res.data.map(c => 
            {
                let category = {
                    "name" : c.name,
                    "slug" : c.slug,
                    "id" : c.id,
                    "articles" : []
                }

                categoryarray.push(category);
            })
        })

        /*全ページ取得*/
        await axios.get('https://tech.gmogshd.com/wp-json/wp/v2/posts?_embed&per_page=100')
        .then((res) => 
        {
            res.data.map((post,index) => 
            {
                /*ひとつ前、一つ後の記事を取得*/
                let nextArticle = "";
                if(res.data[index - 1]) nextArticle = res.data[index - 1]; 
                let previousArticle = "";
                if(res.data[index + 1]) previousArticle = res.data[index + 1];

                for(let i = 0;i<categoryarray.length;i++){
                    for(let l = 0;l<post.categories.length;l++){
                        if(post.categories[l] == categoryarray[i].id){
                            if(post["categoryobj"]){
                                post["categoryobj"].push(categoryarray[i]);
                            }else{
                                post["categoryobj"] = [categoryarray[i]];
                            }
                        }
                    }
                }

                for(let i = 0;i<categoryarray.length;i++){
                    for(let l = 0;l<post.categories.length;l++){
                        if(post.categories[l] == categoryarray[i].id){
                            let eyecatchimgurl = "https://tech.gmogshd.com/wp-content/uploads/2020/10/eyecatch-2.png";
                            if(post._embedded["wp:featuredmedia"]){
                                eyecatchimgurl = post._embedded["wp:featuredmedia"][0].media_details.sizes.full.source_url;
                            }
                            categoryarray[i].articles.push({
                                "date" : post.date.slice(0,10),
                                "modified" : post.modified.slice(0,10),
                                "slug" : post.slug.replace(/%.*/g,""),//日本語カノニカルURLは除外する
                                "title" : post.title.rendered,
                                "eyecatchimgurl" : eyecatchimgurl,
                                "content" : post.content,
                                "description":post.excerpt.rendered,
                                "categoryobj":post.categoryobj
                            });
                        }
                    }
                }

                let obj = {
                    route: '/' + post.slug.replace(/%.*/g,""),
                    payload:{post : post ,next:nextArticle,previous:previousArticle}
                }
                /*10記事ごとに2ページ以降を入れていく*/
                if(index%10==0){
                    let pagenumber = parseInt(index/10 + 1,10);

                    let pagepath = "/page/"+pagenumber;
                    let pages = {
                        route:pagepath,
                        payload : {posts : res.data.slice(index,index+9),mine : pagenumber, numofpage : parseInt(res.data.length / 10 + 1,10)}
                    }
                    generates.push(pages);
                }

                generates.push(obj);
            })
        })

        /*カテゴリページ作成*/
        for(let i = 0;i<categoryarray.length;i++){
            let obj = {
                route : '/category/' + categoryarray[i].slug,
                payload : categoryarray[i]
            }
            generates.push(obj);
        }
        return generates;
    }
},

表示部分

トップページ

async asyncData({ $axios }) {}でビルド時にすべての記事を取得して表示するように実装しました。
本家が10記事ごとにページネーションをかましていたので、同じように表示していますが、実際には全記事検索用に事前に取得しています。

検索機能

検索は本家と合わせてsクエリに検索結果を入れたら検索画面が出るようにしました。
WordPressではsearch=クエリを記事取得のエンドポイントに追加すれば検索となるのでシンプルにそのまま実装しています。

index.vue

mounted : function(){
  let self = this;
  if(this.$router.currentRoute.query.s){
    this.isSearch = true;
    self.posts = [];
    let endpoint = "https://tech.gmogshd.com/wp-json/wp/v2/posts?_embed&per_page=100&search=" + this.$router.currentRoute.query.s;
    axios.get(endpoint).then((res)=>{
      self.articleCount = res.data.length;
      console.log(res.data);
      if(res.data.length == 0){
        return 0;
      }
      res.data.map((post,index) => {
        let obj;
        for(let i = 0;i<self.allposts.length;i++){
          if(post.title.rendered == self.allposts[i].title){
            obj = {
              "title": self.allposts[i].title,
              "description" : self.allposts[i].description,
              "date":self.allposts[i].date,
              "modified":self.allposts[i].modified,
              "category":"hoge",
              "eyecatchimgurl":self.allposts[i].eyecatchimgurl,
              "slug":self.allposts[i].slug,
              "categoryobj":self.allposts[i].categoryobj
            }
          }
        }
        self.posts.push(obj);
      });
    });
  }
},

目次機能

本家にもあったので実装しました。
WP REST APIで得られる本文はHTMLで書かれていたのですが<h1>から<h5>のタグはすべてidが振られていなかったため、idを振ってリスト化し、vue-scrolltoでスムーススクロールするような処理を追加しました。

_article.vue

async asyncData ({params, error, payload }) {
    // payloadでデータを受け取った場合
    if(payload){
        let content  = payload.post.content.rendered;
        let reg = /<h[1-5].*?<\/h[1-5]>/g; /*ヘッダータグの正規表現*/
        let tables = payload.post.content.rendered.match(reg);
        let t = [];
        if(tables){
            for(let i=0;i<tables.length;i++){
                let id = Math.random().toString(32).substring(2); //ランダム文字列を生成
                let obj = {
                    "tag" : tables[i].slice(1,3),
                    "id" : id,
                    "text" : tables[i].match(/>.*</)[0].slice(1).slice(0,-1),
                }
                let replace = "<"+obj.tag+" id='"+obj.id+"'>"+obj.text+"</"+obj.tag+">";//ヘッダーを全て入れ替え
                content = content.replace(tables[i],replace);
                t.push(obj);
            }
        }

        /*目次データを返却*/
        return {
            tableofcontent : t
        }
    }
}

vue-scrolltonpm installでインストールした後pluginに記述します

/plugins/vue-scrollto.js

import Vue from 'vue'
import VueScrollTo from 'vue-scrollto'

Vue.use(VueScrollTo, {
 duration: 700,
 easing: [0, 0, 0.1, 1],
 offset: -100,
})

nuxt.config.jsでの読み込みも忘れずに。

plugins: [
    '~plugins/vue-scrollto'
],

最後に目次を表示してあげれば完璧です。

_article.vue

<strong>目次</strong><b-button variant="outline-dark" v-b-toggle="'collapse-2'" class="m-1 px-1 py-1" style="font-size:12px; font-weight: bold;"><span v-if="!opentoc">開く</span><span v-if="opentoc">閉じる</span></b-button>

<!-- Element to collapse -->
<b-collapse id="collapse-2">
    <b-list-group-item class="py-1 tablelist" v-bind:class="'t'+table.tag" v-scroll-to="'#' + table.id" v-for="table in tableofcontent" to href="#" v-html="table.text"></b-list-group-item>
</b-collapse>

CI/CD

Github Actionを利用するので.github/workflows/main.ymlに以下のように記載します。

.github/workflows/main.yml

# This is a basic workflow to help you get started with Actions

name: CI

# Controls when the action will run. Triggers the workflow on push or pull request
# events but only for the master branch
on:
  push:
    branches: [ master ]
  schedule:
    - cron:  '0 0 * * *'

# A workflow run is made up of one or more jobs that can run sequentially or in parallel
jobs:
  # This workflow contains a single job called "build"
  build:
    # The type of runner that the job will run on
    runs-on: ubuntu-latest

    # Steps represent a sequence of tasks that will be executed as part of the job
    steps:
      # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it
      - name: Checkout 🛎️
        uses: actions/checkout@v2

      - name: setup node
        uses: actions/setup-node@v1
        with:
          node-version: '10.x'

      - 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
        run: npm ci

      - name: test
        run: npm test

      - name: generate
        run: npm run generate

      - name: Deploy 🚀
        uses: JamesIves/github-pages-deploy-action@3.6.2
        with:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          BRANCH: gh-pages # The branch the action should deploy to.
          FOLDER: dist # The folder the action should deploy.
          CLEAN: true # Automatically remove deleted files from the deploy branch

WordPressからの更新通知についていくつか考えたのですが、いったん手軽にcronでデプロイを回してみました。
これで毎日0時0分(UTC)に更新がかかるようになりました。現在のテックブログの更新頻度では十分だと思います。

PWA

lighthouseのチェックの際にPWAのバッヂもあるのでPWA化もしておきました。
@nuxtjs/pwaを利用すれば一瞬でできます。アイコンとテーマカラーを設定しないとlighthouse満点にならないので注意してください。

以上でJAMStackテックブログが構築できました!

まとめ

ということで今回は勝手にWordPress -> JAMStack化できました。
0からの構築だとルーティングについてはいろいろ考慮すべき点が多く大変でした。GatsbyやGridsomeのdata-source WordPressを利用すればその辺だいぶ楽になるのかもしれません。

コメント機能や表示回数など、一部持ち込めなかった機能もありましたが、表示速度もだいぶ高速化できましたしPWA化もできたのでホームに追加もできるようになりました。

ただJAMStackも完璧ではないため、長期で運用していくには保守性やホスティング先、CI/CDの上限を考える必要がありますし、指定時間に投稿ができない、コメントの反映が即時ではないためリアルタイムのコミュニケーションには不向きといろいろ課題もあります。

それぞれの良いところ、悪いところを理解したうえで最適なアーキテクチャを選定していきたいですね。

以上!ここまで読んでいただきありがとうございました。

謝辞

この企画を最初に話した際に、既存のテックブログのシステムを思いっきり否定しているのに掲載を快諾してくださったCTO室浅野さん,大西さん。大変ありがとうございました。

参考文献・引用

[1]AWSでJAMstackことはじめ(基礎知識編)
[2]Jamstackって何なの?何がいいの?