[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