Skip to main content

sz_rust_core/
multi_app.rs

1//! 多应用调度器模块 — MultiAppDispatcher
2//!
3//! 对齐 PHP `auto_multi_app=true` + `app_map` + `domain_bind`。
4//!
5//! ## 功能
6//!
7//! - [`MultiAppDispatcher`]:注册多个应用 Router,按路径前缀或域名分发
8//! - [`AppEntry`]:单个应用条目(name + Router + 可选 domain)
9//!
10//! ## PHP 对齐
11//!
12//! | PHP 配置项 | 值 | Rust 行为 |
13//! |-----------|----|----------|
14//! | `auto_multi_app` | `true` | 启用多应用解析 |
15//! | `app_map` | `oapc/admin/api/farm/oapi/cashier/scene` | 路径前缀分发 |
16//! | `domain_bind` | `[]` | 域名绑定(默认为空,可通过 `register_with_domain` 注册) |
17//! | `deny_app_list` | `['common']` | 拒绝访问 `common`(在 [`router`](crate::router) 中处理) |
18//!
19//! ## 端口分配
20//!
21//! PHP 项目端口分配:
22//!
23//! | 应用 | 端口 |
24//! |------|------|
25//! | oapc | 8801 |
26//! | admin | 8802 |
27//! | api | 8803 |
28//! | cashier | 8804 |
29//! | scene | 8805 |
30//!
31//! 这些端口信息在 `config/app.yml` 中维护,调度器本身不直接绑定端口,
32//! 而是由 [`server::serve()`](crate::server::serve) 在启动时使用。
33//!
34//! ## 用法
35//!
36//! ```ignore
37//! use sz_rust_core::multi_app::MultiAppDispatcher;
38//!
39//! let mut dispatcher = MultiAppDispatcher::new();
40//! dispatcher.register("oapc", oapc_router);
41//! dispatcher.register("admin", admin_router);
42//!
43//! // 按路径查询应用
44//! assert_eq!(dispatcher.dispatch_by_path("/oapc/customer/index"), Some("oapc"));
45//!
46//! // 整合到主 Router
47//! let main_router = dispatcher.build();
48//! ```
49
50use std::collections::HashMap;
51
52use axum::Router;
53
54/// 单个应用条目
55#[derive(Debug)]
56pub struct AppEntry {
57    /// 应用名(如 `oapc` / `admin`)
58    pub name: String,
59    /// 应用的 axum::Router
60    pub router: Router,
61    /// 可选绑定的域名(对齐 PHP `domain_bind`)
62    pub domain: Option<String>,
63}
64
65/// 多应用调度器
66///
67/// 用于注册多个应用的 Router,并按路径前缀或域名分发。
68#[derive(Debug, Default)]
69pub struct MultiAppDispatcher {
70    /// 应用映射表(应用名 → AppEntry)
71    apps: HashMap<String, AppEntry>,
72}
73
74impl MultiAppDispatcher {
75    /// 创建空的调度器
76    pub fn new() -> Self {
77        Self::default()
78    }
79
80    /// 注册一个应用(无域名绑定)
81    ///
82    /// ## 参数
83    ///
84    /// - `name`:应用名(必须与 PHP `app_map` 中的 key 一致)
85    /// - `router`:该应用的 axum::Router
86    pub fn register(&mut self, name: impl Into<String>, router: Router) {
87        let name = name.into();
88        self.apps.insert(
89            name.clone(),
90            AppEntry {
91                name,
92                router,
93                domain: None,
94            },
95        );
96    }
97
98    /// 注册一个应用(带域名绑定)
99    ///
100    /// 对齐 PHP `domain_bind`。
101    ///
102    /// ## 参数
103    ///
104    /// - `name`:应用名
105    /// - `domain`:绑定的域名(如 `oapc.example.com`)
106    /// - `router`:该应用的 axum::Router
107    pub fn register_with_domain(
108        &mut self,
109        name: impl Into<String>,
110        domain: impl Into<String>,
111        router: Router,
112    ) {
113        let name = name.into();
114        self.apps.insert(
115            name.clone(),
116            AppEntry {
117                name,
118                router,
119                domain: Some(domain.into()),
120            },
121        );
122    }
123
124    /// 按路径前缀分发
125    ///
126    /// 解析 URI 的第一段(如 `/oapc/customer/index` → `oapc`),
127    /// 如果该段对应已注册的应用名,则返回应用名。
128    ///
129    /// ## 参数
130    ///
131    /// - `uri`:请求 URI(如 `/oapc/customer/index?id=1`)
132    ///
133    /// ## 返回
134    ///
135    /// - `Some(app_name)`:路径前缀匹配到已注册应用
136    /// - `None`:未匹配(应该走默认应用 `index`)
137    pub fn dispatch_by_path(&self, uri: &str) -> Option<&str> {
138        let path = uri.split('?').next().unwrap_or(uri);
139        let first = path
140            .trim_start_matches('/')
141            .split('/')
142            .next()
143            .filter(|s| !s.is_empty())?;
144
145        self.apps.get(first).map(|entry| entry.name.as_str())
146    }
147
148    /// 按域名分发
149    ///
150    /// 对齐 PHP `domain_bind`:如果请求的 Host 头匹配到某个应用的 domain,
151    /// 则返回该应用名。
152    ///
153    /// ## 参数
154    ///
155    /// - `host`:Host 头(如 `oapc.example.com:8801` 或 `oapc.example.com`)
156    ///
157    /// ## 返回
158    ///
159    /// - `Some(app_name)`:域名匹配到已注册应用
160    /// - `None`:未匹配
161    pub fn dispatch_by_domain(&self, host: &str) -> Option<&str> {
162        // 剥离端口号
163        let domain = host.split(':').next().unwrap_or(host);
164        self.apps
165            .values()
166            .find(|entry| entry.domain.as_deref() == Some(domain))
167            .map(|entry| entry.name.as_str())
168    }
169
170    /// 获取应用条目
171    pub fn get(&self, name: &str) -> Option<&AppEntry> {
172        self.apps.get(name)
173    }
174
175    /// 获取已注册应用数量
176    pub fn len(&self) -> usize {
177        self.apps.len()
178    }
179
180    /// 是否为空
181    pub fn is_empty(&self) -> bool {
182        self.apps.is_empty()
183    }
184
185    /// 列出所有应用名
186    pub fn app_names(&self) -> Vec<&str> {
187        self.apps.keys().map(|s| s.as_str()).collect()
188    }
189
190    /// 构建整合的主 Router
191    ///
192    /// 将每个应用的 Router 用 `axum::Router::nest` 嵌套到主 Router,
193    /// 路径前缀为 `/{app_name}`。
194    ///
195    /// ## 注意
196    ///
197    /// 域名绑定(`domain_bind`)需要在反向代理(Nginx)层处理,
198    /// 将不同域名的请求转发到不同的应用路径前缀。
199    pub fn build(&self) -> Router {
200        let mut main = Router::new();
201        for (name, entry) in &self.apps {
202            let prefix = format!("/{name}");
203            main = main.nest(&prefix, entry.router.clone());
204        }
205        main
206    }
207}
208
209#[cfg(test)]
210mod tests {
211    use super::*;
212    use axum::body::Body;
213    use axum::http::{Method, Request, StatusCode};
214    use http_body_util::BodyExt;
215    use tower::ServiceExt;
216
217    fn make_router(body: &'static str) -> Router {
218        Router::new().route("/", axum::routing::get(move || async move { body }))
219    }
220
221    #[test]
222    fn test_register_and_get() {
223        let mut dispatcher = MultiAppDispatcher::new();
224        dispatcher.register("oapc", make_router("oapc"));
225        assert_eq!(dispatcher.len(), 1);
226        assert!(!dispatcher.is_empty());
227        assert!(dispatcher.get("oapc").is_some());
228        assert!(dispatcher.get("admin").is_none());
229    }
230
231    #[test]
232    fn test_register_with_domain() {
233        let mut dispatcher = MultiAppDispatcher::new();
234        dispatcher.register_with_domain("oapc", "oapc.example.com", make_router("oapc"));
235        let entry = dispatcher.get("oapc").unwrap();
236        assert_eq!(entry.domain.as_deref(), Some("oapc.example.com"));
237    }
238
239    #[test]
240    fn test_dispatch_by_path_seven_apps() {
241        let mut dispatcher = MultiAppDispatcher::new();
242        for app in ["oapc", "admin", "api", "farm", "oapi", "cashier", "scene"] {
243            dispatcher.register(app, make_router("ok"));
244        }
245
246        for app in ["oapc", "admin", "api", "farm", "oapi", "cashier", "scene"] {
247            assert_eq!(
248                dispatcher.dispatch_by_path(&format!("/{app}/customer/index")),
249                Some(app)
250            );
251        }
252    }
253
254    #[test]
255    fn test_dispatch_by_path_with_query_string() {
256        let mut dispatcher = MultiAppDispatcher::new();
257        dispatcher.register("oapc", make_router("ok"));
258        assert_eq!(
259            dispatcher.dispatch_by_path("/oapc/customer/index?id=1&page=2"),
260            Some("oapc")
261        );
262    }
263
264    #[test]
265    fn test_dispatch_by_path_root_returns_none() {
266        let mut dispatcher = MultiAppDispatcher::new();
267        dispatcher.register("oapc", make_router("ok"));
268        assert_eq!(dispatcher.dispatch_by_path("/"), None);
269        assert_eq!(dispatcher.dispatch_by_path(""), None);
270    }
271
272    #[test]
273    fn test_dispatch_by_path_unknown_app_returns_none() {
274        let mut dispatcher = MultiAppDispatcher::new();
275        dispatcher.register("oapc", make_router("ok"));
276        assert_eq!(dispatcher.dispatch_by_path("/unknown/foo/bar"), None);
277        // 未注册的应用名 unknown 应当返回 None
278        assert_eq!(dispatcher.dispatch_by_path("/common/foo/bar"), None);
279    }
280
281    #[test]
282    fn test_dispatch_by_domain() {
283        let mut dispatcher = MultiAppDispatcher::new();
284        dispatcher.register_with_domain("oapc", "oapc.example.com", make_router("oapc"));
285        dispatcher.register_with_domain("admin", "admin.example.com", make_router("admin"));
286
287        assert_eq!(
288            dispatcher.dispatch_by_domain("oapc.example.com"),
289            Some("oapc")
290        );
291        assert_eq!(
292            dispatcher.dispatch_by_domain("admin.example.com"),
293            Some("admin")
294        );
295        assert_eq!(dispatcher.dispatch_by_domain("unknown.com"), None);
296    }
297
298    #[test]
299    fn test_dispatch_by_domain_with_port() {
300        let mut dispatcher = MultiAppDispatcher::new();
301        dispatcher.register_with_domain("oapc", "oapc.example.com", make_router("ok"));
302
303        // 端口应该被剥离
304        assert_eq!(
305            dispatcher.dispatch_by_domain("oapc.example.com:8801"),
306            Some("oapc")
307        );
308        assert_eq!(
309            dispatcher.dispatch_by_domain("oapc.example.com:8443"),
310            Some("oapc")
311        );
312    }
313
314    #[test]
315    fn test_app_names() {
316        let mut dispatcher = MultiAppDispatcher::new();
317        dispatcher.register("oapc", make_router("a"));
318        dispatcher.register("admin", make_router("b"));
319
320        let mut names = dispatcher.app_names();
321        names.sort();
322        assert_eq!(names, vec!["admin", "oapc"]);
323    }
324
325    #[tokio::test]
326    async fn test_build_nests_all_apps() {
327        let mut dispatcher = MultiAppDispatcher::new();
328        dispatcher.register("oapc", make_router("oapc body"));
329        dispatcher.register("admin", make_router("admin body"));
330
331        let router = dispatcher.build();
332
333        // GET /oapc → oapc body(子 Router 的 / 路由)
334        let req = Request::builder()
335            .method(Method::GET)
336            .uri("/oapc")
337            .body(Body::empty())
338            .unwrap();
339        let resp = router.clone().oneshot(req).await.unwrap();
340        assert_eq!(resp.status(), StatusCode::OK);
341        let bytes = resp.into_body().collect().await.unwrap().to_bytes();
342        assert_eq!(&bytes[..], b"oapc body");
343
344        // GET /admin → admin body
345        let req = Request::builder()
346            .method(Method::GET)
347            .uri("/admin")
348            .body(Body::empty())
349            .unwrap();
350        let resp = router.oneshot(req).await.unwrap();
351        assert_eq!(resp.status(), StatusCode::OK);
352        let bytes = resp.into_body().collect().await.unwrap().to_bytes();
353        assert_eq!(&bytes[..], b"admin body");
354    }
355
356    #[tokio::test]
357    async fn test_build_empty_dispatcher() {
358        let dispatcher = MultiAppDispatcher::new();
359        let router = dispatcher.build();
360        // 空调度器,所有请求返回 404
361        let req = Request::builder()
362            .method(Method::GET)
363            .uri("/oapc")
364            .body(Body::empty())
365            .unwrap();
366        let resp = router.oneshot(req).await.unwrap();
367        assert_eq!(resp.status(), StatusCode::NOT_FOUND);
368    }
369
370    #[tokio::test]
371    async fn test_build_with_seven_apps() {
372        let mut dispatcher = MultiAppDispatcher::new();
373        for app in ["oapc", "admin", "api", "farm", "oapi", "cashier", "scene"] {
374            let body_str = Box::leak(format!("{app} body").into_boxed_str());
375            dispatcher.register(app, make_router(body_str));
376        }
377        let router = dispatcher.build();
378
379        for app in ["oapc", "admin", "api", "farm", "oapi", "cashier", "scene"] {
380            let req = Request::builder()
381                .method(Method::GET)
382                .uri(format!("/{app}"))
383                .body(Body::empty())
384                .unwrap();
385            let resp = router.clone().oneshot(req).await.unwrap();
386            assert_eq!(resp.status(), StatusCode::OK);
387        }
388    }
389
390    #[test]
391    fn test_empty_dispatcher() {
392        let dispatcher = MultiAppDispatcher::new();
393        assert!(dispatcher.is_empty());
394        assert_eq!(dispatcher.len(), 0);
395        assert!(dispatcher.app_names().is_empty());
396    }
397}