יישום Dependency Injection ב־Clean Architecture

בפוסטים הקודמים סקרנו את עקרונות הארכיטקטורה הנקייה (Clean Architecture) והשוונו אותה לגישות אחרות, הפעם נצלול לצד הפרקטי. נדבר בגובה העיניים על אחד המרכיבים הקריטיים ביישום Clean Architecture – Dependency Injection (הזרקת תלויות). נבין למה זה כל כך חשוב, ונראה דוגמאות קוד (Node.js + TypeScript עם Express).

רענון קצר: מהי ארכיטקטורה נקייה ולמה זה חשוב?

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

  • Entities (יישויות) – מודלים עסקיים בסיסיים (למשל: משתמש, משימה, הזמנה) בלי תלות במסד נתונים מסוג ספציפי או ספריות.
  • Use Cases (מקרי שימוש) – הלוגיקה העסקית עצמה, המימוש של מה שהמערכת עושה (למשל: "רישום משתמש", "יצירת הזמנה חדשה"). שכבה זו מגדירה ממשקים שאחרים יממשו. שימו לב שחלק גורסים שכל מקרה שימוש צריך להיות בקובץ משלו, וחלק אחר גורס , ולדעתי בצדק, שהחלוקה צריכה להיות לפי הלוגיקה העסקית עצמה.
  • Interface Adapters (מתאמים) – שכבה שמגשרת בין הליבה העסקית לבין העולם החיצון. כאן נמצאים ממירי נתונים, Controllers, Gateways וכדומה, שמתרגמים נתונים מהמשתמש או ממקום אחר אל הלוגיקה העסקית ולהיפך.
  • Frameworks & Drivers (תשתיות) – השכבה החיצונית שכוללת מסדי נתונים, שרתי Web (כמו Express), ספריות צד ג' וכו'.

כלל הזהב של Clean Architecture הוא "כלל התלות": תלויות נעות פנימה – קוד בליבה העסקית לא יודע שום דבר על מה שמחוץ לו. המשמעות היא ששכבת ה-Use Cases אוסרת על עצמה להתייחס ישירות למסד נתונים, HTTP או כל פרט מסגרת. איך עושים זאת בפועל? בעזרת הגדרת ממשקים מופשטים בליבה, ומימוש שלהם בתשתיות. כאן בדיוק נכנס הנושא של Dependency Injection.

למה Dependency Injection קריטי ב-Clean Architecture?

Dependency Injection (הזרקת תלויות) הוא דפוס עיצוב שעוזר לנו להפוך את העיקרון התיאורטי של Clean Architecture למשהו מעשי. הרעיון פשוט: במקום שclass ייצור בעצמו את התלויות שלו (נגיד לפתוח חיבור DB, ליצור אובייקט שירות וכדומה), אנחנו מעבירים לו את התלויות מבחוץ. כך הclass תלוי בממשק מופשט ולא במימוש ספציפי.

במילים אחרות, הזרקת תלויות מאפשרת לנו לעמוד בכלל התלות: ה-Use Case תלוי בממשק (אבסטרקטי) שהוגדר בליבה, ולא במימוש הקונקרטי שנמצא בשכבת התשתית. זה מגלם את עקרון ה-DIP (Dependency Inversion Principle) מתוך SOLID – תלות בהפשטות במקום במימושים.

למה זה כל כך חשוב? כי בלי DI, קל מאוד "לערבב שכבות" בלי לשים לב או מתוך הרגל מגונה. למשל, אם פונקציה בלוגיקה העסקית תפתח ישירות חיבור למסד נתונים או תקרא לשירות חיצוני, שברנו את ההפרדה: הלוגיקה העסקית כעת תלויה בפרט טכני והתוצאה היא קוד שקשה לבדוק (תארו לכם שכל טסט יחויב לתקשר עם DB אמיתי), וקשה לתחזק (החלפת מסד נתונים תדרוש שינוי בקוד הליבה). באמצעות DI, אנחנו דואגים שהלוגיקה העסקית תהיה Plug-and-Play – אפשר לחבר לה כל מימוש שנרצה מבחוץ, מבלי לשנות את הקוד של הליבה. זה מקל על בדיקות Unit (אפשר להזרים תלות "דמה" למטרת טסט), ומכין את הקרקע לגמישות מירבית – החלפת ספריית DB, שינוי ספק שירות, או הרצה בסביבה שונה – כל אלו קורים בשכבה החיצונית בלי להשפיע על הקוד העסקי.

איך מיישמים Dependency Injection בפועל?

נשתמש בדוגמה של ניהול רשימת משימות (To-Do) בשביל הפשטות. נניח שיש לנו אפליקציה שמנהלת משימות, ואנחנו רוצים להפריד בין הלוגיקה העסקית לניהול משימות לבין פרטי המימוש של מסד הנתונים ופרטי ה-API.

1. הגדרת ממשקים בליבת המערכת

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

// בקובץ בשכבת ה-Use Cases (למשל src/domain/todoRepository.ts)
interface ITodoRepository {
  getAll(): Promise<Todo[]>;
}

// יישות עסקית בסיסית (Entity)
class Todo {
  constructor(public id: string, public title: string,
 public completed: boolean) {}
}

// Use Case: לוגיקה עסקית לקבלת משימות לא-גמורות
class TodoService {
  // מקבל רפוזיטורי דרך ה-Constructor (תלות מוזרקת)
  constructor(private todoRepo: ITodoRepository) {}

  async getIncompleteTodos(): Promise<Todo[]> {
    const todos = await this.todoRepo.getAll();
    // לוגיקה עסקית: סינון משימות שהושלמו
    return todos.filter(todo => !todo.completed);
// אם יש בריפו אפשרות לשלוף את זה כבר מפולטר כמובן שעדיף
  }
}

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

2. מימוש התלויות בשכבת התשתית

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

// בקובץ בשכבת התשתיות (למשל src/infrastructure/mongoTodoRepository.ts)
class MongoTodoRepository implements ITodoRepository {
  async getAll(): Promise<Todo[]> {
    const collection = mongo.db("app").collection("todos");
    return collection.find({}).toArray();
  }
}

הקלאס הזה יודע לעבוד מול MongoDB ולהחזיר את רשימת המשימות. העניין המהותי: המחלקה הזאת מממשת את הממשק ITodoRepository. כלומר, מהצד של הלוגיקה העסקית, היא "מתחזה" לסוג הנתון שה-Use Case יודע לעבוד איתו. כעת נוכל לחבר בין ה-Use Case למימוש הזה.

3. חיווט התלויות (Composition Root) בחלק העליון של האפליקציה

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

// בקובץ הראשי של השרת (למשל src/app.ts)
import express from "express";
import { MongoTodoRepository } from "./infrastructure/mongoTodoRepository";
import { TodoService } from "./domain/todoService";

const app = express();

// יצירת מופעים של המימושים הקונקרטיים
const todoRepository = new MongoTodoRepository();
const todoService = new TodoService(todoRepository);

// הגדרת ראוטים ב-Express ושימוש ב-TodoService
app.get("/todos", async (req, res) => {
  try {
    const todos = await todoService.getIncompleteTodos();
    res.json(todos);
  } catch (err) {
    res.status(500).send(err.message);
  }
});

app.listen(3000, () => {
  console.log("Server running on port 3000");
});

כאן אנחנו יוצרים MongoTodoRepository (שכבת תשתית) ומזריקים אותו ל-TodoService (שכבת Use Case). ה-TodoService מוכן ומזומן לשימוש, מבלי לדעת בכלל מאיפה הגיעו הנתונים. בראוטר של Express, אנחנו יכולים לזמן את todoService.getIncompleteTodos() כדי לקבל את המשימות ולהחזיר אותן כ-JSON. שימו לב איך הראוט לא יוצר שום אובייקט של DB או נוגע בפרטי המימוש – הכול הוזרק מראש.

4. אופציונלי: שימוש במכולת תלויות (Dependency Injection Container)

בפרויקטים קטנים, יצירת אובייקטים ידנית והזרקה שלהם כמו שהראינו זה מספיק. אבל ככל שהפרויקט גדל, וכמות התלויות גדלה (לדוגמה, Use Case עם 3-4 שירותים שונים שתלויים בו), נרצה לנהל זאת בצורה אוטומטית ומסודרת יותר. כאן נכנסות לתמונה ספריות DI למיניהן (מה שנקרא Container או "מכולת תלויות").

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

import "reflect-metadata";
import { container } from "tsyringe";
import { TodoService } from "./domain/todoService";
import { MongoTodoRepository } from "./infrastructure/mongoTodoRepository";

// רישום התלות בקונטיינר: כשמבקשים ITodoRepository, תן מופע של MongoTodoRepository
container.register<ITodoRepository>("ITodoRepository", { useClass: MongoTodoRepository });

// קבלת מופע מוכן של TodoService עם כל התלויות מוזרקות אוטומטית
const todoService = container.resolve(TodoService);

בספריה זו, נשתמש בדקורטורים של TypeScript כדי לסמן את המחלקות הניתנות להזרקה (@injectable) ואת התלויות שהן צריכות (@inject("ITodoRepository") בתוך הבנאי). לא נעמיק כאן לכל הפרטים – העיקר הוא להבין שיש כלים שעוזרים לנהל את ההזרקה בצורה אוטומטית, במיוחד בפרויקטים גדולים, וזה ממש נוח להשתמש בהם , וגם שומר על הסדר.. 🙂 מסגרות עבודה כמו NestJS למשל, מובנות כולה סביב מנגנון DI מובנה שמפשט מאוד את כל התהליך (אבל זה כבר נושא לפוסט נפרד 😉).

יתרונות הגישה (ולמה זה שווה את ההשקעה)

