بهترین روش‌های من – Java

۱. Pattern.compile() به جای String.replaceAll() در حلقه

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

Pattern همیشه باید ابتدا کامپایل شود. اگر همان Pattern بارها روی رشته‌های کوتاه اجرا شود، کارایی به‌مراتب بهتر است اگر Pattern کامپایل‌شده دوباره استفاده شود.

۲. StringBuilder به جای + در حلقه

new StringBuilder() در حلقه کاراتر از + است.

۳. استفاده‌ی درست از equals() و hashCode()

گاهی همه‌ی فیلدها در equals() یا hashCode() لحاظ می‌شوند. این برای بررسی اینکه آیا یک Entity تغییر کرده مفید است، اما نادرست است. هدف equals() بررسی تغییر یک Entity نیست، بلکه تشخیص این است که آیا – حتی اگر نمونه‌ی شیء یکی نباشد – باز هم با همان Entity سروکار داریم. یعنی: فقط از کلید اصلی (Primary Key) استفاده کنید!

این برای هر نوع Collection لازم است. وقتی .contains() را روی یک List صدا می‌زنید، Java از متد equals استفاده می‌کند. HashMapها علاوه بر آن به hashCode() نیاز دارند. این دو باید با هم بازنویسی (override) شوند.

یک دام رایج، Entityهای ذخیره‌نشده‌ی بدون ID است. اینجا احتیاط کنید و زود persist کنید تا از مشکلات جلوگیری شود.

برای مقایسه:

قرارداد این متدها همیشه باید رعایت شود:

۴. دام‌های 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() متکی‌اند و هرگز نباید بی‌واسطه استفاده شوند:

۵. نوع‌های داده‌ی پیچیده را درجا تعریف نکنید

برای نوع‌های داده‌ی پیچیده همیشه کلاس‌های جداگانه بسازید، به جای تودرتو کردن Pair، Listها و مانند آن.

بد:

// 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);

۶. کش – اما درست (راهنمای راه‌اندازی حداقلی)

کش (Cache، تلفظ /kæʃ/، مثل «cash») در اصل فقط یک Map است که مدخل‌ها را نگه می‌دارد. از چیزهای پیچیده‌تر مثل ابطال زمان‌محور و مانند آن صرف‌نظر شده است.

وقتی یک Map ساده کافی است، لازم نیست همیشه سراغ کتابخانه بروید! بسیاری از توسعه‌دهندگان این را درک نمی‌کنند و از سر ترس اصولاً کتابخانه برمی‌دارند. مهم‌ترین نکته در کش کردن این است که ابطال در زمان درست انجام شود.

دام شماره‌ی ۱: نشت حافظه (Memory Leak)

نشت حافظه هنگام کش کردن خیلی راحت پیش می‌آید. کافی است فراموش کنید equals() و hashCode() را درست بازنویسی کنید تا یک HashMap داشته باشید که حافظه را نشت می‌دهد. روشی که همیشه امن است، ساختن دستی کلیدهای Map است. خوب است فقط ID یک Entity را لحاظ کنید، وگرنه Map حتی با تغییر یک فیلد هم نشت می‌کند. و این درست هم هست، چون اگر نام کوچک یک کاربر تغییر کند، او هنوز همان کاربر است. مقایسه کنید: ۳. استفاده‌ی درست از equals() و hashCode()

دام شماره‌ی ۲: ایمنی نخ (Thread Safety)

کش در بهترین حالت از یک ConcurrentHashMap استفاده می‌کند که عملیات اتمیک و thread-safe ارائه می‌دهد. 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 می‌شد.

۷. سریال‌سازی – آیا هنوز لازم است؟

پرسش من: یک قاعده‌ی Sonar هست با عنوان فیلدهای یک کلاس «Serializable» باید یا transient باشند یا serializable. این قاعده وقتی ظاهر می‌شود که یک فیلد غیرقابل‌سریال‌سازی به کلاستان اضافه کنید. متن قاعده می‌گوید:

برای نمونه، بیشتر فریم‌ورک‌های برنامه‌های J2EE زیر بار، اشیا را روی دیسک می‌ریزند، و یک شیء ظاهراً Serializable با اعضای داده‌ای که نه transient هستند و نه serializable می‌تواند باعث از کار افتادن برنامه شود و راه را برای مهاجمان باز کند.

به نظرم این حرف قدیمی است. در Effective Java 3rd Edition آمده است که سریال‌سازی هنگام توسعه‌ی نرم‌افزار جدید اهمیتی ندارد. همچنین حذف سریال‌سازی از Java یک «هدف بلندمدت» در Oracle است. خودِ معمار ارشد Java، Mark Reinhold، گفته است:

«سریال‌سازی در ۱۹۹۷ اشتباهی وحشتناک بود.»

این پرسش که آیا فریم‌ورک Spring ممکن است اشیا را روی دیسک بریزد، پیش‌تر همین‌جا مطرح شده بود:

پس پرسش من این است:

آیا هیچ‌یک از سرورهای برنامه یا فریم‌ورک‌های مدرن، یا خود پیاده‌سازی J2EE (به‌ویژه مؤلفه‌های Java که با CDI، EJB و JPA تعامل دارند)، اشیا را زیر بار سنگین روی دیسک می‌ریزند؟

یا این گفته منسوخ شده است؟

پاسخ:

خلاصه: اگر یک برنامه‌ی وب توزیع‌شده‌ی دارای حالت (stateful) پیاده‌سازی نمی‌کنید، ماندگاری نشست (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 باید به‌طور پیش‌فرض از اشیای Serializable در نشست‌های HTTP برای چنین مواردی پشتیبانی کند.

در برخی سرورها می‌توان این را تنظیم یا جایگزین کرد، مثلاً با سریال‌سازی Jackson-JSON یا چیزی مشابه. برای نمونه ببینید https://stackoverflow.com/questions/46459707/how-to-use-jackson-instead-of-jdkserializationredisserializer-in-spring

مورد استفاده‌ی رایج دیگر کش کردن است. هدف این است که اشیا با بیشترین سرعت ممکن در دسترس بمانند، در حالت ایده‌آل در حافظه، اما شاید موقتاً روی دیسک یا یک سرور کش خارجی منتقل شوند.
یک API استاندارد برای آن وجود دارد (JSR-107) که صریحاً تصمیم گرفته اشیای Serializable را الزامی نکند، اما بیشتر پیاده‌سازی‌ها هنوز به‌طور پیش‌فرض برای بخش انتقال به دیسک از سریال‌سازی Java استفاده می‌کنند.

این‌ها را هم می‌توانید برای پشتیبانی از سازوکارهای دیگر سریال‌سازی تنظیم کنید، مثلاً برای ehcache: https://www.ehcache.org/documentation/3.8/serializers-copiers.html

۸. Hibernate: راهبرد واکشی (Fetching)

با توجه به مقاله‌ی EAGER fetching is a code smell، نوشته‌ی یک همه‌چیزدان فوق‌العاده کاریزماتیک که از مشارکت‌کنندگان Hibernate است، LAZY به‌عنوان FetchType ارجح است. حتی اگر برخی نوع‌های داده بدون کلید خارجی معنایی نداشته باشند، باز هم این نوع‌ها به‌تنهایی کوئری می‌شوند (getCount()، جدول‌های داده و غیره).

در اینجا همه‌ی سناریوها به ترتیب میزان رضایت‌بخش بودن راه‌حل آمده‌اند، از کم‌رضایت‌بخش‌ترین شروع می‌کنیم:

۱. بدترین حالت: LazyInitializationException

نیازی به توضیح نیست. همه‌ی ما در این درد شریکیم.

۲. راه‌حل سریع

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());
}

۳. Unproxy مستقیم در سرویس

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 ???; 
}

۴. Fetch مستقیم در سرویس (این کار را بکنید!)

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);
        });
}

۹. Hibernate Search

Hibernate Search به‌طور یکپارچه با Hibernate JPA متصل است. تغییر Entityها را متوجه می‌شود و به‌سادگی پیکربندی می‌شود:

@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).