ch
Feedback
ToCode

ToCode

前往频道在 Telegram

טיפים קצרים למתכנתים מאת ינון פרק

显示更多
1 419
订阅者
无数据24 小时
-17
+230
帖子存档
ToCode
1 419
# אוכל ישן במקרר רוב האנשים מצליחים לזהות די טוב קופסאות פלסטיק ששוכבות במקרר יותר מדי זמן עד שכבר אי אפשר לאכול את מה שיש בפנים. ורוב המאכלים גם שורדים יותר מכמה שעות בתוך אותה קופסת פלסטיק. הזמן שבאמצע הוא החלק המעניין: כל הפעמים שאני מסתכל על הקופסה כשהאוכל עדיין לא ממש רקוב ומתלבט אם לאכול, ואז בסוף יורד מזה כי הוא כבר יושב שם כמה ימים אבל עדיין שומר את הקופסה כי מי יודע ואולי פעם הבאה אני אהיה מספיק רעב בשביל לאכול אותו ... ויותר מדי מתכנתים שאני מכיר, כשמתחילים לכתוב קוד לא אידאלי נקשרים אליו כמו לאותה קופסה שאני מחזיק במקרר. במקום למחוק את מה שלא עובד באותו רגע הם מעדיפים להמשיך עם אותו קו מחשבה שלא הצליח ולבנות עוד מעקף קטן עם עוד פגיעה קטנה בביצועים. אנחנו יודעים שבעתיד זה יגיע למצב שכבר אי אפשר לתחזק אותו, אבל כרגע המצב לא כזה גרוע וצריך להתקדם עם הפיצ'ר. מה - למחוק קוד שאני כבר שבועיים עובד עליו? לחזור עכשיו לנקודת ההתחלה? איך מסבירים לבוס כזה דבר? ואיך מסבירים את זה לעצמי? אז ממשיכים עם הפיצ'ר כי ממשיכים ובסוף אחרי שנה יוצאים לריפקטורינג גדול כי כבר אי אפשר לתחזק את כדור הבוץ הענק שאנחנו קוראים לו קוד. הרבה יותר קל לזרוק את האוכל ואת הקוד כשמזהים את הסימנים הראשונים של הריקבון. אבסטרקציה גרועה לא תשתפר פיתאום כי יעבור עוד שבוע, להיפך, היא רק תהיה יותר גרועה כי עוד ועוד קוד במערכת יועתק ממנה וישתמש בה. השבועיים אחורה שאתם חוזרים? זה הרבה יותר קצר משבועיים (כי אתם כבר יודעים מה צריך לבנות ומה הדרך הנכונה לבנות אותו), והחיסכון בזמן בשנה הקרובה יהיה הרבה יותר גדול.

ToCode
1 419
נסו למצוא איך לשנות את סדר המיון כדי למצוא את המשתמש עם הכי פחות משימות פתוחות, ואז למצוא את המשתמש עם הכי הרבה משימות סגורות. ## יצירת ענן תגיות מכל התגיות בכל המשימות הדוגמה האחרונה לפוסט והחביבה עליי ביותר היא היכולת לקחת את כל הערכים משדה שנשמר בתור מערך וליצור מהם קבוצה אחת גדולה שמכילה את כולם. זה עוזר כשיש לנו למשל רשימת תגיות ואנחנו רוצים לייצר ענן תגיות או פשוט להציג בצד של המסך רשימה של כל התגיות האפשריות. ה Pipeline הראשון לוקח את שדה tags ומוציא את כל הערכים החוצה מהמערך כדי שנוכל לשחק איתם באמצעות שלב unwind, אחרי זה הוא מארגן את כולם בקבוצות אבל שימו לב למזהה שבחרתי - פשוט null. מזהה זה הוא זהה לכל המסמכים ולכן נקבל רק מסמך אחד בתוצאה. השדה tags של אותו מסמך בתוצאה מכיל את התוצאה של פקודת $addToSet, שלוקחת מכל מסמך את שדה tags שלו ומוסיפה אותו לקבוצה:
db.collection.aggregate([
  {
    "$unwind": "$tags"
  },
  {
    "$group": {
      _id: null,
      tags: {
        "$addToSet": "$tags"
      }
    }
  },
  
])
אתם מוזמנים לשחק עם השאילתה כאן: https://mongoplayground.net/p/2sxnLVSARWl נסו להוריד את ה $unwind ותראו אם אתם מצליחים לנחש מה יקרה. אפשרות שניה ויותר מועילה היא להוסיף לכל תגית גם את מספר המסמכים שמתויגים בתגית זו. בשביל זה אני משתמש ב tags בתור המזהה כדי שכל המסמכים שיש להם את אותה תגית יהיו באותה קבוצה, ומוסיף להם שדה בשם count שמכיל את מספר המסמכים בקבוצה:
db.collection.aggregate([
  {
    "$unwind": "$tags"
  },
  {
    "$group": {
      _id: "$tags",
      "count": {
        "$count": {}
      }
    }
  },
])
צריך להגיד: התחביר של Aggregation Pipeline הוא לא אהבה ממבט ראשון. יש המון פקודות ולמרות זאת לפעמים אתם לא מוצאים בדיוק את הפקודה שאתם צריכים, ולפעמים צריך למצוא דרכים יצירתיות כדי לגרום ל Pipeline לבצע בדיוק את החישוב שרוצים. ועדיין, אחרי קצת אימונים אתם תראו ש Pipelines מצליחים לתת פיתרון לא פחות טוב (ואולי לפעמים קצת יותר טוב) מ SQL.

