首页 / 文章 / 在动笔写特写的第一行之前需要解答的七个问题

在动笔写特写的第一行之前需要解答的七个问题

一份实施前的检查清单,涵盖真实用户问题、完成保障、规则归属、历史数据、重试与并发处理、可观测性以及发布安全性。

2797 词

在处理新任务时最快能感受到高效工作的方式,就是打开编辑器开始构建:添加接口端点、字段和组件。问题在于,最初的实现会悄悄回答所有没人提出过的疑问,而这些答案又会固化成架构、API契约和测试,之后要修改它们会非常困难。本指南将介绍经验丰富的工程师在编码前会思考的七个问题、忽略这些问题会导致什么后果,以及如何让工作量保持适度,从而加快交付速度而非拖慢进度。

为何最初的实现如此重要

当请求到来时,从编写代码入手能让原本抽象的任务显得具体且规模更小。然而那些未解决的问题并未消失——仍然需要有人来决定业务规则由谁负责、多步骤操作中的“完成”标准是什么,以及在变更之前创建的记录将如何处理。如果没人做出决定,代码就会意外地自行决定。

这种意外的决定很少仅限于某个局部。第一个版本的架构往往会成为表格布局、响应格式、所有人都需要导入的共享辅助函数,以及界面所依赖的状态模型。一旦其他代码开始调用它,且生产数据也遵循这一架构,想要改变方向就不得不进行数据迁移、编写兼容性补丁并协调发布时间。相比之下,事先花一小时思考正确的问题其实成本很低。

从表面上看,这似乎是在犹豫不决:阅读现有的工作流程,询问用户真正想要实现什么,检查旧数据的运行情况,讨论部分失败的问题。实际上,这不过是代码本来就需要解决的同一类问题,只不过是在改变主意还比较容易的时候去处理而已。

1. 将所需功能与根本问题分开

需求通常以解决方案的形式出现:添加一个按钮、一个筛选器、一个导出功能或一种新的状态。这些需求或许完全合理,但它们描述的是人们想象中的功能实现方式,而非其背后的困扰或业务目标。

满足这些潜在需求会改变产品的设计方向。要求导出 CSV 文件的请求,很可能来自那些无法对比各部门周度数据的管理人员。虽然导出功能确实有帮助,但它也会带来重复的手动表格处理工作,而保存好的报告或定时生成的汇总表完全可以避免这些麻烦。如果有人要求增加一个状态值,或许说明某个字段已经承担了过多功能,同时要处理支付、审批和交付等事务。添加这个值虽然能解决当前问题,却会让数据模型变得更加复杂难懂。

