Jekyll2025-09-01T14:30:24+08:00https://imageslr.com/feed.xmlImages’ BlogSoftware | Elon Z Images。📺【监控】研发基本功 - Grafana 使用指南 🆕2024-04-05T18:00:00+08:002024-04-05T18:00:00+08:00https://imageslr.com/2024/grafana〇、前言

Grafana 是一个开源的数据可视化和监控平台。它提供了一个灵活且强大的界面,可以连接到各种不同类型的数据源,将其中的数据以图表的形式进行展示和分析。

Grafana 的灵活性和可扩展性使其成为一个强有力的服务运维工具信息获取工具

  • 服务运维:展示服务的工程指标,如 CPU 利用率、吞吐、错误率等。
  • 信息获取:展示服务的内部状态,如实时在线人数、订单数等。

配置好 Grafana 看板,可以提升问题排查的效率:直接在 Grafana 查看各类数据,无需跳转到数据源;也可以发现事件之间的关联,例如“CPU 利用率变高,是因为请求量涨了”。

本文围绕 Grafana 在后端开发中的高频使用场景,分享了 Grafana 的基础概念、可视化、高级功能等。目标是让读者知道 Grafana 有什么功能,先留下印象,然后在需要配置看板时随时查阅。

学习 Grafana 的最好方式是亲自上手操作。本文使用 Grafana 官方网站提供的沙盒环境做演示。Grafana 的沙盒环境提供了一个测试数据源,可以声明式地生成随机时序数据,用于调试看板的功能。

💡 演示版本:Grafana v10.3.0

Grafana 界面总览

image-20240121161253079

  • ①:Dashboard 操作区 (添加行、添加 Panel、设置、保存)。
  • ②:时间范围选择区。
  • ③:自动刷新周期。
  • ④:变量选择区,见下文 Variables
  • ⑤:链接区,见下文 Links
  • ⑥:Dashboard 内容区,包含多个 Rows,每个 Row 下面有多个 Panels。

一、基础

1. URL / 链接

一个 Grafana 看板的链接如下所示:

https://play.grafana.org/d/000000012/grafana-play-home?orgId=1

从前往后依次:

  • /d 表示看板 (Dashboard),相应的,如果是一个文件夹 (Folder),那么这里是 /f

    https://play.grafana.org/dashboards/f/QQTPJnF4z/
    
  • 000000012是这个看板的唯一ID,在整个系统中不能重复。

  • grafana-play-home 是这个看板的名称。如果是中文名称,会自动转成拼音。看板的名称可以随便改,也可以重复。通过 URL 的 ID 就能确定唯一的 Grafana 看板,名称只是用来展示的。

  • ?orgId=1 是看板的参数部分。所有变量 / 参数会通过 & 连接。

每次修改看板的变量值,url 里就会增加形如 var-foo=xxx&var-bar=xxx 的字符串,这是当前看板的所有变量的取值:

image-20240121150637119

每次修改看板右上角的时间范围,或者鼠标拖动框选一段时间轴,url 里就会增加形如 from=xxx&to=xxx 的字符串,这是当前看板的时间戳范围:

https://play.grafana.org/d/000000012/grafana-play-home?orgId=1&from=1689073420099&to=1689076407767

💡 如果你的目的是希望其他人在打开链接时能够还原现场,那么需要分享带有时间范围和变量取值的完整 URL。否则,只需要看板 ID 即可,比如 https://play.grafana.org/d/000000012

2. JSON Model

Grafana 的整个看板内容 —— 包括所有的设置项 —— 都是用 JSON 描述的。这意味着我们可以直接编辑 JSON 格式的字符串,来达到修改看板的目的。某些场景下,这比操作 UI 界面更方便。

可以在“Dashboard Settings - JSON Model”找到当前看板的 JSON:

image-20240121151125411

JSON Model 中的字段说明:

  • iteration:在什么时间被修改。Grafana 通过这个字段来判断是否和其他人的修改发生冲突。
  • iduid:看板的唯一标识,即 URL 中的 ID 部分。
  • links:看板上面的链接

  • templating:看板的变量 Variables
  • panels:类型是数组,每个元素表示页面中的一个面板 (Panel,type=graph),或者一行 (Row,type=row)。

  • 所有字段说明:https://grafana.com/docs/grafana/latest/dashboards/json-model/

JSON Model 的典型使用场景:

  • 复制整个 Dashboard
  • 批量复制或修改多个 Panels
  • 批量替换所有 Panels 的 DataSource 或 Metrics 前缀
  • 批量为所有 Panels 注入 Query 对应的外部链接 (需要编写代码解析和修改 JSON Model)

💡 可以直接在 VS Code 等编辑器里修改 JSON Model,也可以用 JavaScript 或 Python 代码修改。修改后的内容粘贴到设置页,保存即可生效。但要注意,以下几个字段必须使用原来的值,不能随意替换:iduiditeration。否则会报错:“Dashboard has been changed by someone else”。

3. 版本控制

在“Dashboard Settings - Versions”可以看到最近的更改历史,可以回滚。

image-20240121160708350

4. 文件夹 / 权限控制

Grafana 的权限控制遵循 RBAC 策略。

Grafana 提供以下三种角色:

  • Viewer:可以查看仪表盘,但不能修改
  • Editor:可以查看和修改仪表盘
  • Admin:可以管理整个仪表盘、分配权限

Grafana 的权限可以在以下两个层级配置:

  • 仪表盘:一个 Dashboard 权限。
  • 文件夹:一个文件夹可以包含多个 Dashboard。拥有该文件夹权限的用户,会自动拥有文件下所有仪表盘的权限。

在“Dashboard Settings - Permissions”可以修改他人权限。权限可以分配给个人或团队。

如果想创建一个”只读”的看板,只需要将 Editor 的权限从Edit改成View

5. 复制 Dashboard

💡 为了方便练习和保存,可以先把 Grafana 官方沙盒看板复制一份。

(1) 如果有看板的编辑权限,进入看板的设置页,点击 Save As... 即可: image-20240121153633234

(2) 如果没有编辑权限,可以点击「Share - Export - View JSON」,复制 JSON Model。 image-20240121153808991

接下来有两种导入方式:

① 方法一:”New Dashboard - Import → Import via dashboard JSON model”,粘贴 JSON Model 内容,Load。然后修改 Name 和 UID,否则会报错。

image-20240121154205507

image-20240121154205507

② 方法二:新建一个空白看板,进入该看板的「Dashboard Settings - JSON Model」,使用该看板的 iduidtitleiteration 字段的值,合并到刚才复制的 JSON Model 中,整体粘贴覆盖,保存。

6. 复制 Panel

如果有编辑权限

  1. Panel 菜单 → More → Copy:

    image-20240121154733223

  2. Add panel → Paste panel

    image-20240121155038233

如果没有编辑权限

  1. Panel 菜单 → Inspect → Panel JSON: image-20240121154904497

  2. 将这个 Panel JSON 复制到目标 Dashboard 的 JSON Model - Panels 中。或者参考这个 Panel JSON,手动配置一个一样的 Panel。

7. 保存看板默认状态

看板的所有状态都可以被保存。包括:

  • 当前选择的时间范围
  • 当前各个变量的值
  • 当前每一行是展开还是折叠的
  • 右上角的自动刷新周期

建议:

  1. 建议设置好看板的默认状态,比如:选择最近 3 小时、选择默认机房、展开核心指标行、其他行默认折叠。
  2. 点击 Save,勾选以下两项,保存看板的默认状态:

    image-20240121160835012

  3. 分享仅带有 /d/{unique_id} 的简单 URL,比如 https://play.grafana.org/d/000000012

二、变量 Variables

变量 (Variables) 是 Grafana 的一项强大功能,可以用于创建动态的、可配置的、模板化的仪表盘。比如创建一个通用大盘,监控多个服务,而无需为每个服务创建单独的看板。

1. 配置变量

变量类型

变量可以在“Dashboard Settings - Variables”配置。

image-20240121225242430

Grafana 提供了多种变量类型:

  1. Query:使用数据源的查询语言,动态获取可选项。
  2. Custom:手动定义一组可选项,是一组用逗号分隔的值列表,这些值将作为下拉菜单的选项。
  3. Textbox:添加一个文本框,用户可以输入任意文本。
  4. Constant:常量,在仪表盘的变量区域不可见。
  5. Interval:定义一组时间间隔,可以用于更改仪表板上的时间范围或聚合级别。
  6. Data source:这种类型的变量在有多个数据源时特别有用,允许用户动态切换数据源。
  7. Ad-hoc filters:动态添加、修改或删除过滤条件,仅支持某些数据源,如 ES、InfluxDB。

下面将依次介绍使用频率最高的变量类型:CustomTextboxQuery

Custom

image-20240121230253110

  • ①:提供几个固定的选项,逗号分隔。

  • ②:默认单选,可以支持多选。

  • ③:当允许多选时,可以有一个“全选”的选项。

  • ④:“全选”的默认值是所有值拼起来,如{value1, value2, ...}。 可以自定义一个值,如*

  • ⑤:Custom 变量的选择框也是输入框,可以临时输入一个不存在于固定选项中的值,如下图。

image-20240121230355503

Textbox

简化版的 Custom。就是一个输入框,可以输入任意值。

image-20240121230508338

Query

Custom 类型的变量只能提供固定的值列表,而 Query 类型的变量可以实时查询某个 metrics name 下的某个 tag 的取值。典型的应用场景是“获取服务的所有上游 / 下游”。

下面以 OpenTSDB 数据源为例,演示 Query 类型变量的使用方式:

image-20240121230725233

  • ①:这里是 OpenTSDB 提供的查询语法,详见官网,作用是获取 Metrics 打点 throughput 下的 from tag 的所有值。其时间范围默认是最近一个小时。
  • ②:建议将选项列表设置为字母顺序排列,方便查找和选择
  • ③:如果 Query 中用到了别的变量,需要将Refresh设置为On Dashboard Load或者On Time Range Change

Query Options 中的“Regex”可以用来过滤字段。比如只保留 test_ 开头的值:

/^test_/

另外,用正则的捕获组,可以把 test_ 前缀去掉,只保留后面的内容。典型的使用场景是:Query 返回了 test_footest_bar,但需要提取其中的 foobar 用在 Panel 中:

/((?<=test_)*)/

https://grafana.com/docs/grafana/latest/variables/filter-variables-with-regex/

💡建议:能用 Query 尽量不要用 Custom,这样能保证看板的通用性。

💡 在配置 Panel 时,经常会出现foo=*这样的语法,用来枚举foo的所有可能取值。这种情况下,建议将foo配置为一个 Query 变量,使用foo=${foo}。不仅默认效果一样,必要时还可以按值过滤下钻,逐步定位问题。

变量默认值

1.7-保存看板默认状态 所述:

  • 默认情况下,当选择了某个变量后,变量的值会附在 URL 里,以 var-xxx 开头:
      https://play.grafana.org/d/000000012/grafana-play-home-copy-2?orgId=1&var-query0=123&from=1705823477666&to=1705824050985&var-foo=1233&var-bar=213&var-custom=1&var-custom=2&var-from=222
    
  • 保存看板时,会提示是否要保存当前选择的变量值作为默认值:

    image-20240121160835012

  • 保存默认值后,访问不携带任何参数的看板链接:https://play.grafana.org/d/000000012,就会自动加载变量的默认值。

因此,建议保存变量的默认值。一般来说,默认值都是 All,然后让用户自己过滤。某些变量可以设置成一个主要的值,避免面板上曲线太多,对用户产生干扰。

2. 使用变量

基本使用

使用变量的语法是$varname${varname}。推荐使用后一种,因为在某些场景下,grafana 无法正确区分变量名的边界,比如把$service.xxx.xxx识别成一个变量,但实际上应该是$service

在任何地方都可以直接使用变量:

  • 标题
  • 链接
  • Query
  • Alias
  • Tags

💡 Grafana 官方的演示看板:Templated dynamic dashboard。基于变量实现了一个动态看板,变量的值来自 Graphite Query。

变量修饰符

变量会被替换为一个字符串。当某个变量可以多选时,Granafa 默认会生成{foo,bar}形式的字符串。可以通过修饰符控制生成的字符串形式。

常用的修饰符:

  • ${var:pipe}:生成foo|bar。这个是 OpenTSDB tags 能够识别的语法,因此建议 tags 中所有变量值都写成:pipe的形式。
  • ${var:queryparam}:生成var=foo&var=bar。如果希望通过 URL 传递变量,需要这样写。
  • 所有修饰符见 Grafana 文档

💡 Grafana 官方的演示看板:Template VariablesFormatting Options。修改 servers 变量,查看不同修饰符的渲染结果。 image-20230722221428085

内置变量 / 全局变量

Grafana 内置了一些全局变量,比如时间范围$__from$__to,或者$__all_variables (所有变量的当前取值,表示为 url query parameters 形式) 等。详见 Grafana 文档

全局变量也支持修饰符。$__from$__to还支持如下的日期格式化语法:

image-20240121232653652

💡典型使用场景:配置了跳转到其他看板的链接,希望附带当前看板的所有状态 (变量值、时间范围等)。Grafana 官方 Demo:内置全局变量

3. 变量联动 / 键值对映射

变量联动是生产环境中的常见需求,但当前 Grafana 没法很好支持。一个典型的场景是根据地区选择相应的服务器:

   A    |  B 
------- | -------
cn      | shanghai
us      | new_york, silicon

解决方法是:

  • A 配置成一个 Custom 变量,值是 cn, us
  • B 配置成一个 Query 类型的变量,值形如 Query($A)

B 的具体语法依赖于选择的数据源。可以把 A → B 的映射存到 Prometheus 等数据库来筛选,但这样比较麻烦。更简单的方法是实现一个类似 httpbin.org 这样的 Echo 服务,并注册为一个数据源。这种 Echo 服务会将传递过来的参数原封不动地返回,比如访问 https://httpbin.org/get?cn=shanghai&us=new_york&us=silicon 会得到以下结果:

{
  "args": {
    "cn": "shanghai", 
    "us": [
      "new_york", 
      "silicon"
    ]
  }
}

然后使用 jsonPath: $.args[$A] 就能获取到 A 对应的 B 的值了。

具体实现略,欢迎补充。如果有需求,请联系公司的 Grafana 管理员。

4. 变量循环 / Repeated

Grafana 提供了 Repeated rows 和 Repeated Panels 功能,可以根据变量的值动态复制行或面板的布局。

以行重复为例。在行标题旁边点击齿轮图标,打开“Row Options”,可以看到一个名为 “Repeat for” 的选项。在这里选择要按哪个变量重复,然后保存。

image-20240121235350674

然后这一行便会按照该变量的当前取值重复多次,行内的所有面板均会被复制。在重复行内,访问重复变量名 $custom 将会获取到单个值,而不是所有值,如下图所示。

image-20240121235543708

image-20240121235615376

💡 Grafana 官方的演示看板:

三、数据源 Data Sources

在配置 Grafana 看板时,需要在“Query”区域选择一个数据源:

image-20240405225426753

Grafana 有两种常见的数据源:OpenTSDBBosun。Grafana 的数据源需要在管理员后台配置,这里我们假设读者了解这两个数据源、且公司已经在 Grafana 系统中配置好了这两个数据源。接下来介绍这些数据源的使用方法。

Grafana Play Ground 还提供了一个测试数据源,可以声明式地生成随机时序数据。在后面的“可视化”章节中,我们会使用这个测试数据源生成示例数据。

1. OpenTSDB

img

  • Aggregator (第一行):聚合方式,avg 或 sum。
  • Alias:提供一个可读的别名。常见的使用方式是配合“Filters / Tags”,比如有个 tag key 的名称为 cluster,alias 就可以配置为 cluster=$tag_cluster,当有 defaulttest 两个 cluster 时,会显示 cluster=defaultcluster=test

    image-20240405230738145

  • Filters / Tags:根据 tags key=value 过滤。这两个是互斥的,配一个就可以。推荐用 Filters。
    • literal_or:包含,支持 .* 通配符
    • not_literal_or:不包含
    • regexp:POSIX 兼容格式的正则表达式
    • group_by (仅 Filters 有此选项,Tags 总是 true):如果勾选上,那么同名的 tag_value 会聚合成一条曲线
  • Rate:对应 OpenTSDB 的 rate_counter 类型的打点。选择后会出现“Counter”选项。
  • TopK:仅保留若干个最高/最低值,适用于按 Tag 分组后曲线过多的场景。比如展示所有主机的 CPU 利用率时,可以仅展示 top 10 和 bottom 10。

💡 关于 OpenTSDB 数据源的更多配置说明,详见 Grafana 官方文档

2. Bosun

所有 OpenTSDB 类型的都可以写成 bosun 的形式。举个例子,假设我们有一个OpenTSDB 查询,它使用以下参数:

  • metric:system.cpu.usage
  • aggregator:sum
  • tags:host=*
  • downsample:5m-avg

将这个 OpenTSDB 查询转换为 Bosun 表达式后如下所示:

$q("sum:5m-avg:system.cpu.usage{host=*}")

但是 bosun 的可读性不高,除了以下场景外,不建议使用 Bosun,尽量使用 OpenTSDB

  • 计算错误率。使用 Bosun 可以表示两个时序打点的除法:

     $succ = q("sum:rate{counter}:success.throughput", "$start", "")
     $error = q("sum:rate{counter}:error.throughput", "$start", "")
     $error / ($succ + $error)
    

💡 关于 Bosun 语法的更多说明,详见 Bosun 官方文档

3. Grafana Test Data

TestData data source 是 Grafana 官方提供的一个测试数据源,用于生成模拟的时序数据,非常适合用来测试看板的功能。Grafana 的沙盒环境内置了这个数据源,我们在第五节“可视化”章节中会使用到。

img

  • Random Walk:随机数,可以指定 Series num

  • Slow Query:指定返回数据的耗时

  • CSV Context:自己粘贴一个 CSV 数据,比如:

    Name,Value,Unit,Color
    Temperature,10,degree,green
    Pressure,100,bar,blue
    Speed,30,km/h,red
    

四、界面 Rows / Panels

这一节介绍了“行”和“面板”的基础操作和配置。

1. 行 Rows

在 Dashboard 的右上角添加行:

image-20240405232212269

当行折叠起来时,最右边会有一个 Handler (下图①),点击拖动可以调整该行的位置:

image-20240405232114641

鼠标移动到行标题上,会出现一个齿轮和删除按钮 (上图②)。点击齿轮,可以修改行的标题,或配置按变量重复行 (见 2.4)。

2. 面板 Panels

面板 (Panel) 右上角的菜单提供了以下功能:

image-20240405232533826

  • View:在整个网页窗口里,放大查看这个面板
  • Edit:编辑面板,高频使用
  • Share:分享该面板的链接
  • Explort:基于该面板配置的 Query,调整查询参数、时间范围或可视化形式
  • Inspect:查看该面板的 Data 和 JSON Model (在没有编辑权限的时候,可以通过 JSON 查看面板配置的 Metrics 名称是什么)

鼠标移动到面板的标题区域,鼠标指针会变成一个十字,拖动可以移动面板。

3. 提升面板的可读性

点击“Edit”进入面板的编辑页,右边提供了一系列配置项:

image-20240405233608653

强调以下几个功能,提高面板的可读性和信息量,使其更易用:

  • 标题:面板的标题需要和 metrics 含义一致
    • cpu.utilizationCPU 利用率
    • xxx.calledby.successXXX 接口成功 QPS
  • 描述:为看板补充必要的、更详细的描述信息,用户将鼠标移动到图标上时会展示 image-20240405234150418

  • Tooltip 降序排列:和变量要按字母序排列一样,鼠标浮动到面板上展示的 Tooltip 要降序排列,降序排列后刚好和所有曲线从上到下的顺序一致。Panel Settings - Display - Hover tooltip - Sort Order - Decreasing (不同版本的 Grafana,配置项的路径可能有区别)

  • Legend 按表格展示,适用于曲线分组较多的场景:Legend - 勾选“Show As Table” + “Max / Avg / Current”,按 avg 降序排列 (不同版本的 Grafana,配置项的路径或名称可能有区别,比如下图最新版的 Grafana 中 avg 被替换成了 Mean)

    image-20240405235749133

    • Show As Table:按表格展示,可以方便地升序或降序排列。
    • Avg (Mean):了解这段时间范围的整体情况,推荐设置为默认排序方式。
    • Max:有时候有用,比如想查看晚高峰的 CPU 利用率峰值。
    • Current (Last):看实时变化,比如错误数有没有下降。
  • 设置合适的单位:Panel Settings - Axes - Unit。常用的场景:
    • QPS:Misc - short
    • 利用率:Misc - Percent (0.0~1.0)
    • 带宽:Data - bytes
    • 延迟:Time - microseconds (us)
  • Alias:应当包含尽可能多的信息量。
    • 默认的曲线名称是根据 Query 配置自动生成,形如 metrics_name{key1=value1, key2=value2},比如 X 调用 Y 服务的 foo 接口,名称默认是 throughput{from=X, to=Y, api=foo},可读性很差。
    • 建议配置一个更直观的 Alias,比如上面的例子,可以改为 $tag_from → $tag_to::$tag_api(),展示出来形如 X → Y::foo(),能直观看出打点的含义。
  • 配置和面板 Query 含义一致的链接:用户发现某个面板的数据有异常后,经常需要基于该面板的 Query,做更进一步的查询。这里可以用 Grafana 面板自带的 Explore 功能,但更多时候用户会跳转到另一个平台,比如 OpenTSDB 数据源总是有一个配套的 Metrics 平台、数据库总是有一个 SQL 平台… 可以将 Query 对应的平台链接附在 Panel 上,用户就可以在左上角“描述”区域直接点击链接跳转。Panel Settings - Links - Add。

    image-20240406000638035

    • URL 里可以使用变量。常见的是将 Query 用到的变量和 Grafana 当前选择的时间范围附在 URL 里传递过去,这样用户点开链接后,看到的是和 Grafana 面板一模一样的数据,能直接配置其他筛选条件。
    • 勾选“Open in new tab”

💡 面板的可读性越高,排查问题的效率越高:

  • 可读性低的表现:总是需要编辑面板,查看 metrics 名称和 tags 是什么。
  • 可读性高的表现:通过面板标题和曲线名称,就可以直观地获取这些信息。

五、可视化 Visualizations

这一小节会介绍 Grafana 的几种可视化形式及其配置项。建议在 Grafana 沙盒里编辑测试,会更直观。如果要获取看板设计的灵感,可以参考附录中的 Grafana Demo。

Graph 折线图

90% 以上的场景,用折线图就够了:

image-20240406001337537

💡 Grafana 官方的演示看板:① Time Series 时序图(折线图)总览② 每个配置项的细节

以 Grafana 最新版沙盒为例,面板编辑页的常用配置项 (从上到下):

  • Alias、Tooltip 降序排列、Legend Show As Table:略,见上文

  • Axis:
  • Graph styles:
    • Lines、Bars、Points:显示为折线图、柱状图还是散点图
    • Line width:线宽
    • Fill opacity:折线图是否要有填充,建议设置为 0
    • Line style:线条样式,实线 or 虚线 or 点状,适用于同比视图中,区分今天和昨天的曲线
    • Show points:是否展示每个数据点 (默认只展示折线)
  • Standard options - Unit:建议配置合理的单位,以提升看板的可读性
    • QPS:Misc - short
    • 利用率:Misc - Percent (0.0~1.0)
    • 带宽:Data - bytes
    • 延迟:Time - microseconds (us) override
  • Data links:添加链接,详见 4.3-提升面板的可读性

  • Value mappings:按条件将某个值映射为其他值,比如将 P0P1 映射为核心非核心

  • Thresholds:添加一条阈值线 (或填充区域)。可以配置阈值的颜色和值,默认大于阈值的区域会被填充颜色,如果想表达“低于阈值”时是异常情况 (比如服务 SLO 指标),可以替换下图中 Base 和 80 的颜色。

    image-20240406004127227

  • Series Override:一个很有用的功能,按照正则表达式匹配曲线名称,然后修改这些曲线的属性。常见的使用场景是:(1) 为某些特殊的曲线指定颜色,比如总是高亮核心服务;(2) 将名称中包含 test 的曲线设置为虚线,表示这些是测试数据;(3) 在一个面板中画两条曲线,其中一条曲线的纵轴在左侧,另一条曲线的纵轴在右侧 (修改 Y-Axes)。

Pie 饼图

饼图适合表示各项数据的占比:

image-20240406010555305

💡 Grafana 官方的演示看板:饼图和柱状图Piechart 饼图

在旧版本的 Grafana 中,提供了一个“Combine (only for percentages)”配置项,可以将占比低于指定阈值的数据都聚合成”Others”,这样饼图就不会出现很多占比非常小的区域了。

Bar 直方图 / 柱状图

image-20240406010043454

💡 Grafana 官方的演示看板:饼图和柱状图

  • Stacking:是否要将所有直方图堆叠起来。适合展示一些关心每个时刻总和的指标,比如当前有多少台机器 Coredump 了。

Gauge 水位线

Gauge 适合展示总体水位,比如带宽是否满了,或者服务稳定性是否跌破阈值。

image-20230722231032773

💡 Grafana 官方的演示看板:GaugeBar Gauge

Stat 统计

突出显示当前时刻的值,可以在底部以阴影方式显示这段时间的曲线。

image-20230722230754838

💡 Grafana 官方的演示看板:Stat 统计

Text / Markdown / HTML 文本

Text 类型的面板支持写 Markdown 或者 HTML。

Markdown 适合写看板的使用说明:

image-20240406011449181

HTML 可以写更复杂的内容,比如把另一个 Grafana 内嵌到当前 Grafana:

<iframe 
src="proxy.php?url=https://{grafana链接}?kiosk=tv&${__url_time_range}" 
width="100%" height="100%" frameborder="0"></iframe>

上面 src 里的 kiosk=tv 改成 kiosk,被嵌入的看板就没有标题栏、变量栏、Links 了,融合度更好。