ToCode
1 419
אחרי שהשלב הראשון מסתיים ויש לנו את הקבוצות מונגו ממשיך לשלב השני שכולל פקודת $match. פקודה זו מסננת את המסמכים (כלומר את הקבוצות) לפי הפרמטר שקיבלה. ב Pipeline הדוגמה הפרמטר הזה הגדיר שהשדה totalSaleAmount יהיה גדול או שווה ל 100, ולכן רק הקבוצות שמתאימות יופיעו בתוצאה. סך הכל תוצאת ה Pipeline תהיה:
{ "_id" : "abc", "totalSaleAmount" : NumberDecimal("170") }
{ "_id" : "xyz", "totalSaleAmount" : NumberDecimal("150") }
{ "_id" : "def", "totalSaleAmount" : NumberDecimal("112.5") }
נמשיך לעוד כמה דוגמאות והפעם על אוסף נתונים של משימות. ## יצירת הנתונים אלה הנתונים לדוגמה ב Collection שלי. יש פה משימות לביצוע, לכל משימה יש סטטוס (בשדה done) האם בוצעה או לא, משתמש שהמשימה בבעלותו, טקסט של המשימה ורשימת תגיות:
[
  {
    "key": 1,
    text: "do it!",
    done: false,
    owner: "dan",
    tags: [
      "home"
    ],
  },
  {
    "key": 2,
    text: "do something else",
    done: false,
    owner: "mike",
    tags: [
      "home",
      "work"
    ],
  },
  {
    key: 3,
    text: "fix stuff",
    done: true,
    owner: "mike",
    tags: [
      "home",
      "garden"
    ],
  },
  {
    key: 4,
    text: "eat something",
    done: false,
    owner: "mike",
    tags: [],
  }
]
## ספירת משימות פתוחות של כל המשתמשים משימה ראשונה היא למצוא כמה משימות פתוחות יש סך הכל לכל שמשתמשים יחד. שתי הפקודות שיעזרו לנו כאן הן $match ו $count: הראשונה תסנן רק את המשימות שעדיין לא בוצעו, והשניה תספור כמה משימות כאלה היו. כך נראה ה Pipeline:
db.collection.aggregate([
  {
    $match: {
      done: false
    }
  },
  {
    $count: "totalOpenTasks",
    
  }
])
ואם אתם רוצים לשחק עם הקוד, להריץ דרך הדפדפן או לשנות קצת ולראות את האפקט הכנתי דף ב Mongo Playground שמתאים והוא מחכה לכם בקישור כאן: https://mongoplayground.net/p/QwZCzp4etfd ## ספירת משימות פתוחות של משתמש מסוים ומה אם נרצה להתאים את השאילתה רק למשתמש מסוים? הכל קל בעולם, רק צריך לשנות את התנאי ב $match. כך יראה הקוד:
db.collection.aggregate([
  {
    $match: {
      done: false,
      owner: "dan",
      
    }
  },
  {
    $count: "dansOpenTasks",
  }
])
וכאן יש את ה Mongo Playground המתאים לו: https://mongoplayground.net/p/s08WtYHrjbp ## יצירת דוח: כמה משימות פתוחות יש לכל משתמש בואו נסבך קצת את העניינים ונשאל כמה משימות פתוחות יש לכל אחד מהמשתמשים במערכת. אם נדמיין את ה Pipeline אז בשביל משימה כזאת צריך להחליף את הפקודה השניה, במקום לספור כמה מסמכים קיבלנו, נרצה לקבץ יחד לקבוצות את המשימות לפי שדה owner (כלומר לפי המשתמש שהמשימה שייכת לו), ולספור כמה מסמכים יש בכל קבוצה כזאת. התחביר קצת מסורבל והוא נראה כך:
db.collection.aggregate([
  {
    $match: {
      done: false,
    }
  },
  {
    $group: {
      "_id": "$owner",
      "openTasks": {
        $count: {}
      }
    }
  }
])
ופה אפשר לשחק עם השאילתה: https://mongoplayground.net/p/7FhgRMgHFvE ## זיהוי המשתמש עם הכי הרבה משימות פתוחות אחרי שגילינו כמה משימות פתוחות יש לכל משתמש אפשר להמשיך ולחפש את המשתמש שיש לו הכי הרבה משימות פתוחות. זו משימה שעשויה לבלבל כי אין ב Aggregation Pipeline פעולה של Max שמחזירה את המסמך עם הערך המקסימלי. במקום זה אנחנו צריכים להשתמש במיון וב limit. זה קצת מבלבל כי sort נשמע כמו משהו שלוקח יותר זמן מחישוב ערך מקסימלי, אבל מונגו מספיק חכם בשביל לזהות את התבנית הזאת ולא למיין באמת את כל המסמכים. הנה הקוד:
db.collection.aggregate([
  {
    $match: {
      done: false,
    }
  },
  {
    $group: {
      "_id": "$owner",
      "openTasks": {
        $count: {}
      }
    }
  },
  {
    "$sort": {
      "openTasks": -1
    }
  },
  {
    $limit: 1,
  }
])
שבאמת ייתן את התוצאה:
[
  {
    "_id": "mike",
    "openTasks": 2
  }
]
ואתם יכולים לשחק איתו בקישור הזה: https://mongoplayground.net/p/fUtBp28Cakb.

