最大努力通知型
1、Rabbitmq實現延遲隊列
2、使用swoft自帶服務組件
3、將最大努力通知服務放入到swoft
柔性事務解決方案:最大努力通知(定期校對)
實現
業務活動的主動方,在完成業務處理之後,向業務活動的被動方發送消息,被動方需主動響應正確消息,否則根據定時策略,最大努力通知。
業務活動的被動方也可以向業務活動主動方查詢,恢復丟失的業務消息。
約束
被動方的處理結果不影響主動方的處理結果
成本
業務查詢與校對系統的建設成本
適用範圍
對業務最終一致性的時間敏感度低
跨企業的業務活動
方案特點
業務活動的主動方在完成業務處理後,向業務活動被動方發送通知消息(允許消息丟失)
主動方可以設置時間階梯型通知規則,在通知失敗後按規則重複通知,直到通知N次後不主動方提供校對查詢接口給被動方按需校對查詢,用於恢復丟失的業務消息
應用案例
銀行通知、商戶通知等(各大交易業務平臺間的商戶通知:多次通知、查詢校對、對賬文件)
Rabbitmq延遲隊列實現原理:
由於rabbitmq本身並沒有提供延遲隊列的功能,所以需要藉助
1、rabbitmq 可以針對 Queue和Message 設置 x-message-ttl 來控制消息的生存時間,如果超時,消息變爲 dead letter(死信)
2、rabbitmq 的queue 可以配置 x-dead-letter-exchange 和 x-dead-letter-routing(可選)
兩個參數,來控制隊列出現 dead letter 的時候,重新發送消息的目的地
比如:假設設置一條消息存活時間爲10秒鐘,那麼10秒鐘之後,如果沒有消費者消費,這條消息就會變爲dead letter(死信),就會把該消息轉發到指定交換機跟隊列當中,然後被消費
在隊列上指定一個Exchange,則在該隊列上發生如下情況,
1.消息被拒絕(basic.reject or basic.nack),且requeue=false
2.消息過期而被刪除(TTL)
3.消息數量超過隊列最大限制而被刪除
4.消息總大小超過隊列最大限制而被刪除
就會把該消息轉發到指定的一個exchange
同時也可以指定一個可選的x-dead-letter-routing-key,表示默認的routing-key,如果沒有指定,則使用消息的routeing-key(也跟指定的exchange有關,
如果是Fanout類型的exchange,則會轉發到所有綁定到該exchange的所有隊列)
代碼體現
在網關rpc控制器中引入notify連接池
/**
* @\Swoft\Rpc\Client\Annotation\Mapping\Reference(pool="notify.pool")
*
* @var NotifyInterface
*/
看看bean連接池中的設置,其中的’provider’=> bean(\App\Test\RpcProvider::class)是服務地址提供類,提供由consul服務得到的健康地址
'notify' => [
'class' => \Swoft\Rpc\Client\Client::class,
'serviceName' => 'notify',
'version' => '1.0',
'setting' => [
'timeout' => 0.5,
'connect_timeout' => 1.0,
'write_timeout' => 10.0,
'read_timeout' => 2,
],
'packet' => bean('rpcClientPacket'),
'provider'=> bean(\App\Test\RpcProvider::class) //服務地址提供類
],
'notify.pool' => [
'class' => \Swoft\Rpc\Client\Pool::class,
'client' => bean('notify'),
'minActive' => 50,
'maxActive' => 500,
'maxWait' => 500,
'maxWaitTime' => 10,
'maxIdleTime' => 40,
'time'=>5
],
現在看看\App\Test\RpcProvider::class這個類的代碼
/**
* Class RpcProvider
*
* @since 2.0
*
* @Bean()
*/
class RpcProvider implements ProviderInterface
{
/**
* @Inject()
*
* @var Agent
*/
private $agent;
/**
* @param Client $client
*
* @return array
* @throws ReflectionException
* @throws ContainerException
* @throws ClientException
* @throws ServerException
* @example
* [
* 'host:port',
* 'host:port',
* 'host:port',
* ]
*/
public function getList(Client $client): array
{
// Get health service from consul
//$services = $this->agent->services();
//單個服務獲取自己的地址
$services = [
'106.52.210.201:9804'
];
return $services;
}
}
現在來看getList裏面的serveice方法,是從consul裏面獲取所有的健康地址,這裏可以調用負載均衡的類來調用某一個健康地址,這裏我省略了,直接返回一個可用的地址
public function services(): Response
{
return $this->consul->get('/v1/agent/services');
}
那consul裏面的地址是怎麼來的,首先得註冊,看看這個consul的配置
<?php
return [
'consul' => [
'address' => '127.0.0.1',
'port' => 8500,
'register' => [
'ID' =>'payxxx',
'Name' =>'payxxx',
'Tags' =>['primary'],
'Address' =>'106.52.210.201',
'Port' =>9805,
'Check' => [
'tcp' => '106.52.210.201:9801',
'interval' => '10s',
'timeout' => '2s',
],
'Weights'=>[
'passing'=>5,
'warning'=>1
]
],
'discovery' => [
'dc' => 'dc1',
'passing'=>true,
'tag'=>'primary'
]
],
];
然後通過這個類把地址註冊到consul服務中
class ConsulProvider {
const REGISTER_PATH = '/v1/agent/service/register';
const DISCOVERY_PATH = '/v1/health/service/';
const KV_PATH='/v1/kv';
private $address = "http://127.0.0.1";
private $port = 8500;
private $registerId = '';
private $registerName = 'user';
private $registerTags = [];
private $registerEnableTagOverride = false;
private $registerAddress = 'http://127.0.0.1';
private $registerPort = 88;
private $registerCheckId = '';
private $registerCheckName = 'user';
private $registerCheckTcp = '127.0.0.1:8099';
private $registerCheckInterval = 10;
private $registerCheckTimeout = 1;
private $discoveryDc = "";
private $discoveryNear = "";
private $discoveryTag = "";
private $discoveryPassing = true;
public function getServiceList(string $serviceName, $config)
{
$query = [
'passing' =>$config['discovery']['passing'],
'dc' =>$config['discovery']['dc']
];
if (!empty($config['discovery']['tag'])) {
$query['tag'] =$config['discovery']['tag'];
}
$queryStr = http_build_query($query);
$path = sprintf('%s%s', self::DISCOVERY_PATH, $serviceName);
$result=$this->Curl_request('http://'.$config['address'].':'.$config['port'].$path."?".$queryStr,'GET');
$services = json_decode($result, true);
$address=[];
foreach ($services as $key=>$v){
foreach ($v['Checks'] as $k=>$c){
//判斷是否是活躍的,並且名稱是想要查詢的服務
if($c['ServiceName']==$serviceName && $c['Status']=='passing'){
$address[$key]['address']=$v['Service']['Address'].':'.$v['Service']['Port'];
$address[$key]['Weight']=$v['Service']['Weights']['Passing'];
}
}
}
return $address;
}
/**
* register service
*
* @param array ...$params
*
* @return bool
*/
public function registerService(...$params)
{
//$request=new HttpRequest();
//$request->put('http://'.$params[0]['address'].':'.$params[0]['port'].self::REGISTER_PATH,json_encode($params[0]['register']));
$result=$this->Curl_request('http://'.$params[0]['address'].':'.$params[0]['port'].self::REGISTER_PATH,'PUT',json_encode($params[0]['register']));
output()->writeln(sprintf('<success>RPC service register success by consul ! tcp=%s:%d</success>', $params[0]['address'],$params[0]['port']));
}
/**
* @param string $serviceName
* @return string
*/
private function getDiscoveryUrl(string $serviceName): string
{
$query = [
'passing' => $this->discoveryPassing,
'dc' => $this->discoveryDc,
'near' => $this->discoveryNear,
];
if (!empty($this->discoveryTag)) {
$query['tag'] = $this->discoveryTag;
}
$queryStr = http_build_query($query);
$path = sprintf('%s%s', self::DISCOVERY_PATH, $serviceName);
return sprintf('%s:%d%s?%s', $this->address, $this->port, $path, $queryStr);
}
public function Curl_request($url, $method = 'POST', $data = [])
{
$method = strtoupper($method);
//初始化
$ch = curl_init();
//設置請求地址
curl_setopt($ch, CURLOPT_URL, $url);
// 檢查ssl證書
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
// 從檢查本地證書檢查是否ssl加密
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, $url);
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method);
//curl_setopt($ch, CURLOPT_HTTPHEADER, "Content-type:application/json;charset=utf-8", "Accept:application/json");
//設置請求數據
if (!empty($data)) {
curl_setopt($ch, CURLOPT_POSTFIELDS, $data);
}
curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
$res = curl_exec($ch);
curl_close($ch);
return $res;
}
public function putKV($data,$config){
$result=$this->Curl_request('http://'.$config[0]['address'].':'.$config[0]['port'].self::KV_PATH,'PUT',$data);
var_dump($result);
}
}
有了可用的地址,就可以調用相關的服務了,業務活動的主動方,在完成業務處理之後,通過rpc調用notifyServeice微服務向業務活動的被動方發送消息
public function order()
{
//預發送消息(消息狀態子系統)
$msg_id=session_create_id(md5(uniqid()));
$data=[
'msg_id'=>uniqid(),
'version'=>1,
'create_time'=>time(),
'message_body'=>['order_id'=>12133,'shop_id'=>2],
'notify_url'=>'http://127.0.0.1:9804/notify/index',//通知地址
'notify_rule'=>[1=>5,2=>10,3=>15],//單位爲秒
'notify_retries_number'=>0, //重試次數,
'default_delay_time'=>1,//毫秒爲單位
'status'=>1, //消息狀態
];
var_dump($this->notifyService->publish($data));
return ['1'];
}
發佈消息的publish方法,首先設置參數控制(延遲時間),一個緩存隊列和一個延遲隊列,連接rabbitmq,當消息週期達到時,把消息轉移到延遲(死信)隊列中,接下來是緩存隊列的聲明以及綁定隊列名字和交換機,然後是延遲(死信)隊列聲明以及綁定隊列名字和交換機,發佈消息到交換機當中,並且綁定好路由關係
public function publish($data): array
{
$delayTime = $data['default_delay_time']; //參數控制(延遲時間)
//緩存的一組交換機及隊列
$cacheExchangeName = 'cacheExchange_test1_' . $delayTime;
$cacheQueueName = 'cache_delay_test1_' . $delayTime;
//延遲隊列
$delayExchangeName = 'delayExchange';
$delayRouteKey = '/delay';
$delayQueueName = 'delay';
$connection = $this->rabbit->connect();
$connectionRabbit = $connection->connection;
$channel = $connectionRabbit->channel(); //建立通道
//是希望消息的存活週期達到時,把消息轉移到死信隊列當中
$table = new \PhpAmqpLib\Wire\AMQPTable();
$table->set('x-dead-letter-exchange', $delayExchangeName); //消息死亡之後轉移到的交換機
$table->set('x-dead-letter-routing-key', $delayRouteKey); //消息死亡之後綁定的路由key
$table->set('x-message-ttl', $delayTime); //設置消息存活時間
//緩存隊列
$channel->exchange_declare($cacheExchangeName, \PhpAmqpLib\Exchange\AMQPExchangeType::DIRECT, false, true, false);
//注意參數位置及如果之前已經生成了隊列,不能覆蓋修改
$channel->queue_declare($cacheQueueName, false, true, false, false, false, $table);
$channel->queue_bind($cacheQueueName, $cacheExchangeName, '');
//死信(延遲)
$channel->exchange_declare($delayExchangeName, \PhpAmqpLib\Exchange\AMQPExchangeType::DIRECT, false, true, false);
$channel->queue_declare($delayQueueName, false, true, false, false);
$channel->queue_bind($delayQueueName, $delayExchangeName, $delayRouteKey);
$msg = new \PhpAmqpLib\Message\AMQPMessage(json_encode($data), ['delivery_mode' => \PhpAmqpLib\Message\AMQPMessage:: DELIVERY_MODE_PERSISTENT]);
//發佈消息到交換機當中,並且綁定好路由關係
if($channel->basic_publish($msg,$cacheExchangeName,'')){
$res=["status" => 1, "result" => '發佈通知'];
}else{
$res=["status" => 1, "result" => '發佈通知'];
}
$channel->close();
$connection->release(true);
return $res;
}
發佈消息到交換機以後,設置一個監聽事件,定時器去消費延遲隊列,首先獲取延遲任務,連接rabbitmq,聲明並綁定交換機和隊列名稱,調用basic_consume,給最大通知次數+1,如果通知次數超過3次,則返回通知失敗次數過多,把消息存入redis,然後把這個消息從隊列中取出來標記已消費,也就是迴應ack,如果沒有到達最大通知次數,通過協程建立一個http連接,通知(‘106.52.210.201’, 9515)這個地址的商戶,如果返回成功,則顯示通知成功,如果失敗,則修改延遲時間,重新發布消息,繼續投遞延遲任務,最後不管成功失敗,都要回應ack,表示這條消息已經被消費
public function handle(EventInterface $event): void
{
//延遲幾秒,註冊自身才能調用
swoole_timer_tick(2000, function () {
sgo(function () {
try {
//自動初始化一個Context上下文對象(協程環境下)
$context = ServiceContext::new();
Context::set($context);
//獲取延遲任務消費
$delayExchangeName = 'delayExchange';
$delayRouteKey = '/delay';
$delayQueueName = 'delay';
$connection = $this->rabbit->connect();
$connectionRabbit = $connection->connection;
$channel = $connectionRabbit->channel(); //建立通道
$channel->exchange_declare($delayExchangeName, \PhpAmqpLib\Exchange\AMQPExchangeType::DIRECT, false, true, false);
$channel->queue_declare($delayQueueName, false, true, false, false);
$channel->queue_bind($delayQueueName, $delayExchangeName, $delayRouteKey);
//消費者
$channel->basic_consume($delayQueueName, '', false, false, false, false, function ($message) {
$data = json_decode($message->body, true);
var_dump($data);
//按照階梯投遞
$data['notify_retries_number'] += 1;
if ($data['notify_retries_number'] >= 3) {
//寫入到redis當中
var_dump("失敗次數過多");
$message->delivery_info['channel']->basic_ack($message->delivery_info['delivery_tag']);
return;
}
//執行業務邏輯(通知商戶),根據url地址通知商戶
$cli = new \Swoole\Coroutine\Http\Client('106.52.210.201', 9515);
$cli->set([ 'timeout' => 1]);
$cli->post($data['notify_url'],$data);
if('success'==$cli->body){
var_dump("通知成功");
}else{
//繼續投遞延遲任務
$data['default_delay_time']=$data['notify_rule'][$data['notify_retries_number']]*1000;
$this->notifyService->publish($data);
var_dump("通知失敗繼續投遞");
}
//迴應ack
$message->delivery_info['channel']->basic_ack($message->delivery_info['delivery_tag']);
});
// Loop as long as the channel has callbacks registered
while ($channel->is_consuming()) {
$channel->wait(); //阻塞消費
}
/**
* @param \PhpAmqpLib\Channel\AMQPChannel $channel
* @param \PhpAmqpLib\Connection\AbstractConnection $connection
*/
function shutdown($channel, $connection)
{
$channel->close();
$connection->close();
}
register_shutdown_function('shutdown', $channel, $connection);
} catch (\Exception $e) {
var_dump($e->getMessage());
}
});
});
}
然後看看商戶迴應方法,測試用
/**
* @RequestMapping("index")
* @throws Throwable
*/
public function index(): Response
{
if(mt_rand(1,2)==1){
$content='success';
}else{
$content='fail';
}
return Context::mustGet()
->getResponse()
->withContentType(ContentType::HTML)
->withContent($content);
}