Swoft 原始碼解讀

daydaygo發表於2019-02-16

官網: https://www.swoft.org/

原始碼解讀: http://naotu.baidu.com/file/8…

號外號外, 歡迎大家 star, 我們開發組定了一個 star 1000+ 就線下聚一次的小目標

PHP 裡面的 yii/laravel 框架算是非常「重」的了. 這裡的 先不具體到 效能 層面, 主要是框架的設計思想和框架整合的服務, 讓框架可以既可以快速解決很多問題, 又可以輕鬆擴充套件.

PHP 中的框架, 有 yii/laravel 在, 應該無出其右了.

這次解讀 swoft 的原始碼 — 基於 swoole2.0 原生協程的框架. 同時, swoft 使用了大量 swoole 提供的功能, 也非常適合閱讀它的程式碼, 來學習如何造輪子. 其實解讀過 yii/laravel 這樣的框架後, 一些 通用 的框架設計思想就不贅述了, 主要講解和 伺服器開發 相關的部分, 思路也會按照官網的 feature list 展開.

前半部分聚焦框架常用的功能:

  • 全域性容器注入 & MVC 分層設計
  • 註解機制(亮點, 強烈推薦瞭解一下)
  • 高效能路由
  • 別名機制 $aliases
  • RestFul風格
  • 事件機制
  • 強大的日誌系統
  • 國際化(i18n)
  • 資料庫 ORM

後半部分聚焦伺服器相關的功能:

  • 基礎概念(亮點, 第一個基於 swoole2.0 原生協程的框架)
  • 連線池
  • 服務治理熔斷、降級、負載、註冊與發現
  • 任務投遞 & Crontab 定時任務
  • 使用者自定義程式
  • Inotify 自動 Reload

