[譯] 用 Rust 寫一個微服務

nettee發表於2019-03-02

請允許我在寫這樣一篇用 Rust 寫一個微服務的文章的開頭先談兩句 C++。我成為 C++ 社群的一個相當活躍的成員已經很長一段時間了。我參加會議並貢獻了演講,跟隨語言的更現代化的特性的發展和傳播,當然也寫了很多程式碼。C++ 讓使用者在寫程式碼時能對程式的所有方面有非常細粒度的控制,不過代價是陡峭的學習曲線,以及寫出有效的 C++ 程式碼所需的大量知識。然而,C++ 也是一個非常古老的語言。它由 Bjarne Stroustrup 在 1985 年構思出來。因此,它即使在現代標準中也帶有很多的歷史包袱。 當然,在 C++ 建立之後,關於語言設計的研究仍在繼續,也導致了一些如 GoRustCrystal 等很多有趣的新語言的誕生。然而,這些新語言中很少有能夠既具有比現代 C++ 更有趣的功能,同時仍保證具備和 C++ 同樣的效能和對記憶體、硬體的控制。Go 想要替代 C++,但正如 Rob Pike 發現的那樣,C++ 程式設計師對一種效能較差而又提供較少控制的語言不是很感興趣。不過,Rust 卻吸引了很多 C++ 愛好者。Rust 和 C++ 有不少相同的設計目標,比如零成本抽象,以及對記憶體的精細控制。除此之外,Rust 還新增了很多讓程式更安全、更有表達力,以及讓開發更高效的語言特性。我對 Rust 最感興趣的東西是

  • 借用檢查,極大地提升了記憶體安全性(再也沒有 SEGFAULT 了!);
  • 預設的不可變性(const);
  • 符合直覺的語法糖,例如模式匹配(pattern matching);
  • 沒有內建的(算數)型別間的隱式轉換。

閒聊完畢。本文的剩餘部分將引導你建立一個小而完整的微服務 —— 類似於我為我的部落格所寫的 URL 縮短器。我說的微服務指的是一個使用 HTTP,接受請求,訪問資料庫,返回一個響應(可能運送著 HTML),打包在一個 Docker 容器中,並可以放在雲上的某個地方的這樣一種應用。在這篇文章中,我會構建一個簡單的聊天應用,允許你儲存和檢索訊息。我會在過程中介紹一些相關的包(crate)。你可以在 GitHub 上找到服務的完整程式碼。

使用 HTTP

我們需要讓我們的 web 服務做的第一件事就是如何使用 HTTP 協議,也就是我們的應用(伺服器)需要接收並解析 HTTP 請求,並返回 HTTP 響應。雖然有很多類似 FlaskDjango 的高階框架能將這一切封裝起來,我們還是選擇使用稍微低階一點的 hyper 庫來處理 HTTP。這個庫使用網路庫 tokiofutures,讓我們能建立一個乾淨的非同步 web 伺服器。此外,我們還會使用 logenv-logger 兩個 crate 來實現日誌功能。

我們首先設定好 Cargo.toml,下載上述的 crate:

[package]
name = "microservice_rs"
version = "0.1.0"
authors = ["you <you@email>"]
[dependencies]
env_logger = "0.5.3"
futures = "0.1.17"
hyper = "0.11.13"
log = "0.4.1"
複製程式碼

然後是實際的程式碼。Hyper 中有 Service 的概念。它是一個實現了 Service trait 的型別,有一個 call 函式,接收一個表示解析過的 HTTP 請求的 hyper::Request 物件作為引數。對於一個非同步服務來說,這個函式必須返回一個 Future。下面是基本的樣板檔案,我們可以直接放在 main.rs 中:

extern crate hyper;
extern crate futures;

#[macro_use]
extern crate log;
extern crate env_logger;

use hyper::server::{Request, Response, Service};

use futures::future::Future;

struct Microservice;

impl Service for Microservice {
  type Request = Request;
  type Response = Response;
  type Error = hyper::Error;
  type Future = Box<Future<Item = Self::Response, Error = Self::Error>>;

  fn call(&self, request: Request) -> Self::Future {
    info!("Microservice received a request: {:?}", request);
    Box::new(futures::future::ok(Response::new()))
  }
}
複製程式碼

注意到我們還需要為我們的服務宣告一些基本的型別。我們裝箱了 future 型別,因為 futures::future::Future 本身只是一個 trait,不能作為函式的返回值。在 call() 內部,我們目前返回一個最簡單的有效值,一個包含空響應的裝箱 future。