Grafana Dashboard 顶部可以展示链接,通常会在这里附加其他看板和相关文档的链接。

image-20240406132300574

添加链接:Settings → Links → New link,点击右边的 ↑ ↓ 箭头可以调整顺序。

image-20240406132130917

配置项:

image-20240406132127748

  • 链接标题
  • 链接类型:Dashboards 或 Link,前者是罗列当前系统里的所有看板,后者是自定义链接。
  • URL:链接地址。这里可以传递看板里的变量,比如配置了 xxx.com?${foo:queryparam},如果当前看板的 foo 变量取值为 123,则会生成这样的链接:xxx.com?foo=123
  • Tooltip:鼠标移上去会有一个提示说明。
  • include current time range:点击链接跳转时,在 url 里传递当前看板选择的时间范围 from=xxx&to=xxx。如果链接是另一个看板,则推荐勾选。
  • include current template variable values:点击链接跳转时,在 url 里传递当前看板选择的所有变量 foo=xxx&bar=xxx&baz=xxx。如果链接是另一个看板且变量配置是一致的,则推荐勾选。
  • Open link in new tab:在新标签页打开,推荐勾选。

七、其他 Others

Dashboard Settings:

  • Auto refresh:配置不同的自动刷新间隔,在右上角选择,之后看板会每隔有单时间就自动刷新

    image-20240406140354809

    image-20240406140443330

  • Now delay:某些数据源的数据有延迟,最新时刻的数据可能不准,监控上会出现掉底。解决办法是配置 Now delay,总是丢弃掉最近 30s 的数据。

    image-20240406140723421

  • 打开 Crosshair:Dashboard Settings - Graph tooltip,在不同 Panel 中同步显示当前鼠标所指的时刻。

    image-20240406140721056


交互:

  • 在 Grafana Panel 里拖动可以选择一段时间范围,放大查看数据:

    image-20240406140559946


  • Grafana Panel 编辑页“Query”右边还有一个“Transform data”功能。通过 Transform 可以对 Query 结果相加、相除或合并等。典型的应用场景是“分别配置成功吞吐和失败吞吐的两个 Query,然后配置一个 Transform,计算错误率”,好处是相比于 Bosun 表达式可读性更高。

    image-20240406141137156

附录

Grafana 官方示例看板

Grafana 官方提供的所有示例:https://play.grafana.org/dashboards

除了上文已经列出来的示例,这里收录了一些其他可能有用的看板:

完整 Demo:

服务监控面板应该如何建设

一个服务级别的看板,应当包含这几行:

  • 服务整体 (CPU、内存、吞吐、延时等核心指标)
  • 上游调用 (上游成功吞吐、失败吞吐、Error Code、错误率、延时 Avg、延时 P99)
    • 这里要在参数和 tags 里配置 from,区分不同上游;配置 method,区分不同接口。

  • 调用下游 (调用下游成功吞吐、失败吞吐、Error Code、错误率、延时 Avg、延时 P99 等)
    • 这里要在参数和 tags 里配置 to,区分不同下游。

  • 异常情况 (错误日志、Coredump、Panic 等)
  • 内部状态 (视服务而定,如候选条数、缓存大小等)
  • 分步骤延时
  • 单机视图 (CPU 利用率、内存利用率、Error QPS 等 Top 10 的 Hosts、Pods)
  • 分 Env 视图 (分线上环境、测试环境、小流量环境查看 CPU 利用率、内存利用率、Error QPS 等)

TODO:通用服务大盘,目前仅在字节内网可用

]]>
Images。
☑️【2024】TODO LIST2024-01-01T12:00:00+08:002024-01-01T12:00:00+08:00https://imageslr.com/2024/todo去年工作较忙,很少更新博客。新的一年,希望能保证至少一个季度一篇博客。对博客的要求依然是长期有价值。下面是想到的一些专题,后续再看有没有其他灵感:

  • Grafana 使用指南》:Grafana 是一个开源的监控展示平台,也是后端开发人员排查问题的必备工具。虽然 Grafana 官方提供了详细的文档,但全英文 + 超长篇幅,阅读成本过高。这篇文章会围绕 服务运维信息获取 中的高频使用场景,分享 Grafana 的基础概念 (JSON Model、Query、Variable)、可视化配置 (Panel、各类图表)、高级技巧 (变量联动、内嵌 HTML 页面)。同时,文章会讨论一个标准后端服务的稳定性看板应该如何建设,并提供一个覆盖全文技巧的看板示例。
  • 如何写技术文档》:文档本身是一个信息传递工具,在多团队、多业务的工作模式下,写文档和写代码一样重要。然而大部分同学在写代码方面花了较多时间,但写文档比较随意,造成后续理解和沟通成本高。技术文档话题多样,相比于律师/财务等职业的文档,很难用统一的标准与框架来约束。因此,技术文档的好坏和文笔无关,和思维有关。这篇文章会围绕如何写技术文档,分享这些内容:
    • 目标:让没有任何经验或上下文的读者能很快理解文章。
    • 思维:明确受众、规划结构、撰写内容。
    • 三趟式的写作流程:收集资料 (召回)、粗写、精写。
    • 其他技巧:样式规范、画图技巧、Markdown、快捷键、锚点
  • 工作三周年的感悟》:马上工作三周年了,工作方向调整了几次,也逐渐从实习生成长为模块负责人、校招生 Mentor、面试官。希望能做一次系统的总结,为自己的下一阶段做准备,也供其他同学参考。会覆盖这些话题:
    • 做好工作:工具 (OKR、GTD)、思维 (分析问题、理解需求、提出方案;解决根本问题、通用化设计、面向长期)。
    • 团队合作:和同事沟通、和上级沟通、如何带新人。
    • 职业规划:定期复盘、短期和长期规划、里程碑。广度和深度。
    • 其他问题:判断某件工作的价值、判断自身的价值、精力与优先级分配。

其他内容暂时没有想到。如果读者有建议,欢迎在评论区留言。

去年其实也围绕工作内容,在公司内网分享了几篇文章,包括研发基本功 (IDE、稳定性保障、服务看板建设、C++ 单元测试)、混排串讲、特征工程串讲等。但考虑到我的博客读者大部分是校招新人,这些和具体工作相关的内容就不在这里分享了。

]]>
Images。
🧑🏻‍💻【社招/校招/实习】字节跳动 Ads Infra 团队 🔥2023-07-11T09:00:00+08:002023-07-11T09:00:00+08:00https://imageslr.com/2023/ads-infraImages。💻【C++】研发基本功 - GTest / GMock 单元测试实践手册2023-06-10T12:00:00+08:002023-06-10T00:00:00+08:00https://imageslr.com/2023/gtest一、前言

📌 本文来自 Ads Infra 内部分享,欢迎加入 👉🏻

作为架构部门,我们的很多核心仓库都是 C++ 编写,目前基本都有 80% 的增量单测覆盖率卡点。编写单测的好处不言而喻:通过构造各种 case,可以发现空指针、大数越界等肉眼不容易发现的 bug。此外,单测也可以在不引流的情况下,测试功能是否正确。因此,编写单测是必要的,为新增代码补充单测是每个研发同学的基本功

但是,C++ 编写单测也是最麻烦的。根据日常观察,大部分同学没有系统地写过单测,基本依赖照抄现有代码,单测写得慢,且不标准。此外,没有掌握常见的调试技巧,主要通过 cout 逐行打日志和重新编译来定位问题,进一步降低了单测编写效率。

本文旨在解决上述问题:

  • 本文的受众:开发过 C++ 模块、知道 GTest / GMock 的基本使用、编写过单测代码、能完成简单场景的单测需求、但对于复杂的代码则无从下手的同学;写单测感觉很不爽、知道痛点在哪儿、但不知道如何解决的同学;平时 review 代码只看业务逻辑、不看单测合理性的 reviewer 同学。
  • 本文的内容:分享 GTest、GMock 的核心用法、常用技巧 + 单测编写的思路 + GDB 调试方法。只讲最必要的、最常用的内容,能覆盖大部分场景的单测需求。不讲花活儿,但会引用外部文档供扩展阅读。
  • 本文的目标:(1) 通过分享上述内容,让大家系统掌握单测编写和调试方法,写起来更丝滑,查问题更高效。(2) 对齐认知,让单测真正发挥作用。知道什么是正确的、有效的、好的单测,并写出这样的单测。知道什么是无效的、差的单测,并避免写出 / 合入这样的单测。以对待线上代码的标准来对待单测。

二、Hello, world:从一个单测示例开始

为下面这段代码编写单测:

int check_threshold(RequestContext ctx, Ad ad) {
    if (ad.pricing == CPT) {
        return -1;
    }
    if (ad.pricing == CPM) {
        if (ctx.params.use_stable_thresh || ad.use_stable_thresh()) {
            return 2;
        }
        return ctx.get_threshold(ad);
    }
    ...
}

编写出来的单测代码可能是这样的:

// Case 1
TEST(ChecksThresholdTest, CheckThreshForCPT) {
    // 1. 构造输入
    RequestContext ctx;
    Ad ad;
    ad.pricing = CPT;
    
    // 2. 检查输出
    EXPECT_EQ(check_threshold(ctx, ad), -1);
}

// Case 2
TEST(CheckThresholdTest, CheckThreshForCPM) {
    // 1. 构造输入
    MockRequestContext ctx;  // 这是一个 GMock 对象
   
    // 使用大括号分隔不同 case
    {
        Ad ad;
        ad.pricing = CPM;
        ctx.params.use_stable_thresh = true;
        EXPECT_EQ(check_threshold(ctx, ad), 2);        
        ctx.params.use_stable_thresh = false;  // reset
    }
        
    // 上面对于 if(a||b) 的分支来说,只达到了 50% 分支覆盖率
    // 尝试达到 100% 覆盖率
    {
        Ad ad;
        ad.pricing = CPM;
        ad.should_use_stable_thresh = true; // 假设 ad.use_stable_thresh() 函数内部用了这个字段来判断
        ASSERT_TRUE(ad.use_stable_thresh()); // 上一行修改是为了控制这个函数的结果,所以最好 ASSERT 一下
        EXPECT_EQ(check_threshold(ctx, ad), 2);  
    }
  
    // 默认分支
    {
        Ad ad;
        ad.pricing = CPM;
        EXPECT_CALL(ctx, get_threshold).WillOnce(Return(100));
        EXPECT_EQ(check_threshold(ctx, ad), 100);
    }
}

涉及到的方面:

  • 构造输入:手动
  • 检测输出:EXPECT
  • 控制外部函数的返回值:EXPECT_CALL
  • 分支覆盖率:对于 if(a||b),需要分别构造 a == trueb == true 两个 case。

三、GTest

3.1 基本概念:Test Suite、Test Case

Test Suite

TEST(TestSuiteName, TestCaseName) {
    // 单测代码
    EXPECT_EQ(func(0), 0);
}
  • TestSuiteName 用来汇总 test case,相关的 test case 应该是相同的 TestSuiteName。一个文件里只能有一个 TestSuiteName,建议命名为这个文件测试的类名。
  • TestCaseName 是测试用例的名称。建议有意义,比如“被测试的函数名称”,或者被测试的函数名的不同输入的情况。
  • TestSuiteName_TestCaseName 的组合应该是唯一的。
  • GTest 生成的类名是带下划线的,所以上面这些名字里不建议有下划线。

Test Case

一个 TEST(Foo, Bar){...} 就是一个 Test Case。考虑到构造输入有成本,通常一个 TEST(Foo, Bar) 里会反复修改输入,构造多个 case,测试不同的执行流程。这里建议用大括号分隔不同的 case,整体更条理。另一个好处在于:每个变量的生命周期仅限于大括号内。这样就可以反复使用相同的变量名,而不用给变量名编号。

TEST(Foo, bar) {
    // case 1: enable = true
    {
        Context ctx;
        params.enable_refresh = true;
        ASSERT_EQ(ctx->is_enable_fresh(), true);
    }
    
    // case 2: enable = false
    {
        Context ctx;
        params.enable_refresh = false;
        ASSERT_EQ(ctx->is_enable_fresh(), false);
    }
}

此外,如果待测函数十分复杂,建议拆分多个 TEST(Foo, Bar){...},避免 Test Case 代码膨胀。比如:

// 待测函数
int foo(Ad ad) {
    if (!ad)
        return -1;
    switch(ad.pricing) {
        case CPT:
            ...
        case GD:
            ...
    }
}
// 输入为空
TEST(Foo, IsNil) {
    ...
}

// 输入是 CPT 广告
TEST(Foo, IsCpt) {
    ...
}

// 输入是 GD 广告
TEST(Foo, IsGd) {
    ...
}

3.2 善用 TEST_F,避免写重复的代码

GTest 提供了多种测试宏,其中最为常用的是 TESTTEST_F,它们的区别如下:

  1. TEST:这是最基本的测试宏,代表一个最小测试单元。在执行 TEST 宏时,gtest 会为每个 TEST 定义一个独立的实例,使其互相隔离,避免对同一个变量进行修改或共享等可能带来的副作用。
  2. TEST_F:这是 TestFixture 的测试宏。TestFixture 是一个类,可以在多个测试用例之间共享数据结构或方法。对于同一个 Test Suite 的所有 Test Cases,会创建一个 TestFixture 对象,其 SetUp 函数会在每个 Test Case 执行之前被调用,而 TearDown 函数则会在每个 Test Case 执行之后被调用。

使用 Test Fixture Class,可以避免写重复的代码:

  • 将共享的变量作为成员变量,可以在 test case 中直接访问;变量初始化、回收逻辑放到 SetUp()、TearDown()
  • 提供公共方法,可以在 test case 中直接使用

示例代码:

class FooTest : public ::testing::Test {
protected:
  // 在每个 Test Case 运行开始前,都会调用 SetUp,这里可以初始化
  void SetUp() override {
    ctx = RequestContext("123");
  }
  
  // 在每个 Test Case 运行结束后,都会调用 TearDown
  void TearDown() override {}
  
  // 所有 Test Case 都可以直接访问这些变量和方法
  Ad new_ad() { return Ad(ctx); }
  RequestContext ctx;
};

TEST_F(FooTest, enable_foo) { // 这里会初始化 FooTest 对象
  ctx->params.enable_foo = true; // 可以访问 FooTest 中的变量
  auto item = new_ad(); // 可以调用 FooTest 中的方法
  ...
}

// 每个 test case 都是独立的,这里会初始化另一个 FooTest 对象
TEST_F(FooTest, OnTestProgramStart) { 
  // ...
}

实际使用技巧:

  • 共享一些变量,比如预先初始化好单测依赖的 Context 对象
  • 封装一些公共方法,尤其是构造通用数据对象的方法
  • 派生更多子类:
    • 建议每个服务有一个公共的 BaseTestFixture,继承 ::testing::Test,封装全局通用的方法
    • 其他单测可以再继承 BaseTestFixture,提供某个测试场景下共享变量和方法

3.3 断言:EXPECT 与 ASSERT 宏

用来判断某个变量的值是否符合预期。前者在校验失败时会打印失败信息,然后继续运行。后者会直接终止。

💡 正确使用 ASSERT 和 EXPECT 前缀:

  • 如果某个判断不通过时,会影响后续步骤,要使用 ASSERT。常见的是空指针,或者数组访问越界。

    如果某个 EXPECT 失败会导致后续一连串 EXPECT 失败,那么第一个 EXPECT 应该换成 ASSERT。这就像编译时的报错信息,往往只有第一个是有用的,其他错误都只是刷屏。

  • 其他情况,可以使用 EXPECT,尽可能多测试几个用例。

下面罗列一些最常用的 EXPECT 宏,把前缀换成 ASSERT 也可以使用。完整列表见文档

(1) 一元 / 二元比较

  • EXPECT_TRUE(foo)EXPECT_FALSE(foo):判断一个变量是否是 true 或 false。
  • 二元比较:
    • EXPECT_EQ(foo, bar):判断两个变量是否相等。
      • 只要重载了==运算符就可以,所以也可以判断两个 vector 是否相等。
    • EXPECT_NE(foo, bar):判断 foo != bar。
    • EXPECT_LT(foo, bar):foo < bar,less than。
    • EXPECT_LE(foo, bar): foo ≤ bar,less or equal。
    • EXPECT_GT(foo, bar):foo > bar,greater than。
    • EXPECT_GE(foo, bar): foo ≥ bar,greater or equal。

(2) 浮点数比较

  • EXPECT_DOUBLE_EQ(foo, 0.1):浮点数比较不能使用 EXPECT_EQ
  • EXPECT_FLOAT_EQ:同上。
  • EXPECT_NEAR(foo, bar, abs_val):判断两个数字的绝对值相差是否小于等于 abs_val。

    double pi = 3.141592653589793238;
    double approx_pi = 3.14;
    EXPECT_NEAR(pi, approx_pi, 0.01);  // 检测两个 π 值,允许误差在 0.01 以内
    

(3) 字符串比较

  • EXPECT_STREQ(foo, "bar"):判断两个字符串是否相等。这里比较的是 C 风格的字符串,即 char*。如果某个对象是 std::string,需要调用其 c_str() 方法。如果两个对象都是 std::string,可以使用 EXPECT_EQ

    std::string str = "hello";
    EXPECT_STREQ(str.c_str(), "hello");
    
  • EXPECT_STRNE:不相等。
  • EXPECT_STRCASEEQ:忽略大小写,是否相等。
  • EXPECT_STRCASENE:忽略大小写,是否相等。

(4) 其他

  • EXPECT_THROW/EXPECT_NO_THROW:处理异常,不要自行try-catch

  • EXPECT_THAT:这实际上是 GMock 提供的宏,需要和 匹配器 Matcher 配合使用,详见下文。这是写出优雅单测的必备技能

  • EXPECT_CALL:同样是 GMock 提供的宏,判断函数被调用的次数,详见下文。

  • EXPECT_PRED(func, arg1, arg2, ...):自定义一个返回 bool 的谓词,传给该谓词一系列参数,判断是否返回 true。如果失败,会依次打印传入的参数值。

    std::vector<int> vec = {1, 2, 3};
    EXPECT_PRED([](const std::vector<int>& v) { return v.size() == 3; }, vec);
    

断言失败时输出自定义信息

默认当 EXPECT 或 ASSERT 失败时,GTest 会打印预期值和实际值:

EXPECT_EQ(4, 3);

/path/to/test.cpp:7: Failure
Expected equality of these values:
  4
  result
    Which is: 3

但有时候,这些信息不够定位具体的失败原因。可以像这样输出自定义日志,这些日志仅在 EXPECT 失败时才打印:

for (int i = 0; i < x.size(); i++) {
  EXPECT_EQ(x[i], y[i]) << "x and y differ at index " << i;
}

还可以在 TestFixture 中封装 debug 函数,输出更详细的信息。比如,被测对象中包含了一些位图 std::bitset。在 EXPECT 失败时打印位图信息,有助于排查单测失败的原因:

class BitsetTest : public BaseTest {
public:
  std::string debug_message() {
      stringstream ss;
      for (const auto& iter : bitset_maps) {
        ss << "bitset: name=" << iter.first << " value=" << iter.second << std::endl;
      }
      return ss.string();
  }
}

TEST_F(BitsetTest, validate) {
    // ...
    EXPECT_TRUE(validate(ad, pos)) << debug_message();
}

四、GMock

4.1 原理与示例

GMock 是 Google Test 提供的一个 C++ mocking 框架,可以用于创建虚拟的对象和方法。GMock 的原理是利用 C++ 的多态特性,覆盖 virtual 函数,将函数调用转发到相应的 mock 函数中。

GMock 基本使用流程如下:

  1. 继承被 mock 的类,定义一个新的 Mock 类
  2. 使用 GMock 提供的 mock 宏,用于实现 Mock 类的方法
  3. 通过上面的 Mock 类,创建一个模拟对象
  4. 通过 EXPECT_CALL 宏,控制模拟方法的返回值
#include <gmock/gmock.h>

class FooInterface {
public:
    virtual int foo(int) { return 3; } // ① 需要定义为虚函数
};

// ② 需要声明一个 Mock 类,并声明 MOCK_METHOD
class MockFoo: public FooInterface {
public:
    MOCK_METHOD1(foo, int(int)); // 记录函数名字 + 类型信息到 MockFoo 对象上
};

using ::testing::Return;
TEST(FooInterface, foo) {
    MockFoo mockFoo; // ③ 需要声明 Mock 出来的子类
    EXPECT_CALL(mockFoo, foo(3)).Times(1). // 自定义函数返回值
                WillOnce(Return(10));
    EXPECT_EQ(mockFoo.foo(3), 10); // return 10
}

使用 GMock 有两个前提:(1) 被 Mock 的方法必须是虚函数;(2) 必须替换掉被 mock 的对象,将其赋值为 mock 对象。其不足之处:(1) 使用 GMock 时必须定义一个 Mock class;(2) 如果想 mock 非虚函数,需要变更函数签名,这可能不太安全;(3) 对于函数内部的局部变量,无法赋值,也就无法 mock。

4.2 EXPECT_CALL

语法:

EXPECT_CALL(mock_object, method(matchers))
    .Times(cardinality)
    .WillOnce(action)
    .WillRepeatedly(action);

比如下面代码的含义是:调用 turtle 对象的 GetX(string) 方法 5 次,每次传入的参数都是”hello”,第一次返回 100,第二次返回 150,之后几次返回 200:

using ::testing::Return;
...
EXPECT_CALL(turtle, GetX("hello"))
    .Times(5)
    .WillOnce(Return(100))
    .WillOnce(Return(150))
    .WillRepeatedly(Return(200));

基数:判断函数调用次数

  • Times(n):调用 n 次
  • Times(0):不被调用
  • Times(AtLeast(n)):至少被调用 n 次
  • WillOnce(action):被调用 1 次,执行自定义行为
  • WillRepeatedly(action):被调用任意次,执行自定义行为

Action:控制被调用时的行为

Will 开头的接口可以传入一个 Action 参数,设置 mock 函数被调用时的行为。常用的:

  • Return:返回指定值。比如 WillOnce(Return(100))

  • ReturnRefByRefReturn 不支持返回引用类型的变量,需要用这两个宏。

  • SetArgReferee<n>(value):修改传入的第 n 个引用类型的参数的值,下标 n 从 0 开始。

    class MockGetter : public Getter {
        public:
        MOCK_METHOD(int, get, (const string&, string&));
    };
      
    TEST(MockGetter, SetArgRefereeTest) {
        const std::string key = "foo_key";
        std::string value;
      
        MockGetter getter;
        EXPECT_CALL(getter, get(key, _))
            .WillOnce(SetArgReferee<1>("bar_value"));
      
        getter.get(key ,value);
        EXPECT_EQ(sum, "bar_value");
    }
    
  • DoAll(action1, action2, ...):执行多个 Action,比如修改参数的值 + 设定返回值:

    EXPECT_CALL(calc, Add(_, _, _))
      .WillOnce(DoAll(SetArgReferee<2>(8), Return(true)));
    
  • 直接传入一个 lambda 函数,或者 Invoke(function):执行自定义的函数,比如:

    // 传入 lambda 函数
    EXPECT_CALL(calc, Add).WillOnce([](int a, int b)) {
        return a + b + 1;
    });
      
    // 传入函数指针
    int AddFunc(int a, int b) {
        return a + b + 1;
    }
    EXPECT_CALL(calc, Add(_, _)).WillOnce(Invoke(AddFunc));
      
    // 传入类方法
    class AddHelper {
        public:
        int Add(int a, int b) {
            return a + b + 1;
        }
    };
    AddHelper helper;
    EXPECT_CALL(calc, Add(_, _)).WillOnce(Invoke(&helper, &AddHelper::Add));
    
    • Lambda 函数的签名必须和被 Mock 的函数一致。
    • Invoke 函数可接受任何可调用对象作为参数,包括函数指针、函数对象、Lambda 表达式等

Matcher:匹配传给函数的参数

Matcher 能够实现在复杂场景下进行断言,可以让测试用例更加灵活和可读,是写出优雅单测的必备工具。

Matcher 提供了一系列常用的比较函数,例如 Eq、Ne、Lt、Gt、Le、Ge 等,可以满足不同类型变量的比较。

Matcher 有两个使用场景:

  1. 和 EXPECT_CALL 配合使用,用于检查传递给函数的参数值是否符合预期

    // 期望第一个参数大于 2,第二个参数小于 6
    EXPECT_CALL(calc, Add(Gt(2), Le(6)));
    calc.Add(3, 5);  // 可以通过检测
    calc.Add(2, 7);  // 不能通过检测
    
  2. 和 EXPECT_THAT 配合使用,用于检查某个变量的值是否符合预期

    // int_foo > 6
    EXPECT_THAT(int_foo, Gt(6));
       
    // 判断一个 vector 的元素值
    std::vector<int> result = {1, 2, 5};
    EXPECT_THAT(result, ElementsAre(1, 2, Gt(3)));
       
    // 判断一个 unordered_map 的元素值
    std::unordered_map<string, int> result = {{"idt_a", 1}, {"idt_b", 2}};
    EXPECT_THAT(result, UnorderedElementsAre(Pair("idt_a", 1), Pair("idt_b", 2)));
       
    // 期望 foo 包含子串 "hello"
    EXPECT_THAT(foo, HasSubStr("hello"));  
    
通配符:_A<type>

_ 可以匹配任意类型的任意变量。它位于 ::testing 命名空间下。示例:

using namespace testing;
EXPECT_CALL(calc, Add(_, _)).Times(1);
EXPECT_CALL(calc, Add).Times(1);  // 省略参数列表,和上面等价

A<type>() 或者 An<type>() 匹配类型是 type 的任意变量。其应用场景主要是匹配重载函数。示例:

class Foo {
    void DoSomething(int a, int b);
    void DoSomething(int a, string b);
}

EXPECT_CALL(foo, DoSomething(_, A<int>()));  // 预期调用第一个函数
常用匹配器

完整列表见 http://google.github.io/googletest/reference/matchers.html,下面罗列常用的匹配器:

  • 一般比较
    • value:写出字面量的值,就是精确匹配,等价于 Eq(value)

      EXPECT_CALL(foo, method(100)).Times(1); 
      EXPECT_CALL(foo, method(Eq(100))).Times(1);  // 和上面等价
      
    • Ge(value)GtLeLt:>= (greater or equal)、> (greater)、<= (less or equal)、< (less)。

      EXPECT_THAT(int_foo, Gt(100));  // int_foo > 100
      EXPECT_THAT(int_foo, Le(200));  // int_foo <= 200
      
    • Ne(value):不等于,not equal。
    • IsFalse()IsTrue():转成 bool 值后是 false 或 true。非 0 值、非空指针等都可以视为 true。
    • IsNull()NotNull():指针是否为空。
  • 浮点数比较
    • DoubleEq(a_double)FloatEq(a_float):浮点数相等。

      double foo = 0.01 + 0.02;
      EXPECT_THAT(foo, Eq(0.03));  // 会失败
      EXPECT_THAT(foo, DoubleEq(0.03));  // 会成功
      
    • DoubleNear(a_double, max_abs_error):浮点数近似,差值的绝对值小于给定的 abs_error

      double foo = 0.03 + 0.001;
      EXPECT_THAT(foo, DoubleNear(0.01));   // 会失败
      EXPECT_THAT(foo, DoubleNear(0.001));  // 会成功
      
    • FloatNear(a_float, max_abs_error):同上。

  • 字符串比较
    • StartsWith(prefix):指定前缀
    • EndsWith(suffix):指定后缀
    • HasSubstr(string):包含子串

      std::string str = "hello, world";
      EXPECT_THAT(str, StartsWith("hello"));
      EXPECT_THAT(str, EndsWith("world"));
      EXPECT_THAT(str, HasSubstr("llo"));
      
    • IsEmpty():字符串为空
    • StrEq(string)StrNe(string):字符串相等或不等

      EXPECT_CALL(m, foo(StrEq("hello, world")).Times(1);
      m.foo("hello, world");  // 符合预期
      
    • StrCaseEq(string)StrCaseNe(string):忽略大小写,字符串相等或不等

      EXPECT_CALL(m, foo(StrCaseEq("hello, world")).Times(1);
      m.foo("HELLO, WORLD");  // 符合预期
      
    • ContainsRegex(string):正则表达式匹配
  • 容器比较
    • ElementsAre(e0, e1, ..., en):每个元素依次是什么,用于 vector、map、set 等。

      std:vector<int> v = {1, 2, 4};
      EXPECT_THAT(v, ElementsAre(1, 2, 4));  // 值是 {1, 2, 4}
      EXPECT_THAT(v, ElementsAre(1, 2, Gt(3)));  // 值是 {1, 2, 大于 3 的任意值}
          
      // 下面这样也可以,但不如上面只写一行优雅,不推荐
      vector<int> expect_vector = {1, 2, 4};
      EXPECT_EQ(v, expected_vector);
      
    • UnorderedElementsAre(e0, e1, ..., en):同上,用于 unordered_set、unordered_map 等。

      std:set<int> v = {1, 2, 4};
      EXPECT_THAT(v, UnorderedElementsAre(1, 2, 4));  // 值包含 {1, 2, 4}
      EXPECT_THAT(v, UnorderedElementsAre(1, 2, Gt(3)));  // 值包含 {1, 2, 大于 3 的任意值}
      
    • ContainerEq(container):效果同上,但会打印出哪些元素不一致。
    • Contains(e):包含一个元素和 e 匹配,这里 e 可以是一个精确值,也可以是一个匹配器。
    • Contains(e).Times(n):检测 e 指定的元素出现 n 次。Times(0) 表示不能包含这样的元素。

      EXPECT_THAT(v, Contains(5));  // 需要包含一个值为 5 的元素
      EXPECT_THAT(v, Contains(Lt(5)));  // 需要包含一个值 <5 的元素
      EXPECT_THAT(v, Contains(Lt(5)).Times(3));  // 需要包含 3 个值 <5 的元素
      
    • Each(e):每个元素都要匹配 e

      EXPECT_THAT(v, Each(Lt(5)));  // 每个元素都需要 <5
      EXPECT_THAT(v, Each(AllOf(Lt(5), Gt(3)));  // 每个元素都需要 >3 且 <5
      
    • IsSubsetOf(array)IsSubsetOf(begin, end):参数是指定数组的子集,顺序可以不一致。
  • 成员匹配
    • Field(&class::field, m):匹配字段值,用于结构体检测,比如:

      struct MyStruct {
          int value = 42;
          std::string greeting = "aloha";
      };
      MyStruct s;
      EXPECT_THAT(s, FieldsAre(42, "aloha"));
      
    • Pair(m1, m2):匹配一个 std::pair,经常和 ElementsAre 配合使用,匹配一个 map:

      std::map m = {
          {"hello", 1},
          {"world", 2},
      };
      EXPECT_THAT(m, ElementsAre(Pair("hello", 1), Pair("world", 2)));
      
  • 指针匹配
    • Pointee:匹配一个指针或 shared_ptr,常和 Field 一起检测某个指针的字段值:

      struct Item { int id; };
      std::shared_ptr<Item> obj = std::make_shared<Item>();
      obj->id = 1;
      EXPECT_THAT(obj, Pointee(Field(&Item::id, 1));
      
  • 复合匹配
    • AllOf(m1, m2, ..., mn):匹配所有给定的匹配器。

      std:vector<int> v = {4, 5};
      EXPECT_THAT(v, Each(AllOf(Le(5), Gt(3)));  // 每个元素都需要 >3 且 <=5
      
    • AnyOf(m1, m2, ..., mn):匹配任何一个给定的匹配器。

      std:vector<int> v = {1, 2, 6, 7};
      EXPECT_THAT(v, Each(AnyOf(Lt(3), Gt(5)));  // 每个元素要么 <3,要么 >5
      
    • Not(m):不匹配给定的匹配器,可以和 AllOfAnyOf 配合使用。

      EXPECT_THAT(v, Each(AllOf(Gt(3), Lt(5)));  // 3 < each_item < 5
      EXPECT_THAT(v, Each(Not(AllOf(Gt(3), Lt(5))));  // each_item <= 3 || each_item >=5
      
    • Conditional(cond, m1, m2):cond 为 true 时匹配 m1,否则匹配 m2

      EXPECT_THAT(v, Conditional(is_ad, Gt(5), Lt(3));  // v = is_ad ? v > 5 : v < 3
      
匹配器的优先级

在使用 GMock 的 EXPECT_CALL 宏进行 mock 函数参数匹配时,一次函数调用可能命中多个匹配器:

EXPECT_CALL(calc, add).Times(1);                // 任意参数
EXPECT_CALL(calc, add(_, _)).Times(1);          // 和上面等价
EXPECT_CALL(calc, add(3, 5)).Times(1);          // 字面量,精确匹配
EXPECT_CALL(calc, add(Gt(2), Lt(6))).Times(1);  // 比较,模糊匹配

calc.add(3, 5);  // 这一行理论上可以匹配上面每一个 EXPECT_CALL

匹配的优先级如下:模糊匹配器 > 精确匹配器 > 通配符

  • 模糊匹配器:Lt (小于)、Gt (大于) 等
  • 精确匹配器:字面量、Eq (相等) 等
  • 通配符:_ 等
  • 当优先级相同时,越近的声明优先级越高。

这引入了一些使用技巧:

  1. 只设置必要的匹配器。如果对某个参数的值不感兴趣,请写 _ 作为参数,这意味着“一切皆有可能”。

    EXPECT_CALL(calc, add(5, _).Times(1);  // 如果只关心第一个参数的值,第二个参数就写成 _
    EXPECT_CALL(calc, add(5, 3).Times(1);  // 如果这样写,之后代码变动,单测可能就不通过了
    
  2. 如果对所有参数的值都不感兴趣,可以省略参数列表,这和把每个参数都写成 _ 是一致的。好处是后续改了函数签名后,比如新增了一个参数,单测是不需要改动的。

    EXPECT_CALL(calc, add).Times(1);       // 任意参数
    EXPECT_CALL(calc, add(_, _)).Times(1); // 和上面等价
    
  3. 利用匹配器的优先级,可以细粒度地控制函数在不同参数下的返回值。比如 mock 一个 getter,我们希望在 key == foo 时返回 bar、key == hello 时返回 world,其他 key 通通返回空字符串,那么可以这样写:

    EXPECT_CALL(getter, get).WillRepeatedly(Return(""));
    EXPECT_CALL(getter, get("foo")).WillRepeatedly(Return("bar"));
    EXPECT_CALL(getter, get("hello")).WillRepeatedly(Return("world"));
       
    EXPECT_STREQ(getter.get("foo"), "bar");
    EXPECT_STREQ(getter.get("hello"), "world");
    EXPECT_STREQ(getter.get("aaa"), "");
    EXPECT_STREQ(getter.get("bbb"), "");
    

Uninteresting call:处理非预期调用

非预期调用是指未被 EXPECT_CALL 匹配的调用。当有非预期调用时,会有 warning 日志输出:

Uninteresting mock function call - returning default value.
    Function call: foo(42)
          Returns: 0

有两种处理方式。

NiceMock:不要输出 warning 信息

GMock 有三种级别:Nice Mock、Naggy Mock、Strict Mock。

默认是 Naggy Mock,当有非预期调用时,输出 warning 日志。

Uninteresting mock function call - returning default value.
    Function call: foo(42)
          Returns: 0

如果我们希望非预期调用不要有 warning,可以用 NiceMockNiceMock 是一个模板类:

class MyMockClass : public MyClass {
    MOCK_METHOD(...)
};
MyMockClass mock;  // 这非预期调用会有 warning 日志
NiceMock<MyMockClass> mock; // 改成这样就不会有 warning 日志了

也可以在 Mock Class 定义的时候,直接继承 NiceMock

class MyMockClass : public NiceMock<MyClass> {
    MOCK_METHOD(...)
};
MyMockClass mock;  // 这里非预期调用会返回默认值,不会有 warning 日志

Strict Mock 在有非预期调用时会直接 fail。也是一个模板类,使用方法和 NiceMock 类似。

打印调用栈:检查非预期调用来自哪里

当有非预期调用时,如果我们希望检查非预期调用来自哪里,可以打印调用栈。有两种方式。

  • 一种是通过 EXPECT_CALL 打印调用栈:

    #include <boost/stacktrace.hpp>
      
    void print_stack_trace() {
      std::cout << "call stack:" << std::endl;
      const auto frames = boost::stacktrace::stacktrace();
      for (const auto& frame : frames) {
        std::cout << "  " << frame << std::endl;
      }
    }
      
    EXPECT_CALL(...).WillRepeatedly([](){
      print_stack_trace();
      return xxx;  // 返回默认值
    });
    
  • 另一种方式是使用 GTest 提供的选项 --gmock_verbose=info,该选项会打印每次 Mock Method 被调用时的参数和调用栈。需要在单测 main 函数执行 ::testing::InitGoogleMock(&argc, argv)**。

4.3 ON_CALL

ON_CALL 可以和 EXPECT_CALL 配合使用。ON_CALL 设置函数的默认行为,EXPECT_CALL 临时修改其行为。

💡 ON_CALL 和 EXPECT_CALL 的语法很像,但提供了不同的语义。EXPECT_CALL 目的在于定义一个预期,即我们期望被测试函数在某些特定条件下应该调用哪些函数,如果没有满足预期的调用,则认为是一次失败。ON_CALL 只是为了指定被测试函数的默认行为。

ON_CALL 通常用在 Mock 类的构造函数、或者 TestFixture 的 SetUp 函数里:

  1. 令 mock 函数始终返回某个自定义的值
  2. 将 mock 函数的默认操作委托给基类或其他实例进行。一个具体使用场景:希望 Mock 某个函数,默认还是执行原有操作,但当有需要的时候,可以临时更改其行为。这时就可以在 ON_CALL 里把默认操作委托给基类,后续再在 EXPECT_CALL 里临时控制其返回值。

     class MockFoo : public Foo {
     public:
     // Normal mock method definitions using gMock.
     MOCK_METHOD(char, DoThis, (int n), (override));
     MOCK_METHOD(void, DoThat, (const char* s, int* p), (override));
          
     // 构造函数里,委托 Mock 接口的操作给其他类
     MockFoo() {
     // 委托给基类
     ON_CALL(*this, DoThat).WillByDefault([this](const char* s, int* p) {
     Foo::DoThat(s, p);
     });
     // 委托给另一个对象
     ON_CALL(*this, DoThis).WillByDefault([this](int n) {
     return fake_.DoThis(n);
     });
     }
          
     private:
     FakeFoo fake_;  // Keeps an instance of the fake in the mock.
     };
    

五、Tips

5.1 编译参数

访问私有变量

错误的做法:#define private public,或者定义 getter 函数。前者可能导致编译报错,后者需要修改代码。

正确的做法:-fno-access-control,放在单测的 optimize 参数里。

修改 Const 字段

错误的做法:定义 setter 函数。需要修改代码。

较好的做法:使用 const_cast<Type&> 修改常量类型。

优化级别改为 O0

好处:单测覆盖率报告更准。

5.2 运行单测

运行特定单测:--gtest_filter

什么时候需要运行特定单测:

  • 运行所有单测,发现某个单测失败了。但这个时候单测日志已经刷屏,看不到这个单测的具体失败原因了。
  • 修复单测 bug 后重新编译,只希望运行上次失败的那个单测。

语法:--gtest_filter=TestSuite.TestCase支持通配符 \* 和排除符 -

  • --gtest_filter=FooTest.Bar,只运行 FooTest.Bar
  • --gtest_filter=*FooTest*,运行所有名称里包含 FooTest 的单测。
  • --gtest_filter=FooTest.*:BarTest.*,运行 FooTest 和 BarTest 两个 suites 下的所有单测。
  • --gtest_filter=FooTest.*-FooTest.Bar,运行 FooTest 下的所有单测,但不运行 FooTest.Bar。
  • --gtest_filter=FooTest.*:BarTest.*-FooTest.Bar:BarTest.Foo,运行 FooTest 和 BarTest 下的所有单测,但不运行 FooTest.Bar 和 BarTest.Foo。
  • 详细的匹配规则见文档

重复运行单测多次:--gtest_repeat--gtest_break_on_failure

有些单元测试涉及到多线程,可能会偶发性的不通过。

可以使用 --gtest_repeat=-1--gtest_break_on_failure运行多次来复现。

临时禁用某个单测:DISABLED_

可以使用DISABLED_前缀来跳过某项测试:

TEST_F(DISABLED_BarTest, DoesXyz) { ... }
TEST_F(BarTest, DISABLED_DoesXyz) { ... }

DISABLED 之后,单测日志会输出 DISABLED 的单测数量:

img

之后在修理单测过程中,可以使用 --gtest_also_run_disabled_tests 或者 --gtest_filter 来执行被 DISABLED 的单测。

相比于把整段单测代码全部注释掉,加一个 DISABLED_ 前缀的 diff 更少,而且后续可以直接运行。

5.3 输出日志

std::cout 输出的日志会直接展示在终端。

💡 建议:能用 EXPECT 就不要写 std::cout

  • 如果 cout 的日志是确定性的,那么应该写成断言。
  • 如果是 debug 用的,那么在写完单测后应该删除。
  • 如果期望单测失败时打印,那么应该放在 EXPECT_CALL()... << ... 后面,而不是直接输出。
  • 除此之外,这些日志没有任何意义,只会刷屏,没有保留的必要。

5.4 使用 GDB 运行和调试程序

🔗 GDB 快速入门 / 速查手册https://imageslr.com/2023/gdb.html

GDB 也是研发基本功之一。使用 GDB 断点调试的效率远高于加日志+重新编译单测,但大部分人依然使用后面这种调试方式,原因可能是认为 GDB 的上手成本太高。但实际上,GDB 入门只需要 3 分钟。这里罗列 GDB 的基本使用姿势,足够覆盖大部分单测场景。上面高亮块里也提供了一个速查手册。

  1. 进入 GDB,同时加载单测程序:
      gdb ./path/to/unit_test
    
  2. 加载动态链接库:
      set env LD_LIBRARY_PATH=...
    
  3. 运行单测:r。如果要运行指定单测,加 --gtest_filter 参数:
      r --gtest_filter=FooTest.bar_method
    
  4. 打断点:b。比如:
      b 文件名:行号
      b prime/src/auction/validator/frame/validator.cpp:52
    
  5. 从断点处继续运行:c
  6. 逐行执行:n
  7. 打印变量:p 变量名
  8. 查看 core 栈:bt

六、单测编写规范

💡 单测代码也需要经过 Code Review。单测代码和线上代码同等重要。

6.1 目录结构、文件与命名规范

单测的目录结构,要和源码的目录结构一致 [强制]

单测文件的路径名,等价于源码的文件名加上 _test 后缀。

目的在于:让写单测的人能很快定位是否已经有这个文件或这个类的单测,让新增代码更聚合,避免写重复单测。

// bad
src/
  common/
    item_data.cpp
  frame/
    request_context.cpp
unittest/
  item_data_test.cpp // 这里直接平铺在 unittest 目录下了,和 src 目录层级不一致
  request_context_test.cpp
  
// good
src/
  common/
    item_data.cpp
  frame/
    request_context.cpp
unittest/
  common/
    item_data_test.cpp
  frame/
    request_context_test.cpp

TestSuite、TestCase 命名规范 [建议]

TestSuite 建议命名为被测试的类名加上 Test 后缀:

// bad
TEST(MyTest, foo) {...}

// good
TEST(RequestContextTest, foo) {...}

TestCase 建议命名为被测试的函数名,不要随意起名,也不需要增加不必要的前缀:

// bad
TEST(RequestContextTest, test_uav) {
    ASSERT_EQ(ctx->init_uav_to_group_bid(), 1);
}

// good
TEST(RequestContextTest, init_uav_to_group_bid) { // 不需要加 test_ 前缀
    ASSERT_EQ(ctx->init_uav_to_group_bid(), 1);
}

GTest 生成的类名是带下划线的,所以上面这些名字建议用驼峰形式。

6.2 写有用的单测,而不只是通过单测覆盖率卡点

禁止写无用单测 [强制]

经典问题:“假单测”。为了通过单测覆盖率卡点、便只是在单测里执行了一下新增函数,但不检测其返回值,没有任何断言逻辑。之前遇到过有同学写了几百行单测,reviewer 从头看到尾,居然一行 EXPECT 都没有,(╯‵□′)╯。

还有一种场景是“单测”:新增了一个分支逻辑,引入了一坨逻辑,但只是在某个已有单测里,把这分支的控制参数打开了,完全没有自己构造输入去覆盖新增逻辑。这样即使覆盖率也能达标,也属于无用单测。

测试不符合预期的边界情况,而不是只测试符合预期的情况 [建议]

单测的目的之一在于测试程序的鲁棒性,即当输入不符合预期时,是否能正确处理。比如一个 stoi 函数 —— 将字符串转成整数。在构造输入时,最基本的是 123 这种合法字符串,此外还应当构造 0.9999 (小数)、123abc (含非法字符) 等非法输入,以及 1781234123412341234 这种合法但越界的输入。

6.3 写优雅的、可理解的、易于维护的单测:代码风格与注释

不要用 std::cout 输出变量值,改为用 ASSERT / EXPECT 检查 [强制]

能用 EXPECT 就不要写 std::cout:

  • 如果 cout 的日志是确定性的,那么应该写成断言。
  • 如果是 debug 用的,那么在写完单测后应该删除。
  • 如果期望单测失败时打印,那么应该放在 EXPECT_CALL()... << ... 后面,而不是直接输出。
  • 除此之外,这些日志没有任何意义,只会刷屏,没有保留的必要。
// bad 
std::cout << "ads_size = " << rsp.ads.size() << std::endl; // 这一行多此一举
EXPECT_EQ(rsp.ads.size(), 1);

// good
EXPECT_EQ(rsp.size(), 1); // 这一行在检测失败时,会打印 rsp.size() 的值
EXPECT_EQ(rsp.size(), 1) << rsp.ads.debug_string() << std::endl;  // 可以在检测失败时,打印更多 debug 日志

不要直接写数值,要写清楚这个数字是怎么算的 [建议]

直接写一个数字 2965,其他人并不知道这个数字是怎么算出来的,后续有问题也不好排查。

写出这个数字的计算过程,映射到代码分支上,其他人好看懂。这也是白盒化单测的表现之一。

// bad
params.alpha = 2;
params.beta = 2.5;
ASSERT_EQ(params.get_score(), 2965); // 这 2965 咋算的?

// good
params.alpha = 2;
params.beta = 2.5;
ASSERT_EQ(params.get_score(), 2 * 2.5 * 593); // alpha * beta * ctx.bid

// good: 把变量名直接注释在字面量后面
ASSERT_EQ(params.get_score(), 2 /* alpha */ * 2.5 /* beta */ * 593 /* ctx.bid */);

使用大括号分隔、缩进不同的 Test Case [建议]

一个 TEST(Foo, Bar){...} 就是一个 Test Case。考虑到构造输入有成本,通常一个 TEST(Foo, Bar) 里会反复修改输入,构造多个 case,测试不同的执行流程。这里建议用大括号分隔不同的 case,整体更条理。另一个好处在于:每个变量的生命周期仅限于大括号内。这样就可以反复使用相同的变量名,而不用给变量名编号。

// bad
TEST(Foo, bar) {
    Context ctx1;
    params.enable_refresh = true;
    ASSERT_EQ(ctx1->is_enable_fresh(), true);
    
    Context ctx2;
    params.enable_refresh = false;
    ASSERT_EQ(ctx2->is_enable_fresh(), false);
}

// good
TEST(Foo, bar) {
    // case 1: enable = true
    {
        Context ctx;
        params.enable_refresh = true;
        ASSERT_EQ(ctx->is_enable_fresh(), true);
    }
    
    // case 2: enable = false
    {
        Context ctx;
        params.enable_refresh = false;
        ASSERT_EQ(ctx->is_enable_fresh(), false);
    }
}

此外,如果待测函数十分复杂,建议拆分多个 TEST(Foo, Bar){...},避免 Test Case 代码膨胀。比如:

// 待测函数
int foo(Ad ad) {
    if (!ad)
        return -1;
    switch(ad.pricing) {
        case CPT:
            ...
        case GD:
            ...
    }
}
// 输入为空
TEST(Foo, IsNil) {
    ...
}

// 输入是 CPT 广告
TEST(Foo, IsCpt) {
    ...
}

// 输入是 GD 广告
TEST(Foo, IsGd) {
    ...
}

正确使用 ASSERTEXPECT 前缀 [建议]

  • 前者在校验失败时会直接终止,后者则会继续运行。
  • 如果某个判断不通过时会影响后续步骤 ,需要使用 ASSERT。常见的是空指针,或者数组访问越界。

    如果某个 EXPECT 失败会导致后续一连串 EXPECT 失败,那么第一个 EXPECT 应该换成 ASSERT。这就像编译时的报错信息,往往只有第一个是有用的,其他错误都只是刷屏。

  • 其他情况,可以使用 EXPECT,尽可能多测试几个用例。
  • 此外,如果修改了某个字段的目的是影响某个函数的返回值,那么最好补一行 ASSERT。好处显而易见:代码即注释;且在查单测 bug 的时候,这些断言能够预先排除一些问题。

    // bad
    req.type = Type::foo;  // 其他人看不懂这一行的目的是什么
    EXPECT_EQ(req.get_value(), 1);
      
    // good
    req.type = Type::foo;
    ASSERT_TRUE(context.is_foo());  // 这里表明,上一行是为了影响代码里这个判断函数的结果
    EXPECT_EQ(req.get_value(), 1);
    

解除对外部逻辑的依赖 / 耦合 [建议]

  • 如果被测代码里用到了某个全局变量:
    • Bad:从请求入口开始执行全部代码、间接构造该变量。这样太黑盒了。
    • Good:直接就地构造变量,然后赋值到全局字段上。
  • 如果被测代码里调用了某个函数:
    • Bad:想办法构造外部函数的输入,以此来影响其返回结果。这样会导致被测函数与外部函数耦合 —— 需要看外部函数的实现逻辑,且如果后续外部函数改动了,当前函数的单测可能会不通过。
    • Good:使用 GMock 劫持该函数,在单测里控制其返回结果。完全不需要关心外部函数的实现。

为单测补充详细的注释 [建议]

单测写出来必须的白盒的、可理解的、可维护的。如果不补充注释,其他人根本看不懂这些单测在测试什么逻辑,也无法确保其有效,后续修单测也很痛苦。

为单测补充注释时,重点要说明「这些赋值对应了哪个分支条件」,目标是让其他人扫一眼源码就能知道这些单测在测试哪些逻辑。

// bad
req.type = Type::foo;
req.from = "localhost";
EXPECT_EQ(ctx.get_value(), 5);

// good:补充注释
req.type = Type::foo;  // is_foo()
req.from = "localhost";  // is_local_req()
EXPECT_EQ(ctx.get_value(), 5);  // 本地请求,默认值是 5

// best:代码即注释
req.type = Type::foo;
ASSERT_TRUE(ctx->is_foo());
req.from = "localhost";
ASSERT_TRUE(ctx->is_local_req());
EXPECT_EQ(ctx.get_value(), 5);  // 本地请求,默认值是 5

6.4 写稳定的单测

Mock 所有 IO,不要依赖外部数据 [强制]

单测里禁止访问外部服务,最好是整个单测能够断网。

之前遇到的实际 case:

  • 单测依赖线上服务,导致必须在一台线上环境的容器里才能启动单测。
  • 单测依赖了线上 redis 里的测试数据,过了半年后数据过期了,线上单测突然挂了。

参考文档

Gtest 官方手册 (Google Test Primer) ,以及部门内的分享。

]]>
Images。
🚀【工程】服务性能优化入门与实践指南2023-04-09T18:00:00+08:002023-04-09T18:00:00+08:00https://imageslr.com/2023/service-performance-optimization一、前言

在后端场景中,服务是一种提供特定功能的模块或系统,通过 REST API、RPC 等方式对外提供接口。服务可以独立运行,也可以和其他服务共同协作,构成一个庞大的系统。常见的服务有鉴权服务、搜索服务、数据库服务、广告召回服务等。

服务是整个系统的重要组成部分,为前端应用和其他上游服务提供支持,必须保证稳定可靠。现代服务通常需要应对高并发的请求、处理大规模的数据。随着业务和架构复杂度的增加,性能问题也会随之出现。这不仅会影响用户体验,也可能影响整个系统的稳定性。因此,服务性能优化显得尤为重要。通过优化服务性能,一方面可以降低延迟,保障服务的高可用性,提升用户体验,另一方面可以减少 CPU、内存等硬件资源的消耗,节约成本。

在这篇文章中,我们将围绕「服务性能优化」展开讨论,从代码、系统、架构等层面,探索服务性能优化的最佳实践。

二、相关术语

衡量服务性能的指标

  • 延时 Latency:分成客户端和服务端两个视角,客户端即调用方,服务端即被调用方。客户端延时,表示调用方从发出请求到收到服务响应所需的时间。服务端延时,表示服务从收到请求到发出响应所需的时间。前者比后者多出两次网络传输、以及序列化 / 反序列化的时间。客户端延时通常有一个上界,这是因为客户端会设置超时时间。而服务端延时没有这样的上界,因为服务端无法感知客户端是否已经超时结束。显然,延时越低越好。
  • 吞吐量 Throughput:服务在一段时间内处理请求的能力,单位通常是 QPS (Queries Per Second) 或 TPS (Transactions Per Second)。QPS 指每秒钟能够处理多少个查询请求,常用于数据库、搜索引擎等场景。TPS 指每秒钟能够完成多少个事务或操作,通常用于交易系统、支付系统等场景。在保证稳定性的前提下,吞吐量越高越好。
  • 错误率 Error Rate:服务出现错误的请求数占总请求数的百分比。错误率直观展示了服务的稳定性,越低越好。
  • 资源使用率:运行服务的主机或容器上各种系统资源的使用率,包括 CPU、内存、磁盘和网络等。不同服务对于资源的需求不同,例如 CPU 密集型服务更注重 CPU 的使用率,内存密集型服务则更注重内存的使用率。资源使用率越高,说明服务的负载越大,可能导致服务响应变慢、稳定性降低。因此,各项资源使用率越低越好。某些场景下可以用 A 资源兑换 B 资源,比如某个定制版的 Golang 编译器通过优化内存管理模型,用冗余的内存来兑换 CPU,效果是 (CPU 50%, MEM 10%) → (CPU 30%, MEM 60%)。对于 CPU 密集型服务来说,这个内存到 CPU 的兑换比是很划算的。

SLO、SLA

SLO 和 SLA 也是服务性能优化中两个常见的概念:

  • SLO (Service Level Objective):服务水平目标,通常是一个数值或范围,比如稳定性达到 99.9%,即 3 个 9,表示一年内的停机时间最多为 8 小时 45 分钟、一个月内的停机时间最多为 43.2 分钟。注意,「SLO 达到 3 个 9」和「错误率低于 0.1%」并不是等价的。
  • SLA (Service Level Agreement):服务水平协议,是由服务提供方和服务使用方达成的一份协议,约定了服务提供方应该达到的最小服务水平,若达不到应有补偿。
  • SLO 是为了满足 SLA 而制定的。SLO 可以看作是 SLA 的内部指标,用于衡量服务是否符合 SLA 中约定的服务水平要求。

avg、pct50、pct99

当我们观测服务性能指标时,通常会查看一个统计值,比如所有请求的平均延时、集群中所有主机的平均 CPU 使用率等。

  • avg (平均值) 是所有数据的算术平均值,可以帮助我们快速了解服务性能的整体水平,但是不够准确,容易被异常值影响。
  • pct50 (中位数) 是位于所有数据最中间的一个值。和 avg 相比,pct50 更稳定,不易受异常值影响。适用于数据分布不均匀、有异常值或者极值的场景。
  • pct99 (百分位数) 是位于所有数据第 99% 位置的值,比如 100 个请求中的前 99 个请求,它们的延时都比 pct99 小,只有最后 1 个请求的延时比 pct99 大。pct99 可以帮助我们快速发现一些问题,比如存在大包体、慢查询等长尾请求,或者集群中有异常实例。
  • 类似的,还有 pct90pct999 等指标。

总之,pct{n} 反映了数据的分布情况,有助于我们了解服务在极端情况下的性能表现。实际场景下,我们需要同时关注 avgpct50pct99 等指标,以获取更全面的性能数据。

基准测试、压力测试

基准测试 (Benchmarking) 是一种衡量系统性能的标准化方法。基准测试常用来验证性能优化效果:首先在系统上运行一系列测试程序,保存性能指标结果;然后在代码或硬件环境变化之后,再执行一次基准测试,以确定那些变化对性能的影响。

压力测试 (Stress Testing) 通过增加系统负载,测试系统在极端情况下的表现。压力测试可以帮助发现系统的性能瓶颈。常用的压测工具有 Apache JMeter、LoadRunner 等。

三、分析服务性能问题的工具

日志和监控

  • 收集数据:通过日志和监控记录服务运行过程中的信息。这些信息既要包含时间戳、接口名、IP 地址、请求延时等通用信息,也要包含服务内部的具体数据,如每个子步骤的执行耗时、请求传入的数据量级等。

    监控数据一般用 Metrics 框架来收集。一个知名的 Metrics 框架是 OpenTSDB

    Metrics 框架提供了存储时序数据对时序数据进行聚合查询的功能:

    • 时序数据:对于某一项数据,记录其在不同时间点的值。绘制出来是横轴为时刻、纵轴为值的折线图。
    • 聚合查询:支持多种聚合方式 (avg、sum、p99 等) 和多种查询条件。

    Metrics 框架提供了通用的 API,可以在应用程序中收集各种类型的指标,比如 counter、timer 等。Metrics 数据可以展示在 Grafana 看板中。

  • 展示数据:对收集到的日志和监控数据进行处理,可视化地展示其中的各项关键指标。常用的可视化工具是 Grafana

    image-20230405210259360

    图片来源:Grafana Playground

  • 分析数据:观察指标的 avgpct99 等分位数,分析是否有长尾请求或单点异常;观察指标随时间的变化情况,定位出现性能异常的时间;同时观察多项指标,发现指标之间的关联关系,比如某时刻请求量上涨,导致 CPU 利用率上涨,进而导致服务稳定性下降。

下面是几个分析数据的实际案例。

(1) 根据日志发现单点异常:

假设我们从服务集群上下载了一批请求日志:

192.168.0.1 - - [23/Sep/2021:14:45:32 +0800] "GET /api/v1/users?page=1&limit=20 HTTP/1.1" 200 3567 "-" "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/93.0.4577.82 Safari/537.36" 0.504
192.168.0.2 - - [23/Sep/2021:14:45:33 +0800] "POST /api/v1/login HTTP/1.1" 200 256 "-" "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/93.0.4577.82 Safari/537.36" 1.267
192.168.0.3 - - [23/Sep/2021:14:45:34 +0800] "DELETE /api/v1/user/123 HTTP/1.1" 204 0 "-" "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/93.0.4577.82 Safari/537.36" 0.873
192.168.0.4 - - [23/Sep/2021:14:45:35 +0800] "PUT /api/v1/user/123 HTTP/1.1" 200 1343 "-" "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/93.0.4577.82 Safari/537.36" 0.901
192.168.0.5 - - [23/Sep/2021:14:45:36 +0800] "GET /api/v1/products?id=1234 HTTP/1.1" 200 4382 "-" "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/93.0.4577.82 Safari/537.36" 0.702
...

每行日志对应了一个请求,日志的第一列是处理该请求的服务主机 IP 地址,最后一列是处理该请求的耗时,单位是秒。我们可以这样统计每台主机处理请求耗时的 avg 指标:

awk '{ips[$1]++; total[$1]+=$NF} \
END {for (ip in ips) { \
        avg=total[ip]/ips[ip]; \
        n=ips[ip]; \
        printf("%-15s requests: %-5d avg_time: %.3fs\n", ip, ips[ip], avg); \
    } \
}' log.txt | sort -k3nr

以上命令执行后,会输出每个 IP 地址的请求数量和平均耗时,并按平均耗时从大到小排列。输出格式如下:

192.168.0.2    requests: 1011     avg_time: 2.267s
192.168.0.4    requests: 1103     avg_time: 0.901s
192.168.0.3    requests: 1021     avg_time: 0.873s
192.168.0.5    requests: 1007     avg_time: 0.702s
192.168.0.1    requests: 1097     avg_time: 0.504s

不难发现, 192.168.0.2 的平均耗时远大于其他主机。最简单的处理办法是重启或迁移它。

但上面从日志统计耗时的流程很繁琐。实际场景中,一般使用 Metrics 框架收集单个请求的耗时、每台主机的 CPU 利用率等指标,然后在 Grafana 中展示。

(2) 根据监控发现单点异常:

通过 Metrics 和 Grafana 可以更实时、更直观地发现单点异常。比如下图是一个服务单机 QPS 的 Grafana 看板,当有一条曲线远远高于 / 低于其他曲线时,说明对应的主机有单点异常。在这个场景下,很有可能是集群负载均衡器的问题。

image-20230405220119360

火焰图

火焰图是一种性能分析工具,它以可视化的方式展示系统中的函数调用层级和执行时长。

image-20230405224650600

图源:http://openresty.org/download/user-flamegraph.svg

火焰图是一张形如火炬的 SVG 图片。火焰图上的每个矩形代表了一个函数的执行过程,其宽度表示执行时间的长短。矩形从下向上表示函数的调用层次,底部是外层函数,顶部是被调用的函数。矩形颜色没有含义,只是为了便于区分。

显然,矩形的宽度越宽,该函数的执行时间就越长,表明该函数可能存在性能问题。我们需要寻找火焰图中最宽的矩形,针对性地优化代码。

生成火焰图时,首先需要使用 perfDTrace 等命令,收集一份包含函数执行堆栈的数据报告。然后可以使用 Brendan Gregg 开发的 FlameGraph 或者 Google 开发的 pprof 等工具,根据收集到的数据生成火焰图。最后可以使用 d3-flame-graph 等工具,将静态的 SVG 文件转换成动态的 HTML 文件,以便深入分析。

img

图源:https://github.com/spiermar/d3-flame-graph

火焰图不仅可以用来分析 CPU 热点,也可以用来排查内存泄漏问题。这里需要使用某些内存分配追踪工具,记录内存的分配和释放情况,然后基于这些数据生成内存火焰图。内存火焰图的矩形块颜色是绿色的,每个矩形块的宽度表示该函数内部分配的字节数。

image-20230405231152705

图源:Memory Leak (and Growth) Flame Graphs - Brendan Gregg

最后介绍一下差分火焰图。差分火焰图可以对比不同时间段的两张火焰图的差异,以观察哪些函数的资源开销发生了变化。差分火焰图的形状和第二张火焰图相同,矩形块的颜色表示该函数资源开销 (占比) 的差异,红色代表增长,蓝色代表减少。

Differential Flame Graphs

图源:Differential Flame Graphs - Brendan Gregg

差分火焰图可以用来定量分析某项性能优化工作是否有效,比如优化了一个热点函数后,应该能从差分火焰图上看到该函数的 CPU 开销有显著减少。此外,差分火焰图也可以用来排查内存泄漏问题,比如在一台发生内存泄漏的机器上,每隔一段时间采集一份内存数据报告,然后生成内存差分火焰图,便可以很直观地看出增长的内存来自哪里。

Perf

Perf 是 Linux 操作系统中一个强大的性能分析工具,可以用来追踪 CPU、内存和 I/O 等方面的性能问题。它的原理是利用 Linux 内核提供的系统调用接口,跟踪和记录各种事件的性能数据,并输出到文本文件中。Perf 命令可以和火焰图工具结合使用 —— 前者收集数据,后者可视化展示。

使用 perf record 命令,将程序的 CPU 执行情况记录到 perf.data 文件中:

perf record -p {pid} sleep 30

上面的命令表示采集指定 pid 的进程,持续 30s。可能的输出:

$ ./perf record -p 59 sleep 30
Lowering default frequency rate from 4000 to 1000.
Please consider tweaking /proc/sys/kernel/perf_event_max_sample_rate.
[ perf record: Woken up 55 times to write data ]
[ perf record: Captured and wrote 21.482 MB perf.data (462814 samples) ]

使用 perf report 命令,可视化地查看和分析数据。默认加载当前目录的 perf.data 文件:

perf report

一个可能的数据样例如下,从中我们可以看到每个函数执行占用的 CPU 百分比:

# Samples: 100K of event 'cycles:u'
# Event count (approx.): 1000000
#
# Overhead  Command      Shared Object                  Symbol
# ........  .......  .................  ..............................
#
    38.02%  my_prog  libfoo.so.1.2.3   /usr/lib64/libfoo.so.1.2.3
              9.21%  Foo::bar()
              8.08%  Foo::baz()
              7.12%  Foo::qux()
              6.61%  Foo::quux()
              4.48%  Annex::foo()
              2.22%  Annex::bar()
              0.30%  Annex::baz()
              0.01%  std::string::operator[](unsigned long)
              0.01%  std::operator+(std::basic_string<char, std::char_traits<char>, std::allocator<char> > const&, char const*)
              0.01%  Annex::qux()

    24.41%  my_prog  libbar.so.4.5.6   /usr/lib64/libbar.so.4.5.6
             13.05%  Bar::foo()
              6.89%  Bar::bar()
              4.70%  Bar::baz()

加载数据后,按 / 可以搜索函数名,会从高到低展示不同线程中该函数的 CPU 占比。

一般来说,我们可以很快通过 perf report 或火焰图定位到哪个函数是热点。接下来需要在机器指令级别深入分析产生性能热点的原因。在某个函数名上回车,可以进入该函数,查看每条机器指令的执行开销。以下是一个可能的数据报告:

------------------------------------------------
 Percent |   Source code & Disassembly of noploop
------------------------------------------------
         :   int main(int argc, char **argv)
         :   {
    0.00 :    8048484:       55                      push   %ebp
    0.00 :    8048485:       89 e5                   mov    %esp,%ebp
[...]
    0.00 :    8048530:       eb 0b                   jmp    804853d <main+0xb9>
         :                           count++;
   14.22 :    8048532:       8b 44 24 2c             mov    0x2c(%esp),%eax
    0.00 :    8048536:       83 c0 01                add    $0x1,%eax
   14.78 :    8048539:       89 44 24 2c             mov    %eax,0x2c(%esp)
         :           memcpy(&tv_end, &tv_now, sizeof(tv_now));
         :           tv_end.tv_sec += strtol(argv[1], NULL, 10);
         :           while (tv_now.tv_sec < tv_end.tv_sec ||
         :                  tv_now.tv_usec < tv_end.tv_usec) {
         :                   count = 0;
         :                   while (count < 100000000UL)
   14.78 :    804853d:       8b 44 24 2c             mov    0x2c(%esp),%eax
   56.23 :    8048541:       3d ff e0 f5 05          cmp    $0x5f5e0ff,%eax
    0.00 :    8048546:       76 ea                   jbe    8048532 <main+0xae>
[...]

从中可以看到,cmp 指令占用了大量的 CPU 时钟周期,原因是它位于一个循环体中。

下面是另一份数据报告,对应了一段「在哈希表中查找关键字」的代码:

    0.00 :    xor    %edx,%edx
    1.80 :    mov    $rdx,$r8
         :    _ZNKSt10_HashtableI1St4pair__equal_toI1ESt4hash_20_Default_
    0.03 :    mov    (%rcx),%rax
    0.03 :    mov    (%rax,%r8,8),%rax
   18.60 :    test   %rax,%rax
    ...
   14.78 :    cmp    0x8(%rbx),%r14
    ...

💡 上面的 _ZNKSt10_xxx 是一个 C++ 符号名。使用 c++filt 命令可以将其转换为人类可读的形式。

从中可以看出,访存指令的开销很大,这表明哈希表在查找过程中经常失败。对应的优化手段有调整哈希函数、改进哈希冲突解决策略等,以减少哈希表的 miss 率。

按照类似的思路,我们可以利用 Perf 命令,在指令级别分析某个函数成为性能热点的原因。比如:某指令在循环中被频繁执行、某指令涉及访存操作、某指令依赖某些暂不可用的数据 (如锁)、某指令本身是一个多周期指令等。针对不同的问题,需要采用不同的优化方案。

最后,Brandan Gregg 有一篇非常详细的 Perf 命令使用指南,涵盖了 CPU 统计、事件分析、内核跟踪等话题,配合火焰图,基本可以排查出任意性能问题。建议深入阅读原文,此处不再展开。

pprof

pprof 是 Golang 官方提供的性能分析工具,可以生成 CPU、内存等多种类型的 Profiling 数据,支持以可视化的方式展示。pprof 内置了火焰图、函数调用图、表格等多种展示方式。

对于 Golang 服务的性能优化,建议使用 pprof,或者 pkg/profile 等开源库。

四、与性能优化相关的基础知识

后面几节将从不同层面讨论服务性能的优化手段。在此之前,有必要先了解一些基础知识。这些知识可以帮助我们更深入地理解程序性能优化的原理和方法,从而更有效地进行性能优化。

并行与并发

并行 (Parallelism) 和并发 (Concurrency) 都是计算机处理多个任务的方式:

  • 并行是指在同一时刻,同时执行多个任务,这通常依赖多个处理器或核心。
  • 并发是指在一段时间内,通过任务的交替执行来同时处理多个任务。并发在宏观上是多任务同时运行,但是在任意时刻只有一个任务被处理。

实际场景中,可以通过多进程、多线程、协程等技术实现并发,通过向量化、GPU 计算等技术实现并行,从而充分利用 CPU 资源,减少空闲时间,提高程序性能。

指令级并行

CPU 通常具有多个执行单元 (如整数单元、浮点数单元),可以同时执行多个指令,这种技术称为指令并行 (Instruction-level parallelism, ILP)。以下是一些相关机制:

  1. 流水线 Pipeline:将 CPU 执行指令的过程划分为多个独立的阶段,通常包括取指令、译码、执行、访存、写回等,然后使用不同的硬件单元来并行执行不同阶段的指令。流水线可以提高 CPU 的效率,但如果遇到数据依赖或分支预测错误等问题,会导致流水线停顿。

  2. 乱序执行 Out-of-Order Execution (OOO):在 CPU 中使用重排序缓冲区来缓存乱序执行的指令结果,再将结果按照原有的顺序提交给 CPU。

  3. 预取 Prefetch:提前将下一条指令所需的数据从内存加载到 CPU 缓存中,避免因内存访问延迟而导致的指令停顿。

  4. 动态指令调度 Dynamic Instruction Scheduling:使用指令调度器动态地调整指令的执行顺序,优化指令的执行流程。动态指令调度通常在流水线中进行,通过分析先前已执行的指令,来决定下一个要执行的指令是哪一个,以避免潜在的数据冲突和分支预测错误。

  5. 分支预测 Branch Prediction:if-else 语句、for 循环等分支指令,在执行时会根据条件跳转到不同的代码块。由于其跳转目标不确定,CPU 可能会浪费很多时间在等待分支跳转的过程中。为了解决这个问题,CPU 使用分支预测机制,在执行分支指令之前预测下一个跳转的目标指令,并进行预取。如果预测错误,CPU 需要重新执行正确分支的所有指令。

局部性原理

CPU 访问内存的时间比执行指令要长得多。因此,CPU 内部通常拥有多级缓存,如 L1 缓存、L2 缓存、L3 缓存等。缓存越靠近 CPU,访问速度越快,但容量越小;相反,缓存的级别越高,容量越大,但速度越慢。

局部性原理指出,在计算机程序执行过程中,访问的数据和指令通常集中在空间上相邻的位置 (空间局部性),且会在一段时间内被反复使用 (时间局部性)。因此,CPU 可以通过预取等技术将需要访问的数据和指令提前载入到 CPU 高速缓存中,以降低访存延迟的影响。

当 CPU 访问内存时,它并不是仅仅把单个字节或单个字从内存中读取到缓存,而是以块为单位,一次性载入多个连续字节。这个单位称为缓存块 (Cache Line),其大小通常是 64 或 128 字节。如果 CPU 访问的数据和指令都集中在一个缓存块中,那么就可以一次性载入缓存,避免多次访问内存。

因此在编写代码时,应该尽可能地减少访问内存的次数,使用数组、结构体等数据结构,避免不规则的内存访问模式,充分利用缓存。

编译器优化

编译器会对源代码进行各种优化,以使生成的汇编代码更加高效。常见的优化手段有:

  1. 指令重排序:将程序中的语句按照一定的顺序进行排列,减少指令之间的相关性,提高指令并行度和 CPU 流水线效率。
  2. 常量折叠:在编译期间计算出常量表达式的结果,减少程序运行时的计算量。比如 i = 320*200*32 会直接代替为 2,048,000,而不是生成两个乘法指令。
  3. 常量传播:替换表达式中已知常量,这是一个持续传播的过程,会和常量折叠交错使用。
  4. 公共子表达式消除:识别重复的表达式,计算出结果并缓存,减少重复计算。
  5. 强制内联:将函数的代码直接嵌入调用者的代码中,减少函数调用的开销。
  6. 消除死代码:识别程序中不会执行的代码,并将其从程序中删除,减少程序的大小。
  7. 循环优化:对循环代码进行代码重排、循环展开、循环移位等操作,减少指令数量。
  8. 向量化:利用 CPU 的 SIMD 指令集,将程序中的标量计算转换为向量计算。
  9. 尾调用优化:当函数调用位于函数体尾部的位置时,这种函数调用称为尾调用。对于尾调用函数,CPU 可以不保留外层函数的调用记录,直接用内层函数取代。

在编写代码时,应注意代码的结构和风格,给予编译器相关提示,以方便编译器进行优化。

🔗 扩展阅读:编译优化 - OI WikiCSE 231 - LLVM ProjectLLVM 循环优化器 Polly 架构

数据结构和算法

选择合适的数据结构和算法是提高程序性能的关键。

比如,C++ 提供了 unordered_setset 来存储键值对。unordered_set 使用了哈希表实现,不保证元素的有序性,但是插入和查找的平均时间复杂度为 O(1)。set 使用红黑树实现,保证元素的有序性,但插入和查找的时间复杂度为 O(log n)。因此,如果需要有序地遍历元素,应该使用 set。如果需要高效地插入和查找元素,应该使用 unordered_set

再比如,少量数据查询在不在,使用哈希表就可以实现。但海量数据查询在不在,位图或布隆过滤器可能是更合适的方式。

优化算法也可以降低程序的时间复杂度。比如使用快速排序代替冒泡排序,又或者在搜索过程中加入一些条件判断来剪枝、以及引入启发式搜索,提高搜索效率。

五、 代码层面的优化

💡 下文主要描述了 C++ 的优化方法。

使用静态分析工具

静态代码分析工具可以在不执行程序的情况下,发现潜在的代码问题,并给出优化建议。常见的 C++ 静态代码分析工具有 Clangd、Cppcheck、Coverity 等。我使用的是 Clangd,它提供了 VS Code 插件,能自动标识出不安全或低效率的代码,并给出 Quick fix 建议。

减少函数调用

函数调用会消耗时间和空间,可以使用宏定义和 inline 函数来内嵌代码。但如果代码过长,会降低编译期和运行期的性能。对于那些非常短小或者频繁调用的函数,可以用 inline 优化。

避免频繁创建和销毁对象

  • 通过引用或指针传递参数,而不是通过值拷贝。
  • for 循环里的 auto foo 改成 const auto& foo
  • 正确使用 std::move(),避免对象拷贝。

编写局部性原理友好的代码

  • 使用连续的存储结构。比如使用数组代替链表,使用 vector 代替 set 等。
  • 按照行优先遍历数组,而不是按照列优先。

使用高效的数据结构和算法

  • string_view 和 span 是 C++ 的两个标准库,类比 string 和 vector。区别在于它们对外提供只读的数据,多个对象可以共享底层的内存,避免操作时的内存拷贝。
  • 使用 flat_map、flat_set 等代替 map 和 set。前者使用了连续的内存空间存储键值对,相比于后者使用离散空间存储,遍历和随机访问的速度更快,但插入和删除操作变慢。
  • 使用 f14 hash_table 代替 unordered_set。f14 在性能上有显著提升,尤其是在处理小数据集时的性能表现更佳。
  • 使用 sonic-cpp 代替 rapidjson。sonic-cpp 利用向量化 (SIMD) 指令、优化内存布局和按需解析等关键技术,极大地提升了序列化、反序列化和增删改查的性能。
  • 使用 PB 或 Thrift 等二进制数据格式,代替 JSON、XML 等文本格式。优点是序列化 / 反序列化效率更高,生成的数据更小。缺点是配置成本高,生成的数据人类不可读,

深入编译器优化

https://godbolt.org 是一个在线网页,可以实时将 C++ 代码编译成汇编指令,展示汇编指令和源码的对应关系,以及运行编译产物。支持 clangd、gcc 等多种编译器,支持自定义编译选项和添加外部依赖库。非常适合调试简单代码,或者深入分析编译过程。

  • 打开 O2 或者 O3 等优化选项。O2 启用了许多常见的优化,如函数内联、循环展开和常量传播等。O3 在 O2 的基础上进一步优化代码,例如使用更高级的寄存器分配算法和更好的循环优化,但可能会导致编译时间变长。
  • 使用 constconstexprconstevalconstinit 等关键字:
    • constexpr :表达式、函数、变量可以在编译期计算得到结果
    • consteval:函数必须在编译期计算得到结果
    • constinit:变量必须在编译期完成初始化
  • 为不会抛异常的函数添加 noexpect 关键字。比如为移动构造函数加上此关键字,那么 vector 的 push_back 函数将调用移动构造函数,而不是默认的拷贝构造函数。
  • 提高分支预测成功率:
    • 使用 [[likely]][[unlikely]] 修饰分支,提示编译器分支的进入概率。
    • 使用 [[assume]] 修饰表达式,提示编译器该表达式在运行时的结果必定为真。
    • 将数据排序,保证按顺序遍历时,前 50% 数据进入 A 分支,后 50% 数据进入 B 分支 (Godbolt 示例)。
    • 避免分支,将分支语句改为读取一个 bool 变量 (Godbolt 示例)。
  • 使用 SIMD 指令集,将程序中的标量计算转换为向量计算:
    • 显式使用。例如使用第三方库,封装了多种使用 SIMD 指令集的函数。或者使用编译器提供的原生向量化指令。
    • 隐式使用。编译器能够自动把某些循环代码优化为向量化指令,前提是我们要编写向量化友好的代码,比如不在循环里引入分支指令 (Godbolt 示例)。

使用 Auto FDO 优化技术

Auto FDO (自动反馈优化) 是一种编译器优化技术。它利用程序在运行时的性能数据,分析哪些代码路径被频繁执行,从而优化编译器生成的代码。本质上是利用真实的数据,反过来提高分支预测的成功率。实际场景中,程序的输入会经常变化,对应的代码路径分布也会变化。因此,即使是同一份代码,也需要定期重新运行 Auto FDO。

使用并发编程技术

如多进程、多线程、协程、异步 IO 等,提高 CPU 的利用率。

使用更高效的内存分配库

jemalloctmalloc

扩展阅读

六、 系统架构层面的优化

优化硬件资源

如使用 SSD、扩大内存等,提升磁盘读写速度。或者增加集群机器数,但是要考虑成本。

利用缓存

缓存并不仅指 CPU 上的 L1 / L2 / L3 缓存。理论上总是可以用速度更快的存储作为慢速存储节点的缓存。比如在内存里维护一个本地文件的缓存,或者使用 redis 作为数据库的缓存等。使用缓存时,要注意为数据设置合理的过期时间,以及选择合适的淘汰算法。

数据库性能调优

数据库调优的目的是优化数据库访问和查询的耗时,常见的手段有加索引、分库分表等,这里不作展开。

容器化集群的优化手段

  • 实现负载均衡,保证各个服务节点的资源利用率均匀。
  • 开启自动弹性扩缩容策略。低流量时缩容,节约成本。高流量时扩容,降低负载。
  • 潮汐集群。一般来说,在线业务在白天的流量很高,但晚上基本没有流量,冗余的机器资源正好可以给大数据、模型训练等离线任务使用。潮汐集群主要是为了节约成本,但通过资源共享,也变相地扩展了服务的计算资源。

限流和熔断

  • 限流指控制服务以恒定的速率处理流量,多余的流量会被丢弃。这是为了在不确定和不稳定的流量环境中保证系统稳定运行。常见的限流算法有漏桶算法和令牌桶算法。
  • 熔断指当下游服务因访问压力过大而响应变慢或失败时,上游可以暂时切断下游调用,以保护系统整体的可用性。

降级机制

  • 降级指在服务出现故障或者超载等情况下,主动减少或者关闭一些服务,以保证核心服务正常运行。比如秒杀系统中,只更新缓存里的库存数,然后异步更新数据库。或者推荐系统中,使用高热内容代替个性化推荐内容。
  • 对于核心服务,可以通过降低服务质量,来减轻系统压力。比如减少查询结果数量、降低返回的图片质量,减少精排服务的预估条数等。
  • 服务降级应该是一种有计划、有条理和可控的行为,在出现故障之前就需要预先规划对应的降级预案。降级预案可以手动操作,也可以自动触发。

个性化的降级机制

  • 引入流量价值预估机制,实现请求粒度的个性化降级。比如广告系统中,可以预估当前请求的价值,低于门槛的请求自动丢弃,不召回广告,节省算力。
  • 引入剩余延时机制,根据请求在全链路的剩余延时,自动调节各个模块的降级参数。比如一个广告请求,如果前置链路耗时较短,那么可以给后续的精排模块传入更多的候选,以获得更好的点击效果。
  • 引入算力分配机制。还是以广告系统为例,在系统层面,80% 的收入是由 20% 的广告贡献的,那么可以将这 20% 的广告和剩下的 80% 的广告拆分成两个数据库,分配不同的召回条数。在用户层面,可以结合流量价值预估,减少低价值请求的预估条数,将系统的算力更多地分配给高价值请求。

七、总结

性能优化是每个程序员的必修课。这既需要掌握相关基础知识,也需要有实际操作经验。

建议阅读《CSAPP》等经典书籍,并了解机器指令的原理,以更好地指导性能优化工作。线上服务在不断迭代,需要持续进行性能优化。每次性能优化后,必须通过基准测试和压力测试,验证性能优化的效果,让数据说话。

以上就是本文的全部内容,欢迎交流讨论。

参考文献

]]>
Images。
💻【程序员】使用 Netlify + Jekyll 快速搭建个人博客2023-03-12T12:00:00+08:002023-03-12T12:00:00+08:00https://imageslr.com/2023/jekyll-netlify前言

在这篇文章中,我们将学习如何利用 Netlify + Github + Jekyll,快速零成本搭建个人博客。

每个技术人都应该有自己的博客。正如 Github Profile 一样,博客也是一张对外展示的名片。Github 展示了你的开源项目和编码水平,博客则展示了你的思考与技术沉淀。

为什么我不建议选择 CSDN、博客园、竹白等平台,或者语雀、飞书文档等个人知识库呢?一方面,每个平台有不同的调性。读者对你的印象,会受到这个平台其他作者的平均值的影响。有的平台虽然 SEO 做得很好、总是出现在搜索引擎的首位,但内容质量属实不敢恭维。出现在这样的平台上,很难保证读者不会给你的文章预设一个较低的分数。另一方面,这些平台不支持自定义主题,大家都使用统一的样式和排版风格,互相之间基本没有区分度,个人符号很难在其中得到展示。最后,有些平台是封闭的,无法被搜索引擎索引到。

所以,我建议申请一个独特的域名,搭建一个专属于你的个人博客。在这里,我们拥有完整的自主权,可以修改主题样式、监控网站数据、分享只属于你的内容、结交志同道合的朋友。

当然,自建博客也有缺点,比如 SEO 差、访客数量少、缺少交互性等。但对我来说,写博客不是为了获得知名度和商业收入,而是想纯粹地记录和分享。我在搜集资料、解决问题的过程中耗费了不少时间,写一篇博客不仅可以帮助自己理清思路,还可以让知识复用。提高文章的信息量、让博客的内容有长期价值、让每位读者都有收获,这便是写作的意义。

总之,博客是一个值得精心打磨的作品。如果一份简历上附有独立博客的链接,我一定会想点进去看一看。如果你也有这样的想法、希望输出有价值的内容、享受书写的乐趣,那就参考下面的步骤,用 10 分钟的时间搭建一个博客吧。

Quick Start

这一节我们将直接用 Netlify + Github + Jekyll 零成本搭建个人博客。简单介绍一下原理:

  • Jekyll 是一个静态博客生成器,它可以把 markdown 格式的文本内容转成静态的 HTML 页面。可以修改 CSS 来配置博客的样式和风格,网上有很多现成的主题可以使用。
  • 每个 Jekyll 项目是一个文件夹,包含了这个博客的所有内容,如 markdown 文章、图片、CSS 文件、字体资源等。
  • Jekyll 构建的产物是纯 HTML 页面。我们把它拖到任何一个静态站点托管服务上,便可以在浏览器中访问。
  • Github 提供了 Github Pages。这是一个免费的静态站点托管服务,我们可以直接在某个 Github 仓库里托管 HTML 页面,然后通过 <username>.github.io 去访问。
  • Jekyll 内置了对 Github Pages 的支持。我们只需要把 Jellky 项目上传到 Github 仓库,再把该仓库设置为 Github Pages,就可以在每次仓库提交后,自动构建和发布。
  • Netlify 是一个类似于 Github Pages 的静态站点托管服务,界面操作体验更好,构建和访问速度更快。绑定一个 Github 仓库之后,Netlify 会监听该仓库的提交、自动完成构建和发布。普通用户每个月有一定的免费流量额度,作为个人博客来说完全够用了。我们可以通过 Netlify 提供的 唯一id.netlify.app 链接来访问网站,但既然是个人博客,最好还是绑定到一个自己的域名上。

一、安装 Jekyll 环境

参考 Jekyll 的官方文档

先安装基本环境 Ruby 和 Ruby Gems,详见 https://jekyllrb.com/docs/installation/

然后安装 jekyll 和 bundler:

gem install jekyll bundler

之后创建第一个 Jekyll 项目。你可以从零创建一个默认项目:

jekyll new myblog

也可以直接复用 Github 上公开的主题,比如 jasper

git clone https://github.com/jekyllt/jasper

最后构建网站,这会在 _site 目录下生成 HTML 页面,同时以 HTTP 方式提供服务:

# cd jasper
# bundle add webrick
# bundle install
bundle exec jekyll serve --livereload

访问 http://localhost:4000,便可以看到我们的博客首页:

image-20230312151902964

💡 给 serve 命令添加 –livereload 选项,可以在源文件有任何改变时自动刷新页面。

💡 亲测 MacOS 安装 Jekyll 环境比较麻烦,可以考虑使用现成的 docker 镜像,参考这篇文章

二、创建 Github 仓库

这里我们需要把上一步创建的 Jekyll 项目上传到 Github 仓库。比较基础,就不再赘述了。

三、配置 Github Pages (可选)

💡 这一节只是为了演示 Github Pages 的功能,不建议使用它部署个人博客,推荐使用 Netlify + 自定义域名。

Github Pages 的官方文档有详细教程,下面是摘要:

  1. 创建一个名为 <username>.github.io 的代码仓库:

    创建存储库字段

  2. 上传 Jekyll 项目到该仓库:

    # cd jasper
    # git remote add origin [email protected]:imageslr/imageslr.github.io.git
    git push
    
  3. 进入 Github 仓库 → Settings → Pages,配置 Jekyll Actions,如下图 ①~④:

    image-20230312154812471

    如果有独立域名,也可以在上图 ⑤ 配置。

  4. 等待几分钟,就可以通过 https://username.github.io 访问博客了。

    如果你使用的是 jasper 主题,需要按照下图修改 _config.yml,才能正常加载到 CSS 资源:

    image-20230312160345749

四、配置 Netlify 项目

  1. 访问 https://app.netlify.com,直接使用 Github 账号登录。

  2. 选择从 Github 导入项目 → 授予 Netlify 权限 → 安装 Netlify 应用 → 导入博客项目:

    image-20230312161113686

    image-20230312161205048

  3. 等待项目首次构建完成:

    image-20230312161406530

  4. 然后便可以使用 Netlify 提供的 唯一id.netlify.app 链接来访问博客了。


Netlify 部分功能说明:

  • 部署状态:

    image-20230312161849257

  • 域名管理:Site settings → Domain management

    image-20230312164404305

五、申请独立域名

为什么要申请独立域名?一方面,域名是我们在互联网上的符号。相比于 Github Pages 的 github.io 和 Netlify 的 netlify.app,个性化的域名有更强的个人色彩,便于读者记忆和分享。另一方面,域名是一个方便的网站定位器。当我们想要从 Netlify 迁移到其他平台时,只需要修改域名的指向记录,而不需要读者重新保存一个新的链接。

申请域名非常简单,只需要选择一个域名服务商、搜索喜欢的域名是否已经被注册、付费。国内的域名服务商有阿里云、腾讯云等,国外的有 GoDaddy 等。域名付费一般以年为单位,首年费用较低,但后续续费价格可能增加。国内注册域名需要备案。

注册域名后,可以参考上面的步骤,将域名指向 Netlify 的博客项目。

深入讨论

博客框架

静态博客 vs 动态博客

静态博客生成器 (Static Site Generator) 不依赖数据库,所有博客内容都以文件的形式存储。静态博客生成器的作用是把 Markdown 格式的文本内容转成静态的 HTML 页面,需要我们自行部署。优点是轻量、易用、访问速度快、可以在本地缓存页面后离线查看。缺点是发布内容慢,需要更新本地文件 → 上传 → 部署,以及插件数量少,需要自行编码集成。

动态博客依赖数据库,博客内容是数据库里的一个条目。优点是使用简单,能在线编写文章,有丰富的插件,自带管理后台。缺点是需要运行在服务器上,部署和维护较为繁琐。一个知名的动态博客框架是 WordPress

💡 我个人推荐使用静态博客框架,原因是上手简单、成本低、好维护、文章能够本地存档。

静态博客框架对比

以下对比了几个知名的静态博客生成器。

Jekyllhttps://jekyllrb.com

  • Ruby 实现的老牌博客框架。
  • 优点:网上有丰富的教程和主题;原生支持 Github Pages;支持 Sass;支持 Liquid 语法,某些语法糖很好用。
  • 缺点:装环境比较麻烦;构建速度相比于其他框架较慢;近期迭代较少。

Hexohttps://hexo.io/zh-cn

  • Node 实现的博客框架。
  • 优点:主题众多;安装简单;构建速度快;支持 Github Pages;HTML + CSS + JS 友好;插件众多;良好的中文文档和社区支持;快速迭代。

Hugohttps://gohugo.io

  • Golang 实现的博客框架。
  • 优点:轻便,构建速度快;无需安装环境,整个工具是一个二进制文件。
  • 缺点:主题使用 Go 模板开发,需要熟悉 Go。

VuePresshttps://vuepress.vuejs.org/zh/

  • Vue 驱动的静态网站生成器。非常适合用于 Wiki、API 文档等网站类型。
  • 优点:安装简单;构建速度快;可以使用 Vue 实现自定义组件,网站的动态性更强。
  • 缺点:主题较少。

💡 我最终选择了 Jekyll,只是因为喜欢它的主题。从易用性来说,我更推荐 Hexo。

使用 Markdown 编写

Markdown 是一种用来写作的轻量级标记式语言,它使用简洁的纯文本格式来编写文档,可以转换成有效的 HTML 或 PDF 文档。Markdown 的语法十分简单,常用的标记符号不超过十个,几分钟就能掌握。可以通过这篇文章学习:少数派:认识与入门 Markdown

基本上所有的静态网站生成器都是用 Markdown 写的。许多网站也支持 Markdown 语法,如 Github、少数派、石墨文档、飞书文档等。

我最初使用 VS Code 编辑博客的 .md 文件,同时打开浏览器预览效果。后来换到了 Typora,粘贴图片会更方便,也支持所见即所得。最后开发了一个和博客样式一致的 Typora 主题,就不需要再打开浏览器了。

博客优化

博客插件

以下是我的博客使用的插件。大部分插件都提供了傻瓜式的安装方法,某些插件需要有一定前端基础。这些插件都是免费的。

  • 不蒜子:极简网页计数器,两行代码搞定 PV、UV 统计。

  • giscus:由 GitHub Discussion 驱动的评论系统。

  • Google Analytics:Google 提供的站点统计工具,可以分析流量来源、所在国家、每个页面的阅读人次等。类似的工具还有百度网站统计

  • Algolia:网站搜索工具。本身是一个付费服务,但对开源社区提供了免费额度,需要发邮件申请,一般三个工作日会回复。申请通过后,algolia 会定时给你的网站建索引,之后在网站上添加一个搜索按钮,接入其 SDK,就可以搜索全站内容了。但如果博客的 SEO 做得不错,直接通过 site: xxx.com 在 Google 搜索就足够了。

    image-20230312205507859

💡 建议在开发环境下关闭这些插件,避免不必要的数据污染。

SEO 优化

因为 Netlify 的服务器在国外,一开始百度无法索引到我的博客内容。解决办法是在百度站长平台中主动推送网站的 sitemap:

image-20230312205009660

我使用的是 jekyll,需要安装 jekyll-sitemap 插件,这会在构建博客时生成一个 sitemap 文件。之后把这个链接提交到上图的输入框中,过十几天就可以在百度搜索到网站内容了。验证方法是在百度搜索 site: imageslr.com

除此之外,我的博客就没有做过 SEO 优化了,也没有主动推广过。目前来看,Google 的搜索效果最好、流量最多,百度聊胜于无。每天大约 80 UV。

性能优化

  1. 获取自动优化建议。通过 Google PageSpeed InsightsWeb Page Test 等工具。

    16333641783878

  2. 用 CDN 加速。比如从 CDN 加载 JS、CSS 文件,而不是放在博客的 assets 目录下。图片也可以上传到 CDN,许多 markdown 编辑器都支持配置图床,比如 Typora:

    image-20230312211016537

  3. 减少加载的文件大小。比如使用 .min.js 文件、去除用不到的 CSS、使用 ImageOptim 优化图片大小等。
  4. 提升特定地区的访问速度。可以通过访问速度测试工具,检测网站在不同国家和地区的访问速度。如果某些地区访问延迟很高或者 ping 不通,考虑在该地区部署一台实例。
  5. 如果自己部署了服务器,打开 gzip 选项。

Jeklins 自动部署 (可选)

去年我把博客部署在了腾讯云服务器上,但因为访问量较少,就没再续费了。期间参考这篇文章配置了基于「Gitee + Jenkins + 飞书机器人」的自动部署流程,记录下来,以备不时之需。

最终效果:我只需要往 Github 推送最新的提交,服务器就会自动拉取最新代码并构建,部署成功或失败都会给我发送一条飞书消息。

image-20230312213846047

具体实现:

  1. 因为国内服务器无法访问 Github,需要创建一个 Gitee 仓库,然后参考这篇文章,配置 Github 仓库自动同步到 Gitee。我的实现在这里
  2. 飞书开放平台,申请一个飞书机器人,参考这篇文档获取机器人的 webhook 地址。之后就可以通过 curl 命令来给自己发送飞书消息了。
  3. 服务器安装 Jenkins,创建 CI 任务。我的实现在这里,里面集成了发送飞书消息的功能。
  4. 把 Jekins 的 Webhook 地址添加到 Gitee 仓库的 WebHooks 中。参考这篇文章

结语

最后我想说,搭建博客是成本最低的操作,持续输出才是最难的。要多写精品文章、写原创内容。不要发一些可以很容易检索到的内容。提高博客文章的信息量、让博客的内容有长期价值、让每位读者都有收获。

]]>
Images。
💬【Think】关于工作的浮墨笔记2023-02-09T10:00:00+08:002023-02-09T10:00:00+08:00https://imageslr.com/2023/flomo工作了一年半载,脑海中经常会蹦出一些想法,也有些感悟心得。但写一篇文章的周期太长了,产出速度越来越慢。经验需要交流分享才能激发价值,于是打算单独开一个讨论贴,发一些工作中的碎碎念,抛砖引玉。

这里直接复用了 Github Discussion,在本文的评论区里可以查看。没有主题,随时记录。欢迎大家分享你的学习方法、工作心得、提效指南等。

原文链接:https://github.com/imageslr/blog/discussions/103

]]>
Images。
💻【Linux】GDB 入门笔记2023-01-06T18:00:00+08:002023-01-06T18:00:00+08:00https://imageslr.com/2023/gdb前言

GDB 全称 GNU Project debugger,是一个通用的 C / C++ 程序调试器,可以用来深入分析程序的运行过程,或者排查程序崩溃的原因。

GDB 主要有以下几个功能:

  1. 运行程序,随心所欲地查看程序内部状态 (如变量值、寄存器值)、控制程序的行为 (如逐行执行、反向执行等)
  2. 使程序在特定位置中断,或者满足条件时才中断
  3. 当程序崩溃时,查看完整现场,分析发生了什么
  4. 改变程序状态 (如临时修改某个变量值),以测试程序在不同情况下的行为

在日常工作中,我经常会使用 GDB。比如线上发生 coredump,需要用 GDB 来排查;调试程序时,使用 GDB 打断点,逐行执行,效率远高于加 debug 日志。

GDB 和 Vim 一样,只需要学会几个简单的命令,就能解决大部分问题。但它们就像一把瑞士军刀,有丰富的功能和技巧,只有深入掌握,才能成为效率提升利器。

本文面向的读者是 C / C++ 程序员,主要内容包括 GDB 的基本命令、进阶用法和实践案例。目标是使读者掌握 GDB 的常见使用方法,满足日常开发所需。读者也可以将本文作为 GDB 命令的速查手册,随时查阅。

本文约定:

  • 代码格式:如果没有前缀,或者前缀是 $,表示在 shell 执行;如果前缀是 (gdb),表示在 GDB 内执行;(gdb) 命令后面的 // xxx 是注释内容,不包含在要执行的命令中。

〇、Hello, world

安装 GDB

本文在 Linux (CentOS) 环境下运行 GDB,读者也可以使用网页版 GDB

Linux 系统可以使用包管理器安装:

$ sudo apt-get update
$ sudo apt-get install gdb

Mac 系统可以使用 brew 安装:

$ brew install gdb

Mac 还需要给 GDB 签名,参考 GDB Wiki,否则会有这样的报错:

Starting program: /x/y/foo
Unable to find Mach task port for process-id 28885: (os/kern) failure (0x5).
 (please check gdb is codesigned - see taskgated(8))

使用 GDB

下面是一个使用 GDB 设置断点、逐行运行程序的示例。

  1. 编写 C++ 程序:

    // main.cpp
    #include <iostream>
    using namespace std;
       
    void print_foo(int v) {
      int i = v + 5;
      i = i + 3;
      cout << "i == " << i << endl;
    }
       
    int main() {
      int a = 0;
      a += 1;
      a += 2;
      print_foo(a);
      return 0;
    }
    
  2. 编译程序,添加 -g 选项,保留 debug info:

    $ g++ -g main.cpp -o example
    
  3. 进入 gdb,加载二进制程序,最后一行表示符号表加载成功:

    $ gdb example
    GNU gdb (GDB) 12.1
    Copyright ...
    Find the GDB manual and other documentation resources online at:
        <http://www.gnu.org/software/gdb/documentation/>.
    For help, type "help".
    Type "apropos word" to search for commands related to "word"...
    Reading symbols from example...
    
  4. main() 函数第一行设置一个断点,运行程序:

    (gdb) b main.cpp:12
    Breakpoint 1 at 0x55555555522c: file main.cpp, line 12.
    (gdb) r
    Starting program: /home/a.out 
    Breakpoint 1, main () at main.cpp:12
    12        int a = 0;
    
  5. 逐行执行程序,打印变量 a 的值:

    next 命令输出的是下一行要执行的代码。如果下一行是函数,next 命令会执行完整个函数,停在函数的下一行 (step over)。

    (gdb) next
    13        a += 1;
    (gdb) p a
    $3 = 1
    (gdb) next
    14        a += 2;
    (gdb) next
    15        print_foo(a);
    (gdb) p a
    $4 = 3
    
  6. step 命令会进入函数,停在函数的第一行 (step into):

    (gdb) step    
    print_foo (v=21845) at main.cpp:5
    5       void print_foo(int v) {
    
  7. backtrack 命令可以查看当前程序的调用栈:

    (gdb) backtrace
    #0  print_foo (v=21845) at main.cpp:5
    #1  0x0000555555555245 in main () at main.cpp:15
    
  8. continue 命令会执行程序,直到遇到下一个断点。这里没有下一个断点了,整个程序正常退出:

    (gdb) continue
    Continuing.
    [Inferior 1 (process 1308) exited normally]
    

命令的简写形式

大部分 GDB 命令都有一个简写形式,一般是命令的首字母,比如:

  • backtracebt
  • breakb
  • continuec
  • nextn
  • infoi

某些命令有相同的前缀,只需要写出前几个能区分的字符,GDB 就可以识别:

(gdb) i w    // 无法判断
Ambiguous info command "w": w32, warranty, watchpoints, win.
(gdb) i wat  // 可以识别,等于 info watchpoints
No watchpoints.

此外,在 GDB 中如果什么都不输入,直接回车,会重复执行上一条命令。

命令的适用场景

当应用程序异常退出时,操作系统会生成一个 coredump 文件,记录了程序退出时的所有内存状态。GDB 可以读取这个文件,查看程序退出时的变量值或者寄存器值,但是无法执行程序。即只能使用静态命令,如 pbti

GDB 也可以直接加载一个二进制程序并执行。在这种情况下,GDB 不仅可以随时查看程序当前的变量值或其他内存状态,还可以控制程序的运行,如设置断点、单步执行、反向执行等。即不仅可以使用静态命令,还可以使用 rbc动态命令。

帮助和术语

在 GDB 内使用 apropos {keyword} 可以模糊查找某条命令:

image-20230102213313034

使用 help {command} 可以查看某个具体命令的帮助文档:

image-20230102213725055

此外,使用 GDB 最好了解一些计算机的基础知识:

  • 操作系统:coredump、栈帧、线程等。
  • 组成原理:寄存器、汇编、ABI 等。

部分术语的说明详见附录。

一、基本命令

选择线程: t

info thread 可以查看当前进程的所有线程。示例程序是单线程的:

(gdb) info threads
  Id   Target Id            Frame 
* 1    process 1537 "example" main () at main.cpp:15

thread / t 可以查看当前位于哪个线程:

(gdb) t
[Current thread is 1 (process 3496)]

在多线程程序里,可以通过 t {id} 切换线程,每个线程有独立的调用栈。

查看堆栈: bt

backtrace / bt 可以查看调用栈。调用栈展示了从 main() 入口到当前断点或进程退出时刻的所有函数调用路径:

(gdb) bt
#0  0x0 in (unknown) at :0
#1  0x1a796e7c in foo() at main.cpp:13
#2  0x6259058 in bar() at main.cpp:17
#3  0x6bb7580 in main() at main.cpp:83

选择栈帧: f

每次函数调用,会创建一个独立的栈帧,对应上面的 #0#1#2。默认在 #0

frame / f 可以跳转到指定栈帧:

(gdb) f 2
#2  bar() at main.cpp:17
17        int a = foo();

up / down 可以向上层或下层跳转,对应编号增大或减小。

打印变量: p

基本使用

print / p 可以打印一个变量的值,支持数字、字符串、结构体、指针等变量类型:

(gdb) p a // int a = 3;
$1 = 3

打印出来的值会存在名为 $1$2、… 的变量里,后续可以直接复用:

(gdb) p $1 // 等价于 p a
$2 = 3

p 有一些可选参数:

  • -elements:限制字符串或者数组打印的元素数量
  • -max-depth:限制嵌套结构体的最大打印层数
  • …,help p 查看所有参数

💡 p 可以打印当前栈帧和全局作用域内的变量。如果打印变量时提示变量已经 optimized,可以尝试用 f 切换到更上层的栈帧。

打印指针

指针变量

p 后面跟一个指针类型的变量,打印的是指针的值,即指针所指向的地址:

(gdb) p b // int* b = &a;
$1 = (int *) 0x7ffd3dcfa27c

可以用解引用运算符,打印指针指向的值:

(gdb) p *b
$2 = 1

如果是字符串指针,p 会同时输出指针指向的地址字符串的内容

p str
$3 = (char*) 0x7ffc734ff250 "hello,world"

如果希望只打印地址,可以使用说明符 /a

(gdb) p/a str
$4 = 0x7ffc734ff250

/a 表示 address,即把变量的值以地址的形式打印。

地址字面量

p 默认会把十六进制的字面量看成是数字,输出一个十进制的整数:

(gdb) p 0x7ffd3dcfa27c
$1 = 140725640471164
(gdb) p 140725640471164 == 0x7ffd3dcfa27c
$2 = true

如果想把数字解释为地址、打印地址上的内容,需要先指定变量类型,然后解引用:

(gdb) p *(int*)0x7ffd3dcfa27c
$3 = 1

更简单的语法是 {TYPE}ADDRESS:

(gdb) p {int}0x7ffd3dcfa27c
$4 = 1

也可以用 x 命令打印地址。

转换指针类型

指针的类型可以转换,以不同方式解释其指向的内存区域:

// char* c = "hello, world";
(gdb) p c
$1 = (char *) 0x7ffc734ff250 "hello, world";
(gdb) p *(int*)c
$2 = 1819043176
(gdb) p {int}c
$3 = 1819043176

打印内存可以发现,1819043176 就是把 h e l l 四个字符解释成了一个整数:

(gdb) x/w 0x7ffc734ff250    // 以 word 形式打印,4 个字节
0x7ffc734ff250:	1819043176  // 上述 4 个字符的 ASCII 码转成整数

1819043176 对应的十六进制是 0x6C6C6568,恰好依次是 l , l , eh 的 ASCII 码。

打印结构体的字段

如果指针 p 指向某个结构体,可以用 p ptr->field 打印字段的值。

在 GDB 里,.-> 是一样的,所以无论 ptr 是否是指针,都可以用 p.field 打印字段的值。

打印数组

语法:p ELEMENT@LEN。从 ELEMENT 的地址开始向后解释 LEN 大小的内存单元,内存单元的大小是 sizeof(T)

栈上数组

如果 array 是栈上数组,可以直接 p array,会打印数组的所有元素:

// int array[] = {1, 2, 3, 4};
(gdb) p array
$1 = {1, 2, 3, 4}

也可以 p array[INDEX]@LEN,从某个下标开始打印指定的长度:

(gdb) p array[1]@[3] // array[1] 的类型是 int
$2 = {1, 2, 3}

但不能 p array@LEN,因为栈上数组 array 的类型是 int[4] 而不是 int

(gdb) p array@3
$3 = {{1, 2, 3, 4}, {-693741568, 32764, 1033857024, -1536906435}, {0, 0, -793505661, 32580}}
堆上数组

如果 array 是堆上数组,可以 p *array@LEN

// int* array = (int*)malloc(3 * sizeof(int));
(gdb) p *array@3 // *array 是数组的第一个元素,类型是 int
$1 = {1, 2, 3}

或者 p array[INDEX]@LEN,从某个下标开始打印:

(gdb) p array[1]@3 // array[1] 的类型是 int
$2 = {2, 3, 4}

但不能 p array ,因为堆上数组 array 的类型是 int* 指针,值是一个地址:

(gdb) p array
$3 = 0x55669a743eb0

也不能 p array@LEN,理由同上。array 是一个 int* 指针,保存在栈上,这里会输出栈上相邻内存的值,没有任何意义:

(gdb) p array@3
$4 = {0x55669a743eb0, 0x55669a255330, 0x200000001}

如果只有一个地址字面量,可以把它强制转换为指针类型,然后用同样的语法打印:

(gdb) p ((int*)0x55669a743eb0))[2]
$5 = 3

格式化输出

可以在 p 后面添加说明符 (specifier),把一个变量解释为给定的类型:

(gdb) p foo // int foo = 98;
$1 = 98
(gdb) p/c foo // 将 98 解释为字符
$2 = 98 'b'

所有说明符:

  • p/a:将变量解释为指针 address,使用十六进制打印

  • p/c:将变量解释为字符 char,打印为字符

  • p/o:使用八进制打印变量

  • p/x:使用十六进制打印变量

  • p/u:将变量解释为无符号整数 unsigned,使用十进制打印

  • p/s:将变量解释为字符串,打印输出

  • help x 查看全部:

    o(octal), x(hex), d(decimal), u(unsigned decimal),
    t(binary), f(float), a(address), i(instruction), 
    c(char), s(string) and z(hex, zero padded on the left)
    

STL 容器

std::shared_ptr

直接打印:

// std::shared_ptr<int> ptr = std::make_shared<int>(1);
(gdb) p ptr
$1 = std::shared_ptr<int> (use count 1, weak count 0) = {
  get() = 0x5596169122f0}
(gdb) p *ptr
$2 = 1

或者根据上面 get() 方法给出的地址打印:

(gdb) p {int}0x5596169122f0
$3 = 1

或者根据 shard_ptr 内部的私有变量 _M_ptr 打印:

(gdb) p ptr._M_ptr
$4 = 0x5596169122f0
(gdb) p *(ptr._M_ptr)
$5 = 1
std::vector

直接打印:

// std::vector<int> vec = {1, 2, 3, 4};
(gdb) p vec
$1 = std::vector of length 4, capacity 4 = {1, 2, 3, 4}

vector 也有私有变量保存了数据的实际存储位置:

  • _M_impl._M_start:数组起始地址
  • _M_impl._M_finish:数组结束地址 (数组最后一个元素的下一个)

可以根据这个指针打印:

(gdb) p {int}vec._M_impl._M_start
$2 = 1
(gdb) p {int}vec._M_impl._M_start@3
$3 = {1, 2, 3}
(gdb) p ({int}vec._M_impl._M_start)[2]
$4 = 3
std::string

直接打印:

(gdb) p str
$1 = "hello,world"

或者根据私有变量 _M_dataplus._M_p 打印,其类型是 char*

(gdb) p str._M_dataplus._M_p
$2 = (std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> >::pointer) 0x7ffc734ff250 "hello,world"
使用插件 STL-Views

使用 GDB 直接打印 set、stack、map 等 STL 类型是十分困难的。GDB 支持使用 python 编写 printer。GDB 官网提供了现成的 STL 容器的 printer,安装十分容易,开箱即用。

先下载源代码到 home 目录,如果终端不支持科学上网,可以网页里打开后复制内容,然后在 vim 里粘贴源代码:

$ wget https://sourceware.org/gdb/wiki/STLSupport?action=AttachFile&do=get&target=stl-views-1.0.3.gdb -O ~/stl-views-1.0.3.gdb

进入 gdb,加载插件,查看帮助:

(gdb) source ~/stl-views-1.0.3.gdb
(gdb) help pset
(gdb) help pmap

使用:

(gdb) pset s
(gdb) pset s int
(gdb) pset s int 20

如果打印内容被省略

打印字符串的时候,如果有重复的字符,可能会被合并成一个:

(gdb) p "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
$1 = 'a' <repeats 30 times>

可以通过命令 set print repeats 0 设置为不合并:

(gdb) set print repeats 0
(gdb) p "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
$2 = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"

打印数组的时候,如果元素过多,中间的元素会被省略。可以通过以下设置为不省略:

set print elements 0

查看历史变量

通过 p 打印出来的值会存在名为 $1$2、… 的变量里 (value history),后续可以直接复用:

(gdb) p a
$1 = 123
(gdb) p $1 // 等价于 p a
$2 = 123

一些特殊的变量:

  • $:最近打印的变量
  • $$$ 之前的变量,倒数第二个
  • $$n:最后一个变量往前的第 n 个变量,比如 $$0 就是 $$$1 就是 $$

可以批量打印历史变量:

  • show values:打印最后 10 个历史变量
  • show values +:打印刚才打印过的历史变量的后 10 个历史变量

打印内存: x

x 可以查看一个内存地址的值,以指定的格式打印。

(gdb) x/s 0x7ffc734ff250  // 以字符串形式打印
0x7ffc734ff250:	"hello,world"

x 支持的格式化说明符

  • x/c:将地址解释为字符 char,打印为字符

  • x/o:使用八进制打印变量

  • x/x:使用十六进制打印变量

  • x/u:将地址解释为无符号整数 unsigned,使用十进制打印

  • x/s:将地址解释为字符串

  • help x 查看全部:

    o(octal), x(hex), d(decimal), u(unsigned decimal),
    t(binary), f(float), a(address), i(instruction), 
    c(char), s(string) and z(hex, zero padded on the left)
    

xp 的区别:

  • 传入一个数字,p 会当作一个数字字面量,输出原始值的十进制;而 x 会当作一个地址,输出对应内存区域的值。比如:

    (gdb) p 0x10    // 字面量
    $1 = 16  	      // 输出十进制值
    (gdb) p/x 0x10  // 以十六进制形式输出
    $2 = 0x10
      
    (gdb) x/s 0x10  // 这个内存地址解释为字符串
    0x10 "hello, world"    
    (gdb) x/c 0x10  // 把这个地址上的内容解释为单个字符
    0x10:	'h'
    (gdb) x/d 0x10  // 把这个地址上的内容解释为整数
    0x10:	104
    
  • 传入一个指针,p 会输出指针的值,即一个十六进制地址;而 x 会输出指针指向的内存区域的值:

    (gdb) p str_pointer;
    $1 = 0x7ffc
      
    (gdb) x/s 0x7ffc
    0x7ffc "hello world"
    

x 的完整语法:x/FMT ADDRESSF / M / T 是可选的参数。

  • F:一个数字,表示输出几个内存单元,默认是 1
  • M:格式化说明符,o / x / d / u / s
  • T:一个内存单元的字节数,默认是 4 个字节,可选的是 b(byte), h(halfword), w(word), g(giant, 8 bytes)
  • ADDRESS:一个内存地址,可以是一个字面量,也可以是一个指针类型的变量

例如,x/3uh 0x1234 表示从内存地址 0x1234 开始,以双字节为单位,输出 3 个无符号整数。

打印类型: ptype

(gdb) ptype foo
type = int

打印各种信息: i

  • info locals:打印当前栈帧的所有局部变量
  • info args:打印所有函数参数
  • info threads: 打印进程的线程信息
  • info registers: 打印当前线程的寄存器信息
  • info sharedlibrary:打印当前加载的动态连接库
  • info proc mappings:打印地址空间中的内存 map,用来确定某个地址的类型
  • help info:所有 info 支持的命令

存储变量 / 修改变量的值: set

set 可以保存一个变量 (convenience variables),方便后续使用:

(gdb) set $foo = *object_ptr

查看所有存储的变量:

(gdb) show convenience
(gdb) show conv  // 简写形式

set 命令也可以用于在运行时修改某个变量的值:

(gdb) set foo.bar = true

如果没有调试符号,上述命令将无法查找到变量的地址。可以手动修改变量所在的内存位置:

set (char)0x7e864a2b = 1

修改变量值的使用场景:

  • 临时修复某个 bug,使程序可以继续运行
  • 给变量设置不同的值,测试不同的 case

断点调试: b

设置 / 清除断点

设置断点:break POINT,简写是 b

(gdb) b foo.cpp:14

设置断点的方式有多种

  • 在当前执行位置设断点:b,没有任何参数
  • 函数名:b function
  • 文件名 + 函数名:b filename:function
  • 行号:b linenum,在当前文件设置断点
  • 文件名 + 行号:b filename:linenum,在特定文件设置断点
  • 偏移量:b +offset / b -offset,在当前栈帧执行位置的前后设置断点
  • 给汇编命令打断点:略

删除断点:clear

(gdb) clear foo.cpp:14

clear 的语法和 break 相同,需要指定要删除的断点的位置:

  • clear:删除当前执行位置上的所有断点
  • clear functionclear filename:function
  • clear linenumclear filename:linenum
  • delete:删除所有断点,简写是 d

设置临时断点:tbreak。参数同 break,命中一次后就会自动删除。

GDB - Setting breakpoints

GDB - Deleting breakpoints

停用 / 启用断点

停用断点:disable

(gdb) disable      // 停用所有断点
(gdb) disable NUM  // 停用编号为 n 的断点

停用断点后,断点将暂时不被触发。可以通过 enable 命令启用断点,语法同 disable

继续运行: cont

命中断点后程序会停止运行,此时可以输入 continue 命令,继续运行程序。简写是 cont

查看所有断点:i b

(gdb) i b
(gdb) info breakpoints

这会以表格的形式展示断点编号、是否是临时断点、是否 enable、断点位置等信息。

在函数返回前中断

有时候希望在函数返回前中断,从而检查函数的返回值,或者检查函数是在哪一个 return 语句返回的。

有两种方式。一种是反向调试,先正向执行,直到函数返回,然后再反向执行,设置断点:

(gdb) record
(gdb) fin
(gdb) reverse-step

另一种方式更通用。所有的函数无论有多少条 return 语句,在编译成汇编指令后,一定是只有一条 retq 指令。因此可以在汇编指令里找到 retq 所在位置打断点:

int main() {
  return foo(0);
}

(gdb) disas foo  // 查看汇编
Dump of assembler code for function foo:
   0x0000000000400448 <+0>: push   %rbp
   0x0000000000400449 <+1>: mov    %rsp,%rbp
   ...
   0x0000000000400473 <+43>:    jmp    0x40047c <foo+52>
   0x0000000000400480 <+56>:    retq   // 这里就是函数的返回指令
End of assembler dump.

(gdb) b *0x0000000000400480  // 在 retq 指令打断点
Breakpoint 1 at 0x400480

(gdb) r  // 运行程序,直到命中断点
Breakpoint 1, 0x0000000000400480 in foo ()

(gdb) p var
$1 = 42

监控断点: watch

GDB 可以监控一个变量,直到它被修改时才触发断点:

(gdb) watch foo
(gdb) watch bar.var

如果想在变量被读取时中断,可以使用 rwatchawatch

  • rwatch:仅当变量被读取时终端
  • awatch:当变量被读取或写入时中断

查看所有 watchpoints:

(gdb) info watchpoints

禁用 / 删除 watchpoints 的命令同 break

GDB - Setting watchpoints

条件断点: b ... if

常规断点 (breakpoints) 和监控断点 (watchpoints) 都可以绑定一个条件,只在满足条件时才触发断点。

“条件”是一个布尔表达式:

(gdb) b foo.cpp:123 if bar == 1
(gdb) b foo.cpp:123 if bar == 1 && foo < 2

如果要判断两个字符串是否相等,可以使用 gdb 的内置函数 $_streq

(gdb) b foo.cpp:123 if $_streq(some_str, "hello_world")

GDB - Break conditions

断点命令列表: commands

可以通过 commands 命令给断点绑定一组自定义命令,当命中断点后会自动执行,如打印变量的值,或者设置另一个断点。

语法:先指定要绑定的断点编号,然后输入自定义命令,最后以 end 结束。例如:

(gdb) commands 1
(gdb) p foo
(gdb) end

断点编号可以通过 i bi wat 获取。如果不给 commands 传入任何编号,则默认绑定到最近触发的断点上。

commands 的应用场景之一是收集信息。比如在某行代码后面插入一行 debug 日志,打印变量或调用栈。由于每次命中断点后,必须输入 cond 命令才会继续运行程序,因此可以在 end 前面加一个 cont 命令,这样程序便可以无需干预、自动运行:

(gdb) b foo.cpp:123
(gdb) commands
(gdb) p bar
(gdb) cont
(gdb) end

commands 的另一个应用场景是临时修复一个 bug,以便让程序正常运行。比如在某一行错误代码后面,给变量设置正确的值。同样要以 continue 命令结尾:

(gdb) b foo.cpp:123
(gdb) commands
(gdb) silent  // 这个命令后面的命令不会有任何输出
(gdb) set x = y + 4
(gdb) cont
(gdb) end

GDB - Breakpoint command lists

运行程序: n / s / c / fin / u

  • run / r:运行程序,直到遇到第一个断点或者运行结束
  • start:启动程序,临时停在 main() 的第一行
  • next / n:逐行执行,如果某一行是函数,不会进入到函数里,而是会执行完整个函数 (step over)
  • step / s:逐行执行,如果某一行是函数,会进入到函数的第一行 (step into)
  • continue / c:从断点位置继续执行,直到遇到下一个断点或者运行结束
  • finish / fin:执行到函数结束,停在 return 后的下一条语句
  • until / u
    • 不加任何参数:执行直到当前语句结束,比如在 for loop 里 until 会跳到 for 循环体的下一行
    • 加参数:执行直到特定位置,参数的语法同 break,等价于 tbreak + continue
  • quit / q:退出 GDB

直接回车会重复上一次执行的命令,所以在单步跟踪的时候,无论是 s 还是 n 都可以连续敲回车继续执行。

输出日志: set logging

可以把 GDB 的所有输出打印到日志里,作进一步分析。

需要执行这两个命令:

(gdb) set logging file gdb.txt
(gdb) set logging on
copying output to gdb.txt

这样任何命令的输出便会写到 gdb.txt,前提是 shell 拥有该文件的写入权限。

配合以下命令,确保输出完整内容:

set print repeats 0       // 否则相同的连续字符会被合并
set print elements 0      // 否则过长的数组会被省略
set height 0              // 否则如果一页显示不完,会停下来要求 continue
set width 0  

二、进阶用法

配置文件: ~/.gdbinit

~/.vimrc~/.zshrc 一样,GDB 也有默认的配置文件 ~/.gdbinit。可以把一些常用的配置、插件、自定义命令放在 ~/.gdbinit

Github 上有一些开箱即用的 ~/.gdbinit 文件:

gdb-dashboard 使用笔记:

  • 使用 -output 命令将某些组件在其他终端显示,比如终端 A 执行 gdb 命令,终端 B 显示断点、变量值、调用栈。在终端输入 tty 命令就可以查看当前终端的序号。
  • 介绍文章:https://zhuanlan.zhihu.com/p/435918702

加载插件: source

GDB 可以使用 Python API 来实现自定义脚本。脚本可以直接写在 ~/.gdbinit,或者写在一个单独的文件中,然后通过 source 命令加载。

网上有很多可用的插件,比如 STL views 提供了一些打印 STL 容器的命令。

三、实践案例

TODO 待补充

附录

学习资源

术语

栈帧

调用栈 (call stack) 被分成若干个栈帧 (stack frame),每个栈帧包括和一次函数调用相关的所有数据:函数的参数、函数的局部变量、以及函数的返回地址等。

程序启动时只有一个栈帧,即 main 函数,又称初始栈帧最外层栈帧。每次函数调用都会创建一个新的栈帧,每次函数返回时一个栈帧也会被弹出。当前执行的函数所对应的栈帧又称最内层栈帧

GDB 给每个栈帧分配了一个数字,最内层栈帧的编号是 0,外层栈帧依次加 1。可以通过 bt 命令展示所有栈帧,通过 f 命令加上编号进入到对应的栈帧。

GDB - Stack frames

Core Dump

当进程崩溃时,操作系统会把进程当前的所有内存和寄存器状态信息保存到 core dump 文件中。Core dump file 是一个二进制文件,需要配合 debug info 来赋予其含义。GDB 可以读取 core dump 文件,协助分析进程崩溃的瞬间发生了什么。

可能会产生 core dump 文件的场景:

  • 段错误 Segmentation Fault
    • Null Pointer Dereference (NPD)
    • Stack Overflow / Buffer Overflow
    • Use After Free (UAF)
    • Double Free
    • Out Of Memory (OOM)
  • 其他一些会引起 core dump 的 signal

GDB Coredumps

Debug Info

Debug 是编译器生成的调试用的符号表,保留了源代码的信息,如标识符名称、可执行文件中第几条机器指令对应源代码的第几行等,但并不是把整个源文件嵌入到可执行文件中。

gcc 或 g++ 在编译时,可以通过 -g 选项生成 debug info。如果没有 debug info,GDB 就无法按源码行打断点、输出变量的值、或者展示 coredump 文件中的调用栈信息。

DWARF 是现在操作系统 debug info 的主要标准。Debug info 保存在程序 ELF 文件的 .debug_info 段中。

The GDB developer’s GNU Debugger tutorial, Part 2: All about debuginfo

📒 相关文章:💻【Linux】Vim 学习笔记

]]>
Images。
💻【Linux】Vim 入门笔记2023-01-05T12:00:00+08:002023-06-22T00:00:00+08:00https://imageslr.com/2021/vim📺 视频分享:VS Code × Vim

最近,我在 VS Code 中深度使用了 Vim 插件。Vim 快捷键基本代替了所有的鼠标操作,极大提升了我的编码效率。因此,我做了一期视频分享。

在下面的视频中,我分享了 VS Code 和 Vim 的快捷键、如何在实际编码场景中使用 Vim、以及如何配置 Vim 的快捷键映射。欢迎大家一起交流!

查看目录和时间戳
  • 00:00 为什么不用 Vim 编辑器,而是要在 VS Code 中使用 Vim 插件?
  • 03:00 VS Code 的常用快捷键
  • 08:19 Vim - 移动 / Normal Mode
  • 20:15 Vim - 编辑 / Insert Mode
  • 28:29 Vim - 复制粘贴
  • 31:10 Vim - 选择 / Visual Mode
  • 36:08 Vim - 其他 / Inside & Around
  • 41:43 VSCode × Vim 实际编码过程演示
查看思维导图

vim

为什么要学习 vim

最近遇到在服务端编辑代码文件、查日志的场景比较多,所以想要系统学习一下 vim。

Vim 对于每个服务端开发人员都不陌生,这可能是我们接触最多的 Linux 软件。所有类 Unix 的系统(Linux、Mac)都安装了 vim。当我们通过终端操作文本时,vim 或许是我们唯一的选择。

然而,vim 的使用方式和我们所熟悉的可视化编辑器完全不同,它的的快捷键是如此奇怪,不易上手。因此除非兴趣使然,我们很少会主动学习 vim。它的上限够高,下限也足够低,只需要掌握最基本的操作:↑↓←→i<ESC>:wq,就可以覆盖大部分使用场景。那为什么还需要再深入学习 vim 呢?

主要原因是:用较少的学习成本,换来较大的效率提升。Vim 常用的几个快捷键,可以在手指不离开键盘热区的情况下快速定位光标或编辑内容,这些内容的学习成本并不高。如果你开发运维的过程中和 vim 打交道的次数越来越多,掌握这些技巧可以极大的提升开发效率。即使现在没有需求,也可以提前上手这个强大的工具。

为什么要写这篇文章

现有的 vim 教程 / 文章大多直接罗列完整的 vim 快捷键列表,让人不知从何下手。我认为应当先掌握最重要的、最高频的快捷键,满足日常开发所需;其他低频使用的快捷键,可以作为一个速查表按需查看,vim 的进阶用法也可以之后再深入研究。

因此,我尝试作为一个 vim 初学者,总结 vim 主要和次要的快捷键,同时提供一些学习 vim 的资源。

注意:在阅读本文时,你随时可以在终端执行 vimtutor,打开一个教程文本文件,尝试某个快捷键或命令。

学习资源

这里顺便再推荐一些可视化学习资源:

术语说明

vim 中有一些术语:

  • mode:标准模式 normal、插入模式 insert、可视模式 visual 等。
  • verb:vim 里执行的操作,比如删除 d、修改 c、拷贝 y、查找 f 等。verb 后面需要跟一个 motion,表示该操作生效的范围。
  • motion:vim 里移动的范围,比如向右一个字母 l、向下一行 j、向右一个单词 w 等。本文中有时候也称其为 “range”。

💡 如果你对 vim 的语法感兴趣,可以阅读这一篇文章:https://learnvim.irian.to/basics/vim_grammar

符号说明

标题的前缀:

  • [M]:移动类,move
  • [E]:编辑类,edit
  • [F]:查找类,find

入门

标准模式 / 插入模式

  • 标准模式(Normal Mode):进入 vim 的默认模式,这个模式下按下任何键不会实际输入到文本中,按下 : 可以执行命令
  • 插入模式(Insert Mode):在标准模式按下 i 进入插入模式,此时可以输入文本;按下 <ESC> 退出插入模式

💡 除了以上两个模式,vim 还有 visual mode,用来选择一个范围的文本。

建议配置 jj 退出插入模式,这样左手不需要移动到最左上角去按下 <ESC>。在标准模式下执行:

imap jj <Esc>

可以将这条命令写到 vim 的配置文件中。我实际上是把 jj / kk / jk / kj 都配成了退出插入模式。

执行命令::<command>

按下 : 后输入命令,按回车执行。如 :set number 会显示行号。

退出 vim::q / ZZ

  • :q / :quit:退出 vim,不作任何改动
  • :q!:退出 vim,丢弃已有的改动
  • :wq:保存更改(write)并退出(quit)vim
  • ZZ:等同于 :wq,这个快捷键输入比 :wq 更快,注意是大写 Z

保存文件: :w / :w <filename>

  • :w:保存更改
  • :w <filename>:保存到一个新的文件

[M] 基本移动:h / j / k / l

使用 hjkl 而不是 ,这能够避免将手指移出键盘热区再移回来。如果有必要的话,甚至可以禁用方向键,来强制自己使用 hjkl

map <Left> <Nop>
map <Right> <Nop>
map <Up> <Nop>
map <Down> <Nop>

[M] 前往第一行 / 最后一行:gg / G

  • gg:前往第一行
  • 1G:同 gg
  • G:前往最后一行

[M] 前往第 n 行:nG:n

  • nG:前往第 n 行。1G 可以前往第一行。如果希望在 vim 中显示行号,可以在标准模式下执行 set number 命令,也可以将这条命令写到 vim 的配置文件中。
  • :n 也可以前往第 n 行,比如 :123前往第 123 行。

[M] 向右移动一个单词:w / e

  • w:向右移动一个单词,光标将落在下一个单词的首字符
  • e:向右移动一个单词,光标将落在当前一个单词的最后一个字符

在这里,一个单词的定义是连续的「数字+字母+下划线」,或者连续的「特殊字符」。比如 hello, world!!! 里包含 hello,world!!! 四个单词。按 w 跳转时,会跳过单词后面的所有空白字符,落在下一个单词的开头。示例:

↓ 光标在这里
Hello, world!
     ↑ 按下 w
    ↑ 按下 e
Hello, world!
       ↑ 按两下 w
     ↑ 按两下 e

类似的还有 W / E,区别在于这两个快捷键对单词的定义是连续的「非空字符」,即以空格作为单词的分界线。比如 hello, world!!! 里,包含 hello,world!!! 两个单词。

[M] 向左移动一个单词:b

b 向左移动到前一个单词的首字符,相当于是 w 的逆操作。b 取 backwards 首字母,「单词」的定义同 w

2b 向左移动两个单词,nb 向左移动 n 个单词。

B 向左移动一个单词,将「空格」作为单词的分隔符(同 WE)。

[M] 移动到前一个单词的末尾:ge

ge 移动到前一个单词的末尾,gE 将空格作为单词的分隔符。

[M] 前往当前行第一个 / 最后一个字符:0 / $ / _ / ^

  • 0:前往第一个字符,可以理解成是第 0 列
  • $:前往最后一个字符
  • _:前往第一个非空字符,这在编写 python 等有缩进的代码时很有用。^ 等价。

[E] 删除字符:x / X

  • x:删除当前字符,等同于 <Delete>
  • X:删除前一个字符

[E] 删除单词:dw

「单词」的定义同 w,单词后面的任意多个空格将被删除。

类似的还有 dW,删除下一个空格前的单词。

[E] 删除当前行:dd

略。

[E] 在当前位置后面插入:a

i 在当前位置前面插入(insert),a 在当前位置后面插入(append)。

[E] 在当前行开始 / 末尾插入:I / A

略。

[E] 在当前行下面 / 上面插入新行:o / O

插入新的空白行。

[E] 改变大小写:~ / gu / gU

  • ~:将光标下的字母改变大小写。3~ 是将光标开始的 3 个字母改变大小写。

  • gu<motion>:指定范围的字母变成小写。比如 guw 是后一个单词全变成小写,guj 是当前整行改成小写。gU 则是改成大写。
  • guu:将当前行的字母改成小写。gUU 是改成大写。
  • guiw:将光标所在的单词改成小写。详见下文 inside / around

[F] 移动到下一个指定字符:f<target>

ft 移动到下一个 t 出现的位置,f2 移动到下一个 2 出现的位置。f 取 forward 的首字母。

F 类似于 f,向前移动到前一个指定字符。

t 类似于 f,只不过光标会移动到下一个指定字符之前T 类似于 F,只不过光标会移动到前一个指定字符之后t 取 until 的含义。

示例:

    ↓ 光标在这里
Hello, world!
        ↑ fo
         ↑ fr
↑ Fh
       ↑ to
 ↑ Th

[E] 撤销 / 重做:u / <Ctrl> + r

  • u:撤销(undo)
  • <Ctrl> + r:重做(redo)

中场休息:vim 的一些模式

重复 n 次操作:n<action>

Vim 中几乎所有操作都可以通过一个 n 前缀来重复 n 次:

  • 5h 向左移动 5 个字符。5j 向下移动 5 行。2w 向右移动两个单词,等同于按两次 w
  • 2x 删除两个字符,2X 向左删除两个字符。
  • 3fa 在当前行查找第 3 次出现的 a,等同于按 3 次 fa
  • 2u 撤销前两步操作,等同于按两次 u2<Ctrl> + r 重做被撤销的两步操作,等同于按两次 <Ctrl> + r

💡 移动类命令 (motion) 如 h / w,和操作类命令 (verb) 如 d / x / f 都支持在前面加 n 来重复多次。

操作任意范围:<verb><motion>

我们以 d 命令为例。d 可以和任意光标移动的操作结合,来删除一个范围的内容。

比如:w 是跳到下一个单词的开头,那么 dw 就是删除到下一个单词的开头;b 是跳转到前一个单词的开头,那么 db 就是删除到前一个单词的开头。以此类推,d0 是删除当前位置到行开头的所有内容,dG 是删除当前行到文件末尾的所有内容,dgg 是删除当前行到文件开头的所有内容。

大部分命令都支持 <verb><n><motion><n><verb><motion> 两种模式,比如 d2w2dw 都是删除后两个单词。但在语义上有区别:d2w 表示删除 2w 范围的内容,而 2dw 表示 dw 命令重复 2 次。

💡 d / c / y 等 verb,都支持上述模式。比如 c2w 修改后两个单词,y2j 复制下两行。

命令的小写和大写:d / D

不同的方向:

  • x 向右、X 向左
  • p 向下、P 向上
  • o 向下、O 向上
  • f 向右、F 向左

更严格的条件:

  • w 将特殊字符作为独立单词,W 只将空格作为单词分隔符
  • e / Eb / B 同理

更大的范围:

  • a 在当前位置后面插入、A 在当前行末尾插入
  • i 在当前位置前插入、I 在当前行开始插入
  • d 删除一个范围、D 删除到行末尾
  • c 删除一个范围、C 删除到行末尾,并进入编辑模式
  • s 删除当前字符,并进入编辑模式;S 删除当前整行,并进入编辑模式

连续操作:

  • r 替换一个字符、R 连续替换多个字符直到按下 <Esc>

两个字母重复:dd / cc / yy / guu

两个 verb 字母重复,表示对当前整行操作:

  • dd:删除整行
  • cc:删除整行,并进入编辑模式
  • yy:复制整行
  • guu:当前整行变成小写
  • gUU:当前整行变成大写

重复上次操作:. / , / ;

  • .:重复上次的编辑操作。比如执行了 A123<Esc> 在当前行尾插入 123 后,可以移动到下一行,按 . 在该行末尾插入 123
  • , / ;:重复当前行内的上一次 / 下一次 f 查找。比如在当前行按 fa 找到第一个 a 字符后,按 ; 可以查找下一个,等价于再按 fa。按 , 是查找上一个,等价于 Fa
  • n / N:按 /bar<Enter> 搜索 bar 字符串后,按 n 可以查找下一个,按 N 查找上一个。

进阶

[E] 替换一个字符:r

r:再按下任意键,替换(replace)当前字符,等同于 x + i。示例:

    ↓ 光标在这里
Helle, world!
# 先按 r,再按 o
Hello, world!
    ↑ 光标在这里

[E] 替换连续多个字符:R

R:替换连续的多个字符,按下 <Esc> 可以退出替换模式。

[E] 更改一个范围的内容:c

c 取 change 的首字母,这个命令的便捷之处在于将「删除操作」和「进入编辑模式」合二为一,可以少按一个键。

  • cw:更改下一个单词,等同于 dw + i
  • c2w:更改后两个单词,等同于 d2w + i
  • c$:更改从当前位置到行结束的所有内容,等同于 d$ + i

d 一样,c 也可以和任意光标移动的操作符结合,来更改一个范围的内容。

[E] 删除当前字符,并进入编辑模式:s

s 等同于 x + i

[E] 删除当前整行,并进入编辑模式:S / cc

S 等同于 dd + occ 也可以删除整行,并进入编辑模式。

[E] 从当前位置开始向右删除整行:D

D 等同于 d$

[E] 从当前位置开始向右删除整行,并进入编辑模式:C

C 等同于 c$,或者 d$ + a,或者 D + a

[V] 可视模式选择内容:v / V / <Ctrl> + v

按下 v 进入可视模式(visual mode),然后移动光标以选择文本。可以针对选中的文本执行任意操作 (verb),比如:

  • 按下 y 可以复制选中的文本,再移动到别的位置按下 p 粘贴这些文本
  • 按下 d 可以删除选中的文本

vim-visual-mode

按下 ctrl + v 可以进入 Visual Block Mode,选择一个矩形块里的内容:

image-20230315105320258

按下大写 V 可以选中整行。常见的使用技巧:

  1. V 选中整行,按 j 向下选中多行,然后 y 复制。
  2. V 选中整行,按 j 向下选中多行,按 < 向左缩进,按 . 继续缩进。
  3. V 选中整行,按 j 向下选中多行,按 = 格式化。

复制下一个单词:yw

y 取 yank(复制)的首字母。yw 复制下一个单词,p 可以将其粘贴(put)到指定位置。

事实上,ycd 一样,可以和任意光标移动的操作符结合,来复制一个范围的内容。比如 y$ 将复制当前位置到行末尾的全部内容,yh 将复制光标前面的字符,yG 复制光标所在行到最后一行的所有内容。

最后,yy 复制当前行,可以和 dd 一起理解 —— dd 删除一整行,快捷键重复表示操作的是一整行,不管光标位置在哪里。第二个 yd 并没有语义上的含义。

复制当前行:yy / 2yy / y2j

yy 复制当前行,p 粘贴到目标位置。

nyy 复制当前行往下的 n 行,包括当前行。

ynj 也是复制当前行往下的 n 行。比如 y2j 会复制当前行和下一行。

粘贴到下一行 / 上一行:p / P

如上所述,p 粘贴到目标位置。

通过 dd 删除某一行后,也可以按下 p,将删除掉的内容放置到当前光标位置下一行。注意这里是「放置」而不是「粘贴」,因为 dd 将被删除的行保存到了缓冲区,而 p 其实是将缓冲区的内容放置到当前位置,所以 p 取 put 的首字母,而非 paste。

同理,yy 将当前行保存到缓冲区,但不删除。这样 yy + p 就可以实现“复制-粘贴”的操作。

大写 P 粘贴到上一行。

当前行置顶:zt / zb / zz

zt 把当前行置于屏幕顶端。z 字取其象形意义,模拟一张纸的折叠变形。t 取 top 的首字母。

zz 将当前行置于屏幕中央。zb 将当前行置于屏幕底端,b 取 bottom 的首字母。

缩进:< / >

选中文本后,< 是向左缩进,> 是向右缩进。

格式化:=

= 可以将选中的文本格式化。这个命令可以配合 V 使用 —— 通过 V 选中多行,然后按 = 格式化选中的文本。

高级

[E] inside 和 around:di* / da*

对于 Vim 的删除命令 d,还有一类比较常用的操作是以 i (insideinner) 和 a (around) 为后缀的命令,用于删除以当前光标所在的语法元素内部或周围的字符。比如:

  • diwdaw:前者删除当前光标所在的单词,后者会删除当前光标所在的单词与后面的空格。
  • di(da(:前者会删除括号内的内容,后者还会删除括号本身。类似的还有di[da[di{da{等。
  • dibdab:等价于 di(da)b 表示 bracket。
  • di"da":前者删除双引号内的内容,后者还会删除引号本身。
  • ditdat:修改 tag 包围的内容。vim 会自动将 <tag></tag> 识别为一对 tag。这在前端开发场景中很有用。
  • dip:删除当前整段 (paragraph) 的内容。

除了 d 命令,c / v / gu 等命令也可以和 inside、around 组合。比如 ciw 是修改当前单词,viw 是选中当前单词。

inside、around 也可以和上面的 重复 n 次 结合使用。比如光标位于 (a * (b + c)) 的字符 c 时,按 di( 将删除内层括号里的 b + c,按 d2i( 将删除外层括号里的全部内容。

inside 和 around 命令可以大大提高操作效率,尤其是在编辑代码时。

[F] 查找文档中的关键字:/<pattern>

/ 从光标所在位置向后查找关键字,n / N 查找下一个 / 上一个匹配的位置。

? 向前查找,不过很少使用。如果想向前查找的话,使用 / + N 就可以了。

q/q? 可以列出 /? 的查找历史,上下选择,按 i 编辑,回车执行,:q退出。

<pattern> 可以是正则表达式,比如 /vim$ 查找位于行尾的 vim。查找特殊字符时需要转义,比如 /vim\$ 查找 vim$

在查找模式中加入 \c 表示大小写不敏感查找,\C 表示大小写敏感,比如 /foo\c 会查找 fooFoo 等。默认是大小写敏感,可以执行 :set ignorecase 或写入配置文件设置大小写不敏感为默认的查找模式。

查找相关命令:

set ic // 等价于 set ignorecase
set hls is // 高亮匹配项
nohlsearch // 移除匹配项的高亮显示

[F] 查找当前光标对应的完整单词:* / #

示例:

  ↓ 光标在这里
Hello, world!

此时按下*,将向后查找 Hello 这个单词。按下 # 是向前查找。

[F] 在代码块匹配的括号之间跳转:%

% 在匹配的括号之间跳转。需要将光标放在 {}[]() 上,然后按 %。 如果光标所在的位置不是 {}[](),那么会向右查找第一个 {}[]()

[F] 光标跳转到前一个位置 / 后一个位置:<Ctrl> + o / <Ctrl> + i

在标准模式下,<Ctrl> + o 将光标跳转到前一个位置,<Ctrl> + i 跳转到后一个位置。

注意这里使用的是“跳转”。h / j/ k / l / w 等移动将不会记录在「跳转表」中,只有通过 gg / nG / 查找时的 n / N 等命令执行的跳转操作,才可以通过 <Ctrl> + o / <Ctrl> + i 来回跳转。

补充:

  • 在 VS Code 中,向前一个 / 后一个位置跳转的快捷键是 <Ctrl> + [ / <Ctrl> + ]
  • 在 Intellij 等 Jetbrains 系列软件中,向前一个 / 后一个位置跳转的快捷键是 <Command> + [ / <Command> + ]。如果不是,可以在 Preferences 中搜索 back,然后在 KeyMap -> Main menu -> Navigate -> Back 中设置。

[F][E] 替换文本::{range}s/{old}/{new}/{flag}

:s(substitute)命令用来查找和替换文本。语法如下:

:{range}s/{old}/{new}/{flag}

表示在指定范围 range 内查找字符串 old 并替换为 barflag 说明了替换模式,如只替换首次出现、或全部替换。

作用范围 range

作用范围分为当前行、全文、行范围、选区等:

  • 当前行:空白,默认,如 :s/foo/bar/g
  • 全文:%,如 :%s/foo/bar/g
  • n~m 行:n,m,如 :5,12s/foo/bar/g 表示 5~12 行
  • 当前行与之后 n 行:.,+n,如 :.,+2s/foo/bar/g 表示当前行与之后 2 行
  • 选区:略

替换模式 flag

替换模式:

  • 空白:默认,只替换光标位置之后的首次出现,如 :%s/foo/bar
  • g:全局替换,替换每次出现(global),如 :%s/foo/bar/g
  • i:忽略大小写
  • c:交互式替换,每次替换前需要用户确认(confirm),如 :%s/foo/bar/gc 表示查找全文的所有 foo 并替换为 bar,每次替换前都需要确认:
    • 按下回车执行后,提示 replace with bar (y/n/a/q/l/^E/^Y)?
    • y 表示替换
    • n 表示不替换
    • a 表示替换后续所有
    • q 表示退出查找模式
    • l 表示替换当前位置并退出查找模式
    • ^E^Y 用于向上、向下滚动屏幕,^ 表示 <Ctrl>

配合 v 可视模式选择替换区域


首先按 v 进入可视模式,选择要替换的文本范围。

然后按下: 进入命令行模式,Vim 将自动插入:'<,'>,表示选择的范围。

接下来,输入替换命令:

:'<,'>s/old_text/new_text/g

这将在选择的区域内替换所有匹配到的 old_text

录制宏:q@

Vim 的宏提供了将一系列操作记录下来然后重复执行的机制。它可以大大提高重复性的操作效率。使用宏的步骤如下:

  1. 录制宏。按下 q 键,然后再按下一个字母 (如 a,这是宏的名字),开始录制宏。这时会在状态栏显示 recording a。在录制时,执行要重复的操作,包括移动、删除、插入等等。执行完操作后,按下 q 键结束录制。
  2. 执行宏。按下 @ 键,然后在输入框中输入之前记录宏的字母 (如 a),按下回车键即可执行宏。也可以连续执行多次,比如执行 10 次,只需在 @a 后面加上 10 即可。

另外,按下 v 键进入 visual mode 选中多行,可以批量针对多行文本执行宏。使用 :reg 命令可以查看所有已经保存的宏。如果在执行宏时出现错误,可以通过使用 :debug 命令进入调试模式。

在 vim 中执行 shell 命令::!<command>

比如通过 vim 编辑文本的时候,希望打印当前目录,但是又不想退出 vim,那么就可以直接在 vim 中执行::!pwd,这等同于在 shell 中执行 pwd

获得命令提示:

  • 在 vim 中输入 :,再按下 <Ctrl> + d,将展示所有可以在 vim 中使用的命令。

  • 输入 :w,再按下 <Ctrl> + d,将展示所有可以在 vim 中使用的、以 w 开头的命令。

配置文件

配置文件位于 ~/.vimrc,其内容是若干行可在 vim 中执行的命令,会在每次打开 vim 时自动执行。示例:

set number # 显示行号
set releativenumber # 显示相对行号
set ignorecase # 大小写不敏感查找
set ic # 等价于 set ignorecase
set smartcase # 如果有一个大写字母,则切换到大小写敏感查找
set hls is # 高亮匹配项

Github 有很多开箱即用的 vimrc 文件,比如 amix/vimrc

💡 vim 可以修改键位映射。这里建议把高频使用的命令放在触手可及的键位上,比如 ^ (跳到当前行第一个非空字符) 就很常用,但这个键很难按。像 W / E / R / S 这些键位并不经常用,就可以配成其他操作。

配置插件

其他工具 Vim 化

Chrome

Vimium,通过类似 vim 风格的命令操作浏览器窗口。

VS Code

Vim 插件,将 VS Code 的编辑器转为 vim 模式。最近高频使用,Vciwdi{ 等命令显著提升了编码效率。

编辑 settings.json 文件,配置 jj 替换 ESC:

    "vim.insertModeKeyBindings": [
        {
            "before": ["j", "j"],
            "after": ["<Esc>"]
        }
    ]

终端 / Bash / zsh

在终端输入 set -o vi 可以切换到 vim 模式,按下 ESC 就能进入 vim 的 normal mode,修改终端命令的时候很好用。推荐将其写入 .bashrc.zshrc 等配置文件。

在 zsh 中,也可以通过快捷键 Ctrl + x, Ctrl + e 打开 vim 编辑当前命令。

总结

掌握「入门」一节中的快捷键,基本可以满足大部分使用场景。如果想进一步提升效率,那么「进阶」一节中的快捷键也值得学习。「高级」一节的内容,由于我还没有将 vim 作为主力开发工具,尚未深入研究,所以等以后有机会再补充。

可以在其他编辑器中配合 vim 插件,来培养 vim 的使用习惯。将 Chrome vim 化,也能体验到 vim 带来的酷炫与极客感。

最后,在实践中学习命令!如果只是阅读而不尝试,那么很快就会遗忘。

希望本文对你有帮助。

附录 1:速查表

仅作为正文的补充,记录一些可能有用的快捷键。

光标移动

快捷键 作用
^ 移动到当前行第一个非空字符
<Space> 向右移动一个字符,等同于 l
<Alt> + ←, <Alt> + → 向左 / 向右移动一个单词,等同于 w / b
:n<enter> 跳到指定行,等同于 nG
ngg 跳到指定行,等同于 nG
H 光标移动到屏幕最上方(head)
M 光标移动到屏幕中央(middle)
L 光标移动到屏幕最下方(last)
/{keyword} 向后搜索,回车定位
?{keyword} 向前搜索,回车定位

💡 Tips:如果想快速跳转到某个函数名,更建议用搜索。例如,如果你想将光标移动到某个函数的开头,可以使用 /{函数名} 命令来查找该函数名,然后再使用 b 命令将光标移动到函数名前的空格上。原因:如果目标位置距离较远,数需要跳转几行或几个单词很低效,而搜索可以直接跨行定位关键词。/ 是向后搜索,? 是向前搜索。

屏幕滚动

快捷键 作用
向上 / 向下滚动一行 <Ctrl> + y / <Ctrl> + e
向上 / 向下滚动一页 <Ctrl> + f / <Ctrl> + b(forward,backward)
向上 / 向下滚动半页 <Ctrl> + d / <Ctrl> + u

这些命令在大部分 Unix 软件中都可以使用,比如 manlesstmux(需要先进入滚动模式)

编辑

快捷键 作用
J 将当前行和下一行用空格连成一行
Jx 将当前行和下一行直接连成一行,相当于在下一行的行首按 <Backspace>
di( 删除括号内的内容
da( 删除括号内的内容,包括括号本身
ci( 删除括号内的内容,同时进入编辑模式
ddp 上下两行交换,实际上就是 dd + p

其他

快捷键 作用
:help 查看帮助文档
:help :{command} 查看一个具体命令的帮助文档,如 :help :q 查看 :q 的帮助文档
^y$ 复制一行
ggyG 复制整个文件
q: 查看历史命令,上下选择,按 i 编辑,回车执行,:q退出

附录 2:vim 命令

可以在 vim 标准模式下输入 :<command> 执行,也可以写入配置文件。

set number     # 显示行号

set ignorecase # 大小写不敏感查找
set smartcase  # 如果有一个大写字母,则切换到大小写敏感查找

imap ii <Esc>  # 在插入模式下,映射 ii 到 <Esc>

# 在标准模式下,禁用方向键
map <Left> <Nop>
map <Right> <Nop>
map <Up> <Nop>
map <Down> <Nop>

set paste # 进入粘贴模式,这可以避免粘贴多行代码时被自动缩进
set nopaste # 粘贴完之后,执行这条命令退出粘贴模式

📒 相关文章:💻【Linux】GDB 学习笔记

]]>
Images。
⚙️【方法论】我的效率提升方法论 - 工具使用篇 ⑥2022-05-01T23:00:00+08:002023-03-14T00:00:00+08:00https://imageslr.com/2022/efficiency-workflow〇、前言

现在市面上有太多的效率工具,我们很容易陷入一个误区:喜欢体验新鲜的工具,但没有明确的使用目的,仅仅是为了好玩,或者以为能提高生产力,到头来却发现是在浪费时间。因此在选择工具时,我参考了一些通用思维 (📥 收件箱 🔖 工作区 🪒 奥卡姆剃刀),先思考自己需要哪些功能,再去寻找提供这些功能的工具,在不同场景下构建了类似的工作流 (Workflow)。这样可以降低系统的复杂度,减轻工具带来的认知负担。

本文分享了我在日常工作场景中使用的一些效率工具,操作系统是 macOS。

macOS 的初始化可以参考 💻 从零开始配置高效 Mac 开发环境

一、浏览器

标签页管理

场景

尽管我是一名程序员,但实际上我用浏览器的时间比写代码的时间还长,60% 以上的工作时间都是在 Chrome 浏览器中度过的:

image-20220227231120572

图:Chrome 的使用时间远超写代码的时间

浏览器已经快成为一个新的操作系统,无论是看文档、查资料、做表格、写周报,都离不开它。这导致我们常常会打开很多个标签页。据我观察,身边大多数同事的 Chrome 浏览器都是这样的:

image-20220213190712070

同时打开这么多的标签页,带来的问题也很明显:

  1. 浏览器占用了大量 CPU 和内存,导致系统运行卡顿。
  2. 只能看到图标,看不到标题,无法快速找到特定的标签页。

对于第一个问题,可以安装 Tab Suspender 插件解决,这个插件可以自动暂停长期未查看的标签页,节省内存。对于第二个问题,我安装过一些标签页搜索插件,Chrome 后来也提供了内置的标签页搜索功能,但这些方式都需要手动输入标签页的标题,很不方便。

image-20220308100927844

图:Chrome 内置的标签页搜索功能

我也尝试过使用 Chrome 自带的标签页分组功能,临时折叠一些标签页。但这个功能有点鸡肋:各个分组默认放在同一个窗口中,分组间的界限不明显,同时展开多个分组时,标签页还是被挤的只剩下个图标,而且 Chrome 关闭后分组信息也没了。

image-20220308100505029

图:Chrome 内置的标签页分组功能

方案

我用 🔖 工作区 思维解决了「浏览器标签页管理」的问题。

工作区思维的第一个要点:完成特定任务的场所、一系列关联资源的集合。我将 Chrome 分成多个窗口,每个窗口是一个“工作区”,包含和某项工作相关的全部标签页。通过将不同工作的上下文独立开来,可以减少混乱、提升注意力。

但是多窗口也带来一个问题:不同窗口间切换比较麻烦。Mac 系统的 Command + Tab 快捷键无法在相同应用间切换,Command + ` 快捷键可以在同一个应用的不同窗口间切换,但没有预览界面。因此只能激活调度中心、肉眼判断每个窗口的内容是什么、然后选择一个窗口。

Xnip2022-02-27_23-39-51

图:Mac 触控板四指上划,打开调度中心,在多个 Chrome 窗口间切换

安装 AltTab 插件可以完美地解决上述问题。这是一个即装即用的 Mac 窗口切换增强工具,按下 Command + Tab / Command + ` 切换窗口时可以显示缩略图。详细配置方法见这里

image-20220313233854266

图:安装 AltTab 之后,按下 Command+` 切换窗口时,会显示缩略图

工作区思维的第二个要点:自动保存、用完即走、一键恢复。上面这套工作流的问题在于:Chrome 退出后,所有分组信息会全部消失。我习惯在周末关闭工作相关的窗口,周一再重新打开。有没有一个工具,能够自动保存我每个窗口的页面、在关闭后也能一键恢复?

经过一番搜寻,我找到了 Workspaces 插件。尽管这是一个比较小众的插件,但是它和工作区思维完美契合:允许将多个标签页创建为一个工作区、自动保存当前工作区中打开的标签页、在重新打开工作区时自动恢复

我将常用的场景、进行中的工作都保存成了工作区:

image-20220502000307963

图:我的工作区列表

有了这个插件,我不需要再同时打开很多个标签页或窗口,而是可以根据当前关注的事项,按需打开工作区。当我需要处理某项工作时,打开对应的工作区;处理完之后,直接关闭整个窗口。随用随开、用完即走,这极大限度地降低了干扰,减少了上下文切换的开销。

总结

推荐的工作流:

  1. 安装 Workspaces 插件:
    • 按照「场景」或「项目」划分工作区。
    • 随时关闭某个不使用的工作区窗口,用的时候再打开。
    • 激活 Workspace 插件的快捷键是 Alt + w
  2. 安装 AltTab 插件,通过快捷键 Cmd + ` 快速切换窗口。
  3. 安装 TabSuspender 插件,暂停长期不使用的标签页,节省内存。
  4. 安装 TabModifier 插件,为标签页提供一个有辨识度的标题。下图左面两个标签页是不同的微服务,但使用了相同的标题;右面两个标签页则使用 TabModifier 插件修改了标题,更容易区分:
  5. 将窗口命名为工作区名称,切换或选择时会更具辨识度。 下面是默认的表现,每个窗口的名称是当前打开的标签页标题:
    下面是修改窗口名称后的表现:
    操作方式:在标签栏空白区域右键 - 为窗口命名,或者菜单栏 - 窗口 - 为窗口命名。

📥 收件箱🔖 工作区 思维管理标签页和窗口:

  1. 打开一个 Inbox 窗口。Inbox 窗口是一个收件箱,放在这里的标签页全是待处理的,例如“待阅读”“待填写”“待评审”等。处理完后关闭。
  2. 控制工作区窗口的数量。人的注意力是有限的,最多同时处理 2~3 件事。因此,最多同时打开三个工作区,当前不用的工作区通通关闭,减少分神。
  3. 每个打开的标签页,都需要定期整理:要么移动到 Inbox 窗口,表示待处理;要么分配到特定的工作区窗口,持久保存;要么关闭。
  4. 不需要每打开一个标签页就立刻整理。可以先进行手头的工作,等闲下来之后再整理。

如何在地址栏搜索标签页:

输入标题或 url 的内容,点击“切换到标签页”:

image-20220407000918703

书签管理

书签管理也是浏览器一个很重要的话题。我们会把任何可能有用的、或者感兴趣的网页存成书签,但往往是收藏的时候很顺手,想用的时候却找不到。下面是我解决这个问题的方法。

(1) 为书签设置一个有意义的名称

Chrome 的地址栏支持搜索书签和历史记录。输入标题url 中的关键字,会加粗显示:

image-20220415000222673

图:在地址栏搜索关键字,会在标题和 url 中加粗显示

因此,可以为每个书签设置一个有意义的名称。当需要查找一个书签时,直接在地址栏输入几个关键字,比先思考它属于哪个类别、再去查找对应的文件夹要更方便。

我采用[平台]名称 的命名方式,比如 [Gitlab]imageslr/blog[TCC]ad.engine.api。这里可以配合 TabModifier 插件,使标签页的标题和书签名称一致。

书签名还可以添加一些辅助搜索的 SEO 短语,比如 性能平台-云服务 可以修改为 性能平台-云服务|golang pprof|profile|服务性能优化|内存泄露排查

image-20220410234734913

图:添加一些描述页面功能的、合乎直觉的、在搜索时很容易能回想起来的短语

当书签名足够有信息量时,我们甚至不需要书签栏,直接在地址栏搜索关键字就能打开想要的书签。事实上,我在使用浏览器时,书签栏就始终是隐藏状态。

💡 这里再推荐一个 Chrome 插件:Holmes。安装后,在地址栏输入 * 再按 Tab,就能搜索书签了。

(2) 使用文件夹管理书签

不要把书签直接保存在书签栏上,而是要放在文件夹里。书签的标题会占用书签栏的空间。

不需要创建层层嵌套的文件夹。一般来说,在书签命名良好的情况下,我们可以很快搜索到想要的内容。因此,书签的文件夹只需要简单的划分,粒度可以粗一些,层级可以扁平一些。附录是我的书签分类方式。

(3) 将参考资料移动到别处

许多书签实际上是”参考资料“ —— 或者是对某项工作有用的参考文档,或者是一些学习资料,又或者是一些感兴趣的文章。我们需要定期整理书签栏,将这些”参考资料“移动到别处:

  • 某项工作的参考文档:移动到这项工作的项目文档。
  • 学习资料:移动到学习笔记,或者移动到任务清单,作为一个 TODO。
  • 感兴趣的文章:移动到稍后读工具。

总之,”参考资料“应当移动到特定的上下文 中,而不是放在书签栏里石沉大海。书签栏只保留那些需要经常打开的、真正有用的页面,减少干扰,易于维护。

二、Alfred

Spotlight 是 Mac 系统内置的一个快速搜索工具。市面上有一些类 Spotlight 工具,提供了不输于原生 Spotlight 的搜索功能、丰富的效率工具、以及高度的自定义能力。最常见的是 AlfreduToolsRaycast,网上有很多介绍这三个工具的文章,此处不再赘述。

个人认为,这类工具提升效率的关键在于:(1) 多用键盘,少用鼠标;(2) Don’t Repeat Yourself,通过自定义配置,减少重复操作。下面会举例说明。

我使用的是 Alfred。它的功能很全,插件丰富,就算不折腾,默认功能也已经足够好用。下面罗列了一些我常用的功能。

💡 在 2023 年的今天,我更推荐使用 Raycast。它涵盖了 Alfred 的几乎所有功能,但界面更美观、更易用。附录是 Raycast 和 Alfred 的对比。

基本设置

  • 快捷键:双击 Alt。这样一只手就能激活 Alfred。
  • 关闭 Shift 预览:Preferences - Features - Previews - Quick Look (取消勾选)。这个预览功能其实没啥用,还很容易误触。

打开 / 切换 App

👎   鼠标移动到 Dock 栏,点击图标。
👍   激活 Alfred,输入 App 拼音的前几个字母,回车。

操作鼠标是一个很低效的动作。每次都需要右手先离开键盘、找到鼠标、移动和点击、再把手放回键盘,重新校准手指位置;这个过程中,眼睛还必须配合鼠标指针的移动。

建议使用 Alfred 充当 App 启动器。输入 App 名称 (拼音或首字母) 即可启动,双手不需要离开键盘,速度更快、更方便。

image-20220413231612708

图:使用 Alfred 查找 App,按 Cmd + n 打开

此外,还可以为常用的 App 配置全局快捷键,便捷切换可见状态。配置方法见 Preferences - Workflows - 右下角加号 - Getting Started - Hotkeys。比如我把 Alt+QAlt+EAlt+F 分配给了提醒事项、飞书和微信。

image-20220413230408023

图:配置一个简单的 workflow,就可以通过快捷键显示 / 隐藏 App

剪贴板历史 / Snippets

👎   使用两个工具,分别管理剪贴板历史和代码片段,资源占用大、操作流程长。
👍   使用 Alfred 解决所有问题。

Alfred 内置了剪贴板历史工具,非常好用,且资源占用小。我用它替换了 iPaste。配置方式:

  • Preference - Features - Clipboard History,我的快捷键是 Cmd + Shift + V
  • 勾选上 Keep Plain TextKeep ImagesKeep File List 以同时保存文本和文件
  • 在“Advanced”里勾选 Auto-paste on return,这样按下回车后就会自动粘贴

这之后就可以通过快捷键查看历史记录了。上下键选择某条记录,回车粘贴,也可以通过 Cmd + 数字 直接选择;支持输入关键字搜索。

Clipboard History

Alfred 剪贴板工具的最赞之处在于能够和 Snippets 联动

Alfred 内置了一个 Snippets 管理工具,可以创建多个清单来管理自己的代码片段:

Snippets Prefs

我把经常执行的一些命令保存成了代码片段,这之后就可以直接在剪贴板历史里查看了,也可以根据关键字搜索代码段的名称或内容:

Snippets Viewer

剪贴板历史的内容,可以直接保存到 Snippets 里。只需要呼出剪贴板历史工具,选中一行,然后按 Cmd + S 快捷键。强烈推荐使用这个功能,大幅降低录入成本。

Snippets 的详细使用说明见 Alfred 官网

搜索文件

👎   在 Finder 中手动查看每个文件夹,或者使用 Finder 的搜索功能。
👍   激活 Alfred,输入文件名,回车。

Alfred 的搜索功能很强大。只需记住这两个命令:

  • 空格 + 文件名:按文件名搜索,支持拼音。
  • in + 字符串:按文件内容搜索。

image-20220413232520845

图:空格 + 文件名,搜索文件

image-20220413232728058

图:in + 文件名,搜索文件内容

选中搜索结果后,按 Enter 打开文件,按 Command + Enter 打开文件所在的文件夹。

此外,还可以自定义搜索过滤器,获得更精确的搜索结果。比如我经常会搜索自己的笔记,格式都是 markdown,于是便配置了一个只搜索 .md 文件的 workflow。配置方法见附录

image-20220414000949319

图:自定义 Workflow,只搜索 markdown 文件,既能搜索文件名,也能搜索文件内容

👎   打开浏览器 - 进入搜索页 - 点击搜索栏 - 输入搜索内容 - 回车
👍   激活 Alfred - 输入搜索内容 - 回车

Web Search 是 Alfred 的一大特色功能。在 Alfred 输入要搜索的内容、回车,就可以立刻打开搜索结果页。

Alfred 内置了很多搜索引擎 (Preferences - Features - Web Search):

Web Searches

使用时,需要输入搜索引擎的 Keyword,然后在空格后输入要搜索的内容,例如 google somethinggmail something

可以将常用的搜索引擎设置为默认结果,这样就不需要输入关键字了。配置路径在 Preferences - Features - Default Results - Fallbacks - Setup fallback results:

image-20220417003831536

图:新增默认搜索结果

我设置的 fallback results 是 Google公司内网搜索

image-20220417001602114

图:输入任何内容,都可以在 Google 或内网搜索,回车或 Command+2 打开结果页

Alfred 还可以自定义 Web Search。我们每天除了使用 Google 等搜索引擎,还会在公司的许多内部平台搜索,比如搜索代码库、搜索机器 IP、搜索内网等。这些平台的搜索功能都可以配置为自定义 Web Search,从而省去和浏览器的交互。配置方法见附录

格式固定的 url 也可以配置为 Web Search。比如:

  • github 的链接格式是 github.com/用户名/仓库名,我配置了一个 Web Search:https://github.com/{query}。之后在 Alfred 中输入 github vuejs/vue,就可以直接打开 https://github.com/vuejs/vue
  • 公司内部服务平台的链接格式是 https://cloud.xxx.net/service/服务名,我也配置成了 Web Search:https://cloud.xxx.net/service/{query},这样连搜索的步骤都省下了。

最后,尽量通过 Alfred 执行搜索操作。只需专注于内容本身,完全不需要任何浏览器操作。

💡   字节跳动的同学可以在内网搜索“Alfred Web Search 合集”,获取我整理的十余个内部 Web Search 配置。

Workflow

Workflow 是 Alfred 的核心功能。Workflow 类似于 iOS / Mac 的「快捷指令」,通过可视化的方式串联一系列操作,之后用一个命令直接执行整个流程。很多工作中的重复性操作都可以配置为 Workflow,节省时间,提高效率。

网络上有许多 Alfred Workflow 资源:

我常用的是这几个:

Don’t Repeat Yourself。多观察自己有哪些重复的操作,尝试把它配置成 workflow。举个例子,我经常需要执行一个命令,里面包含了 1.0.1 这样的版本号,版本号每次执行都不一样。一开始,我是手动填充版本号。后来配置了一个 Workflow,只需要输入版本号,就能自动拼接完整命令,并复制到剪贴板,非常方便。

image-20220417161324541

图:输入 curl_code,再输入版本号,就能将命令复制到剪贴板,可以直接去粘贴运行

Alfred Workflow 的配置教程可以在 Alfred 官网 查看。Alfred 中也内置了许多示例教程,见 Preferences - Workflows - 左下角加号 + 。

三、个人知识库

每个人都需要一个知识库。知识库最大的意义是充当大脑外存,帮助我们管理知识,并在需要的时候快速查阅。一方面,我们学习的新知识,如果不经常使用,很快就会忘记,因此需要整理在知识库里,以便日后复习。另一方面,我们总是会遇到各种问题,每次都去 Google 无疑会浪费时间,如果记录在知识库里,下次就可以直接在知识库检索,事半功倍。

在大一时,我就开始有意识地搭建个人知识库,至今已经积累了 1200 多篇笔记。尽管这些笔记里有很多都是偶尔才会打开,但因为都是用自己熟悉的方式记录的,所以往往扫一眼就能回想起完整的上下文,节省了从许多原始资料中筛选重点内容的时间。

我的知识库管理应用是 MWeb。下面是我的一些使用心得。

All In Markdown

Markdown 是一种用来写作的轻量级标记式语言,它使用简洁的纯文本格式来编写文档,可以转换成有效的 HTML 或 PDF 文档。Markdown 最重要的设计是易读易写 —— 语法轻量化;纯文本格式也能够直接在字面上被阅读。

Markdown 不需要像 Word 那样先选中文字、再点击工具栏的图标,常见的排版都可以用键盘完成。使用 Markdown 写作,我们可以专注于内容本身,更流畅地表达自己的思路。

每个程序员都应该学习 Markdown、使用 Markdown。Markdown 的语法十分简单,常用的标记符号不超过十个,几分钟就能掌握。目前许多网站都支持 Markdown 语法,如 Github、少数派、石墨文档、飞书文档等。我的博客也是用 Markdown 写的。

我会优先选择支持 Markdown 完整语法的笔记应用。目前,我使用 MWeb 管理自己的所有笔记;当需要输出长文时,我会配合使用 Typora

少数派:认识与入门 Markdown

随手记

知识的输入是构建个人知识库的重要一环。我们经常会在各种场景下遇到碎片化的信息:或者是与同事交流时,了解到一个业务背景;或者是看某篇文档时,发现一个名词解释;或者是查一个问题时,学到一个新的工具…… 这些知识都是有用的,但我们很少有时间可以停下手头的工作,去整理这些内容。这时,一个触手可得的随手记工具就显得尤为重要。

随手记工具是知识的缓冲区、收件箱。任何时候,只要遇到有用的知识,就随手记录下来。每隔一段时间,再把随手记的内容整理到个人知识库中。将知识管理分为「收集」和「整理」两步,可以简化知识录入的成本,在不打断当前工作心流的前提下,捕捉每个重要信息。

随手记工具的核心在于快速。我使用的是 MWeb 的 快速笔记 功能,按下快捷键,就可以记录 Markdown 内容。随手记工具也可以是和知识库分开的,比如你也可以使用 Mac 的 快速备忘录,或者 Drafts 等任何趁手的工具。重点在于定期整理、定期清空随手记中的内容。

💡   进一步阅读:📥 收件箱思维 - 信息管理

可搜索性

个人知识库的可搜索性很重要。如果每次查阅时都很不方便,那么知识库就失去了作为大脑外存的意义。可以从以下几点来提升知识库的可搜索性:(1) 结构;(2) 标题和内容;(3) SEO 关键词。

结构

当我们在图书馆查找一本书时,可以根据图书分类法,很快定位到一本图书。同理,为知识库设置合理的文件夹层级,也可以帮助我们快速定位一篇笔记。

知识库的结构没有统一的规范,符合个人认知即可。下面是一个示例:

  • 工作记录
    • XX 项目开发
    • XX 工具调研
  • 源码分析
  • 运维手册
  • 学习笔记
    • C++
    • Golang
  • 个人
    • 面试求职
    • 双月计划

这里我的建议是:如非必要,勿增实体。不要在一开始就设置非常详细的层级结构,这样只会加重选择困难。前期最好只设置必要的文件夹,层级尽量扁平,比如「工作」「个人」「学习」等。之后当笔记的数量积累到一定程度时,再拆分成更细粒度的分组。总之,渐进式地迭代我们的知识库系统,而不是追求一步到位。

标题和内容

  1. 标题要有信息量,方便在搜索结果中定位。标题中可以附带一些关键词。
  2. 合并内容重复的、相似的笔记,减少搜索结果中的干扰项。

SEO 关键词

当我们搜索一篇笔记时,往往想到的都是一些离散的关键词,而不是一句连续的话。因此,可以在正文中添加一些辅助搜索的 SEO 关键词。思考一下,当你看到这篇笔记时,最先想到的是哪些词语,这些词语就可以作为它的关键词。

关键词的格式要特殊一些,以便和正文内容区分,比如我设置的是 [XXX]

image-20220501211734069

图:笔记示例,上面的 [文件描述符] [stderr] 等就是 SEO 关键词

搜索时,可以组合搜索关键词和正文内容:

image-20220501212415500

图:使用 mweb alfred workflow 搜索 MWeb 中的笔记

知识库应用

最后,我们讨论应该如何选择一款知识库应用。

我的知识库应用是 MWeb。对我来说,它的优点是:

  • 支持完整的 Markdown 语法,包括 LaTex 公式、流程图等 (语法完整)
  • 可以直接在编辑器中粘贴图片,会自动转换为 Markdown 语法 (写作流畅)
  • 无限层级的文件夹;文档支持自定义排序 (文档管理能力)
  • 开发式文档库,可以使用其他工具编辑、搜索 .md 文件 (可扩展性)
  • 内置的「快速笔记」功能 (输入 → 整理 → 输出一条龙)
  • Mac 原生应用,也有 iOS App (比网页响应速度更快)
  • 支持自定义主题

缺点是:

  • 不支持所见即所得 (但可以使用 Typora 写作、MWeb 专注于文档管理)
  • 搜索能力一般,不支持正则表达式 (可以自己开发一个 alfred workflow,但成本较高)
  • 因为文档在 Mac 本地存储,使用 iCloud 同步,所以 iOS 加载文档的速度较慢 (但我几乎不使用 iOS 查看笔记)
  • 不支持双向链接

类似的知识库应用还有思源笔记, 支持本地文档库、双向链接、所见即所得。此外还有 Web 版的知识库应用,如飞书云文档Notion 等。这些应用都支持 Markdown 语法,功能上各有优劣,请按实际需求选用。如果读者有推荐的知识库应用,也欢迎评论区补充。

四、理念

如非必要,勿增实体

  • 使用一组简单的工具,完成不同的需求;而不是使用一个复杂的工具,完成全部的需求。
  • 使用一个复杂的工具时,前期只使用必要的功能。不要强行迎合软件,而是要根据实际需求,渐进式地选用新功能。

Don’t Repeat Yourself

  • 经常需要复制粘贴的内容 (如地址、邮箱、发票抬头、工号等),存成 Snippet。电脑上可以使用 Alfred Snippet;手机上可以使用备忘录,或者在输入法中配置快捷短语。
  • 重复性操作,配置成 快捷指令Alfred Workflow

善用 SEO 关键词

SEO 关键词可以增加信息量,提升检索效率。下面这些位置可以添加 SEO 关键词:

  • Chrome 书签标题
  • 笔记标题、笔记内容
  • 文件名
  • … (所有要查找的位置)

多用键盘,少用鼠标

  1. 使用 Alfred 作为 App 启动器;使用 Alfred 搜索文件;配置 Alfred Web Search
  2. 学会这几个文本操作快捷键,适用于任何文本编辑的场景:
    • 移动光标:
      • 移动一个字符:
      • 移动一个单词:Alt + ←Alt + →
      • 移动到行首 / 行尾:⌘Cmd + ←Cmd + →,或者 Ctrl + aCtrl + e
    • 选中文本:
      • 选中一个字符:Shift + ←Shift + →
      • 选中一个单词:Shift + Alt + ←Shift + Alt + →
      • 选中到行首 / 行尾:Shift + Cmd + ←Shift + Cmd + →
    • 删除文本:
      • 删除一个字符:← Backspace
      • 删除一个单词:Alt + ← Backspace
      • 删除到行首:Cmd + ← Backspace
    • 你会发现其中的一些模式:Alt 操作单词、Cmd 操作整行、Shift 选中文本。
  3. 一些有用的快捷键:
    • Cmd + A (全选)、 Cmd + Z (撤销)、Cmd + Shift + Z (重做)
    • 文本编辑器: Cmd + B (加粗)、Cmd + I (斜体)、Cmd + U (下划线)
    • Chrome 左 / 右切换标签页:Cmd + Shift + [Cmd + Shift + ]
    • Mac 切换应用窗口 (建议配合 AltTab 使用):
    • 不同应用程序:Cmd + TabCmd + Shift + Tab
    • 相同应用程序:Cmd + `
  4. 不同的软件快捷键可以配置成一样的,便于记忆。比如:
    • 我经常使用飞书云文档、MWeb 和 Typora 编写 Markdown 文件。因为飞书云文档不支持自定义快捷键,所以我把其他两个软件的 Markdown 编辑快捷键都配置成和飞书云文档一样的。
    • Chrome、iTerm2 都可以按 Cmd + n (n=1,2,3…) 来切换标签页,我给 VS Code 也配置了同样的快捷键,按 Cmd + n 可以切换当前打开的源文件。

💡   Mac 的某些应用程序没有提供快捷键配置入口,这种情况下可以在系统偏好设置中更改,详见附录

五、我日常使用的工具列表

💻 从零开始配置高效 Mac 开发环境

六、附录

书签文件夹示例

  • 快速入口:经常浏览的网站,比如工作日报、Github Trending、文档库首页等。
  • Inbox:待整理的内容。
  • Workspace:里面是一些子文件夹,每个子文件夹是一项具体的工作。
  • 📒:常用的参考手册、知识库链接。
  • ☁️:公司内部的云平台链接,比如代码库、微服务、动态配置中心等。
  • Metrics:常用的 metrics 打点。
  • Monitor:各种监控大盘。
  • Tool:常用的工具,比如 JSON 格式化、DAG 可视化、正则表达式测试等。
  • Archive:归档。

image-20220404211219820

Alfred 搜索过滤器配置方法

  1. 根据模板创建 Workflow:Preferences - Workflows - 右下角加号 - Examples - Simple File Search。 image-20220414000257947
  2. 双击修改 File Filter。
  3. 只搜索 .md 文件。这里可以把一个 .md 文件拖进去,会自动设置文件类型: image-20220413235656912
  4. 只搜索指定目录: image-20220414000450840
  5. 不仅搜索文件名,也搜索文件内容: image-20220414000609625

Alfred Web Search 配置方法

配置 Web Search 的方法很简单。以 github 为例,首先在搜索框中输入内容,回车:

image-20220417005255179

然后观察搜索结果页的 URL,是否包含了输入的搜索内容 imageslr/blog

https://github.com/search?q=imageslr%2Fblog

imageslr/blog 替换为占位符 {query}

https://github.com/search?q={query}

在 Alfred 中新增自定义搜索 (Preference - Features - Web Search - Add Custom Search):

image-20220417010021237

然后就可以使用了:

image-20220417010218015

同理,百度搜索的 Search URL 是:

https://www.baidu.com/s?wd={query}

Google 的 Search URL 是:

https://www.google.com/search?q={query}

评论区里会不断更新我日常使用的 websearch 配置。

Raycast vs Alfred

本文提到的所有 Alfred 的功能,Raycast 都有:

  1. 打开 App:Raycast 可以直接给某个 App 绑定快捷键。
  2. Web Search:在 Raycast 里是 Quicklinks,可以直接粘贴 Alfred 的 Web Search 配置。可以设置 Fallback Result。但不支持重复的 Alias (Keyword)。
  3. 剪贴板历史:支持保存文本、图片、文件。按 Cmd + S 同样能保存 Snippets。但 Snippets 不支持分类保存。

其他方面:

  1. 快捷键和 Alfred 基本一致,比如按 Cmd + 1、Cmd + 2 等可以快速选择。配置方式和 Alfred 也基本一致,但体验更佳。
  2. 快捷笔记 Floating Notes 功能使用很方便,代替了系统的便签。
  3. 界面现代,交互丝滑。扩展市场应用丰富,安装简便。新手教程友好。
  4. 个人使用完全免费。

因此,建议使用 Raycast 代替 Alfred。


Raycast 也可以直接替换某些系统插件:

  • Rectangle:窗口布局工具。Raycast 提供了完全一致的功能 (Window Management),且可以直接导入 Rectangle 的快捷键 (Presets)。

Mac 系统自定义快捷键配置方法

以 Typora 为例。点击菜单栏,可以看到这样的操作列表,每个操作标题后面都有对应的快捷键:

image-20220501232842330

进入「系统偏好设置 - 键盘 - 快捷键」,选中「App 快捷键」:

image-20220501232927238

点击 + 号,选择应用程序,输入菜单中的操作标题,输入自定义快捷键,就可以覆盖应用程序的默认快捷键了:

image-20220501233111588

Tips

  • 搜狗输入法:打开“中英文自动输入空格”
  • 建议自己申请一个工作专用的 Google 账号,而不是使用公司分配的账号。后者在离职后会被回收。
  • 富文本如何转为 Markdown:粘贴到 Typora,再复制成 Markdown 即可。
  • 网页如何转为 Markdown:同上。
]]>
Images。