Swoft 2.x 微服務基礎(Consul、RPC 服務發現、限流與熔斷器)

1. Swoft 服務註冊與發現;

1.1 Consul 概況;

服務的發現與註冊

  • 配置共享:調用端裏面有數據庫或者 Redis 的鏈接,單機開發的時候寫在配置文件裏,但是多臺服務器或者通過 docker 部署,配置一旦發生改變(服務器沒了,地址改了),這些改變無法讓調用端獲取到。這時候需要一個統一的配置中心,配置發生改變的時候要有一些即時的機制讓調用端不需要大量的修改配置

Docker 安裝 Consul

# Consul 鏡像地址:https://hub.docker.com/_/consul
# 拉取鏡像
docker pull consul
# 啓動
docker run -d --name=consul -p 8500:8500 \
consul agent -server -bootstrap -ui -client 0.0.0.0

# 查看版本
docker exec -it consul consul -v

# 開啓防火牆
iptables -I INPUT -p tcp --dport 8500 -j ACCEPT

下載安裝 Consul

  • 下載地址:https://www.consul.io/downloads.html,解壓出來直接是一個可執行文件
  • 下載 1.5.3 版本,最新的 1.7.0 版本有 bug
  • 拷貝到 /usr/local/consul,執行 ./consul -v 出現版本號,即可正常使用

啓動 Consul

# 參數配置
# -data-dir 數據目錄
# -bind  指定機器
# -server 以服務器模式進行啓動
# -bootstrap 指定自己爲 leader,而不需要選舉
# -ui 啓動一個內置管理 web 界面(瀏覽器訪問:http://192.168.60.221:8500)
# -client 指定客戶端可以訪問的 IP。設置爲 0.0.0.0 則任意訪問,否則默認本機可以訪問
./consul agent -data-dir=/home/hua/consul -bind=192.168.60.221 \
-server -bootstrap -client 0.0.0.0 -ui -client=0.0.0.0

基本操作

# 1. 服務端模式:負責保存信息、集羣控制、與客戶端通信、與其它數據中心通信
## 新開一個終端,查看當前多少個節點
./consul members
# 返回
Node    Address              Status  Type    Build  Protocol  DC   Segment
hua-PC  192.168.60.221:8301  alive   server  1.5.3  2         dc1  <all>

## 1.1 通過 API 的方式來調用並查看:https://www.consul.io/api/index.html
# 查看節點:
curl http://192.168.60.221:8500/v1/agent/members

## 1.2 註冊服務
# 服務註冊好之後,是會通過一定的方式保存到服務端
# 有服務註冊,就會檢查(比如服務掛了,就會發出警告)
# 列出當前所有註冊好的服務(目前爲空):https://www.consul.io/api/agent/service.html
curl http://192.168.60.221:8500/v1/agent/services
# 1.2.1 註冊一個服務
# 參考 1:https://www.consul.io/api/agent/service.html#register-service
# 參考 2 格式:https://www.consul.io/api/agent/service.html#sample-payload
curl http://192.168.60.221:8500/v1/agent/service/register \
--request PUT \
--data '{
	"ID": "testservice",
	"Name": "testservice",
	"Tags": ["test"],
	"Address": "192.168.60.221",
	"Port": 18306,
	"Check": {"HTTP": "http://192.168.60.221:18306/consul/health","Interval": "5s"}
}'

# 1.2.2 反註冊
curl http://192.168.60.221:8500/v1/agent/service/deregister/testservice \
--request PUT

## 1.3 檢查服務狀態是否健康:https://www.consul.io/api/health.html
curl http://192.168.60.221:8500/v1/health/checks/testservice

# 2. 客戶端模式:無狀態,將請求轉發服務器或者集羣,此時服務器或集羣並不保存任何內容

# 3. 基於 Agent 守護進程

1.2 在 Consul 註冊服務、反註冊;

1.2.1 註冊服務;

# 添加如下代碼
'consul'            => [
        'host'      => '192.168.60.221',
        'port'      => '8500'
    ],
  • 新建 Swoft\App\consul\RegService.php
<?php

namespace app\consul;

use Swoft\Bean\Annotation\Mapping\Inject;
use Swoft\Consul\Agent;
use Swoft\Event\Annotation\Mapping\Listener;
use Swoft\Event\EventHandlerInterface;
use Swoft\Event\EventInterface;
use Swoft\Log\Helper\CLog;
use Swoft\Server\SwooleEvent;

/**
 * @Listener(event=SwooleEvent::START)
 * Swoole 服務啓動的的時候執行 handle(
 */
class RegService implements EventHandlerInterface {

    /**
     * @Inject()
     * 注入 agent 操作 consul
     * @var Agent
     */
    private $agent;

