นากการ์ตูนสีเขียวใส่เอี๊ยมเรียงเหรียญทองและธนบัตรบนโต๊ะไม้ ข้างถาดที่มีตัวเลขไทยรูปทรงการ์ตูนและเครื่องคิดเงินสีเขียวมิ้นต์

แสดงเงินบาทใน Java 21 ด้วย NumberFormat พร้อมเลขไทยและเทสต์

น้องกายEngineering

ใบเสร็จต้องแสดงยอด 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