商品價格的多幣種方案

首發於 樊浩柏科學院

倘若,你是某個國內電商平臺的商品中心項目負責人。忽然今天,接到了一個這樣的需求:商品在原人民幣價格的基礎架構上,須支持盧比(印度)價格。html

預覽圖

需求

需求點,能夠描述爲:架構

  • 購買的用戶,商品價格須要支持盧比;
  • 營運人員,商品管理系統依然使用人民幣價格;

一樣這個需求,定了如下兩個硬指標:框架

  • 必須實現需求;
  • 必須快速上線;

問題

首先,咱們必須認可的是,這確實是個簡單的需求,但這也是個夠坑爹的需求。主要遇到的問題以下:優化

  • 涉及商品價格的系統衆多;
  • 各上層系統調用商品價格接口繁多;
  • 商品價格相關字段較多;

爲了實現快速上線,咱們在原人民幣的商品價格基礎架構上,只能進行少許且合適的改造。因此,最後咱們的改造方向爲:儘可能只改造商品價格源頭系統,即商品中心,其餘上層系統儘可能不改動。this

可行性調研

改造商品中心,商品價格支持盧比。可行的改造方案有 2 種:spa

一、數據表價格字段存盧比設計

將原人名幣價格相關的數據表字段,存盧比值,數據表並新增人名幣字段。code

二、接口輸出數據時轉化爲盧比orm

原人名幣相關的數據表字段依然存人民幣值,在接口輸出數據時,將價格相關字段值轉化爲盧比。htm

針對以上方案,咱們須要注意 2 個問題:

  • 匯率會天天變化,因此商品價格也會變化;
  • 後續商品價格,可能須支持多幣種;

上述 方案 ①,商品中心只需改造數據表。而後天天根據匯率刷新商品價格,原價格字段就都變成了盧比。方案相對簡單,也容易操做,但缺點是:對任然須要人民幣價格的系統,即商品管理系統須改造。
方案 ②,須要改造商品中心業務邏輯。因爲涉及的價格字段較多,改造較複雜,主要優勢是:匯率變更對商品價格影響較小,且可拓展支持多幣種價格(能夠根據地區標識,獲取相應的商品價格)。

解決方案

最終,爲了系統的可擴展性,咱們選擇了方案 ②。

解決方案

這裏主要改造了商品中心,主要解決 透傳地區標識支持多幣種價格 這 2 個問題。

透傳地區標識

咱們的業務系統主要分爲 API 和 Service 項目,API 暴露出 HTTP 接口,API 與 Service 和 Service 與 Service 以前使用 RPC 接口通訊。因爲商品中心涉及到價格的接口繁多,不可能對每一個接口都增長地區標識的參數。因此咱們弄了一套調用鏈路透傳地區標識的機制。

機制原理

思路就是,先將地區標識放在全局上下文中,API 接口經過 Header 頭X-Location攜帶地區標識;而對於 RPC 接口,咱們的 RPC 框架已支持了 Context,不須要改造。

透傳地區標識機制

代碼實現

傳遞全局上下文

因爲 RPC 框架已支持了 Context,因此 API 和 RPC 接口透傳全局上下文略有不一樣。實現以下:

class Location
{
    public static function init()
    {
        global $context;

        if (empty($context['location'])) {
            return;
        }

        // API在這裏直接獲取X-Location頭
        if (!empty($_SERVER['HTTP_X_LOCATION'])) {
            $context['location'] = $_SERVER['HTTP_X_LOCATION'];
        }
        // RPC Server會自動獲取Context
    }
}
上述 init()方法,須要在項目入口位置初始化。

其中,RPC 接口不須要操做全局上下文。由於 RPC Client 在調用時會自動獲取全局變量$context值並在 RPC 協議數據中追加 Context,同時 RPC Server 在收到請求時會自動獲取 RPC 協議數據中的 Context 值並設置全局變量$context

RPC Client 傳遞 Context 實現以下:

protected function addGlobalContext($data)
{
    global $context;

    $context = !is_array($context) ? array() : $context;
    
    // data爲待請求的RPC協議數據
    $data['Context'] = $context;
    return $data;
}

RPC Server 獲取 Context 實現以下:

public function getGlobalContext($packet)
{
    global $context;
    
    $context = array();
    // packet爲接收的RPC協議數據
    if(isset($packet['Context'])) {
        $context = $packet['Context'];
    }
}

當設置了 Context 後,RPC 通訊時協議數據會攜帶location字段,內容以下:

RPC
325
{"data":"{\"version\":\"1.0\",\"user\":\"xxx\",\"password\":\"xxx\",\"timestamp\":1553225486.5455,\"class\":\"xxx\",\"method\":\"xxx\",\"params\":[1]}","signature":"xxx","Context":{"location":"india"}}
設置地區標識

