1 背景
1.1 命名空间解决什么问题
提到命名空间,想必PHP开发的同学,都不陌生吧,PHP命名空间可以解决以下两类问题:
用户编写的代码与PHP内部的类/函数/常量或第三方类/函数/常量之间的名字冲突。
为很长的标识符名称(通常是为了缓解第一类问题而定义的)创建一个别名(或简短)的名称,提高源代码的可读性。
因此,贝壳的很多PHP项目都开启了命名空间。
1.2 命名空间和类常量的使用容易出现的错误
命名空间的一个重要特征,是允许通过别名引用或导入外部的完全限定名称,导入方式:使用 use 操作符导入,但是如果没有导入命名空间,直接使用类的话,根据命名空间解析规则解析后,如果没有对应的类文件,就会报致命错误:“Class xxx not found”。
同样,类常量没有定义的情况,也会造成"常量没有定义"的致命错误。
这种致命错误,一般在研发自测和测试过程中可以发现,但是多人合作开发过程中,很有可能会出现合并分支时,导入代码被合掉,或者出现在逻辑异常分支,而自测和qa测试没有覆盖,都可能会带到线上。这类错误一旦上线,就会造成接口500,还会阻断业务流程,并且排查加回滚耗时,影响十分严重,以上都是血的教训啊。
命名空间&类常量自动检测,可以让我们在开发阶段,借助工具主动发现问题,避免把问题带到线上。
2 检测分析
2.1 检测分析
2.1.1 命名空间检测分析
要检测一个文件中,用到的类的命名空间是否导入,check的思路很明确:看每个使用的类,根据命名空间解析规则转换后,是否对应有对应的类文件。若没有对应类文件,那这个类的使用就会出现“类找不到”的致命错误了。
根据这个思路,我们要通过扫描待检测文件,获取这些信息:所有使用的类、所有导入的命名空间、当前文件的命名空间、命名空间对应的文件。
2.1.2 类常量检测分析
要检测一个文件中,用到的类常量是否定义,我的思路是:对于一个类常量,获取到类对应的文件路径,然后check类文件中是否定义该常量,若没有定义,继续check其父类文件,父类文件没有定义,再check父类的父类···,也就是递归check父类文件,看类常量是否定义。
根据这个思路,我们要通过扫描待检测文件,获取这些信息:文件中所有使用的类常量及类名,类的文件地址,父类文件。
其中,类的文件地址,在命名空间的检测中,我们已经可以根据类名,获取到类的文件地址,因此,上述需要的信息可以进一步提炼为:文件中所有使用的类常量及其对应类名、父类类名。
综上,需要在文件中解析的内容如下:所有使用的类、所有导入的命名空间、当前文件的命名空间、命名空间对应的文件、所有使用的类常量、父类。
2.2 文件解析
2.2.1 扫描获取文件中使用的类
类的使用有如下四种方式:
//new 实例化一个对象
$a = new TestClass();
//静态调用
ClassName::test();
self::test(); //当前类
parent::test();//调用父类方法
其中,self 和 parent这俩个特殊的关键字是用于在类定义的内部对其属性或方法进行访问的,因此,我们根据另外两种使用方式,使用正则匹配的方式,解析文件中使用的类:
$regExr = "/(?<=[^*][\s![(.=&|])\\\\*[a-zA-Z][\\\\\w]+(?=::)|(?<=new\s)[^self\w+](?=\()/";
preg_match_all($regExr, $this->fileString, $matches);
2.2.2 扫描获取文件中导入的命名空间
命名空间的导入是通过操作符use来实现的,因此,通过正则匹配方式也可以获取到文件中导入的所有命名空间,哦对了,别忘了PHP7命名空间导入的新语法。
use App\Models\TestModel;
use App\Library\Enums\{Error, Metric as MetricEnums}; //PHP7命名空间导入的新语法
得到导入的命名空间后,还要考虑另一个问题:根据命名空间的解析规则获取到转换后的完整命名空间,因此,还需要解析出每个导入的命名空间的别名/类名。
private function setDeclaireSpace()
{
//检索文件中声明的命名空间
$regExr = "/(?<=use\s)\\\*[A-Za-z].*(?=;)/";
preg_match_all($regExr, $this->fileString, $namespaceMatches);
$classFullName = $namespaceMatches[0] ?? [];
//别名和php7新语法
foreach ($classFullName as $fullName) {
$fullNames = [];
//是否使用php7语法声明了多个命名空间
$usephp7 = strpos($fullName, "{");
if ($usephp7) {
$spacePrefix = substr($fullName, 0, $usephp7);
$allNames = substr($fullName, $usephp7 + 1, (strpos($fullName, "}") - 1 - $usephp7));
$allNames = explode(',', $allNames);
foreach ($allNames as $item) {
$fullNames[] = $spacePrefix . trim($item);
}
} else {
$fullNames = [$fullName];
}
foreach ($fullNames as $value) {
if (preg_match("/(\S+)\sas\s(\w+)/i", $value, $matches)) {
//有别名
$spaceName = $matches[1];
$aliasName = $matches[2];
} else {
$spaceName = $value;
$aliasName = substr($value, strrpos($value, self::FANXIEGANG) + 1);
}
!isset($this->declaireSpace[$aliasName]) && $this->declaireSpace[$aliasName] = trim($spaceName, self::FANXIEGANG);
}
}
}
2.2.3 获取待扫描文件的命名空间和父类
关于获取文件的命名空间和父类,根据其对应定义语法,通过正则匹配,也可以获取到。但值得注意的是,PHP虽然是单继承,但是类可是以实现多个接口的,因此“父类”可能有多个,我们要进行很多次的正则匹配,比较考验正则功底,性能也不高,因此考察之后,我们选用了另一种方法获取这些内容:使用token_get_all函数-解析PHP文件。
简单介绍下token_get_all函数:
说明
token_get_all ( string $source ) : array
token_get_all() 解析提供的 source 源码字符,然后使用 Zend 引擎的语法分析器获取源码中的 PHP 语言的解析器代号
参数
source:需要解析的 PHP 源码.
解析内容示例如下,解释器代号见附录:
显然,这个函数可以轻松获取到文件的命名空间,父类等信息,示例代码如下:
public function init()
{
$namespace = '';
$extends = '';
$tokens = token_get_all($this->fileString);
for ($index = 0; isset($tokens[$index]); $index++) {
if (!isset($tokens[$index][0])) {
continue;
}
if (T_NAMESPACE === $tokens[$index][0]) {
$index += 2; // Skip namespace keyword and whitespace
while (isset($tokens[$index]) && is_array($tokens[$index])) {
$namespace .= $tokens[$index++][1];
}
$this->namespace = $namespace;
}
if ((T_CLASS === $tokens[$index][0] || T_TRAIT === $tokens[$index][0] || T_INTERFACE === $tokens[$index][0]) && T_WHITESPACE === $tokens[$index + 1][0] && T_STRING === $tokens[$index + 2][0]) {
$this->type = self::CLASS_TYPE_MAP[$tokens[$index][0]] ?? '';
$index += 2; // Skip class keyword and whitespace
$this->class = $tokens[$index][1];
}
if (T_EXTENDS === $tokens[$index][0] && T_WHITESPACE === $tokens[$index + 1][0]) {
$index += 2; // Skip namespace keyword and whitespace
while (isset($tokens[$index]) && is_array($tokens[$index]) && T_WHITESPACE !== $tokens[$index][0]) {
$extends .= $tokens[$index++][1];
}
$this->baseClass = $extends;
$this->baseClassType = self::TYPE_CLASS;
break;
}
if (T_IMPLEMENTS === $tokens[$index][0] && T_WHITESPACE === $tokens[$index + 1][0]) {
$index += 2; // Skip namespace keyword and whitespace
$implements = '';
while (isset($tokens[$index])) {
if (!is_array($tokens[$index]) && $tokens[$index] !== ',') {
break;
}
if (is_array($tokens[$index]) && T_WHITESPACE !== $tokens[$index][0]) {
$implements .= $tokens[$index][1];
}
if (is_string($tokens[$index])) {
$implements .= $tokens[$index];
}
$index ++;
}
$this->implements = explode(',', $implements);
$this->baseClassType = self::TYPE_INTERFACE;
break;
}
}
}
2.2.4 扫描获取文件中使用的类常量及对应类名
类常量的访问方式和静态成员类似,可以通过类名或在成员方法中使用self访问,但在PHP 5.3.0之后也可以使用对象来访问(这种case暂不检测),其次,根据贝壳的代码规范,类中的常量名称必须是大写,根据以上特点,可以使用正则匹配的方式,获取文件中所有使用的类常量:
public function init()
{
$namespace = '';
$extends = '';
$tokens = token_get_all($this->fileString);
for ($index = 0; isset($tokens[$index]); $index++) {
if (!isset($tokens[$index][0])) {
continue;
}
if (T_NAMESPACE === $tokens[$index][0]) {
$index += 2; // Skip namespace keyword and whitespace
while (isset($tokens[$index]) && is_array($tokens[$index])) {
$namespace .= $tokens[$index++][1];
}
$this->namespace = $namespace;
}
if ((T_CLASS === $tokens[$index][0] || T_TRAIT === $tokens[$index][0] || T_INTERFACE === $tokens[$index][0]) && T_WHITESPACE === $tokens[$index + 1][0] && T_STRING === $tokens[$index + 2][0]) {
$this->type = self::CLASS_TYPE_MAP[$tokens[$index][0]] ?? '';
$index += 2; // Skip class keyword and whitespace
$this->class = $tokens[$index][1];
}
if (T_EXTENDS === $tokens[$index][0] && T_WHITESPACE === $tokens[$index + 1][0]) {
$index += 2; // Skip namespace keyword and whitespace
while (isset($tokens[$index]) && is_array($tokens[$index]) && T_WHITESPACE !== $tokens[$index][0]) {
$extends .= $tokens[$index++][1];
}
$this->baseClass = $extends;
$this->baseClassType = self::TYPE_CLASS;
break;
}
if (T_IMPLEMENTS === $tokens[$index][0] && T_WHITESPACE === $tokens[$index + 1][0]) {
$index += 2; // Skip namespace keyword and whitespace
$implements = '';
while (isset($tokens[$index])) {
if (!is_array($tokens[$index]) && $tokens[$index] !== ',') {
break;
}
if (is_array($tokens[$index]) && T_WHITESPACE !== $tokens[$index][0]) {
$implements .= $tokens[$index][1];
}
if (is_string($tokens[$index])) {
$implements .= $tokens[$index];
}
$index ++;
}
$this->implements = explode(',', $implements);
$this->baseClassType = self::TYPE_INTERFACE;
break;
}
}
}
2.2.5 获取命名空间对应的文件
我们使用的是贝壳封装的laravel框架,根据composer自动加载原理,在项目的 vendor/composer/autoload_classmap.php 中,有全部的命名空间和文件的映射关系。
综上,已经完成了文件解析,封装成一个文件类,如下:
OK,检测的前置条件都做好了,我们开始进行检测吧。
3 检测实现
3.1 检测流程
检测想要在代码提交时触发,因此,使用gitlab-ci工具。gitlab-ci是一个简易版的jenkins,runner可以理解为是Jenkins的slave,机器(或docker)通过runner程序与git服务器进行通信,当有新的任务发布到当前runner时,runner会执行.gitlab-ci.yml所定义的CI指令。
检测流程如下:
3.2 检测核心实现
3.2.1 命名空间检测核心实现
检测流程如下图:
根据命名空间规则依次转换为类的全命名空间:
public static function getClassNameSpace($class, $declaireSpace, $namespace)
{
if (empty($class)) {
return '';
}
//获取完整的命名空间声明
$classInfo = explode("\\", $class);
$aliasName = $classInfo[0];
if (empty($aliasName)) {
//以 \ 开头,完全限定名称
return trim($class, "\\");
}
//为了提高效率,先看是否有严格声明的。
if (isset($declaireSpace[$aliasName])) {
unset($classInfo[0]);
return rtrim($declaireSpace[$aliasName] . "\\" . implode("\\", $classInfo), "\\");
}
//命名空间大小写不敏感
foreach ($declaireSpace as $classKey => $classSpace) {
if (strtolower($aliasName) == strtolower($classKey)) {
unset($classInfo[0]);
return rtrim($declaireSpace[$classKey] . "\\" . implode("\\", $classInfo), "\\");
}
}
//以上不区分大小写,仍没有匹配声明的类
return empty($namespace) ? $class : $namespace . "\\" . $class;
}
根据class_map查找对应文件,判断文件是否存在:
public static function checkNamespace($class, $namespace, $declaireSpace, $classMap)
{
if (empty($class)) {
return false;
}
//获取完整的命名空间声明
$fullClass = self::getClassNameSpace($class, $declaireSpace, $namespace);
//是否有该命名空间的映射关系
$lowerFullClass = strtolower($fullClass);
if (isset($classMap[$lowerFullClass])) {
return [
'fullClass' => $fullClass,
'classFile' => $classMap[$lowerFullClass],
];
}
//未正确引入命名空间
Log::error("{$class},fullClass={$fullClass},无命名空间映射");
return false;
}
3.2.2 类常量检测核心实现
检测流程如下:
常量是否定义
常量使用关键字const定义,因此,可以使用正则匹配的方式,check常量是否定义:
preg_match("/(?<=[const|CONST]\s)\s*{$constant}\s*=\s*([\s\S]*?)(?=;)/", $constantFileString, $matchRes);
递归check父类
在前置条件中,我们已经可以获取基类文件,上面也可以判断常量是否定义,并且在命名空间检测时,已经得到类-类文件的映射关系,在此基础上,就可以很容易实现递归check父类了,实现如下:
public static function checkConstant($filePath, $constant, $classMap, $project, $projectDir)
{
if (empty($filePath)) {
return false;
}
$checkRes = false;
//判断当前类是否声明了$constant常量
$define = self::constantIsDefine($filePath, $constant);
if ($define) {
return [
'constFile' => $filePath,
'constValue' => $define[$constant],
];
}
$baseFile = self::getBaseFile($filePath, $classMap, $project, $projectDir);
foreach ($baseFile as $item) {
$checkRes = self::checkConstant($item, $constant, $classMap, $project, $projectDir);
if (!empty($checkRes)) {
break;
}
}
return $checkRes;
}
3.3 检测结果示例
借助gitlab-runner的能力,可以做到在每次代码提交时进行扫描检测,检测结果以企业微信方式通知,以下是一些检测出的报警case:
4 未来规划
综上,我们基于laravel框架搭建的这个检测平台,能够对业务代码中命名空间是否导入、导入的命名空间类源文件是否存在以及类常量是否定义的进行检测。在每次代码提交时进行扫描检测,检测结果以企业微信方式通知。
目前二手9个项目已接入检测扫描,能够有效主动发现问题,杜绝这类线上问题产生。
未来,我们将接入公司内部的KeOnes代码检测平台,让更多的laravel项目可以使用!