ראינו איך מיישמים Dependency Injection, אבל מה יוצא לנו מכל זה? כמה יתרונות בולטים:

  • מודולריות והחלפה קלה של רכיבים – כיוון שהלוגיקה העסקית תלויה רק בממשקים, אפשר לממש אותם במספר דרכים. היום אתם עובדים עם MongoDB? מחר רוצים לעבור ל-PostgreSQL או אפילו לשירות חיצוני? אין בעיה – מוסיפים מימוש חדש של ITodoRepository והלוגיקה לא צריכה להשתנות בכלל. אותו דבר לגבי כל שירות חיצוני (API, תשלום, הודעות וכדומה).
  • בדיקות יחידה קלות – זו אולי המתנה הגדולה ביותר. באמצעות DI אפשר להזרים ל-Use Case מימוש דמה (Mock או Stub) של התלות במקום המימוש האמיתי. למשל, בשביל לבדוק את TodoService שלנו, נוכל ליצור מחלקה שמממשת ITodoRepository אבל מחזירה נתונים מזויפים מתוך מערך בזיכרון. כך נבדוק את הלוגיקה (סינון completed וכו') בלי צורך במסד נתונים אמיתי או תלות בסביבה חיצונית. הבדיקות רצות מהר יותר והן אמינות כי הן ממוקדות רק בלוגיקה.
  • קוד קריא ותחזוקתי – כשמקפידים על הזרקת תלויות, מבנה הקוד נעשה ברור יותר. כשרואים בנאי של מחלקה שמקבל את כל התלויות שלו מבחוץ, מיד ברור לנו מה תלוי במה. זה מאלץ אותנו גם לחשוב על הפרדת אחריות: אם יש מחלקה שמקבלת 5-6 תלויות, אולי זו נורה אדומה שהאחריות שלה רחבה מדי. בקוד "ספגטי" ללא DI, קשה לראות את זה כי אובייקטים נוצרים בחבואה בתוך הפונקציות.
  • עמידה בעקרונות SOLID ועיצוב נקי – DI הוא ממש יישום ישיר של Dependency Inversion Principle (ה-D ב-SOLID). אימוץ שלו דוחף אותנו באופן טבעי לקוד עם צימוד נמוך (Low Coupling) וקוהזיה גבוהה, שזה בדיוק מה שאנחנו רוצים בארכיטקטורה נקייה. שינוי בדרישה עסקית ישפיע רק על שכבת הלוגיקה, ושינוי טכני ישפיע רק על שכבת התשתית – בדיוק כפי שהתכוונו.

מה קורה כשלא מיישמים נכון? בעיות נפוצות בצימוד תלויות

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

  • צימוד חזק בין רכיבים – ללא DI, נמצא לעיתים קרובות קוד ש"קושר" חלקים שונים חזק אחד לשני. למשל, Controller שיוצר בעצמו אובייקט Service, שבתוכו יוצר אובייקט Repository. מצב כזה מקשה מאוד לשנות משהו באמצע. אם מחר נרצה שה-Service ישתמש ברפוזיטורי אחר (נניח, לשימוש בסוג מסד נתונים שונה או API אחר), נצטרך לשנות את הקוד גם ב-Controller. כל רכיב שמורכב בתוך קוד של רכיב אחר הופך את השינוי למסוכן ומסובך יותר.
  • קוד שקשה לבדוק – תארו לעצמכם פונקציית שירות שפונה ישירות ל-API חיצוני. כשתרצו לבדוק אותה, היא תמיד תנסה לקרוא ל-API האמיתי. בלי DI, אין דרך קלה להגיד לקוד "בזמן טסט תשתמש במשהו אחר". מפתחים עוקפים את זה לפעמים ע"י "Flags" או תנאים בתוך הקוד ("אם ENV=test אז…"), אבל זה פתרון לא אלגנטי ועתיר בבאגים. דרך הרבה יותר נקייה: DI – להעביר אובייקט חלופי בבדיקות.
  • הפרת עקרון האחריות היחידה – לפעמים חוסר ב-DI מעיד על עיצוב לקוי. למשל, מחלקה שפותחת חיבור לבסיס נתונים וגם מעבדת את הנתונים וגם מחזירה תשובה – היא כנראה עושה יותר מדי. הזרקת תלויות "מכריחה" אותנו לפצל אחריות בצורה הגיונית: מחלקה עסקית תקבל ממשק של מחלקת DB, במקום לעשות את עבודת ה-DB בעצמה. אם מגלים שקשה מאוד לבצע DI ברכיב מסוים, ייתכן שהוא ממלא מספר תפקידים שראוי להפריד.
  • הזרקת תלות לא מתאימה – גם כשמיישמים DI, צריך להיזהר לא להזריק דברים מהשכבה הלא נכונה. למשל, טעות תהיה להזריק אובייקט של Request ו-Response של Express ישירות ל-Use Case. למה זו טעות? כי בקוד העסקי שלנו אסור שיהיה תלוי במבנה של HTTP או Express. במקום זאת, ה-Controller (בשכבת ה-Interface Adapters) יכול לשלוף את הנתונים הרלוונטיים מ-Request (כמו פרמטרים או JSON מהגוף) ולהעביר אותם כפרמטרים רגילים ל-Use Case. ה-Use Case מחזיר תוצאה פשוטה (למשל אובייקט או ערך), וה-Controller מתרגם את זה ל-Response. הקפדה על כך ששכבות לא "יזלגו" לתוך אחת השנייה היא חלק בלתי נפרד מיישום נכון של DI במסגרת Clean Architecture.

סיכום והמלצות לדרך

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

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

  • התחילו בקטן – זהו חלק במערכת שלכם שיש בו תלות "קשיחה" שגורמת לכאב ראש (אולי גישה לבסיס נתונים, קריאה לשירות מרוחק, או אפילו שימוש במחלקה סטטית שקשה לשנות). תגדירו לו ממשק ותזריקו אותו במקום ליצור ישירות. תראו איך זה משפיע על הקוד.
  • הגדירו ממשקים ברורים – הקדישו מחשבה לעיצוב הממשק של התלות. הוא צריך להיות מספיק מופשט כדי שלא ידלוף לוגיקה של מימוש. למשל, ITodoRepository לא אמור לחשוף את ספריית ה-ORM שלכם – הוא מספק פעולות עסקיות (getAll, save, וכדומה) במונחי הדומיין.
  • שקלו שימוש בכלי DI בפרויקטים גדולים – אם יש לכם עשרות רבות של תלויות ופונקציונליות מורכבת, ספריית DI יכולה לחסוך קוד חיווט ולמנוע שגיאות. עם זאת, אל תמהרו לסבך פרויקט קטן עם Container – הרבה פעמים הזרקה "ידנית" בקובץ אחד מספיקה. תמיד אפשר להכניס Container כשמרגישים שהגודל מצדיק.
  • אל תשכחו את הכוונה המקורית – DI הוא אמצעי, לא מטרה בפני עצמה. המטרה היא קוד נקי, מבודד, שנוח לעבוד איתו. אם אתם מוצאים את עצמכם מנסים בכוח להזריק כל דבר זז ומסבכים את הקוד, חזרו צעד אחורה ותבדקו איפה באמת נדרש בידוד. לפעמים גם בגישה נקייה אפשר לאפשר לעצמכם "קיצור דרך" איפה שזה לא נורא, במיוחד בפרויקטים פשוטים – העיקר הוא המודעות והיכולת להשתפר עם הזמן.

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

כמו תמיד, אשמח לשמוע על החוויות והתובנות שלכם! האם יצא לכם ליישם Dependency Injection בפרויקטים שלכם? אילו יתרונות או קשיים פגשתם בדרך? מרגישים שהקוד נעשה נקי יותר? ספרו לי בתגובות – נתראה שם!

ארכיטקטורה נקייה – עקרונות ואיך ליישם אותה בפרויקט אמיתי

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

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

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

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

    בשלב הקוד, אפשר להתחיל בקטן: לבחור מקרה שימוש אחד ולממש אותו בצורה "נקייה" מקצה לקצה – כך תיצרו דפוס שאפשר לשכפל בהמשך. הקפידו להפריד כבר מהתחלה בין חלקי הקוד השונים: לוגיקה עסקית במקום אחד, קוד של גישה למסד נתונים/Filesystem במקום אחר, וקוד של HTTP או ממשק משתמש במקום נוסף. אם תתחילו ככה, גם אם לא הכל מושלם, תהיו בכיוון הנכון.
  • בפרויקט קיים: כאן האתגר גדול משמעותית, כי יש מערכת עובדת (ואולי מבולגנת) שצריך בהדרגה "לנקות". החדשות הטובות הן שלא חייבים (ולא כדאי) לבצע כתיבה מחדש מאפס. במקום זאת, אפשר לאמץ את עקרונות הארכיטקטורה הנקייה צעד אחר צעד: בחרו רכיב או איזור בעייתי בקוד והתחילו לבודד אותו. למשל, אם יש חלק בקוד שכולל גם לוגיקה וגם קריאות ישירות למסד נתונים, נסו להפריד ביניהם: צרו מחלקת שירות עסקי (Use Case) שמכילה את הלוגיקה, והזיזו את הקריאות למסד נתונים למחלקת "מאגר" (Repository) נפרדת. הגדירו ממשק (interface) בין השניים, כדי שהלוגיקה לא תהיה תלויה במימוש הטכני. באופן הדרגתי, כל פונקציונליות חדשה שתוסיפו – כבר תכתבו לפי העקרונות הנקיים, וחלקים ישנים תוכלו לשפר בהדרגה כאשר נוגעים בהם. כן, ייתכן שבתקופת הביניים תחיו עם שילוב של סגנונות, אבל זה בסדר כל עוד אתם במגמת שיפור מתמדת.

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

תכנון מקרי השימוש והליבה העסקית

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

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

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

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

ארגון הקבצים והתיקיות בפרויקט נקי

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

src/  
├── domain/            # שכבת הדומיין (ליבה עסקית)
│   ├── entities/      # ישויות ומודלים עסקיים
│   └── interfaces/    # ממשקים אבסטרקטיים (למשל, Repository)
├── use-cases/         # שכבת היישום (מקרי שימוש)
├── infrastructure/    # שכבת תשתית (מימושים טכניים)
│   ├── db/            # קוד גישה למסד נתונים
│   ├── services/      # שירותים חיצוניים (לדוגמה, SMTP, API חיצוני)
│   └── frameworks/    # קוד מסגרת (HTTP server, Express וכו')
└── interface-adapters/ # מתאמי ממשק (למשל, Controllers/Routes, View models)

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

עוד טיפ בארגון הקבצים: שמות ברורים. השתמשו בשמות שמרמזים על התפקיד בשכבה. למשל, UserRepository עבור ממשק, MongoUserRepository עבור המימוש שלו, או UserController עבור קובץ שמטפל בבקשות HTTP של משתמש. ככה, גם בלי לפתוח את הקובץ, מפתח יבין מה התפקיד שלו.

זכרו שאין צורך להגזים במספר השכבות אם הן לא מוסיפות ערך ברור. לפרויקט קטן ייתכן שתעבדו רק עם 3 תיקיות עיקריות: domain, application (use cases) ו-infrastructure. לפרויקט גדול אולי תוסיפו גם שכבת interface-adapters לתיווך. התאימו את המבנה לגודל וצרכי הפרויקט, אבל שמרו על העיקרון שכל שכבה תלויה רק בשכבות ה"פנימיות" ממנה (ולא ההיפך).

היפוך תלות: מתי ליצור ממשקים והזרקת תלויות

עיקרון מפתח בארכיטקטורה נקייה הוא היפוך התלות (Dependency Inversion Principle) – במקום שהלוגיקה שלנו תהיה תלויה במימושים טכניים, נעדיף שהמימושים יתלו דווקא בהגדרות אבסטרקטיות של הלוגיקה. מה זה אומר בפועל? שימוש בממשקים (interfaces) בכל פעם שהלוגיקה העסקית שלנו צריכה משהו שנמצא מחוץ לה.

מתי נכון ליצור ממשק? בכל נקודת מגע בין הליבה העסקית לבין שירות חיצוני, מסד נתונים או מערכת אחרת. דוגמה קלאסית: גישה לנתונים. אם ה-Use Case שלנו צריך לשמור אובייקט במסד נתונים, הוא לא יקרא ישירות לפונקציה של MongoDB או PostgreSQL. במקום זה נגדיר ממשק כמו IUserRepository עם מתודות כמו save ו-getById. הממשק הזה יוגדר בשכבת הדומיין או היישומים (פנימית), וה-Use Case יקבל משהו שמממש את הממשק הזה. המשהו הזה – המימוש – יחיה בשכבת התשתית (למשל מחלקת MongoUserRepository). כך ה-Use Case "מדבר" בשפה שלו (אבסטרקטית), ולא אכפת לו אם מתחת אנחנו עובדים עם Mongo, קובץ, או כל דבר אחר.

מלבד מאגרים (Repositories), יצירת ממשקים נכונה גם עבור שירותים חיצוניים: למשל, ממשק IMailService לשליחת אימייל, ממשק IPaymentGateway לתשלומים, וכו'. הקריטריון הוא פשוט: האם הדבר הזה עלול להשתנות או להתחלף מבלי לשנות את הלוגיקה העסקית? אם כן – הפכו אותו לממשק. אם לא (למשל, קוד לוגי פנימי לחלוטין), אין צורך בממשק בכוח. לא כל מחלקה קטנה זקוקה לממשק; התמקדו בגבולות שבין הלוגיקה לטכנולוגיה.

אחרי שיצרנו ממשקים, נשתמש בהזרקת תלויות (Dependency Injection) כדי לספק ל-Use Case את המימושים המתאימים בזמן ריצה. הזרקת תלויות היא פשוט דרך מתוחכמת להגיד "נעביר מבחוץ את האובייקט שה-Use Case צריך, במקום שה-Use Case עצמו ייצור או יאתר אותו". אגב, בשביל DI לא חייבים ספרייה מיוחדת: אפשר פשוט להעביר את התלות דרך הקונסטרקטור (Constructor Injection) או כפרמטר לפונקציה. בסביבת Node.js הרבה פעמים מנהלים את ההרכבה הזו באופן ידני בקובץ התחלתי (composition root), וזה לגמרי בסדר גמור. רק בפרויקטים מאוד גדולים אולי ניעזר בכלי DI אוטומטי, אבל לרוב אין בכך צורך בהתחלה.

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

דוגמה מעשית ב-Node.js ו-TypeScript

נניח שאנחנו רוצים לממש Use Case של "יצירת משתמש חדש" במערכת שלנו, כולל שמירת המשתמש למסד נתונים ושליחת אימייל ברכה לאחר ההרשמה. נראה איך זה נראה בקוד עם ארכיטקטורה נקייה:

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

// ממשקים בשכבת הליבה (דומיין)
interface IUserRepository {
  save(user: User): Promise<void>;
  getById(id: string): Promise<User | null>;
}

interface IEmailService {
  sendWelcomeEmail(user: User): Promise<void>;
}

// יישות (Entity) – ייצוג של משתמש
class User {
  constructor(public id: string, public name: string, public email: string) {}
}

// Use Case – לוגיקה עסקית לרישום משתמש חדש
class CreateUser {
  constructor(
    private userRepo: IUserRepository,
    private emailService: IEmailService
  ) {}

  async execute(user: User): Promise<void> {
    // בדיקה עסקית פשוטה
    if (!user.name || !user.email) {
      throw new Error("Name and email are required");
    }
    // שמירת המשתמש דרך ממשק המאגר
    await this.userRepo.save(user);
    // שליחת אימייל ברכה דרך ממשק האימייל
    await this.emailService.sendWelcomeEmail(user);
  }
}

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

// מימוש ה-Repository עבור MongoDB (שכבת תשתית)
class MongoUserRepository implements IUserRepository {
  async save(user: User): Promise<void> {
    const collection = mongo.db("app").collection("users");
    await collection.insertOne(user);
  }
  async getById(id: string): Promise<User | null> {
    const collection = mongo.db("app").collection("users");
    return collection.findOne({ id });
  }
}

// מימוש שירות האימייל (שכבת תשתית)
class SimpleEmailService implements IEmailService {
  async sendWelcomeEmail(user: User): Promise<void> {
    // כאן היינו משתמשים בספרייה לשליחת אימייל, לדוגמה nodemailer.
    console.log(`Sending welcome email to ${user.email}...`);
    // לדוגמה: await emailLibrary.send({ to: user.email, ... });
  }
}

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

// הרכבת התלויות והרצת מקרה השימוש
const userRepository = new MongoUserRepository();
const emailService = new SimpleEmailService();
const createUserUseCase = new CreateUser(userRepository, emailService);

// דוגמה להפעלת מקרה השימוש
await createUserUseCase.execute(new User("123", "דני", "[email protected]"));

אפשר לדמיין שקוד ההרצה הזה נמצא למשל בקובץ שמאתחל את השרת (נניח בקובץ הראשי של האפליקציה), או בתוך Route ספציפי. הרעיון הוא שבנקודה הזו אנחנו יוצרים את המימושים ומזריקים אותם. אם היינו משתמשים במסגרת כמו Express, היינו מפעילים את createUserUseCase.execute() מתוך ה-Controller של הנתיב הרלוונטי, ומעבירים לו את הנתונים מהבקשה.

שימו לב כיצד ה-Use Case שלנו (CreateUser) לא יודע שום דבר על MongoDB, על console.log, או על איפה האפליקציה רצה. הוא פשוט מבצע את הלוגיקה העסקית: בודק את הנתונים, שומר, ושולח אימייל דרך הממשקים שניתנו לו. עבור בדיקות אוטומטיות – אפשר ליצור אובייקט CreateUser ולהזריק לו אובייקטי דמה שמממשים את הממשקים (למשל, FakeUserRepository ששומר למערך בזיכרון, ו-FakeEmailService שלא באמת שולח כלום), וכך לבדוק את הלוגיקה בלי תלות במסד אמיתי או בסביבת אימייל. עבור פיתוח – זה אומר שאם מחר נחליט לעבור מ-MongoDB ל-PostgreSQL, נכתוב מחלקת Repository חדשה ונתחוף אותה במקום MongoUserRepository, בלי לגעת בשורה של CreateUser. בדיוק בשביל הגמישות הזו התכנסנו.

בשורה התחתונה – טיפים להטמעה מוצלחת של הגישה

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

  • הפרידו לוגיקה מתשתית מהיום הראשון: גם אם לא בונים את כל השכבות בבת אחת, כדאי לפחות ליצור הבחנה ברורה בין קוד עסקי לקוד של גישה למסד, תקשורת, וכו'. אפילו אם זה אומר לשים אותם בקבצים נפרדים או מודולים שונים – זה צעד ראשון חשוב שישתלם מהר מאוד.
  • אל תנסו לתכנן הכול מראש עד הפרט האחרון: ארכיטקטורה נקייה היא מדריך, לא חוק קשיח. ייתכן שבהתחלה לא תהיו בטוחים מהם כל מקרי השימוש או כל הממשקים שתצטרכו. זה בסדר! התחילו ממה שברור, ובנו את הארכיטקטורה בהדרגה תוך כדי למידה. עדיף ליישם 80% מהעקרונות באופן עקבי מאשר לשאוף ל-100% ולתקוע את הפרויקט.
  • ממשקים – השתמשו במידה הנכונה: כפי שאמרנו, לא לכל מחלקה צריך ממשק. התמקדו ביצירת ממשקים בגבולות שבין הלוגיקה למימוש חיצוני. אם יש לכם שירות פנימי שמשמש רק את הלוגיקה ואין סיבה להחליף אותו – אפשר לוותר על יצירת ממשק עבורו בשלבים הראשונים. המנעו מאינסוף ממשקי-סרק. במקום זאת, צרו אותם איפה שזה מעניק לכם גמישות או יכולת בדיקה.
  • Layered, but pragmatic: המטרה אינה לייצר יותר שכבות מקובצת "כי הארכיטקט אמר". המטרה היא להקל על החיים שלנו כמפתחים. אם אתם מרגישים שחלק מסוים בארכיטקטורה הנקייה מסרבל את הפיתוח בלי תועלת ברורה – בחנו את זה. ייתכן שצריך לכוונן את הפרקטיקה (אולי לאחד שתי שכבות, אולי לדחות הכנסת ספריית DI מתוחכמת לשלב מאוחר יותר). שמרו על הרוח הכללית של ההפרדה, אבל התאימו אותה לצרכים שלכם וליכולות שלכם כצוות.
  • למדו מהתנסות ושפרו בהתמדה: אחרי שמיישמים Use Case אחד או שניים, עשו רטרוספקטיבה: האם המבנה שבחרנו עוזר לנו, או שיש תוספות/שינויים שכדאי לעשות? ארכיטקטורה נקייה יכולה לחייב משמעת, אבל ברגע שרואים את היתרונות (קוד קריא, בדיקות שעוברות חלק, יכולת שינוי מהירה), המוטיבציה של הצוות תעלה. שתפו אחד את השני בדוגמאות, כתבו הנחיות פנימיות אם צריך, ותמיד היו פתוחים לשפר את הסדר בפרויקט.

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

וכמו תמיד – אשמח לשמוע מכם בתגובות! 🚀 האם התחלתם ליישם ארכיטקטורה נקייה בפרויקטים שלכם? נתקלתם בהתלבטויות או תובנות מעניינות? אל תהססו לשתף או לשאול.

Node.js Worker Threads – על קצה המזלג

כידוע, Async ו Await הם לא פתרונות קסם, ולעיתים, אנו נדרשים לבצע מטלה שעלולה "לתקוע" את הEvent Loop, כמו חישוב מורכב , ליטרציה כבדה של מערכים ועוד.
אם יש לכם פונקציה שעושה חישוב מורכב, להוסיף בתחילתה async לא ייפתור את הבעיה והיא עדיין תתקע את הEvent Loop.
להזכירכם , NODE עובד בצורה של Event Loop שהוא Single Threaded למעשה.

(ליתר דיוק, הוא דווקא כן עושה שימוש בTHREADS , דהיינו הlibuv מייצר POOL של 4 THREADS כברירת מחדל מה שמאפשר לEVENT LOOP להעביר "משימות כבדות" לPOOL בצורה אוטומטית, בעיקר משימות שקשורות למערכת הקבצים, הצפנות, דחיסות וכו. אבל זה כבר נושא אחר).
בכל מיקרה, זאת האחריות שלנו כמפתחים לכתוב קוד שעושה שימוש בEvent Loop בצורה הנכונה ביותר.

נו, אז מה הפיתרון?

פיתרון אחד לבעיה שאומנם לא מתאים תמיד מבחינת ארכיטקטורה נכונה לכל המקרים, אבל לעיתים דווקא כן יכול להתאים הוא שימוש בWorker Threads, הדגשים הם:

  • לכל THREAD יש Process אחד ואת הEVENT LOOP שלו. – מה שרץ בWorker Thread אחד לא משפיע על השני מבחינת חסימה (None Blocking)

  • ניתן לחלוק זיכרון (לדוגמה באמצעות SharedArrayBuffer) וניתן להעביר מידע לThread שלנו.

  • לכל THREAD יש את הINSTANCE של הV8 וlubuv שלו (ISOLATED, וכן, הם צורכים משאבים)
  • מומלץ בעיקר למשימות שדורשות משאבי CPU

בואו נתחיל ונכתוב את הקוד הבא בParent שלנו (יכול להיות גם בApp.js שלכם):

//Parent Code:
const { Worker } = require('worker_threads');
const worker = new Worker('./worker.js');

בשורה 3 אנו טוענים לWORKER את הקובץ שאנחנו רוצים שהWORKER יריץ, במקרה שלנו worker.js.

נוסיף לקובץ את השורות הבאות:

//Subscribing for message on our Parent.
worker.on('message', message => console.log(message))

//Sending Message to our Worker
worker.postMessage('Hello');

בשורה 2, אנו "נרשמים" (Subscribe) להודעה מהWORKER שלנו, משמע כאשר הWORKER שלנו ישלח לנו הודעה במקרה שלנו הPARENT יריץ console.log(message).

בשורה 5, אנו שולחים לWORKER שלנו message עם הערך 'Hello', עד כאן, לא קרה בעצם יותר מדי, יצרנו WORKER ועשינו Subscribe לMessage ממנו, שלחנו לו Message עם הערך Hello. אבל הParent שלנו עדיין לא ידפיס לconsole את הmessage והסיבה לכך היא שהWorker שלנו כרגע לא שולח לParent
Message בחזרה.

בworker.js נכתוב את הקוד הבא:

const { parentPort } = require('worker_threads');
parentPort.on('message', message => 
    parentPort.postMessage({ hello: message })
);

בשורה 2 אנו עושים Subscribe מהWorker שלנו לParent, דהיינו, כאשר הWorker שלנו יקבל Message הוא יריץ את הפקודה
parentPort.postMessage({ hello: message });
מה שישלח למעשה לParent שלנו .

עד כאן למעשה יצרנו סינכרון מלא בין הParent שלנו לבין הWorker Thread שלו. הנה הקוד המלא:

//Parent Code:
const { Worker } = require('worker_threads');
const worker = new Worker('./worker.js');

//Subscribing for message on our Parent.
worker.on('message', message => console.log(message));

//Sending Message to our Worker
worker.postMessage('Hello');
const { parentPort } = require('worker_threads');
parentPort.on('message', message => 
    parentPort.postMessage({ hello: message });
)

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

const {Worker, isMainThread} = require('worker_threads');

//האם אנו רצים מה
//Parent?
if(isMainThread) {
//יוצרים Worker 
// חדש ושולחים אליו את הקובץ הנוכחי
 const worker = new Worker(__filename);
} else {
  //קוד זה רץ ב
  //Worker
 console.log('Hi from your worker!');
}

במקרה הנל אנו עושים שימוש בisMainThread שמאפשר לנו לדעת האם אנחנו רצים כרגע מתוך הWorker Thread או מתוך הParent שלנו ולעשות הפרדה בין הCode שירוץ.


איך מעבירים מידע ראשוני לWorker שלנו?

כדי להעביר DATA לWorker , נוכל לעשות שימוש באפשרות workerData, לדוגמה:

const { worker, inMainThread, workerData} = require('worker_threads');

if(isMainThread) {
 const worker = new Worker(__filename, { workerData: 'Hi!' })
} else {
 console.log(workerData); // will print 'Hi!'
}

יש עוד דברים מעניין שניתן לעשות בהם שימוש כמו SHARE_ENV ,eval, ממליץ לכם לתת הצצה בתיעוד ולגלות דברים מעניינים.

אופציה נוספת שאני חושב ששווה לתת דגש עליה היא resourceLimit:

const { Worker } = require('worker_threads');
const worker = new Worker('./worker.js', { resourceLimits: {maxOldGenerationSizeMb: 10} })

במקרה הנל אנו מגבילים את הגודל של הHEAP (זיכרון) ל10MB. אם הWorker יגיע למגבלה הזאת הוא יושמד עם שגיאה.

אילו Events קיימים בWorker?

  • message , לדוגמא parentPort.postMessage()
  • exit – כשהworker שלנו הפסיק.
  • online – כאשר הWorker שלנו התחיל להריץ את הקוד שלו
  • error – כאשר נזרקה מהworker שלנו uncaught exception.

הערה לגבי השימוש בpostMessage:


בדוגמאות הנל עשינו שימוש ב postMessage כדי לשלוח מידע/הודעות בין הPARENT לThread. חשוב מאוד לדעת שכאשר אנו עושים שימוש בPostMessage, הדאטא שאנו מעבירים בעצם "משוכפל" – Cloned עם כל החסרונות / ייתרונות בכך, דהיינו, שיכפול של דאטא מורכב יכול לעלות לנו בהרבה כח CPU, ככל שהדאטא יותר מורכב/מסובך/עמוק יותר כך הוא יידרוש יותר כח מחשוב.

שלא נדבר על הRAM הכפול בעצם שאנו צורכים כדי לשמור את הדאטא הנוסף הזה בזיכרון.
מבחינת ארכיטקטורה, מאוד חשוב לשים לב לזה. כדי ללמוד יותר על הנושא ניתן להריץ חיפוש בגוגל עם המונח is postmessage slow, וגם להכיר את האפשרות של TransferList ועוד. (אולי בהזדמנות אכתוב על זה פוסט נפרד).

מצאתם טעות? הערות? שאלות? הסתדרתם? נתקעתם? כתבו לי בתגובות!


תבניות אסינכרוניות בNodeJS – על קצה המזלג

תבניות אסינכרוניות בNodeJS,
SyncPattern, Async Error CallBack, The Promise Pattern – Async-Await

Sync Pattern

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

const fs = require('fs');
let fileName = 'logs.txt';
const fileData = fs.readFileSync(fileName);

console.log('File content is', fileData);

console.log('Sync Test');

בקוד הנל, אנו קוראים את כל התוכן של הקובץ, לאחר שקראנו אותו אנו מדפיסים את התוכן שלו. ולאחר מכן מדפיסים 'Sync Test'. בקוד הסינכרוני הנל קודם יודפס תוכן הקובץ ורק לאחר מכן יודפס 'Sync Test'. חשוב להדגיש: הקוד הנל לא עובר דרך הEvent Loop של NodeJS.


Async Error CallBack

בדוגמה הבאה, נכתוב קוד אסינכרוני בNodeJS בגישת CallBack. הקוד הנל יעבור דרך הEvent Loop ויתבצע בצורה אסינכרונית.

const fs = require('fs');

let fileName = 'logs.txt';
fs.readFile(fileName, function cback(err,data) {
 console.log('File content is', data);
});

console.log('Async Test');

בקוד הנל, אנו משתמשים במתודה readFile שהיא מתודה אסינכרונית אשר עושה שימוש בEventLoop.
אנו לא נוכל לגשת לתוכן הקובץ ישירות אחרי קריאה לפונקציה הזאת – כיוון שהוא עדיין לא זמין, וזאת הסיבה שאנו צריכים להשתמש בפונקצית CallBack – במקרה שלנו cback שמקבלת 2 פרמטרים. הפרמטר הראשון err (לשגיאות) והפרמטר השני data – התוכן של הקובץ.

לאחר שקריאת הקובץ תסתיים, מתבצעת קריאה לפונקצית הCallBack שלנו ורק בה יש לנו גישה לתוכן של הקובץ (Event Loop)

  1. קריאת הקובץ
  2. הדפסת Async Test
  3. הדפסת תוכן הקובץ על ידי פונקציית הCallBack
    כאשר למעשה הEvent Loop רץ רק פעמיים, בפעם הראשונה קריאת הקובץ + הדפסת הAsync Test ובפעם השנייה הרצת פונקצית הCallBack שלנו.

    שימו לב, שכיון שמדובר בכתיבה אסינכרונית, שורת הAsync Test שלנו תופיע לפני שורות התוכן של הקובץ שלנו.

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

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

const fs = require('fs');

let fileName = 'logs.txt';
let newFileName = 'newlog.txt';
fs.readFile(fileName, function cback(err,data) {
 fs.writeFile(newFileName, data, function cback2(err) {
  //yada yada
 });
});

console.log('Async Test');

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


The Promise Pattern, Async – Await

Nodejs מגיע עם כלי שמאפשר לעשות "להמיר" לpromise כל פונקציה אסינכרונית שהיא בילט אין.

const fs = require('fs');
const util = require('util');

const readLogFile = util.promisify('fs.readFile');

async function readLogFile(fileName) {
 const logFileData = await readFile(fileName);
 console.log('Log Data is', logFileData);
}

readLogFile('logs.txt');

console.log('Promise Async Test');

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

const readFile = util.promisify(fs.readFile);

בדוגמה הקודמת כמובן לא השתמשנו בהן ועשינו במקום זאת שימוש בutil.promisify.

ראו את הדוגמה הבאה בה אנו גם קוראים מקובץ וגם כותבים לקובץ עם Promise

const fs = require('fs').promises;

let fileName = 'logs.txt';
let newFileName = 'newlog.txt';

async function copyLog(filename, newFileName) {
 await logFileData = await fs.readFile(filename);
 await fs.writeFile(newFileName, logFileData);
}

copyLog('logs.txt','logs-copy.txt');
console.log('Async Test');

נכון שהקוד הרבה יותר ברור מהקוד הבא?

const fs = require('fs');

let fileName = 'logs.txt';
let newFileName = 'newlog.txt';
fs.readFile(fileName, function cback(err,data) {
 fs.writeFile(newFileName, data, function cback2(err) {
  //yada yada
 });
});

console.log('Async Test');


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


מצאתם טעות? הערות? שאלות? הסתדרתם? נתקעתם? כתבו לי בתגובות!