#graphql #juniper #web #database-schema #data-store

juniper-eager-loading

使用Juniper时消除N+1查询错误

13个版本 (5个重大更改)

0.5.1 2020年3月4日
0.5.0 2019年11月27日
0.4.2 2019年11月14日
0.2.0 2019年6月30日

#11 in #juniper

MIT 许可证

70KB
475

juniper-eager-loading

这是一个用于避免N+1查询错误的库,旨在与Juniperjuniper-from-schema 一起使用。

它旨在使最常见的关联设置易于处理,同时保持灵活,允许您根据需要自定义。它也是100%数据存储无关的。因此,无论您的API是否由SQL数据库或其他API支持,您仍然可以使用此库。

有关用法示例和更多信息,请参阅crate文档


lib.rs:

juniper-eager-loading是一个用于避免N+1查询错误的库,旨在与Juniperjuniper-from-schema 一起使用。

它旨在使最常见的关联设置易于处理,同时保持灵活,允许您根据需要自定义。它也是100%数据存储无关的。因此,无论您的API是否由SQL数据库或其他API支持,您仍然可以使用此库。

如果您熟悉GraphQL中的N+1查询和预加载,可以直接跳转到"一个真实示例"

注意:由于此库需要juniper-from-schema,因此最好首先熟悉该库。

目录

什么是N+1查询错误?

假设您有以下GraphQL模式

schema {
    query: Query
}

type Query {
    allUsers: [User!]!
}

type User {
    id: Int!
    country: Country!
}

type Country {
    id: Int!
}

然后有人执行以下查询

query SomeQuery {
    allUsers {
        country {
            id
        }
    }
}

如果您使用SQL数据库作为数据存储并天真地解析该查询,您将在日志中看到如下内容

select * from users
select * from countries where id = ?
select * from countries where id = ?
select * from countries where id = ?
select * from countries where id = ?
...

这种情况发生是因为你首先加载所有用户,然后对每个用户进行循环,加载该用户的国家信息。这意味着1次查询用于加载用户,N次额外的查询用于加载国家。因此得名“N+1查询”。这种类型的错误会严重影响应用程序的性能,因为你进行的数据库调用比必要的多得多。

解决这个问题的一个可能方法是称为“预加载”。其思路是在循环遍历用户之前先加载所有国家。因此,你只需进行2次查询,而不是N+1次。

select * from users
select * from countries where id in (?, ?, ?, ?)

由于你是在循环之前预先加载国家,这种策略称为“预加载”。

GraphQL中的N+1s

如果你在实现GraphQL API时不够小心,你会遇到很多这些N+1查询错误。每当一个字段返回类型列表,并且这些类型在其解析器中执行查询时,你都会遇到N+1查询错误。

这也是REST API中存在的问题,但由于响应是固定的,我们可以更容易地设置必要的预加载,因为我们知道需要计算响应的类型。

然而,在GraphQL中,响应不是固定的。它们取决于传入的查询,这些查询在执行之前是未知的。因此,设置正确的预加载量需要在执行查询之前检查它们,并预加载请求的类型,以便实际的解析器不需要运行查询。这正是这个库所做的事情。

此库在高级上如何工作

如果你有一个像这样的GraphQL类型

type User {
    id: Int!
    country: Country!
}

你可以创建相应的Rust模型类型,如下所示

struct User {
    id: i32,
    country_id: i32
}

然而,这种方法有一个大问题。你将如何解析字段 User.country 而不进行数据库查询?解析器可以访问的只是一个带有 country_id 字段的 User。它无法从数据库中获取国家...

从根本上说,这些类型的模型结构不适合与GraphQL一起进行预加载。因此,这个库采取了不同的方法。

如果我们为数据库模型和GraphQL模型创建了单独的结构体呢?如下所示

#
mod models {
    pub struct User {
        id: i32,
        country_id: i32
    }

    pub struct Country {
        id: i32,
    }
}

struct User {
    user: models::User,
    country: HasOne<Country>,
}

struct Country {
    country: models::Country
}

enum HasOne<T> {
    Loaded(T),
    NotLoaded,
}

现在我们可以用如下代码解析查询

  1. 加载所有用户(第一次查询)。
  2. 将用户映射到一个国家ID列表。
  3. 使用这些ID加载所有国家(第二次查询)。
  4. 将用户与具有正确ID的国家配对,因此将 User.countryHasOne::NotLoaded 改为 HasOne::Loaded(matching_country)
  5. 在解析GraphQL字段 User.country 时,只需返回加载的国家。

一个真实示例

