[GraphDB普及系列]了解Multi-Model API

  • 2019 年 10 月 27 日
  • 笔记

简介

OrientDB诞生之初是文档数据库,其中包含的无索引链接设计让它完美地具备了图数据库的能力,但彼时其核心API依然是Document API,随后,基于Apache TinkerPop 2.x 实现的Graph API,作为一个单独的组件加入其中。这种割裂的API设计,显然不符合OrientDB多模型数据库的定位。因此,v3.0版本之后,Multi-Model API作为新的核心出现在整个API体系中。

文目的就是带领大家体验新的API。仅仅通过其java doc来学习难免枯燥,因此借由一个简单的业务场景,构建一个web应用,让应用开发员能更切实地了解Multi-Model API,了解OrientDB。

业务场景

借鉴微博的一小部分功能,来构建一个简单的web应用,模型设计如下:

profile   EDGE=HasFollowed  <------->  VERTEX=Account  <>-------->  VERTEX=Profile     created_time                 id                           name                                  nickname                     phoneNum                                  created_time                 gender                                                               address

Account表示账号,其中除了账号的基本属性外,通过profile属性关联到Profile类,该类中包含用户的基本信息,而HasFollowed作为边来表述Account之间的 “关注“ 关系。

应用内要实现的功能包括:

  • 创建上述模型中的CLASS(可以理解为sql中的建表)
  • 新增账号
  • 修改昵称
  • 关注
  • 查询我的关注
  • 取关
> 以上所有设计都只是作为掌握Multi-model API的辅助,不推荐直接应用在实际开发过程

工程说明

整个工程采用Spring Boot + Sring MVC Framework搭建,使用Maven作为构建工具,jdk8,以上工具在这里不再过多介绍,有兴趣的童鞋可以到文末查看项目的github地址,自行取阅。

Maven依赖

类比于常见的web应用,业务应用通过orientdb-client组件,以remote连接方式访问远端的OrientDB数据库实例,maven依赖如下(笔者使用3.0.7版本):

<dependency>     <groupId>com.orientechnologies</groupId>     <artifactId>orientdb-client</artifactId>     <version>${orientdb.version}</version>  </dependency>

TinkerPop 2的API需要依赖 orientdb-graphdb模块,而想使用TinkerPop 3 API的话,需要依赖 orientdb-gremlin模块。

管理数据库连接

@Configuration  public class OrientDbConfig {        @Bean      public ODatabasePool oDatabasePool() {          Map<String, Object> params = new HashMap();          params.put(DB_POOL_MIN.getKey(), 10);          params.put(DB_POOL_MAX.getKey(), 100);          params.put(DB_POOL_ACQUIRE_TIMEOUT.getKey(), 30000);          params.put(DB_POOL_IDLE_TIMEOUT.getKey(), 0);          params.put(DB_POOL_IDLE_CHECK_DELAY.getKey(), 0);          OrientDBConfig config =  OrientDBConfig.builder().fromMap(params).build();          return new ODatabasePool(orientDB(), "demodb", ADMIN, ADMIN, config);      }        @Bean      public OrientDB orientDB() {          return new OrientDB("remote:yourhost", ROOT, ROOT,                              OrientDBConfig.defaultConfig());      }  }

一切数据库操作,都依赖OrientDB实例( orientdb-client组件使用 Binary Protocol 协议,通过TCP/IP socket进行业务应用与数据库实例间的交互),同时其连接池实例为ODatabasePool。将orientDB和oDatabasePool都声明成spring的bean,方便依赖注入以及其生命周期的管理(OrientDB、ODatabasePool均重写了close方法,Spring会在应用退出时释放其占用的资源,实现优雅停机,kill -9除外)。

创建相关的CLASS

首先来创建相关的CLASS,通常这类操作应该交由DBA角色来实现,这里只是为了介绍API的功能。

