使用 Rust 构建强大的 GraphQL 服务器

2025-05-26

使用 Rust 构建强大的 GraphQL 服务器

照片由 Bill Oxford 在 Unsplash 上拍摄

使用 Rust、Juniper、Diesel 和 Actix 设置 GraphQL 服务器;在此过程中了解 Rust 的 Web 框架和强大的宏。

源代码:github.com/iwilsonq/rust-graphql-example

通过 GraphQL 为应用程序提供服务正迅速成为向客户端传递数据的最简单、最有效的方式。无论您使用的是移动设备还是浏览器,它都会提供所请求的数据,仅此而已。

客户端应用程序不再需要将来自不同数据源的信息拼接在一起。GraphQL 服务器负责集成,从而消除了对多余数据和往返数据请求的需求。

当然,这意味着服务器必须处理来自不同来源的聚合数据,例如自有后端服务、数据库或第三方 API。这可能会占用大量资源,我们如何优化 CPU 时间?

Rust 是一门令人惊叹的语言,它将 C 等低级语言的原始性能与现代语言的表达能力完美结合。它强调类型和内存安全,尤其是在并发操作中可能存在数据竞争的情况下。

让我们看看如何使用 Rust 构建 GraphQL 服务器。我们将学习

  • Juniper GraphQL 服务器
  • Actix Web 框架与 Juniper 集成
  • 用于查询 SQL 数据库的 Diesel
  • 用于这些库的有用的 Rust 宏和派生特征

请注意,我不会详细介绍如何安装 Rust 或 Cargo。本文假设您对 Rust 工具链有一些初步了解。

设置 HTTP 服务器

首先,我们需要初始化我们的项目,cargo然后安装依赖项。

    cargo new rust-graphql-example
    cd rust-graphql-example
Enter fullscreen mode Exit fullscreen mode

初始化命令引导我们的 Cargo.toml 文件,其中包含我们的项目依赖项以及一个包含简单“Hello World”示例的main.rs文件。

    // main.rs

    fn main() {
      println!("Hello, world!");
    }
Enter fullscreen mode Exit fullscreen mode

作为健全性检查,请随意运行cargo run以执行该程序。

在 Rust 中安装必要的库意味着添加一行包含库名称和版本号的内容。让我们像这样更新 Cargo.toml 的依赖项部分:


    # Cargo.toml

    [dependencies]
    actix-web = "1.0.0"
    diesel = { version = "1.0.0", features = ["postgres"] }
    dotenv = "0.9.0"
    env_logger = "0.6"
    futures = "0.1"
    juniper = "0.13.1"
    serde = "1.0"
    serde_derive = "1.0"
    serde_json = "1.0"
Enter fullscreen mode Exit fullscreen mode

本文将介绍如何使用Juniper作为 GraphQL 库,并使用Actix作为底层 HTTP 服务器来实现 GraphQL 服务器。Actix 拥有非常优秀的 API,并且与稳定版 Rust 兼容良好。

添加这些行后,项目下次编译时将包含这些库。在编译之前,让我们用一个基本的 HTTP 服务器来更新 main.rs,以处理索引路由。

    // main.rs
    use std::io;

    use actix_web::{web, App, HttpResponse, HttpServer, Responder};

    fn main() -> io::Result<()> {
        HttpServer::new(|| {
            App::new()
                .route("/", web::get().to(index))
        })
        .bind("localhost:8080")?
        .run()
    }

    fn index() -> impl Responder {
        HttpResponse::Ok().body("Hello world!")
    }
Enter fullscreen mode Exit fullscreen mode

顶部的前两行将我们需要的模块引入范围。这里的 main 函数返回一个io::Result类型,这使得我们可以使用问号作为处理结果的简写。

服务器的路由和配置是在的实例中创建的App,该实例是在 HTTP 服务器的构造函数提供的闭包中创建的。

路由本身由 index 函数处理,该函数的名称可以随意命名。只要该函数正确实现,Responder就可以将其用作 index 路由的 GET 请求的参数。

如果我们正在编写 REST API,我们可以继续添加更多路由和相关的处理程序。我们很快就会看到,我们正在用路由处理程序列表来交换对象及其关系。

现在我们将介绍 GraphQL 库。

创建 GraphQL Schema

每个 GraphQL 模式的根源都是一个根查询。通过这个根查询,我们可以查询对象列表、特定对象以及它们可能包含的任何字段。

将其称为 QueryRoot,我们将在代码中用相同的名称表示它。由于我们不会设置数据库或任何第三方 API,因此我们将对此处的少量数据进行硬编码。

