前言

前一段时间给我的开源下载器 Gopeed 实现了一套扩展系统,相关设计草案可以在这里查看,基于这套扩展系统可以很方便的通过javascript来实现一些定制化的功能,目前已经实现的扩展有:

然后重点是这些扩展也是全平台支持的,这意味着你可以在windowsmaclinuxandroidiosweb平台上安装和使用这些扩展,是不是很酷?接下来就来介绍下我是如何实现这套扩展系统的。

扩展系统设计

从大体上来说,扩展系统分为四个部分:

  • 扩展标准
  • 扩展脚本引擎
  • 扩展管理器
  • 扩展开发工具包

扩展标准

要实现一个扩展系统,首先要定义一个扩展标准,这个标准包括扩展的目录结构、扩展的配置文件、扩展的脚本文件等等,这一部分我参照(抄)了Chrome的扩展标准,即扩展由一个文件夹组成,文件夹中必须包含一个manifest.json声明文件,最简单的扩展目录结构如下:

├── index.js
└── manifest.json

去中心化设计

市面上大多数软件的扩展系统都是中心化的,比如:Chrome 扩展VS Code 扩展,这样的好处是可以很方便检索和分发扩展,不过我觉得Gopeed作为一个下载器,中心化的扩展系统一点也没有BitTorrent协议的去中心化精神,而且我也不想去维护一个中心化的扩展仓库,所以我决定基于git来实现一个去中心化的扩展系统。

git作为一个分布式版本控制系统,完美契合了去中心化的需求,每个扩展都是一个git仓库,扩展的安装和更新都是通过git clone来实现,这样只需要把扩展托管到githubgitlabgitee等平台上,就可以实现扩展的分发和更新了。

巧妙利用 Topic 分发扩展

去中心化之后没有一个类似扩展商店的平台来分发扩展,那么用户要怎么找到扩展呢?这里巧妙利用了Github Topic功能,只要给扩展项目仓库打上gopeed-extension的主题标签,然后通过topics/gopeed-extension就可以让用户找到你的扩展了:

有个问题就是没法通过关键字来搜索扩展,庆幸的是Github有着强大的搜索功能,可以通过topic和关键字进行搜索,例如:搜索youtube关键字,输入topic:gopeed-extension youtube,就可以找到youtube相关的扩展了:

扩展配置文件

扩展配置文件是一个json文件,用来声明扩展的一些基本信息,例如:扩展的名称、版本、描述、图标、脚本文件等等,这里我贴一个上面Youtube扩展的配置文件:

{
  "name": "youtube",
  "author": "monkeyWie",
  "title": "Youtube",
  "description": "Youtube video download",
  "icon": "icon.png",
  "version": "1.0.4",
  "homepage": "https://github.com/monkeyWie/gopeed-extension-youtube",
  "repository": {
    "url": "https://github.com/monkeyWie/gopeed-extension-youtube"
  },
  "scripts": [
    {
      "event": "onResolve",
      "match": {
        "urls": [
          "*://youtube.com/watch/*",
          "*://m.youtube.com/watch/*",
          "*://www.youtube.com/watch/*"
        ]
      },
      "entry": "dist/index.js"
    }
  ],
  "settings": [
    {
      "name": "quality",
      "title": "Quality",
      "description": "Video quality",
      "type": "string",
      "value": "highest",
      "options": [
        {
          "label": "Highest",
          "value": "highest"
        },
        {
          "label": "Lowest",
          "value": "lowest"
        }
      ]
    }
  ]
}

配置里有几个关键字段:

  • name:扩展的名称。
  • author:扩展的作者。
  • repository:扩展的仓库地址,用来检测扩展是否有更新。
  • scripts:扩展的脚本文件,用来配置脚本的入口和匹配规则。
  • settings:扩展的设置声明,在下载器中生成对应的界面提供给用户进行设置。

扩展重名问题

由于扩展是去中心化的,就有可能存在扩展重名的问题,所以我在扩展的配置文件里增加了一个author字段,用来区分扩展的作者,通过nameauthor来作为扩展的一个唯一标识,例如:

{
  "name": "youtube",
  "author": "monkeyWie"
}

这个扩展的唯一标识是monkeyWie@youtube,这样就可以降低扩展重名的概率了,当然这并不能完全避免扩展重名的问题,不过先这样吧,以后如果扩展数量真的多到一定程度了,再考虑其他的解决方案。

monorepo 支持

考虑到有可能在一个仓库里开发多个扩展,所以我也对这种情况进行了支持,通过repository里的path字段来支持monorepo,例如:

{
  "repository": {
    "url": "https://github.com/GopeedLab/gopeed-extension-samples",
    "path": "github-release-sample"
  }
}

然后在安装扩展的时候只需要用#来拼接urlpath即可,例如:

https://github.com/GopeedLab/gopeed-extension-samples#github-release-sample

扩展脚本执行入口

现在有了脚本,但是还需要配置一个脚本的运行入口,也就是脚本在什么时候才会被执行,目前我定了三个入口:

  • onResolve:当解析一个下载链接时触发。
  • onStart:当开始下载时触发。
  • onError:当下载失败时触发。

执行入口有了,但是你肯定不希望脚本在所有的下载任务中都执行,所以还需要配置一个匹配规则,目前支持两种匹配规则:

  • urls:按下载链接匹配,规则和 chrome 扩展的匹配规则一致。
  • labels:按下载任务标签匹配。

拿上面Youtube扩展来举例,这个扩展的脚本入口配置如下:

{
  "scripts": [
    {
      "event": "onResolve",
      "match": {
        "urls": [
          "*://youtube.com/watch/*",
          "*://m.youtube.com/watch/*",
          "*://www.youtube.com/watch/*"
        ]
      },
      "entry": "dist/index.js"
    }
  ]
}

表示当解析一个Youtube的视频链接时,就会执行dist/index.js脚本,效果如下:

扩展设置

有时候扩展需要用户提供一些基本的设置,比如Cookie默认清晰度等等,所以我设计了一套标准化的扩展声明,用来声明扩展的设置,这样下载器就可以根据声明来生成对应的界面,让用户进行设置,然后在扩展脚本里可以获取到用户设置的值,这样就可以实现一些定制化的功能了。

还是拿上面的Youtube扩展来举例,这个扩展的设置声明如下:

{
  "settings": [
    {
      "name": "quality",
      "title": "Quality",
      "description": "Video quality",
      "type": "string",
      "value": "highest",
      "options": [
        {
          "label": "Highest",
          "value": "highest"
        },
        {
          "label": "Lowest",
          "value": "lowest"
        }
      ]
    }
  ]
}

表示这个扩展有一个quality的设置,类型是string,默认值是highest,用户可以在highestlowest中选择一个,然后在扩展脚本里获取用户设置的值,这样就可以实现根据用户设置的清晰度来下载视频了,效果如下:

扩展脚本引擎

要支持灵活的扩展开发需求,肯定是需要一个图灵完备的脚本编程语言,常用的有luapythonjavascript等,这里我选择了javascript,因为javascript是一门非常流行的脚本语言,而且有着非常丰富的生态,当然还有个很重要的原因就是有一个非常优秀的javascript解释器库 goja,性能非常好,而且支持完整的ES5.1标准和大部分ES6+标准,得益于它是纯golang实现的,所以可以很方便的进行跨平台编译。

注入全局对象

goja只是一个纯粹的javascript解释器,它不像在浏览器或者Node.js环境一样内置了特殊的API,比如XMLHttpRequestfetchsetTimeout等等,所以我需要在golang中实现这些API,然后注入到全局对象中,这样才能让扩展脚本能像在浏览器环境一样使用这些API

当然我不可能完全模拟一个浏览器环境,目前只实现了一些常用的API,例如:XMLHttpRequestfetchsetTimeoutsetIntervalcryptoBufferconsole等等,这样就得到了一个阉割版的浏览器环境的javascript解释器。

