Préparation
Avoir le hands on en local
cargo install mdbook
mdbook serve
Lancer le service ratings
cd ratings
PORT=7878 RUST_LOG=info cargo run
Vérifier qu'il marche:
curl localhost:7878/ratings/0
curl localhost:7878/ratings/0 -d '{"reviewer":"me","rating":3}'
curl localhost:7878/ratings/0
Lancer le service avec rebuild automatique
cargo install cargo-watch
cd reviews
RUST_LOG=warn RATINGS_URL=http://127.0.0.1:7878 cargo watch --ignore db.sqlite -x 'run'
Serveur HTTP
Démarrer une application et un système d'acteur
Dans lib.rs
extern crate actix;
extern crate actix_web;
extern crate futures;
extern crate serde;
#[macro_use]
extern crate serde_derive;
use actix_web::middleware::Logger;
use actix_web::{server, App};
pub struct AppState {
}
Dans la fonction run
let sys = actix::System::new("reviews");
server::new(move || {
App::with_state(AppState {
}).middleware(Logger::default())
}).bind(addr)
.unwrap()
.start();
let _ = sys.run();
Résultat
Un serveur http qui écoute, mais ne répond que des 404
curl localhost:9080 -i
HTTP/1.1 404 Not Found
content-length: 0
date: Thu, 19 Apr 2018 21:36:42 GMT
Router les requêtes
Définir les méthodes retournant une réponse
Dans reviews.rs
use std::collections::HashMap;
use actix_web::{error, AsyncResponder, HttpResponse, Json, Path, State};
use futures::Future;
use futures;
Définir comment extraire le product_id du path
#[derive(Deserialize)]
pub struct ProductId {
product_id: i32,
}
GET des reviews
pub fn reviews(
product_id: Path<ProductId>,
state: State<super::AppState>,
) -> Box<Future<Item = HttpResponse, Error = error::Error>> {
let product_id = product_id.product_id;
unimplemented!()
}
POST d'une review
#[derive(Debug, Deserialize, Serialize)]
pub struct NewReview {}
pub fn create_review(
product_id: Path<ProductId>,
review: Json<NewReview>,
state: State<super::AppState>,
) -> Box<Future<Item = HttpResponse, Error = error::Error>> {
let product_id = product_id.product_id;
unimplemented!()
}
Définir les routes
Dans lib.rs
use actix_web::http;
mod reviews;
server::new(move || {
App::with_state(AppState {
}).middleware(Logger::default())
.resource("/reviews/{product_id}", |r| {
r.method(http::Method::GET).with2(reviews::reviews);
r.method(http::Method::POST).with3(reviews::create_review);
})
}).bind(addr)
.unwrap()
.start();
Résultat
Les routes définies ne répondent pas, et un log apparaît quand on les appelle
curl localhost:9080/reviews/0 -i
curl: (52) Empty reply from server
thread 'arbiter:"85373381-235b-47af-bde8-4d85e5b778d8":"actor"' panicked at 'not yet implemented', src/reviews.rs:18:5
Renvoyer des données
Définir le modèle
Dans models.rs
#[derive(Serialize, Debug)]
pub struct Review {
pub product_id: i32,
pub reviewer: String,
pub review: String,
}
Dans lib.rs
mod models;
Traduire avec les entrées / sorties
Dans reviews.rs
use models;
Définir la structure JSON de sortie
#[derive(Debug, Serialize)]
pub struct Product {
pub id: i32,
pub reviews: Vec<Review>,
}
#[derive(Debug, Serialize)]
pub struct Review {
pub reviewer: String,
pub text: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub rating: Option<Rating>,
}
#[derive(Debug, Serialize)]
pub struct Rating {
pub stars: i32,
pub color: Color,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum Color {
Blue,
Yellow,
Red,
}
Convertir le modèle en sortie
pub fn rating_nb_to_rating(rating: i32) -> Rating {
Rating {
stars: rating,
color: match rating {
1 => Color::Red,
2 | 3 => Color::Yellow,
_ => Color::Blue,
},
}
}
pub fn reviews_with_ratings(
reviews: Vec<models::Review>,
ratings: HashMap<String, i32>,
) -> Vec<Review> {
reviews
.iter()
.map(|review| {
let reviewer = review.reviewer.clone();
Review {
rating: ratings.get(&reviewer).map(|&r| rating_nb_to_rating(r)),
reviewer: reviewer,
text: review.review.clone(),
}
})
.collect()
}
Renvoyer la réponse
let ratings = HashMap::new();
let reviews = vec![];
let build_response_from_ratings = move |ratings| {
futures::future::result({
// build the response
let product = Product {
id: product_id,
reviews: reviews_with_ratings(reviews, ratings),
};
// return a 200 response with the reviews as json
Ok(HttpResponse::Ok().json(product))
})
};
build_response_from_ratings(ratings).responder()
Définir la structure JSON en entrée
#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct NewReview {
reviewer: String,
text: String,
rating: i32,
}
Convertir l'entrée en modèle
let review_to_save = models::Review {
product_id: product_id as i32,
reviewer: review.reviewer.clone(),
review: review.text.clone(),
};
Renvoyer la réponse
futures::future::result({
Ok(HttpResponse::Ok().json(review.clone()))
}).responder()
Résultat
Récupérer les reviews d'un product donne une liste vide
curl localhost:9080/reviews/0 -i
HTTP/1.1 200 OK
content-length: 21
content-type: application/json
date: Thu, 19 Apr 2018 21:59:21 GMT
{"id":0,"reviews":[]}
Poster une review la renvoie telle quelle
curl localhost:9080/reviews/0 -i -H 'Content-Type: application/json' -d '{"reviewer":"moi","rating":3,"text":"mon avis"}'
HTTP/1.1 200 OK
content-length: 47
content-type: application/json
date: Thu, 19 Apr 2018 22:00:57 GMT
{"reviewer":"moi","text":"mon avis","rating":3}
Poster un JSON invalide répond une erreur 400 et un message dans les logs
curl localhost:9080/reviews/0 -i -H 'Content-Type: application/json' -d '{"reviewer":"moi","rating":3}'
HTTP/1.1 400 Bad Request
content-length: 0
date: Thu, 19 Apr 2018 22:01:54 GMT
{"msg":"Error occured during request handling: Json deserialize error: missing field `text` at line 1 column 29","level":"WARN","ts":"2018-04-20T00:01:55.287447+02:00","logger":"app"}
Acteur DB
Définir l'acteur
Dans db.rs
use actix::prelude::*;
pub struct DbExecutor();
impl Actor for DbExecutor {
type Context = SyncContext<Self>;
}
Définir le message GetReviews
use models;
#[derive(Debug)]
pub struct GetReviews {
pub product_id: i32,
}
impl Message for GetReviews {
type Result = Result<Vec<models::Review>, ()>;
}
impl Handler<GetReviews> for DbExecutor {
type Result = Result<Vec<models::Review>, ()>;
fn handle(&mut self, msg: GetReviews, _: &mut Self::Context) -> Self::Result {
warn!("getting reviews for product {}", msg.product_id);
Ok(vec![
models::Review {
product_id: msg.product_id,
review: "great!".to_string(),
reviewer: "user1".to_string(),
},
])
}
}
Définir le message SaveReview
#[derive(Debug)]
pub struct SaveReview {
pub review: models::Review,
}
impl Message for SaveReview {
type Result = Result<models::Review, ()>;
}
impl Handler<SaveReview> for DbExecutor {
type Result = Result<models::Review, ()>;
fn handle(&mut self, msg: SaveReview, _: &mut Self::Context) -> Self::Result {
warn!("saving review {:?}", msg.review);
Ok(msg.review)
}
}
Ajouter l'acteur à l'AppState
Dans lib.rs
use actix::prelude::*;
mod db;
pub struct AppState {
db: Addr<Syn, db::DbExecutor>,
}
let db_addr = SyncArbiter::start(3, move || db::DbExecutor());
server::new(move || {
App::with_state(AppState {
db: db_addr.clone(),
}).middleware(Logger::default())
Appeller l'acteur pendant les requêtes
Dans reviews.rs
use db;
Get des reviews
Enlever
let reviews = vec![];
Et remplacer
futures::future::result({
par:
state
.db
.send(db::GetReviews {
product_id: product_id,
})
.map(|result| match result {
Ok(reviews) => reviews,
Err(err) => {
error!("{:?}", err);
vec![]
}
})
.from_err()
.and_then(move |reviews| {
Save d'une review
Remplacer
futures::future::result({ Ok(HttpResponse::Ok().json(review.clone())) }).responder()
par:
state
.db
.send(db::SaveReview {
review: review_to_save,
})
.from_err()
.and_then(move |_| { Ok(HttpResponse::Ok().json(review.clone())) }).responder()
Résultat
Notre avis s'affiche !
curl localhost:9080/reviews/0 -i
HTTP/1.1 200 OK
content-length: 57
content-type: application/json
date: Thu, 19 Apr 2018 22:13:43 GMT
{"id":0,"reviews":[{"reviewer":"user1","text":"great!"}]}
À la sauvegarde d'un avis, un log s'affiche:
curl localhost:9080/reviews/0 -i -H 'Content-Type: application/json' -d '{"reviewer":"moi","rating":3,"text":"mon avis"}'
HTTP/1.1 200 OK
content-length: 47
content-type: application/json
date: Thu, 19 Apr 2018 22:23:06 GMT
{"reviewer":"moi","text":"mon avis","rating":3}
{"msg":"saving review Review { product_id: 0, reviewer: \"moi\", review: \"mon avis\" }","level":"WARN","ts":"2018-04-20T00:23:06.973686+02:00","logger":"app"}
Configuration
module config.rs
use std::env;
#[derive(Debug)]
pub struct Config {
pub host: String,
pub port: String,
pub database_url: String,
pub ratings_url: String,
}
impl Config {
pub fn new() -> Self {
Self {
host: env::var("HOST").unwrap_or_else(|_| "0.0.0.0".to_string()),
port: env::var("PORT").unwrap_or_else(|_| "9081".to_string()),
database_url: env::var("DATABASE_URL").unwrap_or_else(|_| "db.sqlite".to_string()),
ratings_url: env::var("RATINGS_URL")
.unwrap_or_else(|_| "http://ratings:9080".to_string()),
}
}
}
Ecouter sur l'adresse de la configuration
Dans bin.rs
mod config;
Remplacer l'assignation existante d'addr
par:
let config = config::Config::new();
let addr = format!("{}:{}", config.host, config.port);
Rendre la configuration accessible
Dans lib.rs
#[macro_use]
extern crate lazy_static;
mod config;
lazy_static! {
static ref CONFIG: config::Config = config::Config::new();
}
Résultat
Notre service écoute maintenant sur le port 9081
curl localhost:9081/reviews/0 -i
HTTP/1.1 200 OK
content-length: 57
content-type: application/json
date: Thu, 19 Apr 2018 22:27:26 GMT
{"id":0,"reviews":[{"reviewer":"user1","text":"great!"}]}
Utilisation de diesel
Nouvelles dépendances
Dans lib.rs
#[macro_use]
extern crate diesel;
extern crate r2d2;
use diesel::prelude::*;
use diesel::r2d2::ConnectionManager;
Migrations et schémas
Dans lib.rs
mod schema;
Ajout d'attributs au model
Dans model.rs
use super::schema::reviews;
#[derive(Serialize, Debug, Queryable, Insertable)]
#[table_name = "reviews"]
pub struct Review {
pub product_id: i32,
pub reviewer: String,
pub review: String,
}
Configurer le pool de connection
Dans lib.rs
, à la place de l'assignation à db_addr
:
let manager = ConnectionManager::<SqliteConnection>::new(CONFIG.database_url.clone());
let pool = r2d2::Pool::builder().build(manager).expect("Failed to create pool.");
let db_addr = SyncArbiter::start(3, move || db::DbExecutor(pool.clone()));
Acteur db db.rs
Importer les nouvelles dépendances
use diesel;
use diesel::prelude::*;
use diesel::r2d2::{ConnectionManager, Pool};
use schema;
Ajouter le pool de connection
Changer la définition de DbExecutor
par:
pub struct DbExecutor(pub Pool<ConnectionManager<SqliteConnection>>);
Récupérer les reviews
Changer le message GetReviews
par:
#[derive(Debug)]
pub struct GetReviews {
pub product_id: i32,
}
impl Message for GetReviews {
type Result = Result<Vec<models::Review>, diesel::result::Error>;
}
impl Handler<GetReviews> for DbExecutor {
type Result = Result<Vec<models::Review>, diesel::result::Error>;
fn handle(&mut self, msg: GetReviews, _: &mut Self::Context) -> Self::Result {
warn!("getting reviews for product {}", msg.product_id);
use self::schema::reviews::dsl::*;
let conn: &SqliteConnection = &self.0.get().unwrap();
let items = reviews
.filter(product_id.eq(msg.product_id))
.load::<models::Review>(conn)?;
Ok(items)
}
}
Sauvegarder une review
Changer le message SaveReview
par:
#[derive(Debug)]
pub struct SaveReview {
pub review: models::Review,
}
impl Message for SaveReview {
type Result = Result<models::Review, diesel::result::Error>;
}
impl Handler<SaveReview> for DbExecutor {
type Result = Result<models::Review, diesel::result::Error>;
fn handle(&mut self, msg: SaveReview, _: &mut Self::Context) -> Self::Result {
warn!("saving review {:?}", msg.review);
use self::schema::reviews::dsl::*;
let conn: &SqliteConnection = &self.0.get().unwrap();
diesel::insert_into(reviews)
.values(&msg.review)
.execute(conn)?;
Ok(msg.review)
}
}
Résultat
On récupère les reviews de la base de données:
curl localhost:9081/reviews/0
{
"reviews" : [
{
"text" : "An extremely entertaining play by Shakespeare. The slapstick humour is refreshing!",
"reviewer" : "Reviewer1"
},
{
"reviewer" : "Reviewer2",
"text" : "Absolutely fun and entertaining. The play lacks thematic depth when compared to other plays by Shakespeare."
}
],
"id" : 0
}
Les avis envoyés sont sauvegardés:
curl localhost:9081/reviews/1 -H 'Content-Type: application/json' -d '{"reviewer":"moi","rating":3,"text":"mon avis"}'
{"reviewer":"moi","text":"mon avis","rating":3}
curl localhost:9081/reviews/1
{
"id" : 1,
"reviews" : [
{
"text" : "mon avis",
"reviewer" : "moi"
}
]
}
Communiquer avec le service ratings
Dans reviews.rs
Nouveaux imports
use actix_web::client::ClientRequest;
use actix_web::HttpMessage;
Récupérer les ratings
Définir la structure du json
#[derive(Debug, Deserialize)]
pub struct RatingsResponse {
id: i32,
pub ratings: HashMap<String, i32>,
}
Appeler le service
Remplacer l'assignation de ratings
par:
let ratings = ClientRequest::get(&format!("{}/ratings/{}", ::CONFIG.ratings_url, product_id))
.finish()
.unwrap()
.send()
.map_err(error::Error::from)
.and_then(move |resp| {
resp.json()
.from_err()
.and_then(|ratings: RatingsResponse| Ok(ratings.ratings))
})
.or_else(|err| {
// in case of error, log it and continue with an empty list of ratings
error!("{:?}", err);
Ok(HashMap::new())
});
Et remplacer
build_response_from_ratings(ratings).responder()
par
ratings.and_then(build_response_from_ratings).responder()
Sauvegarder un nouveau rating
Appeler le service
Remplacer
state
.db
.send(db::SaveReview {
review: review_to_save,
})
.from_err()
.and_then(move |_| Ok(HttpResponse::Ok().json(review.clone())))
.responder()
par:
ClientRequest::post(&format!("{}/ratings/{}", ::CONFIG.ratings_url, product_id))
.json(review.clone())
.unwrap()
.send()
.map(|_| ())
.or_else(|err| {
// in case of error, log it and ignore it
error!("{:?}", err);
Ok(())
})
.and_then(move |_| {
state
.db
.send(db::SaveReview {
review: review_to_save,
})
.from_err()
.and_then(move |_| Ok(HttpResponse::Ok().json(review.clone())))
})
.responder()
Résultat
Les ratings sont maintenant dispo quand on demande des avis, et l'application ratings
a loggé qu'elle a été appellée
curl localhost:9081/reviews/0
{
"reviews" : [
{
"rating" : {
"stars" : 5,
"color" : "blue"
},
"text" : "An extremely entertaining play by Shakespeare. The slapstick humour is refreshing!",
"reviewer" : "Reviewer1"
},
{
"reviewer" : "Reviewer2",
"rating" : {
"color" : "blue",
"stars" : 4
},
"text" : "Absolutely fun and entertaining. The play lacks thematic depth when compared to other plays by Shakespeare."
}
],
"id" : 0
}
{"msg":"requesting ratings for product 0","level":"INFO","ts":"2018-04-20T00:52:35.348631+02:00","logger":"app"}
{"msg":"[RESPONSE][d65d721f-b0d4-4689-a1fd-7b455fedd76d][HTTP/1.1][200 OK][755µs]","level":"INFO","ts":"2018-04-20T00:52:35.348900+02:00","logger":"app"}
À la sauvegarde d'un avis, la note est sauvegardée dans l'application ratings
curl localhost:9081/reviews/3 -H 'Content-Type: application/json' -d '{"reviewer":"moi","rating":3,"text":"mon avis"}'
{"reviewer":"moi","text":"mon avis","rating":3}
curl localhost:9081/reviews/3
{
"reviews" : [
{
"reviewer" : "moi",
"text" : "mon avis",
"rating" : {
"color" : "yellow",
"stars" : 3
}
}
],
"id" : 3
}
{"msg":"saving new rating NewRating { reviewer: \"moi\", rating: 3 } for product 3","level":"INFO","ts":"2018-04-20T01:00:22.008725+02:00","logger":"app"}
{"msg":"[RESPONSE][4c0951b3-f302-4759-9458-506a6b1a9f97][HTTP/1.1][200 OK][822µs]","level":"INFO","ts":"2018-04-20T01:00:22.009032+02:00","logger":"app"}
Ajouter un healthcheck
Nouvelle dépendance
Dans lib.rs
extern crate time;
Module health
Dans health.rs
Ajouter les imports
use actix_web::{HttpRequest, HttpResponse};
use time;
Déclarer les données retournées
#[derive(Serialize)]
pub struct Healthcheck {
now: i64,
version: &'static str,
status: &'static str,
}
Retourner les données
pub fn healthcheck(_: HttpRequest<super::AppState>) -> HttpResponse {
HttpResponse::Ok().json(Healthcheck {
now: time::now_utc().to_timespec().sec,
version: env!("CARGO_PKG_VERSION"),
status: "Reviews is healthy",
})
}
Ajouter la route pour /GET healthcheck
Dans lib.rs
mod health;
.resource("/health", |r| {
r.method(http::Method::GET).f(health::healthcheck)
})
Résultat
curl localhost:9081/health
{
"status" : "Reviews is healthy",
"now" : 1524179124,
"version" : "0.1.0"
}