为了给这个例子添加一点色彩,该模式将描述一个通用的成员列表。

在 src 下,添加一个名为 graphql_schema.rs 的新文件以及以下内容:

    // graphql_schema.rs
    use juniper::{EmptyMutation, RootNode};

    struct Member {
      id: i32,
      name: String,
    }

    #[juniper::object(description = "A member of a team")]
    impl Member {
      pub fn id(&self) -> i32 {
        self.id  
      }

      pub fn name(&self) -> &str {
        self.name.as_str()
      }
    }

    pub struct QueryRoot;

    #[juniper::object]
    impl QueryRoot {
      fn members() -> Vec<Member> {
        vec![
          Member {
            id: 1,
            name: "Link".to_owned(),
          },
          Member {
            id: 2,
            name: "Mario".to_owned(),
          }
        ]
      }
    }
Enter fullscreen mode Exit fullscreen mode

除了导入之外,我们还定义了项目中的第一个 GraphQL 对象,即成员。它们很简单,只有 id 和 name。稍后我们会考虑更复杂的字段和模式。

将类型存根QueryRoot为单元结构体后,我们就可以定义字段本身了。Juniper 公开了一个名为 的 Rust 宏,object它允许我们在整个架构的不同节点上定义字段。目前,我们只有 QueryRoot 节点,因此我们将在该节点上公开一个名为 members 的字段。

Rust 宏的语法通常与标准函数不同。它们不仅仅是接受一些参数并产生结果,它们还会在编译时扩展为更复杂的代码。

公开架构

在我们创建成员字段的宏调用下面,我们将定义RootNode在我们的模式上公开的类型。

    // graphql_schema.rs

    pub type Schema = RootNode<'static, QueryRoot, EmptyMutation<()>>;

    pub fn create_schema() -> Schema {
      Schema::new(QueryRoot {}, EmptyMutation::new())
    }
Enter fullscreen mode Exit fullscreen mode

由于 Rust 的强类型特性,我们被迫提供突变对象参数。Juniper 提供了一个EmptyMutation结构体,专门用于这种情况,即当我们想要创建只读模式时。

现在架构已准备就绪,我们可以在 main.rs 中更新服务器以处理“/graphql”路由。既然有一个游乐场也很不错,我们将为 GraphiQL(交互式 GraphQL 游乐场)添加一个路由。

    // main.rs
    #[macro_use]
    extern crate juniper;

    use std::io;
    use std::sync::Arc;

    use actix_web::{web, App, Error, HttpResponse, HttpServer};
    use futures::future::Future;
    use juniper::http::graphiql::graphiql_source;
    use juniper::http::GraphQLRequest;

    mod graphql_schema;

    use crate::graphql_schema::{create_schema, Schema};

    fn main() -> io::Result<()> {
        let schema = std::sync::Arc::new(create_schema());
        HttpServer::new(move || {
            App::new()
                .data(schema.clone())
                .service(web::resource("/graphql").route(web::post().to_async(graphql)))
                .service(web::resource("/graphiql").route(web::get().to(graphiql)))
        })
        .bind("localhost:8080")?
        .run()
    }
Enter fullscreen mode Exit fullscreen mode

你会注意到我指定了一些我们将要使用的导入,包括我们刚刚创建的模式。另请参见:

  • 我们create_schema在 Arc 内部调用(原子引用计数),以允许跨线程共享不可变状态(我知道这里用🔥烹饪)
  • 我们用move标记 HttpServer::new 中的闭包,表示闭包拥有内部变量的所有权,也就是说,它获得了schema
  • schema传递给data方法,表明它将在应用程序内部用作两个服务之间的共享状态

现在我们必须实现这两个服务的处理程序。从“/graphql”路由开始:

    // main.rs

    // fn main() ...

    fn graphql(
        st: web::Data<Arc<Schema>>,
        data: web::Json<GraphQLRequest>,
    ) -> impl Future<Item = HttpResponse, Error = Error> {
        web::block(move || {
            let res = data.execute(&st, &());
            Ok::<_, serde_json::error::Error>(serde_json::to_string(&res)?)
        })
        .map_err(Error::from)
        .and_then(|user| {
            Ok(HttpResponse::Ok()
                .content_type("application/json")
                .body(user))
        })
    }
Enter fullscreen mode Exit fullscreen mode

我们对“/graphql”路由的实现,从应用程序状态中针对我们的模式执行 GraphQL 请求。它通过创建一个Future 对象web::block链接成功和错误状态的处理程序来实现这一点。

Future 类似于 JavaScript 中的 Promises,这足以理解这段代码片段。如果您想更深入地了解 Rust 中的 Future,我推荐Joe Jackson 的这篇文章

为了测试我们的 GraphQL 模式,我们还将为“/graphiql”添加一个处理程序。

    // main.rs

    // fn graphql() ...

    fn graphiql() -> HttpResponse {
        let html = graphiql_source("http://localhost:8080/graphql");
        HttpResponse::Ok()
            .content_type("text/html; charset=utf-8")
            .body(html)
    }
Enter fullscreen mode Exit fullscreen mode

这个处理程序要简单得多,它仅仅返回 GraphiQL 交互式游乐场的 HTML。我们只需要指定哪个路径来服务我们的 GraphQL 模式,在本例中是“/graphql”。

通过cargo run导航到http://localhost:8080/graphiql,我们可以尝试我们配置的字段。

graphiql 中的会员查询

它似乎确实比使用Node.js 和 Apollo设置 GraphQL 服务器需要更多的努力,但 Rust 的静态类型加上其令人难以置信的性能使其成为一项值得的交易——如果你愿意为此付出努力的话。

为真实数据设置 Postgres

如果我到此为止,我甚至无法充分理解文档中的示例。我在开发时自己编写的两个成员的静态列表在本篇文章中不会出现。

安装 Postgres 和设置您自己的数据库属于另一篇文章,但我将介绍如何安装diesel,这是用于处理 SQL 数据库的流行 Rust 库。

请参阅此处,了解如何在您的计算机上本地安装 Postgres。如果您更熟悉 MySQL,也可以使用其他数据库。

diesel CLI 将引导我们初始化表。让我们安装它:

    cargo install diesel_cli --no-default-features --features postgres
Enter fullscreen mode Exit fullscreen mode

之后,我们将向工作目录中的 .env 文件添加一个连接 URL:

    echo DATABASE_URL=postgres://localhost/rust_graphql_example > .env
Enter fullscreen mode Exit fullscreen mode

一旦有了它,您就可以运行:

    diesel setup

    # followed by

    diesel migration generate create_members
Enter fullscreen mode Exit fullscreen mode

现在你的目录中会有一个 migrations 文件夹。里面有两个 SQL 文件:一个 up.sql 用于设置数据库,另一个 down.sql 用于关闭数据库。

我将以下内容添加到 up.sql 中:

    CREATE TABLE teams (
      id SERIAL PRIMARY KEY,
      name VARCHAR NOT NULL
    );

    CREATE TABLE members (
      id SERIAL PRIMARY KEY,
      name VARCHAR NOT NULL,
      knockouts INT NOT NULL DEFAULT 0,
      team_id INT NOT NULL,
      FOREIGN KEY (team_id) REFERENCES teams(id)
    );

    INSERT INTO teams(id, name) VALUES (1, 'Heroes');
    INSERT INTO members(name, knockouts, team_id) VALUES ('Link', 14, 1);
    INSERT INTO members(name, knockouts, team_id) VALUES ('Mario', 11, 1);
    INSERT INTO members(name, knockouts, team_id) VALUES ('Kirby', 8, 1);

    INSERT INTO teams(id, name) VALUES (2, 'Villains');
    INSERT INTO members(name, knockouts, team_id) VALUES ('Ganondorf', 8, 2);
    INSERT INTO members(name, knockouts, team_id) VALUES ('Bowser', 11, 2);
    INSERT INTO members(name, knockouts, team_id) VALUES ('Mewtwo', 12, 2);
Enter fullscreen mode Exit fullscreen mode

然后在 down.sql 中添加:

    DROP TABLE members;
    DROP TABLE teams;
Enter fullscreen mode Exit fullscreen mode

如果你以前写过 SQL,这些语句应该会比较容易理解。我们正在创建两个表,一个用于存储团队,另一个用于存储团队成员。

如果你还没注意到的话,我根据《任天堂明星大乱斗》建模了这些数据。这有助于巩固学习成果。

现在运行迁移:

    diesel migration run
Enter fullscreen mode Exit fullscreen mode

如果您想验证 down.sql 脚本是否能够销毁这些表,请运行:diesel migration redo

现在,我将 GraphQL 模式文件命名为 graphql_schema.rs 而不是 schema.rs 的原因是因为 diesel 默认在我们的 src 方向覆盖该文件。

该文件保存了我们 SQL 表的 Rust 宏表示。了解这个宏的具体工作原理并不重要table!,但尽量不要编辑这个文件——字段的顺序很重要!

    // schema.rs (Generated by diesel cli)

    table! {
        members (id) {
            id -> Int4,
            name -> Varchar,
            knockouts -> Int4,
            team_id -> Int4,
        }
    }

    table! {
        teams (id) {
            id -> Int4,
            name -> Varchar,
        }
    }

    joinable!(members -> teams (team_id));

    allow_tables_to_appear_in_same_query!(
        members,
        teams,
    );
Enter fullscreen mode Exit fullscreen mode

最后,感谢一条评论,我们将要导入 diesel 并在 main.rs 中公开模式模块:


    #[macro_use]
+   extern crate diesel;
    extern crate juniper;

    use std::io;
    use std::sync::Arc;

    use actix_web::{web, App, Error, HttpResponse, HttpServer};
    use futures::future::Future;
    use juniper::http::graphiql::graphiql_source;
    use juniper::http::GraphQLRequest;

    mod graphql_schema;
+   mod schema;

    use crate::graphql_schema::{create_schema, Schema};


Enter fullscreen mode Exit fullscreen mode

为我们的处理器连接 Diesel

为了提供表中的数据,我们必须首先Member使用新字段更新我们的结构:

// graphql_schema.rs

+ #[derive(Queryable)]
pub struct Member {
  pub id: i32,
  pub name: String,
+ pub knockouts: i32,
+ pub team_id: i32,
}

#[juniper::object(description = "A member of a team")]
impl Member {
  pub fn id(&self) -> i32 {
    self.id  
  }

  pub fn name(&self) -> &str {
    self.name.as_str()
  }

+ pub fn knockouts(&self) -> i32 {
+   self.knockouts
+ }

+ pub fn team_id(&self) -> i32 {
+   self.team_id
+ }
}
Enter fullscreen mode Exit fullscreen mode

请注意,我们还将Queryable派生属性添加到Member。这将告诉 Diesel 在 Postgres 中查询正确表所需的一切信息。

另外,添加一个Team结构:

    // graphql_schema.rs

    #[derive(Queryable)]
    pub struct Team {
      pub id: i32,
      pub name: String,
    }

    #[juniper::object(description = "A team of members")]
    impl Team {
      pub fn id(&self) -> i32 {
        self.id
      }

      pub fn name(&self) -> &str {
        self.name.as_str()
      }

      pub fn members(&self) -> Vec<Member> {
        vec![]
      }
    }
Enter fullscreen mode Exit fullscreen mode

稍后,我们将更新members函数以Team返回数据库查询。但首先,让我们为成员添加一个根调用。

    // graphql_schema.rs
    + extern crate dotenv;

    + use std::env;

    + use diesel::pg::PgConnection;
    + use diesel::prelude::*;
    + use dotenv::dotenv;
    use juniper::{EmptyMutation, RootNode};

    + use crate::schema::members;

    pub struct QueryRoot;

    +  fn establish_connection() -> PgConnection {
    +    dotenv().ok();
    +    let database_url = env::var("DATABASE_URL").expect("DATABASE_URL must be set");
    +    PgConnection::establish(&database_url).expect(&format!("Error connecting to {}", database_url))
    +  }

    #[juniper::object]
    impl QueryRoot {
      fn members() -> Vec<Member> {
    -   vec![
    -     Member {
    -       id: 1,
    -       name: "Link".to_owned(),
    -     },
    -     Member {
    -       id: 2,
    -       name: "Mario".to_owned(),
    -     }
    -   ]
    +   use crate::schema::members::dsl::*;
    +   let connection = establish_connection();
    +   members
    +     .limit(100)
    +     .load::<Member>(&connection)
    +     .expect("Error loading members")
      }
    }
Enter fullscreen mode Exit fullscreen mode

非常好,我们第一次使用了 diesel 查询。初始化连接后,我们使用由table!schema.rs 中的宏生成的成员 dsl,并调用load,表示我们希望加载Member对象。

建立连接意味着使用我们之前声明的环境变量连接到我们的本地 Postgres 数据库。

假设所有输入都正确,请使用 重新启动服务器cargo run,打开 GraphiQL 并发出成员查询,也许添加两个新字段。

