Chrome 录制插件技术调研文档,Automa 部分代码解析

885 阅读7分钟

背景

项目原本为 Playwright 实现的 RPA,现考虑平台兼容性与用户学习成本,使用 Chrome Extension 作为 Playwright 代替品。 所以 Chrome Extension 需要包含录制操作与播放功能。

调研维度

  1. 实现的基本原理,如基于 DOM Event Record 与 Workflow/Block
  2. 活跃度,主要从 github star 数、代码更新频率等判断
  3. 功能,可实现实际业务需求且需要考虑其缺陷与隐患
  4. 生产环境可用性,市面上是否已经存在使用这个解决方案的案例
  5. 可维护性,从工作量、学习/维护成本、对于业务的侵入度、最佳实践等方面考量

Automa

Automa 是一个基于 Workflow 实现自动化操作的开源平台。

Github Repo Star 8k+ Issues 42 Website

技术栈

  • JavaScript
  • Vue 3 (UI 框架)
  • Chrome Extension API (Chrome 插件 API)
  • Webpack 5 (模块化打包工具)
  • Postcss + TailwindCss (CSS 框架)
  • Tiptap (富文本编辑器)
  • Vue Flow (流程图框架)
  • Code Mirror (代码编辑器)
  • RxJS (响应式编程扩展库)

功能

  • 基于 Chrome Extension Content Script 实现的录制功能,包含切换标签页、事件监听等功能 image.png

  • 完善的触发器系统可以实现主动/自动执行 Workflow 功能

  • Workflow 视图编辑器 image.png

  • Workflow 支持分支条件、循环等流程控制能力、完善的纠错系统以及 Script 注入 Block image.png

    流程控制

    image.png

  • 持久化存储与凭据系统

  • 基于 chrome.alarms 的定时任务功能

  • 日志系统

  • 数据备份与恢复系统

部分代码解析

记录部分 Automa 的技术架构与代码解析,以便日后调整、优化和迭代。

录制

入口文件:src/content/services/recordWorkflow/index.js

  1. 执行通过 chrome.scripting.executeScript 注入 Document 上下文:
// browser 约等于 chrome
await browser.scripting.executeScript({
  target: {
    tabId: tab.id,
    allFrames: true,
  },
  files: ['recordWorkflow.bundle.js'],
});
  1. 创建录制控制台元素至的 document 中:
export default function () {
  const rootElement = document.createElement('div');
  // 使用 shadow DOM 隔离 APP 实例,避免各种污染
  rootElement.attachShadow({ mode: 'open' });
  // 全局唯一ID,保证
  rootElement.setAttribute('id', 'automa-recording');
  rootElement.classList.add('automa-element-selector');
  document.body.appendChild(rootElement);
  // 注入样式
  return injectAppStyles(rootElement.shadowRoot, customCSS).then(() => {
    const appRoot = document.createElement('div');
    appRoot.setAttribute('id', 'app');
    rootElement.shadowRoot.appendChild(appRoot);
    // 创建Vue3实例
    const app = createApp(App).use(vRemixicon, icons);
    // 挂载至 shadow DOM
    app.mount(appRoot);
    return app;
  });
}
  1. 注册全局的事件捕获监听器,在这些监听器中添加 Block
if (isMainFrame) {
  window.addEventListener('message', onMessage);
  document.addEventListener('scroll', onScroll, true);
}
// 事件捕获
document.addEventListener('click', onClick, true);
document.addEventListener('change', onChange, true);
document.addEventListener('keydown', onKeydown, true);
  1. 将录制的 Recording 存入 chrome.storage

Workflow Execute

无论是从 Popup 还是 Dashboard 都是通过 Event 来触发 Workflow Execute。 所以最终都会在 background service worker 中执行:

MessageListener 实现在 src/utils/message.js 中

const message = new MessageListener('background');
// ...
// Workflow Execute Listener
message.on('workflow:execute', async async (workflowData, sender) => {
  // ...
});
// ...
// 注入 runtime.onMessage
browser.runtime.onMessage.addListener(message.listener());

Workflow Execute Listener,Automa 会创建一些状态,然后调用 BackgroundWorkflowUtils.executeWorkflow 方法:

BackgroundWorkflowUtils.executeWorkflow(workflowData, workflowData?.options || {});

跳转到 BackgroundWorkflowUtils.executeWorkflow 中,也同样只是对 startWorkflowExec 的一层封装:

// BackgroundWorkflowUtils static function
async function executeWorkflow(workflowData, options) {
  if (workflowData.isDisabled) return;
  // 检查版本
  const isMV2 = browser.runtime.getManifest().manifest_version === 2;
  startWorkflowExec(workflowData, options, isMV2);
}