要啟動伺服器,我們繫結一個 IP 地址到 hyper::server::Http 例項,並呼叫它的 run() 方法:

fn main() {
  env_logger::init();
  let address = "127.0.0.1:8080".parse().unwrap();
  let server = hyper::server::Http::new()
    .bind(&address, || Ok(Microservice {}))
    .unwrap();
  info!("Running microservice at {}", address);
  server.run().unwrap();
}
複製程式碼

有了上面的程式碼,hyper 會在 localhost:8080 開始監聽 HTTP 請求,解析並將其轉發到我們的 Microservice 類。請注意,每次有新請求到來,都會建立一個新的例項。我們現在可以啟動伺服器,用 curl 發來一些請求!我們在終端中啟動伺服器:

$ RUST_LOG="microservice=debug" cargo run
  Finished dev [unoptimized + debuginfo] target(s) in 0.0 secs
    Running `target/debug/microservice`
INFO 2018-01-21T23:35:05Z: microservice: Running microservice at 127.0.0.1:8080
複製程式碼

然後在另一個終端中向它傳送一些請求:

$ curl 'localhost:8080'
複製程式碼

在第一個終端中,你應該能看到類似下面的輸出

$ RUST_LOG="microservice=debug" cargo run
  Finished dev [unoptimized + debuginfo] target(s) in 0.0 secs
  Running `target/debug/microservice`
Running microservice at 127.0.0.1:8080
INFO 2018-01-21T23:35:05Z: microservice: Running microservice at 127.0.0.1:8080
INFO 2018-01-21T23:35:06Z: microservice: Microservice received a request: Request { method: Get, uri: "/", version: Http11, remote_addr: Some(V4(127.0.0.1:61667)), headers: {"Host": "localhost:8080", "User-Agent": "curl/7.54.0", "Accept": "*/*"} }
複製程式碼

萬歲!我們有了一個用 Rust 寫的基礎的伺服器。注意到在上面的命令中,我將 RUST_LOG="microservice=debug" 新增到了 cargo run 中。由於 env_logger 會搜尋這個特定的環境變數,我們通過這種方式控制它的行為。這個環境變數("microservice=debug")的第一部分指定了我們希望啟動的日誌的根模組,第二部分(= 後面的部分)指定了可見的最小日誌級別。預設情況下,只有 error! 會被記錄。

現在,讓我們的伺服器真正做點事情。因為我們在構建一個聊天應用,我們想要處理的兩個請求型別是 POST 請求(有包含使用者名稱和訊息的表單資料)和 GET 請求(有可選的用來根據時間過濾的 beforeafter 引數)。

接收 POST 請求

我們先從寫資料的這一部分開始。我們的接受傳送到我們服務的根路徑("/")的 POST 請求,並期望請求的表單資料中包含 usernamemessage 欄位。然後,這些資訊會傳入一個函式,寫進資料庫中。最終,我們返回一個響應。

首先重寫 call() 方法:

fn call(&self, request: Request) -> Self::Future {
      match (request.method(), request.path()) {
        (&Post, "/") => {
          let future = request
            .body()
            .concat2()
            .and_then(parse_form)
            .and_then(write_to_db)
            .then(make_post_response);
          Box::new(future)
        }
        _ => Box::new(futures::future::ok(
          Response::new().with_status(StatusCode::NotFound),
        )),
      }
    }
複製程式碼

我們通過匹配請求的方法和路徑來區分不同的請求。在我們的例子中,請求的方法會是 PostGet。我們服務的唯一有效路徑是根路徑 "/"。如果方法是 &Post 並且路徑正確,我們就呼叫前面提到的函式。注意到我們可以優雅地使用組合函式來串聯 future。組合子 and_then 會在 future 正確解析(不包含錯誤)的情況下,使用 future 中包含的值來呼叫一個函式。這個呼叫的函式也必須返回一個新的 future。這允許我們在多個處理階段之間傳遞值,而不是現場計算出某個值。最終,我們使用組合子 then,無論 future 的狀態如何都會執行回撥函式。這樣,它會得到一個 Result,而不是一個值。

這裡是上面使用到的函式的內容:

struct NewMessage {
  username: String,
  message: String,
}

fn parse_form(form_chunk: Chunk) -> FutureResult<NewMessage, hyper::Error> {
  futures::future::ok(NewMessage {
    username: String::new(),
    message: String::new(),
  })
}

