博客

👶🏻 通过GraphQL让WordPress焕然一新

Leonardo Losoviz
作者:Leonardo Losoviz ·

WordPress是一个历史悠久的CMS:它诞生于17年前,充满了PHP代码,如果有机会重新来过,这些代码会以不同的方式编写。

GraphQL是访问数据的现代接口。请注意"接口"这个词:它不关心底层数据系统是如何实现的,只关心如何暴露数据。

将这两者结合在一起会发生什么?我们应该如何设计GraphQL接口来访问WordPress中的数据?

大致上有两种可行策略:

  1. 尊重传统,提供一个保持WordPress数据模型原样的映射,包括多年来积累的技术债务

  2. 解决技术债务,提供一个以抽象、不必然绑定于WordPress的方式暴露数据的接口

两种方法各有利弊,没有对错之分。这只是一种主观选择,将某些行为优先于其他行为。

对于插件 Gato GraphQL,我选择了后一种方式,尝试创建一个虽然基于WordPress并为WordPress服务,但不受WordPress束缚的GraphQL schema(例如,去除了不一致的名称和关系)。

结果是GraphQL让WordPress焕然一新:虽然我们仍然以WordPress作为底层CMS,保留其遗留PHP代码,但数据层可以基于常识而非传统重新构建。数据层从青少年时期重新回到了幼儿阶段。

GraphQL + WordPress 完美搭档

结果是一个表示WordPress数据模型的GraphQL schema,同时支持嵌套的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都是分类法(taxonomy)。

下面是WordPress的数据库图,展示了所有实体的数据是如何存储的:

WordPress数据库图

映射是否是数据库图的精确复制?

在将WordPress数据库映射到GraphQL schema时,是否需要1对1地遵循上面的图表?

不,不需要。数据库图是实际的实现,而GraphQL是从客户端访问数据的接口。两者相关,但可以有所不同。GraphQL不关心数据库:它不用SQL命令思考,也不需要知道存在名为wp_postswp_users的数据库表。

因此,在为WordPress创建GraphQL schema时,我们不需要过于担心数据库图。这意味着我们可以生成一个修复WordPress数据模型部分技术债务的GraphQL schema。

将WordPress数据模型映射为GraphQL schema

让我们开始映射。首先,尽可能将原始实体映射为type。从WordPress数据模型的实体列表中,我们为GraphQL schema生成以下type:

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

然后,我们为每个type添加所有预期的字段。为了表示schema,我们可以使用SDL,即Schema Definition Language。(这仅用于文档目的;插件本身不使用SDL来编写schema,全部是PHP代码。)

以下是Post的字段(众多字段中的一部分):

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

以下是User的字段(众多字段中的一部分):

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

我们还创建相应的连接(connection),即返回另一个实体(而非数字或字符串等标量)的字段。例如,我们表示一个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数据模型中的所有实体继续执行此操作。完成后,我们将得到WordPress的GraphQL schema,可以使用Voyager客户端(在插件菜单中以"Interactive Schema"形式提供)查看:

WordPress的GraphQL schema

这个schema与WordPress数据库图有相似之处,但也有许多不同。让我们来分析一下。

没有实体的操作映射为Root字段

WordPress数据库图表示数据的存储方式,因此没有"起点"。然而GraphQL是用于检索数据的接口,因此必须有一个执行查询的初始阶段。

这个初始阶段就是Root type,更准确地说,是QueryRootMutationRoot两个type(分别处理queries和mutations)。

在这两个type中,我们映射所有不依赖于特定实体的操作,例如执行get_posts()get_users()wp_signon()

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

字段不需要与其所代表的操作具有相同的名称或签名。例如,使用字段名logUserIn可以认为比signOn更合适。

所有mutation都放在MutationRoot下

有些操作确实依赖于特定实体,例如wp_update_post(),它被应用于某个post。对应的mutation必须添加到GraphQL schema的MutationRoot type中,因为这是GraphQL的规范。

那么,这个操作映射如下:

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

该插件还支持嵌套的mutation,作为可选功能提供(因为这不是标准的GraphQL行为)。这样,mutation也可以添加到任何type下,而不仅仅是MutationRoot。此时,我们得到:

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

处理custom posts

GraphQL中没有type继承。因此,我们无法拥有一个CustomPost type,并声明PostPage继承它。

GraphQL提供了两种资源来弥补这一不足:接口(interface)和联合类型(union type)。

对于第一种,我们在schema中创建一个CustomPost接口,声明custom post预期的所有字段,并定义PostPage type来实现该接口:

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!
}

对于第二种,我们在schema中创建一个CustomPostUnion type,返回所有custom post types:

union CustomPostUnion = Post | Page

并在适当的情况下让字段返回此type:

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

可以看出,在GraphQL schema中我们需要明确区分何时处理posts,何时处理custom posts,因为它们并不相同!将这两者混用是WordPress的技术债务,我们可以修复它。

因此,custom post始终称为CustomPost而非Post,处理custom posts的字段始终称为customPosts而非posts,接收custom post ID的字段参数称为customPostID而非postID(即使这是映射的WordPress函数中的称呼)。

这样,期望的行为始终清晰:

  • 字段User.customPosts可以返回包括posts和pages在内的任意custom post列表,而User.posts只返回posts
  • 字段Root.setFeaturedImageOnCustomPost可以为任意custom post添加特色图片,这就是它不叫setFeaturedImageOnPost的原因

不将tags(和categories)合并到单一type中

为什么type叫PostTagPostCategory也一样),而不直接叫Tag

因为执行以下查询时(其中product是一个CPT),posts和products的tags字段结果始终不同,互不重叠:

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

添加到posts的tags在检索products的tags时不会显示,反之亦然(除非product也使用post_tag分类法,此时也可以用PostTag type表示)。这在WordPress中不算大问题,因为这些条目可以视为同一数据库表中的不同行。但对于强类型的GraphQL来说,这很重要。

因此,将这些实体分开保持在各自的type下是一个好的设计决策:posts的tags使用PostTag type返回,如果自定义插件实现了自己的product CPT,其tags必须使用ProductTag type。

赋予媒体项独立的身份

WordPress中的媒体实体是custom post types,只是因为这在实现上很方便。然而,GraphQL schema可以避免这种技术债务,将媒体元素建模为独立实体,而不是custom posts。

这意味着GraphQL schema做出以下决策:

  • 查询customPosts字段时不会获取媒体元素
  • Media type不实现CustomPost接口,也不属于CustomPostUnion type
  • Media type没有custom post type预期的许多字段,如excerptdatestatus。相反,它只有媒体元素预期的字段:
type Media {
  id: ID!
  src: String!
  width: Int
  height: Int
}

识别和映射enum

在某些情况下,WordPress使用来自固定集合的固定值。例如,post的状态只能是"publish""draft""pending""trash"

在GraphQL中,我们可以将这些视为enum(而非字符串),并创建相应的枚举类型。按照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

Blocks在WordPress数据库图中不直接可见,因为它们存储在wp_posts中(不存在wp_blocks表),但它们仍然是独立的实体。

因此,我们引入Block type来映射它们:

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

订阅我们的新闻通讯

及时了解 Gato GraphQL 的所有更新。