[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(); } }
- 注入連接池,並通過acquire()方法獲取連接對象:ODatabaseSession,通過try-with-resources來保證操作結束後其資源得到釋放,同時注意ODatabaseSession 是非線程安全的。
- getMetadata()可以獲取數據庫的元數據信息,包括Schemas、索引、調度器、函數庫、安全信息等。代碼中使用其判斷對應的CLASS是否已經存在,避免重複創建的異常。
- createClass()用來創建CLASS,OrientDB支持繼承,這裡通過指定父類
V
,將Profile和Account都創建成Vertex(同理,E
是Edge的父類),這也體現了Mutil-Model的理念,一種API可以同時實現文檔和圖的操作。createVertexClass()和createEdgeClass()可以實現同樣的功能。 - createProperty()用來創建CLASS所包含的屬性,除基本屬性外,也支持引用。代碼中通過指定profile為OType.LINK類型,而建立了Account到Profile的1:1引用關係(這裡設計成引用只是為了更多的展示API的功能,使用Edge來建立關係也是合理的)。
- OrientDB支持多種Schema模式,這個setStrictMode(true)指定使用Schema-Full模式,後續插入過程中不能再新增屬性,這雖然犧牲了一些靈活性,但是提高了性能並節省了磁盤空間,結合業務場景酌情選擇適合的模式。
- createIndex()用來創建索引,OrientDB中包含SB-Tree,Hash,Lucene等多種索引。代碼中為phoneNum創建了默認的唯一索引(SB-Tree),因為考慮到手機號碼可能需要範圍查詢(如like 186%),而為Account ID(本文採用UUID)添加UNIQUE_HASH_INDEX,因為其基本不可能範圍查詢,這樣提高檢索性能,並節省空間。
- 最後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); } }
- 使用update語句執行修改邏輯,這樣查詢的條件就不僅限於@rid。
- 盡量使用參數化的查詢語句,不要每次通過字符串連接而生成語句。每次接收到sql語句後,OrientDB會parse語句,生成AST,並緩存,如果使用字符串連接的形式每次都無法命中緩存,而需要重新parse(雖然parse過程不是非常消耗資源的動作,但是零消耗總好過低消耗)。
- 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 } }
- 通過CREATE EDGE語句可以實現同樣的邏輯,同時也要注意兩個Vertex的方向。
- 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)); }
- delete()方法可以直接通過@rid刪除相應的記錄。
- 在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