在Kotlin中使用GraphQL处理全局唯一的Node ID
开始摆放
如果要开发GraphQL API,在Web前端无论使用哪个GraphQL客户端库,通常会首先设计API以满足Relay的规范。为了能够通过仅指定节点ID来重新获取任何时间的服务器资源,需要为实体提供一个全局唯一的ID机制。要做到这一点,有很多事情要做,但首先需要在服务器端为实体提供一个全局唯一的ID。
如果在将实体记录到数据库时采用UUID而不是自增ID作为主键,那么可以重用该值。但是,通常会分配连续编号的ID。由于连续编号的ID很难满足实体的跨越性唯一性,所以需要稍加设计。在Relay的官方指南中,它们解释了一种方法,大致如下。
Node 接口和 node 字段假定此 refetch 需要全局唯一标识符。如果没有全局唯一标识符的系统通常可以通过将类型与类型相关的 ID 组合来合成它们,这就是此示例中所做的。
我们得到的 ID 是 base64 字符串。ID 被设计为不透明(在 node 的 id 参数中应该传递的是系统中某个对象查询 id 的结果),而对字符串进行 base64 编码是 GraphQL 中的一种有用惯例,用于提醒查看者该字符串是一个不透明的标识符。
基本上就是將表示實體名稱的字串和連續編號ID結合起來的值,進行Base64編碼,然後將編碼後的字串作為ID使用。順便提一下,Relay提供的範例中,他們是使用冒號(:)將實體名稱和連續編號ID分隔,再進行Base64編碼。 遵照這個方法,基本上不會出錯。
在决定尝试模仿之前,并没有做什么特别困难的事情,所以即使自己编写了一个用于生成ID的方法也完全可以。但考虑到这种处理的水平可能是每个人都需要的,而且很可能已经在库中实施了,所以我进行了一些调查,本篇文章就是关于这些调查的内容。这里提到的库当然指的是标题中的GraphQL Kotlin。虽然很少听说国内使用GraphQL Kotlin的案例,但如果用Kotlin编写SpringBoot并创建GraphQL API,使用GraphQL Kotlin作为其扩展版本而不是直接使用GraphQL Java会更加方便实用,所以我推荐使用它。
调查日志
首先,我浏览了GraphQL Kotlin的官方文档,但可惜并没有找到相关描述。然而,如果回溯至GraphQL Kotlin所基于的GraphQL Java的文档,我们可以在Relay Support一章中找到如下描述。
基本的Relay支持已经包含在内。
请查看https://github.com/graphql-java/todomvc-relay-java以获取一个完整的示例项目。
这个样本代码似乎有一些提示。我稍微看了一下,在TodoSchema.java文件内找到了一些看起来像是相关实现的部分。
private void createUserType() {
userType = newObject()
.name("User")
.field(newFieldDefinition()
.name("id")
.type(new GraphQLNonNull(GraphQLID))
.dataFetcher(environment -> {
User user = (User) environment.getSource();
return relay.toGlobalId("User", user.getId()); // <- これ
}
)
.build())
.field(newFieldDefinition()
.name("todos")
.type(connectionFromUserToTodos)
.argument(relay.getConnectionFieldArguments())
.dataFetcher(simpleConnection)
.build())
.withInterface(nodeInterface)
.build();
}
看起来relay.toGlobalId(“User”, user.getId())这部分是相对合理的。
当我查看GraphQL Java源码中的定义时,大致是这样的。
private static final java.util.Base64.Encoder encoder = java.util.Base64.getUrlEncoder().withoutPadding();
private static final java.util.Base64.Decoder decoder = java.util.Base64.getUrlDecoder();
public String toGlobalId(String type, String id) {
return encoder.encodeToString((type + ":" + id).getBytes(StandardCharsets.UTF_8));
}
这确实符合我所期望的内容。顺便提一下,还有逆向转换的方法(将唯一ID转为实体名称和连续ID)也被正确地准备好了。
public static class ResolvedGlobalId {
public ResolvedGlobalId(String type, String id) {
this.type = type;
this.id = id;
}
private final String type;
private final String id;
public String getType() {
return type;
}
public String getId() {
return id;
}
}
//(中略)
public ResolvedGlobalId fromGlobalId(String globalId) {
String[] split = new String(decoder.decode(globalId), StandardCharsets.UTF_8).split(":", 2);
if (split.length != 2) {
throw new IllegalArgumentException(String.format("expecting a valid global id, got %s", globalId));
}
return new ResolvedGlobalId(split[0], split[1]);
}
如果使用这两种方法,唯一ID的问题似乎可以解决。
一个实施的示例
假设从数据库中取出的实体的ID是Int类型,为了将其转换为唯一ID并包含在API响应中,或者从API请求中接收到唯一ID并将其重新转换为连续ID以便在数据库中进行搜索,我定义了以下扩展函数,以便执行这些操作更加方便。
import com.expediagroup.graphql.generator.scalars.ID
import graphql.relay.Relay
fun ID.toInt(): Int {
val globalId = Relay().fromGlobalId(this.toString())
return globalId.id.trim().toInt()
}
fun Int.toID(type: String): ID {
val globalId = Relay().toGlobalId(type, this.toString())
return ID(globalId)
}
这样一来,您就可以轻松满足Relay的规格要求了!