大家新年好,看春晚无聊,写一篇帖子介绍一下我们是如何给Halo配置域名二级目录(example.com/post)。
一、背景与问题
已有域名 example.com,主站运行其他业务。希望将 Halo 博客系统部署在 example.com/post 子路径下,而非单独的子域名。
核心难点
Halo(基于 Spring WebFlux)默认假设部署在域名根路径 /。当 Nginx 以子路径反向代理时:
| 问题 | 具体表现 |
| 静态资源 404 | HTML 中引用 /themes/xxx/style.css,实际应为 /post/themes/xxx/style.css |
| API 调用失败 | 前端 SPA 发送 fetch('/apis/...'),缺少 /post 前缀 |
| 302 重定向错误 | 服务端返回 Location: /login,而非 /post/login |
| 控制台 SPA 路由失败 | Vue Router 的 createWebHistory('/console/') 缺少子路径 |
| WebSocket/SSE 连接失败 | 连接地址 /ws 应为 /post/ws |
| 页面导航错误 | window.location.href = "/uc" 跳转到根路径而非子路径下 |
二、整体架构
2.1 方案选型
我们选择了开发 Halo 插件的方式,利用 Halo 的 AdditionalWebFilter 扩展点,在请求/响应层面完成所有路径改写。无需修改 Halo 前端代码或 Nginx 配置。
2.2 架构图
浏览器请求 https://example.com/post/console/dashboard
│
▼
Nginx: location /post/ → proxy_pass http://halo:8090 (保留 /post 前缀)
│
▼
┌─────────────────────────────────────────────────────┐
│ Halo 服务端 │
│ │
│ ① SubPathRequestFilter (HIGHEST_PRECEDENCE) │
│ 剥离前缀: /post/console/dashboard → /console/... │
│ 对 JS/CSS 设置 Accept-Encoding: identity │
│ │
│ ② Halo 正常处理请求,返回 HTML/JS/CSS │
│ │
│ ③ SubPathResponseFilter (LOWEST_PRECEDENCE - 1000) │
│ · HTML 属性重写: href/src → 加 /post 前缀 │
│ · JS/CSS 内容重写: /console/ → /post/console/ │
│ · 注入 Bridge Script(拦截客户端 API 调用) │
│ · Location 响应头重写: /login → /post/login │
│ │
└─────────────────────────────────────────────────────┘
│
▼
浏览器收到正确的 HTML,所有资源 /post/xxx 正常加载
│
▼
Bridge Script 持续拦截 SPA 中的运行时调用:
fetch('/apis/xxx') → fetch('/post/apis/xxx')
new WebSocket('/ws') → new WebSocket('/post/ws')
script.src = '/console/' → '/post/console/'
location.href = '/uc' → '/post/uc'
2.3 修改范围总览
| 修改类型 | 文件 | 作用 |
| 新增插件 | SubPathRequestFilter.java | 请求阶段剥离子路径前缀 |
| 新增插件 | SubPathResponseFilter.java | 响应阶段路径重写 + Bridge Script 注入 |
| 新增插件 | SubPathPlugin.java | 插件入口类 |
| 新增插件 | plugin.yaml / settings.yaml | 插件元数据与配置表单 |
| 新增插件 | plugin-components.idx | Spring Bean 索引 |
| 修改源码 | SpringComponentsFinder.java | 修复 Docker 环境下插件索引读取失败的 BUG |
三、插件实现详解
3.1 项目结构
halo-plugin-subpath/
├── build.gradle
├── src/main/java/run/halo/subpath/
│ ├── SubPathPlugin.java # 插件入口
│ ├── SubPathRequestFilter.java # 请求过滤器
│ └── SubPathResponseFilter.java # 响应过滤器
└── src/main/resources/
├── META-INF/
│ ├── MANIFEST.MF # PF4J 插件描述
│ └── plugin-components.idx # Spring Bean 索引(关键)
├── plugin.yaml # 插件元数据
└── extensions/
└── settings.yaml # 配置表单
3.2 请求过滤器 — SubPathRequestFilter.java
优先级:Ordered.HIGHEST_PRECEDENCE(最先执行)
核心逻辑:
@Override
public Mono<Void> filter(ServerWebExchange exchange, WebFilterChain chain) {
String originalPath = exchange.getRequest().getURI().getPath();
// 从插件配置读取子路径(如 /post)
return settingFetcher.get("basic").map(/* 解析 subPath */)
.flatMap(subPath -> {
// 如果请求以子路径开头,则剥离前缀
if (originalPath.startsWith(subPath + "/") || originalPath.equals(subPath)) {
String newPath = originalPath.substring(subPath.length());
if (newPath.isEmpty()) newPath = "/";
ServerHttpRequest.Builder reqBuilder =
exchange.getRequest().mutate().path(newPath);
// 对控制台/UC 的 JS/CSS 资源,强制请求未压缩内容
// 以便响应过滤器进行字符串替换
if (newPath.startsWith("/console/assets/")
|| newPath.startsWith("/uc/assets/")) {
reqBuilder.headers(h -> h.set("Accept-Encoding", "identity"));
}
return chain.filter(
exchange.mutate().request(reqBuilder.build()).build());
}
return chain.filter(exchange);
});
}
关键设计:
- 为什么设置
Accept-Encoding: identity:Halo 默认对 JS/CSS 使用 gzip 压缩,压缩后的二进制数据无法进行字符串替换。必须在请求阶段强制后端返回原始文本。
3.3 响应过滤器 — SubPathResponseFilter.java
优先级:Ordered.LOWEST_PRECEDENCE - 1000(较后执行)
响应过滤器是整个方案的核心,分三层处理:
第一层:HTML 属性重写
使用 Jsoup 解析 HTML,为所有绝对路径属性添加子路径前缀:
private String rewriteHtmlLinks(String html, String subPath) {
Document doc = Jsoup.parse(html);
// 重写所有资源引用
for (Element el : doc.select("link[href]")) rewriteAttr(el, "href", subPath);
for (Element el : doc.select("script[src]")) rewriteAttr(el, "src", subPath);
for (Element el : doc.select("img[src]")) rewriteAttr(el, "src", subPath);
for (Element el : doc.select("a[href]")) rewriteAttr(el, "href", subPath);
for (Element el : doc.select("form[action]")) rewriteAttr(el, "action", subPath);
// ... video[src], video[poster], source[src], base[href] 等
// 注入 <base href="/post/"> 标签
if (doc.select("base").isEmpty()) {
doc.head().prependElement("base").attr("href", subPath + "/");
}
// 对控制台/UC 页面注入 Bridge Script
if (isConsoleHtml(doc)) {
injectSubPathBridgeScript(doc, subPath);
}
return doc.html();
}
// 判断是否需要重写
private boolean shouldRewrite(String url, String subPath) {
return url.startsWith("/") // 以 / 开头的绝对路径
&& !url.startsWith("//") // 排除协议相对 URL
&& !url.startsWith(subPath + "/") // 排除已有前缀的
&& !url.equals(subPath);
}
关键设计:
- 为什么同时覆写
writeWith() 和 writeAndFlushWith():Halo 使用 Thymeleaf 模板引擎,Thymeleaf 通过 writeAndFlushWith() 输出 HTML,而非常规的 writeWith()。必须两个方法都覆写才能拦截所有 HTML 响应。
第二层:JS/CSS 内容重写
Vite 打包的控制台 JS 模块中有硬编码路径,服务端 HTML 重写无法覆盖:
private String rewriteConsoleAssetContent(String content, String subPath) {
return content
.replace("/console/", subPath + "/console/") // /console/ → /post/console/
.replace("/uc/", subPath + "/uc/"); // /uc/ → /post/uc/
}
仅对 /console/assets/ 和 /uc/assets/ 路径的 JS/CSS 文件生效。
第三层:Bridge Script 注入
这是最关键的部分。控制台和个人中心是 Vue SPA 应用,大量 URL 在运行时通过 JavaScript 动态生成。Bridge Script 注入到 HTML 的 <head> 最前面,拦截所有客户端 API 调用。
拦截清单:
| 拦截目标 | 方式 | 解决的问题 |
HTMLScriptElement.prototype.src | 属性拦截器 | 动态加载的 JS 文件路径 |
HTMLLinkElement.prototype.href | 属性拦截器 | 动态加载的 CSS 文件路径 |
HTMLImageElement.prototype.src | 属性拦截器 | 动态加载的图片路径 |
HTMLAnchorElement.prototype.href | 属性拦截器 | Vue 动态绑定的链接 |
Element.prototype.setAttribute | 方法补丁 | Vue 3 通过 setAttribute 设置动态属性 |
window.fetch | 方法补丁 | API 调用 |
XMLHttpRequest.prototype.open | 方法补丁 | XHR 请求 |
window.WebSocket | 构造函数替换 | WebSocket 连接 |
window.EventSource | 构造函数替换 | SSE 连接 |
window.VueRouter | defineProperty | Vue Router 的 createWebHistory |
window.open | 方法补丁 | 新窗口打开 |
navigation 事件 | Navigation API | window.location.href 等全页面导航 |
核心重写函数:
(function(){
var SP = '/post'; // 子路径
var OG = location.origin; // 当前域名
// 判断路径是否需要重写
function needsRewrite(u) {
return typeof u === 'string'
&& u.charAt(0) === '/' // 以 / 开头
&& u.charAt(1) !== '/' // 不是 //(协议相对)
&& u.indexOf(SP + '/') !== 0 // 还没有子路径前缀
&& u !== SP;
}
// 支持相对路径和完整 URL 的通用重写函数
function rwAny(u) {
if (typeof u !== 'string') return u;
if (needsRewrite(u)) return SP + u;
try {
var p = new URL(u, OG);
if (p.origin === OG && needsRewrite(p.pathname)) {
p.pathname = SP + p.pathname;
return p.toString();
}
} catch(e) {}
return u;
}
// 拦截 DOM 属性 setter(同步,浏览器发请求之前就能修改)
function patchProp(proto, prop) {
var d = Object.getOwnPropertyDescriptor(proto, prop);
if (!d || !d.set) return;
Object.defineProperty(proto, prop, {
set: function(v) { d.set.call(this, rwAny(v)); },
get: d.get, configurable: true, enumerable: true
});
}
patchProp(HTMLScriptElement.prototype, 'src');
patchProp(HTMLLinkElement.prototype, 'href');
patchProp(HTMLImageElement.prototype, 'src');
patchProp(HTMLAnchorElement.prototype, 'href');
// 拦截 setAttribute(Vue 3 使用此方法绑定动态属性)
var oSetAttr = Element.prototype.setAttribute;
Element.prototype.setAttribute = function(n, v) {
if ((n === 'href' || n === 'src' || n === 'action') && typeof v === 'string') {
v = rwAny(v);
}
return oSetAttr.call(this, n, v);
};
// 拦截 fetch API
var oFetch = window.fetch;
window.fetch = function(i, init) {
if (typeof i === 'string') i = rwAny(i);
return oFetch.call(this, i, init);
};
// 拦截 XMLHttpRequest
var oOpen = XMLHttpRequest.prototype.open;
XMLHttpRequest.prototype.open = function(m, u) {
if (typeof u === 'string') u = rwAny(u);
return oOpen.apply(this, [m, u, ...Array.from(arguments).slice(2)]);
};
// 拦截 WebSocket
var OWS = window.WebSocket;
function PatchedWS(url, protocols) {
if (typeof url === 'string') url = rwAny(url);
return protocols !== undefined ? new OWS(url, protocols) : new OWS(url);
}
PatchedWS.prototype = OWS.prototype;
window.WebSocket = PatchedWS;
// 拦截 Vue Router 的 createWebHistory
var _VR;
Object.defineProperty(window, 'VueRouter', {
get: function() { return _VR; },
set: function(v) {
_VR = v;
if (v && v.createWebHistory) {
var orig = v.createWebHistory;
v.createWebHistory = function(base) {
if (base && typeof base === 'string' && base.indexOf(SP) !== 0) {
base = SP + base;
}
return orig.call(this, base);
};
}
},
configurable: true, enumerable: true
});
// 拦截 window.location.href 赋值等全页面导航(Navigation API)
// Chrome 105+, Edge 105+, Firefox 128+
if (window.navigation) {
navigation.addEventListener('navigate', function(e) {
try {
var dest = new URL(e.destination.url);
if (dest.origin === OG && needsRewrite(dest.pathname)) {
e.preventDefault();
location.href = SP + dest.pathname + dest.search + dest.hash;
}
} catch(ex) {}
});
}
})();
关键设计决策:
- 为什么用属性拦截器而非 MutationObserver:MutationObserver 是异步的,浏览器在回调触发前就已开始请求原始 URL,导致
bundle.js 加载 404。属性拦截器是同步的,在请求发出前就能修改。
- 为什么用 Navigation API 拦截
location.href:Chrome 出于安全策略不允许通过 Object.defineProperty 重写 Location.prototype.href 的 setter。Navigation API 是唯一可靠的全页面导航拦截方式。
3.4 Location 响应头重写
服务端 302 重定向也需要处理:
decoratedResponse.beforeCommit(() -> {
String location = decoratedResponse.getHeaders().getFirst("Location");
if (location != null && shouldRewrite(location, effectiveSubPath)) {
decoratedResponse.getHeaders().set("Location", effectiveSubPath + location);
}
return Mono.empty();
});
3.5 插件配置文件
plugin.yaml(插件元数据):
apiVersion: plugin.halo.run/v1alpha1
kind: Plugin
metadata:
name: subpath-support
spec:
enabled: true
requires: ">=2.20.0"
version: "1.0.0"
displayName: "子路径部署支持"
description: "支持将 Halo 部署在域名子路径下,自动处理所有路由和资源路径"
settingName: "subpath-support-settings"
configMapName: "subpath-support-configmap"
settings.yaml(配置表单):
apiVersion: v1alpha1
kind: Setting
metadata:
name: subpath-support-settings
spec:
forms:
- group: basic
label: 基础配置
formSchema:
- $formkit: text
name: subPath
label: 子路径
value: ""
help: "输入子路径,例如 /blog 或 /post(以 / 开头,不以 / 结尾)"
validation: "required|matches:/^/[a-zA-Z0-9\\-_]*$/"
plugin-components.idx(Spring Bean 索引):
run.halo.subpath.SubPathPlugin
run.halo.subpath.SubPathRequestFilter
run.halo.subpath.SubPathResponseFilter
⚠️ 此文件极为关键:Halo 插件系统通过此索引发现 Spring Bean。缺少此文件会导致过滤器不被注册,插件"静默失败"。
3.6 build.gradle
plugins {
id 'java'
}
group = 'run.halo.subpath'
version = '1.0.0'
java {
sourceCompatibility = JavaVersion.VERSION_21
targetCompatibility = JavaVersion.VERSION_21
}
dependencies {
compileOnly project(':api') // Halo 插件 API
compileOnly 'org.springframework.boot:spring-boot-starter-webflux:3.4.1'
compileOnly 'org.projectlombok:lombok:1.18.34'
annotationProcessor 'org.projectlombok:lombok:1.18.34'
implementation 'org.jsoup:jsoup:1.18.3' // HTML 解析
}
jar {
manifest {
attributes(
'Plugin-Class': 'run.halo.subpath.SubPathPlugin',
'Plugin-Id': 'subpath-support',
'Plugin-Version': version,
'Plugin-Requires': '>=2.20.0'
)
}
from {
configurations.runtimeClasspath.collect { it.isDirectory() ? it : zipTree(it) }
}
duplicatesStrategy = DuplicatesStrategy.EXCLUDE
}
四、Halo 源码修改(仅一处)
4.1 问题
在 Docker 容器环境下,Halo 的 SpringComponentsFinder 使用 PluginClassLoader.getResourceAsStream() 读取 plugin-components.idx 文件,但由于 Docker 文件系统和 ClassLoader 层次的特殊性,读取返回 null,导致插件的 Bean 不被发现。
4.2 修改文件
application/src/main/java/run/halo/app/plugin/SpringComponentsFinder.java
4.3 修改内容
在 readPluginStorage() 方法中添加两层后备读取方案:
private Set<String> readPluginStorage(PluginWrapper pluginWrapper) {
var bucket = new HashSet<String>();
try {
// 第一层:标准 ClassLoader 读取
try (var resourceStream = pluginWrapper.getPluginClassLoader()
.getResourceAsStream(EXTENSIONS_RESOURCE)) {
if (resourceStream == null) {
// 标准方式失败,尝试直接读 JAR
readFromPluginJar(pluginWrapper, bucket);
} else {
collectExtensions(resourceStream, bucket);
}
}
} catch (IOException e) {
log.error("Failed to read components", e);
}
return bucket;
}
// 后备方案一:直接打开 JAR 文件读取
private void readFromPluginJar(PluginWrapper pluginWrapper, Set<String> bucket) {
var pluginPath = pluginWrapper.getPluginPath();
if (pluginPath == null || !Files.isRegularFile(pluginPath)) {
readFromFreshClassLoader(pluginWrapper, bucket);
return;
}
try (var jarFile = new JarFile(pluginPath.toFile())) {
var entry = jarFile.getJarEntry(EXTENSIONS_RESOURCE);
if (entry != null) {
try (var is = jarFile.getInputStream(entry)) {
collectExtensions(is, bucket);
}
}
} catch (IOException e) {
readFromFreshClassLoader(pluginWrapper, bucket);
}
}
// 后备方案二:创建全新的 URLClassLoader 读取
private void readFromFreshClassLoader(PluginWrapper pluginWrapper, Set<String> bucket) {
var classLoader = pluginWrapper.getPluginClassLoader();
if (classLoader instanceof URLClassLoader urlCl) {
try (var freshCl = new URLClassLoader(urlCl.getURLs(), null)) {
try (var is = freshCl.getResourceAsStream(EXTENSIONS_RESOURCE)) {
if (is != null) {
collectExtensions(is, bucket);
}
}
} catch (IOException e) {
log.warn("All fallback methods failed for plugin '{}'",
pluginWrapper.getPluginId(), e);
}
}
}
五、Nginx 配置(参考)
插件方案对 Nginx 配置的要求非常简单:
location /post/ {
proxy_pass http://halo-server:8090; # 注意:无尾部斜杠,保留 /post 前缀
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
关键点:proxy_pass 末尾不加斜杠,这样 Nginx 会将完整路径(含 /post 前缀)传给后端。插件在请求阶段剥离前缀。
六、构建与部署
6.1 构建插件
# 在 Halo 源码根目录执行
./gradlew :halo-plugin-subpath:build -x test
# 产物路径
# halo-plugin-subpath/build/libs/halo-plugin-subpath-1.0.0.jar
6.2 部署插件
将 JAR 复制到 Halo 的插件目录:
# 复制到 Docker 容器
docker cp halo-plugin-subpath-1.0.0.jar \
halo-container:/root/.halo2/plugins/subpath-support-1.0.0.jar
# 重启 Halo
docker restart halo-container
6.3 部署源码修改
编译并替换 SpringComponentsFinder.class:
# 编译
./gradlew :application:compileJava
# 复制到容器
docker cp \
application/build/classes/java/main/run/halo/app/plugin/SpringComponentsFinder.class \
halo-container:/application/BOOT-INF/classes/run/halo/app/plugin/
# 重启
docker restart halo-container
💡 建议:将此 class 文件通过 Docker volume 挂载覆盖,避免镜像更新后丢失。
6.4 Halo 站点配置
在 Halo 后台 → 设置 → 基本 中,将 外部访问地址 (externalUrl) 设置为:
https://example.com/post
6.5 验证
# 检查前台页面链接是否包含子路径
curl -s https://example.com/post/ | grep 'href="/post/'
# 检查控制台重定向
curl -sI https://example.com/post/console/
# 检查调试头(确认插件生效)
curl -sI https://example.com/post/ | grep X-SubPath-Debug
# 检查 Bridge Script 注入(需要带 Session Cookie)
curl -s https://example.com/post/console/ -b 'SESSION=xxx' | grep 'data-subpath-bridge'
七、踩坑记录与解决方案
| # | 问题 | 原因 | 解决方案 |
| 1 | 插件过滤器不被调用 | 缺少 plugin-components.idx 文件 | 手动创建并列出所有 Bean 类名 |
| 2 | Docker 中索引文件读不到 | PluginClassLoader 在容器环境下失效 | 修改 SpringComponentsFinder 添加 JAR 直读和 URLClassLoader 后备方案 |
| 3 | Thymeleaf 渲染的 HTML 未被重写 | 只覆写了 writeWith(),Thymeleaf 使用 writeAndFlushWith() | 同时覆写两个方法 |
| 4 | 控制台 SPA 的 API 调用失败 | 客户端 fetch/XHR 使用不带前缀的绝对路径 | 注入 Bridge Script 拦截所有网络请求 |
| 5 | bundle.js 加载 404 | MutationObserver 异步,浏览器已发出原始请求 | 改用同步的属性拦截器 (Object.defineProperty) |
| 6 | JS/CSS 重写后浏览器解码失败 | 响应被 gzip 压缩,无法文本替换 | 请求阶段设 Accept-Encoding: identity |
| 7 | Vue 动态绑定的链接缺少前缀 | Vue 3 通过 setAttribute 设置属性 | 拦截 Element.prototype.setAttribute |
| 8 | window.location.href = "/uc" 跳转错误 | Chrome 不允许重写 Location.prototype.href setter | 使用 Navigation API 拦截全页面导航 |
八、技术原理深入
8.1 为什么 MutationObserver 不行?
时间轴:
t0: script.src = "/console/assets/bundle.js" ← 赋值发生
t1: 浏览器解析属性,开始发起 HTTP 请求 ← 请求已发出!
t2: MutationObserver 回调触发 ← 太晚了
t3: 服务端返回 404 ← 路径不对
使用属性拦截器后:
t0: script.src = "/console/assets/bundle.js"
→ setter 被拦截 → 实际赋值为 "/post/console/assets/bundle.js"
t1: 浏览器解析正确的属性,请求 /post/console/assets/bundle.js ✓
8.2 为什么 Navigation API 是拦截 location.href 的唯一可靠方式?
// ❌ 不可行:Chrome 安全策略禁止重写 Location.prototype.href
Object.defineProperty(Location.prototype, 'href', { set: ... });
// → 静默失败,setter 不会被调用
// ❌ 不可行:window.location 是不可配置的
Object.defineProperty(window, 'location', { ... });
// → TypeError
// ✅ 可行:Navigation API(Chrome 105+)
navigation.addEventListener('navigate', (e) => {
// 在导航发生前拦截并修正 URL
e.preventDefault();
location.href = correctedUrl;
});
8.3 为什么同时需要服务端重写和客户端 Bridge Script?
| 场景 | 服务端可处理 | 需要 Bridge Script |
HTML 中的 <link>/<script> 静态引用 | ✅ | |
Vite 打包的动态 import() | | ✅ |
fetch('/apis/...') API 调用 | | ✅ |
new WebSocket('/ws') | | ✅ |
Vue Router 的 createWebHistory | | ✅ |
302 重定向的 Location 头 | ✅ | |
window.location.href = '/uc' | | ✅ |
两者缺一不可。服务端处理首次页面加载,Bridge Script 处理 SPA 运行时。
九、适用范围与限制
9.1 适用于
- Halo 2.20+ 版本
- 任何基于 Nginx 反向代理的子路径部署场景
- Docker 或裸机部署
9.2 已知限制
JSON API 中的 URL 不处理:插件只重写 HTML/JS/CSS 中的路径。API 返回的 JSON 中的绝对路径(如插件 logo URL)不会被服务端重写,但 Bridge Script 在客户端通过 setAttribute 拦截可覆盖大部分情况。
Navigation API 浏览器兼容性:window.location.href 拦截依赖 Navigation API,需要 Chrome 105+ / Edge 105+ / Firefox 128+。Safari 暂不支持,需回退到在 JS bundle 中直接替换 URL 字符串。
Docker 镜像更新风险:SpringComponentsFinder.class 直接复制到容器中。镜像更新后需要重新部署。建议使用 Docker volume 挂载。
十、总结
本方案通过 一个 Halo 插件(400 行 Java 代码)+ 一处源码 Bug 修复,实现了完整的子路径部署支持:
- 零 Nginx 配置修改
- 零前端代码修改
- 通过插件配置动态更改子路径
- 覆盖前台、控制台、个人中心全部场景
核心思路是请求阶段剥离前缀 + 响应阶段添加前缀 + 客户端运行时拦截三层协同,确保从服务端到浏览器的每一个 URL 都正确携带子路径前缀。