Next.jsでGoogle Analyticsを使えるようにする

**Next.jsとはVercelが作成しているReactのフレームワークです。**面倒な設定を書かなくてもすぐに使えるZero Configを標榜しており、実際にwebpackやTypeScriptと一緒にReactを書く際にも特別な準備は不要です。SSRにも対応しており、Reactで開発するならNext.jsかFacebook製のCreate React Appを使うのがスタンダードになってます。

私は実務でNext.jsを使っており、このフレームワークはとても便利だと思っています。私はNext.jsの大ファンなので、Reactでの開発時にNext.jsを使う現場が増えるといいなと思って記事を書いています。

関連記事: Next.js + esa.io + VercelでJAMStackな爆速ブログを構築する

**この記事では、Next.jsでReactアプリケーションを作成する時に、Google Analytics(以下、GA)の設定をする方法をご紹介します。**一通り設定した後、TypeScript化していきます。

基本的にはNext.jsのExampleを参考にしています。ただ、実務で使うとこれだけでは足りないところがあるので、記事内では実務への橋渡しとなるような内容を盛り込んでいます。

なお、Google Analyticsのアカウント取得方法やスニペットの取得方法は記載していません。

Google AnalyticsのIDを.envに記述する

まず、ルートディレクトリに.envファイルを作成し、GAのIDを.envに記述します。

.env
NEXT_PUBLIC_GOOGLE_ANALYTICS_ID=UA-SOME_ANALYTICS_ID-1

Next.jsでは、.envに記述した環境変数をprocess.env.NEXT_PUBLIC_GOOGLE_ANALYTICS_IDで取得できます。この値はビルド時に注入されます。これが最初の設定です。

NEXT_PUBLIC_という接頭辞をつけると、ブラウザにも露出する値になります。

GAイベントを発火させる関数を作成する

次に、GAイベントを発火させる関数を作成します。関数はsrc/lib/gtag.jsというファイルに記述していきます。

src/lib/gtag.js
export const GA_ID = process.env.NEXT_PUBLIC_GOOGLE_ANALYTICS_ID || ''

// IDが取得できない場合を想定する
export const existsGaId = GA_ID !== ''

// PVを測定する
export const pageview = (path) => {
  window.gtag('config', GA_ID, {
    page_path: path,
  })
}

// GAイベントを発火させる
export const event = ({action, category, label, value = ''}) => {
  if (!existsGaId) {
    return
  }

  window.gtag('event', action, {
    event_category: category,
    event_label: JSON.stringify(label),
    value,
  })
}

このファイルではgtagというGAのメソッドをラップする関数を作ります。

pageviewという関数でページビューを送信するには、configコマンドを使用します。引数でパスを受け取り、page_pathでURLのパスを送信します。

eventという関数でGAイベントを送信する関数を記述します。例えば、DOMのクリックイベントやSubmitのイベントなどを取得する時に使います。

なお、GAに関する詳しい内容はGoogleの公式ドキュメントをご覧ください。

_app.jsにGAのスクリプトを書き込む

_app.jsは全てのページで共通のHTMLを書くコンポーネントです。この_app.jsにGA用のscriptタグを記述します。

pages/_app.js
import Head from "next/head";
import React from 'react'

import { GA_ID, existsGaId } from '../src/lib/gtag'

const App = ({ Component, pageProps }) => {
  return (
    <>
      <Head>
        {/* Google Analytics */}
        {existsGaId && (
          <>
            <script async src={`https://www.googletagmanager.com/gtag/js?id=${GA_ID}`} />
            <script
              dangerouslySetInnerHTML={{
                __html: `
                  window.dataLayer = window.dataLayer || [];
                  function gtag(){dataLayer.push(arguments);}
                  gtag('js', new Date());
                  gtag('config', '${GA_ID}', {
                    page_path: window.location.pathname,
                  });`,
              }}
            />
          </>
        )}
      </Head>

      <Component {...pageProps} />
    </>
  )
}

export default App

これで Google Analytics の JS を読み込むことができました。

Next.js v11 の Script コンポーネントを利用してリファクタする

Next.js の v11 から、Script コンポーネントが導入されました。 このコンポーネントを用いると、外部の JS を読み込むタイミングを簡単に制御できます。

ここでは、Script タグを使って Google Analytics の JS をサイトの実行に必要なスクリプトを読み込んでページがインタラクティブになった後に読み込むことで、ページが操作可能になるまでの時間を短縮する書き方を紹介します。

その恩恵を端的に書くと、Lighthouse でのスコアが上昇し、SEO に好影響を与えます。

src/components/GoogleAnalytics.js
import Script from 'next/script'
import { existsGaId, GA_ID } from '../lib/gtag'

const GoogleAnalytics = () => (
  <>
    {existsGaId && (
      <>
        <Script defer src={`https://www.googletagmanager.com/gtag/js?id=${GA_ID}`} strategy="afterInteractive" />
        <Script id="ga" defer strategy="afterInteractive">
          {`
              window.dataLayer = window.dataLayer || [];
              function gtag(){dataLayer.push(arguments);}
              gtag('js', new Date());    
              gtag('config', '${GA_ID}');
          `}
        </Script>
      </>
    )}
  </>
)

export default GoogleAnalytics

GoogleAnalytics コンポーネントはこれだけです。先程_app.jsに書いたものを切り出しただけです。

あとは_app.jsでこのコンポーネントを呼び出すだけです。

pages/_app.js
import React from 'react'

import GoogleAnalytics from '../src/components/GoogleAnalytics'

const App = ({ Component, pageProps }) => {
  return (
    <>
      <GoogleAnalytics />

      <Component {...pageProps} />
    </>
  )
}

export default App

これで Google Analytics の読み込みタイミングを遅らせることができました。

_app.jsにPVをカウントするイベントを記述する

Next.js製のサイトはSPAであるため、ページを遷移するときにJavaScriptでURLを書き換えます。その際、Google Analyticsはアクセスした最初のページしかページビュー測定のイベントを送信しません。つまり、ユーザーがサイト内を回遊したときの各ページのPVを測定できないのです。

この問題は、Next.jsのRouterを使えば解決できます。RouterのURL書き換えが完了した時に発火するrouteChangeCompleteイベントのコールバックとしてpageview関数を設定します。

これをpages/_app.jsに記述します。

pages/_app.js
import { useRouter } from "next/router";
import React, { useEffect } from 'react'

import { existsGaId, pageview } from '../src/lib/gtag'
import GoogleAnalytics from '../src/components/GoogleAnalytics'

const App = ({ Component, pageProps }) => {
  const router = useRouter()

  useEffect(() => {
    if (!existsGaId) {
      return
    }

    const handleRouteChange = (path) => {
      pageview(path)
    }

    router.events.on('routeChangeComplete', handleRouteChange)

    return () => {
      router.events.off('routeChangeComplete', handleRouteChange)
    }
  }, [router.events])

  return (
    <>
      <GoogleAnalytics />

      <Component {...pageProps} />
    </>
  )
}

export default App

これで、ページ遷移時のPVイベントをGAに送信できました。

_app.jsの記述は長くなりがちなので、Custom Hooks に切り出しておくと便利です。

src/hooks/usePageView.js
import { useEffect } from 'react'
import { useRouter } from "next/router";

import { existsGaId, pageview } from '../lib/gtag'

export default function usePageView() {
  const router = useRouter()

  useEffect(() => {
    if (!existsGaId) {
      return
    }

    const handleRouteChange = (path) => {
      pageview(path)
    }

    router.events.on('routeChangeComplete', handleRouteChange)

    return () => {
      router.events.off('routeChangeComplete', handleRouteChange)
    }
  }, [router.events])
}

_app.jsは以下のようにリファクタリングできます。

pages/_app.js
import React from 'react'

import usePageView from '../src/hooks/usePageView'
import GoogleAnalytics from '../src/components/GoogleAnalytics'

const App = ({ Component, pageProps }) => {
  usePageView() // 追加

  return (
    <>
      <GoogleAnalytics />

      <Component {...pageProps} />
    </>
  )
}

export default App

GAイベントをReactコンポーネントに設定する

**では、実際にReactコンポーネントでGAイベントの設定をしましょう。**以下ではContactコンポーネントでのボタンをクリックした時に、inputタグに入力されたメッセージをGAに送信します。

src/components/Contact.js
import React from 'react'
import Layout from './Layout'

import * as gtag from '../lib/gtag'

const Contact = () => {
  const [message, setMessage] = React.useState('')

  const handleInput = e => setMessage(e.target.value)
  const handleSubmit = e => {
    e.preventDefault()

    gtag.event({
      action: 'submit_form',
      category: 'Contact',
      label: message,
    })

    setMessage('')
  }

  return (
    <Layout>
      <h1>This is the Contact page</h1>
      <form onSubmit={handleSubmit}>
        <label>
          <span>Message:</span>
          <textarea onChange={handleInput} value={message} />
        </label>
        <button type="submit">submit</button>
      </form>
    </Layout>
  )
}

export default Contact

今回はhandleSubmitの中でeventを発火しています。同様にclickイベントならhandleClickの中に、onChangeイベントならhandleChangeの中にevent`関数を記述します。Reactでは取得したいイベントに応じて、GAイベントを柔軟に記述できます。

ここまででNext.jsでGoogle Analyticsを使うための設定ができました。

より詳しく知りたい方は ここまでのコードを反映したサンプルレポジトリ をご覧ください。

以下では、より実務に即した内容をご紹介します。

TypeScript対応をする

実務ではReactとTypeScriptの環境で開発している方も多いと思います。そのような方のために、Next.js + Google Analytics + TypeScriptの対応方法をご紹介します。

windowからGAイベントのプロパティを使うために型ファイルをインストールする

Next.jsでTypeScriptを使えるようにすると、ルートディレクトリにnext-env.d.tsというファイルが作成されます。これはNext.jsでTSを使うなら必須のファイルで、削除してはいけません。

前の章lib/gtag.jswindow.gtag()をラップする関数を作りましたね。

gtag.jsgtag.tsに書き換えると、windowオブジェクトにgtagというプロパティは存在しないという意味のエラーが表示されます。

TS2339: Property 'gtag' does not exist on type 'Window'.

この型エラーを消すために、Google Analytics 用の型ファイルをインストールしましょう。

$ npm i -D @types/gtag.js

これでgtag.tsでエラーが出なくなりました。

発火させるイベントを型で管理する

TypeScriptに対応したため、gtagをラップする関数に型をつけていきます。

まず、gtag.jsの拡張子をgtag.tsに変更します。そして、Google Analytics のイベントの型を作成します。

src/lib/gtag.ts
type ContactEvent = {
  action: 'submit_form'
  category: 'contact'
  label: string
}

type ClickEvent = {
  action: 'click'
  category: 'other'
  label: string
}

export type Event = ContactEvent | ClickEvent

型の作成は必須ではありません。

ただ、型で管理するとイベント設定時のスペルミスや値が undefined になるミスを未然に防ぐことができる上に、後からの仕様変更に強くなるためこのように書くことをお勧めしています。

こうすることでEvent.jsがサイト全体のGAイベントのドキュメント代わりになります。

数が増えると管理が大変なので、yml などでイベントの定義を PM に書いてもらい、TypeScript の型を自動生成するのがベストだとは思います。

次にwindw.gtagをラップする event 関数と、pageview関数の引数に型をつけます。

src/lib/gtag.ts
// ...
export type Event = ContactEvent | ClickEvent

export const event = ({action, category, label}: Event) => {
  if (!existsGaId) {
    return
  }

  window.gtag('event', action, {
    event_category: category,
    event_label: JSON.stringify(label)
  })
}

export const pageview = (path: string) => {
  window.gtag('config', GA_ID, {
    page_path: path,
  })
}

これで型をつけることができました。

モジュールごとに分割せず一ファイルにまとめる

これまで、gtag や hooks、GoogleAnalytics コンポーネントなど役割に応じてファイルを分割してきました。

しかし、全て Google Analytics に関するモジュールであるため、gtag.tsxの1ファイルにまとめてしまう方が管理が簡単かもしれません。

以下では、これまで書いてきたモジュールを1ファイル内にまとめておくコードを記載します。また、Event 型を実践用に少し書き換えています。

src/lib/gtag.tsx
import { useRouter } from 'next/router'
import Script from 'next/script'
import { useEffect } from 'react'

export const GA_ID = process.env.NEXT_PUBLIC_GOOGLE_ANALYTICS_ID || ''

// IDが取得できない場合を想定する
export const existsGaId = GA_ID !== ''

// PVを測定する
export const pageview = (path: string) => {
  window.gtag('config', GA_ID, {
    page_path: path,
  })
}

// GAイベントを発火させる
export const event = ({ action, category, label, value = '' }: Event) => {
  if (!existsGaId) {
    return
  }

  window.gtag('event', action, {
    event_category: category,
    event_label: label ? JSON.stringify(label) : '',
    value,
  })
}

// _app.tsx で読み込む
export const usePageView = () => {
  const router = useRouter()

  useEffect(() => {
    if (!existsGaId) {
      return
    }

    const handleRouteChange = (path: string) => {
      pageview(path)
    }

    router.events.on('routeChangeComplete', handleRouteChange)
    return () => {
      router.events.off('routeChangeComplete', handleRouteChange)
    }
  }, [router.events])
}

// _app.tsx で読み込む
export const GoogleAnalytics = () => (
  <>
    {existsGaId && (
      <>
        <Script defer src={`https://www.googletagmanager.com/gtag/js?id=${GA_ID}`} strategy="afterInteractive" />
        <Script
          defer
          dangerouslySetInnerHTML={{
            __html: `
              window.dataLayer = window.dataLayer || [];
              function gtag(){dataLayer.push(arguments);}
              gtag('js', new Date());    
              gtag('config', '${GA_ID}');
            `,
          }}
          strategy="afterInteractive"
        />
      </>
    )}
  </>
)

