如果你喜欢的DevOps工具还没有被DevLake支持,不要担心。实现一个DevLake插件并不困难。在这篇文章中,我们将了解DevLake插件的基础知识,并一起从头开始建立一个插件的例子。
DevLake插件是用Go的plugin包构建的共享库,在运行时与DevLake核心挂钩。
一个插件可以通过三种方式扩展DevLake的能力。
一个插件主要包括可以由DevLake核心执行的子任务的集合。对于数据源插件,一个子任务可能是从数据源中收集一个实体(例如,来自Jira的问题)。除了子任务,还有一些钩子,插件可以实现自定义其初始化、迁移等。最重要的接口列表见下文。
下图是一个插件执行的流程:
flowchart TD subgraph S4[Step4 Extractor 运行流程] direction LR D4[DevLake] D4 -- "Step4.1 创建\n ApiExtractor 并执行" --> E["ExtractXXXMeta.\nEntryPoint"]; E <-- "Step4.2 读取raw table" --> E2["RawDataSubTaskArgs\n.Table"]; E -- "Step4.3 解析 RawData" --> ApiExtractor.Extract ApiExtractor.Extract -- "返回 gorm 模型" --> E end subgraph S3[Step3 Collector 运行流程] direction LR D3[DevLake] D3 -- "Step3.1 创建\n ApiCollector 并执行" --> C["CollectXXXMeta.\nEntryPoint"]; C <-- "Step3.2 创建raw table" --> C2["RawDataSubTaskArgs\n.RAW_BBB_TABLE"]; C <-- "Step3.3 构造请求query" --> ApiCollectorArgs.\nQuery/UrlTemplate; C <-. "Step3.4 通过 ApiClient \n请求并返回HTTP" --> A1["HTTP APIs"]; C <-- "Step3.5 解析\n并返回请求结果" --> ResponseParser; end subgraph S2[Step2 DevLake 的自定义插件] direction LR D2[DevLake] D2 <-- "Step2.1 在\`Init\` \n初始化插件" --> plugin.Init; D2 <-- "Step2.2 (Optional) 调用\n与返回 migration 脚本" --> plugin.MigrationScripts; D2 <-- "Step2.3 (Optional) \n初始化并返回taskCtx" --> plugin.PrepareTaskData; D2 <-- "Step2.4 返回\n 需要执行的子函数" --> plugin.SubTaskContext; end subgraph S1[Step1 DevLake 的运行] direction LR main -- "通过 \`runner.DirectRun\`\n 移交控制权" --> D1[DevLake]; end S1-->S2-->S3-->S4
图中信息非常多,当然并不期望马上就能消化完,仅仅作为阅读后文的参考即可。
在本节中,我们将介绍如何从头创建一个数据收集插件。要收集的数据是 Apache 项目的所有 Committers 和 Contributors 信息,目的是检查其是否签署了 CLA。我们将通过:
https://people.apache.org/public/icla-info.json 获取 Committers 信息邮件列表 获取 Contributors 信息 我们将演示如何通过 Apache API 请求并缓存所有 Committers 的信息,并提取出结构化的数据。Contributors 的收集仅做一些思路的介绍。**注意:**在开始之前,请确保DevLake已经能正确启动了。
关于插件的其他信息: 一般来说, 我们需要这几个目录:
api,models和tasksapi实现config-ui等其他服务所需的api注:如果这些概念让你感到迷惑,不要担心,我们稍后会逐一解释。
DevLake 提供了专门的工具 Generator 来创建插件,可以通过运行go run generator/main.go creat-plugin icla来构建新插件,创建的时候会需要输入「是否需要默认的apiClient with_api_client」和「要收集的网站endpoint」。
with_api_client用于选择是否需要通过api_client发送HTTP APIs。endpoint用于确认插件将请求哪个网站,在本案例中是https://people.apache.org/。
现在我们的插件里有三个文件,其中api_client.go和task_data.go在子文件夹tasks/中。 
接下来让我们试着运行plugin_main.go中的main函数来启动插件,运行结果应该如下:
$go run plugins/icla/plugin_main.go [2022-06-02 18:07:30] INFO failed to create dir logs: mkdir logs: file exists press `c` to send cancel signal [2022-06-02 18:07:30] INFO [icla] start plugin invalid ICLA_TOKEN, but ignore this error now [2022-06-02 18:07:30] INFO [icla] scheduler for api https://people.apache.org/ worker: 25, request: 18000, duration: 1h0m0s [2022-06-02 18:07:30] INFO [icla] total step: 0
😋 没有报错,那就是成功啦~ plugin_main.go这里定义了插件,有一些配置是保存在task_data.go中。这两个文件就构成了最简单的插件,而文件api_client.go后面会用来发送HTTP APIs。
在开始创建之前,我们需要先了解一下子任务的执行过程。
plugin_main.PrepareTaskData(),准备一些子任务所需要的环境数据,本项任务中需要创建一个apiClient。plugin_main.SubTaskMetas()的子任务,子任务都是互相独立的函数,可以用于完成注入发送API请求,处理数据等任务。每个子任务必须在
SubTaskMeta中定义,并实现其中的SubTaskEntryPoint函数,其结构为type SubTaskEntryPoint func(c SubTaskContext) error更多信息见:https://devlake.apache.org/blog/how-apache-devlake-runs/
注:如果这些概念让你感到迷惑,跳过跟着一步步做就好。
同样的,运行go run generator/main.go create-collector icla committer来创建子任务。Generator运行完成后,会自动创建新的文件,并在plugin_main.go/SubTaskMetas中激活。

