如何用Ruby、Rails、Active Record和No N+1绘制QL

2020-11-10 02:32:42

你的工作是开发一个非常成熟的Web应用程序,将后端和前端完全分开。这些服务器端代码是用Ruby编写的,主要负责通过丰富且文档齐全的API将HTTP请求转换成SQL语句(在Java ORM的帮助下)。您选择GraphQL而不是REST来简化您的端点,但是您的数据库对所有这些额外的查询并不满意。经过多次搜索,你会发现一本关于如何与N+1搏斗的详尽的动手指南,这本指南来自一位资深的图形QL-Rubyist…。它来了!

GraphQL可以在创建仅限后端的Rails应用程序方面创造奇迹,为您的客户端(无论是前端框架还是其他API使用者)提供一个单一的端点,用于获取任何形状和大小的数据,无论它们可能需要什么形状和大小。

只有一个圈套,但这是一个非常大的圈套。N+1大。

由于要卸载的最新关联的列表通常是在运行时确定的,因此在查询数据库时很难变得非常聪明。

您可以接受父记录的一个查询+每个关联的一个查询的悲哀现实(因此是“N+1”,尽管更严格的术语将是“1+N”)-或者您可以提前用花哨的SQL语句加载所有可能的关联。但是,如果你有一个非常丰富的模式,而这也是你一开始就不会改用GraphQL的主要原因,那么预加载可能会给整个数据库带来比让N+1锁定运行更大的压力。幸运的是,在Ruby-GraphQL世界中有一些工具可以让我们在加载什么、何时加载以及如何加载方面变得更有选择性、更聪明。

为了不是没有根据,让我们为一个非常简单的“Twitter克隆”应用程序起草一个简单的应用程序的简单模式的实际例子。这里的主要目标不是保持原创,而是能够立即与不同类型的人联系起来。它们是推特(Tweet)、用户(User)和用户查看器(Viewer)。微博阅读器是指查看其他用户推文的推送的用户。我们为“当前用户”创建了一个单独的类型,因为它可能会在“一般”用户上公开否则无法访问的属性。

类类型::tweet<;BaseObject字段:内容,字符串,NULL:FALSE字段:作者,Types::User,NULL:FALSE结束类类型::User<;Types::BaseObject字段:昵称,字符串,NULL:FALSE结束类类型::查看器<;Types::BaseObject字段:Feed,[Types::Tweet],NULL:FALSE def Feed#在本例中,FeedBuilder是返回Tweet关系的查询对象#。对于(CURRENT_USER)结束类类型::Query<;Types::BaseObject字段:查看器,Types::Viewer,NULL:TRUE,RESOVER_METHOD::CURRENT_USER END类GraphqlSchema<;GraphQL::Schema Query Types::Query End。

我还准备了一份摘要,在一个单独的文件中包含了我们整个Rails的“应用程序”。您不能运行它,但是它的功能足以通过我们在本文后面讨论的比较不同优化方法所包含的规范。要查看代码并运行测试规范,您可以在任何临时文件夹中的终端中运行以下命令:

CURL";https://gist.githubusercontent.com/DmitryTsepelev/d0d4f52b1d0a0f6acf3c5894b11a52ca/raw/cba338548f3f87c165fc7ec07eb2c5b55120f7a2/2_demo.rb";&>demo.rbcreatedb nplusonedb#要创建PostgreSQL测试数据库,需要Postgres安装rspec demo.rb#来运行比较不同N+1格斗技术的测试。

此代码立即包含一个NN+1问题。查询包括推文作者昵称的推文提要将触发对推文表格的单一查询,并在特定用户中触发N个查询。

