筋トレの重量に伸びがなくなってきたため、ジムに行く回数を3回にして5ヶ月がたったlinyowsです。順調に重量は増え、食事制限はしてませんが代謝が増えたことで脂肪も大分燃焼されている気がします。あと、ジムが習慣になったことで、ジムに行かない日はちょっとソワソワしています。が、すこぶる元気です。今日は、「GitHub Actionsで「ロリポップ!」「ヘテムル」をもっと便利に使おう 」や「PRPLパターンで「ロリポップ!」「ヘテムル」のWordPressを爆速にしよう」の記事の続編?にあたる記事です(前回の公開が 2020年5月なので大分時間が空いてしまいました)。
また、先日のNotion Japanが主催するイベントで発表した内容の延長の話なので、興味がある方は下のスライドもご覧ください。
セキュリティ対応の必要性
ブログやサイトを自前で作る場合、WordPressを利用することは多いと思います。なぜなら、ブログやサイトのコンテンツ管理をWordPressに任せることで、表示に関するクリエイティブな部分に集中することができるからです。また、WordPressには、プラグインのエコシステムが充実しており、データ拡張のためのフィールド追加や、SEOに関する機能など、さまざまな便利機能をオプションとして利用することができます。これらのエコシステムを作るのは全世界の有志なので、当然ながら危険な脆弱性があることがしばしばあります。このような脆弱性は、利用者が継続的に管理しなければなりません。利用するプラグインが、メンテナンスされていればアップデートで問題ないでしょう。もしメンテナンスがされていなければ、プラグインの交換をしたり、利用を止めたり、などの対応をしなければなりません。そうしなければ、管理しているサイトが改竄されたり、乗っ取られる恐れがあります。なんだか物騒に聞こえますが、事実世の中で発生していることであります。
Headless CMSとしてのNotion
セキュリティインシデントにならないように、先述の通り継続的なセキュリティ対応が必要なのですが、管理するサイトの数に比例して運用が結構大変です。どうにか楽する方法はないのでしょうか。その1つの方法として考えられるのがマネージドなHeadless CMSを使うことです。世の中には、例えば、ContentfulやHygraphといったマネージドサービスが存在しています。そんな中で今回提案するのはNotionを使う方法です。なぜ、Notionなのかというと、Notionを作業スペースとして使っている場合、その延長上にサイト管理ができるということです。また、Notionにはデータベースの機能とドキュメントの機能があり、それらの機能を公式にAPIとして提供しています。Notionはプロダクト的にHeadless CMSと謳ってはないものの、機能的にはHeadless CMSを満たしているというわけです。Notionは、柔軟性の高いデータベース設計が可能であることから、Database as a Serviceと言っても過言ではないでしょう。
NotionとNext.jsの組み合わせ
背景や意図を伝えたところで、いよいよNotionを使ってより安全で爆速のサイトを作っていきましょう。爆速にするために、今回、Next.jsのexportコマンドを使って静的サイトにします。ロリポップやへテムルでは、Node.jsランタイムを提供していないため、前回の記事同様に静的サイトにするというわけです。また、静的サイトはキャッシュ可能なため、ロリポップやへテムルの環境においては、より閲覧者に近いところでレスポンスを返すことができます。今回ハイレベルアーキテクチャは以下のようになります。GitHub Actionsでビルドしたりデプロイするのはこれまでと変わりませんが、コンテンツが丸っとNotion側にあるため、ロリポップはストレージ的役割となります。
使用するもの
以下のサービスやツールを使用します。
- ロリポップ!ハイスピードプラン
- ロリポップ!が提供するドメイン
- Next.js App
- GitHub Repository
- GitHub Actions(Next.jsのexportとSCPによるデプロイ)
- Notion パーソナルプラン
ロリポップのセットアップ
まず、ロリポップのハイスピードプランを申し込みます(安心してください!クレカ登録無しでお試しで10日間使えます)。ロリポップのドメインは、通常 mute-hiji-3584
のようなhaikunateされたIDがサブドメインに入っています。今回はロリポップのドメインを使うので適切なIDを入力します。申込みが完了するとユーザー専用ページというコンソールサイトへ遷移します。
アクセラレータの有効化
サイトレスポンスのパフォーマンスを上げるため、Web Proxyサーバのキャッシュを有効にするアクセラレーターをOnにします。左のナビゲーションの サーバーの設定・設定
> ロリポップ!アクセラレータ
から設定できます。
SSHの有効化
GitHub Actionsでビルド済みの静的サイトをSCPによってファイルをアップロードするため、SSHを有効にしておきます。左のナビゲーションの サーバーの管理・設定
> SSH
から設定できます。
Notion のセットアップ
Notionをパーソナルプランで申し込み、今回のサイトで使うページを作成します。私の例では、分かりやすいようにドメイン名のページを作りました。その中にサイトコンテンツが格納されるデータベースを作成します。今回はサンプルとして、ペパボのサービス紹介のサイトを作ります。必要なデータベースプロパティに 公開日時
URL
Slug
を日付型、URL型、テキスト型で追加します。データベースには、ペパボのサービスごとにページを追加していき、カバーにロゴ画像を設定します。
APIアクセストークンの作成
次に、Notion APIに使うアクセストークンを作成していきます。作成は、ナビゲーション左上の 設定
の コネクト
に インテグレーションを作成または管理する
のリンクがあるので開きます。Notionではサービス拡張をインテグレーションと呼んでいて、API利用者もその一つという位置付けになるようです。今回の用途はブログコンテンツの読み込みを想定しているので権限は読み込みのみで設定指定おきます。作成すると、トークンが発行されます。さらに、このインテグレーションをNotionのページに割り当てる必要があります。先ほど作ったドメイン名のページに移動したら、ページ右上に ...
のアイコンがあるのでその中のコネクトの追加から先ほど作ったインテグレーションを割り当てます。Notionの権限は、親ページに設定したものをその配下にある子や孫に対して引き継ぐため、これだけで配下のページは全てAPIからアクセス可能になります。
データベースに情報を入力していくと以下のような状態になりました。ページ本文には、サイトのキャプチャや Notionのブロックの一つである コールアウト を設定したりしました。
Next.js Appの作成
さて、今度はサイトの方を作っていきます。Next.jsを使うのですが手軽に用意したいので、 create-next-app
を使います。NotionのAPIによるデータ取得や表示を透過的に行うことができるパッケージ Notionate
を追加します。そして、先ほど作成したAPIトークンとデータベースのIDを .env
として保存します。データベースのIDは、データベースのページのURLが https://www.notion.so/102db5ac15844bc5a0b7f4419b2c33a0?v=c01d56f22a744b889a61edd186e2726a
だったとしたら querystringを省いたbasenameを指定します。
$ npm i -g create-next-app
# 好みでTypeScriptを指定
$ create-next-app --ts linyows.her.jp
$ cd linyows.her.jp
$ yarn add notionate
$ cat << EOF > .env
NOTION_CACHE=true
NOTION_TOKEN=secret_*************************************
NOTION_DBID=102db5ac15844bc5a0b7f4419b2c33a0
EOF
共通処理の関数
Notion データベース APIには、filterやsortなどのクエリを渡すことができます。それらを定義し、データベースへのリクエストを共通化しておきます。 Notionate
は、Notion APIからデータ取得と画像などのダウンロードを一気に行う関数 FetchDatabase
を提供するので、 Next.jsの getStaticProps
で呼び出す想定です。ローカルにダウンロードした画像のパスをAPIのレスポンスに付加したデータを返すので、必要な値をNext.jsのPropsにセットしてやります。今回は、データベースのプロパティである 公開日時
が未来のものを除外しています。 Notionate
はデータベースを全ページ取得しローカルファイルにキャッシュする仕組みになっているため、一件取得もその結果からfindするようにしています。また、Notion APIのレスポンスはかなり冗長なデータになるので、ページコンテンツとして使いやすいように build
関数で加工できるようにしておきます。また、Next.jsで exportするには 各ページのパスを渡してビルドできるようにしなければなりません。そのためのパスリストを返す関数も作っておきます。
import {
FetchDatabase,
QueryDatabaseParameters,
RichTextItemResponse,
SelectPropertyResponse,
DateResponse,
DBPageBase,
QueryDatabaseResponseEx,
} from 'notionate'
export type DBPage = DBPageBase & {
cover: {
src: string
}
properties: {
"タイトル": {
type: "title"
title: RichTextItemResponse[]
id: string
}
Slug: {
type: "rich_text"
rich_text: RichTextItemResponse[]
id: string
}
"公開日時": {
type: "date"
date: DateResponse | null
id: string
}
"タグ": {
type: "multi_select"
multi_select: SelectPropertyResponse[]
id: string
}
}
}
export type Service = {
id: string
title: string
slug: string
date: string
tags: string[]
cover: string
}
export const build = (page: DBPage): Service => {
const p = page.properties
return {
id: page.id,
title: p["タイトル"].title.map(v => v.plain_text).join(',') || '',
slug: p.Slug.rich_text.map(v => v.plain_text).join(',') || '',
date: p["公開日時"].date?.start || '',
tags: p["タグ"].multi_select.map(v => v.name) || [],
cover: page.cover.src,
}
}
const q = {
database_id: process.env.NOTION_DBID,
filter: {
property: '公開日時',
date: {
on_or_before: (new Date(Date.now())).toISOString(),
},
},
sorts: [
{
property: '公開日時',
direction: 'ascending',
},
],
limit: 10,
} as QueryDatabaseParameters
export const getServices = async () => {
return await FetchDatabase(q)
}
export const getService = async (slug: string) => {
const svc = await getServices()
return svc.results.find(v => {
const p = v as unknown as DBPage
return p.properties.Slug.rich_text.map(vv => vv.plain_text).join(',') === slug
})
}
export const getPaths = async () => {
const svc = await getServices()
return svc.results.map(v => {
const p = v as DBPage
const slug = p.properties.Slug.rich_text.map(v => v.plain_text).join(',')
return { params: { slug } }
})
}
各ページの変更
次に、各ページの変更をしていきます。 pages/_app.tsx
は、各ページで使う Notionate
のスタイルをロードしておきます。
import type { AppProps } from 'next/app'
import '../styles/globals.css'
import 'notionate/dist/styles/notionate.css'
function MyApp({ Component, pageProps }: AppProps) {
return <Component {...pageProps} />
}
export default MyApp
pages/index.tsx
は、Notionで言うギャラリービューを表示します。 Notionate
がギャラリーコンポーネントを提供するので、共通化関数から取得したデータをコンポーネントに渡すだけです。 keys
はギャラリーのカードで表示したいプロパティとその順番になります。 href
は、カードのリンクパスとなり、データベースのプロパティの Slug
を使ったパスとするために指定します。
import { GetStaticProps, NextPage } from 'next'
import type { ReactElement } from 'react'
import Link from 'next/link'
import { QueryDatabaseResponseEx } from 'notionate'
import { Gallery } from 'notionate/dist/components'
import { getServices } from '../lib/service'
type Props = {
services: QueryDatabaseResponseEx
}
export const getStaticProps: GetStaticProps<Props> = async () => {
const services = await getServices()
return {
props: {
services,
}
}
}
const Home: NextPage<Props> = ({ services }) => {
return <Gallery
keys={['タイトル', '公開日時', 'タグ']}
db={services}
preview="cover"
size="small"
fit={true}
href="/services/[Slug]"
link={Link as React.FC<{ children: ReactElement<'a'>, href: string}>}
/>
}
export default Home
詳細ページは、Next.jsのルーティング作法に合わせてpages/services/[slug].tsx
でファイルを作ります。内容は以下になります。Notionのページブロック情報を取得する FetchBlocks
を使って取得したデータをブロックコンポーネントに渡します。そうすることで簡単にNotionのページが再現されます。
import { GetStaticProps, NextPage, PreviewData } from 'next'
import {
FetchBlocks,
ListBlockChildrenResponseEx,
} from 'notionate'
import {
Service,
build,
getPaths,
getService,
DBPage,
getServices,
} from '../../lib/service'
import { Blocks } from 'notionate/dist/components'
type Props = {
service?: Service
blocks?: ListBlockChildrenResponseEx
}
type Params = {
slug: string
}
export const getStaticPaths = async () => {
const paths = await getPaths()
return {
paths,
fallback: false,
}
}
export const getStaticProps: GetStaticProps<Props, Params, PreviewData> = async ({ params }) => {
const service = await getService(params!.slug)
if (!service) {
return {
props: {},
redirect: {
destination: '/404'
}
}
}
const blocks = await FetchBlocks(service.id)
return {
props: {
service: build(service as unknown as DBPage),
blocks,
},
revalidate: 60,
}
}
const Service: NextPage<Props> = ({ service, blocks }) => {
return (
<>
<header className="service-header">
<div>
<h2 className="name">{service?.title}</h2>
<p className="meta">
作成日: <span>{service?.date}</span>,
タグ: {service?.tags.map((tag, i) => (
<span key={`${i}`}>{tag}</span>
))}
</p>
</div>
<img className="cover" src={service?.cover} width="250px" />
</header>
<Blocks blocks={blocks!} />
</>
)
}
export default Service
スタイル周りを省略してますが、ここまで出来たらほぼ完成です。手元で yarn run
するとサイトが確認できるでしょう。
GitHub RepositoryとWorkflowの準備
最後に、GitHubのActionsを使ってNext.jsのexportとその成果物をデプロイするためのworkflowを作ります。 .github/workflows/build.yml
のパスで以下のファイルを作ります。mainブランチ以外では、ビルドのみを行い、mainブランチはscpでロリポップにデプロイを行うというものです。yarnのキャッシュなどを行った方が速くなると思いますが今回は省いています。
name: Build and Deploy
on:
repository_dispatch:
pull_request:
push:
branches:
- main
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [18.x]
env:
NOTION_CACHE: true
NOTION_TOKEN: ${{ secrets.NOTION_TOKEN }}
NOTION_DBID: ${{ secrets.NOTION_DBID }}
steps:
- uses: actions/checkout@v3
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v3
with:
node-version: ${{ matrix.node-version }}
cache: 'npm'
- name: Run builds and deploys with ${{ matrix.node-version }}
run: |
yarn install && yarn build && yarn export
deploy:
runs-on: ubuntu-latest
needs: [build]
if: github.ref == 'refs/heads/main'
env:
NOTION_CACHE: true
NOTION_TOKEN: ${{ secrets.NOTION_TOKEN }}
NOTION_DBID: ${{ secrets.NOTION_DBID }}
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version: '18'
cache: 'npm'
- name: Run next export
run: |
yarn install && yarn build && yarn export
- name: deploy by scp
uses: appleboy/scp-action@master
with:
host: ${{ secrets.SSH_HOST }}
username: ${{ secrets.SSH_USERNAME }}
password: ${{ secrets.SSH_PASSWORD }}
port: ${{ secrets.SSH_PORT }}
source: "out/*"
target: "web"
strip_components: 1
ロリポップで提供されるドメインは web
直下のパスが固定となっているため、Next.jsが作る out
ディレクトリを取り除くためのオプションである strip_components
が trueにしています。さらに、GitHub ActionsのSecretを GitHubのリポジトリの設定から登録します。設定するのは、ローカルの .env
に設定したNotionのAPIトークンやデータベースIDと、SCPのためのSSH接続情報です。SSH接続情報は、ロリポップのSSHページに掲載されています。
出来たものを gitコミットし、GitHubのリポジトリにPushします。すると、Workflowが動いてビルドとデプロイが行われました。
デプロイ先を見ると正しく機能していることがわかります。
Notionのコンテンツを更新した際は、GitHub ActionsのGUIから最新のDeployの Re-run
ボタンを押すか、workflowに仕込んだhookである repository_dispatch
をHTTP Postで発火させることでデプロイが行われます。残念ながら、今のところNotionに Outgoing-webhook的なことを設定できませんので、デプロイを自動化したい場合は、Google App Scriptなどを用いて監視しデプロイを自動でできるようにするなどの対応になると思います。普通に、 Re-run
ボタンでいいと思いますが。
curl -X POST -H "Authorization: token <your-token>" \
-H "Accept: application/vnd.github.everest-preview+json" \
-d "{\"event_type\":\"Deploy the latest content on notion\"}" \
-i https://api.github.com/repos/<your-name>/<repo-name>/dispatches
ひと工夫
Next.jsはdefaultだとtrailing slash無しです。通常、Apacheを提供するホスティングサービスはtrailing slashになっているため、Next.jsがexportしたファイルとApacheのルーティングが合わずRootページ以外で404になってしまいます。Next.jsの設定でtrailing slashにすることはできますが、Apache側をtrailing slash無しの振る舞いになるように以下の内容で .htaccess
を配置します。厳密に言うと、ロリポップのハイスピードプランは ApacheではなくApache互換なLitespeedです。また、合わせてHTTPをHTTPSにするルールも入れています。
DirectoryIndex index.html
DirectorySlash Off
ErrorDocument 404 /404.html
RewriteEngine On
# REWRITE to HTTPS
RewriteCond %{HTTPS} off
RewriteRule ^(.*)$ https://%{HTTP_HOST}%{REQUEST_URI} [R=301,L]
# REMOVE TRAILING SLASH
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^(.*)/$ /$1 [R=301,L]
# REWRITE to index.html
RewriteCond %{REQUEST_URI} "^/$"
RewriteRule ^(.*)$ /index.html [L]
# REWRITE to .html
RewriteCond %{REQUEST_URI} !index\.html$
RewriteCond %{REQUEST_URI} !^.*\.html$
RewriteCond %{REQUEST_FILENAME} !-f
RewriteRule ^(.*)$ %{REQUEST_FILENAME}.html [L]
まとめ
ロリポップとNotion、GitHubを使うことで、ハイパフォーマンスでHeadless CMSのセキュリティがNotionに担保されているという、もろもろがマネージドになり、運用トータルで超安価なサイトを作ることができました。私がNotionヘビーユーザとして一番のメリットは、Notion上で普段の作業からシームレスにサイトの記事を書けるというところです。
Notionには豊富な機能があり、それらが簡単に扱える様GUIのデザインがとても考えられて提供されています。そのため、Notionを扱うのはとても簡単なのですが、NotionのAPIを扱うとなると、途端にNotionの複雑性と対峙することになります。それらをNext.jsから扱うとなると、結構厄介な作業が必要になりますが、今回、 Notionate
を使うことで手軽にNotionをHeadless CMSとしてサイトを作ることができました。Notionのデータベースは、カラムの型にバリエーションがあり、それらを全てAPIで操作することが可能です。Notionという素晴らしいプロダクトに隠されたフルマネージドなDatabase as a Serviceを使いこなし、安価で安全なサイトを作っていきましょう。
今回の成果物
作成したサイトとリポジトリは以下です。
元になった Notionは以下のリンク先です。
裏話になりますが、 Notionate
は私が今回作ったパッケージです。バグや機能追加などがあればissueに報告お願いします!
また、合わせて作った Notionとホスティングサービスで使える 問い合わせフォームAPIは、後日、私の個人ブログで紹介する予定です。