// イベントを型で管理
type ContactEvent = {
  action: 'submit_form'
  category: 'contact'
}

type ClickEvent = {
  action: 'click'
  category: 'other'
}

export type Event = (ContactEvent | ClickEvent) & {
  label?: Record<string, string | number | boolean>
  value?: string
}

よければ参考にしてみてください。

まとめ

Next.jsでGoogle Analyticsを使えるようにした上で、TypeScriptに対応しました。

Next.jsを本番環境で使用する場合、Google Analyticsは必須です。Google Analyticsを使ってユーザー行動を取得し、プロダクトの改善にぜひ役立ててください。

日本でNext.jsが現場で使われるケースが増えることを願っています。

Next.jsGoogle Analytics
プログラミングをするパンダ
プログラミングをするパンダ (@Panda_Program)
Software Engineer

ISUCON11に参加した

大学時代の友人でサーバー・インフラエンジニアである @0daryo と 2021/8/21 に行われた ISUCON11 に参加しました。

彼も 参加記録を書いている ので、その記事とはまた違った内容を書こうと思います。DRY原則ですね。何時に何をやったかというタイムラインはそちらの記事に記載されています。

大学時代の友人エンジニアが誘ってくれた

ISUCON自体は何年も前から知っていたのですが、難しそうなイメージがあったので参加していませんでした。

今年は 0daryo が声をかけてくれました。声をかけられた時は6月上旬、大会の参加申し込みが6月下旬で大会予選は8月です。

その頃ちょうど個人開発で何か作ろうかなと思っていたため、本番に向けて1ヶ月もの間、日夜準備に励むことになりそうだから断ろうかなと思っていました。

他にも断る理由はいくらでもありました。使用言語は彼の得意な Go 言語だけど自分はチュートリアルくらいしかやったことがないことや、自分は元々 PHP を書くバックエンドエンジニアだったけど、今はフロントエンドに専念していること。

それでもパフォーマンスチューニング自体に興味はあったので、とりあえず良問と言われている第9回の過去問を一緒に読み合わせしてから参加するかを決めることにしました。

6月、過去問の読み合わせ

6月のある日曜日に Google Meets を使って二人で過去問のマニュアルとレギュレーションを読みました。アプリケーションの実装内容であるmain.goをざっと眺めてみて、個々の関数の処理自体は思ったより入り組んでいないと思いました。

また、7月は個人開発をするのであまり準備する時間は取れないことを 0daryo に伝えると別に構わないとのことだったので、参加を決めました。

ちなみに、7月の個人開発の成果は note PDFy という note の記事をネット上で PDF 化するツールです。

0daryo が参加申し込みをしてくれた後、予選までの間に2回ほど休日1日をまるまる使った事前準備をしました。

惜しむらくは、同僚で ISUCON 経験者の @cureseven さんの誘ってくれた2回の ISUCON 勉強会が、自分たちの事前準備の日に2回とも被っていたことです。

彼女たちのチーム「牡蠣に当たる時の効果音→カキーン」は本戦出場もしたことがあるすごいチームなので、もしそちらに参加していたら自分たちの準備内容も最終スコアも変わってきたのかもしれません。

7月、事前準備

事前準備の内容は前出の 0daryo のブログ に記述があります。

そこに書いていないことを補足すると、matsuu さんが公開してくださっている ISUCON の AWS の AMI の第9回のものを利用させて貰い、AWS 上に EC2 インスタンスを立ててベンチを実行するなどしていました。

さらに、New Relic をインストールしたり netdata を入れてみて CPU 使用率などサーバー側の監視を行ったり、alp で nginx のログ解析をしてみたり、MySQL の Slow Query のログを吐き出したりして二人で見るようにしていました。

そして、それらの手順やメモを GitHub 上に残しておいて、予選当日はコマンドを実行するだけで各種のツールを導入できるようにしました。

ただ、そこから先の対応をしていなかったので今回は良い結果にならなかったのだと思います。

「あの API のレスポンスが遅い」「このクエリが重い」という分析はできたものの、ツールの導入方法や使い方を調べて導入することで時間を消費してしまったことで、その対策方法を考えて実装する練習をしていませんでした。

大会予選の後、0daryo が「いやー、準備したのに思ったよりやりたいことができなかったっスね」と感想を漏らしたので、「事前練習では分析だけで満足して『改善してスコアを上げる』という成功体験を積んでなかったからそうなるよ」と笑いながら答えたのですが、後から振り返るとこの答えが本質を突いてるように思いました。

ただ、これくらいの気楽さがないとおそらく自分は参加していなかったので、来年参加する時はしっかりスコアを上げるところまで準備でやりたいですね。

予選当日の午前

大会当日の 9:40 から事前説明があることをアナウンスで知っていたので、いつもより早めに起床して待機していました。

