nebula graph orm框架 norm 用法解析 - 基本用法

392 阅读7分钟

在日常工作之中,我们可能会面临图谱(graph)类的需求,一般需要抽象各类节点以及节点之间的关系,构成一个复杂的网状结构,会从某一个节点开始, 查找和该点具备某种关系的其他点,或者查找两个点之间的最短路径。这类需求使用图数据库会优于使用关系型数据库,因为图数据库本身存储的就是点和边,整体的语法就是写入点或边之间的关系,依据某些关系查询点等等,数据库本身就是按照图的方式存储的,所以开发图谱类的需求时会非常易用,也易于开发一些比较复杂的需求,同时在海量节点和边的情况下,整体性能也要远好于基于关系型数据库手动抽象边和点。

这里引用了一篇文章,知识图谱可视化技术在美团的实践与探索,里面包含比较详细的图谱类的需求,这是一个简单的例子。 image

在涉及到图数据库的具体选型时,目前知名度最高、使用最广泛的是neo4j,但是neo4j开源版本其实功能限制很多,比如数据库只能在一个节点上运行,这实际上已经决定开源版本是绝对无法在生产环境使用的,但肯定还是开源免费的更有性价比一些,尤其是一些小企业快速迭代试错的时候,肯定更希望选择开源免费的产品。针对于这样的考虑,目前我个人感觉nebula graph是非常好的选择,相对于neo4j,nebula graph是强schema的,这意味着最终nebula graph的性能是要略优于neo4j的,从官方给出的测试结果来看,也确实是要更好一些,同时nebula graph本身也是分布式数据库,数据自动分片至多个节点,易于横向扩容,这样可以很好地应对数据快速增长所带来的挑战。

nebula graph支持两种语法,一种是原生的nGQL,另一种是兼容openCypher,也就是使用neo4j的语法,我个人感觉如果决定使用nebula graph最好还是使用nGQL,即使之前习惯于openCypher最好也还是使用nGQL,openCypher毕竟是兼容过来的,很多语法会变得比较怪,还有些是不支持的,而且官方肯定不能把nGQL给舍弃掉,这就导致openCypher语法需要始终考虑nGQL怎么支持,其实用起来很别扭。另外nGQL其实设计的也很好,使用一种类SQL的方式进行图数据库的查询,几乎没有什么学习成本,并且相对于openCypher更接近于过程式,这种过程式的思维可能更符合中国人的思维习惯。norm框架也是针对于nGQL语法设计的,openCypher那种图形声明式的语法,其实也很难通过链式调用的方式拼接语句。

目前官方提供的golang客户端,即 github.com/vesoft-inc/nebula-go 只提供了一个连接池,并不支持语句的链式拼接以及返回结果的解析,使用起来很痛苦,尤其是返回结果的解析,因为图数据库返回的很多都是边和点,边和点本身就是复合结构,解析的时候相当于需要解析多层,才能将返回结果解析至业务层定义的结构体上,同时还会返回map, list, set等集合类型,这些集合的元素可能又是点和边,点和边又包含属性,解析起来会很麻烦。

比如下面的语句:GO FROM "player102" OVER serve YIELD $$ as t;,其实是很简单的查询,返回结果如下:

image.png

返回结果是一个多层级的结构,返回的数据本身又包含vid以及每个tag的属性,解析返回值会比较麻烦。

norm介绍:

norm是专门针对于nebula graph设计的golang orm框架,整体使用方法十分接近gorm,golang程序员可以非常轻松的使用,框架也通过链式调用的方式拼接nGQL语句,通过Find等方法,解析返回值,同时很友好地支持复合结构的解析,比如返回了一个vertex list,可以解析到用户定义的一个struct slice之上。下面写一些基本的用法,这些例子都是直接使用的nebula graph的官方文档中的,这些例子也基本包括了nGQL的各种语法。本地安装了nebula graph之后,默认就会有一个demo_basketballplayer图空间,里面会包含一些节点和边,这些节点和边与官方文档中的例子是保持一致的,主要包含篮球球员和球队节点,以及球员和球员之间、球员和球队之间的一些关系,因为默认就会包含这些数据,可以方便地进行测试。

