كيفية بناء خوادم GraphQL قوية مع الصدأ

إعداد خادم GraphQL باستخدام Rust و Juniper و Diesel و Actix ؛ التعرف على أطر عمل الويب الخاصة بـ Rust ووحدات الماكرو القوية على طول الطريق.

كود المصدر: github.com/iwilsonq/rust-graphql-example

أصبح تقديم التطبيقات عبر GraphQL سريعًا الطريقة الأسهل والأكثر فاعلية لتوصيل البيانات للعملاء. سواء كنت على جهاز محمول أو متصفح ، فإنه يوفر البيانات المطلوبة وليس أكثر.

لم تعد تطبيقات العميل بحاجة إلى تجميع المعلومات معًا من مصادر بيانات منفصلة. تتولى خوادم GraphQL مسؤولية التكامل ، مما يلغي الحاجة إلى البيانات الزائدة وطلبات البيانات ذهابًا وإيابًا.

بالطبع ، هذا يعني أن الخادم يجب أن يتعامل مع تجميع البيانات من مصادر مختلفة ، مثل خدمات الخلفية المملوكة للمنزل ، أو قواعد البيانات ، أو واجهات برمجة تطبيقات الطرف الثالث. قد يكون هذا مكثفًا للموارد ، كيف يمكننا تحسين وقت وحدة المعالجة المركزية؟

الصدأ هو أعجوبة للغة ، حيث يجمع بين الأداء الخام للغة منخفضة المستوى مثل C مع تعبيرات اللغات الحديثة. إنه يؤكد على أمان النوع والذاكرة ، خاصةً عند وجود سباقات بيانات محتملة في العمليات المتزامنة.

دعونا نرى ما الذي يدخل في بناء خادم GraphQL باستخدام Rust. سوف نتعلم عن

  • خادم جونيبر GraphQL
  • إطار عمل الويب Actix متكامل مع Juniper
  • ديزل للاستعلام عن قاعدة بيانات SQL
  • وحدات ماكرو Rust مفيدة والسمات المشتقة للعمل مع هذه المكتبات

لاحظ أنني لن أخوض في التفاصيل المتعلقة بتثبيت Rust أو Cargo. تفترض هذه المقالة بعض المعرفة الأولية لسلسلة أدوات Rust.

إعداد خادم HTTP

للبدء ، نحتاج إلى تهيئة مشروعنا باستخدام cargoالتبعيات ثم تثبيتها.

 cargo new rust-graphql-example cd rust-graphql-example 

يقوم أمر التهيئة بتشغيل ملف Cargo.toml الخاص بنا والذي يحتوي على تبعيات مشاريعنا بالإضافة إلى ملف main.rs الذي يحتوي على مثال بسيط "Hello World".

 // 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" 

ستغطي هذه المقالة تنفيذ خادم GraphQL باستخدام Juniper كمكتبة GraphQL و Actix كخادم HTTP أساسي. يحتوي Actix على واجهة برمجة تطبيقات لطيفة جدًا ويعمل بشكل جيد مع الإصدار الثابت من Rust.

عند إضافة هذه السطور ، في المرة التالية التي يجمع فيها المشروع ، سيتضمن تلك المكتبات. قبل أن نقوم بالتجميع ، لنقم بتحديث main.rs باستخدام خادم HTTP أساسي ، مع التعامل مع مسار الفهرس.

 // 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!") } 

أول سطرين في الأعلى يجلبان الوحدة التي نحتاجها إلى النطاق. ترجع الوظيفة الرئيسية هنا io::Resultنوعًا ، مما يسمح لنا باستخدام علامة الاستفهام كاختصار لمعالجة النتائج.

يتم إنشاء توجيه الخادم وتكوينه في مثيل App، الذي يتم إنشاؤه في إغلاق يوفره مُنشئ خادم HTTP.

يتم التعامل مع المسار نفسه بواسطة دالة الفهرس ، التي يكون اسمها عشوائيًا. طالما أن هذه الوظيفة تنفذ بشكل صحيح Responder، فيمكن استخدامها كمعامل لطلب GET في مسار الفهرس.

إذا كنا نكتب واجهة برمجة تطبيقات REST ، فيمكننا متابعة إضافة المزيد من المسارات والمعالجات المرتبطة بها. سنرى قريبًا أننا نتاجر بقائمة من معالجات الطريق للأشياء وعلاقاتهم.

الآن سوف نقدم مكتبة GraphQL.

إنشاء مخطط GraphQL

يوجد استعلام جذري في جذر كل مخطط GraphQL. من هذا الجذر ، يمكننا الاستعلام عن قوائم الكائنات ، والكائنات المحددة ، وأي حقول قد تحتوي عليها.

