ブログ

👶🏻 GraphQLでWordPressを若返らせる

Leonardo Losoviz
著者: Leonardo Losoviz ·

WordPressはレガシーCMSです。17年以上前に生まれたこのシステムは、今から作り直すとすれば違う形で書かれるであろうPHPコードで溢れています。

GraphQLはデータにアクセスするための現代的なインターフェースです。「インターフェース」という言葉に注目してください。GraphQLは、データシステムの内部実装がどうなっているかを気にせず、データをどのように公開するかにのみ関心を持ちます。

この2つを組み合わせると何が起きるのでしょうか?WordPressのデータにアクセスするためのGraphQLインターフェースはどのように設計すべきでしょうか?

大まかに言えば、2つの戦略が考えられます。

  1. 伝統を尊重し、WordPressのデータモデルをそのまま維持するマッピングを提供する(長年にわたって蓄積された技術的負債も含めて)

  2. 技術的負債を解消し、データを抽象的かつWordPressに縛られない形で公開するインターフェースを提供する

どちらのアプローチにも利点と欠点があり、どちらが正しいというわけではありません。ある振る舞いを別の振る舞いより優先する、主観的な選択に過ぎません。

プラグイン Gato GraphQL では後者のアプローチを選択し、WordPressをベースとしてWordPressのために動作しながらも、WordPressに縛られない(たとえば、一貫性のない名前や関係を取り除いた)GraphQLスキーマの作成を試みました。

その結果、GraphQLはWordPressを若返らせます。基盤のCMSとしてのWordPressはそのまま残り、レガシーなPHPコードも変わりませんが、データ層は伝統ではなく常識に基づいて新たに作り直せます。データ層は思春期から再び幼児期へと戻るのです。

GraphQL + WordPressは相性抜群

その結果はWordPressのデータモデルを表すGraphQLスキーマであり、ネストされたmutationもサポートしています。

どのように実現したかを見ていきましょう。

WordPressのデータモデル

WordPressには以下のエンティティがあります。

  • posts
  • pages
  • custom posts
  • メディア要素
  • users
  • user roles
  • tags
  • categories
  • comments
  • blocks
  • metaプロパティ
  • その他(options、plugins、themesなど)

これらのエンティティには階層を持つものがあります。たとえば、post、page、メディア要素はいずれもcustom post typesであり、tagsとcategoriesはどちらもタクソノミーです。

以下はWordPressのデータベース図であり、すべてのエンティティのデータがどのように保存されているかを示しています。

WordPressのデータベース図

マッピングはDBダイアグラムの完全な複製か?

WordPressのデータベースをGraphQLスキーマにマッピングする際、上記のダイアグラムを1対1で踏襲する必要があるのでしょうか?

いいえ、そうではありません。データベース図は実際の実装ですが、GraphQLはクライアントからデータにアクセスするためのインターフェースです。この2つは関係していますが、異なっていてもかまいません。GraphQLはデータベースを気にしません。SQLコマンドで考えたり、wp_postswp_usersというテーブルが存在することを知っている必要もないのです。

ですから、WordPressのGraphQLスキーマを作成する際にデータベース図をそのまま気にする必要はありません。つまり、WordPressのデータモデルが抱える技術的負債の一部を解消するGraphQLスキーマを作ることができます。

WordPressのデータモデルをGraphQLスキーマとしてマッピングする

マッピングを進めましょう。まず、元のエンティティをできる限りtypeとしてマッピングします。WordPressのデータモデルにおけるエンティティの一覧から、GraphQLスキーマ用に以下のtypeを作成します。

  • Post
  • Page
  • Media
  • User
  • UserRole
  • PostTag
  • PostCategory
  • Comment

次に、すべてのtypeに期待されるフィールドを追加します。スキーマを表すために、SDLまたはSchema Definition Languageを使用できます。(これはドキュメント目的のみで使用します。プラグイン自体はSDLでスキーマをコード化しているわけではなく、すべてPHPコードです。)

Postのフィールド(その他多数の中から)は次のとおりです。

type Post {
  id: ID!
  title: String
  content: String
  excerpt: String
  publishedAt: Date!
}

Userのフィールド(その他多数の中から)は次のとおりです。

type User {
  id: ID!
  name: String
  email: String!
}

対応するコネクションも作成します。コネクションとは、スカラー(数値や文字列など)ではなく別のエンティティを返すフィールドです。たとえば、postに著者がいることと、userがpostsを所有していることを表します。

type Post {
  author: User!
}
 
type User {
  posts: [Post]
}

フィールドとコネクションは引数を受け取ることもできます。たとえば、Post.dateをフォーマット可能にし、User.postsでエントリの検索と件数制限を行えるようにします。

type Post {
  date(format: String): Date!
}
 
type User {
  posts(limit: Int, search: String): [Post]
}

WordPressのデータモデルに含まれるすべてのエンティティについてこの作業を続けます。完成すると、Voyagerクライアント(プラグインのメニューで「Interactive Schema」として利用可能)を使って確認できるWordPressのGraphQLスキーマができあがります。

WordPressのGraphQLスキーマ

このスキーマはWordPressのデータベース図と似た部分もありますが、多くの違いもあります。分析してみましょう。

エンティティを持たない操作はRootフィールドとしてマッピングされる

WordPressのデータベース図はデータの保存方法を表しているため、「起点」がありません。しかしGraphQLはデータを取得するためのインターフェースなので、クエリを実行するための出発点が必要です。

この出発点がRoottypeです。より正確には、QueryRootMutationRootという2つのtype(それぞれqueriesとmutationsを扱う)です。

この2つのtypeには、get_posts()get_users()wp_signon()のようなエンティティに依存しないすべての操作をマッピングします。

type QueryRoot {
  posts: [Post]!
  users: [User]!
}
 
type MutationRoot {
  logUserIn(username: String, password: String): User
}

フィールドは、それが表す操作と同じ名前やシグネチャを持つ必要はありません。たとえば、フィールド名をsignOnではなくlogUserInにする方がより適切と考えられます。

すべてのmutationはMutationRootの下に置く

wp_update_post()のように特定のエンティティに依存する操作もあります。これはあるpostに対して適用されます。対応するmutationはGraphQLスキーマのMutationRoottypeに追加しなければなりません。これがGraphQLの仕様です。

この操作は次のようにマッピングされます。

type MutationRoot {
  updatePost(input: {
    postID: ID!,
    newTitle: String,
    newContent: String
  }): Post
}

このプラグインはネストされたmutationもサポートしています(標準的なGraphQLの動作ではないため、オプトイン機能として提供)。その場合、mutationはMutationRootだけでなく任意のtypeの下に追加できます。この場合、次のようになります。

type Post {
  update(input: {
    newTitle: String,
    newContent: String
  }): Post!
}

custom postsの扱い

GraphQLにはtypeの継承がありません。そのため、CustomPostというtypeを持ち、PostPageがそれを継承するという構造は取れません。

GraphQLはこの制約を補うために2つの手段を提供しています。インターフェースとunion typesです。

1つ目として、スキーマにCustomPostインターフェースを作成し、custom postに期待されるすべてのフィールドを宣言して、PostPageがそのインターフェースを実装するよう定義します。

interface CustomPost {
  title: String
  content: String
  excerpt: String
  date(format: String): Date!
}
 
type Post implements CustomPost {
  title: String
  content: String
  excerpt: String
  date(format: String): Date!
}
 
type Page implements CustomPost {
  title: String
  content: String
  excerpt: String
  date(format: String): Date!
}

2つ目として、すべてのcustom post typesを返すCustomPostUniontypeをスキーマに作成します。

union CustomPostUnion = Post | Page

そして、適切な場面ではこのtypeを返すフィールドを定義します。

type QueryRoot {
  customPost(id: ID): CustomPostUnion
  customPosts: [CustomPostUnion]!
}
 
type User {
  customPosts: [CustomPostUnion]
}
 
