從一個 issue 出發,帶你玩圖資料庫 NebulaGraph 核心開發

語言: CN / TW / HK

如何 build NebulaGraph?如何為 NebulaGraph 核心做貢獻?即便是新手也能快速上手,從本文作為切入點就夠了。

NebulaGraph 的架構簡介

為了方便對 NebulaGraph 尚未了解的讀者也能快速直接從貢獻程式碼為起點了解它,我把開發、貢獻核心程式碼入手所需要的基本架構知識在這裡以最小資訊量的形式總結一下。作為前導知識,請資深的 NebulaGraph 玩家直接跳過這一章節。

服務、程序

NebulaGraph 的架構和 Google Spanner、TiDB 很相似,核心部分只有三種服務程序:Graph 服務、Meta 服務和 Storage 服務。它們之間彼此通過 TCP 之上的 Thrift RPC 協議進行通訊。

http://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 Service:nebula-graphd

Graph 服務是對外接收相簿登入、圖查詢請求、叢集管理操作、Schema 定義所直接連線的服務,它的程序名字叫 graphd,表示 nebula graph daemon。

Graph 服務的每一個程序是無狀態的,這使得橫向擴縮 Graph 服務的例項非常靈活、簡單。

Graph 服務也叫 Query Engine,其內部和傳統的資料庫系統的設計非常相似,分為:解析、校驗、計劃、執行幾部分。

Meta Service:nebula-metad

Meta 服務顧名思義負責元資料管理,程序名字叫 metad。這些元資料包括:

  • 所有的圖空間、Schema 定義
  • 使用者鑑權、授權資訊
  • 叢集服務的發現與服務的分佈
  • 圖空間中的資料分佈

Meta 服務的程序可以單例項部署。在非單機部署的場景下,為了資料、服務的高 SLA ,以奇數個例項進行部署。通常來說 3 個 nebula-metad 就足夠了,3 個 nebula-metad 通過 Raft 共識協議構成一個叢集提供服務。

Storage Service:nebula-storaged

Storage 服務儲存所有的圖資料,程序名字叫 storaged。storaged 分散式地儲存圖資料,為 Graph 內部的圖查詢執行期提供底層的圖語義儲存介面,方便 Storage 客戶端通過 Thrift RPC 協議面向涉及的 storaged 示例進行圖語義的讀寫。

當 NebulaGraph 中圖空間的副本數大於 1 的時候,每一個分割槽都會在不同 storaged 示例上有副本,副本之間則通過 Raft 協議協調同步與讀寫。

程序間通訊、服務發現機制

在 NebulaGraph 中 graphd、metad、storaged 之間通過 Thrift 協議進行遠端呼叫(RPC),下邊給一些例子:

  • graphd 會通過 metaclient 呼叫 metad:將自己報告為一個正在執行的服務,以便被發現;再為使用者(使用 graphclient )登入進行 RPC 呼叫;當它處理 nGQL 查詢時,獲取圖儲存分佈情況;
  • graphd 會通過 storageclient 呼叫 storaged:當 graphd 處理 nGQL 時,先從 metad 獲得所需的元資訊,再進行圖資料的讀/寫;
  • storaged 會通過 metaclient呼叫 metad:將 storaged 報告為一個正在執行的服務,以便被發現。

當然,有狀態的儲存引擎內部也有叢集同步的流量與通訊。比如,storaged 與其他 storaged 有 Raft 連線;metad 與其他 metad 例項有 Raft 連線。

開發環境搭建

接下來,我們開始 NebulaGraph 的構建、開發環境的部分。

NebulaGraph 只支援在 GNU/Linux 分支中構建。目前來說,最方便的方式是在社群預先提供好了依賴的容器映象的基礎上在容器內部構建、除錯 NebulaGraph 程式碼的更改和 Debug

建立一個容器化的 NebulaGraph 叢集

為了更方便地除錯程式碼,我習慣提前建立一個 NebulaGraph Docker 環境。推薦使用官方的 Docker-Compose 方式部署,也可以使用我在官方 Docker-Compose 基礎之上弄的一鍵部署工具:nebula-up

下面以 nebula-up 為例:

在 Linux 開發伺服器中執行 curl -fsSL nebula-up.siwei.io/install.sh | bash 就可以了。

程式碼獲取

