跳转到内容

博客

基于 Starlight 文档站点接入 Microsoft Clarity 的完整实践指南

从数据洞察到用户增长:HagiCode 博客接入 Clarity Analytics 的完整指南

Section titled “从数据洞察到用户增长:HagiCode 博客接入 Clarity Analytics 的完整指南”

本文将分享如何在 Starlight 文档站点中优雅地接入 Microsoft Clarity,不仅能看清用户行为,还能确保隐私合规。这套方案是我们在 HagiCode 项目中实践总结出来的,希望能给同样在折腾数据统计的你一点参考。

以下代码展示了如何在 Astro 集成中根据环境变量动态注入 Microsoft Clarity 脚本,仅在生效时进行生产环境加载。

105 | interface Props {
106 | // 未来可扩展: 允许手动覆盖 Project ID
107 | projectId?: string;
108 | }
109 |
110 | const {
111 | projectId = import.meta.env.CLARITY_PROJECT_ID,
112 | } = Astro.props;
113 |
114 | const isProduction = import.meta.env.PROD;
115 | ---
116 |
117 | {isProduction && projectId && (
118 | <script is:inline define:vars={{projectId}}>
119 | (function(c,l,a,r,i,t,y){

文件:openspec/changes/archive/2026-01-30-microsoft-clarity-integration/design.md

在运营 HagiCode 的过程中,我们一直面临一个”盲盒”问题:我们产出内容,但不清楚用户是如何阅读的。虽然 GitHub 能看到 Star 数,但这太滞后了。我们需要知道:

  • 用户到底有没有看完我们的教程?
  • 那些复杂的配置文档,是在哪一步劝退了用户的?
  • 我们的 SEO 优化是否真的带来了有效流量?

市面上有很多分析工具,比如 Google Analytics(GA)和 Microsoft Clarity。GA 功能强大但配置复杂,且受到隐私法规(如 GDPR)的严格限制。而 Clarity 作为微软推出的免费热力图工具,不仅功能直观,而且在隐私合规上相对宽松,非常适合技术文档站点。

我们的目标很明确:在 HagiCode 的文档站点中无缝集成 Clarity,既要在所有页面生效,又要给用户留有”退出”的权利(隐私合规)。

HagiCode 主题初始化逻辑:优先读取本地存储,回退至系统偏好,默认暗色。

67 | function getInitialTheme(): Theme {
68 | // 1. 检查 localStorage
69 | const stored = localStorage.getItem('hagicode-theme');
70 | if (stored) return stored as Theme;
71 |
72 | // 2. 检测系统偏好
73 | const systemDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
74 | if (systemDark) return 'dark';
75 |
76 | // 3. 默认暗色
77 | return 'dark';
78 | }
79 | ```
80 |
81 | ### 决策 3:主题应用方式
82 |
83 | **选择**:在 `<html>` 根元素设置 `data-theme` 属性
84 |
85 | **对比方案**
86 |

文件:openspec/changes/archive/2026-01-29-theme-toggle-implementation/design.md

本文分享的方案来自我们在 HagiCode 项目中的实践经验。HagiCode 是一个基于 AI 的代码辅助工具,在开发过程中,我们需要维护大量的技术文档和博客。为了更好地理解用户需求,我们探索并实施了这套数据接入方案。

起初,我们在 Proposal 阶段讨论了多种集成方式。既然我们使用的是 Starlight(基于 Astro 的文档框架),最直观的想法是利用 Astro 的 Hooks。

我们首先尝试了修改 astro.config.mjs,计划在构建时注入 Clarity 脚本。虽然这种方式能保证全局覆盖,但缺乏灵活性——我们无法根据用户的偏好动态加载或卸载脚本。

考虑到用户体验和隐私控制,我们最终决定采用 组件覆盖 的方案。Starlight 允许开发者覆盖其内部组件,这意味着我们可以接管 <footer><head> 的渲染逻辑,从而精细控制 Clarity 的加载时机。

这里有一个小插曲:原本我们想创建一个名为 StarlightWrapper.astro 的布局包装器。但在实际调试中发现,Starlight 的路由机制并不会自动调用这个自定义 Wrapper,这导致脚本在部分页面失效。这算是一个典型的”想当然”踩坑经历,提醒我们必须深入理解框架的渲染流程,而不是盲目套用通用框架模式

为了确保 Clarity 脚本在所有页面(包括文档和博客)加载,并且不破坏原有的页面结构,我们选择了覆盖 Starlight 的 Footer 组件。

  1. 全局性:Footer 几乎在所有标准页面都会出现。
  2. 非侵入性:将脚本放在 Footer 区域(实际渲染在 body 底部)不会阻塞页面的关键渲染路径(LCP),对性能影响最小。
  3. 逻辑集中:可以在组件内部统一处理 Cookie 同意逻辑。

首先,你需要在 Microsoft Clarity 注册并创建一个新项目。获取你的 Project ID(类似 k8z2ab3xxx 这样的字符串)。

下面通过环境变量配置与日期判断代码,实现新年期间的逻辑控制,请参考具体实现。

46 | function isLunarNewYearPeriod() {
47 | const now = new Date();
48 | const year = now.getFullYear();
49 | const month = now.getMonth() + 1; // 1-12
50 | const day = now.getDate();
51 |
52 | // 2025年蛇年新年期间 (1月29日 - 2月12日)
53 | if (year === 2025) {
54 | if (month === 1 && day >= 29) return true;
55 | if (month === 2 && day <= 12) return true;
56 | }
57 | // 2026年马年新年期间 (2月17日 - 3月3日)
58 | if (year === 2026) {
59 | if (month === 2 && day >= 17) return true;
60 | if (month === 3 && day <= 3) return true;
61 | }
62 | return false;
63 | }
64 |
65 | const stored = localStorage.getItem('starlight-theme');

文件:src/pages/index.astro

为了安全起见,不要硬编码 ID。建议将 ID 存入环境变量。

在项目根目录创建 .env 文件:

Terminal window
# Microsoft Clarity ID
PUBLIC_CLARITY_ID="你的_Clarity_ID"

以下是监听系统主题变化的实现代码,展示了如何仅在未手动设置时跟随系统切换主题。

445 | const handleChange = (e: MediaQueryListEvent) => {
446 | // 仅在用户未手动设置时跟随系统
447 | if (!localStorage.getItem(THEME_KEY)) {
448 | setThemeState(e.matches ? 'dark' : 'light');
449 | }
450 | };
451 |
452 | mediaQuery.addEventListener('change', handleChange);
453 | return () => mediaQuery.removeEventListener('change', handleChange);
454 | }, []);
455 |
456 | return { theme, toggleTheme, setTheme: manuallySetTheme };
457 | }
458 | ```
459 |
460 | #### 3. `src/components/ThemeButton.tsx` - 按钮组件
461 |
462 | **职责**:渲染主题切换按钮,处理用户交互
463 |
464 | **组件接口**

文件:openspec/changes/archive/2026-01-29-theme-toggle-implementation/design.md

src/components/ 目录下创建文件 StarlightFooter.astro。Starlight 会自动识别这个文件并覆盖默认的 Footer。

核心代码逻辑如下:

src/components/StarlightFooter.astro
---
// 1. 引入原始组件以保留其默认功能
import DefaultFooter from '@astrojs/starlight/components/StarlightFooter.astro';
// 2. 获取环境变量
const clarityId = import.meta.env.PUBLIC_CLARITY_ID;
// 3. 定义简单的注入脚本(内联方式)
// 注意:生产环境建议将此逻辑抽离到单独的 .js 文件中以利用缓存
const initScript = `
(function(c,l,a,r,i,t,y){
c[a]=c[a]||function(){(c[a].q=c[a].q||[]).push(arguments)};
t=l.createElement(r);t.async=1;t.src="https://www.clarity.ms/tag/"+i;
y=l.getElementsByTagName(r)[0];y.parentNode.insertBefore(t,y);
})(window, document, "clarity", "script", "${clarityId}");
`;
---
<DefaultFooter {...Astro.props} />
{/* 仅在生产环境且 ID 存在时注入脚本 */}
{import.meta.env.PROD && clarityId && (
<script is:inline define:vars={{ clarityId }}>
{initScript}
</script>
)}

关键点解析

  • is:inline:告诉 Astro 不要处理这个 script 标签内的内容,直接输出到 HTML。这对第三方统计脚本至关重要,否则 Astro 的打包优化可能会导致脚本失效。
  • define:vars:这是 Astro 3+ 的特性,允许在作用域内安全地注入变量。
  • import.meta.env.PROD:确保在本地开发时(除非为了调试)不产生无效统计,保持数据纯净。

仅仅加上代码是不够的,特别是在 GDPR 管辖区域。我们需要尊重用户的选择。

HagiCode 的做法是提供一个简单的开关。虽然这不是全功能的 Cookie Banner,但对于纯展示的技术文档站点来说,通常属于”必要”或”统计”类 Cookie,可以通过隐私声明告知并默认开启,或者在 Footer 链接到隐私设置页面。

如果需要更严谨的控制,你可以结合 localStorage 来记录用户的选择:

本文将介绍用于主题切换与持久化的 TypeScript 工具函数,通过类型安全与环境检测实现严谨控制。

367 | export function getInitialTheme(): Theme;
368 | export function getSystemTheme(): Theme;
369 | export function setTheme(theme: Theme): void;
370 | export function applyTheme(theme: Theme): void;
371 | ```
372 |
373 | **设计原则**
374 | - **纯函数**:无副作用(除了 `setTheme``applyTheme`
375 | - **类型安全**:完整的 TypeScript 类型推导
376 | - **环境检测**:SSR 安全(`typeof window` 检查)
377 | - **单一职责**:每个函数只做一件事
378 |
379 | **关键实现**
380 | ```typescript
381 | export function getInitialTheme(): Theme {
382 | if (typeof window === 'undefined') return 'dark';
383 |
384 | const stored = localStorage.getItem(THEME_KEY);
385 | if (stored === 'light' || stored === 'dark') return stored;
386 |

文件:openspec/changes/archive/2026-01-29-theme-toggle-implementation/design.md

// 简单示例:检查用户是否拒绝统计
const consent = localStorage.getItem('clarity_consent');
if (consent !== 'denied') {
// 执行上面的 Clarity 初始化代码
window.clarity('start', clarityId);
}

在将这套方案落地到 HagiCode 的过程中,我们总结了几个容易被忽视的细节:

  1. StarlightWrapper.astro 是个陷阱: 如前所述,不要试图去创建一个全局 Wrapper 来注入脚本,这在 Starlight 中行不通。老老实实覆盖特定组件(如 StarlightFooter.astroStarlightHead.astro)才是正解。

  2. 脚本位置的性能考量: 虽然 Clarity 建议放在 <head> 中以确保数据准确性,但对于文档站点,首屏加载速度(LCP)直接影响了 SEO 和用户留存。我们选择了放在 Footer(Body 底部),这会轻微丢失极少量”秒退”用户的数据,但换来了更快的页面加载体验,这是一个值得的权衡。

  3. 开发环境的干扰: 一定要加上 import.meta.env.PROD 判断。在开发模式下,你会频繁刷新页面,这会产生大量无意义的测试数据,污染你的 Clarity 仪表盘。

部署完成后,你可以在 Clarity 控制台查看实时数据。通常在几分钟内,你就能看到用户的heatmap(热力图)和 recordings(录屏)。

对于 HagiCode 来说,通过这些数据我们发现:

  • 很多用户会反复查看”快速开始”章节,说明我们的安装指引可能还不够直观。
  • “API 参考”页面的停留时间最长,证实了我们核心用户群体的需求。

接入 Microsoft Clarity 并不需要复杂的服务端改造,也不需要引入沉重的 SDK。

利用 Starlight 的组件覆盖机制,我们仅通过一个轻量级的 StarlightFooter.astro 组件,就实现了全局数据统计。这种”微集成”的方式,既保证了代码的整洁,又赋予了我们洞察用户行为的能力。

如果你也在运营技术类项目,特别是像 HagiCode 这样需要不断迭代文档的项目,强烈建议尝试接入 Clarity。数据会告诉你,用户真正的痛点在哪里。


感谢您的阅读,如果您觉得本文有用,快点击下方点赞按钮👍,让更多的人看到本文。

本内容采用人工智能辅助协作,经本人审核,符合本人观点与立场。

HagiCode 启动页设计:React 19 应用中填补 Hydration 空白期的极致体验

为 HagiCode 设计 12 种极致的启动体验:从极简到赛博朋克

Section titled “为 HagiCode 设计 12 种极致的启动体验:从极简到赛博朋克”

在 React 19 应用下载和 Hydration 的短暂间隙,是留给用户感知品牌个性的黄金窗口。本文分享了我们在 HagiCode 项目中,基于 HTML/CSS/JS 构建的一套完整的启动风格系统。

HagiCode 作为一个基于 ASP.NET Core 10 和 React 19 (Vite) 的现代化应用,采用了前后端分离部署的架构。前端产物被打包放置于后端的 wwwroot/ 目录下由 ASP.NET Core 托管。

然而,这种架构带来了一个经典的用户体验痛点:当用户访问网页时,浏览器需要先加载 HTML,再下载巨大的 JS Bundle,最后由 React 执行 Hydration(注水)。在这几百毫秒到数秒的”真空期”里,用户面对的是一片空白,或者是一个毫无生气的静态页面。

为了填补这段间隙,并注入 HagiCode 的品牌个性,我们需要设计一套完全基于 index.html 内联代码的启动风格系统。

本文分享的启动页设计方案来自我们在 HagiCode 项目中的实践经验。作为一个 AI 代码助手,HagiCode 不仅关注代码生成的效率,也同样重视开发者的视觉体验。这套启动系统正是我们在追求极致前端性能过程中的产物。

在动手设计之前,我们必须先明确技术约束。既然要在 index.html 中内联实现,意味着我们不能加载任何外部 CSS 或 JS 文件(除了 React 本身的 Bundle)。

  1. 零依赖原则:所有样式必须写在 <style> 标签内,逻辑写在 <script> 标签内。
  2. 防御式 CSS:为了防止 React 应用挂载后,全局样式污染启动页,我们决定使用高优先级的 ID 前缀(如 #boot-screen)包裹所有启动样式。
  3. 性能优先:动画尽量使用 CSS transformopacity,避免触发重排,确保不阻塞主线程。
  4. 视觉一致性:颜色、字体必须与 HagiCode 的 Tailwind 配置保持一致。

我们采用了一种变体模式。核心逻辑封装在一个立即执行函数(IIFE)中,具体的渲染逻辑作为配置项注入。这样我们就可以通过简单的配置切换不同的风格,而不需要重复编写 DOM 操作逻辑。

以下是核心的架构代码:

<!-- 内联于 index.html -->
<div id="boot-root"></div>
<script>
(function() {
const BootSequence = {
config: {
theme: 'terminal', // 可配置为 'minimal', 'skeleton', 'code-rain' 等
color: '#3b82f6' // 品牌色
},
// 核心生命周期
init() {
this.render();
this.listenForMount();
},
// 渲染当前选定的风格
render() {
const root = document.getElementById('boot-root');
if (this.variants[this.config.theme]) {
root.innerHTML = this.variants[this.config.theme].render();
}
},
// 监听 React 挂载成功,优雅退出
listenForMount() {
window.addEventListener('hagicode:ready', () => {
const screen = document.getElementById('boot-root');
// 先淡出,再移除 DOM,避免闪烁
screen.style.opacity = '0';
screen.style.transition = 'opacity 0.3s ease';
setTimeout(() => screen.remove(), 300);
});
},
// 12种风格的实现逻辑集中在这里
variants: {
// ...具体实现见下文
}
};
BootSequence.init();
})();
</script>

我们将这 12 种风格分为了六大类,以满足不同场景和审美需求。

“少即是多”。对于追求极致加载速度的场景,我们提供了最轻量的方案。

屏幕中心只有一个简单的圆点,配合呼吸动画。

  • 实现:CSS @keyframes 控制scale和opacity。
  • 适用:任何需要保持页面绝对干净的场合。

通过 SVG stroke-dasharray 动画,模拟手绘般绘制出 HagiCode 的 Logo 线条,随后淡入文字。

  • 技巧:使用 SVG 路径动画,极具质感。

“欺骗眼睛的艺术”。通过模拟真实 UI 布局,让用户感觉页面已经加载了一半。

3. Sidebar Chat Skeleton (侧边栏骨架屏)

Section titled “3. Sidebar Chat Skeleton (侧边栏骨架屏)”

这可能是最实用的一种。我们手动用 HTML 构建了与 React 组件 SidebarChatInput 一模一样的布局,并覆盖灰色条纹动画。

  • 价值:当 React hydrate 完成时,骨架屏瞬间变成真实组件,用户几乎感觉不到切换。

模拟提案卡片加载时的堆叠动效,使用 3D 变换让卡片微微浮动。

展示 HagiCode 的极客基因。

在屏幕中心渲染一个几何体(正方形),它会随着时间平滑地变换为圆形、三角形,最后变成 Logo。

  • 技术:CSS border-radius 的平滑过渡。

向《黑客帝国》致敬。使用 JetBrains Mono 字体,在背景中落下淡淡的字符流。

  • 注意:为了性能,字符流必须限制在较小的区域或降低刷新频率。

赛博朋克风格的发光圆环,利用 box-shadow 的多重叠加产生强烈的发光感。

让系统”活”起来。

这是一个动态加载器。根据当前日期判断节日(如春节、圣诞节),加载对应的 SVG 动画。

  • 例子:春节时,屏幕下方会有红灯笼轻轻摆动。

背景使用 HagiCode 品牌色的流体渐变,配合 background-sizebackground-position 的动画,营造出极光般的流动感。

向开发者致敬。

模拟控制台输出。一行行代码快速滚动:

> Initializing HagiCode Core...
> Loading models...
> Connecting to neural network...

这会让每一个开发者都感到亲切。

屏幕顶部一条细细的进度条,右侧显示百分比。虽然我们无法获取真实的下载进度,但可以用一个定时器模拟出一个”可信”的加载过程(前 80% 快速,后 20% 减速)。

这是一个很有趣的创意。屏幕上散落着一些方块,它们汇聚到中心,逐渐拼凑出 HagiCode 的 Logo 图标。象征着代码的构建过程。

在 HagiCode 的实际开发中,我们总结了一些至关重要的实践细节。

千万别偷懒不写前缀。曾经有一次,我们没有给启动页样式加 ID 限制,导致 React 挂载后的全局 div 样式意外影响了启动页,导致布局崩坏。 经验:所有 CSS 选择器都挂在 #boot-screen 下,且使用 !important 提升优先级(仅在启动页 CSS 中)。

React mount 成功后,不要直接 remove() 启动页 DOM。 正确做法

  1. React 触发 window.dispatchEvent(new Event('hagicode:ready'))
  2. 启动页监听到事件,先设置 opacity: 0
  3. 等待 300ms (CSS transition 时间),确保用户看不见了,再执行 .remove()

启动页的颜色代码是写死在 index.html 里的。如果我们修改了 Tailwind 的主色,必须同步修改这里。 优化方案:在 Vite 构建脚本中,编写一个简单的插件,读取 tailwind.config.js 并将颜色变量注入到 index.html 的模板变量中,实现单一数据源。

启动页通常需要使用品牌字体,但如果字体加载慢,会出现 FOUT (Flash of Unstyled Text)。 解决方案:在 <head> 中加入 <link rel="preload" href="/fonts/JetBrainsMono.woff2" as="font" type="font/woff2" crossorigin>。这是提升体验的低成本高回报手段。

我们在 index.html 底部注入了 performance.mark('boot-start'),并在 React 挂载成功时标记 boot-end意义:通过 Application Insights 收集这些数据,我们可以真实看到启动页对用户感知等待时间(Perceived Loading Time)的缩短程度。数据表明,优秀的骨架屏能让用户对”慢速网络”的容忍度提升 50% 以上。

一个好的启动页,不仅仅是”等待时的装饰”,它是产品与用户第一次交互的握手信号。在 HagiCode 项目中,这套基于 Variants 模式的启动系统,让我们能够灵活地在不同节日、不同版本间切换风格,极大地增强了产品的趣味性和专业感。

本文分享的方案完全基于原生 Web 标准,没有引入任何沉重的依赖,这正是 HagiCode 追求”轻量且强大”的体现。如果你觉得这套方案有价值,欢迎来 HagiCode 仓库看看我们的源码实现,甚至贡献你的创意设计!

如果本文对你有帮助,欢迎来 GitHub 给个 Star,公测已开始,期待你的反馈!


感谢您的阅读,如果您觉得本文有用,快点击下方点赞按钮👍,让更多的人看到本文。

本内容采用人工智能辅助协作,经本人审核,符合本人观点与立场。

.NET Core 双数据库实战:优雅融合 PostgreSQL 与 SQLite 的最佳实践

.NET Core 双数据库实战:让 PostgreSQL 与 SQLite 和平共处

Section titled “.NET Core 双数据库实战:让 PostgreSQL 与 SQLite 和平共处”

在构建现代化应用时,我们经常面临这样的抉择:开发环境渴望轻量便捷,而生产环境则需要高并发与高可用。本文将分享如何在 .NET Core 项目中优雅地同时支持 PostgreSQL 和 SQLite,实现“开发用 SQLite,生产用 PG”的最佳实践。

在软件开发中,环境差异化一直是困扰开发团队的难题之一。以我们正在构建的 HagiCode 平台为例,这是一个基于 ASP.NET Core 10 和 React 的 AI 辅助开发系统,内部集成了 Orleans 进行分布式状态管理,技术栈相当现代且复杂。

在项目初期,我们遇到了一个典型的工程痛点:开发人员希望本地环境能够“开箱即用”,不希望安装和配置繁重的 PostgreSQL 数据库;但在生产环境中,我们需要处理高并发写入和复杂的 JSON 查询,这时轻量级的 SQLite 又显得力不从心。

如何在保持代码库统一的前提下,让应用既能像客户端软件一样利用 SQLite 的便携性,又能像企业级服务一样发挥 PostgreSQL 的强悍性能?这就是本文要探讨的核心问题。

本文分享的双数据库适配方案,直接来源于我们在 HagiCode 项目中的实战经验。HagiCode 是一个集成了 AI 提示词管理和 OpenSpec 工作流的下一代开发平台。正是为了兼顾开发者的体验和生产环境的稳定性,我们探索出了这套行之有效的架构模式。

欢迎访问我们的 GitHub 仓库了解项目全貌:HagiCode-org/site

核心内容一:架构设计与统一抽象

Section titled “核心内容一:架构设计与统一抽象”

要在 .NET Core 中实现双数据库支持,核心思想是“依赖抽象而非具体实现”。我们需要把数据库的选择权从业务代码中剥离出来,交给配置层决定。

  1. 统一接口:所有的业务逻辑都应依赖于 DbContext 基类或自定义的接口,而不是具体的 PostgreSqlDbContext
  2. 配置驱动:通过 appsettings.json 中的配置项,在应用启动时动态决定加载哪个数据库提供程序。
  3. 特性隔离:针对 PostgreSQL 特有的功能(如 JSONB)进行适配处理,确保在 SQLite 中也能降级运行。

在 ASP.NET Core 的 Program.cs 中,我们不应硬编码 UseNpgsqlUseSqlite。相反,我们应该读取配置来动态决定。

首先,定义配置类:

public class DatabaseSettings
{
public const string SectionName = "Database";
// 数据库类型:PostgreSQL 或 SQLite
public string DbType { get; set; } = "PostgreSQL";
// 连接字符串
public string ConnectionString { get; set; } = string.Empty;
}

然后,在 Program.cs 中根据配置注册服务:

// 读取配置
var databaseSettings = builder.Configuration.GetSection(DatabaseSettings.SectionName).Get<DatabaseSettings>();
// 注册 DbContext
builder.Services.AddDbContext<ApplicationDbContext>(options =>
{
if (databaseSettings?.DbType?.ToLower() == "sqlite")
{
// SQLite 配置
options.UseSqlite(databaseSettings.ConnectionString);
// SQLite 的并发写入限制处理
// 注意:在生产环境中建议开启 WAL 模式以提高并发性能
}
else
{
// PostgreSQL 配置(默认)
options.UseNpgsql(databaseSettings.ConnectionString, npgsqlOptions =>
{
// 开启 JSONB 支持,这在处理 AI 对话记录时非常有用
npgsqlOptions.UseJsonNet();
});
// 配置连接池重连策略
options.EnableRetryOnFailure(3);
}
});

核心内容二:处理差异性与迁移策略

Section titled “核心内容二:处理差异性与迁移策略”

PostgreSQL 和 SQLite 虽然都支持 SQL 标准,但在具体特性和行为上存在显著差异。如果不处理好这些差异,很可能会出现“本地跑得通,上线就报错”的尴尬情况。

在 HagiCode 中,我们需要存储大量的提示词和 AI 元数据,这通常涉及 JSON 列。

  • PostgreSQL:拥有原生的 JSONB 类型,查询性能极佳。
  • SQLite:没有原生的 JSON 类型(新版本有 JSON1 扩展,但对象映射上仍有差异),通常存储为 TEXT。

解决方案: 在 EF Core 的实体映射中,我们将其配置为可转换的类型。

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
// 配置实体
modelBuilder.Entity<PromptTemplate>(entity =>
{
entity.Property(e => e.Metadata)
.HasColumnType("jsonb") // PG 使用 jsonb
.HasConversion(
v => JsonSerializer.Serialize(v, (JsonSerializerOptions)null),
v => JsonSerializer.Deserialize<Dictionary<string, object>>(v, (JsonSerializerOptions)null)
);
});
}

当使用 SQLite 时,虽然 HasColumnType("jsonb") 会被忽略或产生警告,但由于配置了 HasConversion,数据会被正确地序列化和反序列化为字符串存入 TEXT 字段,从而保证了兼容性。

绝对不要试图让同一套 Migration 脚本同时适配 PG 和 SQLite。由于主键生成策略、索引语法等的不同,这必然会导致失败。

推荐实践: 维护两个迁移分支或项目。在 HagiCode 的开发流中,我们是这样处理的:

  1. 开发阶段:主要在 SQLite 下工作。使用 Add-Migration Init_Sqlite -OutputDir Migrations/Sqlite
  2. 适配阶段:开发完一段功能后,切换连接字符串指向 PostgreSQL,执行 Add-Migration Init_Postgres -OutputDir Migrations/Postgres
  3. 自动化脚本:编写一个简单的 PowerShell 或 Bash 脚本,根据当前环境变量自动应用对应的迁移。
Terminal window
# 简单的部署逻辑伪代码
if [ "$DATABASE_PROVIDER" = "PostgreSQL" ]; then
dotnet ef database update --project Migrations.Postgres
else
dotnet ef database update --project Migrations.Sqlite
fi

核心内容三:HagiCode 的实战经验总结

Section titled “核心内容三:HagiCode 的实战经验总结”

在将 HagiCode 从单一数据库重构为双数据库支持的过程中,我们踩过一些坑,也总结了一些关键的经验,希望能给大家避坑。

PostgreSQL 是服务端-客户端架构,支持高并发写入,事务隔离级别非常强大。而 SQLite 是文件锁机制,写入操作会锁定整个数据库文件(除非开启 WAL 模式)。

建议: 在编写涉及频繁写入的业务逻辑时(例如实时保存用户的编辑状态),一定要考虑到 SQLite 的锁机制。在设计 HagiCode 的 OpenSpec 协作模块时,我们引入了“写前合并”机制,减少数据库的直接写入频率,从而在两种数据库下都能保持高性能。

PostgreSQL 的连接建立成本较高,依赖连接池。而 SQLite 连接非常轻量,但如果不及时释放,文件锁可能会导致后续操作超时。

Program.cs 中,我们可以针对不同数据库做精细化调整:

if (databaseSettings?.DbType?.ToLower() == "sqlite")
{
// SQLite:保持连接开启能提升性能,但要注意文件锁
options.UseSqlite(connectionString, sqliteOptions =>
{
// 设置命令超时时间
sqliteOptions.CommandTimeout(30);
});
}
else
{
// PG:利用连接池
options.UseNpgsql(connectionString, npgsqlOptions =>
{
npgsqlOptions.MaxBatchSize(100);
npgsqlOptions.CommandTimeout(30);
});
}

很多开发者(包括我们团队早期的成员)容易犯一个错误:只在开发环境(通常是 SQLite)跑单元测试。

我们在 HagiCode 的 CI/CD 流水线中强制加入了 GitHub Action 步骤,确保每次 Pull Request 都要跑过 PostgreSQL 的集成测试。

# .github/workflows/test.yml 示例片段
- name: Run Integration Tests (PostgreSQL)
run: |
docker-compose up -d db_postgres
dotnet test --filter "Category=Integration"

这帮我们拦截了无数次关于 SQL 语法差异、大小写敏感性的 Bug。

通过引入抽象层和配置驱动的依赖注入,我们在 HagiCode 项目中成功实现了 PostgreSQL 和 SQLite 的“双轨制”运行。这不仅极大降低了新开发者的上手门槛(不需要装 PG),也为生产环境提供了坚实的性能保障。

回顾一下关键点:

  1. 抽象至上:业务代码不依赖具体数据库实现。
  2. 配置分离:开发和生产使用不同的 appsettings.json
  3. 迁移分离:不要尝试一套 Migration 走天下。
  4. 特性降级:在 SQLite 中以兼容性优先,在 PostgreSQL 中以性能优先。

这种架构模式不仅适用于 HagiCode,也适用于任何需要在轻量级开发和重量级生产之间寻找平衡的 .NET 项目。


如果本文对你有帮助,欢迎来 GitHub 给个 Star,或者直接体验 HagiCode 带来的高效开发流程:

公测已开始,欢迎安装体验!


感谢您的阅读,如果您觉得本文有用,快点击下方点赞按钮👍,让更多的人看到本文。

本内容采用人工智能辅助协作,经本人审核,符合本人观点与立场。

Docusaurus 3.x 到 Astro 5.x 迁移实战:利用 Islands 架构实现性能与构建速度双重提升

从 Docusaurus 3.x 到 Astro 5.x:HagiCode 站点迁移实战复盘

Section titled “从 Docusaurus 3.x 到 Astro 5.x:HagiCode 站点迁移实战复盘”

本文复盘了我们将 HagiCode 官方网站从 Docusaurus 3.x 迁移至 Astro 5.x 的全过程。我们将深入探讨如何通过 Astro 的 Islands 架构解决性能瓶颈,同时保留现有的 React 组件资产,实现构建速度与加载性能的双重提升。

2026 年 1 月,我们对 HagiCode 的官方站点进行了一次”心脏移植手术”——将核心框架从 Docusaurus 3.x 全面迁移至 Astro 5.x。这不是一次冲动的大重构,而是经过深思熟虑的技术抉择。

在迁移前,我们的站点虽然功能完善,但逐渐显露出一些”富贵病”:构建产物体积臃肿、JavaScript 负载过高,且页面加载速度在复杂文档页面下不够理想。作为一个 AI 代码助手项目,HagiCode 需要频繁更新文档和功能介绍,构建效率直接影响发布速度。同时,我们希望站点对搜索引擎(SEO)更加友好,以便让更多开发者发现这个项目。

为了解决这些痛点,我们做了一个大胆的决定:整个构建系统推倒重来,迁移到 Astro。这个决定带来的变化,可能比你想象的还要大——稍后我会具体说。

本文分享的站点迁移方案,来自我们在 HagiCode 项目中的实践经验。

HagiCode 是一款致力于提升开发效率的 AI 代码助手,我们不仅关注核心功能的迭代,同样重视开发者体验。这次站点的重构,也是为了让用户在浏览文档和官网时能获得极致的加载速度。

在 React 生态中,Docusaurus 一直是文档站点的”标准答案”。它开箱即用,插件丰富,社区活跃。但是,随着 HagiCode 功能的增加,我们也感受到了它的局限性:

  1. 性能瓶颈:Docusaurus 本质上是一个 React SPA(单页应用)。哪怕你是写纯静态页面,客户端也需要加载 React 运行时并进行水合,这对于简单的文档页面来说太重了。
  2. 资源体积:即便页面内容很少,打包后的 JS 体积也相对固定,这对移动端用户和网络较差的环境不够友好。
  3. 灵活性不足:虽然也能扩展,但在构建流程的定制上,我们渴望拥有更底层的控制权。

Astro 的出现正好解决了这些问题。它提供了一个全新的”岛屿架构”(Islands Architecture):默认情况下,Astro 生成零 JavaScript 的静态 HTML,只有需要交互的组件才会”激活”并加载 JS。这意味着我们的站点大部分内容都是纯 HTML,速度极快。

迁移不是简单的复制粘贴,而是思维模式的转变。我们从 Docusaurus 的”全 React 模式”切换到了 Astro 的”Core + Islands”模式。

首先,我们需要从 docusaurus.config.ts 转向 astro.config.mjs。这不仅是文件名的变化,更是路由和构建逻辑的重写。

在 Docusaurus 中,一切皆插件;而在 Astro 中,一切皆集成。我们需要重新定义站点的基础路径、构建输出模式(静态 vs SSR)以及资源压缩策略。

迁移前:

docusaurus.config.ts
export default {
title: 'HagiCode',
url: 'https://hagicode.com',
baseUrl: '/',
// ... 更多配置
};

迁移后:

astro.config.mjs
import { defineConfig } from 'astro/config';
import react from '@astrojs/react';
export default defineConfig({
integrations: [react()],
site: 'https://hagicode.com',
base: '/',
// 针对静态资源的优化配置
build: {
inlineStylesheets: 'auto',
},
});

这是迁移中最头疼的部分。我们现有的站点有很多 React 组件(比如 Tabs 组件、代码高亮、反馈按钮等)。直接扔掉太可惜,全都保留又会导致 JS 负载过重。

HagiCode 采用了渐进式水合策略:

  • 纯静态组件:对于展示型内容(如页眉、页脚、纯文本文档),重写为 Astro 组件(.astro 文件),在构建时直接渲染为 HTML。
  • 交互式岛屿:对于必须保留交互的组件(如主题切换器、Tabs 切换、代码块复制按钮),保留 React 实现,并添加 client:loadclient:visible 指令。

例如,我们的文档中常用的 Tabs 组件:

src/components/Tabs.jsx
import { useState } from 'react';
import './Tabs.css'; // 引入样式
export default function Tabs({ items }) {
const [activeIndex, setActiveIndex] = useState(0);
// ... 状态逻辑
return (
<div className="tabs-wrapper">
{/* 渲染逻辑 */}
</div>
);
}

在 Markdown 中使用时,我们明确告诉 Astro:“这个组件需要 JS”:

src/content/docs/example.mdx
import Tabs from '../../components/Tabs.jsx';
<!-- 只在组件进入视口时才加载 JS -->
<Tabs client:visible items={...} />

这样,非视口内的交互组件不会抢占带宽,极大地优化了首屏加载速度。

3. 样式系统的适配:CSS Modules 到 Scoped

Section titled “3. 样式系统的适配:CSS Modules 到 Scoped”

Docusaurus 默认支持 CSS Modules,而 Astro 推崇使用 Scoped CSS(通过 <style> 标签)。两者的核心思想都是隔离样式,但语法不同。

在 HagiCode 的迁移中,我们将大部分复杂的 CSS Modules 拆解为 Astro 的 Scoped 样式。这其实是件好事,因为在 .astro 文件中,样式和模板写在同一个文件里,维护起来更加直观。

改造前:

Tabs.module.css
.wrapper { background: var(--ifm-background-color); }

改造后 (Astro Scoped):

Tabs.astro
<div class="tabs-wrapper">
<slot />
</div>
<style>
.tabs-wrapper {
/* 直接使用 CSS 变量,适配主题 */
background: var(--bg-color);
padding: 1rem;
}
</style>

同时,我们统一了全局 CSS 变量系统,利用 Astro 的环境感知能力,确保暗色模式在不同页面间的切换无缝衔接。

在 HagiCode 的实际迁移过程中,我们遇到了不少坑,这里挑几个最典型的分享一下。

HagiCode 支持子路径部署(比如部署到 GitHub Pages 的子目录)。在 Docusaurus 中,它自动处理 baseUrl。但在 Astro 中,处理图片链接和 API 请求时,我们需要更小心。

我们引入了环境变量机制来统一管理:

// 在构建脚本中处理路径
const getBasePath = () => import.meta.env.VITE_SITE_BASE || '/';

切记,不要在代码中硬编码 / 开头的路径。在开发环境和生产环境,或者配置了 base 路径后,这会导致资源 404。

我们的旧站点有一些 Node.js 脚本(用于自动抓取 Metrics 数据、更新 sitemap 等),它们是用 CommonJS (require) 写的。Astro 和现代构建工具全面拥抱 ES Modules (import/export)。

如果你也有类似的脚本,记得把它们全部重构为 ES Modules。这是大势所趋,早点改了早点省心。

// 旧方式
const fs = require('fs');
// 新方式
import fs from 'fs';

搜索引擎已经收录了 HagiCode 旧的 Docusaurus 页面。如果直接切到 Astro,URL 结构发生变化,会导致大量 404,权重大跌。

我们在 Astro 中配置了重定向规则:

astro.config.mjs
export default defineConfig({
redirects: {
'/docs/old-path': '/docs/new-path',
// 批量映射旧链接到新链接
}
});

或者在服务器配置层面处理。确保旧链接能 301 重定向到新地址,这对 SEO 至关重要。

从 Docusaurus 迁移到 Astro,对 HagiCode 来说,不仅仅是一次框架升级,更是一次对”性能优先”理念的实践。

我们的收获:

  • 极致的 Lighthouse 分数:迁移后,HagiCode 站点的性能评分轻松接近满分。
  • 更快的构建速度:Astro 的增量构建让我们文档更新的发布时间缩短了一半。
  • 保留了灵活性:通过 Islands 架构,我们没有牺牲任何交互功能,依然可以在需要的地方使用 React。

如果你也在维护文档型站点,并且深受打包体积或加载速度的困扰,不妨试试 Astro。虽然迁移过程需要动动手术(比如把 PCode 的名字改成 HagiCode,把组件一个个挖过来),但换来的是如丝般顺滑的用户体验,绝对值得。

本文分享的构建系统,正是我们在开发 HagiCode 过程中实际踩坑、实际优化出来的方案。如果你觉得这套方案有价值,说明我们的工程实力还不错——那么 HagiCode 本身也值得关注一下。

如果本文对你有帮助,欢迎来 GitHub 给个 Star,公测已经开始啦!


感谢您的阅读,如果您觉得本文有用,快点击下方点赞按钮👍,让更多的人看到本文。

本内容采用人工智能辅助协作,经本人审核,符合本人观点与立场。

StreamJsonRpc 在 HagiCode 中的深度集成与实践

StreamJsonRpc 在 HagiCode 中的深度集成与实践

Section titled “StreamJsonRpc 在 HagiCode 中的深度集成与实践”

本文详细介绍了 HagiCode(原 PCode)项目如何成功集成 Microsoft 的 StreamJsonRpc 通信库,以替换原有的自定义 JSON-RPC 实现,并解决了集成过程中的技术痛点与架构挑战。

StreamJsonRpc 是微软官方维护的用于 .NET 和 TypeScript 的 JSON-RPC 通信库,以其强大的类型安全、自动代理生成和成熟的异常处理机制著称。在 HagiCode 项目中,为了通过 ACP (Agent Communication Protocol) 与外部 AI 工具(如 iflow CLI、OpenCode CLI)进行通信,并消除早期自定义 JSON-RPC 实现带来的维护成本和潜在 Bug,项目决定集成 StreamJsonRpc。然而,在集成过程中遇到了流式 JSON-RPC 特有的挑战,特别是在处理代理目标绑定和泛型参数识别时。

为了解决这些痛点,我们做了一个大胆的决定:整个构建系统推倒重来。这个决定带来的变化,可能比你想象的还要大——稍后我会具体说。

先介绍一下本文的”主角项目”

如果你在开发中遇到过这些烦恼:

  • 多项目、多技术栈,构建脚本维护成本高
  • CI/CD 流水线配置繁琐,每次改都要查文档
  • 跨平台兼容性问题层出不穷
  • 想让 AI 帮忙写代码,但现有工具不够智能

那么我们正在做的 HagiCode 可能你会感兴趣。

HagiCode 是什么?

  • 一款 AI 驱动的代码智能助手
  • 支持多语言、跨平台的代码生成与优化
  • 内置游戏化机制,让编码不再枯燥

为什么在这里提它? 本文分享的 StreamJsonRpc 集成方案,正是我们在开发 HagiCode 过程中实践总结出来的。如果你觉得这套工程化方案有价值,说明我们的技术品味还不错——那么 HagiCode 本身也值得关注一下。

想了解更多?

当前项目处于 ACP 协议集成的关键阶段,面临着以下几个技术痛点和架构挑战:

原有的 JSON-RPC 实现位于 src/HagiCode.ClaudeHelper/AcpImp/,包含 JsonRpcEndpointClientSideConnection 等组件。维护这套自定义代码成本高,且缺乏成熟库的高级功能(如进度报告、取消支持)。

在尝试将现有的 CallbackProxyTarget 模式迁移到 StreamJsonRpc 时,发现 _rpc.AddLocalRpcTarget(target) 方法无法识别通过代理模式创建的目标。具体表现为,StreamJsonRpc 无法自动将泛型类型 T 的属性拆分为 RPC 方法参数,导致服务器端无法正确处理客户端发起的方法调用。

现有的 ClientSideConnection 混合了传输层(WebSocket/Stdio)、协议层(JSON-RPC)和业务层(ACP Agent 接口),导致职责不清,且存在 AcpAgentCallbackRpcAdapter 方法绑定缺失的问题。

WebSocket 传输层缺少对原始 JSON 内容的日志输出,导致在调试 RPC 通信问题时难以定位是序列化问题还是网络问题。

针对上述问题,我们采用了以下系统化的解决方案,从架构重构、库集成和调试增强三个维度进行优化:

删除 JsonRpcEndpoint.csAgentSideConnection.cs 及相关的自定义序列化转换器(JsonRpcMessageJsonConverter 等)。

引入 StreamJsonRpc NuGet 包,利用其 JsonRpc 类处理核心通信逻辑。

定义 IAcpTransport 接口,统一处理 WebSocketStdio 两种传输模式,确保协议层与传输层解耦。

// IAcpTransport 接口定义
public interface IAcpTransport
{
Task SendAsync(string message, CancellationToken cancellationToken = default);
Task<string> ReceiveAsync(CancellationToken cancellationToken = default);
Task CloseAsync(CancellationToken cancellationToken = default);
}
// WebSocket 传输实现
public class WebSocketTransport : IAcpTransport
{
private readonly WebSocket _webSocket;
public WebSocketTransport(WebSocket webSocket)
{
_webSocket = webSocket;
}
// 实现发送和接收方法
// ...
}
// Stdio 传输实现
public class StdioTransport : IAcpTransport
{
private readonly StreamReader _reader;
private readonly StreamWriter _writer;
public StdioTransport(StreamReader reader, StreamWriter writer)
{
_reader = reader;
_writer = writer;
}
// 实现发送和接收方法
// ...
}

检查现有的动态代理生成逻辑,确定 StreamJsonRpc 无法识别的根本原因(通常是因为代理对象没有公开实际的方法签名,或者使用了 StreamJsonRpc 不支持的参数类型)。

将泛型属性拆分为明确的 RPC 方法参数。不再依赖动态属性,而是定义具体的 Request/Response DTO(数据传输对象),确保 StreamJsonRpc 能通过反射正确识别方法签名。

// 原有的泛型属性方式
public class CallbackProxyTarget<T>
{
public Func<T, Task> Callback { get; set; }
}
// 重构后的具体方法方式
public class ReadTextFileRequest
{
public string FilePath { get; set; }
}
public class ReadTextFileResponse
{
public string Content { get; set; }
}
public interface IAcpAgentCallback
{
Task<ReadTextFileResponse> ReadTextFileAsync(ReadTextFileRequest request);
// 其他方法...
}

在某些复杂场景下,手动代理 JsonRpc 对象并处理 RpcConnection 可能比直接添加目标更灵活。

确保该组件显式实现 StreamJsonRpc 的代理接口,将 ACP 协议定义的方法(如 ReadTextFileAsync)映射到 StreamJsonRpc 的回调处理器上。

在 WebSocket 或 Stdio 的消息处理管道中,拦截并记录 JSON-RPC 请求和响应的原始文本。利用 ILogger 在解析前和序列化后输出原始 payload,以便排查格式错误。

// 日志增强的传输包装器
public class LoggingAcpTransport : IAcpTransport
{
private readonly IAcpTransport _innerTransport;
private readonly ILogger<LoggingAcpTransport> _logger;
public LoggingAcpTransport(IAcpTransport innerTransport, ILogger<LoggingAcpTransport> logger)
{
_innerTransport = innerTransport;
_logger = logger;
}
public async Task SendAsync(string message, CancellationToken cancellationToken = default)
{
_logger.LogTrace("Sending message: {Message}", message);
await _innerTransport.SendAsync(message, cancellationToken);
}
public async Task<string> ReceiveAsync(CancellationToken cancellationToken = default)
{
var message = await _innerTransport.ReceiveAsync(cancellationToken);
_logger.LogTrace("Received message: {Message}", message);
return message;
}
public async Task CloseAsync(CancellationToken cancellationToken = default)
{
_logger.LogDebug("Closing connection");
await _innerTransport.CloseAsync(cancellationToken);
}
}

封装 StreamJsonRpc 连接,负责 InvokeAsync 和连接生命周期管理。

public class AcpRpcClient : IDisposable
{
private readonly JsonRpc _rpc;
private readonly IAcpTransport _transport;
public AcpRpcClient(IAcpTransport transport)
{
_transport = transport;
_rpc = new JsonRpc(new StreamRpcTransport(transport));
_rpc.StartListening();
}
public async Task<TResponse> InvokeAsync<TResponse>(string methodName, object parameters)
{
return await _rpc.InvokeAsync<TResponse>(methodName, parameters);
}
public void Dispose()
{
_rpc.Dispose();
_transport.Dispose();
}
// StreamRpcTransport 是对 IAcpTransport 的 StreamJsonRpc 适配器
private class StreamRpcTransport : IDuplexPipe
{
// 实现 IDuplexPipe 接口
// ...
}
}

协议层 (IAcpAgentClient / IAcpAgentCallback)

Section titled “协议层 (IAcpAgentClient / IAcpAgentCallback)”

定义清晰的 client-to-agent 和 agent-to-client 接口,移除 Func<IAcpAgent, IAcpClient> 这种循环依赖的工厂模式,改用依赖注入或直接注册回调。

基于 StreamJsonRpc 的最佳实践和项目经验,以下是实施过程中的关键建议:

StreamJsonRpc 的核心优势在于强类型。不要使用 dynamicJObject 传递参数。应为每个 RPC 方法定义明确的 C# POCO 类作为参数。这不仅解决了代理目标识别问题,还能在编译时发现类型错误。

示例:将 CallbackProxyTarget 中的泛型属性替换为 ReadTextFileRequestWriteTextFileRequest 等具体类。

使用 [JsonRpcMethod] 特性显式指定 RPC 方法名称,不要依赖默认的方法名映射。这可以防止因命名风格差异(如 PascalCase vs camelCase)导致的调用失败。

public interface IAcpAgentCallback
{
[JsonRpcMethod("readTextFile")]
Task<ReadTextFileResponse> ReadTextFileAsync(ReadTextFileRequest request);
[JsonRpcMethod("writeTextFile")]
Task WriteTextFileAsync(WriteTextFileRequest request);
}

StreamJsonRpc 提供了 JsonRpc.ConnectionLost 事件。务必监听此事件以处理进程意外退出或网络断开的情况,这比单纯依赖 Orleans 的 Grain 失效检测更及时。

_rpc.ConnectionLost += (sender, e) =>
{
_logger.LogError("RPC connection lost: {Reason}", e.ToString());
// 处理重连逻辑或通知用户
};
  • Trace 级别:记录完整的 JSON Request/Response 原文。
  • Debug 级别:记录方法调用栈和参数摘要。
  • 注意:确保日志中不包含敏感的 Authorization Token 或大文件内容的 Base64 编码。

StreamJsonRpc 原生支持 IAsyncEnumerable。在实现 ACP 的流式 Prompt 响应时,应直接使用 IAsyncEnumerable 而不是自定义的分页逻辑。这能极大简化流式处理的代码量。

public interface IAcpAgentCallback
{
[JsonRpcMethod("streamText")]
IAsyncEnumerable<string> StreamTextAsync(StreamTextRequest request);
}

保持 ACPSessionClientSideConnection 的分离。ACPSession 应专注于 Orleans 的状态管理和业务逻辑(如消息入队),通过组合而非继承的方式使用 StreamJsonRpc 连接对象。

通过全面集成 StreamJsonRpc,HagiCode 项目成功解决了原自定义实现的维护成本高、功能局限性和架构分层混乱等问题。关键改进包括:

  1. 采用强类型 DTO 替代动态属性,提高了代码的可维护性和可靠性
  2. 实现了传输层抽象和协议层分离,提升了架构的清晰性
  3. 增强了日志记录功能,便于排查通信问题
  4. 引入了流式传输支持,简化了流式处理的实现

这些改进为 HagiCode 提供了更稳定、更高效的通信基础,使其能够更好地与外部 AI 工具进行交互,并为未来的功能扩展奠定了坚实的基础。


如果本文对你有帮助:


感谢您的阅读,如果您觉得本文有用,快点击下方点赞按钮👍,让更多的人看到本文。

本内容采用人工智能辅助协作,经本人审核,符合本人观点与立场。

利用 Worker Threads 优化 Vite 构建性能的实战

120秒到45秒:利用 Worker Threads 优化 Vite 构建性能的实战

Section titled “120秒到45秒:利用 Worker Threads 优化 Vite 构建性能的实战”

在处理大型前端项目时,生产环境的代码构建往往让人望眼欲穿。本文分享如何通过 Node.js Worker Threads 将 Vite 构建中的代码混淆环节耗时从 120 秒降低至 45 秒,并详细介绍 HagiCode 项目中的实施细节与踩坑经验。

在我们的前端工程化实践中,随着项目规模的扩大,构建效率问题逐渐凸显。特别是在生产环境构建流程中,为了保护源码逻辑,我们通常会引入 JavaScript 混淆工具(如 javascript-obfuscator)。这一步虽然必要,但计算量巨大,极其消耗 CPU 资源。

HagiCode项目的早期开发阶段,我们遇到了一个非常棘手的性能瓶颈:生产构建时间随着代码量的增加迅速恶化。

具体痛点如下

  • 单线程串行执行混淆任务,CPU 单核跑满,其他核心闲置
  • 构建时间从最初的 30 秒飙升至 110-120 秒
  • 每次修改代码后的构建验证流程极其漫长,严重拖慢了开发迭代效率
  • CI/CD 流水线中,构建环节成为最耗时的部分

为什么 HagiCode 会有这个需求? HagiCode 是一款 AI 驱动的代码智能助手,其前端架构包含复杂的业务逻辑和 AI 交互模块。为了确保核心代码的安全性,我们在生产发布时强制开启了高强度混淆。面对长达两分钟的构建等待,我们决定对构建系统进行一次深度的性能优化。

既然提到了这个项目,不妨多介绍两句。

如果你在开发中遇到过这些烦恼:

  • 多项目、多技术栈,构建脚本维护成本高
  • CI/CD 流水线配置繁琐,每次改都要查文档
  • 跨平台兼容性问题层出不穷
  • 想让 AI 帮忙写代码,但现有工具不够智能

那么我们正在做的 HagiCode 可能你会感兴趣。

HagiCode 是什么?

  • 一款 AI 驱动的代码智能助手
  • 支持多语言、跨平台的代码生成与优化
  • 内置游戏化机制,让编码不再枯燥

为什么在这里提它? 本文分享的 JavaScript 并行混淆方案,正是我们在开发 HagiCode 过程中实践总结出来的。如果你觉得这套工程化方案有价值,说明我们的技术品味还不错——那么 HagiCode 本身也值得关注一下。

想了解更多?


在着手解决性能问题之前,我们需要先理清思路,确定最优的技术方案。

核心决策:为什么选择 Worker Threads?

Section titled “核心决策:为什么选择 Worker Threads?”

Node.js 环境下实现并行计算主要有三种方案:

  1. child_process:创建独立的子进程
  2. Web Workers:主要用于浏览器端
  3. worker_threads:Node.js 原生多线程支持

经过对比分析,HagiCode 最终选择了 Worker Threads,原因如下:

  • 零序列化开销:Worker Threads 位于同一进程,可以通过 SharedArrayBuffer 或转移控制权的方式共享内存,避免了进程间通信的大额序列化成本。
  • 原生支持:Node.js 12+ 版本内置支持,无需引入额外的重依赖。
  • 上下文统一:调试和日志记录比子进程更方便。

任务粒度:如何拆分混淆任务?

Section titled “任务粒度:如何拆分混淆任务?”

混淆一个巨大的 JS Bundle 文件很难并行(因为代码有依赖关系),但 Vite 的构建产物是由多个 Chunk 组成的。这给了我们一个天然的并行边界:

  • 独立性:Vite 打包后的不同 Chunk 之间依赖关系已解耦,可以安全地并行处理。
  • 粒度适中:通常项目会有 10-30 个 Chunk,这个数量级非常适合并行调度。
  • 易于集成:Vite 插件的 generateBundle 钩子允许我们在文件生成前拦截并处理这些 Chunk。

我们设计了一个包含四个核心组件的并行处理系统:

  1. Task Splitter:遍历 Vite 的 bundle 对象,过滤不需要混淆的文件(如 vendor),生成任务队列。
  2. Worker Pool Manager:管理 Worker 的生命周期,负责任务的分发、回收和错误重试。
  3. Progress Reporter:实时输出构建进度,消除用户的等待焦虑。
  4. ObfuscationWorker:实际执行混淆逻辑的工作线程。

基于上述分析,我们开始动手实现这套并行混淆系统。

首先,我们在 vite.config.ts 中集成并行混淆插件。配置非常直观,只需指定 Worker 数量和混淆规则。

import { defineConfig } from 'vite'
import { parallelJavascriptObfuscator } from './buildTools/plugin'
export default defineConfig(({ mode }) => {
const isProduction = mode === 'production'
return {
build: {
rollupOptions: {
...(isProduction
? {
plugins: [
parallelJavascriptObfuscator({
enabled: true,
// 根据 CPU 核心数自动调整,建议留出一个核心给主线程
workerCount: 4,
retryAttempts: 3,
fallbackToMainThread: true, // 出错时自动降级为单线程
// 过滤掉 vendor chunk,通常不需要混淆第三方库
isVendorChunk: (fileName: string) => fileName.includes('vendor-'),
obfuscationConfig: {
compact: true,
controlFlowFlattening: true,
deadCodeInjection: true,
disableConsoleOutput: true,
// ... 更多混淆选项
},
}),
],
}
: {}),
},
},
}
})

Worker 是执行任务的单元。我们需要定义好输入和输出的数据结构。

注意:这里的代码虽然简单,但有几个坑点需要注意。比如 parentPort 的空值检查,以及错误处理。在 HagiCode 的实践中,我们发现有些特殊的 ES6 语法可能会导致混淆器崩溃,所以加上了 try-catch 保护。

import { parentPort } from 'worker_threads'
import javascriptObfuscator from 'javascript-obfuscator'
export interface ObfuscationTask {
chunkId: string
code: string
config: any
}
export interface ObfuscationResult {
chunkId: string
obfuscatedCode: string
error?: string
}
// 监听主线程发来的任务
if (parentPort) {
parentPort.on('message', async (task: ObfuscationTask) => {
try {
// 执行混淆
const obfuscated = javascriptObfuscator.obfuscate(task.code, task.config)
const result: ObfuscationResult = {
chunkId: task.chunkId,
obfuscatedCode: obfuscated.getObfuscatedCode(),
}
// 将结果发回主线程
parentPort?.postMessage(result)
} catch (error) {
// 处理异常,确保单个 Worker 崩溃不会阻塞整个构建
const result: ObfuscationResult = {
chunkId: task.chunkId,
obfuscatedCode: '',
error: error instanceof Error ? error.message : 'Unknown error',
}
parentPort?.postMessage(result)
}
})
}

这是整个方案的核心。我们需要维护一个固定大小的 Worker 池,采用 FIFO(先进先出) 策略调度任务。

import { Worker } from 'worker_threads'
import os from 'os'
export class WorkerPool {
private workers: Worker[] = []
private taskQueue: Array<{
task: ObfuscationTask
resolve: (result: ObfuscationResult) => void
reject: (error: Error) => void
}> = []
constructor(options: WorkerPoolOptions = {}) {
// 默认为核心数 - 1,给主线程留一点喘息的空间
const workerCount = options.workerCount ?? Math.max(1, (os.cpus().length || 4) - 1)
for (let i = 0; i < workerCount; i++) {
this.createWorker()
}
}
private createWorker() {
const worker = new Worker('./worker.ts')
worker.on('message', (result) => {
// 任务完成后,从队列中取出下一个任务
const nextTask = this.taskQueue.shift()
if (nextTask) {
this.dispatchTask(worker, nextTask)
} else {
// 如果没有待处理任务,标记 Worker 为空闲
this.activeWorkers.delete(worker)
}
})
this.workers.push(worker)
}
// 提交任务到池中
public runTask(task: ObfuscationTask): Promise<ObfuscationResult> {
return new Promise((resolve, reject) => {
const job = { task, resolve, reject }
const idleWorker = this.workers.find(w => !this.activeWorkers.has(w))
if (idleWorker) {
this.dispatchTask(idleWorker, job)
} else {
this.taskQueue.push(job)
}
})
}
private dispatchTask(worker: Worker, job: any) {
this.activeWorkers.set(worker, job.task)
worker.postMessage(job.task)
}
}

等待是痛苦的,尤其是不知道还要等多久。我们增加了一个简单的进度报告器,实时反馈当前状态。

export class ProgressReporter {
private completed = 0
private readonly total: number
private readonly startTime: number
constructor(total: number) {
this.total = total
this.startTime = Date.now()
}
increment(): void {
this.completed++
this.report()
}
private report(): void {
const now = Date.now()
const elapsed = now - this.startTime
const percentage = (this.completed / this.total) * 100
// 简单的 ETA 估算
const avgTimePerChunk = elapsed / this.completed
const remaining = (this.total - this.completed) * avgTimePerChunk
console.log(
`[Parallel Obfuscation] ${this.completed}/${this.total} chunks completed (${percentage.toFixed(1)}%) | ETA: ${(remaining / 1000).toFixed(1)}s`
)
}
}

部署这套方案后,HagiCode 项目的构建性能有了立竿见影的提升。

我们在以下环境进行了测试:

  • CPU:Intel Core i7-12700K (12 cores / 20 threads)
  • RAM:32GB DDR4
  • Node.js:v18.17.0
  • OS:Ubuntu 22.04

结果对比

  • 单线程(优化前):118 秒
  • 4 Workers:55 秒(提升 53%
  • 8 Workers:48 秒(提升 60%
  • 12 Workers:45 秒(提升 62%

可以看出,收益并不是线性的。当 Worker 数量超过 8 个后,提升幅度变小。这主要受限于任务分配的均匀度和内存带宽瓶颈。

在 HagiCode 的实际使用中,我们也遇到了一些坑,这里分享给大家:

Q1: 构建时间没有明显减少,反而变慢了?

  • 原因:Worker 创建本身有开销,或者 Worker 数量设置过多导致上下文切换频繁。
  • 解决:建议 Worker 数量设置为 CPU 核心数 - 1。同时检查是否有单个 Chunk 特别大(例如 > 5MB),这种”巨无霸”文件会成为短板,可以考虑优化代码分割策略。

Q2: 偶尔出现 Worker 崩溃,构建失败?

  • 原因:某些特殊的代码语法可能导致混淆器内部报错。
  • 解决:我们实现了 自动降级机制。当 Worker 连续失败次数达到阈值时,插件会自动回退到单线程模式,确保构建不中断。同时记录下错误的文件名,方便后续针对性修复。

Q3: 内存占用过高(OOM)?

  • 原因:每个 Worker 都需要独立内存空间来加载混淆器和解析 AST。
  • 解决
    • 减少 Worker 数量。
    • 增加 Node.js 的内存限制:NODE_OPTIONS="--max-old-space-size=4096" npm run build
    • 确保不在 Worker 内部持有不必要的大对象引用。

通过引入 Node.js Worker Threads,我们成功将 HagiCode 项目的生产构建时间从 120 秒降低到了 45 秒左右,极大提升了开发体验和 CI/CD 效率。

这套方案的核心在于:

  1. 合理拆分任务:利用 Vite 的 Chunk 作为并行单元。
  2. 资源控制:使用 Worker 池避免资源耗尽。
  3. 容错设计:自动降级机制确保构建稳定性。

如果你也在为前端构建效率发愁,或者你的项目也在做重度代码处理,不妨试试这套方案。当然,更推荐你直接关注我们的 HagiCode 项目,这些工程化的细节都已经集成在里面了。

如果本文对你有帮助,欢迎来 GitHub 给个 Star,或者参与公测体验一下~


感谢您的阅读,如果您觉得本文有用,快点击下方点赞按钮👍,让更多的人看到本文。

本内容采用人工智能辅助协作,经本人审核,符合本人观点与立场。

HagiCode 实践:如何利用 GitHub Actions 实现 Docusaurus 自动部署

为 HagiCode 添加 GitHub Pages 自动部署支持

Section titled “为 HagiCode 添加 GitHub Pages 自动部署支持”

本项目早期代号为 PCode,现已正式更名为 HagiCode。本文记录了如何为项目引入自动化静态站点部署能力,让内容发布像喝水一样简单。

在 HagiCode 的开发过程中,我们遇到了一个很现实的问题:随着文档和提案越来越多,如何高效地管理和展示这些内容成了当务之急。我们决定引入 GitHub Pages 来托管我们的静态站点,但是手动构建和部署实在是太麻烦了——每次改动都要本地构建、打包,然后手动推送到 gh-pages 分支。这不仅效率低下,还容易出错。

为了解决这个问题(主要是为了偷懒),我们需要一套自动化的部署流程。本文将详细记录如何为 HagiCode 项目添加 GitHub Actions 自动部署支持,让我们只需专注于内容创作,剩下的交给自动化流程。

嘿,介绍一下我们正在做的东西

我们正在开发 HagiCode——一款 AI 驱动的代码智能助手,让开发体验变得更智能、更便捷、更有趣。

智能——AI 全程辅助,从想法到代码,让编码效率提升数倍。便捷——多线程并发操作,充分利用资源,开发流程顺畅无阻。有趣——游戏化机制和成就系统,让编码不再枯燥,充满成就感。

项目正在快速迭代中,如果你对技术写作、知识管理或者 AI 辅助开发感兴趣,欢迎来 GitHub 看看~

在动手之前,我们得先明确这次任务到底要干啥。毕竟(这里打错了,应该是毕竟)磨刀不误砍柴工嘛。

  1. 自动化构建:当代码推送到 main 分支时,自动触发构建流程。
  2. 自动部署:构建成功后,自动将生成的静态文件部署到 GitHub Pages。
  3. 环境一致性:确保 CI 环境和本地构建环境一致,避免”本地能跑,线上报错”的尴尬。

考虑到 HagiCode 是基于 Docusaurus 构建的(一种非常流行的 React 静态站点生成器),我们可以利用 GitHub Actions 来实现这一目标。

GitHub Actions 是 GitHub 提供的 CI/CD 服务。通过在代码仓库中定义 YAML 格式的工作流文件,我们可以定制各种自动化任务。

我们需要在项目根目录下的 .github/workflows 文件夹中创建一个新的配置文件,比如叫 deploy.yml。如果文件夹不存在,记得先手动创建一下。

这个配置文件的核心逻辑如下:

  1. 触发条件:监听 main 分支的 push 事件。
  2. 运行环境:最新版的 Ubuntu。
  3. 构建步骤
    • 检出代码
    • 安装 Node.js
    • 安装依赖 (npm install)
    • 构建静态文件 (npm run build)
  4. 部署步骤:使用官方提供的 action-gh-pages 将构建产物推送到 gh-pages 分支。

以下是我们最终采用的配置模板:

name: Deploy to GitHub Pages
# 触发条件:当推送到 main 分支时
on:
push:
branches:
- main
# 可以根据需要添加路径过滤,比如只有文档变动才构建
# paths:
# - 'docs/**'
# - 'package.json'
# 设置权限,这对于部署到 GitHub Pages 很重要
permissions:
contents: read
pages: write
id-token: write
# 并发控制:取消同一分支的旧构建
concurrency:
group: "pages"
cancel-in-progress: false
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
# 注意:必须设置 fetch-depth: 0,否则可能导致构建版本号不准确
with:
fetch-depth: 0
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 20 # 建议与本地开发环境保持一致
cache: 'npm' # 启用缓存可以加速构建过程
- name: Install dependencies
run: npm ci
# 使用 npm ci 而不是 npm install,因为它更快、更严格,适合 CI 环境
- name: Build website
run: npm run build
env:
# 如果你的站点构建需要环境变量,在这里配置
# NODE_ENV: production
# PUBLIC_URL: /your-repo-name
- name: Upload artifact
uses: actions/upload-pages-artifact@v3
with:
path: ./build # Docusaurus 默认输出目录
deploy:
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
runs-on: ubuntu-latest
needs: build
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4

在实际操作中,我们遇到了一些问题,这里分享出来希望大家能避开(或者提前准备好解决方案)。

最开始配置的时候,部署总是报错 403 (Forbidden)。查了好久才发现,是因为 GitHub 默认的 GITHUB_TOKEN 并没有写入 Pages 的权限。

解决方案:在仓库的 Settings -> Actions -> General -> Workflow permissions 中,务必选择 “Read and write permissions”

Docusaurus 默认把构建好的静态文件放在 build 目录。但是有些项目(比如 Create React App 默认是 build,Vite 默认是 dist)可能配置不一样。如果在 Actions 中报错找不到文件,记得去 docusaurus.config.js 里检查一下输出路径配置。

如果你的仓库不是用户主页(即不是 username.github.io),而是项目主页(比如 username.github.io/project-name),你需要配置 baseUrl

docusaurus.config.js 中:

module.exports = {
// ...
url: 'https://hagicode.com', // 你的 Hagicode URL
baseUrl: '/', // 根路径部署
// ...
};

这一点很容易被忽略,配置不对会导致页面打开全是白屏,因为资源路径加载不到。

配置完所有东西并推送代码后,我们就可以去 GitHub 仓库的 Actions 标签页看戏了。

你会看到黄色的圆圈(工作流正在运行),变绿就代表成功啦!如果变红了,点击进去查看日志,通常都能排查出问题(大部分时候是拼写错误或者路径配置不对)。

构建成功后,访问 https://<你的用户名>.github.io/<仓库名>/ 就能看到崭新的站点了。

通过引入 GitHub Actions,我们成功实现了 HagiCode 文档站的自动化部署。这不仅节省了手动操作的时间,更重要的是保证了发布流程的标准化。现在不管是哪位小伙伴更新了文档,只要合并到 main 分支,几分钟后就能在线上看到最新的内容。

核心收益

  • 效率提升:从”手动打包、手动上传”变成”代码即发布”。
  • 降低错误:消除了人为操作失误的可能性。
  • 体验优化:让开发者更专注于内容质量,而不是被繁琐的部署流程困扰。

虽然配置 CI/CD 刚开始有点麻烦(尤其是各种权限和路径问题),但这是一次性投入,长期回报巨大的工作。强烈建议所有静态站点项目都接入类似的自动化流程。


感谢您的阅读,如果您觉得本文有用,快点击下方点赞按钮👍,让更多的人看到本文。

本内容采用人工智能辅助协作,经本人审核,符合本人观点与立场。

基于 C# 和 Nuke 打造现代化构建系统的最佳实践

告别脚本地狱:为什么我们选择用 C# 打造现代化构建系统

Section titled “告别脚本地狱:为什么我们选择用 C# 打造现代化构建系统”

揭秘 HagiCode 项目如何利用 Nuke 实现类型安全、跨平台且高度可扩展的自动化构建流程,彻底解决传统构建脚本的维护痛点。

在软件开发的漫长旅途中,“构建”这个词往往让人又爱又恨。爱的是,一键点击,代码变成产品,那是程序员最迷人的时刻;恨的是,维护那一堆乱糟糟的构建脚本,简直是噩梦。

在很多项目中,我们习惯了用 Python 写脚本,或者用 XML 配置文件(想象一下那段被 <property> 支配的恐惧)。但随着项目复杂度的提升,尤其是像 HagiCode 这样涉及前后端、多平台、多语言混合开发的项目,传统的构建方式开始显得力不从心。脚本逻辑分散、缺乏类型检查、IDE 支持弱……这些问题像一个个小坑,时不时就让开发团队绊个跟头。

为了解决这些痛点,在 HagiCode 项目中,我们决定引入 Nuke —— 一个基于 C# 的现代化构建系统。它不仅仅是一个工具,更像是一种对构建流程的重新思考。今天,我们就来聊聊为什么选择它,以及它是如何让我们的开发体验”起飞”的。

嘿,介绍一下我们正在做的东西

我们正在开发 HagiCode —— 一款 AI 驱动的代码智能助手,让开发体验变得更智能、更便捷、更有趣。

智能 —— AI 全程辅助,从想法到代码,让编码效率提升数倍。便捷 —— 多线程并发操作,充分利用资源,开发流程顺畅无阻。有趣 —— 游戏化机制和成就系统,让编码不再枯燥,充满成就感。

项目正在快速迭代中,如果你对技术写作、知识管理或者 AI 辅助开发感兴趣,欢迎来 GitHub 看看~

你可能心里会犯嘀咕:“哎呀,构建系统那么多,比如 Make、Gradle,甚至直接用 Shell 脚本不行吗?为啥非得整一个 C# 的?”

这其实是个好问题。Nuke 的核心魅力在于它把我们最熟悉的编程语言特性带进了构建脚本的世界。

1. 将构建流程模块化:Target 的艺术

Section titled “1. 将构建流程模块化:Target 的艺术”

Nuke 的设计理念非常清晰:一切皆为目标

在传统的脚本里,我们可能会写出几百行线性执行的代码,逻辑错综复杂。而在 Nuke 中,我们将构建流程分解为独立的 Target(目标)。每个目标只负责一件事,比如:

  • Clean: 清理输出目录
  • Restore: 还原依赖包
  • Compile: 编译代码
  • Test: 运行单元测试

这种设计非常符合单一职责原则。就像搭积木一样,我们可以随意组合这些 Target。更重要的是,Nuke 允许我们定义 Target 之间的依赖关系。比如,你想要 Test,那系统会自动检查你是否先执行了 Compile;想要 Compile,自然得先 Restore

这种依赖关系图不仅让逻辑更清晰,还极大地提高了执行效率,Nuke 会自动分析最优执行路径。

2. 类型安全:告别拼写错误的噩梦

Section titled “2. 类型安全:告别拼写错误的噩梦”

用过 Python 写构建脚本的朋友肯定遇到过这种尴尬:脚本跑了五分钟,最后报错说 Confi.guration 拼写错了,或者传了一个字符串给了一个本该是数字的参数。

使用 C# 编写构建脚本最大的优势就是 类型安全。这意味着:

  • 编译时检查:你在敲代码的时候,IDE 就会告诉你哪里错了,不用等到运行时才发现。
  • 重构无忧:如果你想改个变量名或者方法名,IDE 的重构功能一键搞定,不用全局搜索替换提心吊胆。
  • 智能提示:强大的 IntelliSense 会自动补全代码,你不需要去翻文档记那些生僻的 API。

以前在 Windows 上写 .bat,在 Linux 上写 .sh,为了兼容两者,还得写个 Python 脚本。现在,只要是 .NET Core(现 .NET 5+)能跑的地方,Nuke 就能跑。

这意味着无论团队成员是使用 Windows、Linux 还是 macOS,无论是用 Visual Studio、VS Code 还是 Rider,大家执行的都是同一套逻辑。这就极大地消除了”在我机器上能跑”这类环境差异导致的问题。

Nuke 提供了一套非常优雅的参数解析机制。你不需要手动去解析 string[] args,只需要定义一个属性,加上 [Parameter] 特性,Nuke 就会自动处理命令行参数和配置文件的映射。

比如,我们可以轻松定义构建配置:

[Parameter("Configuration to build - Default is 'Debug'")]
readonly Configuration BuildConfiguration = IsLocalBuild ? Configuration.Debug : Configuration.Release;
Target Compile => _ => _
.DependsOn(Restore)
.Executes(() =>
{
// 在这里使用 BuildConfiguration,它是类型安全的
DotNetBuild(s => s
.SetConfiguration(BuildConfiguration)
.SetProjectFile(SolutionFile));
});

这种写法既直观又不容易出错。

空谈误国,实干兴邦。让我们看看在 HagiCode 项目中,具体是怎么落地这套方案的。

我们不想让构建脚本污染项目根目录,也不想搞得像某些 Java 项目那样目录结构深不见底。所以,我们将所有与 Nuke 相关的构建文件统一放置在 nukeBuild/ 文件夹中。

这样做的好处是:

  • 项目根目录保持清爽。
  • 构建逻辑内聚,方便管理。
  • 新成员加入时,一眼就能看到”哦,这是构建相关的逻辑”。

在设计 Target 时,我们遵循了一个原则:原子化 + 依赖流

每个 Target 应该足够小,只做一件事。比如 Clean 就只管删文件,不要在里面顺便做打包。

推荐的依赖流大概是这个样子的:

Clean -> Restore -> Compile -> Test -> Pack

当然,这不是绝对的。比如如果你只想跑个测试,不想打包,Nuke 允许你直接执行 nuke Test,它会自动处理好前置的 Restore 和 Compile 步骤。

构建脚本最怕的是什么?是报错信息不明确。比如构建失败了,日志只显示 “Error: 1”,这就让人很抓狂。

在 Nuke 中,由于我们可以直接使用 C# 的异常处理机制,因此可以非常精确地捕获和报告错误。

Target Publish => _ => _
.DependsOn(Test)
.Executes(() =>
{
try
{
// 尝试发布到 NuGet
DotNetNuGetPush(s => s
.SetTargetPath(ArtifactPath)
.SetSource("https://api.nuget.org/v3/index.json")
.SetApiKey(ApiKey));
}
catch (Exception ex)
{
Log.Error($"发布失败了,兄弟们检查一下 Key 对不对: {ex.Message}");
throw; // 确保构建进程以非零退出码结束
}
});

构建脚本本身也是代码,也需要测试。Nuke 允许我们为构建流程编写测试,确保当我们修改了构建逻辑后,不会破坏现有的发布流程。这在持续集成(CI)流水线中尤为重要。

通过引入 Nuke,HagiCode 的构建流程变得前所未有的顺畅。它不仅仅是一个工具的替换,更是工程化思维的提升。

我们收获了什么?

  • 可维护性:代码即配置,逻辑清晰,新人也能快速上手。
  • 稳定性:强类型检查减少了 90% 以上的低级错误。
  • 一致性:跨平台的统一体验,消除了环境差异。

如果说以前写构建脚本是”在黑暗中摸索”,那么使用 Nuke 就像是”开着灯走夜路”。如果你受够了维护那些难以调试的脚本语言,不妨试试把构建逻辑也搬到 C# 的世界里来,也许你会发现,原来构建也可以这么优雅。


感谢您的阅读,如果您觉得本文有用,快点击下方点赞按钮👍,让更多的人看到本文。

本内容采用人工智能辅助协作,经本人审核,符合本人观点与立场。

如何使用 GitHub Actions + image-syncer 实现 Docker Hub 到 Azure ACR 的自动化镜像同步

实现 Docker Hub 到 Azure ACR 的自动化镜像同步

Section titled “实现 Docker Hub 到 Azure ACR 的自动化镜像同步”

本文介绍了如何使用 GitHub Actions 和 image-syncer 工具,实现 Docker Hub 镜像到 Azure Container Registry 的自动化同步,解决了国内及部分 Azure 区域访问 Docker Hub 速度慢的问题,提升了镜像的可用性和 Azure 环境的部署效率。

HagiCode 项目使用 Docker 镜像作为核心运行时组件,主要镜像托管在 Docker Hub。随着项目发展和 Azure 环境部署需求的增加,我们遇到了以下痛点:

  • 镜像拉取速度慢,Docker Hub 在国内及部分 Azure 区域访问受限
  • 依赖单一镜像源存在单点故障风险
  • Azure 环境下使用 Azure Container Registry 能获得更好的网络性能和集成体验

为解决这些问题,我们需要建立一个自动化的镜像同步机制,将 Docker Hub 的镜像定期同步到 Azure ACR,确保用户能够在 Azure 环境中获得更快的镜像拉取速度和更高的可用性。

我们正在开发 HagiCode——一款 AI 驱动的代码智能助手,让开发体验变得更智能、更便捷、更有趣。

智能——AI 全程辅助,从想法到代码,让编码效率提升数倍。便捷——多线程并发操作,充分利用资源,开发流程顺畅无阻。有趣——游戏化机制和成就系统,让编码不再枯燥,充满成就感。

项目正在快速迭代中,如果你对技术写作、知识管理或者 AI 辅助开发感兴趣,欢迎来 GitHub 看看。

在制定解决方案时,我们对比了多种技术方案:

  • 增量同步:仅同步变更的镜像层,显著减少网络传输
  • 断点续传:网络中断后可恢复同步
  • 并发控制:支持配置并发线程数,提升大镜像同步效率
  • 完善的错误处理:内置失败重试机制(默认 3 次)
  • 轻量级部署:单二进制文件,无依赖
  • 多仓库支持:兼容 Docker Hub、Azure ACR、Harbor 等
  • 不支持增量同步:每次都需要拉取完整的镜像内容
  • 效率较低:网络传输量大,时间长
  • 简单易用:使用熟悉的 docker pull/push 命令
  • 复杂度高:需要配置 Azure CLI 认证
  • 功能限制:az acr import 功能相对单一
  • 原生集成:与 Azure 服务集成良好

决策 1:同步频率设置为每日 UTC 00:00

Section titled “决策 1:同步频率设置为每日 UTC 00:00”
  • 平衡镜像新鲜度和资源消耗
  • 避开业务高峰期,减少对其他操作的影响
  • Docker Hub 镜像通常在每日构建后更新
  • 保持与 Docker Hub 的完全一致性
  • 为用户提供灵活的版本选择
  • 简化同步逻辑,避免复杂的标签过滤规则

决策 3:使用 GitHub Secrets 存储认证信息

Section titled “决策 3:使用 GitHub Secrets 存储认证信息”
  • GitHub Actions 原生支持,安全性高
  • 配置简单,易于管理和维护
  • 支持仓库级别的访问控制
  • 使用 GitHub Secrets 加密存储
  • 定期轮换 ACR 密码
  • 限制 ACR 用户权限为仅推送
  • 监控 ACR 访问日志

风险 2:同步失败导致镜像不一致

Section titled “风险 2:同步失败导致镜像不一致”
  • image-syncer 内置增量同步机制
  • 自动失败重试(默认 3 次)
  • 详细的错误日志和失败通知
  • 断点续传功能
  • 增量同步减少网络传输
  • 可配置并发线程数(当前设置为 10)
  • 监控同步的镜像数量和大小
  • 在非高峰时段运行同步

我们采用 GitHub Actions + image-syncer 的自动化方案,实现从 Docker Hub 到 Azure ACR 的镜像同步。

  • 在 Azure Portal 中创建或确认 Azure Container Registry
  • 创建 ACR 访问密钥(用户名和密码)
  • 确认 Docker Hub 镜像仓库访问权限

在 GitHub 仓库设置中添加以下 Secrets:

  • AZURE_ACR_USERNAME: Azure ACR 用户名
  • AZURE_ACR_PASSWORD: Azure ACR 密码

在 .github/workflows/sync-docker-acr.yml 中配置工作流:

  • 定时触发:每天 UTC 00:00
  • 手动触发:支持 workflow_dispatch
  • 额外触发:publish 分支推送时触发(用于快速同步)
顺序参与方动作说明
1GitHub Actions触发工作流由定时、手动或 publish 分支推送触发
2GitHub Actions → image-syncer下载并执行同步工具进入实际同步阶段
3image-syncer → Docker Hub获取镜像 manifest 与标签列表读取源仓库元数据
4image-syncer → Azure ACR获取目标仓库已存在镜像信息判断目标侧当前状态
5image-syncer对比源与目标差异识别需要同步的镜像层
6image-syncer → Docker Hub拉取变更的镜像层仅传输需要更新的内容
7image-syncer → Azure ACR推送变更的镜像层完成增量同步
8image-syncer → GitHub Actions返回同步统计信息包含结果、差异和错误信息
9GitHub Actions记录日志并上传 artifact便于后续审计与排查

以下是实际运行的工作流配置(.github/workflows/sync-docker-acr.yml):

name: Sync Docker Image to Azure ACR
on:
schedule:
- cron: "0 0 * * *" # 每天 UTC 00:00
workflow_dispatch: # 手动触发
push:
branches: [publish]
permissions:
contents: read
jobs:
sync:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Download image-syncer
run: |
# 下载 image-syncer 二进制文件
wget https://github.com/AliyunContainerService/image-syncer/releases/download/v1.5.5/image-syncer-v1.5.5-linux-amd64.tar.gz
tar -zxvf image-syncer-v1.5.5-linux-amd64.tar.gz
chmod +x image-syncer
- name: Create auth config
run: |
# 生成认证配置文件 (YAML 格式)
cat > auth.yaml <<EOF
hagicode.azurecr.io:
username: "${{ secrets.AZURE_ACR_USERNAME }}"
password: "${{ secrets.AZURE_ACR_PASSWORD }}"
EOF
- name: Create images config
run: |
# 生成镜像同步配置文件 (YAML 格式)
cat > images.yaml <<EOF
docker.io/newbe36524/hagicode: hagicode.azurecr.io/hagicode
EOF
- name: Run image-syncer
run: |
# 执行同步 (使用新版 --auth 和 --images 参数)
./image-syncer --auth=./auth.yaml --images=./images.yaml --proc=10 --retries=3
- name: Upload logs
if: always()
uses: actions/upload-artifact@v4
with:
name: sync-logs
path: image-syncer-*.log
retention-days: 7
  • 定时触发:cron: “0 0 * * *” - 每天 UTC 00:00 执行
  • 手动触发:workflow_dispatch - 允许用户在 GitHub UI 手动运行
  • 推送触发:push: branches: [publish] - 发布分支推送时触发(用于快速同步)
hagicode.azurecr.io:
username: "${{ secrets.AZURE_ACR_USERNAME }}"
password: "${{ secrets.AZURE_ACR_PASSWORD }}"
docker.io/newbe36524/hagicode: hagicode.azurecr.io/hagicode

此配置表示将 docker.io/newbe36524/hagicode 的所有标签同步到 hagicode.azurecr.io/hagicode

  • —auth=./auth.yaml: 认证配置文件路径
  • —images=./images.yaml: 镜像同步配置文件路径
  • —proc=10: 并发线程数为 10
  • —retries=3: 失败重试 3 次

在 GitHub 仓库的 Settings → Secrets and variables → Actions 中配置:

Secret 名称描述示例值获取方式
AZURE_ACR_USERNAMEAzure ACR 用户名hagicodeAzure Portal → ACR → Access keys
AZURE_ACR_PASSWORDAzure ACR 密码xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxxAzure Portal → ACR → Access keys → Password
  1. 访问 GitHub 仓库的 Actions 标签页
  2. 选择 Sync Docker Image to Azure ACR 工作流
  3. 点击 Run workflow 按钮
  4. 选择分支并点击 Run workflow 确认
  1. 在 Actions 页面点击具体的工作流运行记录
  2. 查看各个步骤的执行日志
  3. 在页面底部的 Artifacts 区域下载 sync-logs 文件
Terminal window
# 登录到 Azure ACR
az acr login --name hagicode
# 列出镜像及其标签
az acr repository show-tags --name hagicode --repository hagicode --output table
  • 定期轮换 Azure ACR 密码(建议每 90 天)
  • 使用专用的 ACR 服务账户,限制权限为仅推送
  • 监控 ACR 的访问日志,及时发现异常访问
  • 不要在日志中输出认证信息
  • 不要将认证信息提交到代码仓库
  • 调整 —proc 参数:根据网络带宽调整并发数(建议 5-20)
  • 监控同步时间:如果同步时间过长,考虑减少并发数
  • 定期清理日志:设置合理的 retention-days(当前为 7 天)
Error: failed to authenticate to hagicode.azurecr.io

解决方案:

  1. 检查 GitHub Secrets 是否正确配置
  2. 验证 Azure ACR 密码是否过期
  3. 确认 ACR 服务账户权限是否正确
Error: timeout waiting for response

解决方案:

  1. 检查网络连接
  2. 减少并发线程数(—proc 参数)
  3. 等待网络恢复后重新触发工作流
Warning: some tags failed to sync

解决方案:

  1. 检查同步日志,识别失败的标签
  2. 手动触发工作流重新同步
  3. 验证 Docker Hub 源镜像是否正常
  • 定期检查 Actions 页面,确认工作流运行状态
  • 设置 GitHub 通知,及时获取工作流失败通知
  • 监控 Azure ACR 的存储使用情况
  • 定期验证镜像标签一致性

Q1: 如何同步特定标签而不是所有标签?

Section titled “Q1: 如何同步特定标签而不是所有标签?”

修改 images.yaml 配置文件:

# 仅同步 latest 和 v1.0 标签
docker.io/newbe36524/hagicode:latest: hagicode.azurecr.io/hagicode:latest
docker.io/newbe36524/hagicode:v1.0: hagicode.azurecr.io/hagicode:v1.0

在 images.yaml 中添加多行配置:

docker.io/newbe36524/hagicode: hagicode.azurecr.io/hagicode
docker.io/newbe36524/another-image: hagicode.azurecr.io/another-image
  • 自动重试:image-syncer 内置重试机制(默认 3 次)
  • 手动重试:在 GitHub Actions 页面点击 Re-run all jobs
  • 在 Actions 页面查看实时日志
  • 下载 sync-logs artifact 查看完整日志文件
  • 日志文件包含每个标签的同步状态和传输速度
  • 首次全量同步:根据镜像大小,通常需要 10-30 分钟
  • 增量同步:如果镜像变更小,通常 2-5 分钟
  • 时间取决于网络带宽、镜像大小和并发设置

在工作流中添加通知步骤:

- name: Notify on success
if: success()
run: |
echo "Docker images synced successfully to Azure ACR"

在工作流中添加标签过滤逻辑:

- name: Filter tags
run: |
# 仅同步以 v 开头的标签
echo "docker.io/newbe36524/hagicode:v* : hagicode.azurecr.io/hagicode:v*" > images.yaml
- name: Generate report
if: always()
run: |
echo "## Sync Report" >> $GITHUB_STEP_SUMMARY
echo "- Total tags: $(grep -c 'synced' image-syncer-*.log)" >> $GITHUB_STEP_SUMMARY
echo "- Sync time: ${{ steps.sync.outputs.duration }}" >> $GITHUB_STEP_SUMMARY

通过本文介绍的方法,我们成功实现了从 Docker Hub 到 Azure ACR 的自动化镜像同步。这个方案利用 GitHub Actions 的定时触发和手动触发功能,结合 image-syncer 的增量同步和错误处理机制,确保了镜像的及时同步和一致性。

我们还讨论了安全最佳实践、性能优化、故障排查等方面的内容,帮助用户更好地管理和维护这个同步机制。希望本文能够为需要在 Azure 环境中部署 Docker 镜像的开发者提供有价值的参考。


感谢您的阅读,如果您觉得本文有用,快点击下方点赞按钮👍,让更多的人看到本文。

本内容采用人工智能辅助协作,经本人审核,符合本人观点与立场。

GitHub Issues 集成

从零构建 GitHub Issues 集成:HagiCode 的前端直连实践

Section titled “从零构建 GitHub Issues 集成:HagiCode 的前端直连实践”

本文记录了在 HagiCode 平台中集成 GitHub Issues 的全过程。我们将探讨如何通过”前端直连 + 后端最小化”的架构,在保持后端轻量的同时,实现安全的 OAuth 认证与高效的 Issues 同步。

HagiCode 作为一个 AI 辅助开发平台,核心价值在于连接想法与实现。但在实际使用中,我们发现用户在 HagiCode 中完成了 Proposal(提案)后,往往需要手动将内容复制到 GitHub Issues 中进行项目跟踪。

这带来了几个明显的痛点:

  1. 工作流割裂:用户需要在两个系统之间来回切换,体验不仅不流畅,还容易导致关键信息在复制粘贴的过程中丢失。
  2. 协作不便:团队其他成员习惯在 GitHub 上查看任务,无法直接看到 HagiCode 中的提案进展。
  3. 重复劳动:每当提案更新,就要人工去 GitHub 更新对应的 Issue,增加不必要的维护成本。

为了解决这个问题,我们决定引入 GitHub Issues Integration 功能,打通 HagiCode 会话与 GitHub 仓库的连接,实现”一键同步”。

嘿,介绍一下我们正在做的东西

我们正在开发 HagiCode —— 一款 AI 驱动的代码智能助手,让开发体验变得更智能、更便捷、更有趣。

智能 —— AI 全程辅助,从想法到代码,让编码效率提升数倍。便捷 —— 多线程并发操作,充分利用资源,开发流程顺畅无阻。有趣 —— 游戏化机制和成就系统,让编码不再枯燥,充满成就感。

项目正在快速迭代中,如果你对技术写作、知识管理或者 AI 辅助开发感兴趣,欢迎来 GitHub 看看~


技术选型:前端直连 vs 后端代理

Section titled “技术选型:前端直连 vs 后端代理”

在设计集成方案时,摆在我们面前的有两条路:传统的”后端代理模式”和更激进的”前端直连模式”。

在传统的后端代理模式中,前端所有的请求都要先经过我们的后端,再由后端去调用 GitHub API。这虽然逻辑集中,但给后端带来了不小的负担:

  1. 后端臃肿:需要编写专门的 GitHub API 客户端封装,还要处理 OAuth 的复杂状态机。
  2. Token 风险:用户的 GitHub Token 必须存储在后端数据库中,虽然可以加密,但毕竟增加了安全风险面。
  3. 开发成本:需要数据库迁移来存储 Token,还需要维护一套额外的同步服务。

前端直连模式则要轻量得多。在这个方案中,我们只利用后端来处理最敏感的”密钥交换”环节(OAuth callback),获取到 Token 后,直接存在浏览器的 localStorage 里。后续创建 Issue、更新评论等操作,直接由前端发 HTTP 请求到 GitHub。

对比维度后端代理模式前端直连模式
后端复杂度需要完整的 OAuth 服务和 GitHub API 客户端仅需一个 OAuth 回调端点
Token 管理需加密存储在数据库,有泄露风险存储在浏览器,仅用户自己可见
实施成本需数据库迁移、多服务开发主要是前端工作量
用户体验逻辑统一,但服务器延迟可能稍高响应极快,直接与 GitHub 交互

考虑到我们要的是快速集成和最小化后端改动,最终我们采用了”前端直连模式”。这就像给浏览器发了一张”临时通行证”,拿到证之后,浏览器就可以自己去 GitHub 办事了,不需要每次都找后端管理员批准。


在确定架构后,我们需要设计具体的数据流。整个同步流程的核心在于如何安全地获取 Token 并高效地利用它。

整个系统可以抽象为三个角色:浏览器(前端)、HagiCode 后端、GitHub。

+--------------+ +--------------+ +--------------+
| 前端 React | | 后端 | | GitHub |
| | | ASP.NET | | REST API |
| +--------+ | | | | |
| | OAuth |--+--------> /callback | | |
| | 流程 | | | | | |
| +--------+ | | | | |
| | | | | |
| +--------+ | | +--------+ | | +--------+ |
| |GitHub | +------------>Session | +----------> Issues | |
| |API | | | |Metadata| | | | | |
| |直连 | | | +--------+ | | +--------+ |
| +--------+ | | | | |
+--------------+ +--------------+ +--------------+

关键点在于:只有 OAuth 的一小步(获取 code 换 token)需要经过后端,之后的粗活累活(创建 Issue)都是前端直接跟 GitHub 打交道。

当用户点击 HagiCode 界面上的”Sync to GitHub”按钮时,会发生一系列复杂的动作:

用户点击 "Sync to GitHub"
1. 前端检查 localStorage 获取 GitHub Token
2. 格式化 Issue 内容(将 Proposal 转换为 Markdown)
3. 前端直接调用 GitHub API 创建/更新 Issue
4. 调用 HagiCode 后端 API 更新 Session.metadata (存储 Issue URL 等信息)
5. 后端通过 SignalR 广播 SessionUpdated 事件
6. 前端接收事件,更新 UI 显示"已同步"状态

安全问题始终是集成第三方服务的重中之重。我们做了以下考量:

  1. 防 CSRF 攻击:在 OAuth 跳转时,生成随机的 state 参数并存入 sessionStorage。回调时严格验证 state,防止请求被伪造。
  2. Token 存储隔离:Token 仅存储在浏览器的 localStorage 中,利用同源策略(Same-Origin Policy),只有 HagiCode 的脚本才能读取,避免了服务器端数据库泄露波及用户。
  3. 错误边界:针对 GitHub API 常见的错误(如 401 Token 过期、422 验证失败、429 速率限制),设计了专门的错误处理逻辑,给用户以友好的提示。

纸上得来终觉浅,咱们来看看具体的代码是怎么实现的。

后端只需要做两件事:存储同步信息、处理 OAuth 回调。

数据库变更 我们只需要在 Sessions 表增加一个 Metadata 列,用来存储 JSON 格式的扩展信息。

-- 添加 metadata 列到 Sessions 表
ALTER TABLE "Sessions" ADD COLUMN "Metadata" text NULL;

实体与 DTO 定义

src/HagiCode.DomainServices.Contracts/Entities/Session.cs
public class Session : AuditedAggregateRoot<SessionId>
{
// ... 其他属性 ...
/// <summary>
/// JSON metadata for storing extension data like GitHub integration
/// </summary>
public string? Metadata { get; set; }
}
// DTO 定义,方便前端序列化
public class GitHubIssueMetadata
{
public required string Owner { get; set; }
public required string Repo { get; set; }
public int IssueNumber { get; set; }
public required string IssueUrl { get; set; }
public DateTime SyncedAt { get; set; }
public string LastSyncStatus { get; set; } = "success";
}
public class SessionMetadata
{
public GitHubIssueMetadata? GitHubIssue { get; set; }
}

这是连接的入口。我们使用标准的 Authorization Code Flow。

src/HagiCode.Client/src/services/githubOAuth.ts
// 生成授权 URL 并跳转
export async function generateAuthUrl(): Promise<string> {
const state = generateRandomString(); // 生成防 CSRF 的随机串
sessionStorage.setItem('hagicode_github_state', state);
const params = new URLSearchParams({
client_id: clientId,
redirect_uri: window.location.origin + '/settings?tab=github&oauth=callback',
scope: ['repo', 'public_repo'].join(' '),
state: state,
});
return `https://github.com/login/oauth/authorize?${params.toString()}`;
}
// 在回调页面处理 Code 换取 Token
export async function exchangeCodeForToken(code: string, state: string): Promise<GitHubToken> {
// 1. 验证 State 防止 CSRF
const savedState = sessionStorage.getItem('hagicode_github_state');
if (state !== savedState) throw new Error('Invalid state parameter');
// 2. 调用后端 API 进行 Token 交换
// 注意:这里必须经过后端,因为需要 ClientSecret,不能暴露在前端
const response = await fetch('/api/GitHubOAuth/callback', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code, state, redirectUri: window.location.origin + '/settings?tab=github&oauth=callback' }),
});
if (!response.ok) throw new Error('Failed to exchange token');
const token = await response.json();
// 3. 存入 LocalStorage
saveToken(token);
return token;
}

有了 Token 之后,我们就需要一个强有力的工具来调 GitHub API。

src/HagiCode.Client/src/services/githubApiClient.ts
const GITHUB_API_BASE = 'https://api.github.com';
// 核心请求封装
async function githubApi<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
const token = localStorage.getItem('gh_token');
if (!token) throw new Error('Not connected to GitHub');
const response = await fetch(`${GITHUB_API_BASE}${endpoint}`, {
...options,
headers: {
...options.headers,
Authorization: `Bearer ${token}`,
Accept: 'application/vnd.github.v3+json', // 指定 API 版本
},
});
// 错误处理逻辑
if (!response.ok) {
if (response.status === 401) throw new Error('GitHub Token 失效,请重新连接');
if (response.status === 403) throw new Error('无权访问该仓库或超出速率限制');
if (response.status === 422) throw new Error('Issue 验证失败,可能标题重复');
throw new Error(`GitHub API Error: ${response.statusText}`);
}
return response.json();
}
// 创建 Issue
export async function createIssue(owner: string, repo: string, data: { title: string, body: string, labels: string[] }) {
return githubApi(`/repos/${owner}/${repo}/issues`, {
method: 'POST',
body: JSON.stringify(data),
});
}

最后一步,就是把 HagiCode 的 Session 数据转换成 GitHub Issue 的格式。这有点像”翻译”工作。

// 将 Session 对象转换为 Markdown 字符串
function formatIssueForSession(session: Session): string {
let content = `# ${session.title}\n\n`;
content += `**> HagiCode Session:** #${session.code}\n`;
content += `**> Status:** ${session.status}\n\n`;
content += `## Description\n\n${session.description || 'No description provided.'}\n\n`;
// 如果是 Proposal 类型,添加额外字段
if (session.type === 'proposal') {
content += `## Chief Complaint\n\n${session.chiefComplaint || ''}\n\n`;
// 添加一个深链接,方便从 GitHub 跳回 HagiCode
content += `---\n\n**[View in HagiCode](hagicode://sessions/${session.id})**\n`;
}
return content;
}
// 点击同步按钮的主逻辑
const handleSync = async (session: Session) => {
try {
const repoInfo = parseRepositoryFromUrl(session.repoUrl); // 解析仓库 URL
if (!repoInfo) throw new Error('Invalid repository URL');
toast.loading('正在同步到 GitHub...');
// 1. 格式化内容
const issueBody = formatIssueForSession(session);
// 2. 调用 API
const issue = await githubApiClient.createIssue(repoInfo.owner, repoInfo.repo, {
title: `[HagiCode] ${session.title}`,
body: issueBody,
labels: ['hagicode', 'proposal', `status:${session.status}`],
});
// 3. 更新 Session Metadata (保存 Issue 链接)
await SessionsService.patchApiSessionsSessionId(session.id, {
metadata: {
githubIssue: {
owner: repoInfo.owner,
repo: repoInfo.repo,
issueNumber: issue.number,
issueUrl: issue.html_url,
syncedAt: new Date().toISOString(),
}
}
});
toast.success('同步成功!');
} catch (err) {
console.error(err);
toast.error('同步失败,请检查 Token 或网络');
}
};

通过这套”前端直连”方案,我们用最少的后端代码实现了 GitHub Issues 的无缝集成。

  1. 开发效率高:后端改动极小,主要是数据库加一个字段和一个简单的 OAuth 回调接口,大部分逻辑都在前端完成。
  2. 安全性好:Token 不经过服务器数据库,降低了泄露风险。
  3. 用户体验佳:直接从前端发起请求,响应速度快,不需要经过后端中转。

在实际部署时,有几个坑大家要注意:

  • OAuth App 设置:记得在 GitHub OAuth App 设置里填正确的 Authorization callback URL(通常是 http://localhost:3000/settings?tab=github&oauth=callback)。
  • 速率限制:GitHub API 对未认证请求限制较严,但用 Token 后通常足够(5000次/小时)。
  • URL 解析:用户输入的 Repo URL 千奇百怪,记得正则要匹配 .git 后缀、SSH 格式等情况。

目前的功能还是单向同步(HagiCode -> GitHub)。未来我们计划通过 GitHub Webhooks 实现双向同步,比如在 GitHub 里关闭 Issue,HagiCode 这边的会话状态也能自动更新。这需要我们在后端暴露一个 Webhook 接收端点,这也是下一步要做的有趣工作。

希望这篇文章能给你的第三方集成开发带来一点灵感!如果有问题,欢迎在 HagiCode GitHub 上提 Issue 讨论。