בדיקות אוטומטיות ב-Clean Architecture – איך לתכנן קוד שנוח לבדוק?

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

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

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

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

בדיקות אוטומטיות הן הכלי המרכזי שמאפשר לנו:

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

שכבות נקיות = בדיקות קלות

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

  • שכבת Entities ו־Use Cases נקייה לחלוטין מתלויות טכניות
  • תשתיות כמו DB או API מרוחק מופרדות לגמרי
  • ה־Use Case מקבל הכל בהזרקה (Dependency Injection)

אז מה נשאר לנו לעשות? לבדוק את הלוגיקה העסקית ללא שום תלות במסד נתונים או HTTP.

שכבת הליבה (Entities & Use Cases)

שכבת הליבה היא הלב של הבדיקות שלכם. היא עצמאית לחלוטין מכל תשתית (DB, רשת וכו'). לכן קל מאוד לבדוק אותה:

  • בדיקות יחידה (Unit Tests) – פשוטות ומהירות.
  • אין צורך ב־Mocks מורכבים של תשתיות.

לדוגמה, בואו נבדוק Use Case פשוט של רישום משתמש חדש:

// useCase/registerUserUseCase.ts
export class RegisterUserUseCase {
  constructor(private userRepo: IUserRepository) {}

  async execute(user: User) {
    const existing = await this.userRepo.findByEmail(user.email);
    if (existing) {
      throw new Error("User already exists");
    }
    await this.userRepo.save(user);
  }
}

הטסט פשוט ונקי:

// test/registerUserUseCase.test.ts
import { expect } from "chai";
import sinon from "sinon";
import { RegisterUserUseCase } from "../useCase/registerUserUseCase";

describe("RegisterUserUseCase", () => {
  it("should throw error if user already exists", async () => {
    const user = { id: "123", email: "[email protected]" };

    const mockRepo = {
      findByEmail: sinon.stub().resolves(user),
      save: sinon.stub(),
    };

    const useCase = new RegisterUserUseCase(mockRepo);

    try {
      await useCase.execute(user);
      throw new Error("Expected error was not thrown");
    } catch (err) {
      expect(err.message).to.equal("User already exists");
      expect(mockRepo.save.called).to.be.false;
    }
  });

  it("should save new user if not exists", async () => {
    const user = { id: "456", email: "[email protected]" };

    const mockRepo = {
      findByEmail: sinon.stub().resolves(null),
      save: sinon.stub().resolves(),
    };

    const useCase = new RegisterUserUseCase(mockRepo);

    await useCase.execute(user);

    expect(mockRepo.save.calledOnceWith(user)).to.be.true;
  });
});

שימו לב כמה הבדיקות ברורות, קצרות ונקיות – בלי חיבור ל־DB, בלי פייקים מוזרים, בלי תלויות מיותרות.

בדיקות אינטגרציה (Integration Tests) – תשתיות אמיתיות, בלי הלוגיקה

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

// test/userRepository.integration.test.ts
import { expect } from "chai";
import { MongoClient } from "mongodb";
import { MongoUserRepository } from "../infrastructure/mongoUserRepository";

describe("MongoUserRepository", () => {
  let client: MongoClient;
  let repo: MongoUserRepository;

  before(async () => {
    client = await MongoClient.connect("DB_CONNECTION_STRING");
    repo = new MongoUserRepository(client.db("TEST_DB"));
  });

  after(async () => {
    await client.db("test-db").dropDatabase();
    await client.close();
  });

  it("should save and retrieve a user", async () => {
    const user = { id: "789", email: "[email protected]" };
    await repo.save(user);

    const result = await repo.findByEmail(user.email);
    expect(result).to.deep.equal(user);
  });
});

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

טיפים לבדיקה נכונה ב־Clean Architecture

שמרו על בדיקות יחידה "טהורות" – בלי DB, בלי רשת, בלי תלויות חיצוניות.
השתמשו ב־Mocks פשוטים עם sinon.stub() ולא ב־Mocking מטורף.
בדיקות אינטגרציה רק איפה שצריך – תשתית, חיבורים חיצוניים.
אל תערבבו שכבות בטסט – כל טסט בודק רק שכבה אחת.

טעויות נפוצות שכדאי להימנע מהן

❌ כתיבה של טסט שמריץ Use Case ובו זמנית מתחבר ל־DB או רשת – תוצאה: בדיקה איטית, שברירית, ומבלבלת.
❌ בדיקות שלא ברורות מה הן בודקות – "עובד" אבל לא ברור מה נבדק באמת.
❌ שימוש ב־Mocks שמתחילים להיראות כמו קוד אמיתי – אם אתם כותבים Class מלא רק בשביל לבדוק – עצרו רגע.

לסיכום

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

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

רוצה לשתף?

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

כתבו לי בתגובות – תמיד שמח ללמוד מכם גם.

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

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

טעות #1: יצירת ממשקים מיותרים

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

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

לדוגמה, בפרויקט Node.js עם TypeScript, נתקלתי במקרים שבהם מגדירים ממשק UserRepository ואחריו מחלקה MongoUserRepository שמממשת אותו – למרות שאין שום מימוש אחר. הקוד נראה בערך כך:

interface UserRepository {
  findById(id: string): Promise<User | null>;
  save(user: User): Promise<void>;
}

class MongoUserRepository implements UserRepository {
  async findById(id: string): Promise<User | null> {
    // ... מממש חיפוש במסד נתונים MongoDB ...
  }
  async save(user: User): Promise<void> {
    // ... מממש שמירה במסד הנתונים ...
  }
}

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

איך להימנע מהטעות הזו? פשוט: לא להפוך הכל למופשט אם אין בכך צורך. אפשר להתחיל במחלקה קונקרטית (למשל מחלקת UserRepository ללא ממשק) ולהגדיר ממשק רק כשבאמת צריך אותו – למשל כשיש כמה מימושים, או כדי להקל על כתיבת בדיקות יחידה. אפשר תמיד להוסיף ממשק מאוחר יותר אם הפרויקט מתרחב, אבל קשה יותר להסיר מורכבות מיותרת אחרי שהיא נטמעה בקוד. כלל אצבע טוב הוא להימנע מ"Abstraction for the sake of abstraction" – אל תבצעו הפשטה רק כי "ככה עושים", אלא רק כשיש לה הצדקה מעשית. זה דורש תירגול וניסיון.

טעות #2: הפרדה קיצונית מדי

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

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

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

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

טעות #3: מיקום שגוי של לוגיקה

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

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

זה קטע קוד לדוגמה מroute ב-Express, שבו נעשתה טעות כזו:

// Express controller example (presentation layer)
app.post('/orders', async (req, res, next) => {
  try {
    const orderData = req.body;
    // בדיקת כלל עסקי בשכבת הקונטרולר - טעות!
    if (orderData.items.length === 0) {
      return res.status(400).json({ error: 'Order must include at least one item' });
    }
    const order = await OrderModel.create(orderData);  // שמירת ההזמנה ישירות דרך מודל הנתונים
    res.status(201).json(order);
  } catch (err) {
    next(err);
  }
});

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

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

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

טעות #4: שכבות סרק

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

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

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

class OrderService {
  constructor(private orderRepo: OrderRepository) {}
  
  async getOrderDetails(id: string) {
    // שכבת Service שלא מוסיפה שום לוגיקה משל עצמה
    return this.orderRepo.findById(id);
  }
}

במקרה הזה, המחלקה OrderService לא מבצעת שום עיבוד או לוגיקה משל עצמה. היא פשוט עוטפת קריאה ל-orderRepo. אם ה-controller שלנו קורא רק ל-OrderService.getOrderDetails, יכולנו באותה מידה לקרוא ישירות ל-orderRepo.findById ולוותר על השכבה הזאת לחלוטין.

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

טעות #5: חשיפת הדומיין למימושים חיצוניים

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

סימן אזהרה מיידי הוא אם אתם מוצאים בקוד הדומיין import של ספריות כמו Express, Mongoose, Axios או כל framework חיצוני אחר. למשל, אם יש לנו מחלקת ישות (Entity) שבנויה ישירות על מודל של ORM, או פונקציה בדומיין שקוראת ל-API חיצוני בעזרת ספריית HTTP – זה אומר שחשפנו את הדומיין שלנו למימוש חיצוני.

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

// טעות: מודל Mongoose המשולב בליבת הדומיין
import mongoose from 'mongoose';

const UserSchema = new mongoose.Schema({
  name: String,
  email: String
});
export const UserModel = mongoose.model('User', UserSchema);

// שימוש במודל החיצוני בתוך פונקציית דומיין:
export async function getUserName(id: string): Promise<string | null> {
  const userDoc = await UserModel.findById(id);  // תלות ישירה ב-Mongoose בתוך הדומיין
  return userDoc ? userDoc.name : null;
}

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

כדי להימנע מחשיפת הדומיין, עלינו להקפיד שהכיוון תמיד יהיה כזה: הדומיין מגדיר חוזים (ממשקים), והחוץ מממש אותם. למשל, במקרה של מסד נתונים הדומיין יכול להגדיר ממשק UserRepository מופשט, וההטמעה של Mongoose תיעשה בשכבת ה-Repository שב-Infrastructure. כך הלוגיקה העסקית (getUserName וכדומה) תקרא לפונקציות של UserRepository שאינן תלויות בטכנולוגיה, ואלו בתורן יקראו בפועל ל-Mongoose. באופן דומה, אם הדומיין צריך לקרוא ל-Service חיצוני (API למשל), נגדיר ממשק מתאים ונממש אותו בשכבה חיצונית. בקיצור, הדומיין נשאר "נקי" מטכנולוגיות, ומקבל רק את מה שהוא צריך.

סיכום: לאזן בין העקרונות לאפקטיביות היומיומית

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

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

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

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 ועוד. (אולי בהזדמנות אכתוב על זה פוסט נפרד).

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


פריימווארקים פופולארים לWEB בNodeJS – על קצה המזלג

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

הפופולארית ביותר – Express

התקנה באמצעות NPM

npm install express

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

const express = require('express');
const myServer = express();

myServer.get('/', (req, res) => {
 res.send('Welcome to our website');
});

myServer.get('/products', (req, res) => {
 res.send('Products list');
});

myServer.listen(4153, () => {
 console.log('Hello from express server');
});

כאשר משתמש יכנס לכתובת הראשית של הURL שלנו, הוא יקבל בדפדפן את ההודעה
Welcome to our website.
כאשר הגולש ייכנס לכתובת /products הוא יקבל בדפדפן את ההודעה Products list.

לאתר אקספרס – https://expressjs.com/


פריימוארקים פופולאריים נוספים:
1. KOAJS – לאתר ליחצו כאן
2. Sails – דומה מאוד לRails, היא MVC, עם אפשרויות מיוחדות לREST API's. ליחצו כאן לאתר
3. Meteor – עולם ומלואו, לאתר ליחצו כאן
4. וכמובן האהובה עלי ביותר: NEST, לאתר ליחצו כאן

שימוש בNodemon בסביבות הפיתוח שלנו

NodeMon מאפשר לנו לאתחל את שרת הNode שלנו בכל פעם שמתבצע שינוי בקובץ, יעיל מאוד והכרחי כאשר אנו מפתחים בNode.

על מנת להתקין את nodemon נריץ את הפקודה הבאה:

npm i -g nodemon

נניח והקובץ הראשי שלנו הוא main.js, כדי להריץ אותו באמצעות nodemon נריץ את הפקודה הבאה:

nodemon main.js

וזאת במקום הפקודה הרגילה שהיא:

node main.js

ועכשיו בכל פעם שנשנה את הקובץ main.js שלנו – nodemon יפעיל את עצמו מחדש ואנו נוכל לראות את השינויים שעשינו מיידית ללא צורך לאתחל מחדש את השרת.

יצירת שרת HTTP בNodeJS באמצעות הAPI http – על קצה המזלג

http – הוא בילטאין בNodeJS, לכן, לא נדרשת התקנה של שום ספרייה. בExpress למשל אנו נצטרך כמובן להתקין אותה באמצעות NPM.

//השורה הבאה מחזירה את ה
// API של 
// http
const myHttpAPI = require('http');
const myRequestListener = (req, res) => {
 res.end('Hello from NodeJS !!!');
}

//הפקודה הבאה יוצרת את השרת שלנו למעשה, אך לא מפעילה אותו
const myServer = myHttpAPI.createServer(myRequestListener);

//נאזין לקריאות לשרת שיצרנו מקודם בפורט 80 או כל פורט אחר שנבחר
myServer.listen(80, () => {
 console.log('YAY! The server is ready!');
});

זוכרים שדיברנו על Events?
בכל פעם שמתקבלת בקשה – Request לשרת שלנו, משודר למעשה EVENT בשם 'request'.
הפונקציה myRequestListener שלנו בעצם "מאזינה" לEvent 'request' ומופעלת בכל פעם שמגיע אלינו request חדש.

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


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

קלסטרים בNodeJS – הקדמה.

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

ניתן להריץ קלסטרים של נוד באמצעות מודל הCluster שהוא בילט-אין בNode. לחילופין ניתן ליצור קלסטר בשימוש בכלים כמו PM2 שיכול לעשות שימוש בכל הליבות של השרת שלנו בצורה אוטומטית (עם Load Balancer מובנה) ולהפעיל מחדש את האפלקציה שלנו ללא Downtime.

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

"אירועים" – EventEmitter בNodeJS על קצה המזלג

EventEmitter הוא מודל בילט אין בNodeJS, הוא מאפשר "האזנה" לאירועים ו"שידור" שאירוע קיים קרה לכל ה"מאזינים".

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

const EventEmitter = require('events');
const ourEmitter = new EventEmitter();

//נאזין לאירוע NEW_USER_REGISTER
//בכל פעם שהאירוע ישודר, הפונקציה הנל תרוץ
ourEmitter.on('NEW_USER_REGISTER'), () => {
 console.log('משתמש חדש נירשם למערכת');
}

//ניתן להאזין לאירוע ביותר ממקום אחד
ourEmitter.on('NEW_USER_REGISTER'), () => {
 console.log('שלח אימייל ברוך הבא');
}

//על מנת לשדר אירוע נריץ את הפקודה הבאה
ourEmitter.emit('NEW_USER_REGISTER');

הEventEmitter עוזר כאשר אנו צריכים לעדכן מודלים אחרים שאירוע מסויים קרה והוא שימושי מאוד, חשוב להכיר אותו!

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

תבניות אסינכרוניות ב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.


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