这篇文章和例子是基于Actix 0.7的。
在过去的几个月里,我一直在玩Rust,我很喜欢它。
与学习其他语言相比,Rust的学习曲线无疑是相当陡峭的,但是,根据我目前的经验,当它被点击并编译出一些东西时,它往往能很好地工作,这是很有意义的。
在学习一门新的语言时,我喜欢做的一件事是做一些我在概念上熟悉的东西,看看它在新的语言中看起来如何。这通常是一个小的CLI工具或一个Web应用程序。在这种情况下,我们将看一下Rust中的基本网络应用。
在Rust中,有几种方法可以做到这一点--有几个框架具有不同的优势。对于这个例子,我决定使用Actix,因为它看起来很成熟,而且有很好的文档/维护。
总的来说,当涉及到网络生态系统时,Rust绝对没有达到Java、node.js、Ruby或Go的阶段,但它有稳定的进展,这里有记录。
请记住,我既是Rust的新手,也是Actix的新手,所以虽然我在这里所做的事情可以做,也可以做我想做的事情,但它可能不是最好/最理想的做事方法。)
我们将在这篇文章中看到的网络应用暴露了一个小型的REST API,它将传入的请求代理给另一个API。
在这个例子中,我将使用Timeular公共API作为代理目标,因为我对它非常熟悉。任何人都可以创建一个免费账户并创建API凭证,但这篇文章的概念也可以应用于任何其他网络API。
我们要创建的API是这样的。
GET /rest/v1/activities- 返回所有活动POST /rest/v1/activities- 创建一个活动GET /rest/v1/{activity_id}- 返回一个具有给定id的活动PATCH /rest/v1/{activity_id}- 更新具有给定id的活动DELETE /rest/v1/{activity_id}- 删除具有给定id的活动
我们还将看一下如何使用docker 容器化应用程序。
actix 框架以异步方式处理请求,但在这种情况下,处理程序本身并不是异步实现的,例如使用期货。我决定一开始就保持简单,但在后续的文章中,我将把处理程序也转换为异步模型。
好了,我们开始吧。)
设置和配置
为了配置服务,在这种情况下,为了设置Timeular 证书,我们使用envconfigcrate,它基本上将给定的环境变量转换为定义的Rust结构。
在我们的例子中,它的配置看起来像这样。
#[derive(Envconfig)]
pub struct Config {
#[envconfig(from = "API_KEY", default = "")]
pub api_key: String,
#[envconfig(from = "API_SECRET", default = "")]
pub api_secret: String,
}
然后,在main ,我们调用。
let config = match Config::init() {
Ok(v) => v,
Err(e) => panic!("Could not read config from environment: {}", e),
};
对于logging ,我们使用slogcrate。我不会去讨论日志的任何细节,但有几种方法可以设置它。我们将在我们的应用程序中使用的方法是这样的。
pub fn setup_logging() -> slog::Logger {
let decorator = slog_term::TermDecorator::new().build();
let drain = slog_term::CompactFormat::new(decorator).build().fuse();
let drain = slog_async::Async::new(drain).build().fuse();
slog::Logger::root(drain, o!())
}
这是直接从文档中提取的,并创建了一个高效的、异步的日志记录方式。
另外,为了对Timeular API进行认证请求,我们需要先登录。我们的应用程序在启动Web服务器之前就完成了这项工作,然后将返回的JSON Web Token 给处理程序。
登录的工作方式是这样的。
let jwt = match external::get_jwt(&api_key, &api_secret) {
Ok(v) => v,
Err(e) => panic!("Could not get the JWT: {}", e),
};
在后面的文章中,我们会看一下external 包中实际的get_jwt 方法,该方法执行login 请求,但现在,我们只说这是有效的,我们有一个有效的JWT 。
好了,这些都是基本要素--现在我们来看看实际的Actix网络服务器。
Actix有State 的概念,它可以在处理程序之间共享。这个状态对象将被复制到每个处理程序中,我们将把我们的logging 处理程序和我们的JWT 放在那里。
App::with_state(AppState {
jwt: jwt.to_string(),
log: log.clone(),
})
然后,我们为文章开头指定的端点定义我们的路由。
server::new(move || {
App::with_state(AppState {
jwt: jwt.to_string(),
log: log.clone(),
})
.scope("/rest/v1", |v1_scope| {
v1_scope.nested("/activities", |activities_scope| {
activities_scope
.resource("", |r| {
r.method(http::Method::GET).f(handlers::get_activities);
r.method(http::Method::POST)
.with_config(handlers::create_activity, |cfg| {
(cfg.0).1.error_handler(handlers::json_error_handler);
})
})
.resource("/{activity_id}", |r| {
r.method(http::Method::GET).with(handlers::get_activity);
r.method(http::Method::DELETE)
.with(handlers::delete_activity);
r.method(http::Method::PATCH)
.with_config(handlers::edit_activity, |cfg| {
(cfg.0).1.error_handler(handlers::json_error_handler);
});
})
})
})
.resource("/health", |r| {
r.method(http::Method::GET).f(handlers::health)
})
.finish()
})
.bind("0.0.0.0:8080")
.unwrap()
.run();
因此,这段代码创建了一个新的actix网络服务器,端口为8080 。首先,我们添加我们的state 对象,然后我们定义一个/rest/v1 范围,所有定义的路由都在这个范围内。
接下来的子路由是/ 和/{activity_id} ,它将处理程序分成处理指定的activity 的端点和不处理的端点。
现在我们定义了这个结构,实际的端点是相当简单的,它们是用HTTP方法定义的,如http::Method::GET ,然后提供一个处理函数,例如:handlers::get_activities 。
对于POST 和PATCH 处理程序,我们还添加了一个自定义的错误处理程序,称为handlers::json_error_handler ,我们将在后面看一下。这个错误处理程序基本上可以优雅地处理JSON解析错误。
此外,我们还创建了一个简单的/health 端点,如果服务已经启动,它只返回OK 。
外部API调用
在我们进入实际的处理程序之前,我们将看看external 包,它基本上只是Timeular API的一个非常基本的HTTP客户端。
对于提出HTTP请求,我们将使用reqwestcrate,它有一个非常简单的API。
我们将不看所有的请求,因为它们相当相似,但完整的代码在GitHub上(链接在底部)。
external 包的基本 API 是为每个单独的请求提供一个函数,如get_activities 。
pub fn get_activities(jwt: &str) -> Result<ActivitiesResponse, Error> {
let activities_path = format!("{}/activities", BASE_URL);
let result = get(&activities_path, jwt)?;
serde_json::from_str(&result).map_err(|e| format_err!("could not parse json, reason: {}", e))
}
这些函数实际上相当简单。首先,我们定义请求的路径,然后把它和我们的JSON Web Token 传递给我们正在使用的HTTP方法的辅助方法,它看起来像这样。
fn get(path: &str, jwt: &str) -> Result<String, Error> {
let client = reqwest::Client::new();
let res = client
.get(path)
.header("Authorization", format!("Bearer {}", jwt))
.send()
.context("error during get request")?;
parse_result(res)
}
在这里,我们创建一个新的reqwest 客户端,配置一个请求并发送。在最后,我们对结果进行解析,如下所示。
fn parse_result(mut res: Response) -> Result<String, Error> {
let mut buf: Vec<u8> = vec![];
if res.status().is_success() {
res.copy_to(&mut buf)
.context("could not copy response into buffer")?;
} else {
return Err(format_err!("request error: {}", res.status()));
}
let result = std::str::from_utf8(&buf)?;
Ok(result.to_string())
}
这个辅助方法基本上只是将给定的响应解析为一个字符串。
正如你在上面的get_activities 中所看到的,我们然后将这个String解析为一个名为ActivitiesResponse 的serde 数据对象,它只是一个列表,其中包括Activities:
#[derive(Serialize, Deserialize, Debug)]
pub struct ActivitiesResponse {
pub activities: Vec<ActivityResponse>,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
#[serde(rename_all = "camelCase")]
pub struct ActivityResponse {
pub id: String,
pub name: String,
pub color: String,
pub integration: String,
pub device_side: Option<Number>,
}
对于这些操作中的每一个,错误都被处理并返回到Result 。
为了同时展示一个请求的例子,即向服务器发送一些东西,我们还将看一下上面提到的get_jwt 方法。
pub fn get_jwt(api_key: &str, api_secret: &str) -> Result<String, Error> {
let mut body = HashMap::new();
body.insert("apiKey", api_key);
body.insert("apiSecret", api_secret);
let jwt_path = format!("{}/developer/sign-in", BASE_URL);
let result = post(&jwt_path, &body, "")?;
let json: Result<SignInResponse, Error> = serde_json::from_str(&result)
.map_err(|e| format_err!("could not parse json, reason: {}", e));
Ok(json.unwrap().token)
}
基本上,传入的有效载荷被简单地转换为HashMap ,然后作为一个主体传递给reqwest ,其余部分与上面的例子非常相似。
处理程序
在看了外部API之后,处理程序实际上是相当简单的。我们将看看一个简单的GET 请求,以及一个变异的POST 请求。
同样,我们将看一下最简单的处理函数,叫做get_activities 。
pub fn get_activities(
req: &HttpRequest<AppState>,
) -> Result<Json<ActivitiesResponse>, AnalyzerError> {
let jwt = &req.state().jwt;
let log = &req.state().log;
external::get_activities(jwt)
.map_err(|e| {
error!(log, "Get Activities ExternalServiceError {}", e);
AnalyzerError::ExternalServiceError
})
.map(Json)
}
所有处理程序的基本结构是,从服务器logging 句柄和JWT state ,并调用相关的external 方法,然后调用Timeular API。
此外,如果发生错误,该错误会被转换为一个自定义的错误类型。为了能够做到这一点,我们需要实现Actix的error:ResponseError 特质。
impl error::ResponseError for AnalyzerError {
fn error_response(&self) -> HttpResponse {
match *self {
AnalyzerError::ExternalServiceError => HttpResponse::InternalServerError()
.content_type("text/plain")
.body("external service error"),
AnalyzerError::ActivityNotFoundError => HttpResponse::NotFound()
.content_type("text/plain")
.body("activity not found"),
}
}
}
actix中的处理程序必须返回实现actix-web的Responder 特质的东西,这是很灵活的。例如,/health 端点只是返回一个字符串。
pub fn health(_: &HttpRequest<AppState>) -> impl Responder {
"OK".to_string()
}
然而,JSON端点返回一个Result<Json<DomainObject>, AnalyzerError> ,其中DomainObject 是从external API调用中返回的数据对象,它被actix-web的Json 类型包裹,自动返回漂亮的JSON给调用者。
好了,现在让我们看看一个端点,它向API发送了一些东西,create_activity 。
pub fn create_activity(
(req, activity): (HttpRequest<AppState>, Json<ActivityRequest>),
) -> Result<Json<ActivityResponse>, AnalyzerError> {
let jwt = &req.state().jwt;
let log = &req.state().log;
info!(log, "creating activity {:?}", activity);
external::create_activity(&activity, jwt)
.map_err(|e| {
error!(log, "Create Activity ExternalServiceError {}", e);
AnalyzerError::ExternalServiceError
})
.map(Json)
}
正如你所看到的,它是比较类似的,除了Json<ActivityRequest> ,它被传递给处理程序。
#[derive(Serialize, Deserialize, Debug)]
pub struct ActivityRequest {
pub name: String,
pub color: String,
pub integration: String,
}
把这个数据对象传递给处理程序,在actix中基本上是自动进行的。由于Json<ActivityRequest> 在处理程序的签名中,actix期望有一个JSON有效载荷,它需要可以解析到给定的数据对象。
处理程序的其他部分与上面提到的GET 端点相同。
上面提到的json_error_handler ,它处理在解析传入的JSON有效载荷时出现的错误。为了做到这一点,需要像这样实现一个自定义的错误处理程序。
pub fn json_error_handler(err: error::JsonPayloadError, _: &HttpRequest<AppState>) -> Error {
error::InternalError::from_response(
"",
HttpResponse::BadRequest()
.content_type("application/json")
.body(format!(r#"{{"error":"json error: {}"}}"#, err)),
)
.into()
}
在这里,我们创建了一个错误处理程序,当actix中发生JsonPayloadError ,它就会被使用。如果发生这种情况,我们会返回一个HTTP 400错误,告诉用户JSON是不正确的。这个自定义的错误处理程序被用于所有接收JSON有效载荷的端点。
就这样了:)
现在我们可以使用cargo run ,用我们的证书作为环境变量启动服务器,并通过这个锈迹斑斑的Web应用程序与我们的Timeular活动进行交互。
完整的代码可以在这里找到。
Dockerizing the Application
为了能够在生产环境中使用这样的网络应用程序,最好使用docker 将其容器化。我使用这个Dockerfile 来实现这个目的。
FROM ekidd/rust-musl-builder:nightly as builder
ADD . ./
RUN sudo chown -R rust:rust /home/rust
RUN cargo build --release
FROM alpine:latest
RUN apk --no-cache add ca-certificates
COPY --from=builder /home/rust/src/target/x86_64-unknown-linux-musl/release/analyzer /usr/local/bin/analyzer
EXPOSE 8080
CMD ["/usr/local/bin/analyzer"]
首先,我们使用rust-musl-builder 镜像构建应用程序,在第二步中,我们将创建的Linux二进制文件移动到一个普通的alpine镜像中并在那里运行。
这种方法有一个好处,那就是生成的镜像非常小,而且不包括整个Rust工具链。
总结
我仍然处于我的Rust之旅的开始阶段,在经历了最初适应借贷检查器和严格的编译器的挫折之后,它已经有了很大的乐趣,而且很有收获。
关于使用Rust编写网络应用程序的可能性--我想说必要的库和工具是存在的,但在这方面当然还没有达到其他语言的水平。
然而,在这个领域似乎有很多进展,所以我相信Rust会在接下来的时间里迎头赶上,此外,除了构建网络应用,你还可以用Rust做很多其他有趣的事情。)