0%

本来这一篇应该有很多图才是的,但是这玩意加个图实在是太麻烦了,没有搞,主要讲讲流程。

这个系统的另一部分是 grafana,不过这里不说了,那个需要很多图才能说明白,可以自行搜索。

前言

本文不会细说 Prometheus 的原理、机制等,这里只是简单说一下,详细的还得自己去看文档

文档链接:prometheus

Prometheus 是什么?

简单来说,就是一个时序数据库(TSDB),它可以存储时序数据,也可以向外提供查询(通过 PromQL)。

Prometheus 的作用是什么?

  • 通过存储时序数据,让我们可以监控一些我们感兴趣的系统指标,如 CPU、内存 占用等,然后可以通过 AlertManager 来在指标到达阈值的时候告知相关人员。从而做出及时处理。

  • 另外一个方面,我们也可以通过历史时序数据来看到指标的变化趋势,从而在趋势发生的初期就做出处理。

Prometheus 能监控什么?

  • 服务器基础指标,只有你想不到,没有它监控不到,如:CPU、内存、磁盘、网络、甚至上下文切换次数等

  • 常见软件的监控,如 MySQL、MongoDB,这些软件通过一些 exporter 来采集,目前有很多现成的,覆盖了常见的软件。具体可以在 exporters 上找到

  • 自定义指标的监控,其实 Prometheus 采集的数据格式很简单,只要我们按照指定的格式返回一段 HTTP 文本就行。

Prometheus 工作机制

上面说了,Prometheus 的定位其实是一个数据库,所以在实际使用中,一般会配合使用 grafana 来将 Prometheus 采集到的数据展示为图表。

使用环境

  • CentOS 7 64位

Prometheus 安装

下载 Prometheus

下载地址:Prometheus

1
2
3
wget https://github.com/prometheus/prometheus/releases/download/v2.29.1/prometheus-2.29.1.linux-amd64.tar.gz
tar -xvf prometheus-2.29.1.linux-amd64.tar.gz
cd prometheus-2.29.1.linux-amd64

修改配置文件

prometheus.yml

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
# my global config
global:
scrape_interval: 15s # Set the scrape interval to every 15 seconds. Default is every 1 minute.
evaluation_interval: 15s # Evaluate rules every 15 seconds. The default is every 1 minute.
# scrape_timeout is set to the global default (10s).

# Alertmanager configuration
#alerting:
# alertmanagers:
# - static_configs:
# - targets:
# - localhost:9093

# Load rules once and periodically evaluate them according to the global 'evaluation_interval'.
#rule_files:
# - "rules.yml"
# - "second_rules.yml"

# A scrape configuration containing exactly one endpoint to scrape:
# Here it's Prometheus itself.
scrape_configs:
# The job name is added as a label `job=<job_name>` to any timeseries scraped from this config.
- job_name: 'server_status'

# metrics_path defaults to '/metrics'
# scheme defaults to 'http'.

static_configs:
- targets: ['test1.com:9092', 'test2.com:9092', 'test3.com:9092']

relabel_configs:
- source_labels: [__address__]
target_label: instance
regex: '([^:]+)(:[0-9]+)?'
replacement: '${1}'

这里先把 alertingrule_files 的配置注释掉,后面安装了 AlertManager 之后再来配置。

需要注意的时候,到这一步的时候,我们的 prometheus 是还不能正常使用的,因为我们的数据源实际上还没有配置。

这里需要详细说明的是:

  • global.scrape_interval 间隔多久采集一次数据
  • scrape_configs 采集的数据源配置

scrape_configs 配置详解

这个是最核心的一个配置了,主要配置了我们的 Prometheus 需要从哪里采集数据,先说一下上面每一个配置项:

  • job_name 这个是区分我们的时序数据源类型的标识,这个标识是我们自己区分数据使用的,可以自定义。这里命名为 server_status 是因为我这个配置就是采集服务器的状态数据的。
  • static_configs 主要作用是配置我们的数据源地址
  • relabel_configs 我们在这个配置里面可以改写一些标签的值,比如上面这个配置会将 test1.com:9092 里面的端口移除,最后我们使用 PromQL 查询的时候就可以不填写端口了。