type Comment {
  customPost: CustomPostUnion!
}

ご覧のとおり、GraphQLスキーマではpostsを扱っているのかcustom postsを扱っているのかを明示的に区別する必要があります。これらは同じではないからです!この2つを混同して使うことはWordPressの技術的負債であり、修正できます。

そのため、custom postは常にPostではなくCustomPostと呼び、custom postsを扱うフィールドは常にpostsではなくcustomPostsと呼び、custom postのIDを受け取るフィールド引数はマッピング先のWordPress関数での名称がpostIDであってもcustomPostIDと呼びます。

こうすることで、期待する動作が常に明確になります。

  • フィールドUser.customPostsはpostsやpagesを含む任意のcustom postのリストを返せる一方、User.postsはpostsのみを返す
  • フィールドRoot.setFeaturedImageOnCustomPostは任意のcustom postにアイキャッチ画像を追加できる(だからsetFeaturedImageOnPostとは呼ばない)

tags(とcategories)を単一のtypeにまとめない

なぜPostTagPostCategoryも同様)というtype名なのでしょうか?単にTagではいけないのでしょうか?

なぜなら、このクエリを実行する場合(productはCPTとする)、postsとproductsのtagsフィールドの結果は常に異なり、重なることがないからです。

query {
  posts {
    tags {
      id
      name
    }
  }
  products {
    tags {
      id
      name
    }
  }
}

postsに追加されたtagsはproductsのtagsを取得しても表示されません(逆も同様です)。ただし、productがpost_tagタクソノミーを使用している場合はPostTagtypeで表現することもできます。WordPressではこれらのアイテムを同じデータベーステーブルの異なる行として扱えるため大きな問題ではありませんが、強い型付けを持つGraphQLでは重要な区別です。

そのため、これらのエンティティをそれぞれ独自のtypeで分離しておくことがよい設計です。postsのtagsはPostTagtypeで返し、カスタムプラグインが独自のproduct CPTを実装する場合はそのtagsにProductTagtypeを使用すべきです。

メディアアイテムに独自のアイデンティティを与える

WordPressのメディアエンティティは実装上の便宜からcustom post typesとして扱われています。しかしGraphQLスキーマではこの技術的負債を回避し、メディア要素をcustom postsではなく独自のエンティティとしてモデル化できます。

これにより、GraphQLスキーマでは次のような決定がなされます。

  • customPostsフィールドをクエリしてもメディア要素は取得されない
  • MediatypeはCustomPostインターフェースを実装せず、CustomPostUniontypeに含まれない
  • Mediatypeはcustom post typeに期待されるexcerptdatestatusなどのフィールドを持たない。代わりに、メディア要素として期待されるフィールドのみを持つ。
type Media {
  id: ID!
  src: String!
  width: Int
  height: Int
}

enumの識別とマッピング

WordPressでは、一定の値セットから固定値を使用する場面があります。たとえば、postのステータスは"publish""draft""pending""trash"のいずれかのみです。

GraphQLではこれらをstring型ではなくenum型として扱い、対応するenumeration typeを作成できます。GraphQLの標準に従えば、enumは大文字で記述されます。

enum CUSTOM_POST_STATUS {
  PUBLISH
  DRAFT
  PENDING
  TRASH
}

しかし、そうするとWordPressと直接やり取りするためにクエリを使用できなくなります。get_posts( [ "post_status" => "PUBLISH" ] )は動作しないからです。

そこで妥協案として、これらのenum値は小文字のまま維持します。

enum CUSTOM_POST_STATUS {
  publish
  draft
  pending
  trash
}

追加のtypeのマッピング

ブロックはWordPressのデータベース図では直接見えません。wp_postsに保存されており(wp_blocksというテーブルは存在しない)、それでも独自のエンティティです。

そこで、ブロックをマッピングするためにBlocktypeを導入します。

type Post {
  blocks: [Block]
}
 
type Block {
  type: String!
  attributes: JSONObject
}

ニュースレターを購読する

Gato GraphQL のすべてのアップデートを把握しましょう。