במדריך הזה מוסבר על אופטימיזציות לשירותי Knative serving שנכתבו בשפת התכנות Java, וגם על מידע רקע שיעזור לכם להבין את היתרונות והחסרונות של חלק מהאופטימיזציות. המידע בדף הזה הוא תוספת לטיפים כלליים לאופטימיזציה, שרלוונטיים גם ל-Java.
אפליקציות מסורתיות מבוססות-אינטרנט ב-Java מיועדות לשרת בקשות עם רמת מקביליות גבוהה וזמן אחזור נמוך, והן נוטות להיות אפליקציות שפועלות לאורך זמן. גם ה-JVM עצמו מבצע אופטימיזציה של קוד הביצוע לאורך זמן באמצעות JIT, כך שנתיבים פעילים עוברים אופטימיזציה והאפליקציות פועלות בצורה יעילה יותר לאורך זמן.
הרבה מהשיטות המומלצות והאופטימיזציות באפליקציות מסורתיות מבוססות-אינטרנט של Java מתמקדות ב:
- טיפול בבקשות מקבילות (גם מבוססות-thread וגם I/O לא חוסם)
- הפחתת זמן האחזור של התגובה באמצעות איגום חיבורים (connection pooling) ועיבוד באצווה של פונקציות לא קריטיות, למשל שליחת עקבות ומדדים למשימות ברקע.
הרבה מהאופטימיזציות המסורתיות האלה פועלות היטב באפליקציות שפועלות לאורך זמן, אבל יכול להיות שהן לא יפעלו טוב בשירות Knative serving, שפועל רק כשהוא מציג בקשות באופן פעיל. בדף הזה מוסבר על כמה אופטימיזציות שונות ועל פשרות שקשורות למילוי בקשות מסוג Knative, שבהן אפשר להשתמש כדי לקצר את זמן ההפעלה ולצמצם את השימוש בזיכרון.
אופטימיזציה של קובץ אימג' של קונטיינר
אופטימיזציה של קובץ אימג' של קונטיינר יכולה לקצר את זמני הטעינה וההפעלה. אפשר לבצע אופטימיזציה של התמונה על ידי:
- מזעור של קובץ אימג' של קונטיינר
- הימנעות משימוש בקובצי JAR של ארכיון ספריות מקוננות
- שימוש ב-Jib
מזעור קובץ אימג' של קונטיינר
מידע נוסף על הבעיה הזו מופיע בדף הטיפים הכלליים בנושא צמצום מאגרי תגים. בדף הטיפים הכללי מומלץ לצמצם את התוכן של תמונת המאגר כך שיכלול רק את מה שנדרש. לדוגמה, חשוב לוודא שקובץ אימג' של קונטיינר לא מכיל :
- קוד מקור
- פריטי מידע שנוצרו בתהליך פיתוח (Artifact) של Maven
- כלי בנייה
- ספריות Git
- קובצי הפעלה או כלי עזר שלא בשימוש
אם אתם יוצרים את הקוד מתוך קובץ Docker, השתמשו ב-Docker multi-stage build כדי שקובץ אימג' של קונטיינר הסופי יכלול רק את JRE ואת קובץ ה-JAR של האפליקציה עצמה.
איך להימנע מקובצי JAR של ספריות מקוננות
חלק מהמסגרות הפופולריות, כמו Spring Boot, יוצרות קובץ ארכיון של אפליקציה (JAR) שמכיל קובצי JAR נוספים של ספריות (JARs מוטבעים). צריך לפתוח או לחלץ את הקבצים האלה במהלך ההפעלה, והם יכולים להגדיל את מהירות ההפעלה ב-Knative serving. אם אפשר, כדאי ליצור קובץ JAR דק עם ספריות חיצוניות: אפשר להשתמש ב-Jib כדי להוסיף את האפליקציה למאגר תגים באופן אוטומטי
שימוש ב-Jib
משתמשים בתוסף Jib כדי ליצור קונטיינר מינימלי ולשטח את ארכיון האפליקציה באופן אוטומטי. Jib פועל עם Maven ועם Gradle, ופועל עם אפליקציות Spring Boot ללא צורך בהגדרה. יכול להיות שחלק ממסגרות האפליקציות ידרשו הגדרות נוספות של Jib.
אופטימיזציות של JVM
אופטימיזציה של ה-JVM לשירות Knative serving יכולה לשפר את הביצועים ואת השימוש בזיכרון.
שימוש בגרסאות JVM שמודעות לקונטיינר
במכונות וירטואליות ובמכונות, כדי להקצות מעבד (CPU) וזיכרון, ה-JVM מבין את המעבד והזיכרון שהוא יכול להשתמש בהם ממיקומים ידועים, לדוגמה, ב-Linux, /proc/cpuinfo ו-/proc/meminfo. עם זאת, כשמריצים את התהליך בקונטיינר, מגבלות המעבד והזיכרון מאוחסנות ב-/proc/cgroups/.... גרסאות ישנות יותר של JDK ממשיכות לחפש ב-/proc במקום ב-/proc/cgroups, מה שיכול לגרום לשימוש רב יותר במעבד (CPU) ובשימוש בזיכרון ממה שהוקצה. המצב הזה יכול לגרום ל:
- מספר מוגזם של שרשורים כי גודל מאגר השרשורים מוגדר על ידי
Runtime.availableProcessors() - גודל ערימה מקסימלי שחורג ממגבלת הזיכרון של המאגר. מכונת ה-JVM משתמשת בזיכרון באופן אגרסיבי לפני שהיא מבצעת איסוף אשפה. במצב כזה, קל מאוד לחרוג ממגבלת הזיכרון של הקונטיינר ולגרום להפסקת הפעולה שלו בגלל חוסר זיכרון (OOM).
לכן, צריך להשתמש בגרסת JVM שמודעת לקונטיינר. גרסאות OpenJDK גדולות או שוות לגרסה 8u192 מודעות לקונטיינר כברירת מחדל.
הסבר על השימוש בזיכרון של JVM
השימוש בזיכרון של JVM מורכב משימוש בזיכרון מקורי ומשימוש בערימה. זיכרון העבודה של האפליקציה נמצא בדרך כלל ב-heap. הגודל של ה-heap מוגבל על ידי ההגדרה Max Heap. במופע של Knative serving עם זיכרון RAM של 256MB, אי אפשר להקצות את כל הזיכרון הזה ל-Max Heap, כי גם JVM ומערכת ההפעלה צריכים זיכרון Native, למשל, מחסנית של שרשור, מטמוני קוד, ידיות קבצים, מאגרי נתונים וכו'. אם האפליקציה נסגרת בגלל חוסר זיכרון (OOMKilled) ואתם רוצים לדעת מה השימוש בזיכרון של JVM (זיכרון Native + Heap), אתם יכולים להפעיל את Native Memory Tracking כדי לראות את השימוש אחרי שהאפליקציה נסגרת בהצלחה. אם האפליקציה שלכם נסגרת בגלל חריגה של זיכרון (OOM), היא לא תוכל להדפיס את המידע. במקרה כזה, צריך להריץ את האפליקציה עם יותר זיכרון כדי שהיא תוכל ליצור את הפלט בהצלחה.
אי אפשר להפעיל את מעקב הזיכרון המקורי באמצעות משתנה הסביבה JAVA_TOOL_OPTIONS. צריך להוסיף את ארגומנט ההפעלה של שורת הפקודה של Java לנקודת הכניסה של קובץ אימג' של קונטיינר, כדי שהאפליקציה תופעל עם הארגומנטים האלה:
java -XX:NativeMemoryTracking=summary \
-XX:+UnlockDiagnosticVMOptions \
-XX:+PrintNMTStatistics \
...
אפשר להעריך את השימוש בזיכרון המקומי על סמך מספר הכיתות לטעינה. כדאי להשתמש במחשבון זיכרון Java בקוד פתוח כדי להעריך את צורכי הזיכרון.
השבתה של קומפיילר האופטימיזציה
כברירת מחדל, ל-JVM יש כמה שלבים של הידור JIT. למרות שהשלבים האלה משפרים את היעילות של האפליקציה לאורך זמן, הם גם יכולים להגדיל את התקורה של השימוש בזיכרון ולהאריך את זמן ההפעלה.
באפליקציות ללא שרת (serverless) שפועלות לזמן קצר (לדוגמה, פונקציות), כדאי להשבית את שלבי האופטימיזציה כדי לקצר את זמן ההפעלה, גם אם זה אומר לוותר על יעילות לטווח ארוך.
בשירות Knative serving, מגדירים את משתנה הסביבה:
JAVA_TOOL_OPTIONS="-XX:+TieredCompilation -XX:TieredStopAtLevel=1"
שימוש בשיתוף נתונים של מחלקות אפליקציות
כדי לצמצם עוד יותר את זמן ה-JIT ואת השימוש בזיכרון, אפשר להשתמש בשיתוף נתוני מחלקות של אפליקציות (AppCDS) כדי לשתף את מחלקות ה-Java שעברו הידור מראש כארכיון. אפשר לעשות שימוש חוזר בארכיון AppCDS כשמפעילים מופע נוסף של אותה אפליקציית Java. מכונת ה-JVM יכולה לעשות שימוש חוזר בנתונים שחושבו מראש מהארכיון, וכך לקצר את זמן ההפעלה.
השיקולים הבאים רלוונטיים לשימוש ב-AppCDS:
- צריך ליצור מחדש את ארכיון AppCDS שרוצים לעשות בו שימוש חוזר בדיוק באותה גרסה, באותה ארכיטקטורה ובאותה הפצה של OpenJDK ששימשו ליצירה המקורית שלו.
- כדי ליצור את רשימת המחלקות לשיתוף, צריך להריץ את האפליקציה לפחות פעם אחת, ואז להשתמש ברשימה הזו כדי ליצור את ארכיון AppCDS.
- הכיסוי של המחלקות תלוי בנתיב הקוד שמופעל במהלך הרצת האפליקציה. כדי להגדיל את הכיסוי, מפעילים באופן פרוגרמטי עוד נתיבי קוד.
- כדי ליצור את רשימת הכיתות הזו, האפליקציה צריכה לצאת בהצלחה. מומלץ להטמיע דגל אפליקציה שמשמש לציון יצירה של ארכיון AppCDS, כדי שהאפליקציה תוכל לצאת מיד.
- אפשר לעשות שימוש חוזר בארכיון AppCDS רק אם מפעילים מופעים חדשים בדיוק באותו אופן שבו נוצר הארכיון.
- ארכיון AppCDS פועל רק עם חבילת קובצי JAR רגילה. אי אפשר להשתמש בקובצי JAR מוטמעים.
דוגמה לשימוש ב-Spring Boot בקובץ JAR מוצלל
אפליקציות Spring Boot משתמשות ב-uber JAR מוטמע כברירת מחדל, שלא יפעל ב-AppCDS. לכן, אם אתם משתמשים ב-AppCDS, אתם צריכים ליצור JAR מוצלל. לדוגמה, באמצעות Maven ותוסף Maven Shade:
<build>
<finalName>helloworld</finalName>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-shade-plugin</artifactId>
<configuration>
<keepDependenciesWithProvidedScope>true</keepDependenciesWithProvidedScope>
<createDependencyReducedPom>true</createDependencyReducedPom>
<filters>
<filter>
<artifact>*:*</artifact>
<excludes>
<exclude>META-INF/*.SF</exclude>
<exclude>META-INF/*.DSA</exclude>
<exclude>META-INF/*.RSA</exclude>
</excludes>
</filter>
</filters>
</configuration>
<executions>
<execution>
<phase>package</phase>
<goals><goal>shade</goal></goals>
<configuration>
<transformers>
<transformer implementation="org.apache.maven.plugins.shade.resource.AppendingTransformer">
<resource>META-INF/spring.handlers</resource>
</transformer>
<transformer implementation="org.springframework.boot.maven.PropertiesMergingResourceTransformer">
<resource>META-INF/spring.factories</resource>
</transformer>
<transformer implementation="org.apache.maven.plugins.shade.resource.AppendingTransformer">
<resource>META-INF/spring.schemas</resource>
</transformer>
<transformer implementation="org.apache.maven.plugins.shade.resource.ServicesResourceTransformer" />
<transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
<mainClass>${mainClass}</mainClass>
</transformer>
</transformers>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
אם קובץ ה-JAR המוצלל מכיל את כל התלויות, אפשר ליצור ארכיון פשוט במהלך בניית הקונטיינר באמצעות Dockerfile:
# Use Docker's multi-stage build
FROM adoptopenjdk:11-jre-hotspot as APPCDS
COPY target/helloworld.jar /helloworld.jar
# Run the application, but with a custom trigger that exits immediately.
# In this particular example, the application looks for the '--appcds' flag.
# You can implement a similar flag in your own application.
RUN java -XX:DumpLoadedClassList=classes.lst -jar helloworld.jar --appcds=true
# From the captured list of classes (based on execution coverage),
# generate the AppCDS archive file.
RUN java -Xshare:dump -XX:SharedClassListFile=classes.lst -XX:SharedArchiveFile=appcds.jsa --class-path helloworld.jar
FROM adoptopenjdk:11-jre-hotspot
# Copy both the JAR file and the AppCDS archive file to the runtime container.
COPY --from=APPCDS /helloworld.jar /helloworld.jar
COPY --from=APPCDS /appcds.jsa /appcds.jsa
# Enable Application Class-Data sharing
ENTRYPOINT java -Xshare:on -XX:SharedArchiveFile=appcds.jsa -jar helloworld.jar
השבתת אימות הכיתה
כש-JVM טוען מחלקות לזיכרון לצורך ביצוע, הוא מוודא שהמחלקה לא שונתה ואין בה עריכות זדוניות או נתונים פגומים. אם אתם בוטחים בצינור להכנת תוכנה להפצה (לדוגמה, אם אתם יכולים לאמת כל פלט), אם אתם בוטחים לחלוטין בבייטקוד (bytecode) בקובץ אימג' של קונטיינר שלכם והאפליקציה שלכם לא טוענת מחלקות ממקורות מרוחקים שרירותיים, אתם יכולים להשבית את האימות. אם מספר גדול של כיתות נטען בזמן ההפעלה, השבתת האימות עשויה לשפר את מהירות ההפעלה.
בשירות Knative serving, מגדירים את משתנה הסביבה:
JAVA_TOOL_OPTIONS="-noverify"
הקטנת גודל מחסנית השרשור
רוב אפליקציות האינטרנט ב-Java מבוססות על thread לכל חיבור. כל שרשור Java צורך זיכרון מקומי (לא ב-heap). האזור הזה נקרא מחסנית השרשור, וגודל ברירת המחדל שלו הוא 1MB לכל שרשור. אם האפליקציה מטפלת ב-80 בקשות בו-זמניות, יכול להיות שיש לה לפחות 80 שרשורים, כלומר 80MB של שטח מחסנית שרשורים בשימוש. הזיכרון הזה הוא בנוסף לגודל הערימה. יכול להיות שערך ברירת המחדל גדול מהנדרש. אפשר להקטין את גודל מחסנית השרשור.
אם תצמצמו יותר מדי, תראו את java.lang.StackOverflowError. אפשר ליצור פרופיל של האפליקציה ולמצוא את גודל מחסנית השרשור האופטימלי להגדרה.
בשירות Knative serving, מגדירים את משתנה הסביבה:
JAVA_TOOL_OPTIONS="-Xss256k"
צמצום השרשורים
כדי לבצע אופטימיזציה של הזיכרון, אפשר לצמצם את מספר השרשורים, להשתמש באסטרטגיות ריאקטיביות לא חוסמות ולהימנע מפעילויות ברקע.
צמצום מספר השרשורים
כל שרשור Java עשוי להגדיל את השימוש בזיכרון בגלל Thread Stack.
ב-Knative serving אפשר להגדיר עד 80 בקשות בו-זמניות. במודל של שרשור לכל חיבור, צריך לכל היותר 80 שרשורים כדי לטפל בכל הבקשות בו-זמנית.
ברוב שרתי האינטרנט ומסגרות העבודה אפשר להגדיר את המספר המקסימלי של השרשורים והחיבורים. לדוגמה, ב-Spring Boot, אפשר להגביל את מספר החיבורים המקסימלי בקובץ applications.properties:
server.tomcat.max-threads=80
כתיבת קוד ריאקטיבי לא חוסם כדי לבצע אופטימיזציה של הזיכרון ושל ההפעלה
כדי להפחית באמת את מספר השרשורים, כדאי לאמץ מודל תגובתי של תכנות לא חוסם, כך שאפשר יהיה לצמצם משמעותית את מספר השרשורים תוך טיפול ביותר בקשות בו-זמניות. מסגרות אפליקציות כמו Spring Boot עם Webflux, Micronaut ו-Quarkus תומכות באפליקציות אינטרנט ריאקטיביות.
לרוב, למסגרות תגובתיות כמו Spring Boot עם Webflux, Micronaut ו-Quarkus יש זמני הפעלה מהירים יותר.
אם תמשיכו לכתוב קוד חוסם במסגרת לא חוסמת, התפוקה ושיעורי השגיאות יהיו גרועים משמעותית בשירות Knative Serving. הסיבה לכך היא שבמסגרות שאינן חוסמות יש רק כמה שרשורים, למשל 2 או 4. אם הקוד שלכם חוסם, הוא יכול לטפל במספר קטן מאוד של בקשות בו-זמניות.
יכול להיות שהמסגרות האלה שאינן חוסמות גם יעבירו קוד חסימה למאגר שרשורים לא מוגבל – כלומר, למרות שהן יכולות לקבל הרבה בקשות בו-זמנית, קוד החסימה יפעל בשרשורים חדשים. אם השרשורים מצטברים ללא הגבלה, תנצלו את כל משאבי המעבד ותתחילו להשתמש בזיכרון הווירטואלי. ההשפעה על זמן האחזור תהיה משמעותית. אם אתם משתמשים במסגרת שאינה חוסמת, חשוב להבין את מודלים של מאגר השרשורים ולהגביל את המאגרים בהתאם.
הימנעות מפעילויות ברקע
Knative serving מגביל את השימוש במעבד של מופע כשהמופע הזה לא מקבל יותר בקשות. עומסי עבודה מסורתיים שיש להם משימות ברקע דורשים התייחסות מיוחדת כשמריצים אותם ב-Knative serving.
לדוגמה, אם אתם אוספים מדדים של אפליקציות ומקבצים את המדדים כדי לשלוח אותם באופן תקופתי ברקע, המדדים האלה לא יישלחו כשהשימוש במעבד מוגבל. אם האפליקציה שלכם מקבלת בקשות באופן קבוע, יכול להיות שתראו פחות בעיות. אם לאפליקציה יש QPS נמוך, יכול להיות שהמשימה ברקע לא תופעל אף פעם.
הנה כמה דפוסים מוכרים שפועלים ברקע וחשוב לשים לב אליהם:
- מאגרי חיבורים של JDBC – ניקויים ובדיקות חיבורים מתרחשים בדרך כלל ברקע
- שולחי מעקב מבוזר – בדרך כלל, מעקבים מבוזרים נשלחים בקבוצות באופן תקופתי או כשהמאגר מלא ברקע.
- שולחי מדדים – בדרך כלל המדדים נשלחים בקבוצות באופן תקופתי ברקע.
- ב-Spring Boot, כל השיטות שמסומנות בהערה
@Async - טיימרים – כל הטריגרים שמבוססים על טיימר (למשל, יכול להיות שהפעולה לא תתבצע אם יש הגבלת קצב העברה של נתוני CPU (למשל, ScheduledThreadPoolExecutor, Quartz או
@ScheduledSpring annotation). - מקבלים של הודעות – לדוגמה, לקוחות של Pub/Sub streaming pull, לקוחות של JMS או לקוחות של Kafka, בדרך כלל פועלים בשרשורים ברקע בלי צורך בבקשות. הן לא יפעלו אם לא יהיו בקשות באפליקציה. לא מומלץ לקבל הודעות בדרך הזו ב-Knative serving.
אופטימיזציה של אפליקציות
בקוד של שירות Knative serving, אפשר גם לבצע אופטימיזציה לזמני הפעלה מהירים יותר ולשימוש בזיכרון.
צמצום משימות ההפעלה
לאפליקציות מסורתיות מבוססות-אינטרנט של Java יש הרבה משימות להשלים במהלך ההפעלה, למשל טעינה מראש של נתונים, חימום המטמון, הקמת מאגרי חיבורים וכו'. אם המשימות האלה מבוצעות ברצף, הן עלולות להיות איטיות. עם זאת, אם רוצים שהן יפעלו במקביל, צריך להגדיל את מספר ליבות המעבד.
בשלב הזה, Knative serving שולח בקשה ממשתמש אמיתי כדי להפעיל מופע של הפעלה במצב התחלתי (cold start). משתמשים שהוקצתה להם בקשה במופע שהופעל לאחרונה עלולים להיתקל בעיכובים ארוכים. ב-Knative serving אין כרגע בדיקת מוכנות כדי למנוע שליחת בקשות לאפליקציות לא מוכנות.
שימוש במאגר חיבורים
אם אתם משתמשים במאגרי חיבורים, שימו לב שמאגרי חיבורים עשויים להסיר חיבורים לא נחוצים ברקע (ראו מניעת משימות ברקע). אם האפליקציה שלכם כוללת QPS נמוך ויכולה לסבול זמן אחזור גבוה, כדאי לשקול לפתוח ולסגור חיבורים לכל בקשה. אם באפליקציה יש QPS גבוה, יכול להיות שהסרת פריטים ברקע תמשיך להתבצע כל עוד יש בקשות פעילות.
בשני המקרים, הגישה למסד הנתונים של האפליקציה תוגבל על ידי מספר החיבורים המקסימלי שמותר במסד הנתונים. מחשבים את מספר החיבורים המקסימלי שאפשר ליצור לכל מופע של Knative serving, ומגדירים את מספר המופעים המקסימלי של Knative serving כך שמספר המופעים המקסימלי כפול מספר החיבורים לכל מופע יהיה קטן ממספר החיבורים המקסימלי המותר.
שימוש ב-Spring Boot
אם אתם משתמשים ב-Spring Boot, כדאי לשקול את האופטימיזציות הבאות
שימוש ב-Spring Boot מגרסה 2.2 ואילך
החל מגרסה 2.2, בוצעה אופטימיזציה משמעותית של Spring Boot כדי לשפר את מהירות ההפעלה. אם אתם משתמשים בגרסאות של Spring Boot שקטנות מ-2.2, כדאי לשדרג או להחיל אופטימיזציות ספציפיות באופן ידני.
שימוש באתחול מדורג
יש דגל גלובלי של אתחול עצלן שאפשר להפעיל ב-Spring Boot 2.2 ומעלה. הפעולה הזו תשפר את מהירות ההפעלה, אבל בתמורה הבקשה הראשונה עשויה להיות עם זמן אחזור ארוך יותר כי היא תצטרך לחכות עד שרכיבים יאותחלו בפעם הראשונה.
אפשר להפעיל את האתחול המדורג בapplication.properties:
spring.main.lazy-initialization=true
או באמצעות משתנה סביבה:
SPRING_MAIN_LAZY_INITIALIZATIION=true
עם זאת, אם אתם משתמשים ב-min-instances, אתחול עצל לא יעזור, כי האתחול אמור להתרחש כש-min-instance מתחיל.
איך נמנעים מסריקת כיתות
סריקת הכיתה תגרום לקריאות נוספות מהדיסק במילוי בקשות מסוג Knative, כי בדרך כלל הגישה לדיסק במילוי בקשות מסוג Knative איטית יותר ממכונה רגילה. חשוב לוודא שהסריקה של הרכיבים מוגבלת או שנמנעים ממנה לחלוטין. מומלץ להשתמש ב-Spring Context Indexer כדי ליצור מראש אינדקס. השיפור במהירות ההפעלה תלוי באפליקציה שלכם.
לדוגמה, בקובץ pom.xml של Maven מוסיפים את התלות של יצירת האינדקס (זהו למעשה מעבד אנוטציות (Annotation processor)):
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-context-indexer</artifactId>
<optional>true</optional>
</dependency>
שימוש בכלים למפתחים של Spring Boot שלא בסביבת ייצור
אם אתם משתמשים ב-Spring Boot Developer Tool במהלך הפיתוח, ודאו שהוא לא נארז בתמונת קונטיינר הייצור. זה יכול לקרות אם יצרתם את אפליקציית Spring Boot בלי התוספים של Spring Boot build (לדוגמה, באמצעות התוסף Shade או באמצעות Jib ליצירת קונטיינר).
במקרים כאלה, צריך לוודא שכלי ה-build מחריג במפורש את כלי הפיתוח של Spring Boot. או, משביתים את הכלי למפתחים של Spring Boot באופן מפורש.
המאמרים הבאים
טיפים נוספים זמינים במאמר