如何在飞牛中使用统一网关
起因
之前写 leelaa-load-glb 飞牛插件的时候,我使用的是固定端口启动服务:
{
"protocol": "http",
"port": "5073",
"url": "/"
}2
3
4
5
这个方式比较容易理解,应用启动一个 HTTP 服务,飞牛再通过端口访问它。
但是这样做有一个问题:每个应用都要占一个端口,端口还可能和其他应用冲突。应用安装、卸载、权限控制,也都需要自己考虑。
后来开发 leelaa-load-pdf 的时候,我换成了飞牛的统一网关。
这次应用不再监听 5073 之类的 TCP 端口,而是监听一个 Unix Socket,再由飞牛统一网关把请求转发到应用中。
最终的访问形式大概是这样:
/app/leelaa-pdfload飞牛负责外部访问入口,应用只需要关心自己的路由。
统一网关到底做了什么
可以先看一下完整的请求链路:
graph LR
A[浏览器或飞牛文件管理器] --> B["飞牛统一网关 /app/leelaa-pdfload"]
B --> C["Unix Socket leelaa-pdfload.sock"]
C --> D[Rust 后端服务]
D --> E[前端静态文件]
D --> F["/api/file PDF 文件接口"]
传统方式是:
浏览器 -> 192.168.1.100:5073 -> 应用统一网关方式是:
浏览器 -> 飞牛统一网关 -> Unix Socket -> 应用所以它和 Nginx 反向代理有一点像,但这里不需要我们自己再部署 Nginx,也不需要给应用暴露一个固定端口。
先看项目结构
leelaa-load-pdf 是一个 Vue + Vite 前端,后端使用 Rust 编写,最后打包成飞牛的 .fpk 应用。
和统一网关相关的文件主要有这些:
fnConfig/
├── app/
│ └── ui/
│ └── config # 飞牛应用入口配置
├── cmd/
│ └── main # 应用启动脚本
└── manifest # 应用基本信息
rust-api/
└── src/
├── config.rs # 读取网关环境变量
└── main.rs # 监听 Unix Socket,并注册路由2
3
4
5
6
7
8
9
10
11
12
这里最重要的是四个值:
| 配置 | 示例 | 作用 |
|---|---|---|
gatewaySocket | leelaa-pdfload.sock | 告诉飞牛应用使用哪个 Socket |
gatewayPrefix | /app/leelaa-pdfload | 应用在统一网关下的路径前缀 |
FNNAS_GATEWAY_SOCKET | /.../leelaa-pdfload.sock | 后端实际监听的 Socket 绝对路径 |
FNNAS_GATEWAY_PREFIX | /app/leelaa-pdfload | 后端实际注册的路由前缀 |
gatewayPrefix 和 FNNAS_GATEWAY_PREFIX 必须保持一致。
一、配置飞牛应用入口
打开项目中的 fnConfig/app/ui/config,这是一个 JSON 文件。
leelaa-load-pdf 中配置了三个入口:
{
".url": {
"leelaa.pdfload.Application": {
"title": "PDF 阅读器",
"icon": "images/icon_{0}.png",
"type": "url",
"protocol": "",
"gatewaySocket": "leelaa-pdfload.sock",
"gatewayPrefix": "/app/leelaa-pdfload",
"url": "/app/leelaa-pdfload",
"allUsers": true
},
"leelaa.pdfload.viewer": {
"title": "PDF 阅读器(窗口)",
"icon": "images/icon_{0}.png",
"type": "iframe",
"protocol": "",
"gatewaySocket": "leelaa-pdfload.sock",
"gatewayPrefix": "/app/leelaa-pdfload",
"url": "/app/leelaa-pdfload/reader",
"allUsers": true,
"fileTypes": [
"pdf",
"PDF"
],
"noDisplay": true,
"control": {
"accessPerm": "readonly",
"pathPerm": "readonly"
}
}
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
gatewaySocket
这里填写 Socket 的文件名:
"gatewaySocket": "leelaa-pdfload.sock"注意,这里不是绝对路径。
飞牛通过这个名字找到应用的网关 Socket,应用启动脚本再把它转换成实际路径:
GATEWAY_SOCKET="${TRIM_APPDEST}/leelaa-pdfload.sock"gatewayPrefix
这是应用在统一网关中的路径前缀:
"gatewayPrefix": "/app/leelaa-pdfload"它相当于应用的“根目录”。
如果页面地址是:
/app/leelaa-pdfload/reader那么后端最终收到的请求路径,也应该能匹配到:
/app/leelaa-pdfload/readerurl
主入口配置为:
"url": "/app/leelaa-pdfload"PDF 文件右键打开时,则使用另一个隐藏入口:
"url": "/app/leelaa-pdfload/reader"fileTypes 表示这个入口只处理 PDF 文件。noDisplay: true 表示它不是一个需要单独显示在应用列表里的入口,而是给文件管理器调用的。
type 怎么选
url:通常会以独立页面打开。iframe:通常会嵌入飞牛的应用窗口中。
我这里的首页使用 url,文件右键预览使用 iframe。如果你的应用不需要嵌入飞牛窗口,也可以全部使用 url。
统一网关模式下,protocol 和 port 不再是重点。leelaa-load-pdf 里使用的是:
"protocol": ""也没有配置 port。
二、启动脚本创建 Unix Socket
接下来是 fnConfig/cmd/main。
飞牛安装应用后,会把应用释放到自己的目录中。启动脚本可以通过环境变量拿到应用目录,因此不要把路径写死。
核心配置如下:
SERVER_ENTRY="${TRIM_APPDEST}/server/leelaa-pdfload-api"
GATEWAY_SOCKET="${TRIM_APPDEST}/leelaa-pdfload.sock"
FRONTEND_DIR="${TRIM_APPDEST}/ui"2
3
这里分别对应:
- Rust 后端可执行文件
- Unix Socket 文件
- 前端静态文件目录
启动进程时,把这几个值传给后端:
(
export FRONTEND_DIR="${FRONTEND_DIR}"
export FNNAS_GATEWAY_SOCKET="${GATEWAY_SOCKET}"
export FNNAS_GATEWAY_PREFIX="/app/leelaa-pdfload"
exec "${SERVER_ENTRY}"
) >> "${LOG_FILE}" 2>&1 &2
3
4
5
6
这三行是整个统一网关接入的关键:
export FRONTEND_DIR="${FRONTEND_DIR}"
export FNNAS_GATEWAY_SOCKET="${GATEWAY_SOCKET}"
export FNNAS_GATEWAY_PREFIX="/app/leelaa-pdfload"2
3
为什么要删除旧 Socket
应用重启时,旧的 Socket 文件可能还存在。
所以启动前先清理:
rm -f "${GATEWAY_SOCKET}" 2>/dev/null || true停止应用时也要清理:
rm -f "${GATEWAY_SOCKET}" 2>/dev/null || true否则后端再次启动时,可能会遇到:
Unix Socket 绑定失败给 Socket 设置权限
Socket 创建之后,后端可以设置权限:
fs::set_permissions(
&socket_path,
fs::Permissions::from_mode(0o660),
)?;2
3
4
leelaa-load-pdf 使用应用自己的用户和用户组运行,并没有直接用 root 启动服务。
三、后端监听 Unix Socket
后端启动时读取飞牛传入的环境变量。
rust-api/src/config.rs 中的核心代码如下:
pub const DEFAULT_GATEWAY_PREFIX: &str = "/app/leelaa-pdfload";
pub fn load_config() -> AppConfig {
let frontend_dir = first_env(["FRONTEND_DIR"])
.map(PathBuf::from)
.unwrap_or_else(default_frontend_dir);
let gateway_socket =
first_env(["FNNAS_GATEWAY_SOCKET", "LEELAA_GATEWAY_SOCKET"])
.map(PathBuf::from);
let gateway_prefix =
first_env(["FNNAS_GATEWAY_PREFIX"])
.unwrap_or_else(|| DEFAULT_GATEWAY_PREFIX.to_string());
AppConfig {
frontend_dir,
gateway_socket,
gateway_prefix,
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
然后在 main.rs 中绑定 Socket:
let socket_path = cfg
.gateway_socket
.clone()
.ok_or_else(|| anyhow::anyhow!("未配置 FNNAS_GATEWAY_SOCKET,服务无法启动"))?;
serve_unix_socket(app, socket_path).await?;2
3
4
5
6
真正的监听代码:
let listener = UnixListener::bind(&socket_path)?;
loop {
let (stream, _) = listener.accept().await?;
let service = service.clone();
tokio::spawn(async move {
let io = TokioIo::new(stream);
let builder = AutoBuilder::new(TokioExecutor::new());
if let Err(err) = builder
.serve_connection_with_upgrades(io, service)
.await
{
tracing::warn!("Unix Socket 连接处理失败: {}", err);
}
});
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
这一步和普通的:
axum::Server::bind(...)不一样。统一网关模式下,应用不需要绑定 0.0.0.0:端口,而是绑定本地 Socket 文件。
四、让后端路由匹配网关前缀
只监听 Socket 还不够,后端还要注册正确的 URL。
leelaa-load-pdf 中的路由大概是这样:
fn build_app(
state: Arc<PdfState>,
frontend_dir: &PathBuf,
gateway_prefix: &str,
limiter: RateLimiter,
) -> Router {
let root = app_routes(
state.clone(),
frontend_dir,
"/",
limiter.clone(),
);
let prefix_root = gateway_prefix.trim_end_matches('/');
root.route(
&format!("{}/", prefix_root),
get(index_handler(frontend_dir.clone())),
)
.nest(
gateway_prefix,
app_routes(state, frontend_dir, "/", limiter),
)
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
应用内部的实际路由:
fn app_routes(
state: Arc<PdfState>,
frontend_dir: &PathBuf,
frontend_mount: &str,
limiter: RateLimiter,
) -> Router {
let api = Router::new()
.route("/file", get(file).post(method_not_allowed))
.with_state(state);
Router::new()
.route("/health", get(|| async { "OK" }))
.route("/", get(index_handler(frontend_dir.clone())))
.route("/reader", get(index_handler(frontend_dir.clone())))
.nest("/api", api)
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
最后得到的地址关系就是:
| 网关地址 | 后端路由 |
|---|---|
/app/leelaa-pdfload/ | 首页 |
/app/leelaa-pdfload/reader | PDF 阅读页面 |
/app/leelaa-pdfload/health | 健康检查 |
/app/leelaa-pdfload/api/file | PDF 文件接口 |
如果 gatewayPrefix 写成 /app/leelaa-pdfload,后端却只注册 /app/pdfload,网关能找到 Socket,但路由仍然会返回 404。
所以这里不能只改 fnConfig/app/ui/config,后端也必须同步修改。
五、前端构建时设置 base
这是最容易忘记的一步。
开发环境中,Vite 默认会认为应用运行在根路径:
/但安装到飞牛之后,应用实际运行在:
/app/leelaa-pdfload/所以 vite.config.js 中需要读取环境变量:
export default defineConfig({
base: process.env.VITE_APP_BASE_URL || "/",
});2
3
打包飞牛应用时设置:
{
"scripts": {
"build:fpk": "cross-env VITE_APP_BASE_URL=/app/leelaa-pdfload/ vite build && node scripts/build-all.js"
}
}2
3
4
5
注意末尾的 /:
/app/leelaa-pdfload/它会影响这些内容:
- JavaScript 和 CSS 静态资源路径
- Vue Router 的 history base
- PDF.js worker 路径
- CMap 字体文件路径
- 前端请求
/api/file时的实际地址
Vue Router 也要使用同一份 base:
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
{
path: '/',
name: 'home',
component: HomeView
},
{
path: '/reader',
name: 'reader',
component: () => import('../views/ReaderView.vue')
}
]
})2
3
4
5
6
7
8
9
10
11
12
13
14
15
前端请求接口时,也不要把 /api/file 写死成绝对根路径:
const base = import.meta.env.BASE_URL || '/'
const apiBase = base === '/' ? '' : base.replace(/\/$/, '')
const pdfUrl = `${apiBase}/api/file?path=${encodeURIComponent(path)}`2
3
4
在飞牛中,它最终应该请求:
/app/leelaa-pdfload/api/file而不是:
/api/file六、打包时前后端放到正确的位置
统一网关配置正确,但如果文件没有打包到正确目录,应用仍然启动不了。
最终的应用结构应该类似这样:
release/
├── app/
│ ├── server/
│ │ └── leelaa-pdfload-api
│ └── ui/
│ ├── index.html
│ ├── assets/
│ └── ...
├── cmd/
│ └── main
├── config/
│ ├── privilege
│ └── resource
└── manifest2
3
4
5
6
7
8
9
10
11
12
13
14
其中:
- 后端二进制放到
app/server - 前端构建产物放到
app/ui - 飞牛入口配置放到
app/ui/config(该文件位于前端目录中) - 启动脚本放到
cmd/main
我在 build-all.js 中做的事情,本质上就是:
- 构建前端。
- 把前端复制到
release/app/ui。 - 编译 Rust 后端。
- 把后端复制到
release/app/server。 - 复制
fnConfig中的飞牛配置。 - 使用
fnpack build打包成.fpk。
七、完整配置检查清单
在打包之前,可以按下面的清单检查一次:
入口配置
gatewaySocket是否填写了 Socket 文件名。gatewayPrefix是否为/app/你的应用名。url是否和gatewayPrefix对得上。- 是否误配置了不需要的
port。
启动脚本
GATEWAY_SOCKET是否指向TRIM_APPDEST下的 Socket。- 启动前是否删除了旧 Socket。
- 是否导出了
FNNAS_GATEWAY_SOCKET。 - 是否导出了
FNNAS_GATEWAY_PREFIX。 - 后端和前端路径是否存在。
后端
- 是否使用
UnixListener。 - 是否能从环境变量读取 Socket 路径。
gateway_prefix是否和入口配置一致。- 是否注册了首页、静态资源、API 等路由。
- Socket 权限是否允许飞牛网关访问。
前端
- Vite 的
base是否设置为/app/你的应用名/。 - Vue Router 是否使用
import.meta.env.BASE_URL。 - API 请求是否带上应用前缀。
- worker、字体、图片等静态资源是否使用 base URL。
常见问题
1. 应用能启动,但是页面白屏
优先检查 Vite 的 base。
如果前端构建时没有设置:
VITE_APP_BASE_URL=/app/leelaa-pdfload/浏览器会去根路径查找:
/assets/index.js但统一网关下正确路径应该是:
/app/leelaa-pdfload/assets/index.js2. 网关返回 404
检查这两个配置是否完全一致:
fnConfig/app/ui/config
gatewayPrefix
FNNAS_GATEWAY_PREFIX2
3
4
还要检查后端路由是否真的挂载到了这个前缀下面。
3. 日志提示 Socket 绑定失败
常见原因有三个:
- 旧 Socket 没有删除。
- Socket 的父目录不存在。
- 当前运行用户没有创建或访问 Socket 的权限。
启动前清理旧文件,并确保后端有创建父目录的逻辑:
if let Some(parent) = socket_path.parent() {
tokio::fs::create_dir_all(parent).await?;
}2
3
4. 首页正常,点开 /reader 后失败
这通常是前端路由和后端回退处理没有对上。
后端至少要能返回 index.html:
.route("/reader", get(index_handler(frontend_dir.clone())))同时,前端 Router 的 base 必须和应用前缀一致。
5. PDF 接口请求成了 /api/file
说明前端把 API 当成了根路径。
不要直接这样写:
fetch('/api/file')应该拼接 import.meta.env.BASE_URL:
const base = import.meta.env.BASE_URL || '/'
fetch(`${base.replace(/\/$/, '')}/api/file`)2
6. 为什么不直接使用固定端口
固定端口当然也能工作,但它不属于统一网关模式。
如果你的应用只是自己临时使用,固定端口比较简单;如果希望应用更像一个完整的飞牛应用,尤其是要和文件管理器、应用窗口、权限体系结合,使用统一网关会更合适。
写在最后
这次 leelaa-load-pdf 接入统一网关,真正让我理解的不是多了一个 gatewaySocket 配置,而是:
飞牛应用的前端路径、入口配置、启动脚本、后端监听方式,本质上是一整条链路。
只改其中一个地方,通常都不能正常工作。
最后再把最核心的配置缩成一张图:
应用入口配置
gatewaySocket = leelaa-pdfload.sock
gatewayPrefix = /app/leelaa-pdfload
│
▼
启动脚本
FNNAS_GATEWAY_SOCKET=/应用目录/leelaa-pdfload.sock
FNNAS_GATEWAY_PREFIX=/app/leelaa-pdfload
│
▼
Rust 后端
UnixListener::bind(socket)
nest("/app/leelaa-pdfload", routes)
│
▼
前端构建
VITE_APP_BASE_URL=/app/leelaa-pdfload/2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
这四处对齐之后,应用就能通过飞牛统一网关正常访问了。
如果你也准备开发飞牛应用,建议一开始就把这些路径规划好,不要等到应用已经写完、打包完成之后,才发现所有请求都少了一层 /app/应用名。