exporter 安装

Exporter 是什么?

Exporter 就是部署在我们要监控的服务器上,协助 Prometheus 采集数据的一个轻量的服务。(目标服务器->exporter)->Prometheus

Exporter 可以看作是一个 HTTP 服务器,它运行在我们的服务器上,当我们请求它的时候,它会去采集服务器上的指标数据,然后返回给发起请求的客户端。

比如,node_exporter,假设我们在 1.2.3.4:9092 这个服务器上启动了 node_exporter,当我们请求 http://1.2.3.4:9092/metrics 的时候,会返回如下数据:

1
2
3
4
5
6
7
8
9
# HELP go_gc_duration_seconds A summary of the pause duration of garbage collection cycles.
# TYPE go_gc_duration_seconds summary
go_gc_duration_seconds{quantile="0"} 2.8845e-05
go_gc_duration_seconds{quantile="0.25"} 4.7691e-05
go_gc_duration_seconds{quantile="0.5"} 8.7005e-05
go_gc_duration_seconds{quantile="0.75"} 0.000117043
go_gc_duration_seconds{quantile="1"} 0.002664528
go_gc_duration_seconds_sum 74.855671321
go_gc_duration_seconds_count 681610

这就是 exporter 返回的格式,HELP 说明接下来的数据是什么样的数据,而 TYPE 指定了接下来数据的类型。Prometheus 总共有四种数据类型,上面的 go_gc_duration_seconds 的类型为 summary

安装一个 node_exporter

到 node_exporter 的 releases 页面下载一个来安装:

1
2
3
wget https://github.com/prometheus/node_exporter/releases/download/v1.2.2/node_exporter-1.2.2.linux-amd64.tar.gz
tar -xvf node_exporter-1.2.2.linux-amd64.tar.gz
cd node_exporter-1.2.2.linux-amd64

启动 node_exporter

1
./node_exporter --web.listen-address=:9092

测试是否安装成功

1
curl http://1.2.3.4:9092

如果能返回类似上面格式的数据,说明我们的 exporter 部署成功了。

修改 Prometheus 配置

将里面的 scrape_configs 修改为以下内容:

1
2
3
4
5
6
7
8
9
10
11
scrape_configs:
- job_name: 'server_status'

static_configs:
- targets: ['1.2.3.4:9092']

relabel_configs:
- source_labels: [__address__]
target_label: instance
regex: '([^:]+)(:[0-9]+)?'
replacement: '${1}'

这里的 1.2.3.4 在我们配置的时候替换为实际的 ip 即可,这里的 relabel_configs 配置表明我们最终的目标主机地址的标签会替换为名字为 instance 的标签。

启动 Prometheus

到现在为止,我们的配置文件如下:

prometheus.yml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# my global config
global:
scrape_interval: 15s # Set the scrape interval to every 15 seconds. Default is every 1 minute.
evaluation_interval: 15s # Evaluate rules every 15 seconds. The default is every 1 minute.

scrape_configs:
- job_name: 'server_status'

static_configs:
- targets: ['1.2.3.4:9092']

relabel_configs:
- source_labels: [__address__]
target_label: instance
regex: '([^:]+)(:[0-9]+)?'
replacement: '${1}'

我们使用这个配置文件来启动我们的 Prometheus 服务器:

1
prometheus --config.file=prometheus.yml --web.listen-address=:9091

这里的 --config.file 指定了配置文件的路径,--web.listen-address 指定了我们的 Prometheus 监听的地址。

测试

假设我们的 Prometheus 的服务器 ip 为 192.168.2.168,我们通过浏览器访问 http://192.168.2.168:9091 就可以看到我们的 Prometheus 自带的 web 页面,这个页面可以看到我们的采集目标是否正常运作(也就是上面的 scrape_configs 指定的地址能否正常访问),也可以通过 PromQL 来做一些查询。

Prometheus 报警规则设置

我们在 prometheus 安装目录下定义一个名字为 rules.yml 的配置文件,这个文件主要用来配置 Prometheus 的报警规则的。下面是一个示例文件:

rules.yml

1
2
3
4
5
6
7
8
9
10
11
groups:
- name: test_cpu_monitor
rules:
- alert: Cpu
expr: (1 - avg(rate(node_cpu_seconds_total{mode="idle"}[1m])) by (instance)) * 100 > 90
for: 1m
labels:
severity: "warning"
annotations:
summary: "{{ $labels.instance }} {{ $labels.job }} cpu usage larger than 90%"
description: "cpu usage {{ $labels.instance }}"
  • expr 是一个 PromQL 表达式,这个表达式的结果是 true 的时候,这个报警规则报警状态为 pending(还不会报警),如果 5 分钟之后,这个表达式依然是 true,则会触发报警。

我们可以使用 prometheus 安装目录下面的 promtool 来检查报警规则配置是否正确:

1
./promtool check config prometheus.yml

在增加了这个报警规则配置文件之后,接下来我们还需要修改 Prometheus 的配置文件,添加以下配置:

1
2
rule_files:
- "rules.yml"

AlertManager 安装配置

我们可以在官方的 github releases 上找到我们对应系统版本的安装包。

1
2
3
wget https://github.com/prometheus/alertmanager/releases/download/v0.23.0-rc.0/alertmanager-0.23.0-rc.0.linux-amd64.tar.gz
tar -xvf alertmanager-0.23.0-rc.0.linux-amd64.tar.gz
cd alertmanager-0.23.0-rc.0.linux-amd64

我们添加以下配置文件:

alertmanager.yml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
global:
smtp_smarthost: 'smtp.qq.com:25'
smtp_from: 'xx@qq.com'
smtp_auth_username: 'xxx@qq.com'
smtp_auth_password: 'xxyy'
route:
group_by: ['alertname']
group_wait: 30s
group_interval: 5m
repeat_interval: 1h
receiver: 'web.hook' # 发送报警信息给下面哪一个接收方,匹配 receivers 里面的 name
receivers:
- name: 'ruby'
email_configs:
- to: 'xx@gmail.com'
- name: 'web.hook'
webhook_configs:
- url: 'http://localhost:8010/dingtalk/webhook2/send'
inhibit_rules:
- source_match:
severity: 'critical'
target_match:
severity: 'warning'
equal: ['alertname', 'dev', 'instance']

测试配置文件是否正确

在 alertmanager 的安装目录下面有一个名为 amtool 的文件,我们可以通过这个文件来检查我们的配置文件是否正确

1
./amtool check-config alertmanager.yml

安装配置钉钉 webhook

在这里我们就不使用邮件来发送了,我们使用钉钉来发送报警通知。

这里的报警需要用到钉钉机器人,具体可以搜索一下,然后去钉钉群里面配置一个即可。

prometheus-webhook-dingtalk

在 github 上已经有一个实现的钉钉报警webhook版本了,直接使用即可:

1
2
3
wget https://github.com/timonwong/prometheus-webhook-dingtalk/releases/download/v2.0.0/prometheus-webhook-dingtalk-2.0.0.linux-amd64.tar.gz
tar -xvf prometheus-webhook-dingtalk-2.0.0.linux-amd64.tar.gz
cd prometheus-webhook-dingtalk-2.0.0.linux-amd64

我们在这个目录下创建一个 config.yml 配置文件:

1
2
3
targets:
webhook2:
url: https://oapi.dingtalk.com/robot/send?access_token=xxx

在这里其他无关的配置就不列出来了,关键的配置就这三行。url 就是我们钉钉机器人的请求地址。这个地址可以在配置钉钉机器人的界面获取。

启动 webhook

配置完之后,我们可以通过以下命令来启动:

1
./prometheus-webhook-dingtalk --config.file=config.yml --web.listen-address=:8010

修改 AlertManager 配置

在上一步启动的时候,我们会看到它输出了一个 webhook 地址,如 http://localhost:8010/dingtalk/webhook2/send,而这个地址就是沟通 alertmanager 跟 钉钉 的桥梁了。

我们在上面安装配置 AlertManager 那一步已经写入了这个配置,所以这一步就直接跳过了。

给 Prometheus 添加报警配置

我们安装了 AlertManager、也安装了钉钉报警的 webhook 服务,但是这个时候我们的 Prometheus 依然是不能报警的,我们还需要修改 Prometheus 的配置,让 Prometheus 在判断到需要报警的时候,去告知 AlertManager。

prometheus.yml 添加以下配置:

1
2
3
4
5
alerting:
alertmanagers:
- static_configs:
- targets:
- localhost:9093

这个 localhost:9093 是我们 AlertManager 监听的端口。

修改完配置之后,我们将 rules.yml 里面的 PromQL 表达式的报警值设置低一点,这样我们就可以看到报警了,待我们测试没有问题,再将报警值修改为我们预定的值即可。比如:

1
expr: (1 - avg(rate(node_cpu_seconds_total{mode="idle"}[1m])) by (instance)) * 100 > 1  # CPU 占用大于 1% 就报警

修改完 prometheus.yml 以及 rules.yml 这两个配置文件之后,重启一下 Prometheus,让配置生效。

不出意外的话,过 5 分钟我们就可以收到报警通知了。

最终的目录结构以及配置文件、启动命令

Prometheus

目录结构:

1
2
3
4
5
6
7
8
9
10
.
├── console_libraries
├── consoles
├── data
├── LICENSE
├── NOTICE
├── prometheus # prometheus 启动的入口文件
├── prometheus.yml # Prometheus 配置文件
├── promtool # 检查 prometheus 配置文件是否有效的工具
└── rules.yml # 报警规则配置,会在 prometheus.yml 引入
  1. prometheus.yml
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
global:
scrape_interval: 15s # Set the scrape interval to every 15 seconds. Default is every 1 minute.
evaluation_interval: 15s # Evaluate rules every 15 seconds. The default is every 1 minute.

alerting:
alertmanagers:
- static_configs:
- targets:
- localhost:9093

rule_files:
- "rules.yml"

scrape_configs:
- job_name: 'server_status'

static_configs:
- targets: ['1.2.3.4:9092'] # 这里为监控目标服务器上 node_exporter 的地址

relabel_configs:
- source_labels: [__address__]
target_label: instance
regex: '([^:]+)(:[0-9]+)?'
replacement: '${1}'
  1. rules.yml
1
2
3
4
5
6
7
8
9
10
11
groups:
- name: test_cpu_monitor
rules:
- alert: Cpu
expr: (1 - avg(rate(node_cpu_seconds_total{mode="idle"}[1m])) by (instance)) * 100 > 90 # cpu 占用大于 90 的时候报警
for: 1m
labels:
severity: "warning"
annotations:
summary: "{{ $labels.instance }} {{ $labels.job }} cpu usage larger than 90%"
description: "cpu usage {{ $labels.instance }}"

启动命令:

1
./prometheus --config.file=prometheus.yml --web.listen-address=:9091

node_exporter

这个需要在我们的目标服务器上安装,当然,我们也可以安装在 Prometheus 所在的服务器上。

目录结构:

1
2
3
├── LICENSE
├── node_exporter
└── NOTICE

这个没有需要配置的,启动的时候指定一个监听地址即可,如下:

启动命令:

1
./node_exporter --web.listen-address=:9092

AlertManager

目录结构:

1
2
3
4
5
6
├── alertmanager     # AlertManager 的启动入口文件
├── alertmanager.yml # AlertManager 配置文件
├── amtool # 检查配置文件是否有效的工具
├── data
├── LICENSE
└── NOTICE
  1. alertmanager.yml
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
global:
smtp_smarthost: 'smtp.qq.com:25'
smtp_from: 'xx@qq.com'
smtp_auth_username: 'xx@qq.com'
smtp_auth_password: 'xx'
route:
group_by: ['alertname']
group_wait: 30s
group_interval: 5m
repeat_interval: 1h
receiver: 'web.hook'
receivers:
- name: 'web.hook'
webhook_configs:
- url: 'http://localhost:8010/dingtalk/webhook2/send'
inhibit_rules:
- source_match:
severity: 'critical'
target_match:
severity: 'warning'
equal: ['alertname', 'dev', 'instance']

启动命令:

1
./alertmanager --config.file=alertmanager.yml

prometheus-webhook-dingtalk

目录结构:

1
2
3
4
5
├── config.example.yml          # 配置示例文件
├── config.yml # 实际使用的配置文件
├── contrib
├── LICENSE
└── prometheus-webhook-dingtalk # 启动的入口文件
  1. config.yml
1
2
3
targets:
webhook2:
url: https://oapi.dingtalk.com/robot/send?access_token=db655f2c0a13a5abc91368422e39d0e83bc397218047e66c24acf55af43e0fc2

启动命令:

1
./prometheus-webhook-dingtalk --config.file=config.yml --web.listen-address=:8010

再进一步

上面我们都是通过手动运行的方式来启动的,但是在实际应用中,我们可能会使用 supervisor 来配置。又或者有更高级的方式,k8s 然后服务发现啥的。

Prometheus 提供了一个叫 PromQL(Prometheus Query Language) 的函数式查询语言,允许用户实时地选择和聚合时间序列数据。查询结果可以展示为表格或者图表,或者被通过 HTTP API 来消费(比如被 grafana 使用)。

表达式语言数据类型

在 Prometheus 的表达式语言里面,一个表达式或者子表达式可以计算为以下四种类型之一:

  • 即时向量:一组时间序列,每个时间序列包含一个样本,所有时间序列都共享相同的时间戳
  • 范围向量:一组时间序列,包含每个时间序列随时间变化的数据点范围
  • 标量:一个简单的数字浮点值
  • String:一个简单的字符串值;目前未使用

时间序列选择器

即时向量选择器

  • 选择名称为 http_requests_total 的所有时间序列
1
http_requests_total

根据标签过滤

1
http_requests_total{job="prometheus"}

标签匹配运算符:

  • =:选择与提供的字符串完全相同的字符串
  • !=: 选择不等于提供的字符串的标签
  • =~: 选择与提供的字符串正则表达式匹配的标签
  • !~: 选择与提供的字符串不匹配的标签

例如,此选择所有环境为 stagingtestingdevelopment,以及 HTTP 方法不是 GET 的时间序列数据:

1
http_requests_total{environment=~"staging|testing|development", method!="GET"}
  • 根据指标名称匹配
1
{__name__=~"job:.*"}

范围向量选择器

范围向量字面量的工作方式与即时向量字面量类似,不同之处在于它们从当前时刻选择了一系列样本。从语法上讲,在向量选择器的末尾将持续时间附加在方括号([])中,以指定应该为每个结果范围向量元素提供多远的时间值。

  • 选择过去 5 分钟内名称为 http_requests_total 以及 job 标签为 prometheus 的所有时序数据
1
http_requests_total{job="prometheus"}[5m]

持续时间

持续时间指定为一个数字,后跟以下单位之一:

  • ms - 毫秒
  • s - 秒
  • m - 分钟
  • h - 小时
  • d - days
  • w - 周
  • y - 年

如:

1
2
3
4
5h
1h30m
5m
10s

偏移修改器

  • 返回 http_requests_total 过去 5 分钟相当于当前查询评估时间的值:
1
http_requests_total offset 5m

请注意,offset 修饰符总是需要立即跟随选择器,即以下是正确的:

1
sum(http_requests_total{method="GET"} offset 5m)

以下是不正确的:

1
sum(http_requests_total{method="GET"}) offset 5m
  • 同样适用于范围向量。返回 http_requests_total 一周前的 5 分钟频率:
1
rate(http_requests_total[5m] offset 1w)

@修饰符

  • 返回时间戳为 1609746000 的 http_requests_total 的值
1
http_requests_total @ 1609746000

请注意,@修饰符总是需要立即跟随选择器

1
2
sum(http_requests_total{method="GET"} @ 1609746000)  // 正确
sum(http_requests_total{method="GET"}) @ 1609746000 // 无效

这同样适用于范围向量。

1
rate(http_requests_total[5m] @ 1609746000)

offset 一起使用:

1
2
3
4
# offset after @
http_requests_total @ 1609746000 offset 5m
# offset before @
http_requests_total offset 5m @ 1609746000

默认情况禁用 @ 修饰符。

子查询

子查询允许你对给定的范围和分辨率运行即时查询。子查询的结果是一个范围向量:

语法:

1
<instant_query> '[' <range> ':' [<resolution>] ']' [ @ <float_literal> ] [ offset <duration> ]
  • <resolution> 是可选的。

简单的时间序列查询

  • 返回所有指标名字为 http_requests_total 的时间序列
1
http_requests_total
  • 返回所有名字为 http_requests_total,并且 jobhandler 标签为以下值的时间序列
1
http_requests_total{job="apiserver", handler="/api/comments"}
  • 返回一个时间范围内的指标数据(返回一个范围向量,range vector)
1
http_requests_total{job="apiserver", handler="/api/comments"}[5m]

返回范围向量的表达式不能直接用图表展示,但是可以通过表格的方式展示。

  • 匹配标签的时候我们也可以使用正则表达式,如:
1
http_requests_total{job=~".*server"}
  • 选择不是 4xx 的状态的数据:
1
http_requests_total{status!~"4.."}

子查询

  • 返回 http_requests_total 过去 30 分钟内每 5 分钟的速率,分辨率(resolution)为 1 分钟
1
rate(http_requests_total[5m])[30m:1m]
  • 嵌套的子查询
1
max_over_time(deriv(rate(distance_covered_total[5s])[30s:5s])[10m:])

使用函数、操作符等

  • 返回 http_requests_total 过去 5 分钟每秒的速率
1
rate(http_requests_total[5m])
  • 根据相同 job 统计
1
2
3
sum by (job) {
rate(http_requests_total[5m])
}
  • 不同指标计算
1
(instance_memory_limit_bytes - instance_memory_usage_bytes) / 1024 / 1024
  • 根据相同标签聚合
1
2
3
sum by (app, proc) {
instance_memory_limit_bytes - instance_memory_usage_bytes
} / 1024 / 1024

过去的一个星期里,一直在做一些重构工作(因为这部分代码年久失修导致出问题的时候排查非常困难),因为关联的业务逻辑比较多,加上业务流程太长等原因,费了好大的劲才把其中一部分逻辑从原来散落在各个 service 里面抽离出来。前两天把这部分代码重构完之后,一开始想着自己测试一遍,没问题就交给测试测,这是相对容易的一种方式。但其实重构后的代码还是有不少的问题,只不过没有原来那么大的问题,所以内心隐隐觉得后面如果这部分代码出 bug 还是会不太好修。

为什么写单元测试?

重构完这一部分代码之后,想着就按 plan A 来测试吧,然后就跑去看了下《聊聊架构》,看到了里面第20章的单元测试之后,对于单元测试多年以来的一些困惑得到了答案。比如里面讲到的“单元测试测什么”,很简单的一句话,就是测软件工程师自己写的逻辑,对于服务代码、存储代码、别人写的代码是不需要测试的(这里的意思是在测自己的逻辑的时候,如果需要调用外部的方法,我们不需要再去验证这个外部调用是否正确,因为这一步验证是应该放在那个外部方法对应的单元测试里面实现,这也是书里一直强调的“权责对等”的问题)。

其实一开始写现在重构的这部分代码的时候,就已经尝试过写“单元测试”,这里的单元测试是带有引号的,因为以前觉得那是的单元测试,但实际上那叫集成测试,那时候写的是对接口的测试,就是通过模拟请求,测试请求的结果准确性。这样做的问题是,先要生成模拟数据,但是这部分代码需要的关联实在是太多了,导致写完生成模拟数据的代码就已经花了很多时间了,而且这样有个问题是,对数据库强依赖,实在是难以处理,所以那时候的测试只写了一个版本,后续就没有维护了。毕竟实在是没法维护,依赖太多,耦合太多,流程太长,要做到响应需求的变化非常的困难。