fn write_to_db(entry: NewMessage) -> FutureResult<i64, hyper::Error> {
  futures::future::ok(0)
}

fn make_post_response(
  result: Result<i64, hyper::Error>,
) -> FutureResult<hyper::Response, hyper::Error> {
  futures::future::ok(Response::new().with_status(StatusCode::NotFound))
}
複製程式碼

我們的 use 語句也發生了一點變化:

use hyper::{Chunk, StatusCode};
use hyper::Method::{Get, Post};
use hyper::server::{Request, Response, Service};

use futures::Stream;
use futures::future::{Future, FutureResult};
複製程式碼

讓我們觀察一下 parse_form。它接收一個 Chunk(訊息體),從中解析出使用者名稱和訊息,同時恰當地處理錯誤。為了解析表單,我們使用 url 這個 crate(你需要使用 cargo 下載它):

use std::collections::HashMap;
use std::io;

fn parse_form(form_chunk: Chunk) -> FutureResult<NewMessage, hyper::Error> {
  let mut form = url::form_urlencoded::parse(form_chunk.as_ref())
    .into_owned()
    .collect::<HashMap<String, String>>();

  if let Some(message) = form.remove("message") {
    let username = form.remove("username").unwrap_or(String::from("anonymous"));
    futures::future::ok(NewMessage {
      username: username,
      message: message,
    })
  } else {
    futures::future::err(hyper::Error::from(io::Error::new(
        io::ErrorKind::InvalidInput,
        "Missing field 'message",
    )))
  }
}
複製程式碼

在將表單解析為一個 hashmap 之後,我們嘗試從中移除 message 鍵。因為這是一個必填項,所以如果移除失敗,就返回一個錯誤(error)。如果移除成功,我們接著獲取 username 欄位,如果這個欄位不存在的話,就使用預設值 "anonymous"。最後,我們返回一個包含簡單的 NewMessage 結構體的一個成功的 future。

我現在不會立刻討論 write_to_db 函式。資料庫的互動本身非常複雜,所以我會使用後續的一個章節來介紹這個函式,以及對應的從資料庫中讀取訊息的函式。然而,注意到 write_to_db 在成功時返回 i64 型別的值,這是新訊息提交到資料庫中的時間戳。

先讓我們看看我們如何將響應返回給任何向微服務發來的請求:

#[macro_use]
extern crate serde_json;

fn make_post_response(
  result: Result<i64, hyper::Error>,
) -> FutureResult<hyper::Response, hyper::Error> {
  match result {
    Ok(timestamp) => {
      let payload = json!({"timestamp": timestamp}).to_string();
      let response = Response::new()
        .with_header(ContentLength(payload.len() as u64))
        .with_header(ContentType::json())
        .with_body(payload);
      debug!("{:?}", response);
      futures::future::ok(response)
    }
    Err(error) => make_error_response(error.description()),
  }
}
複製程式碼

我們在 result 上進行匹配,看看我們是否能成功寫入資料庫。如果成功,我們會建立一個 JSON 負載,構成我們返回的響應體。為此我使用了 serde_json 這個 crate,你應當將其新增到 Cargo.toml 中。當構建響應結構體時,我們需要設定正確的 HTTP 頭。在這個例子中,這意味著將 Content-Length 頭欄位設定為響應體的長度,將 Content-Type 頭欄位設定為 application/json

我已經重構了程式碼,將在錯誤情況下構建響應體的功能變成一個單獨的函式 make_error_response,因為我們稍後會重新使用它:

fn make_error_response(error_message: &str) -> FutureResult<hyper::Response, hyper::Error> {
  let payload = json!({"error": error_message}).to_string();
  let response = Response::new()
    .with_status(StatusCode::InternalServerError)
    .with_header(ContentLength(payload.len() as u64))
    .with_header(ContentType::json())
    .with_body(payload);
  debug!("{:?}", response);
  futures::future::ok(response)
}
複製程式碼

響應的構建與前一個函式相當相似,不過這次我們必須將響應的 HTTP 狀態設定為 StatusCode::InternalServerError(狀態 500)。預設的狀態是 OK(200),因此我們之前不需要設定狀態。

接收 GET 請求

下面,我們轉向 GET 請求,這些請求發到伺服器是要獲取訊息。我們允許請求有兩個查詢引數(query arguments)beforeafter。兩個引數都是時間戳,用於根據訊息的時間戳來約束會獲取哪些訊息。兩個引數都是可選的。如果 beforeafter 引數都不存在,我們將只返回最後的訊息。

