[译] 用 Rust 写一个微服务

请容许我在写这样一篇用 Rust 写一个微服务的文章的开头先谈两句 C++。我成为 C++ 社区的一个至关活跃的成员已经很长一段时间了。我参加会议并贡献了演讲,跟随语言的更现代化的特性的发展和传播,固然也写了不少代码。C++ 让用户在写代码时能对程序的全部方面有很是细粒度的控制,不过代价是陡峭的学习曲线,以及写出有效的 C++ 代码所需的大量知识。然而,C++ 也是一个很是古老的语言。它由 Bjarne Stroustrup 在 1985 年构思出来。所以,它即便在现代标准中也带有不少的历史包袱。 固然,在 C++ 建立以后,关于语言设计的研究仍在继续,也致使了一些如 GoRustCrystal 等不少有趣的新语言的诞生。然而,这些新语言中不多有可以既具备比现代 C++ 更有趣的功能,同时仍保证具有和 C++ 一样的性能和对内存、硬件的控制。Go 想要替代 C++,但正如 Rob Pike 发现的那样,C++ 程序员对一种性能较差而又提供较少控制的语言不是很感兴趣。不过,Rust 却吸引了不少 C++ 爱好者。Rust 和 C++ 有很多相同的设计目标,好比零成本抽象,以及对内存的精细控制。除此以外,Rust 还添加了不少让程序更安全、更有表达力,以及让开发更高效的语言特性。我对 Rust 最感兴趣的东西是html

  • 借用检查,极大地提高了内存安全性(再也没有 SEGFAULT 了!);
  • 默认的不可变性(const);
  • 符合直觉的语法糖,例如模式匹配(pattern matching);
  • 没有内置的(算数)类型间的隐式转换。

闲聊完毕。本文的剩余部分将引导你建立一个小而完整的微服务 —— 相似于我为个人博客所写的 URL 缩短器。我说的微服务指的是一个使用 HTTP,接受请求,访问数据库,返回一个响应(可能运送着 HTML),打包在一个 Docker 容器中,并能够放在云上的某个地方的这样一种应用。在这篇文章中,我会构建一个简单的聊天应用,容许你存储和检索消息。我会在过程当中介绍一些相关的包(crate)。你能够在 GitHub 上找到服务的完整代码。前端

使用 HTTP

咱们须要让咱们的 web 服务作的第一件事就是如何使用 HTTP 协议,也就是咱们的应用(服务器)须要接收并解析 HTTP 请求,并返回 HTTP 响应。虽然有不少相似 FlaskDjango 的高级框架能将这一切封装起来,咱们仍是选择使用稍微低级一点的 hyper 库来处理 HTTP。这个库使用网络库 tokiofutures,让咱们能建立一个干净的异步 web 服务器。此外,咱们还会使用 logenv-logger 两个 crate 来实现日志功能。android

咱们首先设置好 Cargo.toml,下载上述的 crate:ios

[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 中:git

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() 方法:github

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 发来一些请求!咱们在终端中启动服务器:golang

$ 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
复制代码

而后在另外一个终端中向它发送一些请求:web

$ curl 'localhost:8080'
复制代码

在第一个终端中,你应该能看到相似下面的输出sql

$ 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前端后端区块链产品设计人工智能等领域,想要查看更多优质译文请持续关注 掘金翻译计划官方微博知乎专栏

相关文章
相关标签/搜索