PHP强制规范检查的最佳实践

规范是团队中最重要的部分之一,多数团队是靠自觉+review维护规范,而强制检查是最有效的方案。
本文就水一下介绍下PHP_CodeSniffer在项目中强制执行的最佳实践。

规范检查和表单检验一样,服务端是必须要检查的,客户端检查主要是优化使用体验。

客户端

引入组件

1
$ composer require 'squizlabs/php_codesniffer' --dev

自定义规范

如果你的项目用的是Laravel框架,会发现它并没有完全遵守PSR2规范,这时候就需要使用自定义规范。
在项目根目录创建phpcs.xml作为自定义规范,这里放出我的项目的例子。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
<?xml version="1.0"?>
<ruleset name="MyStandard">
<description>基于PSR2,去掉部分规则</description>
<arg name="tab-width" value="4"/>

<rule ref="PSR2">
<!-- 不限制行的长度 -->
<exclude name="Generic.Files.LineLength"/>
<!-- 跳过的目录 -->
<exclude-pattern>bootstrap/cache/*</exclude-pattern>
<exclude-pattern>node_modules/*</exclude-pattern>
<exclude-pattern>public/*</exclude-pattern>
<exclude-pattern>resources/*</exclude-pattern>
<exclude-pattern>storage/*</exclude-pattern>
<exclude-pattern>vendor/*</exclude-pattern>
</rule>

<!-- Laravel Migration & Seeder 没有命名空间 -->
<rule ref="PSR1.Classes.ClassDeclaration">
<exclude-pattern>database/*</exclude-pattern>
</rule>
</ruleset>

配置命令

我的使用场景只检查php,为了加快执行速度,跳过了部分目录。
加上这些参数之后命令会比较长,先加入到composer.json的脚本中,方便执行。

composer.json

1
2
3
4
5
6
7
8
"scripts": {
"phpcs": [
"./vendor/bin/phpcs --extensions='php' --standard='./phpcs.xml' --ignore='*/bootstrap/*,*/docker/*,*/node_modules/*,*/public/*,*/resources/*,*/storage/*,*/vendor/*,_ide_helper*,*.blade.php' -p"
],
"phpcbf": [
"./vendor/bin/phpcbf --extensions='php' --standard='./phpcs.xml' --ignore='*/bootstrap/*,*/docker/*,*/node_modules/*,*/public/*,*/resources/*,*/storage/*,*/vendor/*,_ide_helper*,*.blade.php' -p"
]
}

手动执行

1
2
$ composer phpcs ./
$ composer phpcbf ./

image

自动执行

通过pre-commit钩子,在提交时自动执行phpcs进行检查,如果未通过检查将阻止提交。

在项目根目录创建git-pre-commit-hook:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
#!/bin/sh

PROJECT=`php -r "echo dirname(dirname(dirname(realpath('$0'))));"`
STAGED_FILES_CMD=`git diff --cached --name-only --diff-filter=ACMR HEAD | grep \\\\.php`

# Determine if a file list is passed
if [ "$#" -eq 1 ]
then
oIFS=$IFS
IFS='
'
SFILES="$1"
IFS=$oIFS
fi
SFILES=${SFILES:-$STAGED_FILES_CMD}

echo "Checking PHP Lint..."
for FILE in $SFILES
do
php -l -d display_errors=0 $PROJECT/$FILE
if [ $? != 0 ]
then
echo "Fix the error before commit."
exit 1
fi
FILES="$FILES $PROJECT/$FILE"
done

if [ "$FILES" != "" ]
then
echo "Running Code Sniffer. Code standard PSR2."
composer phpcs -- $FILES
if [ $? != 0 ]
then
echo "Fix the error before commit!"
echo "Run"
echo " composer phpcbf -- $FILES"
echo "for automatic fix or fix it manually."
exit 1
fi
fi

exit $?

同样是在项目根目录创建git-hook-setup.sh,用于安装钩子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#!/bin/sh

if [ -e .git/hooks/pre-commit ];
then
PRE_COMMIT_EXISTS=1
else
PRE_COMMIT_EXISTS=0
fi

cp ./git-pre-commit-hook .git/hooks/pre-commit
chmod +x .git/hooks/pre-commit

if [ "$PRE_COMMIT_EXISTS" = 0 ];
then
echo "Pre-commit git hook is installed!"
else
echo "Pre-commit git hook is updated!"
fi

composer.json的钩子中加入git钩子的安装脚本 禁止套娃

1
2
3
4
5
6
7
8
"scripts": {
"install-hooks": [
"sh ./git-hook-setup.sh"
],
"post-install-cmd": [
"@install-hooks"
],
}

每一位开发者在克隆项目后都会执行composer install,安装完依赖就会自动安装git钩子,从而实现自动检查。

image

使用命令行提交被拒:

image

正常安装钩子后,使用PHPStorm提交时将出现Run Git hooks选项并且默认勾选,如果没有,就重启大法。

image

使用PHPStorm提交被拒:

image

虽然客户端有办法跳过自动检查,不过后面服务端也会进行检查,不必担心。客户端检查主要是提高用户体验,也可以杜绝不规范的提交。

PHPStorm协同

正常来说,在引入PHPCS组件后,PHPStorm会自动发现配置。如果没有自动配置,需要在 Languages & Frameworks > PHP > Quality Tools > Code Sniffer 中配置。

image

不过这时候默认是使用PSR2规范,需要手动选择一下自定义的规范。
Editor > Inspections > PHP > Quality tools > PHP Code Sniffer validation 中,将Coding standard改为了Custom,并点击冒号图标,选中项目根目录的phpcs.xml

image

保存后重启PHPStorm以生效。

服务端

精力有限,这里只介绍我们团队用的GitLab的方案。其他的托管仓库只要支持Hook都能实现。

GitLab

首先进入GitLab所在终端,找到存放所有仓库的目录。

1
2
3
4
5
6
$ cat /etc/gitlab/gitlab.rb |grep git_data_dirs -A 4
git_data_dirs({
"default" => {
"path" => "/data/gitlab"
}
})

然后进入项目所在仓库目录,创建custom_hooks目录,并在该目录中创建pre-receive:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
#!/usr/bin/env bash

# 创建临时目录
TMP_DIR=$(mktemp -d)
# 删除临时目录(脚本退出时)
trap 'rm -rf "$TMP_DIR"' exit

# 警告数
warnings_count=0
# 错误数
errors_count=0
# 自定义规范
custom_standard='phpcs.xml'

#hash
EMPTY_REF='0000000000000000000000000000000000000000'

# Colors
PURPLE='\033[35m'
RED='\033[31m'
RED_BOLD='\033[1;31m'
YELLOW='\033[33m'
YELLOW_BOLD='\033[1;33m'
GREEN='\033[32m'
GREEN_BOLD='\033[1;32m'
BLUE='\033[34m'
BLUE_BOLD='\033[1;34m'
COLOR_END='\033[0m'

while read oldrev newrev ref
do
# 当push新分支的时候oldrev会不存在,删除时newrev就不存在
if [[ $oldrev != $EMPTY_REF && $newrev != $EMPTY_REF ]]; then
echo -e "\n${PURPLE}CodeSniffer check result:${COLOR_END}"
# 取最新版本的自定义规则,不存在则使用PSR2
git show $newrev:$custom_standard > $TMP_DIR/$custom_standard
if [[ $? != 0 ]]; then
standard='PSR2'
else
standard=$TMP_DIR/$custom_standard
fi
# 被检查了的文件数
checked_file_count=0
# 找出哪些文件被更新了
for line in $(git diff-tree -r $oldrev..$newrev | grep -oP '.*\.(php)' | awk '{print $5$6}')
do
# 文件状态
# D: deleted
# A: added
# M: modified
status=$(echo $line | grep -o '^.')

# 不检查被删除的文件
if [[ $status == 'D' ]]; then
continue
fi

# 文件名
file=$(echo $line | sed 's/^.//')

# 为文件创建目录
mkdir -p $(dirname $TMP_DIR/$file)
# 保存文件内容
git show $newrev:$file > $TMP_DIR/$file

output=$(phpcs --standard=$standard --colors --encoding=utf-8 -n -p $TMP_DIR/$file)

warning=$(echo $output | grep -oP '([0-9]+) WARNING' | grep -oP '[0-9]+')
error=$(echo $output | grep -oP '([0-9]+) ERROR' | grep -oP '[0-9]+')

if [[ $warning || $error ]]; then

msg="${file}: "

if [[ $warning > 0 ]]; then
msg="$msg${YELLOW_BOLD}${warning}${COLOR_END} ${YELLOW}warnings${COLOR_END} "

let "warnings_count = warnings_count + 1"
fi
if [[ $error > 0 ]]; then
msg="$msg${RED_BOLD}${error}${COLOR_END} ${RED}errors${COLOR_END}"

let "errors_count = errors_count + 1"
fi

echo -e " $msg"
fi

let "checked_file_count = checked_file_count + 1";

done

if [[ $checked_file_count == 0 ]]; then
echo -e " ${BLUE_BOLD}No file was checked.${COLOR_END}"
elif [[ $warnings_count == 0 && $errors_count == 0 ]]; then
echo -e "${GREEN_BOLD}$(cowsay 'Congratulations!!!')${COLOR_END}"
elif [[ $errors_count == 0 ]]; then
echo -e " ${BLUE}Good job, no errors were found!!!${COLOR_END}"
fi

fi
done

if [[ $warnings_count > 0 || $errors_count > 0 ]]; then
echo -e "${RED}$(cowsay -f dragon 'PHPCS Check Error!!!')${COLOR_END}"
exit 1
fi

exit 0

之后客户端推送提交时,便会执行这个钩子进行检查。

推送通过:

image

推送被拒:

image

如果使用PHPStorm的UI进行推送,被拒是不提示原因的,不过相信大家心里一般都有b数的。

image

1
2
3
4
5
6
7
8
 _____________________________ 
< 你心里没点b数吗? >
-----------------------------
\ ^__^
\ (oo)\_______
(__)\ )\/\
||----w |
|| ||

额外规则

phpcs自带的规则集比较少,这里推荐一组规则集slevomat/coding-standard,最新版本使用了做了自动安装,使用体验非常丝滑。

1
composer require slevomat/coding-standard --dev

如果你的项目中有使用ORM、数据库的字段有使用下划线、并且希望变量名强制使用小驼峰,那目前截至v3.5的phpcs自带规则Squiz.NamingConventions.ValidVariableName.NotCamelCaps将无法实现你的需求,会把$user->created_at误判为不规范,参照Issue#1773

phpcs发布v4.0可能会修复,我写了个组件cloudycity/coding-standard,可以暂时解决这个问题。

1
composer require cloudycity/coding-standard --dev

最终的规则集

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
<?xml version="1.0"?>
<ruleset name="MyStandard">
<!-- 新版本不需要手动安装第三方规则 -->
<!-- <config name="installed_paths" value="vendor/slevomat/coding-standard"/> -->
<description>基于PSR2与slevomat的规则</description>
<arg name="tab-width" value="4"/>

<rule ref="PSR2">
<!-- 不限制行的长度 -->
<exclude name="Generic.Files.LineLength"/>
<!-- 跳过的目录 -->
<exclude-pattern>bootstrap/cache/*</exclude-pattern>
<exclude-pattern>node_modules/*</exclude-pattern>
<exclude-pattern>public/*</exclude-pattern>
<exclude-pattern>resources/*</exclude-pattern>
<exclude-pattern>storage/*</exclude-pattern>
<exclude-pattern>vendor/*</exclude-pattern>
</rule>

<!-- Laravel Migration & Seeder 没有命名空间 -->
<rule ref="PSR1.Classes.ClassDeclaration">
<exclude-pattern>database/*</exclude-pattern>
</rule>

<!-- 变量名采用驼峰 -->
<rule ref="CloudyCityCodingStandard.NamingConventions.ValidVariableName.NotCamelCaps"/>

<!-- 字符串连接符前后空格 -->
<rule ref="Squiz.Strings.ConcatenationSpacing">
<properties>
<property name="spacing" value="1"/>
</properties>
</rule>

<!-- 确保函数前后空行,首个/最后函数除外 -->
<rule ref="Squiz.WhiteSpace.FunctionSpacing">
<properties>
<property name="spacing" value="1"/>
<property name="spacingBeforeFirst" value="0"/>
<property name="spacingAfterLast" value="0"/>
</properties>
</rule>

<!-- 操作符前后空格 -->
<rule ref="Squiz.WhiteSpace.OperatorSpacing">
<properties>
<property name="ignoreNewlines" value="false"/>
<property name="ignoreSpacingBeforeAssignments" value="false"/>
</properties>
</rule>

<!-- 单行数组空格 -->
<rule ref="SlevomatCodingStandard.Arrays.SingleLineArrayWhitespace"/>

<!-- 多行数组元素强制以逗号结尾 -->
<rule ref="SlevomatCodingStandard.Arrays.TrailingArrayComma"/>

<!-- 禁止数组隐式创建 -->
<rule ref="SlevomatCodingStandard.Arrays.DisallowImplicitArrayCreation"/>

<!-- 禁止自动生成的注释 -->
<rule ref="SlevomatCodingStandard.Commenting.ForbiddenComments">
<properties>
<property
name="forbiddenCommentPatterns"
type="array"
value="~^Created by \S+\.\z~i,"/>
</properties>
</rule>
</ruleset>

GitLab的兼容

只要在gitlab的机器上也用composer引入slevomat/coding-standard即可。

在哪里引入都可以,这边选择和钩子同一个目录。
image

授予执行权限

1
2
chmod a+x vendor/squizlabs/php_codesniffer/bin/phpcs
chmod a+x vendor/squizlabs/php_codesniffer/bin/phpcbf

修改pre-receive中的执行命令

1
output=$(/usr/local/bin/php7 /data/gitlab/repositories/php/demo.git/custom_hooks/vendor/squizlabs/php_codesniffer/bin/phpcs --standard=$standard --colors --encoding=utf-8 -n -p  $TMP_DIR/$file)

最后

上面提到的主要是针对单个仓库的强制规范检查策略,主要是我目前团队的各个项目的规范都有不同。
如果你的各项目都有相同的规范,可以将规范提取为一个组件,服务端也可以使用全局Hook,不必为每一个仓库加Hook。

References

PHP_CodeSniffer
Git Hooks
GitLab Server Hooks
客户端Hook自动安装脚本
服务端Hook脚本