Best Practices ของผม – 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 เท่านั้น!
สิ่งนี้จำเป็นสำหรับ collection ทุกประเภท เมื่อเรียก .contains() บน List แล้ว Java จะใช้เมธอด equals ส่วน HashMap ยังต้องใช้ hashCode() ด้วย
ทั้งสองเมธอดจึงควร override ไปพร้อมกัน
หลุมพรางที่พบบ่อยคือ entity ที่ยังไม่ได้บันทึกและยังไม่มี ID ตรงนี้ต้องระวัง และควร persist ให้เร็วเพื่อหลีกเลี่ยงปัญหา
อ่านเพิ่มเติม:
ต้องปฏิบัติตามสัญญา (contract) ของเมธอดเหล่านี้เสมอ:
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() เช่นกัน และไม่ควรใช้แบบเปล่า ๆ โดยไม่ปรับแต่ง:
@CreationTimestamp@UpdateTimestamp@PastOrPresent
5. อย่าประกาศชนิดข้อมูลที่ซับซ้อนแบบ inline
สำหรับชนิดข้อมูลที่ซับซ้อน ให้สร้างคลาสของตัวเองเสมอ แทนการซ้อน 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 ...
}
ข้อดี:
- ได้ชื่อที่ดีกว่า
getFirst()และgetSecond() - วิธีใช้งานถูกกำหนดไว้ชัดเจนผ่าน getter และ setter (คลาสนี้ immutable หรือไม่? ลิสต์ไหนเพิ่มข้อมูลได้? ต้องสร้างอ็อบเจกต์อย่างไร?) → Encapsulation อธิบายวิธีใช้โค้ดได้ด้วยตัวเอง
- อ่านง่ายขึ้นอย่างเห็นได้ชัด
ข้อยกเว้น:
สำหรับความสัมพันธ์ง่าย ๆ Pair ใช้ได้ดีมาก! ตัวอย่างเช่น:
Pair<User, Training> pair = this.addCurrentUserToTraining(training);
6. การแคช – ทำให้ถูกวิธี (คู่มือตั้งค่าขั้นต่ำ)
แคช (cache ออกเสียงว่า /kæʃ/ เหมือน “cash”) โดยหลักการแล้วก็คือ Map ที่เก็บรายการข้อมูลไว้ ในที่นี้ไม่ได้ทำเรื่องที่ซับซ้อนกว่านั้น เช่น การ invalidate ตามเวลา
ไม่จำเป็นต้องใช้ไลบรารีเสมอไป ถ้า Map ธรรมดาก็เพียงพอแล้ว! นักพัฒนาหลายคนไม่เข้าใจข้อนี้ และหยิบไลบรารีมาใช้ทุกครั้งเพราะความกลัว สิ่งสำคัญที่สุดในการแคชคือ ต้อง invalidate ในเวลาที่ถูกต้อง
หลุมพรางที่ 1: Memory Leak
Memory Leak เกิดขึ้นได้ง่ายมากเวลาทำแคช แค่ลืม override equals() และ hashCode() ให้เรียบร้อย ก็จะได้ HashMap
ที่หน่วยความจำรั่วทันที วิธีที่ปลอดภัยเสมอคือสร้าง key ของ Map ด้วยตัวเอง ที่ดีคือใช้แค่ ID ของ entity เท่านั้น ไม่เช่นนั้น Map จะรั่วตั้งแต่ตอนที่
แก้ไขฟิลด์เพียงฟิลด์เดียว ซึ่งก็ถูกต้องแล้ว เพราะแค่ชื่อของผู้ใช้เปลี่ยน ก็ยังเป็นผู้ใช้คนเดิมอยู่ดี อ่านเพิ่มเติม: 3. ใช้ equals() และ hashCode()
ให้ถูกต้อง
หลุมพรางที่ 2: Thread Safety
แคชควรใช้ ConcurrentHashMap ซึ่งมีการทำงานแบบ atomic และ thread-safe ให้ Map ถูกแก้ไขผ่านการเรียกเพียงครั้งเดียวเท่านั้น – computeIfAbsent() – ด้วยวิธีนี้จึงได้ Thread Safety:
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 ชื่อ Fields in a “Serializable” class should either be transient or serializable (ฟิลด์ในคลาสที่เป็น “Serializable” ควรเป็น transient หรือ serializable) กฎนี้จะแจ้งเตือน เมื่อเพิ่มฟิลด์ที่ serialize ไม่ได้ลงในคลาส โดยกฎระบุไว้ว่า:
ตัวอย่างเช่น เมื่อมีโหลดสูง เฟรมเวิร์กแอปพลิเคชัน J2EE ส่วนใหญ่จะ flush อ็อบเจกต์ลงดิสก์ และอ็อบเจกต์ที่อ้างว่าเป็น Serializable แต่มีสมาชิกข้อมูลที่ไม่ใช่ transient และ serialize ไม่ได้ อาจทำให้โปรแกรม ล่ม และเปิดช่องให้ผู้โจมตีได้
สำหรับผม ข้อความนี้ฟังดูล้าสมัยแล้ว ในหนังสือ Effective Java 3rd Edition ระบุว่า Serialization ไม่มีความสำคัญเมื่อพัฒนาซอฟต์แวร์ใหม่ อีกทั้ง Removing Serialization from Java Is a 'Long-Term Goal' at Oracle และ Mark Reinhold หัวหน้าสถาปนิกของ Java เองก็กล่าวไว้ว่า
“Serialization คือความผิดพลาดครั้งใหญ่หลวงในปี 1997”
คำถามว่าเฟรมเวิร์ก Spring อาจ flush อ็อบเจกต์หรือไม่ เคยมีคนถามไว้ที่นี่แล้ว:
ผมจึงขอถามว่า:
มีแอปพลิเคชันเซิร์ฟเวอร์ เฟรมเวิร์ก หรือตัว implementation ของ J2EE เองในยุคปัจจุบัน (โดยเฉพาะคอมโพเนนต์ Java ที่ทำงานร่วมกับ CDI, EJB, JPA) ที่ flush อ็อบเจกต์ลงดิสก์ เมื่อมีโหลดสูงหรือไม่?
หรือข้อความนี้ล้าสมัยไปแล้ว?
คำตอบ:
TL;DR ถ้าคุณไม่ได้สร้างเว็บแอปพลิเคชันแบบ stateful ที่กระจายตัวได้ (distributable) ไม่ได้เปิดใช้ session persistence และใช้แค่แคชในหน่วยความจำ คุณก็อาจไม่ต้อง ใช้ Serializable ที่ไหนเลย
คำอธิบาย:
สำหรับการทำ session replication นั้น Java Serialization ยังคงจำเป็นตาม Servlet Specification ฉบับล่าสุด: https://jakarta.ee/specifications/servlet/5.0/jakarta-servlet-spec-5.0.html#distributed-environments
กรณีใช้งานที่พบบ่อยคือ เมื่อมีเซิร์ฟเวอร์หลายเครื่องอยู่หลัง load balancer และคุณล็อกอินบนเซิร์ฟเวอร์เครื่องหนึ่ง คุณจะได้คุกกี้ JSESSIONID ถ้า load balancer ส่งคำขอถัดไปของคุณ
ไปยังเซิร์ฟเวอร์อีกเครื่อง แอตทริบิวต์การล็อกอินและแอตทริบิวต์อื่น ๆ ในขอบเขต session จะถูก serialize และทำสำเนาไปยังเซิร์ฟเวอร์เครื่องใหม่ เพื่อให้ JSESSIONID ของคุณยังใช้ได้ที่นั่น
อีกกรณีที่ต้องใช้ Serialization กับแอตทริบิวต์ในขอบเขต session คือเมื่อแอปพลิเคชันเซิร์ฟเวอร์ตัดสินใจย้าย session ไปเก็บไว้บนดิสก์ เช่นใน Tomcat 8 สามารถตั้งค่าให้ทำแบบนี้ เมื่อมี session มากเกินกว่าจะเก็บไว้ในหน่วยความจำได้ หรือเมื่อรีสตาร์ตเซิร์ฟเวอร์แล้วต้องการให้ session ยังอยู่ ดู https://tomcat.apache.org/tomcat-9.0-doc/config/manager.html
แอปพลิเคชันเซิร์ฟเวอร์ทุกตัวที่รองรับ Servlet 5.0 ต้องรองรับอ็อบเจกต์ Serializable ใน HTTP session เป็นค่าเริ่มต้นสำหรับกรณีการใช้งานลักษณะนี้
สำหรับเซิร์ฟเวอร์บางตัว สามารถปรับแต่งหรือแทนที่กลไกนี้ได้ เช่น ใช้ JSON serialization ของ Jackson หรือวิธีที่คล้ายกัน ดูตัวอย่างที่ https://stackoverflow.com/questions/46459707/how-to-use-jackson-instead-of-jdkserializationredisserializer-in-spring
อีกกรณีที่พบบ่อยคือการแคช ซึ่งใช้เพื่อให้เข้าถึงอ็อบเจกต์ได้เร็วที่สุด โดยในอุดมคติจะอยู่ในหน่วยความจำ แต่ก็อาจถูกย้ายไปเก็บบนดิสก์หรือบนแคชเซิร์ฟเวอร์ภายนอกชั่วคราว
มี API มาตรฐานสำหรับเรื่องนี้ (JSR-107) ซึ่งเลือกโดยเจตนาที่จะไม่บังคับให้อ็อบเจกต์ต้องเป็น Serializable แต่ implementation ส่วนใหญ่ยังคงใช้ Java Serialization เป็นค่าเริ่มต้นในส่วนของการย้ายข้อมูลออกไปเก็บ
คุณสามารถปรับแต่งส่วนนี้ให้รองรับกลไก serialization อื่นได้เช่นกัน เช่น สำหรับ ehcache: https://www.ehcache.org/documentation/3.8/serializers-copiers.html
8. Hibernate: กลยุทธ์การ fetch
เมื่อพิจารณาจากบทความ EAGER fetching is a code smell ซึ่งเขียนโดยจอมรู้ดีผู้มีเสน่ห์ล้นเหลือที่เป็น contributor ของ Hibernate
ควรเลือก LAZY เป็น FetchType แม้ข้อมูลบางชนิดจะไม่มีความหมายเลยถ้าไม่มี foreign key แต่ข้อมูลชนิดเหล่านี้ก็ยังถูก
query แยกเดี่ยว ๆ อยู่ดี (getCount(), data table ฯลฯ)
ต่อไปนี้คือทุกสถานการณ์ เรียงตามระดับความน่าพอใจของวิธีแก้ เริ่มจากที่น่าพอใจน้อยที่สุด:
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. Unproxy ใน service โดยตรง
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. Fetch ใน service โดยตรง (ทำแบบนี้!)
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 ได้อย่างไร้รอยต่อ มันรับรู้เมื่อ 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;
// ...
}
บนเครื่อง local การค้นหาถูกปิดไว้:
// 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();
}
การค้นหาแบบคลุมเครือ (fuzzy) และพฤติกรรมการค้นหา
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 ใช้ได้กับเวอร์ชันนี้เท่านั้น!
- ดาวน์โหลด: https://github.com/DmitryKey/luke/releases
- ใช้เปิดดูอินเด็กซ์จากโฟลเดอร์
target/ได้ - เป็นจุดที่ดีสำหรับตรวจว่า entity ถูกทำอินเด็กซ์อย่างถูกต้องหรือไม่
- สั่งค้นหาจากใน Luke ได้โดยตรงด้วย
ElasticSearch
Hibernate Search รองรับ ElasticSearch ด้วย (ใช้แทน Lucene และมีการค้นหาแบบ fuzzy ที่ดีกว่า / ให้ความรู้สึกแบบ Google มากกว่า)