German (Deutsch) translation by Katharina Grigorovich-Nevolina (you can also view the original English article)
Das von Martin Fowler definierte und in Patterns of Enterprise Application Architecture veröffentlichte Money Pattern ist eine hervorragende Möglichkeit, Wert-Einheit-Paare darzustellen. Es wird Geldmuster genannt, weil es in einem finanziellen Kontext entstanden ist und wir werden seine Verwendung hauptsächlich in diesem Kontext unter Verwendung von PHP veranschaulichen.
Ein PayPal-ähnliches Konto
Ich habe keine Ahnung, wie PayPal implementiert ist, aber ich denke, es ist eine gute Idee, seine Funktionalität als Beispiel zu nehmen. Lassen Sie mich Ihnen zeigen, was ich meine, mein PayPal-Konto hat zwei Währungen: US-Dollar und Euro. Es hält die beiden Werte getrennt, aber ich kann Geld in jeder Währung erhalten, ich kann meinen Gesamtbetrag in jeder der beiden Währungen sehen und ich kann in jeder der beiden Währungen extrahieren. Stellen Sie sich für dieses Beispiel vor, wir extrahieren in einer der Währungen und die automatische Umrechnung erfolgt, wenn der Saldo dieser bestimmten Währung geringer ist als das, was wir überweisen möchten, aber in der anderen Währung noch genügend Geld vorhanden ist. Außerdem beschränken wir das Beispiel auf nur zwei Währungen.
Ein Konto bekommen
Wenn ich ein Kontoobjekt erstellen und verwenden möchte, möchte ich es mit einer Kontonummer initialisieren.
1 |
function testItCanCrateANewAccount() { |
2 |
$this->assertInstanceOf("Account", new Account(123)); |
3 |
}
|
Das wird offensichtlich fehlschlagen, da wir noch keine Account-Klasse haben.
1 |
class Account { |
2 |
|
3 |
}
|
Nun, das Schreiben in eine neue "Account.php"-Datei und das Erfordernis im Test haben es bestanden. Dies alles geschieht jedoch nur, um uns mit der Idee vertraut zu machen. Als nächstes denke ich darüber nach, die id des Kontos zu erhalten.
1 |
function testItCanCrateANewAccountWithId() { |
2 |
$this->assertEquals(123, (new Account(123))->getId()); |
3 |
}
|
Ich habe den vorherigen Test tatsächlich in diesen geändert. Es gibt keinen Grund, den ersten zu behalten. Es hat sein Leben gelebt, was bedeutet, dass ich gezwungen war, über die Account-Klasse nachzudenken und sie tatsächlich zu erstellen. Wir können jetzt weitermachen.
1 |
class Account { |
2 |
|
3 |
private $id; |
4 |
|
5 |
function __construct($id) { |
6 |
$this->id = $id; |
7 |
}
|
8 |
|
9 |
public function getId() { |
10 |
return $this->id; |
11 |
}
|
12 |
|
13 |
}
|
Der Test ist bestanden und Account sieht aus wie eine echte Klasse.
Währungen
Basierend auf unserer PayPal-Analogie möchten wir möglicherweise eine primäre und eine sekundäre Währung für unser Konto definieren.
1 |
private $account; |
2 |
|
3 |
protected function setUp() { |
4 |
$this->account = new Account(123); |
5 |
}
|
6 |
|
7 |
[...] |
8 |
|
9 |
function testItCanHavePrimaryAndSecondaryCurrencies() { |
10 |
$this->account->setPrimaryCurrency("EUR"); |
11 |
$this->account->setSecondaryCurrency('USD'); |
12 |
|
13 |
$this->assertEquals(array('primary' => 'EUR', 'secondary' => 'USD'), $this->account->getCurrencies()); |
14 |
}
|
Der obige Test zwingt uns nun, den folgenden Code zu schreiben.
1 |
class Account { |
2 |
|
3 |
private $id; |
4 |
private $primaryCurrency; |
5 |
private $secondaryCurrency; |
6 |
|
7 |
[...] |
8 |
|
9 |
function setPrimaryCurrency($currency) { |
10 |
$this->primaryCurrency = $currency; |
11 |
}
|
12 |
|
13 |
function setSecondaryCurrency($currency) { |
14 |
$this->secondaryCurrency = $currency; |
15 |
}
|
16 |
|
17 |
function getCurrencies() { |
18 |
return array('primary' => $this->primaryCurrency, 'secondary' => $this->secondaryCurrency); |
19 |
}
|
20 |
|
21 |
}
|
Derzeit halten wir die Währung als einfache Zeichenfolge. Das mag sich in Zukunft ändern, aber wir sind noch nicht da.
Geben Sie mir das Geld
Es gibt unendlich viele Gründe, Geld nicht als einfachen Wert darzustellen. Gleitkommaberechnungen? Jemand? Was ist mit Währungsbrüchen? Sollten wir 10, 100 oder 1000 Cent in einer exotischen Währung haben? Nun, dies ist ein weiteres Problem, das wir vermeiden müssen. Was ist mit der Zuweisung unteilbarer Cent?
Es gibt einfach zu viele und exotische Probleme bei der Arbeit mit Geld, um sie in Code zu schreiben. Wir werden also direkt mit der Lösung fortfahren, dem Geldmuster. Das ist ein recht einfaches Muster mit großen Vorteilen und vielen Anwendungsfällen, die weit außerhalb des Finanzbereichs liegen. Wann immer Sie ein Wert-Einheit-Paar darstellen müssen, sollten Sie wahrscheinlich dieses Muster verwenden.



Das Geldmuster ist im Grunde eine Klasse, die einen Betrag und eine Währung enthält. Dann werden alle mathematischen Operationen für den Wert in Bezug auf die Währung definiert. "allocate()" ist eine spezielle Funktion, um einen bestimmten Geldbetrag auf zwei oder mehr Empfänger zu verteilen.
Als Benutzer von Money möchte ich das in einem Test tun können:
1 |
class MoneyTest extends PHPUnit_Framework_TestCase { |
2 |
|
3 |
function testWeCanCreateAMoneyObject() { |
4 |
$money = new Money(100, Currency::USD()); |
5 |
}
|
6 |
|
7 |
}
|
Aber das wird noch nicht funktionieren. Wir brauchen sowohl Money als auch Currency. Noch mehr brauchen wir Currency vor Money. Das wird eine einfache Klasse sein, daher werde ich das Testen vorerst überspringen. Ich bin mir ziemlich sicher, dass die IDE den größten Teil des Codes für mich generieren kann.
1 |
class Currency { |
2 |
|
3 |
private $centFactor; |
4 |
private $stringRepresentation; |
5 |
|
6 |
private function __construct($centFactor, $stringRepresentation) { |
7 |
$this->centFactor = $centFactor; |
8 |
$this->stringRepresentation = $stringRepresentation; |
9 |
}
|
10 |
|
11 |
public function getCentFactor() { |
12 |
return $this->centFactor; |
13 |
}
|
14 |
|
15 |
function getStringRepresentation() { |
16 |
return $this->stringRepresentation; |
17 |
}
|
18 |
|
19 |
static function USD() { |
20 |
return new self(100, 'USD'); |
21 |
}
|
22 |
|
23 |
static function EUR() { |
24 |
return new self(100, 'EUR'); |
25 |
}
|
26 |
|
27 |
}
|
Das reicht für unser Beispiel. Wir haben zwei statische Funktionen für USD- und EUR-Währungen. In einer realen Anwendung hätten wir wahrscheinlich einen allgemeinen Konstruktor mit einem Parameter und würden alle Währungen aus einer Datenbanktabelle oder, noch besser, aus einer Textdatei laden.
Nehmen Sie als Nächstes die beiden neuen Dateien in den Test auf:
1 |
require_once '../Currency.php'; |
2 |
require_once '../Money.php'; |
3 |
|
4 |
class MoneyTest extends PHPUnit_Framework_TestCase { |
5 |
|
6 |
function testWeCanCreateAMoneyObject() { |
7 |
$money = new Money(100, Currency::USD()); |
8 |
}
|
9 |
|
10 |
}
|
Dieser Test schlägt immer noch fehl, kann aber jetzt zumindest die Currency finden. Wir fahren mit einer minimalen Money-Implementierung fort. Ein bisschen mehr als für diesen Test unbedingt erforderlich, da es sich wiederum hauptsächlich um automatisch generierten Code handelt.
1 |
class Money { |
2 |
|
3 |
private $amount; |
4 |
private $currency; |
5 |
|
6 |
function __construct($amount, Currency $currency) { |
7 |
$this->amount = $amount; |
8 |
$this->currency = $currency; |
9 |
}
|
10 |
|
11 |
}
|
Bitte beachten Sie, dass wir den Typ Currency für den zweiten Parameter in unserem Konstruktor erzwingen. Das ist ein guter Weg, um zu vermeiden, dass unsere Kunden Junk als Währung einsenden.
Geld vergleichen
Das erste, was mir in den Sinn kam, nachdem ich das minimale Objekt zum Laufen gebracht hatte, war, dass ich Geldobjekte irgendwie vergleichen muss. Dann erinnerte ich mich, dass PHP ziemlich klug ist, wenn es darum geht, Objekte zu vergleichen, also schrieb ich diesen Test.
1 |
function testItCanTellTwoMoneyObjectAreEqual() { |
2 |
$m1 = new Money(100, Currency::USD()); |
3 |
$m2 = new Money(100, Currency::USD()); |
4 |
|
5 |
$this->assertEquals($m1,$m2); |
6 |
$this->assertTrue($m1 == $m2); |
7 |
}
|
Nun, das geht tatsächlich vorbei. Die Funktion "assertEquals" kann die beiden Objekte vergleichen, und selbst die integrierte Gleichheitsbedingung von PHP "==" sagt mir, was ich erwarte. Nett.
Aber was ist, wenn wir daran interessiert sind, dass einer größer ist als der andere? Zu meiner noch größeren Überraschung besteht auch der folgende Test ohne Probleme.
1 |
function testOneMoneyIsBiggerThanTheOther() { |
2 |
$m1 = new Money(200, Currency::USD()); |
3 |
$m2 = new Money(100, Currency::USD()); |
4 |
|
5 |
$this->assertGreaterThan($m2, $m1); |
6 |
$this->assertTrue($m1 > $m2); |
7 |
}
|
Was uns führt zu ...
1 |
function testOneMoneyIsLessThanTheOther() { |
2 |
$m1 = new Money(100, Currency::USD()); |
3 |
$m2 = new Money(200, Currency::USD()); |
4 |
|
5 |
$this->assertLessThan($m2, $m1); |
6 |
$this->assertTrue($m1 < $m2); |
7 |
}
|
... ein Test, der sofort besteht.
Plus, Minus, Multiplizieren
Da so viel PHP-Magie tatsächlich mit Vergleichen funktioniert, konnte ich nicht widerstehen, diese zu versuchen.
1 |
function testTwoMoneyObjectsCanBeAdded() { |
2 |
$m1 = new Money(100, Currency::USD()); |
3 |
$m2 = new Money(200, Currency::USD()); |
4 |
$sum = new Money(300, Currency::USD()); |
5 |
|
6 |
$this->assertEquals($sum, $m1 + $m2); |
7 |
}
|
Was fehlschlägt und sagt:
1 |
Object of class Money could not be converted to int |
Hmm. Das klingt ziemlich offensichtlich. An diesem Punkt müssen wir eine Entscheidung treffen. Es ist möglich, diese Übung mit noch mehr PHP-Magie fortzusetzen, aber dieser Ansatz wird dieses Tutorial irgendwann in ein PHP-Cheatsheet anstelle eines Entwurfsmusters verwandeln. Treffen wir also die Entscheidung, die tatsächlichen Methoden zum Addieren, Subtrahieren und Multiplizieren von Geldobjekten zu implementieren.
1 |
function testTwoMoneyObjectsCanBeAdded() { |
2 |
$m1 = new Money(100, Currency::USD()); |
3 |
$m2 = new Money(200, Currency::USD()); |
4 |
$sum = new Money(300, Currency::USD()); |
5 |
|
6 |
$this->assertEquals($sum, $m1->add($m2)); |
7 |
}
|
Dieser Test schlägt ebenfalls fehl, aber mit einem Fehler, der uns mitteilt, gibt es keine "add"-Methode für Money.
1 |
public function getAmount() { |
2 |
return $this->amount; |
3 |
}
|
4 |
|
5 |
function add($other) { |
6 |
return new Money($this->amount + $other->getAmount(), $this->currency); |
7 |
}
|
Um zwei Money-Objekte zusammenzufassen, benötigen wir eine Möglichkeit, die Menge des Objekts abzurufen, das wir als Argument übergeben. Ich schreibe lieber einen Getter, aber es wäre auch eine akzeptable Lösung, die Klassenvariable als öffentlich festzulegen. Aber was ist, wenn wir Euro zu Euro hinzufügen wollen?
1 |
/**
|
2 |
* @expectedException Exception
|
3 |
* @expectedExceptionMessage Both Moneys must be of same currency
|
4 |
*/
|
5 |
function testItThrowsExceptionIfWeTryToAddTwoMoneysWithDifferentCurrency() { |
6 |
$m1 = new Money(100, Currency::USD()); |
7 |
$m2 = new Money(100, Currency::EUR()); |
8 |
|
9 |
$m1->add($m2); |
10 |
}
|
Es gibt verschiedene Möglichkeiten, mit Operationen an Money-Objekten mit unterschiedlichen Währungen umzugehen. Wir werden eine Ausnahme auslösen und diese im Test erwarten. Alternativ können wir einen Währungsumrechnungsmechanismus in unserer Anwendung implementieren, ihn aufrufen, beide Money-Objekte in eine Standardwährung konvertieren und sie vergleichen. Wenn wir einen ausgefeilteren Währungsumrechnungsalgorithmus hätten, könnten wir jederzeit von einem in einen anderen umrechnen und in dieser umgerechneten Währung vergleichen. Die Sache ist, dass bei der Konvertierung Konvertierungsgebühren berücksichtigt werden müssen und die Dinge ziemlich kompliziert werden. Lassen Sie uns einfach diese Ausnahme auslösen und weitermachen.
1 |
public function getCurrency() { |
2 |
return $this->currency; |
3 |
}
|
4 |
|
5 |
function add(Money $other) { |
6 |
$this->ensureSameCurrencyWith($other); |
7 |
return new Money($this->amount + $other->getAmount(), $this->currency); |
8 |
}
|
9 |
|
10 |
private function ensureSameCurrencyWith(Money $other) { |
11 |
if ($this->currency != $other->getCurrency()) |
12 |
throw new Exception("Both Moneys must be of same currency"); |
13 |
}
|
Das ist besser. Wir überprüfen, ob die Währungen unterschiedlich sind, und lösen eine Ausnahme aus. Ich habe es bereits als separate private Methode geschrieben, weil ich weiß, dass wir es auch in den anderen mathematischen Operationen benötigen werden.
Subtraktion und Multiplikation sind der Addition sehr ähnlich. Hier ist also der Code, und Sie finden die Tests im angehängten Quellcode.
1 |
function subtract(Money $other) { |
2 |
$this->ensureSameCurrencyWith($other); |
3 |
if ($other > $this) |
4 |
throw new Exception("Subtracted money is more than what we have"); |
5 |
return new Money($this->amount - $other->getAmount(), $this->currency); |
6 |
}
|
7 |
|
8 |
function multiplyBy($multiplier, $roundMethod = PHP_ROUND_HALF_UP) { |
9 |
$product = round($this->amount * $multiplier, 0, $roundMethod); |
10 |
return new Money($product, $this->currency); |
11 |
}
|
Bei der Subtraktion müssen wir sicherstellen, dass wir genug Geld haben, und bei der Multiplikation müssen wir Maßnahmen ergreifen, um die Dinge auf- oder abzurunden, damit die Division (Multiplikation mit Zahlen kleiner als eins) keine "halben Cent" ergibt. Wir halten unseren Betrag in Cent, dem niedrigstmöglichen Faktor der Währung. Wir können es nicht mehr teilen.
Einführung der Währung in unser Konto
Wir haben ein fast vollständiges Money und eine fast vollständige Currency. Es ist Zeit, diese Objekte dem Account vorzustellen. Wir beginnen mit der Currency und ändern unsere Tests entsprechend.
1 |
function testItCanHavePrimaryAndSecondaryCurrencies() { |
2 |
$this->account->setPrimaryCurrency(Currency::EUR()); |
3 |
$this->account->setSecondaryCurrency(Currency::USD()); |
4 |
|
5 |
$this->assertEquals(array('primary' => Currency::EUR(), 'secondary' => Currency::USD()), $this->account->getCurrencies()); |
6 |
}
|
Aufgrund der dynamischen Typisierung von PHP besteht dieser Test problemlos. Ich möchte jedoch die Methoden in Account zwingen, Currency-Objekte zu verwenden und nichts anderes zu akzeptieren. Das ist nicht obligatorisch, aber ich finde diese Art von Typhinweisen äußerst nützlich, wenn jemand anderes unseren Code verstehen muss.
1 |
function setPrimaryCurrency(Currency $currency) { |
2 |
$this->primaryCurrency = $currency; |
3 |
}
|
4 |
|
5 |
function setSecondaryCurrency(Currency $currency) { |
6 |
$this->secondaryCurrency = $currency; |
7 |
}
|
Jetzt ist es für jeden, der diesen Code zum ersten Mal liest, offensichtlich, dass das Account mit Currencyfunktioniert.
Geld auf Ihr Konto einführen
Die zwei grundlegenden Aktionen, die ein Konto bereitstellen muss, sind: Einzahlung - dh Hinzufügen von Geld zu einem Konto - und Abheben - bedeutet Entfernen von Geld von einem Konto. Die Einzahlung hat eine Quelle und die Auszahlung hat ein anderes Ziel als unser Girokonto. Wir werden nicht näher auf die Implementierung dieser Transaktionen eingehen, sondern uns nur auf die Implementierung der Auswirkungen konzentrieren, die diese auf unser Konto haben. Wir können uns also einen solchen Test für die Einzahlung vorstellen.
1 |
function testAccountCanDepositMoney() { |
2 |
$this->account->setPrimaryCurrency(Currency::EUR()); |
3 |
$money = new Money(100, Currency::EUR()); //That's 1 EURO |
4 |
$this->account->deposit($money); |
5 |
|
6 |
$this->assertEquals($money, $this->account->getPrimaryBalance()); |
7 |
}
|
Dies wird uns zwingen, ziemlich viel Implementierungscode zu schreiben.
1 |
class Account { |
2 |
|
3 |
private $id; |
4 |
private $primaryCurrency; |
5 |
private $secondaryCurrency; |
6 |
private $secondaryBalance; |
7 |
private $primaryBalance; |
8 |
|
9 |
function getSecondaryBalance() { |
10 |
return $this->secondaryBalance; |
11 |
}
|
12 |
|
13 |
function getPrimaryBalance() { |
14 |
return $this->primaryBalance; |
15 |
}
|
16 |
|
17 |
function __construct($id) { |
18 |
$this->id = $id; |
19 |
}
|
20 |
|
21 |
[...] |
22 |
|
23 |
function deposit(Money $money) { |
24 |
$this->primaryCurrency == $money->getCurrency() ? $this->primaryBalance = $money : $this->secondaryBalance = $money; |
25 |
}
|
26 |
|
27 |
}
|
OK OK. Ich weiß, ich habe mehr geschrieben, als für die Produktion unbedingt notwendig war. Aber ich möchte Sie nicht mit kleinen Schritten zu Tode langweilen, und ich bin mir auch ziemlich sicher, dass der Code für SecondaryBalance korrekt funktioniert. Es wurde fast vollständig von der IDE generiert. Ich werde es sogar überspringen, es zu testen. Während dieser Code unseren Test besteht, müssen wir uns fragen, was passiert, wenn wir nachfolgende Einzahlungen vornehmen? Wir möchten, dass unser Geld zum vorherigen Saldo hinzugefügt wird.
1 |
function testSubsequentDepositsAddUpTheMoney() { |
2 |
$this->account->setPrimaryCurrency(Currency::EUR()); |
3 |
$money = new Money(100, Currency::EUR()); //That's 1 EURO |
4 |
$this->account->deposit($money); //One euro in the account |
5 |
$this->account->deposit($money); //Two euros in the account |
6 |
|
7 |
$this->assertEquals($money->multiplyBy(2), $this->account->getPrimaryBalance()); |
8 |
}
|
Nun, das schlägt fehl. Also müssen wir unseren Produktionscode aktualisieren.
1 |
function deposit(Money $money) { |
2 |
if ($this->primaryCurrency == $money->getCurrency()){ |
3 |
$this->primaryBalance = $this->primaryBalance ? : new Money(0, $this->primaryCurrency); |
4 |
$this->primaryBalance = $this->primaryBalance->add($money); |
5 |
}else { |
6 |
$this->secondaryBalance = $this->secondaryBalance ? : new Money(0, $this->secondaryCurrency); |
7 |
$this->secondaryBalance = $this->secondaryBalance->add($money); |
8 |
}
|
9 |
}
|
Das ist viel besser. Wir sind wahrscheinlich mit der deposit-Methode fertig und können mit withdrawfortfahren.
1 |
function testAccountCanWithdrawMoneyOfSameCurrency() { |
2 |
$this->account->setPrimaryCurrency(Currency::EUR()); |
3 |
$money = new Money(100, Currency::EUR()); //That's 1 EURO |
4 |
$this->account->deposit($money); |
5 |
$this->account->withdraw(new Money(70, Currency::EUR())); |
6 |
|
7 |
$this->assertEquals(new Money(30, Currency::EUR()), $this->account->getPrimaryBalance()); |
8 |
|
9 |
}
|
Das ist nur ein einfacher Test. Die Lösung ist auch einfach.
1 |
function withdraw(Money $money) { |
2 |
$this->primaryCurrency == $money->getCurrency() ? |
3 |
$this->primaryBalance = $this->primaryBalance->subtract($money) : |
4 |
$this->secondaryBalance = $this->secondaryBalance->subtract($money); |
5 |
}
|
Nun, das funktioniert, aber was ist, wenn wir eine Currency verwenden möchten, die nicht in Ihrem Konto enthalten ist? Wir sollten dafür eine Ausnahme machen.
1 |
/**
|
2 |
* @expectedException Exception
|
3 |
* @expectedExceptionMessage This account has no currency USD
|
4 |
*/
|
5 |
|
6 |
function testThrowsExceptionForInexistentCurrencyOnWithdraw() { |
7 |
$this->account->setPrimaryCurrency(Currency::EUR()); |
8 |
$money = new Money(100, Currency::EUR()); //That's 1 EURO |
9 |
$this->account->deposit($money); |
10 |
$this->account->withdraw(new Money(70, Currency::USD())); |
11 |
}
|
Geldmuster: Der richtige Weg, um Werte-Einheiten-Paare darzustellen
1 |
function withdraw(Money $money) { |
2 |
$this->validateCurrencyFor($money); |
3 |
$this->primaryCurrency == $money->getCurrency() ? |
4 |
$this->primaryBalance = $this->primaryBalance->subtract($money) : |
5 |
$this->secondaryBalance = $this->secondaryBalance->subtract($money); |
6 |
}
|
7 |
|
8 |
private function validateCurrencyFor(Money $money) { |
9 |
if (!in_array($money->getCurrency(), $this->getCurrencies())) |
10 |
throw new Exception( |
11 |
sprintf( |
12 |
'This account has no currency %s', |
13 |
$money->getCurrency()->getStringRepresentation() |
14 |
)
|
15 |
);
|
16 |
}
|
Aber was ist, wenn wir mehr zurückziehen wollen als wir haben? Dieser Fall wurde bereits angesprochen, als wir die Subtraktion von Money implementierten. Hier ist der Test, der es beweist.
1 |
/**
|
2 |
* @expectedException Exception
|
3 |
* @expectedExceptionMessage Subtracted money is more than what we have
|
4 |
*/
|
5 |
function testItThrowsExceptionIfWeTryToSubtractMoreMoneyThanWeHave() { |
6 |
$this->account->setPrimaryCurrency(Currency::EUR()); |
7 |
$money = new Money(100, Currency::EUR()); //That's 1 EURO |
8 |
$this->account->deposit($money); |
9 |
$this->account->withdraw(new Money(150, Currency::EUR())); |
10 |
}
|
Umgang mit Abheben und Tauschen
Eines der schwierigeren Dinge, mit denen wir umgehen müssen, wenn wir mit mehreren Währungen arbeiten, ist der Austausch zwischen ihnen. Das Schöne an diesem Entwurfsmuster ist, dass wir dieses Problem etwas vereinfachen können, indem wir es in seiner eigenen Klasse isolieren und einkapseln. Während die Logik in einer Exchange-Klasse sehr komplex sein kann, wird ihre Verwendung viel einfacher. Stellen wir uns für dieses Tutorial vor, wir haben nur eine sehr grundlegende Exchange-Logik. 1 EUR = 1,5 USD.
1 |
class Exchange { |
2 |
|
3 |
function convert(Money $money, Currency $toCurrency) { |
4 |
if ($toCurrency == Currency::EUR() && $money->getCurrency() == Currency::USD()) |
5 |
return new Money($money->multiplyBy(0.67)->getAmount(), $toCurrency); |
6 |
if ($toCurrency == Currency::USD() && $money->getCurrency() == Currency::EUR()) |
7 |
return new Money($money->multiplyBy(1.5)->getAmount(), $toCurrency); |
8 |
return $money; |
9 |
}
|
10 |
|
11 |
}
|
Wenn wir von EUR in USD umrechnen, multiplizieren wir den Wert mit 1.5, wenn wir von USD in EUR umrechnen, teilen wir den Wert durch 1.5, andernfalls nehmen wir an, dass wir zwei Währungen des gleichen Typs umrechnen, also tun wir nichts und geben nur das Geld zurück. In Wirklichkeit wäre dies natürlich eine viel kompliziertere Klasse.
Mit einer Exchange-Klasse kann das Account nun unterschiedliche Entscheidungen treffen, wenn wir Money in einer Währung abheben möchten, aber wir bewegen uns nicht genug in dieser bestimmten Währung. Hier ist ein Test, der dies besser veranschaulicht.
1 |
function testItConvertsMoneyFromTheOtherCurrencyWhenWeDoNotHaveEnoughInTheCurrentOne() { |
2 |
$this->account->setPrimaryCurrency(Currency::USD()); |
3 |
$money = new Money(100, Currency::USD()); //That's 1 USD |
4 |
$this->account->deposit($money); |
5 |
|
6 |
$this->account->setSecondaryCurrency(Currency::EUR()); |
7 |
$money = new Money(100, Currency::EUR()); //That's 1 EURO = 1.5 USD |
8 |
$this->account->deposit($money); |
9 |
|
10 |
$this->account->withdraw(new Money(200, Currency::USD())); //That's 2 USD |
11 |
|
12 |
$this->assertEquals(new Money(0, Currency::USD()), $this->account->getPrimaryBalance()); |
13 |
$this->assertEquals(new Money(34, Currency::EUR()), $this->account->getSecondaryBalance()); |
14 |
}
|
Wir setzen die Hauptwährung unseres Kontos auf USD und zahlen einen Dollar ein. Dann setzen wir die Sekundärwährung auf EUR und zahlen einen Euro ein. Dann ziehen wir zwei Dollar ab. Schließlich erwarten wir, bei null Dollar und 0,34 Euro zu bleiben. Natürlich löst dieser Test eine Ausnahme aus, daher müssen wir eine Lösung für dieses Dilemma implementieren.
1 |
function withdraw(Money $money) { |
2 |
$this->validateCurrencyFor($money); |
3 |
if ($this->primaryCurrency == $money->getCurrency()) { |
4 |
if( $this->primaryBalance >= $money ) { |
5 |
$this->primaryBalance = $this->primaryBalance->subtract($money); |
6 |
}else{ |
7 |
$ourMoney = $this->primaryBalance->add($this->secondaryToPrimary()); |
8 |
$remainingMoney = $ourMoney->subtract($money); |
9 |
$this->primaryBalance = new Money(0, $this->primaryCurrency); |
10 |
$this->secondaryBalance = (new Exchange())->convert($remainingMoney, $this->secondaryCurrency); |
11 |
}
|
12 |
|
13 |
} else { |
14 |
$this->secondaryBalance = $this->secondaryBalance->subtract($money); |
15 |
}
|
16 |
}
|
17 |
|
18 |
private function secondaryToPrimary() { |
19 |
return (new Exchange())->convert($this->secondaryBalance, $this->primaryCurrency); |
20 |
}
|
Wow, es mussten viele Änderungen vorgenommen werden, um diese automatische Konvertierung zu unterstützen. Was passiert, ist, dass wir, wenn wir aus unserer Primärwährung extrahieren und nicht genug Geld haben, unseren Saldo der Sekundärwährung in Primärwährung umwandeln und die Subtraktion erneut versuchen. Wenn wir immer noch nicht genug Geld haben, löst das $ourMoney-Objekt die entsprechende Ausnahme aus. Andernfalls setzen wir unseren Primärsaldo auf Null und konvertieren das verbleibende Geld zurück in die Sekundärwährung und setzen unseren Sekundärsaldo auf diesen Wert.
Es bleibt der Logik unseres Kontos überlassen, eine ähnliche automatische Umrechnung für die Sekundärwährung durchzuführen. Wir werden eine solche symmetrische Logik nicht implementieren. Wenn Ihnen die Idee gefällt, betrachten Sie sie als Übung für Sie. Denken Sie auch an eine allgemeinere private Methode, die in beiden Fällen die Magie der automatischen Konvertierung bewirkt.
Diese komplexe Änderung unserer Logik zwingt uns auch dazu, einen weiteren unserer Tests zu aktualisieren. Wann immer wir automatisch konvertieren wollen, müssen wir ein Gleichgewicht haben, auch wenn es nur Null ist.
1 |
/**
|
2 |
* @expectedException Exception
|
3 |
* @expectedExceptionMessage Subtracted money is more than what we have
|
4 |
*/
|
5 |
function testItThrowsExceptionIfWeTryToSubtractMoreMoneyThanWeHave() { |
6 |
$this->account->setPrimaryCurrency(Currency::EUR()); |
7 |
$money = new Money(100, Currency::EUR()); //That's 1 EURO |
8 |
$this->account->deposit($money); |
9 |
|
10 |
$this->account->setSecondaryCurrency(Currency::USD()); |
11 |
$money = new Money(0, Currency::USD()); |
12 |
$this->account->deposit($money); |
13 |
|
14 |
|
15 |
$this->account->withdraw(new Money(150, Currency::EUR())); |
16 |
}
|
Geld zwischen Konten zuweisen
Die letzte Methode, die wir für Money implementieren müssen, ist allocate. Das ist die Logik, die entscheidet, was zu tun ist, wenn Geld auf verschiedene Konten aufgeteilt wird, die nicht genau gemacht werden können. Wenn wir beispielsweise 0,10 Cent haben und diese auf zwei Konten in einem Anteil von 30 bis 70 Prozent verteilen möchten, ist das einfach. Ein Konto erhält drei Cent und die anderen sieben. Wenn wir jedoch die gleiche 30-70-Verhältnisverteilung von fünf Cent vornehmen wollen, haben wir ein Problem. Die genaue Zuordnung wäre 1.5 Cent auf einem Konto und 3.5 Cent auf dem anderen. Aber wir können keine Cent teilen, deshalb müssen wir unseren eigenen Algorithmus implementieren, um das Geld zuzuweisen.
Es gibt mehrere Lösungen für dieses Problem. Ein gängiger Algorithmus besteht darin, jedem Konto nacheinander einen Cent hinzuzufügen. Wenn ein Konto mehr Cent als seinen genauen mathematischen Wert hat, sollte es aus der Zuordnungsliste gestrichen werden und kein weiteres Geld erhalten. Hier ist eine grafische Darstellung.



