NebulaGraph 内核贡献开发指南

如何 build NebulaGraph?如何为 NebulaGraph 内核做贡献?从本章作为切入点就够了。

为了方便对 NebulaGraph 尚未了解的读者也能快速直接从贡献代码为起点了解它,我把开发、贡献内核代码入手所需要的基本架构知识也在这里以最小信息量的形式总结一下,作为前导知识,请资深的 NebulaGraph 玩家直接跳过这一章节。

NebulaGraph 的架构和 Google Spanner,TiDB 很相似,核心部分只有三种服务、进程:Graph 服务、Meta 服务和 Storage 服务。它们之间彼此通过 TCP 之上的 Thrift RPC 协议进行通信。

https://docs-cdn.nebula-graph.com.cn/docs-2.0/1.introduction/2.nebula-graph-architecture/nebula-graph-architecture-1.png

NebulaGraph 是存储与计算分离的架构,它的 Meta 服务和 Storage 服务共同组成了存储层,Graph 服务是内核提供的计算层。

这样的设计使得 NebulaGraph 的集群部署可以灵活按需分配计算、存储的资源,比如我们可以为同一个集群中创建不同配置的两组 Graph 服务实例、用来面向不同类型的业务。

同时,计算层解耦于存储层使得在 NebulaGraph 之上的构建不同的特定计算层成为可能,比如 NebulaGraph Algorithm、NebulaGraph Analytics 就是在 NebulaGraph 之上构建了异构的另一个计算层,如果需要,任何人也可以定制自己的专属计算层,从而满足统一图基础存储之上的复合、多样的计算需求。

Graph 服务是对外接受图库登录、图查询请求、集群管理操作、schema 定义所直接连接的服务,他的进程名字叫 graphd,表示 nebula graph daemon。

Graph 服务的每一个进程是无状态的,这使得横向扩缩 Graph 服务的实例非常灵活、简单。

Graph 服务也叫 Query Engine,其内部和传统的数据库系统的设计非常相似,分为:解析、校验、计划、执行几部分。

https://docs-cdn.nebula-graph.com.cn/docs-2.0/1.introduction/2.nebula-graph-architecture/query-engine-architecture.png

Meta 服务顾名思义负责元数据管理,进程名字叫 metad。这些元数据包括:

  • 所有的图空间、Schema 定义
  • 用户鉴权、授权信息
  • 集群服务的发现与服务的分布
  • 图空间中的数据分布

Meta 服务的进程可以单实例部署,在非单机部署的场景下,为了数据、服务的高 SLA ,我们可以奇数多个实例的部署,通常来说 3 个 nebula-metad 就足够了,三个 nebula-metad 通过 RAFT 共识协议构成一个集群提供服务。

https://docs-cdn.nebula-graph.com.cn/docs-2.0/1.introduction/2.nebula-graph-architecture/meta-architecture1.png

Storage 服务存储所有的图数据,进程名字叫 storaged。storaged 分布式地存储图数据,为 Graph 内部的图查询执行期提供底层的图语义存储接口,方便 Storage 客户端通过 Thrift RPC 协议面向涉及的 storaged 示例进行图语义的读写。

当 NebulaGraph 中图空间的副本数大于 1 的时候,每一个分区都会在不同 storaged 示例上有副本,副本之间则通过 RAFT 协议协调同步与读写。

https://www-cdn.nebula-graph.com.cn/nebula-blog/nebula-reading-storage-architecture.png

参考 文档:架构介绍,了解更多详情;

推荐阅读社区官方播客的架构系列文章:nebula-graph.com.cn/tags/架构系列

graphd、metad、storaged 之间通过 Thrift 协议进行远程调用(RPC),下边给一些例子:

graphd 会通过 metaclient 调用 metad

  • 将自己报告为一个正在运行的服务,以便被发现
  • 为用户(使用 graphclient )登录进行 RPC 调用
  • 当它处理 nGQL 查询时,获取图形存储分布情况

graphd 会通过 storageclient 调用 storaged

  • 在处理 nGQL 时,在它从 metad 获得所需的元信息后,进行图形数据的读/写

storaged 会通过 metaclient调用 metad

  • 将自己报告为一个正在运行的服务,以便被发现

当然有状态的存储引擎内部也有集群同步的流量与通信

  • storaged 与其他 storaged 有 RAFT 连接

  • metad 与其他 metad 实例有 RAFT 连接

接下来我们开始 NebulaGraph 的构建、开发环境的部分。

NebulaGraph 只支持在 GNU/Linux 分支中构建,目前来说,最方便的方式是在社区预先提供好了依赖的容器镜像的基础上在容器内部构建、调试 NebulaGraph 代码的更改和 Debug。

为了更方便地调试代码,我习惯提前创建一个 NebulaGraph Docker 环境,我们可以使用官方的 Docker-Compose 方式部署,也可以使用我在官方 Docker-Compose 基础之上弄的一键部署工具:nebula-up.siwei.io。

以 nebula-up 为例:

在我们的 Linux 开发服务器中执行 curl -fsSL nebula-up.siwei.io/install.sh | bash 就可以了。

NebulaGraph 的代码仓库托管在 GitHub 之上,我们可以在有互联网的地方直接克隆下来:

1
2
git clone git@github.com:vesoft-inc/nebula.git
cd nebula

有了 NebulaGraph 集群,我们可以借助 https://github.com/vesoft-inc/nebula-dev-docker/ 提供的开箱即用开发容器镜像,搭建开发环境:

1
2
3
4
5
6
7
8
9
export TAG=ubuntu2004
docker run -ti \
  --network nebula-net \
  --security-opt seccomp=unconfined \
  -v "$PWD":/home/nebula \
  -w /home/nebula \
  --name nebula_dev \
  vesoft/nebula-dev:$TAG \
  bash

这其中,-v "$PWD" 表示当前的 NebulaGraph 代码本地的路径会被映射到开发容器内部的 /home/nebula,而启动的容器名字是 nebula_dev

待这个容器启动之后,我们会自动进入到这个容器的 bash shell 之中,如果我们输入 exit 退出容器,它会被关闭,如果我们想再次启动容器,只需要执行:

1
docker start nebula_dev

之后,我们的编译、Debug、测试工作都在 nebula_dev 容器内部进行,在容器是运行状态的情况下,可以随时新建一个容器内部的 bash shell 进程:

1
docker exec -ti nebula_dev bash

注,为了保持编译环境是最新的,我们可以定期删除、拉取、重建这个开发容器,以保持环境与代码相匹配。

nebula_dev 这个容器内部,我们可以进行代码编译:

进入编译容器

1
docker exec -ti nebula_dev bash

用 CMake 准备 makefile,第一次构建的时候,为了节省时间、内存,我关闭了测试(-DENABLE_TESTING=OFF):

1
2
mkdir build && cd build
cmake -DCMAKE_CXX_COMPILER=$TOOLSET_CLANG_DIR/bin/g++ -DCMAKE_C_COMPILER=$TOOLSET_CLANG_DIR/bin/gcc -DENABLE_WERROR=OFF -DCMAKE_BUILD_TYPE=Debug -DENABLE_TESTING=OFF ..

开始编译,根据服务器的空闲 CPU 个数和内存量力而行,比如我在自己 72 核心的服务器上准备允许同时运行 64 个 job,则运行:

1
make -j64

第一次构建的时间会慢一些,在 make 成功之后,我们也可以执行 make install 把二进制安装到像生产安装时候一样的路径:

1
2
3
4
5
6
7
root@1827b82e88bf:/home/nebula/build# make install

root@1827b82e88bf:/home/nebula/build# ls /usr/local/nebula/bin
db_dump  db_upgrader  meta_dump  nebula-graphd  nebula-metad  nebula-storaged

root@1827b82e88bf:/home/nebula/build# ls /usr/local/nebula/
bin  etc  pids  scripts  share

以 GraphD 调试为例。

安装一些后边会方便 Debug 额外用到的依赖

1
2
3
4
5
6
7
8
9
# 装一个 ping,测试一下 nebula-up 安装的集群可以访问
apt update && apt install iputils-ping -y
# ping graphd 试试看
ping graphd -c 4

# 安装 gdb gdb-dashboard
apt install gdb -y
wget -P ~ https://git.io/.gdbinit
pip install pygments

