圖資料庫|如何從零到一構建一個企業股權圖譜系統?

nebulagraph發表於2022-04-28
本文首發於 Nebula Graph Community 公眾號

從零到一:如何構建一個企業股權圖譜系統?

我們知道無論是監管部門、企業還是個人,都有需求去針對一個企業、法人做一些背景調查,這些調查可以是法律訴訟、公開持股、企業任職等等多種多樣的資訊。這些背景資訊可以輔助我們做商業上的重要決策,規避風險:比如根據公司的股權關係,瞭解是否存在利益衝突比如是否選擇與一家公司進行商業往來。

在滿足這樣的關係分析需求的時候,我們往往面臨一些挑戰,比如:

  1. 如何將這些資料的關聯關係體現在系統之中?使得它們可以被挖掘、利用
  2. 多種異構資料、資料來源之間的關係可能隨著業務的發展引申出更多的變化,在結構資料庫中,這意味著 Schema 變更
  3. 分析系統需要儘可能實時獲取需要的查詢結果,這通常涉及到多跳關係查詢
  4. 領域專家能否快速靈活、視覺化獲取分享資訊

那麼如何構建這樣一個系統解決以上挑戰呢?

資料存在哪裡?

前提:資料集準備,為了更好的給大家演示解決這個問題,我寫了一個輪子能隨機生成股權結構相關的資料,生成的資料的例子在這裡

這裡,我們有法人公司的資料,更有公司與子公司之間的關係公司持有公司股份法人任職公司法人持有公司股份法人之間親密度的關係資料。

資料存在哪裡?這是一個關鍵的問題,這裡我們劇透一下,答案是:圖資料庫。然後我們再簡單解釋一下為什麼這樣一個股權圖譜系統跑在圖資料庫上是更好的。

在這樣一個簡單的資料模型之下,我們可以很直接的在關係型資料庫中這麼建模:

why_0_tabular

而這麼建模的問題在於:這種邏輯關聯的方式使得無論資料的關聯關係查詢表達、儲存、還是引入新的關聯關係都不是很高效。

  • 查詢表達不高效是因為關係型資料庫是面向表結構設計的,這決定了關係查詢要寫巢狀的 JOIN。

    • 這就是前邊提到的挑戰 1:能夠表達,但是比較勉強,遇到稍微複雜的情況就變得很難。
  • 儲存不高效是因為表結構被設計的模式是面向資料記錄,而非資料之間的關係:我們雖然習慣了將資料中實體(比如法人)和實體關聯(比如持有股權 hold_sharing_relationship)以另外一個表中的記錄來表達、儲存起來,這邏輯上完全行得通,但是到了多跳、大量需要請求資料關係跳轉的情況下,這樣跨表 JOIN 的代價就成為了瓶頸。

    • 這就是前邊提到的挑戰 3:無法應對多條查詢的效能需要。
  • 引入新的關聯關係代價大,還是前邊提到的,表結構下,用新的表來表達持有股權 hold_sharing_relationship這個關聯關係是可行的,但是這非常不靈活、而且昂貴,它意味著我們在引入這個關係的時候限定了起點終點的型別,比如股權持有的關係可能是法人->公司,也可能是公司->公司,隨著業務的演進,我們可能還需要引入政府->公司的新關係,而這些變化都需要做有不小代價的工作:改動 Schema。

    • 這就是前邊提到的挑戰 2:無法應對業務上對資料關係上靈活多變的要求。

當一個通用系統無法滿足不可忽視的具體需求的時候,一個新的系統就會誕生,這就是圖資料庫,針對這樣的場景,圖資料庫很自然地特別針對關聯關係場景去設計整個資料庫:

  • 面向關聯關係表達的語義。(挑戰 1)

    • 如下表,我列舉了一個等價的一跳查詢在表結構資料庫與圖資料庫中,查詢語句的區別。大家應該可以看出“找到所有持有和 p_100 共同持有公司股份的人”這樣的查詢表達可以在圖資料庫如何自然表達,這僅僅是一條查詢的區別,如果是多跳的話,他們的複雜度區分還會更明顯一些。
表結構資料庫圖資料庫(屬性圖)
why_1_sql_joinwhy_1_ngql
  • 將關聯關係儲存為物理連線,從而使得跳轉查詢代價最小。(挑戰 3、2)

    • 圖資料之中,從點擴充(找到一個或者多個關係的另一頭)出去的代價是非常小的,這因為圖資料庫是一個專有的系統,得益於它主要關心“圖”結構的設計,查詢確定的實體(比如和一個法人 A )所有關聯(可能是任職、親戚、持有、等等關係)其他所有實體(公司、法人)這個查詢的代價是 O(1) 的,因為它們在圖資料庫的資料機構裡是真的連結在一起的。
    • 大家可以從下表的定量參考資料一窺圖資料庫在這種查詢下的優勢,這種優勢在多跳高併發情況下的區別是“能”與”不能“作為線上系統的區別,是“實時”與“離線”的區別。
    • 在面向關聯關係的資料建模和資料結構之下,引入新的實體、關聯關係的代價要小很多,還是前邊提到的例子:
      在 Nebula Graph 圖資料中引入一個新的“政府機構”型別的實體,並增加政府機構->公司的“持有股份”的關聯關係相比於在非圖模型的資料庫中的代價小很多。
表結構資料庫圖資料庫(屬性圖)
4 跳查詢時延 1544 秒4 跳查詢時延 1.36 秒
  • 建模符合直覺;圖資料庫有面向資料連線的資料視覺化能力(挑戰 4)

    • 大家在下表第二列中可以對比我們本文中進行的股權分析資料在兩種資料庫之中的建模的區別,尤其是在關心關聯關係的場景下,我們可以感受到屬性圖的模型建立是很符合人類大腦直覺的,而這和大腦之中神經元的結構可能也有一些關係。
    • 圖資料庫中內建的視覺化工具提供了一般使用者便捷理解資料關係的能力,也給領域專家使用者提供了表達請求複雜資料關係的直觀介面。
表結構資料庫圖資料庫(屬性圖)
why_0_tabularwhy_0_graph_based
表結構資料庫與圖資料庫的總體比較:

<!---

GO FROM "p_100" OVER hold_share YIELD dst(edge) AS corp_with_share |\
GO FROM $-.corp_with_share OVER hold_share REVERSELY YIELD properties(vertex).name;
SELECT a.id, a.name, c.name
FROM person a
JOIN hold_share b ON a.id=b.person_id
JOIN corp c ON c.id=b.corp_id
WHERE c.name IN (SELECT c.name
FROM person a
JOIN hold_share b ON a.id=b.person_id
JOIN corp c ON c.id=b.corp_id
WHERE a.id = 'p_100')

-->

表結構資料庫圖資料庫(屬性圖)
查詢why_1_sql_joinwhy_1_ngql
建模why_0_tabularwhy_0_graph_based
效能4 跳查詢時延 1544 秒4 跳查詢時延 1.36 秒

綜上,在本教程裡,我們將利用圖資料庫來進行資料儲存。

圖資料建模

前面在討論資料存在哪裡的時候,我們已經揭示了在圖資料庫中建模的方式:本質上,在這張圖中,將會有兩種實體:

  • 公司

四種關係:

  • 作為親人–>
  • 作為角色–> 公司
  • 或者 公司持有股份–> 公司
  • 公司作為子機構–> 公司

這裡面,實體與關係本身都可以包含更多的資訊,這些資訊在圖資料庫裡就是實體、關係自身的屬性。如下圖表示:

  • 的屬性包括 nameage
  • 公司的屬性包括 namelocation
  • 持有股份 這個關係有屬性 share(份額)
  • 任職這個關係有屬性 rolelevel

why_0_graph_based

資料入庫

本教程中,我們使用的圖資料庫叫做 Nebula Graph(星雲圖資料庫),它是一個以 Apache 2.0 許可證開源的分散式圖資料庫。

Nebula Graph in Github: https://github.com/vesoft-inc...

在向 Nebula Graph 匯入資料的時候,關於如何選擇工具,請參考這篇文件這個視訊

這裡,由於資料格式是 csv 檔案並且利用單機的客戶端資源就足夠了,我們可以選擇使用 nebula-importer 來完成這個工作。

提示:在匯入資料之前,請先部署一個 Nebula Graph 叢集,最簡便的部署方式是使用 nebula-up 這個小工具,只需要一行命令就能在 Linux 機器上同時啟動一個 Nebula Graph 核心和視覺化圖探索工具 Nebula Graph Studio。如果你更願意用 Docker 部署,請參考這個文件

本文假設我們使用 Nebula-UP 來部署:

curl -fsSL nebula-up.siwei.io/install.sh | bash

這裡的資料是生成器生成的,你可以按需生成任意規模隨機資料集,或者選擇一份生成好了的資料在這裡

有了這些資料,我們可以開始匯入了。

$ pip install Faker==2.0.5 pydbgen==1.0.5
$ python3 data_generator.py
$ ls -l data
total 1688
-rw-r--r--  1 weyl  staff   23941 Jul 14 13:28 corp.csv
-rw-r--r--  1 weyl  staff    1277 Jul 14 13:26 corp_rel.csv
-rw-r--r--  1 weyl  staff    3048 Jul 14 13:26 corp_share.csv
-rw-r--r--  1 weyl  staff  211661 Jul 14 13:26 person.csv
-rw-r--r--  1 weyl  staff  179770 Jul 14 13:26 person_corp_role.csv
-rw-r--r--  1 weyl  staff  322965 Jul 14 13:26 person_corp_share.csv
-rw-r--r--  1 weyl  staff   17689 Jul 14 13:26 person_rel.csv

匯入工具 nebula-importer 是一個 golang 的二進位制檔案,使用方式就是將匯入的 Nebula Graph 連線資訊、資料來源中欄位的含義的資訊寫進 YAML 格式的配置檔案裡,然後通過命令列呼叫它。可以參考文件或者它的 GitHub 倉庫裡的例子。

這裡我已經寫好了準備好了一份 nebula-importer 的配置檔案,在資料生成器同一個 repo 之下的這裡

最後,只需要執行如下命令就可以開始資料匯入了:

注意,在寫本文的時候,nebula 的新版本是 2.6.1,這裡對應的 nebula-importer 是 v2.6.0,如果您出現匯入錯誤可能是版本不匹配,可以相應調整下邊命令中的版本號。
git clone https://github.com/wey-gu/nebula-shareholding-example
cp -r data_sample /tmp/data
cp nebula-importer.yaml /tmp/data/
docker run --rm -ti \
    --network=nebula-docker-compose_nebula-net \
    -v /tmp/data:/root \
    vesoft/nebula-importer:v2.6.0 \
    --config /root/nebula-importer.yaml

你知道嗎?TL;DR

實際上,這份 importer 的配置裡幫我們做了 Nebula Graph 之中的圖建模的操作,它們的指令在下邊,我們不需要手動去執行了。

CREATE SPACE IF NOT EXISTS shareholding(partition_num=5, replica_factor=1, vid_type=FIXED_STRING(10));
USE shareholding;
CREATE TAG person(name string);
CREATE TAG corp(name string);
CREATE TAG INDEX person_name on person(name(20));
CREATE TAG INDEX corp_name on corp(name(20));
CREATE EDGE role_as(role string);
CREATE EDGE is_branch_of();
CREATE EDGE hold_share(share float);
CREATE EDGE reletive_with(degree int);

相簿中查詢資料

Tips: 你知道嗎,你也可以無需部署安裝,通過 Nebula-Playground 之中,找到股權穿透來線上訪問同一份資料集。

我們可以藉助 Nebula Graph Studio 來訪問資料,訪問我們部署 Nebula-UP 的伺服器地址的 7001 埠就可以了:

假設伺服器地址為 192.168.8.127,則有:

  • Nebula Studio 地址:192.168.8.127:7001
  • Nebula Graph 地址:192.168.8.127:9669
  • 預設使用者名稱:root
  • 預設密碼:nebula

訪問 Nebula Studio:

studio_login

選擇圖空間: Shareholding

studio_space_selection

之後,我們就可以在裡邊探索比如一個公司的三跳以內的股權穿透,具體的操作可以參考:股權穿透線上 Playground 的介紹

Studio 股權穿透

構建一個圖譜系統

這部分的程式碼開源在 GitHub 上:

https://github.com/wey-gu/neb...

本專案的 Demo 也在 PyCon China 2021 上的演講中有過展示:視訊地址

在此基礎之上,我們可以構建一個提供給終端使用者來使用的股權查詢系統了,我們已經有了圖資料庫作為這個圖譜的儲存引擎,理論上,如果業務允許,我們可以直接使用或者封裝 Nebula Graph Studio 來提供服務,這完全是可行也是合規的,不過,有一些情況下,我們需要自己去實現介面、或者我們需要封裝出一個 API 給上游(多端)提供圖譜查詢的功能。

為此,我為大家寫了一個簡單的例項專案,提供這樣的服務,他的架構也很直接:

  • 前端接受使用者要查詢的穿透法人、公司,按需發請求給後端,並用 D3.js 將返回結果渲染為關係圖
  • 後端接受前端的 API 請求,將請求轉換為 Graph DB 的查詢,並返回前端期待的結果
  ┌───────────────┬───────────────┐
  │               │  Frontend     │
  │               │               │
  │    ┌──────────▼──────────┐    │
  │    │ Vue.JS              │    │
  │    │ D3.JS               │    │
  │    └──────────┬──────────┘    │
  │               │  Backend      │
  │    ┌──────────┴──────────┐    │
  │    │ Flask               │    │
  │    │ Nebula-Python       │    │
  │    └──────────┬──────────┘    │
  │               │  Graph Query  │
  │    ┌──────────▼──────────┐    │
  │    │ Graph Database      │    │
  │    └─────────────────────┘    │
  │                               │
  └───────────────────────────────┘

後端服務-->圖資料庫

詳細的資料格式分析大家可以參考這裡

查詢語句

我們假設使用者請求的實體是 c_132 ,那麼請求 1 到 3 步的關係穿透的語法是:

MATCH p=(v)-[e:hold_share|:is_branch_of|:reletive_with|:role_as*1..3]-(v2) \
WHERE id(v) IN ["c_132"] RETURN p LIMIT 100

這裡邊 ()包裹的是圖之中的點,而[] 包裹的則是點之間的關係:邊,所以:

(v)-[e:hold_share|:is_branch_of|:reletive_with|:role_as*1..3]-(v2) 之中的:

(v)-[xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx]-(v2)應該比較好理解,意思是從 vv2 做擴充。

現在我們介紹中間[]包裹的部分,這裡,它的語義是:經由四種型別的邊(:之後的是邊的型別,|代表或者)通過可變的跳數:*1..3 (一跳到三跳)。

所以,簡單來說整理看開,我們的擴充的路徑是:從點 v 開始,經由四種關係一到三跳擴充到點v2,返回整個擴充路徑 p,限制 100 個路徑結果,其中 vc_132

Nebula Python Client/ SDK

我們已經知道了查詢語句的語法,那麼就只需要在後端程式里根據請求、通過圖資料庫的客戶端來發出查詢請求,並處理返回結構就好了。在今天的例子中,我選擇使用 Python 來實現後端的邏輯,所以我用了 Nebula-python 這個庫,它是 Nebula 的 Python Client。

你知道麼?截至到現在,Nebula 在 GitHub 上有 Java,GO,Python,C++,Spark,Flink,Rust(未GA),NodeJS(未GA) 的客戶端支援,更多的語言的客戶端也會慢慢被髮布哦。

下邊是一個 Python Client 執行一個查詢並返回結果的例子,值得注意的是,在我實現這個程式碼的時候,Nebula Python 尚未支援返回 JSON (通過session.execute_json())結果,如果你要實現自己的程式碼,我非常推薦試試 JSON 哈,就可以不用從物件中一點點取資料了,不過藉助 iPython/IDLE 這種 REPL,快速瞭解返回物件的結構也沒有那麼麻煩。

$ python3 -m pip install nebula2-python==2.5.0 # 注意這裡我引用舊的記錄,它是 2.5.0,
$ ipython
In [1]: from nebula2.gclient.net import ConnectionPool
In [2]: from nebula2.Config import Config
In [3]: config = Config()
   ...: config.max_connection_pool_size = 10
   ...: # init connection pool
   ...: connection_pool = ConnectionPool()
   ...: # if the given servers are ok, return true, else return false
   ...: ok = connection_pool.init([('192.168.8.137', 9669)], config)
   ...: session = connection_pool.get_session('root', 'nebula')
[2021-10-13 13:44:24,242]:Get connection to ('192.168.8.137', 9669)

In [4]: resp = session.execute("use shareholding")
In [5]: query = '''
   ...: MATCH p=(v)-[e:hold_share|:is_branch_of|:reletive_with|:role_as*1..3]-(v2) \
   ...: WHERE id(v) IN ["c_132"] RETURN p LIMIT 100
   ...: '''
In [6]: resp = session.execute(query) # Note: after nebula graph 2.6.0, we could use execute_json as well

In [7]: resp.col_size()
Out[7]: 1

In [9]: resp.row_size()
Out[10]: 100

我們往下分析看看,我們知道這個請求本質上結果是路徑,它有一個 .nodes() 方法和 .relationships()方法來獲得路徑上的點和邊:

In [11]: p=resp.row_values(22)[0].as_path()

In [12]: p.nodes()
Out[12]:
[("c_132" :corp{name: "Chambers LLC"}),
 ("p_4000" :person{name: "Colton Bailey"})]

In [13]: p.relationships()
Out[13]: [("p_4000")-[:role_as@0{role: "Editorial assistant"}]->("c_132")]

對於邊來說有這些方法 .edge_name(), .properties(), .start_vertex_id(), .end_vertex_id(),這裡 edge_name 是獲得邊的型別。

In [14]: rel=p.relationships()[0]

In [15]: rel
Out[15]: ("p_4000")-[:role_as@0{role: "Editorial assistant"}]->("c_132")

In [16]: rel.edge_name()
Out[16]: 'role_as'

In [17]: rel.properties()
Out[17]: {'role': "Editorial assistant"}

In [18]: rel.start_vertex_id()
Out[18]: "p_4000"

In [19]: rel.end_vertex_id()
Out[19]: "c_132"

對於點來說,可以用到這些方法 .tags(), properties, get_id(),這裡邊 tags 是獲得點的型別,它在 Nebula 裡叫標籤tag

這些概念可以在文件裡獲得更詳細的解釋。

In [20]: node=p.nodes()[0]

In [21]: node.tags()
Out[21]: ['corp']

In [22]: node.properties('corp')
Out[22]: {'name': "Chambers LLC"}

In [23]: node.get_id()
Out[23]: "c_132"

前端渲染點邊為圖

詳細的分析大家也可以參考這裡

為了方便實現,我們採用了 Vue.js 和 vue-network-d3(D3 的 Vue Binding)。

通過 vue-network-d3 的抽象,能看出來餵給他這樣的資料,就可以把點邊資訊渲染成很好看的圖

nodes: [
        {"id": "c_132", "name": "Chambers LLC", "tag": "corp"},
        {"id": "p_4000", "name": "Colton Bailey", "tag": "person"}],
relationships: [
        {"source": "p_4000", "target": "c_132", "properties": { "role": "Editorial assistant" }, "edge": "role_as"}]

d3-demo

前端<--後端

詳細資訊可以參考這裡

我們從 D3 的初步研究上可以知道,後端只需要返回如下的 JSON 格式資料就好了

Nodes:

[{"id": "c_132", "name": "Chambers LLC", "tag": "corp"},
 {"id": "p_4000", "name": "Colton Bailey", "tag": "person"}]

Relationships:

[{"source": "p_4000", "target": "c_132", "properties": { "role": "Editorial assistant" }, "edge": "role_as"},
 {"source": "p_1039", "target": "c_132", "properties": { "share": "3.0" }, "edge": "hold_share"}]

於是,,結合前邊我們用 iPython 分析 Python 返回結果看,這個邏輯大概是:

def make_graph_response(resp) -> dict:
    nodes, relationships = list(), list()
    for row_index in range(resp.row_size()):
        path = resp.row_values(row_index)[0].as_path()
        _nodes = [
            {
                "id": node.get_id(), "tag": node.tags()[0],
                "name": node.properties(node.tags()[0]).get("name", "")
                }
                for node in path.nodes()
        ]
        nodes.extend(_nodes)
        _relationships = [
            {
                "source": rel.start_vertex_id(),
                "target": rel.end_vertex_id(),
                "properties": rel.properties(),
                "edge": rel.edge_name()
                }
                for rel in path.relationships()
        ]
        relationships.extend(_relationships)
    return {"nodes": nodes, "relationships": relationships}

前端到後端的通訊是 HTTP ,所以我們可以藉助 Flask,把這個函式封裝成一個 RESTful API:

前端程式通過 HTTP POST 到 /api

參考這裡
from flask import Flask, jsonify, request



app = Flask(__name__)


@app.route("/")
def root():
    return "Hey There?"


@app.route("/api", methods=["POST"])
def api():
    request_data = request.get_json()
    entity = request_data.get("entity", "")
    if entity:
        resp = query_shareholding(entity)
        data = make_graph_response(resp)
    else:
        data = dict() # tbd
    return jsonify(data)


def parse_nebula_graphd_endpoint():
    ng_endpoints_str = os.environ.get(
        'NG_ENDPOINTS', '127.0.0.1:9669,').split(",")
    ng_endpoints = []
    for endpoint in ng_endpoints_str:
        if endpoint:
            parts = endpoint.split(":")  # we dont consider IPv6 now
            ng_endpoints.append((parts[0], int(parts[1])))
    return ng_endpoints

def query_shareholding(entity):
    query_string = (
        f"USE shareholding; "
        f"MATCH p=(v)-[e:hold_share|:is_branch_of|:reletive_with|:role_as*1..3]-(v2) "
        f"WHERE id(v) IN ['{ entity }'] RETURN p LIMIT 100"
    )
    session = connection_pool.get_session('root', 'nebula')
    resp = session.execute(query_string)
    return resp

這個請求的結果則是前邊前端期待的 JSON,像這樣:

curl --header "Content-Type: application/json" \
     --request POST \
     --data '{"entity": "c_132"}' \
     http://192.168.10.14:5000/api | jq

{
  "nodes": [
    {
      "id": "c_132",
      "name": "\"Chambers LLC\"",
      "tag": "corp"
    },
    {
      "id": "c_245",
      "name": "\"Thompson-King\"",
      "tag": "corp"
    },
    {
      "id": "c_132",
      "name": "\"Chambers LLC\"",
      "tag": "corp"
    },
...
    }
  ],
  "relationships": [
    {
      "edge": "hold_share",
      "properties": "{'share': 0.0}",
      "source": "c_245",
      "target": "c_132"
    {
      "edge": "hold_share",
      "properties": "{'share': 9.0}",
      "source": "p_1767",
      "target": "c_132"
    },
    {
      "edge": "hold_share",
      "properties": "{'share': 11.0}",
      "source": "p_1997",
      "target": "c_132"
    },
...
    },
    {
      "edge": "reletive_with",
      "properties": "{'degree': 51}",
      "source": "p_7283",
      "target": "p_4723"
    }
  ]
}

放到一起

專案的程式碼都在 GitHub 上,最後其實只有一兩百行的程式碼,把所有東西拼起來之後的程式碼是:

├── README.md         # You could find Design Logs here
├── corp-rel-backend
│   └── app.py        # Flask App to handle Requst and calls GDB
├── corp-rel-frontend
│   └── src
│       ├── App.vue
│       └── main.js   # Vue App to call Flask App and Renders Graph
└── requirements.txt

最終效果

我們做出來了一個簡陋但是足夠具有參考性的小系統,它接受一個使用者輸入的實體的 ID,再回車之後:

  • 前端程式把請求發給後端
  • 後端拼接 Nebula Graph 的查詢語句,通過 Nebula Python 客戶端請求 Nebula Graph
  • Nebula Graph 接受請求做出穿透查詢,返回結構給後端
  • 後端將結果構建成前端 D3 接受的格式,傳給前端
  • 前端接收到圖結構的資料,渲染股權穿透的資料如下:

<video width="800" controls>
<source src="https://siwei.io/corp-rel-graph/demo.mov" type="video/mp4">
</video>

總結

現在,我們知道得益於圖資料庫的設計,在它上邊構建一個方便的股權分析系統非常自然、高效,我們或者利用圖資料庫的圖探索視覺化能力、或者自己搭建,可以為使用者提供非常高效、直觀的多跳股權穿透分析。

如果你想了解更多關於分散式圖資料庫的知識,歡迎關注 Nebula Graph 這個開源專案,它已經被國內很多團隊、公司認可選為圖時代資料技術儲存層的利器,大家可以訪問這裡,或者這裡,瞭解更多相關的分享和文章。

未來,我會給大家分享更多圖資料庫相關的文章、視訊和開源示例專案思路分享和教程,歡迎大家關注我的網站: siwei.io。


Nebula 社群首屆徵文活動進行中!? 獎品豐厚,全場景覆蓋:擼碼機械鍵盤⌨️、手機無線充?、健康小助手智慧手環⌚️,更有資料庫設計、知識圖譜實踐書籍? 等你來領,還有 Nebula 精緻周邊送不停~?

歡迎對 Nebula 有興趣、喜鑽研的小夥伴來書寫自己和 Nebula 有趣的故事呀~

交流圖資料庫技術?加入 Nebula 交流群請先填寫下你的 Nebula 名片,Nebula 小助手會拉你進群~~

相關文章