整体架构: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 类库同时目标 net48 和 net10.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 是一个包含多个可选字段的结构体,但每次只能激活一个。如果不做互斥校验,一个配置中同时写了 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 样板代码的痛点:
csharp
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 的混合架构,希望这篇文章能提供一些参考。