[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