norm安装及初始化:

使用以下方式安装norm:

go get github.com/haysons/norm

和gorm类似,norm也提供了一个全局的DB对象,这个对象也是并发安全的,故一般来说一个服务只要包含一个norm.DB单例即可,norm会使用官方提供的session pool管理和nebula graph server端建立的连接,下面是初始化的方法:

    var db *norm.DB
    
    func main() {
      conf := &norm.Config{
        Username:    "root",
        Password:    "nebula",
        SpaceName:   "demo_basketballplayer",
        Addresses:   []string{"127.0.0.1:9669"},
        ConnTimeout: 10 * time.Second,
      }
      var err error
      db, err = norm.Open(conf)
      if err != nil {
        log.Fatal(err)
      }
      defer db.Close()
    }

添加好配置项调用Open函数即可,后续就可以使用db单例进行数据库的读写。

节点的定义:

以下定义了一个 Player结构体,用以表示数据库中的player节点,严格来讲应该是player tag,nebula graph中一个节点是可以包含多个tag的,但目前player只有一个tag,就简称为player节点,

    type Player struct {
      VID  string `norm:"vertex_id"`
      Name string `norm:"prop:name"`
      Age  int    `norm:"prop:age"`
    }
    ​
    func (p Player) VertexID() string {
      return p.VID
    }
    ​
    func (p Player) VertexTagName() string {
      return "player"
    }
    ​
    ​
    type Team struct {
      VID  string `norm:"vertex_id"`
      Name string `norm:"prop:name"`
    }
    ​
    func (t Team) VertexID() string {
      return t.VID
    }
    ​
    func (t Team) VertexTagName() string {
      return "team"
    }

Player 结构体需要实现 VertexID()方法,以表明自己是一个节点,由于目前图空间定义的vid是字符串类型的,所以返回值为string,如果是int64类型的,那么该方法返回int64即可,都可以表明自己是一个点。VID字段是结点的vid值,通过字段的norm:"vertex_id"表明该字段代表节点的vid,这样在进行赋值的时候就可以将server返回的vid赋值到该字段。当然如果你的vid并不是单独定义的,而是通过属性表达出来的,比如Player的vid是md5(Name),那么可以不定义VID字段,VertexID方法可以直接返回md5(name)。

Player 结构体同时还实现了VertexTagName()方法,表明Player结构体同时还是一个vertex tag,因为目前节点只有一组属性,就都定义在一个结构体之上了,这样很易用,因为大多数情况下节点都只有一组属性,用一个结构体会很方便,否则vertex tag只能以结构体的字段存在,赋值的时候太麻烦了,不过norm也支持同一个vertex具备多个tag,后面会再介绍。Name和Age字段就是节点所包含的属性了,通过norm:"prop:name"可以指定数据库中节点的属性名,如果不指定,默认会将字段名驼峰转下划线作为数据库中的属性名。

同样还定义了一个Team结构体,映射了数据库中的team节点,用以表示球队节点。

norm在解析结构体时只能解析导出的字段,不导出的字段会直接忽略,如果希望主动忽略导出的字段,可以在结构体后面加上norm:"-",这和gorm是一致的。norm也支持结构体的内嵌,可以定义一个Base结构体,包含一些公共字段内嵌到其他结构体之上,从而更为优雅地复用代码。

边的定义:

以下定义了一个Serve结构体,以映射数据库中的serve边,表示球员和球队的关系。边会包含类型、起始点、终止点以及一个rank值,这四者唯一标识一条边,表示起始节点到终止节点存在某种关系,rank则代表关系的优先级或者版本,大多数场景下保持默认值0即可。

    type Serve struct {
      SrcID     string `norm:"edge_src_id"`
      DstID     string `norm:"edge_dst_id"`
      Rank      int    `norm:"edge_rank"`
      StartYear int64  `norm:"prop:start_year"`
      EndYear   int64  `norm:"prop:end_year"`
    }
    ​
    func (s Serve) EdgeTypeName() string {
      return "serve"
    }

