如何区分操作意图与执行者
在 我之前的文章中 ,我认为软件自动化需要在操作意图和执行环节之间添加一个中间层。 原因很简单:规范应该明确什么是成功,而执行者则负责决定如何实现这一目标。 这种分离带来了一种重要的可能性: 如果操作规范与执行机制相互独立,那么多个执行者都应该能够满足同样的规范要求。 这听起来很直观,但实际上却会引发一些复杂的问题: 两种不同的执行器能否达到相同的结果? 我们该如何对它们进行比较? 当执行器发生变化时,哪些部分必须保持稳定? 哪些差异是可以接受的? 每个执行器应该提供什么样的证据来证明自己的结果? 我们如何判断执行器的独立性是真实的还是仅仅理论上的? 这些问题非常重要,因为现代软件系统很少会长
在我之前的文章中,我认为软件自动化需要在操作意图和执行环节之间添加一个中间层。
原因很简单:规范应该明确什么是成功,而执行者则负责决定如何实现这一目标。
这种分离带来了一种重要的可能性:
如果操作规范与执行机制相互独立,那么多个执行者都应该能够满足同样的规范要求。
这听起来很直观,但实际上却会引发一些复杂的问题:
两种不同的执行器能否达到相同的结果?
我们该如何对它们进行比较?
当执行器发生变化时,哪些部分必须保持稳定?
哪些差异是可以接受的?
每个执行器应该提供什么样的证据来证明自己的结果?
我们如何判断执行器的独立性是真实的还是仅仅理论上的?
这些问题非常重要,因为现代软件系统很少会长期使用同一种执行机制。
团队成员会发生变化:
CI/CD平台
云服务提供商
部署系统
基础设施工具
编排引擎
事件自动化工具
AI代理
如果改变执行器会导致操作结果的含义也随之改变,那么这个系统就并非真正由规范来驱动的。执行者仍然拥有过多的决策权。
在这篇文章中,我会向您展示如何将操作规范与具体的执行器分离出来,让相同的规范通过两种不同的实现方式来执行,然后收集证据、比较结果,并找出执行器独立性在哪些地方会出现问题。
我们的目标并不是要证明两个执行器在内部的工作原理完全相同,而是要确定它们是否能够满足同样的操作要求。
先决条件
您需要掌握以下内容:
软件架构
接口与依赖反转原则
TypeScript或类似的语言
CI/CD与部署相关概念
可观测性技术
基本测试方法
操作规范的定义与编写
您不需要使用Kubernetes、Terraform或任何特定的云平台。这些示例都是为了简化演示而设计的,因此其架构结构非常清晰、易于理解。
目录
执行器独立性的真正含义
执行器独立性意味着,即使负责执行具体操作的机制发生了变化,相关的操作规范仍然有效。
例如,假设某份规范规定如下:
部署订单系统版本v42。
约束条件:
- 至少需要3个副本
- 错误率必须≤1%
- 95%请求的响应时间必须≤400毫秒
- 必须能够执行回滚操作
某个执行器可能会使用以下方式来实现这一规范:
蓝绿部署方案
还有一种执行器可能会选择:
┌── 执行器A
操作规范 ───┼── 执行器B
├── 执行器C
└── 人工智能代理
即使执行机制发生了变化,规范本身仍然保持稳定。
这就是执行器独立性的含义。
为什么执行器独立性如此重要
软件团队会不断更换所使用的工具。部署工作流程也可能会发生变动,例如从:
什么是成功的标准
哪些约束条件是必须遵守的
需要收集哪些证据
何时允许执行回滚操作
那么这些操作规范实际上从来就没有真正独立于旧工具而存在。
这种情况会带来多种风险:
工具锁定效应:系统在技术层面上可能是可移植的,但操作规则却无法随之迁移。
隐藏的行为变化:新的执行器可能会保留原有的部署步骤,但却忽略某些重要的约束条件。
重新实现过程中出现偏差:团队可能会试图大致复现旧有的功能,而无法做到完全一致。
审计困难:很难证明新的执行器确实遵守了相同的操作规范。
执行器独立性为我们的迁移工作提供了更可靠的保障:我们可以保留原有的规范不变,只更换具体的执行机制。
从一份稳定的操作规范开始
要想验证执行器独立性的有效性,首先需要确定一些稳定不变的要素。
例如:
type DeploymentSpec = {
service: string;
version: string;
minReplicas: number;
maxErrorRate: number;
maxP95LatencyMs: number;
};
然后,就可以根据这份规范来编写具体的实现代码:
const spec: DeploymentSpec = {
service: "orders",
version: "v42",
minReplicas: 3,
maxErrorRate: 0.01,
maxP95LatencyMs: 400,
};
此规范不应包含以下内容:
kubectl、helm、terraform、AWS、Azure、Argo、GitHub Actions
这些都属于执行器的范畴。
本规范旨在描述操作层面的契约规则。
定义执行器契约
现在我们需要明确执行器必须满足的最低接口要求。
例如:
type ExecutionEvidence = {
deployedVersion: string;
availableReplicas: number;
errorRate: number;
p95LatencyMs: number;
};
interface DeploymentExecutor {
execute(
spec: DeploymentSpec
): Promise;
}
这个接口并没有规定部署的具体实现方式。
它仅规定了以下要求:
根据给定的规范,执行相应的操作,并返回结果数据。
在本文中,“结果数据”指的是执行操作后产生或收集到的、可用于判断实际操作效果的信息。对于部署任务而言,这些信息可能包括当前运行的版本号、可用的副本数量、误差率以及95%延迟时间等。
这些数据并不是执行器自己对操作是否成功的主观判断,而是我们可以用来与规范进行对比的实际数值。
这一点非常重要。如果执行器接口中包含了特定于某种工具的概念,那么其通用性就会受到影响。
例如,以下代码所示的接口就具有很强的依赖性:
interface DeploymentExecutor {
executeKubectlCommand(
namespace: string,
manifestPath: string
): Promise;
}
这个接口明显假设了使用Kubernetes来进行部署操作。
这样的设计显然不具备执行器的通用性。
构建第一个执行器
让我们来创建一个简单的、基于内存的执行器。
class RollingDeploymentExecutor
implements DeploymentExecutor {
async execute(
spec: DeploymentSpec
): Promise {
return {
deployedVersion: spec.version,
availableReplicas: spec.minReplicas,
errorRate: 0.004,
p95LatencyMs: 280,
};
}
}
这个执行器模拟了滚动部署的流程。
从内部实现来看,它的操作过程可以理解为:
创建新的副本实例 → 等待这些实例正常运行 → 逐步替换原有的副本实例
不过这些具体实现细节并不需要被包含在规范中。执行器本身负责决定如何完成这些操作。
构建第二个执行器
现在我们来创建另一个具有不同策略的执行器。
class BlueGreenExecutor
implements DeploymentExecutor {
async execute(
spec: DeploymentSpec
): Promise {
return {
deployedVersion: spec.version,
availableReplicas: spec.minReplicas + 2,
errorRate: 0.003,
p95LatencyMs: 260,
};
}
}
从概念上来说,这个执行器可以如下运作:
创建一个并行环境;
验证该环境是否正常运行;
切换请求处理流程;
同时保持旧环境的可用性。
它的内部实现过程可能有所不同,但生成的证据格式是相同的。这意味着这两种执行器都可以根据相同的操作规范来进行评估。
通过两种执行器执行相同的操作规范
现在让我们同时运行这两个执行器。
const rolling =
new RollingDeploymentExecutor();
const blueGreen =
new BlueGreenExecutor();
const rollingEvidence =
await rolling.execute(spec);
const blueGreenEvidence =
await blueGreen.execute(spec);
目前,我们拥有以下情况:
相同的操作规范;
不同的执行器;
不同的内部实现机制;
但生成的证据格式是相同的。
关键的问题并不在于它们是否执行了相同的步骤——事实上它们并没有。真正重要的是,它们是否都满足了相同的操作要求。
比较证据结果,而非内部实现细节
执行器的独立性取决于其产生的结果,而不是具体的实现方式。
滚动部署方案:
副本数量 = 3;
错误率 = 0.4%;
95%响应时间 = 280毫秒。
蓝绿部署方案:
副本数量 = 5;
错误率 = 0.3%;
95%响应时间 = 260毫秒。
虽然这些输出结果并不完全相同,但它们都可能符合相应的规范要求。这才是关键所在。
执行器的独立性并不要求:
使用相同的命令;
执行相同的步骤数量;
采用相同的拓扑结构;
保持相同的时间安排;
使用相同的基础设施。
它要求的是:
具有相同的操作目的;
满足所有必要的约束条件;
生成符合规范要求的证据结果;
最终得到可接受的结果。
这一点与基于接口的编程方式类似:两种不同的实现方式可以在内部机制上存在差异,但只要它们能够满足相同的契约要求,就可以被视为有效的实现方案。
规范化执行器特定的证据结果
在实际应用中,不同的执行器往往会生成不同格式的证据数据。
{
"readyReplicas": 3,
"image": "orders:v42",
"latencyP95": 280
}
{
"instancesHealthy": 5,
"releaseVersion": "v42",
"p95Ms": 260
}
直接比较这两种格式的证据数据是不可能的,因此需要使用适配器来进行转换。
type CanonicalEvidence = {
version: string;
availableReplicas: number;
p95LatencyMs: number;
};
适配器A的实现如下:
function normalizeRolling(
raw: {
readyReplicas: number;
image: string;
latencyP95: number;
}
): CanonicalEvidence {
return {
version:
raw.image.split(":")[1],
availableReplicas:
rawreadyReplicas,
p95LatencyMs:
raw.latencyP95,
};
}
这个适配器将滚动执行器的原生输出转换为规范证据模型。它从图像标签中提取版本信息,将readyReplicas映射到availableReplicas,并将latencyP95重命名为p95LatencyMs。
重要的是,该适配器并不会改变这些数据的实际含义。它只是将执行器特有的字段名称和格式转换为规范层所期望的通用表示形式而已。
适配器B:
function normalizeBlueGreen(
raw: {
instancesHealthy: number;
releaseVersion: string;
p95Ms: number;
}
): CanonicalEvidence {
return {
version:
raw.releaseVersion,
availableReplicas:
raw.instancesHealthy,
p95LatencyMs:
raw.p95Ms,
};
}
这个适配器对蓝/绿执行器也执行相同的转换操作。虽然其原始输出使用不同的字段名称,但这些数值所代表的含义是相同的:版本、可用容量以及95%延迟时间。
当这两个适配器都投入使用后,系统的其他部分就不再需要理解那些特定于执行器的数据结构了。它们可以使用同一个规范模型来评估这两种执行器产生的结果。
现在,这两种执行器生成的证据都可以用相同的模型来进行分析评估。
这是一个非常重要的架构界限:特定于执行器的证据属于执行器本身,而规范证据则属于规范层。
将执行结果与合规性评估分开
执行器应该负责生成相应的证据,但它不应该去判断某个操作是否符合规范要求。
这种区分能够有效避免另一种形式的耦合问题。
例如,应该避免这样做:
return {
success: true,
};
一个通用的成功标志其实并不能提供太多有用的信息。
正确的做法应该是返回具体的证据数据:
return {
deployedVersion: "v42",
availableReplicas: 3,
errorRate: 0.004,
p95LatencyMs: 280,
};
然后再单独对这些证据进行评估。
type ConformanceResult = {
conformant: boolean;
failures: string[];
};
function evaluateConformance(
spec: DeploymentSpec,
evidence: ExecutionEvidence
): ConformanceResult {
const failures: string[] = [];
if (
evidence.deployedVersion !==
spec.version
) {
failures.push(
"版本不一致"
);
}
if (
evidence.availableReplicas <
spec.minReplicas
) {
failures.push(
"可用副本数量不足"
);
}
if (
evidence.errorRate >
spec.maxErrorRate
) {
failures.push(
"错误率过高"
);
}
if (
evidence.p95LatencyMs >
spec.maxP95LatencyMs
) {
failures.push(
"延迟时间过长"
);
}
return {
conformant:
failures.length === 0,
failures,
};
}
评估者会收到两样信息:一是定义了哪些内容应该是正确的规范,二是描述了实际观察结果的证据。
它会独立地检查每一项约束条件。如果版本有误,就会记录wrong-version;如果副本数量不足,就会记录insufficient-replicas;错误率和延迟的检查方式也是如此。
只有当没有出现任何故障时,conformant的值才会被设置为true。列出具体的故障名称是非常有用的,因为这能解释为什么某次执行没有符合规范要求,而不会简单地将所有情况都归类为false。
正因为如此,执行者应该提供具体的事实而非最终的判断结果。如果规范发生了变化,或者需要进行审计以重新评估决策过程,又或者你想使用完全相同的规则来比较多个执行者的结果,那么同样的证据都可以被重新用来进行评估。
执行者 → 证据
规范 + 证据 → 是否符合要求
这种区分在后续的过程中会变得非常重要。
什么才算等同的执行结果?
两个执行者产生的证据不必完全相同,只要它们满足相同的规范要求即可。
举个例子:执行者A
副本数量:3
错误率:0.4%
延迟:280毫秒
执行者B
副本数量:5
错误率:0.3%
延迟:260毫秒
这两个执行者都可能通过评估。
但现在假设执行者B产生的结果如下:副本数量:2
错误率:0.3%
延迟:260毫秒
这样,它就违反了一项约束条件。
因此:执行者A:符合规范
执行者B:不符合规范
尽管这两个执行者都是独立的实现方案,但只有执行者B满足了本次执行的规范要求。
这种区别非常重要。
执行者的独立性并不能保证它们一定能够正确地完成任务,它只是为人们提供了一种可以用来评估其正确性的标准。
执行者的独立性通常会在哪些情况下失效?
存在几种常见的故障情况。
规范中针对特定工具的字段
例如:kubernetesNamespace: production
helmChart: orders
如果这些内容属于实现细节,那么它们就不应该被包含在操作规范中。
执行者自行设定的成功标准
如果每个执行者都自行设定不同的阈值,那么规范就不再具有权威性了。隐藏的先决条件
执行器A可能需要获得批准,而执行器B则可能不需要。
如果批准是操作流程中必不可少的一部分,那么这一规则就不应该仅适用于某个特定的执行器。
隐藏的恢复行为
有的执行器会自动进行回滚操作,而另一些则会让出现故障的系统继续运行。
如果恢复行为至关重要,那么相关规范就应该明确体现这一要求。
语义上的差异
不同的工具可能会使用相同的术语,但它们的含义可能有所不同。
例如:
健康的、准备好的、可用的、正在运行的
这些概念都需要有明确的定义。否则,执行器之间的互操作性就只是表面上的东西而已。
如何测试执行器的互操作性
你可以明确地测试执行器之间的独立性。
首先需要制定一套规范。
例如:
const cases: DeploymentSpec[] = [
{
service: "orders",
version: "v42",
minReplicas: 3,
maxErrorRate: 0.01,
maxP95LatencyMs: 400,
},
{
service: "payments",
version: "v18",
minReplicas: 5,
maxErrorRate: 0.005,
maxP95LatencyMs: 250,
},
];
然后,将每一条规范应用到每一个执行器上,进行测试。
const executors:
DeploymentExecutor[] = [
new RollingDeploymentExecutor(),
new BlueGreenExecutor(),
];
for (const spec of cases) {
for (const executor of executors) {
const evidence =
await executor.execute(spec);
const result =
evaluateConformance(
spec,
evidence
);
console.log({
spec: spec.service,
executor:
executor.constructor.name,
result,
});
}
}
这样就能得到一个有用的测试结果表格:
Rolling BlueGreen
Orders v42 PASS PASS
Payments v18 PASS FAIL
现在,你就获得了关于执行器互操作性的确切信息。
这种测试方法比仅仅假设两个工具都支持部署操作、因此它们是等效的,要可靠得多。
为什么这对AI代理来说很重要
对于AI代理而言,执行器之间的独立性显得更加重要。
一个AI代理每次都可能会生成不同的执行计划。
例如:
执行步骤1:扩展规模、进行部署、验证结果、确定路由方案
执行步骤2:创建并行环境、进行验证、切换流量分配
执行步骤3:部署测试版本、进行观察、逐步扩大部署范围
由于这些计划各不相同,因此执行器的行为也会动态变化。在这种情况下,试图让各个执行器之间的操作步骤完全相同是不现实的。
但是,规范本身仍然可以保持稳定不变。
例如:
部署“orders v42”版本。
约束条件:
副本数量 >= 3
错误率 <= 1%
延迟时间 <= 400毫秒
验证内容:
当前运行中的版本信息、副本数量、错误率、延迟时间这个人工智能代理可以选择任何可行的方案,而其最终产生的结果仍然会使用相同的评估标准来进行评价。
这形成了一种强有力的界限:
代理的自主性
必须遵循操作规范的限制
代理可以优化执行过程,但它不能擅自重新定义成功的标准。
一个简单的端到端示例
让我们把各个部分组合起来看看。
首先从规范开始:
const spec: DeploymentSpec = {
service: "orders",
version: "v42",
minReplicas: 3,
maxErrorRate: 0.01,
maxP95LatencyMs: 400,
};
然后创建两个执行器:
const executors:
DeploymentExecutor[] = [
new RollingDeploymentExecutor(),
new BlueGreenExecutor(),
];
接下来运行它们:
for (const executor of executors) {
const evidence =
await executor.execute(spec);
const conformance =
evaluateConformance(
spec,
evidence
);
console.log(
executor.constructor.name,
evidence,
conformance
);
}
可能的结果如下:
RollingDeploymentExecutor
evidence:
version = v42
replicas = 3
error rate = 0.004
p95 = 280
conformance:
PASS
而另一个执行器的结果可能是:
BlueGreenExecutor
evidence:
version = v42
replicas = 5
error rate = 0.003
p95 = 260
conformance:
PASS
这两个执行器的行为并不完全相同,但它们都满足了相同的操作规范要求。
现在假设第二个执行器报告的结果是:replicas = 2
BlueGreenExecutor
conformance:
FAIL
reason:
insufficient-replicas
这正是我们所需要的:规范本身保持不变,而执行器的具体实现可以有所不同。
证据能够用来判断执行结果是否符合既定的要求。
实际应用中的工作流程
如果你想在真实的系统中测试执行器之间的独立性,我建议按照以下步骤进行操作。
1. 选择一项具体的操作
例如:
部署服务
恢复备份
更新证书
调整工作负载分配
2. 提取操作规范
需要明确的内容包括:
目标
约束条件
证据收集要求
恢复方案预期
3. 删除与特定工具相关的语言
需要删除的内容包括:
kubectl
Terraform
GitHub Actions
AWS CLI
特定的资源标识符
只保留那些真正属于操作规范核心内容的部分。
4>定义执行器接口
让执行器负责完成以下任务:
5. 构建第一个适配器
只需对当前的实现方式进行包装,无需重新编写代码。
6. 构建第二个执行器
可以使用以下方法:
其他工具
其他的部署策略
模拟器
测试替身
人工智能代理
7. 规范化证据数据
将特定于执行器的观测结果映射到统一的证据模型中。
8>分别评估合规性
不要让执行器来决定自己是否通过了测试。
9>比较各执行器的兼容性
使用相同的规范在多个执行器上运行测试。
10>分析差异之处
需要思考以下问题:
是执行器出错了吗?
规范本身是否不完整?
是否有证据数据缺失?
这些概念实际上是否等价?
这些差异其实很有价值,它们能帮助我们发现隐藏的耦合关系。
执行器独立性并不意味着什么
执行器的独立性并不意味着所有工具都可以互相替代。
不同的执行器可能会在以下方面存在差异:
功能能力
成本
延迟
故障模式
安全机制
操作复杂性
有时,某个规范可能要求某种功能,而某个执行器根本无法提供这种功能。
例如:
零停机时间的部署方案
这种需求在某些平台上可以实现,但在其他平台上则不行。
这并不是规范的缺陷,而是执行器的局限性。执行器确实无法满足这个规范的要求。
执行器的独立性也不意味着可以忽略实现细节。实现细节对于以下方面仍然非常重要:
性能
安全性
成本
可靠性
可维护性
关键在于:系统的操作逻辑不应依赖于某种特定的执行机制。
从可替代工具到稳定的操作意图
软件基础设施在不断变化。
各种工具会不断出现或消失,部署策略也会不断发展,云平台也在不断更新,智能代理的功能也在不断增强。
如果将操作意图直接嵌入到每个执行器中,那么任何变更都可能导致系统语义上的变动。
但是,如果将操作意图独立地表示出来:
操作规范
↓
稳定的契约
↓
可替代的执行器
那么系统的演进就会变得更加容易。
此外,这种方式还能让我们获得另一项优势:
我们可以利用多个执行器提供的证据数据,
并根据相同的规范来对这些数据进行分析。
这样一来,我们就不再需要问这样的问题:
这个工具完成任务了吗?
然后开始思考这样一个问题:
这种执行过程在多大程度上符合规范要求?
这就是下一个需要探讨的问题。
结论
“执行器的独立性”并不意味着要假装所有工具都是一样的;它的真正意义在于保护操作意图不受实现方式变化的影响。
规范文件描述的是:
应该发生什么
需要满足哪些约束条件
需要提供哪些证据
而执行器则负责决定:
规范文件
↓
执行器A ──→ 证据A
执行器B ──→ 证据B
↓
评估结果
即使两个执行器采用完全不同的策略,它们仍然可以满足相同的操作要求。
对于普通的自动化系统来说,这是一个非常重要的特性。
当这些执行器是具有自主性的智能体时,这一特性就变得更加重要了——因为它们的内部执行计划可能会在每次运行时发生变化。
但一旦多个执行器都能根据相同的规范进行操作,那么另一个问题就不可避免地出现了:
我们应该如何衡量实际执行结果与规范要求之间的符合程度呢?
接下来,我正是想探讨这个问题。
相关文章
可执行的操作规范如何使软件自动化过程具备可验证性
现代软件系统正变得越来越自动化。我们正在自动化部署流程、基础设施调整、扩展操作、事件响应以及数据处理流程。 现在,借助人工智能工具,我们甚至开始自动化一些运营决策的制定过程。这看似是一种进步,但实际上却引发了一个容易被忽视的问题: 我们在执行各种操作方面变得越来越熟练,但与此同时,我们却并没有更清楚地界定这些操作究竟应该实现什么目标。 一个部署流程可能运行顺利,但却仍然违反了某些重要的业务约束条件; 一个基础设施配置脚本可能没有出现任何错误,但执行完成后系统状态仍然会出问题; 一个人工智能工具可能会完成一系列操作,但其最终产生的结果却无法被任何人验证。 在许多系统中,运营决策的意图仍然存在于各
阅读全文
为什么软件自动化需要在“意图”与“执行”之间添加一层中间环节
软件自动化擅长将指令转化为具体的行动。 一个部署流程可以完成发布操作,Terraform可以配置基础设施,运行手册可以重启服务,而代理程序则可以分析遥测数据并选择相应的补救措施。 但这些系统都有一个共同的弱点:它们通常更擅长执行指令,而非理解这些指令背后的真正意图。 当自动化过程较为简单时,这种差距很容易被忽视。如果一个脚本只执行一条命令,那么这个脚本本身或许就足以说明应该发生什么。 然而,随着系统变得越来越分布式、越来越受策略驱动,也越来越自主,执行逻辑开始承担一些原本并不属于它的职责。 如今,一个部署工作流程可能会包含以下内容: 需要部署什么 它可以在哪里运行 必须遵守哪些约束条件 需要检
阅读全文
如何向英国税务海关总署的“数字化纳税”API提交季度更新报告
每年有四次,所有参与“税收数字化”计划的个体经营者和房东都必须向英国税务海关总署提交一份收入与支出的汇总报表。 2026-27纳税年度的第一个截止日期是8月7日,这个期限已经过去;第二个截止日期则是11月7日。每份汇总报表都需要通过软件向英国税务海关总署发送一次API请求,而本教程正是专门讲解如何正确完成这些请求操作的。 您无需事先阅读任何其他资料即可跟随本教程进行操作。每次进行“税收数字化”集成时所需的一次性设置信息(包括沙箱应用程序、OAuth 2.0访问令牌以及防欺诈相关配置)已在下方的简要总结中列出,同时每段代码示例中也都会使用到一些辅助函数。 如果您想深入了解这些设置步骤,我曾在 之
阅读全文
如何使用 Next.js 和 Jev 构建一个能够自动将错误信息发送到 GitHub 的人工智能支持系统
每个网站都会收到用户的反馈,而其中大部分反馈最终都会被搁置在某个不太合适的地方。有的访客会发现某个按钮无法正常使用,然后会通过电子邮件联系你;还有人会在社交媒体上留言,反映他们的手机无法打开某个页面;再有人则会填写你的联系表格,提出一些功能改进的建议,而这些建议就会和新闻通讯、收据之类的信息一起堆在你的收件箱里。 当你终于坐下来准备处理这些反馈时,你会发现那些错误报告分散在三个不同的地方。其中有一半的报告缺少必要的详细信息,而那些被上传到GitHub上的报告,也是有人手动复制过去的,有时甚至还会把访客的电子邮件地址也粘贴在里面。 因为我想为自己的项目提供更好的支持系统,所以我决定自己动手开发它
阅读全文