PHP 框架的設計, 可以參考 [PSR(PHP Standards Recommendations
)](http://www.php-fig.org/psr/).

全域性容器注入 & MVC 分層設計

之所以把這 2 個放一起講, 是因為一個是 , 一個是 . 只是新人聽得比較多的是 MVC 的分層設計思想, 全域性容器注入瞭解相對較少.

  • MVC 分層設計: 更偏向於業務

MVC 是一種簡單通用並且實用的 對業務進行拆分然後加以實現 的設計, 本質還是 分層設計. 更重要的, 還是掌握 分層設計 的思想, 這個在工程實踐中大量的使用到, 比如 OSI 7 層網路模型 和 TCP/IP 4 層網路模型. 我分層設計可以有效的確定 系統邊界和職責劃分.

想要培養分層設計的思想, 其實可以從 入手, 在拆輪子然後拼輪子的過程中, 你會驚奇的發現, 藝術就在其中.

榫卯 app: https://www.douban.com/note/3…

  • 全域性容器注入

在進入這個概念之前, 先要認清另一個概念: 物件導向程式設計. 更常用的可能是 程式導向程式設計 vs 物件導向程式設計. 這裡不會長篇大論, 只就思維方式來進行比較:

  1. 程式導向程式設計: 一條接一條指令的執行, 這是計算機喜歡的方式
  2. 物件導向程式設計: 通過物件來 抽象 裡面不同的事物, 通過事物之間的聯絡, 來解決與之相關的業務.

從這個角度來看, 物件導向 可能是更符合人類的思維方式, 或者說更智慧的思維方式:

上者勞人. 抽象好管理物件, 從而更好的完成任務.

但是使用物件導向程式設計的過程中, 就會出現一個問題: new, 需要管理好物件之間依賴關係, 全域性容器注入就是做這樣一件事. 使用 new, 表明一個物件需要依賴另一個物件, 但是使用容器, 則是一個物件告訴容器它需要什麼物件.

怎麼實現我不管 — 這就是使用 new 和容器注入的區別, 學名叫 控制反轉.

所以, 容器是 , 在處理具體業務時, 由容器按需提供相應的 MVC 物件來處理.

註解進位制

在容器的實現上, 或者說框架的底層上, 其實各個框架都 大同小異. 這裡說一下 swoft 不同的地方 — 引入註解進位制.

簡單解釋一下註解進位制: 通過新增註釋 & 解析註釋, 將註釋轉化為一些特定的有意義的程式碼.

更簡單一點: 註釋 == 程式碼

實現起來其實也很簡單, 只是可能接觸的比較少 — 反射:

// BeanParserInjectParser
class InjectParser extends AbstractParser
{

    /**
     * Inject註解解析
     *
     * @param string $className
     * @param object $objectAnnotation
     * @param string $propertyName
     * @param string $methodName
     *
     * @return array
     */
    public function parser(string $className, $objectAnnotation = null, string $propertyName = "", string $methodName = "", $propertyValue = null)
    {
        $injectValue = $objectAnnotation->getName();
        if (!empty($injectValue)) {
            return [$injectValue, true];
        }

        // phpdoc解析
        $phpReader = new PhpDocReader(); // 將註釋轉化為類
        $property = new ReflectionProperty($className, $propertyName); // 使用反射
        $propertyClass = $phpReader->getPropertyClass($property);

        $isRef = true;
        $injectProperty = $propertyClass;
        return [$injectProperty, $isRef];
    }
}

如果熟悉 java, 會發現裡面有很多地方在方法前用到了 @override, 在 symfony 中也使用到了這樣的方式. 好處是一定程度的內聚, 使用起來更加簡潔, 而且可以減少配置.

高效能路由

首先回答一個問題, 路由是什麼? 從物件的角度出發, 其實路由就對應 URL. 那 URL 是什麼呢?

URL, Uniform Resource Locator, 統一資源定位符.

所以, 路由這一層抽象, 就是為了解決 — 找到 URL 對應需要執行的邏輯.

現在再來解釋一下 swoft 提到的高效能:

// app/routes.php: 路由配置檔案
$router = SwoftApp::getBean(`httpRouter`); // 通過容器拿 httpRouter

// config/beans/base.php: beans 配置檔案
`httpRouter`      => [
    `class`          => SwoftRouterHttpHandlerMapping::class, // httpRouter 其實對應這個
    `ignoreLastSep`  => false,
    `tmpCacheNumber` => 1000,
    `matchAll`       => ``,
],

// SwoftRouterHttpHandlerMapping
private $cacheCounter = 0;
private $staticRoutes = []; // 靜態路由
private $regularRoutes = []; // 動態路由
protected function cacheMatchedParamRoute($path, array $conf){} // 會快取匹配到的路由
// 路由匹配的方法也很簡單: 校驗 -> 處理靜態路由 -> 處理動態路由
public function map($methods, $route, $handler, array $opts = [])
{
    ...
    $methods = static::validateArguments($methods, $handler);
    ...
    if (self::isNoDynamicParam($route)) {
        ...
    }
    ...
    list($first, $conf) = static::parseParamRoute($route, $params, $conf);
}

高效能 = 路由匹配邏輯簡單 + 路由快取

別名機制 $aliases

用過 yii 的對這個就比較熟悉了, 其實是這樣一個 進化過程:

  • 使用 __DIR__ / DIRECTORY_SEPARATOR 等拼接出絕對路徑
  • 使用 define() / defined() 定義全域性變數來使用路徑
  • 使用 $aliases 變數替代全域性變數

這裡只展示一下配置的地方, 實現只是在類中開一個變 $aliases 屬性儲存一下就行了:

// config/define.php
// 基礎根目錄
!defined(`BASE_PATH`) && define(`BASE_PATH`, dirname(__DIR__, 1));
// 註冊別名
$aliases = [
    `@root`       => BASE_PATH,
    `@app`        => `@root/app`,
    `@res`        => `@root/resources`,
    `@runtime`    => `@root/runtime`,
    `@configs`    => `@root/config`,
    `@resources`  => `@root/resources`,
    `@beans`      => `@configs/beans`,
    `@properties` => `@configs/properties`,
    `@commands`   => `@app/Commands`
];
App::setAliases($aliases);

RestFul風格

restful 的思想其實很簡單: 以資源為核心, 業務其實是圍繞資源的增刪改查. 具體到 http 中:

  • url 只作為資源標識, 有 2 種形式, itemitem/id, 後者表示操作具體某個資源
  • http method(get/post/put等)用來對應資源的 CRUD
  • 使用 json 格式進行資料的 輸入輸出

實現起來也很簡單: 路由 + 返回

事件機制

先用 3W1H(who what why how) 分析法的思路來解釋一下 事件機制, 更重要的是, 這個有什麼用.

正常的程式執行, 或者說人的思維趨勢, 都是按照 時間線性序列 的, 保持 連續性. 不過現實中會存在各種 打斷, 程式也不是永遠都是 就緒狀態, 那麼, 就需要有一種機制, 來處理可能出現的各種打斷, 或者在程式不同狀態之間切換.

事件機制發展到現在, 有時候也算是一種預留手段, 根據你的經驗在需要的地方 埋點, 方便之後 打補丁.

swoft 的事件機制基於 PSR-14 實現, 高度內聚簡潔.

由三部分組成:

  • EventManager: 事件管理器
  • Event: 事件
  • EventHandler / Listener: 事件處理器/監聽器

執行流程:

  • 先生成 EventManager
  • 將 Event 和 EventHandler 註冊到 EventManager
  • 觸發 Event, EventManager 就會呼叫相應的 EventHandler

使用起來就更加簡單了:

use SwoftEventEventManager;

$em = new EventManager;

// 註冊事件監聽
$em->attach(`someEvent`, `callback_handler`); // 這裡也可以使用註解機制, 實現事件監聽註冊

// 觸發事件
$em->trigger(`someEvent`, `target`, [`more params`]);

// 也可以
$event = new Event(`someEvent`, [`more params`]);
$em->trigger($event);

來看一下 swoft 在事件機制這裡用來提升效能的亮點:

namespace SwoftEvent;

class ListenerQueue implements IteratorAggregate, Countable
{
    protected $store;

    /**
     * 優先順序佇列
     * @var SplPriorityQueue
     */
    protected $queue;

    /**
     * 計數器
     * 設定最大值為 PHP_INT_MAX == 300
     * @var int
     */
    private $counter = PHP_INT_MAX;

    public function __construct()
    {
        $this->store = new SplObjectStorage(); // Event 物件先新增都這裡
        $this->queue = new SplPriorityQueue(); // 然後加入優先順序佇列, 之後進行排程
    }
    ...
}

稍微玩過 ACM 的人對 優先順序佇列 就不會陌生了, 基本所有 OJ 都有相關的題庫. 不過 PHPer 不用太操心底層實現, 直接藉助 SPL 庫即可.

SPL, Standard PHP Library, 類似 C++ 的 STL, PHPer 一定要了解一下.

強大的日誌系統

使用 monolog/monolog 來實現日誌系統基本已成為標配了, 當然底層還是實現 PSR-3 標準. 不過這個標準出現比較早, 發展到現在, 隱藏得比較深了.

這也是建立技術標準/協議的理由, 劃定好 最佳實踐, 之後的努力都是朝著越來越易用發展.

swoft 的日誌系統, 由 2 部分組成:

  • SwoftLogLogger: 日誌主體功能
  • SwoftLogFileHandler: 輸出日誌

至於另一個檔案, SwoftLogLog, 只是對 Logger 的一層封裝, 呼叫起來更方便而已.

當然, swoft 的日誌系統和 yii2 框架有明顯相似的地方:

// 都在 App 中快讀暴露日誌功能
public static function info($message, array $context = array())
{
    self::getLogger()->info($message, $context); // 其實還是使用 Logger 來處理
}

// 都新增了 profile 功能
public static function profileStart(string $name)
{
    self::getLogger()->profileStart($name);
}
public static function profileEnd($name)
{
    self::getLogger()->profileEnd($name);
}

值得一提的是, yii2 框架的日誌系統由三部分組成:

  • Logger: 日誌主體功能
  • Dispatch: 日誌分發, 可以將同一個日誌分發給不同的 Target 處理
  • Target: 日誌消費者

這樣的設計, 其實是將 FileHandler 的功能進行拆解, 更靈活, 更方便擴充套件.

來看看 swoft 日誌系統強大的一面:

private function aysncWrite(string $logFile, string $messageText)
{
    while (true) {
        // 使用 swoole 非同步檔案 IO
        $result = SwooleAsync::writeFile($logFile, $messageText, null, FILE_APPEND);
        if ($result == true) {
            break;
        }
    }
}

當然, 也可以選擇同步的方式:

private function syncWrite(string $logFile, string $messageText)
{
    $fp = fopen($logFile, `a`);
    if ($fp === false) {
        throw new InvalidArgumentException("Unable to append to log file: {$this->logFile}");
    }
    flock($fp, LOCK_EX); // 注意要加鎖
    fwrite($fp, $messageText);
    flock($fp, LOCK_UN);
    fclose($fp);
}

PS: 日誌統計分析功能開發團隊正在開發中, 歡迎大家推薦方案~

國際化(i18n)

這個功能的實現比較簡單, 不過 i18n 這個詞倒是可以多講一句, 原詞是 internationalization, 不過實在太長了, 所以簡寫為 i18n, 類似的還有 kubernetes -> k8s.

資料庫 ORM

ORM 這個發展很也成熟了, 看清楚下面的進化史就好了:

  • Statement: 直接執行 sql 語句
  • QueryBuild: 使用鏈式呼叫, 來實現拼接 sql 語句
  • ActiveRecord: Model, 用來對映資料庫中的表, 實際還是封裝的 QueryBuild

當然這一層層的封裝好處也很明顯, 減少 sql 的存在感.

// insert
$post = new Post();
$post->title = `daydaygo`;
$post->save();

// query
$post = Post::find(1);

// update
$post->content = `coder at work`;
$post->save();

// delete
$post->del();

要實現這樣的效果, 還是有一定的程式碼量的, 也會遇到一些問題, 比如 程式碼提示, 還有一些更高階的功能, 比如 關聯查詢

基本概念

  • 併發 vs 並行

抓住 並行 這個範圍更小的概念就容易理解了, 並行是要 同時執行, 那麼只能多 cpu 核心同時運算才行; 併發則是因為 cpu執行和切換速度快, 時間段內執行多個程式, 巨集觀上 看起來 像在同時執行

  • 協程 vs 程式

一種簡單的說法 協程是使用者態的執行緒. 執行緒由作業系統進行排程, 可以自動排程到多 cpu 上執行; 同一個時刻同一個 cpu 核心上只有一個協程執行, 當遇到使用者程式碼中的阻塞 IO 時, 底層排程器會進入事件迴圈, 達到 協程由使用者排程 的效果

  • swoole2.0 原生

具體的實現原理大家到官網檢視, 會有更詳細的 wiki 說明, 我這裡從 工具 使用的角度來說明一下

  1. 限制條件一: 需要 swoole2.0 的協程 server + 協程 client 配合
  2. 限制條件二: 在協程 server 的 onRequet, onReceive, onConnect 事件回撥中才能使用
$server = new SwooleHttpServer(`127.0.0.1`, 9501, SWOOLE_BASE);

// 1: 建立一個協程
$server->on(`Request`, function($request, $response) {
    $mysql = new SwooleCoroutineMySQL();
    // 協程 client 有阻塞 IO 操作, 觸發協程排程
    $res = $mysql->connect([
        `host` => `127.0.0.1`,
        `user` => `root`,
        `password` => `root`,
        `database` => `test`,
    ]);
    // 阻塞 IO 事件就緒, 協程恢復執行
    if ($res == false) {
        $response->end("MySQL connect fail!");
        return;
    }
    // 出現阻塞 IO, 繼續協程排程
    $ret = $mysql->query(`show tables`, 2);
    $response->end("swoole response is ok, result=".var_export($ret, true));
});

$server->start();

注意: 觸發一次回撥函式, 就會在開始的時候生成一個協程, 結束的時候銷燬這個協程, 協程的生命週期, 伴隨此處回撥函式執行的生命週期

連線池

swoft 的連線池功能實現, 主要在 src/Pool 下, 主要由三部分組成:

  • Connect: 連線, 值得一提的是, 為了後續使用方便, 這裡同時配置了 同步連線 + 非同步連線
  • Balancer: 負載均衡器, 目前提供 2 種策略, 隨機數 + 輪詢
  • Pool: 連線池, 核心部分, 負責連線的管理和排程

PS: 自由切換同步/非同步客戶端非常簡單, 切換一下連線就好

直接上程式碼:

// 使用 SqlQueue 來管理連線
public function getConnect()
{
    if ($this->queue == null) {
        $this->queue = new SplQueue(); // 又見 Spl
    }

    $connect = null;
    if ($this->currentCounter > $this->maxActive) {
        return null;
    }
    if (!$this->queue->isEmpty()) {
        $connect = $this->queue->shift(); // 有可用連線, 直接取
        return $connect;
    }

    $connect = $this->createConnect();
    if ($connect !== null) {
        $this->currentCounter++;
    }
    return $connect;
}

// 如果接入了服務治理, 將使用排程器
public function getConnectAddress()
{
    $serviceList = $this->getServiceList(); // 從 serviceProvider 那裡獲取到服務列表
    return $this->balancer->select($serviceList);
}

服務治理熔斷、降級、負載、註冊與發現

swoft 的服務治理相關的功能, 主要在 src/Service 下:

  • Packer: 封包器, 和協議進行對應, 看過 swoole 文件的同學, 就能知道協議的作用了
  • ServiceProvider: 服務提供者, 用來對接第三方服務管理方案, 目前已實現 Consul
  • Service: RPC服務呼叫, 包含同步呼叫和協程呼叫(deferCall()), 目前新增 callback 實現簡單的 降級
  • ServiceConnect: 連線池中 Connect 的 RPC Service 實現, 不過個人認為放到連線池中實現更好
  • Circuit: 熔斷, 在 src/Circuit 中實現, 有三種狀態, 關閉/開啟/半開
  • DispatcherService: 服務排程器, 在 Service 之前封裝一層, 新增 Middleware/Event 等功能

這裡看看熔斷這部分的程式碼, 半開狀態的邏輯複雜一些, 值得參考:

// SwoftCircuitCircuitBreaker
public function init()
{
    // 狀態初始化
    $this->circuitState = new CloseState($this);
    $this->halfOpenLock = new swoole_lock(SWOOLE_MUTEX); // 使用 swoole lock
}

// SwoftCircuitHalfOpenState
public function doCall($callback, $params = [], $fallback = null)
{
    // 加鎖
    $lock = $this->circuitBreaker->getHalfOpenLock();
    $lock->lock();
    ...
    // 釋放鎖
    $lock->unlock();
}

任務投遞 & Crontab 定時任務

swoft 任務投遞的實現機制當然離不開 SwooleTimer::tick()(SwooleServer->task() 底層執行機制是一樣的) , swoft 在實現的時候, 新增了 喜聞樂見 的 crontab 方式, 實現在 src/Crontab 下:

  • ParseCrontab: 解析 crontab
  • TableCrontab: 使用 SwooleTable 實現, 用來儲存 crontab 任務
  • Crontab: 連線 Task 和 TableCrontab

這裡主要看一下 TableCrontab:

// 儲存原始的任務
private $originStruct = [
    `rule`       => [SwooleTable::TYPE_STRING, 100],
    `taskClass`  => [SwooleTable::TYPE_STRING, 255],
    `taskMethod` => [SwooleTable::TYPE_STRING, 255],
    `add_time`   => [SwooleTable::TYPE_STRING, 11]
];
// 儲存解析後的任務
private $runTimeStruct = [
    `taskClass`  => [SwooleTable::TYPE_STRING, 255],
    `taskMethod` => [SwooleTable::TYPE_STRING, 255],
    `minte`      => [SwooleTable::TYPE_STRING, 20],
    `sec`        => [SwooleTable::TYPE_STRING, 20],
    `runStatus`  => [SwooleTABLE::TYPE_INT, 4]
];

使用者自定義程式

自定義程式對 SwooleProcess 的封裝, swoft 封裝之後, 想要使用使用者自定義程式更簡單了:

繼承 AbstractProcess 類, 並實現 run() 來執行業務邏輯.

swoft 中功能實現在 src/Process 下, 框架自帶三個自定義程式:

  • Reload: 配合 ext-inotify 擴充套件實現自動 reload, 下面會具體講解
  • CronTimer: crontab 裡的 task 在這裡觸發 SwooleServer->tick()
  • CronExec: 實現協程 task, 實現中.

程式碼就不貼了, 這裡再擴充套件一個比較適合使用自定義程式的場景: 訂閱服務

Inotify 自動 Reload

伺服器程式大都是常駐程式, 有效減少物件的生成和銷燬, 提供效能, 但是這樣也給伺服器程式的開發帶來了問題, 需要 reload 來檢視生效後的程式. 使用 ext-inotify 擴充套件可以解決這個問題.

直接上程式碼, 看看 swoft 中的實現:

// SwoftProcessReloadProcess
public function run(Process $process)
{
    $pname = $this->server->getPname();
    $processName = "$pname reload process";
    $process->name($processName);

    /* @var Inotify $inotify */
    $inotify = App::getBean(`inotify`); // 自定義程式來啟動 inotify
    $inotify->setServer($this->server);
    $inotify->run();
}

// SwoftBaseInotify
public function run()
{

    $inotify = inotify_init(); // 使用 inotify 擴充套件

    // 設定為非阻塞
    stream_set_blocking($inotify, 0);

    $tempFiles = [];
    $iterator = new RecursiveDirectoryIterator($this->watchDir);
    $files = new RecursiveIteratorIterator($iterator);
    foreach ($files as $file) {
        $path = dirname($file);

        // 只監聽目錄
        if (!isset($tempFiles[$path])) {
            $wd = inotify_add_watch($inotify, $path, IN_MODIFY | IN_CREATE | IN_IGNORED | IN_DELETE);
            $tempFiles[$path] = $wd;
            $this->watchFiles[$wd] = $path;
        }
    }

    // swoole Event add
    $this->addSwooleEvent($inotify);
}
private function addSwooleEvent($inotify)
{
    // swoole Event add
    swoole_event_add($inotify, function ($inotify) { // 使用 SwooleEvent
        // 讀取有事件變化的檔案
        $events = inotify_read($inotify);
        if ($events) {
            $this->reloadFiles($inotify, $events); // 監聽到檔案變動進行更新
        }
    }, null, SWOOLE_EVENT_READ);
}

寫在最後

再補充一點, 在實現服務管理(reload stop)時, 使用的 posix_kill(pid, sig);, 並不是用 SwooleServer 中自帶的 reload() 方法, 因為我們當前環境的上下文並不一定在SwooleServer 中.

想要做好一個框架, 尤其是一個開源框架, 實際上要比我們平時寫 業務程式碼 要難很多, 一方面是業務初期的 多快好省, 往往要上一些 能跑 的程式碼. 這裡引入一些關於程式碼的觀點:

  • 程式碼質量: bug 率 + 效能
  • 程式碼規範: 形成規範可以提高程式碼開發/使用的體驗
  • 程式碼複用: 這是軟體工程的難題, 需要慢慢積累, 有些地方可以通過遵循規範走走捷徑

總結起來就一句話:

想要顯著提高編碼水平或者快速積累相關技術知識, 參與開源可以算是一條捷徑.

相關文章