
这个项目选择了一种不太常见的组合Go 做底层引擎.NET WPF 做桌面界面。两端通过两种方式通信——CGo 编译的 DLL 直接 P/Invoke 调用以及基于 stdin/stdout 的 JSON-Line 子进程协议。┌──────────────────────────────────────┐│ WPF 托盘应用 (.NET 10) ││ ┌─────────┐ ┌───────────────────┐ ││ │ MiniPanel│ │ ProgressOverlay │ ││ └────┬─────┘ └────────┬─────────┘ ││ │ │ ││ ┌────┴─────────────────┴─────────┐ ││ │ ExecutionService │ ││ └────────┬───────────────────────┘ ││ │ IEngineService ││ ┌────────┴────────┐ ││ │ RobotgoNative │ GoProcess ││ │ (P/Invoke DLL) │ (子进程) ││ └────────┬────────┘ │├────────────┼────────────────────────┤│ Go DLL │ Go EXE (serve 模式) ││ ┌────────┴────────┐ ││ │ executor │ ││ ├─────────────────┤ ││ │ 19 种 action │ ││ ├─────────────────┤ ││ │ robotgo 封装 │ ││ │ (鼠标/键盘/图像) │ ││ └─────────────────┘ │└──────────────────────────────────────┘为什么要这么分层把引擎和控制界面分开有几个好处Go 通过 CGo 可以直接调用 Windows APIrobotgo 底层依赖性能无损耗而界面部分用 WPF 可以获得原生的 Windows 桌面体验。Core 类库同时目标 net48 和 net10.0意味着即使在不支持 .NET 10 的老机器上也能跑。DLL 直调和子进程模式通过同一个接口对 UI 层透明——这是整个架构最关键的设计决策// IEngineService.cs — 统一的引擎抽象public interface IEngineService{event Action? OnEvent;bool IsAvailable { get; }void Preload(string workflowPath);void ExecuteWithInputs(string workflowPath, int fromStep,bool debug, string? inputsJson);void Stop();}无论底层是 P/Invoke 调 DLL 还是 spawn 子进程ViewModel 层完全不需要关心。人类模拟不只是加个随机延迟大多数自动化工具的人类模拟就是 sleep(rand(100, 500))。这个项目做得更深——它模拟了四种真实的人类操作特征。贝塞尔曲线鼠标路径人类移动鼠标不是一个匀速直线而是略带弧线、先快后慢。项目用三次贝塞尔曲线来近似// human.go — 三次贝塞尔公式func bezierPoint(t float64, p0, p1, p2, p3 geom.Point) geom.Point {mt : 1 - treturn geom.Point{X: int(mtmtmtfloat64(p0.X) 3mtmttfloat64(p1.X) 3mtttfloat64(p2.X) tttfloat64(p3.X)),Y: int(mtmtmtfloat64(p0.Y) 3mtmttfloat64(p1.Y) 3mtttfloat64(p2.Y) tttfloat64(p3.Y)),}}关键在控制点的生成上——P₁ 和 P₂ 不是固定的而是根据起点到终点的距离做随机偏转±45°偏移量在距离的 20%~50% 之间。采样点数量 距离 × 0.8最少 50 个最多 200 个行走时前 30% 路径加速、后 10% 减速。短距离20px直接跳过曲线因为太短的曲线反而显得不自然。还有一个细节15% 的概率会先 overshoot 目标再回正模拟人类手滑的情况。QWERTY 邻接打字错误人类打字不是完美的所以项目实现了三种基于 QWERTY 键盘物理邻接关系的错误错误类型 0按到相邻键 → 退格 → 重新输入正确字符错误类型 1跳过当前字符 → 打下个 → 退格 → 补当前 → 补下个错误类型 2交换当前和下个字符 → 双退格 → 重新正确输入keyboard_layout.go 维护了一个完整的 QWERTY 邻接映射表var adjacentKeys map[rune][]rune{‘a’: {‘q’, ‘w’, ‘s’, ‘z’},‘b’: {‘v’, ‘g’, ‘h’, ‘n’},‘c’: {‘x’, ‘d’, ‘f’, ‘v’},// … 46 个按键的邻接关系}这些错误的触发概率由 mistakeRate 参数控制0.0~1.0错误发生后自动用退格修正最终输出正确文本。整个过程对调用方完全透明。空闲抖动与滚动噪声空闲抖动长等待被拆分为 200-500ms 的 chunk每个 chunk 有 15% 概率做 ±5px 的鼠标微移动滚动噪声总滚动量拆为步长 1-3 的小步骤步骤间 30-120ms 延迟20% 概率轻微回滚这些看似微小的行为叠加在一起让自动化操作的模式识别难度显著提高。可扩展的动作系统策略模式 工厂分发项目支持 19 种动作点击、双击、右键、拖拽、打字、按键、组合键、等待、等待消失、滚动、延时、打开URL、刷新、后退、前进、切换标签页、输入提示、确认、通知。如果每种动作都写一堆 if-else很快就会失控。解决方案是一个经典的组合——Runner 接口 工厂函数 模板方法// action.go — 核心抽象type Runner interface {Execute(eng Engine) error}type Engine interface {Click(x, y int) errorDoubleClick(x, y int) errorFindElement(templatePath string) (int, int, error)TypeText(text string) error// … 共 19 个底层原语}工厂函数 FromConfig 通过 switch 将 YAML 配置分发到具体实现func FromConfig(cfg config.Action) (Runner, error) {switch {case cfg.Click ! nil:return newClickAction(cfg.Click) // 单击case cfg.DoubleClick ! nil:return newDoubleClickAction(cfg.DoubleClick) // 双击case cfg.Type ! nil:return newTypeAction(cfg.Type) // 打字// … 19 个分支}}targetedAction 组合模式值得单独拿出来讲。单击、双击、右键三种动作都需要定位目标—可能是坐标也可能是图像模板。Go 没有继承但通过结构体嵌入实现了相同的效果type targetedAction struct {template stringcoord *geom.Point}func (a *targetedAction) resolveTarget(eng Engine) (int, int, error) {if a.coord ! nil {return a.coord.X, a.coord.Y, nil}return eng.FindElement(a.template)}type ClickAction struct {targetedAction // 嵌入复用 resolveTarget}三种动作各嵌入一个 targetedAction共用定位逻辑各自实现不同的点击方法。YAML 中同一个字段既可以是模板路径字符串也可以是坐标 map——parseTargetedAction 泛型工厂统一处理了这种多态。测试方面MockEngine 完整实现了 Engine 接口所有调用记录到 []MockCall 切片中。这样所有 19 种动作都可以在不依赖真实鼠标键盘的情况下验证执行逻辑func TestClickAction_WithTemplate(t *testing.T) {eng : NewMockEngine()eng.FindResults []geom.Point{{X: 100, Y: 200}}action : ClickAction{targetedAction{template: btn.png}} action.Execute(eng) clickCall : eng.FindCall(Click) assert.Equal(t, 100, clickCall.Args[x]) assert.Equal(t, 200, clickCall.Args[y])}图像匹配窗口优先全屏兜底图像匹配是 RPA 的核心能力之一。项目的策略是两级回退FindElement(“templates/btn.png”)│├─ 方案 B窗口内搜索browserPID ! 0 时启用│ ├─ 截取浏览器窗口区域│ ├─ 在区域内搜索模板│ └─ 找到 → 返回窗口内绝对坐标│ 未找到 ↓│└─ 方案 A全屏搜索回退├─ 从缓存获取模板位图├─ 全屏搜索└─ 返回中心坐标窗口优先的好处是减少搜索范围、提高匹配速度同时避免误匹配到窗口外的相似元素。模板位图通过 sync.RWMutex 保护的 map 缓存首次加载后后续调用直接复用Engine.Close() 时统一释放全部资源。此外还有一个通用的轮询框架 waitCondition每 200ms 检查一次条件供 WaitForElement等待元素出现和 WaitForElementGone等待元素消失共用。错误处理三种策略的决策树自动化执行中遇到错误是常态——可能是网络延迟导致页面未加载完可能是弹窗挡住了目标元素。项目提供了三种错误处理策略由 YAML 配置的 on_error 字段控制on_error:┌─────────┬──────────┬──────────┐│ abort │ skip │ retry │├─────────┼──────────┼──────────┤│ 截图保存 │ 记录 Warn│ 重试 ││ 写错误日志│ 清除错误 │ (最多 N ││ 弹窗通知 │ 继续下一步│ 次) ││ 终止执行 │ │ 耗尽→abort│└─────────┴──────────┴──────────┘所有路径都通过 context.Context 支持取消调用方可以随时 cancel() 终止执行。关键的一点是所有错误路径都保持了回调协议对称——即使 skip 了错误OnActionDone 仍然会被调用UI 侧的进度显示不会因为跳过步骤而错乱。配置校验8 层流水线YAML 配置的健壮性直接影响自动化执行的可靠性。项目实现了一条 8 层校验流水线Load(path)→ os.ReadFile原始字节→ encoding.ToUTF8GBK→UTF-8 自动转换→ yaml.Unmarshal结构体反序列化→ applyDefaults默认值填充→ Validate1. 结构完整性Name 非空、Steps ≥ 12. 步骤基础每步 Name 非空、Actions ≥ 13. 动作完整性至少一个字段被设置4. 单动作约束互斥只能有一个字段5. 模板文件存在性所有引用的模板文件必须存在6. Map 字段完整性Drag.From/To、Type.Into/Text 等非空7. 交互字段校验Prompt/Confirm 的 Title/Message 非空8. Settings 范围speed ∈ [0.1,5.0]、mistake_rate ∈ [0,1]其中第 4 层单动作约束特别重要——YAML 中的 action 是一个包含多个可选字段的结构体但每次只能激活一个。如果不做互斥校验一个配置中同时写了 click 和 type 两种动作行为将不可预测。校验器会在加载时就拒绝这种歧义配置而不是等到运行时。JSON-Line 协议极简的进程间通信Go 引擎与 .NET GUI 之间的通信协议设计得异常简洁——基于 stdin/stdout 的 JSON-Line每行一个完整 JSON 对象Go → C#: {“type”:“step_start”,“idx”:0,“total”:3,“name”:“打开浏览器”}Go → C#: {“type”:“action_start”,“step_idx”:0,“idx”:0,“action”:“click”,“detail”:“单击 (templates/btn.png)”}Go → C#: {“type”:“action_done”,“step_idx”:0,“idx”:0}Go → C#: {“type”:“step_done”,“idx”:0}Go → C#: {“type”:“workflow_done”,“ok”:true,“total_elapsed_sec”:12.34}C# → Go: {“type”:“stop”} # 随时可取消Go → C#: {“type”:“stopped”} # 确认已停止DLL 直调模式下同样的事件结构通过 C 函数指针回调推送到 C# 侧RobotgoNative 在静态构造器中注册回调并将 JSON 反序列化为相同的 ServeEvent 类型——协议消息在两种通信方式间完全复用。C# 侧的 ExecutionService.HandleEvent() 通过一个简洁的 switch 分发 7 种事件类型到 ViewModel 更新驱动 WPF 进度条、步骤描述和错误提示的实时刷新。WPF 端MVVM 的现代化实践Tray 应用使用 CommunityToolkit.Mvvm 8.4 的源生成器模式避免了传统 MVVM 框架中手写 INotifyPropertyChanged 和 ICommand 样板代码的痛点public partial class MiniPanelViewModel : ObservableObject{[ObservableProperty]private string _workflowPath; // 源生成器自动生成 WorkflowPath 属性和通知[RelayCommand] private async Task SelectWorkflow() // 源生成器自动生成 SelectWorkflowCommand { // ... }}还有一个容易被忽略但很实用的设计迷你面板在启动执行时自动隐藏完成后通过托盘重新唤出。托盘图标的右键菜单串联了整个交互流程——选择工作流 → 填写变量 → 执行 → 查看结果。进度浮窗设置了 WS_EX_TRANSPARENT 扩展样式实现点击穿透不会干扰正在执行的自动化操作。几个值得回味的工程决策回顾整个项目有几个选择我认为特别值得记录Go 做引擎而非 C# 做引擎。robotgo 是一个 Go 库底层通过 CGo 调用 Windows API。用 Go 直接承接 robotgo 是阻力最小的路径。CGo 编译出的 DLL 又恰好能被 .NET P/Invoke 消费形成了一个自然的跨语言桥接。两种通信方式共存接口统一。DLL 直调性能高、延迟低适合生产环境子进程模式隔离性好、可独立调试适合开发阶段和需要进程隔离的场景。IEngineService 接口让这两种模式可以随时切换而不影响 UI 代码。错误处理策略可配置而非硬编码。有些自动化脚本需要失败即停有些则需要跳过错误继续。把 abort/skip/retry 的选择权交给配置而非代码让同一套引擎适配了不同的业务场景。人类模拟不是事后添加的 feature。humanMode 从设计之初就嵌入在引擎层启用了人类模拟后所有操作自动走贝塞尔路径、打字错误、空闲抖动——不需要逐个动作手动开关。YAML 配置的单动作约束。这看起来是个小细节但实际上防止了运行时最常见的配置错误在一个 action 里同时写了两种互斥操作。在加载阶段就拒绝比运行时才发现要好得多。结语构建一个桌面 RPA 框架本质上是解决一系列工程问题怎样让代码模拟人类操作的随机性怎样让同一个系统既支持坐标驱动又支持视觉驱动怎样设计一套可扩展的动作体系怎样在两种语言、两种运行时之间建立高效的通信这些问题没有标准答案但每一个选择都值得被记录和讨论。如果你对桌面自动化感兴趣或者正在考虑 Go 与 .NET 的混合架构希望这篇文章能提供一些参考。