内容整理自官方开发文档

本文档的目标是将 Sentry SDK性能监控功能的演变置于上下文中。

我们首先总结了如何将性能监控添加到 SentrySDK

然后我们讨论 identified issues(已确定的问题) 吸取的经验教训以及解决这些问题的举措。

介绍

早在 2019 年初,Sentry 就开始尝试向 SDK 添加跟踪功能。

PythonJavaScript SDK 是设计和开发第一个概念的测试平台。

概念验证于 2019 年 4 月 29 日 发布

并于 2019 年 5 月 7 日交付给 Sentry

PythonJavaScript 是显而易见的选择,因为它们允许我们试验检测 Sentry 自己的后端前端

请注意,上述工作与 OpenCensus 和 OpenTracing 合并形成 OpenTelemetry 是同时代的。

SentryAPISDK 实现借鉴了 OpenTelemetry 1.0 之前版本的灵感,并结合了我们自己的想法。

例如,我们的 Span 状态列表与 2019 年底左右在 OpenTelemetry 规范中可以找到的匹配。

使用 API 后,性能监控支持随后扩展到其他 SDKSentry 的性能监控 解决方案于 2020 年 7 月普遍可用。

OpenTelemetry 的跟踪规范 1.0 版于 2021 年 2 月发布。

我们最初的实现重用了我们现有的错误报告机制:

  • Event type 扩展了新字段。 这意味着我们可以节省时间并快速开始向 Sentry 发送事件,而不是设计实现全新的摄取管道,这一次,不是 error,而是一种新的 transaction 事件类型。

  • 由于我们只是发送一种新型事件,因此也重用了 SDK 传输层。
  • 由于我们共享摄取管道(ingestion pipeline),这意味着我们共享存储以及发生在所有事件上的处理的许多部分。

我们的实现演变成明确强调 TransactionSpan 之间的区别。部分原因是重用 Event 接口的副作用。

Transaction 与客户产生了良好的共鸣。

他们允许突出显示代码中的重要工作块,例如浏览器页面加载或 http 服务器请求。

客户可以查看和浏览 transaction 列表,而在 transaction 中,span 为更细粒度的工作单元提供详细的时间安排。

在下一节中,我们将讨论当前模型的一些缺点。

已确定的问题

虽然统一 SDK 架构hubclientscope

transaction ingestion 模型的重用有其优点,但经验揭示了一些我们将其分为两类的问题。

第一组与 scope 传播有关,本质上是确定 当前 scope 是什么的能力。用户代码中的手动检测以及 SDK 集成中的自动检测都需要此操作。

第二组是与用于将 transaction 数据从 SDK 发送到 Sentrywire 格式相关的问题。

Scope 传播

该问题由 getsentry/sentry-javascript#3751 跟踪。

Unified SDK 架构 基本上是基于每个并发单元存在一个 hub,每个 hub 有一堆 clientscope 对。

Client 保存配置并负责通过 transport 向 Sentry 发送数据,而 scope 保存附加到传出事件(例如 tagbreadcrumb)的上下文数据。

每个 hub 都知道当前的 scope 是什么。它始终是堆栈顶部的 scope。困难的部分是 “per unit of concurrency(每单位并发)” 有一个 hub

例如,JavaScript 是具有事件循环和异步代码执行的单线程。

没有标准的方法来承载跨异步调用工作的上下文数据。

因此,对于 JavaScript 浏览器应用程序,只有一个全局 hub 共享用于同步异步代码。

类似的情况出现在 Mobile SDK 上。

用户期望上下文数据(例如 tagscurrent user 是什么、

breadcrumbs 以及存储在 scope 上的其他信息)可以从任何线程获得和设置。

因此,在这些 SDK 中,只有一个全局 hub

在这两种情况下,当 SDK 必须处理 reporting errors 时,一切都相对较好。

随着跟踪 transactionspan 的额外责任,scope 变得不适合存储当前的 span,因为它限制了并发 span 的存在。

对于浏览器 JavaScript,一个可能的解决方案是使用 Zone.jsAngular 框架的一部分。

主要挑战是它增加了包的大小,并且可能会无意中影响最终用户应用程序,因为它对 JavaScript 运行时引擎的关键部分进行了猴子修补(monkey-patches)

当我们尝试为手动检测创建更简单的 API 时,scope 传播问题变得尤为明显。

