本文已遷移至:http://thinkinside.tk/2013/01/01/money.html
快年底了,假如你們公司的美國總部給每個人發了一筆201212.21美元的特別獎金,作為程序員的你, 該如何把這筆錢收入囊中?
1 美元?美元!
你可能覺得,這根本不是問題。在自己的賬戶中直接加上一筆“轉入”就行了。但是首先就遇到了幣種的問題。
一般來說,銀行賬戶都是單幣種的。你可能會說不對啊,我的一卡通就能存入不同的幣種啊?但那是一個“賬號(Account Number)”對應的多個“賬戶(Account)”。 通常財務記賬的時候,一個“賬戶(Account)”都使用同一幣種。
賬戶(Account)記錄了資金的往來,包含很多條目(Entry)。賬戶會記錄結余,結余等於所有條目中金額的總和。
我們不可能為每個幣種設計一種條目,所以需要抽象出一個貨幣類——Money,適用於各種不同的幣種:
Money類至少要記錄金額和幣種:
- 對於金額,由於貨幣存在最小面額,所以金額的類型可以采用定點小數或者整型。考慮到會對金額進行一些運算,用整數處理應該更方便。如果用java語言實現,可以使用
lang類型。
- 對於幣種,java提供了java.util.Currency類,專門用於表示貨幣,符合ISO 4217貨幣代碼標准。Currency使用Singleton模式,需要用getInstance方法獲得實例。
主要的方法包括:
-
- String getCurrencyCode() 獲取貨幣的ISO 4217貨幣代碼
- int getDefaultFractionDigits() 獲取與此貨幣一起使用的默認小數位數
- static Currency getInstance(Locale locale) 返回給定語言環境的國家/地區的 Currency 實例
- static Currency getInstance(String currencyCode) 返回給定貨幣代碼的 Currency 實例。
- String getSymbol() 獲取默認語言環境的貨幣符號
- String getSymbol(Locale locale) 獲取指定語言環境的貨幣符號
- String toString() 返回此貨幣的 ISO 4217 貨幣代碼
通過Currency類的幫助,我們的Money類看起來大概是這個樣子(為了方便,提供多種構造函數):
public class Money { private long amount; private Currency currency; public double getAmount() { return BigDecimal.valueOf(amount, currency.getDefaultFractionDigits()).doubleValue(); } public Currency getCurrency() { return currency; } public Money(double amount, Currency currency) { this.currency = currency; this.amount = Math.round(amount * centFactor()); } public Money(long amount, Currency currency) { this.currency = currency; this.amount = amount * centFactor(); } private static final int[] cents = new int[] { 1, 10, 100, 1000,10000 }; private int centFactor() { return cents[currency.getDefaultFractionDigits()]; } }
用Money類表示我們的$201212.21獎金,就是:
Money myMoney = new Money(201212.21,Currency.getInstance(Locale.US));
2 存入賬戶
終於解決了幣種的問題,可以把錢存入賬戶了。存入的邏輯是:在條目中記錄一筆賬目,並計算賬戶的余額。
不同幣種之間相加或相減是沒有意義的,為了避免人為錯誤,在Money的代碼中就要禁止這種操作。我們可以采用拋出異常的方式。 為了簡單起見,這里不再定義一個單獨的"MoneyException",而是直接使用java.lang.Exception:
public Money add(Money money) throws Exception{ if(!money.getCurrency().equals(this.currency)){ throw(new Exception("different currency can't be add")); } BigDecimal value = this.getAmount().add(money.getAmount()); Money result = new Money(value.doubleValue(),this.getCurrency()); return result; } public Money minus(Money money) throws Exception{ if(!money.getCurrency().equals(this.currency)){ throw(new Exception("different currency can't be minus")); } BigDecimal value =this.getAmount().add(money.getAmount().negate()); Money result = new Money(value.doubleValue(),this.getCurrency()); return result; }
3 收稅
先不要高興得太早,這筆錢屬於“一次性所得”,需要交20%的個人所得稅。稅后所得應該是多少?
你可能說:是80%。只要為Money加上一個multiply(double factor)方法就可以進行計算了。
但是牽扯到了舍入的問題。由於貨幣存在最小單位,在做乘/除法運算的時候就要考慮到舍入的問題了。最好是能夠控制舍入的行為。假如稅務部門對於 舍入的計算有明確規定,我們也可以做一個遵紀守法的好公民。
在java.math.BigDecimal中定義了7種舍入模式:
- ROUNDUP:等於遠離0的數。
- ROUNDDOWN:等於靠近0的數。
- ROUNDCEILING:等於靠近正無窮的數。
- ROUNDFLOOR:等於靠近負無窮的數。
- ROUNDHALFUP:等於靠近的數,若舍入位為5,應用ROUNDUP。
- ROUNDHALFDOWN:等於靠近的數,若舍入位為5,應用ROUNDDOWN。
- ROUNDHALFEVEN:舍入位前一位為奇數,應用ROUNDHALFUP;舍入位前一位為偶數,應用ROUNDHALFDOWN。
我們可以借用這些模式作為參數:
public static final int ROUND_UP = BigDecimal.ROUND_UP; public static final int ROUND_DOWN = BigDecimal.ROUND_DOWN; public static final int ROUND_CEILING = BigDecimal.ROUND_CEILING; public static final int ROUND_FLOOR = BigDecimal.ROUND_FLOOR; public static final int ROUND_HALF_UP = BigDecimal.ROUND_HALF_UP; public static final int ROUND_HALF_DOWN = BigDecimal.ROUND_HALF_DOWN; public static final int ROUND_HALF_EVEN = BigDecimal.ROUND_HALF_EVEN; public static final int ROUND_UNNECESSARY = BigDecimal.ROUND_UNNECESSARY; public Money multiply(double multiplicand, int roundingMode) { BigDecimal amount = this.getAmount().multiply(new BigDecimal(multiplicand)); amount = amount.divide(BigDecimal.ONE,roundingMode); return new Money(amount.doubleValue(),this.getCurrency()); } public Money divide(double divisor, int roundingMode) { BigDecimal amount = this.getAmount().divide(new BigDecimal(divisor), roundingMode); Money result = new Money(amount.doubleValue(), this.getCurrency()); return result; }
4 轉成人民幣
盡管各領域的國際化提了十幾年,但是在國內想直接用美元消費還是有一定困難。所以你決定將這筆錢換成人民幣。
對於賬戶來說,就是在美元賬戶和人民幣賬戶分別做一筆轉出和轉入。 轉入和轉出的amount值是不同的,因為涉及到幣種轉換的問題。 顯然,賬戶對象不應該知道如何進行匯率轉換,責任又落在了Money類上。
最直觀的做法是在Money類上增加一個convertTo(Currency currency)的方法。 但匯率實在是一個復雜的問題:
- 匯率是經常變化的;
- 匯率轉換時的舍入處理會有相關的約定;
這些復雜的問題處理如果直接放在Money類上會顯得十分笨重,單獨設計一個MoneyConverter類會比較好:
import java.util.Currency; public interface MoneyConverter { Money convertTo(Money money,Currency currency) throws Exception; }
我們實現一個最簡單的轉化器,使用固定的匯率值:
import java.math.BigDecimal; import java.util.Currency; import java.util.Locale; public class SimpleMoneyConverter implements MoneyConverter { private static final BigDecimal DOLLAR_TO_CNY = new BigDecimal(6.2365); private static final Currency DOLLAR = Currency.getInstance(Locale.US); private static final Currency CNY = Currency.getInstance(Locale.CHINA); @Override public Money convertTo(Money money,Currency target) throws Exception{ if(!known(money.getCurrency()) || !known(target)){ throw (new Exception("unknown currency")); } BigDecimal factorSource =BigDecimal.ONE, factorTarget = BigDecimal.ONE; if(money.getCurrency().equals(DOLLAR)) factorSource = DOLLAR_TO_CNY; if(target.equals(DOLLAR)) factorTarget = DOLLAR_TO_CNY; BigDecimal value = money.getAmount().multiply(factorSource).divide(factorTarget); return new Money(value.doubleValue(),target); } private boolean known(Currency currency){ return(currency.equals(DOLLAR) || currency.equals(CNY) ); } }
可以看到,即使是最簡單的轉換器,處理起來也比較麻煩。所以千萬不要在Money類中做這件事情。
通過轉換器可以很容易得到轉成人民幣后的值。
5 分錢
有好處不能獨享。這筆錢你決定和老婆三七開。當然,你三!
這又是一個新的舍入問題:即使你指定各自的舍入計算方法,也不能保證各部分舍入后的值加總后仍等於原值。
前面的“可定制乘除法”似乎不能很好的解決這個問題,所以我們需要一個新的方法: Money[] allocate(double[] ratioes)
傳入分配比例的數組,返回分配結果的數組。
為了保證分配的公平,可以使用偽隨機數來處理誤差。
該方法的實現如下:
public Money[] allocate(double[] ratioes) throws Exception{ if(ratioes.length==0){ throw (new Exception("there is no ratio")); } double ratioTotal = 0; for(double ratio:ratioes){ ratioTotal += ratio; } if(0==ratioTotal){ throw(new Exception("total of ratioes is zero")); } double total = this.getAmount().doubleValue(); double delta = total; Money[] results = new Money[ratioes.length]; for(int i=0;i<ratioes.length;i++){ double amount = total*ratioes[i]/ratioTotal; results[i] = new Money(amount,this.getCurrency()); delta -= results[i].getAmount().doubleValue(); } int i = (int)(Math.random() * ratioes.length); results[i] = results[i].minus(new Money(delta,this.getCurrency())); return results; }
6 記賬
將一切重要的數據保存到數據庫是很通常的做法。但是將Money保存到數據庫的時候,你要小心了!
Money不能作為單獨的實體。如果把Money當做實體來處理,就會產生一些問題:
- 會有很多實體關聯到Money,比如本文中的Account,Entry等。
- 需要非常小心處理對Money對象的引用,避免多個實體引用到同一個Money對象。在第一點的前提下,這會變得很困難。
所以應該把Money嵌入到需要的實體中,而不是把Money作為單獨的實體。這樣,Money僅僅是實體對象(比如Entry)的一個屬性,只不過其具有多個內置的屬性值。
在JPA中,可以使用@Embeddable來標注Money類。
更復雜的情況是,由於一個Account中的所有Entry都應該具有相同的Currency,將Currency保存到Account中會更簡潔,Entry中只記錄ammount。
可以為Money的currency屬性增加@Transient標注,在Entry類的getMoney中進行組裝。
7 來點高級的
在DDD(領域驅動設計)中,Money是典型的值對象(Value Object)。值對象與實體的根本區別是:值對象不需要進行標識(ID)。
這會帶來一些處理上的不同:
- 實體對象根據ID判斷是否相等,值對象只根據內部屬性值判斷是否相等
- 值對象通常小而且簡單,創建的代價較小
- 值對象只傳遞值,不傳遞對象引用,不用判斷值對象是否指向同一個物理對象
- 通常將值對象設計為通過構造函數進行屬性設置,一旦創建就無法改變其屬性值
由於值對象根據內部屬性值判等,我們要為Money類覆蓋equals方法: public boolean equals(Object other)
8 其他未盡事宜
- 我們還可以為Money類增加互相比較的方法(略)
- 可以在構造函數中進行格式校驗(略)
- 可以增加一些幫助顯式的方法 使用currency的getSymbol(Locale locale)方法、和NumberFormat的format方法,比如:
NumberFormat nf=NumberFormat.getCurrencyInstance(Locale.CHINA);
String s=nf.format(73084.803984);// result:¥73,084.80
9 小結
本文探討如何在應用中處理貨幣類型,包括幣種轉換、各種計算、如何持久化等內容。
貨幣類型是典型的值對象,本文也介紹了一點值對象的特點。更多的內容可以參考DDD。