פיתוח מונחה בדיקות בשפת קוטלין – חלק 2

על מה הפוסט?

זהו המשך לחלק הראשון (מומלץ לקרוא או לעיין בקצרה). בחלק זה נוסיף לאפליקציה קריאה לשירות REST חיצוני ע”י שימוש בתכנות תגובתי (Reactive Programming). הפוסט נוקט בגישה פרקטית, כלומר מדגים שימוש ומספק הסבר קצר בלבד על כל תכונה/טכנולוגיה סביב השימוש בה. מי שרוצה להעמיק מוזמן לעיין בקישורים למטה.

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

יש פרויקט מלווה בGithub. מי שרוצה להעמיק מוזמן להוריד את הפרויקט, לעבור לbranch בשם part2, למחוק את הקוד (להשאיר רק את הטסטים) ולממש בעצמו.

מה זה תכנות תגובתי?

תכנות תגובתי (Reactive Programming) כשמו כן הוא – מודל שמבוסס על תגובה לאירועים. בפועל זה מתבצע ע”י שימוש בזרמים תגובתיים (Reactive Streams), סוג של זרמים אסינכרוניים שמאפשרים לנו לעבוד בצורה מונחית אירועים. נגיב כאשר המידע הדרוש לנו מוכן ע”י הצהרה על קוד (callback) שיקרא ע”י המודל בתגובה לאירוע מסוים. שימו לב שניתן למדל כל פעולה בצורה זאת, כולל קריאת רשת ואפילו קליק בעכבר.

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

המודל מאפשר Non blocking IO (או בקיצור NIO) בצורה טבעית. בפעולת IO רגילה הנים (thread) תפוס (busy wait) בזמן שהוא מחכה למידע מהרשת. בפעולת NIO המצב שונה, אנחנו נעזרים ביכולות של מערכת ההפעלה ומספקים callback שמופעל כאשר יש מידע זמין לקריאה. הצורך במודל זה נובע מכך שמספר הנימים המקסימלי האפשרי חסום בכמות הזיכרון בעוד שמספר חיבורי הרשת הפעילים יכול להיות גבוה הרבה יותר. כך שמתאפשר לטפל בכמות גדולה הרבה יותר של פניות עם אותה החומרה.

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

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

המשך פיתוח האפליקציה מהחלק הראשון

קריאה לשירות חיצוני

כזכור, האפליקציה שבנינו בחלק הראשון מספקת מידע על אחסון של אתרים (ISP) וכרגע מחזירה מידע דמה סטטי. כעת, נבנה מחבר (connector/client) לאתר חיצוני (ip-api) שממנו נשאב את המידע עבור דומיין מסוים.

ראשית נרשום טסט:

import io.kotlintest.shouldBe
import io.kotlintest.specs.StringSpec

class ConnectorTests: StringSpec({
    "ip-api returns correct hosting details" {
        IpApiConnector().invoke("codejunkie.blog") shouldBe
                HostingDetails(isp = "GoDaddy.com, LLC1", country = "United States")
    }
})

נשתמש בספריית KotlinTest (הסבר בחלק הראשון) שמאפשרת מתן שם טסט אינפורמטיבי בשפה טבעית וטסט בצורת DSL. בקוטלין ויתרו על new – בשורה 7 אנחנו יוצרים מופע חדש של HostingDetails ומשווים אותו לתוצאה המוחזרת מinvoke ע”י שימוש במתודה shouldBe.

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

class ConnectorTests: StringSpec({
    "ip-api return correct hosting details" {
        IpApiConnector().invoke("codejunkie.blog").shouldBe(
                HostingDetails(isp = "GoDaddy.com, LLC", country = "United States"))
    }
})

ה’קסם’ שמאפשר את הDSL הנקי מתבצע ע”י הגדרת הפונקציה shouldBe כפונקציית infix, שמאפשרת (תחת מספר מגבלות) להגדיר פונקציה שמקבלת משתנה ללא סוגריים (במקרה שלנו את HostingDetails) ואפילו לוותר על הנקודה ה’מקשרת’ מהפונקציה invoke שאותה היא מרחיבה וכך מתאפשר התחביר הנקי בדוגמה הראשונה.

כעת יש לנו קוד שלא מתקמפל, נעמוד על כל אחת מהשגיאות בטסט ונעזר בIntellij (ע”י alt+enter) כדי ליצור את הקוד החסר (שלד בלבד כמובן). קיבלנו את זה:

class IpApiConnector {
    fun invoke(domain: String): Any {
        TODO("not implemented") //To change body of created functions use File | Settings | File Templates.
    }
}

class HostingDetails(isp: String, country: String) {

}

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

נמלא את המחלקה IpApiConnector בקוד הדרוש כדי להעביר את הטסט:

class IpApiConnector {
    private val client = WebClient.create("http://ip-api.com/json/")

    fun invoke(domain: String) = client.
            get().
            uri(domain).
            retrieve().
            bodyToMono(DomainDetails::class.java).
            block()
}

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

שורה 2- ניצור מופע של WebClient (כזכור ללא new), עם כתובת בסיס (base url) שמצביעה לשירות החיצוני שאליו נרצה לגשת.

WebClient היא מחלקה שנוספה בספרינג 5.0 כחלק מספריית הWebFlux של ספרינג. הספרייה מבוססת על Project Reactor (ספרייה לתכנות תגובתי) ומאפשרת הוצאת קריאות HTTP בצורה תגובתית. העבודה עם WebClient ועם הAPI של Project Reactor היא בFluent API, שמאפשר שרשור פעולות ע”י החזרת this מכל מתודה.

שורה 5- מצהירים על פעולת get – כלומר GET HTTP Method. עדיין לא נשלחת קריאה בפועל.

שורה 6- נעביר URI שיתחבר לכתובת הבסיס שהגדרנו בשורה 2, כך שיתקבל http://ip-api.com/json/$domain, שהוא הURL הדרוש לצורך קבלת מידע עבור הדומיין מip-api.

שורה 7- מסיימת את הבקשה (בפועל עדיין לא מתבצעת קריאה)

שורה 8- נצהיר על המרה של התשובה (response body) שתחזור מהקריאה לאובייקט מטיפוס DomainDetails, שתבוצע בפועל ע”י שימוש בספריית Jackson המוכרת לצורך המרת הJSON החוזר מהקריאה. אובייקט זה נעטף בMono שהוא האובייקט שחוזר בפועל בשורה שמונה.

Mono הוא publisher לזרם תגובתי (reactive stream), זהו אובייקט מרכזי מProject Reactor שעליו מבוסס WebFlux. כדי שתתבצע קריאה בפועל יש צורך בצרכן (subscriber) שימשוך נתונים מהזרם/Mono. אדגיש שוב, ללא צרכן לא תתבצע הקריאה לip-api. כלומר, עד לשלב זה (כולל) בעצם הצהרנו על pipeline לביצוע אבל לא התבצעה קריאה בפועל.

שורה 9- נבצע block, שיעצור את הנים הנוכחי עד שתחזור תוצאה. זה משהו שלא נרצה לעשות לעולם בתכנות תגובתי, מכיוון שזה סותר את כל הרעיון שמאפשר להגיב לאירוע ולקבל Non blocking IO… אנחנו עושים את זה כאן באופן זמני, כדי להקל על ההבנה. ע”י ביצוע block אנחנו בעצם מייצרים צרכן לזרם שיחכה עד שיהיו נתונים בזרם (כלומר תשובה משירות הREST של ip-api).

נריץ שוב את הטסט ונגלה שהוא עדין נכשל עם השגיאה הבאה:

java.lang.AssertionError: expected: blog.codejunkie.demo.controller.HostingDetails@530dbe64 but was: blog.codejunkie.demo.controller.HostingDetails@3a2b04a9
Expected :blog.codejunkie.demo.controller.HostingDetails@530dbe64 
Actual   :blog.codejunkie.demo.controller.HostingDetails@3a2b04a9

מניח שזו שגיאה די מוכרת למתכנתיי ג’אווה מנוסים – לא מימשנו equals בHostingDetails וההשוואה נכשלה. בקוטלין יש פתרון פשוט, שימוש במילה שמורה data לפני הגדרת המחלקה:

data class HostingDetails(val isp : String, val country : String)

ע”י שינוי פשוט זה שינינו את המחלקה למחלקת נתונים (data class) שמייצרת מימוש אוטומטי של equals, hashCode ואפילו toString. השימוש בval לפני הגדרת התכונות של המחלקה מסמן שלא ניתן לשנות ערכים אחרי ההצבה (שקול לfinal בג’אווה). נריץ את הטסט – עובר!

לסיכום, הקוד למחבר (connector) נראה כך (הוספנו גם אנוטציה של Component כדי שנוכל לחווט לcontroller בהמשך):

@Component
class IpApiConnector {
    private val client = WebClient.create("http://ip-api.com/json/")

    fun invoke(domain: String) = client.get().uri(domain).retrieve().
                                 bodyToMono(HostingDetails::class.java).block()
}

data class HostingDetails(val isp : String, val country : String)

עדין יש בעיה בקוד – כזכור ביצענו block בקוד פרודקשיין! נתקן ע”י העברה של המתודה block מהקוד לטסט:

class IpApiConnector {
    private val client = WebClient.create("http://ip-api.com/json/")

    fun invoke(domain: String) = client.get().uri(domain).retrieve().bodyToMono(HostingDetails::class.java)
}

data class HostingDetails(val isp : String, val country : String)

class ConnectorTests: StringSpec({
    "ip-api return correct hosting details" {
        IpApiConnector().invoke("codejunkie.blog").block() shouldBe
                HostingDetails(isp = "GoDaddy.com, LLC", country = "United States")
    }
})

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

נריץ את הטסט ונבצע commit. למי שלא עבד בKotlin אני ממליץ לשכפל את הפרויקט בGitHub, לעבור לcommit ולהחזיר את הblock לקוד הפרודקשיין. עכשיו תעשו את אותו דבר בגא’ווה ותוכלו להעריך כמה זמן הtype inference של קוטלין יכול לחסוך לכם וכמה גמישות מתאפשרת.

חיווט המחבר (Connector)

כעת נרצה לחווט את המחבר (Connector) כך שיקרא על-ידי הController. ראשית, נזכיר שבשלב זה המימוש של הcontroller קבוע ונראה כך (בדיוק כמו ש’עזבנו’ אותו בחלק הראשון של הפוסט):

@GetMapping("/isp")
fun ispDetails() = mapOf(
    "country" to "United States",
    "isp" to "GoDaddy.com, LLC"
)

 נשנה את הטסט האינטגרטיבי שהגדרנו בחלק הראשון:

"codejunkie.blog return the correct details" {
    given {
        on {
            get("/isp?domain=codejunkie.blog") itHas {
                statusCode(200)
                body("country", equalTo("United States"))
                body("isp", equalTo("GoDaddy.com, LLC"))
            }
        }
    }
}

"google.com return the correct details" {
    given {
        on {
            get("/isp?domain=google.com") itHas {
                statusCode(200)
                body("country", equalTo("United States"))
                body("isp", equalTo("Google"))
            }
        }
    }
}

הטסט משתמש בRestAssured שהכרנו בחלק הראשון. הוספנו פרמטר domain לבקשה (במקום הURI הקבוע “isp/” מחלק אחד) וטסט נוסף, כדי שנוכל לבדוק התנהגות שונה לdomains שונים. הטסט הראשון שולח את codejunkie.blog ומצפה לאותה תשובה כמו קודם (כלומר, טסט זה יעבור עם המימוש הנוכחי של הcontroller), בעוד שהטסט השני שולח את google.com ומצפה לתשובה שונה. נריץ את הטסט ונראה שהוא אכן עובר עבור codejunkie ונכשל כצפוי עבור google.com.

נעשה גם שינוי קטנטן בבדיקת קצה לקצה:

"isp details service passes sanity - DSL" {
    given {
        on {
            get("/isp?domain=codejunkie.blog") itHas {
                statusCode(200)
            }
        }
    }
}

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

כדי לגרום לטסט לעבור עבור google.com נחליף את התשובה הסטטית בקריאה למחבר שלנו על מנת לקבל תשובה דינמית (שתהיה שונה עבור כל דומיין). קלי קלות בקוטלין:

@RestController
class IspController(val connector : IpApiConnector) {

    @GetMapping("/isp")
    fun ispDetails(@RequestParam() domain: String) = connector.invoke(domain)
}

בשורה 5 אנחנו קוראים למחבר ומחזירים ישירות את האובייקט שחוזר ממנו, זהו אובייקט מטיפוס Mono שכזכור מצריך צרכן כדי לפעול. קודם ‘יצרנו’ צרכן זה ע”י ביצוע block במחבר עצמו, כעת הצרכן יווצר ע”י ספרינג שבעצם יפעיל את המתודה subscribe של המחלקה Mono (אחת ההעמסות שלה) על המונו שחוזר ויעביר לה consumer מתאים שימיר את האובייקט לתשובה מתאימה (כלומר לJSON שיחזור ללקוח שקרא לcontroller). למען הסדר הטוב, כך נראית מתודת subscribe של Mono:

public abstract class Mono<T> implements Publisher<T> {
....
....

  public final Disposable subscribe(Consumer<? super T> consumer) {
     ....
  }

...
...
}

מתודה זאת מקבלת callback (מטיפוס consumer) שנקרא ע”י התשתית של Reactor כאשר קיימים נתונים בזרם התגובתי. ספרינג בעצם מספק את הקוד שיופעל כאשר קיימים נתונים בזרם, כלומר כאשר קיים אירוע (event) שיש להגיב אליו.

אציין שניתן להשתמש בתחביר יותר פונקציונלי עבור הcontroller (שנוסף בספרינג 5) ועובד יפה בקוטלין. במקרה זה, לדעתי הקוד יותר ברור במבנה ה’ישן’. למי שבכל זאת מתעניין יש דוגמה לתחביר זה בGithub.

נריץ את הטסט האינטגרטיבי, אחרי שנראה שעבר נעלה את האפליקציה ונריץ את בדיקת הקצה לקצה ונבצע commit נוסף.

תמיכה במספר שרתים באותה בקשה

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

"isp details service passes sanity for multiple hosts as input" {
    given {
        jsonBody(mapOf("domains" to arrayOf("codejunkie.blog", "google.com")))
        on {
            post("/isp") itHas {
                statusCode(200)
            }
        }
    }
}

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

curl -X POST \
  http://localhost:8080/isp \
  -d '{"domains" : ["codejunkie.blog","google.com"]}'

שימו לב כמה קל ליצור את המבנה הנ”ל ע”י שימוש בmapOf וarrayOf של קוטלין.

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

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

"codejunkie.com and google.com return the correct details" {
    given {
        jsonBody(mapOf("domains" to arrayOf("codejunkie.blog", "google.com")))
        on {
            post("/isp") itHas {
                statusCode(200)
                body("[0].country", CoreMatchers.equalTo("United States"))
                body("[0].isp", CoreMatchers.equalTo("GoDaddy.com, LLC"))
                body("[1].country", CoreMatchers.equalTo("United States"))
                body("[1].isp", CoreMatchers.equalTo("Google"))

            }
        }
    }
}

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

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

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

נותר רק להעביר את הטסטים… נוסיף לcontrollers את הקוד הבא:

@PostMapping("/isp")
fun multipleDomainsDetails(@RequestBody() multipleHostsRequest: MultipleHostsRequest) =
        Flux.concat(multipleDomainsRequest.domains.map(connector::invoke))

data class MultipleDomainsRequest(val domains: List<String>)

מה בעצם קורה כאן? אנחנו רוצים לקרוא למחבר (connector) עבור כל אחד מהדומיינים ובכך לקבל תשובה עבור כולם באותה הבקשה. נעשה זאת ע”י שימוש בפונקציית map שממפה (ממירה) את הדומיין למונו שחוזר מהמתודה invoke של המחבר (map מקבלת למבדה), כך שFlux.concat תקבל רשימה של Monos (שחזרו מconnector.invoke) ותפעיל אותם סדרתית. בהמשך נראה כיצד ניתן בשינוי פשוט למקבל את הקריאות.

map שקולה למתודה domains.stream().map בג’אווה רק בהרבה פחות קוד. כלומר, בג’אווה היינו עושים משהו כזה:

    List<Mono> monos = multipleDomainsRequest.domains.
            stream().
            map(connector::invoke).
            collect(Collectors.toList())
    return Flux.concat(monos)

מי שלא מכיר תכנות פונקציונלי ועבודה עם streams בג’אווה מוזמן לקרוא כאן.

שינוי מבנה התשובה

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

נשנה את הבדיקה האינטגרטיבית לצפות למפה (משם הדומיין למידע עבורו):

"codejunkie.com and google.com return the correct details" {
    given {
        jsonBody(mapOf("domains" to arrayOf("codejunkie.blog", "google.com")))
        on {
            post("/isp") itHas {
                statusCode(200)
                body("'codejunkie.blog'.country", equalTo("United States"))
                body("'codejunkie.blog'.isp", equalTo("GoDaddy.com, LLC"))
                body("'google.com'.country", equalTo("United States"))
                body("'google.com'.isp", equalTo("Google"))

            }
        }
    }
}

רק כדי להבהיר, זוהי התשובה שהטסט מצפה לה:

{
    "codejunkie.blog": {
        "isp": "GoDaddy.com, LLC",
        "country": "United States"
    },
    "google.com": {
        "isp": "Google",
        "country": "United States"
    }
}

נשנה את הקוד כדי להעביר את הטסט:

@PostMapping("/isp")
fun multipleDomainsDetails(@RequestBody() request: MultipleHostsRequest) =
  Flux.concat(request.domains.map { domain ->
  connector.invoke(domain).map { data -> mapOf(domain to data) }
  }).reduce({ a, b -> a.plus(b) })

נעבור על הקוד:

שורה 3- מתחילים כמו קודם, request.domains.map ממירה את הdoamin למונו שחוזר מconnector.invoke.
שורה 4- ממירה את המונו למפה שנוצרת ע”י mapOf (לא להתבלבל עם map שממפה/ממירה מאובייקט אחד לאחר) שבה ערך אחד, כך שהמפתח הוא הדומיין והערך הוא המידע עבור דומיין זה כפי שיחזור מהמונו. לאחר הקריאה לconcat נקבל Flux שיכול להכיל מספר ערכים, כלומר מספר מפות.
שורה 5- נאחד את כל המפות (הרשימה שחזרה מconcat) למפה אחת (עטופה במונו) ע”י הפעלת פונקציית reduce על הFlux ושימוש בפונקציה plus שקוטלין מוסיפה לmap (מאחורי הקלעים יש פשוט שימוש בpullAll של map המוכר מג’אווה).

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

כלומר, אכן חוזר מונו. אך הטיפוסים מעט מטעים, מכיוון שניתן לחשוב שהמונו כבר מכיל את המפה מאותחלת כאשר הוא חוזר, כלומר שהקריאות כבר בוצעו לפני שהחזרנו את המונו מהפונקציה (כמו שהיה מתבצע ב’תכנות מסורתי’). אך כזכור, מונו (ו-Flux) לא ‘פועלים’ ללא צרכן (subscriber).

נעזר בdebugger כדי להבין מה קורה:

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

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

מתי נשתמש בתכנות תגובתי?

בחלק זה פיתחנו אפליקציה פשוטה וראינו את הממשק הנוח והפונקציונלי שמקובל בעולם התכנות התגובתי (ובפרט בProject Reactor ו-WebFlux). לא נחשפנו לכל החוזקות של הממשק, כמו למשל הוספה של delay או retries שמבוצעת בפשטות ע”י הוספת פעולה לקוד ההצהרתי. מצד שני, גם לא נחשפנו לכל הסיבוך הנלווה מהעבודה עם מונו עקב פשטות האפליקציה, אך זה בהחלט יסבך באפליקציה יותר מורכבת עם יותר שכבות ורכיבים.

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

קורוטינות מספקות מעין נימים ‘רזים’, כך שניתן להריץ כמות רבה מאוד שלהן יחסית לנימים רגילים שצורכים הרבה יותר זיכרון. היתרון הוא שניתן לקבל אסינכרוניות וnon-blocking תוך שמירה על צורת תכנות אימפרטיבית (תכנות מפורש מסורתי) ללא שימוש במונו וצרכנים. ניתן אפילו לשלב בין תכנות תגובתי וקורוטינות…

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

קישורים לקריאה נוספת

The introduction to Reactive Programming you’ve been missing
Going Reactive with Spring, Coroutines and Kotlin Flow
Reactor – How to Combine Publishers (Flux/Mono)
Why you should learn Reactive Programming (Android)

 

 

Leave a Reply

Your email address will not be published. Required fields are marked *

פיתוח מונחה בדיקות בשפת קוטלין

על מה הפוסט?

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

בפוסט זה נכתוב אפליקציית REST פשוטה שמחזירה תשובה סטטית ובפוסט הבא נרחיב את האפליקציה כך שתפנה לשירות חיצוני ע”י שימוש בתכנות תגובתי (Reactive Programming).

יש פרויקט מלווה בGithub. מי שרוצה להעמיק מוזמן להוריד את הפרויקט, לעבור לbranch בשם part1, למחוק את הקוד (להשאיר רק את הטסטים) ולממש בעצמו.

שפת קוטלין

קוטלין היא שפת פיתוח חדשה יחסית שפותחה ע”י JetBrains ומאפשרת קלות ונוחות פיתוח שאנו מכירים משפות דינמיות בשפה סטטית. השפה רצה על הJVM ויש לה תאימות מצוינת עם קוד ג’אווה. לשפה תחביר מאוד פשוט שמזכיר קצת Groovy והיא מאוד קלה ללמידה למפתחי ג’אווה. היא מכילה את רוב הפיצ’רים המודרניים שחסרים בג’אווה כגון Default/Named Arguments, Type Inference & Null Safety (השפה מגנה מNull ברמת הקומפלייר!) ומאפשרת כתיבת קוד פונקציונלי בצורה הרבה יותר נוחה מג’אווה. מה שזה אומר בפועל, זה שהקוד שלנו יהיה הרבה יותר קצר, קריא ומהיר לכתיבה ותחזוקה.

תכנות מונחה בדיקות (TDD)

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

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

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

יצירת פרויקט Spring Boot עם קוטלין

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

למי שלא מכיר את Spring Boot, אסביר בקצרה שמדובר בתשתית לפיתוח Micro services שמבוססת על תשתיות קודמות של ספרינג כגון Spring MVC.  ספרינג תומך בקוטלין ומספק אתר מצוין ליצירת פרויקט חדש – https://start.spring.io. נגלוש לאתר, נבחר פרויקט Gradle עם קוטלין ונוסיף את Reactive Web.

הפרויקט שנוצר סה”כ עובד, הבעיה היחידה שנתקלתי בה היא צורך לשנות את גרסת הJVM מ1.9 ל1.8 (הדבר מתבצע מתפריט הFile->Settings מסך Kotlin Compiler תחת Target JVM version). נפתח את הפרויקט ע”י בחירת קובץ הbuild.gradle. נקבל פרויקט שמכיל קובץ Application וקובץ בדיקות. מאוד דומה למה שהיינו מקבלים אם היינו מחוללים פרויקט בג’אווה.

נתבונן בקובץ DemoApplication.kt:

@SpringBootApplication
class DemoApplication

fun main(args: Array<String>) {
 runApplication<DemoApplication>(*args)
}

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

נעבור לשורה 4 – המילה fun מגדירה פונקציה בקוטלין, כאשר ברירת המחדל לפונקציות בקוטלין היא פומבית (public). השורה שקולה להגדרת main בג’אווה. הפונקציה מוגדרת מחוץ למחלקה, מכיוון שבשונה מג’אווה מעמד הפונקציות שווה למעמד המחלקות (אין צורך לעטוף כל פונקציה במחלקה). שימו לב לצורה השונה של הגדרת הפרמטר- שם המשתנה קודם להצהרה על הטיפוס.

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

קצת על בדיקות

בפוסט זה נכתוב מספר סוגי בדיקות:
1. בדיקות יחידה (Unit Tests) – שימוש בכפילים (mocks) לכל תלות מחוץ לקוד הנבדק. בדיקות שרצות מהר ועוזרות לשיפור המבנה הפנימי של הקוד והגעה לDesign טוב מכיוון שהן דוחפות אותנו למדל את הקוד בצורה קלה לבדיקה, שמביאה לצמצום תלויות קשיחות בקוד. הבדיקות מחייבות אותנו לחשוב מה כל קטע קוד עושה ומדוע.
2. בדיקות אינטגרציה (Integration Tests) – בדיקות של מספר יחידות יחד או התממשקות אל ומתוך השירות שלנו (לשירות אחרים). עדיין יהיה שימוש בכפילים אך הטסט יהיה איטי יותר.
3. בדיקות קצה לקצה (End to End Tests)- בדיקת השירות שלנו מבחוץ (קופסה שחורה). אין שימוש בכפילים. הטסטים הכי איטיים והכי שבירים.

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

נושא הטסטים הוא רחב מספיק למספר פוסטים ממוקדים… בפוסט זה (ובהמשכיו) נראה בצורה פרקטית מתי להשתמש בכל טסט. מי שרוצה להרחיב מוזמן לקרוא את הפוסט המצוין – The Practical Test Pyramid.

הוספת בדיקת קצה לקצה

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

class SanityE2ETestsJavaStyle {
    @Test
    fun `isp service pass sanity - Java`() {
        `when`().
            get("/isp").
        then().
            statusCode(200)
    }
}

נשתמש בREST Assured ספרייה מומלצת לג’אווה שמספקת DSL
(Domain-specific language) שמאפשר כתיבת בדיקות לממשקי REST בצורה נוחה.

כזכור המילה fun משמשת להגדרת פונקציה בקוטלין (שורה 3), שימו לב שהשם של הפונקציה במירכאות. קוטלין מאפשרת להגדיר שם לפונקציה המכיל רווחים ע”י תחימת השם במירכאות, דבר המשפר את קריאות הבדיקות. מעל הפונקציה (שורה 2) השתמשנו באנוטציה המוכרת Test@ של Junit.

בשורה 4, המילה when (פונקציה סטטית שמסופקת ע”י REST Assured) היא במירכאות מכיוון שזוהי מילה שמורה בקוטלין. במקרה זה השימוש במירכאות מאפשר תאימות עם פונקציות מג’אווה.

הכתיבה המוכרת מג’אווה מאפשרת ממשק (API) נוח מאוד, אך ניתן לכתוב את הבדיקה בצורה יותר אלגנטית בקוטלין:

class SanityE2ETests : StringSpec({
    "isp service pass sanity" {
        given {
            on {
                get("/isp") itHas {
                    statusCode(200)
                }
            }
        }
    }
})

השתמשנו בKotlinTest ספריה שעושה ‘קסמים’ ע”י שימוש ביכולות מתקדמות של קוטלין כדי לשפר את הDSL המוגבל שאפשרי בג’אווה לDSL קריא יותר ודומה יותר למה שמוכר משפות דינמיות.

בשורה 1 אנחנו יורשים מStringSpec, הנקודתיים מחליפות את המילה extend בג’אווה. בהמשך השורה אנחנו מעבירים לבנאי של StringSpec פונקציה שבה מוגדרות הבדיקות שלנו. המחלקה StringSpec מאפשרת תחביר יותר ‘זורם’ ע”י שימוש במחרוזת במקום פונקציה להגדרת טסט.

השימוש במחרוזת מתאפשר ע”י תכונה מעניינת של קוטלין – הרחבת מחלקות קיימות ללא שימוש בירושה או ב’טריקים’ אחרים כגון Decorator. ע”י שימוש בתכונה זו נוכל להוסיף מתודות חדשות למחלקות המוכרות מג’אווה. כאן מבוצעת הרחבה מסוג invoke שמאפשרת להפעיל את האובייקט בתור פונקציה, כלומר שורה 2 היא בעצם קריאה לפונקציה שמוגדרת על כל מחרוזת (טיפוס String מג’אווה) בסקופ של StringSpec ובנותיה. קוטלין מאפשרת להשמיט את הסוגריים במקרה של פונקציה שמקבלת פונקציה כפרמטר. הפונקציה “isp service pass sanity” מקבלת את הפונקציה given בתור פרמטר, באותו אופן given מקבלת את on  ו-on מקבלת את get. זה מאפשר תחביר יותר זורם אבל יכול מאוד לבלבל בהתחלה.

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

יופי, יש טסט שנכשל. אפשר לכתוב קוד.

הגדרת מיפוי

 בחלק זה נגדיר Controller של ספרינג שיאפשר לאפליקציה שלנו לקבל בקשות REST:

@RestController
class IspController {
    
    @GetMapping("/isp")
    fun isp() = "OK"
}

די דומה להגדרה המוכרת מג’אווה, רק הרבה יותר תמציתי. הפונקציה בשורה 5 מחזירה מחרוזת קבועה “OK” וממופה לUrl בשורה 4. בקוטלין ניתן להשמיט הרבה פרטים כאשר מדובר בפונקציה בת שורה אחרת. הפונקציה למעשה שקולה ל:

fun isp(): String {
  return "OK"  
}

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

לצורך הרצת הטסט נעלה את השרת שלנו ע”י הרצת DemoApplication.kt (מכיוון שמדובר בטסט שבודק שרת ‘חי’) ונריץ את הטסט. עובר? יופי, אפשר להמשיך.

בדיקת אינטגרציה

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

לכן, נוסיף טסט אינטגרטיבי שיכשל-

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class IntegrationTests : StringSpec() {
    @LocalServerPort
    var port: Int = 0

    override fun listeners() = listOf(SpringListener)

    override fun beforeTest(description: Description) {
        RestAssured.port = port
    }

    init {
        "isp controller pass integration" {
            given {
                on {
                    get("/isp") itHas {
                        statusCode(200)
                        body("country", CoreMatchers.equalTo("United States"))
                        body("isp", CoreMatchers.equalTo("GoDaddy.com, LLC"))
                    }
                }
            }
        }
    }
}

האנוטציה בשורה 1 תעלה את האפליקציה שלנו בצורה אוטומטית כחלק מהטסט בפורט רנדומלי (כמו שהיינו עושים בג’אווה). בשורה 2 אנחנו מגדירים מחלקה לטסט שיורשת מStringSpec שהכרנו קודם.

בשורה 3 נשתמש באנוטציה המוכרת מג’אווה, כדי להציב את הפורט הרנדומלי שנבחר בשדה port. השדה מוגדר בשורה 4, ע”י שימוש במילה השמורה var או val כאשר השימוש ב val שקול לשימוש בfinal בג’אווה. שימו לב לאתחול של המשתנה באפס. הדבר דרוש מכיוון שבקוטלין משתנה לא יכול להיות null ונקבל שגיאת קומפילציה עבור שדה לא מאותחל או ניסיון להשתמש במשתנה מקומי ללא אתחול! אציין שניתן להשתמש במילה השמורה lateinit כדי להורות לקוטלין שהמשתנה יאותחל בהמשך, אך זה לא עובד בPrimitives (שימו לב שInt בקוטלין הוא פרימטיב).

שורה 6 דרושה כדי להריץ את הטסט עם ספרינג. זוהי בעצם המקבילה של StringSpec לRunwith@ מJunit4 או ExtendWith@ מJunit5. נשתמש גם בטסט זה בRest Assured שבו השתמשנו בבדיקה קצה לקצה. בשורה 9 נאתחל את הספריה לשימוש בפורט הרנדומלי שנבחר ע”י ספרינג.

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

נתקן את הController כדי להעביר את הטסט:

@GetMapping("/isp")
fun ispDetails() = mapOf(
    "country" to "United States",
    "isp" to "GoDaddy.com, LLC"
)

בקוד הנ”ל אנחנו מגדירים מפה עם שני מפתחות. לא חושב שיש מה להסביר יותר מידי, סה”כ אלגנטי וברור. המפה שתיווצר תהיה מטיפוס java.util.LinkedHashMap. אם נרצה HashMap רגיל, פשוט נחליף את mapOf בhashMapOf. קל. האתחול אולי מעט מסורבל (יחסית לפייתון למשל), אבל הגישה למפה היא אלגנטית כמצופה מהשפה:

print(ispDetails()["isp"])

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

אחת ההחלטות החכמות בכתיבת קוטלין הייתה להתבסס על הcollections של ג’אווה מה שבעצם מאפשר תאימות מלאה עם קוד ג’אווה ישן. עוד משהו מעניין הוא שבקוטלין ברירת המחדל לMap (ולכל collection) היא immutable, כלומר לא נוכל לשנותה לאחר האתחול (קיימת גם MutableMap במידת הצורך). הדבר מתבצע ע”י הגדרה מחדש של הממשקים (interfaces) המוכרים מג’אווה בקוטלין, כך שכל ממשק הוא בעצם readonly view של המחלקה המממשת מג’אווה.

נריץ את הטסט ואחרי שנראה שהוא עובר נעלה את האפליקציה ונריץ את בדיקת הקצה לקצה שלנו. נבצע את הcommit הראשון שלנו.

סיכום

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

בפוסט הבא נראה איך קוראים לשירות חיצוני ע”י שימוש בProject Reactor וכמובן שנכתוב עוד הרבה טסטים על הדרך:)

קישורים

The Practical Test Pyramid
קוטלין (Kotlin) למפתחי ג’אווה ותיקים
The Three Laws of TDD (Featuring Kotlin) – Uncle Bob
Try Kotlin (Online code execution)

 

 

Leave a Reply

Your email address will not be published. Required fields are marked *

קצת על תכנות פונקציונלי בג’אווה

על מה הפוסט?

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

תוכן עניינים

 

מה זה?

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

למה צריך את זה?

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

השינוי כולל הפיכה של פונקציות לישויות עצמאיות בשפה ע”י הכנסת Interface פונקציונלי וLambdas והרחבה של הCollection Framework עם Stream שמאפשר להשתמש בדפוסים פונקציונליים עם כל אחד ממבני הנתונים הקיימים בשפה.

איך משתמשים בזה?

נתחיל בדוגמת הדפסה של אברי רשימה (כל הדוגמאות זמינות בGithub):

List l = Arrays.asList("one", "two", "tree");
l.forEach(s -> System.out.println(s));

השתמשנו במתודה חדשה forEach שנוספה לIterable  בג’אווה 8. המתודה מקבלת למבדה (Lambda) בתור קלט. למבדה היא קטע קוד שניתן להעביר כפרמטר למתודה (דומה למצביע לפונקציה ב++C). בדוגמה שלנו הלמבדה מורצת ע”י המתודה forEach על כל אחד מהאברים וזה שקול כמובן לקוד הבא:

List<String> l = Arrays.asList("one", "two", "tree");
for (String s : l) {
    System.out.println(s);
}
 זה אולי לא נראה כשינוי מדהים, אבל די הרבה קרה פה. כדי להבין איך זה עובד נוציא את הלמבדה משורה 2 בדוגמה הראשונה למשתנה:
Consumer<String> consumer = s -> System.out.println(s);
l.forEach(consumer);

קיבלנו משתנה מטיפוס Consumer, שזוהי ההגדרה שלו:

@FunctionalInterface
public interface Consumer {

/**
* Performs this operation on the given argument.
*
* @param t the input argument
*/
void accept(T t);

זהו בעצם ממשק (Interface) פונקציונלי, שמאפשר להחליף מימוש של מחלקה בעלת מתודה אחת בלמבדה. כלומר, ללא תמיכה בלמבדה הקוד היה נראה ככה:

Consumer<String> verboseConsumer = new Consumer<String>() {
    @Override
    public void accept(String s) {
        System.out.println(s);
    }
};
l.forEach(verboseConsumer);

שימו לב שכל מחלקה/ממשק בעלת מתודה אחת יכולה לשמש בתור למבדה. כלומר, נוכל להשתמש בכל מחלקה שכוללת מתודה יחידה (שמקבלת מחרוזת ומחזירה void) ולהשתמש בה בforEach (אין צורך להרחיב את Consumer).

בכתיבת למבדה נשאף להשתמש אך ורק בפרמטרים המועברים ללמבדה. בג’וואה אנחנו לא יכולים לשנות ערכים של משתנים מקומיים מתוך הלמבדה, אבל כן יכולים לשנות שדות של המחלקה. מאוד רצוי להימנע מכל שימוש ושינוי של state חיצוני בלמבדה שיוצר side-effect, מקשה על ההבנה של הקוד ומגביל שימוש חוזר בקוד (ואם הגענו לזה, פשוט צריך למצוא דרך אחרת לביצוע הלוגיקה).

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

List<String> l = Arrays.asList("one", "two", "tree");
l.forEach(System.out::println);
public class Sea {
    private String region;
    private Integer area;
    private String name;

    public Sea(String name, String region, Integer area) {
        this.name = name;
        this.area = area;
        this.region = region;
    }

    //Class is a bean and includes the standard getters,setters,equals,hashcode which are omitted here to reduce noise
}

 

 ונגדיר קצת נתונים:

private List<Sea> seas = Arrays.asList(
        new Sea("Baltic Sea", "Europe, Africa, and Asia",1641650 ),
        new Sea("Caribbean Sea", "Americas", 2754000),
        new Sea("Mediterranean Sea", "Europe, Africa, and Asia", 2500000)
);

נרוץ על הנתונים שהגדרנו ונמצא את השטח הגדול ביותר:

Optional<Integer> largestArea = seas.stream().
        map(Sea::getArea).
        max(Comparator.naturalOrder());

assertTrue(largestArea.isPresent());
assertEquals(2754000, largestArea.get().intValue());

קרו פה מספר דברים, אז נעבור שורה שורה… שימו לב לשימוש במתודה stream בשורה הראשונה. בדוגמה הקודמת השתמשנו בforEach שנוסף לIterable ומאפשר הפעלת פונקציה על כל איבריו. לצורך פעולות יותר מורכבות אנחנו צריכים ליצר זרם (Stream), שזה בפשטות אוסף סופי או אינסופי של איברים. בדוגמה שלנו הזרם נוצר מרשימה ולכן ‘מכיל’ את כל אברי הרשימה.

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

בדוגמה שלנו הפעלנו שתי פעולות על הזרם. הראשונה בשורה 2, פעולת מיפוי (map) המאפשרת להפוך זרם המכיל טיפוס מסוים לזרם חדש המכיל טיפוס אחר ע”י מעבר על כל אחד מהאיברים והפעלת פונקציית המרה על האיבר. בעזרתה, אנחנו עוברים מזרם שמכיל Seas לזרם שמכיל Integers (השטחים של הימים). הזרם החדש שומר על הסדר של הזרם המקורי. זוהי פעולת ביניים, כלומר היא תבוצע בפועל כאשר יהיה צורך בתוצר שלה לצורך השלמת פעולה מסיימת. הפעולה השניה max בשורה 3, היא הפעולה המסיימת שלנו, היא רצה על הזרם ומחזירה את השטח המקסימלי תוך שהיא גוררת את ביצוע פעולת הביניים. הפעולה max מקבלת את הComparator המוכר של ג’אווה, המימוש של naturalOrder עובד על משתנים שממשים Comparable ופשוט מפעיל compareTo על האיברים.

 

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

Optional<Sea> largestSea = seas.stream().
        max(Comparator.comparing(Sea::getArea));
assertSeaName(largestSea, "Caribbean Sea");

בשורה 2 אנחנו משתמשים בפונקציה max מהדוגמה הקודמת, שכאמור מקבלת את הComparator המוכר של ג’אווה. שימו לב לשימוש בComparator.comparing, אנחנו מעבירים למתודה comparing למבדה שמחלצת את השדה שאיתו אנחנו רוצים להשוות. המתודה תפעיל את הלמבדה שלנו על כל האברים. שקול לקוד הבא, שבו הגדרנו את הComparator בצורה מפורשת:

Optional<Sea> largestSea = seas.stream().
        max((s1, s2) -> s1.getArea() > s2.getArea() ? 1 : -1);
assertSeaName(largestSea, "Caribbean Sea");

 