    /**
     * @param EventInterface $event
     */
    public function handle(EventInterface $event): void
    {
        $service = [
            'ID'                => 'prodservice-id-1',
            'Name'              => 'prodservice',
            'Tags'              => [
                'http'
            ],
            'Address'           => '192.168.60.221',
            'Port'              => 18306,   //$httpServer->getPort(),
            'Meta'              => [
                'version' => '1.0'
            ],
            'EnableTagOverride' => false,
            'Weights'           => [
                'Passing' => 10,
                'Warning' => 1
            ],
            // 健康檢查方法 1: 註冊服務的時候,加入健康檢查器
//            "checks" => [
//                [
//                    "name"  => "prod-check",
//                    "http"  => "http://192.168.60.221:18306/consul/health",
//                    "interval" => "10s",
//                    "timeout" => "5s"
//                ]
//            ]
        ];


        // Register
        $this->agent->registerService($service);

        // 註冊第二個服務
        $service2 = $service;
        $service2["ID"] = 'prodservice-id-2';
        $this->agent->registerService($service2);

        // 註冊第三個服務
        $service3 = $service;
        $service3["ID"] = 'prodservice-id-3';
        $this->agent->registerService($service3);


        // 健康檢查方法 2:代碼
        $this->agent->registerCheck([
                    "name"  => "prod-check1",
                    "http"  => "http://192.168.60.221:18306/consul/health",
                    "interval" => "10s",
                    "timeout" => "5s",
                    "serviceid" => "prodservice-id-1"
                ]);

        $this->agent->registerCheck([
            "name"  => "prod-check2",
            "http"  => "http://192.168.60.221:18306/consul/health2",
            "interval" => "10s",
            "timeout" => "5s",
            "serviceid" => "prodservice-id-2"
        ]);

        $this->agent->registerCheck([
            "name"  => "prod-check3",
            "http"  => "http://192.168.60.221:18306/consul/health3",
            "interval" => "10s",
            "timeout" => "5s",
            "serviceid" => "prodservice-id-3"
        ]);

        CLog::info('Swoft http register service success by consul!');
    }
}

在這裏插入圖片描述

1.2.2 反註冊;

<?php

namespace app\consul;

use Swoft\Bean\Annotation\Mapping\Inject;
use Swoft\Consul\Agent;
use Swoft\Event\Annotation\Mapping\Listener;
use Swoft\Event\EventHandlerInterface;
use Swoft\Event\EventInterface;
use Swoft\Server\SwooleEvent;

/**
 * Class DeregisterServiceListener
 *
 * @since 2.0
 *
 * @Listener(SwooleEvent::SHUTDOWN)
 */
class UnregService implements EventHandlerInterface {

    /**
     * @Inject()
     *
     * @var Agent
     */
    private $agent;

    /**
     * @param EventInterface $event
     */
    public function handle(EventInterface $event): void
    {
        $this->agent->deregisterService('prodservice-id-1');
        $this->agent->deregisterService('prodservice-id-2');
        $this->agent->deregisterService('prodservice-id-3');
    }
}
  • 控制檯 Ctrl + C 退出,完成反註冊

1.3 健康檢查;

  • 新建 Swoft\App\Http\Controller\Consul.php
<?php

namespace App\Http\Controller;

use Swoft\Http\Server\Annotation\Mapping\Controller;
use Swoft\Http\Server\Annotation\Mapping\RequestMapping;
use Swoft\Http\Server\Annotation\Mapping\RequestMethod;

/**
 * Class Consul
 * @Controller(prefix="/consul")
 */
class Consul{

    /**
     * @RequestMapping(route="health",method={RequestMethod::GET})
     */
    public function health(){
        return ["status" => "ok"];
    }

    /**
     * @RequestMapping(route="health2",method={RequestMethod::GET})
     */
    public function health2(){
        return ["status" => "ok"];
    }

}

1.4 服務發現;

<?php

namespace App\Http\Controller;

use App\consul\ServiceClient;
use App\consul\ServiceHelper;
use App\consul\ServiceSelector;
use Swoft\Bean\Annotation\Mapping\Inject;
use Swoft\Consul\Agent;
use Swoft\Consul\Health;
use Swoft\Http\Message\Request;
use Swoft\Http\Server\Annotation\Mapping\Controller;
use Swoft\Http\Server\Annotation\Mapping\RequestMapping;
use Swoft\Http\Server\Annotation\Mapping\RequestMethod;

/**
 * Class MyClient
 * @Controller(prefix="/client")
 */
class MyClient{

    /**
     * @Inject()
     *
     * @var Agent
     */
    private $agent;

    /**
     * @Inject()
     *
     * @var Health
     */
    private $health;

    /**
     * @Inject()
     *
     * @var ServiceHelper
     */
    private $serviceHelper;

    /**
     * @Inject()
     *
     * @var ServiceSelector
     */
    private $selector;

    /**
     * @Inject()
     *
     * @var ServiceClient
     */
    private $serviceClient;

    /**
     * @RequestMapping(route="services",method={RequestMethod::GET})
     * 獲取當前服務
     * 訪問:http://192.168.60.221:18306/client/services
     * 等同於:http://192.168.60.221:8500/v1/health/checks/prodservice
     */
    public function getService(){
        // $service = $this->agent->services();
        // return $service->getResult();
        // 返回隨機服務
//        return  $this->selector->selectByRandom(
//            // 獲取正常服務列表
//            $this->serviceHelper->getService("prodservice")
//        );
        // ip_hash 算法獲取服務
        // return $this->selector->selectByIPHash(ip(), 
        //		$this->serviceHelper->getService("prodservice"));

        // 輪詢獲取
        return $this->selector->selectByRoundRobin(
            $this->serviceHelper->getService("prodservice")
        );

    }

    /**
     * @RequestMapping(route="health",method={RequestMethod::GET})
     * 健康檢查:https://www.consul.io/api/health.html#list-checks-for-service
     * 訪問:http://192.168.60.221:18306/client/health
     * 等同於:http://192.168.60.221:8500/v1/health/checks/prodservice?
     * filter=Status==passing
     */
    public function getHealth(){
        // checks() 方法需要修改 Swoft\vendor\swoft\consul\src\Health.php
        // 'query' => OptionsResolver::resolve($options, ['dc', "filter"]),
        $service = $this->health->checks("prodservice", 
        	["filter" => "Status==passing" ]);    // 服務名
        return $service->getResult();
    }

    /**
     * @RequestMapping(route="call",method={RequestMethod::GET})
     * 1.6 調用封裝後的方法,獲取服務
     */
    public function call(Request $request){
        return $this->serviceClient->call("prodservice", "/prod/list");

    }

}
  • 封裝以上代碼,新建: Swoft\App\consul\ServiceHelper.php
<?php

namespace App\consul;


use Swoft\Bean\Annotation\Mapping\Bean;
use Swoft\Bean\Annotation\Mapping\Inject;
use Swoft\Consul\Agent;
use Swoft\Consul\Health;

/**
 * Class ServiceHelper
 * @Bean()
 */
class ServiceHelper{

    /**
     * @Inject()
     *
     * @var Agent
     */
    private $agent;

    /**
     * @Inject()
     *
     * @var Health
     */
    private $health;

    /**
     * @param string $serviceName
     * @return array
     * 根據服務名 獲取健康服務列表
     */
    public function getService(string $serviceName) : array {
        $service = $this->agent->services()->getResult();
        $checks = $this->health->checks($serviceName, 
        	["filter" => "Status==passing"])->getResult();

        $passingNode = [];  // [0=>'s1', 1=>'s2', 2=>'s3']
        foreach ($checks as $check){
            $passingNode[] = $check['ServiceID'];
        }

        if(count($passingNode) == 0) return [];

        return array_intersect_key($service, array_flip($passingNode));

    }

}

1.5 算法獲取服務;

  • 新建:Swoft\App\consul\ServiceSelector.php
<?php

namespace App\consul;

use Swoft\Bean\Annotation\Mapping\Bean;

/**
 * Class ServiceSelector
 * @package App\consul
 * @Bean()
 * 如果不用 @Bean() 注入,可以把方法都寫成 static 方法
 */
class ServiceSelector{

    private $nodeIndex = 0;

    /**
     * @param array $serviceList
     *  1.5.1 隨機獲取一個服務
     */
    public function selectByRandom(array $serviceList){
        $getIndex = array_rand($serviceList);   // ['prod-1' => 'xxx']
        return $serviceList[$getIndex];
    }

    /**
     * @param string $ip
     * @param array $serviceList
     * @return mixed
     *  1.5.2 ip_hash 獲取一個服務
     */
    public function selectByIPHash(string $ip, array $serviceList){
        $getIndex = crc32($ip)%count($serviceList);
        $getKey = array_keys($serviceList)[$getIndex];
        return $serviceList[$getKey];
    }

    /**
     * @param array $serviceList
     *  1.5.3 輪詢算法 獲取一個服務
     */
    public function selectByRoundRobin(array $serviceList){
//        if($this->nodeIndex >= count($serviceList)){
//            $this->nodeIndex = 0;
//        }
//
//        $getKey = array_keys($serviceList)[$this->nodeIndex];
//        $this->nodeIndex++;

        $getKey = array_keys($serviceList)[$this->nodeIndex];
        $this->nodeIndex = ($this->nodeIndex + 1) % count($serviceList);
        return $serviceList[$getKey];

    }

}

1.6 封裝 client 類、調用 http api;

# 安裝方法
# 進入到項目目錄
composer require guzzlehttp/guzzle
  • 新建:Swoft\App\consul\ServiceClient.php
<?php

namespace App\consul;

use Swoft\Bean\Annotation\Mapping\Bean;
use Swoft\Bean\Annotation\Mapping\Inject;

/**
 *
 * @Bean()
 */
class ServiceClient{

    const SELECT_RAND = 1;
    const SELCET_IPHASH = 2;
    const SELECT_ROUNDROBIN = 3;

    /**
     * @Inject()
     *
     * @var ServiceHelper
     */
    private $serviceHelper;
    /**
     * @Inject()
     *
     * @var ServiceSelector
     */
    private $selector;

    /**
     * @param string $service
     * @param int $selectType
     * @return mixed
     * 以某種算法獲取服務
     */
    private function loadService(string $service, int $selectType){
        $serviceList = $this->serviceHelper->getService($service);

        switch ($selectType){
            case self::SELECT_RAND:
                return $this->selector->selectByRandom($serviceList);
            case self::SELCET_IPHASH:
                return $this->selector->selectByIPHash(ip(), $serviceList);
            default:
                return$this->selector->selectByRoundRobin($serviceList);
        }
    }

    /**
     * @param $service
     * @param $endpoint 端點,地址
     * @param string $method
     * @param int $selectType
     * @return mixed
     */
    public function call($service, $endpoint, $method = "GET", $selectType = ServiceClient::SELECT_ROUNDROBIN){
        // 從 consul 獲取服務
        $getService = $this->loadService($service, $selectType);

        $client = new \GuzzleHttp\Client();
        // endpoint,好比:/prod/list  就是 path
        // 目前是 HTTP 方式
        // 如果調用的是 RPC 服務,需要修改
        $url = "http://" . $getService["Address"] . ":" . $getService["Port"] . $endpoint;
        $response = $client->request($method, $url);
        return $response->getBody();
    }

}