这里有个很有意思的地方,其中XMLHttpRequest是用goang实现的,然后fetch是通过whatwg-fetch这个 npm 包做的polyfill,不得不感叹js还是好玩,各种奇技淫巧,而且偷偷告诉你,react-native也是用whatwg-fetch来实现的fetch,别问我怎么知道的,因为我就是借鉴(抄)的它。

关于这部分具体实现,大家如果有兴趣可以看下源码

脚本引擎和 Golang 的交互

脚本引擎准备好了,回到之前脚本入口配置,每当满足匹配规则时,就会执行对应的脚本,这里就需要脚本引擎和 Golang 的交互了,我需要把Golang中的对象传递给脚本引擎,然后脚本引擎访问和修改这些对象,这样就可以实现脚本和 Golang 的交互了。

比如在onResovle入口中,我需要把Golang中的Request对象传递给脚本引擎,先来看看扩展的脚本:

gopeed.events.onResolve((ctx) => {
  ctx.res = {
    name: "example",
    files: [
      {
        name: "index.html",
        req: {
          url: "https://example.com",
        },
      },
    ],
  };
});

脚本引擎执行gopeed.events.onResolve()来注册一个回调函数,然后在Golang中获取到回调函数,并在执行的时候把上下文ctx作为参数传递进去,示例代码如下:

type Request struct {
  URL     string `json:"url"`
}

type FileInfo struct {
    Name string `json:"name"`
    Path string `json:"path"`
    Size int64  `json:"size"`

    Req *Request `json:"req"`
}

type Resource struct {
  Name string `json:"name"`
    Size int64  `json:"size"`
    Files []*FileInfo `json:"files"`
}

type OnResolveContext struct {
    Req *Request  `json:"req"`
    Res *Resource `json:"res"`
}

// 当触发解析任务时,执行扩展脚本,以下为伪代码
func (d *Download) Resolve(req *Request) *Response{
  // 1. 按照规则匹配生效的扩展
  ext := d.matchExt(req)
  if ext != nil {
    // 2. 注入 gopeed 全局对象
    gopeed := &Gopeed{
      events: map[string]goja.Callable
    }
    d.engine.Set("gopeed", gopeed)
    // 3. 执行扩展脚本拿到回调函数
    d.engine.RunScript(ext.script)
    onResolve := gopeed.events["onResolve"]
    // 4. 执行回调函数,传入上下文参数
    ctx := &OnResolveContext{
      Req: &Request{
        URL: "https://example.com",
      },
    }
    d.engine.CallFunction(onResolve, ctx)
    // 5. 获取上下文结果进行处理
    if ctx.Res != nil {
      // 如果扩展脚本返回了解析结果,就直接返回
      return ctx.Res
    }
  }

  // ... 正常解析逻辑
}

以上就是一个扩展脚本的执行流程,这里只是举例了onResolve入口,其他入口的执行流程也是类似的。

扩展管理器

扩展的基本功能已经有了,接下来就需要一个扩展管理器来管理扩展的安装、更新、卸载等等。

安装扩展

前面说过,扩展是通过git clone来进行安装的,这里我通过go-git这个库来进行git相关操作,步骤如下:

  1. 通过git clone把扩展仓库克隆到本地临时目录。
  2. 读取manifest.json配置文件,解析扩展的基本信息。
  3. 如果是一个有效的扩展项目,就把扩展移动到下载器扩展文件夹下,并按照扩展的唯一标识重命名扩展文件夹。
  4. 把扩展信息写入到本地数据库中。

更新扩展

更新扩展和安装扩展步骤类似,区别就是第 3 步做diff操作,找出需要新增、修改、删除的文件,然后进行相应的操作。

这里可能会有人疑问为啥不直接用 git pull 来做更新,因为考虑到要支持直接通过本地文件夹安装扩展,而这种方式安装的扩展不一定是个 git 仓库,所以就自己实现了一套更新逻辑,也就是说我只依赖 git clone 来作为一种扩展安装方式,这样的话抛开 git 扩展系统也是可以正常工作的。

卸载扩展

这个就比较简单了,只需要把扩展文件夹删除,然后从数据库中删除扩展信息即可。

扩展开发工具包

前面说过扩展引擎是一个阉割版的浏览器环境,并且只支持部分es6+语法,如果是纯手写javascript来开发扩展,那么开发体验肯定是非常糟糕的,就像在IE 8上用着es5语法开发一样,所以为了能提升扩展开发体验,我开发了一个配套的javascript 库,这个库是由pnpm monorepo管理的,里面包含多个npm包,接下来我会一一介绍。

脚手架

首先是一个脚手架,用来初始化一个扩展项目,只需要执行以下命令即可快速初始化一个扩展项目:

npx create-gopeed-ext@latest

√ Project name (gopeed-extension-demo) ...
√ Choose a template » Webpack

Success! Created gopeed-extension-demo at D:\code\study\js\gopeed-extension-demo
Inside that directory, you can run several commands:

  git init
    Initialize git repository

  npm install
    Install dependencies

  npm run dev
    Compiles and hot-reloads for development.

  npm run build
    Compiles and minifies for production.

We suggest that you begin by typing:

  cd gopeed-extension-demo

Happy coding!

里面提供了两种模板:

  • Webpack:

    内置了webpack+babel+eslint+prettier的模版,对于有前端开发经验的同学来说应该很熟悉了,有了webpack+babel,可以随意使用最新的es语法开发扩展,而且还可以使用npm包,webpack 配置好了GopeedPolyfillPlugin,它会自动垫片上node环境下才有的API,比如httppath等等,这样就可以在扩展脚本里使用这些API了。

  • Pure:
    javascript的模版,没有任何依赖,适合那些不想用装node环境的同学,当然只适合开发一些简单的扩展,如果要用到npm包,还是推荐用Webpack模版。

扩展类型声明

没有类型提示的开发体验是非常糟糕的,我用typescript写了一个配套的类型声明库,这样在vscode里就可以有完整的类型提示了,由于我注入的是全局对象,在typescript中需要这样声明:

declare global {
  const gopeed: Gopeed;
}

在安装完依赖之后,无需显示引用就可以获得类型提示,效果如下:

Polyfill 插件

此仓库forknode-polyfill-webpack-plugin,用于垫片node环境的 API,比如httppath等等,在webpack生态中这已经是一套非常成熟的垫片方案了,但是由于Gopeed的扩展环境并不是真正的浏览器环境,所以在使用这些垫片方案时碰到了一些问题,比如说有个vm模块,它是node环境下用来环境隔离的,基于浏览器环境的垫片实现是通过iframe来实现的,但是Gopeed扩展环境是没有iframe的,导致此垫片方案都不适用,我就fork了一份来为Gopeed定制了一套vm垫片方案,当然我还修改了部分垫片实现,使其更适合Gopeed的扩展环境。

总的来说就是我要让Gopeed扩展环境尽可能对齐浏览器环境,然后其它的都通过垫片来实现,如果有需要特殊处理的垫片,就做定制化开发处理,有了这套垫片方案,就可以愉快的使用大部分npm包了,目前我开发的几个扩展都是基于npm包来实现的,效果还是不错的。

后续如果有更多的垫片需求,我会继续完善这个库。

结语

我花费了大量的时间和精力来实现这套扩展系统,希望Gopeed能像油猴那样有一个完善的扩展生态,目前我开发的几个扩展只是抛砖引玉,毕竟我一个人的力量还是有限的,希望能有更多感兴趣的同学参与进来开发扩展,让Gopeed的功能更加强大,最后的最后,希望大家能给 Gopeed 点个star支持一下,十分感谢!

相关链接


mokeyWie
2.5k 声望642 粉丝

全干工程师~