👶🏻 通过GraphQL让WordPress焕然一新
WordPress是一个历史悠久的CMS:它诞生于17年前,充满了PHP代码,如果有机会重新来过,这些代码会以不同的方式编写。
GraphQL是访问数据的现代接口。请注意"接口"这个词:它不关心底层数据系统是如何实现的,只关心如何暴露数据。
将这两者结合在一起会发生什么?我们应该如何设计GraphQL接口来访问WordPress中的数据?
大致上有两种可行策略:
-
尊重传统,提供一个保持WordPress数据模型原样的映射,包括多年来积累的技术债务
-
解决技术债务,提供一个以抽象、不必然绑定于WordPress的方式暴露数据的接口
两种方法各有利弊,没有对错之分。这只是一种主观选择,将某些行为优先于其他行为。
对于插件 Gato GraphQL,我选择了后一种方式,尝试创建一个虽然基于WordPress并为WordPress服务,但不受WordPress束缚的GraphQL schema(例如,去除了不一致的名称和关系)。
结果是GraphQL让WordPress焕然一新:虽然我们仍然以WordPress作为底层CMS,保留其遗留PHP代码,但数据层可以基于常识而非传统重新构建。数据层从青少年时期重新回到了幼儿阶段。

结果是一个表示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数据库映射到GraphQL schema时,是否需要1对1地遵循上面的图表?
不,不需要。数据库图是实际的实现,而GraphQL是从客户端访问数据的接口。两者相关,但可以有所不同。GraphQL不关心数据库:它不用SQL命令思考,也不需要知道存在名为wp_posts和wp_users的数据库表。
因此,在为WordPress创建GraphQL schema时,我们不需要过于担心数据库图。这意味着我们可以生成一个修复WordPress数据模型部分技术债务的GraphQL schema。
将WordPress数据模型映射为GraphQL schema
让我们开始映射。首先,尽可能将原始实体映射为type。从WordPress数据模型的实体列表中,我们为GraphQL schema生成以下type:
PostPageMediaUserUserRolePostTagPostCategoryComment
然后,我们为每个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"形式提供)查看:

这个schema与WordPress数据库图有相似之处,但也有许多不同。让我们来分析一下。
没有实体的操作映射为Root字段
WordPress数据库图表示数据的存储方式,因此没有"起点"。然而GraphQL是用于检索数据的接口,因此必须有一个执行查询的初始阶段。
这个初始阶段就是Root type,更准确地说,是QueryRoot和MutationRoot两个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,并声明Post和Page继承它。
GraphQL提供了两种资源来弥补这一不足:接口(interface)和联合类型(union type)。
对于第一种,我们在schema中创建一个CustomPost接口,声明custom post预期的所有字段,并定义Post和Page 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叫PostTag(PostCategory也一样),而不直接叫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字段时不会获取媒体元素 Mediatype不实现CustomPost接口,也不属于CustomPostUniontypeMediatype没有custom post type预期的许多字段,如excerpt、date和status。相反,它只有媒体元素预期的字段:
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
}