从零构建 Windows 桌面 RPA 框架:Go 与 .NET 的跨语言协奏

整体架构:Go 引擎 + .NET 壳

这个项目选择了一种不太常见的组合: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 API(robotgo 底层依赖),性能无损耗;而界面部分用 WPF 可以获得原生的 Windows 桌面体验。Core 类库同时目标 net48net10.0,意味着即使在不支持 .NET 10 的老机器上也能跑。

DLL 直调和子进程模式通过同一个接口对 UI 层透明------这是整个架构最关键的设计决策:

csharp 复制代码
// IEngineService.cs --- 统一的引擎抽象
public interface IEngineService
{
    event Action<ServeEvent>? 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))。这个项目做得更深------它模拟了四种真实的人类操作特征。

1. 贝塞尔曲线鼠标路径

人类移动鼠标不是一个匀速直线,而是略带弧线、先快后慢。项目用三次贝塞尔曲线来近似:

go 复制代码
// human.go --- 三次贝塞尔公式
func bezierPoint(t float64, p0, p1, p2, p3 geom.Point) geom.Point {
    mt := 1 - t
    return geom.Point{
        X: int(mt*mt*mt*float64(p0.X) + 3*mt*mt*t*float64(p1.X) + 3*mt*t*t*float64(p2.X) + t*t*t*float64(p3.X)),
        Y: int(mt*mt*mt*float64(p0.Y) + 3*mt*mt*t*float64(p1.Y) + 3*mt*t*t*float64(p2.Y) + t*t*t*float64(p3.Y)),
    }
}

关键在控制点的生成上------P₁ 和 P₂ 不是固定的,而是根据起点到终点的距离做随机偏转(±45°),偏移量在距离的 20%~50% 之间。采样点数量 = 距离 × 0.8(最少 50 个,最多 200 个),行走时前 30% 路径加速、后 10% 减速。短距离(<20px)直接跳过曲线,因为太短的曲线反而显得不自然。

还有一个细节:15% 的概率会先 overshoot 目标再回正,模拟人类手滑的情况。

2. QWERTY 邻接打字错误

人类打字不是完美的,所以项目实现了三种基于 QWERTY 键盘物理邻接关系的错误:

复制代码
错误类型 0:按到相邻键 → 退格 → 重新输入正确字符
错误类型 1:跳过当前字符 → 打下个 → 退格 → 补当前 → 补下个
错误类型 2:交换当前和下个字符 → 双退格 → 重新正确输入

keyboard_layout.go 维护了一个完整的 QWERTY 邻接映射表:

go 复制代码
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),错误发生后自动用退格修正,最终输出正确文本。整个过程对调用方完全透明。

3. 空闲抖动与滚动噪声

复制代码
空闲抖动:长等待被拆分为 200-500ms 的 chunk,
          每个 chunk 有 15% 概率做 ±5px 的鼠标微移动
          
滚动噪声:总滚动量拆为步长 1-3 的小步骤,
          步骤间 30-120ms 延迟,20% 概率轻微回滚

这些看似微小的行为叠加在一起,让自动化操作的模式识别难度显著提高。


可扩展的动作系统:策略模式 + 工厂分发

项目支持 19 种动作(点击、双击、右键、拖拽、打字、按键、组合键、等待、等待消失、滚动、延时、打开URL、刷新、后退、前进、切换标签页、输入提示、确认、通知)。如果每种动作都写一堆 if-else,很快就会失控。

解决方案是一个经典的组合------Runner 接口 + 工厂函数 + 模板方法

go 复制代码
// action.go --- 核心抽象
type Runner interface {
    Execute(eng Engine) error
}

type Engine interface {
    Click(x, y int) error
    DoubleClick(x, y int) error
    FindElement(templatePath string) (int, int, error)
    TypeText(text string) error
    // ... 共 19 个底层原语
}

工厂函数 FromConfig 通过 switch 将 YAML 配置分发到具体实现:

go 复制代码
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 没有继承,但通过结构体嵌入实现了相同的效果:

go 复制代码
type targetedAction struct {
    template string
    coord    *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 种动作都可以在不依赖真实鼠标键盘的情况下验证执行逻辑:

go 复制代码
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.ToUTF8(GBK→UTF-8 自动转换)
  → yaml.Unmarshal(结构体反序列化)
  → applyDefaults(默认值填充)
  → Validate:
      1. 结构完整性(Name 非空、Steps ≥ 1)
      2. 步骤基础(每步 Name 非空、Actions ≥ 1)
      3. 动作完整性(至少一个字段被设置)
      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 是一个包含多个可选字段的结构体,但每次只能激活一个。如果不做互斥校验,一个配置中同时写了 clicktype 两种动作,行为将不可预测。校验器会在加载时就拒绝这种歧义配置,而不是等到运行时。


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 框架中手写 INotifyPropertyChangedICommand 样板代码的痛点:

csharp 复制代码
public partial class MiniPanelViewModel : ObservableObject
{
    [ObservableProperty]
    private string _workflowPath;  // 源生成器自动生成 WorkflowPath 属性和通知

    [RelayCommand]
    private async Task SelectWorkflow()  // 源生成器自动生成 SelectWorkflowCommand
    {
        // ...
    }
}

还有一个容易被忽略但很实用的设计:迷你面板在启动执行时自动隐藏,完成后通过托盘重新唤出 。托盘图标的右键菜单串联了整个交互流程------选择工作流 → 填写变量 → 执行 → 查看结果。进度浮窗设置了 WS_EX_TRANSPARENT 扩展样式,实现点击穿透,不会干扰正在执行的自动化操作。


几个值得回味的工程决策

回顾整个项目,有几个选择我认为特别值得记录:

  1. Go 做引擎而非 C# 做引擎。robotgo 是一个 Go 库,底层通过 CGo 调用 Windows API。用 Go 直接承接 robotgo 是阻力最小的路径。CGo 编译出的 DLL 又恰好能被 .NET P/Invoke 消费,形成了一个自然的跨语言桥接。

  2. 两种通信方式共存,接口统一 。DLL 直调性能高、延迟低,适合生产环境;子进程模式隔离性好、可独立调试,适合开发阶段和需要进程隔离的场景。IEngineService 接口让这两种模式可以随时切换而不影响 UI 代码。

  3. 错误处理策略可配置而非硬编码。有些自动化脚本需要"失败即停",有些则需要"跳过错误继续"。把 abort/skip/retry 的选择权交给配置而非代码,让同一套引擎适配了不同的业务场景。

  4. 人类模拟不是事后添加的 featurehumanMode 从设计之初就嵌入在引擎层,启用了人类模拟后所有操作自动走贝塞尔路径、打字错误、空闲抖动------不需要逐个动作手动开关。

  5. YAML 配置的"单动作约束"。这看起来是个小细节,但实际上防止了运行时最常见的配置错误:在一个 action 里同时写了两种互斥操作。在加载阶段就拒绝,比运行时才发现要好得多。


结语

构建一个桌面 RPA 框架,本质上是解决一系列工程问题:怎样让代码模拟人类操作的随机性?怎样让同一个系统既支持坐标驱动又支持视觉驱动?怎样设计一套可扩展的动作体系?怎样在两种语言、两种运行时之间建立高效的通信?

这些问题没有标准答案,但每一个选择都值得被记录和讨论。如果你对桌面自动化感兴趣,或者正在考虑 Go 与 .NET 的混合架构,希望这篇文章能提供一些参考。


项目地址:https://github.com/znlgis/robotgo-flow

相关推荐
逝水无殇3 小时前
C# 正则表达式详解
开发语言·后端·正则表达式·c#
朱永博3 小时前
使用Onnruntime实现Padim/Patchcore推理
开发语言·c#
ArixBit4 小时前
用Go手撸 Agent 框架#8:手写一个最小 Agent,先让 loop 跑起来
go·agent
铅笔侠_小龙虾6 小时前
Rust 学习(8)-切片类型
学习·rust·c#
习明然17 小时前
我的本地化AI项目(三)
人工智能·python·electron·c#·avalonia
golang学习记1 天前
Go Gin框架是怎么设计出来的?
go
范小多1 天前
C#获取和风天气数据
java·开发语言·python·c#
z落落1 天前
C# Task WaitAll、WaitAny、WhenAll、WhenAny
开发语言·c#
玖玥拾1 天前
C# 语言进阶(十四)Unity UI界面开发 + 网络消息联动
服务器·开发语言·网络·ui·unity·c#·游戏引擎