# Anser-Gateway開發守則 ###### tags: `Anser` [TOC] ## Code Style * 命名原則以 **`駝峰`** 命名為主 * 變數命名方式以描述清楚為目標,例如: ```php= class ServiceDiscovery { /** * ServiceDiscovery 設定檔實體 * * @var \Config\ServiceDiscovery */ protected $serviceDiscoveryConfig; /** * GatewayRegister 設定檔實體 * * @var GatewayRegister */ protected $gatewayRegister; /** * 須被訪問的服務 * * @var array */ protected array $defaultServiceGroup = []; /** * 重新搜尋的等待Timer * * @var integer */ public int $reloadTime; ``` 每一變數命名方式皆為變數的職責為何。 * 變數命名後,**`註解須清楚標記該變數型別為何`**,如int則為integer(Line 24~29),若為類別(Line 3~8),則標記類別名稱。 * 方法撰寫原則與上述相似,方法名稱以描述行為為主;若方法具備傳入子,則須明確標記傳入子型別(Line 7),註解部分盡可能撰寫清楚該方法職責為何,並標記`param`和`return` 型別,若無回傳值`return`則回傳void。可特別注意Line 7 結尾的`:void`,此處為標記回傳型別,記得加上,讓PHP編譯檢查。 ```php= /** * 如服務回傳多組service的話則使用該方法 * * @param array $services * @return void */ public function setServices(array $services): void { foreach ($services as $service) { $serviceEntity = $service["Service"]; $serviceTags = $serviceEntity["Tags"]; $serviceName = $serviceEntity["Service"]; $serviceAddress = $serviceEntity["Address"]; $servicePort = $serviceEntity["Port"]; $serviceSchemeIsHttp = false; foreach ($serviceTags as $tag) { if (strpos($tag, 'http_scheme=') === 0) { $serviceScheme = substr($tag, strlen('http_scheme=')); $serviceSchemeIsHttp = strtolower($serviceScheme) == 'http' ? false : true; break; } } $this->localServices[$serviceName][] = [ "name" => $serviceName, "address" => $serviceAddress, "port" => $servicePort, "scheme" => $serviceSchemeIsHttp ]; } } ``` ## AnserGateway 生命週期 ### 角色描述 :::success * Client : 通常指外部使用者或對Anser-Gateway發起請求的人 * GatewayWorker.php : Anser-Gateway運行關鍵,初始化Workerman的Worker,運行Anser-Gateway邏輯 * Anser-Gateway Class : Anser-Gateway處理HTTP Request/Response 的類別,內部將Request進行拆解後導向Routes、Filters、Controllers等元件解析請求。 * Router Class : 路由器,根據傳入的Request URI找尋User從config/Routes.php定義的路由,且呼叫所定義的Controller與Filter * Filter Class : 過濾器,分為before、After Filter,Before作用於Router Class後,Controller Class前,主要作某些不宜實作於Controller層的邏輯,例如登入驗證等;After作用於Controller Class後,適合做一些回傳Response前的驗證。 * Controller Class : 完整闡述業務邏輯之處,如與微服務溝通、呼叫Orchestration等行為。 * Response : 由Controller Class建構時產生(此時為空),Controller Class作用結束後會產生第一階段Response,若Controller Class完成後有綁定After Filter ,則會將Response帶入Filter中進行After Filter實作,最終回傳Response;若未綁定After Filter,則將直接回傳Response。 ::: ![Anser Gateway class diagram](https://hackmd.io/_uploads/rJcaPUR0a.jpg) 圖1. Anser-Gateway的Request/Response處理邏輯 ![Anser Gateway class diagram (1)](https://hackmd.io/_uploads/r1apsIRAT.jpg) 圖2. Anser-Gateway元件圖 ## 關於新類別的開發 ### 設定檔 :::danger 若實作類別不存在開發人員自訂義類別參數,則可略過此段。 ::: 在軟體開發中,某些系統參數是私密或個人化的,故不宜直接被Hard Code於類別或功能模組中,故採環境變數(Environment-Variables, ENV)進行帶入。[關於ENV](https://www.w3schools.in/php/environment-variables) #### Env在Anser-Gateway中的實踐 :::success 主要檔案位置 system/Config/BaseConfig.php system/Config/DotEnv.php ::: ##### DotEnv.php 該檔案主要職責為將我們在.env檔案中定義的環境設定變數存入到memory中,讓PHP呼叫;會在anser啟動時被初始化。 ##### BaseConfig.php 所有涉及環境變數的類別設定皆須繼承該類別,該類別主要職責為讀取類別變數,並將.env檔案中的參數賦值至類別中。 以服務探索為例 `config/ServiceDiscovery.php`,類別於Line 7繼承了BaseConfig,並存在一些變數未被賦值,此時可觀察下面的.env檔案,內部定義皆與`ServiceDiscovery.php`相同,該處須注意的為,在.env中註冊變數時記得以`類別名稱.變數名稱`的方式進行定義,如: `servicediscovery.defaultServiceGroup` ```php= <?php // config/ServiceDiscovery.php namespace Config; use AnserGateway\Config\BaseConfig; class ServiceDiscovery extends BaseConfig { /** * 需要被探索的服務名稱 * * @var array<string> */ public array $defaultServiceGroup = []; /** * Consul Server IP Address and port * * @var string */ public string $address = 'http://localhost:8500'; /** * HTTP Scheme [http or https] * * @var string */ public string $scheme = 'http'; /** * Consul Server DataCenter * * @var string */ public string $dataCenter = ''; /** * 請求間隔 * * @var integer */ public int $reloadTime = 10; /** * 服務負載均衡演算法 * * @var string */ public string $LBStrategy = 'random'; public function __construct() { parent::__construct(); // 因.env傳入為字串,故使用explode作切割 if(getenv('servicediscovery.defaultServiceGroup') !== ''){ $this->defaultServiceGroup = explode(',', getenv('servicediscovery.defaultServiceGroup')); } } } ``` ```env= #.env #-------------------------------------------------------------------- # Service Discovery #-------------------------------------------------------------------- # servicediscovery.defaultServiceGroup = ServiceName1,ServiceName2 # [required] Same name as the service registered on Consul, If you have more than one service, please separate them with a comma(,). # servicediscovery.address = 'localhost:8600' # [required] # servicediscovery.scheme = 'http' # [optional] defaults to "http" # servicediscovery.dataCenter = '' # [optional] # servicediscovery.reloadTime = 300 # [optional] php json encode opt value to use when serializing requests # servicediscovery.LBStrategy = random ``` ### 類別中呼叫外部API 此時建議使用Anser-Action進行操作,若直接使用GuzzleHTTP的話會出現小複雜的效能負擔(這部份往Coroutine和HTTP連線管理去了)。 #### 實際撰寫方式 以`system/ServiceDiscovery/ServiceDiscovery.php`為例,下述程式碼為Anser-Gateway啟動時將自己註冊至Consul中的步驟,此處直接使用Anser-Action進行實現,可有效使用HTTP連線管理。 ```php= /** * 註冊AnserGateway 至 Consul Server * * @param string $httpScheme * @param integer $port * @return bool */ public function registerSelf(string $httpScheme, int $port): bool { $gatewayAddress = sprintf( '%s://%s:%s', $httpScheme, $this->gatewayRegister->address, $port ); $checkRoute = sprintf( '%s/%s', $gatewayAddress, $this->gatewayRegister->healthRoute ); array_push($this->gatewayRegister->tags, "http_scheme={$httpScheme}"); $action = (new Action( $this->consulAddress, "PUT", "v1/agent/service/register" ))->addOption("json", [ "id" => $this->gatewayRegister->id, "name" => $this->gatewayRegister->name, "tags" => $this->gatewayRegister->tags, "address" => $this->gatewayRegister->address, "port" => (int)$port, "check" => [ "name" => $this->gatewayRegister->name, "service_id" => $this->gatewayRegister->id, "http" => $checkRoute, "interval" => $this->gatewayRegister->interval, "timeout" => $this->gatewayRegister->timeout ] ])->doneHandler(function ( ResponseInterface $response, Action $runtimeAction ) { $body = $response->getBody()->getContents(); $data = json_decode($body, true); $runtimeAction->setMeaningData($data); })->failHandler(function ( ActionException $e ) { if($e->isClientError()) { $e->getAction()->setMeaningData([ "code" => $e->getStatusCode(), "msg" => "client error" ]); } elseif ($e->isServerError()) { $e->getAction()->setMeaningData([ "code" => $e->getStatusCode(), "msg" => "server error" ]); } elseif($e->isConnectError()) { $e->getAction()->setMeaningData([ "msg" => $e->getMessage() ]); } }); $data = $action->do()->getMeaningData(); if (isset($data['msg'])) { throw \AnserGateway\ServiceDiscovery\Exception\ServiceDiscoveryException::forAnserGatewayRegisterError($data); } /** * Consul 註冊成功回傳為null */ if(is_null($data)) { return true; } return false; } ``` ### 類別初始化位置 若是元件規模較大或為單例,建議於`system/Worker/GatewayWorker.php`進行初始化。 簡單介紹GatewayWorker的週期,主要有兩個時期 :::success 一、$webWorker->onWorkerStart 這邊簡單來說就是當Worker被建立時,需要做些甚麼,很像JS的callable函式實作方式。 二、$webWorker->onMessage 當Worker接收到外部Request時會動作的部分,此處也可以用JS的callable去理解,當某個按鈕被按到時就觸發。 此處放置較重要的功能模組,如AnserGateway Class ::: * 以Line 30的`$serviceDiscovery`為例,變數屬性為static(靜態)並賦予初始值null(盡量給初始值)。 * 變數初始化周期在於`onWorkerStart`時,參考Line 72~75。 * 若功能需對每一請求進行處理則可放在`onMessage`執行,視功能規模必要性實作於此,若可實作於`runtimeTcpConnection`或`Filter`的話盡量實作在那邊。 ```php= <?php namespace AnserGateway\Worker; use Config\Gateway; use Swow\Coroutine; use Workerman\Timer; use Workerman\Worker; use AnserGateway\Autoloader; use AnserGateway\Worker\Swow; use AnserGateway\AnserGateway; use AnserGateway\Router\Router; use Workerman\Protocols\Http\Request; use Workerman\Protocols\Http\Response; use SDPMlab\Anser\Service\ServiceList; use AnserGateway\Router\RouteCollector; use Workerman\Connection\TcpConnection; use AnserGateway\HTTPConnectionManager; use AnserGateway\Worker\WorkerRegistrar; use AnserGateway\ServiceDiscovery\ServiceDiscovery; class GatewayWorker extends WorkerRegistrar { protected Gateway $gatewayConfig; public static $routeList; public static $router; public static $serviceDiscovery = null; public function __construct() { $this->gatewayConfig = new Gateway(); self::staticSetting(); } public function initWorker(): Worker { $config = $this->gatewayConfig; $webWorker = new Worker( sprintf( '%s://%s:%s', $config->ssl ? 'https' : 'http', '0.0.0.0', $config->listeningPort ), $this->gatewayConfig->ssl ? [ 'ssl' => [ 'local_cert' => $config->sslCertFilePath, 'local_pk' => $config->sslKeyFilePath, 'verify_peer' => $config->sslVerifyPeer, 'allow_self_signed' => $config->sslAllowSelfSigned, ], ] : [] ); $webWorker->name = 'AnserGateway'; $webWorker->reloadable = true; $this->instanceSetting($webWorker); // On start $webWorker->onWorkerStart = static function (Worker $worker) use ($config) { Autoloader::$instance->appRegister(); Autoloader::$instance->composerRegister(); require_once PROJECT_CONFIG . 'Service.php'; //此處開始框架其他部件初始化 \AnserGateway\Worker\GatewayWorker::$routeList = RouteCollector::loadRoutes(); \AnserGateway\Worker\GatewayWorker::$router = new Router(\AnserGateway\Worker\GatewayWorker::$routeList); if ($config->enableServiceDiscovery) { \AnserGateway\Worker\GatewayWorker::$serviceDiscovery = new ServiceDiscovery(); \AnserGateway\Worker\GatewayWorker::$serviceDiscovery->registerSelf($config->ssl ? 'https' : 'http', $config->listeningPort); } ServiceList::setGlobalHandlerStack(HTTPConnectionManager::connectionMiddleware()); HTTPConnectionManager::$hostMaxConnectionNum = 150; HTTPConnectionManager::$waitConnectionTimeout = 200; // Timer包co ,實作服務發現邏輯... if (!is_null(\AnserGateway\Worker\GatewayWorker::$serviceDiscovery)) { // first discovery ServiceList::setServiceDataHandler(\AnserGateway\Worker\GatewayWorker::$serviceDiscovery->serviceDataHandler()); // 預先探索服務 \AnserGateway\Worker\GatewayWorker::$serviceDiscovery->doServiceDiscovery(); Timer::add( \AnserGateway\Worker\GatewayWorker::$serviceDiscovery->reloadTime, static function () { Coroutine::run(static function (): void { \AnserGateway\Worker\GatewayWorker::$serviceDiscovery->doServiceDiscovery(); }); } ); } }; // Worker $webWorker->onMessage = static function (TcpConnection $connection, Request $request) use ($config) { Coroutine::run(static function () use ($connection, $request, $config): void { $config->runtimeTcpConnection($connection, $request); # Injection Router class to AnserGateway $gateway = new AnserGateway(\AnserGateway\Worker\GatewayWorker::$router); try { $workermanResponse = $gateway->handleRequest($request); } catch (\Exception $e) { $workermanResponse = new Response( 500, [ 'Content-Type' => 'application/json charset=utf-8', ], json_encode([ 'code' => 500, 'msg' => $e->getMessage(), 'data' => null ]) ); } $connection->send($workermanResponse); unset($gateway); }); }; return $webWorker; } protected function instanceSetting(Worker &$worker) { $worker->count = $this->gatewayConfig->workerCount; $worker->user = $this->gatewayConfig->workerUser; $this->gatewayConfig->initWorker($worker); } public function staticSetting() { Worker::$eventLoopClass = Swow::class; Worker::$stdoutFile = $this->gatewayConfig->stdoutFile; Worker::$logFile = $this->gatewayConfig->logFile; TcpConnection::$defaultMaxPackageSize = $this->gatewayConfig->defaultMaxPackageSize; TcpConnection::$defaultMaxSendBufferSize = $this->gatewayConfig->defaultMaxSendBufferSize; } } ```