介绍
GraphQL 是一种强大而灵活的 API 查询语言,使客户端能够准确请求他们所需的数据,从而消除信息的过度获取和获取不足。然而,随着 GraphQL 查询变得更加复杂并涉及多个数据源,有效地检索数据并向客户端提供数据可能具有挑战性。这就是 GraphQL 数据加载器发挥作用的地方。
GraphQL 数据加载器是优化 GraphQL API 的关键组件,旨在解决臭名昭著的 N+1 查询问题,该问题在 GraphQL 服务器重复获取相关项目列表的相同数据时发生。数据加载器通过批处理和缓存请求,帮助简化从各种来源(例如数据库、API,甚至本地缓存)获取数据的过程。通过这样做,他们显着提高了 GraphQL 查询的效率和性能。
在本中,我们将深入研究批处理功能,通过查看数据加载器的 java 实现来探索它如何发挥其魔力。
批处理
批处理是将多个单独的数据检索请求收集到单个批处理请求中的过程,从而减少对数据源的调用次数。在处理 GraphQL 查询中的关系时,这一点尤其重要。
考虑一个典型场景,其中 GraphQL 查询请求一个项目列表,以及每个项目的附加相关数据(例如用户信息)。如果不进行批处理,这将导致对每个项目进行单独的数据库查询或 API 请求,从而导致 N+1 查询问题。通过批处理,可以将这些单独的请求有效地组合成单个请求,从而大大减少数据源的往返次数
Java 数据加载器批处理
假设我们有一个如下所示的 graphql 查询
markdown
{
user {
name
friends {
name
}
}
}
它生成以下查询结果
json
{
"user": {
"name": "zhangsan",
"friends": [
{
"name": "lisi",
},
{
"name": "wanmgwu",
},
{
"name": "zhouliu",
}
]
}
}
一个简单的实现方法是为查询响应中的每个用户执行一次调用以检索一个用户对象,即 4 次调用,一次针对根对象,一次针对列表中的每个好友。
然而,它DataLoader
不会立即执行远程调用,它只是将调用排入队列并返回一个 Promise ( CompletableFuture
) 来传递用户对象。一旦我们将构建查询结果的所有调用排入队列,我们必须请求DataLoader
开始执行它们。这就是奇迹发生的地方。将DataLoader
开始提取每次调用的用户 ID 并将其放入一个列表中,该列表将用于查询我们配置的后端,并仅使用一个请求即可检索用户列表。
批处理通常按级别进行,在本例中我们有 2 个级别。root 用户和他的朋友。通过使用DataLoader
batchig,此响应将只需要 2 次调用。
代码示例
让我们添加一些代码来展示如何使用它。
我们首先需要拥有一个BatchLoader
. 它将从用户后端批量加载用户,从而减少对该后端的 API 调用量。
kotlin
List<User> loadUsersById(List<Long> userIds) {
System.out.println("Api call to load users = " + userIds);
return users.stream().filter(u -> userIds.contains(u.id())).toList();
}
BatchLoader<Long, User> userBatchLoader = new BatchLoader<>() {
@Override
public CompletionStage<List<User>> load(List<Long> userIds) {
return CompletableFuture.supplyAsync(() -> {
return loadUsersById(userIds);
});
}
};
然后我们需要创建一个DataLoader
将使用前面的BatchLoader
来执行整个用户树的加载。
ini
var userLoader = DataLoaderFactory.newDataLoader(userBatchLoader);
var userDTO = new UserDTO();
userLoader.load(1L).thenAccept(user -> {
userDTO.id = user.id();
userDTO.name = user.name();
user.friends().forEach(friendId -> {
userLoader.load(friendId).thenAccept(friend -> {
userDTO.friends.add(new FriendDTO(friend.id(), friend.name()));
});
});
});
userLoader.dispatchAndJoin();
System.out.println(userDTO);
它将产生以下调试输出
bash
Api call to load users = [1]
Api call to load users = [2, 3, 4]
UserDTO{id=1, name='John', friends=[FriendDTO[id=2, name=Jane], FriendDTO[id=3, name=Bob], FriendDTO[id=4, name=Alice]]}
如果您对它的内部工作原理感到好奇,我将向您展示用户的一种自定义实现DataLoader
。不是真正的。只需一个简化版本即可帮助您了解全貌。
ini
static class UserLoader {
BatchLoader<Long, User> userBatchLoader;
record QueueEntry(long id, CompletableFuture<User> value) { }
List<QueueEntry> loaderQueue = new ArrayList<>();
UserLoader(BatchLoader<Long, User> userBatchLoader) {
this.userBatchLoader = userBatchLoader;
}
CompletableFuture<User> load(long userId) {
var future = new CompletableFuture<User>();
loaderQueue.add(new QueueEntry(userId, future));
return future;
}
List<User> dispatchAndJoin() {
List<User> joinedResults = dispatch().join();
List<User> results = new ArrayList<>(joinedResults);
while (loaderQueue.size() > 0) {
joinedResults = dispatch().join();
results.addAll(joinedResults);
}
return results;
}
CompletableFuture<List<User>> dispatch() {
var userIds = new ArrayList<Long>();
final List<CompletableFuture<User>> queuedFutures = new ArrayList<>();
loaderQueue.forEach(qe -> {
userIds.add(qe.id());
queuedFutures.add(qe.value());
});
loaderQueue.clear();
var userFutures = userBatchLoader.load(userIds).toCompletableFuture();
return userFutures.thenApply(users -> {
for (int i = 0; i < queuedFutures.size(); i++) {
var userId = userIds.get(i);
var user = users.get(i);
var future = queuedFutures.get(i);
future.complete(user);
}
return users;
});
}
}
所以,首先看一下CompletableFuture<User> load(long userId)
,它不执行任何 userId 查找,它只是:
- 将查找排入队列
- 生成一个,
CompletableFuture
让您根据您提供的查找链接进一步查找。因此,查找被推迟,直到我们实际使用dispatchAndJoin()
现在,看看List<User> dispatchAndJoin()
。一旦我们准备好检索用户列表,就会调用该函数。它会:
-
调用
CompletableFuture<List<User>> dispatch()
将执行以下操作:- 将所有 userId 分组到一个列表中,并将其发送到底层
BatchLoader
,底层对后端执行实际的 API 调用。 - 完成我们注册查找时(当我们调用 )时提供的 CompletableFuture
CompletableFuture<User> load(long userId)
,从而向 中添加更多元素loaderQueue
。此时,下一级的 userId 查找已排队。
- 将所有 userId 分组到一个列表中,并将其发送到底层
-
当 中还有剩余元素时重复该过程
loaderQueue
。