摘要
本文簡單的介紹了協程的概念及基本原理,以及協程在PHP中的一種實現方案(PECL/Swoole)。最後,結合Opensearch PHP SDK的協程改造過程演示了具體的使用方法。
協程
與進程、線程一樣,協程是邏輯代碼線之間隔離的一種方法。只不過進程和線程是由操作系統直接支持,並負責調度的;協程的粒度比線程更小,操作系統無法感知,因此調度工作必須由程序自己完成。
從目標上來看,協程與epoll等模型基本一致:都是爲了降低進程(線程)調度引發的頻繁上下文切換的資源消耗,最終提高系統效率。使用epoll模型編寫的代碼大量使用回調函數(類似下面的僞代碼):
connect(uri, connected() {
send(data, sent() {
receive(received(response) {
// ...
});
});
})
在實際編寫中,一般不會使用這麼深層次的函數嵌套結構,但是上例從側面描述了異步代碼的編寫困境:效率高,閱讀難。
與epoll模型不同,協程代碼不需要編寫很多回調函數,代碼邏輯看起來和同步代碼一樣:
connect(uri);
send(data);
response = receive();
// ...
協程調度器完成了其中的調度工作:感知掛起,完成調度。
協程的概念提出的很早,只是最近有些編程語言原生支持協程(如:Go)才使得其變得較爲熱門。PHP解釋器對各種C類庫的依賴較爲嚴重,代碼中大量使用同步方法。因此直接在Zend Engine中支持協程困難重重。好在有擴展開發人員編寫了大量的實現代碼,爲我們解決了這個問題。
PECL/Swoole
PECL/Swoole是使用C/C++開發的PHP異步網絡通訊擴展,提供異步非阻塞網絡通訊支持。基於PECL/Swoole擴展,我們可以在PHP非線程安全模式下實現多線程的網絡通訊,提高PHP程序的吞吐能力。
自2.0開始,PECL/Swoole提供了原生的協程支持。開發者可以藉助一整套新編寫的類和方法實現單線程的基於協程的網絡通訊。自4.0開始,PECL/Swoole重寫了協程部分全部的代碼,棄用了(未發佈的3.0版本)基於微信C++協程庫的對於協程的實現方案,自主實現了較爲穩定的協程方案。
下面的代碼展示瞭如何通過PECL/Swoole實現簡單的HTTP客戶端請求(與PECL/Swoole版本無關):
go(function() {
$cli = new \Swoole\Coroutine\Http\Client('127.0.0.1', 9501);
$cli->setHeaders(['Host' => 'localhost']);
$cli->set(['http_proxy_host' => HTTP_PROXY_HOST, 'http_proxy_port' => HTTP_PROXY_PORT]);
$result = $cli->get('/get?json=true');
var_dump($cli->body);
});
代碼中的匿名函數首先通過IP地址和端口號創建了HTTP客戶端對象,然後分別設置了頭信息和代理信息,最後通過GET
方法獲取URI的響應結果並輸出。
示例代碼中的go()
函數是PECL/Swoole協程實現的核心:在其中執行的代碼全部受到協程調度器的管控,並在某個協程操作掛起時自動切換到其他協程待處理的代碼段中。下面的僞代碼展示瞭如何藉助go()
函數同時發出多個請求:
for ($i=0; $i<10; ++$i) {
go(function() use($i) {
$response = request('/region');
echo "#{$i}: " . $response . PHP_EOL;
});
}
由於協程調度器的存在,代碼不會在request()
函數處停留,全部請求幾乎同時發出。這就意味着獲得響應的順序也不會嚴格按照#0, #1, …的順序進行:哪個請求先返回,哪個請求的的echo
語句先被執行。
當然,PECL/Swoole目前只支持其自制的、經過改造的網絡通訊類,其他尚未改造的阻塞函數(或方法)無法被支持。
改造手記
與大部分的PHP編寫的HTTP客戶端程序一樣,Opensearch PHP SDK使用cURL作爲默認的HTTP請求工具。藉助ext/curl,我們可以實現絕大多數的阻塞式的HTTP請求(包括HTTPS請求)。但是對於協程程序來說,這裏就是需要重點改造的地方。
1.改造原有代碼
在OpenSearch\Client\OpenSearchClient
類中,我們找到了前輩們提取出的公用請求方法_curl()
:
private function _curl($url, $items) {
$method = strtoupper($items['method']);
$options = array(
CURLOPT_HTTP_VERSION => 'CURL_HTTP_VERSION_1_1',
CURLOPT_CONNECTTIMEOUT => $this->connectTimeout,
CURLOPT_TIMEOUT => $this->timeout,
CURLOPT_CUSTOMREQUEST => $method,
CURLOPT_HEADER => false,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_USERAGENT => "opensearch/php sdk " . self::SDK_VERSION . "/" . PHP_VERSION,
CURLOPT_HTTPHEADER => $this->_getHeaders($items),
);
if ($method == self::METHOD_GET) {
$query = $this->_buildQuery($items['query_params']);
$url .= preg_match('/\?/i', $url) ? '&' . $query : '?' . $query;
} else{
if(!empty($items['body_json'])){
$options[CURLOPT_POSTFIELDS] = $items['body_json'];
}
}
if ($this->gzip) {
$options[CURLOPT_ENCODING] = 'gzip';
}
if ($this->debug) {
$out = fopen('php://temp','rw');
$options[CURLOPT_VERBOSE] = true;
$options[CURLOPT_STDERR] = $out;
}
$session = curl_init($url);
curl_setopt_array($session, $options);
$response = curl_exec($session);
curl_close($session);
$openSearchResult = new OpenSearchResult();
$openSearchResult->result = $response;
if ($this->debug) {
$openSearchResult->traceInfo = $this->getDebugInfo($out, $items);
}
return $openSearchResult;
}
上述代碼的大致流程是:
- 設置cURL請求參數;
- 請求並獲取響應體;
- 構建並返回
OpenSearch\Generated\Common\OpenSearchResult
對象;
首先,我們需要提供一個可供用戶切換的開關,便於協程開發者從cURL模式切換爲Swoole模式:
/** @var IHttpHandler */
private $httpHandler = null;
public function __construct($accessKey, $secret, $host, $options = array()) {
// ...
$this->httpHandler = new CUrlHttpHandler();
// ...
}
public function setHttpHandler(IHttpHandler $httpHandler)
{
$this->httpHandler = $httpHandler;
}
其次,定義IHttpHandler
接口:
interface IHttpHandler
{
/**
* Performs a HTTP request and returns response body
*
* @return string|false
*/
public function request($url, $items, $connectTimeout, $timeout, $gzip, $debug);
}
接口方法request()
的參數和返回值保持與原_curl()
方法一致,但是追加了一些原來可以通過$this->
獲取到的配置參數。
注:如果深入改造的話,可以考慮將這些$this->
參數移入IHttpHandler
的抽象實現中。
使用該接口改造原_curl()
方法:
private function _curl($url, $items) {
$response = $this->httpHandler->request($url, $items
, $this->connectTimeout, $this->timeout, $this->gzip, $this->debug);
// ...
}
由於原_curl()
方法中包含對OpenSearchClient
類私有方法的調用,考慮建立IHttpHandler
的抽象實現共享這部分方法:
abstract class AbstractHttpHandler implements IHttpHandler
{
// Extract from OpenSearchClient
public function _getHeaders($items) {
// ...
}
// Extract from OpenSearchClient
public function _buildQuery($params) {
// ...
}
}
在改造原_curl()
方法時,原有的代碼就可以拼接出CUrlHttpHandler
:
class CUrlHttpHandler extends AbstractHttpHandler
{
public function request($url, $items, $connectTimeout, $timeout, $gzip, $debug)
{
$method = strtoupper($items['method']);
$options = array(
CURLOPT_HTTP_VERSION => 'CURL_HTTP_VERSION_1_1',
CURLOPT_CONNECTTIMEOUT => $connectTimeout,
CURLOPT_TIMEOUT => $timeout,
CURLOPT_CUSTOMREQUEST => $method,
CURLOPT_HEADER => false,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_USERAGENT => "opensearch/php sdk " . OpenSearchClient::SDK_VERSION . "/" . PHP_VERSION,
CURLOPT_HTTPHEADER => $this->_getHeaders($items),
);
if ($method == OpenSearchClient::METHOD_GET) {
$query = $this->_buildQuery($items['query_params']);
$url .= preg_match('/\?/i', $url) ? '&' . $query : '?' . $query;
} else{
if(!empty($items['body_json'])){
$options[CURLOPT_POSTFIELDS] = $items['body_json'];
}
}
if ($gzip) {
$options[CURLOPT_ENCODING] = 'gzip';
}
if ($debug) {
$out = fopen('php://temp','rw');
$options[CURLOPT_VERBOSE] = true;
$options[CURLOPT_STDERR] = $out;
}
$session = curl_init($url);
curl_setopt_array($session, $options);
$response = curl_exec($session);
curl_close($session);
return $response;
}
}
只是需要有兩點修改:
- 原有的
$this->
對屬性的使用全部變更爲局部變量,如:$this->debug
更換爲$debug
; - 原有的
self::
對常量的使用全部變更爲OpenSearchClient::
;
最後,就是我們本次的重頭戲SwooleHttpHandler
了。
2.新的方法
PECL/Swoole的更新迭代速度飛快,因此其文檔遠遠追不上最新的版本。很多時候,我們只能夠靠分析其源代碼探尋可以使用屬性或者方法。
首先,建立請求類對象:
$host = parse_url($url, PHP_URL_HOST);
$client = new \Swoole\Coroutine\Http\Client($host);
然後,對應cURL配置各種參數:
// ...
// 跳過CURLOPT_HTTP_VERSION(Swoole默認使用HTTP/1.1)
// 跳過CURLOPT_CONNECTTIMEOUT(注意:暫無法設置連接超時時間)
// CURLOPT_TIMEOUT
$client->set(['timeout' => $timeout]);
// CURLOPT_CUSTOMREQUEST
$client->setMethod($method);
// 跳過CURLOPT_HEADER(Swoole默認將響應頭、體分離)
// 跳過CURLOPT_RETURNTRANSFER(Swoole默認返回響應體)
// CURLOPT_USERAGENT
$headers['User-Agent'] = "opensearch/php sdk " . OpenSearchClient::SDK_VERSION . "/" . PHP_VERSION;
// CURLOPT_ENCODING
if ($gzip) {
$headers['Accept-Encoding'] = 'gzip';
}
// CURLOPT_HTTPHEADER
$client->setHeaders($headers); // NAME => VALUE
接下來,根據請求類型存放請求體:
if ($method == OpenSearchClient::METHOD_GET) {
$query = $this->_buildQuery($items['query_params']);
$url .= preg_match('/\?/i', $url) ? '&' . $query : '?' . $query;
} else {
if(!empty($items['body_json'])){
$client->setData($items['body_json']); // Request body
}
}
最後,請求並返回結果:
$result = $client->execute($url); // Boolean
if (!$result) {
return false;
}
return $client->body;
至此,改造完畢。
3.測試使用
注:下面的代碼只是展示了改造後的客戶端類如何使用,並不涉及多請求的並行演示:
go(function() {
$coClient = OpensearchClientBuilder::build();
$coClient->setHttpHandler(new OpenSearch\Client\SwooleHttpHandler()); // 更換請求處理器
$coClient = new OpensearchClientResponseParser($coClient);
$result = $coClient->get('/region');
fprintf(STDOUT, "name=%s" . PHP_EOL, $result['result']['name']);
});
後記
雖然在Opensearch PHP SDK中支持協程並非用戶提出的需求,但是作爲一家技術型公司,爲用戶提供更多的技術選擇可能性也是我們應該提倡、做到的。
本文中提到的PHP協程並非只有PECL/Swoole一種解決方案,PHP開發組也在考慮將協程內置的可能性。然而從功能完整性(即使存在上文中提到無法設置“連接超時時間”等問題)和穩定性上來看,PECL/Swoole無疑是當下最出色的。