在 Laravel 中實現「福勒的貨幣設計模式」

varro發表於2019-12-02

“這個世界上有很大比例的計算機都在操縱金錢,因此,我一直感到困惑的是,金錢實際上並不是任何主流程式語言中的一流資料型別。 缺乏型別會導致問題,這是最明顯的周邊貨幣。 如果您所有的計算都是用一種貨幣完成的,那麼這並不是一個大問題,但是一旦涉及多種貨幣,您就希望避免在不考慮貨幣差異的情況下將美元加到日元中。 更細微的問題是舍入。 貨幣計算通常四捨五入為最小的貨幣單位。 執行此操作時,由於舍入錯誤,很容易損失幾美分(或您當地的等值貨幣)“ -- 馬丁福勒的貨幣設計模式(Flower's money pattern)

為什麼要使用貨幣類

相比於使用 float 類來儲存貨幣的傳統做法,使用貨幣類(Money library)可以照顧到處理金錢的所有細微複雜之處,比如四捨五入、金額計算、匯率換算、貨幣格式化輸出等

貨幣模式在 Laravel 中的使用

<?php

namespace App\Http\Controllers;

use App\Support\Money;

class TestController extends Controller
{
    public function index()
    {
        // 從容器中新建一個價值為 1000 美元的貨幣類, 其單位為「分」
        $money = app(Money::class, [100000, 'USD']);

        // 格式化輸出
        $money->value(); // 1000.00
        $money->format(); // $1,000.00

        // 貨幣運算
        $money2 = app(Money::class, [5000, 'USD']);
        $money->add($money2)->format(); // $1,050.00
        $money->divide(10)->format(); // $100.00

        // 根據實時匯率換算為人民幣
        $money->convertTo('CNY')->format(); // CN¥7,028.70

        // 根據固定匯率換算
        $exchangeRate = [
            'USD' => [
                'EUR' => 0.7,
                'CNY' => 8.7654321,
            ],
        ];

        $money->fixedConvertTo('EUR', $exchangeRate)->format(); // €700.00
        $money->fixedConvertTo('CNY', $exchangeRate)->format(); // CN¥8,765.43

        // 從字串解析貨幣
        Money::parse('$300')->format(); // $300.00
    }
}

在 Laravel 中實現貨幣模式

cknow/laravel-money

moneyphp/money是貨幣模式的主要實現,cknow/laravel-money在其之上做了 Laravel 框架的擴充

安裝

$ composer require cknow/laravel-money

釋出配置檔案

$ php artisan vendor:publish --provider="Cknow\Money\MoneyServiceProvider"

內容如下:

# config/money.php

<?php

return [
    /*
     |--------------------------------------------------------------------------
     | Laravel money
     |--------------------------------------------------------------------------
     */
    'locale' => config('app.locale', 'en_US'), // 預設地區
    'currency' => config('app.currency', 'USD'), // 預設幣種
];

florianv/laravel-swap

florianv/swap 支援請求多種三方 API 獲取匯率,並寫入快取,最終完成貨幣的匯率換算。florianv/laravel-swap 是對 florianv/swap 在 Laravel 框架的封裝

安裝

$ composer require florianv/laravel-swap

釋出配置檔案

$ php artisan vendor:publish --provider="Swap\Laravel\SwapServiceProvider"
# config/swap.php

<?php

......

return [

    // 使用哪種快取驅動
    'cache'           => 'redis',

    // 快取過期時間
    'options' => [
        'cache_ttl' => 12 * 60 * 60,
    ],

    // 匯率 API, 我使用的是 currency_converter, 需要在此處填入你自己的服務商祕鑰
    'services'        => [
        'currency_converter' => [
            'access_key' => 'key',
            'enterprise' => false,
        ],
    ],
];

Money Service Provider

cknow/laravel-moneyflorianv/laravel-swap 的使用方式有些繁瑣。通過 Laravel 的服務容器可以簡化許多操作。

# app/Providers/MoneyServiceProvider.php

<?php

namespace App\Providers;

use Money\Currency;
use Money\Converter;
use App\Support\Money;
use Money\Exchange\SwapExchange;
use Money\Exchange\FixedExchange;
use Money\Currencies\ISOCurrencies;
use Money\Formatter\DecimalMoneyFormatter;
use Illuminate\Contracts\Foundation\Application;
use Cknow\Money\MoneyServiceProvider as CknowMoneyServiceProvider;

class MoneyServiceProvider extends CknowMoneyServiceProvider
{
    public function boot()
    {
        // 為貨幣類設定預設地區
        Money::setLocale(config('money.locale'));

        // 為貨幣類設定預設幣種
        Money::setCurrency(config('money.currency'));
    }

    public function register()
    {
        /*
        |--------------------------------------------------------------------------
        | 繫結 ISO Currency
        |--------------------------------------------------------------------------
        |
        | ISO 標準貨幣幣種列表
        |
        */

        $this->app->singleton(ISOCurrencies::class, static function (Application $app) {
            return new ISOCurrencies();
        });

        $this->app->alias(ISOCurrencies::class, 'iso_currencies');

        /*
        |--------------------------------------------------------------------------
        | 繫結 Currency
        |--------------------------------------------------------------------------
        |
        | 貨幣幣種
        |
        */

        $this->app->bind(Currency::class, static function (Application $app, array $parameters = []) {
            $currencyCode = current($parameters);

            return new Currency($currencyCode ?: \Cknow\Money\Money::getCurrency());
        });

        $this->app->alias(Currency::class, 'currency');

        /*
        |--------------------------------------------------------------------------
        | 繫結 Fixed Exchange
        |--------------------------------------------------------------------------
        |
        | 固定匯率貨幣換算服務
        |
        */

        $this->app->bind(FixedExchange::class, static function (Application $app, array $exchangeRate = []) {
            return new FixedExchange($exchangeRate);
        });

        $this->app->alias(FixedExchange::class, 'fixed_exchange');

        /*
        |--------------------------------------------------------------------------
        | 繫結 Swap Exchange
        |--------------------------------------------------------------------------
        |
        | 動態匯率貨幣換算服務
        |
        */

        $this->app->singleton(SwapExchange::class, static function (Application $app) {
            return new SwapExchange($app->get('swap'));
        });

        $this->app->alias(SwapExchange::class, 'swap_exchange');

        /*
        |--------------------------------------------------------------------------
        | 繫結 Fixed Converter
        |--------------------------------------------------------------------------
        |
        | 固定匯率貨幣換算器
        |
        */

        $this->app->bind('fixed_converter', static function (Application $app, array $exchangeRate = []) {
            return new Converter($app->get('iso_currencies'), $app->make('fixed_exchange', $exchangeRate));
        });

        /*
        |--------------------------------------------------------------------------
        | 繫結 Swap Converter
        |--------------------------------------------------------------------------
        |
        | 動態匯率貨幣換算器
        |
        */

        $this->app->singleton('swap_converter', static function (Application $app) {
            return new Converter($app->get('iso_currencies'), $app->get('swap_exchange'));
        });

        /*
        |--------------------------------------------------------------------------
        | 繫結 Decimal Money Formatter
        |--------------------------------------------------------------------------
        |
        | 預設的貨幣列印格式 - 小數格式
        |
        */

        $this->app->singleton(DecimalMoneyFormatter::class, static function (Application $app) {
            return new DecimalMoneyFormatter(app('iso_currencies'));
        });

        $this->app->alias(DecimalMoneyFormatter::class, 'decimal_money_formatter');

        /*
        |--------------------------------------------------------------------------
        | 繫結 Money
        |--------------------------------------------------------------------------
        |
        | 貨幣類
        |
        */

        $this->app->bind(Money::class, static function (Application $app, array $parameters = []) {
            $amount = current($parameters);
            $currencyCode = next($parameters);

            $currency = $app->make(Currency::class, [$currencyCode]);

            return new Money($amount, $currency);
        });

        $this->app->alias(Money::class, 'money');
    }
}

封裝自己的貨幣類

根據自己的情況實現一些方法,方便使用

# app/Support/Money.php

<?php

namespace App\Support;

use Money\Currency;
use Cknow\Money\Money as BaseMoney;
use Money\Formatter\DecimalMoneyFormatter;

class Money extends BaseMoney
{
    /**
     * 根據動態匯率換算金額.
     *
     * @param  string  $currency 要換算的幣種
     *
     * @return self
     */
    public function convertTo(string $currency): self
    {
        $money = app('swap_converter')->convert($this->money, app(Currency::class, [$currency]));

        return $this->rebuild($money);
    }

    /**
     * 根據固定匯率換算金額.
     *
     * @param  string  $currency 要換算的幣種
     * @param  array  $exchangeRate 自定義的匯率
     *
     * @return self
     */
    public function fixedConvertTo(string $currency, array $exchangeRate): self
    {
        $money = app('fixed_converter', $exchangeRate)->convert($this->money, app(Currency::class, [$currency]));

        return $this->rebuild($money);
    }

    /**
     * 以小數形式輸出金額.
     *
     * @return string
     */
    public function value(): string
    {
        return app(DecimalMoneyFormatter::class)->format($this->money);
    }

    /**
     * 將 \Money\Money 類轉換為 \App\Support\Money 類.
     *
     * @param \Money\Money $money
     *
     * @return self
     */
    protected function rebuild($money): self
    {
        $currencyCode = $money->getCurrency()->getCode();

        $amount = $money->getAmount();

        return app(self::class, [$amount, $currencyCode]);
    }

    /**
     * 重寫父類魔術方法, 在存入資料庫等操作時自動轉換為小數.
     *
     * @return string
     */
    public function __toString()
    {
        return $this->value();
    }
}

到目前為止,便可以實現文章一開始的貨幣操作、匯率換算、列印功能

在 Eloquent Model 中使用貨幣類

貨幣在資料庫中一般以浮點數的形式儲存,使用 Eloquent Model 的 cast 特性,可以在把資料取出來時就把浮點數形式的貨幣轉換為貨幣類

vkovic/laravel-custom-casts

vkovic/laravel-custom-casts 可以將 Eloquent Model 的屬性(資料庫的欄位)自動轉換成自定義的類

安裝

$ composer require vkovic/laravel-custom-casts

新建 Money Cast

#app/Models/Casts/MoneyCast.php

<?php

namespace App\Models\Casts;

use App\Support\Money;
use Vkovic\LaravelCustomCasts\CustomCastBase;

class MoneyCast extends CustomCastBase
{
    /**
     * 屬性存入資料庫時轉換為浮點數.
     *
     * @param  Money $money
     *
     * @return string
     */
    public function setAttribute($money)
    {
        if (! $money instanceof Money) {
            return $money;
        }

        return $money->divide(100)->value();
    }

    /**
     * 取出屬性時轉換為 Money 類.
     *
     * @param $money
     *
     * @return Money
     */
    public function castAttribute($money): Money
    {
        $money = bcmul($money, 100, 4);

        return app(Money::class, [$money]); // 在我的專案中預設幣種為 USD, 所以這裡省略了第二個引數
    }
}

moneyphp/money 中貨幣的基本單位為「分」,但我的專案中貨幣的基本單位為「元」,所以我在取出 / 存入貨幣時分別乘以 / 除以了 100。

在模型中引入

# app/Models/Order.php

<?php

namespace App\Models;

use App\Models\Casts\MoneyCast;
use Illuminate\Database\Eloquent\Model;
use Vkovic\LaravelCustomCasts\HasCustomCasts;

class Order extends Model
{
    use HasCustomCasts;

    public $casts  = [
        'order_total' => MoneyCast::class // 將訂單總金額自動轉為貨幣類
    ];
}

使用

<?php

namespace App\Http\Controllers;

use App\Support\Money;

class TestController extends Controller
{
    public function index()
    {
        $order = Order::query()->first();  // 該訂單的金額在資料庫中為 67.84

        dump($order->order_total); // 該欄位已自動從浮點數轉為了貨幣類

        dump($order->order_total->format());
    }
}

# 結果

App\Support\Money {#1677 ▼
  #money: Money\Money {#1753 ▼
    -amount: "6784"
    -currency: Money\Currency {#1682 ▼
      -code: "USD"
    }
  }
  #attributes: []
}

"$67.84"

其他

擴充包 moneyphp/money 還提供了許多其他有用的方法,可以看下它的官方文件

我在使用這個擴充包時根據專案的情況做了一些封裝處理,如有不足或更好的實現歡迎指出

原文連結:在 Laravel 中實現「貨幣設計模式」

相關文章