NebulaGraph 的程式碼倉庫託管在 GitHub 之上,在聯網的情況下直接克隆:

git clone [email protected]:vesoft-inc/nebula.git
cd nebula

建立開發容器

有了 NebulaGraph 叢集,我們可以藉助 nebula-dev-docker 提供的開箱即用開發容器映象,搭建開發環境:

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 退出容器,它會被關閉。如果我們想再次啟動容器,只需要執行:

docker start nebula_dev

之後的編譯、Debug、測試工作都在 nebula_dev 容器內部進行。在容器是執行狀態的情況下,可以隨時新建一個容器內部的 bash shell 程序:

docker exec -ti nebula_dev bash

為了保持編譯環境是最新版,可以定期刪除、拉取、重建這個開發容器,以保持環境與程式碼相匹配。

編譯環境

nebula_dev 這個容器內部,我們可以進行程式碼編譯。進入編譯容器:

docker exec -ti nebula_dev bash

用 CMake 準備 makefile。第一次構建時,為了節省時間、記憶體,我關閉了測試 -DENABLE_TESTING=OFF

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,則執行:

make -j64

第一次構建的時間會慢一些,在 make 成功之後,我們也可以執行 make install 把二進位制安裝到像生產安裝時候一樣的路徑:

[email protected]:/home/nebula/build# make install

[email protected]:/home/nebula/build# ls /usr/local/nebula/bin
db_dump  db_upgrader  meta_dump  nebula-graphd  nebula-metad  nebula-storaged

[email protected]:/home/nebula/build# ls /usr/local/nebula/
bin  etc  pids  scripts  share

除錯 NebulaGraph

以 graphd 除錯為例。

安裝依賴

安裝一些後邊會方便 Debug 額外用到的依賴:

# 裝一個 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 ~ http://git.io/.gdbinit
pip install pygments

準備客戶端

準備一個 NebulaGraph 的命令列客戶端:

# 新開一個 nebula_dev 的 shell
docker exec -ti nebula_dev bash

# 下載 nebula-console 二進位制檔案,並賦予可執行許可權,命名為 nebula-console 並安裝到 /usr/bin/ 下
wget http://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 這個測試資料:

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

gdb 執行 graphd

用 gdb 執行剛剛編譯的 nebula-graphd 二進位制,讓它成為一個新的 graphd 服務,名字就叫 nebula_dev

首先啟動 gdb:

# 新開一個 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 的子程序:

set follow-fork-mode child

設定待除錯 graphd 的啟動引數(配置):

  • meta_server_addrs 填已經啟動的叢集的所有 metad 的地址;
  • local_ipws_ip 填本容器的域名,port 是 graphd 監聽埠;
  • log_dir 是輸出日誌的目錄,vminloglevel 是日誌的輸出等級;
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 行,可以再執行:

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

配置前邊安裝的 gdb-dashboard,一個開源的 gdb 介面外掛。

# 設定在 gdb 介面上展示 程式碼、歷史、回撥棧、變數、表達幾個部分,詳細參考 http://github.com/cyrus-and/gdb-dashboard
dashboard -layout source history stack variables expressions

最後我們讓程序通過 gdb 跑起來吧:

run

之後,我們就可以在這個視窗/shell 會話下除錯 graphd 程式了。

修改 NebulaGraph 程式碼

這裡,我以 issue#3513 為例子,快速介紹一下程式碼修改的過程。

讀程式碼

這個 issue 表達的內容是在有一小部分使用者決定把 JSON 以 String 的形式儲存在 NebulaGraph 中的屬性裡。因為這種方式比較罕見且不被推崇,NebulaGraph 沒有直接支援對 JSON String 解析。

由於不是一個通用型需求,這個功能是希望熱心的社群使用者自己來實現並應用在他的業務場景中。但在該 issue 中,剛好有位新手貢獻者在裡邊回覆、求助如何開始參與這塊的功能實現。藉著這個契機,我去參與討論看了一下這個功能可以實現成什麼樣子。最終討論的結果是可以做成和 MySQL 中的 JSON_EXTRACT 函式那樣,改為只接受 JSON String、無需處理輸出路徑引數。

一句話來說就是,為 NebulaGraph 引入一個解析 JSON String 為 Map 的函式。那麼,如何實現這個功能呢?

在哪裡修改

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

一般情況下,可以自底向上地瞭解 NebulaGraph 整體的程式碼結構,再一點點找到函式處理的位置。這時候,除了程式碼本身,一些面向貢獻者的文章可能會幫助大家事半功倍對整體有一個瞭解。NebulaGraph 官方也除了一個系列文章,大家做專案貢獻前不妨閱讀了解下,參見:延伸閱讀 5。

具體的實操起來呢?我從 pr#4526 瞭解到所有函式入口都被統一管理在 src/common/function/FunctionManager.cpp 之中。通過搜尋、理解當中某個函式的關鍵詞之後,可以很容易理解一個函式實體的關鍵詞、輸入/輸出資料型別、函式體處理邏輯的程式碼在哪裡實現

此外,在同一個根目錄下,src/common/function/test/FunctionManagerTest.cpp 之中則是所有這些函式的單元測試程式碼。用同樣的方式也可以知道新加的一個函式需要如何在裡邊實現基於 gtest 的單元測試。

開始改程式碼

在修改程式碼之前,確保在最新的 master 分支之上建立一個單獨的分支。在這裡的例子中,我把分支名字叫 fn_JSON_EXTRACT

git checkout master
git pull
git checkout -b fn_JSON_EXTRACT

通過 Google 瞭解與交叉驗證 NebulaGraph 內部使用的 utils 庫,知道應該用 folly::parseJson 把字串讀成 folly::dynamic。再 cast 成 NebulaGraph 內建的 Map() 型別。最後,藉助於 Stack Overflow/GitHub Copilot,我終於完成了第一個版本的程式碼修改。

除錯程式碼

我興沖沖地改好了第一版的程式碼,信心滿滿地開始編譯!實際上,因為我是 CPP 新手,即使在 Copilot 加持下,我的程式碼還是花了好幾次修改才通過編譯。

編譯之後,我用 gdb 把修改了的 graphd 啟動起來。用 console 發起 JSON_EXTRACT 的函式呼叫。先調通了期待中的效果,並試著跑幾種異常的輸入。在發現新問題、修改、編譯、除錯的幾輪迴圈下讓程式碼達到了期望的狀態。

這時候,就該把程式碼提交到遠端 GitHub 請專案的資深貢獻者幫忙 review 啦!

提交 PR

PR(Pull Request)是 GitHub 中方便多人程式碼協作、程式碼審查中的一種方式。它通過把一個 repo 下的分支與這個審查協作的例項(PR)做對映,得到一個專案下唯一的 PR 號碼之後,生成單獨的網頁。在這個網頁下,我們可以做不同貢獻者之間的交流和後續的程式碼更新。這個過程中,程式碼提交者們可以一直在這個分支上不斷提交程式碼直到程式碼的狀態被各方同意 approve,再合併 merge 到目的分支中。

這個過程可以分為:

  • 建立 GitHub 上遠端的個人開發分支;
  • 基於分支建立目標專案倉庫中的 PR;
  • 在 PR 中協作、討論、不斷再次提交到開發分支直到多方達到合併、或者關閉的共識;

提交到個人遠端分支

在這一步驟裡,我們要把當前的本地提交的 commit 提交到自己的 GitHub 分叉之中。

commit 本地修改

首先,確認本地的修改是否都是期待中的:

# 先確定修改的檔案
$ git status
# 再看看修改的內容
$ git diff

再 commit,這時候是在本地倉庫提交 commit:

# 新增所有當前目錄(. 這個點表示當前目錄)修改過的檔案為待 commit
$ git add .
# 然後我們可以看一下狀態,這些修改的檔案狀態已經不同了
$ git status
# 最後,提交在本地倉庫,並用 -m 引數指定單行的 commit message
$ git commit -m "feat: introduce function JSON_EXTRACT"

提交到自己遠端的分支

在提交之前,要確保自己的 GitHub 賬號之下確實存在 NebulaGraph 程式碼倉庫的分叉 fork。比如,我的 GitHub 賬號是 wey-gu,那麼我對 http://github.com/vesoft-inc/nebula 的分叉應該就是 http://github.com/wey-gu/nebula

如果還沒有自己的分叉,可以直接在 http://github.com/vesoft-inc/nebula 上點選右上角的 Fork,建立自己的分叉倉庫。

當遠端的個人分叉存在之後,我們可以把程式碼提交上去:

# 新增一個新的遠端倉庫叫 wey
git remote add wey [email protected]:wey-gu/nebula.git
# 提交 JSON_EXTRACT 分支到 wey 這個 remote 倉庫
git push wey JSON_EXTRACT

在個人遠端分叉分支上建立 PR

這時候,我們訪問這個遠端分支:http://github.com/wey-gu/nebula/tree/fn_JSON_EXTRACT,就能找到 Open PR 的入口:

點選 Open pull request 按鈕,進入到建立 PR 的介面了,這和在一般的論壇裡提交一個帖子是很類似的:

提交之後,我們可以等待、或者邀請其他人來做程式碼的審查 review。往往,開源專案的貢獻者們會從他們的各自角度給出程式碼修改、優化的建議。經過幾輪的程式碼修改、討論後,這時候程式碼會達到最佳的狀態。

在這些審查者中,除了社群的貢獻者(人類)之外,還有自動化的機器人。它們會在程式碼庫中自動化地通過持續整合 CI 的方式執行自動化的審查工作,可能包括以下幾種:

  • CLA:Contributor License Agreement,貢獻者許可協議。PR 作者在首次提交程式碼到專案時,所需簽署的協議。因為程式碼將被提交到公共空間,這份協議的簽署意味著作者同意程式碼被分享、複用、修改;
  • lint:程式碼風格檢查,這也是最常見的 CI 任務;
  • test:各種層面的測試檢查任務。

通常來說,所有自動化審查機器人執行的任務全都通過後,貢獻的程式碼狀態才能被認為是可合併的。不出意外,我首次提交的程式碼果然有測試的失敗提示。

除錯 CI 測試程式碼

NebulaGraph 裡所有的 CI 測試程式碼都能在本地被觸發。當然,它們都有被單獨觸發的方式。我們需要掌握如何單獨觸發某個測試,而不是在每次修改一個小的測試修復、提交到伺服器,就等著 CI 做全量的執行,這樣會浪費掉幾十分鐘。

CTest

本次 PR 提交中,我修改的函式程式碼同一層級下的單元測試 CTest 就有問題。問題發生的原因有多種,可能是測試程式碼本身、程式碼變更破壞了原來的測試用例、測試用例發現程式碼修改本身的問題。

我們要根據 CTest 失敗的報錯進行排查和程式碼修改。再編譯程式碼,在本地執行一下這個失敗的用例:

# 我們需要進入到我們的編譯容器內部的 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,我開始興高采烈地等待著 2 位以上的審查者幫忙批准程式碼,最後合併它!

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

另一位貢獻者請我新增 TCK 的測試用例。

TCK

TCK 的全稱是 The Cypher Technology Compatibility Kit,它是 NebulaGraph 從 openCypher 社群繼承演進而來的一套測試框架,並用 Python 做測試用例格式相容的實現。

它的優雅在於,我們可以像寫英語一樣去描述我們想實現的端到端功能測試用例,像這樣!

# 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 測試用例了:

# 還是在編譯容器內部,進入到 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

再次邀請 review

待我們把需要的測試調通、再次提交 PR 並且 CI 用例全都通過之後,我們可以再次邀請之前幫助審查程式碼的同學做做最後的檢視,如果一切都順利,程式碼就會被合併了!

就這樣,我的第一個 CPP PR 終於被合併成功,大家能看到我留在 NebulaGraph 中的程式碼了。

延伸閱讀:

  1. 基於 BDD 理論的 NebulaGraph 整合測試框架重構,http://nebula-graph.com.cn/posts/bdd-testing-practice
  2. 如何向 NebulaGraph 增加一個測試用例,http://nebula-graph.com.cn/posts/bdd-testing-practice-add-test-case
  3. NebulaGraph 文件之架構介紹,http://docs.nebula-graph.com.cn/master/1.introduction/3.nebula-graph-architecture/1.architecture-overview/
  4. NebulaGraph 原始碼解讀系列,http://www.nebula-graph.com.cn/posts/nebula-graph-source-code-reading-00

謝謝你讀完本文 (///▽///)

如果你想嚐鮮圖資料庫 NebulaGraph,記得去 GitHub 下載、使用、(^з^)-☆ star 它 -> GitHub;和其他的 NebulaGraph 使用者一起交流圖資料庫技術和應用技能,留下「你的名片」一起玩耍呀~