Serve结构体实现了EdgeTypeName()方法,返回了当前边的类型,表明自己是一条边。同时结构体必须定义起始节点和终止节点,否则解析结构体时会报错,比如这里定义了SrcID和DstID字段,指定边的起始和终止节点,字段上必须定义norm:"edge_src_id",这样才能表明该字段代表起始和终止节点,如果图空间vid类型为int64,那么这两个字段定义为int64类型即可。这里还定义了Rank字段,声明norm:"edge_rank",指定了边的rank值,如果不需要rank值不定义该字段即可。serve边主要表示球员服务于哪一个球队,所以还包含两个属性,StartYear和EndYear表示服务开始和结束的时间。由于目前还不支持结构体的内嵌,故SrcID, DstID, Rank这些字段不能放到一个公共的结构体之上,只能先直接写在结构体之上。

节点和边的写入:

    func Insert() {
      // 写入球员节点
      player := &Player{
        VID:  "player1001",
        Name: "Kobe Bryant",
        Age:  33,
      }
      if err := db.InsertVertex(player).Exec(); err != nil {
        log.Fatalf("insert player failed: %v", err)
      }
      // 写入球队节点
      team := &Team{
        VID:  "team1001",
        Name: "Lakers",
      }
      if err := db.InsertVertex(team).Exec(); err != nil {
        log.Fatalf("insert team failed: %v", err)
      }
      // 写入球员和球队节点之间的关系
      serve := &Serve{
        SrcID:     "player1001", // 球员player1001
        DstID:     "team1001",   // 球队team1001
        StartYear: time.Date(1996, 1, 1, 0, 0, 0, 0, time.Local).Unix(), // 服务开始和结束时间
        EndYear:   time.Date(2012, 1, 1, 0, 0, 0, 0, time.Local).Unix(),
      }
      if err := db.InsertEdge(serve).Exec(); err != nil {
        log.Fatalf("insert serve failed: %v", err)
      }
    }

给代表节点和边的结构体赋值之后,就可以调用db对象的InsertVertexInsertEdge方法进行节点和边的写入,底层会基于结构体实现的方法以及定义的字段拼接对应的nGQL语句,之后实际写入数据库。和gorm使用上的差别在于最后需要调用Exec()方法才能最终执行该语句,这是因为norm在设计上statement和db对象是完全分开的,InsertVertex也属于语句拼接部分,最终需要Exec()结束拼接,并向数据库server端发送语句。InsertVertexInsertEdge参数也可以是切片,比如[]*Player或者[]Player,这样最后执行的就是一次插入多个点或边的语句。

节点和边的查询:

fetch语句通过vid直接查询节点:
    func Query() {
      // FETCH PROP ON player "player1001" YIELD vertex as v
      player := new(Player)
      err := db.Fetch("player", "player1001").
        Yield("vertex as v").
        FindCol("v", player)
      if err != nil {
        log.Fatalf("fetch player failed: %v", err)
      }
      log.Printf("player: %+v", player)
    }

直接基于vid查询刚刚写入的节点,通过FetchYield方法即可拼接这个语句,最后调用FindCol解析返回结果,赋值给player变量。最后的FindCol方法可以方便地提取nebula graph返回值中的某个字段,由于nebula graph返回的都是二维表结构的数据,即使我们只定义一个返回字段,最终外部也还是包了一层,而且和关系型数据库不同,二维表中的值往往本身还是一个复合结构,比如这里v字段就是一个vertex,这样应用层解析时会非常麻烦,使用FindCol可以直接提取返回值的某一个字段进行赋值,类似于gorm中的Pluck方法,FindCol的命名虽然不如Pluck优雅,但是更直观、更好理解。另外需要赋值的变量必须使用指针传参,否则norm无法对其进行赋值。

