أفضل ممارساتي – Java

1. Pattern.compile() بدلًا من String.replaceAll() داخل الحلقة

public String replaceAll(String regex, String replacement) {
    return Pattern.compile(regex).matcher(this).replaceAll(replacement);
}

يجب دائمًا تصريف الـ Pattern أولًا. فإذا نُفِّذ الـ Pattern نفسه مرات كثيرة على نصوص قصيرة، يكون الأداء أفضل بكثير عند إعادة استخدام الـ Pattern المُصرَّف.

2. StringBuilder بدلًا من + داخل الحلقة

استخدام new StringBuilder() أفضل أداءً من + داخل الحلقة.

3. استخدام equals() وhashCode() بشكل صحيح

أحيانًا تُدرج كل الحقول في equals() أو hashCode(). هذا مفيد للتحقق مما إذا كان الـ Entity قد تغيّر، لكنه خاطئ. فالغرض من equals() ليس التحقق مما إذا كان الـ Entity قد عُدِّل، بل تحديد ما إذا كان الأمر يتعلق بالـ Entity نفسه، حتى لو لم تكن نسخة الكائن هي نفسها. أي: استخدم المفتاح الأساسي (Primary Key) فقط!

يلزم ذلك لكل أنواع الـ Collections. فعند استدعاء .contains() على List، تستخدم Java الدالة equals. وتحتاج الـ HashMaps إضافةً إلى ذلك إلى hashCode(). لذا يجب إعادة تعريفهما معًا.

من المزالق الشائعة الـ Entities غير المحفوظة التي لا تملك ID. توخَّ الحذر هنا واحفظها مبكرًا لتجنّب المشكلات.

للمقارنة:

يجب دائمًا الالتزام بعقد هذه الدوال:

4. مزالق DateTime عند خلط أنظمة التشغيل

بعض أنظمة التشغيل تملك ساعة داخلية بدقة النانوثانية، وبعضها بدقة الميلي ثانية. لذلك لا تعمل ZonedDateTime.now() بالطريقة نفسها على المنصتين، وتُفسد equals(). بدلًا من ذلك اضبط الدقة دائمًا!

/**
 * truncating the precision, because it relies on the internal clock of linux/macOs/windows.
 * windows and older macOS versions use only milliseconds, whereas linux and new macOS versions use microsecond precision.
 */
public static ZonedDateTime getNow() {
    return ZonedDateTime.now().truncatedTo(DateTimeUtil.PRECISION);
}

كذلك تعتمد دوال JPA التالية على ZonedDateTime.now()، ولا ينبغي أبدًا استخدامها كما هي:

5. لا تصرّح بأنواع البيانات المعقدة ضمنيًا

أنشئ دائمًا أصنافًا خاصة لأنواع البيانات المعقدة بدلًا من تداخل Pair وLists وما شابه.

سيئ:

// unlesbar
public Triple<List<Pair<List<List<ChatMessage>>, ZonedDateTime>>, Language, ChatRoom> getChatMessagesGroupedByDay();

الأفضل:

public class FTOChatMessageCollection {

    private List<FTOChatMessageCollectionPerDay> messagesPerDay;
    private Language language;
    private ChatRoom chatRoom;

    // constructor, getter & setter ...
}
public class FTOChatMessageCollectionPerDay {

    private List<FTOChatMessage> messages;
    private ZonedDateTime day;

    // constructor, getter & setter ...
}

المزايا:

الاستثناءات:

للعلاقات البسيطة يكون Pair ممتازًا! على سبيل المثال:

Pair<User, Training> pair = this.addCurrentUserToTraining(training);

6. التخزين المؤقت – لكن بالطريقة الصحيحة (دليل إعداد مبسّط)

الـ Cache (ويُنطق /kæʃ/، مثل «cash») ليس في جوهره سوى Map تحتفظ بالمدخلات. وقد استغنينا عن أمور أكثر تعقيدًا مثل الإبطال الزمني وما شابهه.