- Collector将从HTTP或其他数据源收集数据,并将数据保存到rawLayer中。
httpCollector的SubTaskEntryPoint中,默认会使用helper.NewApiCollector来创建新的ApiCollector对象,并调用其execute()来并行收集。注:如果这些概念让你感到迷惑,跳过就好。
现在你可以注意到在plugin_main.go/PrepareTaskData.ApiClient中有引用data.ApiClient,它是Apache DevLake推荐用于从HTTP APIs请求数据的工具。这个工具支持一些很有用的功能,比如请求限制、代理和重试。当然,如果你喜欢,也可以使用http库来代替,只示会显得更加繁琐而已。
回到正题,现在的目标是从https://people.apache.org/public/icla-info.json收集数据,因此需要完成以下步骤:
我们已经在之前中把https://people.apache.org/填入tasks/api_client.go/ENDPOINT了,现在在看一眼确认下。

public/icla-info.json填入UrlTemplate,删除不必要的迭代器,并在ResponseParser中添加println("receive data:", res)以查看收集是否成功。
好了,现在Collector已经创建好了,再次运行main来启动插件,如果一切顺利的话,输出应该是这样的:
[2022-06-06 12:24:52] INFO [icla] start plugin invalid ICLA_TOKEN, but ignore this error now [2022-06-06 12:24:52] INFO [icla] scheduler for api https://people.apache.org/ worker: 25, request: 18000, duration: 1h0m0s [2022-06-06 12:24:52] INFO [icla] total step: 1 [2022-06-06 12:24:52] INFO [icla] executing subtask CollectCommitter [2022-06-06 12:24:52] INFO [icla] [CollectCommitter] start api collection receive data: 0x140005763f0 [2022-06-06 12:24:55] INFO [icla] [CollectCommitter] finished records: 1 [2022-06-06 12:24:55] INFO [icla] [CollectCommitter] end api collection [2022-06-06 12:24:55] INFO [icla] finished step: 1 / 1
从以上日志中,可以看到已经能打印出收到数据的日志了,最后一步是在ResponseParser中对响应体进行解码,并将其返回给DevLake,以便将其存储在数据库中。
ResponseParser: func(res *http.Response) ([]json.RawMessage, error) {
body := &struct {
LastUpdated string `json:"last_updated"`
Committers json.RawMessage `json:"committers"`
}{}
err := helper.UnmarshalResponse(res, body)
if err != nil {
return nil, err
}
println("receive data:", len(body.Committers))
return []json.RawMessage{body.Committers}, nil
},
再次运行函数main,结果如下,此时可以在数据库表_raw_icla_committer中看到一条新的数据。
…… receive data: 272956 /* <- 这个数字表示收到了272956个Committer */ [2022-06-06 13:46:57] INFO [icla] [CollectCommitter] finished records: 1 [2022-06-06 13:46:57] INFO [icla] [CollectCommitter] end api collection [2022-06-06 13:46:57] INFO [icla] finished step: 1 / 1

- Extractor将从rawLayer中提取数据并保存到工具db表中。
- 除了一些具体的处理内容,主流程与采集器类似。
从HTTP API收集的数据目前仅仅保存在表_raw_XXXX中,但其使用起来却很不容易。因此我们将继续从其中提取Committer的名字。目前Apache DevLake建议用gorm来保存数据,所以我们将用gorm创建一个模型,并将其添加到plugin_main.go/AutoMigrate()中。
plugins/icla/models/committer.go
package models
import (
"github.com/apache/incubator-devlake/models/common"
)
type IclaCommitter struct {
UserName string `gorm:"primaryKey;type:varchar(255)"`
Name string `gorm:"primaryKey;type:varchar(255)"`
common.NoPKModel
}
func (IclaCommitter) TableName() string {
return "_tool_icla_committer"
}
plugins/icla/plugin_main.go 
在做完以上步骤以后,就可以再次运行插件,刚定义的数据表_tool_icla_committer会自动创建,就像下面的截图。 
接下来,让我们运行go run generator/main.go create-extractor icla committer并输入命令行提示的内容,来创建新的子任务。