这个想法是公开一个 Sentry.trace 函数,该函数将隐式传播 tracingscope 数据,

并支持同步和异步代码的深度嵌套。

举个例子,假设有人想测量搜索 DOM 树需要多长时间。Tracing(跟踪) 此操作将如下所示:

await Sentry.trace(
{
op: 'dom',
description: 'Walk DOM Tree',
},
async () => await walkDomTree()
);

使用 Sentry.trace 功能,用户在添加计时数据时不必担心保留对正确 transactionspan 的引用。

用户可以在 walkDomTree 函数中自由创建子 SpanSpan 将在正确的层次结构中排序。

实际 trace 函数的实现相对简单

(参见具有示例实现的 PR)。

然而,了解异步代码和全局集成中的当前 span 是一个尚未克服的挑战。

以下两个示例综合了 scope 传播问题。

无法确定当前 Span

考虑一些需要获取对当前 span 的引用的自动检测代码,在这种情况下,手动 scope 传播不可用。

// SDK code
function fetchWrapper(/* ... */) {
/*
... some code omitted for simplicity ...
*/
const parent = getCurrentHub().getScope().getSpan(); // <1>
const span = parent.startChild({
data: { type: 'fetch' },
description: `${method} ${url}`,
op: 'http.client',
});
try {
// ...
// return fetch(...);
} finally {
span.finish();
}
}
window.fetch = fetchWrapper; // User code
async function f1() {
const hub = getCurrentHub();
let t = hub.startTransaction({ name: 't1' });
hub.getScope().setSpan(t);
try {
await fetch('https://example.com/f1');
} finally {
t.finish();
}
}
async function f2() {
const hub = getCurrentHub();
let t = hub.startTransaction({ name: 't2' });
hub.getScope().setSpan(t);
try {
await fetch('https://example.com/f2');
} finally {
t.finish();
}
}
Promise.all([f1(), f2()]); // run f1 and f2 concurrently

在上面的例子中,几个并发的 fetch 请求触发了 fetchWrapper helper 的执行。 行 <1> 必须能够根据当前的执行流程观察到不同的 span,导致如下两个 span 树:

t1
\
|- http.client GET https://example.com/f1
t2
\
|- http.client GET https://example.com/f2

这意味着,当 f1 运行时,parent 必须引用 t1,而当 f2 运行时,parent 必须是 t2

不幸的是,上面的所有代码都在争先恐后地更新和读取单个 hub 实例,因此观察到的 span 树不是确定性的。例如,结果可能错误地为:

t1
t2
\
|- http.client GET https://example.com/f1
|- http.client GET https://example.com/f2

作为无法正确确定当前 span 的副作用,

fetch 集成的显示实现(和其他)在JavaScript 浏览器 SDK 中选择创建 flat transactions

其中所有子 span 都是 transaction 的直接子代(而不是具有适当的多级树结构)。

请注意,其他跟踪库也面临同样的挑战。

OpenTelemetry for JavaScript 中有几个(在开放时)问题与确定父跨度和正确的上下文传播(包括异步代码)相关:

相互冲突的数据传播预期

每当我们添加前面讨论过的 trace 函数,或者只是尝试使用 Zones 解决 scope 传播时,就会出现预期冲突。

当前的 spantagsbreadcrumbs 等一起存储在 scope 中的事实使数据传播变得混乱,

因为 scope 的某些部分旨在仅传播到内部函数调用中(例如,tags),

而其他人预计会传播回调用者(例如,breadcrumbs),尤其是在出现 error 时。

这是一个例子:

function a() {
trace((span, scope) => {
scope.setTag('func', 'a');
scope.setTag('id', '123');
scope.addBreadcrumb('was in a');
try {
b();
} catch(e) {
// How to report the SpanID from the span in b?
} finally {
captureMessage('hello from a');
// tags: {func: 'a', id: '123'}
// breadcrumbs: ['was in a', 'was in b']
}
})
} function b() {
trace((span, scope) => {
const fail = Math.random() > 0.5;
scope.setTag('func', 'b');
scope.setTag('fail', fail.toString());
scope.addBreadcrumb('was in b');
captureMessage('hello from b');
// tags: {func: 'b', id: '123', fail: ?}
// breadcrumbs: ['was in a', 'was in b']
if (fail) {
throw Error('b failed');
}
});
}

