背景

Eslint

Eslint,我们常应用在代码静态扫描中,通过设定的Eslint的语法规则,来对代码进行检查,通过规则来约束代码的风格,以此来提高代码的健壮性,避免因为

代码不规范导致应用出现bug的可能。而规则是自由的,可以设定内部自己团队适用的规则,也可以直接使用开源社区比较热门的规则集合, 比如Airbnb等

Sonarqube

Sonarqube是一个用于代码质量管理的开源平台,用于管理源代码的质量

通过插件形式,可以支持包括Java,C#,C/C++,PL/SQL,Cobol,JavaScript,Groovy等等二十几种编程语言的代码质量管理与检测

自定义规则

实际业务中,我们可以把团队的编码规范和最佳实践通过自定义规则落地到实际项目中,在编码阶段对开发者进行提示和约束,然后在Sonarqube进行统一管理,这对于多人协助、代码维护、统一代码风格都会有很大的帮助。

按照Sonarqube官方文档描述,每个语言都有对应的第三方整合插件开发支持

JavaScript中则建议使用Eslint进行整合

参考链接:

  1. https://docs.sonarsource.com/sonarqube/latest/analyzing-source-code/importing-external-issues/importing-third-party-issues/#list-of-properties
  2. https://github.com/SonarSource/sonar-custom-rules-examples/tree/master/javascript-custom-rules

自定义 ESlint 规则

认识 ESlint

ESLint默认使用 Espree 解析器将代码解析为 AST (抽象语法树),然后再对代码进行检查。

看下面一段代码使用 espree 解析器转换成的抽象语法树结构,此处可以使用 astexplorer (astexplorer.net/) 快速查看解析成的 AST 结构:

代码转换为 AST 后,可以很方便的对代码的每个节点进行检查。

在这个示例中:let age = 10,对应右侧的asttree,可以看到一级tree为VariableDeclaration,这对应之后eslint扫描时的第一阶段,而后每一个declaration对应着其子节点。当鼠标点击每一个代码位置(如let/age/10),每一部分代码对应其每一个Identifier节点,这些数据都可以在扫描阶段获取到

初始化项目

代码示例为:sql代码不允许包含left join n次

项目参考:

  1. https://github.com/uphold/eslint-plugin-sql-template/tree/master
  2. https://github.com/gajus/eslint-plugin-sql

ESLint 官方为了方便开发者开发插件,提供了使用 Yeoman 模板 generator-eslint。

1、安装脚手架工具:

1
npm install -g yo generator-eslint

2、创建一个文件夹:

目录名格式建议为 eslint-plugin-<plugin-name>

当在应用时,就可以直接使用plugins: ['<plugin-name>']

1
2
mkdir eslint-plugin-demo
cd eslint-plugin-demo

3、初始化 ESlint 插件项目:

1
2
3
4
5
6
7
8
yo eslint:plugin

# 命令行交互流程,流程结束后生成 ESLint 插件项目基本框架
? What is your name? xiaowu // 作者名字
? What is the plugin ID? eslint-plugin-demo // 插件 ID
? Type a short description of this plugin: 最大左连接个数 // 插件描述
? Does this plugin contain custom ESLint rules? Yes // 插件是否包含自定义ESLint规则
? Does this plugin contain one or more processors? No // 插件是否包含一个或多个处理器

创建规则

上一个命令行生成了 ESLint插 件的项目模板,接下来创建一个具体的规则。

1
yo eslint:rule  // 生成 eslint rule的模板文件

示例规则:检查代码中是否存在 http 链接,并提示应该使用 https 链接

1
2
3
4
5
6
7
What is your name? xiaowu
? Where will this rule be published? (Use arrow keys) // 规则将在哪里发布
❯ ESLint Plugin // ESLint 插件
ESLint Core // 官方核心规则
? What is the rule ID? no-more-left-join // 规则 ID
? Type a short description of this rule: 检查代码中是否存在 http 链接 // 规则描述
? Type a short example of the code that will fail: // 输入一个失败例子的代码(跳过就行)

添加了规则之后的项目结构:

1
2
3
4
5
6
7
8
9
10
11
12
13
├── README.md
├── docs
│ └── rules
│ └── no-more-left-join.md // 规则文档
├── lib
│ ├── index.js 入口文件
│ └── rules
│ └── no-more-left-join.js // 具体规则源码
├── package.json
└── tests
└── lib
└── rules
└── no-more-left-join.js // 测试文件

编写规则

参考:

  1. https://zh-hans.eslint.org/docs/latest/extend/custom-rules

对于eslint的官方文档,我们需要关注两个对象:metacontext

meta(对象) 包含规则的元数据,例如你当前规则的类型,其规则描述等等。属性参考:https://zh-hans.eslint.org/docs/latest/extend/custom-rules#

context 对象包含了额外的功能,例如ast节点信息,所有的数据抓取也来自这部分。属性参考:https://zh-hans.eslint.org/docs/latest/extend/custom-rules#-1

还有性能测试:https://zh-hans.eslint.org/docs/latest/extend/custom-rules#-16

以及单元测试部分:https://zh-hans.eslint.org/docs/latest/extend/custom-rules#-15

AST参考

我们将所需验证扫描的代码拷贝至该网站,然后得到右面的树状结构

通过结果可以看到,我们需要监听的AST节点类型为:ExportNamedDeclaration

代码部分