运行完成后,来看看刚才创建的committer_extractor.go中的函数extract,很明显参数中的resData.data是原始数据,我们需要用json解码,并创建IclaCommitter模型来保存它们。
Extract: func(resData *helper.RawData) ([]interface{}, error) {
names := &map[string]string{}
err := json.Unmarshal(resData.Data, names)
if err != nil {
return nil, err
}
extractedModels := make([]interface{}, 0)
for userName, name := range *names {
extractedModels = append(extractedModels, &models.IclaCommitter{
UserName: userName,
Name: name,
})fco
}
return extractedModels, nil
},
再次运行插件,结果如下:
[2022-06-06 15:39:40] INFO [icla] start plugin invalid ICLA_TOKEN, but ignore this error now [2022-06-06 15:39:40] INFO [icla] scheduler for api https://people.apache.org/ worker: 25, request: 18000, duration: 1h0m0s [2022-06-06 15:39:40] INFO [icla] total step: 2 [2022-06-06 15:39:40] INFO [icla] executing subtask CollectCommitter [2022-06-06 15:39:40] INFO [icla] [CollectCommitter] start api collection receive data: 272956 [2022-06-06 15:39:44] INFO [icla] [CollectCommitter] finished records: 1 [2022-06-06 15:39:44] INFO [icla] [CollectCommitter] end api collection [2022-06-06 15:39:44] INFO [icla] finished step: 1 / 2 [2022-06-06 15:39:44] INFO [icla] executing subtask ExtractCommitter [2022-06-06 15:39:46] INFO [icla] [ExtractCommitter] finished records: 1 [2022-06-06 15:39:46] INFO [icla] finished step: 2 / 2
可以看到有两个任务运行完成,同时观察数据库发现,提交者的数据已经保存在_tool_icla_committer中了~ 
注意。这里有两种方式(开源或自己使用)。因此 Converter 不是必须的,但我们鼓励使用它,因为 Converter 和 DomainLayer 非常有助于建立通用的仪表盘。关于 DomainLayer 的更多信息请见:https://devlake.apache.org/docs/DataModels/DevLakeDomainLayerSchema/
- Converter 将处理 DomainLayer 的数据,并将其保存到 DomainLayer 层中。
- 使用
helper.NewDataConverter来创建一个 DataConvertor 的对象,然后调用execute()来运行。
有时 OpenApi 会受到 token 或其他保护,只有获得 token 才来能访问。例如在本案例中,我们只有在登录private@apahce.com后,才能收集到关于普通 Contributor 签署ICLA的数据。但这里受限于篇幅,仅仅简单介绍一下如何收集需要授权的数据。
让我们注意api_client.go文件,其中NewIclaApiClient通过.env加载配置了ICLA_TOKEN,它让我们可以在.env中添加ICLA_TOKEN=XXXX,并在apiClient.SetHeaders()中使用它来模拟登录状态。代码如下。 
当然,我们可以使用username/password来获取模拟登录后的token,试着根据实际情况进行调整即可。
更多相关细节请看https://github.com/apache/incubator-devlake
如下gitlab插件示例所示 将所有需要被外部插件访问到的 model 均添加到返回值中。
var _ core.PluginModel = (*Gitlab)(nil) func (plugin Gitlab) GetTablesInfo() []core.Tabler { return []core.Tabler{ &models.GitlabConnection{}, &models.GitlabAccount{}, &models.GitlabCommit{}, &models.GitlabIssue{}, &models.GitlabIssueLabel{}, &models.GitlabJob{}, &models.GitlabMergeRequest{}, &models.GitlabMrComment{}, &models.GitlabMrCommit{}, &models.GitlabMrLabel{}, &models.GitlabMrNote{}, &models.GitlabPipeline{}, &models.GitlabProject{}, &models.GitlabProjectCommit{}, &models.GitlabReviewer{}, &models.GitlabTag{}, } }
可以使用如下方式来使用该接口
if pm, ok := plugin.(core.PluginModel); ok {
tables := pm.GetTablesInfo()
for _, table := range tables {
// do something
}
}
恭喜你! 第一个插件已经创建完毕! 🎖 我们鼓励开源贡献~ 接下来还需要学习 migrationScripts 和 domainLayers 来编写规范的、平台无关的代码。更多信息请访问https://devlake.apache.org/docs/DataModels/DevLakeDomainLayerSchema,或联系我们以获得热情洋溢的帮助。