ToCode
1 419
# מדריך קצר לאגרגציות ב MongoDB אחד הפיצ'רים האהובים עליי ב MongoDB הוא היכולת לחבר יחד מסמכים לקבלת תוצאה באמצעות מיני שפת תכנות שלהם שנקראת Aggregation Pipeline. אגיד לכם את האמת אהבה ממבט ראשון לא היתה שם, אבל אחרי שלמדתי להשתמש בפיצ'ר ולהתמצא בתיעוד שלהם החיים שלי עם מונגו השתפרו משמעותית. בואו נראה איך זה עובד. ## מהו Aggregation Pipeline דמיינו שפת תכנות רק שהתוכנית שאתם כותבים בה רצה על קלט מאוד ספציפי: רשימת מסמכים מ Mongo שמתאימים לקריטריון מסוים. כל פקודה בתוכנית עושה משהו עם הקלט הזה, למשל תהיה פקודה שתוסיף שדה לכל מסמך, פקודה אחרת שמסננת החוצה חלק מהמסמכים, פקודה שלישית שמשאירה רק שדות מסוימים של המסמכים ופקודה רביעית שמאחדת מסמכים שונים לקבוצה. הפקודות האלה נקראות Aggregation Stages ואפשר למצוא רשימה שלהן ופירוט על כל אחת בדף התיעוד כאן: https://docs.mongodb.com/manual/reference/operator/aggregation-pipeline/. וכן כל Stage כזה מקבל פרמטרים שאומרים לו איך בדיוק לעשות את הדבר שלו, למשל הפקודה $count מחליפה את כל המסמכים במסמך עם שדה בודד שהערך שלו הוא כמה מסמכים עברו דרכה. היא תקבל פרמטר שיגיד לה מה שם השדה שיהיה במסמך התוצאה. הפקודה $match מסננת את המסמכים שעוברים דרכה ותתן רק למסמכים מסוימים לעבור. היא תקבל פרמטר שאומר לאיזה מסמכים מותר להמשיך. את ה Pipeline אנחנו כותבים בתור רצף של פקודות בתוך מערך, וכל פקודה היא אוביקט JavaScript. זה תחביר שלוקח זמן להתרגל אליו. הנה דוגמה ל Pipeline:
  [
    // First Stage
    {
      $group :
        {
          _id : "$item",
          totalSaleAmount: { $sum: { $multiply: [ "$price", "$quantity" ] } }
        }
     },
     // Second Stage
     {
       $match: { "totalSaleAmount": { $gte: 100 } }
     }
   ]
יש פה שתי פקודות: הפקודה הראשונה היא פקודת $group והשניה פקודת $match. עכשיו בואו נדמיין כמה מסמכי Mongo שהולכים לעבור דרך ה Pipeline הזה. אולי הם נראים כך:
[
  { "_id" : 1, "item" : "abc", "price" : NumberDecimal("10"), "quantity" : NumberInt("2"), "date" : ISODate("2014-03-01T08:00:00Z") },
  { "_id" : 2, "item" : "jkl", "price" : NumberDecimal("20"), "quantity" : NumberInt("1"), "date" : ISODate("2014-03-01T09:00:00Z") },
  { "_id" : 3, "item" : "xyz", "price" : NumberDecimal("5"), "quantity" : NumberInt( "10"), "date" : ISODate("2014-03-15T09:00:00Z") },
  { "_id" : 4, "item" : "xyz", "price" : NumberDecimal("5"), "quantity" :  NumberInt("20") , "date" : ISODate("2014-04-04T11:21:39.736Z") },
  { "_id" : 5, "item" : "abc", "price" : NumberDecimal("10"), "quantity" : NumberInt("10") , "date" : ISODate("2014-04-04T21:23:13.331Z") },
  { "_id" : 6, "item" : "def", "price" : NumberDecimal("7.5"), "quantity": NumberInt("5" ) , "date" : ISODate("2015-06-04T05:08:13Z") },
  { "_id" : 7, "item" : "def", "price" : NumberDecimal("7.5"), "quantity": NumberInt("10") , "date" : ISODate("2015-09-10T08:43:00Z") },
  { "_id" : 8, "item" : "abc", "price" : NumberDecimal("10"), "quantity" : NumberInt("5" ) , "date" : ISODate("2016-02-06T20:20:13Z") },
]
הפקודה הראשונה היא פקודת $group שמקבלת בתור פרמטר אוביקט. האוביקט מתאר איך יראו הקבוצות שאנחנו בונים. הוא מכיל שדה בשם _id עם הערך $item, שזה אומר בעברית שכל קבוצה תהיה מזוהה לפי הערך של שדה item של כל מסמך. אז המסמך הראשון מגיע ו Mongo מסתכל על שדה item שלו ורואה את הערך abc, מבין שאין עדיין מסמך כזה בתוצאה ונותן לו לעבור. אחרי זה עוברים עוד כמה מסמכים עד שמגיעים למסמך החמישי ששוב מכיל בשדה item את אותו ערך abc. הפעם מונגו מזהה שהוא כבר ראה את הערך הזה ומוסיף את המסמך החמישי לאותה קבוצה יחד עם המסמך הראשון. אנחנו יודעים שכל המסמכים באותה קבוצה מכילים את אותו ערך לשדה _id שלהם, כי ככה בנינו את הקבוצות, אבל מה לגבי השדות האחרים? אז אנחנו רואים שהפרמטר ש $group קיבל היה אוביקט ויש שם באמת מפתח נוסף בשם totalSaleAmount. מפתח נוסף מגדיר שיהיה שדה נוסף באוביקט התוצאה, והערך של השדה יהיה החישוב שאנחנו רושמים באוביקט - כלומר סכום של מכפלות המחיר בכמות. בכל מסמך שמגיע מונגו ייקח את המחיר, יכפיל אותו בכמות ויוסיף את זה לערך ששמור לו עבור הקבוצה.