Und ein Test, um unseren Standpunkt zu beweisen, ist unten.
1 |
function testItCanAllocateMoneyBetween2Accounts() { |
2 |
$a1 = $this->anAccount(); |
3 |
$a2 = $this->anAccount(); |
4 |
$money = new Money(5, Currency::USD()); |
5 |
$money->allocate($a1, $a2, 30, 70); |
6 |
|
7 |
$this->assertEquals(new Money(2, Currency::USD()), $a1->getPrimaryBalance()); |
8 |
$this->assertEquals(new Money(3, Currency::USD()), $a2->getPrimaryBalance()); |
9 |
}
|
10 |
|
11 |
private function anAccount() { |
12 |
$account = new Account(1); |
13 |
$account->setPrimaryCurrency(Currency::USD()); |
14 |
$account->deposit(new Money(0, Currency::USD())); |
15 |
return $account; |
16 |
}
|
Wir erstellen nur ein Money-Objekt mit fünf Cent und zwei Konten. Wir rufen allocate auf und erwarten, dass sich die zwei bis drei Werte in den beiden Konten befinden. Wir haben auch eine Hilfsmethode erstellt, um schnell Konten zu erstellen. Der Test schlägt wie erwartet fehl, aber wir können ihn ganz einfach bestehen.
1 |
function allocate(Account $a1, Account $a2, $a1Percent, $a2Percent) { |
2 |
$exactA1Balance = $this->amount * $a1Percent / 100; |
3 |
$exactA2Balance = $this->amount * $a2Percent / 100; |
4 |
|
5 |
$oneCent = new Money(1, $this->currency); |
6 |
while ($this->amount > 0) { |
7 |
if ($a1->getPrimaryBalance()->getAmount() < $exactA1Balance) { |
8 |
$a1->deposit($oneCent); |
9 |
$this->amount--; |
10 |
}
|
11 |
if ($this->amount <= 0) |
12 |
break; |
13 |
if ($a2->getPrimaryBalance()->getAmount() < $exactA2Balance) { |
14 |
$a2->deposit($oneCent); |
15 |
$this->amount--; |
16 |
}
|
17 |
}
|
18 |
}
|
Nun, nicht der einfachste Code, aber er funktioniert korrekt, wie das Bestehen unseres Tests beweist. Das einzige, was wir mit diesem Code noch tun können, ist, die kleine Duplizierung innerhalb der while-Schleife zu reduzieren.
1 |
function allocate(Account $a1, Account $a2, $a1Percent, $a2Percent) { |
2 |
$exactA1Balance = $this->amount * $a1Percent / 100; |
3 |
$exactA2Balance = $this->amount * $a2Percent / 100; |
4 |
|
5 |
while ($this->amount > 0) { |
6 |
$this->allocateTo($a1, $exactA1Balance); |
7 |
if ($this->amount <= 0) |
8 |
break; |
9 |
$this->allocateTo($a2, $exactA2Balance); |
10 |
}
|
11 |
}
|
12 |
|
13 |
private function allocateTo($account, $exactBalance) { |
14 |
if ($account->getPrimaryBalance()->getAmount() < $exactBalance) { |
15 |
$account->deposit(new Money(1, $this->currency)); |
16 |
$this->amount--; |
17 |
}
|
18 |
}
|
Abschließende Gedanken
Was ich an diesem kleinen Muster erstaunlich finde, ist die große Auswahl an Fällen, in denen wir es anwenden können.
Wir sind mit unserem Geldmuster fertig. Wir haben gesehen, dass es sich um ein recht einfaches Muster handelt, das die Besonderheiten des Geldkonzepts zusammenfasst. Wir haben auch gesehen, dass diese Kapselung die Belastung durch Berechnungen aus dem Konto verringert. Das Konto kann sich darauf konzentrieren, das Konzept aus Sicht der Bank von einer höheren Ebene aus darzustellen. Das Konto kann Methoden wie die Verbindung mit Kontoinhabern, IDs, Transaktionen und Geld implementieren. Es wird ein Orchestrator sein, kein Taschenrechner. Geld kümmert sich um Berechnungen.
Was ich an diesem kleinen Muster erstaunlich finde, ist die große Auswahl an Fällen, in denen wir es anwenden können. Grundsätzlich können Sie jedes Wert-Einheit-Paar verwenden, wenn Sie es haben. Stellen Sie sich vor, Sie haben eine Wetteranwendung und möchten eine Darstellung für die Temperatur implementieren. Das wäre das Äquivalent unseres Geldobjekts. Sie können Fahrenheit oder Celsius als Währungen verwenden.
Ein weiterer Anwendungsfall ist, wenn Sie eine Mapping-Anwendung haben und Abstände zwischen Punkten darstellen möchten. Mit diesem Muster können Sie problemlos zwischen metrischen und imperialen Messungen wechseln. Wenn Sie mit einfachen Einheiten arbeiten, können Sie das Exchange-Objekt löschen und die einfache Konvertierungslogik in Ihrem "Money"-Objekt implementieren.
Ich hoffe, Ihnen hat dieses Tutorial gefallen und ich bin gespannt, wie Sie dieses Konzept auf unterschiedliche Weise anwenden können. Danke fürs Lesen.



