这是系列的第二篇,总的四个部分如下:
- Tower 概览
- Hyper原理和Axum初试
- Tonic gRPC客户端和服务端示范
- 如何把Axum和Tonic服务集成到一个应用中
如果您还没有准备好,我建议您先阅读文章的第一部分。
快速回顾
- Tower 提供了一个Serivce trait,是一个基本的从请求到响应的异步函数。
- Service 是参数化的请求类型,并且有一个Response的关联类型。
- 并且还有Error和Future两个关联类型。
- Serivce 允许在检查服务是否接受新的请求和处理请求都可以是异步。
一个web应用最终会有两种异步请求和响应行为:
- 内部:一个服务接受HTTP请求并返回响应。
- 外部:一个服务接受新的网络连接并返回内部服务。
记住上面实现,让我看看Hyper实现方式。
Hyper中服务
既然我们对Tower有些了解,是时候让我们投入到Hyper奇妙世界。上面我们看到的我们直接用Hyper实现一次,但是Hyper有一些额外的麻烦需要处理:
- Request 和 Response 类型的参数化是通过request/response主体表示的。
- 有很多的特性(traits)和参数化的公共API,很多参考文档中并没有提及而且很多表述不明确。
Hyper是遵从创建者模式初始化Http服务,来替代我们以前假的服务示例中run函数。提供配置参数后,你就创建了一个活跃的服务通过构建器提供的serve方法。不必深究太多,让我门看看文档中函数签名:
pub fn serve<S, B>(self, new_service: S) -> Server<I, S, E>
where
I: Accept,
I::Error: Into<Box<dyn StdError + Send + Sync>>,
I::Conn: AsyncRead + AsyncWrite + Unpin + Send + 'static,
S: MakeServiceRef<I::Conn, Body, ResBody = B>,
S::Error: Into<Box<dyn StdError + Send + Sync>>,
B: HttpBody + 'static,
B::Error: Into<Box<dyn StdError + Send + Sync>>,
E: NewSvcExec<I::Conn, S::Future, S::Service, E, NoopWatcher>,
E: ConnStreamExec<<S::Service as HttpService<Body>>::Future, B>,
有很多参数限制,并且很多文档表述不清晰。希望我们能搞清楚这些。但是目前,让我们从简单的开始:Hyper主页的"Hello world"示例:
use std::{convert::Infallible, net::SocketAddr};
use hyper::{Body, Request, Response, Server};
use hyper::service::{make_service_fn, service_fn};
async fn handle(_: Request<Body>) -> Result<Response<Body>, Infallible> {
Ok(Response::new("Hello, World!".into()))
}
#[tokio::main]
async fn main() {
let addr = SocketAddr::from(([127, 0, 0, 1], 3000));
let make_svc = make_service_fn(|_conn| async {
Ok::<_, Infallible>(service_fn(handle))
});
let server = Server::bind(&addr).serve(make_svc);
if let Err(e) = server.await {
eprintln!("server error: {}", e);
}
}
以下遵从我们以上创建的相同模式:
handle 是一个从Http请求到响应的异步函数,并且如果失败返回Infallible错误值。
- Request和Response都是一Body作为参数,Body是默认的Http请求体表示。
- handle 被service_fn 包装并返回Service<Request<Body>>类型,这就是上面提及的app_fn。
我们调用make_service_fn,类似上文app_factory_fn,返回需要的Service<&AddrStream>(我们简要说下&AddrStream):
- 我们并不关心&AddrStream值,所以可以忽略。
- 从内部函数 make_service_fn 返回的值必须是Future,所以我们要用async包起来。
- Future的返回值是一个Result 类型,所以包装返回Ok
- 我们需要帮助编译器一个小忙给Infallible做下类型标注,否则编译器不知道Ok(service_fn(handle))类型表达式。
至少有三个理由可说明用这种层次的抽象写一个web应用是一件痛苦的事情:
- 手动管理这些碎片化的服务是一种煎熬。
- 高层次辅助函数的方式是非常少的,比如,请求体Json化。
- 任何种类错误在我们类型中可能导致非常大的非本地错误信息,致使调试困难。
所以,我们稍后很高兴从Hyper转到Axum,但是现在,让我们继续探索Hyper。
规避service_fn
和make_service_fn
我觉得最有帮助的是当尝试Hyper实现简单的app时候,不使用service_fn和make_service_fn。所以现在让我们来实现它。我们将创建一个简单的计数器app(如果不如预料,那就失败了) 。我们需要两种不同的数据类型:一个是是"app factory",一个是app自身。让我们从app自身开始:
struct DemoApp {
counter: Arc<AtomicUsize>,
}
impl Service<Request<Body>> for DemoApp {
type Response = Response<Body>;
type Error = hyper::http::Error;
type Future = Ready<Result<Self::Response, Self::Error>>;
fn poll_ready(&mut self, _cx: &mut std::task::Context) -> Poll<Result<(), Self::Error>> {
Poll::Ready(Ok(()))
}
fn call(&mut self, _req: Request<Body>) -> Self::Future {
let counter = self.counter.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
let res = Response::builder()
.status(200)
.header("Content-Type", "text/plain; charset=utf-8")
.body(format!("Counter is at: {}", counter).into());
std::future::ready(res)
}
}
这个实现用了标准库的std::future::Ready结构体创建了一个理解Ready的Future。换句话说,我们的应用没有异步行为。我设置了一个Error 关联类型hyper::http::Error。例如:你向header提交了一个非有效的字符串,比如非ASCII字符,便会导致错误。因为我们阅读了很多次,poll_ready仅仅是等待处理下一个请求。
DemoAppFactory的实现并没有多少不同:
struct DemoAppFactory {
counter: Arc<AtomicUsize>,
}
impl Service<&AddrStream> for DemoAppFactory {
type Response = DemoApp;
type Error = Infallible;
type Future = Ready<Result<Self::Response, Self::Error>>;
fn poll_ready(&mut self, _cx: &mut std::task::Context) -> Poll<Result<(), Self::Error>> {
Poll::Ready(Ok(()))
}
fn call(&mut self, conn: &AddrStream) -> Self::Future {
println!("Accepting a new connection from {:?}", conn);
std::future::ready(Ok(DemoApp {
counter: self.counter.clone()
}))
}
}
我们给Service传了不同的参数,这次是&AddrStream。我确实最初发现命名很迷糊。在Tower中,Service以Request作为参数。DemoApp中,是Request<Body>。DemoAppFactory中,参数是&AddrStream,记住,一个Service仅仅是生成一个从输入到输出的可失败的异步函数。如参数可能是Request<Body>或者是&AddrStream,也或者是全部。
相似的,除了DemoApp之外,"response"这里也不是HTTP响应。我又发现是用术语"输入"和"输出"来避免请求和响应的名称重载更容易一些。
最后,我们的main函数和以前"hello word"的例子一样:
#[tokio::main]
async fn main() {
let addr = SocketAddr::from(([0, 0, 0, 0], 3000));
let factory = DemoAppFactory {
counter: Arc::new(AtomicUsize::new(0)),
};
let server = Server::bind(&addr).serve(factory);
if let Err(e) = server.await {
eprintln!("server error: {}", e);
}
}
如果你想更深入理解,我推荐你在这个应用上面添加一些异步行为。你如何修改Future?如果你用trait 对象,如何精确定住这个对象?
现在是时候深入我一致避免的话题了。
traits深入理解
然我们重新回到上面serve函数签名:
pub fn serve<S, B>(self, new_service: S) -> Server<I, S, E>
where
I: Accept,
I::Error: Into<Box<dyn StdError + Send + Sync>>,
I::Conn: AsyncRead + AsyncWrite + Unpin + Send + 'static,
S: MakeServiceRef<I::Conn, Body, ResBody = B>,
S::Error: Into<Box<dyn StdError + Send + Sync>>,
B: HttpBody + 'static,
B::Error: Into<Box<dyn StdError + Send + Sync>>,
E: NewSvcExec<I::Conn, S::Future, S::Service, E, NoopWatcher>,
E: ConnStreamExec<<S::Service as HttpService<Body>>::Future, B>,
写这篇文章之前,我从没有尝试深入理解这些绑定。所以这对我们来说是一个冒险!!!(或许最后以我的一些文件PRs结束)然我们以类型变量开始。总的来说,我们有四个类型变量:两个在impl块,两个在方法上:
- I 表示新的链接流
- E 代表执行器
- S 表示我们将运行的service。使用我们上面的术语就是"app factory"。用Tower/Hyper术语,便是"make service"
- B 是service 返回的response体(是app 不是 "app factory")
I:Accept
I 需要实现Accept trait,代表可以从一些资源中接受新的链接的能力。唯一实现这个的是装箱的AddrIncoming
,可以从SocketAddr创建。实际上,这就是 Server::bind
功能。
Accept有两个关联类型,Error 必须可以转化成错误对象或者Into<Box<dyn StdError + Send + Sync>>
.这是每一个(几乎?)我们看到的错误关联类型的需求,所以此后我们略过。我们需要能够将任何发生错误转换成统一的表达式。
Conn关联类型代表的是私人链接。鉴于AddrIncoming,Conn的关联类型是addrStream
. 为了通信必须实现AsyncRead和AsyncWrite traits,为了不同线程间传递,必须实现send trait和‘static 以及 unpin。Unpin 的需求需要深入到栈存储,我确实不知道这么驱动的。
S: MakeServiceRef
很多traits 没有出现在公共文档中,MakeServiceRef 便是其中之一。这似乎是故意的。下面资料:
Just a sort-of "trait alias" of MakeService, not to be implemented by anyone, only used as bounds.
你是否困惑我们为什么得到AddrStream的引用?这个trait 具有转变的能力。总的来看,绑定S: MakeServiceRef<I::Conn, Body, ResBody = B>意味着:
- S 必须是Service
- S 必须接受输入类型&I::Conn
- 并且转换为新的Service为输出
- 新的Service接受Request<Body>为输入,Response<ResBody>为输出
当我们讨论时:ResBody必须实现HttpBody
.正如你所想的,上面提到的Body结构体要实现了HttpBody
.还有很多实现的。实际上,当我们使用Tonic和gRPC时,其他响应体也需要我们去处理。
NewSvcExec
and ConnStreamExec
E参数的默认值是Exec,在生成的文档中并没有出现。但是你可以在这些资料中看到。Exec的主要思想是指定如何派生任务,默认使用的是tokio::spawn;
我不太确定所有这些是如何实现的。但是我相信标题中两个trait允许对链接service 和 请求service 使用不同的任务处理。
Axum初试
Axum是一个新的web框架,它是撰写这完整的博客文章初衷。我们不再像上面那样直接使用Hyper处理,而是使用Axum重新实现我们的计数器web服务。我们用 axum = "0.2"
,crate文档提供了Axum很好的概述,我不打算在这里复制信息。相反,这里是我重写代码,我们将分析以下几个主要部分:
use axum::extract::Extension;
use axum::handler::get;
use axum::{AddExtensionLayer, Router};
use hyper::{HeaderMap, Server, StatusCode};
use std::net::SocketAddr;
use std::sync::atomic::AtomicUsize;
use std::sync::Arc;
#[derive(Clone, Default)]
struct AppState {
counter: Arc<AtomicUsize>,
}
#[tokio::main]
async fn main() {
let addr = SocketAddr::from(([0, 0, 0, 0], 3000));
let app = Router::new()
.route("/", get(home))
.layer(AddExtensionLayer::new(AppState::default()));
let server = Server::bind(&addr).serve(app.into_make_service());
if let Err(e) = server.await {
eprintln!("server error: {}", e);
}
}
async fn home(state: Extension<AppState>) -> (StatusCode, HeaderMap, String) {
let counter = state
.counter
.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
let mut headers = HeaderMap::new();
headers.insert("Content-Type", "text/plain; charset=utf-8".parse().unwrap());
let body = format!("Counter is at: {}", counter);
(StatusCode::OK, headers, body)
}
首先,我不讨论AddExtensionLayer/
Extension,这是在我们应用中分享共享状态的方式。这和我们分析Hyper和Tower不相关,所以我提供了个链接 link to the docs demonstrating how this works。有趣的是,你会发现这个实现依赖的是Tower提供的中间件,所以,两者并没有安全分离开。
总之,回到我们讨论的内容。在main函数方法内,我们现在用路由的概念去构建我们的应用。
let app = Router::new()
.route("/", get(home))
.layer(AddExtensionLayer::new(AppState::default()));
本质上说就是:"当请求路径'/'时,调用home函数,并添加中间件处理拓展的事情"。home函数使用提取器得到AppState,并返回(StatusCode, HeaderMap, String) 元祖作为响应。在Axum中,任何实现了IntoResponse
trait的可作为处理函数的返回值。
无论如何,我们app的值是路由。但是路由不能直接被Hyper运行。相反,我们需要转换为MakeService,幸运的是,这比较简单:我们可以调用 app.into_make_service()
.转换。让我们看看方法签名:
impl<S> Router<S> {
pub fn into_make_service(self) -> IntoMakeService<S>
where
S: Clone;
}
让我走的更远一些:
pub struct IntoMakeService<S> { /* fields omitted */ }
impl<S: Clone, T> Service<T> for IntoMakeService<S> {
type Response = S;
type Error = Infallible;
// other stuff omitted
}
Router<S> 是一个可以生成S服务的值,ntoMakeService<S>将获取某种类型的连接信息T,并异步生成该服务S。因为Error是Infallible 类型,我们知道是不可能失败的。正如我们所说的异步,阅读IntoMakeService 的service实现,我们看到了一种熟悉的模式:
fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
Poll::Ready(Ok(()))
}
fn call(&mut self, _target: T) -> Self::Future {
future::MakeRouteServiceFuture {
future: ready(Ok(self.service.clone())),
}
}
并且,可以注意到作为链接信息T的值并没有任何绑定和信息。IntoMakeService 仅仅扔掉了链接信息(如果你想进一步了解,请查看 into_make_service_with_connect_info
.)换句话说:
Router<S>
是一个可以让我们添加路由和中间件的类型- 可以转换
Router<S>
成IntoMakeService<S> - 但是IntoMakeService<S>只是对S的包装以符合Hyper的需求
- 因此,真正的重点是S
所以,S的类型来自哪里?这取决于router和layer 你的调用。比如 get 方法的签名如下:
pub fn get<H, B, T>(handler: H) -> OnMethod<H, B, T, EmptyRouter>
where
H: Handler<B, T>,
pub struct OnMethod<H, B, T, F> { /* fields omitted */ }
impl<H, B, T, F> Service<Request<B>> for OnMethod<H, B, T, F>
where
H: Handler<B, T>,
F: Service<Request<B>, Response = Response<BoxBody>, Error = Infallible> + Clone,
B: Send + 'static,
{
type Response = Response<BoxBody>;
type Error = Infallible;
// and more stuff
}
get方法返回OnMethod值,OnMethod是一个Service,接受Request作为参数并且返回Response<BoxBody>
.由于方法体表述有很多有意思的逻辑,我们最终会深入讨论。但是基于我们对Hyper和Tower新的理解,这里的类型也变的不那么晦涩难懂,实际上,反而更容易理解。
关于上面例子的最后一点需要说明的是,Axum 可以直接和Hyper协同合作,包括Server 类型。Axum可以从Hyper重新导出许多内容,如果需要,您可以直接从Hyper使用这些类型。换句话说,Axum非常接近底层库,只是在上面提供了一些便利。这也是为什么我对深入研究Axum感到非常兴奋的原因之一。
综上所述:
- Tower提供了从输入到输出的抽象可异步的函数,可能会失败,被称之为service.
- HTTP 服务有两个层面的服务,低层次的服务是从HTTP请求到HTTP响应,高层次的服务是根据链接信息返回低层次的服务
- Hyper有很多额外的特性,有些是可见的,有些是不可见的,这允许更多的通用性,也让事情变得更复杂。
- Axum位于Hyper的上层,为许多常见情况提供了一个更易于使用的接口。它通过提供Hyper期望看到的相同类型的service 来实现这一点。围绕HTTP主体表示进行了一系列改变。
下一段旅程:让我们来看下一个构建Hyper 服务的库,我们将在下一篇进行介绍。
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。