今回のテーマを説明する手の込んだムービーに大会運営側の意気込みが伝わってきました。

10時、競技開始

10時に競技開始です。午前中は動作、接続確認をしたり、まっさらな状態でベンチを回しました。初回スコアが3500くらいで、結局このスコアを超えたのは競技時間が延長になった18時以降でした。

午前中は事前に用意したタイムテーブル通りに進んでいきました。

当日のタイムスケジュールのテーブル

Cloud Formation を実行して EC2 への接続を確認し、レギュレーション、マニュアルをしっかり読み、事前に用意したコマンドを実行して各種サーバー監視ツールを導入しました。

DB とコードの管理方法

当日戸惑ったのは、DB が mariaDB だったこと、コードの管理をどうするか決めていなかったことです。

前者は MySQL と互換性があるとのことだったので特に問題視せず、事前に用意した my.cnf を使って対応を進めました。後者は、サーバー内で直接コードを書き換える、Git で push してローカルで pull して書き換える、scp でファイルをローカルに落としてきてそれを Git 管理するという3つの案が出ました。0daryo は最後の方法にしたいとのことだったのでその方法を採ることにしました。

開発に必要な Go のファイル一式をダウンロードし、Git の private レポジトリに push、コードを変更した後は 0daryo が書いたビルドスクリプトを実行してビルドファイルを scp で送ることでデプロイをしていました。

ただ、サーバーに Git が導入済みだったので、来年は普通にサーバーのコードを Git に push して、サーバーでビルドすれば良いかなと思いました。

ブランチを分けて開発していましたが、常に声を掛け合って作業していたので issue を立てて管理することは特にしていませんでした。PR は差分確認のために作るという程度でした。

手元で開発できる環境を一通り整えた後、0daryo が Go のアプリケーション側に New Relic のコードを仕込みました。再度ベンチを回して、New Relic の画面で一番レスポンスが遅いエンドポイントがわかったところで昼休憩にしました。

予選当日の午後

午後は、レスポンスが遅い上位2つのエンドポイントを手分けして改善することにしました。

対策してもスコアが全然上がらない

0daryo は POST /api/condition/:jia_isu_uuid で Bulk Insert を実行したり遅延書き込みの対応、自分は DB のカラムに Index を貼ったり、Nginx の設定ファイルを書き換えたりしていました。

しかし、この辺りからではベンチを実行してもスコアが1000前後をウロウロするようになりました。 998から変わらなかったり、上がっても+20とかだったので、何かの施策でスコアが +100 に増えたくらいで喜んでいました。

いくつか施策を打ってもこれくらいの変化だったので、もしかしたら下位フィニッシュもありえるというムードが漂い始めます。

アプリの改善に着手するもアプローチを間違える

0daryo が GET /api/isu を触っている間に、自分はもう1つのレスポンス速度が遅いエンドポイントである GET /api/trends を見ていました。

コードを読んでみると明らかにループの中で SQL を発行しているではありませんか。「さすが ISUCON、これこそが音に聞くN+1よ。推測より計測。これを改善すればスコア大幅改善間違いなし」と改善に取り組みました。

しかし、結論から書くとここに時間を取られすぎてしまいました。アプローチ方法を間違えたのです。

実際は認証が不要なエンドポイントだったのでインメモリにデータを載せていればよかったのですが、「これはN+1問題だ」と思い込んで SQL と Go のコードの書き換えばかりに気を取られていました。

この間に「DBに挿入されている画像をファイル化する」ことや「椅子のステータスを計算後の値に変更する」などの施策は思いついたものの、そこまで動くことができませんでした。

結局このエンドポイントを改善できずに2~3時間くらい溶かした後、17時過ぎに負荷の過多のせいかベンチマークが実行できなくなりました。ベンチマークが復旧しないままなすすべなく競技終了の18時が近づいてきました。

「スコアはダメでも最後にベンチだけ回そう」

アプリ側で詰まっていたため、New Relic をサーバーから削除したり MySQL でスロークエリのログを出さないようする変更を加えた後、スコアも1300くらいで低いままだしベンチマークも実行できないので、重い敗戦ムードの中もう切り上げようかなと二人で話していました。

しかし、Twitter に「ISUCON延長」というつぶやきを見つけ、Discord を確認したら18時少しすぎに競技再開、競技終了は18:45に延長とのアナウンスを発見。

競技終了後のスコア測定方法は、インスタンスを再起動した後に運営側がベンチマークを実行するというものです。この再起動試験の後にベンチマークの実行に失敗すると失格となります。

スコアがいくら低くても失格だけは避けたかったので、「スコアはダメでも最後にベンチだけ回そう」と特に期待せず競技再開を待ちました。

競技再開後、早速ベンチマークを実行してみて非常に驚きました。今までの最高スコアが1300程度だったのに、画面に表示されているスコアは1000どころか3000、5000、8000とみるみる上昇するではないですか。

その後も、13000、15000と上がっていき、最終的にスコアは17000程度まで上がりました。これには二人で快哉を叫びました。

次につながる負け方

あわや20000越えかという勢いあるスコアの上昇を見ているだけで、さっきまでの敗戦ムードは吹き飛びました。

このスコアでは本戦に出場なんてとてもできないことは二人ともわかっています。上位チームなら朝の1時間以内に余裕で越えたスコアだったでしょう。

しかし、負けでも次につながる良い負け方でした。野球で例えると9回裏にツーアウトから満塁まで持っていけたような明日につながる負け方だと 0daryo が笑顔で話していました。

最後に、二人で来年もまた出場してみようと約束しました。

スコアの急激な上昇というカタルシスがなければ、来年また出場しようとすら思わなかったはずです。

結果発表

結果、最終スコアは16680で598組中249位でした。午後には真ん中上くらいにはいきたいねと二人で話していたので、一応それくらいの順位に着地しました。

自分たちと同様に今回初参加の同僚たちのいくつかチームがみんな100位以内に入っている中、客観的には誇れる結果にはならなかったです。

しかし、昔の友人と連絡を取り合って再び協力したり、普段業務で触れない箇所を触ったり、点数の急上昇を目撃するなど競技としての面白さを味わうことができたことに満足しています。

