
ย่อตัวเลขบนแดชบอร์ดด้วย CompactNumberFormat ใน Java 21
การ์ดยอดขายบนมือถือมีพื้นที่นิดเดียว แต่ยอดเริ่มยาวจนเบียดชื่อร้านและหน่วยบาทออกนอกกรอบ การย่อเลขช่วยให้คนกวาดตาดูภาพรวมได้เร็วขึ้น โดยยังเก็บยอดเต็มไว้ตรวจสอบได้ ผมจะใช้ Java ย่อทั้งยอดวิวและยอดขาย พร้อมเทสต์ผลภาษาไทยครับ
WHY: ย่อเพื่ออ่านเร็ว แต่ต้องรู้ว่าเสียความละเอียดตรงไหน
- ลดความยาวตัวเลขบนการ์ดและตาราง
- ใช้กติกาตาม Locale ซึ่งคือชุดภาษาและภูมิภาค แทนการต่อท้าย K หรือคำว่า “ล้าน” เองทุกจุด
- กำหนดทศนิยมและวิธีปัดเศษให้ตรงกันทั้งหน้าจอ
- เทสต์ข้อความเพื่อจับการเปลี่ยนแปลงเมื่ออัปเดตระบบ
CompactNumberFormat มีตั้งแต่ Java 12 และออกแบบมาสำหรับการแสดงตัวเลขในพื้นที่จำกัดโดยตรง บทความนี้ใช้ Java 21 เป็นฐาน ยอดขายทั้งหมดเป็นข้อมูลสมมติ
ข้อความย่อใช้แสดงภาพรวม ควรเก็บยอดเต็มไว้ลงบัญชีและเปิดให้ตรวจสอบได้ เพราะการปัดเศษทำให้ความละเอียดหายไป
ทำงานยังไง
เรียก NumberFormat.getCompactNumberInstance(locale, style) เพื่อสร้างตัวจัดรูปแบบตามภาษาและสไตล์ แล้วส่งเลขเข้า format() วิธีสร้างผ่าน factory หรือเมธอดที่สร้างออบเจ็กต์ให้เรา เป็นวิธีที่แนะนำใน CompactNumberFormat API
เหมือนป้ายราคาหน้าร้านครับ ป้ายเล็กเขียนสั้นลง formatter ช่วยเขียนป้ายตามธรรมเนียมแต่ละภาษา โดยยอดจริงหลังร้านยังเท่าเดิม
SHORT ขอรูปแบบสั้น ส่วน LONG ขอรูปแบบยาว ผลที่ผมรันบน JDK ที่ระบุด้านล่าง สำหรับ 1,200 คือ 1.2K ทั้งใน Locale.US และ th-TH เมื่อใช้ SHORT แต่ LONG ให้ 1.2 thousand และ 1.2 พัน ตามลำดับ เลือกภาษาไทยจึงไม่ได้แปลว่า K จะกลายเป็น “พัน” ทันที
ค่าเริ่มต้นไม่แสดงทศนิยม และปัดแบบ HALF_EVEN ตาม เอกสาร CompactNumberFormat ซึ่งกรณีกึ่งกลางจะเลือกค่าที่หลักสุดท้ายเป็นเลขคู่ เราจะใช้ setMaximumFractionDigits(1) เพื่อให้มีทศนิยมได้ไม่เกินหนึ่งหลัก และเลือก HALF_UP เพื่อปัดขึ้นเมื่ออยู่กึ่งกลาง เช่น 1,250 ในตัวอย่างกลายเป็น 1.3K ส่วนค่าเริ่มต้น HALF_EVEN เมื่อเปิดทศนิยมหนึ่งหลักให้ 1.2K
maximum คือเพดานทศนิยม ถ้าต้องการกำหนดขั้นต่ำด้วย ให้ใช้ setMinimumFractionDigits() ตาม NumberFormat API
โค้ดตัวอย่างพร้อมเทสต์
บันทึกบล็อกนี้เป็น Main.java ด้วย UTF-8 แล้วรัน javac -encoding UTF-8 Main.java ตามด้วย java -ea Main -ea เปิดตรวจ assert ถ้าลืมใส่จะไม่ได้ตรวจเงื่อนไขเทสต์ครับ
ตัวอย่างเก็บยอดขายร้านมะลิเป็นบาทเต็ม parseAll() ใช้ ParsePosition หรือตัวติดตามตำแหน่งที่อ่านถึง เพื่อปฏิเสธข้อความที่มีส่วนเกินท้ายตัวเลข
import java.math.RoundingMode;
import java.text.NumberFormat;
import java.text.ParsePosition;
import java.util.Locale;
public class Main {
record ShopDay(String shop, long views, long salesBaht) {}
static NumberFormat compact(Locale locale, NumberFormat.Style style) {
NumberFormat format = NumberFormat.getCompactNumberInstance(locale, style);
format.setMaximumFractionDigits(1);
format.setRoundingMode(RoundingMode.HALF_UP);
return format;
}
static Number parseAll(NumberFormat format, String text) {
ParsePosition position = new ParsePosition(0);
Number value = format.parse(text, position);
if (value == null || position.getIndex() != text.length()) {
throw new IllegalArgumentException("อ่านตัวเลขไม่ครบ: " + text);
}
return value;
}
static void expectText(String actual, String expected) {
assert actual.equals(expected) : "expected=" + expected + ", actual=" + actual;
}
public static void main(String[] args) {
assert Runtime.version().feature() == 21 : "ตัวอย่างนี้ทดสอบบน Java 21";
// Expected strings observed on Temurin 21.0.12.1+1-LTS.
Locale thai = Locale.forLanguageTag("th-TH");
NumberFormat usShort = compact(Locale.US, NumberFormat.Style.SHORT);
NumberFormat usLong = compact(Locale.US, NumberFormat.Style.LONG);
NumberFormat thShort = compact(thai, NumberFormat.Style.SHORT);
NumberFormat thLong = compact(thai, NumberFormat.Style.LONG);
ShopDay day = new ShopDay("ร้านมะลิ", 1200L, 12345L);
expectText(usShort.format(day.views()), "1.2K");
expectText(usLong.format(day.views()), "1.2 thousand");
expectText(thShort.format(day.views()), "1.2K");
expectText(thLong.format(day.views()), "1.2 พัน");
expectText(usShort.format(day.salesBaht()), "12.3K");
expectText(thLong.format(day.salesBaht()), "1.2 หมื่น");
expectText(thShort.format(1250000L), "1.3M");
expectText(thLong.format(1250000L), "1.3 ล้าน");
NumberFormat defaults = NumberFormat.getCompactNumberInstance(
Locale.US, NumberFormat.Style.SHORT);
assert defaults.getRoundingMode() == RoundingMode.HALF_EVEN;
expectText(defaults.format(1200L), "1K");
defaults.setMaximumFractionDigits(1);
expectText(defaults.format(1250L), "1.2K");
expectText(usShort.format(1250L), "1.3K");
assert parseAll(usShort, "1.2K").longValue() == 1200L;
assert parseAll(thLong, "1.2 พัน").longValue() == 1200L;
long usRestored = parseAll(usShort, usShort.format(day.salesBaht())).longValue();
long thRestored = parseAll(thLong, thLong.format(day.salesBaht())).longValue();
assert usRestored == 12300L;
assert thRestored == 12000L;
boolean rejected = false;
try {
parseAll(usShort, "1.2K trailing");
} catch (IllegalArgumentException expected) {
rejected = true;
}
assert rejected : "ต้องไม่ยอมรับข้อความที่มีส่วนเกิน";
System.out.println(day.shop());
System.out.println("ยอดวิว: " + thShort.format(day.views()));
System.out.println("ยอดขาย: " + thLong.format(day.salesBaht()) + " บาท");
System.out.println("OK: compact number tests passed");
}
}
ผมรันด้วย OpenJDK Temurin 21.0.12.1+1-LTS โดยคอมไพล์ด้วย javac 21.0.12.1 และเปิด -ea แล้ว เทสต์ทั้งหมดผ่านครับ
ผลพิมพ์มี ยอดวิว: 1.2K และ ยอดขาย: 1.2 หมื่น บาท ปิดท้ายด้วย OK: compact number tests passed แบบไทย LONG ใช้หน่วยหมื่นในเคสนี้ ขณะที่ US SHORT ใช้ K
parse ใช้ formatter ภาษาและสไตล์เดียวกับข้อความ เทสต์ยืนยันว่า 1.2 พัน อ่านได้ 1,200 แต่ยอดขาย 12,345 หลังย่ออ่านกลับได้ 12,000 ในไทย LONG และ 12,300 ใน US SHORT การ parse จึงคืนยอดเดิมไม่ได้
จุดที่คนพลาดบ่อย
- คิดว่าเลือกภาษาไทยแล้วจะได้คำไทยทุกแบบ — ในสภาพแวดล้อมที่ทดสอบ SHORT ยังให้ K และ M ให้ลองทั้ง SHORT และ LONG แล้วเลือกรูปแบบที่เหมาะกับหน้าจอจริง
- เปิดทศนิยมแต่ไม่ได้ตกลงวิธีปัดเศษ — ค่าเริ่มต้นเป็น HALF_EVEN หากหน้าจอต้องใช้ HALF_UP ให้ตั้งชัดเจน และมีเทสต์ค่ากึ่งกลางอย่าง 1,250 ไม่ใช่ทดสอบเฉพาะเลขลงตัว
- ใช้ข้อความย่อแทนข้อมูลต้นฉบับ — เก็บยอดเต็มสำหรับคำนวณและส่งออกเสมอ เพิ่มคำว่า “บาท” ในส่วนแสดงผลอย่างตัวอย่าง เพราะ compact format ไม่ใช่ currency format หรือรูปแบบสกุลเงิน
- เห็น parse สำเร็จแล้วคิดว่าอ่านครบ — ตรวจตำแหน่งสุดท้ายด้วย ParsePosition ด้วย เอกสาร NumberFormat ระบุว่าการ parse อาจไม่ได้ใช้ทุกอักขระจนจบสตริง ตัวช่วยในตัวอย่างจึงตรวจทั้งค่าที่คืนมาและความยาวที่อ่านไป
- ล็อกข้อความเทสต์แต่ปล่อย JDK เปลี่ยน หรือแชร์ formatter ข้ามเธรด — ข้อความขึ้นกับข้อมูล locale รวมถึง CLDR ซึ่งเป็นชุดข้อมูลกติกาภาษาและภูมิภาค และเวอร์ชัน JDK ให้ตรึง Temurin รุ่นที่สังเกตผลไว้ใน CI ซึ่งคือระบบรันตรวจอัตโนมัติ และทบทวนผลเมื่ออัปเกรด ส่วนงานหลายเธรดให้สร้าง formatter แยกตามเธรด เพราะ NumberFormat ไม่รับประกันการใช้งานพร้อมกันโดยไม่มีการควบคุม
ใช้ตรงไหนในงานจริง
- การ์ดยอดวิวและยอดติดตามบนมือถือ ใช้ SHORT เพื่อให้ข้อความสั้น แล้วมีหน้ารายละเอียดแสดงจำนวนเต็ม
- หน้าสรุปยอดขายภาษาไทย ใช้ LONG และใส่หน่วยบาทให้ชัด
- ตารางหลายร้าน ใช้ Locale และกติกาทศนิยมร่วมกันทั้งคอลัมน์
- ระบบเลือกภาษา สร้าง formatter ตามภาษาที่เลือก เอกสารแนะนำให้ใช้ instance ซ้ำเมื่อจัดรูปแบบหลายค่า โดยใช้ภายในเธรดเดียวกัน
เทียบกับทางเลือกอื่น
| approach | ดีตรงไหน | ระวังอะไร |
|---|---|---|
| CompactNumberFormat | ได้รูปแบบย่อตาม Locale และเลือก SHORT/LONG ได้ | มีการปัดเศษ ต้องตรวจข้อความบน JDK ที่ใช้งาน |
| NumberFormat.getNumberInstance | แสดงยอดเต็มตามกติกาภาษา เหมาะกับหน้ารายละเอียด | ตัวเลขยังยาว ต้องเตรียมพื้นที่ |
| NumberFormat.getCurrencyInstance | ใช้เมื่อต้องการรูปแบบสกุลเงิน | เป็นคนละเป้าหมายกับการย่อตัวเลข |
| หารแล้วต่อท้าย K เอง | กำหนดรูปแบบเฉพาะผลิตภัณฑ์ได้ตรงใจ | ต้องดูแลช่วงตัวเลข ภาษา และการปัดเศษเอง |
การเลือก factory สำหรับจำนวนทั่วไป สกุลเงิน และจำนวนย่ออ้างอิงจาก NumberFormat API
Recap
- กำหนด Locale ให้ชัดเจนก่อนสร้าง formatter
- ลองทั้ง SHORT และ LONG โดยไม่เดาคำหน่วยภาษาไทย
- ตั้งจำนวนทศนิยมสูงสุดและ RoundingMode ตามความต้องการ
- เก็บค่าต้นฉบับแยกจากข้อความที่ปัดเศษแล้ว
- ใช้ ParsePosition ตรวจว่าอ่านข้อความครบเมื่อรับข้อมูลกลับ
- รันเทสต์ด้วย java -ea และตรึง JDK สำหรับเทสต์ข้อความ
- แยก formatter ตามเธรดเมื่อทำงานพร้อมกัน