Configurarea unui server GraphQL cu Rust, Juniper, Diesel și Actix; învățând despre cadrele web Rust și macrocomenzile puternice pe parcurs.

Cod sursa: github.com/iwilsonq/rust-graphql-example

Servirea aplicațiilor prin GraphQL devine rapid cel mai simplu și mai eficient mod de a livra date clienților. Fie că sunteți pe un dispozitiv mobil sau un browser, acesta oferă datele solicitate și nimic mai mult.

Aplicațiile clientului nu mai trebuie să îmbine informații din surse de date separate. Serverele GraphQL sunt responsabile de integrare, eliminând nevoia de date în exces și cererile de date dus-întors.

Desigur, acest lucru implică faptul că serverul trebuie să gestioneze agregarea datelor din diferite surse, cum ar fi serviciile de backend deținute de acasă, bazele de date sau API-urile terților. Acest lucru poate necesita resurse mari, cum putem optimiza pentru timpul procesorului?

Rugina este o minune a unui limbaj, îmbinând performanța brută a unui limbaj de nivel scăzut, cum ar fi C, cu expresivitatea limbilor moderne. Accentuează siguranța tipului și a memoriei, mai ales atunci când există potențial curse de date în operațiuni concurente.

Să vedem ce se întâmplă în construirea unui server GraphQL cu Rust. Vom învăța despre

  • Juniper GraphQL Server
  • Cadru web Actix integrat cu Juniper
  • Diesel pentru interogarea unei baze de date SQL
  • Macrocomenzi utile Rust și trăsături derivate pentru lucrul cu aceste biblioteci

Rețineți că nu voi intra în detalii cu privire la instalarea Rust sau Cargo. Acest articol presupune unele cunoștințe preliminare despre lanțul de instrumente Rust.

Configurarea unui server HTTP

Pentru început, trebuie să ne inițializăm proiectul cu cargo și apoi instalați dependențe.

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

Comanda de inițializare pornește fișierul nostru Cargo.toml care conține dependențele proiectelor noastre, precum și un main.rs fișier care are un exemplu simplu „Hello World”.

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

Ca verificare a sănătății, nu ezitați să alergați cargo run pentru a executa programul.

Instalarea bibliotecilor necesare în Rust înseamnă adăugarea unei linii care conține numele bibliotecii și numărul versiunii. Să actualizăm secțiunile de dependențe din Cargo.toml astfel:


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

Acest articol va acoperi implementarea unui server GraphQL folosind Ienupăr ca bibliotecă GraphQL și Actix ca server HTTP subiacent. Actix are un API foarte frumos și funcționează bine cu versiunea stabilă de Rust.

Când aceste linii sunt adăugate, data viitoare când se compilează proiectul va include acele biblioteci. Înainte de a compila, să actualizăm main.rs cu un server HTTP de bază, gestionând ruta index.

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

Primele două linii din partea de sus aduc modulul de care avem nevoie. Funcția principală aici returnează un io::Result tip, care ne permite să folosim semnul întrebării ca stenogramă pentru gestionarea rezultatelor.

Rutarea și configurarea serverului sunt create în instanță de App, care este creat într-o închidere furnizată de constructorul serverului HTTP.

Traseul în sine este gestionat de funcția index, al cărui nume este arbitrar. Atâta timp cât această funcție implementează corect Responder poate fi folosit ca parametru pentru solicitarea GET la ruta index.

Dacă am scrie un API REST, am putea continua cu adăugarea mai multor rute și a manipulatorilor asociați. Vom vedea în curând că tranzacționăm o listă a gestionarilor de rute pentru obiecte și relațiile lor.

Acum vom introduce biblioteca GraphQL.

Crearea schemei GraphQL

La baza fiecărei scheme GraphQL se află o interogare root. Din această rădăcină putem interoga liste de obiecte, obiecte specifice și orice câmpuri ar putea conține.

Apelați-l QueryRoot și îl vom denumi cu același nume în codul nostru. Deoarece nu vom configura o bază de date sau nicio API terță parte, vom codifica cu greu datele mici pe care le avem aici.

Pentru a adăuga puțină culoare acestui exemplu, schema va descrie o listă generică de membri.

Sub src, adăugați un nou fișier numit graphql_schema.rs împreună cu următorul conținut:

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

Împreună cu importurile noastre, definim primul nostru obiect GraphQL din acest proiect, membrul. Sunt ființe simple, cu un id și un nume. Ne vom gândi la câmpuri și modele mai complicate mai târziu.

După scoaterea din QueryRoot tastați ca structură de unitate, ajungem să definim câmpul în sine. Juniper expune o macro Rust numită object ceea ce ne permite să definim câmpuri pe diferitele noduri din schema noastră. Deocamdată, avem doar nodul QueryRoot, așa că vom expune un câmp numit membri.

