PHP Parser 简介和应用 - 为你的代码自动补全单元测试
简介
PHP Parser是由 nikic 开发的一款php抽象语法树(AST)解析工具。PHP Parser同时兼顾接口易用,结构简洁,工具链完善等诸多优点。在工程上,普遍使用PHP Paser生成模板代码,或使用其生成的抽象语法树进行静态分析。
抽象语法树 VS 反射
反射
反射可以在程序运行时动态解析类和方法的结构,基于反射获得的结构,程序可以动态地访问类和类的属性方法,也可以用于创建类实例。相对于抽象语法树,反射取得的结构能够更清晰地反映类结构,因而常用于框架实现路由分发等功能。
抽象语法树
抽象语法树是程序语言源代码经过语法分析和词法分析后获得的解析结构。除反射能够获取到的信息外,还包含了注释、方法与函数的逻辑结构,我们可以认为抽象语法树与源代码是等价的。
PHP Parser详解
功能入口
PhpParser\ParserFactory::create(int $kind, Lexer $lexer = null, array $parserOptions = []): PhpParser\Parser
创建解析器,$kind One of ::PREFER_PHP7, ::PREFER_PHP5, ::ONLY_PHP7 or ::ONLY_PHP5
PhpParser\Parser::parse(string $code, ErrorHandler $errorHandler = null): Node\Stmt[]|null
所有解析器都实现了该方法,传入代码并返回抽象语法树。
PrettyPrinter\Standard::prettyPrintFile($ast): string
用于将抽象语法树转化成代码。
命名空间
PhpParser\Node
包含抽象语法树的所有节点,代码中的变量声明、类引用、逻辑表达式都可以用对应的Node表示。
PhpParser\Node\Stmt
包含表达式节点,如表示namespace的Namespace、class的Class、类方法的ClassMethod等。在后面示例中我会演示如何解析和修改表达式。
PhpParser\Builder
该命名空间下包含生成节点的工厂类,通过getNode方法可以获得对应的节点。
应用示例
一、解析和生成源代码
详见官方示例
二、为代码自动添加测试
需求描述
假设我们在Service层使用了一系列公共静态方法(public static function)提供服务,我们希望确保每一个方法都有单元测试,所以需要查找Service中存在的方法,并生成测试类和测试方法。当然,由于每个Service中包含很多方法,当增加方法时,我们不希望每次手动把旧的测试转移过来,所以需要增量添加测试。
输出结果(新建测试)
<?php
namespace Test\Unit;
use Tests\TestCase;
class ArticleServiceTest extends TestCase
{
public function testGetArticles()
{
}
public function testGetArticle()
{
}
public function testCreateArticle()
{
}
}
输出结果(增量添加)
<?php
namespace Test\Unit;
use Tests\TestCase;
class ArticleServiceTest extends TestCase
{
public function testGetArticles()
{
// 假设这个测试已存在
// 生成的代码将会保留这一段注释
}
public function testGetArticle()
{
}
public function testCreateArticle()
{
}
}
程序代码
use Illuminate\Console\Command;
use PhpParser\Builder\Method;
use PhpParser\Builder\Use_;
use PhpParser\Node;
use PhpParser\Node\Stmt\Class_;
use PhpParser\Node\Stmt\ClassMethod;
use PhpParser\Node\Stmt\Namespace_;
use PhpParser\ParserFactory;
use PhpParser\PrettyPrinter;
use Exception;
/** 略 */
public function handle()
{
// 解析需测试文件
$namespaces = $this->parseFile(app_path("Http/Services/V1/ArticleService.php"));
array_walk($namespaces, function ($namespace) {
[$namespace, $classes] = $namespace;
// 解析文件中的类
foreach ($classes as $class) {
$className = $class->name->name;
$testClassName = "{$className}Test";
$testFilePath = base_path("tests/Unit/{$testClassName}.php");
$classMethodNames = array_filter(array_map(function ($bodyPart) {
if ($bodyPart instanceof ClassMethod && $bodyPart->isPublic() && $bodyPart->isStatic()) {
$methodName = $bodyPart->name->name;
return 'test' . strtoupper($methodName[0]) . substr($methodName, 1);
}
return null;
}, $class->stmts));
if (file_exists($testFilePath)) {
// 读取已添加的测试
[$testNamespace, $testClassMethodNames, $testClass] = $this->getExistsTest($testFilePath);
$todoMethodNames = array_diff($classMethodNames, $testClassMethodNames);
} else {
// 创建空的测试类
$testNamespace = $this->prepareTestFile('Test\Unit');
$todoMethodNames = $classMethodNames;
$testClass = new Class_($testClassName);
$testClass->extends = new Node\Name('TestCase');
}
$testNamespace->stmts = array_filter($testNamespace->stmts, function ($stmt) {
return !($stmt instanceof Class_);
});
$testClass->stmts = array_merge($testClass->stmts, array_map(function ($methodName) {
$method = new Method($methodName);
$method->makePublic();
return $method->getNode();
}, $todoMethodNames));
$testNamespace->stmts[] = $testClass;
$prettyPrinter = new PrettyPrinter\Standard;
echo $prettyPrinter->prettyPrintFile([$testNamespace]);
}
});
}
/**
* 解析已有的测试
*
* @param $testFilePath
* @return array
* @throws Exception
*/
protected function getExistsTest($testFilePath)
{
$testClasses = $this->parseFile($testFilePath);
if (count($testClasses) !== 1) {
throw new Exception('测试文件需有且仅有一个PHP片段');
}
[$testNamespace, $testClasses] = $testClasses[0];
if (count($testClasses) !== 1) {
throw new Exception('测试文件需有且仅有一个类');
}
$testClass = $testClasses[0];
$testClassMethodNames = array_filter(array_map(function ($bodyPart) {
if ($bodyPart instanceof ClassMethod && $bodyPart->isPublic()) {
return $bodyPart->name->name;
}
return null;
}, $testClass->stmts));
return [$testNamespace, $testClassMethodNames, $testClass];
}
protected function prepareTestFile($namespace)
{
$namespace = new \PhpParser\Builder\Namespace_($namespace);
$namespace->addStmt(new Use_('Tests\TestCase', Node\Stmt\Use_::TYPE_NORMAL));
return $namespace->getNode();
}
protected function parseFile($path)
{
$code = file_get_contents($path);
$parser = (new ParserFactory())->create(ParserFactory::PREFER_PHP7);
$ast = $parser->parse($code);
if (!count($ast)) {
throw new Exception('未发现php代码');
}
$namespaces = $this->parsePHPSegments($ast);
return $namespaces;
}
protected function parsePHPSegments($segments)
{
$segments = array_filter($segments, function ($segment) {
return $segment instanceof Namespace_;
});
$segments = array_map(function (Namespace_ $segment) {
return [$segment, $this->parseNamespace($segment)];
}, $segments);
return $segments;
}
protected function parseNamespace(Namespace_ $namespace)
{
$classes = array_values(array_filter($namespace->stmts, function ($class) {
return $class instanceof Class_;
}));
return $classes;
}
结语
感谢nikic的杰作,我们可以通过PHP Paser便捷地解析和修改PHP代码,具备了元编程能力。在此基础上,我们可以实现静态代码分析(SCA)、模板生成代码等工具。使用这些工具开发者可以排查潜在BUG、优化项目代码,减少重复劳动。
PHP Paser算是作者最喜欢的PHP包,喜欢程度甚至高于Laravel这样的全栈框架。本文举了一个简单的自动创建单元测试的例子,除这以外还可以实现更多更好用的功能;第一次写教程,文笔不是很好,算是抛砖引玉了。
本作品采用《CC 协议》,转载必须注明作者和本文链接
推荐文章: