🐡

メンテナンスし辛いMovableTypeをHeadlessCMSとして使えるようにAWS DynamoDB, S3を使って改修する

43 min read

10年漬けのMovableTypeとか触りたくないですよね

そして10年も記事がたまってると静的ファイルを生成する仕組みなので全再構築するとサーバースペックによっては1日以上かかり何万件・何十万件ものファイルが生成されたりします。

MobableTypeとウェブサーバーが別の場合は、rsyncで同期することが多いと思いますが、rsyncに負荷がかかりアクセスがない時でも重くなったりします。
また、MobableType管理外のファイルと管理下のファイルの見分けがつかないことや、生成されたHTMLもPHPを使って中途半端にSSRされていたり、運用コストが大変しんどくなっていきます。

MovableTypeのMTテンプレート自体もどんな処理がされるのか一目でわからなかったり、記事Modelが曖昧でバグが出やすくメンテナンスがしづらい欠点があります。テンプレートからバッチ処理が走るような記述が書かれていることもあります。

Data APIを使ったAjaxの呼び出しもMovableTypeサーバー本体に負荷がかかりやすく重くなることが多いです。

そうした中で記事内のドキュメントデータを構造把握しやすく高負荷でも耐えるヘッドレスCMSの仕組みをMovableTypeに導入していきましょうというのが本記事です。

ヘッドレスCMS化としてまずMovableTypeに以下の機能をつけます。

  • MovableTypeから画像をアップロードするとS3にアップロード
  • 記事の再構築・静的ファイルの生成を止める
  • 記事・カテゴリ・サイトの投稿、変更、削除が行われたらWebhook URLに通知する

MovableTypeから画像がS3にアップロードされて、静的ファイルの生成を止めれば
あとはMovableTypeにあるData APIを使えばHeadlessCMSとして使えます。
ただ、Data APIをそのまま使うとMovableTypeサーバー負荷となり投稿そのものができなくなることがあります。
そこでWebhookを利用してAWSのDynamoDBに取得した記事データを入れDynamoDBから参照するように変更します。
カテゴリ、サイト情報も同様にDynamoDB経由で取得できるようにします。
DynamoDBに入れておけばAppSyncでGraphQL経由で使うようにすることもできますし、負荷にも強くなりオススメです。。

これで記事投稿するまでがMovableTypeの仕事になり、SSRや静的生成の依存がMovableTypeからDynamoDBとS3となる骨格が出来上がります。

実際に開発環境を作ってDynamoDBからSSRするまでをやってみましょう。

開発環境をつくる

Dockerを使って開発環境を作りましょう。
既存の運用中の開発環境をいきなり用意しようとするとそれなりに大掛かりになります、まずは初期状態のMT環境を用意しましょう。
今回は古いMTを改修するということでMT6.7系のMovableTypeの環境を作成します。

Dockerのインストールは

などを参照してみてください。

Dockerの準備

Docker Composeを使ってローカルで動く雛形をGithubに用意しましたのでこれを使います。

git clone git@github.com:mozquito/mt_docker.git

下記のようなファイルが展開されます。

Tree図
├── docker
│   ├── httpd
│   │   ├── Dockerfile
│   │   ├── default.conf
│   │   └── docker-php-entrypoint
│   └── movabletype
│       ├── Dockerfile
│       └── docker-mt-entrypoint
├── README.txt
├── docker-compose.yml
├── mt-config.cgi
└── volumes
    ├── plugins
    └── web1

MovableTypeはライセンスの関係で事前にソースをダウンロードしたMovableTypeをもとにDockerをビルドする必要があります。

手元にMovableTypeがない場合はMovableType開発者登録をして開発者用ライセンスを取得し、MovableTypeをダウンロードします。
MT6.7系のダウンロードするには別途申請が必要なことがあります。
商業利用する場合は別途有償ライセンスが必要です。

  1. ダウンロードしたMovableTypeをmtとリネーム
  2. 直下にあるmt-config.cgiをmtフォルダに移動
  3. docker/movabletypeの中にmtフォルダを移動

これで一旦準備が整いましたのでdocker-compose.ymlがあるディレクトリに移動します。

docker-compose up

としhttp://localhost/mt/mt.cgi にアクセスしてみてください。
MovableTypeが立ち上がります。

docker-compose.ymlの構成では

  • MovableTypeをPSGIで起動
  • Apache,Mysqlを起動
  • enviromentsのパラメータでmt-config.cgiの設定書き換え
  • localhostがDocker Apache上の/var/www/web1を見るように設定
  • Reverse Proxyを使いhttp://localhost/mt/mt.cgi でMovabletypeが動くように設定
  • Apache,MovableTypeの/var/www/web1をvolumes設定でローカル上の./volumes/web1と同期

などの設定が行われています。

MovableTypeの初期設定

ユーザーを作成します

サイトを設定します

ウェブサイトパスを/var/www/web1として後はそのままにしてインストールします。

インストールが終わったら一旦全再構築をして表示されるか確かめます。

ローカル側の./volumes/web1にもファイルが生成されていたらインストール完了です。

Pluginをインストール

  • MovableTypeから画像をアップロードするとS3に同期
  • 記事の再構築と静的ファイルの生成を止める
  • 記事・カテゴリ・サイトの投稿、変更、削除が行われたらWebhookで通知する

この3つの機能をPlugin化してでっち上げていますので今回はこれを使います。
こちらをクリックしてダウンロードします
ダウンロードして解凍したフォルダをHeadlessCMSPack(適当です)とリネームし./volumes/pluginsにディレクトリを移動させます。

PSGI下では

  • インストール/アップグレードの完了時
  • システム設定の変更
  • カスタムフィールドの追加/削除

などの設定変更時にのみPluginの反映(再読み込み)がされます(PIDFilePathの設定が必要で、今回のDocker上では設定済みです)。
即時反映はされないので注意が必要です。

システムのプラグイン設定に移動しプラグインを利用しないボタンを押してプラグインを無効化します。

するとプラグインが全てOffになるとともにHeadlessCMSPackという項目が出てきます(反映されない場合は再読み込みしてください)。

プラグインを利用するボタンを押しプラグインを有効化させたあと、HeadlessCMSPackも有効にします(有効ボタンを押した後、再読み込みする後に反映されることが多いです)。

まだ詳細設定がありますがこれでHeadlessCMSPackプラグインのインストールができました。

静的ファイルが生成されないか確認

HeadlessCMSPackプラグインをインストールしてプラグインを有効にすると、再構築やテンプレートからファイルが書き出されなくなります。ローカルの./volumes/web1内のファイルやディレクトリを全て削除しFirst Websiteを全再構築してみます。全再構築後./volumes/web1内には何もファイルが作成されてないはずです。First Websiteのデザイン(テンプレート)に移動し


インデックステンプレートを全て公開にしてもファイルが作成されないことを確認します。
これで見かけ上はHeadlessCMSとなりました。

S3に画像を同期

次にS3に画像がアップロードされる仕組みを作ります。

S3やDynamoDBを扱うのにここではServerlessFrameworkという構成管理ツールを使います。NodeJS製なのでNodeのインストールが必要です。ServerlessFrameworkをインストールしてない場合はNodeインストール済みの状態で

npm install -g serverless

としてインストールします。
また、AWS CLIのインストール・設定も必要です。
AWS CLIのインストールは公式などを参照してください。

AWS CLIの権限は様々なリソース作成などができるように管理者権限となるAdministratorAccessで始めることが多いです(本番など用途が限定される場合はその権限のみ付与することもあります)。

docker-composer.ymlのあるディレクトリに移動し

serverless create --template aws-nodejs --path mt-sls

とコマンドを叩き、ServerlessFrameworkの雛形を作成します。mt-slsは適当につけたServerlessFramework上での名前になります。

cd ./mt-sls

と移動しserverless.ymlを開き下記のように修正します。

serverless.yml

service: mt-sls
frameworkVersion: '2'

provider:
  name: aws
  runtime: nodejs12.x
  lambdaHashingVersion: 20201221
  region: ap-northeast-1

resources:
 Resources:
   MtUpload:
     Type: AWS::S3::Bucket
     Properties:
       BucketName: movabletype-xxxx # xxxxにはS3上でBucketNameが一意となる文字を入れる

編集し終えたら

sls deploy

とコマンドを叩きS3のバケットを作成します。

今回はlocalhost上からS3にアップロードするため、AmazonS3FullAccess権限のあるユーザーを作成し アクセスキーとシークレットアクセスキーを用意します。

https://qiita.com/miwato/items/291c7a8c557908de5833

用意ができたらサイト、First Website側のプラグイン設定にいきHeadlessCMSPackの詳細設定を開きます。


アクセスキーとシークレットキーと順に入れ、
Regionにはap-northeast-1
Bucektにはserverless.ymlのBucketNameに入れた値を入力して保存してください。

MovableTypeサーバーがEC2上にある場合はアクセスキーとシークレットキーを空欄にしてEC2にS3権限を持っているロールを付与してください

保存し終えたらFirst Websiteのアイテム(新規)に移動します。
ここからテストとして適当な画像をアップロードしてS3上にアップロードされていたら設定完了です。

DynamoDBを作成

MovableTypeからDynamoDBに記事を保存・更新・削除される仕組みを作ります。

  • テスト投稿する
  • DataAPIからどのようなJSONが取得できるか確認する
  • DynamoDBを設計する

という手順を行います。

テスト投稿する

どのようなデータが取れるのかテスト投稿します。

  1. カテゴリをtest1、test2と作成しチェックボックスをつけます。
  2. タグも@page,tag1,tag2,タグ3と入れてみます。
  3. 本文に画像と適当な文字列
  4. 続きにも適当な文字列
  5. 記事アイテムにも画像をいくつかセットします。

以上を入力して投稿してみましょう。

記事はプラグインの機能により出力されないのでData APIで内容を確認します。

Data APIの確認

まずFirst Websiteの管理画面の左メニューから設定→Webサービスと移動します。
Data API の設定にチェックが入ってるかどうかを確認します。
チェックが入ってなければチェックして保存します。

記事データ

http://localhost/mt/mt-data-api.cgi/v3/sites/{site_id}/entries/{entry_id}

サイト情報

http://localhost/mt/mt-data-api.cgi/v3/sites/{site_id}

カテゴリ情報

http://localhost/mt/mt-data-api.cgi/v3/sites/{site_id}/categories/{category_id}
でアクセスできます。

初投稿の記事は初期値のままだとsite_idが1でentry_idが3となります(初期値ではウェブページ用の記事が2本下書投稿されてます)。
http://localhost/mt/mt-data-api.cgi/v3/sites/1/entries/3
にアクセスして値を確認します。

実際の記事データJSON
{
  "id": 3,
  "class": "entry",
  "title": "test",
  "permalink": "http://localhost/2021/01/test.html",
  "body": "<p>test</p>\n<p>test</p>",
  "more": "<p>test<img alt=\"ByiHDionTA4.jpeg\" src=\"http://localhost/ByiHDionTA4.jpeg\" width=\"1080\" height=\"809\" class=\"mt-image-none\" /></p>",
  "excerpt": "test test...",
  "author": {
    "displayName": "admin",
    "userpicUrl": null
  },
  "keywords": "",
  "tags": [
    "@page",
    "tag1",
    "tag2",
    "タグ3"
  ],
  "status": "Publish",
  "basename": "test",
  "customFields": [],
  "categories": [
    {
      "parent": "0",
      "id": 2,
      "label": "test1"
    },
    {
      "id": 3,
      "parent": "0",
      "label": "test2"
    }
  ],
  "blog": {
    "id": "1"
  },
  "createdDate": "2021-01-14T04:03:11+09:00",
  "modifiedDate": "2021-01-14T04:09:55+09:00",
  "date": "2021-01-14T03:55:42+09:00"
  "assets": [
    {
      "mimeType": "image/jpeg",
      "fileExtension": "jpeg",
      "blog": {
        "id": "1"
      },
      "label": "ByiHDionTA4.jpeg",
      "parent": null,
      "tags": [],
      "id": "21",
      "description": null,
      "createdBy": {
        "displayName": "admin",
        "userpicUrl": null
      },
      "updatable": false,
      "class": "image",
      "url": "http://localhost/ByiHDionTA4.jpeg",
      "customFields": [
        {
          "value": null,
          "basename": "assets"
        }
      ],
      "createdDate": "2021-01-14T03:57:39+09:00",
      "meta": {
        "height": "809",
        "fileSize": 52536,
        "width": "1080"
      },
      "type": "画像",
      "filename": "ByiHDionTA4.jpeg",
      "modifiedDate": "2021-01-14T03:57:39+09:00"
    },
    {
      "description": null,
      "id": "22",
      "parent": null,
      "tags": [],
      "updatable": false,
      "class": "image",
      "createdBy": {
        "userpicUrl": null,
        "displayName": "admin"
      },
      "blog": {
        "id": "1"
      },
      "fileExtension": "jpeg",
      "mimeType": "image/jpeg",
      "label": "B-HOT1JnVHi.jpeg",
      "meta": {
        "height": "1350",
        "fileSize": 91264,
        "width": "1080"
      },
      "type": "画像",
      "modifiedDate": "2021-01-14T04:09:44+09:00",
      "filename": "B-HOT1JnVHi.jpeg",
      "customFields": [
        {
          "value": null,
          "basename": "assets"
        }
      ],
      "createdDate": "2021-01-14T04:09:44+09:00",
      "url": "http://localhost/B-HOT1JnVHi.jpeg"
    },
    {
      "tags": [],
      "parent": null,
      "id": "23",
      "description": null,
      "createdBy": {
        "userpicUrl": null,
        "displayName": "admin"
      },
      "updatable": false,
      "class": "image",
      "mimeType": "image/jpeg",
      "fileExtension": "jpeg",
      "blog": {
        "id": "1"
      },
      "label": "B-HOT1JHjS8.jpeg",
      "meta": {
        "height": "1350",
        "width": "1080",
        "fileSize": 88421
      },
      "type": "画像",
      "modifiedDate": "2021-01-14T04:09:44+09:00",
      "filename": "B-HOT1JHjS8.jpeg",
      "url": "http://localhost/B-HOT1JHjS8.jpeg",
      "customFields": [
        {
          "basename": "assets",
          "value": null
        }
      ],
      "createdDate": "2021-01-14T04:09:44+09:00"
    },
    {
      "blog": {
        "id": "1"
      },
      "fileExtension": "jpeg",
      "mimeType": "image/jpeg",
      "label": "B-KSEmmn2cw.jpeg",
      "description": null,
      "id": "24",
      "tags": [],
      "parent": null,
      "updatable": false,
      "createdBy": {
        "userpicUrl": null,
        "displayName": "admin"
      },
      "class": "image",
      "customFields": [
        {
          "basename": "assets",
          "value": null
        }
      ],
      "createdDate": "2021-01-14T04:09:45+09:00",
      "url": "http://localhost/B-KSEmmn2cw.jpeg",
      "type": "画像",
      "meta": {
        "height": "1350",
        "width": "1080",
        "fileSize": 112175
      },
      "modifiedDate": "2021-01-14T04:09:45+09:00",
      "filename": "B-KSEmmn2cw.jpeg"
    }
  ],
  "pingsSentUrl": [],
  "trackbackCount": "0",
  "allowTrackbacks": false,
  "updatable": false,
  "comments": [],
  "allowComments": true,
  "trackbacks": [],
  "commentCount": "0",
}
  "pingsSentUrl": [],
  "trackbackCount": "0",
  "allowTrackbacks": false,
  "updatable": false,
  "comments": [],
  "allowComments": true,
  "trackbacks": [],
  "commentCount": "0",

まず上記の値は、今回はトラックバックやコメント機能は使わないので考えません。

記事のカテゴリは

  "categories": [
    {
      "parent": "0",
      "id": 2,
      "label": "test1"
    },
    {
      "id": 3,
      "parent": "0",
      "label": "test2"
    }
  ],

となっておりカスタムフィールドやディスクリプションなどの値は取れておらずでラベルと親子のidのみの出力となっています。記事のデザインや構成によっては別途DataAPIでカテゴリ情報を取りに行く必要があります。

  "tags": [
    "@page",
    "tag1",
    "tag2",
    "タグ3"
  ],

タグについては列挙されているだけでidなども無い単純な仕組みの様です。
@から始まるタグはシステムタグとしてテンプレートなどで使われるタグなので、記事に表示させないようにする必要があります。

あと大きなところではAssets、画像となります。

  "assets": [
    {
      "id": "21",
      "mimeType": "image/jpeg",
      "fileExtension": "jpeg",
      "blog": {
        "id": "1"
      },
      "label": "ByiHDionTA4.jpeg",
      "parent": null,
      "tags": [],
      "description": null,
      "createdBy": {
        "displayName": "admin",
        "userpicUrl": null
      },
      "updatable": false,
      "class": "image",
      "url": "http://localhost/ByiHDionTA4.jpeg",
      "customFields": [
      ],
      "createdDate": "2021-01-14T03:57:39+09:00",
      "meta": {
        "height": "809",
        "fileSize": 52536,
        "width": "1080"
      },
      "type": "画像",
      "filename": "ByiHDionTA4.jpeg",
      "modifiedDate": "2021-01-14T03:57:39+09:00"
    },
    ...
  ],

assetsのJSONを見るとasset情報自体は完全な形であるようです。
サムネイル画像は作成されていますがこちらには含まれていないので扱いをどうするか決める必要があります。

HeadlessCMS化するにあたってカテゴリやAsset、サイト情報が更新された場合、再構築のような手続きを必要とするかは予め考えておく必要があります。

今回は

  • 記事内のアセットの更新(カスタムフィールドなど)は記事更新が必要
  • カテゴリの更新などは記事に即時反映
  • サイト情報の更新は記事に即時反映

とします。

続いてサイト情報を見てみます。
http://localhost/mt/mt-data-api.cgi/v3/sites/1

{
  "parent": null,
  "id": "1",
  "class": "website",
  "name": "First Website",
  "host": "localhost",
  "url": "http://localhost/",
  "relativeUrl": "/",
  "language": "ja",
  "description": null,
  "archiveUrl": "http://localhost/",
  "timezone": "+09:00",
  "modifiedDate": "2021-01-13T17:55:56+09:00",
  "createdDate": "2021-01-13T17:55:08+09:00",
  "customFields": [],
  "ccLicenseUrl": "",
  "updatable": false,
  "dateLanguage": "ja",
  "pingOthers": [],
  "serverOffset": "9",
  "createdBy": {
    "displayName": "admin",
    "userpicUrl": null
  },
  "modifiedBy": {
    "userpicUrl": null,
    "displayName": "admin"
  },
  "ccLicenseImage": ""
}

取り敢えずタイトルとURLとDescriptionとカスタムフィールドあたりを抑えておけばよさそうです。

カテゴリ情報も見てみます。
http://localhost/mt/mt-data-api.cgi/v3/sites/1/categories/2

{
  "modifiedDate": "2021-01-14T03:55:50+09:00",
  "createdDate": "2021-01-14T03:55:50+09:00",
  "customFields": [
  ],
  "updatable": false,
  "createdBy": {
    "displayName": "admin",
    "userpicUrl": null
  },
  "class": "category",
  "description": null,
  "id": 2,
  "archiveLink": "http://localhost/test1/index.html",
  "parent": "0",
  "label": "test1",
  "blog": {
    "id": "1"
  },
  "basename": "test1"
}

archiveLinkのURLをどうするか以外はAssetと同じ様な構成の様です。

DynamoDBの設計

先ほどのData APIから取れる情報を元にDynamoDBの設計をしていきます。
テーブル数とインデックスを決めます。

Entry Table

まず記事とページですがこちらはもともとmt_entryというテーブルにあるのでEntryテーブルを作って入れることにします。
インデックスについてはentry_idとpermalinkをどちらからでも取得できるように作ります。
一覧についてはカテゴリやタグなど複数設定できるので一覧用のテーブルを作ります。
また、一覧から記事データをgetBatchされるのを考えentry_id(id)をプライマリキー、permalinkをGSIで設定します(GSIはscanとqueryでしか使えない)。

Hash Key
id(Number)
permalink-index(GSI)
Hash Key
Permalink(String)

ArchiveList Table

次に一覧ですがこちらはタグ、カテゴリ、月別アーカイブと3種類のアーカイブが考えられます。
Hash KeyとRange Keyの組み合わせで1つのテーブルにまとめれそうなので
MTのテンプレートタグの名前に合わせてArchiveListテーブルとして一つにまとめます。

Hash Key Range Key attribute
archive_key(String) date id(entry_id

archive_keyには下記のような値が入ります。

archive_key
tag_{blog_id}_{tagname}
category_{blog_id}_{category_id}
folder_{folder_id}
month_{YYYY-MM}
month_{blog_id}_{YYYY-MM}
month_{blog_id}{category_id}{YYYY-MM}
entry-index(GSI)
Hash Key Range Key
id date

Asset Table

AssetですがAssetテーブルとします。今回では使わない。

Hash Key
id(asset_id) (Number)

Meta Table

残りのカテゴリ情報とサイト情報はKey-Valueとしてしか使わない予定なのでMetaテーブルとして1つにまとめます。そのほかに必要なKey-Valueデータがあればこちらに入れるようにします。

Hash Key
meta_key (String)

meta_keyの値は下記のような形にします。

meta_key
site_{site_id}
category_{catgory_id}
その他あれば拡張する

mt-sls/serverless.ymlを開きDynamoDBの構成を編集します。

provider:
  name: aws
  runtime: nodejs12.x
  lambdaHashingVersion: 20201221
  region: ap-northeast-1
resources:
  Resources:
    MtUpload:
      Type: AWS::S3::Bucket
      Properties:
        BucketName: movabletype-xxxx # xxxxにはS3上でBucketNameが一意となる文字を入れる
    EntryTable:
      Type: AWS::DynamoDB::Table
      Properties:
        TableName: Entry
        AttributeDefinitions:
          - AttributeName: permalink
            AttributeType: S
          - AttributeName: id
            AttributeType: N
        KeySchema:
          - AttributeName: id
            KeyType: HASH
        GlobalSecondaryIndexes: 
            - IndexName: Entry
              KeySchema: 
                - AttributeName: permalink
                  KeyType: HASH
              Projection: 
                ProjectionType: ALL
              ProvisionedThroughput: 
                ReadCapacityUnits: 1
                WriteCapacityUnits: 1
        ProvisionedThroughput:
          ReadCapacityUnits: 1
          WriteCapacityUnits: 1
    ArchiveListTable:
      Type: AWS::DynamoDB::Table
      Properties:
        TableName: ArchiveList
        AttributeDefinitions:
          -
            AttributeName: archive_key
            AttributeType: S
          -
            AttributeName: date
            AttributeType: S
        KeySchema:
          -
            AttributeName: archive_key
            KeyType: HASH
          -
            AttributeName: date
            KeyType: RANGE
        ProvisionedThroughput:
          ReadCapacityUnits: 1
          WriteCapacityUnits: 1
    AssetTable:
      Type: AWS::DynamoDB::Table
      Properties:
        TableName: Asset
        AttributeDefinitions:
          -
            AttributeName: id
            AttributeType: N
        KeySchema:
          -
            AttributeName: id
            KeyType: HASH
        ProvisionedThroughput:
          ReadCapacityUnits: 1
          WriteCapacityUnits: 1
    MetaTable:
      Type: AWS::DynamoDB::Table
      Properties:
        TableName: Meta
        AttributeDefinitions:
          -
            AttributeName: meta_key
            AttributeType: S
        KeySchema:
          -
            AttributeName: meta_key
            KeyType: HASH
        ProvisionedThroughput:
          ReadCapacityUnits: 1
          WriteCapacityUnits: 1

Serverless Frameworkからdeployします。

sls deploy

これでDynamoDBのTableが完成しました。

DynamoDBとMovableTypeの連携

次にMovableTypeに投稿したらDynamoDBに記事データが挿入される仕組みを作ります。まずWebhook用URL作成しましょう。

Webhook URLを作成する

Webhook URLをAPI GatewayとLambdaを使ってURLを作成します。

URL 機能
/entry/update DynamoDBに記事データを作成・アップデート 一覧更新
/entry/delete DynamoDBの記事データを削除 一覧から削除
/category/update DynamoDBにカテゴリデータを作成・アップデート
/category/delete DynamoDBにカテゴリデータを削除
/site/update DynamoDBにサイトデータを作成・アップデート
/site/delete DynamoDBにサイトデータを削除
/asset/update DynamoDBにAssetデータを作成・アップデート
/asset/delete DynamoDBにAssetデータを削除、S3の画像も削除

以上のようなwebhook urlを作ります。
まずはURLを作成してみましょう。

serverless.yml
provider:
  name: aws
  runtime: nodejs12.x
  lambdaHashingVersion: 20201221
  region: ap-northeast-1

この下にLambdaの記述とeventの設定を書きます。

functions:
  webhook:
    handler: handler.hello 
    events:
      - httpApi:
          path: /entry/update
          method: POST
      - httpApi:
          path: /entry/delete
          method: POST
      - httpApi:
          path: /category/update
          method: POST
      - httpApi:
          path: /category/delete
          method: POST
      - httpApi:
          path: /site/update
          method: POST
      - httpApi:
          path: /site/delete
          method: POST
      - httpApi:
          path: /asset/update
          method: POST
      - httpApi:
          path: /asset/delete
          method: POST

まずは一通りのURLを作成します。機能は後で付加していきます。
この状態で一旦deployします。

sls deploy

すると以下のような感じでAPI GatewayのURLが作成されます。
(**********には作成したAPI GatewayのIDが入ります)

endpoints:
  POST - https://**********.execute-api.ap-northeast-1.amazonaws.com/entry/update
  POST - https://**********.execute-api.ap-northeast-1.amazonaws.com/entry/delete
  POST - https://**********.execute-api.ap-northeast-1.amazonaws.com/category/update
  POST - https://**********.execute-api.ap-northeast-1.amazonaws.com/category/delete
  POST - https://**********.execute-api.ap-northeast-1.amazonaws.com/site/update
  POST - https://**********.execute-api.ap-northeast-1.amazonaws.com/site/delete
  POST - https://**********.execute-api.ap-northeast-1.amazonaws.com/asset/update
  POST - https://**********.execute-api.ap-northeast-1.amazonaws.com/asset/delete

URLができたのでMovableTypeのwebhookを設定しにいきます。

Webhookを設定

MovableTypeにアクセスしFirst Website内のPlugin設定→HeadlessCMSPackに移動します。
ここで先ほどのWebhook URLを設定します。

動作確認

handler.jsにconsole.log(event)を仕込んで内容を確認します。

module.exports.hello = async (event) => {
  console.log(event)
  return {
    statusCode: 200,
    body: JSON.stringify(
      {
        message: 'Go Serverless v1.0! Your function executed successfully!',
        input: event,
      },
      null,
      2
    ),
  };

  // Use this code if you don't use the http event with the LAMBDA-PROXY integration
  // return { message: 'Go Serverless v1.0! Your function executed successfully!', event };
};

sls deploy

lambdaをアップデートしてから、
記事を更新しCloudWatchで/aws/lambda/mt-sls-dev-webhookのログを見ます。

...
   body: '{"pingsSentUrl":[],"keywords":"","allowTrackbacks":false,"trackbackCount":"0",...',
   isBase64Encoded: false
}

Webhook URLが動作していることとbodyにentryのJSONデータが入ってることが確認できます。

DynamoDBの保存・削除を実装

Webhook URLが動作していることが確認できましたのでDynamoDBの保存・削除を実装を実装します。
まずはLambdaに4つのテーブル(Entry,ArchiveList,Asset,Meta)で読み書きと検索のscanとqueryができるように権限をにつけます。
serverless.ymlのprovider欄を編集します。

provider:
  name: aws
  runtime: nodejs12.x
  lambdaHashingVersion: 20201221
  region: ap-northeast-1
  iamRoleStatements:
      - Effect: Allow
        Action:
          - dynamodb:Query
          - dynamodb:Scan
          - dynamodb:GetItem
          - dynamodb:PutItem
          - dynamodb:UpdateItem
          - dynamodb:DeleteItem
        Resource:
          - { "Fn::GetAtt": ["EntryTable", "Arn"] }
          - { "Fn::GetAtt": ["ArchiveListTable", "Arn"] }
          - { "Fn::GetAtt": ["AssetTable", "Arn"] }
          - { "Fn::GetAtt": ["MetaTable", "Arn"] }

{ "Fn::GetAtt": ["EntryTable", "Arn"] }はserverless.yml内に書かれたResorcesに指定されたIDのARNを書き出すCloudformationの組み込み関数を利用して書いています。これを使うとDynamoDBのarnの値を知らなくてもResourceを指定できます。。

endpoints:
  POST - https://**********.execute-api.ap-northeast-1.amazonaws.com/entry/update
  POST - https://**********.execute-api.ap-northeast-1.amazonaws.com/entry/delete
  POST - https://**********.execute-api.ap-northeast-1.amazonaws.com/category/update
  POST - https://**********.execute-api.ap-northeast-1.amazonaws.com/category/delete
  POST - https://**********.execute-api.ap-northeast-1.amazonaws.com/site/update
  POST - https://**********.execute-api.ap-northeast-1.amazonaws.com/site/delete
  POST - https://**********.execute-api.ap-northeast-1.amazonaws.com/asset/update
  POST - https://**********.execute-api.ap-northeast-1.amazonaws.com/asset/delete

次にこの8つのURLを実装していきしょう。
まずアーカイブの月別アーカイブを実装するため
mt-slsディレクトリに移動し

npm init

とします。コマンド上で色々聞かれますがデフォルトのままで進みます。

npm install dayjs --save

としてdayjsをインストールします。

/entry/update

/entry/update

  • 記事statusがPublishの時にEntryに記事データを挿入
  • ArchiveListの記事と一致するアーカイブの削除(初期化)
  • ArchiveListにタグアーカイブ、カテゴリーアーカイブ、月アーカイブの参照データを作成
  • 記事statusがPublish意外だった場合、Entryの該当記事データを削除、ArchiveList内のentry_idが一致するアーカイブを削除

以上を実装します。

'webhookEntryUpdate'というlambda関数を追加し

 - httpApi:
          path: /entry/update
          method: POST

上のwebhookにあったevent設定をwebhookEntryUpdateに移動します。

functions:
  webhookEntryUpdate:
    handler: handler.entryUpdate 
    events:
      - httpApi:
          path: /entry/update
          method: POST
handler.jsに以下のコードを追記します。。
module.exports.entryUpdate = async (event) => {
  const data = JSON.parse(event.body);
  const day = dayjs(data.date)
  if (data.status === 'Publish') {
    await docClient.put(
      {
        TableName: "Entry",
        Item: data
      }
    ).promise();

    // 該当アーカイブをリセット
    const deleteItems = await docClient.query(
      {
        TableName: "ArchiveList",
        IndexName: 'entryid-index',
        ExpressionAttributeNames: { '#id': 'id' },
        ExpressionAttributeValues: { ':id': data.id },
        KeyConditionExpression: '#id = :id'
      }
    ).promise();
    const batchDeleteRequest = []
    deleteItems.Items.forEach(item => {
      batchDeleteRequest.push({
        DeleteRequest: {
          Key: {
            archive_key: item.archive_key
          }
        }
      })
    })
    await docClient.batchWirte({
      RequestItems: {
        ArchiveList: batchRequest
      }
    }).promise()

    const batchRequest = []
    // カテゴリアーカイブ
    data.categories.forEach(cat => {
      batchRequest.push({
        PutRequest: {
          id: data.id,
          archive_key: 'category_' + data.blog.id + '_' + cat.id,
          date: data.date
        }
      })
      batchRequest.push({
        PutRequest: {
          id: data.id,
          archive_key: 'month_' + data.blog.id + '_' + cat.id + '_' + day.format('YYYY-MM'),
          date: data.date
        }
      })
    })
    // タグアーカイブ
    data.tags.forEach(tagname => {
      if (!tagname.match(/^@/)) {
        batchRequest.push({
          PutRequest: {
            id: data.id,
            archive_key: 'tag_' + data.blog.id + '_' + tagname,
            date: data.date
          }
        })
        batchRequest.push({
          PutRequest: {
            id: data.id,
            archive_key: 'month_' + data.blog.id + '_' + tagname + '_' + day.format('YYYY-MM'),
            date: data.date
          }
        })
      }
    })
    // 月アーカイブ
    batchRequest.push({
      PutRequest: {
        id: data.id,
        archive_key: 'month_' + data.blog.id + '_' + day.format('YYYY-MM'),
        date: data.date
      }
    })
    await docClient.batchWirte({
      RequestItems: {
        ArchiveList: batchRequest
      }
    }).promise()
  } else {
    await docClient.delete(
      {
        TableName: "Entry",
        Key: {
          id: data.id
        }
      }
    ).promise();
    // 該当アーカイブをリセット
    const deleteItems = await docClient.query(
      {
        TableName: "ArchiveList",
        IndexName: 'entryid-index',
        ExpressionAttributeNames: { '#id': 'id' },
        ExpressionAttributeValues: { ':id': data.id },
        KeyConditionExpression: '#id = :id'
      }
    ).promise();
    const batchDeleteRequest = []
    deleteItems.Items.forEach(item => {
      batchDeleteRequest.push({
        DeleteRequest: {
          Key: {
            archive_key: item.archive_key
          }
        }
      })
    })
    await docClient.batchWirte({
      RequestItems: {
        ArchiveList: batchRequest
      }
    }).promise()
  }
  return {
    statusCode: 200,
    body: JSON.stringify(
      {
        message: 'successfully!',
        input: event,
      },
      null,
      2
    ),
  };
};

その他URL

  • /entry/delete
  • /category/update
  • /category/delete
  • /site/update
  • /site/delete
  • /asset/update
  • /asset/delete

上記は特定のテーブルデータを追加・更新。削除です。

serverless.ymlとhandler.jsは以下のようになります。
serverless.yml

service: mt-sls
frameworkVersion: '2'

provider:
  name: aws
  runtime: nodejs12.x
  region: ap-northeast-1
  lambdaHashingVersion: 20201221
  iamRoleStatements:
      - Effect: Allow
        Action:
          - dynamodb:Query
          - dynamodb:Scan
          - dynamodb:GetItem
          - dynamodb:PutItem
          - dynamodb:UpdateItem
          - dynamodb:DeleteItem
        Resource:
          - { "Fn::GetAtt": ["EntryTable", "Arn"] }
          - { "Fn::GetAtt": ["ArchiveListTable", "Arn"] }
          - { "Fn::GetAtt": ["AssetTable", "Arn"] }
          - { "Fn::GetAtt": ["MetaTable", "Arn"] }
functions:
  webhookEntryUpdate:
    handler: handler.entryUpdate 
    events:
      - httpApi:
          path: /entry/update
          method: POST
  webhookEntryDelete:
    handler: handler.entryDelete
    events:
      - httpApi:
          path: /entry/delete
          method: POST
  webhookCategoryUpdate:
    handler: handler.categoryUpdate 
    events:
      - httpApi:
          path: /category/update
          method: POST
  webhookCategoryDelete:
    handler: handler.categoryDelete
    events:
      - httpApi:
          path: /category/delete
          method: POST
  webhookAssetUpdate:
    handler: handler.assetUpdate 
    events:
      - httpApi:
          path: /asset/update
          method: POST
  webhookAssetDelete:
    handler: handler.assetDelete
    events:
      - httpApi:
          path: /asset/delete
          method: POST
  webhookSiteUpdate:
    handler: handler.siteUpdate 
    events:
      - httpApi:
          path: /site/update
          method: POST
  webhookSiteDelete:
    handler: handler.siteDelete
    events:
      - httpApi:
          path: /site/delete
          method: POST
resources:
  Resources:
    MtUpload:
      Type: AWS::S3::Bucket
      Properties:
        BucketName: movabletype-xxxx # xxxxにはS3上でBucketNameが一意となる文字を入れる
    EntryTable:
      Type: AWS::DynamoDB::Table
      Properties:
        TableName: Entry
        AttributeDefinitions:
          - AttributeName: permalink
            AttributeType: S
          - AttributeName: id
            AttributeType: N
        KeySchema:
          - AttributeName: id
            KeyType: HASH
        GlobalSecondaryIndexes: 
            - IndexName: permalink-index
              KeySchema: 
                - AttributeName: permalink
                  KeyType: HASH
              Projection: 
                ProjectionType: ALL
              ProvisionedThroughput: 
                ReadCapacityUnits: 1
                WriteCapacityUnits: 1
        ProvisionedThroughput:
          ReadCapacityUnits: 1
          WriteCapacityUnits: 1
    ArchiveListTable:
      Type: AWS::DynamoDB::Table
      Properties:
        TableName: ArchiveList
        AttributeDefinitions:
          -
            AttributeName: archive_key
            AttributeType: S
          -
            AttributeName: date
            AttributeType: S
          -
            AttributeName: id
            AttributeType: N
        KeySchema:
          -
            AttributeName: archive_key
            KeyType: HASH
          -
            AttributeName: date
            KeyType: RANGE
        GlobalSecondaryIndexes: 
            - IndexName: entry-index
              KeySchema: 
                - AttributeName: id
                  KeyType: HASH
                - AttributeName: date
                  KeyType: RANGE
              Projection: 
                ProjectionType: ALL
              ProvisionedThroughput: 
                ReadCapacityUnits: 1
                WriteCapacityUnits: 1
        ProvisionedThroughput:
          ReadCapacityUnits: 1
          WriteCapacityUnits: 1
    AssetTable:
      Type: AWS::DynamoDB::Table
      Properties:
        TableName: Asset
        AttributeDefinitions:
          -
            AttributeName: id
            AttributeType: N
        KeySchema:
          -
            AttributeName: id
            KeyType: HASH
        ProvisionedThroughput:
          ReadCapacityUnits: 1
          WriteCapacityUnits: 1
    MetaTable:
      Type: AWS::DynamoDB::Table
      Properties:
        TableName: Meta
        AttributeDefinitions:
          -
            AttributeName: meta_key
            AttributeType: S
        KeySchema:
          -
            AttributeName: meta_key
            KeyType: HASH
        ProvisionedThroughput:
          ReadCapacityUnits: 1
          WriteCapacityUnits: 1

handler.js
'use strict';
const AWS = require('aws-sdk')
const dayjs = require('dayjs')

const docClient = new AWS.DynamoDB.DocumentClient({ convertEmptyValues: true });
module.exports.entryUpdate = async (event) => {
  const data = JSON.parse(event.body);
  const day = dayjs(data.date)
  if (data.status === 'Publish') {
    await docClient.put(
      {
        TableName: "Entry",
        Item: data
      }
    ).promise();

    // 該当アーカイブをリセット
    const deleteItems = await docClient.query(
      {
        TableName: "ArchiveList",
        IndexName: 'entry-index',
        ExpressionAttributeNames: { '#id': 'id' },
        ExpressionAttributeValues: { ':id': data.id },
        KeyConditionExpression: '#id = :id'
      }
    ).promise();
    const batchDeleteRequest = []
    deleteItems.Items.forEach(item => {
      batchDeleteRequest.push({
        DeleteRequest: {
          Key: {
            archive_key: item.archive_key
          }
        }
      })
    })
    await docClient.batchWirte({
      RequestItems: {
        ArchiveList: batchRequest
      }
    }).promise()



    const batchRequest = []
    if (data.categories) {
      // カテゴリアーカイブ
      data.categories.forEach(cat => {
        batchRequest.push({
          PutRequest: {
            id: data.id,
            archive_key: 'category_' + data.blog.id + '_' + cat.id,
            date: data.date
          }
        })
        batchRequest.push({
          PutRequest: {
            id: data.id,
            archive_key: 'month_' + data.blog.id + '_' + cat.id + '_' + day.format('YYYY-MM'),
            date: data.date
          }
        })
      })
    }

    // タグアーカイブ
    if( data.tags ) {
      data.tags.forEach(tagname => {
        if (!tagname.match(/^@/)) {
          batchRequest.push({
            PutRequest: {
              id: data.id,
              archive_key: 'tag_' + data.blog.id + '_' + tagname,
              date: data.date
            }
          })
          batchRequest.push({
            PutRequest: {
              id: data.id,
              archive_key: 'month_' + data.blog.id + '_' + tagname + '_' + day.format('YYYY-MM'),
              date: data.date
            }
          })
        }
      })
    }
    // 月アーカイブ
    batchRequest.push({
      PutRequest: {
        id: data.id,
        archive_key: 'month_' + data.blog.id + '_' + day.format('YYYY-MM'),
        date: data.date
      }
    })
    await docClient.batchWirte({
      RequestItems: {
        ArchiveList: batchRequest
      }
    }).promise()
  } else {
    await docClient.delete(
      {
        TableName: "Entry",
        Key: {
          id: data.id
        }
      }
    ).promise();
    // 該当アーカイブをリセット
    const deleteItems = await docClient.query(
      {
        TableName: "ArchiveList",
        IndexName: 'entryid-index',
        ExpressionAttributeNames: { '#id': 'id' },
        ExpressionAttributeValues: { ':id': data.id },
        KeyConditionExpression: '#id = :id'
      }
    ).promise();
    const batchDeleteRequest = []
    deleteItems.Items.forEach(item => {
      batchDeleteRequest.push({
        DeleteRequest: {
          Key: {
            archive_key: item.archive_key
          }
        }
      })
    })
    await docClient.batchWirte({
      RequestItems: {
        ArchiveList: batchRequest
      }
    }).promise()
  }
  return {
    statusCode: 200,
    body: JSON.stringify(
      {
        message: 'successfully!',
        input: event,
      },
      null,
      2
    ),
  };
};
module.exports.entryDelete = async (event) => {
  const data = JSON.parse(event.body);
  await docClient.delete(
    {
      TableName: "Entry",
      Key: {
        id: data.id
      }
    }
  ).promise();
  return {
    statusCode: 200,
    body: JSON.stringify(
      {
        message: 'successfully!',
        input: event,
      },
      null,
      2
    ),
  };
};

module.exports.categoryUpdate = async (event) => {
  const data = JSON.parse(event.body);
  data.meta_key = 'category_' + data.id
  await docClient.put(
    {
      TableName: "Meta",
      Item: data
    }
  ).promise();
  return {
    statusCode: 200,
    body: JSON.stringify(
      {
        message: 'successfully!',
        input: event,
      },
      null,
      2
    ),
  };
};

module.exports.categoryDelete = async (event) => {
  const data = JSON.parse(event.body);
  await docClient.delete(
    {
      TableName: "Meta",
      Key: {
        meta_key: 'category_' + data.id
      }
    }
  ).promise();
  return {
    statusCode: 200,
    body: JSON.stringify(
      {
        message: 'successfully!',
        input: event,
      },
      null,
      2
    ),
  };
};

module.exports.assetUpdate = async (event) => {
  const data = JSON.parse(event.body);
  data.meta_key = 'asset_' + data.id
  await docClient.put(
    {
      TableName: "Asset",
      Item: data
    }
  ).promise();
  return {
    statusCode: 200,
    body: JSON.stringify(
      {
        message: 'successfully!',
        input: event,
      },
      null,
      2
    ),
  };
};
module.exports.assetDelete = async (event) => {
  const data = JSON.parse(event.body);
  await docClient.delete(
    {
      TableName: "Meta",
      Key: {
        meta_key: 'asset_' + data.id
      }
    }
  ).promise();
  return {
    statusCode: 200,
    body: JSON.stringify(
      {
        message: 'successfully!',
        input: event,
      },
      null,
      2
    ),
  };
};

module.exports.siteUpdate = async (event) => {
  const data = JSON.parse(event.body);
  data.meta_key = 'site_' + data.id
  await docClient.put(
    {
      TableName: "Meta",
      Item: data
    }
  ).promise();
  return {
    statusCode: 200,
    body: JSON.stringify(
      {
        message: 'successfully!',
        input: event,
      },
      null,
      2
    ),
  };
};
module.exports.siteDelete = async (event) => {
  const data = JSON.parse(event.body);
  await docClient.delete(
    {
      TableName: "Meta",
      Key: {
        meta_key: 'site_' + data.id
      }
    }
  ).promise();
  return {
    statusCode: 200,
    body: JSON.stringify(
      {
        message: 'successfully!',
        input: event,
      },
      null,
      2
    ),
  };
};


変更後

sls deploy

してMovableTypeを更新するとDynamoDBとS3に反映される仕組みが出来上がりました。

ようやく登り始めたばかりのはてしないHeadless CMSですが長くなったので一旦終わります。次回にDynamoDBを使ったSSRの仕組みを作っていきます。

Discussion

ログインするとコメントできます