أطلق على هذا اسم QueryRoot ، وسنشير إليه بنفس الاسم في الكود الخاص بنا. نظرًا لأننا لن ننشئ قاعدة بيانات أو أي واجهات برمجة تطبيقات تابعة لجهات خارجية ، فسنقوم بترميز البيانات الصغيرة التي لدينا هنا.

لإضافة القليل من اللون إلى هذا المثال ، سيصوّر المخطط قائمة عامة بالأعضاء.

ضمن 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 { vec![ Member { id: 1, name: "Link".to_owned(), }, Member { id: 2, name: "Mario".to_owned(), } ] } } 

إلى جانب وارداتنا ، نحدد أول كائن GraphQL في هذا المشروع ، وهو العضو. هم كائنات بسيطة ، بمعرف واسم. سنفكر في الحقول والأنماط الأكثر تعقيدًا لاحقًا.

بعد استبعاد QueryRootالنوع كبنية وحدة ، نحدد الحقل نفسه. يعرض Juniper ماكرو Rust يسمى objectوالذي يسمح لنا بتعريف الحقول على العقد المختلفة في جميع أنحاء مخططنا. في الوقت الحالي ، لدينا فقط عقدة QueryRoot ، لذلك سنكشف عن حقل يسمى الأعضاء فيه.

غالبًا ما تحتوي وحدات الماكرو الخاصة بالصدأ على بنية غير عادية مقارنة بالوظائف القياسية. إنهم لا يأخذون فقط بعض الحجج وينتجون نتيجة ، بل يتوسعون إلى كود أكثر تعقيدًا في وقت الترجمة.

فضح المخطط

أسفل استدعاء الماكرو الخاص بنا لإنشاء حقل الأعضاء ، سنحدد 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::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داخل قوس (تم حساب المرجع ذريًا) ، للسماح بالحالة الثابتة المشتركة عبر الخيوط (الطهي باستخدام؟ هنا أعلم)
  • نحتفل بالإغلاق داخل HttpServer :: new with move ، مشيرًا إلى أن الإغلاق يأخذ ملكية المتغيرات الداخلية ، أي أنه يكتسب نسخة منschema
  • schemaإلى dataالأسلوب الذي يشير إلى أنه سيتم استخدامه داخل التطبيق كحالة مشتركة بين الخدمتين

يجب علينا الآن تنفيذ معالجات هاتين الخدمتين. بدءًا من المسار "/ graphql":

 // main.rs // fn main() ... fn graphql( st: web::Data
    
     , data: web::Json, ) -> impl Future { web::block(move || { let res = data.execute(&st, &()); Ok::(serde_json::to_string(&res)?) }) .map_err(Error::from) .and_then(|user| { Ok(HttpResponse::Ok() .content_type("application/json") .body(user)) }) } 
    

Our implementation of the "/graphql" route takes executes a GraphQL request against our schema from application state. It does this by creating a future from web::block and chaining handlers for success and error states.

Futures are analogous to Promises in JavaScript, which is enough to understand this code snippet. For a greater explanation of Futures in Rust, I recommend this article by Joe Jackson.

In order to test out our GraphQL schema, we'll also add a handler for "/graphiql".

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

This handler is much simpler, it merely returns the html of the GraphiQL interactive playground. We only need to specify which path is serving our GraphQL schema, which is "/graphql" in this case.

With cargo run and navigation to //localhost:8080/graphiql, we can try out the field we configured.

الاستعلام عن الأعضاء في Graphiql

It does seem to take a little more effort than setting up a GraphQL server with Node.js and Apollo but the static typing of Rust combined with its incredible performance makes it a worthy trade — if you're willing to work at it.

Setting up Postgres for Real Data

If I stopped here, I wouldn't even be doing the examples in the docs much justice. A static list of two members that I wrote myself at dev time will not fly in this publication.

Installing Postgres and setting up your own database belongs in a different article, but I'll walk through how to install diesel, the popular Rust library for handling SQL databases.

See here to install Postgres locally on your machine. You can also use a different database like MySQL in case you are more familiar with it.

The diesel CLI will walk us through initializing our tables. Let's install it:

 cargo install diesel_cli --no-default-features --features postgres 

After that, we will add a connection URL to a .env file in our working directory:

 echo DATABASE_URL=postgres://localhost/rust_graphql_example > .env 

Once that's there, you can run:

 diesel setup # followed by diesel migration generate create_members 

Now you'll have a migrations folder in your directory. Within it, you'll have two SQL files: one up.sql for setting up your database, the other down.sql for tearing it down.

I will add the following to 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); 

And into down.sql I will add:

 DROP TABLE members; DROP TABLE teams; 

If you've written SQL in the past, these statements will make some sense. We are creating two tables, one to store teams and one to store members of those teams.

I am modeling this data based on Smash Bros if you have not yet noticed. It helps the learning stick.

Now to run the migrations:

 diesel migration run 

If you'd like to verify that the down.sql script works to destroy those tables, run: diesel migration redo.