看完这书之后,加上前段时间看了《重构》,所以想着再尝试一下写写单元测试。毕竟《重构》里面提到“重构的第一块基石是自测试代码”(里面很多地方都提到这一点,而且里面介绍的重构步骤也频繁提到“重构->测试”这种模式),也就是说,如果你要对代码进行重构,你的代码首先要有对应的测试代码,这样才能保证你的每一步重构的操作不会对原有代码造成破坏,这样的重构才是真正的重构。

另外一个原因是,用在代码维护的时间往往会比开发时间多很多,如果有了单元测试,可以在后续的维护过程中放心地做一些修改,因为单元测试会告诉你你的修改有没有影响到原有的代码,这也就省下了很多做回归测试的时间。这样可以释放一部分脑容量去思考一些更值得花时间思考的东西,同时我们只需要专注于新的代码,过去的代码不再对我们的思想造成负担。正如《聊聊架构》里面说为什么要做单元测试:“写的代码越多,不安全感累积会越多,最后会发觉自己对自己所写的代码完全没有把握,这是非常影响生活质量的”。

写单元测试过程发现的一个问题

聊完为什么写单元测试之后,再聊聊这几天在单元测试上的一些实践及从中得到的一些启发吧。重构完之后,得到的代码总感觉有点不太完善,但是就是说不出来哪里不对。暂时先不管吧,先去写单元测试代码了。

写单元测试的过程中,重构之后的那一部分代码的问题开始暴露出来了,比如,其中包含了不少对依赖类的属性的使用,大概格式如下面这种:

1
$obj->a->b->c

又或者像下面这种,对依赖对象里面属性下方法的调用:

1
$obj->a->b()

这样有什么问题呢?一眼看上去好像没有啥问题。但是在我写单元测试的时候发现一个问题,对于 $obj->a->b->c 这种代码,想给它们写测试的话,必须先进行一些复杂的 mock 操作,但关键是,a 里面的 b 及 b 里面的 c 属性都不是我当前类关注的内容,现在却要我去 mock 依赖对象的内部实现细节。而且如果我另一个类也需要获取 c 这个属性的时候,给这个新的类写单元测试又要 mock 一堆对象,这明显就会导致产生重复的单元测试操作。如果多个类都需要用到这个 c 属性,那样岂不是会产生一大堆重复的代码。

现在来让我们概括一下,这里的问题就是我需要了解依赖内部才能用好这个依赖的对象。我本来只是想使用依赖的对象,但上面这一种写法却需要我们去了解依赖对象内部的结构,因为对于我来说,$obj 是跟我直接交互的对象,你这个对象里面的细节我并不关心。也就是说, 上面这种写法违背了面向对象封装的特性,主要影响就是,对象的细节实现散落在对象以外的地方,也就不是所谓高内聚的代码了。如果后续这个对象的细节需要改动的时候,就需要去找到所有用到了这个细节的地方去修改,改动的地方可能会非常多。另一方面,如果原来的代码缺乏单元测试,改动越多,改动带来的风险就越大,这些潜在的风险往往会给我们带来一些心智上的负担。可能也会在上线之后不定期给我们带来惊喜。

也许可以用另外一个例子说明这个问题。我们去银行存钱的时候,跟我们交互的是银行的工作人员,具体怎么存我们不需要去干涉(我们也干涉不了),我们只需要告诉他们我要存钱然后将我们的钱和银行卡给工作人员,他们会帮我们完成具体的存钱操作(而不需要我们去告诉工作人员第一步怎么做,第二步怎么做,因为具体怎么操作是工作人员的职责范围内的事,我们不能去干涉)。其实归根到底还是 SOLID 里面的 SRP(单一职责原则)。

那该如果应对这种问题呢?目前我的做法是,将这些对依赖内部细节的实现挪动到对象本身里去,给它们定义对应的方法(它们配得上一个独立的方法)。这也是《聊聊架构》里面多次强调的权责对等,细节的东西本来属于你,现在只是把这一部分职责归还给了你。这样的结果就是,我们想了解的所有细节,都可以在一个地方找到,就是我们依赖的那个对象里面。对于外部来说,能看到的只是那个对象提供的功能(public 方法),但是这就已经足够了。

