Grafana 是一个开源的数据可视化和监控平台。它提供了一个灵活且强大的界面,可以连接到各种不同类型的数据源,将其中的数据以图表的形式进行展示和分析。
Grafana 的灵活性和可扩展性使其成为一个强有力的服务运维工具和信息获取工具:
配置好 Grafana 看板,可以提升问题排查的效率:直接在 Grafana 查看各类数据,无需跳转到数据源;也可以发现事件之间的关联,例如“CPU 利用率变高,是因为请求量涨了”。
本文围绕 Grafana 在后端开发中的高频使用场景,分享了 Grafana 的基础概念、可视化、高级功能等。目标是让读者知道 Grafana 有什么功能,先留下印象,然后在需要配置看板时随时查阅。
学习 Grafana 的最好方式是亲自上手操作。本文使用 Grafana 官方网站提供的沙盒环境做演示。Grafana 的沙盒环境提供了一个测试数据源,可以声明式地生成随机时序数据,用于调试看板的功能。
💡 演示版本:Grafana v10.3.0
Grafana 界面总览:

一个 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 的字符串,这是当前看板的所有变量的取值:

每次修改看板右上角的时间范围,或者鼠标拖动框选一段时间轴,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。
Grafana 的整个看板内容 —— 包括所有的设置项 —— 都是用 JSON 描述的。这意味着我们可以直接编辑 JSON 格式的字符串,来达到修改看板的目的。某些场景下,这比操作 UI 界面更方便。
可以在“Dashboard Settings - JSON Model”找到当前看板的 JSON:

JSON Model 中的字段说明:
iteration:在什么时间被修改。Grafana 通过这个字段来判断是否和其他人的修改发生冲突。id、uid:看板的唯一标识,即 URL 中的 ID 部分。links:看板上面的链接
templating:看板的变量 Variablespanels:类型是数组,每个元素表示页面中的一个面板 (Panel,type=graph),或者一行 (Row,type=row)。
JSON Model 的典型使用场景:
💡 可以直接在 VS Code 等编辑器里修改 JSON Model,也可以用 JavaScript 或 Python 代码修改。修改后的内容粘贴到设置页,保存即可生效。但要注意,以下几个字段必须使用原来的值,不能随意替换:id、uid、iteration。否则会报错:“Dashboard has been changed by someone else”。
在“Dashboard Settings - Versions”可以看到最近的更改历史,可以回滚。

Grafana 的权限控制遵循 RBAC 策略。
Grafana 提供以下三种角色:
Grafana 的权限可以在以下两个层级配置:
在“Dashboard Settings - Permissions”可以修改他人权限。权限可以分配给个人或团队。
如果想创建一个”只读”的看板,只需要将 Editor 的权限从Edit改成View。
💡 为了方便练习和保存,可以先把 Grafana 官方沙盒看板复制一份。
(1) 如果有看板的编辑权限,进入看板的设置页,点击 Save As... 即可:

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

接下来有两种导入方式:
① 方法一:”New Dashboard - Import → Import via dashboard JSON model”,粘贴 JSON Model 内容,Load。然后修改 Name 和 UID,否则会报错。


② 方法二:新建一个空白看板,进入该看板的「Dashboard Settings - JSON Model」,使用该看板的 id、uid、title、iteration 字段的值,合并到刚才复制的 JSON Model 中,整体粘贴覆盖,保存。
如果有编辑权限:
Panel 菜单 → More → Copy:

Add panel → Paste panel

如果没有编辑权限:
Panel 菜单 → Inspect → Panel JSON:

将这个 Panel JSON 复制到目标 Dashboard 的 JSON Model - Panels 中。或者参考这个 Panel JSON,手动配置一个一样的 Panel。
看板的所有状态都可以被保存。包括:
建议:
点击 Save,勾选以下两项,保存看板的默认状态:

/d/{unique_id} 的简单 URL,比如 https://play.grafana.org/d/000000012变量 (Variables) 是 Grafana 的一项强大功能,可以用于创建动态的、可配置的、模板化的仪表盘。比如创建一个通用大盘,监控多个服务,而无需为每个服务创建单独的看板。
变量可以在“Dashboard Settings - Variables”配置。

Grafana 提供了多种变量类型:
下面将依次介绍使用频率最高的变量类型:Custom、Textbox、Query。

①:提供几个固定的选项,逗号分隔。
②:默认单选,可以支持多选。
③:当允许多选时,可以有一个“全选”的选项。
④:“全选”的默认值是所有值拼起来,如{value1, value2, ...}。 可以自定义一个值,如*。
⑤:Custom 变量的选择框也是输入框,可以临时输入一个不存在于固定选项中的值,如下图。

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

Custom 类型的变量只能提供固定的值列表,而 Query 类型的变量可以实时查询某个 metrics name 下的某个 tag 的取值。典型的应用场景是“获取服务的所有上游 / 下游”。
下面以 OpenTSDB 数据源为例,演示 Query 类型变量的使用方式:

throughput 下的 from tag 的所有值。其时间范围默认是最近一个小时。Refresh设置为On Dashboard Load或者On Time Range Change。Query Options 中的“Regex”可以用来过滤字段。比如只保留 test_ 开头的值:
/^test_/
另外,用正则的捕获组,可以把 test_ 前缀去掉,只保留后面的内容。典型的使用场景是:Query 返回了 test_foo和test_bar,但需要提取其中的 foo 和 bar 用在 Panel 中:
/((?<=test_)*)/
https://grafana.com/docs/grafana/latest/variables/filter-variables-with-regex/
💡建议:能用 Query 尽量不要用 Custom,这样能保证看板的通用性。
💡 在配置 Panel 时,经常会出现foo=*这样的语法,用来枚举foo的所有可能取值。这种情况下,建议将foo配置为一个 Query 变量,使用foo=${foo}。不仅默认效果一样,必要时还可以按值过滤下钻,逐步定位问题。
如 1.7-保存看板默认状态 所述:
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
保存看板时,会提示是否要保存当前选择的变量值作为默认值:

因此,建议保存变量的默认值。一般来说,默认值都是 All,然后让用户自己过滤。某些变量可以设置成一个主要的值,避免面板上曲线太多,对用户产生干扰。
使用变量的语法是$varname或${varname}。推荐使用后一种,因为在某些场景下,grafana 无法正确区分变量名的边界,比如把$service.xxx.xxx识别成一个变量,但实际上应该是$service。
在任何地方都可以直接使用变量:
💡 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 官方的演示看板:Template VariablesFormatting Options。修改 servers 变量,查看不同修饰符的渲染结果。

Grafana 内置了一些全局变量,比如时间范围$__from、$__to,或者$__all_variables (所有变量的当前取值,表示为 url query parameters 形式) 等。详见 Grafana 文档。
全局变量也支持修饰符。$__from和$__to还支持如下的日期格式化语法:

💡典型使用场景:配置了跳转到其他看板的链接,希望附带当前看板的所有状态 (变量值、时间范围等)。Grafana 官方 Demo:内置全局变量。
变量联动是生产环境中的常见需求,但当前 Grafana 没法很好支持。一个典型的场景是根据地区选择相应的服务器:
A | B
------- | -------
cn | shanghai
us | new_york, silicon
解决方法是:
cn, usQuery($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 管理员。
Grafana 提供了 Repeated rows 和 Repeated Panels 功能,可以根据变量的值动态复制行或面板的布局。
以行重复为例。在行标题旁边点击齿轮图标,打开“Row Options”,可以看到一个名为 “Repeat for” 的选项。在这里选择要按哪个变量重复,然后保存。

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


💡 Grafana 官方的演示看板:
在配置 Grafana 看板时,需要在“Query”区域选择一个数据源:

Grafana 有两种常见的数据源:OpenTSDB 和 Bosun。Grafana 的数据源需要在管理员后台配置,这里我们假设读者了解这两个数据源、且公司已经在 Grafana 系统中配置好了这两个数据源。接下来介绍这些数据源的使用方法。
Grafana Play Ground 还提供了一个测试数据源,可以声明式地生成随机时序数据。在后面的“可视化”章节中,我们会使用这个测试数据源生成示例数据。

Alias:提供一个可读的别名。常见的使用方式是配合“Filters / Tags”,比如有个 tag key 的名称为 cluster,alias 就可以配置为 cluster=$tag_cluster,当有 default、test 两个 cluster 时,会显示 cluster=default、cluster=test。

.、* 通配符💡 关于 OpenTSDB 数据源的更多配置说明,详见 Grafana 官方文档。
所有 OpenTSDB 类型的都可以写成 bosun 的形式。举个例子,假设我们有一个OpenTSDB 查询,它使用以下参数:
将这个 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 官方文档。
TestData data source 是 Grafana 官方提供的一个测试数据源,用于生成模拟的时序数据,非常适合用来测试看板的功能。Grafana 的沙盒环境内置了这个数据源,我们在第五节“可视化”章节中会使用到。

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
这一节介绍了“行”和“面板”的基础操作和配置。
在 Dashboard 的右上角添加行:

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

鼠标移动到行标题上,会出现一个齿轮和删除按钮 (上图②)。点击齿轮,可以修改行的标题,或配置按变量重复行 (见 2.4)。
面板 (Panel) 右上角的菜单提供了以下功能:

鼠标移动到面板的标题区域,鼠标指针会变成一个十字,拖动可以移动面板。
点击“Edit”进入面板的编辑页,右边提供了一系列配置项:

强调以下几个功能,提高面板的可读性和信息量,使其更易用:
cpu.utilization → CPU 利用率xxx.calledby.success → XXX 接口成功 QPS描述:为看板补充必要的、更详细的描述信息,用户将鼠标移动到图标上时会展示

Tooltip 降序排列:和变量要按字母序排列一样,鼠标浮动到面板上展示的 Tooltip 要降序排列,降序排列后刚好和所有曲线从上到下的顺序一致。Panel Settings - Display - Hover tooltip - Sort Order - Decreasing (不同版本的 Grafana,配置项的路径可能有区别)
Legend 按表格展示,适用于曲线分组较多的场景:Legend - 勾选“Show As Table” + “Max / Avg / Current”,按 avg 降序排列 (不同版本的 Grafana,配置项的路径或名称可能有区别,比如下图最新版的 Grafana 中 avg 被替换成了 Mean)

metrics_name{key1=value1, key2=value2},比如 X 调用 Y 服务的 foo 接口,名称默认是 throughput{from=X, to=Y, api=foo},可读性很差。$tag_from → $tag_to::$tag_api(),展示出来形如 X → Y::foo(),能直观看出打点的含义。配置和面板 Query 含义一致的链接:用户发现某个面板的数据有异常后,经常需要基于该面板的 Query,做更进一步的查询。这里可以用 Grafana 面板自带的 Explore 功能,但更多时候用户会跳转到另一个平台,比如 OpenTSDB 数据源总是有一个配套的 Metrics 平台、数据库总是有一个 SQL 平台… 可以将 Query 对应的平台链接附在 Panel 上,用户就可以在左上角“描述”区域直接点击链接跳转。Panel Settings - Links - Add。

💡 面板的可读性越高,排查问题的效率越高:
这一小节会介绍 Grafana 的几种可视化形式及其配置项。建议在 Grafana 沙盒里编辑测试,会更直观。如果要获取看板设计的灵感,可以参考附录中的 Grafana Demo。
90% 以上的场景,用折线图就够了:

💡 Grafana 官方的演示看板:① Time Series 时序图(折线图)总览、② 每个配置项的细节
以 Grafana 最新版沙盒为例,面板编辑页的常用配置项 (从上到下):
Alias、Tooltip 降序排列、Legend Show As Table:略,见上文
Placement:坐标轴放在哪里,默认靠左
按对数比例展示:Demo - 对数 Scale
Data links:添加链接,详见 4.3-提升面板的可读性
Value mappings:按条件将某个值映射为其他值,比如将 P0、P1 映射为核心、非核心
Thresholds:添加一条阈值线 (或填充区域)。可以配置阈值的颜色和值,默认大于阈值的区域会被填充颜色,如果想表达“低于阈值”时是异常情况 (比如服务 SLO 指标),可以替换下图中 Base 和 80 的颜色。

test 的曲线设置为虚线,表示这些是测试数据;(3) 在一个面板中画两条曲线,其中一条曲线的纵轴在左侧,另一条曲线的纵轴在右侧 (修改 Y-Axes)。饼图适合表示各项数据的占比:

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

💡 Grafana 官方的演示看板:饼图和柱状图
Gauge 适合展示总体水位,比如带宽是否满了,或者服务稳定性是否跌破阈值。

💡 Grafana 官方的演示看板:Gauge、Bar Gauge
突出显示当前时刻的值,可以在底部以阴影方式显示这段时间的曲线。

💡 Grafana 官方的演示看板:Stat 统计
Text 类型的面板支持写 Markdown 或者 HTML。
Markdown 适合写看板的使用说明:

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 顶部可以展示链接,通常会在这里附加其他看板和相关文档的链接。

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

配置项:

xxx.com?${foo:queryparam},如果当前看板的 foo 变量取值为 123,则会生成这样的链接:xxx.com?foo=123。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:在新标签页打开,推荐勾选。Dashboard Settings:
Auto refresh:配置不同的自动刷新间隔,在右上角选择,之后看板会每隔有单时间就自动刷新


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

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

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

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

Grafana 官方提供的所有示例:https://play.grafana.org/dashboards。
除了上文已经列出来的示例,这里收录了一些其他可能有用的看板:
完整 Demo:
Grafana Dashboard:折线图、柱状图、Bar Gauge。Graphite 数据集。

Stats Overview:折线图、柱状图、Gauge 仪表盘、Bar Gauge、Stat。
Big Dashboard:折线图、柱状图、堆叠、统计。Graphite 数据集。
Loki NGINX Service Mesh:折线图、Stat、Gauge、Map、Log 日志、Table (内嵌 Gauge)。
Business Metrics:Stat 统计、阈值。
Multiple Panel Test Example:所有类型的看板速览,包括 Text、Geomap、State timeline、Logs、Histogram、Heatmap、Gauge、Pie chart、Table、Time Series、Stat、Bar chart。
一个服务级别的看板,应当包含这几行:
这里要在参数和 tags 里配置 from,区分不同上游;配置 method,区分不同接口。
这里要在参数和 tags 里配置 to,区分不同下游。
]]>TODO:通用服务大盘,目前仅在字节内网可用
服务运维 和 信息获取 中的高频使用场景,分享 Grafana 的基础概念 (JSON Model、Query、Variable)、可视化配置 (Panel、各类图表)、高级技巧 (变量联动、内嵌 HTML 页面)。同时,文章会讨论一个标准后端服务的稳定性看板应该如何建设,并提供一个覆盖全文技巧的看板示例。其他内容暂时没有想到。如果读者有建议,欢迎在评论区留言。
]]>去年其实也围绕工作内容,在公司内网分享了几篇文章,包括研发基本功 (IDE、稳定性保障、服务看板建设、C++ 单元测试)、混排串讲、特征工程串讲等。但考虑到我的博客读者大部分是校招新人,这些和具体工作相关的内容就不在这里分享了。
📌 本文来自 Ads Infra 内部分享,欢迎加入 👉🏻
作为架构部门,我们的很多核心仓库都是 C++ 编写,目前基本都有 80% 的增量单测覆盖率卡点。编写单测的好处不言而喻:通过构造各种 case,可以发现空指针、大数越界等肉眼不容易发现的 bug。此外,单测也可以在不引流的情况下,测试功能是否正确。因此,编写单测是必要的,为新增代码补充单测是每个研发同学的基本功。
但是,C++ 编写单测也是最麻烦的。根据日常观察,大部分同学没有系统地写过单测,基本依赖照抄现有代码,单测写得慢,且不标准。此外,没有掌握常见的调试技巧,主要通过 cout 逐行打日志和重新编译来定位问题,进一步降低了单测编写效率。
本文旨在解决上述问题:
为下面这段代码编写单测:
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);
}
}
涉及到的方面:
if(a||b),需要分别构造 a == true 和 b == true 两个 case。TEST(TestSuiteName, TestCaseName) {
// 单测代码
EXPECT_EQ(func(0), 0);
}
TestSuiteName 用来汇总 test case,相关的 test case 应该是相同的 TestSuiteName。一个文件里只能有一个 TestSuiteName,建议命名为这个文件测试的类名。TestCaseName 是测试用例的名称。建议有意义,比如“被测试的函数名称”,或者被测试的函数名的不同输入的情况。TestSuiteName_TestCaseName 的组合应该是唯一的。一个 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) {
...
}
GTest 提供了多种测试宏,其中最为常用的是 TEST、TEST_F,它们的区别如下:
TEST:这是最基本的测试宏,代表一个最小测试单元。在执行 TEST 宏时,gtest 会为每个 TEST 定义一个独立的实例,使其互相隔离,避免对同一个变量进行修改或共享等可能带来的副作用。TEST_F:这是 TestFixture 的测试宏。TestFixture 是一个类,可以在多个测试用例之间共享数据结构或方法。对于同一个 Test Suite 的所有 Test Cases,会创建一个 TestFixture 对象,其 SetUp 函数会在每个 Test Case 执行之前被调用,而 TearDown 函数则会在每个 Test Case 执行之后被调用。使用 Test Fixture Class,可以避免写重复的代码:
示例代码:
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) {
// ...
}
实际使用技巧:
BaseTestFixture,继承 ::testing::Test,封装全局通用的方法BaseTestFixture,提供某个测试场景下共享变量和方法用来判断某个变量的值是否符合预期。前者在校验失败时会打印失败信息,然后继续运行。后者会直接终止。
💡 正确使用 ASSERT 和 EXPECT 前缀:
如果某个 EXPECT 失败会导致后续一连串 EXPECT 失败,那么第一个 EXPECT 应该换成 ASSERT。这就像编译时的报错信息,往往只有第一个是有用的,其他错误都只是刷屏。
下面罗列一些最常用的 EXPECT 宏,把前缀换成 ASSERT 也可以使用。完整列表见文档。
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。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 以内
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:忽略大小写,是否相等。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 是 Google Test 提供的一个 C++ mocking 框架,可以用于创建虚拟的对象和方法。GMock 的原理是利用 C++ 的多态特性,覆盖 virtual 函数,将函数调用转发到相应的 mock 函数中。
GMock 基本使用流程如下:
#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。
语法:
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):被调用任意次,执行自定义行为Will 开头的接口可以传入一个 Action 参数,设置 mock 函数被调用时的行为。常用的:
Return:返回指定值。比如 WillOnce(Return(100))。
ReturnRef、ByRef:Return 不支持返回引用类型的变量,需要用这两个宏。
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));
Matcher 能够实现在复杂场景下进行断言,可以让测试用例更加灵活和可读,是写出优雅单测的必备工具。
Matcher 提供了一系列常用的比较函数,例如 Eq、Ne、Lt、Gt、Le、Ge 等,可以满足不同类型变量的比较。
Matcher 有两个使用场景:
和 EXPECT_CALL 配合使用,用于检查传递给函数的参数值是否符合预期
// 期望第一个参数大于 2,第二个参数小于 6
EXPECT_CALL(calc, Add(Gt(2), Le(6)));
calc.Add(3, 5); // 可以通过检测
calc.Add(2, 7); // 不能通过检测
和 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)、Gt、Le、Lt:>= (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):不匹配给定的匹配器,可以和 AllOf、AnyOf 配合使用。
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
匹配的优先级如下:模糊匹配器 > 精确匹配器 > 通配符
这引入了一些使用技巧:
只设置必要的匹配器。如果对某个参数的值不感兴趣,请写 _ 作为参数,这意味着“一切皆有可能”。
EXPECT_CALL(calc, add(5, _).Times(1); // 如果只关心第一个参数的值,第二个参数就写成 _
EXPECT_CALL(calc, add(5, 3).Times(1); // 如果这样写,之后代码变动,单测可能就不通过了
如果对所有参数的值都不感兴趣,可以省略参数列表,这和把每个参数都写成 _ 是一致的。好处是后续改了函数签名后,比如新增了一个参数,单测是不需要改动的。
EXPECT_CALL(calc, add).Times(1); // 任意参数
EXPECT_CALL(calc, add(_, _)).Times(1); // 和上面等价
利用匹配器的优先级,可以细粒度地控制函数在不同参数下的返回值。比如 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"), "");
非预期调用是指未被 EXPECT_CALL 匹配的调用。当有非预期调用时,会有 warning 日志输出:
Uninteresting mock function call - returning default value.
Function call: foo(42)
Returns: 0
有两种处理方式。
GMock 有三种级别:Nice Mock、Naggy Mock、Strict Mock。
默认是 Naggy Mock,当有非预期调用时,输出 warning 日志。
Uninteresting mock function call - returning default value.
Function call: foo(42)
Returns: 0
如果我们希望非预期调用不要有 warning,可以用 NiceMock。NiceMock 是一个模板类:
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)**。
ON_CALL 可以和 EXPECT_CALL 配合使用。ON_CALL 设置函数的默认行为,EXPECT_CALL 临时修改其行为。
💡 ON_CALL 和 EXPECT_CALL 的语法很像,但提供了不同的语义。EXPECT_CALL 目的在于定义一个预期,即我们期望被测试函数在某些特定条件下应该调用哪些函数,如果没有满足预期的调用,则认为是一次失败。ON_CALL 只是为了指定被测试函数的默认行为。
ON_CALL 通常用在 Mock 类的构造函数、或者 TestFixture 的 SetUp 函数里:
将 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.
};
错误的做法:#define private public,或者定义 getter 函数。前者可能导致编译报错,后者需要修改代码。
正确的做法:-fno-access-control,放在单测的 optimize 参数里。
错误的做法:定义 setter 函数。需要修改代码。
较好的做法:使用 const_cast<Type&> 修改常量类型。
好处:单测覆盖率报告更准。
--gtest_filter什么时候需要运行特定单测:
语法:--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 的单测数量:

之后在修理单测过程中,可以使用 --gtest_also_run_disabled_tests 或者 --gtest_filter 来执行被 DISABLED 的单测。
相比于把整段单测代码全部注释掉,加一个 DISABLED_ 前缀的 diff 更少,而且后续可以直接运行。
std::cout 输出的日志会直接展示在终端。
💡 建议:能用 EXPECT 就不要写 std::cout
EXPECT_CALL()... << ... 后面,而不是直接输出。🔗 GDB 快速入门 / 速查手册:https://imageslr.com/2023/gdb.html
GDB 也是研发基本功之一。使用 GDB 断点调试的效率远高于加日志+重新编译单测,但大部分人依然使用后面这种调试方式,原因可能是认为 GDB 的上手成本太高。但实际上,GDB 入门只需要 3 分钟。这里罗列 GDB 的基本使用姿势,足够覆盖大部分单测场景。上面高亮块里也提供了一个速查手册。
gdb ./path/to/unit_test
set env LD_LIBRARY_PATH=...
r。如果要运行指定单测,加 --gtest_filter 参数:
r --gtest_filter=FooTest.bar_method
b。比如:
b 文件名:行号
b prime/src/auction/validator/frame/validator.cpp:52
cnp 变量名bt💡 单测代码也需要经过 Code Review。单测代码和线上代码同等重要。
[强制]单测文件的路径名,等价于源码的文件名加上 _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 建议命名为被测试的类名加上 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 生成的类名是带下划线的,所以上面这些名字建议用驼峰形式。
[强制]经典问题:“假单测”。为了通过单测覆盖率卡点、便只是在单测里执行了一下新增函数,但不检测其返回值,没有任何断言逻辑。之前遇到过有同学写了几百行单测,reviewer 从头看到尾,居然一行 EXPECT 都没有,(╯‵□′)╯。
还有一种场景是“蹭单测”:新增了一个分支逻辑,引入了一坨逻辑,但只是在某个已有单测里,把这分支的控制参数打开了,完全没有自己构造输入去覆盖新增逻辑。这样即使覆盖率也能达标,也属于无用单测。
[建议]单测的目的之一在于测试程序的鲁棒性,即当输入不符合预期时,是否能正确处理。比如一个 stoi 函数 —— 将字符串转成整数。在构造输入时,最基本的是 123 这种合法字符串,此外还应当构造 0.9999 (小数)、123abc (含非法字符) 等非法输入,以及 1781234123412341234 这种合法但越界的输入。
ASSERT / EXPECT 检查 [强制]能用 EXPECT 就不要写 std::cout:
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(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) {
...
}
ASSERT 和 EXPECT 前缀 [建议]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
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
[强制]单测里禁止访问外部服务,最好是整个单测能够断网。
之前遇到的实际 case:
Gtest 官方手册 (Google Test Primer) ,以及部门内的分享。
]]>在后端场景中,服务是一种提供特定功能的模块或系统,通过 REST API、RPC 等方式对外提供接口。服务可以独立运行,也可以和其他服务共同协作,构成一个庞大的系统。常见的服务有鉴权服务、搜索服务、数据库服务、广告召回服务等。
服务是整个系统的重要组成部分,为前端应用和其他上游服务提供支持,必须保证稳定可靠。现代服务通常需要应对高并发的请求、处理大规模的数据。随着业务和架构复杂度的增加,性能问题也会随之出现。这不仅会影响用户体验,也可能影响整个系统的稳定性。因此,服务性能优化显得尤为重要。通过优化服务性能,一方面可以降低延迟,保障服务的高可用性,提升用户体验,另一方面可以减少 CPU、内存等硬件资源的消耗,节约成本。
在这篇文章中,我们将围绕「服务性能优化」展开讨论,从代码、系统、架构等层面,探索服务性能优化的最佳实践。
SLO 和 SLA 也是服务性能优化中两个常见的概念:
当我们观测服务性能指标时,通常会查看一个统计值,比如所有请求的平均延时、集群中所有主机的平均 CPU 使用率等。
avg (平均值) 是所有数据的算术平均值,可以帮助我们快速了解服务性能的整体水平,但是不够准确,容易被异常值影响。pct50 (中位数) 是位于所有数据最中间的一个值。和 avg 相比,pct50 更稳定,不易受异常值影响。适用于数据分布不均匀、有异常值或者极值的场景。pct99 (百分位数) 是位于所有数据第 99% 位置的值,比如 100 个请求中的前 99 个请求,它们的延时都比 pct99 小,只有最后 1 个请求的延时比 pct99 大。pct99 可以帮助我们快速发现一些问题,比如存在大包体、慢查询等长尾请求,或者集群中有异常实例。pct90、pct999 等指标。总之,pct{n} 反映了数据的分布情况,有助于我们了解服务在极端情况下的性能表现。实际场景下,我们需要同时关注 avg、pct50、pct99 等指标,以获取更全面的性能数据。
基准测试 (Benchmarking) 是一种衡量系统性能的标准化方法。基准测试常用来验证性能优化效果:首先在系统上运行一系列测试程序,保存性能指标结果;然后在代码或硬件环境变化之后,再执行一次基准测试,以确定那些变化对性能的影响。
压力测试 (Stress Testing) 通过增加系统负载,测试系统在极端情况下的表现。压力测试可以帮助发现系统的性能瓶颈。常用的压测工具有 Apache JMeter、LoadRunner 等。
收集数据:通过日志和监控记录服务运行过程中的信息。这些信息既要包含时间戳、接口名、IP 地址、请求延时等通用信息,也要包含服务内部的具体数据,如每个子步骤的执行耗时、请求传入的数据量级等。
监控数据一般用 Metrics 框架来收集。一个知名的 Metrics 框架是 OpenTSDB。
Metrics 框架提供了存储时序数据和对时序数据进行聚合查询的功能:
Metrics 框架提供了通用的 API,可以在应用程序中收集各种类型的指标,比如 counter、timer 等。Metrics 数据可以展示在 Grafana 看板中。
展示数据:对收集到的日志和监控数据进行处理,可视化地展示其中的各项关键指标。常用的可视化工具是 Grafana。

图片来源:Grafana Playground
分析数据:观察指标的 avg、pct99 等分位数,分析是否有长尾请求或单点异常;观察指标随时间的变化情况,定位出现性能异常的时间;同时观察多项指标,发现指标之间的关联关系,比如某时刻请求量上涨,导致 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 看板,当有一条曲线远远高于 / 低于其他曲线时,说明对应的主机有单点异常。在这个场景下,很有可能是集群负载均衡器的问题。

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

图源:http://openresty.org/download/user-flamegraph.svg
火焰图是一张形如火炬的 SVG 图片。火焰图上的每个矩形代表了一个函数的执行过程,其宽度表示执行时间的长短。矩形从下向上表示函数的调用层次,底部是外层函数,顶部是被调用的函数。矩形颜色没有含义,只是为了便于区分。
显然,矩形的宽度越宽,该函数的执行时间就越长,表明该函数可能存在性能问题。我们需要寻找火焰图中最宽的矩形,针对性地优化代码。
生成火焰图时,首先需要使用 perf 或 DTrace 等命令,收集一份包含函数执行堆栈的数据报告。然后可以使用 Brendan Gregg 开发的 FlameGraph 或者 Google 开发的 pprof 等工具,根据收集到的数据生成火焰图。最后可以使用 d3-flame-graph 等工具,将静态的 SVG 文件转换成动态的 HTML 文件,以便深入分析。

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

