使用 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
初始化命令引导我们的 Cargo.toml 文件,其中包含我们的项目依赖项以及一个包含简单“Hello World”示例的main.rs文件。
// main.rs
fn main() {
println!("Hello, world!");
}
作为健全性检查,请随意运行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"
本文将介绍如何使用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!")
}
顶部的前两行将我们需要的模块引入范围。这里的 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(),
}
]
}
}
除了导入之外,我们还定义了项目中的第一个 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())
}
由于 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()
}
你会注意到我指定了一些我们将要使用的导入,包括我们刚刚创建的模式。另请参见:
- 我们
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))
})
}
我们对“/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)
}
这个处理程序要简单得多,它仅仅返回 GraphiQL 交互式游乐场的 HTML。我们只需要指定哪个路径来服务我们的 GraphQL 模式,在本例中是“/graphql”。
通过cargo run
导航到http://localhost:8080/graphiql,我们可以尝试我们配置的字段。
它似乎确实比使用Node.js 和 Apollo设置 GraphQL 服务器需要更多的努力,但 Rust 的静态类型加上其令人难以置信的性能使其成为一项值得的交易——如果你愿意为此付出努力的话。
为真实数据设置 Postgres
如果我到此为止,我甚至无法充分理解文档中的示例。我在开发时自己编写的两个成员的静态列表在本篇文章中不会出现。
安装 Postgres 和设置您自己的数据库属于另一篇文章,但我将介绍如何安装diesel,这是用于处理 SQL 数据库的流行 Rust 库。
请参阅此处,了解如何在您的计算机上本地安装 Postgres。如果您更熟悉 MySQL,也可以使用其他数据库。
diesel CLI 将引导我们初始化表。让我们安装它:
cargo install diesel_cli --no-default-features --features postgres
之后,我们将向工作目录中的 .env 文件添加一个连接 URL:
echo DATABASE_URL=postgres://localhost/rust_graphql_example > .env
一旦有了它,您就可以运行:
diesel setup
# followed by
diesel migration generate create_members
现在你的目录中会有一个 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);
然后在 down.sql 中添加:
DROP TABLE members;
DROP TABLE teams;
如果你以前写过 SQL,这些语句应该会比较容易理解。我们正在创建两个表,一个用于存储团队,另一个用于存储团队成员。
如果你还没注意到的话,我根据《任天堂明星大乱斗》建模了这些数据。这有助于巩固学习成果。
现在运行迁移:
diesel migration run
如果您想验证 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,
);
最后,感谢一条评论,我们将要导入 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};
为我们的处理器连接 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
+ }
}
请注意,我们还将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![]
}
}
稍后,我们将更新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")
}
}
非常好,我们第一次使用了 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")
}
}
当运行 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,
}
按照 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 {})
}
我希望一切都已准备就绪,我们现在可以测试迄今为止的所有查询和变异:
# GraphiQL
mutation CreateMemberMutation($data: NewMember!) {
createMember(data: $data) {
id
name
knockouts
teamId
}
}
# example query variables
# {
# "data": {
# "name": "Samus",
# "knockouts": 19,
# "teamId": 1
# }
# }
如果该变异成功运行,您可以打开一瓶香槟,因为您正在使用 Rust 构建高性能且类型安全的 GraphQL 服务器。
感谢阅读
我希望你喜欢这篇文章,也希望它能给你自己的工作带来一些启发。
如果您想了解我下次在 Rust、ReasonML、GraphQL 或整个软件开发领域发表的文章,请随时在Twitter、dev.to或我的网站ianwilson.io上关注我。
源代码在这里github.com/iwilsonq/rust-graphql-example。
其他精彩阅读材料
以下是我们在这里使用过的一些库。它们也提供了很棒的文档和指南,所以一定要读一读 :)
- Rust Futures 在 Tokio 中的实现
- Juniper - Rust 的 GraphQL 服务器
- Diesel - 安全、可扩展的 Rust ORM 和查询生成器
- Actix - Rust 强大的 Actor 系统和最有趣的 Web 框架