ארכיון

ארכיון של מאי, 2011

Regex מיתולוגי

מתי כדאי להשתמש ב Regex כדי לוודא קלט?
מתי זה לא מומלץ?
ואיפה עובר הגבול?
ואולי יש אלטרנטיבות?

תור הזהב של ה Regex

בשנים האחרונות יש הייפ משמעותי ל Regex (או בעברית, "ביטוי רגולרי", אבל אני אשתמש ב"רגקס"), בעיקר סביב וידוא קלט. נראה לי שהסיבה העיקרית לזה היא שמיקרוסופט שיבצו מספר פקדים (Controls) לוידוא קלט כחלק ממערכת ה UI הבסיסית של ה WebForms כבר מויז'ואל סטודיו 2002 (או אולי 2003). הפקדים האלה הם די פשוטים ומוגבלים: הם יכולים לוודא שערך נמצא בטווח כלשהו, או שהוא קיים וכו'. רק שניים מהם נותנים אפשרויות מתקדמות: פקד CustomValidator, שמאפשר פונקציות בדיקה מוגדרות ע"י המפתח עצמו (כחלק מהאפליקציה), ופקד RegularExpressionValidator, שמאפשר לבדוק מול רגקס את הקלט.

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

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

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

דוגמה – כתובת מייל

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