图源:Memory Leak (and Growth) Flame Graphs - Brendan Gregg
最后介绍一下差分火焰图。差分火焰图可以对比不同时间段的两张火焰图的差异,以观察哪些函数的资源开销发生了变化。差分火焰图的形状和第二张火焰图相同,矩形块的颜色表示该函数资源开销 (占比) 的差异,红色代表增长,蓝色代表减少。
图源:Differential Flame Graphs - Brendan Gregg
差分火焰图可以用来定量分析某项性能优化工作是否有效,比如优化了一个热点函数后,应该能从差分火焰图上看到该函数的 CPU 开销有显著减少。此外,差分火焰图也可以用来排查内存泄漏问题,比如在一台发生内存泄漏的机器上,每隔一段时间采集一份内存数据报告,然后生成内存差分火焰图,便可以很直观地看出增长的内存来自哪里。
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 是 Golang 官方提供的性能分析工具,可以生成 CPU、内存等多种类型的 Profiling 数据,支持以可视化的方式展示。pprof 内置了火焰图、函数调用图、表格等多种展示方式。
对于 Golang 服务的性能优化,建议使用 pprof,或者 pkg/profile 等开源库。
后面几节将从不同层面讨论服务性能的优化手段。在此之前,有必要先了解一些基础知识。这些知识可以帮助我们更深入地理解程序性能优化的原理和方法,从而更有效地进行性能优化。
并行 (Parallelism) 和并发 (Concurrency) 都是计算机处理多个任务的方式:
实际场景中,可以通过多进程、多线程、协程等技术实现并发,通过向量化、GPU 计算等技术实现并行,从而充分利用 CPU 资源,减少空闲时间,提高程序性能。
CPU 通常具有多个执行单元 (如整数单元、浮点数单元),可以同时执行多个指令,这种技术称为指令并行 (Instruction-level parallelism, ILP)。以下是一些相关机制:
流水线 Pipeline:将 CPU 执行指令的过程划分为多个独立的阶段,通常包括取指令、译码、执行、访存、写回等,然后使用不同的硬件单元来并行执行不同阶段的指令。流水线可以提高 CPU 的效率,但如果遇到数据依赖或分支预测错误等问题,会导致流水线停顿。
乱序执行 Out-of-Order Execution (OOO):在 CPU 中使用重排序缓冲区来缓存乱序执行的指令结果,再将结果按照原有的顺序提交给 CPU。
预取 Prefetch:提前将下一条指令所需的数据从内存加载到 CPU 缓存中,避免因内存访问延迟而导致的指令停顿。
动态指令调度 Dynamic Instruction Scheduling:使用指令调度器动态地调整指令的执行顺序,优化指令的执行流程。动态指令调度通常在流水线中进行,通过分析先前已执行的指令,来决定下一个要执行的指令是哪一个,以避免潜在的数据冲突和分支预测错误。
分支预测 Branch Prediction:if-else 语句、for 循环等分支指令,在执行时会根据条件跳转到不同的代码块。由于其跳转目标不确定,CPU 可能会浪费很多时间在等待分支跳转的过程中。为了解决这个问题,CPU 使用分支预测机制,在执行分支指令之前预测下一个跳转的目标指令,并进行预取。如果预测错误,CPU 需要重新执行正确分支的所有指令。
CPU 访问内存的时间比执行指令要长得多。因此,CPU 内部通常拥有多级缓存,如 L1 缓存、L2 缓存、L3 缓存等。缓存越靠近 CPU,访问速度越快,但容量越小;相反,缓存的级别越高,容量越大,但速度越慢。
局部性原理指出,在计算机程序执行过程中,访问的数据和指令通常集中在空间上相邻的位置 (空间局部性),且会在一段时间内被反复使用 (时间局部性)。因此,CPU 可以通过预取等技术将需要访问的数据和指令提前载入到 CPU 高速缓存中,以降低访存延迟的影响。
当 CPU 访问内存时,它并不是仅仅把单个字节或单个字从内存中读取到缓存,而是以块为单位,一次性载入多个连续字节。这个单位称为缓存块 (Cache Line),其大小通常是 64 或 128 字节。如果 CPU 访问的数据和指令都集中在一个缓存块中,那么就可以一次性载入缓存,避免多次访问内存。
因此在编写代码时,应该尽可能地减少访问内存的次数,使用数组、结构体等数据结构,避免不规则的内存访问模式,充分利用缓存。
编译器会对源代码进行各种优化,以使生成的汇编代码更加高效。常见的优化手段有:
i = 320*200*32 会直接代替为 2,048,000,而不是生成两个乘法指令。在编写代码时,应注意代码的结构和风格,给予编译器相关提示,以方便编译器进行优化。
🔗 扩展阅读:编译优化 - OI Wiki、CSE 231 - LLVM Project、LLVM 循环优化器 Polly 架构
选择合适的数据结构和算法是提高程序性能的关键。
比如,C++ 提供了 unordered_set 和 set 来存储键值对。unordered_set 使用了哈希表实现,不保证元素的有序性,但是插入和查找的平均时间复杂度为 O(1)。set 使用红黑树实现,保证元素的有序性,但插入和查找的时间复杂度为 O(log n)。因此,如果需要有序地遍历元素,应该使用 set。如果需要高效地插入和查找元素,应该使用 unordered_set。
再比如,少量数据查询在不在,使用哈希表就可以实现。但海量数据查询在不在,位图或布隆过滤器可能是更合适的方式。
优化算法也可以降低程序的时间复杂度。比如使用快速排序代替冒泡排序,又或者在搜索过程中加入一些条件判断来剪枝、以及引入启发式搜索,提高搜索效率。
💡 下文主要描述了 C++ 的优化方法。
静态代码分析工具可以在不执行程序的情况下,发现潜在的代码问题,并给出优化建议。常见的 C++ 静态代码分析工具有 Clangd、Cppcheck、Coverity 等。我使用的是 Clangd,它提供了 VS Code 插件,能自动标识出不安全或低效率的代码,并给出 Quick fix 建议。
函数调用会消耗时间和空间,可以使用宏定义和 inline 函数来内嵌代码。但如果代码过长,会降低编译期和运行期的性能。对于那些非常短小或者频繁调用的函数,可以用 inline 优化。
auto foo 改成 const auto& foo。std::move(),避免对象拷贝。https://godbolt.org 是一个在线网页,可以实时将 C++ 代码编译成汇编指令,展示汇编指令和源码的对应关系,以及运行编译产物。支持 clangd、gcc 等多种编译器,支持自定义编译选项和添加外部依赖库。非常适合调试简单代码,或者深入分析编译过程。
const、constexpr、consteval、constinit 等关键字:
constexpr :表达式、函数、变量可以在编译期计算得到结果consteval:函数必须在编译期计算得到结果constinit:变量必须在编译期完成初始化noexpect 关键字。比如为移动构造函数加上此关键字,那么 vector 的 push_back 函数将调用移动构造函数,而不是默认的拷贝构造函数。[[likely]]、[[unlikely]] 修饰分支,提示编译器分支的进入概率。[[assume]] 修饰表达式,提示编译器该表达式在运行时的结果必定为真。Auto FDO (自动反馈优化) 是一种编译器优化技术。它利用程序在运行时的性能数据,分析哪些代码路径被频繁执行,从而优化编译器生成的代码。本质上是利用真实的数据,反过来提高分支预测的成功率。实际场景中,程序的输入会经常变化,对应的代码路径分布也会变化。因此,即使是同一份代码,也需要定期重新运行 Auto FDO。
如多进程、多线程、协程、异步 IO 等,提高 CPU 的利用率。
如使用 SSD、扩大内存等,提升磁盘读写速度。或者增加集群机器数,但是要考虑成本。
缓存并不仅指 CPU 上的 L1 / L2 / L3 缓存。理论上总是可以用速度更快的存储作为慢速存储节点的缓存。比如在内存里维护一个本地文件的缓存,或者使用 redis 作为数据库的缓存等。使用缓存时,要注意为数据设置合理的过期时间,以及选择合适的淘汰算法。
数据库调优的目的是优化数据库访问和查询的耗时,常见的手段有加索引、分库分表等,这里不作展开。
性能优化是每个程序员的必修课。这既需要掌握相关基础知识,也需要有实际操作经验。
建议阅读《CSAPP》等经典书籍,并了解机器指令的原理,以更好地指导性能优化工作。线上服务在不断迭代,需要持续进行性能优化。每次性能优化后,必须通过基准测试和压力测试,验证性能优化的效果,让数据说话。
以上就是本文的全部内容,欢迎交流讨论。
在这篇文章中,我们将学习如何利用 Netlify + Github + Jekyll,快速零成本搭建个人博客。
每个技术人都应该有自己的博客。正如 Github Profile 一样,博客也是一张对外展示的名片。Github 展示了你的开源项目和编码水平,博客则展示了你的思考与技术沉淀。
为什么我不建议选择 CSDN、博客园、竹白等平台,或者语雀、飞书文档等个人知识库呢?一方面,每个平台有不同的调性。读者对你的印象,会受到这个平台其他作者的平均值的影响。有的平台虽然 SEO 做得很好、总是出现在搜索引擎的首位,但内容质量属实不敢恭维。出现在这样的平台上,很难保证读者不会给你的文章预设一个较低的分数。另一方面,这些平台不支持自定义主题,大家都使用统一的样式和排版风格,互相之间基本没有区分度,个人符号很难在其中得到展示。最后,有些平台是封闭的,无法被搜索引擎索引到。
所以,我建议申请一个独特的域名,搭建一个专属于你的个人博客。在这里,我们拥有完整的自主权,可以修改主题样式、监控网站数据、分享只属于你的内容、结交志同道合的朋友。
当然,自建博客也有缺点,比如 SEO 差、访客数量少、缺少交互性等。但对我来说,写博客不是为了获得知名度和商业收入,而是想纯粹地记录和分享。我在搜集资料、解决问题的过程中耗费了不少时间,写一篇博客不仅可以帮助自己理清思路,还可以让知识复用。提高文章的信息量、让博客的内容有长期价值、让每位读者都有收获,这便是写作的意义。
总之,博客是一个值得精心打磨的作品。如果一份简历上附有独立博客的链接,我一定会想点进去看一看。如果你也有这样的想法、希望输出有价值的内容、享受书写的乐趣,那就参考下面的步骤,用 10 分钟的时间搭建一个博客吧。
这一节我们将直接用 Netlify + Github + Jekyll 零成本搭建个人博客。简单介绍一下原理:
<username>.github.io 去访问。唯一id.netlify.app 链接来访问网站,但既然是个人博客,最好还是绑定到一个自己的域名上。参考 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,便可以看到我们的博客首页:

💡 给 serve 命令添加 –livereload 选项,可以在源文件有任何改变时自动刷新页面。
💡 亲测 MacOS 安装 Jekyll 环境比较麻烦,可以考虑使用现成的 docker 镜像,参考这篇文章。
这里我们需要把上一步创建的 Jekyll 项目上传到 Github 仓库。比较基础,就不再赘述了。
💡 这一节只是为了演示 Github Pages 的功能,不建议使用它部署个人博客,推荐使用 Netlify + 自定义域名。
Github Pages 的官方文档有详细教程,下面是摘要:
创建一个名为 <username>.github.io 的代码仓库:

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

如果有独立域名,也可以在上图 ⑤ 配置。
等待几分钟,就可以通过 https://username.github.io 访问博客了。
如果你使用的是 jasper 主题,需要按照下图修改
_config.yml,才能正常加载到 CSS 资源:
访问 https://app.netlify.com,直接使用 Github 账号登录。
选择从 Github 导入项目 → 授予 Netlify 权限 → 安装 Netlify 应用 → 导入博客项目:


等待项目首次构建完成:

然后便可以使用 Netlify 提供的 唯一id.netlify.app 链接来访问博客了。
Netlify 部分功能说明:
部署状态:

域名管理:Site settings → Domain management

为什么要申请独立域名?一方面,域名是我们在互联网上的符号。相比于 Github Pages 的 github.io 和 Netlify 的 netlify.app,个性化的域名有更强的个人色彩,便于读者记忆和分享。另一方面,域名是一个方便的网站定位器。当我们想要从 Netlify 迁移到其他平台时,只需要修改域名的指向记录,而不需要读者重新保存一个新的链接。
申请域名非常简单,只需要选择一个域名服务商、搜索喜欢的域名是否已经被注册、付费。国内的域名服务商有阿里云、腾讯云等,国外的有 GoDaddy 等。域名付费一般以年为单位,首年费用较低,但后续续费价格可能增加。国内注册域名需要备案。
注册域名后,可以参考上面的步骤,将域名指向 Netlify 的博客项目。
静态博客生成器 (Static Site Generator) 不依赖数据库,所有博客内容都以文件的形式存储。静态博客生成器的作用是把 Markdown 格式的文本内容转成静态的 HTML 页面,需要我们自行部署。优点是轻量、易用、访问速度快、可以在本地缓存页面后离线查看。缺点是发布内容慢,需要更新本地文件 → 上传 → 部署,以及插件数量少,需要自行编码集成。
动态博客依赖数据库,博客内容是数据库里的一个条目。优点是使用简单,能在线编写文章,有丰富的插件,自带管理后台。缺点是需要运行在服务器上,部署和维护较为繁琐。一个知名的动态博客框架是 WordPress。
💡 我个人推荐使用静态博客框架,原因是上手简单、成本低、好维护、文章能够本地存档。
以下对比了几个知名的静态博客生成器。
Jekyll:https://jekyllrb.com
Hugo:https://gohugo.io
VuePress:https://vuepress.vuejs.org/zh/
💡 我最终选择了 Jekyll,只是因为喜欢它的主题。从易用性来说,我更推荐 Hexo。
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 搜索就足够了。

💡 建议在开发环境下关闭这些插件,避免不必要的数据污染。
因为 Netlify 的服务器在国外,一开始百度无法索引到我的博客内容。解决办法是在百度站长平台中主动推送网站的 sitemap:

我使用的是 jekyll,需要安装 jekyll-sitemap 插件,这会在构建博客时生成一个 sitemap 文件。之后把这个链接提交到上图的输入框中,过十几天就可以在百度搜索到网站内容了。验证方法是在百度搜索 site: imageslr.com。
除此之外,我的博客就没有做过 SEO 优化了,也没有主动推广过。目前来看,Google 的搜索效果最好、流量最多,百度聊胜于无。每天大约 80 UV。
获取自动优化建议。通过 Google PageSpeed Insights、Web Page Test 等工具。

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

.min.js 文件、去除用不到的 CSS、使用 ImageOptim 优化图片大小等。去年我把博客部署在了腾讯云服务器上,但因为访问量较少,就没再续费了。期间参考这篇文章配置了基于「Gitee + Jenkins + 飞书机器人」的自动部署流程,记录下来,以备不时之需。
最终效果:我只需要往 Github 推送最新的提交,服务器就会自动拉取最新代码并构建,部署成功或失败都会给我发送一条飞书消息。

具体实现:
最后我想说,搭建博客是成本最低的操作,持续输出才是最难的。要多写精品文章、写原创内容。不要发一些可以很容易检索到的内容。提高博客文章的信息量、让博客的内容有长期价值、让每位读者都有收获。
]]>这里直接复用了 Github Discussion,在本文的评论区里可以查看。没有主题,随时记录。欢迎大家分享你的学习方法、工作心得、提效指南等。
]]>GDB 全称 GNU Project debugger,是一个通用的 C / C++ 程序调试器,可以用来深入分析程序的运行过程,或者排查程序崩溃的原因。
GDB 主要有以下几个功能:
在日常工作中,我经常会使用 GDB。比如线上发生 coredump,需要用 GDB 来排查;调试程序时,使用 GDB 打断点,逐行执行,效率远高于加 debug 日志。
GDB 和 Vim 一样,只需要学会几个简单的命令,就能解决大部分问题。但它们就像一把瑞士军刀,有丰富的功能和技巧,只有深入掌握,才能成为效率提升利器。
本文面向的读者是 C / C++ 程序员,主要内容包括 GDB 的基本命令、进阶用法和实践案例。目标是使读者掌握 GDB 的常见使用方法,满足日常开发所需。读者也可以将本文作为 GDB 命令的速查手册,随时查阅。
本文约定:
$,表示在 shell 执行;如果前缀是 (gdb),表示在 GDB 内执行;(gdb) 命令后面的 // xxx 是注释内容,不包含在要执行的命令中。本文在 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 设置断点、逐行运行程序的示例。
编写 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;
}
编译程序,添加 -g 选项,保留 debug info:
$ g++ -g main.cpp -o example
进入 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...
在 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;
逐行执行程序,打印变量 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
step 命令会进入函数,停在函数的第一行 (step into):
(gdb) step
print_foo (v=21845) at main.cpp:5
5 void print_foo(int v) {
backtrack 命令可以查看当前程序的调用栈:
(gdb) backtrace
#0 print_foo (v=21845) at main.cpp:5
#1 0x0000555555555245 in main () at main.cpp:15
continue 命令会执行程序,直到遇到下一个断点。这里没有下一个断点了,整个程序正常退出:
(gdb) continue
Continuing.
[Inferior 1 (process 1308) exited normally]
大部分 GDB 命令都有一个简写形式,一般是命令的首字母,比如:
backtrace → btbreak → bcontinue → cnext → ninfo → i某些命令有相同的前缀,只需要写出前几个能区分的字符,GDB 就可以识别:
(gdb) i w // 无法判断
Ambiguous info command "w": w32, warranty, watchpoints, win.
(gdb) i wat // 可以识别,等于 info watchpoints
No watchpoints.
此外,在 GDB 中如果什么都不输入,直接回车,会重复执行上一条命令。
当应用程序异常退出时,操作系统会生成一个 coredump 文件,记录了程序退出时的所有内存状态。GDB 可以读取这个文件,查看程序退出时的变量值或者寄存器值,但是无法执行程序。即只能使用静态命令,如 p、bt、i。
GDB 也可以直接加载一个二进制程序并执行。在这种情况下,GDB 不仅可以随时查看程序当前的变量值或其他内存状态,还可以控制程序的运行,如设置断点、单步执行、反向执行等。即不仅可以使用静态命令,还可以使用 r、b、c 等动态命令。
在 GDB 内使用 apropos {keyword} 可以模糊查找某条命令:

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

此外,使用 GDB 最好了解一些计算机的基础知识:
部分术语的说明详见附录。
tinfo 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} 切换线程,每个线程有独立的调用栈。
btbacktrace / 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 可以向上层或下层跳转,对应编号增大或减小。
pprint / 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 , e 和 h 的 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)
直接打印:
// 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<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
直接打印:
(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"
使用 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 个历史变量xx 可以查看一个内存地址的值,以指定的格式打印。
(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)
x 和 p 的区别:
传入一个数字,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 ADDRESS,F / M / T 是可选的参数。
F:一个数字,表示输出几个内存单元,默认是 1M:格式化说明符,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
iinfo locals:打印当前栈帧的所有局部变量info args:打印所有函数参数info threads: 打印进程的线程信息info registers: 打印当前线程的寄存器信息info sharedlibrary:打印当前加载的动态连接库info proc mappings:打印地址空间中的内存 map,用来确定某个地址的类型help info:所有 info 支持的命令setset 可以保存一个变量 (convenience variables),方便后续使用:
(gdb) set $foo = *object_ptr
查看所有存储的变量:
(gdb) show convenience
(gdb) show conv // 简写形式
set 命令也可以用于在运行时修改某个变量的值:
(gdb) set foo.bar = true
如果没有调试符号,上述命令将无法查找到变量的地址。可以手动修改变量所在的内存位置:
set (char)0x7e864a2b = 1
修改变量值的使用场景:
b设置断点:break POINT,简写是 b
(gdb) b foo.cpp:14
设置断点的方式有多种:
b,没有任何参数b functionb filename:functionb linenum,在当前文件设置断点b filename:linenum,在特定文件设置断点b +offset / b -offset,在当前栈帧执行位置的前后设置断点删除断点:clear
(gdb) clear foo.cpp:14
clear 的语法和 break 相同,需要指定要删除的断点的位置:
clear:删除当前执行位置上的所有断点clear function、clear filename:functionclear linenum、clear filename:linenumdelete:删除所有断点,简写是 d设置临时断点:tbreak。参数同 break,命中一次后就会自动删除。
停用断点: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
watchGDB 可以监控一个变量,直到它被修改时才触发断点:
(gdb) watch foo
(gdb) watch bar.var
如果想在变量被读取时中断,可以使用 rwatch 或 awatch:
rwatch:仅当变量被读取时终端awatch:当变量被读取或写入时中断查看所有 watchpoints:
(gdb) info watchpoints
禁用 / 删除 watchpoints 的命令同 break。
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")
commands可以通过 commands 命令给断点绑定一组自定义命令,当命中断点后会自动执行,如打印变量的值,或者设置另一个断点。
语法:先指定要绑定的断点编号,然后输入自定义命令,最后以 end 结束。例如:
(gdb) commands 1
(gdb) p foo
(gdb) end
断点编号可以通过 i b 或 i 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
n / s / c / fin / urun / r:运行程序,直到遇到第一个断点或者运行结束start:启动程序,临时停在 main() 的第一行next / n:逐行执行,如果某一行是函数,不会进入到函数里,而是会执行完整个函数 (step over)step / s:逐行执行,如果某一行是函数,会进入到函数的第一行 (step into)continue / c:从断点位置继续执行,直到遇到下一个断点或者运行结束finish / fin:执行到函数结束,停在 return 后的下一条语句until / u:
until 会跳到 for 循环体的下一行break,等价于 tbreak + continuequit / 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 命令就可以查看当前终端的序号。sourceGDB 可以使用 Python API 来实现自定义脚本。脚本可以直接写在 ~/.gdbinit,或者写在一个单独的文件中,然后通过 source 命令加载。
网上有很多可用的插件,比如 STL views 提供了一些打印 STL 容器的命令。
TODO 待补充
调用栈 (call stack) 被分成若干个栈帧 (stack frame),每个栈帧包括和一次函数调用相关的所有数据:函数的参数、函数的局部变量、以及函数的返回地址等。
程序启动时只有一个栈帧,即 main 函数,又称初始栈帧或最外层栈帧。每次函数调用都会创建一个新的栈帧,每次函数返回时一个栈帧也会被弹出。当前执行的函数所对应的栈帧又称最内层栈帧。
GDB 给每个栈帧分配了一个数字,最内层栈帧的编号是 0,外层栈帧依次加 1。可以通过 bt 命令展示所有栈帧,通过 f 命令加上编号进入到对应的栈帧。
当进程崩溃时,操作系统会把进程当前的所有内存和寄存器状态信息保存到 core dump 文件中。Core dump file 是一个二进制文件,需要配合 debug info 来赋予其含义。GDB 可以读取 core dump 文件,协助分析进程崩溃的瞬间发生了什么。
可能会产生 core dump 文件的场景:
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 学习笔记
]]>最近,我在 VS Code 中深度使用了 Vim 插件。Vim 快捷键基本代替了所有的鼠标操作,极大提升了我的编码效率。因此,我做了一期视频分享。
在下面的视频中,我分享了 VS Code 和 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,打开一个教程文本文件,尝试某个快捷键或命令。
vimtutor 即可打开,mac 系统下是中文文档
这里顺便再推荐一些可视化学习资源:
- 数据结构与算法:Data Structure Visualization、Visualgo
- 正则表达式:regexper
- Git:Learn Git Branching(强烈推荐)、Visualizing Git
vim 中有一些术语:
d、修改 c、拷贝 y、查找 f 等。verb 后面需要跟一个 motion,表示该操作生效的范围。l、向下一行 j、向右一个单词 w 等。本文中有时候也称其为 “range”。💡 如果你对 vim 的语法感兴趣,可以阅读这一篇文章:https://learnvim.irian.to/basics/vim_grammar
标题的前缀:
[M]:移动类,move[E]:编辑类,edit[F]:查找类,find: 可以执行命令i 进入插入模式,此时可以输入文本;按下 <ESC> 退出插入模式💡 除了以上两个模式,vim 还有 visual mode,用来选择一个范围的文本。
建议配置 jj 退出插入模式,这样左手不需要移动到最左上角去按下 <ESC>。在标准模式下执行:
imap jj <Esc>
可以将这条命令写到 vim 的配置文件中。我实际上是把 jj / kk / jk / kj 都配成了退出插入模式。
:<command>按下 : 后输入命令,按回车执行。如 :set number 会显示行号。
:q / ZZ:q / :quit:退出 vim,不作任何改动:q!:退出 vim,丢弃已有的改动:wq:保存更改(write)并退出(quit)vimZZ:等同于 :wq,这个快捷键输入比 :wq 更快,注意是大写 Z:w / :w <filename>:w:保存更改:w <filename>:保存到一个新的文件h / j / k / l使用 h、j、k、l 而不是 ←、↓、→、↑,这能够避免将手指移出键盘热区再移回来。如果有必要的话,甚至可以禁用方向键,来强制自己使用 h、j、k、l:
map <Left> <Nop>
map <Right> <Nop>
map <Up> <Nop>
map <Down> <Nop>
gg / Ggg:前往第一行1G:同 ggG:前往最后一行nG、:nnG:前往第 n 行。1G 可以前往第一行。如果希望在 vim 中显示行号,可以在标准模式下执行 set number 命令,也可以将这条命令写到 vim 的配置文件中。:n 也可以前往第 n 行,比如 :123前往第 123 行。w / ew:向右移动一个单词,光标将落在下一个单词的首字符e:向右移动一个单词,光标将落在当前一个单词的最后一个字符在这里,一个单词的定义是连续的「数字+字母+下划线」,或者连续的「特殊字符」。比如 hello, world!!! 里包含 hello、,、world 和 !!! 四个单词。按 w 跳转时,会跳过单词后面的所有空白字符,落在下一个单词的开头。示例:
↓ 光标在这里
Hello, world!
↑ 按下 w
↑ 按下 e
Hello, world!
↑ 按两下 w
↑ 按两下 e
类似的还有 W / E,区别在于这两个快捷键对单词的定义是连续的「非空字符」,即以空格作为单词的分界线。比如 hello, world!!! 里,包含 hello, 和 world!!! 两个单词。
bb 向左移动到前一个单词的首字符,相当于是 w 的逆操作。b 取 backwards 首字母,「单词」的定义同 w。
2b 向左移动两个单词,nb 向左移动 n 个单词。
B 向左移动一个单词,将「空格」作为单词的分隔符(同 W、E)。
gege 移动到前一个单词的末尾,gE 将空格作为单词的分隔符。
0 / $ / _ / ^0:前往第一个字符,可以理解成是第 0 列$:前往最后一个字符_:前往第一个非空字符,这在编写 python 等有缩进的代码时很有用。^ 等价。x / Xx:删除当前字符,等同于 <Delete>X:删除前一个字符dw「单词」的定义同 w,单词后面的任意多个空格将被删除。
类似的还有 dW,删除下一个空格前的单词。
dd略。
ai 在当前位置前面插入(insert),a 在当前位置后面插入(append)。
I / A略。
o / O插入新的空白行。
~ / gu / gU~:将光标下的字母改变大小写。3~ 是将光标开始的 3 个字母改变大小写。
gu<motion>:指定范围的字母变成小写。比如 guw 是后一个单词全变成小写,guj 是当前整行改成小写。gU 则是改成大写。guu:将当前行的字母改成小写。gUU 是改成大写。guiw:将光标所在的单词改成小写。详见下文 inside / around。f<target>ft 移动到下一个 t 出现的位置,f2 移动到下一个 2 出现的位置。f 取 forward 的首字母。
F 类似于 f,向前移动到前一个指定字符。
t 类似于 f,只不过光标会移动到下一个指定字符之前;T 类似于 F,只不过光标会移动到前一个指定字符之后。t 取 until 的含义。
示例:
↓ 光标在这里
Hello, world!
↑ fo
↑ fr
↑ Fh
↑ to
↑ Th
u / <Ctrl> + ru:撤销(undo)<Ctrl> + r:重做(redo)n<action>Vim 中几乎所有操作都可以通过一个 n 前缀来重复 n 次:
5h 向左移动 5 个字符。5j 向下移动 5 行。2w 向右移动两个单词,等同于按两次 w。2x 删除两个字符,2X 向左删除两个字符。3fa 在当前行查找第 3 次出现的 a,等同于按 3 次 fa。2u 撤销前两步操作,等同于按两次 u。2<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> 两种模式,比如 d2w 和 2dw 都是删除后两个单词。但在语义上有区别:d2w 表示删除 2w 范围的内容,而 2dw 表示 dw 命令重复 2 次。
💡 d / c / y 等 verb,都支持上述模式。比如 c2w 修改后两个单词,y2j 复制下两行。
d / D 等不同的方向:
x 向右、X 向左p 向下、P 向上o 向下、O 向上f 向右、F 向左更严格的条件:
w 将特殊字符作为独立单词,W 只将空格作为单词分隔符e / E、b / 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 查找上一个。rr:再按下任意键,替换(replace)当前字符,等同于 x + i。示例:
↓ 光标在这里
Helle, world!
# 先按 r,再按 o
Hello, world!
↑ 光标在这里
RR:替换连续的多个字符,按下 <Esc> 可以退出替换模式。
cc 取 change 的首字母,这个命令的便捷之处在于将「删除操作」和「进入编辑模式」合二为一,可以少按一个键。
cw:更改下一个单词,等同于 dw + ic2w:更改后两个单词,等同于 d2w + ic$:更改从当前位置到行结束的所有内容,等同于 d$ + i和 d 一样,c 也可以和任意光标移动的操作符结合,来更改一个范围的内容。
ss 等同于 x + i。
S / ccS 等同于 dd + o。cc 也可以删除整行,并进入编辑模式。
DD 等同于 d$。
CC 等同于 c$,或者 d$ + a,或者 D + a。
v / V / <Ctrl> + v按下 v 进入可视模式(visual mode),然后移动光标以选择文本。可以针对选中的文本执行任意操作 (verb),比如:
y 可以复制选中的文本,再移动到别的位置按下 p 粘贴这些文本d 可以删除选中的文本
按下 ctrl + v 可以进入 Visual Block Mode,选择一个矩形块里的内容:

按下大写 V 可以选中整行。常见的使用技巧:
V 选中整行,按 j 向下选中多行,然后 y 复制。V 选中整行,按 j 向下选中多行,按 < 向左缩进,按 . 继续缩进。V 选中整行,按 j 向下选中多行,按 = 格式化。ywy 取 yank(复制)的首字母。yw 复制下一个单词,p 可以将其粘贴(put)到指定位置。
事实上,y 和 c、d 一样,可以和任意光标移动的操作符结合,来复制一个范围的内容。比如 y$ 将复制当前位置到行末尾的全部内容,yh 将复制光标前面的字符,yG 复制光标所在行到最后一行的所有内容。
最后,yy 复制当前行,可以和 dd 一起理解 —— dd 删除一整行,快捷键重复表示操作的是一整行,不管光标位置在哪里。第二个 y 和 d 并没有语义上的含义。
yy / 2yy / y2jyy 复制当前行,p 粘贴到目标位置。
nyy 复制当前行往下的 n 行,包括当前行。
ynj 也是复制当前行往下的 n 行。比如 y2j 会复制当前行和下一行。
p / P如上所述,p 粘贴到目标位置。
通过 dd 删除某一行后,也可以按下 p,将删除掉的内容放置到当前光标位置下一行。注意这里是「放置」而不是「粘贴」,因为 dd 将被删除的行保存到了缓冲区,而 p 其实是将缓冲区的内容放置到当前位置,所以 p 取 put 的首字母,而非 paste。
同理,yy 将当前行保存到缓冲区,但不删除。这样 yy + p 就可以实现“复制-粘贴”的操作。
大写 P 粘贴到上一行。
zt / zb / zzzt 把当前行置于屏幕顶端。z 字取其象形意义,模拟一张纸的折叠变形。t 取 top 的首字母。
zz 将当前行置于屏幕中央。zb 将当前行置于屏幕底端,b 取 bottom 的首字母。
< / >选中文本后,< 是向左缩进,> 是向右缩进。
=按 = 可以将选中的文本格式化。这个命令可以配合 V 使用 —— 通过 V 选中多行,然后按 = 格式化选中的文本。
di* / da*对于 Vim 的删除命令 d,还有一类比较常用的操作是以 i (inside 或 inner) 和 a (around) 为后缀的命令,用于删除以当前光标所在的语法元素内部或周围的字符。比如:
diw 和 daw:前者删除当前光标所在的单词,后者会删除当前光标所在的单词与后面的空格。di( 和 da(:前者会删除括号内的内容,后者还会删除括号本身。类似的还有di[、da[、di{、da{等。dib 和 dab:等价于 di( 和 da)。b 表示 bracket。di" 和 da":前者删除双引号内的内容,后者还会删除引号本身。dit 和 dat:修改 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 命令可以大大提高操作效率,尤其是在编辑代码时。
/<pattern>/ 从光标所在位置向后查找关键字,n / N 查找下一个 / 上一个匹配的位置。
? 向前查找,不过很少使用。如果想向前查找的话,使用 / + N 就可以了。
q/、q? 可以列出 /、? 的查找历史,上下选择,按 i 编辑,回车执行,:q退出。
<pattern> 可以是正则表达式,比如 /vim$ 查找位于行尾的 vim。查找特殊字符时需要转义,比如 /vim\$ 查找 vim$。
在查找模式中加入 \c 表示大小写不敏感查找,\C 表示大小写敏感,比如 /foo\c 会查找 foo、Foo 等。默认是大小写敏感,可以执行 :set ignorecase 或写入配置文件设置大小写不敏感为默认的查找模式。
查找相关命令:
set ic // 等价于 set ignorecase
set hls is // 高亮匹配项
nohlsearch // 移除匹配项的高亮显示
* / #示例:
↓ 光标在这里
Hello, world!
此时按下*,将向后查找 Hello 这个单词。按下 # 是向前查找。
%% 在匹配的括号之间跳转。需要将光标放在 {}[]() 上,然后按 %。 如果光标所在的位置不是 {}[](),那么会向右查找第一个 {}[]()。
<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中设置。
:{range}s/{old}/{new}/{flag}:s(substitute)命令用来查找和替换文本。语法如下:
:{range}s/{old}/{new}/{flag}
表示在指定范围 range 内查找字符串 old 并替换为 bar,flag 说明了替换模式,如只替换首次出现、或全部替换。
作用范围分为当前行、全文、行范围、选区等:
:s/foo/bar/g%,如 :%s/foo/bar/gn,m,如 :5,12s/foo/bar/g 表示 5~12 行.,+n,如 :.,+2s/foo/bar/g 表示当前行与之后 2 行替换模式:
:%s/foo/barg:全局替换,替换每次出现(global),如 :%s/foo/bar/gi:忽略大小写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 的宏提供了将一系列操作记录下来然后重复执行的机制。它可以大大提高重复性的操作效率。使用宏的步骤如下:
q 键,然后再按下一个字母 (如 a,这是宏的名字),开始录制宏。这时会在状态栏显示 recording a。在录制时,执行要重复的操作,包括移动、删除、插入等等。执行完操作后,按下 q 键结束录制。@ 键,然后在输入框中输入之前记录宏的字母 (如 a),按下回车键即可执行宏。也可以连续执行多次,比如执行 10 次,只需在 @a 后面加上 10 即可。另外,按下 v 键进入 visual mode 选中多行,可以批量针对多行文本执行宏。使用 :reg 命令可以查看所有已经保存的宏。如果在执行宏时出现错误,可以通过使用 :debug 命令进入调试模式。
:!<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 这些键位并不经常用,就可以配成其他操作。
Vimium,通过类似 vim 风格的命令操作浏览器窗口。
Vim 插件,将 VS Code 的编辑器转为 vim 模式。最近高频使用,V、ciw、di{ 等命令显著提升了编码效率。
编辑 settings.json 文件,配置 jj 替换 ESC:
"vim.insertModeKeyBindings": [
{
"before": ["j", "j"],
"after": ["<Esc>"]
}
]
在终端输入 set -o vi 可以切换到 vim 模式,按下 ESC 就能进入 vim 的 normal mode,修改终端命令的时候很好用。推荐将其写入 .bashrc 或 .zshrc 等配置文件。
在 zsh 中,也可以通过快捷键 Ctrl + x, Ctrl + e 打开 vim 编辑当前命令。
掌握「入门」一节中的快捷键,基本可以满足大部分使用场景。如果想进一步提升效率,那么「进阶」一节中的快捷键也值得学习。「高级」一节的内容,由于我还没有将 vim 作为主力开发工具,尚未深入研究,所以等以后有机会再补充。
可以在其他编辑器中配合 vim 插件,来培养 vim 的使用习惯。将 Chrome vim 化,也能体验到 vim 带来的酷炫与极客感。
最后,在实践中学习命令!如果只是阅读而不尝试,那么很快就会遗忘。
希望本文对你有帮助。
仅作为正文的补充,记录一些可能有用的快捷键。
| 快捷键 | 作用 |
|---|---|
^ |
移动到当前行第一个非空字符 |
<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 软件中都可以使用,比如
man、less、tmux(需要先进入滚动模式)
| 快捷键 | 作用 |
|---|---|
J |
将当前行和下一行用空格连成一行 |
Jx |
将当前行和下一行直接连成一行,相当于在下一行的行首按 <Backspace> |
di( |
删除括号内的内容 |
da( |
删除括号内的内容,包括括号本身 |
ci( |
删除括号内的内容,同时进入编辑模式 |
ddp |
上下两行交换,实际上就是 dd + p |
| 快捷键 | 作用 |
|---|---|
:help |
查看帮助文档 |
:help :{command} |
查看一个具体命令的帮助文档,如 :help :q 查看 :q 的帮助文档 |
^y$ |
复制一行 |
ggyG |
复制整个文件 |
q: |
查看历史命令,上下选择,按 i 编辑,回车执行,:q退出 |
可以在 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 学习笔记
]]>现在市面上有太多的效率工具,我们很容易陷入一个误区:喜欢体验新鲜的工具,但没有明确的使用目的,仅仅是为了好玩,或者以为能提高生产力,到头来却发现是在浪费时间。因此在选择工具时,我参考了一些通用思维 (📥 收件箱 🔖 工作区 🪒 奥卡姆剃刀),先思考自己需要哪些功能,再去寻找提供这些功能的工具,在不同场景下构建了类似的工作流 (Workflow)。这样可以降低系统的复杂度,减轻工具带来的认知负担。
本文分享了我在日常工作场景中使用的一些效率工具,操作系统是 macOS。
macOS 的初始化可以参考 💻 从零开始配置高效 Mac 开发环境。
尽管我是一名程序员,但实际上我用浏览器的时间比写代码的时间还长,60% 以上的工作时间都是在 Chrome 浏览器中度过的:

图:Chrome 的使用时间远超写代码的时间
浏览器已经快成为一个新的操作系统,无论是看文档、查资料、做表格、写周报,都离不开它。这导致我们常常会打开很多个标签页。据我观察,身边大多数同事的 Chrome 浏览器都是这样的:

同时打开这么多的标签页,带来的问题也很明显:
对于第一个问题,可以安装 Tab Suspender 插件解决,这个插件可以自动暂停长期未查看的标签页,节省内存。对于第二个问题,我安装过一些标签页搜索插件,Chrome 后来也提供了内置的标签页搜索功能,但这些方式都需要手动输入标签页的标题,很不方便。

图:Chrome 内置的标签页搜索功能
我也尝试过使用 Chrome 自带的标签页分组功能,临时折叠一些标签页。但这个功能有点鸡肋:各个分组默认放在同一个窗口中,分组间的界限不明显,同时展开多个分组时,标签页还是被挤的只剩下个图标,而且 Chrome 关闭后分组信息也没了。

图:Chrome 内置的标签页分组功能
我用 🔖 工作区 思维解决了「浏览器标签页管理」的问题。
工作区思维的第一个要点:完成特定任务的场所、一系列关联资源的集合。我将 Chrome 分成多个窗口,每个窗口是一个“工作区”,包含和某项工作相关的全部标签页。通过将不同工作的上下文独立开来,可以减少混乱、提升注意力。
但是多窗口也带来一个问题:不同窗口间切换比较麻烦。Mac 系统的 Command + Tab 快捷键无法在相同应用间切换,Command + ` 快捷键可以在同一个应用的不同窗口间切换,但没有预览界面。因此只能激活调度中心、肉眼判断每个窗口的内容是什么、然后选择一个窗口。

图:Mac 触控板四指上划,打开调度中心,在多个 Chrome 窗口间切换
安装 AltTab 插件可以完美地解决上述问题。这是一个即装即用的 Mac 窗口切换增强工具,按下 Command + Tab / Command + ` 切换窗口时可以显示缩略图。详细配置方法见这里。

图:安装 AltTab 之后,按下 Command+` 切换窗口时,会显示缩略图
工作区思维的第二个要点:自动保存、用完即走、一键恢复。上面这套工作流的问题在于:Chrome 退出后,所有分组信息会全部消失。我习惯在周末关闭工作相关的窗口,周一再重新打开。有没有一个工具,能够自动保存我每个窗口的页面、在关闭后也能一键恢复?
经过一番搜寻,我找到了 Workspaces 插件。尽管这是一个比较小众的插件,但是它和工作区思维完美契合:允许将多个标签页创建为一个工作区、自动保存当前工作区中打开的标签页、在重新打开工作区时自动恢复。
我将常用的场景、进行中的工作都保存成了工作区:

图:我的工作区列表
有了这个插件,我不需要再同时打开很多个标签页或窗口,而是可以根据当前关注的事项,按需打开工作区。当我需要处理某项工作时,打开对应的工作区;处理完之后,直接关闭整个窗口。随用随开、用完即走,这极大限度地降低了干扰,减少了上下文切换的开销。
推荐的工作流:
Alt + w。Cmd + ` 快速切换窗口。


如何在地址栏搜索标签页:
输入标题或 url 的内容,点击“切换到标签页”:

书签管理也是浏览器一个很重要的话题。我们会把任何可能有用的、或者感兴趣的网页存成书签,但往往是收藏的时候很顺手,想用的时候却找不到。下面是我解决这个问题的方法。
Chrome 的地址栏支持搜索书签和历史记录。输入标题或 url 中的关键字,会加粗显示:

图:在地址栏搜索关键字,会在标题和 url 中加粗显示
因此,可以为每个书签设置一个有意义的名称。当需要查找一个书签时,直接在地址栏输入几个关键字,比先思考它属于哪个类别、再去查找对应的文件夹要更方便。
我采用[平台]名称 的命名方式,比如 [Gitlab]imageslr/blog、[TCC]ad.engine.api。这里可以配合 TabModifier 插件,使标签页的标题和书签名称一致。
书签名还可以添加一些辅助搜索的 SEO 短语,比如 性能平台-云服务 可以修改为 性能平台-云服务|golang pprof|profile|服务性能优化|内存泄露排查:

图:添加一些描述页面功能的、合乎直觉的、在搜索时很容易能回想起来的短语
当书签名足够有信息量时,我们甚至不需要书签栏,直接在地址栏搜索关键字就能打开想要的书签。事实上,我在使用浏览器时,书签栏就始终是隐藏状态。
💡 这里再推荐一个 Chrome 插件:Holmes。安装后,在地址栏输入 * 再按 Tab,就能搜索书签了。
不要把书签直接保存在书签栏上,而是要放在文件夹里。书签的标题会占用书签栏的空间。
不需要创建层层嵌套的文件夹。一般来说,在书签命名良好的情况下,我们可以很快搜索到想要的内容。因此,书签的文件夹只需要简单的划分,粒度可以粗一些,层级可以扁平一些。附录是我的书签分类方式。
许多书签实际上是”参考资料“ —— 或者是对某项工作有用的参考文档,或者是一些学习资料,又或者是一些感兴趣的文章。我们需要定期整理书签栏,将这些”参考资料“移动到别处:
总之,”参考资料“应当移动到特定的上下文 中,而不是放在书签栏里石沉大海。书签栏只保留那些需要经常打开的、真正有用的页面,减少干扰,易于维护。
Spotlight 是 Mac 系统内置的一个快速搜索工具。市面上有一些类 Spotlight 工具,提供了不输于原生 Spotlight 的搜索功能、丰富的效率工具、以及高度的自定义能力。最常见的是 Alfred、uTools、Raycast,网上有很多介绍这三个工具的文章,此处不再赘述。
个人认为,这类工具提升效率的关键在于:(1) 多用键盘,少用鼠标;(2) Don’t Repeat Yourself,通过自定义配置,减少重复操作。下面会举例说明。
我使用的是 Alfred。它的功能很全,插件丰富,就算不折腾,默认功能也已经足够好用。下面罗列了一些我常用的功能。
💡 在 2023 年的今天,我更推荐使用 Raycast。它涵盖了 Alfred 的几乎所有功能,但界面更美观、更易用。附录是 Raycast 和 Alfred 的对比。
Alt。这样一只手就能激活 Alfred。Shift 预览:Preferences - Features - Previews - Quick Look (取消勾选)。这个预览功能其实没啥用,还很容易误触。👎 鼠标移动到 Dock 栏,点击图标。
👍 激活 Alfred,输入 App 拼音的前几个字母,回车。
操作鼠标是一个很低效的动作。每次都需要右手先离开键盘、找到鼠标、移动和点击、再把手放回键盘,重新校准手指位置;这个过程中,眼睛还必须配合鼠标指针的移动。
建议使用 Alfred 充当 App 启动器。输入 App 名称 (拼音或首字母) 即可启动,双手不需要离开键盘,速度更快、更方便。

图:使用 Alfred 查找 App,按 Cmd + n 打开
此外,还可以为常用的 App 配置全局快捷键,便捷切换可见状态。配置方法见 Preferences - Workflows - 右下角加号 - Getting Started - Hotkeys。比如我把 Alt+Q、Alt+E、Alt+F 分配给了提醒事项、飞书和微信。

图:配置一个简单的 workflow,就可以通过快捷键显示 / 隐藏 App
👎 使用两个工具,分别管理剪贴板历史和代码片段,资源占用大、操作流程长。
👍 使用 Alfred 解决所有问题。
Alfred 内置了剪贴板历史工具,非常好用,且资源占用小。我用它替换了 iPaste。配置方式:
Cmd + Shift + VKeep Plain Text、Keep Images 和 Keep File List 以同时保存文本和文件Auto-paste on return,这样按下回车后就会自动粘贴这之后就可以通过快捷键查看历史记录了。上下键选择某条记录,回车粘贴,也可以通过 Cmd + 数字 直接选择;支持输入关键字搜索。

Alfred 剪贴板工具的最赞之处在于能够和 Snippets 联动。
Alfred 内置了一个 Snippets 管理工具,可以创建多个清单来管理自己的代码片段:

我把经常执行的一些命令保存成了代码片段,这之后就可以直接在剪贴板历史里查看了,也可以根据关键字搜索代码段的名称或内容:

剪贴板历史的内容,可以直接保存到 Snippets 里。只需要呼出剪贴板历史工具,选中一行,然后按 Cmd + S 快捷键。强烈推荐使用这个功能,大幅降低录入成本。
Snippets 的详细使用说明见 Alfred 官网。
👎 在 Finder 中手动查看每个文件夹,或者使用 Finder 的搜索功能。
👍 激活 Alfred,输入文件名,回车。
Alfred 的搜索功能很强大。只需记住这两个命令:
空格 + 文件名:按文件名搜索,支持拼音。in + 字符串:按文件内容搜索。
图:空格 + 文件名,搜索文件

图:in + 文件名,搜索文件内容
选中搜索结果后,按 Enter 打开文件,按 Command + Enter 打开文件所在的文件夹。
此外,还可以自定义搜索过滤器,获得更精确的搜索结果。比如我经常会搜索自己的笔记,格式都是 markdown,于是便配置了一个只搜索 .md 文件的 workflow。配置方法见附录。

图:自定义 Workflow,只搜索 markdown 文件,既能搜索文件名,也能搜索文件内容
👎 打开浏览器 - 进入搜索页 - 点击搜索栏 - 输入搜索内容 - 回车
👍 激活 Alfred - 输入搜索内容 - 回车
Web Search 是 Alfred 的一大特色功能。在 Alfred 输入要搜索的内容、回车,就可以立刻打开搜索结果页。
Alfred 内置了很多搜索引擎 (Preferences - Features - Web Search):

使用时,需要输入搜索引擎的 Keyword,然后在空格后输入要搜索的内容,例如 google something、gmail something。
可以将常用的搜索引擎设置为默认结果,这样就不需要输入关键字了。配置路径在 Preferences - Features - Default Results - Fallbacks - Setup fallback results:

图:新增默认搜索结果
我设置的 fallback results 是 Google 和公司内网搜索:

图:输入任何内容,都可以在 Google 或内网搜索,回车或 Command+2 打开结果页
Alfred 还可以自定义 Web Search。我们每天除了使用 Google 等搜索引擎,还会在公司的许多内部平台搜索,比如搜索代码库、搜索机器 IP、搜索内网等。这些平台的搜索功能都可以配置为自定义 Web Search,从而省去和浏览器的交互。配置方法见附录。
格式固定的 url 也可以配置为 Web Search。比如:
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 是 Alfred 的核心功能。Workflow 类似于 iOS / Mac 的「快捷指令」,通过可视化的方式串联一系列操作,之后用一个命令直接执行整个流程。很多工作中的重复性操作都可以配置为 Workflow,节省时间,提高效率。
网络上有许多 Alfred Workflow 资源:
我常用的是这几个:
Don’t Repeat Yourself。多观察自己有哪些重复的操作,尝试把它配置成 workflow。举个例子,我经常需要执行一个命令,里面包含了 1.0.1 这样的版本号,版本号每次执行都不一样。一开始,我是手动填充版本号。后来配置了一个 Workflow,只需要输入版本号,就能自动拼接完整命令,并复制到剪贴板,非常方便。

图:输入 curl_code,再输入版本号,就能将命令复制到剪贴板,可以直接去粘贴运行
Alfred Workflow 的配置教程可以在 Alfred 官网 查看。Alfred 中也内置了许多示例教程,见 Preferences - Workflows - 左下角加号 + 。
每个人都需要一个知识库。知识库最大的意义是充当大脑外存,帮助我们管理知识,并在需要的时候快速查阅。一方面,我们学习的新知识,如果不经常使用,很快就会忘记,因此需要整理在知识库里,以便日后复习。另一方面,我们总是会遇到各种问题,每次都去 Google 无疑会浪费时间,如果记录在知识库里,下次就可以直接在知识库检索,事半功倍。
在大一时,我就开始有意识地搭建个人知识库,至今已经积累了 1200 多篇笔记。尽管这些笔记里有很多都是偶尔才会打开,但因为都是用自己熟悉的方式记录的,所以往往扫一眼就能回想起完整的上下文,节省了从许多原始资料中筛选重点内容的时间。
我的知识库管理应用是 MWeb。下面是我的一些使用心得。
Markdown 是一种用来写作的轻量级标记式语言,它使用简洁的纯文本格式来编写文档,可以转换成有效的 HTML 或 PDF 文档。Markdown 最重要的设计是易读易写 —— 语法轻量化;纯文本格式也能够直接在字面上被阅读。
Markdown 不需要像 Word 那样先选中文字、再点击工具栏的图标,常见的排版都可以用键盘完成。使用 Markdown 写作,我们可以专注于内容本身,更流畅地表达自己的思路。
每个程序员都应该学习 Markdown、使用 Markdown。Markdown 的语法十分简单,常用的标记符号不超过十个,几分钟就能掌握。目前许多网站都支持 Markdown 语法,如 Github、少数派、石墨文档、飞书文档等。我的博客也是用 Markdown 写的。
我会优先选择支持 Markdown 完整语法的笔记应用。目前,我使用 MWeb 管理自己的所有笔记;当需要输出长文时,我会配合使用 Typora。
知识的输入是构建个人知识库的重要一环。我们经常会在各种场景下遇到碎片化的信息:或者是与同事交流时,了解到一个业务背景;或者是看某篇文档时,发现一个名词解释;或者是查一个问题时,学到一个新的工具…… 这些知识都是有用的,但我们很少有时间可以停下手头的工作,去整理这些内容。这时,一个触手可得的随手记工具就显得尤为重要。
随手记工具是知识的缓冲区、收件箱。任何时候,只要遇到有用的知识,就随手记录下来。每隔一段时间,再把随手记的内容整理到个人知识库中。将知识管理分为「收集」和「整理」两步,可以简化知识录入的成本,在不打断当前工作心流的前提下,捕捉每个重要信息。
随手记工具的核心在于快速。我使用的是 MWeb 的 快速笔记 功能,按下快捷键,就可以记录 Markdown 内容。随手记工具也可以是和知识库分开的,比如你也可以使用 Mac 的 快速备忘录,或者 Drafts 等任何趁手的工具。重点在于定期整理、定期清空随手记中的内容。
💡 进一步阅读:📥 收件箱思维 - 信息管理
个人知识库的可搜索性很重要。如果每次查阅时都很不方便,那么知识库就失去了作为大脑外存的意义。可以从以下几点来提升知识库的可搜索性:(1) 结构;(2) 标题和内容;(3) SEO 关键词。
当我们在图书馆查找一本书时,可以根据图书分类法,很快定位到一本图书。同理,为知识库设置合理的文件夹层级,也可以帮助我们快速定位一篇笔记。
知识库的结构没有统一的规范,符合个人认知即可。下面是一个示例:
这里我的建议是:如非必要,勿增实体。不要在一开始就设置非常详细的层级结构,这样只会加重选择困难。前期最好只设置必要的文件夹,层级尽量扁平,比如「工作」「个人」「学习」等。之后当笔记的数量积累到一定程度时,再拆分成更细粒度的分组。总之,渐进式地迭代我们的知识库系统,而不是追求一步到位。
当我们搜索一篇笔记时,往往想到的都是一些离散的关键词,而不是一句连续的话。因此,可以在正文中添加一些辅助搜索的 SEO 关键词。思考一下,当你看到这篇笔记时,最先想到的是哪些词语,这些词语就可以作为它的关键词。
关键词的格式要特殊一些,以便和正文内容区分,比如我设置的是 [XXX]:

图:笔记示例,上面的 [文件描述符] [stderr] 等就是 SEO 关键词
搜索时,可以组合搜索关键词和正文内容:

图:使用 mweb alfred workflow 搜索 MWeb 中的笔记
最后,我们讨论应该如何选择一款知识库应用。
我的知识库应用是 MWeb。对我来说,它的优点是:
.md 文件 (可扩展性)缺点是:
类似的知识库应用还有思源笔记, 支持本地文档库、双向链接、所见即所得。此外还有 Web 版的知识库应用,如飞书云文档、Notion 等。这些应用都支持 Markdown 语法,功能上各有优劣,请按实际需求选用。如果读者有推荐的知识库应用,也欢迎评论区补充。
SEO 关键词可以增加信息量,提升检索效率。下面这些位置可以添加 SEO 关键词:
←、→Alt + ←、Alt + →⌘Cmd + ←、Cmd + →,或者 Ctrl + a、Ctrl + eShift + ←、Shift + →Shift + Alt + ←、Shift + Alt + →Shift + Cmd + ←、Shift + Cmd + →← BackspaceAlt + ← BackspaceCmd + ← BackspaceAlt 操作单词、Cmd 操作整行、Shift 选中文本。Cmd + A (全选)、 Cmd + Z (撤销)、Cmd + Shift + Z (重做)Cmd + B (加粗)、Cmd + I (斜体)、Cmd + U (下划线)Cmd + Shift + [ 、Cmd + Shift + ]Cmd + Tab、Cmd + Shift + TabCmd + `Cmd + n (n=1,2,3…) 来切换标签页,我给 VS Code 也配置了同样的快捷键,按 Cmd + n 可以切换当前打开的源文件。💡 Mac 的某些应用程序没有提供快捷键配置入口,这种情况下可以在系统偏好设置中更改,详见附录。
Cmd + T 时,在当前标签页的右边新建标签页

.md 文件。这里可以把一个 .md 文件拖进去,会自动设置文件类型:



配置 Web Search 的方法很简单。以 github 为例,首先在搜索框中输入内容,回车:

然后观察搜索结果页的 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):

然后就可以使用了:

同理,百度搜索的 Search URL 是:
https://www.baidu.com/s?wd={query}
Google 的 Search URL 是:
https://www.google.com/search?q={query}
评论区里会不断更新我日常使用的 websearch 配置。
本文提到的所有 Alfred 的功能,Raycast 都有:
其他方面:
因此,建议使用 Raycast 代替 Alfred。
Raycast 也可以直接替换某些系统插件:
以 Typora 为例。点击菜单栏,可以看到这样的操作列表,每个操作标题后面都有对应的快捷键:

进入「系统偏好设置 - 键盘 - 快捷键」,选中「App 快捷键」:

点击 + 号,选择应用程序,输入菜单中的操作标题,输入自定义快捷键,就可以覆盖应用程序的默认快捷键了:
