- 原文地址:Powering PHP With JanusGraph
- 原文做者:Don Omondi
- 译文出自:掘金翻译计划
- 本文永久连接:github.com/xitu/gold-m…
- 译者:GanymedeNil
- 校对者:allenlongbaobao
随着 JanusGraph 的日益流行,开发者们也毫无疑问地围绕着它开发着相应的工具。在这篇来自 Compose Write Stuff 的文章中,Campus Discounts 的创始人兼首席技术官 Don Omondi 将谈到他为 JanusGraph 开发新的 PHP 库而且分享如何使用它。php
在编程语言的世界中,PHP 并不须要过多介绍。它在 1995 年正式对外发布了 1.0 版本。如今 PHP 已经成为许多独角兽公司的中坚力量,而其中最为人知晓的就是 Facebook,最近像 Slack 也加入了 PHP 的阵营。截至 2017 年 9 月,W3Techs 报告称,在全部已知网站中,服务端编程语言使用了 PHP 的占了 82.8% !前端
在数据库的世界中,JanusGraph 虽是一位新成员,但它却有着深厚的技术底蕴,由于它创建在开源图形数据库的前任领导者 Titan 的基础上。为了提供给您一些关于图数据库的背景知识,请看图数据库简介。虽然 JanusGraph 还很年轻,可是它已经被一个知名的独角兽公司 —— Uber 使用。android
因此最大的问题是,如何使用 PHP 和 JanusGraph 建立一家独角兽公司?相信我,我也但愿我知道答案!可是,若是问题是如何使用 JanusGraph 来强化 PHP ?我却是知道不止一种方法。ios
Gremlin-OGM PHP 库是 Tinkerpop 3+ 兼容的图形数据库(JanusGraph,Neo4j 等)的对象图形映射器,容许您保存数据并运行 gremlin 查询。git
该库已经托管在 Packagist 上了 ,因此能够轻松的使用 Composer 安装。github
composer require the-don-himself/gremlin-ogm
复制代码
使用该库也很容易,由于它有大量的 PHP 注释。可是在咱们开始使用它以前,让咱们深刻探讨一下使用像 JanusGraph 这样的图形数据库时可能遇到的一些问题以及该库如何帮助您避免它们。数据库
首先,具备相同名称的全部属性必须具备相同的数据类型。若是您已经在不一样的数据库中有数据,好比 MySQL 或者 MongoDB ,那么您可能会遇到这种状况。编程
一个很好的例子就是在每一个实体类或文档中称为 id
的字段。一些 ID 多是一个整数的数据类型(一、二、3 等), 其余一些多是字符串类型 (例如在常见问题解答库中的 ID en_1
、es_1
、fr_1
),另外还有好比 MongoDB 的 UUID(例子 59be8696540bbb198c0065c4
)。对于这些不一样的数据类型使用相同属性名称的状况会引起异常。Gremlin-OGM 库会发现这样的冲突并拒绝执行。做为一种解决方法,我建议将标签与单词 id
组合; 例如,用户的标识符变为 users_id
。该库附带一个序列化程序,容许您将字段映射到虚拟属性以免此冲突。后端
其次,属性(property)名称,边缘(edge)标签和顶点(vertex)标签在图中必须都是惟一的。例如,将 Vertex 标记为 tweets
并引用一个对象,而后建立一个 Edge 标记为 tweets
并引用用户操做,或者在 users
Vertex 中建立一个 property tweets
来引用用户发出的推文数量。该库一样会发现这种冲突并拒绝执行。api
第三,对于性能和模式的有效性,我建议确保每一个元素,或者至少每一个顶点包含一个惟一的属性,在该惟一属性上将建立惟一的组合索引(也称为键索引)。这确保全部元素都是惟一的,而且会提升性能,由于在顶点之间添加边缘首先须要查询它们是否先存在。该库容许您为此目的使用 @Id
注释标记属性。
最后,索引。这一点值得写一本或两本书。在 JanusGraph 中,基本上您索引的是属性(毕竟它是一个属性图),可是能够在不一样的顶点和边缘上使用相同的属性名称。这样作时要很是当心。请记住第一件要注意的事情。所以,例如在默认状况下,属性 total_comments
上的索引将跨越全部顶点和边缘。查询其中total_comments
大于 5
的顶点会返回 total_comments > 5
的 users
,total_comments > 5
的博客帖子以及知足该查询的任何其余顶点的混合状况。更糟糕的状况是,一段时间后,若是您在 recipes
顶点了加一个 total_comments
属性,那么你现有的查询就会出错了。
为了防止上述潜在的问题,JanusGraph 容许您在建立索引时设置标签参数以限制其范围。我建议这样作以保持索引更小和更高性能,但这意味着您必须为每一个索引提供一个惟一的名称。Gremlin-OGM 库查找任何冲突的索引名称,若是发现将拒绝执行。
要开始使用 Gremlin-OGM ,咱们首先须要在咱们的源文件夹中建立一个名为 Graph 的目录,例如 src/Graph
。在这个目录下,咱们须要建立两个不一样的目录:一个叫作 Vertices ,另外一个叫作 Edges 。这两个目录如今将包含定义咱们图表元素的PHP类。
顶点文件夹中的每一个类主要使用注释描述顶点标签,关联索引和属性。对于更高级的用例,若是您使用 MongoDB 并拥有一个保存嵌入式文档的类(例如注释集合),则还能够定义最适合的嵌入边缘。
边缘文件夹中的每一个类仍是经过注释描述边缘标签,相关索引和属性。每一个边缘类中的两个属性也可使用注释进行标记,一个用于描述顶点从哪连接过来的,另外一个用于描述顶点要连接去哪。它的使用真的很简单,但咱们仍是用一个实例来讲明吧。
Twitter和图形数据库真的是天生一对。像用户和推文这样的对象能够造成顶点,而诸如 follow ,likes ,tweeted 和 retweets 等操做能够造成边缘。请注意,边缘 tweeted
是以这种方式命名的,以免与顶点 tweets
发生冲突。这个简单的模型的图形表示能够以下图所示。
让咱们在 Graph/Vertexes 文件夹和 Graph/Edges 文件夹中建立相应的类。tweets 类可能以下所示:
<?php
namespace TheDonHimself\GremlinOGM\TwitterGraph\Graph\Vertices;
use JMS\Serializer\Annotation as Serializer;
use TheDonHimself\GremlinOGM\Annotation as Graph;
/**
* @Serializer\ExclusionPolicy("all")
* @Graph\Vertex(
* label="tweets",
* indexes={
* @Graph\Index(
* name="byTweetsIdComposite",
* type="Composite",
* unique=true,
* label_constraint=true,
* keys={
* "tweets_id"
* }
* ),
* @Graph\Index(
* name="tweetsMixed",
* type="Mixed",
* label_constraint=true,
* keys={
* "tweets_id" : "DEFAULT",
* "text" : "TEXT",
* "retweet_count" : "DEFAULT",
* "created_at" : "DEFAULT",
* "favorited" : "DEFAULT",
* "retweeted" : "DEFAULT",
* "source" : "STRING"
* }
* )
* }
* )
*/
class Tweets
{
/**
* @Serializer\Type("integer")
* @Serializer\Expose
* @Serializer\Groups({"Default"})
*/
public $id;
/**
* @Serializer\VirtualProperty
* @Serializer\Expose
* @Serializer\Type("integer")
* @Serializer\Groups({"Graph"})
* @Serializer\SerializedName("tweets_id")
* @Graph\Id
* @Graph\PropertyName("tweets_id")
* @Graph\PropertyType("Long")
* @Graph\PropertyCardinality("SINGLE")
*/
public function getVirtualId()
{
return self::getId();
}
/**
* @Serializer\Type("string")
* @Serializer\Expose
* @Serializer\Groups({"Default", "Graph"})
* @Graph\PropertyName("text")
* @Graph\PropertyType("String")
* @Graph\PropertyCardinality("SINGLE")
*/
public $text;
/**
* @Serializer\Type("integer")
* @Serializer\Expose
* @Serializer\Groups({"Default", "Graph"})
* @Graph\PropertyName("retweet_count")
* @Graph\PropertyType("Integer")
* @Graph\PropertyCardinality("SINGLE")
*/
public $retweet_count;
/**
* @Serializer\Type("boolean")
* @Serializer\Expose
* @Serializer\Groups({"Default", "Graph"})
* @Graph\PropertyName("favorited")
* @Graph\PropertyType("Boolean")
* @Graph\PropertyCardinality("SINGLE")
*/
public $favorited;
/**
* @Serializer\Type("boolean")
* @Serializer\Expose
* @Serializer\Groups({"Default", "Graph"})
* @Graph\PropertyName("retweeted")
* @Graph\PropertyType("Boolean")
* @Graph\PropertyCardinality("SINGLE")
*/
public $retweeted;
/**
* @Serializer\Type("DateTime<'', '', 'D M d H:i:s P Y'>")
* @Serializer\Expose
* @Serializer\Groups({"Default", "Graph"})
* @Graph\PropertyName("created_at")
* @Graph\PropertyType("Date")
* @Graph\PropertyCardinality("SINGLE")
*/
public $created_at;
/**
* @Serializer\Type("string")
* @Serializer\Expose
* @Serializer\Groups({"Default", "Graph"})
* @Graph\PropertyName("source")
* @Graph\PropertyType("String")
* @Graph\PropertyCardinality("SINGLE")
*/
public $source;
/**
* @Serializer\Type("TheDonHimself\GremlinOGM\TwitterGraph\Graph\Vertices\Users")
* @Serializer\Expose
* @Serializer\Groups({"Default"})
*/
public $user;
/**
* @Serializer\Type("TheDonHimself\GremlinOGM\TwitterGraph\Graph\Vertices\Tweets")
* @Serializer\Expose
* @Serializer\Groups({"Default"})
*/
public $retweeted_status;
/**
* Get id.
*
* @return int
*/
public function getId()
{
return $this->id;
}
}
复制代码
Twitter API 很是具备表现力,尽管咱们实际上能够保存比顶点类容许的多得多的数据。可是,对于这个示例,咱们只是对几个属性感兴趣。上述注释将告诉序列化程序仅在将 Twitter API 数据反序列化为顶点类对象时填充这些字段。
为 users
顶点建立一个相似的类。完整的示例代码位于库中的 TwitterGraph 文件夹中。
在 Graph/Edges 文件夹中能够建立一个示例 Follows
边缘类,它看起来像这样:
<?php
namespace TheDonHimself\GremlinOGM\TwitterGraph\Graph\Edges;
use JMS\Serializer\Annotation as Serializer;
use TheDonHimself\GremlinOGM\Annotation as Graph;
/**
* @Serializer\ExclusionPolicy("all")
* @Graph\Edge(
* label="follows",
* multiplicity="MULTI"
* )
*/
class Follows
{
/**
* @Graph\AddEdgeFromVertex(
* targetVertex="users",
* uniquePropertyKey="users_id",
* methodsForKeyValue={"getUserVertex1Id"}
* )
*/
protected $userVertex1Id;
/**
* @Graph\AddEdgeToVertex(
* targetVertex="users",
* uniquePropertyKey="users_id",
* methodsForKeyValue={"getUserVertex2Id"}
* )
*/
protected $userVertex2Id;
public function __construct($user1_vertex_id, $user2_vertex_id)
{
$this->userVertex1Id = $user1_vertex_id;
$this->userVertex2Id = $user2_vertex_id;
}
/**
* Get User 1 Vertex ID.
*
*
* @return int
*/
public function getUserVertex1Id()
{
return $this->userVertex1Id;
}
/**
* Get User 2 Vertex ID.
*
*
* @return int
*/
public function getUserVertex2Id()
{
return $this->userVertex2Id;
}
}
复制代码
为 likes
,tweeted
和 retweets
边缘建立相似的类。完成后,咱们能够经过运行如下命令来检查模型的有效性:
php bin/graph twittergraph:schema:check
复制代码
若是抛出异常,那么咱们须要先解决它们;不然,咱们的模型已经设置好了,如今咱们须要作的就是告诉 JanusGraph 。
TheDonHimself\GremlinOGM\GraphConnection
类负责初始化图形链接。您能够经过建立一个新的实例并在数组中传递一些链接选项来实现。
$options = [
'host' => 127.0.0.1,
'port' => 8182,
'username' => null,
'password' => null,
'ssl' = [
'ssl_verify_peer' => false,
'ssl_verify_peer_name' => false
],
'graph' => 'graph',
'timeout' => 10,
'emptySet' => true,
'retryAttempts' => 3,
'vendor' = [
'name' => _self', 'database' => 'janusgraph', 'version' => '0.2' ], 'twitter' => [ 'consumer_key' => 'LnUQzlkWlNT4oNUh7a2rwFtwe', 'consumer_secret' => 'WCIu0YhaOUBPq11lj8psxZYobCjXpYXHxXA6rVcqbuNDYXEoP0', 'access_token' => '622225192-upvfXMpeb9a3FMhuid6oBiCRsiAokpNFgbVeeRxl', 'access_token_secret' => '9M5MnJOns2AFeZbdTeSk3R81ZVjltJCXKtxUav1MgsN7Z' ] ]; 复制代码
vendor 数组能够指定 vendor-specific 信息,如 gremlin 兼容的数据库、版本、服务主机名称(或 _self
本机)以及图的名称。
最终建立模型,咱们将运行此命令。
php bin/graph twittergraph:schema:create
复制代码
这个命令将要求一个可选的 configPath
参数,该参数是创建链接时包含 options
数组的 yaml 配置文件的位置。该库在根文件夹中有三个示例配置,janusgraph.yaml
,janusgraphcompose.yaml
和 azure-cosmosdb.yaml
。
上述命令将递归遍历咱们的 TwitterGraph/Graph
目录并查找全部 @Graph
注释来构建模型定义。若是发现异常将被抛出;不然,它将启动一个 Graph 事务来一次提交全部属性、边缘和顶点,或者在失败时回滚。
一样的命令也会询问您是否要执行 dry run
。若是指定,则不会将命令发送到 gremlin 服务器,而是将其转储到您能够检查的 command.groovy
文件中。对于Twitter示例,这 26 行是根据您的配置发送或转储的命令(如janusgraph _self 本机)。
mgmt = graph.openManagement()
text = mgmt.makePropertyKey('text').dataType(String.class).cardinality(Cardinality.SINGLE).make()
retweet_count = mgmt.makePropertyKey('retweet_count').dataType(Integer.class).cardinality(Cardinality.SINGLE).make()
retweeted = mgmt.makePropertyKey('retweeted').dataType(Boolean.class).cardinality(Cardinality.SINGLE).make()
created_at = mgmt.makePropertyKey('created_at').dataType(Date.class).cardinality(Cardinality.SINGLE).make()
source = mgmt.makePropertyKey('source').dataType(String.class).cardinality(Cardinality.SINGLE).make()
tweets_id = mgmt.makePropertyKey('tweets_id').dataType(Long.class).cardinality(Cardinality.SINGLE).make()
name = mgmt.makePropertyKey('name').dataType(String.class).cardinality(Cardinality.SINGLE).make()
screen_name = mgmt.makePropertyKey('screen_name').dataType(String.class).cardinality(Cardinality.SINGLE).make()
description = mgmt.makePropertyKey('description').dataType(String.class).cardinality(Cardinality.SINGLE).make()
followers_count = mgmt.makePropertyKey('followers_count').dataType(Integer.class).cardinality(Cardinality.SINGLE).make()
verified = mgmt.makePropertyKey('verified').dataType(Boolean.class).cardinality(Cardinality.SINGLE).make()
lang = mgmt.makePropertyKey('lang').dataType(String.class).cardinality(Cardinality.SINGLE).make()
users_id = mgmt.makePropertyKey('users_id').dataType(Long.class).cardinality(Cardinality.SINGLE).make()
tweets = mgmt.makeVertexLabel('tweets').make()
users = mgmt.makeVertexLabel('users').make()
follows = mgmt.makeEdgeLabel('follows').multiplicity(MULTI).make()
likes = mgmt.makeEdgeLabel('likes').multiplicity(MULTI).make()
retweets = mgmt.makeEdgeLabel('retweets').multiplicity(MULTI).make()
tweeted = mgmt.makeEdgeLabel('tweeted').multiplicity(ONE2MANY).make()
mgmt.buildIndex('byTweetsIdComposite', Vertex.class).addKey(tweets_id).unique().indexOnly(tweets).buildCompositeIndex()
mgmt.buildIndex('tweetsMixed',Vertex.class).addKey(tweets_id).addKey(text,Mapping.TEXT.asParameter()).addKey(retweet_count).addKey(created_at).addKey(retweeted).addKey(source,Mapping.STRING.asParameter()).indexOnly(tweets).buildMixedIndex("search")
mgmt.buildIndex('byUsersIdComposite',Vertex.class).addKey(users_id).unique().indexOnly(users).buildCompositeIndex()
mgmt.buildIndex('byScreenNameComposite',Vertex.class).addKey(screen_name).unique().indexOnly(users).buildCompositeIndex()
mgmt.buildIndex('usersMixed',Vertex.class).addKey(users_id).addKey(name,Mapping.TEXTSTRING.asParameter()).addKey(screen_name,Mapping.STRING.asParameter()).addKey(description,Mapping.TEXT.asParameter()).addKey(followers_count).addKey(created_at).addKey(verified).addKey(lang,Mapping.STRING.asParameter()).indexOnly(users).buildMixedIndex("search")
mgmt.commit()
复制代码
如今咱们有了一个有效的模型设置,咱们须要的只是数据。Twitter API 有很好的文档关于如何请求这些数据。Gremlin-OGM 库附带了一个 twitteroauth 包 (abraham/twitteroauth) 以及一个准备好的只读 Twitter 应用程序,用于测试该库并帮助您开始使用。
从 API 中获取数据后,保持顶点很是简单。首先,将 JSON 反序列化为相应的顶点类对象。所以,例如,@TwitterDev
经过取回的 Twitter 数据 /api/users/show
将被反序列化,如图所示 var_dump()
。
object(TheDonHimself\GremlinOGM\TwitterGraph\Graph\Vertices\Users)#432 (8) {
["id"]=>
int(2244994945)
["name"]=>
string(10) "TwitterDev"
["screen_name"]=>
string(10) "TwitterDev"
["description"]=>
string(136) "Developer and Platform Relations @Twitter. We are developer advocates. We can't answer all your questions, but we listen to all of them!"
["followers_count"]=>
int(429831)
["created_at"]=>
object(DateTime)#445 (3) {
["date"]=>
string(26) "2013-12-14 04:35:55.000000"
["timezone_type"]=>
int(1)
["timezone"]=>
string(6) "+00:00"
}
["verified"]=>
bool(true)
["lang"]=>
string(2) "en"
}
复制代码
序列化的 PHP 对象如今已经开始在各自的顶点和边缘中造成。可是,咱们只能将 gremlin 命令做为字符串发送,因此咱们仍然须要将对象序列化为命令字符串。咱们将使用一个方便命名的类GraphSerializer
来执行此操做。将反序列化的对象传递给GraphSerializer
的一个实例,该实例将处理复杂的序列化,如剥离新行,添加斜杠,将PHP DateTime
转换为 JanusGraph 所指望的格式。GraphSerializer
也优雅地处理 Geopoint 和 Geoshape 序列化。
// Get Default Serializer
$serializer = SerializerBuilder::create()->build();
// Get Twitter User
$decoded_user = $connection->get(
'users/show',
array(
'screen_name' => $twitter_handle,
'include_entities' => false,
)
);
if (404 == $connection->getLastHttpCode()) {
$output->writeln('Twitter User @'.$twitter_handle.' Does Not Exist');
return;
}
// Use default serializer to convert array from Twitter API to Users Class Object handling complex
deserialization like Date Time
$user = $serializer->fromArray($decoded_user, Users::class);
// Initiate Special Graph Serializer
$graph_serializer = new GraphSerializer();
// Use graph serializer to convert Users Class Object to array handling complex deserialization like
Geoshape
$user_array = $graph_serializer->toArray($user);
// Use graph serializer to convert array to a gremlin command string ready to be sent over
$command = $graph_serializer->toVertex($user_array);
复制代码
GraphSerializer 输出将串入 Gremlin 的命令。这个字符串就准备好发送到 JanusGraph 服务器。因此在上面的例子中,它变成:
"g.addV(label, 'users', 'users_id', 2244994945, 'name', 'TwitterDev', 'screen_name', 'TwitterDev', 'description', 'Developer and Platform Relations @Twitter. We are developer advocates. We can\'t answer all your questions, but we listen to all of them!', 'followers_count', 429831, 'created_at', 1386995755000, 'verified', true, 'lang', 'en')"
复制代码
保存边缘要稍微简单一点,由于它的前提是定点存在。所以,库须要知道属性键值对来查找它们。此外,边缘在图数据库中具备方向和多重性。所以,边缘要添加到顶点这很是重要。
这是 Edge 类中 @Graph\AddEdgeFromVertex
和 @Graph\AddEdgeToVertex
属性注释的用途。它们都扩展了 @Graph\AddEdge
注解来指示目标顶点类以及属性键和获取该值所需的方法数组。
假设咱们已经在 Twitter API 中查询到了 tweets ,其中包含一个名为 user
的嵌入字段,用于保存 tweeter 数据。若是 users_id:5
建立了 tweets_id:7
,则序列化的 gremlin 命令将以下所示:
if (g.V().hasLabel('users').has('users_id',5).hasNext() == true
&& g.V().hasLabel('tweets').has('tweets_id',7).hasNext() == true)
{
g.V().hasLabel('users').has('users_id',5).next().addEdge('tweeted',
g.V().hasLabel('tweets').has('tweets_id',7).next())
}
复制代码
所以,两个顶点查询是一个事务,而后在users
与 tweets
之间建立两条边缘。请注意,由于一个用户能够屡次发 tweet ,但每一个 tweet 只能有一个拥有者,因此其重复性为 ONE2MANY
。
若是边缘类具备像 tweeted_on
或 tweeted_from
这样的属性,那么库就会像顶点同样适当地序列化它们。
咱们处理了抓取和保存的数据。数据查询也是库帮助完成的。TheDonHimself\Traversal\TraversalBuilder
类提供了几乎与 gremlin 完美匹配的本地API。例如,在 TwitterGraph 中获取用户能够实现以下。
$user_id = 12345;
$traversalBuilder = new TraversalBuilder();
$command = $traversalBuilder
->g()
->V()
->hasLabel("'users'")
->has("'users_id'", "$user_id")
->getTraversal();
复制代码
获取用户时间线这样稍微复杂的例子能够经过如下方式实现。
$command = $traversalBuilder
->g()
->V()
->hasLabel("'users'")
->has("'screen_name'", "'$screen_name'")
->union(
(new TraversalBuilder())->out("'tweeted'")->getTraversal(),
(new TraversalBuilder())->out("'follows'")->out("'tweeted'")->getTraversal()
)
->order()
->by("'created_at'", 'decr')
->limit(10)
->getTraversal();
复制代码
详细步骤能够在 \TheDonHimself\Traversal\Step
类中找到.
有一个独立的尝试 来建立一种支持 GraphQL to Gremlin 命令的标准。它处于早期阶段,只支持查询而不支持变动。既然它也是我写的,Gremlin-OGM 库固然也支持这个标准,但愿随着时间的推移会有所改进。
可悲的是,它没有像关系数据库,文档数据库和键值数据库那样多的 Graph Database GUI。其中Gephi,可用于经过流式插件来可视化 JanusGraph 数据和查询。与此同时,撰写有 JanusGraph 的数据浏览器,可使用它来显示 TwitterGraph 的一些查询。
将我关注的 5 位用户可视化
def graph = ConfiguredGraphFactory.open("twitter");
def g = graph.traversal();
g.V().hasLabel('users').has('screen_name',
textRegex('(i)the_don_himself')).outE('follows').limit(5).inV().path()
复制代码
可视化 5 位关注个人用户
def graph = ConfiguredGraphFactory.open("twitter");
def g = graph.traversal();
g.V().hasLabel('users').has('screen_name',
textRegex('(i)the_don_himself')).inE('follows').limit(5).outV().path()
复制代码
可视化我喜欢的 5 条推文
def graph = ConfiguredGraphFactory.open("twitter");
def g = graph.traversal();
g.V().hasLabel('users').has('screen_name',
textRegex('(?i)the_don_himself')).outE('likes').limit(5).inV().path()
复制代码
可视化任意 5 条转推以及原推
def graph = ConfiguredGraphFactory.open("twitter");
def g = graph.traversal();
g.V().hasLabel('tweets').outE('retweets').inV().limit(5).path()
复制代码
如今您拥有了它。一个功能强大、考虑周到、操做简单的库,它可帮助您在几分钟内开始使用 PHP 操做 JanusGraph 。若是您使用了使人惊叹的 Symfony 框架,那么您的运气会更好。即将发行的软件包 Gremlin-OGM-Bundle 将帮助您将数据从 RDBMS 或 MongoDB 复制到 Tinkerpop 3+ 兼容图形数据库中。请享用!
掘金翻译计划 是一个翻译优质互联网技术文章的社区,文章来源为 掘金 上的英文分享文章。内容覆盖 Android、iOS、前端、后端、区块链、产品、设计、人工智能等领域,想要查看更多优质译文请持续关注 掘金翻译计划、官方微博、知乎专栏。