ToCode
1 419
# שאלות מראיונות עבודה: כיווץ מחרוזות נתקלתי במקרה בשאלה הזאת שהזכירה לי כמה חשוב להכיר ביטויים רגולאריים, ולו רק בשביל להרגיז את המראיינים שלכם ששואלים שאלות טיפשיות בראיונות עבודה. המשחק הוא כזה - בהינתן מחרוזת עליכם "לכווץ" את המחרוזת באמצעות החלפת כל רצף של אותיות באות אחת מהרצף ואחריה מספר האותיות ברצף. לדוגמה אם המחרוזת המקורית היא:
aaaabbccc
אז אנחנו מחליפים את רצף ה a-ים ב a4, את רצף ה b-ים ב b2 ואת רצף ה c-ים ב c3 ומקבלים:
a4b2c3
אם התוצאה ארוכה יותר מהמחרוזת המקורית יש להחזיר את המחרוזת המקורית, ואם התוצאה קצרה יותר יש להחזיר את התוצאה המכווצת. ## פיתרון כל פעם שאני שומע "רצף של אותיות" בשאלה מהסוג הזה מיד אני חושב על ביטויים רגולאריים. בביטוי רגולארי אני יכול לסגור קטע מסוים שמצאתי בסוגריים ואז להתיחס אליו שוב באמצעות \1. לכן רצף של אותיות הוא בסך הכל הצירוף (\w)\1+ או אם הרצף יכול להיות גם באורך 1 זה יהיה (\w)\1*. בשביל לכווץ מחרוזת מספיק למצוא רצף כזה ולהחליף אותו באות הראשונה ממנו ואחריה אורך הרצף. הקוד בשורה אחת של פייתון ועם פונקציית בדיקה לידו יראה כך:
import re

def compress(original):
    compressed =  re.sub(
            r'(\w)\1*',
            lambda m: m[0][0] + str(len(m[0])),
            original)
    return compressed if len(compressed) < len(original) else original

def test_compress():
    assert compress("a")       == "a"
    assert compress("aaa")     == "a3"
    assert compress("aaabbb")  == "a3b3"
    assert compress("aaabccc") == "a3b1c3"

ToCode
1 419
# התור בפוטו צפון כל שנה ביום האחרון לפני שחוזרים ללימודים אפשר לראות תור של עשרות הורים מיוזעים עומדים ביהודה מכבי מחוץ לפוטו צפון - חנות פיתוח התמונות היחידה בסביבה. כולם קיבלו יום קודם את רשימת הציוד לגן שכוללת תמונה של הפשוש או הפשושית בשביל לתלות על הקיר, אצל כולם התמונות בטלפון ולא באלבומים וכולם מחכים בשמש ומקווים שלא יסגרו לפני שיגיע תורם. התור לתמונה הוא אחד התורים שהכי קל לחסוך. צריך רק לתכנן קדימה וללכת לפתח את התמונה לפני אסיפת ההורים ולפני שמחלקים את רשימת הציוד. זה לא Over Engineering להסתכל קדימה לדבר שאנחנו יודעים שיקרה. זה לא Over Engineering לעשות שיעורי בית, לבדוק מה קרה בשנה שעברה או מה קורה בגנים אחרים. זה לא Over Engineering וזה לא YAGNI אפילו אם שלחת תמונה לפיתוח ובסוף החליטו בגן לוותר על התמונות השנה. תכנון קדימה הוא תמיד משחק של סיכונים וסיכויים והוא המשחק שאנשי מקצוע משחקים כל יום. בואו עם נתונים ותסבירו מה אתם חושבים שהולך לקרות ואיך התכנון שלכם יעזור כשזה יקרה. זה הבסיס ל Engineering מדויק.