来年はもう少し健闘できるように準備していきたいと思います。運営の皆様、大会に参加された方、ISUCON コミュニティの皆様、お疲れ様でした!

performance
プログラミングをするパンダ
プログラミングをするパンダ (@Panda_Program)
Software Engineer

monolithとはどのようなツールか

本記事では Rust 製 CLI ツール monolith を使ってnote の記事を一括保存する方法を紹介します。

monolith は、CSS、JS、画像を含む Web ページを1つの HTML ファイルとして保存するRust製のCLIツールです。

Rust をインストールしている場合、cargo コマンドで導入できます。

$ cargo install monolith

また、Homebrew でもインストール可能です。

$ brew install monolith

コマンド自体はとてもシンプルで、URL と出力先を指定すれば HTML としてダウンロードできます。

$ monolith https://lyrics.github.io/db/P/Portishead/Dummy/Roads/ -o portishead-roads-lyrics.html

noteの記事の保存にmonolithを使う意義

monolith を知ってからユースケースを考えていたのですが、note の記事の一括保存が手軽にできることに気づき、膝を打ちました。

monolith を使うと URL だけで HTML をダウンロードできるため、Puppeteer や Selenium といったヘッドレスブラウザでスクレイピングをするコードを書く必要はありません。

或いは、「note 記事 保存」で検索すると数多くヒットする手法である「それぞれの記事にブラウザでアクセスして、ブラウザの機能を使って PDF にする」という手法も取らずに済みます。

上記の手法と異なり、monolith を使う場合は記事の URL さえあれば記事を保存できるのでとても簡単です。以下はコマンドの例です。

$ monolith https://note.com/foo -o foo.html
$ monolith https://note.com/bar -o bar.html
...

保存する記事が少ないならコマンドを1つずつ実行すれば良いでしょう。

しかし、記事数が多い場合、URL を収集するのは骨が折れます。このため、今回は note の API を使って、シェルスクリプトで特定のユーザーの記事を全部取得します。

全記事取得をするスクリプトを紹介する前に、まずは monolith でできること紹介するため、ある記事を試しにダウンロードしてみます。

実際の記事とmonolithでダウンロードした記事を比較する

今回は、LayerX の福島さんの記事を取得します(福島さんの記事はこれからの社会を考えるに当たりとても勉強になります)。

「withコロナの時代と、経済活動のデジタル化」という記事をダウンロードしてみます。

# 13371870 は記事 ID
$ monolith https://note.com/fukkyy/n/n026e8908f5cd -o 13371870.html

以下は、ブラウザで表示した note の記事とダウンロードした HTML のスクリーンショットを掲載しています。

「withコロナの時代と、経済活動のデジタル化」(note.com)

note の記事のスクリーンショット

monolith でダウンロードした記事 HTML

monolith でダウンロードした記事のスクリーンショット

後者のリンクをクリックすると間違って note.com にアクセスしたと錯覚するかもしれませんが、URL を見ると本サイト上の HTML であることがわかります。

実際に比較するとほとんど同じであることが分かります。差分としては以下の項目が monolith でダウンロードした方には存在しない程度です。

  • コロナウィルスに関する記述であることを示す警告
  • 記事タイトルを表示する header
  • 各種ボタンが並ぶ footer
  • 同じクリエイターの人気記事
  • おすすめの記事
解説パンダくん
解説パンダくん

記事の本文を読むだけなら気にならない程度の違いだね

noteのAPIを使って特定ユーザーの全ての記事URLを取得する

では、note の API を使って福島さんの全記事を取得します。API のエンドポイントの URL と返り値は以下の記事を参考にしました。

「【2020年12月版】noteの非公式APIを使ってユーザーの”全記事データ”を取得してみる※pythonコード有り」

福島さんのユーザー名はfukkyyなのでこの値を利用します。

#!/bin/bash

# ユーザー名
NAME=fukkyy
API="https://note.com/api/v2/creators/${NAME}/contents?kind=note"
TOTAL_COUNT=$(curl -s $API | jq ".data.totalCount")
# 記事は6件ずつ取得できるため6で割る
END=$(( $TOTAL_COUNT / 6 + 1 ))

echo "${NAME}氏の記事を取得します"