这并不意味着要对每一个小请求都进行深入调查,或将简单的修改变成一次完整的产品开发会议。关键是要充分了解用户的具体情况,判断所提出的改动是否真的能改善现状。通常只需提出几个针对性强的问题即可:

  • 今天有谁无法完成工作?
  • 他们目前是手动完成哪些工作的?
  • 这些新信息将支持什么决策?
  • 一旦该功能实现,什么事情会成为可能?
  • 如果跳过这一步,工程师们往往会直接优化原始需求。按钮设计简洁、组件可重复使用、API结构清晰,但功能依然无法令人满意,因为它只是更精确地解决了具体任务而非根本问题。重点不在于推迟编码,而在于确保代码能给出正确的解决方案。

    2. 明确整个操作的成功标准

    许多需求仅描述了某项动作,却未明确其完成的标准。提交申请、批准记录、复制项目或同步数据这些操作听起来都很简单,但一旦涉及多个步骤且其中某一步出错,问题就会显现。

    采用一种能够持久保存更改、写入审计日志行、触发事件并通知申请人的审批流程。如果数据写入成功但通知失败,那么审批是否算成功?如果用户重新尝试,记录会不会被批准两次?如果无法写入审计条目,是否应该撤销状态变更?如果事件发送延迟,审批是已完成还是仍处于待处理状态?这些并非实现层面的琐事,它们决定了产品向用户做出的承诺。

    在编写工作流之前,有用的做法是先划定完成边界:

    • 那些必须同时成功或失败的操作——因为部分完成的状态是无效的——应属于同一事务中。
  • 那些有价值但属于次要作用的效应,比如欢迎邮件,不应决定主要操作是否成功。这类功能通常应放在带有重试机制的后台任务中,并保留明确的“待处理”状态记录。
  • 同样的问题也出现在小型功能中。当有人启动导出操作时,是文件生成且任务被接受就算成功,还是稍后会显示链接?当用户界面显示“已保存”时,是服务器确认了数据持久化,还是仅本地状态发生了变化?

    若不明确界定,每层都会自行定义规则。界面会显示操作成功,而后台仍在处理;工作进程会重复执行用户已认为失败的操作;监控系统则会报告请求正常,即便某个关键副作用已经消失。先确定各项保障措施往往能简化实现过程,因为此时每一步都有明确的职责。这也会影响响应规范:返回“已接受”状态的API与返回“已完成”状态的API有所不同,客户端需要知道自己接收到的是哪种状态。

    3. 确定每项决策由哪一层负责

    如果决策被放在错误的层,功能虽然能产生正确结果,但仍可能存在安全风险。常见例子包括:

    • 前端在未经授权的情况下向用户隐藏按钮,但当直接发起请求时,API仍会接受该请求。
  • 控制器会验证状态转换,但定时任务会直接调用底层服务并绕过该检查。
  • 服务层会拒绝重复数据,但维护脚本却会直接写入表格。
  • 在每种情况下规则都是存在的,但并非所有操作路径都必须经过它。

    解决办法是找到拥有足够权限来管理该规则的层级。用户界面可以为了便捷性而反映权限设置,但它绝不能作为安全边界。控制器是验证HTTP请求格式的理想位置,而业务规则通常需要放在更深的层级,这样才能确保后台任务和内部调用方的行为一致。应用层的唯一性检查虽然能给出友好的错误提示,但只有数据库约束才能在并发写入时真正保护数据一致性。

    所有权既适用于状态也适用于规则。那些需要可共享且能在刷新后依然有效的过滤器很适合通过URL传递。临时输入数据则属于表单本身。权限与持久化状态由服务器提供,客户端不应根据本地推测重新构建自己的版本。

    当权限不明确时,就会出现协调难题:多层重复校验、需要保持同步的相同数据副本,以及不得不进行大量查找和替换的工作。政策更新会影响到用户界面、控制器、服务、工作进程以及一两个查询,但却无法保证所有副本的含义依然一致。应为每个重要决策指定明确的负责人。其他层级可以显示、缓存、执行或传递结果,但不应重新定义它。这样既能降低安全风险,又能减少维护成本,因为每个人都知道真实数据的来源所在。

    4. 处理已存在的数据

    人们会为想要的模型编写新代码,然而生产环境中的数据仍保留着之前所有模型的痕迹。

    对于发布后创建的记录,设置字段为必填项很简单,但成千上万的旧记录可能并未此设置。重新设计的状态模型虽然能很好地描述未来的工作流程,却会让历史记录仍停留在新代码不再识别的状态中。新的必填关系可能指向在旧记录创建时根本不存在的实体。

    在添加验证规则或修改架构之前,请先思考:

    • 现有的记录能否真实地迁移过来?
    • 是否需要一个临时的“未知”状态?
    • 这一变更是在重写历史,还是仅改变未来的行为?

    “真实”才是关键所在。用方便的默认值填补所有空白虽能满足 NOT NULL 约束,却会植入错误数据。如果旧记录的所属部门从未被记录下来,直接将其标记为当前部门虽然能让查询更简单,却会降低历史报告的可靠性。有时,诚实的架构应当为“未知”或“旧系统遗留”之类的值留出空间,因为不知情本身就是该记录历史的一部分。

    旧格式也存在于数据库之外。旧的客户端可能仍会发送以往的数据格式,定时任务可能依赖于新流程想要移除的状态值,而报告则可能依据早已变更的规则来解析列数据。

    你不必永远支持所有的旧有行为,但迁移决策必须明确。有些数据可以安全转换,有些需要人工审核,一些旧客户端应获得兼容期,而另一些则可以刻意停止使用。忽视这个问题并不会让它消失,它只会以各种零散的故障、没人能理解的空值字段、迁移失败以及部署后的支持问题等形式重新出现。尽早做出决策能让团队有一个统一的过渡方案,而非依赖各种零散的猜测。

    5. 假设工作会重复且存在竞争

    功能描述通常设想的是一个用户、一次点击以及一条简洁的流程:请求仅发送一次,在此期间没有其他操作会影响到该记录,随后响应便会送达客户端。但实际生产环境并不能提供这样的保证。

    • 由于页面似乎卡住了,用户会再次点击。
  • 在服务器处理完成但响应尚未到达之前,移动连接断开。
  • 队列多次发送了相同的消息。
  • 两名管理员在几秒钟内分别批准了同一项待处理任务。
  • 定时任务更新了用户仍在查看的旧版本记录。
  • 首先需要考虑的是,该操作是否可以安全地多次执行以及是否可以同时执行。如果重复执行不会造成任何问题,那么可能无需额外的机制。但如果重复执行会导致第二次付款、邀请、文件生成或库存预留,系统就需要一种方法来识别这些多次尝试实际上属于同一逻辑操作。

    该工具箱包含幂等性键、唯一约束、条件更新、用于乐观锁定的版本列、事务处理功能以及已处理消息ID的列表。选择哪种机制取决于风险所在的位置。而“我们先检查过了”这种做法是行不通的,因为它假定在检查与写入之间不会有其他进程介入。

    并发错误尤其危险,因为每一步在审查时看起来都是正确的。问题出在读取与写入之间的间隙:两个请求读取到完全相同的有效快照,各自都通过了检查,却又分别记录了本应只出现一次的结果。与其让数据库存储两个相互矛盾的真相,等待后人手动解决,不如让它明确拒绝其中一项冲突操作。

    6. 规划该功能在正式环境中的说明方式

    在您的机器上,有断点、重复执行操作的功能以及最新的设计信息。而在生产环境中,团队可能只能收到一条说明某项操作失败的反馈消息。

    因此在编写代码之前,请先设想一下排查过程。如果操作失败,要如何确定是哪一步出了问题?能否追踪单个请求在各个服务中的流转情况?日志能否显示该操作是执行了一次还是三次?能否区分“从未启动”、“正在执行”、“部分完成”和“失败”这几种状态?

    这并不是要求记录所有内容。结构混乱的大量数据只会让排查更加困难,而非更简单。有用的可观测性数据只需足够呈现单个重要操作的全貌:

    • 请求ID或关联ID
    • 资源ID及操作名称
    • 执行时长
  • 发生的状态转换
  • 尝试次数
  • 稳定的错误类别
  • 同时也要保留故障的详细信息。失败的查询不应悄无声息地变成空列表;提供方超时也不应掩盖远程端可能已完成工作的情况。在数据被记录之前,宽泛的异常处理块不应将所有原因都简化为同一条通用消息。

    同时要考虑恢复方案。重新运行失败的作业是否安全?支持系统能否无需手动查询多个表就能查看操作的当前状态?用户能否安全地再次尝试,又是否有人能向他们准确说明结果?

    那些不透明的功能一旦出问题就会带来高昂的代价,而事后再添加日志记录往往为时已晚,因为相关上下文仅在操作运行期间存在。提前设计好验证机制,就能让这些机制成为功能的一部分,而非事故发生后才紧急修补的方案。

    7. 确定如何证明该变更是安全的

    如果在代码编写之后才编写测试,这些测试往往只会反映代码当前的架构。因为有辅助函数存在,测试就会验证该函数是否被调用;因为有备用方案存在,测试就会确保其被启用;因为仓库被模拟了,测试就会确认模拟对象返回了预期结果。这类测试虽然能通过,但并不能真正证明什么。

    先确定验证方式往往能暴露设计缺陷。如果某个操作必须是幂等的,测试时应多次执行该操作。如果同一记录不能被同时批准,测试就需要模拟真正的竞争性更新场景。如果“未找到”和“失败”是不同的结果,契约必须确保这两种状态都能被检测到。如果规则是通过数据库约束来强制执行的,那么任何模拟的单元测试都无法证明该规则的有效性。

    这并不意味着每个功能都需要复杂的端到端测试套件。应根据实际执行保障措施的层级来选择测试级别:

    • 纯粹的转换操作可以直接进行单元测试。
    • API契约通常需要集成测试。
    • 在数据库中强制执行的不变量则必须针对真实数据库进行测试。
  • 风险较高的迁移可能需要监控、分阶段部署,或设置带有预定移除日期的功能开关。
  • 可逆性也是需要考虑的因素。如果变更出现异常,是否可以关闭该功能或回滚操作,同时不会丢失此前已写入的数据?架构变更后旧版本的应用程序是否仍能运行,还是迁移过程需要先扩展再收缩的步骤?是否可以在所有人依赖该功能之前先在少量用户群体中进行测试?

    如果某个设计难以测试或难以逆向分析,这通常表明某项操作承担了过多的职责,或者该变更需要更小的中间步骤。由于软件本身总是存在不确定性,因此追求绝对的证明并非目标。关键在于让测试和指标能够体现重要的保障措施,同时确保那些危险的选择可以被撤销,这样错误就能成为教训而非永久性的损害。

    保持前期工作的适度性

    这些问题并非意在论证规划总是优于实际行动。过度分析会阻碍简单工作的推进,还会为那些永远不会出现的风险设计架构。一个合理的筛选标准是:只关注那些一旦出错就会造成严重后果的决策——凡是涉及持久化数据、资金、权限、外部副作用或公共契约的内容都应纳入考量。对于在稳定接口背后的简单复制修改或内部重构,通常无需遵循完整清单。

    以简化形式呈现时,这份检查清单可以写在任务描述中:

    • 用一句话概括真实问题及问题所在方
    • 明确“已完成”意味着什么,以及哪些影响是次要的
    • 每条业务规则的唯一负责人
    • 针对现有数据及旧客户的处理方案
    • 重试时的行为表现以及并发访问情况下的应对方式
    • 需要记录的内容以及故障恢复的方法
  • 如何测试该保证机制以及如何回滚更改
  • 总结

    一旦这些问题都得到解答,代码通常会变得非常清晰明了。状态模型中不可能的组合更少了,每条规则都有明确的位置,数据库负责维护不变性,响应信息会说明任务是已完成还是仅被接收,而测试则针对保证机制本身,而非当前的函数结构。

    正因如此,细心的工程师在开始一项任务时看似进展缓慢,却仍能更快完成:他们不会任由产品定义的模糊性、历史数据、并发问题以及运营中的盲点悄无声息地变成永久性的技术决策。解决这些问题的最佳时机,是在首个便捷的实现方案影响到调用方、测试数据及生产环境之前。编写函数本身往往并非最困难的部分,真正棘手的是决定该函数应具备何种含义。

    相关阅读

  • 从瓶颈角度设计后端系统:从网址缩短服务到电子商务 — 一种以需求为首要考虑的设计 Node.js 后端的方法:何时需要添加负载均衡器、Redis、副本、队列和速率限制,以及每种方案会带来怎样的成本。