ToCode
1 419
# קריטריון האם הוא פותר בעיות מהר? האם הוא רואה את כל מקרי הקצה הרלוונטיים? האם הפיתרונות שלה תמיד הכי יעילים? האם התקשורת איתה טובה? האם הוא יתאמץ לפתור בעיה בכוחות עצמו? האם הוא ידע לבקש עזרה כשצריך? האם היא תסכים ללמוד טכנולוגיה חדשה כשהפרויקט דורש את זה? האם היא לומדת מהר? ראיונות עבודה הולכים לפי תבנית. זה הגיוני כי האנשים שמעבירים את הראיונות יודעים לבחון מועמדים כמו שתמיד בחנו וכמו שבחנו אותם: אנחנו נותנים שאלות מקצועיות ורואים איך הבן אדם מתפקד בתוך השאלות האלה. היום עם כל העבודה מרחוק אני מוצא המון ראיונות עבודה מוקלטים ביוטיוב שמראים את אותו שטאנץ שוב ושוב. המחשבה "אני אדע שזה זה כשאראה את זה" הפכה לקו המוביל, וזה לפעמים מרגיש מצפיה בראיונות האלה שכולם מחפשים את אותו דבר בדיוק. בכל גיוס יש Trade Offs וזאת תמיד התלבטות בין ההיא שהציגה את הפיתרון היעיל ביותר להוא שהצליח לתקשר יותר טוב את תהליך המחשבה שלו. אם גם אתם תקועים ולא מצליחים למצוא את המועמד המושלם, אולי שווה לעצור רגע ולחשוב מחדש על הקריטריונים. שינוי קטן בקריטריונים יכול לפתוח לכם עולם חדש של הזדמנויות, אולי טובות בהרבה מאלה שבדקתם עד עכשיו.

ToCode
1 419
החלק האחרון במסלול שלנו נקרא ה Resolver או ה rootValue. זה המימוש של הסכימה, כלומר אוביקט ה JavaScript שמחבר בין הסכימה לשכבת הנתונים. כך הוא נראה במנהל המשימות שלי:
const db = require('../dl/tasks');

module.exports = {
  tasks: (args) => {
    return db.getTasks();
  },
  task: (args) => {
    const { id } = args;
    return db.getTask(id);
  },
  toggleTask: async (args) => {
    const { id } = args;
    await db.toggleTask(id);
    return await db.getTask(id);
  }
};
שלושת הפונקציות מתאימות לשלושת השדות באוביקטים המיוחדים Query ו Mutation. הסכימה הגדירה ש Task הוא עלה (leaf) ושיש לו את השדות title, description ו done, ולכן GraphQL מצפה לקבל בחזרה מהפונקציות אוביקט או אוביקטים עם שדות אלה. אגב הוא מוכן להתפשר ולקבל Promise שיחזיר אוביקט עם שדות אלה, וזה מה שאנחנו עושים בדוגמה כאן. ## איך מריצים ושאילתות לדוגמה בפוסט הדבקתי רק את הקוד שנראה לי מעניין בשביל להבין את המנגנון. אתם מוזמנים להסתכל בגיטהאב כדי לראות את הפרויקט המלא ואפילו להריץ אותו. אם תורידו אליכם את הפרויקט אז אחרי הרצת npm install תוכלו להפעיל אצלכם את המערכת בהאזנה על פורט 5000 עם הפקודה:
$ PORT=5000 node bin/www
אחרי ההפעלה אפשר לגלוש ל http://localhost:5000/graphql ולקבל ממשק בדיקה של ה API. בממשק אני כותב שאילתות בצד שמאל ומקבל את התשובות בצד ימין. לדוגמה השאילתה:
query {
  tasks { 
    title
    description
    done
  }
}
תחזיר את התשובה:
{
  "data": {
    "tasks": [
      {
        "title": "task1",
        "description": "write some code",
        "done": false
      },
      {
        "title": "task2",
        "description": "test it",
        "done": false
      },
      {
        "title": "task3",
        "description": "deploy",
        "done": false
      }
    ]
  }
}
בשביל לסיים משימה אני יכול להשתמש בפונקציה toggleTask שכתבתי עם השאילתה הבאה:
mutation {
  toggleTask(id: 2) { 
    title
    done
  }
}
ולקבל בחזרה:
{
  "data": {
    "toggleTask": {
      "title": "task2",
      "done": true
    }
  }
}
שזה מצב העניינים באוביקט אחרי השינוי. טיפ למטיבי לכת: ואחרי שבניתם את ה API, תוכלו לחזור לפוסט חיבור אפליקציית ריאקט ל GraphQL שם אני מסביר איך לחבר קוד ריאקט לשרת GraphQL בעזרת ספריית Relay.

ToCode
1 419
# בואו נכתוב שרת GraphQL ב Node.JS כדי לראות איך זה עובד גרף קיו אל הוא הדבר הגדול הבא מאז שהמציאו את הלחם החתוך. הוא פותר את כל הבעיות של REST ואז עוד קצת וכבר כתבתי עליו מספר פוסטים בעבר. אם אתם לא מכירים את GraphQL בכלל שווה להתחיל במבוא שכתבתי כאן: שלום GraphQL. בפוסט היום אני רוצה לכתוב שרת GraphQL ב Node.JS ו Express כדי לראות איך זה עובד. הפרויקט הוא API למערכת ניהול משימות שמאפשר לנו לראות את המשימות הפתוחות במערכת ולעדכן סטטוס "בוצע" של כל משימה. העליתי את כל הקוד בפוסט לפרויקט דוגמה בגיטהאב בקישור: https://github.com/ynonp/express-graphql-demo ## שכבת הנתונים מאפיין חשוב של GraphQL הוא ההפרדה בין שכבת הנתונים לשכבת התקשורת. יש לנו בצד אחד את הקוד שיודע לשלוף מבסיס הנתונים ולעדכן שם את המידע, וקוד זה יכול להיות כתוב בכל ספריה שתרצו לדוגמה Mongo, Mongoose, Sequelize או כמו שאני אכתוב בדוגמה היום ב knex.js. אחריו יש לנו שכבת חיבור שנקראת Resolver או Root Value. שכבת החיבור אחראית על מיפוי הערכים מהשאילתה שמשתמש שלח לפונקציות הגישה לבסיס הנתונים. ובסוף יש לנו את הסכימה שמתארת החוצה איזה מידע אנחנו יכולים לספק. ההפרדה בין השכבות תאפשר לנו בעתיד לשנות בקלות את שיכבת הנתונים שלנו ואפילו להחליף בסיס נתונים לחלוטין ועדיין להשאיר את הממשק החוצה ללקוחות ללא שינוי. מסיבה זו אנחנו מאוד אוהבים להשתמש ב GraphQL בארכיטקטורת Micro Services כדי לבנות את התקשורת בין הסרביסים השונים בצורה יציבה. נתחיל בבניית שכבת הנתונים ובפרויקט הדוגמה בחרתי ב knex.js. הקוד שאחראי על השאילתות הוא בסך הכל קובץ קצר של פחות מ 20 שורות:
// dl/tasks.js
const knex = require('../db/knex');

exports.getTasks = function getTasks() {
  return knex.from('tasks').select('*');
}

exports.getTask = function getTask(id) {
  return knex.from('tasks').where({ id: id }).first('*');
}

exports.getTasksBy = function getTasksBy(attributes) {
  return knex.from('tasks').where(attributes).select('*');
}

exports.toggleTask = function toggleTask(id) {
  return knex('tasks')
  .where({ id })
  .update({ done: knex.raw("CASE WHEN done = 0 THEN 1 ELSE 0 END") });
}
המימוש של הפונקציות הספציפיות פחות חשוב בשביל הדוגמה שלנו. שימוש בבסיס נתונים אחר או בספריית גישה אחרת לבסיס הנתונים היו מייצרים מימושים אחרים, אבל זה בסדר גמור. כל עוד שמות הפונקציות והפרמטרים זהים כל שאר הקוד לא ישתנה. ## הסכימה נעבור לצד השני של העולם בשביל לדבר על הסכימה. סכימה ב GraphQL היא פשוט תיאור של האוביקטים, המאפיינים שלהם ואם יש להם אוביקטים מקוננים אז גם תיאור של אותם שדות פנימיים. הסכימה מתארת גרף ויכולה להתאים למשפטים כמו: 1. לכל משימה יש שדה "סטטוס" מסוג בוליאני ושדה "תיאור" מסוג טקסט 2. לכל משתמש במערכת יש שדה "שם" עם השם שלו 3. לכל משתמש במערכת יש 1 או יותר משימות 4. לכל משימה יש 1 או יותר תגיות חישבו על הסכימה בדיוק כמו על סכימה של בסיס נתונים, רק בלי לדבר על בסיס הנתונים שמאחוריה. ל GraphQL יש שפה מעניינת לכתיבת סכימות ואפשר לקרוא עליה כאן. הנה הסכימה המאוד פשוטה שאני כתבתי לתיאור המשימות והדברים שאנחנו יכולים לעשות עם אותן משימות:
  type Query {
    tasks: [Task],
    task(id: Int!): Task
  }

  type Task {
    title: String,
    description: String,
    done: Boolean,
  }

  type Mutation {
    toggleTask(id: Int!): Task
  }
