Skip to content
扫码开始移动端阅读

如何在飞牛中使用统一网关

2999
14.995分钟

起因

之前写 leelaa-load-glb 飞牛插件的时候,我使用的是固定端口启动服务:

json
{
  "protocol": "http",
  "port": "5073",
  "url": "/"
}

这个方式比较容易理解,应用启动一个 HTTP 服务,飞牛再通过端口访问它。

但是这样做有一个问题:每个应用都要占一个端口,端口还可能和其他应用冲突。应用安装、卸载、权限控制,也都需要自己考虑。

后来开发 leelaa-load-pdf 的时候,我换成了飞牛的统一网关

这次应用不再监听 5073 之类的 TCP 端口,而是监听一个 Unix Socket,再由飞牛统一网关把请求转发到应用中。

最终的访问形式大概是这样:

text
/app/leelaa-pdfload

飞牛负责外部访问入口,应用只需要关心自己的路由。

统一网关到底做了什么

可以先看一下完整的请求链路:

传统方式是:

text
浏览器 -> 192.168.1.100:5073 -> 应用

统一网关方式是:

text
浏览器 -> 飞牛统一网关 -> Unix Socket -> 应用

所以它和 Nginx 反向代理有一点像,但这里不需要我们自己再部署 Nginx,也不需要给应用暴露一个固定端口。

先看项目结构

leelaa-load-pdf 是一个 Vue + Vite 前端,后端使用 Rust 编写,最后打包成飞牛的 .fpk 应用。

和统一网关相关的文件主要有这些:

text
fnConfig/
├── app/
│   └── ui/
│       └── config       # 飞牛应用入口配置
├── cmd/
│   └── main             # 应用启动脚本
└── manifest             # 应用基本信息

rust-api/
└── src/
    ├── config.rs        # 读取网关环境变量
    └── main.rs          # 监听 Unix Socket,并注册路由

这里最重要的是四个值:

配置示例作用
gatewaySocketleelaa-pdfload.sock告诉飞牛应用使用哪个 Socket
gatewayPrefix/app/leelaa-pdfload应用在统一网关下的路径前缀
FNNAS_GATEWAY_SOCKET/.../leelaa-pdfload.sock后端实际监听的 Socket 绝对路径
FNNAS_GATEWAY_PREFIX/app/leelaa-pdfload后端实际注册的路由前缀

gatewayPrefixFNNAS_GATEWAY_PREFIX 必须保持一致。

一、配置飞牛应用入口

打开项目中的 fnConfig/app/ui/config,这是一个 JSON 文件。

leelaa-load-pdf 中配置了三个入口:

json
{
  ".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"
      }
    }
  }
}

gatewaySocket

这里填写 Socket 的文件名:

json
"gatewaySocket": "leelaa-pdfload.sock"

注意,这里不是绝对路径。

飞牛通过这个名字找到应用的网关 Socket,应用启动脚本再把它转换成实际路径:

bash
GATEWAY_SOCKET="${TRIM_APPDEST}/leelaa-pdfload.sock"

gatewayPrefix

这是应用在统一网关中的路径前缀:

json
"gatewayPrefix": "/app/leelaa-pdfload"

它相当于应用的“根目录”。

如果页面地址是:

text
/app/leelaa-pdfload/reader

那么后端最终收到的请求路径,也应该能匹配到:

text
/app/leelaa-pdfload/reader

url

主入口配置为:

json
"url": "/app/leelaa-pdfload"

PDF 文件右键打开时,则使用另一个隐藏入口:

json
"url": "/app/leelaa-pdfload/reader"

fileTypes 表示这个入口只处理 PDF 文件。noDisplay: true 表示它不是一个需要单独显示在应用列表里的入口,而是给文件管理器调用的。

type 怎么选

  • url:通常会以独立页面打开。
  • iframe:通常会嵌入飞牛的应用窗口中。

我这里的首页使用 url,文件右键预览使用 iframe。如果你的应用不需要嵌入飞牛窗口,也可以全部使用 url