lib/rules/no-more-left-join.js

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
/**
* @fileoverview 最大left join数量
* @author xiaowu
*/
"use strict";
const {joinValid} = require("../utilities/index")
/** @type {import('eslint').Rule.RuleModule} */
module.exports = {
meta: {
type: 'problem', // `problem`, `suggestion`, or `layout`
docs: {
description: "限制 SQL 文件中的左连接数量",
recommended: true,
url: null, // URL to the documentation page for this rule
},
fixable: 'code', // Or `code` or `whitespace`
schema: [
{
type: 'object',
properties: {
maxLeftJoin: {
type: 'number',
},
},
additionalProperties: false,
},
],
},

create(context) {
return {
ExportNamedDeclaration(node) {
if (node.declaration && node.declaration.declarations && node.declaration.declarations.length){
node.declaration.declarations.forEach(declaration => joinValid(declaration.init, context, 'left join'));
}
},
}
},
};

joinValid部分

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
/**
* Validate node.
*/
function joinValid(node, context, join_str) {
if (!node) {
return;
}
// variables should be defined here
let maxJoin = 3
if (context.options[0].maxLeftJoin !== undefined && join_str === 'left join') maxJoin = context.options[0].maxLeftJoin
if (context.options[0].maxRightJoin !== undefined && join_str === 'right join') maxJoin = context.options[0].maxRightJoin
if (node.type === 'TaggedTemplateExpression') {
node = node.quasi;
}
let literal = ""
// 带参数
if (node.type === 'ArrowFunctionExpression') {
if (node.body.type === 'TemplateLiteral' && node.body.quasis.length){
literal = node.body.quasis.map(quasi => quasi.value.raw).join('');
}
}
// 不带参数
else if (node.type === 'TemplateLiteral' && node.quasis.length) {
literal = node.quasis.map(quasi => quasi.value.raw).join('');
}
if (literal.length >0 && isSqlQuery(literal)) {
if (literal && literal.length > 0) {
literal = sqlFormat(literal)
const leftJoinCount = literal.split(join_str).length - 1;
if (leftJoinCount > maxJoin) {
context.report({
node,
message: `SQL 文件中的 ${join_str} 数量超过 ${maxJoin} 次`,
});
}
}
}
}

核心代码部分

  • meta 对象:规则的元数据,如类别,文档,可接收的参数(通过 schema 字段配置) 等。
  • create 方法: 返回一个对象,对象的属性设为选择器(也可以是一个事件名称),ESLint 会收集这些选择器,在 AST 遍历过程中会执行所有监听该选择器的回调。
  • context.report:用来抛出警告或错误,并能提供自动修复功能(需要添加 fix 配置)

单元测试

tests/lib/rules/no-more-left-join.js

注:

  1. errors中定义的message和上文context.report中定义的message要保持一致
  2. invalid部分为应该有错误的代码valid部分为应该没有错误的代码。如果出现相反的结果,则单元测试不通过
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
/**
* @fileoverview 最大left join数量
* @author xiaowu
*/
"use strict";

//------------------------------------------------------------------------------
// Requirements
//------------------------------------------------------------------------------

const rule = require("../../../lib/rules/no-more-left-join"),
RuleTester = require("eslint").RuleTester;


//------------------------------------------------------------------------------
// Tests
//------------------------------------------------------------------------------

const ruleTester = new RuleTester({
parserOptions: {
// 配置source type为module,以使用export/import验证代码
sourceType: 'module',
ecmaVersion: 2015,
},
});
ruleTester.run("no-more-left-join", rule, {
invalid: [
{
code: 'export const getContractsSql = `\n' +
'SELECT' +
' * \n' +
'FROM\n' +
' x \n' +
' left \n' +
'join a on a.id = x.id \n' +
' left \n' +
'join b on b.id = x.id \n' +
'WHERE\n' +
' 1=1 ${sqlConditions}\n' +
'ORDER BY\n' +
' update_time DESC \n' +
' ${sqlLimit} \n' +
'`;',
options: [
{
"maxLeftJoin": 1
}
],
errors: [
{
message: 'SQL 文件中的 left join 数量超过 1 次',
}
],
},
],
valid: [
{
code: "import {inspect} from 'util';",
options: [
{
"maxLeftJoin": 1
}
],
errors: [
{
message: 'SQL 文件中的 left join 数量超过 1 次',
}
],
},
],
});

执行npm run test

输出结果:2 passing

项目发布

参考:https://www.xiaowu95.wang/posts/4dab71df/

规则应用

依赖

1
npm install eslint-plugin-demo -D // eslint-plugin-demo 是 npm 包名

业务项目中 ESlint 配置

1
2
3
4
5
6
7
8
9
10
11
12
// .eslintrc
{
...
// 引入插件
plugins: ['demo'], // eslint-plugin- 可以省略,只填 <plugin-name>
// 配置规则
rules: {
// 定义最大left join数量,默认3
"demo/no-more-left-join": ["error",{"maxLeftJoin": 1}],
}
...
}

Sonarqube整合

导出报告

有时候执行eslint报错,并不是因为eslint本身有问题,而是代码层面的问题。

为了防止构建阶段报错影响到后续操作,这里eslint ...; exit 0

1
eslint ./ --ext .ts,.js -f json -o eslint_report.json; exit 0

Sonarqube导入报告

1
/usr/local/src/sonar-scanner/bin/sonar-scanner -Dsonar.projectKey=${CI_PROJECT_NAME} -Dsonar.sources=. -Dsonar.eslint.reportPaths=eslint_report.json -Dsonar.host.url=${SONAR_HOST} -Dsonar.login=${SONAR_LOGIN}

结果展示