הסכימה מורכבת מ-3 טיפוסים: 1. הטיפוס המיוחד Query (הוא מיוחד בגלל השם, Query זה שם שמור ב GraphQL) כולל שדה בשם tasks שצריך להחזיר רשימת משימות, ושדה בשם task שמקבל id מסוג מספר ומחזיר משימה בודדת. וכשאני כותב "שדה" תחשבו בלב "פונקציה", כי זה מה שבאמת ישב בשדות האלה. 2. הטיפוס המיוחד Mutation (שוב מיוחד בגלל השם, Mutation הוא שם שמור ב GraphQL) כולל שדה יחיד בשם toggleTask. שדה כזכור הוא פונקציה והפונקציה כאן מקבלת מזהה ומחזירה משימה. 3. הטיפוס Task מתאר מהי משימה. הוא מספר לנו שלמשימה יש שלושה שדות: title, description ו done ואת הסוגים שלהם. בשביל לבנות אוביקט סכימה של GraphQL אני מעביר את התיאור הטקסטואלי הזה לפונקציה בשם buildSchema. ## ה Resolver

ToCode
1 419
/daily

ToCode
1 419
            stream: Stream[Message, Empty]
            ) -> None:
        request = await stream.recv_message()
        for q in self.chatters.values():
            q.put_nowait(request.text)
        await stream.send_message(Empty())


async def main(*, host: str = '127.0.0.1', port: int = 50051) -> None:
    server = Server([ChatRoom()])
    with graceful_exit([server]):
        await server.start(host, port)
        print(f'Serving on {host}:{port}')
        await server.wait_closed()


if __name__ == '__main__':
    asyncio.run(main())
בצד הלקוח האתגר הוא לבצע שתי משימות במקביל: אני גם צריך להתחבר לשרת ולהדפיס כל הודעה שחוזרת מ Subscribe, וגם צריך להקשיב לשורות שמשתמש מקליד ב stdin וכל פעם שמשתמש מסיים לכתוב שורה לשלוח אותה לשרת. הקסם שעושה את שני הדברים במקביל הוא הפונקציה wait:
async def main() -> None:
    await asyncio.wait([
        asyncio.create_task(chat()),
        asyncio.create_task(read_messages_and_send()),
        ])
זו פונקציה שמקבלת מספר פונקציות אסינכרוניות ומריצה את כולן במקביל. אצלי הפונקציה chat היא זו שמדפיסה את ההודעות מהשרת והפונקציה read_messages_and_send קוראת הודעות מהמקלדת ושולחת אותן לשרת. קוד התוכנית המלא בקובץ client.py נראה כך:
# File: chat_client.py

import asyncio
import sys

from grpclib.client import Channel

from chat_grpc import ChatRoomStub
from chat_pb2 import Message, Empty

async def connect_stdin_stdout():
    loop = asyncio.get_event_loop()
    reader = asyncio.StreamReader()
    protocol = asyncio.StreamReaderProtocol(reader)
    await loop.connect_read_pipe(lambda: protocol, sys.stdin)
    w_transport, w_protocol = await loop.connect_write_pipe(asyncio.streams.FlowControlMixin, sys.stdout)
    writer = asyncio.StreamWriter(w_transport, w_protocol, reader, loop)
    return reader, writer


async def chat() -> None:
    async with Channel('127.0.0.1', '50051') as channel:
        stub = ChatRoomStub(channel)
        
        async with stub.Subscribe.open() as stream:
            await stream.send_message(Empty(), end=True)
            async for reply in stream:
                print(reply)

async def read_messages_and_send() -> None:
    reader, writer = await connect_stdin_stdout()
    async with Channel('127.0.0.1', '50051') as channel:
        stub = ChatRoomStub(channel)
        while True:
            res = await reader.read(100)
            if not res:
                break
            await stub.SendMessage(Message(text=res))


async def main() -> None:
    await asyncio.wait([
        asyncio.create_task(chat()),
        asyncio.create_task(read_messages_and_send()),
        ])



if __name__ == '__main__':
    asyncio.run(main())
מוזמנים לנסות להריץ אצלכם בחלון אחד את השרת ובמספר חלונות אחרים את תוכנית הלקוח ולראות איך החלונות מדברים אחד עם השני. ## מה אהבתי סך הכל gRPC היא ספריה נחמדה לחיבור בין מספר סרביסים. העובדה שיש לה תמיכה באינסוף שפות תכנות אומרת שיהיה לנו קל לחבר קוד שכתוב בשפות שונות, וקובץ ה proto נותן לי דרך סטנדרטית להגדיר ממשק עבור הסרביס שלי. ## מה לא אהבתי למרות שאנחנו ב 2021, הספריה מרגישה קצת כמו חזרה בזמן. בעיקר היתה חסרה לי תמיכה מובנית בניהול הרשאות (כן ראיתי שיש משהו מובנה של גוגל ואפשרות להוסיף פלאגינים. זה לא נראה נוח), וכמו ב REST, גם כאן הממשק מאוד קשיח. כל שינוי קטן במידע מחייב שינוי של הפרוטוקול ותיאום בין מספר סרביסים. הבחירה ב gRPC היא בחירה בביצועים על חשבון גמישות. כשאתם בסיטואציה שהביצועים קריטיים וצריכים להעביר הרבה מידע ומהר יכול להיות ש gRPC יהיה פיתרון טוב. ברוב הסיטואציות GraphQL ייתן תוצאה יותר טובה בגלל נוחות העבודה והגמישות של הפרוטוקול.

ToCode
1 419
        await stream.send_message(HelloReply(message=message))


async def main(*, host='127.0.0.1', port=50051):
    server = Server([Greeter()])
    # Note: graceful_exit isn't supported in Windows
    with graceful_exit([server]):
        await server.start(host, port)
        print(f'Serving on {host}:{port}')
        await server.wait_closed()


if __name__ == '__main__':
    asyncio.run(main())
בצד הלקוח אני מושך דבר שנקרא Stub מתוך אותו קובץ grpc שנוצר אוטומטית, ובעזרתו מפעיל פונקציה מרוחקת. הפעם הקוד נראה הרבה יותר טבעי לפייתון וכבר לא מרגישים את העבודה המבוזרת:
reply = await greeter.SayHello(HelloRequest(text=line.strip()))
וזה קוד צד הלקוח המלא:
# File: client.py

import asyncio

from grpclib.client import Channel

# generated by protoc
from sayhello_pb2 import HelloRequest, HelloReply
from sayhello_grpc import GreeterStub
import sys

async def main():
    async with Channel('127.0.0.1', 50051) as channel:
        greeter = GreeterStub(channel)

        for line in sys.stdin:
            reply = await greeter.SayHello(HelloRequest(text=line.strip()))
            print(reply.message)


if __name__ == '__main__':
    asyncio.run(main())
אני שמרתי את קוד צד השרת בקובץ server.py ואת קוד צד הלקוח בקובץ client.py ואז אפשר משני חלונות שונים להפעיל את השרת והלקוח, לכתוב משהו במסוף בצד הלקוח ולראות את ההדפסה שחוזרת מהשרת. ## תוכנית שניה: שרת Chat נמשיך למשהו קצת יותר מתוחכם והוא תוכנית צ'ט. בניגוד לשרת Echo שבסך הכל שולח לך בחזרה את מה שכתבת, תוכנית צ'ט צריכה להכיר את כל הלקוחות המחוברים וכל פעם שאחד מהם שולח הודעה היא צריכה להודיע לכל הלקוחות האחרים. יש פה שני אתגרים חדשים גם בצד השרת וגם בצד הלקוח: 1. בצד הלקוח אני כבר לא יודע מתי אני אקבל את ההודעה הבאה. אני לא יכול לסמוך על זה שהודעה תגיע רק בתור תשובה להודעה שאני שולח, כי יש אנשים אחרים בחדר ששולחים ומקבלים הודעות כל הזמן. 2. בצד השרת אני כבר לא יכול לשלוח הודעה חזרה רק למי שהרגע פנה אליי, וצריך לשמור את כל הלקוחות המחוברים בזיכרון כדי שכל פעם שאחד שולח הודעה אוכל להחזיר את ההודעה גם לכל האחרים. נתחיל עם הפרוטוקול. בגלל שזה בסך הכל משחק עם gRPC מספיקות לי שתי פונקציות:
syntax = "proto3";

service ChatRoom {
  rpc Subscribe (Empty) returns (stream Message) {}
  rpc SendMessage (Message) returns (Empty) {}
}

message Message {
  string text = 1;
}

message Empty {}
לקוח יקרא לפונקציה Subscribe והתשובה שהוא יקבל תהיה stream של הודעות. ב gRPC, המילה stream אומרת שאני יכול לשלוח יותר מתשובה אחת. במקביל לקוחות אחרים או אותו לקוח יכולים לקרוא לפונקציה SendMessage כדי לשלוח הודעות. כשמישהו קורא ל SendMessage אז כל אלה שעכשיו תקועים על Subscribe מקבלים את ההודעה החדשה, בלי לסיים את התקשורת. נמשיך עם קוד צד השרת. בצד השרת אני שומר במחלקה מבנה נתונים של מילון שמחזיק את כל הלקוחות המחוברים. המפתח הוא כתובת הלקוח והערך הוא תור הודעות שיש לי עבור אותו לקוח. כל פעם שלקוח חדש מתחבר אני יוצר עבורו שורה חדשה במילון ונכנס ללולאה שבה כל פעם שיש הודעה חדשה אני שולח אותה. זה התפקיד של הפונקציה Subscribe. הפונקציה השניה, SendMessage פשוט מוסיפה את ההודעה לתור של כל הלקוחות המחוברים. קוד צד השרת המלא נראה כך:
# File: chat_server.py

import asyncio

from grpclib.server import Server, Stream
from grpclib.utils import graceful_exit

from chat_grpc import ChatRoomBase
from chat_pb2 import Message, Empty

class ChatRoom(ChatRoomBase):
    def __init__(self):
        self.chatters = {}

    async def Subscribe(
            self,
            stream: Stream[Empty, Message]
            ) -> None:

        peer = stream.peer.addr()
        try:
            self.chatters[peer] = asyncio.Queue()
            request = await stream.recv_message()
            
            while True:
                message = await self.chatters[peer].get()
                await stream.send_message(Message(text=message))
                self.chatters[peer].task_done()
        finally:
            print(f"Bye bye {peer}")
            del(self.chatters[peer])

    async def SendMessage(
            self,