@Autowired  ODatabasePool pool;
try (ODatabaseSession session = pool.acquire()) {      OSchema oSchema = session.getMetadata().getSchema();      //如果CLASS不存在,创建      if (!oSchema.existsClass(CLASS_PROFILE) && !oSchema.existsClass(CLASS_ACCOUNT)) {          OClass oProfile = session.createClass(CLASS_PROFILE, CLASS_V);          oProfile.createProperty(PROFILE_NAME, OType.STRING);          oProfile.createProperty(PROFILE_ADDRESS, OType.STRING);          oProfile.createProperty(PROFILE_GENDER, OType.SHORT);          oProfile.createProperty(PROFILE_PHONENUM, OType.STRING);          oProfile.createIndex(IDX_PROFILE_PHONENUM, OClass.INDEX_TYPE.UNIQUE, PROFILE_PHONENUM);          oProfile.setStrictMode(true);            OClass oAccount = session.createClass(CLASS_ACCOUNT, CLASS_V);          oAccount.createProperty(COMMON_ID, OType.STRING);          oAccount.createProperty(ACCOUNT_NICKNAME, OType.STRING);          oAccount.createProperty(COMMON_CREATEDTIME, OType.DATETIME);          oAccount.createProperty(ACCOUNT_PROFILE, OType.LINK, oProfile);          oAccount.createIndex(IDX_ACCOUNT_ID, OClass.INDEX_TYPE.UNIQUE_HASH_INDEX, COMMON_ID);          oAccount.createIndex(IDX_ACCOUNT_NICKNAME, OClass.INDEX_TYPE.UNIQUE_HASH_INDEX, ACCOUNT_NICKNAME);            session.commit();      }  }
  1. 注入连接池,并通过acquire()方法获取连接对象:ODatabaseSession,通过try-with-resources来保证操作结束后其资源得到释放,同时注意ODatabaseSession 是非线程安全的
  2. getMetadata()可以获取数据库的元数据信息,包括Schemas、索引、调度器、函数库、安全信息等。代码中使用其判断对应的CLASS是否已经存在,避免重复创建的异常。
  3. createClass()用来创建CLASS,OrientDB支持继承,这里通过指定父类 V,将Profile和Account都创建成Vertex(同理, E是Edge的父类),这也体现了Mutil-Model的理念,一种API可以同时实现文档和图的操作。createVertexClass()和createEdgeClass()可以实现同样的功能。
  4. createProperty()用来创建CLASS所包含的属性,除基本属性外,也支持引用。代码中通过指定profile为OType.LINK类型,而建立了Account到Profile的1:1引用关系(这里设计成引用只是为了更多的展示API的功能,使用Edge来建立关系也是合理的)。
  5. OrientDB支持多种Schema模式,这个setStrictMode(true)指定使用Schema-Full模式,后续插入过程中不能再新增属性,这虽然牺牲了一些灵活性,但是提高了性能并节省了磁盘空间,结合业务场景酌情选择适合的模式。
  6. createIndex()用来创建索引,OrientDB中包含SB-Tree,Hash,Lucene等多种索引。代码中为phoneNum创建了默认的唯一索引(SB-Tree),因为考虑到手机号码可能需要范围查询(如like 186%),而为Account ID(本文采用UUID)添加UNIQUE_HASH_INDEX,因为其基本不可能范围查询,这样提高检索性能,并节省空间。
  7. 最后commit(),将变更提交到数据库实例。

新增账号

本应用通过json格式的报文进行前后端交互,业务层收到的参数均为json格式。

//报文样例:新增账号  {"nickName":"hello_orientdb","profile":{"name":"张三","address":"上海","gender":1,"phoneNum":"18600000000"}}

图相关CLASS实现

try (ODatabaseSession session = pool.acquire()) {      session.begin();      vertex = newVertex(session, clazz, params);      session.commit();  }
/*  * 通过递归调用,处理像Account中关联Profile的情况  */  private OVertex newVertex(ODatabaseSession session, String clazz, JSONObject params) {      OVertex vertex = session.newVertex(clazz);      for (Map.Entry<String, Object> e: params.entrySet()) {          if (e.getValue() instanceof JSONObject) {              OVertex inner = newVertex(session, e.getKey(), (JSONObject) e.getValue());              vertex.setProperty(e.getKey(), inner);          } else {              vertex.setProperty(e.getKey(), e.getValue());          } // todo 只处理了1:1的关系,1:n或n:n的情况请自行完善      }      session.save(vertex);      return vertex;  }

以上代码通过API中与图相关的CLASS来实现逻辑:newVertex()用来新增一条顶点的记录,OVertex代表顶点(OEdge代表边),其中setProperty()用来设置属性。这里利用递归调用,将嵌套的json报文转化为Account和Profile的引用关系(方法仅供参考)。

SQL实现

StringBuilder sql = new StringBuilder("BEGIN;n");  try (ODatabaseSession session = pool.acquire()) {      newVertexSql(sql, clazz, params);      sql.append("COMMIT;n").append("return $").append(clazz);      try (OResultSet rs = session.execute("sql", sql.toString())) {          rs.stream().findFirst().map(OResult::toJSON).orElse(null);       }  }
/**  * 通过递归生成batch脚本,处理像Account中关联Profile的情况  * 最终执行的语句样例:  * <pre>  * BEGIN;  * LET profile = CREATE VERTEX profile CONTENT {"address":"上海","gender":1,"name":"张三","phoneNum":"18622222222"};  * LET Account = CREATE VERTEX Account CONTENT {"created_time":"2018-10-25 12:14:08","nickName":"hello_orientdb","id":"aa5b2e47-f863-4afe-b38c-9fbf87d8bedd","profile":$profile.@rid};  * COMMIT;  * return $Account  * </pre>  */  public StringBuilder newVertexSql(StringBuilder sql, String clazz, JSONObject params) {          Map<String, Object> temp = new HashMap<>();          StringBuilder inner = new StringBuilder();          for (Map.Entry<String, Object> e: params.entrySet()) {              if (e.getValue() instanceof JSONObject) {                  newVertexSql(sql, e.getKey(), (JSONObject) e.getValue());                  inner.append(""").append(e.getKey()).append("":")                      .append("$").append(e.getKey()).append(".@rid").append("}");              } else {                  temp.put(e.getKey(), e.getValue());              }              // todo 目前只处理了1:1的关系,1:n或n:n的情况请自行完善          }          Map<String, String> args = new HashMap<>();          args.put(KEYWORD_CLASS, clazz);          String content = JSON.toJSONString(temp);          args.put(KEYWORD_CONTENT, inner.length() == 0                          ? content                          : content.substring(0, content.length() - 1) + "," + inner.toString());          sql.append("LET ").append(clazz).append(" = ")              .append(parseSql(CREATE_VERTEX_USE_CONTENT, args)).append("n");          return sql;      }

以上代码展示了另一种方式,除了使用OVertex和OEdge这些CLASS来进行图操作,Multi-Model API也支持执行OrientDB的sql语句:

  • query(),执行幂等操作(SELECT, MATCH, TRAVERSE…)
  • command(),执行所有操作,幂等(SELECT, MATCH…),非幂等 (INSERT, UPDATE, DELETE…) and DDL (CREATE CLASS, CREATE PROPERTY…)
  • execute(),执行脚本(默认为SQL脚本)

这里采用execute()执行Batch脚本的方式,与command()相比,这种方式的好处是减少客户端与数据库实例的交互次数,最终执行的Batch脚本样例见方法说明。

修改昵称

//报文样例:修改昵称  {"id":"6d5f1625-e171-4ab7-be22-8fd1036e41fd","@rid":"#100:0","nickName":"hi_orientdb"}

图相关CLASS实现

OVertex vertex;  try (ODatabaseSession session = pool.acquire()) {      session.begin();      vertex = session.load(new ORecordId(rid));      for (Map.Entry<String, Object> e: properties.entrySet()) {          vertex.setProperty(e.getKey(), e.getValue());      }      vertex.save();      session.commit();  }

在已知记录的@rid情况下,可以直接通过load()方式加载记录,之后通过修改相应的属性实现update的目的。

SQL实现

Map<String, Object> properties = new HashMap<>();  properties.put(COMMON_ID, jo.getString(COMMON_ID));  properties.put(ACCOUNT_NICKNAME, jo.getString(ACCOUNT_NICKNAME));  String sql = "UPDATE Account SET nickname = :nickname WHERE id = :id;";  try (ODatabaseSession session = pool.acquire()) {      try (OResultSet rs = session.command(sql, properties)) {          return rs.stream().findFirst().map(OResult::toJSON).orElse(null);      }  }
  1. 使用update语句执行修改逻辑,这样查询的条件就不仅限于@rid。
  2. 尽量使用参数化的查询语句,不要每次通过字符串连接而生成语句。每次接收到sql语句后,OrientDB会parse语句,生成AST,并缓存,如果使用字符串连接的形式每次都无法命中缓存,而需要重新parse(虽然parse过程不是非常消耗资源的动作,但是零消耗总好过低消耗)。
  3. OrientDB支持java Stream API,使用更方便。

关注

//报文样例:关注  {"HasFollowed":{"id":"cdcc93e8-20e3-4245-92ae-eed49c464994","@rid":"#261:0"},"id":"471c3b4f-0f8c-41a6-b5eb-594dd7ccf5f8","@rid":"#260:0"}

图相关CLASS实现

try (ODatabaseSession session = pool.acquire()) {      session.begin();      OEdge edge = session.newEdge(              session.load(new ORecordId(fromRid)),              session.load(new ORecordId(toRid)),              CLASS_HASFOLLOWED);      session.save(edge);      session.commit();  }

关注相当于在两个Account间插入一条HasFollowed边,newEdge()方法可以实现这个动作,需要注意参数的顺序,OrientDB的边是有方向的。

SQL实现

Map<String, Object> properties = new HashMap<>();  properties.put("fromId", fromId);  properties.put("toId", toId);  String sql = "CREATE EDGE HasFollowed FROM " +              "(SELECT FROM Account WHERE id = :fromId) TO " +              "(SELECT FROM Account WHERE id = :toId);";  try (ODatabaseSession session = pool.acquire()) {      try (OResultSet rs = session.command(sql, properties)) {          //your code      }  }
  1. 通过CREATE EDGE语句可以实现同样的逻辑,同时也要注意两个Vertex的方向。
  2. OResultSet对象也需要close。

我的关注

//报文样例:我的关注  {"@rid":"#260:0", "id":"471c3b4f-0f8c-41a6-b5eb-594dd7ccf5f8"}

图相关CLASS创建

try (ODatabaseSession session = pool.acquire()) {      OVertex resultSet = session.load(new ORecordId(jo.getString(COMMON_RID)));      resultSet.toJSON("fetchPlan:profile:-1 out_HasFollowed.in:1");  }

默认情况下,在remote连接模式下,query或者load都是延迟加载模式,client为了获取连接的记录需要发送多个网络请求来从服务端加载数据,在一些场景下,这非常消耗资源。通过Fetch Plan可以避免这种情况。代码中在toJSON方法中指定了fetchPlan,加载Account中的profile和其关注的Account记录。

SQL创建

try (ODatabaseSession session = pool.acquire()) {      String sql = "match n" +              "  {class:Account, as:self, where:(id = :id)}n" +              "  .outE('HasFollowed'){as:hasFollowed}n" +              "  .inV(){as:follow}n" +              "return n" +              "  follow.@rid as rid, follow.nickName as nickName, follow.id as id, " +              "  follow.profile.name as name, follow.profile.phoneNum as phoneNum, " +              "  follow.profile.gender as gender,  follow.profile.address as address, " +              "  hasFollowed.@rid as hasFollowRid;";      Map<String, Object> properties = new HashMap<>();      properties.put(COMMON_ID, jo.getString(COMMON_ID));      List<String> results = resultSet.stream().map(r -> r.toJSON()).collect(Collectors.toList());  }

对于复杂的图遍历场景,Match是利器。上述代码展示了加载我的关注的Match语句,其中return除了返回Account相关字段,还返回了边的@rid,方便后续取关逻辑的实现。

取消关注

//报文样例:取消关注  {"id":"6d5f1625-e171-4ab7-be22-8fd1036e41fd","@rid":"#100:0,"hasFollowed":"6d5f1625-e171-4ab7-be22-111111111111"}

图相关CLASS实现

try (ODatabaseSession session = pool.acquire()) {      session.delete(new ORecordId(rid));  }
  1. delete()方法可以直接通过@rid删除相应的记录。
  2. 在OrientDB中大部分场景下,图的完整性是由数据库实例维护的。上述代码仅仅删除了边的记录,但是数据库引擎会同时把边两端的顶点中相关的link记录清除掉(即删除边的同时,顶点也会被更新)。

SQL实现

try (ODatabaseSession session = pool.acquire()) {      Map<String, Object> properties = new HashMap<>();      properties.put("fromId", fromId);      properties.put("toId", toId);      String sql = "DELETE EDGE HasFollowed FROM " +                  "(SELECT FROM Account WHERE id = :fromId) TO " +                  "(SELECT FROM Account WHERE id = :toId);";      session.command(sql, properties);  }

与创建边类似,通过DELETE EDGE语句可以实现同样的操作。

总结

本文旨在通过一个简单的web业务场景,带领大家了解OrientDB新的Multi-Model API的一些基础功能。较之前的版本,新的API更能体现“多模型”的产品定位,在提供丰富的图操作同时,兼顾的文档的特性,使用更平滑、更方便。

后续文章会带来更丰富、实用的相关实践经验,欢迎持续关注。

源码地址: https://github.com/xiang-me/OrientDB-tutorials