Πώς να δημιουργήσετε ισχυρούς διακομιστές GraphQL με σκουριά

Ρύθμιση διακομιστή GraphQL με Rust, Juniper, Diesel και Actix. μαθαίνοντας για τα πλαίσια ιστού του Rust και ισχυρές μακροεντολές.

Πηγαίος κώδικας: github.com/iwilsonq/rust-graphql-example

Η εξυπηρέτηση εφαρμογών μέσω GraphQL γίνεται γρήγορα ο ευκολότερος και πιο αποτελεσματικός τρόπος παράδοσης δεδομένων σε πελάτες. Είτε χρησιμοποιείτε φορητή συσκευή είτε πρόγραμμα περιήγησης, παρέχει τα ζητούμενα δεδομένα και τίποτα περισσότερο.

Οι εφαρμογές πελατών δεν χρειάζεται πλέον να συνδυάζουν πληροφορίες από ξεχωριστές πηγές δεδομένων. Οι διακομιστές GraphQL είναι υπεύθυνοι για την ενοποίηση, εξαλείφοντας την ανάγκη για υπερβολικά δεδομένα και αιτήσεις μετ 'επιστροφής για δεδομένα.

Φυσικά, αυτό συνεπάγεται ότι ο διακομιστής πρέπει να χειριστεί τη συγκέντρωση δεδομένων από διαφορετικές πηγές, όπως οικιακές υπηρεσίες backend, βάσεις δεδομένων ή API τρίτων. Αυτό μπορεί να είναι έντασης πόρου, πώς μπορούμε να βελτιστοποιήσουμε για τον χρόνο της CPU;

Το Rust είναι ένα θαύμα μιας γλώσσας, συνδυάζοντας την ακατέργαστη απόδοση μιας γλώσσας χαμηλού επιπέδου όπως το C με την εκφραστικότητα των σύγχρονων γλωσσών. Τονίζει την ασφάλεια του τύπου και της μνήμης, ειδικά όταν υπάρχουν δυνητικά αγώνες δεδομένων σε ταυτόχρονες λειτουργίες.

Ας δούμε τι συμβαίνει στην κατασκευή ενός διακομιστή GraphQL με το Rust. Θα μάθουμε

  • Διακομιστής Juniper GraphQL
  • Πλαίσιο Ιστού Actix ενσωματωμένο στο Juniper
  • Diesel για ερώτηση μιας βάσης δεδομένων 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 έχει ένα πολύ ωραίο API και λειτουργεί καλά με τη σταθερή έκδοση του 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 API, θα μπορούσαμε να προχωρήσουμε στην προσθήκη περισσότερων διαδρομών και σχετικών χειριστών. Σύντομα θα δούμε ότι εμπορευόμαστε μια λίστα χειριστών διαδρομών για αντικείμενα και τις σχέσεις τους.

Τώρα θα παρουσιάσουμε τη βιβλιοθήκη GraphQL.

Δημιουργία σχήματος GraphQL

Στη ρίζα κάθε σχήματος 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 { 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() } 

You'll notice I've specified a number of imports that we will be using, including the schema we've just created. Also see that:

  • we call create_schema inside an Arc (atomically reference counted), to allow shared immutable state across threads (cooking with ? here I know)
  • we mark the closure inside HttpServer::new with move, indicating that the closure takes ownership of the inner variables, that is, it gains a copy of schema
  • schema is passed to the data method indicating that it is to be used inside the application as shared state between the two services

We must now implement the handlers for those two services. Starting with the "/graphql" route:

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

Άλλο τακτοποιημένο υλικό ανάγνωσης

Εδώ είναι μερικές από τις βιβλιοθήκες με τις οποίες συνεργαστήκαμε εδώ. Έχουν επίσης υπέροχη τεκμηρίωση και οδηγούς, οπότε φροντίστε να τους διαβάσετε :)

  • Εφαρμογή Rust Futures στο Tokio
  • Juniper - GraphQL Server για Rust
  • Diesel - Safe, Extensible ORM and Query Builder for Rust
  • Actix - το ισχυρό σύστημα ηθοποιών του Rust και το πιο διασκεδαστικό πλαίσιο ιστού