本章将通过使用 axum 调用分类和文章的 gRPC 服务,来实现博客前台Web服务。

创建项目将加入到 workspace 中:

cargo new blog-frontend

添加依赖

blog-types web 所需的数据结构

由于 web 需要的数据结构和 pb 生成的并不一定相同,比如需要序列化和反序列化、比如可能根据需要增加/删除某些结构体、字段等。

基于此,我们需要定义 web 所需要的数据结构,并提供 From/Into 等方法,方便和 pb 生成的数据结构进行转换。

代码相对简单,请直接在 git 上查看

博客首页 handler::index 的实现

获取分类列表

    let mut cate = state.cate.clone();
    let resp = cate
        .list_category(tonic::Request::new(ListCategoryRequest {
            name: None,
            is_del: Some(false),
        }))
        .await
        .map_err(|err| err.to_string())?;
    let reply = resp.into_inner();
    let mut cate_list: Vec<blog_types::Category> = Vec::with_capacity(reply.categories.len());
    for reply_cate in reply.categories {
        cate_list.push(reply_cate.into());
    }

我们通过调用分类服务的 list_category 方法来获取分类列表。为了共享 gRPC 客户端连接,我们将各种 gRPC 客户端连接通过 AppState 进行共享。

AppState 状态共享

model.rs中,我们定义了 AppState 以实现handler间的状态共享:

pub struct AppState {
    pub cate: CategoryServiceClient<tonic::transport::Channel>,
    pub topic: TopicServiceClient<tonic::transport::Channel>,
    pub tera: Tera,
}

impl AppState {
    pub fn new(
        cate: CategoryServiceClient<tonic::transport::Channel>,
        topic: TopicServiceClient<tonic::transport::Channel>,
        tera: Tera,
    ) -> Self {
        Self { cate, topic, tera }
    }
}
  • cate:连接到分类服务category-srv 的 gRPC 客户端
  • topic:连接到文章服务topic-srv的 gRPC 客户端
  • tera:模板引擎

获取文章列表

    let query_category_id = match params.category_id.clone() {
        Some(cid) => {
            if cid > 0 {
                Some(cid)
            } else {
                None
            }
        }
        None => None,
    };
    let mut tpc = state.topic.clone();
    let resp = tpc
        .list_topic(tonic::Request::new(ListTopicRequest {
            page: params.page.clone(),
            category_id: query_category_id,
            keyword: params.keyword.clone(),
            is_del: Some(false),
            dateline_range: None,
        }))
        .await
        .map_err(|err| err.to_string())?;
    let reply = resp.into_inner();
    let mut topic_list: Vec<blog_types::Topic> = Vec::with_capacity(reply.topics.capacity());
    for reply_topic in reply.topics {
        let mut t: blog_types::Topic = reply_topic.into();
        // 查找分类
        for cate in &cate_list {
            if cate.id == t.category_id {
                t.category_name = cate.name.clone();
                break;
            }
        }
        topic_list.push(t);
    }
    let paginate = blog_types::Paginate {
        page: reply.page,
        page_size: reply.page_size,
        page_totoal: reply.page_totoal,
        record_total: reply.record_total,
        data: topic_list,
    };

文章列表需要接收多个可选参数,我们对其进行定义,并通过 axum 的 Query 进行获取:

#[derive(Deserialize, Serialize)]
pub struct QueryParams {
    pub page: Option<i32>,
    pub category_id: Option<i32>,
    pub keyword: Option<String>,
}

为了方便在模板中组合成url参数,我们还定义了对应的结构体,并对其实现 From<QueryParams>,方便从获取到的参数转换成模板中所需要的参数:

#[derive(Deserialize, Serialize)]
pub struct QueryParamsForUrl {
    pub category_id: i32,
    pub keyword: String,
    pub page: i32,
}

impl From<QueryParams> for QueryParamsForUrl {
    fn from(p: QueryParams) -> Self {
        Self {
            category_id: match p.category_id {
                Some(cid) => cid,
                None => 0,
            },
            keyword: match p.keyword {
                Some(kw) => kw,
                None => "".to_string(),
            },
            page: p.page.unwrap_or(0),
        }
    }
}

请点击查看 handler::index()的完整代码。

博客文章详情 handler::detail 的实现

pub async fn detail(
    Extension(state): Extension<Arc<AppState>>,
    Path(id): Path<i64>,
) -> Result<Html<String>, String> {
    let mut ctx = Context::new();
    // 获取分类列表
    let mut cate = state.cate.clone();
    let resp = cate
        .list_category(tonic::Request::new(ListCategoryRequest {
            name: None,
            is_del: Some(false),
        }))
        .await
        .map_err(|err| err.to_string())?;
    let reply = resp.into_inner();
    let mut cate_list: Vec<blog_types::Category> = Vec::with_capacity(reply.categories.len());
    for reply_cate in reply.categories {
        cate_list.push(reply_cate.into());
    }
    ctx.insert("cate_list", &cate_list);

    // 获取文章详情
    let mut tpc = state.topic.clone();
    let resp = tpc
        .get_topic(tonic::Request::new(GetTopicRequest {
            id,
            inc_hit: Some(true),
            is_del: Some(false),
        }))
        .await
        .map_err(|err| err.to_string())?;
    let reply = resp.into_inner();

    let mut t: blog_types::Topic = match reply.topic {
        Some(topic) => topic.into(),
        None => {
            return Err("不存在的文章".to_string());
        }
    };
    // 查找分类
    for cate in &cate_list {
        if cate.id == t.category_id {
            t.category_name = cate.name.clone();
            break;
        }
    }
    ctx.insert("topic", &t);
    let out = state
        .tera
        .render("detail.html", &ctx)
        .map_err(|err| err.to_string())?;
    Ok(Html(out))
}

启动前台WEB服务

#[tokio::main]
async fn main() {
    let addr = "0.0.0.0:39527";

    let cate = CategoryServiceClient::connect("http://127.0.0.1:19527")
        .await
        .unwrap();
    let topic = TopicServiceClient::connect("http://127.0.0.1:29527")
        .await
        .unwrap();

    let tera = Tera::new("blog-frontend/templates/**/*.html").unwrap();

    let app = Router::new()
        .route("/", get(handler::index))
        .route("/detail/:id", get(handler::detail))
        .layer(Extension(Arc::new(model::AppState::new(cate, topic, tera))));
    axum::Server::bind(&addr.parse().unwrap())
        .serve(app.into_make_service())
        .await
        .unwrap();
}
  • 首先初始化AppState 所需要的 gRPC 客户端连接和模板引擎
  • 定义路由

本章代码位于04/实现前台服务