2. RPC 和服務發現;

2.1 RPC 服務的基本配置;

2.1.1 基本概念;

概念:

  • 中文名稱是“遠程過程調用”。現在做的網站裏面提交表單就是一個遠程調用,它的過程是在服務端執行的。RPC 是一個更廣泛的概念。如果只侷限在 HTTP 這個範疇,現在做的網站和 RESTful API 都是 RPC

基本原理:

  • 建立在客戶端和服務端,網絡是相通的,兩者之間能夠建立 TCP、UDP 連接等基礎之上,能夠傳輸一些內容,這就是 RPC
  • 這裏面的內容需要雙方進行約定。客戶端連上 TCP 之後,傳一個 abc,服務端立馬就知道客戶端要它執行 abc 這個方法。所以 abc 就是協議,協議都是約定好的(比如 JSONRPC),系統做好之後,第三方纔可以根據協議接入

關於 JSONRPC

  • 文檔:http://wiki.geekdream.com/Specification/json-rpc_2.0.html
  • 好比創建一個 JSON 對象,裏面包含一些固定的格式
  • 用戶建立好 TCP 的過程當中,發送如下數據(請求),客戶端只要拼湊如下數據發送給服務端就可以了
  • {"jsonrpc": "2.0", "method": "add", "params": [1,2], "id": 1}
  • 服務端直接獲取數據進行 JSON decode,分別解析參數,在進行執行,執行的過程自己決定
  • 響應結果:{"jsonrpc": "2.0", "result": 3, "id": 1}
  • 現在只要寫代碼完成以上數據的“接收”和“響應”,然後再把過程封裝,使其更加的人性化(本地化)
  • 實際開發有各種框架支持

2.1.2 基本配置;

# 添加如下代碼
'rpcServer'         => [
   'class' => ServiceServer::class,
],
  • 實例操作
# 啓動
# RPC 默認監聽端口 18307
# HTTP 監聽 18306 端口
php ./bin/swoft rpc:start
# 返回
 SERVER INFORMATION(v2.0.8)
  **************************************************************
  * RPC      | Listen: 0.0.0.0:18307, Mode: Process, Worker: 2
  **************************************************************

RPC Server Start Success!
  • 修改 App\consul\regService.php
<?php
// 修改 1:
 $service = [
    'ID'                => 'prodservice-id-1',
     'Name'              => 'prodservice',
     'Tags'              => [
         'rpc'
     ],
     'Address'           => '192.168.60.221',
     // 端口號改成 RPC 服務的 18307
     'Port'              => 18307, 
     'Meta'              => [
         'version' => '1.0'
     ],
     'EnableTagOverride' => false,
     'Weights'           => [
         'Passing' => 10,
         'Warning' => 1
     ],
 ];
 // 修改 2:連接方式修改成 tcp
 // 健康檢查方法 2:代碼
 $this->agent->registerCheck([
   "name"  => "prod-check1",
    //"http"  => "http://192.168.60.221:18306/consul/health",
    "tcp" => "192.168.60.221:18307",
    "interval" => "10s",
    "timeout" => "5s",
    "serviceid" => "prodservice-id-1"
]);

2.2 創建 RPC 服務,客戶端直連調用;

  • 拆分成兩個項目:Swoft(HTTP)和 Swoft_rpc(RPC),Swoft 僅僅是用來調用 RPC,只是爲了完成和客戶端(瀏覽器)的響應,並不完成具體的取數據等等。RPC Server 可以部署在不同的點上,作爲和前端(用戶)交互的入口。這只是一種做法,不代表 Swoft(HTTP)不可以寫業務邏輯
  • 參考:https://www.swoft.org/documents/v2/core-components/rpc-server/#-rpc-client
  • RPC 端新建:Swoft_rpc/App/Rpc/Lib/ProdInterface.php (這是一個接口,拷貝給客戶端 / 調用端)
  • HTTP 端新建:Swoft/App/Rpc/Lib/ProdInterface.php
<?php

namespace App\Rpc\Lib;

interface ProdInterface{

    function getProdList();
}
  • RPC 端新建實現類(繼承以上接口,客戶端不需要實現類):Swoft_rpc/App/Rpc/Service/ProdService.php
<?php

namespace App\Rpc\Service;

use App\Rpc\Lib\ProdInterface;
use Swoft\Rpc\Server\Annotation\Mapping\Service;

/**
 * Class ProdService
 * @package App\Rpc\Service
 * @Service()
 */
class ProdService implements ProdInterface{

    function getProdList()
    {
        return [
            ["prod_id" => 101, "prod_name" => "testprod1"],
            ["prod_id" => 102, "prod_name" => "testprod2"]
        ];
    }
}
<?php

namespace App\Http\Controller;

use App\Rpc\Lib\ProdInterface;
use Swoft\Http\Server\Annotation\Mapping\Controller;
use Swoft\Http\Server\Annotation\Mapping\RequestMapping;
use Swoft\Http\Server\Annotation\Mapping\RequestMethod;
use Swoft\Rpc\Client\Annotation\Mapping\Reference;

/**
 * Class ProdController
 * @Controller(prefix="/prod")
 */
class ProdController{

    /**
     * @Reference(pool="prod.pool")
     *
     * @var ProdInterface
     */
    private $prodService;