下面是處理 GET 請求的 match 分支。它的邏輯比前面的程式碼略多。

(&Get, "/") => {
  let time_range = match request.query() {
    Some(query) => parse_query(query),
    None => Ok(TimeRange {
      before: None,
      after: None,
    }),
  };
  let response = match time_range {
    Ok(time_range) => make_get_response(query_db(time_range)),
    Err(error) => make_error_response(&error),
  };
  Box::new(response)
}
複製程式碼

通過呼叫 request.query(),我們得到一個 Option<&str>,因為一個 URI 可能根本沒有查詢字串。如果查詢存在,我們呼叫 parse_query,它會解析查詢引數,返回一個 TimeRange 結構體。它的定義是

struct TimeRange {
  before: Option<i64>,
  after: Option<i64>,
}
複製程式碼

因為 beforeafter 引數都是可選的,我們將 TimeRange 結構體的兩個欄位都設定為 Option。此外,時間戳可能是無效的(例如不是數字),所以我們應當處理解析其值失敗的情況。在這種情況下,parse_query 會返回一條錯誤訊息,我們可以將其轉發給我們之前寫的 make_error_response 函式。如果解析成功,我們可以繼續呼叫 query_db(為我們獲取訊息)和 make_get_response(建立合適的 Response 物件,並返回給客戶端)。

為了解析查詢字串,我們再次使用之前的 url::form_urlencoded 函式,因為它的語法還是 key=value&key=value。然後我們嘗試獲取 beforeafter 兩個值並將其轉化為整數型別(即時間戳型別):

fn parse_query(query: &str) -> Result<TimeRange, String> {
  let args = url::form_urlencoded::parse(&query.as_bytes())
    .into_owned()
    .collect::<HashMap<String, String>>();

  let before = args.get("before").map(|value| value.parse::<i64>());
  if let Some(ref result) = before {
    if let Err(ref error) = *result {
        return Err(format!("Error parsing 'before': {}", error));
    }
  }

  let after = args.get("after").map(|value| value.parse::<i64>());
  if let Some(ref result) = after {
    if let Err(ref error) = *result {
      return Err(format!("Error parsing 'after': {}", error));
    }
  }

  Ok(TimeRange {
    before: before.map(|b| b.unwrap()),
    after: after.map(|a| a.unwrap()),
  })
}
複製程式碼

不幸的是,這裡的程式碼有些笨重和重複,但在不增加複雜性的情況下很難讓它變得更好了。本質上,我們嘗試從表單中獲取 beforeafter 兩個欄位。如果欄位存在的話,再嘗試將其解析為 i64。我希望能合併多個 if let 語句,所以我們可以寫:

if let Some(ref result) = before && let Err(ref error) = *result {
  return Err(format!("Error parsing 'before': {}", error));
}
複製程式碼

然而,現在 Rust 中不能這麼寫(可以通過打包在元組中的方法,在 if let 語句中寫多個值,但是這些值不能像這裡一樣互相依賴)。

暫時跳過 query_db 的話,make_get_response 看起來非常簡單:

fn make_get_response(
    messages: Option<Vec<Message>>,
) -> FutureResult<hyper::Response, hyper::Error> {
  let response = match messages {
    Some(messages) => {
      let body = render_page(messages);
      Response::new()
        .with_header(ContentLength(body.len() as u64))
        .with_body(body)
    }
    None => Response::new().with_status(StatusCode::InternalServerError),
  };
  debug!("{:?}", response);
  futures::future::ok(response)
}
複製程式碼

如果 messages 這個 option 包含一個值,我們可以將這個訊息傳給 render_page,它會返回一個構成我們的響應體的 HTML 頁面,其中在一個簡單的 HTML 列表中顯示訊息。如果 option 為空,query_db 中出現了一個錯誤,我們會記錄日誌但不會暴露給使用者,所以我們只是返回狀態碼為 500 的響應。我將在模板章節介紹 render_page 的實現。

連線到資料庫

既然我們的服務中有寫入和讀取的路徑,我們就需要將它們與資料庫結合起來進行讀寫。Rust 有一個非常好用和流行的物件關係模型(ORM)庫叫做 diesel。這個庫非常有趣和直觀。將它新增到你的 Cargo.toml 中,並啟用 postgres 功能,因為我們這份教程中要使用 Postgres 資料庫:

diesel = { version = "1.0.0", features = ["postgres"] }
複製程式碼

請保證你已經在機器上安裝了 Postgres,並且可以使用 psql 登入(作為基本的健壯性檢查)。Diesel 還支援 MySQL 等其他 DBMS,你可以在學完本教程之後嘗試它們。

讓我們從為應用建立資料庫模式開始。我們將它放入 schemas/messages.sql 中:

CREATE TABLE messages (
  id SERIAL PRIMARY KEY,
  username VARCHAR(128) NOT NULL,
  message TEXT NOT NULL,
  timestamp BIGINT NOT NULL DEFAULT EXTRACT('epoch' FROM CURRENT_TIMESTAMP)
)
複製程式碼

表中的每一行都儲存一條訊息,包括單調遞增的 ID、作者的使用者名稱、訊息文字和一個時間戳。上面所說的時間戳的預設值會為每個新的條目插入自 epoch 以來的當前秒數。由於 id 列也是自動遞增的,我們最終只需要為每個新行插入使用者名稱和訊息。

現在我們需要將此表與 Diesel 整合。為此,我們需要通過 cargo install diesel_cli 安裝 Diesel CLI。然後你就可以執行下面的命令:

$ export DATABASE_URL=postgres://<user>:<password>@localhost
$ diesel print-schema | tee src/schema.rs
table! {
  messages (id) {
    id -> Int4,
    username -> Varchar,
    message -> Text,
    timestamp -> Int8,
  }
}
複製程式碼

其中 <user>:<password> 是你的資料庫的使用者名稱和密碼。如果你的資料庫沒有密碼,則只需要輸入使用者名稱。後一個命令列印出用 Rust 寫的資料庫表示,我們可以將它儲存在 src/schema.rs 中。table! 巨集來自於 Diesel。除了模式(schema)之外,Diesel 還要求我們寫一個模型(model)。這個我們需要在 src/models.rs 中自己編寫:

#[derive(Queryable, Serialize, Debug)]
pub struct Message {
  pub id: i32,
  pub username: String,
  pub message: String,
  pub timestamp: i64,
}
複製程式碼

這個模型是我們在程式碼中與之互動的 Rust 結構體。為此,我們需要在主模組中新增一些宣告:

#[macro_use]
extern crate serde_derive;
#[macro_use]
extern crate diesel;

mod schema;
mod models;
複製程式碼

此時,我們已經準備好補充我們之前遺漏的函式 write_to_dbquery_db 了。

寫入資料庫

我們從 write_to_db 開始。這個函式只是簡單地將一個條目寫入資料庫,並返回它建立的時間戳:

use diesel::prelude::*;
use diesel::pg::PgConnection;

fn write_to_db(
  new_message: NewMessage,
  db_connection: &PgConnection,
) -> FutureResult<i64, hyper::Error> {
  use schema::messages;
  let timestamp = diesel::insert_into(messages::table)
    .values(&new_message)
    .returning(messages::timestamp)
    .get_result(db_connection);

  match timestamp {
    Ok(timestamp) => futures::future::ok(timestamp),
    Err(error) => {
      error!("Error writing to database: {}", error.description());
      futures::future::err(hyper::Error::from(
          io::Error::new(io::ErrorKind::Other, "service error"),
      ))
    }
  }
}
複製程式碼

就這麼簡單!Diesel 提供了一個非常直觀而且型別安全的查詢介面,我們用它來:

  • 指定我們要插入的表,
  • 指定我們要插入的值(馬上還會再提到),
  • 指定我們想要返回的值(如果有的話),以及
  • 呼叫 get_result,它將實際執行查詢。

這返回給我們一個 QueryResult<i64> 物件,我們可以對它進行匹配,根據需要處理錯誤。上面應當會讓你感到驚訝的兩件事是(1)我們可以直接將 NewMessage 結構體傳入 Diesel,以及(2)我們使用一個神奇的、之前不存在的 db_connection 引數。讓我們解開這兩個謎團!對於(1),上面我給你的程式碼實際上不會通過編譯。為了讓程式碼能編譯,我們需要將 NewMessage 結構體移動到 src/models.rs 中,就放在 Message 結構體下面。程式碼看起來像這樣:

use schema::messages;

#[derive(Queryable, Serialize, Debug)]
pub struct Message {
  pub id: i32,
  pub username: String,
  pub message: String,
  pub timestamp: i64,
}

