【Phan】程式碼靜態掃描

2020-07-16 10:05:39
很多時候,最大的優勢在某些情況下就會變成最大的劣勢。PHP 語法非常靈活,也不用編譯。但是在專案比較複雜的時候,可能會導致一些意想不到的 bug。

背景分析

不知道你的專案是否有遇到過類似的線上故障呢?比如

繼承類語法錯誤導致的故障

檔案1

class Animal
{
    public $hasLeg = false;
}

檔案2

include "Animal.php";
class Dog extends Animal
{
    protected $hasLeg = false;
}
$dog = new Dog();
php Dog.php
Fatal error: Access level to Dog::$hasLeg must be public (as in class Animal) in /Users/mengkang/vagrant-develop/project/untitled1/Dog.php on line 5

09cbff756d0da9edde2a9a9e294f121.png

(注意 IDE 並沒有提示有預發錯誤的喲,我專門截圖)

今天在看程式碼的時候看到一個變數一直重複查詢,就是使用者是否是管理員的身份。我想既然這樣,不然在第一次用的地方就放入到成員變數裡,免得後面都重複查詢。

結果發現我在父類別定義的變數名$isAdmin,之前的程式碼已經在某一個子類裡面單獨定義過了。父類別裡是public屬性,而子類裡是private導致了這個故障。

如果是 java 這種錯誤,無法編譯通過。但是 php 不需要編譯,只要測試沒有覆蓋到剛剛修改的檔案就不會發現這個問題,既是優勢也是弱勢。

引數不符合預期

159fc171249eb29153d0b2426c2d91e.png

有時候a.php,b.php,c.php三個檔案都參照d.php的的一個函數,但是修改了d.php裡面的一個函數的引數個數,如果前面使用的3個檔案裡面的沒有改全,只改了a.php,而測試的時候又沒有覆蓋到b.php和c.php,那麼上線了,就會觸發bug和錯誤了。

錯把陣列當物件

你可能認為這種錯誤太低階了,不可能發生在自己身上,但是根據我的經驗的確會發生,高強度的需求之下,很容易複製貼上一些東西,只複製一半。而且恰巧因為某些邏輯判斷,自己在日常環境開發的時候,出現問題的地方沒有被執行到。

比如下面這段程式碼:

$article = $this->getParam('article');
// 假設下面這段程式碼是複製的
$isPowerEditer = "xxxxx 演示程式碼";
if(!$isPowerEditer){
    if ($article->getUserId() != $uid)
    {
        ...
    }
}

因為複製的來源處,$article是一個物件,所以呼叫了getUserId的方法。但是上面的$article是一個從用戶端獲取的引數,不是物件。

Call to a member function getUserId() on a non-object

而自己測試的時候,因為if(!$isPowerEditer)的判斷導致沒有執行到裡面去。直到上線之後才發現問題。

錯把物件當陣列

ac3ac228a321cf3233e87e9bcaa3947.png

Cannot use object of type DataObjectArticle as array

不禁反思,如果這個專案是 java 的,肯定不會出現上面兩個問題了,因為在專案構建的時候就已經沒法通過了。

不存在的陣列

4b26208a24c34bb8b7d445a1a664134.png

這也不飄紅?多寫了個s呢,可能因為外面包了一個empty所以IDE沒有標記為錯誤吧。所以我們不能太相信IDE。

思考與改進

自造輪子實驗

進一步思考,我們是否能夠做一個工具來自己模擬編譯呢?寫了一個小 demo ,依賴nikic/php-parser

https://github.com/nikic/PHP-Parser

PHP-Parser 可以把PHP程式碼解析為AST,方便我們做語法分析。比如上面的例子

檔案1

class Animal
{
    public $hasLeg = false;
}

檔案2(Dog.php)

include "Animal.php";
class Dog extends Animal
{
    protected $hasLeg = false;
}
$dog = new Dog();

我們利用 PHP-Parser 做了語法解析檢測,程式碼如下:

include dirname(__DIR__)."/vendor/autoload.php";
use PhpParserError;
use PhpParserNodeStmtProperty;
use PhpParserParserFactory;
use PhpParserNodeStmtClass_;
$code = file_get_contents("Dog.php");
$parser = (new ParserFactory)->create(ParserFactory::PREFER_PHP5);
try {
    $ast = $parser->parse($code);
} catch (Error $error) {
    echo "Parse error: {$error->getMessage()}n";
    return;
}
$classCheck = new ClassCheck($ast);
$classCheck->extendsCheck();
class ClassCheck{
    /**
     * @var Class_[]|null
     */
    private $classTable;
    public function __construct($nodes)
    {
        foreach ($nodes as $node){
            if ($node instanceof Class_){
                $name = $node->name;
                if (!isset($this->classTable[$name])) {
                    $this->classTable[$name] = $node;
                }else{
                    // 報錯哪裡類重複了
                    echo $node->getLine();
                }
            }
        }
    }
    public function extendsCheck(){
        foreach ($this->classTable as $node){
            if (!$node->extends){
                continue;
            }
            $parentClassName = $node->extends->getFirst();
            if (!isset($this->classTable[$parentClassName])) {
                exit($parentClassName."不存在");
            }
            $parentNode = $this->classTable[$parentClassName];
            foreach ($node->stmts as $stmt){
                if ($stmt instanceof Property){
                    // 檢視該屬性是否存在於父類別中
                    $this->propertyCheck($stmt,$parentNode);
                }
            }
        }
    }
    /**
     * @param Property $property
     * @param Class_ $parentNode
     */
    private function propertyCheck($property,$parentNode){
        foreach ($parentNode->stmts as $stmt){
            if ($stmt instanceof Property){
                if ($stmt->props[0]->name != $property->props[0]->name){
                    continue;
                }
                if ($stmt->isProtected() && $property->isPrivate()) {
                    echo $stmt->getLine()."n";
                    echo $property->getLine()."n";
                }
            }
        }
    }
}

原理能就是對解析出來的AST繼續做分析,但是前人栽樹後人乘涼,這樣的完整工具已經有大神幫我們做好了。

使用現有工具

https://github.com/phan/phan

可以說它與上面介紹的nikic/php-parser師出同門,依賴nikic/php-astPHP擴充套件

先安裝php-ast擴充套件

大概描述安裝步驟

git clone https://github.com/nikic/php-ast
cd php-ast/
phpize
sudo ./configure --enable-ast
sudo make
sudo make install
cd /etc/php.d
# 引入擴充套件
sudo vim ast.ini
# 就能看到擴充套件啦
php -m | grep ast

安裝 composer

大概描述安裝步驟

curl -sS https://getcomposer.org/installer | php

安裝plan

mkdir test
cd test
~/composer.phar require --dev "phan/phan:1.x"

實驗

實驗1

新建個專案,隨便寫個有問題的程式碼

路徑是src/a.php

<?php
class A extends B
{
    public function a1()
    {
        return $this->a2(1);
    }
    /**
     * @param array $b
     *
     * @return int
     */
    private function a2($b)
    {
        return $b + 1;
    }
}

寫個shell指令碼

#!/bin/bash
function log()
{
    echo -e -n "33[01;35m[YUNQI] 33[01;31m"
    echo [email protected]
    echo -e -n "33[00m"
}
Color_Text()
{
  echo -e " e[0;$2m$1e[0m"
}
Echo_Red()
{
  echo $(Color_Text "$1" "31")
}
Echo_Green()
{
  echo $(Color_Text "$1" "32")
}
Echo_Yellow()
{
  echo $(Color_Text "$1" "33")
}
: > file.list
for file in $(ls src/*)
do
  echo $file >> file.list
done
Echo_Green "file list:n"
Echo_Green "========================n"
cat file.list
Echo_Green "========================n"
Echo_Yellow "Phan runn"
Echo_Yellow "========================n"
./vendor/bin/phan -f file.list -o res.out
Echo_Yellow "========================n"
Echo_Red "error logn"
Echo_Red "========================n"
cat res.out
Echo_Red "========================n"

執行結果

案例中的錯誤

1.類不存在

2.引數型別錯誤

3.語法運算型別推斷

1794fb71fadacc2d91df8da0b124c56.png

實驗2

新增一個src/b.php

<?php
class B{
}

執行結果

能過自動查詢到class B了,不用我們做自動載入規則的指定

d2f7a8f2dca8da87f24775cec9e45b2.png

實驗3

剛剛兩個都是測試的單獨的指令碼,沒有測試專案,其實Plan已經支援了。假如我有一個專案如下

656645af70b2035eb8bf1707986baef.png

我在composer.json裡面指定自動載入規則

{
  "require-dev": {
    "phan/phan": "1.x"
  },
  "autoload": {
    "psr-4": {
      "Mk": "src"
    }
  }
}

然後在專案根目錄執行

./vendor/bin/phan --init --init-level=3

然後就會生成預設的組態檔在.phan目錄裡,最後就可以執行靜態檢測命令了

./vendor/bin/phan --progress-bar

d033fb376300e7d98bdfc33700a3382.png

如圖所示呢,說明根據專案的自動載入規則A,B,C三個類呢都被掃描到了。

以上就是【Phan】程式碼靜態掃描的詳細內容,更多請關注TW511.COM其它相關文章!