    /**
     * @RequestMapping(route="list",method={RequestMethod::GET})
     */
    public function prod(){
        // return ["prod_list"];
        // 接口實現部分在 RPC 端,並不在 HTTP 端
        return $this->prodService->getProdList();
    }
}
  • HTTP 端新建文件:Swoft\App\rpcbean.php
<?php

use Swoft\Rpc\Client\Client as ServiceClient;
use Swoft\Rpc\Client\Pool as ServicePool;

$settings = [
    'timeout'         => 0.5,
    'connect_timeout' => 1.0,
    'write_timeout'   => 10.0,
    'read_timeout'    => 0.5,
];

return [
    'prod' => [
        'class'   => ServiceClient::class,
        // 2.3 操作時,註釋掉
        // 'host'    => '192.168.60.221',
        // 'port'    => 18307,
        'setting' => $settings,
        'packet'  => bean('rpcClientPacket'),
        // 2.3 添加
        'provider' => bean(\App\Rpc\RpcProvider::class)
    ],
    'prod.pool' => [
        'class'  => ServicePool::class,
        'client' => bean('prod'),
    ]
];
  • HTTP 端修改文件:Swoft\App\bean.php
# 修改一下內容(配置合併)
$rpcbeans = require("rpcbean.php");

$beans =  [];

return array_merge($beans, $rpcbeans );

2.3 客戶端通過 Consul 服務發現調用 RPC;

<?php

namespace App\Rpc;

use App\consul\ServiceHelper;
use Swoft\Bean\Annotation\Mapping\Bean;
use Swoft\Bean\Annotation\Mapping\Inject;
use Swoft\Rpc\Client\Client;
use Swoft\Rpc\Client\Contract\ProviderInterface;

/**
 * Class RpcProvider
 * @Bean()
 */
class RpcProvider implements ProviderInterface {

    /**
     * @Inject()
     *
     * @var ServiceHelper
     */
    private $serviceHelper;

    /**
     * @param Client $client
     *
     * @return array
     *
     * @example
     * [
     *     'host:port',
     *     'host:port',
     *     'host:port',
     * ]
     */
    public function getList(Client $client): array
    {
        $services = $this->serviceHelper->getService("prodservice");    // ["id" => []]
        // 參考 \vendor\swoft\rpc-client\src\Connection.php line 162

        $ret = [];
        foreach ($services as $key=>$value) {
            $ret[$key] = $value["Address"] . ":" . $value["Port"];
        }
        //print_r($ret);
        return $ret;

    }
}

另:通過服務名參數的注入方式,調用 RPC 服務

  • 修改文件:Swoft\App\rpcbean.php
<?php

use App\Rpc\RpcProvider;
use Swoft\Rpc\Client\Client as ServiceClient;
use Swoft\Rpc\Client\Pool as ServicePool;

$settings = [
    'timeout'         => 0.5,
    'connect_timeout' => 1.0,
    'write_timeout'   => 10.0,
    'read_timeout'    => 0.5,
];

return [
    'prodProvider' => [
        'class' => RpcProvider::class,
        'service_name' => 'prodservice',    // 服務名
        'serviceHelper' => bean(\App\consul\ServiceHelper::class)
    ],
    'prod' => [
        'class'   => ServiceClient::class,
        // 2.3 操作時,註釋掉
        // 'host'    => '192.168.60.221',
        // 'port'    => 18307,
        'setting' => $settings,
        'packet'  => bean('rpcClientPacket'),
        // 2.3 添加
        'provider' => bean("prodProvider")
    ],
    'prod.pool' => [
        'class'  => ServicePool::class,
        'client' => bean('prod'),
    ]

];
  • 修改:Swoft\App\Rpc\RpcProvider.php
<?php

namespace App\Rpc;

use App\consul\ServiceHelper;
use Swoft\Bean\Annotation\Mapping\Bean;
use Swoft\Bean\Annotation\Mapping\Inject;
use Swoft\Rpc\Client\Client;
use Swoft\Rpc\Client\Contract\ProviderInterface;

/**
 * Class RpcProvider
 * @Bean()
 */
class RpcProvider implements ProviderInterface {

    protected $service_name;    // 服務名

    /**
     *
     * @var ServiceHelper
     */
    protected $serviceHelper;

    /**
     * @param Client $client
     *
     * @return array
     *
     * @example
     * [
     *     'host:port',
     *     'host:port',
     *     'host:port',
     * ]
     */
    public function getList(Client $client): array
    {
        $services = $this->serviceHelper->getService($this->service_name);    
        // ["id" => []]

        $ret = [];
        foreach ($services as $key=>$value) {
            $ret[$key] = $value["Address"] . ":" . $value["Port"];
        }
        return $ret;

    }
}

2.4 限流功能的使用、令牌桶;

限流功能

  • 官方文檔:https://www.swoft.org/documents/v2/microservice/limit/
  • Swoft 在這部分做的比較人性化,基本上不需要了解算法,就可以直接來配置
  • 目前結構如下
    在這裏插入圖片描述
  • 限流功能不一定全部在程序裏面實現,因爲有時候有很多程序,在每一個程序裏配置限流會很麻煩。部分限流功能可以使用網關來搞定(但是網關的限流的可控性沒有程序強)。
  • 如果項目只使用到反向代理,沒有使用到網關,很可能就沒有限流功能,或者覺得限流功能不滿意,那在程序裏面就需要加入限流。限流在實際開發中是一定要加入的,不管是使用單機還是微服務
  • 限流的主要算法是令牌桶算法:https://www.swoft.org/documents/v2/microservice/limit/#heading3
  • 上圖,用戶和服務端交互的過程時候使用瀏覽器和 HTTP API,不會直接和 RPC 進行交互,所以限流功能可以放在網關,也可以放在程序端 HTTP API(Nginx 僅僅爲反向代理),RPC 可放可不放(因爲 HTTP 限制住了,RPC 也限制住了)
  • 但是有的時候 RPC 對應的 HTTP API 是沒有的,是通過一定的第三方庫來生成出來的 HTTP API 反向代理。假設做項目全部寫在 RPC,讓用戶去訪問就得再開發一個 HTTP API,這個比較麻煩,所以第三方庫就有一個專門的網關,來自動生成 HTTP API 的訪問規則,這時候就要在 RPC 這塊也寫上限流。實際開發時候大多數都在網關設置,也不會在 RPC 裏設置
  • 以下在 HTTP API 寫限流:只要在所需要的方法裏面。因爲實際開發,有些業務是不限流的(有些 URL 獲取靜態信息)。有些秒殺搶購商品,不限流,服務器就崩了。
  • HTTP 端部分修改 Swoft/App/Http/Controller/ProdController.php
<?php
// 導入
use Swoft\Limiter\Annotation\Mapping\RateLimiter;

// 修改方法
/**
 * @RequestMapping(route="list",method={RequestMethod::GET})
 * @RateLimiter(key="request.getUriPath()", rate=1, max=5)
 */
public function prod(Request $request){
    // 接口實現部分在 RPC 端,並不在 HTTP 端

    // 假設這裏有多個 URL(路由),RateLimiter 裏都配置的限流
    // 那這裏會有一個 key 的區分,key 必須是一致的
    // 官方 Swoft 的限流,它不是純內存寫的(在很多第三方庫, JAVA,GO 裏,有純內存代碼完成限流算法)
    // 也有一些使用外部第三方(redis)來完成令牌的輸出
    // 這裏需要配置 Redis,因爲是使用 Redis 來完成 RateLimiter 的

    // name:限流器名稱,默認 swoft:limiter
    // rate:允許多大的請求訪問,請求數:秒
    // max:最大的請求數
    return $this->prodService->getProdList();
}

// 由於沒有加降級方法和異常處理,1 秒內快速刷新 5 次,會報以下錯誤
{
	message: "Rate(App\Http\Controller\ProdController->prod) to Limit!"
}
  • 關於令牌桶的過程:桶就好比一個數組,令牌就是元素(數字),每隔一秒把一個令牌放進桶,放到滿爲止(@RateLimiter 設置的 max 值),如果外面還有令牌,也不能放了,就在外面等着
  • 請求之前會做一個攔截,首先去桶裏面看,如果有令牌,就取出來一個,然後給與訪問。如果沒有令牌,就不能訪問,等待下一個令牌放進去,才能訪問。快速刷新頁面 5 次,會把桶裏的令牌全部的消耗掉,桶就空了,不給訪問了(代表限流了)。每個一秒還會往桶裏放一個,因爲設置了 mate 爲 1(每秒訪問一次)。所以等一段時間就又能訪問了
  • Swoft 是使用異步的方式去完成的,可以在更新令牌的時候效率更高一些。傳統的方式(定時任務,定時協程來持續生成令牌)生成令牌的時候開銷有些大
  • 這種開銷也是在一定場景內的,比如說要對每個用戶做訪問限制,根據用戶的 IP 或者用戶名做訪問限制,不同的用戶訪問的速率不一樣,比如會員制,付費了接口可以一秒調用幾次,沒有付費接口幾秒調用一次,在這種場景下,使用延遲計算,效率會更高一些

模擬非付費用戶限制接口請求

  • 實際請求時 URL 爲 /prod/list?uid=666,付費用戶限流放寬,而免費用戶則一秒只能一次
  • 以上需求,如果在 controller 裏直接加 @RateLimiter 就不方便了
  • 參考:https://www.swoft.org/documents/v2/microservice/limit/#heading9
  • HTTP 端完整修改 Swoft/App/Http/Controller/ProdController.php
<?php

namespace App\Http\Controller;

use App\Http\Lib\ProdLib;
use Swoft\Bean\Annotation\Mapping\Inject;
use Swoft\Http\Message\Request;
use Swoft\Http\Server\Annotation\Mapping\Controller;
use Swoft\Http\Server\Annotation\Mapping\RequestMapping;
use Swoft\Http\Server\Annotation\Mapping\RequestMethod;

/**
 * Class ProdController
 * @Controller(prefix="/prod")
 */
class ProdController{

    /**
     * @Inject()
     * @var ProdLib
     */
    protected $prodLib;

    /**
     * @RequestMapping(route="list",method={RequestMethod::GET})
     * RateLimiter(key="request.getUriPath()", rate=1, max=5)
     */
    public function prod(Request $request){
        // 接口實現部分在 RPC 端,並不在 HTTP 端

        // 2.4.1 限流功能
        // 假設這裏有多個 URL(路由),RateLimiter 裏都配置的限流
        // 那這裏會有一個 key 的區分,key 必須是一致的
        // 官方 Swoft 的限流,它不是純內存寫的(在很多第三方庫, JAVA,GO 裏,有純內存代碼完成限流算法)
        // 也有一些使用外部第三方(redis)來完成令牌的輸出
        // 這裏需要配置 Redis,因爲是使用 Redis 來完成 RateLimiter 的

        // name:限流器名稱,默認 swoft:limiter
        // rate:允許多大的請求訪問,請求數:秒
        // max:最大的請求數

        // 2.4.2 模擬非付費用戶限制接口請求
        // 以上需求,如果在 controller 裏直接加 @RateLimiter 就不方便了
        // 需要傳遞 uid 參數,這樣 @RateLimiter 的 key 就很難寫出表達式
        // 官方的表達式可以在源碼 \vendor\swoft\limiter\src\RateLimiter.php
        // 根據 https://www.swoft.org/documents/v2/microservice/limit/#heading8
        // key 表達式內置 CLASS(類名) 和 METHOD(方法名稱) 兩個變量,方便開發者使用
        // 也就是說在寫 @RateLimiter 的 key 寫表達式的時候,可以直接寫 CLASS,取的就是類名
        // 第二點,源碼裏面通過反射取出當前方法裏面的 params
        // 在 prod() 注入一個 Request 參數,但是也不能隨便注入,否則會報一個空錯誤
        // 除了控制器方法還可以寫在普通方法,可以限制任何 bean 裏面的方法,實現方法限速
        // 效果和在控制器裏面寫一樣,而且更加靈活

        $uid = $request->get("uid", 0);
        if($uid <= 0)
            throw new \Exception("error token");

        return $this->prodLib->getProds($uid);

        // 寫入 ProdLib.php
        //return $this->prodService->getProdList();
    }

}

<?php

namespace App\Http\Lib;

use App\Rpc\Lib\ProdInterface;
use Swoft\Bean\Annotation\Mapping\Bean;
use Swoft\Limiter\Annotation\Mapping\RateLimiter;
use Swoft\Rpc\Client\Annotation\Mapping\Reference;

/**
 * Class ProdLib
 * @package App\Http\Lib
 * @Bean()
 */
class ProdLib {

    /**
     * @Reference(pool="prod.pool")
     *
     * @var ProdInterface
     */
    private $prodService;


    public function getProds(int $uid){
        if($uid > 10) {
            // 假設滿足此條件爲 vip 客戶
            return $this->getProdsByVIP();
        } else {
            // 普通用戶
            return $this->getProdsByNormal();

        }

    }


    public function getProdsByVIP() {
        // VIP 用戶不限流
        return $this->prodService->getProdList();
    }


    /**
     * RateLimiter(key="'ProdLib'~'getProdsByNormal'~'Normal'")
     * @RateLimiter(key="CLASS~METHOD~'Normal'", rate=1, max=5)
     * // 語法參照:http://www.symfonychina.com/doc/current/components/expression_language/syntax.html#catalog11
     */
    public function getProdsByNormal() {
        return $this->prodService->getProdList();
    }

}

2.5 熔斷器基本使用;

  • 在熔斷這一塊,外部也有一些第三方的庫,包括一些開源的網關,可以直接在網關裏面設置,程序裏也可以不設置。但有的時候控制的顆粒度需要細一些,就需要在程序裏面控制。之前的服務限流也是同樣的道理
  • 現在的結構是 HTTP API 來訪問 RPC API,把業務全部現在 RPC 裏面。那麼 HTTP API 專門給用戶通過 AJAX 等等方式來進行請求的,完成用戶的響應以及基本邏輯。常規的做法都是以前的單機做法,直接在 HTTP API 裏面把代碼都寫在 Controller 裏面。現在進入微服務年代,以及一些逼格要求,會把業務封裝到 RPC 裏面,那這時候的結構就相對複雜一些,一旦複雜,就會有一些問題需要解決,比如熔斷,比如限流
  • 熔斷,就是 HTTP API 調用 RPC API 的時候,出現了異常或者超時,那這時候是否要直接報錯(當然報錯也是一種方式)?還有一種方式是進行服務降級,就是在本地(HTTP API)部分去寫死或者寫一個業務邏輯很簡單、很少會出錯的一個方法,返回一個默認的數據,這就是最簡單的一個服務降級。當用戶(瀏覽器)去訪問 HTTP API 的時候,如果反覆報錯,那用戶體驗就不是很美麗,所以要在 HTTP API 部分做服務降級,返回一個其它內容(比如推薦商品)
  • 降級在做微服務這一塊是必須得有的,尤其是有多個服務,服務和服務之間進行調用
  • 儘量寫在業務類裏面,Controller 類裏面儘量寫的純淨一些,限流、熔斷寫在業務類裏面,Controller 最終調用的是業務類方法
    在這裏插入圖片描述

2.5.1 Break 註解、降級函數的使用;

  • 設置一個超時時間,如果 RPC 調用多少秒沒響應,則進行服務降級(不要讓它直接報錯)
  • HTTP 端完整修改 Swoft/App/Http/Controller/ProdController.php
<?php

namespace App\Http\Controller;

use App\Http\Lib\ProdLib;
use Swoft\Bean\Annotation\Mapping\Inject;
use Swoft\Http\Message\Request;
use Swoft\Http\Server\Annotation\Mapping\Controller;
use Swoft\Http\Server\Annotation\Mapping\RequestMapping;
use Swoft\Http\Server\Annotation\Mapping\RequestMethod;

/**
 * Class ProdController
 * @Controller(prefix="/prod")
 */
class ProdController{

