以項(xiàng)目驅(qū)動學(xué)習(xí)熄云,以實(shí)踐檢驗(yàn)真知
前言
很多系統(tǒng)都有「處理金額」的需求膨更,比如電商系統(tǒng)、財務(wù)系統(tǒng)缴允、收銀系統(tǒng)荚守,等等。只要和錢扯上關(guān)系练般,就不得不打起十二萬分精神來對待矗漾,一分一毫都不能出錯,否則對系統(tǒng)和用戶來說都是災(zāi)難薄料。
保證金額的準(zhǔn)確性主要有兩個方面:溢出和精度敞贡。溢出是指存儲數(shù)據(jù)的空間得充足,不能金額較大就存儲不下了摄职。精度是指計算金額時不能有偏差誊役,多一點(diǎn)少一點(diǎn)都不行。
溢出問題大家都知道如何解決谷市,選擇位數(shù)長的數(shù)值類型即可蛔垢,即不用 float
用 double
。而精度問題迫悠,double
就無法解決了啦桌,因?yàn)楦↑c(diǎn)數(shù)會導(dǎo)致精度丟失。
我們來直觀感受一下精度丟失:
double money = 1.0 - 0.9;
這個運(yùn)算結(jié)果誰都知道該為 0.1
及皂,然而實(shí)際結(jié)果卻是 0.09999999999999998
。出現(xiàn)這個現(xiàn)象是因?yàn)橛嬎銠C(jī)底層是二進(jìn)制運(yùn)算且改,而二進(jìn)制并不能精準(zhǔn)表示十進(jìn)制小數(shù)验烧。所以在商業(yè)計算等精確計算中要使用其他數(shù)據(jù)類型來保證精度不丟失,一定不要使用浮點(diǎn)數(shù)又跛。
本螃蟹接下來會詳細(xì)講解在實(shí)際開發(fā)中到底該怎樣進(jìn)行商業(yè)計算碍拆,并將所有代碼和 SQL 語句放在了 Github 上,克隆下來即可運(yùn)行慨蓝。
解決方案
有兩種數(shù)據(jù)類型可以滿足商業(yè)計算的需求感混,第一個自然是專為商業(yè)計算而設(shè)計的 Decimal 類型,第二個則是定長整數(shù)礼烈。
Decimal
關(guān)于數(shù)據(jù)類型的選擇弧满,一要考慮數(shù)據(jù)庫梢杭,二要考慮編程語言牵咙。即數(shù)據(jù)庫中用什么類型來存儲數(shù)據(jù)瘟斜,代碼中用什么類型來處理數(shù)據(jù)。
數(shù)據(jù)庫層面自然是用 decimal
類型装处,因?yàn)樵擃愋筒淮嬖诰葥p失的情況,用它來進(jìn)行商業(yè)計算再合適不過彭沼。
將字段定義為 decimal
的語法為 decimal(M,N)
殊鞭,M
代表存儲多少位,N
代表小數(shù)存儲多少位数冬。假設(shè) decimal(20,2)
节槐,則代表一共存儲 20 位數(shù)值,其中小數(shù)占 2 位拐纱。
我們新建一張用戶表铜异,字段很簡單就兩個,主鍵和余額:
這里小數(shù)位置保留 2 點(diǎn)戳玫,代表金額只存儲到分熙掺,實(shí)際項(xiàng)目中存儲到什么單位得根據(jù)業(yè)務(wù)需求來定,都是可以的咕宿。
數(shù)據(jù)庫層面搞定了咱們來看代碼層面币绩,在 Java 中對應(yīng)數(shù)據(jù)庫 decimal
的是 java.math.BigDecimal
類型,它自然也能保證精度完全準(zhǔn)確府阀。
要創(chuàng)建BigDecimal
主要有三種方法:
BigDecimal d1 = new BigDecimal(0.1); // BigDecimal(double val)
BigDecimal d2 = new BigDecimal("0.1"); // BigDecimal(String val)
BigDecimal d3 = BigDecimal.valueOf(0.1); // static BigDecimal valueOf(double val)
前面兩個是構(gòu)造函數(shù)缆镣,后面一個是靜態(tài)方法。這三種方法都非常方便试浙,但第一種方法禁止使用董瞻!看一下這三個對象各自的打印結(jié)果就知道為什么了:
d1: 0.1000000000000000055511151231257827021181583404541015625
d2: 0.1
d3: 0.1
第一種方法通過構(gòu)造函數(shù)傳入 double
類型的參數(shù)并不能精確地獲取到值,若想正確的創(chuàng)建 BigDecimal
田巴,要么將 double
轉(zhuǎn)換為字符串然后調(diào)用構(gòu)造方法钠糊,要么直接調(diào)用靜態(tài)方法。事實(shí)上壹哺,靜態(tài)方法內(nèi)部也是將 double
轉(zhuǎn)換為字符串然后調(diào)用的構(gòu)造方法:
如果是從數(shù)據(jù)庫中查詢出小數(shù)值抄伍,或者前端傳遞過來小數(shù)值,數(shù)據(jù)會準(zhǔn)確映射成 BigDecimal
對象管宵,這一點(diǎn)我們不用操心截珍。
說完創(chuàng)建,接下來就要說最重要的數(shù)值運(yùn)算箩朴。運(yùn)算無非就是加減乘除岗喉,這些 BigDecimal
都提供了對應(yīng)的方法:
BigDecimal add(BigDecimal); // 加
BigDecimal subtract(BigDecimal); // 減
BigDecimal multiply(BigDecimal); // 乘
BigDecimal divide(BigDecimal); // 除
BigDecimal
是不可變對象,意思就是這些操作都不會改變原有對象的值炸庞,方法執(zhí)行完畢只會返回一個新的對象钱床。若要運(yùn)算后更新原有值,只能重新賦值:
d1 = d1.subtract(d2);
口說無憑燕雁,我們來驗(yàn)證一下精度是否會丟失 :
BigDecimal d1 = new BigDecimal("1.0");
BigDecimal d2 = new BigDecimal("0.9");
System.out.println(d1.subtract(d2));
輸出結(jié)果毫無疑問為 0.1
诞丽。
代碼方面已經(jīng)能保證精度不會丟失鲸拥,但數(shù)學(xué)方面除法可能會出現(xiàn)除不盡的情況。比如我們運(yùn)算 10
除以 3
僧免,會拋出如下異常:
為了解決除不盡后導(dǎo)致的無窮小數(shù)問題刑赶,我們需要人為去控制小數(shù)的精度。除法運(yùn)算還有一個方法就是用來控制精度的:
BigDecimal divide(BigDecimal divisor, int scale, int roundingMode)
scale
參數(shù)表示運(yùn)算后保留幾位小數(shù)懂衩,roundingMode
參數(shù)表示計算小數(shù)的方式撞叨。
BigDecimal d1 = new BigDecimal("1.0");
BigDecimal d2 = new BigDecimal("3");
System.out.println(d1.divide(d2, 2, RoundingMode.DOWN)); // 小數(shù)精度為2,多余小數(shù)直接舍去浊洞。輸出結(jié)果為0.33
用 RoundingMode
枚舉能夠方便地指定小數(shù)運(yùn)算方式牵敷,除了直接舍去,還有四舍五入法希、向上取整等多種方式枷餐,根據(jù)具體業(yè)務(wù)需求指定即可。
注意苫亦,小數(shù)精度盡量在代碼中控制毛肋,不要通過數(shù)據(jù)庫來控制。數(shù)據(jù)庫中默認(rèn)采用四舍五入的方式保留小數(shù)精度屋剑。
比如數(shù)據(jù)庫中設(shè)置的小數(shù)精度為2润匙,我存入
0.335
,那么最終存儲的值就會變?yōu)?0.34
唉匾。
我們已經(jīng)知道如何創(chuàng)建和運(yùn)算 BigDecimal
對象孕讳,只剩下最后一個操作:比較。因?yàn)槠洳皇腔緮?shù)據(jù)類型巍膘,用雙等號 ==
肯定是不行的厂财,那我們來試試用 equals
比較:
BigDecimal d1 = new BigDecimal("0.33");
BigDecimal d2 = new BigDecimal("0.3300");
System.out.println(d1.equals(d2)); // false
輸出結(jié)果為 false
,因?yàn)?BigDecimal
的 equals
方法不光會比較值峡懈,還會比較精度蟀苛,就算值一樣但精度不一樣結(jié)果也是 false
。若想判斷值是否一樣逮诲,需要使用int compareTo(BigDecimal val)
方法:
BigDecimal d1 = new BigDecimal("0.33");
BigDecimal d2 = new BigDecimal("0.3300");
System.out.println(d1.compareTo(d2) == 0); // true
d1
大于 d2
,返回 1
幽告;
d1
小于 d2
梅鹦,返回 -1
;
兩值相等冗锁,返回 0
齐唆。
BigDecimal
的用法就介紹到這,我們接下來看第二種解決方案冻河。
定長整數(shù)
定長整數(shù)箍邮,顧名思義就是固定(小數(shù))長度的整數(shù)茉帅。它只是一個概念,并不是新的數(shù)據(jù)類型锭弊,我們使用的還是普通的整數(shù)堪澎。
金額好像理所應(yīng)當(dāng)有小數(shù),但稍加思考便會發(fā)覺小數(shù)并非是必須的味滞。之前我們演示的金額單位是元樱蛤,1.55
就是一元五角五分。那如果我們單位是角剑鞍,一元五角五分的值就會變成 15.5
昨凡。如果再將單位縮小到分,值就為 155
蚁署。沒錯便脊,只要達(dá)到最小單位,小數(shù)完全可以省略光戈!這個最小單位根據(jù)業(yè)務(wù)需求來定哪痰,比如系統(tǒng)要求精確到厘,那么值就是1550
田度。當(dāng)然妒御,一般精確到分就可以了,咱們接下來演示單位都是分镇饺。
咱們現(xiàn)在新建一個字段乎莉,類型為 bigint
,單位為分:
代碼中對應(yīng)的數(shù)據(jù)類型自然是 Long
奸笤⊥锟校基本類型的數(shù)值運(yùn)算我們是再熟悉不過的了,直接使用運(yùn)算操作符即可:
long d1 = 10000L; // 100元
d1 += 500L; // 加五元
d1 -= 500L; // 減五元
加和減沒什么好說的监右,乘和除可能會出現(xiàn)小數(shù)的情況边灭,比如某個商品打八折,運(yùn)算就是乘以 0.8
:
long d1 = 2366L; // 23.66元
double result = d1 * 0.8; // 打八折健盒,運(yùn)算后結(jié)果為1892.8
d1 = (long)result; // 轉(zhuǎn)換為整數(shù)绒瘦,舍去所有小數(shù),值為1892扣癣。即18.92元
進(jìn)行小數(shù)運(yùn)算惰帽,類型自然而然就會變?yōu)楦↑c(diǎn)數(shù),所以我們還要將浮點(diǎn)數(shù)轉(zhuǎn)換為整數(shù)父虑。
強(qiáng)轉(zhuǎn)會將所有小數(shù)舍去该酗,這個舍去并不代表精度丟失。業(yè)務(wù)要求最小單位是什么,就只保留什么呜魄,低于分的單位我們壓根沒必要保存悔叽。這一點(diǎn)和 BigDecimal
是一致的,如果系統(tǒng)中只需要到分爵嗅,那小數(shù)精度就為 2
娇澎, 剩余的小數(shù)都舍去。
不過有些業(yè)務(wù)計算可能要求四舍五入等其他操作操骡,這一點(diǎn)我們可以通過 Math
類來完成:
long d1 = 2366L; // 23.66元
double result = d1 * 0.8; // 運(yùn)算后結(jié)果為1892.8
d1 = (long)result; // 強(qiáng)轉(zhuǎn)舍去所有小數(shù)九火,值為1892
d1 = (long)Math.ceil(result); // 向上取整,值為1893
d1 = (long)Math.round(result); // 四舍五入册招,值為1893
...
再來看除法運(yùn)算岔激。當(dāng)整數(shù)除以整數(shù)時,會自動舍去所有小數(shù):
long d1 = 2366L;
long result = d1 / 3; // 正確的值本應(yīng)該為788.6666666666666是掰,舍去所有小數(shù)虑鼎,最終值為788
如果要進(jìn)行四舍五入等其他小數(shù)操作,則運(yùn)算時先進(jìn)行浮點(diǎn)數(shù)運(yùn)算键痛,然后再轉(zhuǎn)換成整數(shù):
long d1 = 2366L;
double result = d1 / 3.0; // 注意炫彩,這里除以不是 3,而是 3.0 浮點(diǎn)數(shù)
d1 = (long)Math.round(result); // 四射勿入絮短,最終值為789江兢,即7.89元
雖說數(shù)據(jù)庫存儲和代碼運(yùn)算都是整數(shù),但前端顯示時若還是以分為單位就對用戶不太友好了丁频。所以后端將值傳遞給前端后杉允,前端需要自行將值除以 100
,以元為單位展示給用戶席里。然后前端傳值給后端時叔磷,還是以約定好的整數(shù)傳遞。
收尾
關(guān)于金額處理就講解完畢了奖磁。我們學(xué)會了兩個商業(yè)計算方案:
- Decimal 類型
- 定長整數(shù)
其實(shí)商業(yè)計算并沒有什么技術(shù)難度改基,但如果沒有正確處理則會導(dǎo)致難以估量的損失,畢竟和錢相關(guān)的事都不是小事咖为。
本文為了方便大家理解秕狰,所以省略了前后端聯(lián)調(diào)以及數(shù)據(jù)庫操作的內(nèi)容。但既然是項(xiàng)目實(shí)踐躁染,那就得有一個完整項(xiàng)目封恰,所以本螃蟹基于 Spring Boot 搭建了一個完整的 Web 項(xiàng)目,數(shù)據(jù)庫操作和接口都已寫好褐啡,SQL 語句也有,將 Github 倉庫克隆下來即可感受在真實(shí)項(xiàng)目中如何運(yùn)用的本文知識鳖昌。倉庫中還有許多其他項(xiàng)目實(shí)踐备畦,涵蓋各個業(yè)務(wù)各個功能低飒,其中一些模塊的質(zhì)量甚至可以單開一個倉庫,讓你再也不用尋找各個框架 Demo 和腳手架懂盐。歡迎 star褥赊,螃蟹會更新更多項(xiàng)目實(shí)踐的!
我是「RudeCrab」莉恼,一只粗魯?shù)捏π钒韬恚非蠛唵未直┑刂v解技術(shù)。
關(guān)注「RudeCrab」微信公眾號俐银,和螃蟹一起橫行霸道尿背。