{Query{观众{feed{内容作者{昵称}。

让我们从清理代码开始,并将提要加载提取到解析程序--一个封装数据库查询逻辑的特殊类。

类解析器::FeedResolver<;BaseResolver类型[Types::Tweet],NULL:False def Resolve FeedBuilder。对于(CURRENT_USER)END类类型::Viewer<;Types::BaseObject字段:Feed,Resolver:Resolvers::FeedResolver End。

如果您感兴趣,下面是我们的FeedBuilder模块的以下定义,该模块抽象出一些活动记录调用:

通过提取逻辑来创建新的解析器,我们可以创建替代的解析器,并对它们进行热插拔,以便更好地比较结果。以下是一个通过预加载所有关联来解决NN+1问题的解析程序:

类解析程序::FeedResolverPreload<;解析程序::BaseResolver类型[Types::Tweet],NULL:False def Resolve FeedBuilder。对于(CURRENT_USER)。包括(:作者)#使用AR自动加载魔术结束

这个解决方案是最明显的,但不是最理想的:我们将创建一个额外的SQL查询,以便无论如何都可以预加载用户,即使我们只请求最新的推文,而他们并不关心它们的作者(我知道,这很难想象,但假设这是为了更多的匿名数据挖掘操作)。

此外,我们还必须在最顶层(在查询类型中或在属于它的内部解析器中)定义一个完整的关联列表。当一个新的嵌套字段出现在图表的深处时,很容易忘记在列表中添加一个新的关联。

然而,当您知道客户端在大多数情况下(例如,当您控制前端代码时)确实会询问作者的数据时,这种方法非常有用。

GraphQL执行引擎是一种测试工具,负责处理数据查询并准备数据响应。在我们的三部分教程“GraphQL on Trails”中阅读更多内容。

在解析数据查询时,GraphQL的执行引擎知道请求了哪些数据,因此有可能在数据运行时找出应该加载的数据。GraphQL-Ruby宝石配备了一个非常方便的Lookhead功能,可以提前告诉我们是否有特定的字段被请求。让我们在另一个单独的解析器中尝试一下:

类解析程序::FeedResolverLookhead<;解析程序::BaseResolver type[Types::Tweet],NULL:False Extras[:Lookhead]def Resolve(Lookhead:)FeedBuilder。对于(CURRENT_USER)。Merge(Relationship_with_Includes(Lookhead))End Private def Relationship_with_Includes(Lookhead)#.Select?(:Author)在请求作者字段时返回TRUE返回Tweet。除非向前看,否则一切都是如此。选择?(:作者)推文。包括(:作者)结束端。

在本例中,我们仅在客户端要求输入作者字段时,才在USERS表中执行查询。只有在关联最小且不嵌套的情况下,这种方法才能很好地发挥作用。如果我们采用更加复杂的数据模型,其中用户有头像,推文有赞,那么我们的解析器就可以真正快速地脱离控制:

类解析程序::FeedResolverLookhead<;解析程序::BaseResolver type[Types::Tweet],NULL:False Extras[:Lookhead]def Resolve(Lookhead:)Scope=Tweet。其中(用户:用户。后跟_(CURRENT_USER))。订单(CREATED_AT::DESC)。LIMIT(10)SCOPE=WITH_AUTHER(作用域,前视),如果是前视。如果先行查找,则选择?(:作者)SCOPE=WITH_LIKE_BY(SCOPE,LOOKAAD)。如果先行查找,则选择?(:LIKE_BY)作用域结束私有定义WITH_AUTHER(作用域,前视)。精选(:作者)。选择?(:阿凡达)范围。包括(User::Avata_Attach)其他作用域。如果先行查找,则包含(:user)end end def with_like_by(scope,lookhead)。精选(:Like_By)。如果向前看,则选择?(:User)。精选(:Like_By)。选择(:用户)。选择?(:阿凡达)范围。包括(Like:{user::Avata_Attach})其他作用域。包括(Like::User)End Else作用域。包括(:赞)结束

你说得对,那一点也不优雅!如果有一种更好的方法,只在关联被访问时才加载它们,那该怎么办?懒惰的预装可以帮助我们!

在邪恶火星同事的一些帮助下,我写了一个名为ar_lazy_preload的小宝石,它让我们可以后退到最新的预加载解决方案,但不需要任何额外的努力就能让它变得更智能。它发出一个单独的请求,仅在第一次访问该关联之后才会提取所有关联的对象。当然,它也可以在GraphQL示例之外工作,在构建服务器渲染的视图时,它在Rest API或应用程序中非常方便。你所需要做的就是添加gem&34;ar_lazy_preload";来安装你的Gemfile,捆绑安装,然后你就可以像这样写你的解析器了:

GEM的创建完全是出于懒惰的考虑,所以如果你觉得懒惰,即使一直都在输入.lazy_preload,你可以通过添加一行配置来全局启用它,用于所有活动的记录调用:

我们对所做的查询没有太多的控制权,因此很难对其进行定制;

如果懒惰预加载没有打开,我们还是要在最顶层列出所有可能的联想;

如果一个表从两个地方被引用,我们将发出两倍的数据库请求。

在我们的Ruby应用程序中,GraphQL-ruby宝石使GraphQL成为可能,它捆绑了一种更好的方式来使用懒惰执行:

你可以返回一个特殊的懒惰对象,而不是返回数据(这个对象应该记住它替换的数据);

当数据解析器返回懒惰值时,数据执行引擎停止对当前子树的进一步处理;

当所有非惰性值都被解析时,执行引擎会要求懒惰对象进行解析;

惰性对象加载它需要解析的数据,然后为每个惰性字段返回该数据。

这需要一些时间才能完全理解这一点,所以让我们一步一步地实现一个懒惰的解析器。首先,我们可以重复使用不知道这些关联的初始FeedResolver:

然后,我们应该从我们的Tweet类型返回一个非常懒惰的对象。我们需要先传递第一个用户的ID并提供一个完整的查询上下文,因为我们将使用它来存储要加载的ID的列表:

类类型::tweet<;Types::BaseObject字段:Content,String,NULL:FALSE字段:Author,Types::User,NULL:False def Author Resolver::LazyUserResolver。新建(上下文、对象。User_id)结束。

每次初始化一个新对象时,我们都会添加一个挂起的用户ID来更新查询上下文,当第一次调用#user时,我们会发出一个单独的数据库请求,以确保获得我们需要的所有用户。之后,我们可以填写所有惰性字段的用户数据。以下是我们如何实施它:

类解析器::LazyUserResolver def Initialize(Context,user_id)@[email protected]_state=Context[:LAZY_USER_RESOVER]||={user_ids:set。新建,USERS_CACHE:nil}@LAZY_STATE[:user_ids]<;<;user_id end def user_cache[@user_id]end私有定义[email protected]_STATE[:USERS_CACHE]||=BEGIN [email protected]_STATE[:USER_ID]。[email protected]_state[:user_ids]。清除用户。其中(id:user_ids)。INDEX_BY(&;:ID)结束

想知道执行引擎如何才能区分常规对象和懒惰对象?我们应该在模式中定义懒惰解析器:

它告诉执行引擎在解析程序::LazyUserResolver对象未返回时停止解析用户,只有在其他所有非惰性字段解析完成后才会返回解析它。

这是可行的,但这是相当多的样板代码,你可能不得不经常重复。此外,当我们的懒惰解析器需要解析其他懒惰对象时,代码可能会变得相当复杂。幸运的是,还有一种不那么冗长的替代方案。

来自Shopify的宝石GraphQL-Batch使用了与GraphQL-Ruby相同的懒惰机制,但隐藏了难看的样板部分。我们需要做的就是继承GraphQL::Batch::Loader,并实现下一步的Performance方法:

类RecordLoader<;GraphQL::Batch::Loader def Initialize(Model)@model=model end def Perform(IDS)@model。其中(id:ids)。每个{|记录|完成(记录。ID,记录)}个ID。除非已完成?(ID)}结束,否则每个{|ID|FULTURE(ID,NIL)结束。

这个加载器(取自官方repo中的示例目录)在初始化式中需要一个新的模型类(以决定数据应该从哪里加载)。#Performance Method主要负责获取数据,#Fully Method主要用于将关键字与已加载的数据进行关联。

批处理加载器的使用与它的懒惰版本非常相似。我们将User传递给第一个初始值设定项和第二个用户的ID,以便延迟加载(这个ID将被用作获取第二个关联用户的关键字):

类类型::tweet<;BaseObject字段:Content,String,NULL:FALSE字段:Author,Types::User,NULL:False def Author RecordLoader。对于(::User)。加载(对象。Author_id)结束结束

这是怎么回事?当将Use GraphQL::Batch添加到模式中时,Promise#Sync方法会被注册为懒惰地进行解析(它在幕后使用Promise.rb)。当在从GraphQL::Batch::Loader继承的类上调用#Load Method时,它返回一个新的Promise对象-这就是为什么应用执行引擎将其视为一个惰性值。

这种方法有一个非常有用的副作用--你可以通过以下方式链式加载:

但是,即使有了我们上面描述的所有高级技术,最终仍有可能得到N+1。想象一下,我们正在添加一个管理面板,您可以在其中看到一个完整的用户列表。当用户被选中时,会弹出一个新的用户档案,然后你就可以看到他们的粉丝的详细列表。在GraphQL的世界里,数据应该从它所属的地方直接访问,我们可以这样做:

类类型::User<;BaseObject字段:昵称,字符串,NULL:FALSE字段:Followers,[user],NULL:FALSE DO参数:Limit,Integer,Required:True,Default_Value:2 Argument:Cursor,Integer,Required:False End Def Followers(Limit:,Cursor:Nil)Scope=Object。追随者。订单(ID::DESC)。限制(限制)范围=范围。如果游标范围结束类类型::Query<;BaseObject字段:USERS,[USER],NULL:FALSE字段:USER,USER,NULL:TRUE DO参数:USER_ID,ID,REQUIRED:TRUE END DEF USERS::USER。所有终端定义用户(user_id:)::user。查找(User_Id)结束端。

查询GetUser($UserID:ID,$FollowersLimit:Int,$FollowersCursor:ID){user(UserID:$UserID){Followers(Limit:$Limit,Cursor:$FollowersCursor){昵称}}。

当有人试图在相同的查询中用自己的追随者加载一个完整的用户列表时,就会出现这个问题:

查询GetUsersWithFollowers($Limit:INT$FollowersLimit:INT$FollowersCursor:ID){Users(Limit:$Limit){昵称Followers(Limit:$Limit,Cursor:$FollowersCursor){昵称}}。

在这种情况下,我们根本摆脱不了N+1:因为游标分页,我们不得不为每个用户做一个简单的数据库调用。要更好地处理这样的案件,我们可以改用不那么优雅的解决方案,并将分页移动到最高层:

类类型::Query<;BaseObject字段:USERS,[USER],NULL:FALSE字段:USER,USER,NULL:TRUE DO参数:user_id,ID,Required:TRUE结束字段:USER_Followers,[User],NULL:FALSE DO参数:Limit,Integer,Required:TRUE,DEFAULT_VALUE:2参数:Cursor,Integer,Required:FALSE End Def Users::User。所有终端定义用户(user_id:)::user。Find(User_Id)end def user_Followers(user_id:,Limit:,Cursor:nil)Scope=UserConnection。其中(user_id:user_id)。订单(user_id::desc)。限制(限制)范围=范围。如果游标作用域结束,则其中(";user_id<;Cursor";,Cursor)。

这种设计仍然可以让用户轻松加载用户并吸引他们的追随者,但事实证明,我们从传统服务器端的N+1移动到了N+1个HTTP请求。这个解决方案看起来不错,但是嘿,我们喜欢GraphQL的逻辑架构结构!我们想要从新的用户类型中获得追随者!

没问题。我们可以限制在请求多个用户时获取更多关注者字段。当发生这种情况时,让我们返回一个错误:

类类型::Query<;BaseObject字段:Users,[user],NULL:FALSE,Extras:[:Lookhead]字段:User,User,NULL:TRUE DO参数:user_id,id,Required:True End Def Users(Lookhead:)if Lookhead。选择?(:关注者)提升GraphQL::ExecutionError,";关注者只能以单一关联";End::User访问。所有终端定义用户(user_id:)::user。查找(User_Id)结束端。

有了这个方案,仍然有可能吸引一个单一用户的追随者,而且我们已经完全防止了不想要的情况。别忘了在我们的文档中提到这一点!

就这样!。你已经完成了我们的小指南,现在你至少有六种不同的方法可以在你的Ruby-GrapgQL代码中试用,从而让你的应用程序N+1免费。

别忘了在我们的博客上查看关于GraphQL和N+1问题的其他文章:从初学者友好的代码沿线教程(分三部分(从这里开始)介绍如何使用Active Storage Direct Upload构建一个GraphQL应用程序和Reaction前端),到更具体的使用案例-使用GraphQL和Active Storage Direct Upload,处理来自Apollo的持久化查询,以及在GraphQL-Ruby中报告不可为空的违规。

我们还有几个小技巧可以让处理N+1变得更容易,比如在经典的Rails应用程序中处理N+1更容易,我们还提供了几篇与之配套的文章:使用Ruby和Trails的n_plus_one_control测试匹配器及早粉碎N+1个查询,以及如何对抗N+1个查询的九头蛇(Hydra)查询。

在过去的几年里,我们的团队投入了大量的精力,包括构建开源,让GraphQL成为Erails应用领域的一流公民。如果你想在你的Ruby后端引入一个新的GraphQL API--尽管跟我们联系吧。