
แสดงเงินบาทใน Java 21 ด้วย NumberFormat พร้อมเลขไทยและเทสต์
ใบเสร็จต้องแสดงยอด 1,234.50 บาท แต่หน้ารายงานอยากได้สัญลักษณ์เงิน ส่วนเอกสารอีกชุดขอเลขไทย ถ้าแต่ละหน้าต่อข้อความเอง กติกาทศนิยมกับการปัดเศษก็หลุดกันได้ง่าย ผมจะใช้ NumberFormat แยกเรื่องการแสดงผลออกจากยอดเงินจริง แล้วล็อกพฤติกรรมที่ต้องการด้วยเทสต์ครับ
WHY: ยอดเดียวกันต้องอ่านตรงกันทุกหน้าจอ
- ระบุ Locale ให้ชัด เพื่อให้หน้าจอภาษาไทยไม่ต้องอาศัยค่าภูมิภาคเริ่มต้นของเครื่องเซิร์ฟเวอร์
- กำหนดทศนิยมสองตำแหน่งทั้งค่าต่ำสุดและสูงสุด เพื่อให้ยอดเต็มบาทยังมี
.00และยอดที่ละเอียดกว่าสตางค์ผ่านกติกาปัดเศษเดียวกัน - เลือกรูปแบบสกุลเงินหรือข้อความลงท้ายว่า “บาท” ตามข้อกำหนดของเอกสาร โดยเก็บยอดสำหรับคำนวณแยกจากข้อความที่คนอ่าน
- เทสต์ผลลัพธ์ก่อนอัปเดต JDK เพราะสัญลักษณ์ ช่องว่าง และข้อมูลรูปแบบภาษาที่มากับ JDK/CLDR (ชุดข้อมูลภาษาและภูมิภาค) อาจทำให้ข้อความต่างจากเครื่องที่ใช้พัฒนา
ทำงานยังไง
Locale คือข้อมูลระบุภาษาและภูมิภาค เช่น th-TH ส่วน NumberFormat เป็นตัวจัดรูปแบบและอ่านข้อความตัวเลข เอกสาร NumberFormat แยกโรงงานสร้างตัวจัดรูปแบบไว้ชัดเจน: getCurrencyInstance(locale) สำหรับเงิน และ getNumberInstance(locale) สำหรับตัวเลขทั่วไป
นึกถึงยอดเงินเป็นของในกล่อง ส่วน formatter เป็นคนติดป้ายหน้ากล่อง เปลี่ยนป้ายเป็นเลขไทยหรือเติมคำว่าบาทได้ แต่ของข้างในต้องมีจำนวนเดิมครับ ตัวอย่างจึงส่ง BigDecimal เข้า formatter โดยตรง ไม่แปลงยอดเป็น double ก่อน และไม่เอาข้อความที่มีลูกน้ำกลับไปบวกยอด
ถ้าต้องการรูปแบบสกุลเงิน ใช้ NumberFormat.getCurrencyInstance(Locale.forLanguageTag("th-TH")) เครื่องที่ทดสอบได้ ฿1,234.50 ถ้าต้องการ 1,234.50 บาท ใช้ตัวจัดรูปแบบตัวเลข กำหนด setMinimumFractionDigits(2) และ setMaximumFractionDigits(2) แล้วเติมหน่วยเอง ขั้นต่ำช่วยเติมศูนย์ ส่วนขั้นสูงจำกัดทศนิยมที่แสดง
การปัดเศษต้องตกลงกับฝ่ายงานก่อน DecimalFormat ใช้ HALF_EVEN เป็นค่าเริ่มต้น เมื่ออยู่กึ่งกลางพอดีจะเลือกผลที่หลักสุดท้ายเป็นเลขคู่ ส่วน HALF_UP เมื่อกึ่งกลางจะปัดออกจากศูนย์ ตัวอย่าง 1234.505 จึงแสดงเป็น 1,234.50 กับ 1,234.51 ตามลำดับ ตัวจัดรูปแบบไม่ได้ตัดสินนโยบายบัญชีให้เรา และการแสดงผลนี้ไม่ได้แก้ยอด BigDecimal ต้นฉบับ
เลขไทยระบุด้วย th-TH-u-nu-thai โดย u เริ่มส่วนขยาย Unicode และ nu-thai ขอระบบตัวเลขไทย ตามเอกสาร Locale ส่วนขยายเป็นความต้องการที่ส่งให้บริการจัดรูปแบบ และการรองรับขึ้นกับบริการใน runtime จึงควรตรวจผลบนเครื่องที่จะใช้งานด้วย
โค้ดตัวอย่างพร้อมเทสต์
บันทึกเป็น Main.java แล้วรัน javac -encoding UTF-8 Main.java ตามด้วย java -ea Main ตัวเลือก -ea เปิด assert ซึ่งเป็นเงื่อนไขตรวจผล ถ้าลืมใส่ เทสต์เหล่านี้จะไม่ทำงานแม้โปรแกรมยังพิมพ์ OK ได้ ตัวอย่างใช้ยอดสมมติของสมชายในวันที่ 20 ตุลาคม 2026 และตรวจทั้งยอดศูนย์ ยอดติดลบ การปัดเศษ เลขไทย และข้อความนำเข้าที่มีส่วนเกิน
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.text.DecimalFormat;
import java.text.NumberFormat;
import java.text.ParsePosition;
import java.util.Locale;
public class Main {
static final Locale TH = Locale.forLanguageTag("th-TH");
static DecimalFormat number(Locale locale) {
NumberFormat nf = NumberFormat.getNumberInstance(locale);
if (!(nf instanceof DecimalFormat df)) {
throw new IllegalStateException("ต้องใช้ DecimalFormat");
}
df.setMinimumFractionDigits(2);
df.setMaximumFractionDigits(2);
df.setGroupingUsed(true);
return df;
}
// รับเฉพาะข้อความตัวเลข ไม่มีหน่วยบาทหรือช่องว่างท้าย
static BigDecimal parseAmount(String text) {
DecimalFormat parser = number(TH);
parser.setParseBigDecimal(true);
ParsePosition pos = new ParsePosition(0);
Number value = parser.parse(text, pos);
if (!(value instanceof BigDecimal amount)
|| pos.getIndex() != text.length()) {
throw new IllegalArgumentException("ยอดเงินไม่ครบหรือมีข้อความเกิน");
}
return amount;
}
public static void main(String[] args) {
BigDecimal amount = new BigDecimal("1234.50");
NumberFormat currency = NumberFormat.getCurrencyInstance(TH);
String receipt = currency.format(amount);
assert currency.getCurrency().getCurrencyCode().equals("THB");
assert receipt.equals("฿1,234.50") : receipt;
DecimalFormat plain = number(TH);
assert plain.getRoundingMode() == RoundingMode.HALF_EVEN;
assert plain.format(new BigDecimal("1234.505")).equals("1,234.50");
plain.setRoundingMode(RoundingMode.HALF_UP);
assert plain.format(new BigDecimal("1234.505")).equals("1,234.51");
assert plain.format(new BigDecimal("-1234.505")).equals("-1,234.51");
assert plain.format(new BigDecimal("0")).equals("0.00");
String baht = plain.format(amount) + " บาท";
assert baht.equals("1,234.50 บาท");
assert amount.toPlainString().equals("1234.50");
DecimalFormat thai = number(Locale.forLanguageTag("th-TH-u-nu-thai"));
String thaiBaht = thai.format(amount) + " บาท";
assert thaiBaht.equals("๑,๒๓๔.๕๐ บาท") : thaiBaht;
assert parseAmount("1,234.50").compareTo(amount) == 0;
boolean rejected = false;
try {
parseAmount("1,234.50 บาท");
} catch (IllegalArgumentException expected) {
rejected = true;
}
assert rejected : "ต้องปฏิเสธข้อความที่อ่านไม่ครบ";
System.out.println("สมชาย | 2026-10-20 | " + receipt);
System.out.println(baht);
System.out.println(thaiBaht);
System.out.println("OK: all assertions passed");
}
}
ผมรันด้วย OpenJDK Temurin 21.0.12.1+1-LTS โดยเปิด -ea แล้ว assertions ทั้งหมดผ่าน และโปรแกรมพิมพ์บรรทัดสุดท้ายว่า OK: all assertions passed ครับ
ผลสามบรรทัดก่อน OK คือ สมชาย | 2026-10-20 | ฿1,234.50, 1,234.50 บาท และ ๑,๒๓๔.๕๐ บาท เทสต์เทียบข้อความตรงตัวเหล่านี้อิงเครื่องที่ระบุ หาก JDK อีกชุดให้ผลต่าง ให้ตรวจข้อกำหนดเอกสารและผลจริงก่อนปรับค่าที่คาดหวัง
เมธอด number ตรวจชนิดด้วย instanceof เพราะ factory ไม่ได้รับประกันว่าจะคืน DecimalFormat เสมอ ตัวอย่างนี้ต้องใช้ความสามารถอ่านเป็น BigDecimal จึงหยุดพร้อมข้อความชัดเจนหากได้ชนิดอื่น ส่วนการตั้งทศนิยมสองตำแหน่งเป็นกติกาฝั่งแสดงผล อย่าใช้แทนการตรวจว่าข้อมูลนำเข้ามีทศนิยมไม่เกินสองหลัก
จุดที่คนพลาดบ่อย
- ใช้ double เก็บเงินแล้วค่อยจัดรูปแบบ — เลือก BigDecimal จากข้อความทศนิยมตั้งแต่รับข้อมูล เช่น
new BigDecimal("1234.50")โค้ดนี้ส่งค่านั้นตรงเข้า DecimalFormat ที่ตรวจชนิดแล้ว ไม่ผ่าน double ระหว่างทาง ความสวยของหน้าจอช่วยกู้ความละเอียดที่เสียไปก่อนหน้าไม่ได้ - ตั้งเฉพาะจำนวนทศนิยมสูงสุด — ตั้งทั้งขั้นต่ำและขั้นสูงเป็น 2 ถ้าต้องการสองหลักเสมอ และตั้ง rounding mode ให้ตรงกติกางาน เทสต์จุดกึ่งกลางอย่าง
.505รวมทั้งยอดติดลบ อย่าเดาจากยอดที่ไม่ต้องปัด - ใช้ formatter ตัวเดียวร่วมทุก thread — thread คือสายการทำงานที่อาจทำงานพร้อมกัน เอกสาร NumberFormat ระบุว่าโดยทั่วไปไม่ได้ synchronize ให้ ใช้ instance แยกต่อ thread หรือควบคุมการเข้าถึงจากภายนอก ตัวอย่างสร้างในงานแต่ละครั้งเพื่อไม่แชร์สถานะ
- parse แล้วคิดว่าอ่านครบแน่นอน — ใช้ locale ที่ตรงกับข้อมูล เปิด
setParseBigDecimal(true)และตรวจ ParsePosition ว่าอ่านถึงท้ายจริง โค้ดจึงปฏิเสธ1,234.50 บาทเพราะฟังก์ชันนี้รับเฉพาะส่วนตัวเลข การอ่านครบยังไม่ใช่ตัวตรวจรูปแบบเข้มงวดทั้งหมด ต้องตรวจเงื่อนไขธุรกิจ เช่น จำนวนหลักทศนิยมหรือยอดติดลบ เพิ่มต่างหาก - ผูกสัญลักษณ์เงินกับข้อความตายตัวทุกเครื่อง — ตรวจสกุลเงินและตัวอักษรที่ runtime สร้างจริง รวมถึงช่องว่าง อย่าแทน
฿หรือTHBด้วยการตัดข้อความแบบเดา ๆ ถ้างานกำหนดให้ลงท้าย “บาท” ชัดเจน ให้เลือก number formatter แล้วเติมหน่วยตามตัวอย่าง
ใช้ตรงไหนในงานจริง
- หน้ารายละเอียดใบสั่งซื้อ ใช้รูปแบบสกุลเงินเพื่อให้ผู้ใช้เห็นหน่วยของยอดชัดเจน
- รายงานค่าใช้จ่ายภายใน ใช้ตัวเลขสองตำแหน่งลงท้าย “บาท” ให้ตรงแบบฟอร์มที่ทีมบัญชีตกลงไว้
- เอกสารที่ต้องใช้เลขไทย เลือก locale ที่มี
u-nu-thaiโดยเก็บยอดคำนวณชุดเดิม - แบบฟอร์มรับยอดเงิน อ่านตัวเลขตาม locale และตรวจข้อความส่วนเกิน ก่อนนำยอดไปผ่านเงื่อนไขธุรกิจ
เทียบกับทางเลือกอื่น
| approach | ดีตรงไหน | ระวังอะไร |
|---|---|---|
Currency NumberFormat + th-TH | ใช้รูปแบบสกุลเงินของ locale | สัญลักษณ์และช่องว่างต้องตรวจบน JDK ที่ใช้ |
| NumberFormat + คำว่า “บาท” | กำหนดหน่วยลงท้ายตรงแบบฟอร์ม | ตั้งทศนิยมและ rounding mode ให้ครบเอง |
NumberFormat + u-nu-thai | ขอเลขไทยผ่าน locale โดยตรง | ตรวจว่าบริการใน runtime รองรับและได้ผลตามต้องการ |
| DecimalFormat กำหนด pattern เอง | คุมแม่แบบได้ละเอียด | ต้องดูสัญลักษณ์ของ locale และไม่ทำให้รูปแบบอ่านกลับกำกวม |
Recap
- เก็บยอดเป็น BigDecimal จากข้อความ และไม่ส่งผ่าน double
- ระบุ
Locale.forLanguageTag("th-TH")ให้ formatter - เลือก currency formatter หรือ number formatter ให้ตรงรูปแบบเอกสาร
- ตั้งทศนิยมขั้นต่ำและขั้นสูงเป็น 2 เมื่อต้องแสดงสองตำแหน่งเสมอ
- กำหนด HALF_UP หรือ HALF_EVEN ตามกติกางาน พร้อมเทสต์จุดกึ่งกลาง
- ใช้
th-TH-u-nu-thaiเมื่อต้องการเลขไทย และตรวจผลบน JDK จริง - แยก formatter ที่ใช้พร้อมกัน และตรวจขอบเขตข้อความเมื่อ parse
- รันด้วย
java -ea Mainและทบทวนผลข้อความเมื่อเปลี่ยน JDK