Macro-urile de rugină au adesea o sintaxă neobișnuită în comparație cu funcțiile standard. Nu iau doar niște argumente și produc un rezultat, ci se extind într-un cod mult mai complex în momentul compilării.

Expunerea Schemei

Sub apelul nostru macro pentru a crea câmpul membrilor, vom defini RootNode tip pe care îl expunem în schema noastră.

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

Datorită tastării puternice din Rust, suntem forțați să furnizăm argumentul obiectului mutației. Ienupărul expune un EmptyMutation struct doar pentru această ocazie, adică atunci când dorim să creăm o schemă numai în citire.

Acum că schema este pregătită, ne putem actualiza serverul în main.rs pentru a gestiona ruta “/ graphql”. Întrucât să aveți un loc de joacă este, de asemenea, frumos, vom adăuga un traseu pentru GraphiQL, locul de joacă interactiv 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()
    }

Veți observa că am specificat o serie de importuri pe care le vom folosi, inclusiv schema pe care tocmai am creat-o. Vezi și că:

  • noi sunam create_schema în interiorul unui Arc (referință atomică numărată), pentru a permite starea imutabilă partajată pe fire (gătind cu? aici știu)
  • marcăm închiderea în interiorul HttpServer :: nou cu mișcare, indicând faptul că închiderea își asumă proprietatea asupra variabilelor interioare, adică câștigă o copie a schema
  • schema este trecut la data metoda care indică faptul că trebuie utilizată în interiorul aplicației ca stare partajată între cele două servicii

Acum trebuie să implementăm gestionarele pentru aceste două servicii. Începând cu ruta “/ 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))
        })
    }

Implementarea noastră a rutei „/ graphql” execută o cerere GraphQL împotriva schemei noastre din starea aplicației. Face acest lucru prin crearea unui viitor din web::block și înlănțuirea handlerelor pentru stări de succes și eroare.

Futures sunt similare cu Promisiunile din JavaScript, ceea ce este suficient pentru a înțelege acest fragment de cod. Pentru o explicație mai mare despre Futures in Rust, vă recomand acest articol de Joe Jackson.

Pentru a testa schema noastră GraphQL, vom adăuga și un handler pentru „/ 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)
    }

Acest handler este mult mai simplu, doar returnează html-ul terenului de joc interactiv GraphiQL. Trebuie doar să specificăm ce cale servește schemei noastre GraphQL, care este „/ graphql” în acest caz.

Cu cargo run și navigare către http: // localhost: 8080 / graphiql, putem încerca câmpul pe care l-am configurat.

Interogarea membrilor în graphiql

Se pare că este nevoie de ceva mai mult efort decât configurarea unui server GraphQL cu Node.js și Apollo, dar tastarea statică a Rust, combinată cu performanțele sale incredibile, îl face un comerț demn – dacă doriți să lucrați la el.

Configurarea Postgres pentru date reale

Dacă m-aș opri aici, nici nu aș face exemplele din documente multă dreptate. O listă statică de doi membri că am scris eu la ora dev nu va zbura în această publicație.

Instalarea Postgres și configurarea propriei baze de date aparțin unui alt articol, dar voi parcurge modul de instalare motorină, populară bibliotecă Rust pentru manipularea bazelor de date SQL.

Vedeți aici pentru a instala Postgres local pe computerul dvs.. De asemenea, puteți utiliza o bază de date diferită, cum ar fi MySQL, în cazul în care sunteți mai familiarizați cu ea.

CLI diesel ne va ghida prin inițializarea meselor noastre. Să-l instalăm:

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

După aceea, vom adăuga o adresă URL de conexiune la un fișier .env din directorul nostru de lucru:

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

Odată ajuns acolo, puteți rula:

    diesel setup
    
    # followed by
    
    diesel migration generate create_members

Acum veți avea un folder de migrații în directorul dvs. În cadrul acestuia, veți avea două fișiere SQL: unul up.sql pentru configurarea bazei de date, celălalt down.sql pentru demolarea acesteia.

Voi adăuga următoarele la 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);

Și în down.sql voi adăuga:

    DROP TABLE members;
    DROP TABLE teams;

Dacă ați scris SQL în trecut, aceste afirmații vor avea un sens. Creăm două tabele, una pentru stocarea echipelor și una pentru stocarea membrilor acelor echipe.

Modelez aceste date pe baza Smash Bros dacă nu ați observat încă. Ajută stick-ul de învățare.

Acum pentru a rula migrațiile:

    diesel migration run

Dacă doriți să verificați dacă scriptul down.sql funcționează pentru a distruge acele tabele, rulați: diesel migration redo.

Acum motivul pentru care am numit fișierul schemă GraphQL graphql_schema.rs în loc de schema.rs este că diesel suprascrie fișierul în direcția noastră src în mod implicit.

Păstrează o reprezentare macro Rust a tabelelor noastre SQL în acel fișier. Nu este atât de important să știm exact cum se întâmplă acest lucru table! macro funcționează, dar încercați să nu editați acest fișier – ordinea câmpurilor contează!

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

Cablarea handlerelor noastre cu motorină

Pentru a furniza datele din tabelele noastre, trebuie mai întâi să le actualizăm Member struct cu noile câmpuri:

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

Rețineți că adăugăm și fișierul Queryable atribut derivat la Member. Acest lucru îi spune lui Diesel tot ce trebuie să știe pentru a interoga tabelul potrivit în Postgres.

În plus, adăugați un 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<Member> {
        vec![]
      }
    }

În scurt timp, vom actualiza fișierul members funcție activată Team pentru a returna o interogare a bazei de date. Dar mai întâi, să adăugăm un apel root pentru membri.

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

Foarte bine, avem prima noastră utilizare a unei interogări diesel. După inițializarea unei conexiuni, folosim membrii dsl, care este generat de la table! macrocomenzi în schema.rs și apel sarcină, indicând faptul că dorim să încărcăm Member obiecte.

Stabilirea unei conexiuni înseamnă conectarea la baza noastră de date Postgres locală utilizând variabila env pe care am declarat-o anterior.

Presupunând că toate acestea au fost introduse corect, reporniți serverul cu cargo run, deschideți GraphiQL și lansați interogarea membrilor, adăugând poate cele două câmpuri noi.

Interogarea echipelor va fi foarte asemănătoare – diferența fiind că trebuie să adăugăm și o parte din interogare la funcția membrilor de pe Team struct pentru a rezolva relația dintre tipurile 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")
      }
    }

Când rulăm acesta este GraphiQL, obținem:

Interogare mai complexă în graphiql

Îmi place foarte mult modul în care se întâmplă acest lucru, dar mai trebuie să adăugăm un lucru pentru a numi acest tutorial complet.

Mutația Creare membru

La ce bun un server dacă este numai în citire și nu poate fi scris? Ei bine, sunt sigur că și aceștia își folosesc, dar am dori să scriem date în baza noastră de date, cât de greu poate fi?

Mai întâi vom crea un MutationRoot struct care va înlocui în cele din urmă utilizarea noastră de EmptyMutation. Apoi vom adăuga interogarea de inserare a motorinei.

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

Pe măsură ce mutațiile GraphQL merg de obicei, definim un obiect de intrare numit NewMember și faceți din acesta argumentul create_member funcţie. În interiorul acestei funcții, stabilim o conexiune și apelăm interogarea de inserare pe tabela membrilor, trecând întregul obiect de intrare.

Este foarte convenabil că Rust ne permite să folosim aceleași structuri pentru obiecte de intrare GraphQL, precum și pentru obiecte inserabile Diesel.

Permiteți-mi să fac acest lucru puțin mai clar, pentru NewMember struct:

  • derivăm juniper::GraphQLInputObject pentru a crea un obiect de intrare pentru schema noastră GraphQL
  • derivăm Insertable pentru a informa Diesel că această structură este o intrare validă pentru o instrucțiune SQL de inserare
  • adăugăm table_name atribut astfel încât Diesel să știe în ce tabel îl introduceți

Există o mulțime de magie se întâmplă aici. Aceasta este ceea ce îmi place la Rust, are o performanță excelentă, dar codul are caracteristici cum ar fi macro-urile și trăsături derivate pentru a abstra departe boilerplate și pentru a adăuga funcționalitate.

În cele din urmă, în partea de jos a fișierului, adăugați fișierul MutationRoot la schemă:

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

Sper că totul este acolo, putem testa până acum toate întrebările și mutațiile noastre:

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

Dacă acea mutație a funcționat cu succes, puteți deschide o sticlă de șampanie pe măsură ce sunteți pe cale să creați servere GraphQL performante și sigure cu Rust.

Mulțumesc pentru lectură

Sper că ți-a plăcut acest articol, sper și că ți-a dat un fel de inspirație pentru propria ta lucrare.

Dacă doriți să țineți pasul cu data viitoare când renunț la un articol în domeniul Rust, ReasonML, GraphQL sau dezvoltarea de software în general, nu ezitați să-mi dați o urmărire Stare de nervozitate, dev.to, sau pe site-ul meu web la ianwilson.io.

Codul sursă este aici github.com/iwilsonq/rust-graphql-example.

Alte materiale de lectură îngrijite

Iată câteva dintre bibliotecile cu care am lucrat aici. Au și documentații și ghiduri excelente, așa că asigurați-vă că le citiți 🙂