所以 Workflow Execute 的核心执行逻辑在 src/workflowEngine/index.jsstopWorkflowExec 方法中。 那么我们开始解析一下 stopWorkflowExec 方法。

stopWorkflowExec

跳过其中的 helper code,我们直接切入核心点,在第 58 行这个函数创建了一个 WorkflowEngine 实例:

const engine = new WorkflowEngine(convertedWorkflow, {
  options,
  isPopup,
  states: workflowState,
  logger: workflowLogger,
  blocksHandler: blocksHandler(),
});

其中 options,isPopup,workflowState 都是当前 workflow 上下文的状态, 而 workflowLogger 是 Automa 的日志记录模块,它通过 Dexie(一个 indexedDB)库实现的日志持久化:

WorkflowLogger 的依赖代码 src/db/logs.js

import dbLogs, { defaultLogItem } from '@/db/logs';
/* eslint-disable class-methods-use-this */
class WorkflowLogger {
  async add({ detail, history, ctxData, data }) {
    const logDetail = { ...defaultLogItem, ...detail };

    await Promise.all([
      dbLogs.logsData.add(data),
      dbLogs.ctxData.add(ctxData),
      dbLogs.items.add(logDetail),
      dbLogs.histories.add(history),
    ]);
  }
}

blocksHandler 是由多个 Handle 组合的、对应每个 Block 也就是 Workflow 执行块(可以理解为工作流中的每个步骤)。 它通过 require.context 解析 src/workflowEngine/blocksHandler/*.js 获取:

blocksHandler 入口 src/workflowEngine/blocksHandler.js

// 获取handles
const blocksHandler = require.context('./blocksHandler', false, /\.js$/);
// format handles 集合
const handlers = blocksHandler.keys().reduce((acc, key) => {
  // 移除 handle 与 .js
  const name = key.replace(/^\.\/handler|\.js/g, '');
  // 转换为小驼峰命名并alias至default
  acc[toCamelCase(name)] = blocksHandler(key).default;

  return acc;
}, {});

接下来我们继续解析,engine.init();engine.on('destroyed')涉及到 WorkflowEngine 的实现, 那么我们看看它的实现:

文件地址 src/workflowEngine/WorkflowEngine.js

跳过 WorkflowEngine.init 中的状态更新代码,在初始化状态下,最终会执行到 303 行的 addWorker 结束:

class WorkflowEngine {
  async init() {
    // ...
    this.addWorker({ blockId: triggerBlock.id });
  }
}

那这个 addWorker 方法即为是执行逻辑的开始,看看它的逻辑:

class WorkflowEngine {
  addWorker(detail) {
    this.workerId += 1;
    const workerId = `worker-${this.workerId}`;
    const worker = new WorkflowWorker(workerId, this, { blocksDetail: blocks });
    worker.init(detail);
    this.workers.set(worker.id, worker);
  }
}

这里又构造了 WorkflowWorker 的实例并初始化了它,那我们继续跳转:

文件地址 src/workflowEngine/WorkflowWorker.js

class WorkflowWorker {
  init({ blockId, execParam, state }) {
    // ...
    this.executeBlock(block, execParam);
  }
}

继续转到 executeBlock 的实现,我们跳过上下文环境检查的代码,看看它实现执行的核心逻辑是什么?

  1. 获取当前 block 对应的 handle
const blockHandler = this.engine.blocksHandler[toCamelCase(block.label)];
const handler =
  !blockHandler && this.blocksDetail[block.label].category === 'interaction'
    ? this.engine.blocksHandler.interactionBlock
    : blockHandler;

这里 this.engineWorkflowEngineInstance 的引用, 那么访问其 blocksHandler 就是访问之前我们提到的 BlockHandle 集合。

为方便后续阅读体验,这里将 blocksHandler 简写为 bh。

如果不存在对应的 block handler 直接退出并停止 workflow 的执行:

if (!handler) {
  console.error(`${block.label} doesn't have handler`);
  this.engine.destroy('stopped');
  return;
}

bh 在执行过程需要的数据从 templating 函数中获取, 主要逻辑是将 Block Object 数据中的 refKeys 转换至上下文中:

const refData = {
  prevBlockData,
  ...this.engine.referenceData,
  activeTabUrl: this.activeTab.url,
};
const replacedBlock = await templating({
  // ...
});

src/workflowEngine/templating/index.js

export default async function ({ block, refKeys, data, isPopup }) {
  const copyBlock = cloneDeep(block);
  const addReplacedValue = value => {
    if (!copyBlock.replacedValue) copyBlock.replacedValue = {};
    copyBlock.replacedValue = { ...copyBlock.replacedValue, ...value };
  };
  // ...
  for (const blockDataKey of refKeys) {
    // ...
    addReplacedValue(renderedValue.list);
    // ...
  }
}

这里有用到一个 package: object-path,主要提供路径访问数据的功能。

访问的数据来自可以追溯到 this.engine.referenceData,在 WorkflowEngine 初始化的时候创建,在运行时也会动态更新:

this.referenceData = {
  variables,
  table: [],
  secrets: {},
  loopData: {},
  workflow: {},
  googleSheets: {},
  globalData: parseJSON(globalData, globalData),
};

回到 WorkflowWorker,再对 bh 做了一些上下文绑定之后,WorkflowWorker 开始执行 bh:

const bindedHandler = handler.bind(this, replacedBlock, {
  refData,
  prevBlock,
  ...(execParam || {}),
});
result = await blockExecutionWrapper(bindedHandler, block.data);

如果发生异常,将会由外部的 cry/catch 捕获,并生成对应的错误日志。 如果我们需要获取更多的错误信息可以在这里调整:

try {
  // ...
  result = await blockExecutionWrapper(bindedHandler, block.data);
} catch (error) {
  const errorLogData = {
    message: error.message,
    ...(error.data || {}),
    ...(error.ctxData || {}),
  };
  // ...
  const errorLogItem = errorLogData;
  addBlockLog('error', errorLogItem);
}

重试逻辑:

if (blockOnError.retry && blockOnError.retryTimes) {
  await sleep(blockOnError.retryInterval * 1000);
  blockOnError.retryTimes -= 1;
  await this.executeBlock(replacedBlock, execParam, true);
  return;
}

如果 bh 执行成功,WorkflowWorker 会继续递归调用 executeBlocks 执行下一个 bh,直到运行结束或出错:

if (result.nextBlockId && !result.destroyWorker) {
  if (blockDelay > 0) {
    setTimeout(() => {
      executeBlocks(result.nextBlockId, result.data);
    }, blockDelay);
  } else {
    executeBlocks(result.nextBlockId, result.data);
  }
} else {
  this.engine.destroyWorker(this.id);
}

好,到这里就结束了 Workflow 的运行,总结如下:

  1. 通过 chrome.runtime.sendMessage 触发 on('workflow:execute') 事件
  2. 创建 WorkflowEngine 实例,并调用 init 进行初始化
  3. WorkflowEngine.init 中获取所有 BlockHandles 并注册至实例中
  4. WorkflowEngine.init 中创建一个 WorkflowWorker 实例,并初始化
  5. WorkflowWorker 获取当前 Block 对应的 BlockHandle,并注入上下文与数据再调用它
  6. 重复第 5 项直到运行结束或出错

生产环境可用性

Automa 项目已入驻 Chrome 插件市场: 地址

商店使用人数有 80,000+ 位用户,评分 92.81 (4.6 Star),是一个比较优秀的项目。

但 Automa 使用 AGPL License,具有一定法律风险或要求业务项目开源

License 相关链接:AGPL - 华为云

可维护性

由于功能繁杂涉及技术栈广,学习成本高。 并因为使用的是 Javascript,项目可维护性较差(JS 没有类型系统), 所以修改原有功能点会比新增功能更加困难

复杂功能点:

  1. 录制功能,涉及代码量较广
  2. Workflow 编辑器,技术栈复杂且功能庞大

Plasmo

Plasmo 框架是一款功能强大的浏览器扩展程序软件开发工具包(SDK)。使用 Plasmo 来构建浏览器扩展程序,不需要操心扩展的配置文件和构建时的一些奇怪特性。

它只是一个框架并未提供录制操作与播放功能,需要自行实现。

Github Repo Star 6.6k+ Issues 80

技术栈

  • Typescript
  • React

功能

  • 一流的 React + Typescript 支持
  • 声明式开发(自动生成 manifest.json)
  • 将 UI 组件渲染到网页
  • 扩展内置页面
  • 扩展热重载 + React 模块热更新
  • .env* 文件支持
  • 扩展储存 API
  • 扩展通信 API
  • 远程代码打包 (例如 Google Analytics)
  • 支持多个浏览器和 manifest 版本
  • 通过 BPP 进行自动部署
  • 可选 Svelte 或 Vue 进行开发

生产环境可用性

作为兴欣的 Chrome Extension 框架,目前使用的人比较少, See: Plasmo Examples

可维护性

项目使用 TypeScript 开发,可维护性较强, 且未来录制操作与播放功能将为自实现,有很强的代码控制权。

总结

Automa 快速开发的首选

如果不考虑 License 的风险,且录制功能与 Workflow 编辑器无需大改的情况下,Automa 应当是第一选择。 其除了使用 js 作为基础语言(js 可维护性差)外,基本无可挑剔!

Plasmo 自研项目的优势

如果考虑自研项目的控制权(code 实现),使用 Plasmo 作为底层框架也具有一定可行性, 当然这也需要我们付出一些时间成本用于开发基础功能(录制操作与播放功能)。