use juniper::{Executor, FieldResult};
use juniper_eager_loading::{prelude::*, EagerLoading, HasOne};
use juniper_from_schema::graphql_schema;
use std::error::Error;

// Define our GraphQL schema.
graphql_schema! {
    schema {
        query: Query
    }

    type Query {
        allUsers: [User!]! @juniper(ownership: "owned")
    }

    type User {
        id: Int!
        country: Country!
    }

    type Country {
        id: Int!
    }
}

// Our model types.
mod models {
    use std::error::Error;
    use juniper_eager_loading::LoadFrom;

    #[derive(Clone)]
    pub struct User {
        pub id: i32,
        pub country_id: i32
    }

    #[derive(Clone)]
    pub struct Country {
        pub id: i32,
    }

    // This trait is required for eager loading countries.
    // It defines how to load a list of countries from a list of ids.
    // Notice that `Context` is generic and can be whatever you want.
    // It will normally be your Juniper context which would contain
    // a database connection.
    impl LoadFrom<i32> for Country {
        type Error = Box<dyn Error>;
        type Context = super::Context;

        fn load(
            employments: &[i32],
            field_args: &(),
            ctx: &Self::Context,
        ) -> Result<Vec<Self>, Self::Error> {
            // ...
            # unimplemented!()
        }
    }
}

// Our sample database connection type.
pub struct DbConnection;

impl DbConnection {
    // Function that will load all the users.
    fn load_all_users(&self) -> Vec<models::User> {
        // ...
        # unimplemented!()
    }
}

// Our Juniper context type which contains a database connection.
pub struct Context {
    db: DbConnection,
}

impl juniper::Context for Context {}

// Our GraphQL user type.
// `#[derive(EagerLoading)]` takes care of generating all the boilerplate code.
#[derive(Clone, EagerLoading)]
// You need to set the context and error type.
#[eager_loading(
    context = Context,
    error = Box<dyn Error>,

    // These match the default so you wouldn't have to specify them
    model = models::User,
    id = i32,
    root_model_field = user,
)]
pub struct User {
    // This user model is used to resolve `User.id`
    user: models::User,

    // Setup a "has one" association between a user and a country.
    //
    // We could also have used `#[has_one(default)]` here.
    #[has_one(
        foreign_key_field = country_id,
        root_model_field = country,
        graphql_field = country,
    )]
    country: HasOne<Country>,
}

// And the GraphQL country type.
#[derive(Clone, EagerLoading)]
#[eager_loading(context = Context, error = Box<dyn Error>)]
pub struct Country {
    country: models::Country,
}

// The root query GraphQL type.
pub struct Query;

impl QueryFields for Query {
    // The resolver for `Query.allUsers`.
    fn field_all_users(
        &self,
        executor: &Executor<'_, Context>,
        trail: &QueryTrail<'_, User, Walked>,
    ) -> FieldResult<Vec<User>> {
        let ctx = executor.context();

        // Load the model users.
        let user_models = ctx.db.load_all_users();

        // Turn the model users into GraphQL users.
        let mut users = User::from_db_models(&user_models);

        // Perform the eager loading.
        // `trail` is used to only eager load the fields that are requested. Because
        // we're using `QueryTrail`s from "juniper_from_schema" it would be a compile
        // error if we eager loaded associations that aren't requested in the query.
        User::eager_load_all_children_for_each(&mut users, &user_models, ctx, trail)?;

        Ok(users)
    }
}

impl UserFields for User {
    fn field_id(
        &self,
        executor: &Executor<'_, Context>,
    ) -> FieldResult<&i32> {
        Ok(&self.user.id)
    }

    fn field_country(
        &self,
        executor: &Executor<'_, Context>,
        trail: &QueryTrail<'_, Country, Walked>,
    ) -> FieldResult<&Country> {
        // This will unwrap the country from the `HasOne` or return an error if the
        // country wasn't loaded, or wasn't found in the database.
        Ok(self.country.try_unwrap()?)
    }
}

impl CountryFields for Country {
    fn field_id(
        &self,
        executor: &Executor<'_, Context>,
    ) -> FieldResult<&i32> {
        Ok(&self.country.id)
    }
}
#

#[derive(EagerLoading)]

要支持预加载,类型需要实现以下特质

实现这些特质涉及大量的样板代码,因此你应该尽可能使用 #[derive(EagerLoading)] 来生成实现。

有时你可能需要对特定的关联进行自定义的 eager loading,在这种情况下,你仍然需要在你的结构体上保留 #[derive(EagerLoading)],但需要自己实现 EagerLoadChildrenOfType 以满足需要自定义设置的字段。如何实现这个的示例可以在 这里 找到。

