这是关于使用Tower、Hyper、Axum和Tonic将Web和gRPC服务结合成一个单一服务的系列文章中的第二篇。完整的四部分是:
- 塔的概述
- 今天的文章。了解Hyper,以及使用Axum的初步经验
- 为gRPC客户端/服务器演示Tonic
- 如何将Axum和Tonic服务结合成一个单一的服务,9月20日即将推出
如果你还没有看过这个系列的第一篇文章,我建议你去看看。
快速回顾
- Tower提供了一个
Service特质,它基本上是一个从请求到响应的异步函数 Service是在请求类型上的参数化,并且有一个相关的类型,用于处理请求。Response- 它也有一个相关的
Error类型,和一个相关的Future类型 Service允许在检查服务是否准备好接受请求,以及处理请求时的异步行为。- 一个网络应用最终会有两套异步请求/响应行为
- 内部:接受HTTP请求并返回HTTP响应的服务
- 外部:一个接受传入的网络连接并返回内部服务的服务
考虑到这一点,让我们看看Hyper。
Hyper中的服务
现在我们已经有了Tower的经验,是时候深入到Hyper的具体世界了。我们上面看到的大部分内容将直接适用于Hyper。但Hyper还有一些额外的曲线球需要处理:
Request和Response类型都是通过请求/响应体的表示法来进行参数化的。- 在公共API中有一堆额外的特征和类型参数化,有些根本没有出现在文档中,还有很多不清楚的地方
取代我们在之前的假服务器例子中的run 函数,Hyper遵循一个构建者模式来初始化HTTP服务器。Server 在提供了配置值后,你从你的Builder ,用 serve方法创建一个活动的。只是现在要把它拿出来,这是公共文档中的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是一个从 到 的异步函数,它可能会因为一个 的值而失败。RequestResponseInfallibleRequest和Response都是用Body参数化的,这是一个默认的 HTTP 体表示。
handle这句话被包裹在 中,产生一个 。这与上面的 相同。service_fnService<Request<Body>>app_fn- 我们使用
make_service_fn,就像上面的app_factory_fn,来产生Service<&AddrStream>(我们很快就会提到那个&AddrStream)。- 我们并不关心
&AddrStream的值,所以我们忽略了它 make_service_fn中的函数的返回值必须是一个Future,所以我们用 包裹。asyncFuture的输出值必须是一个Result,所以我们用 " "包裹。Ok- 我们需要帮助编译器,提供一个
Infallible的类型注释,否则它将不知道Ok(service_fn(handle))表达式的类型。
- 我们并不关心
由于(至少)三个不同的原因,使用这种抽象水平来编写一个普通的Web应用程序是很痛苦的。
- 手动管理所有这些
Service,是一种痛苦 - 很少有高水平的辅助工具,比如 "将请求体解析为JSON值"
- 你的类型中的任何错误都可能导致非常大的、非本地的错误信息,难以诊断。
因此,我们将更乐意在稍后从Hyper转移到Axum。但现在,让我们继续探索Hyper层的事情。
绕过service_fn 和make_service_fn
当我试图摸索Hyper时,我发现最有用的是实现一个没有service_fn 和make_service_fn 的简单应用。所以,让我们在这里自己去做。我们将创建一个简单的计数器应用程序(如果我不能预测的话)。我们将需要两种不同的数据类型:一种用于 "应用工厂",另一种用于应用本身。让我们从应用程序本身开始:
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 结构来创建一个立即可以使用的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 是一个HyperRequest<Body> 。但在DemoAppFactory 的情况下,它所接受的Request 是一个&AddrStream 。请记住,Service 实际上只是一个可失败的、从输入到输出的异步函数的概括。输入可能是一个Request<Body> ,也可能是一个&AddrStream ,或者完全是其他东西。
同样,这里的 "响应 "也不是一个HTTP响应,而是一个DemoApp 。我再次发现使用 "输入 "和 "输出 "这两个术语更容易,以避免请求和响应的名称重载。
最后,我们的main 函数看起来与 "Hello world "例子中的原始函数基本相同:
#[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 ?如果你使用一个特质对象,你到底是怎么销的?
但现在是时候深入研究一个我回避了很久的话题了。
了解特质
让我们回顾一下上面关于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>,
直到准备这篇博文之前,我从未尝试深入了解所有这些界线。因此,这对我们大家来说将是一次冒险!(也许最后我应该写一些文档PR...)让我们从类型变量开始。我们一共有四个:两个在impl 块本身,两个在这个方法上:
I代表传入的连接流。E代表执行者。S是我们要运行的服务。使用上面的术语,这就是 "应用工厂";使用Tower/Hyper术语,这就是 "制造服务"。B是服务返回的响应体的选择(是 "应用程序",而不是 "应用程序工厂",使用上面的术语)。
I: Accept
I 需要实现 Accept特质,它表示接受来自某个源的新连接的能力。盒子里唯一的实现是为 AddrIncoming,它可以从一个SocketAddr 。而事实上,这正是Server::bind 。
Accept 有两个相关的类型。 ,必须是可以转换为错误对象的东西,或者 。这是我们看的每一个相关错误类型的要求(几乎是?),所以从现在开始我就跳过它们。我们需要能够将发生的任何错误转换为统一的表示。Error Into<Box<dyn StdError + Send + Sync>>
Conn 关联类型代表一个单独的连接。在AddrIncoming 的情况下,关联类型是 AddrStream.这个类型必须实现AsyncRead 和AsyncWrite 进行通信,Send 和'static ,这样它就可以被发送到不同的线程,以及Unpin 。对Unpin 的要求是从堆栈的深处冒出来的,说实话,我不知道是什么驱动了它。
S: MakeServiceRef
MakeServiceRef 是那些没有出现在公共文档中的特征之一。这似乎是故意的,阅读源代码。
只是
MakeService的一种 "特质别名",不会被任何人实现,只作为边界使用。
你是不是对我们为什么会收到一个带有&AddrStream 的引用感到困惑?这是为该转换提供动力的特质。总的来说,trait boundS: MakeServiceRef<I::Conn, Body, ResBody = B> 意味着:
S必须是一个ServiceS将接受输入类型为&I::Conn- 它将反过来产生一个新的
Service作为输出 - 该新服务将接受
Request<Body>作为输入,并产生Response<ResBody>作为输出
在我们讨论这个问题的时候:ResBody 有一个限制,即它必须实现 HttpBody.正如你可能猜到的,上面提到的Body 结构实现了HttpBody 。也有一些实现方式。当我们谈到Tonic和gRPC时,我们会发现事实上还有其他的响应体需要我们去处理。
NewSvcExec 和ConnStreamExec
E 参数的默认值是Exec ,它没有出现在生成的文档中。但当然你可以在源代码中找到它。Exec 的概念是指定任务是如何被催生出来的。默认情况下,它利用了tokio::spawn 。
我并不完全确定所有这些是如何进行的,但我相信标题中的两个特征允许对连接服务(app factory)和请求服务(app)的产卵进行不同的处理。
使用Axum
Axum是一个新的网络框架,是这篇博文的开端。与其像上面那样直接与Hyper打交道,不如用Axum重新实现我们的计数器网络服务。我们将使用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 位。这是我们在应用程序中管理共享状态的方式。这与我们对Tower和Hyper的整体分析并不直接相关,所以我只提供一个文档的链接来展示它的工作原理。有趣的是,你可能会注意到,这个实现依赖于中间件,而中间件事实上是利用了Tower,所以它并不是完全独立的。
总之,回到我们手头的问题上来。在我们的main 函数中,我们现在使用这个Router 概念来建立我们的应用程序:
let app = Router::new()
.route("/", get(home))
.layer(AddExtensionLayer::new(AppState::default()));
这基本上是说,"当你收到对/ 的请求时,请调用home 函数,并添加一个中间件来做整个扩展的事情。"home 函数使用一个提取器来获得AppState ,并返回一个类型为(StatusCode, HeaderMap, String) 的值来表示响应。在Axum中,任何适当命名的IntoResponse 特质的实现都可以从处理函数返回。
总之,我们的app 值现在是一个Router 。但一个Router 不能被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 的服务的值。IntoMakeService<S> 将接受某种连接信息,T ,并异步地产生该服务S 。而且由于Error 是Infallible ,我们知道它不会失败。但是,尽管我们说 "异步",看一下Service 对IntoMakeService 的实现,我们看到了一个熟悉的模式:
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 类型是从哪里来的?它是由你所做的所有route 和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 返回一个 值。而 是一个 ,它接收一个 ,并返回一个 。关于身体的表示,有一些有趣的事情在起作用,我们最终会更深入地研究。但随着我们对Tower和Hyper的新理解,这里的类型不再是不可捉摸的了。事实上,它们甚至可以被仔细审查OnMethod OnMethod Service Request<B> Response<BoxBody>
关于上面的例子,还有一个最后的说明。阿克苏姆直接与很多超能机械一起工作。这包括Server 型。虽然axum 箱子从Hyper中重新输出了许多东西,但如果需要,你可以直接从Hyper中使用这些类型。换句话说,Axum与底层库非常接近,只是在上面提供了一些便利。这也是我对Axum进行更深入的实验感到非常兴奋的原因之一。
所以,在这一点上要总结一下:
- Tower为从输入到输出的异步函数提供了一个抽象,这可能会失败。这就是所谓的服务。
- HTTP服务器有两个层次的服务。下层是一个从HTTP请求到HTTP响应的服务。上层是一个从连接信息到下层服务的服务。
- Hyper有很多额外的特性,有些是可见的,有些是不可见的,这些特性允许更多的通用性,同时也使事情变得更复杂易懂。
- Axum位于Hyper之上,为许多常见情况提供了一个更容易使用的接口。它通过提供Hyper期望看到的相同类型的服务来做到这一点。而且,它似乎在围绕HTTP主体表示做了一堆花哨的工作。
我们旅程的下一步:让我们看看另一个用于构建Hyper服务的库。我们将在下一篇文章中继续讨论这个问题。