到這裏,咱們只須要在全局上下文設置地區標識便可。一旦咱們設置了地區標識,全部業務系統就會在本次的調用鏈路中透傳這個地區標識。實現以下:

class Location
{
    public static function set($location)
    {
        global $context;

        $context['location'] = $location;
        // API須要在這裏單獨設置X-Location頭
        header('X-Location: ' . $context['location']);
    }
}
獲取地區標識

設置了地區標識後,就能夠在本次調用鏈路的全部業務系統中直接獲取。實現以下:

class Location
{
    public static function get()
    {
        global $context;

        if (!isset($context['location'])) {
            return 'china';
        }

        return $context['location'];
    }
}

支持多幣種價格

商品中心

有了地區標識後,商品中心服務就能夠根據地區標識對價格字段進行轉化了。由於設計到價格的數據表和價格字段較多,這裏直接從數據層(Model)進行改造。

改造獲取數據方法

下述的ReadBase類是全部數據表 Model 的基類,全部獲取數據表數據的方法都繼承或調用自getOne()getAll()方法,因此咱們只須要改造這兩個方法。

class ReadBase
{
    public function getOne(array $cond, $fields)
    {
        $data = $this->getReader()->select($this->getFields($fields))->from($this->getTableName())->where($cond)->queryRow();
        
        return $this->getExchangePrice($data);
    }
    
    public function getAll(array $cond, $fields)
    {
        $data = $this->getReader()->select($this->getFields($fields))->from($this->getTableName())->where($cond)->queryAll();
        
        if ($data) {
            foreach ($data as &$one) {
                 $this->getExchangePrice($one);
            }
        }
        
        return $data;
    }
}
後綴匹配價格字段

因爲涉及到價格字段名字較多,且具備不肯定性,因此這裏使用後綴方式匹配。爲了防止一些字段命名不規範,這裏引入了黑名單機制。

protected function isExchangeField($field)
{
    $priceSuffix = array('cost', '_price');
    $black = array();
    $len = strlen($field) ;

    foreach ($priceSuffix as $suffix) {
        $lastPos = $len - strlen($suffix);
        // 非黑名單且非is_
        if (!in_array($field, $black)
            && false === strpos($field, 'is_')
            && $lastPos === strpos($field, $suffix)
        ) {
            return true;
        }
    }

    return false;
}
前綴爲 is_的字段通常定義爲標識字段,默認爲非價格字段。
計算地區價格

上述getExchangePrice()方法,用來根據地區標識轉化價格覆蓋到原價格字段,並自增以_origin後綴的人民幣價格字段。

public function getExchangePrice(&$data)
{
    if (empty($data)) {
        return $data;
    }

    $originPrice = array();
    foreach ($data as $field => &$value) {
        // 是不是價格字段
        if ($this->isExchangeField($field)) {
            $originField = $field . '_origin';
            $originPrice[$originField] = $value;
            // 獲取對應地區的價格
            $value = $this->getExchangePrice($value);
        }
    }
    
    $data = array_merge($originPrice, $data);

    return $data;
}

public static function getExchangePrice($price)
{
    // 獲取地區標識
    $location = Location::get();
    // 匯率
    $exchangeRateConfig = \Config::$exchangeRate;
    if ($location === 'china') {
        return $price;
    } else if (isset($exchangeRateConfig[$location])) {
        $exchangeRate = $exchangeRateConfig[$location];
    } else {
        throw new \BusinessException("not found $location exchange rate");
    }
    // 向上取值並保留兩位小數
    $exchangePrice = bcmul($price, $exchangeRate, 3);

    return number_format(ceil($exchangePrice * 100) / 100, 2, '.', '');
}

其中,getExchangePrice()方法會調用Location::get()獲取地區標識,並根據匯率計算實時價格。

最終,商品中心改造後,獲得的部分商品價格信息,以下:

# 人民幣價格10,匯率10.87
market_price: 108.7
market_price_origin: 10

API系統

對於全部 API 的項目,咱們只須要讓客戶端在全部的請求中增長X-Location頭便可。

GET /product/detail/1 HTTP/1.1

Request Headers
  X-Location: india

API 項目需在入口文件處,初始化地區標識。以下:

Location::init();

商品管理系統

對於商品管理系統,咱們爲了方便運營操做,全部商品價格都應以人民幣。所以,咱們只須要初始化地區標識爲中國,以下:

Location::init();
// 地區設置爲中國
Location::set('china');

總結

爲了實現需求很容易,可是要作到合理且快速卻不簡單。本文的實現的方案,避免了不少坑,但同時也可能又埋下了一些坑。沒有一套方案是萬能的,慢慢去優化吧!

相關文章
相關標籤/搜索