• בלוג
  • צעדים ראשונים עם גרמלין

צעדים ראשונים עם גרמלין

08/10/2023

גרמלין היא שפת שאילתות לבסיסי נתונים גרפיים. היא השלישית שאני סוקר כאן בבלוג אחרי פוסט על דטומיק ודטהלוג ופוסט על סייפר ו neo4j.

1. מה מיוחד בגרמלין

ההבדל המרכזי בין גרמלין לסייפר הוא האינטגרציה בין שפת השאילתות לשפת התכנות. שאילתה בסייפר היא פשוט בלוק של טקסט שיש בו הוראה לבסיס הנתונים. בסיס נתונים מריץ את השאילתה ומחזיר ערך, לדוגמה שאילתה זו תחזיר את כל הצמתים עם התווית Person:

MATCH (p:Person)
RETURN p;

גרמלין היא שפת שאילתות שבנויה בתור ספריה לשפת התכנות שלכם ומשולבת עם שפת התכנות. בדרך כלל דוגמאות גרמלין כתובות בגרובי או ב Java, אבל אני כותב את הקוד כאן בסקאלה כי אני בדיוק לומד אותה גם. אותה שאילתה בגרמלין תהיה:

g.V().hasLabel("Person").toList()

זה לא טקסט שאני צריך לכתוב בתוך מרכאות ולשלוח לבסיס הנתונים אלא ממש הפעלה של פונקציות. יותר מזה בגרמלין מודל העבודה הוא אימפרטיבי ושאילתה ממש מגדירה מסלול בו אנחנו עוברים על הגרף. בואו נראה את השאילתות דרך אותן דוגמאות שכתבתי עליהן בפוסט על סייפר כדי שאפשר יהיה להשוות.

2. מה אנחנו בונים

בשביל הדוגמה נבנה מודל שמייצג פוסטים בבלוג ואנשים שאמורים לקבל עדכונים על פוסטים חדשים דרך האימייל. כל פוסט שייך לקטגוריה מסוימת, אנשים יכולים להירשם כמנויים לקטגוריות ונרצה לקבל רשימה של כל הפוסטים מהקטגוריות שמשתמש מסוים מנוי אליהן, או רק כאלה שהתפרסמו בתאריך מסוים או מאז האימייל האחרון ששלחנו.

3. יצירת צמתים עבור פוסטים, קטגוריות ומנויים

את תוכנית הדוגמה אני כותב בסקאלה ועם מימוש גרף שנקרא tinkergraph. זה גרף שנשמר בזיכרון ומממש את כל ה API של גרמלין. הוא מאוד נוח בכתיבת תוכניות בדיקה כי עכשיו כל תוכנית בדיקה מקבלת משהו שנראה לגמרי כמו בסיס נתונים. בשביל להתחיל לעבוד עם גרמלין וטינקרפופ מתוך תוכנית סקאלה עליי להתקין את הספריות באמצעות השורות הבאות בקובץ build.sbt:

ThisBuild / scalaVersion := "3.3.1"
libraryDependencies += "org.apache.tinkerpop" % "gremlin-core" % "3.7.0"
libraryDependencies += "org.apache.tinkerpop" % "tinkergraph-gremlin" % "3.7.0"

ובתוך התוכנית אני משתמש ב import-ים הבאים:

import org.apache.tinkerpop.gremlin.process.traversal.dsl.graph.{GraphTraversal, GraphTraversalSource}
import org.apache.tinkerpop.gremlin.process.traversal.IO
import org.apache.tinkerpop.gremlin.process.traversal.AnonymousTraversalSource.traversal
import org.apache.tinkerpop.gremlin.process.traversal.Operator.*
import org.apache.tinkerpop.gremlin.process.traversal.Order.*
import org.apache.tinkerpop.gremlin.process.traversal.P
import org.apache.tinkerpop.gremlin.process.traversal.Pop.*
import org.apache.tinkerpop.gremlin.process.traversal.SackFunctions.*
import org.apache.tinkerpop.gremlin.process.traversal.Scope.*
import org.apache.tinkerpop.gremlin.process.traversal.TextP.*
import org.apache.tinkerpop.gremlin.structure.Column.*
import org.apache.tinkerpop.gremlin.structure.Direction.*
import org.apache.tinkerpop.gremlin.structure.T.*
import org.apache.tinkerpop.gremlin.process.traversal.dsl.graph.__.*
import org.apache.tinkerpop.gremlin.structure.{Edge, Vertex}
import org.apache.tinkerpop.gremlin.tinkergraph.structure.*