ليس من الضروري دائمًا استخدام مكتبة إذا كانت Map بسيطة تكفي! كثير من المطورين لا يدركون ذلك ويلجؤون إلى المكتبات مبدئيًا خوفًا. أهم جانب في التخزين المؤقت هو أن يجري الإبطال في الوقت المناسب.

المزلق رقم 1: تسرّب الذاكرة (Memory Leaks)

يمكن أن يحدث تسرّب الذاكرة بسهولة شديدة عند التخزين المؤقت. يكفي أن تنسى إعادة تعريف equals() وhashCode() بشكل سليم، حتى تحصل على HashMap تسرّب الذاكرة. والطريقة الآمنة دائمًا هي إنشاء مفاتيح الـ Map يدويًا. ومن الأفضل الاقتصار على الـ ID الخاص بالـ Entity، وإلا فإن الـ Map تبدأ بالتسرّب بمجرد تغيير حقل واحد. وهذا صحيح أيضًا، فتغيُّر الاسم الأول للمستخدم لا يعني أنه لم يعد المستخدم نفسه. للمقارنة: 3. استخدام equals() وhashCode() بشكل صحيح

المزلق رقم 2: الأمان مع الخيوط (Thread Safety)

يُفضَّل أن يستخدم الـ Cache خريطة ConcurrentHashMap التي توفر عمليات ذرّية وآمنة مع الخيوط. ولا تُعدَّل الـ Map إلا عبر استدعاء واحد – computeIfAbsent() – وبذلك يتحقق الأمان مع الخيوط:

private ConcurrentMap<String, T> cache = new ConcurrentHashMap<>();

protected T getOrCompute(final String key, final Supplier<T> computeFunction) {
    // thread safe, because `computeIfAbsent` is atomic, so no need for `synchronized` here.
    // if you extend this to more than 1 map operation, you should synchronize it
    return this.getCacheMap().computeIfAbsent(key, k -> computeFunction.get());
}

لو كان الكود أطول وفيه عدة استدعاءات، لوجب جعل الدالة بأكملها synchronized.

7. التسلسل (Serialization) – هل ما زلنا بحاجة إليه؟

سؤالي: هناك قاعدة في Sonar بعنوان يجب أن تكون الحقول في صنف «Serializable» إما transient أو قابلة للتسلسل. وتظهر عند إضافة حقل غير قابل للتسلسل إلى الصنف. تنص القاعدة على:

على سبيل المثال، تحت الضغط تقوم معظم أُطر تطبيقات J2EE بتفريغ الكائنات إلى القرص، وقد يتسبب كائن يُفترض أنه Serializable ويحتوي على أعضاء بيانات غير transient وغير قابلة للتسلسل في انهيار البرنامج، ويفتح الباب أمام المهاجمين.

يبدو لي هذا قديمًا. ففي Effective Java 3rd Edition ورد أن التسلسل لم يعد ذا أهمية عند تطوير برمجيات جديدة. كما أن إزالة التسلسل من Java «هدف بعيد المدى» لدى Oracle. وقد صرّح كبير مهندسي Java نفسه، Mark Reinhold، قائلًا:

«كان التسلسل خطأً فادحًا في عام 1997.»

وقد سبق أن طُرح هنا سؤال ما إذا كان إطار Spring قد يفرّغ الكائنات إلى القرص:

لذا أطرح السؤال:

هل تقوم أيٌّ من خوادم التطبيقات أو الأُطر الحديثة، أو تطبيق J2EE نفسه (لا سيما مكونات Java التي تتفاعل مع CDI وEJB وJPA)، بتفريغ الكائنات إلى القرص تحت الضغط الشديد؟

أم أن هذه العبارة قد عفا عليها الزمن؟

الإجابة:

باختصار: إذا لم تكن تطوّر تطبيق ويب موزّعًا ذا حالة، ولم تفعّل حفظ الجلسات (Session Persistence)، وتستخدم ذاكرات تخزين مؤقت داخل الذاكرة فقط، فقد تستغني عن Serializable تمامًا.

الشرح:

بالنسبة لتكرار الجلسات (Session Replication)، لا يزال تسلسل Java أمرًا لا غنى عنه وفق أحدث مواصفات Servlet: https://jakarta.ee/specifications/servlet/5.0/jakarta-servlet-spec-5.0.html#distributed-environments

من حالات الاستخدام الشائعة أن يكون لديك عدة خوادم خلف موازن أحمال، فتسجّل الدخول على أحدها وتحصل على ملف تعريف الارتباط JSESSIONID. فإذا أرسلك موازن الأحمال إلى خادم آخر في الطلب التالي، تُسلسَل سمات تسجيل الدخول وغيرها من السمات المرتبطة بالجلسة وتُنسخ إلى الخادم الجديد، فيبقى JSESSIONID صالحًا هناك أيضًا.

وحالة استخدام أخرى تحتاج فيها إلى التسلسل لسمات الجلسة هي حين يقرر خادم التطبيقات نقل الجلسات إلى القرص. ففي Tomcat 8 مثلًا يمكن ضبط ذلك ليُفعَّل حين يكثر عدد الجلسات بحيث لا تتسع لها الذاكرة، أو عند إعادة تشغيل الخادم مع الرغبة في إبقاء الجلسات حية. انظر https://tomcat.apache.org/tomcat-9.0-doc/config/manager.html

يجب على كل خادم تطبيقات متوافق مع Servlet 5.0 أن يدعم افتراضيًا الكائنات القابلة للتسلسل في جلسات HTTP لمثل هذه الحالات.

وفي بعض الخوادم يمكن تعديل ذلك أو استبداله، مثلًا باستخدام تسلسل Jackson-JSON أو ما شابهه. انظر مثلًا https://stackoverflow.com/questions/46459707/how-to-use-jackson-instead-of-jdkserializationredisserializer-in-spring

وحالة استخدام شائعة أخرى هي التخزين المؤقت. والغرض منه إبقاء الكائنات متاحة بأسرع ما يمكن، ويُفضَّل أن تكون في الذاكرة، لكن قد تُنقل مؤقتًا إلى القرص أو إلى خادم تخزين مؤقت خارجي.
توجد لذلك واجهة برمجية قياسية (JSR-107) اختارت صراحةً عدم اشتراط كائنات Serializable، لكن معظم التطبيقات لا تزال تستخدم تسلسل Java افتراضيًا في جزء النقل إلى القرص.

ويمكنك مجددًا تعديل ذلك لدعم آليات تسلسل أخرى، مثلًا في ehcache: https://www.ehcache.org/documentation/3.8/serializers-copiers.html

8. Hibernate: استراتيجية الجلب (Fetching)

بالنظر إلى مقالة EAGER fetching is a code smell، التي كتبها متحذلق ذو كاريزما استثنائية وهو أحد المساهمين في Hibernate، يُفضَّل LAZY بوصفه FetchType. فحتى لو كانت بعض أنواع البيانات لا معنى لها من دون مفتاح أجنبي، فإن هذه الأنواع تُستعلَم وحدها بالفعل (getCount()، وجداول البيانات، وغير ذلك).

فيما يلي كل السيناريوهات مرتبةً حسب مدى رضانا عن الحل، بدءًا بأقلها إرضاءً:

1. أسوأ الحالات: LazyInitializationException

لا حاجة إلى الكلام. كلنا نتشارك هذا الألم.

2. حل سريع

public void updateChatRoom(ChatMessage message) {
    // reload here or LazyInitializationException would be thrown
    message = this.chatMessageService.reload(message); // not optimal: second roundtrip to the database - could easily take 100ms!
    // we missed the chance to unproxy without a performance hit.

    ChatRoom chatRoom = message.getChatRoom().getUpdatedAt(DateTimeUtil.getNow());
}

