我们[OkayCupid]决定不使用GraphQL进行当地州管理

2020-08-22 00:02:56

在OkCupid这里,我们是使用GraphQL的铁杆粉丝。在我们的任何客户端平台上获取数据时,查询语言提供的抽象为我们提供了在每种情况下精确获取所需数据的灵活性。

归根结底,GraphQL实际上只是一个抽象。突变、查询和订阅类型抽象地模拟了我们与任何数据交互的基本方式。该模式充当某些数据源及其目的地之间的契约,它定义了可以查询哪些数据以及应该如何查询这些数据。在大多数情况下,传入查询概述的数据将由我们的GraphQL服务器实例解析,但该数据的目的地(在我们的例子中,假设它是作为客户端的移动应用程序或Web应用程序)并不真正需要了解相关数据的来源或解析策略。

这真的很好,因为这意味着数据可以来自GraphQL服务器可以访问的任何地方。也许我们想要使用文件系统中的某个东西来解析我们的数据,或者使用本地数据库,或者使用远程数据库。也许我们可以通过RPC或REST或任何协议调用向我们公开的其他服务器,真的。也许我们的数据目前在内存中的某个地方,这在技术上也是很好的!我们的数据源的冷漠使得该模型和数据图的体系结构具有如此高的可伸缩性(Mandi Wise有一个很棒的视频演示了这一点,同时还介绍了联邦图的概念)。

不管数据的来源是什么,客户端实现根本不需要更改,这对于理解使用GraphQL进行本地状态管理的概念至关重要。想象一下您的应用程序的本地状态:毕竟,它真的只是另一个数据源,不是吗?那么,那么问题是,是什么阻止我们利用该查询语言范例提供给我们的抽象来管理这些数据呢?

嗯,答案是什么都没有。如果您将状态存储在应用程序中的某个位置,那么理论上只要您愿意,就可以使用该状态数据解析GraphQL查询。我们试验的来自Apollo(在我们看来,所有东西都是gql的实际提供者)的实现使用GraphQL指令来指示应该以这种方式解析给定查询的哪些部分:@Client。举个例子,让我们设想一下,我们想要一个用户可以互相发送消息的应用程序。要获得有关用户发消息的信息,我们可能会查询我们的服务器。但是,我们可能想知道用户当前是否为其任何给定对话打开了消息窗口。服务器实际上并不关心每个对话的这段信息,但是我们的前端应用程序非常关心它,所以我们可能会选择在客户端状态中存储这样的信息,这是很有意义的。假设情况是这样,那么就可以为该数据构造一个合理的查询(包括本地州),如下所示:

查询getAllMessages($userID:id!){user(id:$userid){name profilePic Messages{id corents{name profilePic}isOpen@client}。

在本例中,我们的每条用户消息的isOpen标志可以存储在我们的客户端的状态中,因为它不是后端关注的问题。但是,其余的数据可以从我们的服务器获取,其他任何东西都不需要更改。在单个查询中混合我们的数据源(客户端和服务器)的能力是一个非常强大的想法,可以产生一些非常灵活的单个查询。

由于解析该@Client指令的策略是遍历,这意味着该指令可以递归地应用于我们数据中的父-子关系和邻居-邻居关系,从而允许我们获得与数据图相同的体验,除了我们的客户端状态。我们的客户端状态可以直接访问其父(非客户端状态)结构的片段,它可以是像布尔标志这样简单的标量字段,甚至可以是更深层次相关和结构化的数据。

客户端状态也不需要与某些服务器数据相关!它可能真的是我们想要存储为客户端状态的任何数据,比如用户当前的主题首选项设置,这完全是前端的问题,但仍然是有状态的。不过,现在黑暗模式很流行,对吧?

那么,像这样的东西是怎么运作的呢?在幕后,我们需要向我们的ApolloClient实例添加一些新的配置,以便它具有解析客户端查询的策略。为此,我们通过向客户端添加一些新的解析器来明确说明这一点,就像编写解析服务器实例上的查询一样。ApolloClient实例可以在初始化时添加解析器,也可以使用client.addResolver(Ome NewResolverToAdd)临时添加解析器。Apollo定义了这样处理解析的函数签名,如果您过去与Apollo-server合作过,应该会非常熟悉:

类型ResolverFn=(父项:任意,参数:任意,{缓存}:{缓存:ApolloCache<;任意>;})=>;任意;

忽略父节点和参数,我们可以看到我们从一个称为cache的对象中分解出一个属性,就像我们在与此函数类型平行的Apollo服务器中分解一个dataSources属性一样。这是因为在这个场景中,我们的缓存承担了客户端世界中的数据源的责任。让我们看看客户端解析器设置在下面的上下文中是什么样子:

Const defaultResolver={query:{user:{message:{isOpen:(parent,args,{cache})=>;{//引用缓存获取您的数据返回cache.readQuery({query:message_is_open_query,变量:{messageId:parent.id,}});},};

在此之后,我们还希望为缓存提供一些初始状态,以便我们的第一次缓存读取将被解析,因此我们也希望定义该状态,并在初始化时将其提供给我们的缓存。

//定义客户端初始状态const defaultState={user:{message:{isOpen:false,}const client=new ApolloClient({//其他Apollo配置,如link//和cache定义解析器:defaultResolver,});//用您的初始stateclient.writeData({query:message_is_open_query,data:{defaultState,}})。

Apollo的直觉是不仅提供此指令和在客户端解析查询的选项,而且提供大多数ApolloClient实例无论如何都定义的客户端缓存(通常用于存储服务器上实际解析的查询的响应),作为存储此客户端状态的位置。这很有意义,我们有一个本地存储(我们的ApolloClient缓存),并且我们有一种与该存储交互的方式(使用gql)……。从本质上讲,这是像Redux(我现在肯定不需要介绍它)或MobX这样的解决方案为我们提供的,对吗?

嗯,是的。它也工作得非常好!然而,当我们将此作为一种选择进行探索时,我们注意到了一些事情,这些事情最终导致我们决定不依赖阿波罗进行国家管理。

那么,我们为什么决定不实施这一点呢?嗯,这个决定背后的理由肯定是针对我们的情况的,也许对其他人来说不会那么重要,但确实包含了一些见解,我认为这些见解将是任何走上阿波罗之路的人都必须权衡的考虑因素。

虽然这是一种阶段管理解决方案,但也带来了一些新的开销。现在,用户必须为其客户端状态编写解析器,尽管任何状态管理选项都是如此,但它并不像看起来那么简单。

要真正正确地编写这些解析器,您需要有一些直接使用缓存的经验/天赋。从3.0版开始,Apollo/Client对缓存处理规范化和非规范化数据的方式进行了一些相当剧烈的更新。理解缓存如何使用ID和__类型名、决定是合并还是替换数据,以及学习如何这样做都是本课程的重点。SideNote,Khalil@Apollo最近发表了一篇令人难以置信的博客文章,深入介绍了Apollo缓存和理解缓存规范化。这为我们的缓存数据提供了两种选择之一:或者确保每个查询都请求我们所请求的数据的唯一标识字段(这不是违背了请求我们想要的任何字段的目的吗?)。或者编写显式的typePolicies来告诉我们的缓存如何规范化我们的数据。从编写必须针对众多不同用例正确工作的客户端库的角度来看,我理解这样的解决方案的动机。然而,这并不是通过Redux这样的解决方案实现的客户端状态问题。

将此范例与Reaction的上下文API、useReducer挂钩、甚至Redux架构进行对比,从开发人员的角度看,Apollo解决方案似乎更易于理解和管理。然而,对于这种权衡,我们确实获得了以相同的方式思考所有应用程序数据并与之交互的能力,这无疑是一个令人敬畏的好处。但这值得吗?

嗯,我们已经在OKC这里使用Redux了,在一些较旧的代码示例中,甚至还使用了回流。在我们的应用程序中添加州管理的新选项真的会造成混乱,这对于刚加入我们团队的人来说,一开始很难理解这一点,这一点无可否认是复杂的。就我个人而言,我觉得开发人员的经验和可维护性应该是任何架构或框架决策的重要决定因素。Redux已经死了这个论点已经被提过很多次了,与之相关的传统成本(以成吨的样板和包装组件的形式出现)很容易被辩称是不太可扩展的,因为在取得任何进展之前,人们必须对少数文件进行更改。尽管如此,多年来它肯定已经成熟了。如果操作得当,这绝对是轻而易举的工作,而且它显然有一定的持久力(更不用说,使用Redux钩子工作实际上真的很好)。更不用说,拆除我们现有的体系结构将需要几个月的时间,在现有的状态管理范例的同时添加另一个要学习和遵循的范例将导致开发人员在编写代码时进行更多的上下文切换,并且可能只会使他们更加困惑和负担更重。

最重要的是,还有无数的资源可以用来使用和理解Redux(或任何成熟的操作系统),这是我在研究阿波罗客户端状态管理时个人肯定有问题的一件事;只是关于阿波罗方法的文档、视频和文章没有那么多,可能是因为与Redux相比,它更加晦涩难懂或处于生命早期。此外,更成熟的解决方案在定义供开发人员使用的稳定API方面可能会提供更大的价值,而较年轻的解决方案在这方面可能会更加动荡(不过,我承认,这都是根据情况而定的)。

然而,反对Redux不得不更改这么多文件和过于复杂的常见论点似乎并没有真正被阿波罗的解决方案补救。我们仍然想明智地将解析器和初始状态的定义放在一起,在我自己做了这样的事情之后,我感觉我只是在为一些Redux写样板,这对我来说是相当具有讽刺意味的。对我来说,直接使用缓存并不比使用商店更复杂。

阿波罗也有一个开发工具,我真的很喜欢使用,而且发现它也很有用,但当与Redux的类似工具相比时,它也感觉有点不成熟。有时,它不想发射。它没有提供像Time Travel这样有趣的特性,我对使用它感到兴奋的一件事是客户端自省,这需要在ApolloClient实例上定义typeDefs(这本质上是为我们的客户端状态创建模式的过程,这实际上是另一组需要担心和管理的事情,但我承认,在这种情况下,TypeScript或codegen可能真的会大放异彩)。在我用过的其他库中,没有必要多次定义我们客户端状态的形状,如果说有什么不同的话,那就是它开始看起来像是开始积累起来的阿波罗(Apollo)端的样板。

我们也一直在试验Reaction的上下文API,估计将来还会考虑其他一些选择。然而,考虑我们选择的对捆绑包大小有影响的内容对我们来说也非常重要。Context/Apollo能否完全消除对状态管理的依赖?对于一些更简单的应用程序,我认为有一些例子已经证明上下文已经足够了。同样,也有一些例子表明阿波罗的解决方案已经足够了!还有MobX、Facebook的新开源产品,提供反冲功能,甚至可以使用XState的状态机对客户端状态进行建模。

人们很难不承认阿波罗所做的令人难以置信的工作。Apollo和GraphQL在清理我们的API和客户的一般网络层方面创造了奇迹。然而,把它作为状态管理的一种选择,对我们的代码库有重大影响,而且在这个时候,考虑到阿波罗客户端状态管理的成熟度和我们客户端状态的当前设置,我们只是觉得这个论点不够有说服力。在我看来,在具有大量现有架构的代码库中实现一些新东西的投资回报率应该相对较高。我只是不确定在这种情况下阿波罗的投资回报是否足够高。

不过,在研究了阿波罗提供的更多内容之后,我们有了一些新的见解,我们希望最终能够得出更好的结论,说明我们应该依靠什么来解决问题,以及我们应该如何考虑我们决定使用的任何库、框架或工具的方法、架构和权衡。在此之前,我们将继续密切关注阿波罗的客户状态解决方案的发展。