Kaip sukurti galingus GraphQL serverius su rūdimis

„GraphQL“ serverio su „Rust“, „Juniper“, „Diesel“ ir „Actix“ nustatymas; sužinoti apie „Rust“ žiniatinklio sistemas ir galingas makrokomandas.

Šaltinio kodas: github.com/iwilsonq/rust-graphql-example

Programų aptarnavimas per „GraphQL“ greitai tampa paprasčiausias ir efektyviausias būdas pateikti duomenis klientams. Nesvarbu, ar naudojate mobilųjį įrenginį, ar naršyklę, jie teikia prašomus duomenis ir nieko daugiau.

Kliento programoms nebereikia susieti informacijos iš atskirų duomenų šaltinių. „GraphQL“ serveriai yra atsakingi už integraciją, todėl nebereikia duomenų pertekliaus ir prašymų į abi puses.

Žinoma, tai reiškia, kad serveris turi tvarkyti duomenis iš įvairių šaltinių, pavyzdžiui, namų valdomų vidinių paslaugų, duomenų bazių ar trečiųjų šalių API. Tai gali reikalauti daug išteklių, kaip mes galime optimizuoti procesoriaus laiką?

Rūdis yra kalbos stebuklas, derinantis neapdorotą žemo lygio kalbos, tokios kaip C, veikimą su šiuolaikinių kalbų išraiškingumu. Tai pabrėžia tipo ir atminties saugumą, ypač kai vienu metu atliekant veiksmus potencialiai vyksta duomenų lenktynės.

Pažiūrėkime, kas yra kuriant „GraphQL“ serverį su „Rust“. Mes ketiname sužinoti apie tai

  • „Juniper GraphQL“ serveris
  • „Actix“ žiniatinklio sistema integruota su „Juniper“
  • Dyzelinas už SQL duomenų bazės užklausą
  • Naudingos „Rust“ makrokomandos ir išvestiniai bruožai darbui su šiomis bibliotekomis

Atkreipkite dėmesį, kad nesigilinsiu į „Rust“ ar „Cargo“ diegimą. Šiame straipsnyje pateikiamos tam tikros išankstinės žinios apie „Rust“ įrankių grandinę.

HTTP serverio nustatymas

Norėdami pradėti, turime inicijuoti savo projektą cargoir įdiegti priklausomybes.

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

Inicijavimo komanda įkelia mūsų Cargo.toml failą, kuriame yra mūsų projektų priklausomybės, taip pat failą main.rs, kuriame yra paprastas „Hello World“ pavyzdys.

 // main.rs fn main() { println!("Hello, world!"); } 

Kaip sveiko proto patikrą, nedvejodami paleiskite cargo run, kad įvykdytumėte programą.

Reikiamų bibliotekų įdiegimas „Rust“ reiškia eilutės su bibliotekos pavadinimu ir versijos numeriu pridėjimą. Atnaujinkime Cargo.toml priklausomybės skyrius taip:

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

Šis straipsnis apims „GraphQL“ serverio įgyvendinimą naudojant „Juniper“ kaip „GraphQL“ biblioteką ir „Actix“ kaip pagrindinį HTTP serverį. „Actix“ turi labai gražią API ir gerai veikia su stabilia „Rust“ versija.

Kai šios eilutės bus pridėtos, kitą kartą, kai projektas bus sudarytas, tose bibliotekose bus. Prieš kompiliuojant, leidžia atnaujinti main.rs naudojant pagrindinį HTTP serverį, tvarkant indekso maršrutą.

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

Pirmosios dvi viršuje esančios eilutės suteikia mums reikalingą modulį. Pagrindinė funkcija čia pateikia io::Resulttipą, kuris leidžia mums klaustuką naudoti kaip stenografą tvarkant rezultatus.

Serverio maršrutas ir konfigūracija yra sukurta egzemplioriuje App, kuris sukurtas uždaryme, kurį teikia HTTP serverio konstruktorius.

Patį maršrutą tvarko indekso funkcija, kurios pavadinimas yra savavališkas. Kol ši funkcija tinkamai įgyvendinama, Responderji gali būti naudojama kaip GET užklausos parametras indekso maršrute.

Jei rašytume REST API, galėtume pridėti daugiau maršrutų ir susijusių tvarkytuvų. Netrukus pamatysime, kad prekiaujame objektų ir jų ryšių maršruto tvarkytojų sąrašu.

Dabar pristatysime „GraphQL“ biblioteką.

„GraphQL“ schemos kūrimas

Kiekvienos „GraphQL“ schemos šaknyje yra pagrindinė užklausa. Iš šios šaknies galime ieškoti objektų, konkrečių objektų ir bet kokių laukų, kuriuose jie gali būti, sąrašų.

Pavadinkime tai „QueryRoot“ ir savo kode tai pažymėsime tuo pačiu pavadinimu. Kadangi neketiname kurti duomenų bazės ar trečiųjų šalių API, sunkiai užkoduosime čia turimus mažus duomenis.

Jei norite pridėti šiek tiek spalvų į šį pavyzdį, schema parodys bendrą narių sąrašą.

Pagal src pridėkite naują failą pavadinimu graphql_schema.rs kartu su šiuo turiniu:

 // 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(), } ] } } 

Kartu su importu mes apibrėžiame savo pirmąjį „GraphQL“ objektą šiame projekte - narį. Jie yra paprastos būtybės, turinčios ID ir vardą. Vėliau pagalvosime apie sudėtingesnes sritis ir modelius.

Išstūmus QueryRoottipą kaip vieneto struktūrą, turime apibrėžti patį lauką. Kadagys atskleidžia „Rust“ makrokomandą, objectkuri leidžia mums apibrėžti laukus skirtinguose mazguose visoje schemoje. Kol kas turime tik „QueryRoot“ mazgą, todėl jame atidengsime lauką, vadinamą nariais.

Rust macros often have an unusual syntax compared to standard functions. They don't merely take some arguments and produce a result, they expand into much more complex code at compile time.

Exposing the Schema

Below our macro call to create the members field, we will define the RootNode type that we expose on our schema.

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

Because of the strong typing in Rust, we are forced to provide the mutation object argument. Juniper exposes an EmptyMutation struct for just this occasion, that is, when we want to create a read-only schema.

Now that the schema is prepared, we can update our server in main.rs to handle the "/graphql" route. Since having a playground is also nice, we'll add a route for GraphiQL, the interactive GraphQL playground.

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

Narių užklausa grafike

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:

Sudėtingesnė užklausa grafike

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.

Jei norėtumėte sekti kitą kartą, kai numesiu straipsnį „Rust“, „ReasonML“, „GraphQL“ ar apskritai programinės įrangos kūrimo srityje, nedvejodami leiskite man sekti „Twitter“, „dev.to“ ar mano svetainėje adresu ianwilson.io.

Šaltinio kodas yra čia github.com/iwilsonq/rust-graphql-example.

Kita tvarkinga skaitymo medžiaga

Čia yra keletas bibliotekų, su kuriomis čia dirbome. Jie taip pat turi puikius dokumentus ir vadovus, todėl būtinai duokite jiems perskaityti :)

  • „Rust Futures“ įgyvendinimas Tokio mieste
  • Kadagys - „GraphQL“ serveris rūdims
  • Dyzelinas - saugus, ištraukiamas ORM ir „Query Builder for Rust“
  • „Actix“ - galinga „Rust“ veikėjų sistema ir smagiausia interneto sistema