在上面的示例中,如果 error 在调用堆栈中冒泡,我们希望能够报告 error 发生在哪个 span(通过引用 SpanID)。

我们希望有面包屑来描述发生的一切,无论哪个 Zones 正在执行,

我们希望在内部 Zone 中设置一个 tag 来覆盖来自父 Zone 的同名 tag

同时继承来自父 Zone 的所有其他 tag。每个 Zone 都有自己的 "current span"

所有这些不同的期望使得很难以一种可以理解的方式重用当前的 scope 概念、面包屑的记录方式以及这些不同的概念如何相互作用。

最后,值得注意的是,在不破坏现有 SDK API 的情况下,重组 scope 管理的更改很可能无法完成。

现有的 SDK 概念 — 如 hubsscopesbreadcrumbsusertagscontexts — 都必须重新建模。

Span 摄取模型

考虑由以下 span 树描述的跟踪:

F*
├─ B*
│ ├─ B
│ ├─ B
│ ├─ B
│ │ ├─ S*
│ │ ├─ S*
│ ├─ B
│ ├─ B
│ │ ├─ S*
│ ├─ B
│ ├─ B
│ ├─ B
│ │ ├─ S* where
F: span created on frontend service
B: span created on backend service
S: span created on storage service

此跟踪说明了 3 个被检测的服务,当用户单击网页上的按钮 (F) 时,后端 (B) 执行一些工作,然后需要对存储服务 (S) 进行多次查询。位于给定服务入口点的 Span 标有 * 以表示它们是 transaction

我们可以通过这个例子来比较和理解 Sentryspan 摄取模型与 OpenTelemetry 和其他类似跟踪系统使用的模型之间的区别。

Sentryspan 摄取模型中,属于 transaction 的所有 span 必须在单个请求中一起发送。

这意味着在整个 B* transaction 期间,所有 B span 都必须保存在内存中,包括在下游服务(示例中的存储服务)上花费的时间。

OpenTelemetry 的模型中,span 在完成时被一起批处理,并且一旦 a) 批次中有一定数量的 spanb) 过了一定的时间就会发送批次。

在我们的示例中,这可能意味着前 3B 跨度将一起批处理并发送,

而第一个 S* 事务仍在存储服务中进行。随后,其他 B span 将一起批处理并在完成时发送,直到最终 B* transaction span 也被发送。

虽然 transaction 作为将 span 组合在一起并探索 Sentry 中感兴趣的操作的一种方式特别有用,

但它们目前存在的形式会带来额外的认知负担。

SDK 维护人员和最终用户在编写检测代码时都必须了解并在 transactionspan 之间进行选择。

在当前的摄取模型中已经确定了接下来几节中的问题,并且都与这种二分法有关。

事务的复杂 JSON 序列化

OpenTelemetry 的模型中,

所有跨度都遵循相同的逻辑格式

用户和检测库可以通过将 key-value 属性附加到任何 span 来为其提供更多含义。

wire 协议使用 span 列表将数据从一个系统发送到另一个系统。

OpenTelemetry 不同,Sentry 的模型对两种类型的 span 进行了严格区分:transaction span(通常称为 transactions)和 regular span

在内存中,transaction spanregular span 有一个区别:transaction span 有一个额外的属性,即 transaction name

但是,当序列化为 JSON 时,差异更大。

Sentry SDK 以直接类似于内存中的 span 的格式将常规 span 序列化为 JSON

相比之下,transaction span 的序列化需要将其 span 属性映射到 Sentry Event

(最初用于 report errors,扩展为专门用于 transactions 的新字段),并将所有子 span 作为列表嵌入 Event 中。

Transaction Span 获取 Event 属性

transaction 从其内存表示转换为 Event 时,

它会获得更多无法分配给 regular span 的属性,

例如 breadcrumbs, extra, contexts, event_id, fingerprint, release, environment, user 等。

生命周期钩子

Sentry SDKerror 事件公开了一个 BeforeSend hook,允许用户在将事件发送到 Sentry 之前修改和/或丢弃事件。

当引入新的 transaction 类型事件时,很快就决定此类事件不会通过 BeforeSend hook,主要有两个原因:

  • 防止用户代码依赖 transaction 的双重形式(有时看起来像一个 span,有时像一个 event,如前几节所述);
  • 为了防止现有的 BeforeSend 函数在编写时只考虑到 error 而干扰 transaction,无论是意外地改变它们、完全丢弃它们,还是导致一些其他意想不到的副作用。

然而,也很明显需要某种形式的 lifecycle hook,以允许用户执行诸如更新 transaction 名称之类的操作。

我们最终达成了中间立场,即通过使用 EventProcessor(一种更通用的 BeforeSend 形式)来允许更改/丢弃 transaction 事件。

这通过在数据离开 SDK 之前让用户立即访问他们的数据来解决问题,但它也有缺点,它比 BeforeSend 使用起来更复杂,并且还暴露了从未打算泄漏的 transaction 二元性。

相比之下,在 OpenTelemetry 中,span 通过 span processor,这是两个生命周期钩子:一个是在 span 开始时,一个是在它结束时。

嵌套事务

Sentry 的摄取模型不是为服务中的嵌套 transaction 而设计的。Transaction 旨在标记服务转换。

在实践中,SDK 无法防止 transaction 嵌套。最终结果可能会让用户感到惊讶,因为每笔 transaction 都会开始一棵新树。关联这些树的唯一方法是通过 trace_id

Sentry 的计费模型是针对每个事件的,无论是 error 事件还是 transaction 事件。这意味着 transaction 中的 transaction 会生成两个可计费事件。

SDK 中,在 transaction 中进行 transaction 将导致内部 span 被围绕它们的最内层 transaction “吞噬”。

在这些情况下,创建 span 的代码只会将它们添加到两个 transaction 之一,从而导致另一个 transaction 中的检测间隙。

SentryUI 并非旨在以有用的方式处理嵌套 transaction

当查看任何一个 transaction 时,就好像 transaction 中的所有其他 transaction 都不存在(树视图上没有直接表示其他 transaction)。

有一个 trace view 功能来可视化共享一个 trace_id 的所有 transaction

trace view 仅通过显示 transaction 而不是子 span 来提供跟踪的概述。如果不先访问某个 transaction,就无法导航到 trace view

对于这种情况(伪代码),用户对 UI 中的期望也存在混淆:

# if do_a_database_query returns 10 results, is the user
# - seeing 11 transactions in the UI?
# - billed for 11 transactions?
# - see spans within create_thumbnail in the innermost transaction only?
with transaction("index-page"):
results = do_a_database_query()
for result in results:
if result["needs_thumbnail"]:
with transaction("create-thumbnail", {"resource": result["id"]}):
create_thumbnail(result)

跨度不能存在于事务之外

Sentry 的追踪体验完全围绕着存在于 transaction 中的 trace 部分。这意味着数据不能存在于 transaction 之外,即使它存在于 trace 中。

如果 SDK 没有进行 transaction,则由 instrumentation 创建的 regular span 将完全丢失。

也就是说,这对 Web server 来说不是什么问题,因为自动检测的 transaction 随着每个传入请求开始和结束。

Transaction 的要求在前端(浏览器、移动和桌面应用程序)上尤其具有挑战性,

因为在这些情况下,自动检测的 transaction 不太可靠地捕获所有 span,因为它们在自动完成之前只持续有限的时间。

trace 以仅作为 span 而不是 transaction 进行检测的操作开始的情况下,会出现另一个问题。在我们的 示例跟踪中,产生 trace 的第一个 span 是由于单击按钮。

如果按钮点击 F* 被检测为常规的 span 而不是 transaction,则很可能不会捕获来自前端的数据。然而,仍会捕获 BS span,导致不完整的踪迹。

Sentry 的模型中,如果一个 span 不是一个 transaction 并且没有作为 transaction 的祖先 span,那么该 span 将不会被摄取。

反过来,这意味着在很多情况下,跟踪丢失了有助于调试问题的关键信息,特别是在前端,transaction 需要在某个时刻结束但执行可能会继续。

自动和手动检测面临着决定是开始 span 还是 transaction 的挑战,考虑到以下因素,决定尤其困难:

  • 如果没有 transaction,则 span 丢失。
  • 如果已经存在 transaction,则存在嵌套事务问题。

缺少 Web Vitals 测量

Sentry 的浏览器工具收集 Web Vitals 测量值。但是,因为这些测量值是使用自动检测的 transaction 作为载体发送到 Sentry 的,所以在自动 transaction 完成后由浏览器提供的测量值将丢失。

这会导致 transaction 丢失一些 Web Vitals 或对 LCP 等指标进行非最终测量。

前端事务持续时间不可靠