go语句通过特定边查找关联的节点:
    func Query() {
      // GO FROM "player1001" OVER serve YIELD $$ as t
      team := new(Team)
      err = db.Go().
        From("player1001").
        Over("serve").
        Yield("$$ as t").
        FindCol("t", team)
      if err != nil {
        log.Fatalf("fetch team failed: %v", err)
      }
      log.Printf("team: %+v", team)
    }

这里通过go语句查找player1001服务的球队,通过Go, From, Over , Yield方法拼接go语句,之后还是通过FindCol方法对team变量进行赋值,因为传入的是一个结构体的指针,而不是一个切片的指针,即使球员关联了多个球队,也只会将返回值的第一项赋值给team变量。

统计节点不同边关联的目标节点的数量:
    func Query() {
      // GO FROM "player1001" OVER * YIELD type(edge) as t | GROUP BY $-.t YIELD $-.t as e, count(*) as cnt
      type edgeCnt struct {
        Edge string `norm:"col:e"`
        Cnt  int    `norm:"col:cnt"`
      }
      edgesCnt := make([]*edgeCnt, 0)
      err = db.Go().
        From("player1001").
        Over("*").
        Yield("type(edge) as t").
        GroupBy("$-.t").
        Yield("$-.t as e, count(*) as cnt").
        Find(&edgesCnt)
      if err != nil {
        log.Fatalf("get edge cnt failed: %v", err)
      }
      for _, c := range edgesCnt {
        log.Printf("edge cnt: %+v\n", c)
      }
    }

这条语句稍微复杂一点,是按照player1001出边的类型分组,统计不同边关联了多少节点,这里用到了GroupBy("$-.t")方法,作用就是在子句中添加分组语句,nGQL中GROUP BY子句必须在管道符之后使用,norm友好地支持管道符,这里的GroupBy("$-.t")方法会自动在语句中加一个管道符,也可以手动添加,类似于下面:

      err = db.Go().
        From("player1001").
        Over("*").
        Yield("type(edge) as t").
        Pipe(). // 表示管道符
        GroupBy("$-.t").
        Yield("$-.t as e, count(*) as cnt").
        Find(&edgesCnt)

使用Pipe()方法即可添加一个管道符,不仅限于GROUP BY子句之前,只要是nGQL中规定的合法添加管道符的位置就可以用Pipe()方法在语句中添加管道符。

在这个语句中我们关心的就不再是返回值的某一个字段了,而是返回值的全部字段,于是定义了一个edgeCnt结构体,用于接收数据库的返回值,通过norm:"col:cnt"中的col属性,可以定义返回值字段的名称,如果不定义的话会默认驼峰转下划线。由于返回值可能有多条,故edgesCnt变量定义成了一个切片[]*edgeCnt,最后使用Find方法对其进行赋值,Find会解析结构体中的字段,并匹配数据库返回值中的字段,分别对其进行赋值。

返回值中包含复合结构:
    func Query() {
      type edgeVertex struct {
        ID string `norm:"col:id"`
        T  *Team  `norm:"col:t"`
      }
      edgeVertexes := make([]*edgeVertex, 0)
      err = db.Go().
        From("player1001").
        Over("serve").
        Yield("id($^) as id, $$ as t").
        Find(&edgeVertexes)
      if err != nil {
        log.Fatalf("get edge vertex failed: %v", err)
      }
      for _, v := range edgeVertexes {
        log.Printf("id: %v, t: %+v", v.ID, v.T)
      }
    }

在这条语句中,返回值既包含一个简单字段id,还同时返回了一个team节点,对于这种结构norm同样可以正常地解析,定义edgeVertex结构体用于接收返回值,T字段类型是*Team,用于接收返回值中的team节点,同时用ID字段接收返回值中边的起始id。