统一网关模式下,protocolport 不再是重点。leelaa-load-pdf 里使用的是:

json
"protocol": ""

也没有配置 port

二、启动脚本创建 Unix Socket

接下来是 fnConfig/cmd/main

飞牛安装应用后,会把应用释放到自己的目录中。启动脚本可以通过环境变量拿到应用目录,因此不要把路径写死。

核心配置如下:

bash
SERVER_ENTRY="${TRIM_APPDEST}/server/leelaa-pdfload-api"
GATEWAY_SOCKET="${TRIM_APPDEST}/leelaa-pdfload.sock"
FRONTEND_DIR="${TRIM_APPDEST}/ui"

这里分别对应:

  • Rust 后端可执行文件
  • Unix Socket 文件
  • 前端静态文件目录

启动进程时,把这几个值传给后端:

bash
(
    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 &

这三行是整个统一网关接入的关键:

bash
export FRONTEND_DIR="${FRONTEND_DIR}"
export FNNAS_GATEWAY_SOCKET="${GATEWAY_SOCKET}"
export FNNAS_GATEWAY_PREFIX="/app/leelaa-pdfload"

为什么要删除旧 Socket

应用重启时,旧的 Socket 文件可能还存在。

所以启动前先清理:

bash
rm -f "${GATEWAY_SOCKET}" 2>/dev/null || true

停止应用时也要清理:

bash
rm -f "${GATEWAY_SOCKET}" 2>/dev/null || true

否则后端再次启动时,可能会遇到:

text
Unix Socket 绑定失败

给 Socket 设置权限

Socket 创建之后,后端可以设置权限:

rust
fs::set_permissions(
    &socket_path,
    fs::Permissions::from_mode(0o660),
)?;

leelaa-load-pdf 使用应用自己的用户和用户组运行,并没有直接用 root 启动服务。

三、后端监听 Unix Socket

后端启动时读取飞牛传入的环境变量。

rust-api/src/config.rs 中的核心代码如下:

rust
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,
    }
}

然后在 main.rs 中绑定 Socket:

rust
let socket_path = cfg
    .gateway_socket
    .clone()
    .ok_or_else(|| anyhow::anyhow!("未配置 FNNAS_GATEWAY_SOCKET,服务无法启动"))?;

serve_unix_socket(app, socket_path).await?;

真正的监听代码:

rust
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);
        }
    });
}

这一步和普通的:

rust
axum::Server::bind(...)

不一样。统一网关模式下,应用不需要绑定 0.0.0.0:端口,而是绑定本地 Socket 文件。

四、让后端路由匹配网关前缀

只监听 Socket 还不够,后端还要注册正确的 URL。

leelaa-load-pdf 中的路由大概是这样:

rust
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),
    )
}

应用内部的实际路由:

rust
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)
}

最后得到的地址关系就是:

网关地址后端路由
/app/leelaa-pdfload/首页
/app/leelaa-pdfload/readerPDF 阅读页面
/app/leelaa-pdfload/health健康检查
/app/leelaa-pdfload/api/filePDF 文件接口

如果 gatewayPrefix 写成 /app/leelaa-pdfload,后端却只注册 /app/pdfload,网关能找到 Socket,但路由仍然会返回 404。

所以这里不能只改 fnConfig/app/ui/config,后端也必须同步修改。

五、前端构建时设置 base

这是最容易忘记的一步。

开发环境中,Vite 默认会认为应用运行在根路径:

text
/

但安装到飞牛之后,应用实际运行在:

text
/app/leelaa-pdfload/

所以 vite.config.js 中需要读取环境变量:

js
export default defineConfig({
  base: process.env.VITE_APP_BASE_URL || "/",
});

打包飞牛应用时设置:

json
{
  "scripts": {
    "build:fpk": "cross-env VITE_APP_BASE_URL=/app/leelaa-pdfload/ vite build && node scripts/build-all.js"
  }
}

注意末尾的 /

text
/app/leelaa-pdfload/