因为所有的数据都必须在一个 transaction 中。Sentry 的浏览器 SDK 为每个页面加载和每个导航创建一个 transaction。这些 transaction 必须在某个时间结束。

如果在 transaction 完成之前关闭浏览器选项卡并将其发送到 Sentry,则所有收集的数据都会丢失。

因此,SDK 需要平衡丢失所有数据的风险与收集不完整和可能不准确的数据的风险。

在观察到最后一个活动(例如传出的 HTTP 请求)后空闲了一段时间后,Transaction 就完成了。

这意味着页面加载或导航 transaction 的持续时间是一个相当随意的值,不一定能改进或与其他事务相比,因为它不能准确代表任何具体和可理解的过程的持续时间。

我们通过将 LCP Web Vital 作为浏览器的默认性能指标来应对这一限制。

但是,如上所述,LCP 值可能会在最终确定之前发送,因此这不是理想的解决方案。

内存缓冲影响服务器

如前所述,当前的摄取模型需要 Sentry SDK 来观察内存中的完整 span 树。

以恒定的并发 transaction 流运行的应用程序将需要大量的系统资源来收集和处理跟踪数据。Web 服务器是出现此问题的典型案例。

这意味着记录 100%span100%transaction 对于许多服务器端应用程序来说是不可行的,因为所产生的开销太高了。

无法批处理事务

Sentry 的摄取模型不支持一次摄取多个事件。特别是,SDK 不能将多个 transaction 批处理为一个请求。

因此,当多笔 transaction 几乎同时完成时,SDK 需要为每个 transaction 发出单独的请求。

这种行为在最好的情况下是非常低效的,在最坏的情况下是对资源(如网络带宽和CPU周期)的严重且有问题的消耗。

兼容性

Transaction Span 的特殊处理与 OpenTelemetry 不兼容。使用 OpenTelemetry SDK 检测现有应用程序的用户无法轻松使用 Sentry 来获取和分析他们的数据。

Sentry 确实为 OpenTelemetry Collector 提供了一个 Sentry Exporter,但是,由于当前的摄取模型,Sentry Exporter 有一个主要的正确性限制

总结

通过在 Sentry 中构建当前的跟踪实现,我们学到了很多。

本文档试图捕捉许多已知的限制,以作为未来改进的基础。

追踪是一个复杂的主题,驯服这种复杂性并非易事。

第一组中的问题 - 与 scope propagation(作用域传播) 相关的问题 - 是 SDK 及其设计方式独有的问题。

解决这些问题将需要对所有 SDK 进行内部架构更改,包括重新设计面包屑等旧功能,

但进行此类更改是实现简单易用的 tracing helper(如可在任何上下文中工作并捕获准确可靠的性能数据的 trace 函数)的先决条件。

请注意,此类更改几乎肯定意味着发布新的主要 SDK 版本,这会破坏与现有版本的兼容性。

第二组中的问题 - 与 span ingestion model(跨度摄取模型) 相关的问题要复杂得多,因为为解决这些问题所做的任何更改都会影响产品的更多部分,并且需要多个团队的协调努力。

尽管如此,对 ingestion model 进行更改将对产品产生不可估量的积极影响,因为这样做会提高效率,使我们能够收集更多数据,并减少 instrumentation 的负担。

系列

Sentry 开发者贡献指南