^((?>[a-zA-Z\d!#$%&'*+\-/=?^_`{|}~]+\x20*|"((?=[\x01-\x7f])[^"\\]|\\[\x01-\x7f])*"\x20*)*(?<angle><))?((?!\.)(?>\.?[a-zA-Z\d!#$%&'*+\-/=?^_`{|}~]+)+|"((?=[\x01-\x7f])[^"\\]|\\[\x01-\x7f])*")@(((?!-)[a-zA-Z\d\-]+(?<!-)\.)+[a-zA-Z]{2,}|\[(((?(?<!\[)\.)(25[0-5]|2[0-4]\d|[01]?\d?\d)){4}|[a-zA-Z\d\-]*[a-zA-Z\d]:((?=[\x01-\x7f])[^\\\[\]]|\\[\x01-\x7f])+)\])(?(angle)>)$

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

דוגמה נוספת – מספר טלפון בישראל

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

^(([0]([2|3|4|8|9|72|73|74|76|77])))[2-9]\d{6,7}$

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

03 6382020

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

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

וידוא קלט בקוד

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

namespace Electron.Validation
{
  public static class CharExt
  {
    public static bool IsDigit(this char c)
    {
      return c >= '0' && c <= '9';
    }
  }
 
  public class PhoneNumber
  {
    public static bool IsValid(string s)
    {
      if (null == s)
        return false;

      var validPrefixes = new[] {
                    "02",
                    "03",
                    "04",
                    "08",
                    "09",
                    "072",
                    "073",
                    "074",
                    "076",
                    "077",
                    "050",
                    "052",
                    "054"
                    };
      int prefixLength = 0;
      var foundMatchingPrefix = false;
      foreach (var validPrefix in validPrefixes)
        if (s.StartsWith(validPrefix))
        {
          prefixLength = validPrefix.Length;
          foundMatchingPrefix = true;
          break;
        }

      if (!foundMatchingPrefix)
        return false;

      // remaining length should be between 6 and 7
      var remainingLength = s.Length prefixLength;
      if (!((6 == remainingLength) || (7 == remainingLength)))
        return false;

      // check the rest of the string to be digits
      for (int i = prefixLength; i < s.Length; i++)
        if (!s[i].IsDigit())
          return false;
     
      return true;
    }
  }
}

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

public class PhoneNumber
{
  public static bool IsValid(string s)
  {
    if (null == s)
      return false;

    var validPrefixes = new[] {
                  "02",
                  "03",
                  "04",
                  "08",
                  "09",
                  "072",
                  "073",
                  "074",
                  "076",
                  "077",
                  "050",
                  "052",
                  "054"
                  };
    int prefixLength = 0;
    var foundMatchingPrefix = false;
    foreach (var validPrefix in validPrefixes)
      if (s.StartsWith(validPrefix))
      {
        prefixLength = validPrefix.Length;
        foundMatchingPrefix = true;
        break;
      }

    if (!foundMatchingPrefix)
      return false;

    if (s.Length == prefixLength)
      return false;

    int nextDigitIndex;
    if (s[prefixLength] == ' ')
      nextDigitIndex = prefixLength + 1;
    else
      nextDigitIndex = prefixLength;

    // remaining length should be between 6 and 7
    var remainingLength = s.Length nextDigitIndex;
    if (!((6 == remainingLength) || (7 == remainingLength)))
      return false;

    // check the rest of the string to be digits
    for (int i = nextDigitIndex; i < s.Length; i++)
      if (!s[i].IsDigit())
        return false;
     
    return true;
  }
}

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

קוד כפול

האמת היא שאם אנחנו חוזרים להקשר של WebForms, אז צריך לכתוב קוד שיבצע את הוידוא הזה גם ב JavaScript. כך שמבחינת תחזוקת קוד, נצטרך לתחזק שתי פונקציות: אחת ב C# ואחת ב JavaScript, וזה חסרון עצום, כי אם הלוגיקה משתנה, צריך לשנות את הקוד בשני חלקים שונים לחלוטין של אותה המערכת, וקיימת האפשרות ששני צוותים שונים לגמרי ינהלו את החלקים האלה. לכן הפתרון של פקד רגקס הוא יותר נוח לתחזוקה, כי רגקס זו פעולה שהיא חלק מדוט נט (C# או vb.net או whatever.net) וגם חלק מ JavaScript.

פתרון אפשרי: המרה אוטומטית

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

1. SharpKit, שלמדתי להכיר אותו במפגש של ALT.NET. זה כלי מסחרי, עם תמחור משתנה (חינם לשימוש אישי). לדבריהם:

When you work with SharpKit, you write C# instead of JavaScript

מעניין.

2. Script#, כלי שכבר קיים מספר שנים. העקרון די דומה. המימוש קצת שונה.

אז מתי להשתמש ב Regex?

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

  1. הקלט סטנדרטי (כמו כתובת מייל או URL), או שאינו צפוי להשתנות גם בעתיד הרחוק
  2. קל למצוא (או לכתוב) תבנית רגקס לקלט
  3. הרגקס נותן מענה ביותר מסביבה אחת. למשל, אם הוא משותף גם ל client וגם ל server (כפי שמתקיים בפיתוח אפליקציות web)
  4. ברור לי (ולצוות הפיתוח) שאם התבנית לא תקינה – מחליפים אותה באחרת ולא מנסים לדבג אותה

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

אלטרנטיבות – תוכנות עזר

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

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

Some people, when confronted with a problem, think "I know, I'll use regular expressions."
Now they have two problems.

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

אלטרנטיבות – DSL

למעשה הסינטקס של רגקס הוא די מכוער. אין לי שום עניין לתחזק גיבוב של תווים שכוללים בקסלשים (\) ופייפים (|), עם דולר ($) וכובע (^) בהתחלה/בסוף, ויש גם הרבה סוגריים מכל הסוגים. ואחרי כל זה, אני צריך להתפלל שזה יעבוד. לו רק היה איזה כלי שיתן לי סינטקס נעים יותר לעבוד איתו, איזה DSL שיתן לי אבסטרקציה לכל הסיפור הזה, חיי היו הרבה יותר קלים.

עם תקווה בעיניים ובאצבעות רועדות, גיגלתי קצת regex dsl, ועל פניו נראה שיש משהו שפיתח אחד בשם Joshua Flanagan: הוא מציע DSL די בסיסי שמחליף את הסינטקס של הרגקס. בדקתי ושיחקתי קצת, והצלחתי להגיע למשהו לא רע. הנה קוד לבניית התבנית שבודקת תקינות של מספר טלפון בישראל, לפני התוספת של רווחים ומקף:

Pattern phoneNumber = Pattern.With
  .AtBeginning.Choice.Either(
    Pattern.With.Literal("02"),
    Pattern.With.Literal("03"),
    Pattern.With.Literal("04"),
    Pattern.With.Literal("08"),
    Pattern.With.Literal("09"),
    Pattern.With.Literal("072"),
    Pattern.With.Literal("073"),
    Pattern.With.Literal("074"),
    Pattern.With.Literal("076"),
    Pattern.With.Literal("077"),
    Pattern.With.Literal("050"),
    Pattern.With.Literal("052"),
    Pattern.With.Literal("054")
    )
  .Digit.Repeat.InRange(6,7).AtEnd;

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

Pattern phoneNumber = Pattern.With
  .AtBeginning.Choice.Either(
    Pattern.With.Literal("02"),
    Pattern.With.Literal("03"),
    Pattern.With.Literal("04"),
    Pattern.With.Literal("08"),
    Pattern.With.Literal("09"),
    Pattern.With.Literal("072"),
    Pattern.With.Literal("073"),
    Pattern.With.Literal("074"),
    Pattern.With.Literal("076"),
    Pattern.With.Literal("077"),
    Pattern.With.Literal("050"),
    Pattern.With.Literal("052"),
    Pattern.With.Literal("054")
    )
  .WhiteSpace.Repeat.Optional
  .Literal("-").Repeat.Optional
  .WhiteSpace.Repeat.Optional
  .Digit.Repeat.InRange(6,7).AtEnd;

הקוד הרבה יותר מובן, ולכן קל לתחזוקה ולשינוי. עם זאת, הסינטקס של ה DSL הזה מורכב מדי לטעמי (או שפשוט צריך להתרגל אליו?). אגב, כדי להגיע לתוצאה הזו הוספתי מתודה ושמתי ב pastebin.
בכל מקרה, אם אני מבין נכון, הפרויקט הזה של Flanagan, כפי הוא עצמו מסביר, הוא בגדר DSL נסיוני בלבד, והוא לא הפך את הנסיון הזה לפרויקט שיתופי, ומעולם לא ניסה את הקוד בפרודקשן. חבל, דווקא יש לזה פוטנציאל.
מי שדווקא כן המשיך עם הרעיון של Flanagan ולקח אותו לכיוון של Linq to Regex הוא רועי אושרוב, מדטנט ידוע.

אז מה נסגר?

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

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

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

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

ועד אז, עזבו אותי מוידוא קלט עם רג-אקס, מבחינתי זה די דרעק-אקס 🙂

קטגוריות:תכנות תגיות:, , ,
Quantcast