它会影响这些内容:

  • JavaScript 和 CSS 静态资源路径
  • Vue Router 的 history base
  • PDF.js worker 路径
  • CMap 字体文件路径
  • 前端请求 /api/file 时的实际地址

Vue Router 也要使用同一份 base:

js
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')
    }
  ]
})

前端请求接口时,也不要把 /api/file 写死成绝对根路径:

js
const base = import.meta.env.BASE_URL || '/'
const apiBase = base === '/' ? '' : base.replace(/\/$/, '')

const pdfUrl = `${apiBase}/api/file?path=${encodeURIComponent(path)}`

在飞牛中,它最终应该请求:

text
/app/leelaa-pdfload/api/file

而不是:

text
/api/file

六、打包时前后端放到正确的位置

统一网关配置正确,但如果文件没有打包到正确目录,应用仍然启动不了。

最终的应用结构应该类似这样:

text
release/
├── app/
│   ├── server/
│   │   └── leelaa-pdfload-api
│   └── ui/
│       ├── index.html
│       ├── assets/
│       └── ...
├── cmd/
│   └── main
├── config/
│   ├── privilege
│   └── resource
└── manifest

其中:

  • 后端二进制放到 app/server
  • 前端构建产物放到 app/ui
  • 飞牛入口配置放到 app/ui/config(该文件位于前端目录中)
  • 启动脚本放到 cmd/main

我在 build-all.js 中做的事情,本质上就是:

  1. 构建前端。
  2. 把前端复制到 release/app/ui
  3. 编译 Rust 后端。
  4. 把后端复制到 release/app/server
  5. 复制 fnConfig 中的飞牛配置。
  6. 使用 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

如果前端构建时没有设置:

bash
VITE_APP_BASE_URL=/app/leelaa-pdfload/

浏览器会去根路径查找:

text
/assets/index.js

但统一网关下正确路径应该是:

text
/app/leelaa-pdfload/assets/index.js

2. 网关返回 404

检查这两个配置是否完全一致:

text
fnConfig/app/ui/config
gatewayPrefix

FNNAS_GATEWAY_PREFIX

还要检查后端路由是否真的挂载到了这个前缀下面。

3. 日志提示 Socket 绑定失败

常见原因有三个:

  • 旧 Socket 没有删除。
  • Socket 的父目录不存在。
  • 当前运行用户没有创建或访问 Socket 的权限。

启动前清理旧文件,并确保后端有创建父目录的逻辑:

rust
if let Some(parent) = socket_path.parent() {
    tokio::fs::create_dir_all(parent).await?;
}

4. 首页正常,点开 /reader 后失败

这通常是前端路由和后端回退处理没有对上。

后端至少要能返回 index.html

rust
.route("/reader", get(index_handler(frontend_dir.clone())))

同时,前端 Router 的 base 必须和应用前缀一致。

5. PDF 接口请求成了 /api/file

说明前端把 API 当成了根路径。

不要直接这样写:

js
fetch('/api/file')

应该拼接 import.meta.env.BASE_URL

js
const base = import.meta.env.BASE_URL || '/'
fetch(`${base.replace(/\/$/, '')}/api/file`)

6. 为什么不直接使用固定端口

固定端口当然也能工作,但它不属于统一网关模式。

如果你的应用只是自己临时使用,固定端口比较简单;如果希望应用更像一个完整的飞牛应用,尤其是要和文件管理器、应用窗口、权限体系结合,使用统一网关会更合适。

写在最后

这次 leelaa-load-pdf 接入统一网关,真正让我理解的不是多了一个 gatewaySocket 配置,而是:

飞牛应用的前端路径、入口配置、启动脚本、后端监听方式,本质上是一整条链路。

只改其中一个地方,通常都不能正常工作。

最后再把最核心的配置缩成一张图:

text
应用入口配置
  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/

这四处对齐之后,应用就能通过飞牛统一网关正常访问了。

如果你也准备开发飞牛应用,建议一开始就把这些路径规划好,不要等到应用已经写完、打包完成之后,才发现所有请求都少了一层 /app/应用名

参考