Sentry 开发者贡献指南 - SDK 开发(性能监控:Sentry SDK API 演进)的更多相关文章

  1. Sentry 开发者贡献指南 - 数据库迁移

    Django 迁移是我们处理 Sentry 中数据库更改的方式. Django 迁移官方文档:https://docs.djangoproject.com/en/2.2/topics/migratio ...

  2. Sentry 开发者贡献指南 - 配置 PyCharm

    概述 如果您使用 PyCharm 进行开发,则需要配置一些内容才能运行和调试. 本文档描述了一些对 sentry 开发有用的配置 配置 Python 解释器:(确保它是 venv 解释器)例如 ~/v ...

  3. Sentry 开发者贡献指南 - SDK 开发(性能监控)

    内容整理于官方开发文档 系列 Docker Compose 部署与故障排除详解 K8S + Helm 一键微服务部署 Sentry 开发者贡献指南 - 前端(ReactJS生态) Sentry 开发者 ...

  4. Sentry 开发者贡献指南 - SDK 开发(事件负载)

    内容整理自官方开发文档 系列 Docker Compose 部署与故障排除详解 1 分钟快速使用 Docker 上手最新版 Sentry-CLI - 创建版本 快速使用 Docker 上手 Sentr ...

  5. Sentry 开发者贡献指南 - 后端服务(Python/Go/Rust/NodeJS)

    内容整理自官方开发文档 系列 1 分钟快速使用 Docker 上手最新版 Sentry-CLI - 创建版本 快速使用 Docker 上手 Sentry-CLI - 30 秒上手 Source Map ...

  6. Sentry 开发者贡献指南 - Feature Flag

    功能 flag 在 Sentry 的代码库中声明. 对于自托管用户,这些标志然后通过 sentry.conf.py 进行配置. 对于 Sentry 的 SaaS 部署,Flagr 用于在生产中配置标志 ...

  7. Sentry 开发者贡献指南 - Django Rest Framework(Serializers)

    Serializer 用于获取复杂的 python 模型并将它们转换为 json.序列化程序还可用于在验证传入数据后将 json 反序列化回 Python 模型. 在 Sentry,我们有两种不同类型 ...

  8. Sentry 开发者贡献指南 - 前端 React Hooks 与虫洞状态管理模式

    系列 Sentry 开发者贡献指南 - 前端(ReactJS生态) Sentry 开发者贡献指南 - 后端服务(Python/Go/Rust/NodeJS) 什么是虫洞状态管理模式? 您可以逃脱的最小 ...

  9. Sentry 开发者贡献指南 - 前端(ReactJS生态)

    内容整理自官方开发文档 系列 1 分钟快速使用 Docker 上手最新版 Sentry-CLI - 创建版本 快速使用 Docker 上手 Sentry-CLI - 30 秒上手 Source Map ...

随机推荐

  1. 【STM32】使用SDIO进行SD卡读写,包含文件管理FatFs(终)-配合内存管理来遍历SD卡

    [STM32]使用SDIO进行SD卡读写,包含文件管理FatFs(一)-初步认识SD卡 [STM32]使用SDIO进行SD卡读写,包含文件管理FatFs(二)-了解SD总线,命令的相关介绍 [STM3 ...

  2. APK 反编译以及遇到的问题

    APK反编译: https://www.cnblogs.com/geeksongs/p/10864200.html 遇到的问题 https://www.jianshu.com/p/55bf5f688e ...

  3. VScode 使用 CMake 入门

    参考 CMake 入门实战 在 linux 平台下使用 CMake 生成 Makefile 并编译的流程如下: 编写 CMake 配置文件 CMakeLists.txt . 执行命令 cmake PA ...

  4. centos7.4 64位安装 git

    参考博客:Linux Jenkins配置Git 1. git --version 查看有没有安装 过 git,没有则 继续 2. git 压缩包下载地址:https://mirrors.edge.ke ...

  5. Go modules基础精进,六大核心概念全解析(上)

    点击一键订阅<云荐大咖>专栏,获取官方推荐精品内容,学技术不迷路! Go 语言做开发时,路径是如何定义的?Go Mudules又为此带来了哪些改变?本文将会全面介绍Go modules六大 ...

  6. intelliJ破解及JavaEE搭建

    intellij2020.3破解 转载自https://www.exception.site/essay/how-to-free-use-intellij-idea-2019-3 第一步: 下载最新的 ...

  7. Android: Client-Server communication by JSON

    Refer to: http://osamashabrez.com/client-server-communication-android-json/ This is a sequel to my l ...

  8. 人工水母搜索算法—matlab代码

    clc clear foj = @ Sphere; Lb = -100; % 搜索空间下界 Ub = 100; % 搜索空间上界 N_iter = 1000; % 最大迭代次数 n_pop = 50; ...

  9. CF1557B Moamen and k-subarrays 题解

    Content 给定一个大小为 \(n\) 的数组.你可以将其分为 \(k\) 个子数组,并按照每个子数组的字典序重新排列这些子数组,再顺次拼接,得到一个新的数组.问是否存在一种划分子数组的方案,使得 ...

  10. Vim使用简介

    Vim操作 Vim真的很酷:D 编辑模式 正常模式:在文件中四处移动光标进行修改 插入模式:插入文本 替换模式:替换文本 可视化(一般,行,块)模式:选中文本块 命令模式:用于执行命令 在不同的操作模 ...