Now the reason why I named the GraphQL schema file graphql_schema.rs instead of schema.rs, is because diesel overwrites that file in our src direction by default.

It keeps a Rust macro representation of our SQL tables in that file. It is not so important to know how exactly this table! macro works, but try not to edit this file — the ordering of the fields matters!

 // 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, ); 

Wiring up our Handlers with Diesel

In order to serve the data in our tables, we must first update our Member struct with the new fields:

 // 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 + } } 

Note that we are also adding the Queryable derived attribute to Member. This tells Diesel everything it needs to know in order to query the right table in Postgres.

Additionally, add a Team struct:

 // 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 { vec![] } } 

In a short while, we will update the members function on Team to return a database query. But first, let us add a root call for members.

 // 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 { - 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::(&connection) + .expect("Error loading members") } } 

Very good, we have our first usage of a diesel query. After initializing a connection, we use the members dsl, which is generated from our table! macros in schema.rs, and call load, indicating that we wish to load Member objects.

Establishing a connection means connecting to our local Postgres database by using the env variable we declared earlier.

Assuming that was all input correctly, restart the server with cargo run, open GraphiQL and issue the members query, perhaps adding the two new fields.

The teams query will be very similar — the difference being we must also add a part of the query to the members function on the Team struct in order to resolve the relationship between GraphQL types.

 // graphql_schema.rs #[juniper::object] impl QueryRoot { fn members() -> Vec { use crate::schema::members::dsl::*; let connection = establish_connection(); members .limit(100) .load::(&connection) .expect("Error loading members") } + fn teams() -> Vec { + use crate::schema::teams::dsl::*; + let connection = establish_connection(); + teams + .limit(10) + .load::(&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 { - vec![] + use crate::schema::members::dsl::*; + let connection = establish_connection(); + members + .filter(team_id.eq(self.id)) + .limit(100) + .load::(&connection) + .expect("Error loading members") } } 

When running this is GraphiQL, we get:

استعلام أكثر تعقيدًا في Graphiql

I really like the way this is turning out, but there is one more thing we must add in order to call this tutorial complete.

The Create Member Mutation

What good is a server if it is read-only and not writable? Well I'm sure those have their uses too, but we would like to write data to our database, how hard can it be?

First we'll create a MutationRoot struct that will eventually replace our usage of EmptyMutation. Then we will add the diesel insertion query.

 // 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, } 

As GraphQL mutations typically go, we define an input object called NewMember and make it the argument of the create_member function. Inside this function, we establish a connection and call the insert query on the members table, passing the entire input object.

It is super convenient that Rust allows us to use the same structs for GraphQL input objects as well as Diesel insertable objects.

Let me make this a little more clear, for the NewMember struct:

  • we derive juniper::GraphQLInputObject in order to create a input object for our GraphQL schema
  • we derive Insertable in order to let Diesel know that this struct is valid input for an insertion SQL statement
  • we add the table_name attribute so that Diesel knows which table to insert it in

There is a lot of magic going on here. This is what I love about Rust, it has great performance but the code has features like macros and derived traits to abstract away boilerplate and add functionality.

Finally, at the bottom of the file, add the MutationRoot to the schema:

 // graphql_schema.rs pub type Schema = RootNode; pub fn create_schema() -> Schema { Schema::new(QueryRoot {}, MutationRoot {}) } 

I hope that everything is there, we can test out all of our queries and mutations thus far now:

 # GraphiQL mutation CreateMemberMutation($data: NewMember!) { createMember(data: $data) { id name knockouts teamId } } # example query variables # { # "data": { # "name": "Samus", # "knockouts": 19, # "teamId": 1 # } # } 

If that mutation ran successfully, you can pop open a bottle of champagne as you are on your way to building performant and type-safe GraphQL Servers with Rust.

Thanks For Reading

I hope you have enjoyed this article, I also hope that it gave you some sort of inspiration for your own work.

إذا كنت ترغب في مواكبة المرة القادمة التي أسقط فيها مقالًا في مجال Rust أو ReasonML أو GraphQL أو تطوير البرامج بشكل عام ، فلا تتردد في إعطائي متابعة على Twitter أو dev.to أو على موقع الويب الخاص بي في ianwilson.io.

شفرة المصدر هنا github.com/iwilsonq/rust-graphql-example.

مواد قراءة أخرى أنيقة

فيما يلي بعض المكتبات التي عملنا معها هنا. لديهم وثائق وأدلة رائعة أيضًا لذا تأكد من إعطائهم قراءة :)

  • تنفيذ عقود الصدأ المستقبلية في طوكيو
  • جونيبر - خادم GraphQL للصدأ
  • الديزل - منشئ استعلام عن الصدأ وآمن وقابل للتوسيع
  • Actix - نظام الممثل القوي لـ Rust وإطار الويب الأكثر متعة