所以,单元测试一方面可以保证我们代码的可靠程度,同时在写测试的时候你会发现你代码设计得不好的地方。有个有趣的评判标准是,编写单元测试的难度与代码质量成反比。最近真的是深有体会。

单元测试之后

准确来讲,这个操作是在我写单元测试的过程中完成的,多亏我们的运维同学,给 gitlab 加上了 runner,让我得以实现在 ci 里面加上单元测试的操作(同时之前 build 的 docker 镜像又一次可以派上用场了)。这样一来,好像开始有那味了,多年以前的一些想法得以实现了。同时,看着 gitlab ci 的 job 都 passed 蛮有意思的,哈哈。

经历了重构、写单元测试、再重构的过程之后,感觉单元测试这件事也许没那么难,如果觉得单元测试写不出来,很可能是因为很多代码出现在了它们不该出现的地方。用专业一点的话来说就是,设计上有缺陷,对于这个目前也还不是太了解。不过有一条很基本的原则,可以做到的话,可能会给我们带来极大的好处,上面也有提到,就是 SRP。个人感觉这可能是所有软件设计的最基本的原则吧。

对个人而言,关于单元测试的很多困惑、认识上的误区已经解决了,接下来其实问题没那么多了,该写单元测试的时候可以写一写了,可以更好地保障一下代码质量了(但是对于生活,依然有很多困惑,诶)。

关于《聊聊架构》这本书

这本书之前听都没听过,在同事吹爆了之后,还是去看了,感觉还是有其独特的价值(可能对于很多不同角色的人都能在书里得到一些启发)。里面一些观点的角度都挺独特的,如果没有很深刻的经验、经历等想不到那些点,又或者可能是因为我读的书少吧。总之,可以一翻。

最后,分享一下最近看的其他书

《代码整洁之道》,这本书其实应该早点看的,操作性不强,但是看完之后会让你看到自己写的代码里面很多不太好的地方,也就是所谓的坏味道(code smells)。比如,之前总是强迫性地给每个方法、属性加上注释,读完之后发现很多注释是没有必要的,一方面可以通过好的命名解决,另一方面代码本身足够简单到不需要注释。

《重构》,Martin Fowler 的经典之作,看的是第二版,实操性很强,在我们需要重构的时候可以当做工具类的书籍来翻阅(不过目前他们有提供在线网站,上面也列出了所有的重构方法,可以直接到他们网站上看)。如果说《代码整洁之道》只是列出 code smells 的话,那这本《重构》就是教你怎么去除这些 code smells。同样可以在本书里发现现有代码写法上的缺陷,因为里面每一种重构的方法都给出了重构的动机,也就是为什么要用那种重构方法。

《编程的逻辑》,没有前面两本经典,但是里面讲到的从需求到最终实现的一些实践方法值得尝试一下。跟软件设计有点关系,但关注的是整一个流程,所以每一个点都不会太深入。

总结

总结一下,聊了些什么东西。 1、代码维护的时间远远大于开发的时间,单元测试的存在可以让我们放心地对代码进行一些修改。 2、单元测试一方面可以保证我们代码的可靠程度,同时在写测试的时候你会发现你代码设计得不好的地方。

其他,关于单元测试的一些认识误区,可以去《聊聊架构》里面看看单元测试这一章。

后记

其实一个多星期之前就想写一下看完《重构》之后的一些想法,但是想着虽然看完了,但是还没有实践过,似乎不是太好。现在好歹也真正地实践过了,可以写一写了,但是之前关于重构的一些想法好像没有了。没有就没有了吧,也许这就是生活吧,有些东西失去了就再也找不回来了。

另外,关于写代码这件事,真的需要很多考量的地方,需要抛却很多主观上的想法,然后对自己写的代码作出客观的评价,而这可能不是一件容易的事。不过是一件值得尝试的事。

在 linux 下执行 shell 脚本的时候,有可能会报错 line 2: $'\r': command not found

出现这种错误的原因是,shell 识别不了 windows 的换行符 \r\n

Ubuntu 解决方法

script
1
2
sudo apt-get install tofrodos
fromdos 文件名

Centos 解决方法

script
1
2
yum -y install dos2unix
dos2unix 文件名