节点和边属性的修改:

    func Update() {
        // 使用结构体更新边的属性,会更新非零值字段
        if err := db.UpdateVertex("player1001", &Player{Age: 23}).Exec(); err != nil {
           log.Fatalf("update player failed: %v", err)
        }
        // 查询更新后的属性值
        prop := make(map[string]interface{})
        err := db.Fetch("player", "player1001").
           Yield("properties(vertex) as p").
           FindCol("p", &prop)
        if err != nil {
           log.Fatalf("fetch player failed: %v", err)
        }
        log.Printf("vertex prop after update: %+v", prop)
        
        // 使用结构体更新边的属性值 
        if err = db.UpdateEdge(Serve{SrcID: "player1001", DstID: "team1001"}, &Serve{StartYear: 160123456}).Exec(); err != nil {
           log.Fatalf("update edge serve failed: %v", err)
        }
        // 查询更新后的属性值
        prop = make(map[string]interface{})
        err = db.Fetch("serve", clause.Expr{Str: `"player1001"->"team1001"`}).
           Yield("properties(edge) as p").
           FindCol("p", &prop)
        if err != nil {
           log.Fatalf("fetch serve failed: %v", err)
        }
        log.Printf("edge prop after update: %+v", prop)
    }

使用UpdateVertex方法更新节点的属性值,通过vid指定需要更新的节点,之后传入一个结构体表明要更新哪些属性,和gorm一致,使用结构体作为更新的参数时只会更新结构体中的非零导出字段,比如这里只会更新age属性,同样也需要调用Exec()方法才能实际执行更新语句。

使用UpdateEdge方法即可更新边的属性值,第一个参数是指定需要更新哪条边,这里传入的是一个实现边接口的结构体,相对于传入一个字符串,直接传入一个结构体会更加简单,否则就需要自己手动去拼接边的表达式,如"player1001"->"team1001",会比较繁琐。第二个参数使用结构体指定需要更新的属性,这里同样只更新非零导出字段。

边和节点的删除:

    func Delete() {
        // 基于vid删除节点
        if err := db.DeleteVertex("player1001").Exec(); err != nil {
           log.Fatalf("delete player failed: %v", err)
        }
        player := new(Player)
        err := db.Fetch("player", "player1001").
           Yield("vertex as v").
           TakeCol("v", player)
        // 节点删除成功之后,使用TakeCol查询,会得到norm.ErrRecordNotFound
        if err != nil {
           log.Printf("after delete, fetch player failed: %v", err)
        }
        
        // 基于边的表达式删除边
        if err = db.DeleteEdge("serve", Serve{SrcID: "player1001", DstID: "team1001"}).Exec(); err != nil {
           log.Fatalf("delete edge serve failed: %v", err)
        }
        serve := new(Serve)
        err = db.Fetch("serve", clause.Expr{Str: `"player1001"->"team1001"`}).
           Yield("edge as e").
           TakeCol("e", serve)
        // 边删除成功之后,使用TakeCol查询,会得到norm.ErrRecordNotFound
        if err != nil {
           log.Printf("after delete, fetch server failed: %v", err)
        }
    }

使用DeleteVertex方法删除节点,需要通过vid参数指定想要删除的节点,删除完成之后,使用TakeCol方法获取刚刚删除的节点时就会得到norm.ErrRecordNotFound错误,TakeTakeCol方法在查询的结果不存在时都会返回norm.ErrRecordNotFound,但FindFindCol在结果不存在时不会返回错误,这和gorm用法是保持一致的。

使用DeleteEdge方法可以删除边,参数还是推荐传入一个实现了边接口的结构体,这样边的表达式就可以由框架完成拼接,上层会更易用一些,删除之后使用TakeCol查询边,同样会得到norm.ErrRecordNotFound错误。

总结:

以上就是norm的基本用法,包括模型的定义、边和节点的基本增删改查,后续会更加详细地介绍norm的整体设计,以及更多详细的用法。也可以直接通过这个页面查看具体的例子,github.com/haysons/nor… ,里面包含了更加详细、丰富的用法。