הידרציה של אובייקטים נראית כמו בעיה פתורה, עד שמתחילים למדוד אותה. אתם שולפים שורה מהדאטהבייס, ממפים את המערך לאובייקט, וממשיכים הלאה. רובנו מתייחסים לזה כאל "אינסטלציה" — משהו בלתי נראה, לא מרשים, ומה שמוחלט הוא שהוא מספיק מהיר. ואז יום אחד אתם מבצעים פרופיילינג (profiling) למשימת batch או לעובד בתור (queue worker) ומבחינים שחלק מפתיע מזמן ה-CPU נעלם לתוך המאפר (mapper). התובנה הזו היא בדיוק מה שהוביל ל-HydraType, הידרטור שנבנה סביב כלל אחד עקשן: מחלקה לעולם לא צריכה לשלם על תכונות שהמאפיינים שלה אינם משתמשים בהן.
אם שדה הוא מחרוזת פשוטה שאינה זקוקה לשום טרנספורמציה, הקוד שנוצר צריך להיראות כאילו אדם כתב אותו ידנית. השמה ישירה. ללא לולאות, ללא פייפליינים וללא רפלקציה.
מה ההידרציה באמת עולה
במבט ראשון, הפיכת ['name' => 'Alice', 'age' => 30] לאובייקט User נראית טריוויאלית. הבעיה מתחילה כשרוצים גמישות. רוב ההידרטורים בעלי השימוש הכללי נשענים על רפלקציה כדי לבחון מחלקות בזמן ריצה. הם בונים מפות מטא-דאטה, מנהלים המרות טיפוסים (type coercion) ופותרים גרפי אובייקטים מקוננים. הם גם אוהבים פייפליינים. כל ערך עובר דרך רצף של מטאטורים (mutators), אזרשנים (assertions) וטרנספורמרים, גם אם הרצף הזה ריק. מבנה הלולאה עצמו מוסיף overhead.
תמשכו כמה עשרות רשומות ולא תשימו בזה לב. אך אפליקציות PHP מודרניות מעבדות באופן שגרתי אלפי הודעות בתור, מייבאות קובצי CSV מאסיביים, או מבצעות הידרציה של סטים עמוקים של תוצאות מ-Elasticsearch. בתרחישים כאלה, מאפר ששורף אפילו כמה מיקרו-שניות לכל שדה הופך לצוואר בקבוק אמיתי. אלף אובייקטים עם שמונה שדות כל אחד מכפילים את זה למס שמרגישים בכל פעולה.
הפילוסופיה של אפס overhead
הרעיון המרכזי מאחורי HydraType הוא בידוד תכונות. המרת טיפוסים, הידרציה של אובייקטים מקוננים ואזרשנים נתמכים כולם, אך אף אחד מהם אינו חובה. אם מאפיין זקוק לכלום מלבד העתקה ישירה ממערך לאובייקט, ה-writer שנוצר מכיל בדיוק את זה. אין פייפליין משותף שמאלץ שדה סקלרי פשוט לחכות בתור אחרי ולידטורים שהוא בכלל לא צריך.
זה קשה יותר להשגה ממה שזה נשמע. ספריות רבות חולקות נתיב ביצוע יחיד עבור כל שדה כדי לשמור על בסיס קוד קטן ואחיד. HydraType נוקטת בגישה ההפוכה. היא מייצרת מחלקת PHP ייחודית עבור כל DTO יעד, תוך הפקת הפעולות המדויקות שכל מחלקה ספציפית דורשת. המורכבות הופכת לבחירה מפורשת (opt-in), ולא למס גורף על כל מאפיין.
איך זה קורה מבחינת מהירות
ארבע בחירות קונקרטיות מפרידות את HydraType הן מהכלים הכלליים מבוססי הרפלקציה ואפילו מגישות אחרות מבוססות קוד גנרטיבי.
1. ייצור קוד פעם אחת, הרצה לנצח
HydraType בוחנת את מחלקת היעד שלכם פעם אחת בלבד, ואז כותבת מחלקת PHP ייעודית המותאמת בדיוק למבנה שלה. אם ה-DTO שלכם מכיל מחרוזת ציבורית $name, מספר שלם $status ואובייקט DateTime פרטי $createdAt, ה-writer שנוצר יודע את כל זה מראש. אין רפלקציה חוזרת, אין ניתוח מחרוזות ואין בניית מטא-דאטה עצלנית בזמן ריצה. ברגע ש-OPCache מקמפל את הקובץ שנוצר, ההידרטור אינו ניתן להבחנה מ-PHP שנכתב ידנית.
2. ביטול פייפליינים ריקים
רוב ההידרטורים בונים את עבודתם סביב פייפליין בזמן ריצה. הם מחזיקים מערך של callables — מטאטורים, ולידטורים, טרנספורמרים — ומבצעים איטרציה על כל ערך. גם כשהמערכים הללו ריקים, ה-foreach או ה-array_reduce עדיין מתבצעים. HydraType מסירה זאת לחלוטין. במהלך יצירת הקוד, היא בודקת מה מאפיין באמת דורש. אם רשימת הפעולות ריקה, היא מפיקה השמה פשוטה כמו $object->name = $data['name'];. בזמן הריצה המערכת לעולם לא מבצעת איטרציה, כי הגנרטור כבר עשה את החשיבה עבורה.
3. שימוש ב-Closure Scoping כדי לחסל כתיבות רפלקציה
מאפיינים פרטיים (private properties) הם כאב ראש קלאסי עבור מאפרים חיצוניים. פתרון המילוט הרגיל הוא ReflectionProperty::setValue(), אך למתודה זו יש מחיר כבד בהשוואה לגישה ישירה למאפיין. HydraType עוקפת זאת באמצעות שימוש ב-Closure::bind. ה-writer שנוצר מכיל closures המוגדרים בתוך ה-scope של מחלקת היעד, מה שמאפשר להם לגעת במאפיינים פרטיים ומוגנים (protected) ישירות ללא רפלקציה. הקישור (binding) מתבצע פעם אחת במהלך הבנייה; לאחר מכן, ה-closure רץ במהירות קרובה למהירות מקומית (native).
4. שימוש חוזר ב-Writer
חלק מהספריות יוצרות אסטרטגיה חדשה או גרף רפלקציה עבור כל אובייקט שהן הידרטורות. HydraType יוצרת את ה-writer שלה פעם אחת, ואז משתמשת באותו מופע (instance) עבור אלפי אובייקטים. העלות לכל אובייקט יורדת למינימום ההכרחי של קביעת ערכי המאפיינים והחזרת התוצאה.
המספרים
ב-PHP 8.2, ההבדל הוא עצום:
- HydraType: 267.9 ns
- Ocramius GeneratedHydrator: 307.9 ns
- Symfony PropertyNormalizer: 8,499.0 ns
- Valinor: 10,755.2 ns
Symfony PropertyNormalizer ו-Valinor הם כלים חזקים, אך הם כלליים. PropertyNormalizer הוא חלק מרכיב serializer שמטפל בזיהוי פורמט, normalizers וגרפי אובייקטים עמוקים. Valinor מקפיד מאוד על עצי טיפוסים (type trees) והמרות (coercion). לכוח הזה יש מחיר. בבדיקה זו, הם היו איטיים בערך פי שלושים עד ארבעים מ-HydraType. אפילו Ocramius GeneratedHydrator, שכבר משתמש בייצור קוד (code generation), נמצא מעט מאחור בגלל הבדלים ארכיטקטוניים סביב pipelines וגישה למאפיינים (property access).
ההקשר חשוב. שאילתת בסיס נתונים בודדת או סבב HTTP (roundtrip) אחד הופכים את כל המספרים הללו לזניחים. אם אתם מבצעים hydration לשלושה אובייקטים לאחר שאילתה, רדיפה אחרי ננו-שניות היא בזבוז אנרגיה. הפער הופך למשמעותי כשמבצעים scaling. תהליך batch שמבצע hydration לחמישים אלף רשומות, או צרכן תור (queue consumer) שבונה מחדש אובייקטים מתוך מערכי cache, ירגישו את ההבדל בין 267 ננו-שניות לעשר מיקרו-שניות. כשמכפילים זאת על פני שדות ושורות, ה-mapper המהיר יותר יכול לחסוך שניות שלמות מעבודה שלמה מבלי לשנות שום לוגיקה עסקית.
השורה התחתונה
השיעור כאן הוא לא שכל פרויקט זקוק ל-hydrator מותאם אישית. השיעור הוא שבחירות ארכיטקטוניות מצטברות. על ידי יצירת קוד הכולל רק את מה שביקשתם, HydraType שומר על מהירות של מאפיינים פשוטים (plain properties) תוך שהוא עדיין מציע פתח מילוט (escape hatch) עבור מאפיינים מורכבים. המרות טיפוסים ואובייקטים מקוננים קיימים כשצריך אותם, אך הם לא כופים את עלות הביצועים על כל מחרוזת סקלרית (scalar string) באובייקט.
לפני שאתם מחליפים כלים, בצעו profiling. אם ה-hydration לא מופיע ב-traces של העולם האמיתי שלכם, אין מה לתקן. אך אם אתם דוחפים מערכי נתונים גדולים דרך mappers כלליים ורואים את ה-CPU נשרף, זכרו ש-assignment פשוט ב-PHP עדיין קיים. לפעמים הקוד המהיר ביותר הוא פשוט הקוד שהייתם כותבים בעצמכם, כזה שנוצר פעם אחת ואז נשכח.