3. فك الـ Proxy مباشرة في الخدمة

public ChatMessage getChatMessage(final Long id, boolean withRoom) {
    // assume this is the same service + the same transaction.
    // so we are in the same persistence context + everything will be performed together at the transaction flush.
    // perfect: no additional roundtrip like in example 2.!

    ChatMessage message = this.chatMessageService.getById(id);

    if (withRoom) {
        ChatRoom chatRoom = this.chatMessageService.unproxy(message.getChatRoom());
    }
   
    // we should not call message.setChatRoom() -> this getter should not even exist as Hibernate would 
    // map this relation for us! setting it again would perform an update on our foreign key!!
    // so we have no option to set the ChatRoom back to the message and have to pass it everywhere in a Tuple or something similar
    return ???; 
}

4. الجلب مباشرة في الخدمة (افعل هذا!)

public ChatMessage getChatMessage(final Long id, boolean withRoom) {
    // all aforementioned problems solved
    return this.executeSingleResultQuery((query, cb, root) -> {
            if (withRoom) {
                root.fetch(ChatRoomEntity_.partition).fetch(ChatPartitionEntity_.chatModule);
            }

            return cb.equal(root.get(ChatRoomEntity_.id), id);
        });
}

9. Hibernate Search

يرتبط Hibernate Search بسلاسة مع Hibernate JPA. فهو يرصد تغيّر الـ Entities، ويمكن إعداده بسهولة:

@Entity
@Indexed(interceptor = SearchableChatMessageInterceptor.class) // hier können Einträge von der Indexierung ausgeschlossen werden
@FullTextFilterDef(name = "SearchableChatMessageFilter", impl = SearchableChatMessageFilter.class) // hier wird der Key festgelegt
public class ChatMessageEntity implements Searchable { // das Searchable dient als Marker-Interface

    @Column
    @Field  // dieses Feld wird indexiert
    private String message;

    @ManyToOne(targetEntity = ChatRoomEntity.class, fetch = FetchType.LAZY, optional = false)
    @IndexedEmbedded(includeEmbeddedObjectId = true, targetElement = ChatRoomEntity.class)   // indexiert den Fremschlüssel mit.
                                                                                             // Über dieses Entity wird auch die Message gesucht,
                                                                                             // wie in @FullTextFilterDef -> SearchableChatMessageFilter definiert
    private ChatRoom chatRoom;

    // ...

}

البحث معطّل في البيئة المحلية:

// DefaultInstanceInitializer.java

// we don't need the global search everywhere, givin an option to override this setting here
protected boolean isBuildSearchIndexes() {
    return !Constants.Mode.isDebug();
}

البحث التقريبي وسلوك البحث

if ((needle.split(" ").length > 1 || needle.contains("\"")) && !searchExact) {
  /*
   * Simple Query String. Provides basic functionality like:
   *
   *     boolean (AND using “+”, OR using “|”, NOT using “-“)
   *     prefix (prefix*)
   *     phrase (“some phrase”)
   *     precedence (using parentheses)
   *     fuzzy (fuzy~2)
   *     near operator for phrase queries (“some phrase”~3)
   */
  query = qb.simpleQueryString()
            .onField(field)
            .andFields(moreFields)
            .withAndAsDefaultOperator()
            .matching(needle)
            .createQuery();
} // ...

Lucene

تصحيح أخطاء Lucene باستخدام Luke

يجب تصحيح أخطاء إصدارنا من Hibernate Search باستخدام Luke بالإصدار 6.6.0. فهو لا يعمل إلا مع هذا الإصدار!

ElasticSearch

يدعم Hibernate Search أيضًا ElasticSearch (ويُستخدم حينها بدلًا من Lucene، ويوفر بحثًا تقريبيًا أفضل / إحساسًا أقرب إلى Google).