聊一聊 php 程式碼提示

weixin_34291004發表於2017-08-28

title: 聊一聊 php 程式碼提示
date: 2017-8-25 15:05:49

這次我們來聊一聊 php 的程式碼提示, 不使用 IDE 的同學也可以瞧瞧看, PHP IDE 推薦 phpstorm.

phpstorm 使用程式碼提示非常簡單, 只需要將程式碼提示檔案放到專案中就好, 我目前放到 vendor/ 目錄下

起源

  1. 最近開發的專案中, 有使用到 PHP 魔術方法單例模式, 導致了需要程式碼提示的問題
  2. 最近在嘗試用 swoole 寫 tcp server, 有需要用到 swoole IDE helper, swoole wiki首頁就有推薦

資料庫模型

在 laravel 中, 如果有一張資料表 lessons 如下:

CREATE TABLE `lessons` (
  `id` int(10) unsigned NOT NULL AUTO_INCREMENT,
  `title` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL,
  `intro` text COLLATE utf8mb4_unicode_ci NOT NULL,
  `image` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL,
  `published_at` timestamp NOT NULL,
  `created_at` timestamp NULL DEFAULT NULL,
  `updated_at` timestamp NULL DEFAULT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

那麼可以建立一個 Lesson 模型和他對應:

<?php
namespace App;

use Illuminate\Database\Eloquent\Model;

class Lesson extends Model
{
    //
}

之後, 我們就可以直接使用下面的方法了:

$lesson = new Lesson();
$lesson->title = 'far'; // set

$lesson = Lession::find(1);
echo $lesson->title; // get

這樣寫是不是很舒服, 或者說很「優雅」?

而實現起來, 非常簡單, __get() / __set() 就可以了:

// laravel 檔案: Illuminate\Database\Eloquent\Model

/**
 * Dynamically retrieve attributes on the model.
 *
 * @param  string  $key
 * @return mixed
 */
public function __get($key)
{
    return $this->getAttribute($key);
}

/**
 * Dynamically set attributes on the model.
 *
 * @param  string  $key
 * @param  mixed  $value
 * @return void
 */
public function __set($key, $value)
{
    $this->setAttribute($key, $value);
}

在 laravel 中, 這樣的實現方式隨處可見, 比如:

// Illuminate\Http\Request $request

$request->abc; // 獲取 http 請求中的 abc 引數, 無論是 get 請求還是 post 請求
$request->get('abc'); // 和上面等效

好了, 原理清楚了. 寫起來確實「舒服」了, 但是, 程式碼提示呢? 難道要自己去資料庫檢視欄位麼?

在我們的另一個使用 hyperframework框架的專案中, 我們使用了 程式碼自動生成的方法:

// lib/Table/Trade.php 檔案
<?php
namespace App\Table;

class Trade
{

    public function getId() {
        return $this->getColumn('id');
    }

    public function setId($id) {
        $this->setColumn('id', $id);
    }
    ...
}

// lib/Model/Trade.php 檔案
<?php
namespace App\Model;
use App\Table\Trade as Base

class Trade extends BaseTable
{
    ...
}

// 這樣我們就可以愉快的使用下面程式碼了
$trade = new Trade();
$trade->setId(1); // set

$trade = Trade::find(1);
$trade->getId(); // get

上面的 lib/Table/Trade.php 檔案使用一個 php 指令碼, 讀取 mysql 中 information_schema.COLUMNS 的記錄, 然後處理字串生成的. 但是, 缺點也非常明顯:

  • 多了一個指令碼需要維護
  • 欄位修改了, 需要重新生成
  • 程式碼結構中, 多了一層 Table 層, 而這層其實就只幹了 get / set

雖然有了程式碼提示了, 這樣做真的好麼? 那好, 我們來按照上面的套路改造一下:

// lib/Models/BaseModel.php
<?php
namespace App\Models;

use Hyperframework\Db\DbActiveRecord;


class BaseModel extends DbActiveRecord
{
    // 獲取 model 對應的資料庫 table 名
    public static function getTableName()
    {
        // 反射, 這個後面會講到
        $class = new \ReflectionClass(static::class);
        return strtolower(preg_replace('/((?<=[a-z])(?=[A-Z]))/', '_', $class->getShortName()));
    }

    public function __get($key) {
        return $this->getColumn($key);
    }

    public function __set($key, $value) {
        $this->setColumn($key, $value);
    }
}

// lib/Models/User.php
<?php
namespace App\Models;

class User extends BaseModel
{
    ...
}

好了, 問題又來了, 程式碼提示怎麼辦? 這樣常見的問題, 當然有成熟的解決方案:

laravel-ide-helper: laravel package, 用來生成 ide helper

上面 Lesson model 的問題, 就可以這樣解決了, 只要執行 php artisan ide-helper:models, 就會幫我們生成這樣的檔案:

<?php
namespace App{
/**
 * App\Lesson
 *
 * @property int $id
 * @property string $title
 * @property string $intro
 * @property string $image
 * @property string $published_at
 * @property \Carbon\Carbon|null $created_at
 * @property \Carbon\Carbon|null $updated_at
 * @method static \Illuminate\Database\Eloquent\Builder|\App\Lesson whereCreatedAt($value)
 * @method static \Illuminate\Database\Eloquent\Builder|\App\Lesson whereId($value)
 * @method static \Illuminate\Database\Eloquent\Builder|\App\Lesson whereImage($value)
 * @method static \Illuminate\Database\Eloquent\Builder|\App\Lesson whereIntro($value)
 * @method static \Illuminate\Database\Eloquent\Builder|\App\Lesson wherePublishedAt($value)
 * @method static \Illuminate\Database\Eloquent\Builder|\App\Lesson whereTitle($value)
 * @method static \Illuminate\Database\Eloquent\Builder|\App\Lesson whereUpdatedAt($value)
 * @mixin \Eloquent
 */
    class Lesson extends \Eloquent {}
}

通過註釋, 我們的程式碼提示, 又回來了!

Facade 設計模式 / 單例設計模式

瞭解 laravel 的話, 對 Facede 一定不陌生, 不熟悉的同學, 可以通過這篇部落格 設計模式(九)外觀模式Facade(結構型) 瞭解一下.

現在來看看, 如果我們需要使用 redis, 在 laravel 中, 我們可以這樣寫:

Redis::get('foo');
Redis::set('foo', 'bar');

底層依舊是通過 ext-redis 擴充套件來實現, 而實際上, 我們使用 ext-redis, 需要這樣寫:

$cache = new \Redis();
$cache->connect('127.0.0.1', '6379');
$cache->auth('woshimima');

$redis->get('foo');
$redis->set('foo', 'bar');

2 個明顯的區別: 1. new 不見了(有時候會不會感覺 new 很煩人); 2. 一個是靜態方法, 一個是普通方法

如果稍微瞭解一點設計模式, 單例模式 肯定聽過了, 因為使用場景實在是太普遍了, 比如 db 連線, 而且實現也非常簡單:

// 簡單實現
class User {
    private static $_instance = null; // 靜態變數儲存全域性例項

    // 私有建構函式,防止外界例項化物件
    private function __construct() {}

    // 私有克隆函式,防止外辦克隆物件
    private function __clone() {}

    //靜態方法,單例統一訪問入口
    public static function getInstance() {
        if (is_null ( self::$_instance ) || isset ( self::$_instance )) {
            self::$_instance = new self ();
        }
        return self::$_instance;
    }
}

// 使用
$user = User::getInstance();

好了, 關於 new 的問題解決了. 接下來再看看靜態方法. 在我們的另一個使用 hyperframework框架的專案中, 我們也實現了自己的 Redis service 類:

// lib/Services/Redis.php 檔案
<?php
namespace App\Services;

use Hyperframework\Common\Config;
use Hyperframework\Common\Registry;

class Redis
{
    /**
     * 將 redis 註冊到 Hyperframework 的容器中
     * 容器這個概念先留個坑, 下次講 laravel 核心的時候, 再一起好好講講
     * 這裡只要簡單理解我們已經實現了 redis 的單例模式就好了
     */
    public static function getEngine()
    {
        return Registry::get('services.redis', function () {
            $redis = new \Redis();
            $redis->connect(
                Config::getString('redis.host'),
                Config::getString('redis.port'),
                Config::getString('redis.expire')
            );
            $redisPwd = Config::getString('redis.pwd');
            if ($redisPwd !== null) {
                $redis->auth($redisPwd);
            }
            return $redis;
        });
    }

    // 重點來了
    public static function __callStatic($name, $arguments)
    {
        return static::getEngine()->$name(...$arguments);
    }

    // k-v
    public static function get($key)
    {
        return static::getEngine()->get($key);
    }
}

拍黑板劃重點: __callStatic(), 就是這個魔術方法了. 另外再看看 ...$arguments, 知識點!

仔細看的話, 我們下面按照 ext-redis 中的方法, 再次實現了一次 $redis->get() 方法, 有 2 點理由:

  • 魔術方法會有一定效能損失
  • 我們又有程式碼提示可以用了, 只是要用啥, 就要自己把 ext-redis 裡的方法封裝一次

好了, 來看看我們的老朋友, laravel 是怎麼實現的吧:

  • laravel: Illuminate\Support\Facades\Facade
// 獲取 service 的單例
protected static function resolveFacadeInstance($name)
{
    if (is_object($name)) {
        return $name;
    }

    if (isset(static::$resolvedInstance[$name])) {
        return static::$resolvedInstance[$name];
    }

    return static::$resolvedInstance[$name] = static::$app[$name];
}

// 魔術方法實現靜態函式呼叫
public static function __callStatic($method, $args)
{
    $instance = static::getFacadeRoot();

    if (! $instance) {
        throw new RuntimeException('A facade root has not been set.');
    }

    return $instance->$method(...$args);
}

然後, 使用上面的 package, 執行 php artisan ide-helper:generate, 就可以得到程式碼提示了:

namespace Illuminate\Support\Facades {
    ...

    class Redirect {
        /**
         * Create a new redirect response to the "home" route.
         *
         * @param int $status
         * @return \Illuminate\Http\RedirectResponse
         * @static
         */
        public static function home($status = 302)
        {
            return \Illuminate\Routing\Redirector::home($status);
        }

        /**
         * Create a new redirect response to the previous location.
         *
         * @param int $status
         * @param array $headers
         * @param mixed $fallback
         * @return \Illuminate\Http\RedirectResponse
         * @static
         */
        public static function back($status = 302, $headers = array(), $fallback = false)
        {
            return \Illuminate\Routing\Redirector::back($status, $headers, $fallback);
        }
        ...
    }
    ...
}

通過反射實現 swoole 程式碼提示

通過反射實現 swoole 程式碼提示來自此專案 flyhope/php-reflection-code, 核心程式碼其實很簡單, 如下

static public function showDoc($class_name) {

    try {
        // 初始化反射例項
        $reflection = new ReflectionClass($class_name);
    } catch(ReflectionException $e) {
        return false;
    }

    // 之後都是字串處理之類的工作了

    // Class 定義
    $doc_title = ucfirst($class_name) . " Document";
    $result = self::showTitle($doc_title);

    $result .= self::showClass($class_name, $reflection) . " {\n\n";

    // 輸出常量
    foreach ($reflection->getConstants() as $key => $value) {
        $result .= "const {$key} = " . var_export($value, true) . ";\n";
    }

    // 輸出屬性
    foreach ($reflection->getProperties() as $propertie) {
        $result .= self::showPropertie($propertie) . "\n";
    }

    //輸出方法
    $result .= "\n";
    foreach($reflection->getmethods() as $value) {
        $result .= self::showMethod($value) . "\n";
    }

    // 檔案結尾
    $result .= "}\n";
    return $result;
}

再回到上面我們使用反射的例子:


// 獲取 model 對應的資料庫 table 名
public static function getTableName()
{
    // 反射, 這個後面會講到
    $class = new \ReflectionClass(static::class);
    return strtolower(preg_replace('/((?<=[a-z])(?=[A-Z]))/', '_', $class->getShortName()));
}

注意, 這裡要使用 static, 如果你使用 self 得到的就是 BaseModel 了. 至於一個簡單的理解 static & self 的方式: static 是指當前記憶體中執行的例項, 所以永遠都是 所見即所得.

魔術方法的效能損失

本來我也想做一下 profile 的, 還折騰起了 xhprof 和 xdebug, 但是其實可以簡單的測試:

$start = microtime();
dosomething();
echo microtime() - $start; // 單位: 微秒

感謝這位仁兄做的測試 PHP 魔術方法效能測試, 實測結果下來效能損失在 10us 內, 這個數量級, 我個人認為除非少數極端要求效能的場景, 完全是可以接受的.

最後, 補充一下 單例模式 的優缺點:

優點:

  1. 改進系統的設計
  2. 是對全域性變數的一種改進

缺點:

  1. 難於除錯
  2. 隱藏的依賴關係
  3. 無法用錯誤型別的資料覆寫一個單例

相關文章