# $END が小数を含む場合、seq の終端は $END を超えない最大の整数
for i in $(seq 1 $END); do
  JSON=$(curl -s "${API}&page=$i")
  IDS=($(echo $JSON | jq '.data.contents[].id'))
  URLS=($(echo $JSON | jq -r '.data.contents[].noteUrl'))
  IDS_COUNT=$(( ${#IDS[@]} - 1 ))

  for j in $(seq 0 $IDS_COUNT); do
    ID=${IDS[$j]}
    URL=${URLS[$j]}

    # ホストが COMEMO の場合、クエリを付与する
    if [[ $URL == *comemo.nikkei.com* ]]; then
      URL="${URL}?gs=0"
    fi

    mkdir -p "note/${NAME}"

    echo "ID: ${ID}"
    monolith $URL -s -o "note/${NAME}/${ID}.html"
  done
done

echo "end"

記事は JSON 形式で6件ずつ取得できます。loop 内で paging をして全ての記事の ID と URL を取得します。

ホストがnote.comではなくcomemo.nikkei.comの場合、gsというクエリを付与しないと HTML を取得できなかったため条件分岐で対応しています。

このスクリプトを実行すると、note/fukkyyディレクトリに HTML ファイルが作成されます。

.
└── note
    └── fukkyy
        ├── 10147393.html
        ├── 10456869.html
        ├── 11562526.html
        ...
        └── 9753513.html

他の方の記事を取得したい場合は、ユーザー名を変更して実行するだけで OK です。

まとめ

monolith を使うと JS、CSS、画像が1つの HTML ファイルにまとまるので整理がとても楽です。

購入済みの有料記事を保存するのであれば以下の手順で取得できます。

  1. curl でログイン session(_note_session_v5) とともに記事 URL にリクエストを送る
  2. 結果をローカルにファイルとして保存する(ex. foo.html
  3. $ cat foo.html | monolith -b https://note.com/foo - > result.html を実行する

また、HTML を 画像 に変換したい場合は、Puppeteer を使えば10行程度のコードで実現できます。

const puppeteer = require('puppeteer');

(async () => {
  const browser = await puppeteer.launch();
  const page = await browser.newPage();
  await page.goto('file:///Users/name/note/fukkyy/8911395.html');
  await page.screenshot({ path: '8911395.png', fullPage: true});

  await browser.close();
})();

便利な CLI ツール monolith の紹介でした。

参考

Linuxメモ : あると便利かもしれないRust製コマンドラインツール

Rust
プログラミングをするパンダ
プログラミングをするパンダ (@Panda_Program)
Software Engineer

Renovate でパッケージのアップデートをする

Renovate は、依存関係の更新チェックを自動化するツールです。

本記事では、Renovate を owned の GitLab で定期実行し、npm パッケージのアップデートを自動化する方法を紹介します。

renovate.jsonを作成する

renovate.jsonをプロジェクトルートに作成します。

renovate.json
{
  "extends": ["config:base"],       // 公式の推奨設定を継承
  "timezone": "Asia/Tokyo",         // タイムゾーンの設定
  "enabledManagers": ["npm"],       // パッケージマネージャの名称
  "lockFileMaintenance": {
    "enabled": true                 // package-lock.json / yarn.lock を更新
  },
  "rangeStrategy": "pin",           // バージョンを固定
  "packageRules": [
    {
      "matchPackagePatterns": ["*"],        // 全パッケージが対象
    },
   {
      "matchDepTypes": ["dependencies"],    // dependencies のみの MR を作成
      "groupName": "dependencies"
    },
    {
      "matchDepTypes": ["devDependencies"], // devDependencies のみの MR を作成
      "groupName": "devDependencies"
    }
  ],
  "baseBranches": ["main"],     // 更新対象のブランチ
  "assignees": ["user_name"]    // 更新チェックの担当者名(GitLab のユーザー名)
}

config:baseの設定内容は公式ドキュメントに記載があります。

また、package.jsonがプロジェクトルートにない場合は、packageFileDir でディレクトリを指定します。

なお、renovate.json はプロジェクトの要求に応じて柔軟に設定ができます。詳しいオプションはConfiguration Options を参照してください。

GitLab CI で定期実行の設定をする

.gitlab-ci.ymlに Renovate 用の設定を記述します。

.gitlab-ci.yml
stages:
  - dependency

renovate:
  stage: dependency
  image:
    name: renovate/renovate:24.95.0
    entrypoint: [""]
  script:
    - renovate --platform gitlab --token $API_TOKEN --endpoint $CI_SERVER_URL/api/v4 $CI_PROJECT_PATH
  rules:
    - if: $RENOVATE && $CI_PIPELINE_SOURCE == "schedule"

.gitlab-ci.ymlのポイントは以下です。

  • Renovate 自体のバージョンアップデートが頻繁にあり、その都度 renovate.json の書き方が少し変わるため、docker image のバージョンを固定します。
  • $API_TOKENは GitLab ユーザーのプロジェクト用のトークンを利用します。read/write と api 実行の権限が必要です。
  • Job を作成する際、cron で週に一度の定期実行の設定します。本業では月曜日の午前3時に設定していました。
  • Job作成時、作成画面で$RENOVATEという変数を設定し、他の定期 Job を実行した時に Renovate の Job が起動しないようにします。

$CI_から始まる変数は、 GitLab CIの環境変数です 。CI でログ多様な値が利用できて便利です。

定期実行ジョブは、GitLab のメニューのCI/CD > Schedules > New Scheduleから設定しましょう。

GitLabの画面

ローカルで Renovate の動作確認をする

Docker image を使用し、Renovate コマンドに--dry-runオプションを与えて、ローカルで動作確認をしましょう。

対象となるリモートブランチ(今回はorigin/main)に renovate.json が存在していることが前提です。

$ docker run --rm renovate/renovate:24.95.0 renovate --platform gitlab --token your_api_token --endpoint https://your-gitlab-hostname.com/api/v4 your/project-name --dry-run

実行結果は以下の通りです。

$ docker run --rm renovate/renovate:24.95.0 renovate --platform gitlab --token your_api_token --endpoint https://your-gitlab-hostname.com/api/v4 your/project-name --dry-run
 INFO: Repository started (repository=your/project-name)
       "renovateVersion": "24.95.0"
 INFO: Dependency extraction complete (repository=your/project-name)
       "baseBranch": "main",
       "stats": {
         "managers": {"npm": {"fileCount": 1, "depCount": 82}},
         "total": {"fileCount": 1, "depCount": 82}
       }

...

 INFO: DRY-RUN: Would commit files to branch renovate/pin-dependencies (repository=your/project-name, branch=renovate/pin-dependencies)
 INFO: DRY-RUN: Would commit files to branch renovate/dependencies (repository=your/project-name, branch=renovate/dependencies)
 INFO: DRY-RUN: Would commit files to branch renovate/devdependencies (repository=your/project-name, branch=renovate/devdependencies)
 INFO: DRY-RUN: Would commit files to branch renovate/major-dependencies (repository=your/project-name, branch=renovate/major-dependencies)
 INFO: DRY-RUN: Would commit files to branch renovate/major-devdependencies (repository=your/project-name, branch=renovate/major-devdependencies)
 INFO: Repository finished (repository=your/project-name)
       "durationMs": 117962

--dry-runなしで実行した場合、以下の MR が作成されることが実行ログからわかります。

  • dependencies のバージョンを固定する renovate/pin-dependencies
  • minor, patch バージョンのアップデート
    • renovate/dependenciesrenovate/devdependencies
  • メジャーバージョンのアップデート
    • renovate/major-dependenciesrenovate/major-devdependencies

Renovate 導入のメリット

Renovate 導入のメリットは多岐に渡ります。

  • 週に一度の更新なので、差分が多くないため更新内容を把握しやすい
  • 依存関係を最新にできるため、セキュリティリスクを低減できる
    • trivy のような脆弱性検査に引っ掛かる回数が減る
  • パッケージ更新のための人的コストが削減できる

型ファイルやリント、テスト関係のパッケージは独立した MR として作成するようにしても良いでしょう。

また、外部ライブラリを利用している箇所のテストがないプロジェクトでは毎回の動作確認の負担が重くなるため、テストを書くモチベーションにもなります。

Renovateの運用について

私が担当しているプロジェクトでの Renovate の運用方法を少し紹介します。前提として、プロジェクトの特徴は以下の通りです。

  • Next.js + TypeScript の中規模のプロジェクト
  • Jest による Unit Test を記述
  • テストカバレッジは90%程度
  • エンジニアは私を含めて3名

運用では、devDependencies の マイナー・パッチバージョンのアップデートの場合、まず staging 環境にマージし CI を実行して、CI が落ちない場合は master マージするというように省力化を図っていました。

CI で TypeScript による静的な型検査、eslint と Jest の実行、Next.js のビルドをしているため、CI が落ちない限りアプリケーションの動作には影響がないと判断しているからです。

メジャーバージョンのアップデートの場合は、CI 上で実行しているコマンドをローカル環境で実行しています。

その他、大規模なプロジェクトの場合は、どうしてもアップデートできないパッケージがあるはずです。

そのようなパッケージは、一旦 renovate.json で更新対象から外す設定をしましょう。そして、残りのバージョンを更新できるパッケージだけを更新することで、いつか来るリファクタリングに備えておくのが良いでしょう。

また、「WEB+DB PRESS Vol.119(Amazon)」 の「フロントエンド脱レガシー」特集によると、cybozu 社では社内共通の renovate.json を作成しているとのことです。

その設定を extend してプロジェクトごとに renovate.json を何度も設定する手間を省いているそうです(cybozu/renovate-config)。

まとめ

手動での動作確認はコストがかかりすぎるため、依存関係の頻繁な更新のためには自動テストが必要不可欠です。外部ライブラリを使う場合はアダプターを作成するとテストが書きやすくなり、テストが落ちる範囲も限定できます。

renovate によるパッケージアップデートで CI/CD の実行回数が増えます。ユーザーをバグから守るテストと CI/CD は両の車輪であり、エクストリーム・プログラミングのプラクティスにも CI/CD とテストの両方が含まれています。

パッケージの更新は少々大変ですが、テストも書きつつ、ソフトウェアのメンテナンスの一環として継続して実施していきたいものです。

RenovateGitLab
プログラミングをするパンダ
プログラミングをするパンダ (@Panda_Program)
Software Engineer

hygenでReactコンポーネントを生成する

昨年、React コンポーネントの作成を自動化するためにシェルスクリプトを書きました(「Reactコンポーネントの雛形生成のシェルスクリプトを書いた」)。

その後、「hygen で生成 - 対話形式の Component 雛形 -」 という記事でファイル生成ツール hygen を知り、とても便利だったため本業のプロジェクトに導入しました。

本記事ではコンポーネント生成のために利用しているテンプレートの紹介と使い方、そして導入した利点を紹介します。

hygenのReactコンポーネント用のテンプレートの紹介

hygen とは、マークダウンのような frontmatter とテンプレートエンジン ejs からなるテンプレートを元に新しいファイルを生成するツールです。

以下のコマンドで hygen をインストールし、サンプル用のテンプレートを生成しましょう。

$ npm i -g hygen
$ hygen init self

コマンド実行後、プロジェクトルートに_templatesディレクトリが作成されます。

_templates
└── generator
    ├── help
    │   └── index.ejs.t
    ├── new
    │   └── hello.ejs.t
    └── with-prompt
        ├── hello.ejs.t
        └── prompt.ejs.t

本業のプロジェクトではこれを改変して、以下のような構成にしています。

_templates
└── component
    └── new
        ├── component.ejs.t
        ├── index.ejs.t
        ├── index.stories.ejs.t
        ├── prompt.js
        └── style.module.ejs.t

これで React コンポーネント、Storybook、scss、index.ts を作成できます。

hygenのテンプレートファイル

各ファイルの中身を紹介します。

React コンポーネント

:component.ejs.t
---
to: src/components/<%= dir %>/<%= h.changeCase.pascal(name) %>/<%= h.changeCase.pascal(name) %>.tsx
---
import React from 'react'
import css from './style.module.scss'

type ContainerProps = unknown

export type Props = unknown

export const Component: React.FC<Props> = (props) => (
  <></>
)

const Container: React.FC<ContainerProps> = (props) => {

  return <Component {...props} />
}

Container.displayName = '<%= h.changeCase.pascal(name) %>'

export default Container

冒頭はファイルパスとファイル名の指定です。

---
to: src/components/<%= dir %>/<%= h.changeCase.pascal(name) %>/<%= h.changeCase.pascal(name) %>.tsx
---

dir h.changeCase.lower(name)でファイルパスを指定し、h.changeCase.pascal(name)でファイル名を決定します。

h.changeCase.lowerh.changeCase.pascalは hygen が用意している関数です。他にも多くの種類があるため、 柔軟に対応できます。

変数に格納する値を指定する方法は後述のprompt.jsの箇所で紹介します。

なお、React コンポーネントは「経年劣化に耐える ReactComponent の書き方」 を参考にしています。

またComponent(Presentation Component)とPropsを named export しているのは、下記の Storybook で利用するためです。

Storybook

:index.stories.ejs.t
---
to: src/components/<%= dir %>/<%= h.changeCase.pascal(name) %>/index.stories.tsx
---
import { Meta, Story } from '@storybook/react'
import React from 'react'

import { Component as <%= h.changeCase.pascal(name) %>, Props } from './<%= h.changeCase.pascal(name) %>'

export default {
  title: '<%= h.changeCase.pascal(name) %>',
} as Meta<Props>

const Template: Story<Props> = ({ ...args }) => <<%= h.changeCase.pascal(name) %> {...args} />

export const Default = Template.bind({})
Default.args = {

}

Storybookのファイルの雛形はStorybook 公式サイト の React + TypeScript の書き方を参考にしています。

ButtonGroup.stories.tsx
import React from 'react';

import { Story, Meta } from '@storybook/react';

import { ButtonGroup, ButtonGroupProps } from '../ButtonGroup';

//👇 Imports the Button stories
import * as ButtonStories from './Button.stories';

export default {
  title: 'ButtonGroup',
  component: ButtonGroup,
} as Meta;

const Template: Story<ButtonGroupProps> = (args) => <ButtonGroup {...args} />;

export const Pair = Template.bind({});
Pair.args = {
  buttons: [{ ...ButtonStories.Primary.args }, { ...ButtonStories.Secondary.args }],
  orientation: 'horizontal',
};

前出のテンプレートファイルと見比べるとほとんど同じことがわかります。

ただ、Propsの型はコンポーネントのファイルから export せず、ComponentPropsで取得する方がスマートかもしれません。

import { Meta, Story } from '@storybook/react'
import React, { ComponentProps } from "react"

import { Component as <%= h.changeCase.pascal(name) %>, Props } from './<%= h.changeCase.pascal(name) %>'

type Props = ComponentProps<typeof <%= h.changeCase.pascal(name) %>>

// ...

scss

:style.module.ejs.t
---
to: src/components/<%= dir %>/<%= h.changeCase.pascal(name) %>/style.module.scss
---
@import 'src/assets/scss/lib/color';
@import 'src/assets/scss/lib/variable';
@import 'src/assets/scss/lib/icon';
@import 'src/assets/scss/lib/function';
@import 'src/assets/scss/lib/mixin';

各ファイルで共通の scss ファイルを import しています。

export

---
to: src/components/<%= dir %>/<%= h.changeCase.pascal(name) %>/index.ts
---
export { default } from './<%= h.changeCase.pascal(name) %>'

default export をしていますが、もちろん書き方を変えると named export に変更できます。

prompt.jsで入力値を決める

prompt.js
module.exports = [
  {
    type: 'select',
    name: 'dir',
    message: 'ディレクトリを選択してください',
    choices: ['common', 'features', 'pages'],
  },
  {
    type: 'input',
    name: 'name',
    message: 'コンポーネント名を入力してください',
    validate: (input) => input !== '',
  },
]

対話型 CLI での値の受け取り方をprompt.jsの中で設定します。プロパティnameの値が ejs 内の変数名に対応します。

例えば、typeselectに指定すると、列挙した値から選択することになります。

また、typeinputの場合は自由入力です。空欄は避けたいため、validateにバリデーションを記述します。

hygenのコマンド実行によるファイル生成

src/components/commonディレクトリにLayoutコンポーネントを作成してみましょう。

コマンド$ hygen component newを実行すると選択肢が列挙されます。

ディレクトリ名をchoicesに設定した値から選択します。

$ hygen component new
? ディレクトリを選択してください … 
❯ common
  features
  pages

次に、自由入力でコンポーネント名をLayoutと入力します。

$ hygen component new
✔ ディレクトリを選択してください · common
✔ コンポーネント名を入力してください · Layout

これでファイルが生成されました。

$ hygen component new
✔ ディレクトリを選択してください · common
✔ コンポーネント名を入力してください · Layout

Loaded templates: _templates
       added: src/components/common/Layout/Layout.tsx
       added: src/components/common/Layout/index.ts
       added: src/components/common/Layout/index.stories.tsx
       added: src/components/common/Layout/style.module.scss

記事の最後で生成したファイルの中身を掲載しています。

hygen導入のメリット

Storybook の書き方の統一と作成漏れの回避

Storybook のコンポーネントの書き方は柔軟なため、一通りではありません。しかし、テンプレートを用意すると大人数で開発していても記述方法の統一できます。

また、当然のことですが「Storybook First な開発」 をする際、Storybook コンポーネントは不可欠です。

テンプレートから一気に Storybook 用のファイルを生成すれば、作成漏れはありません。

デザイナーさんとの効果的な協業

全てのデザイナーさんが React に慣れているわけではありません。HTML と CSS をデザイナーさんが記述する場合、React で実装するという心理的なハードルをできるだけ下げたいです。

そこで、本記事で紹介したテンプレートを使うと責任範囲を限定できます。

つまり、デザイナーさんは Presentation Component に JSX を記述し、style.module.scssに scss を書いて Storybook で動作確認をするだけで OK になるのです。

// ...
import css from './style.module.scss'

// ...

export const Component: React.FC<Props> = (props) => (
  <></> // ここに JSX を記述する
)

デザイナーさんは React コンポーネントの Props の渡し方や、Storybook 用のコンポーネントの書き方を覚えずに済みます。実質的に、HTML と CSS(SCSS)の書き方を知っているだけで作業に取り掛かれるからです。

まとめ

IntelliJ IDEA にテンプレートを用いたファイル作成機能はありますが、ファイルは1つずつしか生成できません。

hygen は複数ファイルを同時に生成したい場面で威力を発揮します。例えば JS を利用しないプロジェクトであっても hygen を導入してもいいかもしれません。Class と Interface、Test 用のファイルを同時に生成するといったケースであっても有用だからです。

また、『UNIXという考え方』(Amazon) という書籍の中で「定理8: 過度の拘束的インターフェースは避ける」と書かれています。

一旦、そのアプリケーションをコマンドインタープリタから起動すると、そのアプリケーションが終了するまでコマンドインタープリタとの対話ができなくなる。ユーザーはアプリケーションのユーザーインターフェースの内部に拘束され、拘束を解くための行動を起こさない限り、その拘束から逃れられない。

『UNIXという考え方』(Mike Gancarz 著、芳尾桂 監訳。オーム社)

hygen はとても柔軟なので様々な指定が可能ではあるものの、対話数はできるだけ絞っておくと良いでしょう。

生成したファイル一覧

React コンポーネント

src/components/common/Layout/Layout.tsx
import React from 'react'
import css from './style.module.scss'

type ContainerProps = unknown

export type Props = unknown

export const Component: React.FC<Props> = (props) => (
  <></>
)

const Container: React.FC<ContainerProps> = (props) => {

  return <Component {...props} />
}

Container.displayName = 'Layout'

export default Container

Storybook

src/components/common/Layout/index.stories.tsx
import { Meta, Story } from '@storybook/react'
import React from 'react'

import { Component as Layout, Props } from './Layout'

export default {
  title: 'Layout',
} as Meta<Props>

const Template: Story<Props> = ({ ...args }) => <Layout {...args} />

export const Default = Template.bind({})
Default.args = {

}

scss

src/components/common/Layout/style.module.scss
@import 'src/assets/scss/lib/color';
@import 'src/assets/scss/lib/variable';
@import 'src/assets/scss/lib/icon';
@import 'src/assets/scss/lib/function';
@import 'src/assets/scss/lib/mixin';

index.ts

src/components/common/Layout/index.ts
export { default } from './Layout'
React
プログラミングをするパンダ
プログラミングをするパンダ (@Panda_Program)
Software Engineer