最近在开发一个用于 Halo 博客系统的 Steam 游戏库展示插件,过程中遇到了不少坑,也学到了很多。今天把整个过程整理一下,希望能给同样想开发 Halo 插件的朋友一些参考。
一、为什么做这个插件
其实想法很简单,我想在博客上展示自己的 Steam 游戏库,让访问我博客的朋友能看到我玩了哪些游戏、玩了多久。Steam 官方有 Web API,可以获取游戏数据,而 Halo 又有插件系统,理论上结合起来应该不难。
但真正动手后才发现,事情远比我想象的复杂。
二、项目搭建遇到的第一个坑
1. Halo 版本问题
刚开始我直接从 GitHub 上下载了 Halo 插件开发模板,结果导入项目后各种报错。后来仔细看文档才发现,Halo 2.x 的插件开发需要特定版本的开发工具。
解决方案:使用 Halo 官方提供的 run.halo.plugin.devtools 插件,版本要和 Halo 版本对应。我使用的是 Halo 2.22.0,所以 build.gradle 里这样配置:
plugins {
id \"run.halo.plugin.devtools\" version \"0.6.1\"
}
halo {
version = '2.22'
}
2. Java 版本要求
Halo 2.22.0 要求 Java 21,而我电脑上默认是 JDK 17。一开始没注意,编译一直失败。
解决方案:在 build.gradle 中明确指定 Java 版本:
java {
toolchain {
languageVersion = JavaLanguageVersion.of(21)
}
}
tasks.withType(JavaCompile).configureEach {
options.encoding = \"UTF-8\"
options.release = 21
}
三、前端构建的坑
1. 双构建配置的必要性
Halo 插件需要两个前端构建输出:
控制台管理界面(在
/console路径下)前端展示页面(在
/steamview路径下)
我一开始只配置了一个 Vite 配置文件,结果前端页面一直加载不出来。
解决方案:创建两个 Vite 配置文件:
// vite.config.ts - 控制台构建
import { viteConfig } from \"@halo-dev/ui-plugin-bundler-kit\";
export default viteConfig({
vite: {
plugins: [/* ... */],
},
});
// vite.frontend.config.ts - 前端页面构建
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
export default defineConfig({
mode: 'production',
plugins: [vue()],
build: {
outDir: 'build/frontend',
lib: {
entry: './src/app.ts',
name: 'steamview',
formats: ['iife'],
fileName: () => 'app.js',
},
},
});
然后在 package.json 中添加对应的构建命令:
{
\"scripts\": {
\"build\": \"vite build\",
\"build:frontend\": \"vite build --config vite.frontend.config.ts\"
}
}
2. 资源复制任务
前端构建完成后,需要把资源复制到正确的位置。我一开始手动复制,后来发现可以在 Gradle 里配置自动化任务:
tasks.register('copyStaticResources', Copy) {
from project(':ui').layout.buildDirectory.dir('dist')
into layout.buildDirectory.dir('resources/main/static')
dependsOn project(':ui').tasks.named('assemble')
}
tasks.register('copyConsoleResources', Copy) {
from project(':ui').layout.buildDirectory.dir('dist')
into layout.buildDirectory.dir('resources/main/console')
dependsOn project(':ui').tasks.named('assemble')
}
tasks.register('copyFrontendResources', Copy) {
from project(':ui').layout.buildDirectory.dir('frontend')
into layout.buildDirectory.dir('resources/main/static')
dependsOn project(':ui').tasks.named('pnpmBuildFrontend')
}
tasks.named('classes') {
dependsOn tasks.named('copyConsoleResources')
dependsOn tasks.named('copyFrontendResources')
}
这样每次构建时,资源会自动复制到正确位置。
3. 前端入口文件的区别
控制台和前端的入口文件不同:
控制台入口:
src/index.ts(使用 Halo 的组件和路由)前端页面入口:
src/app.ts(独立运行)
我一开始把两者搞混了,导致前端页面一直报错。后来才发现前端页面需要打包成 IIFE 格式,直接在 HTML 中引入。
四、Steam API 调用的坑
1. API 频率限制
Steam API 有频率限制,每分钟最多 100 次请求。我第一次没有做缓存,每次刷新页面都重新调用 API,结果很快就被限制了。
解决方案:实现数据缓存机制,使用 Halo 的 ConfigMap 存储游戏数据:
@Service
public class GameCacheService {
private final ReactiveExtensionClient extensionClient;
private static final String CACHE_RESOURCE_NAME = \"game-cache\";
public Mono<Map<String, Object>> getCachedGames(int refreshInterval) {
return extensionClient.fetch(ConfigMap.class, CACHE_RESOURCE_NAME)
.flatMap(configMap -> {
// 检查缓存是否过期
String lastUpdatedStr = (String) data.get(\"lastUpdated\");
Instant lastUpdated = Instant.parse(lastUpdatedStr);
long hoursSinceUpdate = Duration.between(lastUpdated, Instant.now()).toHours();
if (hoursSinceUpdate < refreshInterval) {
return Mono.just(data);
}
return Mono.empty();
});
}
public Mono<Void> saveCachedGames(Map<String, Object> gamesData) {
// 保存到 ConfigMap
}
}
2. 游戏名称本地化
Steam API 返回的游戏名称默认是英文,我想要显示中文名称。Steam Store API 可以获取本地化名称,但需要为每个游戏单独调用。
一开始我直接循环调用,结果很慢而且容易触发频率限制。
解决方案:使用 Reactor 的并发控制,限制同时只有 10 个请求:
public Flux<Map<String, Object>> localizeGameNames(List<Map<String, Object>> games) {
return Flux.fromIterable(games)
.flatMap(game -> {
String appId = (String) game.get(\"appId\");
return steamApiService.getLocalizedGameName(appId)
.map(name -> {
if (name != null) {
game.put(\"name\", name);
}
return game;
})
.onErrorResume(e -> Mono.just(game)); // 失败时保留原名
}, 10); // 并发限制为 10
}
3. 家庭库游戏支持
Steam 的家庭共享功能允许家庭成员共享游戏,但 GetOwnedGames API 只返回自己拥有的游戏,不包括家庭库的游戏。
我一开始没注意到这个问题,导致有些在家庭库中玩过的游戏没显示出来。
解决方案:使用 GetRecentlyPlayedGames API 获取最近游玩的游戏(包括家庭库游戏),然后合并数据:
public Mono<List<Map<String, Object>>> getAllGames(String apiKey, String steamId) {
return Mono.zip(
steamApiService.getOwnedGames(apiKey, steamId),
steamApiService.getRecentlyPlayedGames(apiKey, steamId)
).map(tuple -> {
List<Map<String, Object>> ownedGames = tuple.getT1();
List<Map<String, Object>> recentGames = tuple.getT2();
// 合并游戏数据,去重
Map<String, Map<String, Object>> gameMap = new HashMap<>();
ownedGames.forEach(game -> gameMap.put((String) game.get(\"appId\"), game));
recentGames.forEach(game -> {
String appId = (String) game.get(\"appId\");
if (!gameMap.containsKey(appId)) {
gameMap.put(appId, game);
}
});
return new ArrayList<>(gameMap.values());
});
}
五、响应式编程的坑
Halo 使用 Spring WebFlux,需要使用响应式编程。我之前主要用 Spring MVC,一开始很不习惯。
1. Mono 和 Flux 的区别
Mono 表示 0 或 1 个元素,Flux 表示 0 到 N 个元素。一开始我总是用错,导致编译错误。
示例:
// 获取单个游戏名称 - 使用 Mono
public Mono<String> getLocalizedGameName(String appId) {
return webClient.get()
.uri(url)
.retrieve()
.bodyToMono(String.class);
}
// 获取多个游戏 - 使用 Flux
public Flux<Map<String, Object>> getAllGames() {
return Flux.fromIterable(games)
.flatMap(game -> fetchGameData(game));
}
2. 异常处理
响应式编程的异常处理和传统编程不同,不能直接 try-catch。
解决方案:使用 onErrorResume、doOnError 等操作符:
public Mono<String> getSteamId(String apiKey, String username) {
return webClient.get()
.uri(url)
.retrieve()
.bodyToMono(String.class)
.map(response -> parseResponse(response))
.doOnError(e -> log.error(\"获取 Steam ID 失败: {}\", e.getMessage()))
.onErrorResume(e -> {
log.error(\"处理异常,返回默认值\");
return Mono.just(\"default-value\");
});
}
3. 阻塞代码的问题
响应式编程中不能使用阻塞代码,否则会阻塞事件循环。我一开始在 Mono 里直接调用阻塞方法,导致整个应用卡死。
解决方案:使用 subscribeOn 或 publishOn 切换到不同的调度器:
public Mono<String> processData(String input) {
return Mono.fromCallable(() -> {
// 阻塞操作
return blockingOperation(input);
})
.subscribeOn(Schedulers.boundedElastic()); // 使用弹性调度器处理阻塞操作
}
六、UI 和样式的坑
1. 响应式设计
插件需要在桌面端、平板和移动端都能正常显示。我一开始只考虑了桌面端,导致在手机上布局乱七八糟。
解决方案:使用 CSS Grid 和 Flexbox 实现响应式布局:
.game-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 20px;
@media (max-width: 768px) {
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 15px;
}
@media (max-width: 480px) {
grid-template-columns: 1fr;
gap: 10px;
}
}
2. 加载状态反馈
从 Steam API 获取数据需要时间,如果没有加载提示,用户体验会很差。
解决方案:添加加载动画和状态提示:
<template>
<div v-if=\"loading\" class=\"loading-container\">
<div class=\"spinner\"></div>
<p>正在加载游戏数据...</p>
</div>
<div v-else-if=\"error\" class=\"error-container\">
<p>{{ error }}</p>
</div>
<div v-else class=\"game-list\">
<!-- 游戏列表 -->
</div>
</template>
3. 图片加载优化
游戏封面图片来自 Steam CDN,有时候加载很慢。我一开始没有做优化,导致页面加载时间很长。
解决方案:使用懒加载和占位图:
<template>
<img
:src=\"game.coverUrl\"
:alt=\"game.name\"
loading=\"lazy\"
@error=\"handleImageError\"
/>
</template>
<script setup>
const handleImageError = (e) => {
e.target.src = '/placeholder.png'; // 占位图
};
</script>
七、开发流程的坑
1. 热更新问题
开发时想要前端热更新,但 Halo 插件的开发环境配置比较复杂。我一开始每次修改都要重新构建,效率很低。
解决方案:使用 pnpm dev 命令监听文件变化并自动构建:
cd ui
pnpm dev
同时在另一个终端运行 Halo 开发服务器:
./gradlew.bat haloServer
2. 调试困难
Halo 插件的调试不像普通 Web 应用那么方便,后端日志和前端日志混在一起。
解决方案:
后端:使用
@Slf4j注解和log对象输出详细日志前端:使用浏览器开发者工具的 Console 和 Network 面板
使用
console.log输出调试信息
@Slf4j
@Service
public class SteamApiService {
public Mono<String> getSteamId(String apiKey, String username) {
log.info(\"请求 Steam ID: URL={}\", url);
// ...
log.info(\"Steam ID: {}\", steamId);
}
}
3. 构建产物位置
我一开始找不到构建后的 jar 文件在哪里,浪费了很多时间。
解决方案:构建产物在 build/libs/ 目录下:
./gradlew.bat build
# 产物位置: build/libs/pluginsteamview-1.0.0-SNAPSHOT.jar
八、总结
这次开发 Steam View 插件的经历让我学到了很多:
版本兼容性很重要:Halo 版本、Java 版本、依赖版本都要匹配好
响应式编程需要适应:Mono 和 Flux 的使用、异常处理、调度器选择都需要仔细考虑
API 调用要有限制:频率限制、并发控制、缓存机制都不能少
用户体验要重视:加载状态、错误处理、响应式设计都要做好
开发流程要优化:热更新、调试工具、构建配置都要配置好
虽然过程中遇到了很多坑,但最终看到插件正常运行,在博客上展示出自己的游戏库,还是挺有成就感的。
如果你也想开发 Halo 插件,希望这些经验能帮到你。有问题欢迎交流!


默认评论
Halo系统提供的评论