准备一个 NebulaGraph 的命令行客户端:

1
2
3
4
5
6
7
# 新开一个 nebula_dev 的 shell
docker exec -ti nebula_dev bash

# 下载 nebula-console 二进制文件,并赋予可执行权限,命名为 nebula-console 并安装到 /usr/bin/ 下
wget https://github.com/vesoft-inc/nebula-console/releases/download/v3.2.0/nebula-console-linux-amd64-v3.2.0
chmod +x nebula-console*
mv nebula-console* /usr/bin/nebula-console

连接到前边我们 nebula-up 准备的集群之上,加载 basketballplayer 这个测试数据:

1
2
3
nebula-console -u root -p nebula --address=graphd --port=9669
:play basketballplayer;
exit

我们用 gdb 执行刚刚编译的 nebula-graphd 二进制,让他成为一个新的 graphd 服务,名字就叫 nebula_dev

首先启动 gdb

1
2
3
4
5
6
# 新开一个 nebula_dev 的 shell
docker exec -ti nebula_dev bash

cd /usr/local/nebula/
mkdir -p /home/nebula/build/log
gdb bin/nebula-graphd

在 gdb 内部执行设置必要的参数

跟随 fork 的子进程

1
set follow-fork-mode child

设置待调试 graphd 的启动参数(配置):

  • meta_server_addrs 填已经启动的集群的所有 metad 的地址
  • local_ip 和 ws_ip 填本容器的域名,port 是 graphd 监听端口
  • log_dir 是输出日志的目录,v 和 minloglevel 是日志的输出等级
1
2
3
4
5
6
7
8
9
set args --flagfile=/usr/local/nebula/etc/nebula-graphd.conf.default \
    --meta_server_addrs=metad0:9559,metad1:9559,metad2:9559 \
    --port=9669 \
    --local_ip=nebula_dev \
    --ws_ip=nebula_dev \
    --ws_http_port=19669 \
    --log_dir=/home/nebula/build/log \
    --v=4 \
    --minloglevel=0

如果我们想加断点在 src/common/function/FunctionManager.cpp 2783 行,可以再执行:

1
b /home/nebula/src/common/function/FunctionManager.cpp:2783

配置前边安装的 gdb-dashboard,一个开源的 gdb 界面插件。

1
2
# 设定在 gdb 界面上展示 代码、历史、回调栈、变量、表达几个部分,详细参考 https://github.com/cyrus-and/gdb-dashboard
dashboard -layout source history stack variables expressions

最后我们让进程通过 gdb 跑起来吧:

1
run

之后,我们就可以在这个窗口/shell 会话下调试 graphd 程序了。

这里,我以 #3513 这个 issue 为例子,快速介绍一下代码修改的过程。

这个 issue 表达的内容是在有一小部分用户决定把 JSON 以 String 的形式存储在 NebulaGraph 中的属性里,因为这种方式比较罕见且不被推崇,NebulaGraph 没有直接支持对 JSON String 直接解析的方法。

本来这个功能是等到很希望得到支持的同学过来亲自去实现的,而在 Issue 中,刚好有一位新手贡献者在里边回复求助如何能开始参与这个贡献。接着这个契机,我去参与讨论看了一下这个功能可以实现成什么样子,最终讨论的结果是可以做成和 MySQL 中的 JSON_EXTRACT 函数那样,但是改为只接受 JSON String,无需处理输出路径参数。

这个任务一句话来说就是为 NebulaGraph 引入一个解析 JSON String 为 Map 的函数。那么,应该大概如何实现这个功能呢?

很自然,引入新的函数的更改肯定有很多,所以我们只需要找到之前增加新函数的 PR 就可以快速知道在哪些地方修改了。

当然我们可以自底向上去了解 NebulaGraph 整体的代码结构,然后一点点找到函数处理的位置,事实上有的时候我们也不得不这么做,这时候除了代码本身,一些面向贡献者的文章可能会帮助我们事半功倍对整体有一个了解,NebulaGraph 官方博客里就有这样的一个系列文章,推荐大家在贡献的时候也去通读一下:nebula-graph.com.cn/posts/nebula-graph-source-code-reading-00

于是,我从 #4526 这个 PR 里了解到所有函数入口都被统一管理在 src/common/function/FunctionManager.cpp 之中,通过搜索、理解其中其他某一个函数的关键词之后可以很容易理解一个函数实体的关键词、输入输出数据类型、以及它的处理逻辑的代码在哪里实现。

同时,我注意到在同一个目录下,src/common/function/test/FunctionManagerTest.cpp 之中则是所有这些函数的单元测试代码,用同样的方式也可以知道新加的一个函数需要如何在里边实现基于 gtest 的单元测试。

注:在修改代码之前,请确保自己在最新的 master 分支之上创建一个单独的分支,这里的例子中,我把分支名字叫 fn_JSON_EXTRACT

1
2
3
git checkout master
git pull
git checkout -b fn_JSON_EXTRACT

通过 Google 了解与交叉验证 NebulaGraph 内部使用的 utils 库,我知道我应该用 folly::parseJson 把字符串读成 folly::dynamic 然后再 cast 成 NebulaGraph 内置的 Map() 类型,然后,借助于 StackOverflow/GitHub Copilot,我终于完成了第一个版本的代码修改。

接下来,我兴冲冲地改好了第一版的代码,信心满满地开始编译!实际上因为是 CPP 新手,即使在 Copilot 的加持下,我的代码还是花了好几次修改才通过编译。

然后,我开始用 GDB 把修改了的 GraphD 启动起来,用 console 发起 JSON_EXTRACT 的函数调用,先调通了期待中的效果,并试着跑几种异常的输入,在发现新问题、修改、编译、调试的几轮循环下让代码达到了期望的状态,这时候,我知道我要把代码提交到 GitHub 请项目的资深贡献者帮忙 review 啦!

PR(Pull Request)是 GitHub 中方便多人代码协作、代码审查中的一种方式,它通过把一个 repo 下的分支与这个审查协作的实例(PR)做映射,得到一个项目下唯一的 PR 号码之后,生成单独的网页,在这个网页下,我们可以做不同贡献者之间的交流和后续的代码更新,这个过程中代码提交者们可以一直在这个分支上不断提交代码直到代码的状态被各方同意之后,就可以合并(merge)到目的分支中。

这个过程可以分为:

  • 创建 GitHub 上远程的个人开发分支
  • 基于分支创建目标项目仓库中的 PR
  • 在 PR 中协作、讨论、不断再次提交到开发分支直到多方达到合并、或者关闭的共识

这一步骤里,我们要把当前的本地提交的 commit,提交到自己的 GitHub 分叉之中。

首先,我们确认一下本地的修改是否都是期待中的:

1
2
3
4
# 先确定修改的文件
$ git status
# 再看看修改的内容
$ git diff

然后,把它们 commit(提交在本地仓库)

1
2
3
4
5
6
# 添加所有当前目录(. 这个点表示当前目录)修改过的文件为待 commit
$ git add .
# 然后我们可以看一下状态,这些修改的文件状态已经不同了
$ git status
# 最后,提交在本地仓库,并用 -m 参数指定单行的 commit message
$ git commit -m "feat: introduce function JSON_EXTRACT"

在提交之前,要确保自己的 GitHub 账号之下确实存在 NebulaGraph 代码仓库的分叉(fork),比如我的 GitHub 账号是 wey-gu,我访问,那么我对 https://github.com/vesoft-inc/nebula 的分叉应该就是 https://github.com/wey-gu/nebula

如果还没有自己的分叉,可以直接在 https://github.com/vesoft-inc/nebula 上点击右上角的 Fork,创建自己的分叉仓库。

当远程的个人分叉存在之后,我们可以把代码提交上去了

1
2
3
4
# 添加一个新的远程仓库叫 wey
git remote add wey git@github.com:wey-gu/nebula.git
# 提交 JSON_EXTRACT 分支到 wey 这个 remote 仓库
git push wey JSON_EXTRACT

这时候,我们访问这个远程分支:https://github.com/wey-gu/nebula/tree/fn_JSON_EXTRACT ,就能找到 Open PR 的入口:

https://user-images.githubusercontent.com/1651790/197544575-c04a06cf-b867-409c-832e-f3d893fea784.png

然后点击 Open pull request 按钮,就进入到创建 PR 的界面了,这和在一般的论坛里提交一个帖子是很类似的:

https://user-images.githubusercontent.com/1651790/197545824-93706cc7-32a9-4638-84eb-26573106aa28.png

提交之后,我们可以等待、或者邀请其他人来做代码的审查(review),往往其他的贡献者都能从他们的角度给出一些代码修改的建议和提示,我们需要经过几轮的代码修改、讨论之后使得代码达到最佳的状态。

而这些审查者中除了社区的贡献者(人类)之外,还有一些自动化的机器人,他们会在代码库中自动化的通过持续集成(CI)的方式运行一些自动化的审查工作,可能包括以下几种:

  • CLA:Contributor License Agreement(贡献者许可协议),这是一个要 PR 作者在首次提交代码到项目时签署的协议,因为代码将被提交到公共空间,这份协议的签署意味着作者同意代码被分享、复用、修改这件事情。
  • lint:代码风格检查,这也是最常见的 CI 任务
  • test:各种层面的测试检查任务

通常来说,这些所有的自动化审查机器人所代表的任务也需要全都通过,代码的状态才能被认为是可以合并的,而不出意外,我首次提交的代码果然有测试的失败。

https://user-images.githubusercontent.com/1651790/197549538-4bba79b5-6b0a-42f4-b287-55061c08cbe8.png

NebulaGraph 里所有的 CI 测试代码也都能在本地被触发,并且(显然)都有被单独触发的方式,我们需要掌握它们而不是在每次修改一个小的测试修复之后提交到服务器上等着 CI 做全量的运行(这样常常几十分钟就这么浪费掉了)。

在这个距离的 PR 提交中,我修改的函数代码同一层级下的单元测试 CTest 就有问题,问题可能是测试代码本身造成、我们的修改破坏了原来的测试用例导致、亦或者是我们自己的测试用例发现了代码修改本身的问题。

这次,我们要根据 CTest 失败的报错进行排查和代码修改,然后编译代码,在本地运行一下这个失败的用例:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
# 我们需要进入到我们的编译容器内部的 build 目录下
$ docker exec -ti nebula_dev bash
$ cd build
# 在 -DENABLE_TESTING=ON 之中编译,如果之前的编译 job 数下内存已经跑满了的话,这次可以把 job 数调小一点,因为开启测试会占用更多内存
$ cmake -DCMAKE_CXX_COMPILER=$TOOLSET_CLANG_DIR/bin/g++ -DCMAKE_C_COMPILER=$TOOLSET_CLANG_DIR/bin/gcc -DENABLE_WERROR=OFF -DCMAKE_BUILD_TYPE=Debug -DENABLE_TESTING=ON ..
$ make -j 48

# 可以看到编译成功了 CTest 的单元测试二进制可执行文件
# [100%] Linking CXX executable ../../../../bin/test/function_manager_test
# [100%] Built target function_manager_test

# 执行重新修改过的单元测试!
$ bin/test/function_manager_test

[==========] Running 11 tests from 1 test suite.
[----------] Global test environment set-up.
[----------] 11 tests from FunctionManagerTest
[ RUN      ] FunctionManagerTest.testNull
[       OK ] FunctionManagerTest.testNull (0 ms)
[ RUN      ] FunctionManagerTest.functionCall
W20221020 23:35:18.579897 28679 Map.cpp:77] JSON_EXTRACT nested layer 1: Map can be populated only by Bool, Double, Int, String value and null, now trying to parse from: object
[       OK ] FunctionManagerTest.functionCall (2 ms)
[ RUN      ] FunctionManagerTest.time
[       OK ] FunctionManagerTest.time (0 ms)
[ RUN      ] FunctionManagerTest.returnType
[       OK ] FunctionManagerTest.returnType (0 ms)
[ RUN      ] FunctionManagerTest.SchemaRelated
[       OK ] FunctionManagerTest.SchemaRelated (0 ms)
[ RUN      ] FunctionManagerTest.ScalarFunctionTest
[       OK ] FunctionManagerTest.ScalarFunctionTest (0 ms)
[ RUN      ] FunctionManagerTest.ListFunctionTest
[       OK ] FunctionManagerTest.ListFunctionTest (0 ms)
[ RUN      ] FunctionManagerTest.duplicateEdgesORVerticesInPath
[       OK ] FunctionManagerTest.duplicateEdgesORVerticesInPath (0 ms)
[ RUN      ] FunctionManagerTest.ReversePath
[       OK ] FunctionManagerTest.ReversePath (0 ms)
[ RUN      ] FunctionManagerTest.DataSetRowCol
[       OK ] FunctionManagerTest.DataSetRowCol (0 ms)
[ RUN      ] FunctionManagerTest.PurityTest
[       OK ] FunctionManagerTest.PurityTest (0 ms)
[----------] 11 tests from FunctionManagerTest (5 ms total)

[----------] Global test environment tear-down
[==========] 11 tests from 1 test suite ran. (5 ms total)
[  PASSED  ] 11 tests.

成功!

于是,我把新的更改提交到远程分支上,在 PR 的网页中,可以看到 CI 已经在新的提交的触发下重新编译、执行了,过一会儿果然都 pass 了,于是我兴高采烈地等待着两位以上的审查者帮忙批准代码,然后合并它!

但是,我收到了新的建议:

https://user-images.githubusercontent.com/1651790/197553025-82f1e7e5-b8a8-4729-a9b8-0f13a34adfed.png

另一位贡献者请我添加 TCK 的测试用例。

TCK 的全称是 The Cypher Technology Compatibility Kit,它是 NebulaGraph 从 OpenCypher 社区继承演进而来的一套测试框架,我们用 Python 做了测试用例格式兼容的实现。

它的优雅在于,我们可以像写英语一样去描述我们想实现的端到端功能测试用例,像这样!

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
# tests/tck/features/function/json_extract.feature
Feature: json_extract Function

  Background:
    Test json_extract function

  Scenario: Test Positive Cases
    When executing query:
      """
      YIELD JSON_EXTRACT('{"a": "foo", "b": 0.2, "c": true}') AS result;
      """
    Then the result should be, in any order:
      | result                      |
      | {a: "foo", b: 0.2, c: true} |
    When executing query:
      """
      YIELD JSON_EXTRACT('{"a": 1, "b": {}, "c": {"d": true}}') AS result;
      """
    Then the result should be, in any order:
      | result                      |
      | {a: 1, b: {}, c: {d: true}} |
    When executing query:
      """
      YIELD JSON_EXTRACT('{}') AS result;
      """
    Then the result should be, in any order:
      | result |
      | {}     |

在添加了自己的一个新的 tck 测试用例文本文件之后,我们只需要在测试文件中临时增加标签,并在执行的时候指定标签,就可以单独执行新增的 tck 测试用例了:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
# 还是在编译容器内部,进入到 tests 目录下
cd ../tests
# 安装 tck 测试所需依赖
python3 -m pip install -r requirements.txt
python3 -m pip install nebula3-python==3.1.0
# 运行一个单独为 tck 测试准备的集群
make CONTAINERIZED=true ENABLE_SSL=true CA_SIGNED=true up
# 给 tests/tck/features/function/json_extract.feature 以@开头第一行加上标签,比如 @wey
vi tests/tck/features/function/json_extract.feature
# 执行 pytest (包含 tck 用例),因为制定了 -m "wey",只有 tests/tck/features/function/json_extract.feature 会被执行
python3 -m pytest -m "wey"
# 关闭 pytest 所依赖的集群
make CONTAINERIZED=true ENABLE_SSL=true CA_SIGNED=true down

延伸阅读:

  • 基于 BDD 理论的 Nebula 集成测试框架重构,https://nebula-graph.com.cn/posts/bdd-testing-practice
  • 如何向 NebulaGraph 增加一个测试用例,https://nebula-graph.com.cn/posts/bdd-testing-practice-add-test-case

待我们把需要的测试调通、再次提交 PR 并且 CI 用例全都通过之后,我们可以再次邀请之前帮助审查代码的同学做做最后的查看,如果一切都顺利,代码就会被合并了!

https://user-images.githubusercontent.com/1651790/197557709-2c851a03-d001-4fc9-bc5a-b858219fbded.png

题图来自 Jon

相关内容