如果您想查看没有宏的全示例,请查看 这里

属性

#[derive(EagerLoading)] 需要提供一些属性

名称 描述 默认值 示例
上下文 您的 Juniper 上下文类型。这通常会包含您的数据库连接或其他可以用来加载数据的东西。 不适用 上下文=上下文
错误 可能由 eager loading 导致的错误类型。 不适用 错误= diesel::结果::错误
模型 您 GraphQL 结构体背后的模型类型 模型::{名称结构体} 模型= crates::数据库::模型::用户
id 您的应用程序使用哪种 id 类型? i32 id= UUID
root_model_field 持有支撑模型的字段的名称 {name of struct} 使用蛇形命名。 root_model_field=user
primary_key_field 持有模型主键的字段。此字段仅用于为 #[has_many]#[has_many_through] 关联生成的代码。 id primary_key_field=标识符
打印 如果设置,将打印 GraphqlNodeForModelEagerLoadAllChildren 生成的实现。 未设置 打印

关联

关联类似于“用户有一个国家”。这些是需要 eager loading 来避免 N+1 的问题的字段。每个关联适用于不同的外键设置,并且需要以不同的方式 eager load。它们应该适合您应用程序中的大多数关联。点击查看更多详细信息。

每个关联的文档假设您正在使用 SQL 数据库,但应该可以轻松适应其他类型的数据存储。

对于您的 GraphQL 结构体中属于这些四种类型之一的每个字段,都会由 #[derive(EagerLoading)] 实现特质 EagerLoadChildrenOfType

所有关联支持的属性

所有关联都支持以下属性。

跳过

跳过为该字段实现EagerLoadChildrenOfType。如果您需要自定义实现,这很有用。

打印

这将导致在编译期间打印出为该字段实现的EagerLoadChildrenOfType。当与skip结合使用时,它将打印出一个良好的起点,以便您进行自定义。

生成的代码不会进行格式化。我们建议您使用rustfmt进行格式化。

字段参数

用于指定用于EagerLoadChildrenOfType::FieldArguments的类型。更多信息在这里

例如 #[has_one(fields_arguments = CountryUsersArgs)]。您可以在这里找到完整的示例。

代码生成默认将EagerLoadChildrenOfType::FieldArguments设置为()。这对于不接受参数的字段有效。

预加载接口或联合

对接口或联合使用预加载是可能的,但它需要在QueryTrail上调用.downcast()。有关更多信息,请参阅juniper-from-schema 文档

接受参数的字段预加载

如果您有一个接受参数的 GraphQL 字段,您可能需要考虑将其用于预加载。

如果您为这样的字段使用代码生成,您必须在关联字段上指定类型。更多信息在这里

如果您手动实现EagerLoadChildrenOfType,您必须将EagerLoadChildrenOfType::FieldArguments设置为由 juniper-from-schema 生成的参数结构体的类型。您可以在这里找到更多信息。

您还必须为您的模型实现LoadFrom<T, ArgumentType>。您可以在这里找到完整的示例。

如果您看到类型错误如下

error[E0308]: mismatched types
   --> src/main.rs:254:56
    |
   254 | #[derive(Clone, Eq, PartialEq, Debug, Ord, PartialOrd, EagerLoading)]
    |                                                           ^^^^^^^^^^^^ expected (), found struct `query_trails::CountryUsersArgs`
    |
    = note: expected type `&()`
               found type `&query_trails::CountryUsersArgs<'_>`

这是因为您的 GraphQL 字段 Country.users 接受参数。代码生成默认使用 () 作为参数类型,因此您得到了这个类型错误。有趣的是,编译器不会让您忘记处理参数。

Diesel辅助函数

为许多模型类型实现 LoadFrom 可能会涉及大量的模板代码。如果你使用 Diesel,建议你使用 以下宏之一来生成 实现。

当您的GraphQL模式与数据库模式不匹配时

此库支持大多数类型的懒加载关联设置,但它可能不支持你的应用程序中所有可能存在的设置。它还最好是在你的数据库模式与你的 GraphQL 模式紧密匹配时使用。

如果你发现自己需要实现不支持的功能,请记住,你仍然可以按照自己的方式实现解析函数。所以,如果在解析器中进行查询是获得所需行为唯一方式的话,那就这么做吧。避免一些 N+1 查询比避免所有查询要好。

然而,如果你有一个你认为这个库应该支持的设置,请不要犹豫,提交一个 issue

依赖项

~7–16MB
~238K SLoC