import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
import java.util

עכשיו אפשר להתחיל לעבוד. הפונקציה הראשונה נקראת createPost והיא יוצרת פוסט חדש בבסיס הנתונים. פוסט הוא פשוט צומת עם המאפיינים slug, title ותאריך פירסום. לכל צומת בגרף יש תווית ובעזרת התוויות קל לנו לשלוף צמתים מסוג מסוים. התווית של פוסט תהיה פשוט post. קוד? הנה:

def createPost(g: GraphTraversalSource, slug: String, title: String, publishedAt: String): Vertex =
  g.addV("post")
    .property("slug", slug)
    .property("title", title)
    .property("publishedAt", LocalDateTime.parse(publishedAt, formatter))
    .next()

אז כבר אנחנו רואים שאני לא בונה שאילתה בתור איזה בלוק של טקסט ושולח אותו לדרייבר לתקשורת עם בסיס הנתונים אלא שבניית השאילתה היא אינטרקטיבית באמצעות ספריית גרמלין.

הדבר החשוב השני כאן הוא ששאילתה בנויה מצעדים, וכל פונקציה שאני מפעיל מוסיפה צעד למסלול הניווט בגרף. אז אם נתרגם את הצעדים לעברית נקבל:

  1. צור צומת חדש בגרף עם התווית post ולך אליו.
  2. בצומת בו אתה נמצא הגדר מאפיין בשם slug עם הערך ששמור במשתנה slug.
  3. בצומת בו אתה נמצא הגדר מאפיין בשם title עם הערך מהמשתנה title.
  4. בצומת בו אתה נמצא הגדר מאפיין publishedAt עם הערך שהוא התאריך שקיבלנו במשתנה publishedAt.

הפקודה האחרונה, next, מביאה אותנו לרעיון השלישי החשוב לגבי גרמלין. כל מה שבניתי עד עכשיו הוא תיאור של מסלול ניווט בגרף. מסלול ניווט כזה יוצר סידרה של תוצאות (במסלול שלי סידרה בגודל 1, במקרה הכללי יכולות להיות יותר תוצאות). רק אם אני "מושך" תוצאה או יותר מהסידרה אז משהו בכלל יקרה, והפקודה next מושכת את התוצאה הראשונה מהסידרה. זה נראה כמו סיבוך מיותר אבל בעצם זה כח-על מאוד חשוב של גרמלין, כי הוא יאפשר לי להגדיר שאילתות ולהעביר אותן כפרמטרים לשאילתות אחרות כדי ליצור שאילתות מורכבות.

אם זה היה ברור אפשר להמשיך לעוד שתי פונקציות כדי ליצור קטגוריה ומנוי:

def createCategory(g: GraphTraversalSource, name: String): Vertex =
  g.addV("category")
    .property("name", name)
    .next()

def createSubscriber(g: GraphTraversalSource, email: String): Vertex =
  g.addV("reader")
    .property("email", email)
    .next()

ומתוך ה main אני משתמש בפונקציות בצורה הבאה:

@main def main() =
  val graph = TinkerGraph.open
  val g = traversal.withEmbedded(graph)

  createPost(g, "first", "first post", "2023-01-24T06:00:00")
  createPost(g, "second", "second post", "2023-01-25T06:00:00")
  createPost(g, "hello-gremlin", "Hello World in Gremlin", "2023-04-02T06:00:00")

  createCategory(g, "spam")
  createCategory(g, "gremlin")

  createSubscriber(g, "foo@demomail.com")
  createSubscriber(g, "bar@demomail.com")
  createSubscriber(g, "buz@demomail.com")

4. שאילתות על צמתים

איך נדע שהיצירה עבדה? ננסה למשוך מידע מהגרף. הנה למשל הדפסת כל המנויים:

println(g.V().hasLabel("reader").valueMap().toList)

בעברית הצעדים הם:

  1. קח את כל הצמתים בגרף
  2. סנן מהם רק את אלה שמכילים את התווית reader
  3. קח מכל צומת מפה של כל המאפיינים והערכים שלהם

הפקודה האחרונה, toList, מחליפה את next ומבקשת למשוך את כל התוצאות מהסידרה ולהחזיר רשימה שלהן. השורה תדפיס:

[{email=[foo@demomail.com]}, {email=[bar@demomail.com]}, {email=[buz@demomail.com]}]

אגב לכל צומת יש גם מאפיינים שבסיס הנתונים נותן לו. אפשר להדפיס גם אותם אם אני מעביר ערך true לפונקציה valueMap:

println(g.V().hasLabel("reader").valueMap(true).toList)

ואז מקבלים:

[{id=16, label=reader, email=[foo@demomail.com]}, {id=18, label=reader, email=[bar@demomail.com]}, {id=20, label=reader, email=[buz@demomail.com]}]

באותו אופן אני יכול להדפיס גם את כל הפוסטים:

// Get all blog posts
println(g.V().hasLabel("post").valueMap().toList)

ואפשר גם להדפיס רק צמתים עם מאפיינים מסוימים למשל רק קטגוריות בשם gremlin:

println(g.V().has("category", "name", "gremlin").valueMap().toList)

או רק פוסטים שפורסמו בטווח תאריכים מסוים:

  println(
    g.V()
      .has("post", "publishedAt",
        P.between(
          LocalDateTime.parse("2023-04-01T00:00:00", formatter),
          LocalDateTime.parse("2023-05-01T00:00:00", formatter),
        ))
  .valueMap().toList)

5. חיבור צמתים בקשתות ורישום כמנוי לקטגוריה

עכשיו שיש לנו קטגוריות וקוראים, בואו נחבר ביניהם כדי שנוכל לגלות על פוסטים חדשים שמתפרסמים (וכן עוד מעט נחבר גם את הפוסטים לקטגוריות). זו הפונקציה:

def subscribeToCategory(g: GraphTraversalSource, email: String, categoryName: String): GraphTraversal[Vertex, Edge] =
  g
    .V()
    .has("reader", "email", email)
    .as("reader")
    .V()
    .has("category", "name", categoryName)
    .as("category")
    .addE("subscribed_to")
    .from("reader")
    .to("category")
    .iterate()

בתרגום לעברית זה יהיה:

  1. קח את כל הצמתים.
  2. סנן מהם רק את אלה עם התווית reader ושיש להם מאפיין בשם email עם הערך שהגיע מהמשתנה email.
  3. שמור את כל הצמתים שמצאת במשתנה פנימי של השאילתה שנקרא reader. עוד מעט נחזור אליו.
  4. חזור לכל הצמתים.
  5. סנן מהם רק את אלה שיש להם תווית category, מאפיין name עם הערך מהמשתנה categoryName.
  6. שמור את התוצאה במשתנה הפנימי של השאילתה שנקרא category.
  7. צור קשת חדשה בשם subscribed_to.
  8. חבר את הקשת לצומת שהיה שמור במשתנה השאילתה reader, כך שהקשת תצא מהצומת.
  9. חבר את הקשת לצומת category כך שהקשת תיכנס לצומת.

הפקודה iterate בסוף מבצעת את הטיול בגרף, כמו next ו toList מדוגמאות קודמות.

מעניין לדמיין איך שאילתה כזאת היתה נראית בסייפר, רק לצורך השוואה:

MATCH (r:Reader{email: $email})
MATCH (c:Category{name: $name})
MERGE (r)-[:SUBSCRIBED_TO]-(c)
RETURN r, c;

מעבר להבדל באורך, יש גם הבדל משמעותי בשיטת העבודה - בסייפר אני מתאר מה אני רוצה שיקרה, בגרמלין אני מתאר איך אני רוצה שזה יקרה. גרמלין נותן לי יותר שליטה אבל על חשבון קוד יותר ארוך ויותר מקום לטעויות.

בשביל לחבר פוסט לקטגוריה אני משתמש בשאילתה דומה:

  g
    .V()
    .has("post", "slug", P.within("first", "second"))
    .as("posts")
    .V()
    .has("category", "name", "spam")
    .as("spam")
    .addE("belongs_to")
    .from("posts")
    .to("spam")
    .iterate()

או בעברית - תתחיל עם הפוסטים שה slug שלהם הוא first או second, תמצא קטגוריה בשם spam ותיצור קשת בין הדברים. כך אחבר את הפוסט השלישי לקטגוריה gremlin:

// connect hello-gremlin post to gremlin category
g
  .V()
  .has("post", "slug", P.within("hello-gremlin"))
  .as("posts")
  .V()
  .has("category", "name", "gremlin")
  .as("cat")
  .addE("belongs_to")
  .from("posts")
  .to("cat")
  .iterate()

ולחלק המעניין - איך מוצאים את כל הפוסטים שמשתמש מסוים צריך לקבל? פשוט הולכים עם הקשתות:

def subscriberPosts(g: GraphTraversalSource, email: String): util.List[util.Map[AnyRef, Nothing]] =
  g
    .V()
    .has("reader", "email", email)
    .out("subscribed_to")
    .in("belongs_to")
    .valueMap()
    .toList

מתחילים עם צמתים שיש להם תווית reader, יוצאים מהם דרך הקשת subscribed_to ואז ממשיכים עוד צעד בכיוון ההפוך מקשת שנכנסת אליהם בשם belongs_to. ושימו לב להבדל מול סייפר:

MATCH (r:Reader)-[:SUBSCRIBED_TO]-(c:Category)
MATCH (p)-[:BELONGS_TO]-(c)
RETURN p;

6. יצירת "קטגוריית על" ורישום אליה

אתגר אחרון הוא שאילתות רקורסיביות, שזה אחד הפיצ'רים המדליקים בבסיסי נתונים גרפיים. במקרה שלנו נוכל ליצור קטגוריית-על שהיא קטגוריה שיש בתוכה קטגוריות אחרות, ואז להמשיך וליצור קטגוריית-על-על וכן הלאה. הקוד הזה יוצר קטגוריה בשם all ומחבר את כל הקטגוריות אליה:

  g
    .V()
    .has("category", "name", "all")
    .as("all")
    .V()
    .hasLabel("category")
    .not(has("name", "all"))
    .as("oldCategories")
    .addE("belongs_to")
    .from("oldCategories")
    .to("all")
    .iterate()

והקוד המלהיב באמת הוא הפונקציה הבאה, שמחזירה את כל הפוסטים מכל הקטגוריות שמשתמש מנוי אליהן, ובצורה רקורסיבית כדי לטפל בקטגוריות-על:

def subscriberPostsRecursive(g: GraphTraversalSource, email: String) =
  g
    .V()
    .has("reader", "email", email)
    .out("subscribed_to")
    .repeat(in("belongs_to"))
    .until(hasLabel("post"))
    .valueMap()
    .toList

הלולאה הרקורסיבית מיוצגת על ידי הצעדים repeat ו until - אנחנו ממשיכים לסרוק את הגרף לאורך קשתות belongs_to עד שמגיעים לפוסט, וזה בסדר אם נעבור הרבה קטגוריות בדרך.

עדיין מוקדם להגיד מה דעתי על גרמלין. הוא בטוח הרבה יותר קשה ללמידה בהשוואה לסייפר, יש יותר מילות קוד וקשר הדוק בינו לבין שפת התכנות. השליטה והירידה לפרטים של גרמלין יכולות להרגיש כמו סירבול מיותר, אבל גם מעודדות הבנה ומפחיתות את תחושת ה"קסם". כשגרמלין לא עובד זה מרגיש שיש יותר אופציות לדבג ולהבין מה שבור שם.

אם יש לכם ניסיון עם אחת מהשפות ורוצים להוסיף ולשתף אשמח לשמוע בתגובות, ואם לא אל דאגה עם הזמן אמשיך לשתף רשמים על שתיהן.