 ניתן להשיג את אותו הדבר (חישוב המקסימום) ע”י שימוש בreduce:

Optional<Sea> largestSea =
        seas.stream().reduce((s1, s2) -> s1.getArea() > s2.getArea() ? s1 : s2);
assertSeaName(largestSea, "Caribbean Sea");

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

Stream elements: 4,2,5,3,8

Reduce:
1. 4,2->4
2. 4,5->5
3. 5,3->5
4. 5,8->8

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

List<Sea> result  = seas.stream().
        filter(sea -> sea.getArea() > 2000000).
        sorted(Comparator.comparing(Sea::getArea)).
        collect(Collectors.toList());
assertThat(result, contains(seas.get(2), seas.get(1)));

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

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

Map<String, List<Sea>> result = seas.stream().collect(Collectors.groupingBy(Sea::getRegion));
assertEquals(2, result.size());
assertThat(result.get("Europe, Africa, and Asia"), contains(seas.get(0), seas.get(2)));
assertThat(result.get("Americas"), contains(seas.get(1)));

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

 

עוד נקודה מעניינת – יש חשיבות לסדר הפעולות:

Predicate<Sea> seaPredicate = sea -> {
 System.out.println("filter: " + sea);
 return sea.getArea() > 2000000;
};
Comparator<Sea> seaComparator = (s1, s2) -> {
 System.out.println("sort: " + s1 + " ? " + s2);
 return s1.getArea() - s2.getArea();
};

seas.stream().
 filter(seaPredicate).
 sorted(seaComparator).
 collect(Collectors.toList());

System.out.println("-------------------------");

seas.stream().
 sorted(seaComparator).
 filter(seaPredicate).
 collect(Collectors.toList());

הוספנו הדפסות לפעולת הsort והfilter והתקבל הפלט הבא:

filter: Sea{region='Europe, Africa, and Asia', area=1641650, name='Baltic Sea'}
filter: Sea{region='Americas', area=2754000, name='Caribbean Sea'}
filter: Sea{region='Europe, Africa, and Asia', area=2500000, name='Mediterranean Sea'}
sort: Sea{region='Europe, Africa, and Asia', area=2500000, name='Mediterranean Sea'} ? Sea{region='Americas', area=2754000, name='Caribbean Sea'}
-------------------------
sort: Sea{region='Americas', area=2754000, name='Caribbean Sea'} ? Sea{region='Europe, Africa, and Asia', area=1641650, name='Baltic Sea'}
sort: Sea{region='Europe, Africa, and Asia', area=2500000, name='Mediterranean Sea'} ? Sea{region='Americas', area=2754000, name='Caribbean Sea'}
sort: Sea{region='Europe, Africa, and Asia', area=2500000, name='Mediterranean Sea'} ? Sea{region='Americas', area=2754000, name='Caribbean Sea'}
sort: Sea{region='Europe, Africa, and Asia', area=2500000, name='Mediterranean Sea'} ? Sea{region='Europe, Africa, and Asia', area=1641650, name='Baltic Sea'}
filter: Sea{region='Europe, Africa, and Asia', area=1641650, name='Baltic Sea'}
filter: Sea{region='Europe, Africa, and Asia', area=2500000, name='Mediterranean Sea'}
filter: Sea{region='Americas', area=2754000, name='Caribbean Sea'}

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

Predicate<Integer> predicate = i -> {
     System.out.println("filter: " + i);
     return i > 2000000;
 };

 Function<Sea, Integer> map = s -> {
     System.out.println("map: " + s);
     return s.getArea();
 };
 seas.stream().
         map(map).
         filter(predicate).
         forEach(i -> System.out.println("forEach: " + i));

עיון קצר בהדפסה יאשר שאכן הפעולות מתבצעות בצורה סדרתית (האיבר הראשון מפולטר ולכן אין forEach עבורו):

map: Sea{region='Europe, Africa, and Asia', area=1641650, name='Baltic Sea'}
filter: 1641650
map: Sea{region='Americas', area=2754000, name='Caribbean Sea'}
filter: 2754000
forEach: 2754000
map: Sea{region='Europe, Africa, and Asia', area=2500000, name='Mediterranean Sea'}
filter: 2500000
forEach: 2500000

 

יתרון נוסף של שימוש בזרמים הוא קבלת מקביליות די ב’חינם’:

recordTime("Regular sum", () -> LongStream.range(1, 10000000).sum());
recordTime("Parallel sum", () -> LongStream.range(1, 10000000).parallel().sum());

בשורה 1, אנחנו מייצרים זרם שמכיל מספר גדול של אברים וסוכמים את אברי הזרם, ללא מקביליות. בעוד שבשורה 2 הסכימה מתבצעת בצורה מקבילית. כל הנדרש לצורך סכימה מקבילית הוא שימוש במתודה parallel ע”מ ליצר זרם מקבילי. המתודה recordTime היא מתודת עזר פשוטה שמדפיסה את זמני ההרצה (ניתן למצוא אותה בGitHub). להלן הפלט של ההרצה:

Regular sum took 42,002 micro seconds
Parallel sum took 15,000 micro seconds

ההבדל בזמני הריצה הוא בהחלט משמעותי (כמובן שזו לא בדיקה מדויקת, יותר לקבלת תחושה של ההבדל)! מה בעצם קרה פה? הפעולה sum למעשה מבצעת reduce על כל אברי הרשימה. כלומר הקוד שקול ל:

LongStream.range(1, 10000000).parallel().reduce(0L, Long::sum);

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

thread1: LongStream.range(1, 3000000).reduce(0L, Long::sum);
thread2: LongStream.range(1, 3000000).reduce(0L, Long::sum);
thread3: LongStream.range(1, 4000000).reduce(0L, Long::sum);

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

 

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

largeList = LongStream.range(1, 10000000).boxed().collect(Collectors.toList());
recordTime("Regular sum", () -> largeList.stream().reduce(0L, Long::sum));
recordTime("Parallel ArrayList sum",
        () -> new ArrayList<>(largeList).parallelStream().reduce(0L, Long::sum));
recordTime("Parallel LinkedList sum",
        () -> new LinkedList<>(largeList).parallelStream().reduce(0L, Long::sum));

זמני הרצה:

Regular sum took 317,018 micro seconds
Parallel ArrayList sum took 159,009 micro seconds
Parallel LinkedList sum took 881,050 micro seconds

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

 

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

Map<String, Function<Sea, String>> printFunctionByRegion = Map.of(
 "Americas",
 s -> String.format("Size is %.2f million square miles", s.getArea() * 0.386),
 "Europe, Africa, and Asia",
 s -> String.format("Size is %s million square kilometers", s.getArea()));

seas.stream().
 map(s -> printFunctionByRegion.get(s.getRegion()).apply(s)).
 forEach(System.out::println);

הקוד מבצע הדפסה שונה לפי האזור שבו נמצא הים. בתכנות פרוצדורלי היינו רושמים if או case. בתכנות פונקציונלי אלו הן פקודות מהעבר:) בדוגמה, אנחנו מאתחלים מפה שממפה פונקציה לכל אזור ומפעילים את הפונקציה המתאימה על כל איבר (Map.of נוספה בג’וואה 9 והיא בונה מפה מרשימת הפרמטרים. שאר הדוגמאות בפוסט רצות בג’וואה 8).

מתי נשתמש בזה?

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

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

2 responses to “קצת על תכנות פונקציונלי בג’אווה”

  1. טל says:

    מעולה. תודה!

  2. Guy Lev says:

    Good read. Thank you!

Leave a Reply

Your email address will not be published. Required fields are marked *