    /**
     * @Inject()
     * @var ProdLib
     */
    protected $prodLib;


    /**
     * @RequestMapping(route="list",method={RequestMethod::GET})
     * Breaker 必須打在有 Bean 的類裏面(Controller 就是一個 Bean)
     * Breaker 也沒必要一定要寫在 Controller 裏面,一般都是寫在業務類裏面
     * Controller 類搞的純淨一些,清晰一些,避免寫一些稀奇古怪的方法
     * 就像之前的 RateLimiter() 寫在 ProdLib.php 的最終函數裏
     * Breaker(timeout=2.0,fallback="defaultProds")
     */
    public function prod(Request $request){
        $uid = $request->get("uid", 0);
        if($uid <= 0)
            throw new \Exception("error token");

        return $this->prodLib->getProds($uid);
    }


    public function defaultProds() {
        return [
            'prod_id' => 900, 'prod_name' => '降級內容'
        ];
    }

}
  • 修改 HTTP Swoft/App/Http/Lib/ProdLib.php
<?php

namespace App\Http\Lib;

use App\Rpc\Lib\ProdInterface;
use Swoft\Bean\Annotation\Mapping\Bean;
use Swoft\Breaker\Annotation\Mapping\Breaker;
use Swoft\Limiter\Annotation\Mapping\RateLimiter;
use Swoft\Rpc\Client\Annotation\Mapping\Reference;

/**
 * Class ProdLib
 * @package App\Http\Lib
 * @Bean()
 */
class ProdLib {

    /**
     * @Reference(pool="prod.pool")
     *
     * @var ProdInterface
     */
    private $prodService;

    /**
     * @param int $uid
     * @return mixed
     * @Breaker(timeout=2.0,fallback="defaultProds")
     */
    public function getProds(int $uid){
        if($uid > 10) {
            // 假設滿足此條件爲 vip 客戶
            return $this->getProdsByVIP();
        } else {
            // 普通用戶
            return $this->getProdsByNormal();

        }

    }


    public function getProdsByVIP() {
        // VIP 用戶不限流
        return $this->prodService->getProdList();
    }


    /**
     * RateLimiter(key="'ProdLib'~'getProdsByNormal'~'Normal'")
     * @RateLimiter(key="CLASS~METHOD~'Normal'", rate=1, max=5)
     * // 語法參照:http://www.symfonychina.com/doc/current/components/expression_language/syntax.html#catalog11
     */
    public function getProdsByNormal() {
        return $this->prodService->getProdList();
    }


    public function defaultProds() {
        return [
            'prod_id' => 900, 'prod_name' => '降級內容'
        ];
    }

}
  • 修改 Swoft_rpc/app/Rpc/Service/ProdService.php
<?php

namespace App\Rpc\Service;

use App\Rpc\Lib\ProdInterface;
use Swoft\Rpc\Server\Annotation\Mapping\Service;

/**
 * Class ProdService
 * @package App\Rpc\Service
 * @Service()
 */
class ProdService implements ProdInterface{

    function getProdList()
    {   
        // 延時 3 秒,用來測試降級
        sleep(3);
        return [
            ["prod_id" => 101, "prod_name" => "testprod101"],
            ["prod_id" => 102, "prod_name" => "testprod102"]
        ];
    }
}

2.5.2 Break 熔斷器參數設置、狀態。

  • 熔斷器已經在使用,只不過參數沒有設置到想要的內容,所以看不出熔斷器是否打開或者關閉
  • 在官方文檔有清晰的解釋,到底有哪些狀態,在默認狀態下,熔斷器是關閉的
  • 之前請求,如果超時或者發生異常,會執行降級方法。這很可能每次請求或者點擊都會產生等待。一旦發生超時,很多時候是由於網絡原因、或者網絡不穩定造成的,這時候降級一次沒有關係。如果確實是後臺服務完全掛掉了,那現在每次去請求還是要等待,會造成訪問的性能比較慢和卡
  • 所以熔斷器起到這樣的作用,一旦失敗超時達到一定的次數就打開,一旦打開之後,有一個很大的區別在於,它不會去執行真實的調用方法,而直接去執行降級方法,這是一個基本的概念
  • 如果沒有機會讓熔斷器關閉(後臺服務恢復但是仍然調用降級方法),這也是不對的。需要去設置一定的參數,讓熔斷器去關掉。去設置熔斷器狀態爲半開,就是有一定的機率去請求真實服務的。一旦服務回恢復了,就會把熔斷器關閉,去請求真實服務。如果半開狀態去請求服務,發現還是不行,就又把熔斷器打開了,再次去等待一定時間
<?php
// 參考:https://www.swoft.org/documents/v2/microservice/blown-downgraded/#breaker
// 查看 vendor\swoft\breaker\src\Annotation\Mapping\Breaker
// private $failThreshold = 3; 連續失敗多少次後打開熔斷器
// private $sucThreshold = 3; 連續成功多少次去切換熔斷器狀態
// private $retryTime = 3; 從開啓到半開嘗試切換時間
// 開啓狀態一直請求的是降級方法,一旦進入半開,就會嘗試切換請求真實的服務,一旦成功,把熔斷器關掉
// 關掉,纔是訪問真實的服務

// 修改 Swoft/App/Http/Lib/ProdLib.php
@Breaker(timeout=2.0, fallback="defaultProds", failThreshold=3, sucThreshold=3, retryTime=5)
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章