#[derive(Insertable, Debug)]
#[table_name = "messages"]
pub struct NewMessage {
  pub username: String,
  pub message: String,
}
複製程式碼

這樣,Diesel 可以直接將我們的結構體中的欄位與資料庫中的列關聯起來。多麼簡潔!注意到,為此,資料庫中的表必須叫做 messages,如 table_name 屬性所示。

對於第二個謎團,我們需要稍微修改程式碼,引入資料庫連線的概念。在 Service::call() 中,將以下內容放在頂部:

fn call(&self, request: Request) -> Self::Future {
  let db_connection = match connect_to_db() {
    Some(connection) => connection,
    None => {
      return Box::new(futures::future::ok(
        Response::new().with_status(StatusCode::InternalServerError),
      ))
    }
  };
複製程式碼

其中 connect_to_db 如下定義

use std::env;

const DEFAULT_DATABASE_URL: &'static str = "postgresql://postgres@localhost:5432";

fn connect_to_db() -> Option<PgConnection> {
  let database_url = env::var("DATABASE_URL").unwrap_or(String::from(DEFAULT_DATABASE_URL));
  match PgConnection::establish(&database_url) {
    Ok(connection) => Some(connection),
    Err(error) => {
      error!("Error connecting to database: {}", error.description());
      None
    }
  }
}
複製程式碼

這個函式查詢環境變數 DATABASE_URL 來確定 Postgres 資料庫的 URL,否則使用預定義的常量。然後它嘗試建立一個新的資料庫連線,如果成功的話則返回。你還需要更新處理 GETPOST 的程式碼:

(&Post, "/") => {
  let future = request
    .body()
    .concat2()
    .and_then(parse_form)
    .and_then(move |new_message| write_to_db(new_message, &db_connection))
    .then(make_post_response);
  Box::new(future)
}
(&Get, "/") => {
  let time_range = match request.query() {
    Some(query) => parse_query(query),
    None => Ok(TimeRange {
      before: None,
      after: None,
    }),
  };
  let response = match time_range {
    Ok(time_range) => make_get_response(query_db(time_range, &db_connection)),
    Err(error) => make_error_response(&error),
  };
  Box::new(response)
}
複製程式碼

使用這種方案,我們會在每次請求到來時建立一個新的資料庫連線。取決於你的配置,這種方案可能沒問題。不過,你可能還需要考慮使用 r2d2 建立一個連線池來保持一定數量的連線開啟,並在你需要的時候給你一個連線。

查詢資料庫

我們現在可以將新的訊息寫入資料庫 —— 這太棒了。下面,我們要弄清楚如何通過恰當地查詢資料庫來將它們再讀出來。讓我們實現 query_db

fn query_db(time_range: TimeRange, db_connection: &PgConnection) -> Option<Vec<Message>> {
  use schema::messages;
  let TimeRange { before, after } = time_range;
  let query_result = match (before, after) {
    (Some(before), Some(after)) => {
      messages::table
        .filter(messages::timestamp.lt(before as i64))
        .filter(messages::timestamp.gt(after as i64))
        .load::<Message>(db_connection)
    }
    (Some(before), _) => {
      messages::table
        .filter(messages::timestamp.lt(before as i64))
        .load::<Message>(db_connection)
    }
    (_, Some(after)) => {
      messages::table
        .filter(messages::timestamp.gt(after as i64))
        .load::<Message>(db_connection)
    }
    _ => messages::table.load::<Message>(db_connection),
  };
  match query_result {
    Ok(result) => Some(result),
    Err(error) => {
      error!("Error querying DB: {}", error);
      None
    }
  }
}
複製程式碼

不幸的是,這段程式碼有點複雜。這是因為 beforeafter 都是 Option,而且 Diesel 目前不支援逐步構建查詢的簡單方法。所以我們只能窮舉 beforeafterSome 或者 None,然後決定執行零個、一個或兩個過濾器。不過,查詢本身還是非常簡單和直觀的。由於 where 是 Rust 中的關鍵字,SQL 中的 WHERE 子句是使用 Diesel 中的 filter 方法實現的。像 >= 這樣的關係操作符則是模型結構體上的方法,如 .gt().eq()

渲染 HTML 模板

我們很接近完成了!現在還剩下的就只有編寫我們之前遺漏的 render_page。為此,我們要使用模板庫。在 web 伺服器的上下文中,模板是一種通過動態資料和控制流建立 HTML 頁面的通用概念。其他語言中流行的模板庫有 JavaScript 的 Handlebars 和 Python 的 Jinja。雖然我在 URL 縮短器 專案中使用了 Rust 上的 Handlebars,但是我不得不說 Rust 的模板庫都不怎麼樣。就像 Rust 中的不少領域一樣,沒有像 Jinja 在 Python 中一樣的“準標準庫”. 這使得從中選擇一個很難,因為你永遠不知道它會不會在未來六個月內被棄用。

雖然如此,我們的教程中會使用一個叫做 maud 的模板庫。雖然 maud 不是真實世界應用的最具擴充套件性的選擇,但它也很有趣和強大,允許我們直接用 Rust 寫 HTML 模板。maud 還可以發揮 Rust 巨集的力量,如果有的話。也就是說,maud 需要一個 Rust 的每日構建版本,以啟動巨集程式(procedural macro)功能。這個功能看起來已經接近穩定了

首先,在你的 Cargo.toml 中新增 maud

[dependencies]
maud = "0.17.2"
複製程式碼

然後,將下面的宣告新增到你的 main.rs 的頂部:

#![feature(proc_macro)]
extern crate maud;
複製程式碼

現在,你可以編寫 render_page 了:

fn render_page(messages: Vec<Message>) -> String {
  (html! {
    head {
      title "microservice"
      style "body { font-family: monospace }"
    }
    body {
      ul {
        @for message in &messages {
          li {
            (message.username) " (" (message.timestamp) "): " (message.message)
          }
        }
      }
    }
  }).into_string()
}
複製程式碼

什麼鬼?這確實有點驚人。仔細思考一下,深呼吸。這是在用 Rust 巨集來編寫 HTML 頁面。我勒個去。

確實如此!我們的微服務已經寫完了,而且非常的。我們來執行它:

$ DATABASE_URL="postgresql://goldsborough@localhost" RUST_LOG="microservice=debug" cargo run
Compiling microservice v0.1.0 (file:///Users/goldsborough/Documents/Rust/microservice)
  Finished dev [unoptimized + debuginfo] target(s) in 12.30 secs
  Running `target/debug/microservice`
INFO 2018-01-22T01:22:16Z: microservice: Running microservice at 127.0.0.1:8080
複製程式碼

然後在另一個終端中:

$ curl -X POST -d 'username=peter&message=hi' 'localhost:8080'
{"timestamp":1516584255}
$ curl -X POST -d 'username=mike&message=hi2' 'localhost:8080'
{"timestamp":1516584282}
複製程式碼

你應當立刻能看到除錯日誌:

...
DEBUG 2018-01-22T01:24:14Z: microservice: Request { method: Post, uri: "/", version: Http11, remote_addr: Some(V4(127.0.0.1:64869)), headers: {"Host": "localhost:8080", "User-Agent": "curl/7.54.0", "Accept": "*/*", "Content-Length": "25", "Content-Type": "application/x-www-form-urlencoded"} }
DEBUG 2018-01-22T01:24:14Z: microservice: Response { status: Ok, version: Http11, headers: {"Content-Length": "24", "Content-Type": "application/json"} }
...
複製程式碼

現在,我們用 GET 來獲取一些訊息:

$ curl 'localhost:8080'
<head><title>microservice</title><style>body { font-family: monospace }</style></head><body><ul><li>peter (1516584255): hi</li><li>mike (1516584282): hi2</li></ul></body>
複製程式碼

或者你在瀏覽器中開啟 http://localhost:8080

screenshot

你也可以嘗試在查詢 URL 上新增 ?after=<timestamp>&before=<timestamp>,並驗證你確實只獲得了指定時間範圍內的訊息。

使用 Docker 打包

我將簡單談談如何將這個應用打包為一個 Docker 容器。這和 Rust 本身沒有任何關係,但在此基礎上了解相關的 Docker 容器是很有用的。

Rust 開發人員維護了兩個官方的 Docker 映象:一個是穩定版,一個是用於每日構建的 Rust。穩定版的 Rust 映象就是 rust,每日構建版的映象是 rust-lang/rust:nightly。基於其中一個映象擴充套件出我們的容器非常簡單。我們想基於每日構建的映象。Dockerfile 的內容應當像下面這樣:

FROM rustlang/rust:nightly
MAINTAINER <your@email>

WORKDIR /var/www/microservice/
COPY . .

RUN rustc --version
RUN cargo install

CMD ["microservice"]
複製程式碼

參考典型的微服務架構,我們在另一個 Docker 容器中執行 Postgres 資料庫。如下編寫 Dockerfile-db

FROM postgres
MAINTAINER <your@email>

# Create the table on start-up
ADD schemas/messages.sql /docker-entrypoint-initdb.d/
複製程式碼

然後用 docker-compose.yaml 將它們組合在一起:

version: '2'
services:
  server:
    build:
      context: .
      dockerfile: docker/Dockerfile
    networks:
      - network
    ports:
        - "8080:80"
    environment:
      DATABASE_URL: postgresql://postgres:secret@db:5432
      RUST_BACKTRACE: 1
      RUST_LOG: microservice=debug
  db:
    build:
      context: .
      dockerfile: docker/Dockerfile-db
    restart: always
    networks:
      - network
    environment:
      POSTGRES_PASSWORD: secret

networks:
  network:
複製程式碼

這個檔案有點複雜,但寫好這個以後,其他內容都簡單了。注意到我將兩個 Dockerfile 都放在了 docker/ 目錄下。現在,只需執行 docker-compose up

$ docker-compose up
Recreating microservice_db_1 ...
Recreating microservice_server_1 ... done
Attaching to microservice_db_1, microservice_server_1
server_1  |  INFO 2018-01-22T01:38:57Z: microservice: Running microservice at 127.0.0.1:8080
db_1      | 2018-01-22 01:38:57.886 UTC [1] LOG:  listening on IPv4 address "0.0.0.0", port 5432
db_1      | 2018-01-22 01:38:57.886 UTC [1] LOG:  listening on IPv6 address "::", port 5432
db_1      | 2018-01-22 01:38:57.891 UTC [1] LOG:  listening on Unix socket "/var/run/postgresql/.s.PGSQL.5432"
db_1      | 2018-01-22 01:38:57.917 UTC [20] LOG:  database system was shut down at 2018-01-22 00:10:07 UTC
db_1      | 2018-01-22 01:38:57.939 UTC [1] LOG:  database system is ready to accept connections
複製程式碼

當然,你第一次執行時的輸出可能會有所不同。但無論如何,我們的工作已經全部完成了。你可以將這些程式碼上傳到一個 GitHub 倉庫,然後放到(免費的)AWSGoogle Cloud 例項上,就可以從外部訪問你的服務了。哇哦!

結語

上面的程式碼片段拼在一起大約有 270 行,這已經足夠用 Rust 建立我們完整的微服務了。相比於例如在 Flask 中的等價程式碼,我們的程式碼可能也不是很少。然而,Rust 中還有更多的 web 框架,可以為你提供更多的抽象,例如 Rocket。儘管如此,我相信跟隨這個教程,使用 Hyper 稍微接近底層,會帶給你關於如何利用 Rust 寫一個安全且高效能的 web 服務的一些很好的思路。

我寫這篇博文是想分享我在學習 Rust,以及使用我的知識寫一個小型的 URL 縮短器 web 服務 —— 我用這個 web 服務來縮短我的部落格的 URL(如果你看一眼瀏覽器的 URL 欄,會發現它非常長)—— 時學到的東西。出於這個原因,我覺得我現在對 Rust 提供的特性有了深刻的認識。也知道了 Rust 的這些特性和現代 C++ 相比,哪些表達能力較強且更安全,而哪些表達能力較弱(但不會更不安全)。

我覺得 Rust 的生態系統可能還需要幾年的時間來穩定,才能讓穩定且維護良好的軟體包完成主要的功能。儘管如此,前途還是很光明的。Facebook 已經在研究如何使用 Rust 構建託管其程式碼庫的新 Mercurial 伺服器。越來越多的人將 Rust 視為嵌入式程式設計的一個有趣選擇。我會密切關注這個語言的發展,這意味著我已經在 Reddit 上訂閱了 r/Rust

如果發現譯文存在錯誤或其他需要改進的地方,歡迎到 掘金翻譯計劃 對譯文進行修改並 PR,也可獲得相應獎勵積分。文章開頭的 本文永久連結 即為本文在 GitHub 上的 MarkDown 連結。


掘金翻譯計劃 是一個翻譯優質網際網路技術文章的社群,文章來源為 掘金 上的英文分享文章。內容覆蓋 AndroidiOS前端後端區塊鏈產品設計人工智慧等領域,想要檢視更多優質譯文請持續關注 掘金翻譯計劃官方微博知乎專欄

相關文章