团队查询将非常相似 - 不同之处在于我们还必须将查询的一部分添加到Team结构上的成员函数中,以解决 GraphQL 类型之间的关系。

    // graphql_schema.rs

    #[juniper::object]
    impl QueryRoot {
      fn members() -> Vec<Member> {
        use crate::schema::members::dsl::*;
        let connection = establish_connection();
        members
          .limit(100)
          .load::<Member>(&connection)
          .expect("Error loading members")
      }

    +  fn teams() -> Vec<Team> {
    +    use crate::schema::teams::dsl::*;
    +    let connection = establish_connection();
    +    teams
    +      .limit(10)
    +      .load::<Team>(&connection)
    +      .expect("Error loading teams")
    +  }
    }

    // ...

    #[juniper::object(description = "A team of members")]
    impl Team {
      pub fn id(&self) -> i32 {
        self.id
      }

      pub fn name(&self) -> &str {
        self.name.as_str()
      }

      pub fn members(&self) -> Vec<Member> {
    -    vec![]
    +    use crate::schema::members::dsl::*;
    +    let connection = establish_connection();
    +    members
    +      .filter(team_id.eq(self.id))
    +      .limit(100)
    +      .load::<Member>(&connection)
    +      .expect("Error loading members")
      }
    }
Enter fullscreen mode Exit fullscreen mode

当运行 GraphiQL 时,我们得到:

graphiql 中更复杂的查询

我真的很喜欢这个结果,但是为了使本教程完整,我们还必须添加一件事。

创建成员突变

如果服务器是只读的,不能写入,那还有什么用呢?当然,写入肯定也有用处,但是我们想把数据写入数据库,这能有多难呢?

首先,我们将创建一个MutationRoot结构体,最终它将取代EmptyMutation。然后,我们将添加柴油插入查询。

    // graphql_schema.rs

    // ...

    pub struct MutationRoot;

    #[juniper::object]
    impl MutationRoot {
      fn create_member(data: NewMember) -> Member {
        let connection = establish_connection();
        diesel::insert_into(members::table)
          .values(&data)
          .get_result(&connection)
          .expect("Error saving new post")
      }
    }

    #[derive(juniper::GraphQLInputObject, Insertable)]
    #[table_name = "members"]
    pub struct NewMember {
      pub name: String,
      pub knockouts: i32,
      pub team_id: i32,
    }
Enter fullscreen mode Exit fullscreen mode

按照 GraphQL 突变的惯例,我们定义一个名为 的输入对象NewMember,并将其作为函数的参数create_member。在这个函数内部,我们建立一个连接,并在 members 表上调用插入查询,并传递整个输入对象。

Rust 允许我们对 GraphQL 输入对象和 Diesel 可插入对象使用相同的结构,这非常方便。

对于结构,让我更清楚地说明这一点NewMember

  • 我们派生juniper::GraphQLInputObject是为了为我们的 GraphQL 模式创建一个输入对象
  • 我们派生Insertable是为了让 Diesel 知道这个结构是插入 SQL 语句的有效输入
  • 我们添加table_name属性以便 Diesel 知道将其插入哪个表

这里有很多神奇之处。这就是我喜欢 Rust 的原因,它不仅性能出色,而且代码还具有宏和派生特征等特性,可以抽象出样板代码并添加功能。

最后,在文件底部,添加MutationRoot到架构:

    // graphql_schema.rs

    pub type Schema = RootNode<'static, QueryRoot, MutationRoot>;

    pub fn create_schema() -> Schema {
      Schema::new(QueryRoot {}, MutationRoot {})
    }
Enter fullscreen mode Exit fullscreen mode

我希望一切都已准备就绪,我们现在可以测试迄今为止的所有查询和变异:

    # GraphiQL

    mutation CreateMemberMutation($data: NewMember!) {
      createMember(data: $data) {
        id
        name
        knockouts
        teamId
      }
    }

    # example query variables
    # {
    #   "data": {
    #     "name": "Samus",
    #     "knockouts": 19,
    #     "teamId": 1
    #   }
    # }
Enter fullscreen mode Exit fullscreen mode

如果该变异成功运行,您可以打开一瓶香槟,因为您正在使用 Rust 构建高性能且类型安全的 GraphQL 服务器。

感谢阅读

我希望你喜欢这篇文章,也希望它能给你自己的工作带来一些启发。

如果您想了解我下次在 Rust、ReasonML、GraphQL 或整个软件开发领域发表的文章,请随时在Twitterdev.to或我的网站ianwilson.io上关注我。

源代码在这里github.com/iwilsonq/rust-graphql-example

其他精彩阅读材料

以下是我们在这里使用过的一些库。它们也提供了很棒的文档和指南,所以一定要读一读 :)

文章来源:https://dev.to/open-graphql/building-powerful-graphql-servers-with-rust-3gla
PREV
我是如何被聘用的?为开源项目做贡献
NEXT
仅使用 CSS 将我的 PageSpeed 得分从 92% 提高到 100%