Logo

site iconPiglei | 朱雷

《Python 工匠》作者,现居深圳,负责蓝鲸 aPaaS 平台的后端开发工作。
请复制 RSS 到你的阅读器,或快速订阅到 :

Inoreader Feedly Follow Feedbin Local Reader

Piglei | 朱雷 RSS 预览

一份关于 AI 编程的简明行为指南

2026-03-20 17:31:12

AI 编程发展迅猛,Claude Code、Codex 等 AI Agent 已成为许多软件工程师工作时必不可少的得力助手。然而,对于如何在项目中更好地实践 AI 编程,目前仍然是一副“八仙过海,各显神通”的模样。

假如同一个团队里的工程师,在如何使用 AI 工具的“大问题”上没有达成共识,就会出现协作上的摩擦,对项目产生不良影响。

因此,本文尝试面向软件工程师群体,对 AI 编程的推荐行为实践做一些总结,弱化具体的工具和技巧(比如 Spec 驱动还是不 Spec 驱动?),着眼于更通用的方面(比如初级工程师该完全委派 AI Agent 修 bug 吗?)。

内容分为“对所有人”和“对初级工程师”两部分,其中“对初级工程师”部分,针对这类人群身处的特殊阶段,提供了一些量身定制的建议。

说明:本文部分内容由 jianan、wklken 参与共创;本文观点基于笔者所身处的工作环境总结而来,不一定完全适用于其他项目,酌情采纳;

对所有人

谨记 Agent 不对代码担责

  • AI Agent 拓展了人的能力,是一种“分身”,但不直接承担任何责任,人依旧是代码的最终责任人
  • Review AI 生成的代码,理解它们,不要提交自己不理解的代码
    • 如何判断自己是不是真的理解某件事?“费曼学习法”——你是否能向其他没有背景的人解释它?
  • 不要过度依赖其他人(AI)在 Review 阶段对你的工作做最后把关,这是不负责任的表现

多“协作”,少“委派”

  • 两种心智模型,“协作”意味着你在理解对方的基础上共同做决定,“委派”意味着你只关注最终结果
  • 不要去模仿一位产品经理,仅用自然语言高高在上地提需求,不关注实现细节
    • ~~“怎么实现我不管,这个需求很简单!”~~
  • 深入探索程序设计和整体结构,和 Agent 一起推敲出更优的答案
  • 重复一次:AI 不对代码担责,也不对项目的长期可维护性负责

拆分你的 PR

  • AI 写代码又快又多,很容易产出几千行改动的 PR
  • 大 PR 极大增加了 Review 难度,Review 过程容易浮于表面,最终导致工程质量不可控
  • 不憋“大招”,控制 PR 粒度(推荐<600行),小步快跑,分阶段交付
  • 如果 PR 实在无法拆分,尽量提供 PR 的设计文档(design_notes 目录)以辅助 Reviewer 理解

说明:设计文档并不是详细到代码级别的 Spec 规格说明,而是站在更高抽象层的,供人阅读的功能“说明书”。推荐由人编写核心内容或框架,AI 可辅助补全细节。

善用 Plan 模式,多做前置探索

  • 开发新需求,应当先熟悉相关模块/领域知识,了解项目当前实现等(可借助 AI 调查梳理)
  • 不要在真正理解需求前,就让 AI 开始写代码
    • 重复一次:“费曼学习法”判断自己是否真正理解
  • 先提问或启用 plan 模式与 AI 澄清需求(学习 AI 提示词技巧)
  • 别被 AI 带着走,独立思考,怀疑主义,通过反问等方式来开拓思路
  • 只有对问题有清晰的认知,才能判断 AI 的实现是否合理,而正确的判断无价

采纳稳定的库,优于 AI 从零实现

  • 对于工具函数和库,比起使用第三方库,AI 有种偏向于“自己手撸”的倾向
    • 不是说“造轮子”一定不好,但是……
  • 解决常见领域的成熟问题(例如 gjson),倾向让 AI 采纳库而不是自己实现
  • 如何知道哪些库适合?跟 AI 聊,或自己动手查阅资料,日常积累不可少
  • 多个库可用时,人判断最适合项目的(功能契合度、活跃度,未来演进等等)

创建 PR 前使用 AI Review

  • 创建新 PR 前,应当使用 AI Agent review 当前改动并根据建议调整代码
  • AI 前置 review 可减轻之后人工 review 的负担,提升迭代效率
  • 可使用任何 review SKILL 或简单 prompt 完成

下面是一条示例 prompt:

你是一名代码评审专家。和 master 分支对比以了解当前项目的所有改动,review 这些改动并产出报告。我最关注这些内容:

- 代码逻辑错误或未考虑边界情况;
- 常见的不符合编程语言最佳实践的设计或写法;
- 过于复杂的可以被简化的类/接口/函数设计;
- 可能存在安全隐患的写法(例如 SQL 注入);
- 重新实现了成熟库或项目其他模块已实现的功能(未复用);
- 其他任何不符合项目规范、打破一致性,有问题的内容;

注意:创建 PR 前的 Review 并不直接替代之后的 AI 和人工 Review,这只是一种前置的自查。

让改动可验证,并总是验证

  • 对于 AI 实现的代码,做好自动化测试和自测工作
  • 杜绝提交未经测试的代码,去等待 Review 来“兜底”
    • 代码 Review 不可能找到所有问题,有些问题只有实际执行才能触发
    • 越后期发现的 bug,修复成本越高
  • 鼓励编写自动化测试代码(单测、API 测试)等,做到 AI Agent 可自动验证,进入“验证<->修复”循环

其他

  • 保持好奇心:对 AI 的代码,其中觉得新奇的库、模式或代码片段,打破砂锅问到底
  • 拓展能力边界:打破思维惰性,去解决一些“如果没有 Agent”你绝不会去完成的任务
  • 做正确的事:如果一开始的需求就错了,AI 会帮你在错误的道路上极速狂奔,所以“做正确的事”从未像现在这么重要

对初级工程师

经验不够丰富的初级工程师,对于编程语言和项目领域的理解尚处于早期,此时过度依赖 AI 或拖累学习效率,长远来看对能力发展有不良影响。因此,针对该类人群提供特定建议

质量胜过效率

  • AI 虽然是提效工具,但处于初级阶段时,应当更注重质量而非速度
  • 快速地交付低质量的代码,实际上更低效(后续 review 反复调整、技术债务)
  • 如果更慢的方式,能让自己学到更多东西,那么选更慢的(沟通好 deadline 的前提下)

尝试手动修复 Bug

  • AI Agent 非常擅长全自动修复 Bug,但对于初级工程师…
  • 不建议一股脑全丢给 Agent,由 Agent 完全无监督地“自动化”修复
  • 用聊天模式,提出自己的思考和疑问,在分析 Bug 的过程中加深对项目的理解
  • 这有助于更快、更深入地理解代码逻辑和架构

重复一次:多把 Agent 作为协作者来共同工作,而不是完全委派给 Agent。

自己先动脑,在 AI 给出的方案之外寻求

  • 对任何任务,AI 给出的是“最有可能成立”的答案,但它不一定适合你
  • 盲目遵循,丧失了拓展自身技术视野的可能性
  • 先花一个固定的时间(如半小时)自己设计思路,之后询问对比 AI 给出的思路(先有解再“对答案”)
  • 鼓励极限探索 AI 的能力边界

其他

  • 让 AI 反问自己,通过问答来加强对项目和相关技术的理解
    • 类似让 AI 给你做一场关于项目的模拟“面试”
  • 读官方文档:AI 知识有截止日期,存在幻觉问题, 文档仍然是深入掌握某项技术的首选(慢即是快
  • 补充软件设计和架构模式方面的短板,了解和学习设计模式、领域驱动设计(DDD)等
    • 现阶段 AI 并不擅长整体设计,更擅长在明确的框架下做具体的函数级实现
    • 良好的设计、健壮与灵活的架构,保证项目可持续迭代,离不开人来掌舵
    • 通过阅读书籍构建自己的知识体系
  • 关注非功能性需求:AI 往往默认只关注“功能跑通”,忽视其他工程层面的问题:安全、可维护性、并发安全和扩展性等,有意识地去关注它们

结语

如有不同看法或补充条目,欢迎讨论。

AI 编程是一种“框架”

2026-02-10 12:04:45

使用 AI 类编程工具越多,我便越来越强烈地感觉到:AI 编程是一种“框架(Framework)”。

框架是每位程序员的老朋友,它通常针对特定领域所设计,能极大提升编码效率。以 REST API 服务为例,一些成熟的框架(比如 Django REST Framework)能做到仅需定义数据模型和视图类,便生成一套功能完备的 CRUD API 服务。

AI 编程工具和传统框架一样,都是一种“杠杆”,它们允许人们用少量输入撬动庞大的功能。二者区别仅在于输入类型不同,框架需要代码(或配置),而 AI 仅需一句简单的自然语言提示词——“写一个书店网站”

框架的问题

作为一种新型框架,“AI 编程”所带来的便利性,足以把人类从前发明过的任何一种框架按在地上摩擦,但是,框架天生所具备的问题并未消失。

1. 抽象泄露

所有框架都有一个共性,它们提供了极高级的抽象,以降低实现特定功能所需的工作量。比如,Django 的 ORM 提供了“数据模型”这层抽象,免去了手写 SQL 语言、设计数据校验等繁重工作。

但不幸的是,抽象都会泄露

一名 Django 初学者编写的网站上线后,客户投诉爆表,因为列表页加载极为缓慢。此时,打开数据库监控面板,他会发现这个看似简单的页面,每访问一次就会发起 400 次数据库查询。要解决该问题,他必须弄脏双手,撕开 ORM 这个抽象层,弄懂“属性如何被加载?”,搞清楚“N+1 问题”的真相。

而使用 AI 编程工具,“抽象泄露”则发生在我们不得不抛弃轻松的自然语言提示词——“实现 XXX 功能”,转而说出“你对 items 变量的状态转换的理解不对”的时刻。

毋庸讳言,现阶段的 AI 仍存在智能瓶颈。当自然语言无法驱动 AI 准确完成工作时,打破抽象,打开已布满灰尘的 IDE,用精确到变量名的提示词帮 AI 找到根因,是我们的唯一选择。

2. 丧失控制权

世上实现代码复用的所有模式,大致可被分为两种:框架(framework)和库(library)。

要区分一个东西是框架还是库,关键在于找到“谁控制着程序的整体结构?” 这个问题的答案。使用框架,控制权牢牢掌握在框架手中,你所编写的程序,是镶嵌在伟岸的框架程序中的一部分。这有点像是去完成一副卡通图,所有元素都已用浅灰色线条勾边,你只负责给不同部位涂上不同颜色。

而使用库,控制权则仍掌握在你手里。你负责调配和使用不同的库,来搭建起整个程序。这像是玩积木,手边有千千万万个积木和模组,你负责把它们组装成想要的样子。

丧失控制权会带来哪些危害?这主要体现在可修改性上。

使用框架时,当程序需要定制一项功能,由于缺少控制权,工作的开展依赖于框架其是否支持该自定义选项。如果支持,那么整个过程会像热刀切开黄油一样丝滑。但假若框架并不支持,那么很不幸,你极可能在一个简单需求上花掉成吨时间。

平心而论,在“控制权”维度上,AI 编程还称不上天生属于“框架”还是“库”。但不可否认的是,当下最流行的一种趋势就是把它当成框架来使用。比如在 Vibe Coding 时,人类仅负责提供模糊的自然语言提示词,毫不关心程序的入出口与整体结构,一切由 AI Agent 这个框架所掌控。

认知成本:DRF 的启示

为何人们倾向于将 AI 当成框架?这是个有趣的问题。

我认为答案的关键在于一个词:认知成本。框架与生俱来的特质,给了我们一种暗示:你可以用最低的认知成本实现最复杂的功能。 而人类生来就爱“省力”,少动脑而完成更多,是我们与生俱来追求的目标。

拿 DRF(Django REST Framework) 来举例。在 DRF 框架中,为一类数据模型实现一套 CRUD API,只需编写以下 4 行代码:

class AccountViewSet(viewsets.ModelViewSet):
    queryset = Account.objects.all()
    serializer_class = AccountSerializer
    permission_classes = [IsAccountAdminOrReadOnly]

一个 class,三个属性,极低的认知成本,全套 RESTful API,怎么看这都是一门极其划算的生意。

但是,正如前面所提到的,框架的便利性是一把双刃剑,伴随框架出现的“抽象泄露”和“控制权丧失”问题不可避免。

做个假设,现在我们需要调整 API,让 create 方法返回不同的响应体,为 list 方法增加额外的过滤条件,基于以上代码该怎么做?答案是,先重写包含 get_queryset 在内的多个方法,再用一批 if/else 补丁将整个 ModelViewSet 爆改到面目全非。

既然如此,有没有更好的办法?答案是肯定的,我们完全可以弃用高级的 ModelViewSet,仅采用不附带任何魔法的 ViewSet,完全基于模块的组合来实现相同功能。

class AccountViewSet(viewsets.ViewSet):
    permission_classes = [IsAccountAdminOrReadOnly]

    def list(self, request):
        # 针对 list 的特殊过滤逻辑
        qs = Accounts.objects.filter(...)
        serializer = AccountSerializer(qs, many=True)
        return Response(serializer.data)

    def create(self, request):
        # 针对 create 的特殊响应结构
        serializer = AccountForCreationSerializer(obj)
        return Response(AccountForCreationSerializer(obj).data)

相比起来,新方案最直观的变化是代码量变多了,也显得更加啰嗦,但更大的改变其实发生在“认知成本”层面上。

ModelViewSet 版代码里,认知成本表面上很低,但更多成本其实被藏了起来,作为一种隐藏的认知债务所存在。后续,当需求发生变化时,这些债务给我们实现功能带来了巨大的麻烦。调整代码结构后,原本隐藏的债务浮出了水面,代码的可视性和可维护性也得到了有效提升。

假如以“框架和库”这个设定来观察上面的案例,新代码的组织模式更接近于“库”——人作为程序的主人去组织整个功能,而非“框架”——人仅作为 ModelViewSet 的仆人去填补空缺。

至此我们可以看到,框架与库并非一种对物品的二元化严格分类,而更像两种不同的思维方式。因为即便是在 DRF 这个庞大的框架中,也存在各种不同的代码组织模式,一些偏框架,另一些则明显偏向库。

也许,该把 AI 编程当成一种库

回到 AI 编程。将 AI 编程作为一种框架,会引导我们采用框架式思维,不断追求编写更短的提示词、对代码付出更少的关注,付出更少的认知成本来达成目的。

然而,就像前面的例子所展示的,在这种思维模式下,认知债务将不断堆积,框架与生俱来的“抽象泄露”与“控制权丧失”问题,也将在未来对我们产生严重危害。

既然如此,何不转变思维,将 AI 编程当成一种“库”?就像去使用任何其他库一样,我们作为程序的主人,调用它来完成工作。

这种思维模式的转变,可能意味着:

  • 不再追求“写更少实现更多”:用更少的提示词(代码)实现更多功能,看上去很美,但也意味着大量的认知债务随之累积;
    • 找到编写提示词的“甜蜜区”,付出 相对较少 而非绝对意义上的最少的认知成本;
  • 关注程序结构: 比起在前 AI 时代,你现在可能更需要关注程序的整体结构,作为总设计师去设计整个程序,将正确的结构和约束内化到 AGENTS.md 中;
  • 更精准的提示词: 在理解已有程序的基础上,编写更精准的提示词来引导 AI 完成工作,而不是任其发挥,让 AI 主导一切;
  • 审查代码: 即便使用同一种框架,在遇到棘手问题时,一位熟读框架文档的人也会比另一位愣头青更有效率,如果把 AI 编写的代码归为框架,那么你应该去审查这份代码,从而在不可避免的“抽象泄露”发生时,将其危害降到最低。

毋庸置疑,AI 编程是一项革命性的进步,它的出现,让我们可以站在一个之前只存在于想象中的思维高度来编写程序,但 AI 编程毕竟不是魔法,如同历史上任何一个框架,极度便利的背后隐藏着抽象泄露、控制权丧失及认知债务等多重风险。

所以,AI 编程仍然不是银弹,它无法将我们从认知成本中真正解放出来。然而,在真正的银弹出现前,认识到 AI 编程作为一种新型“框架”的局限性,并适当使用“库”的心智模型来驾驭它,或许是我们的最佳选择。

Python 的 OrderedDict 为什么有序?

2025-10-30 10:42:55

现在是 2025 年,网上已很少见到 Python 字典有序性的相关讨论。自从 Python 在 2018 年发布 3.7 版本,将“字典保持成员的插入序”写进语言规范后,人们已渐渐习惯有序的字典。那曾经调皮、无序的字典,早已像 2.7 版本一样成为过去,只在某些老登们忆苦思甜时被提起。

而在那个字典无法保持顺序的年代,如果我们要用到有序的字典,我们用什么?答案是:collections.OrderedDict

但是,随着内置字典已经有序,OrderedDict 似乎也渐渐变得不再必要。不过,截止到目前(3.14 版本)为止,它仍然存在于标准库模块 collections 中。这主要是出于以下几个原因:

  1. 保持向前兼容,已依赖其的旧代码可以保持不变;
  2. 行为不同:OrderedDict 在判断相等性时会将键顺序纳入考量,内置字典不会;
  3. 更多特性:OrderedDict 拥有 move_to_end 等方法。
>>> d
OrderedDict([('a', 1), ('b', 2), ('c', 3)])
>>> d.move_to_end('a')
>>> d
OrderedDict([('b', 2), ('c', 3), ('a', 1)])    # 1
  1. move_to_end() 可以把某个键移动到字典的末尾

本文将深入 OrderedDict 类型的内部实现,了解在 Python 中实现一个有序的字典,需要做哪些工作。

注:具体来说,标准库中的 OrderedDict 数据结构有 C 和 Python 两套不同实现,各自适用不同的运行环境,二者的实现类似;本文针对 Python 版本编写。

一个双向链表和另一个字典

OrderedDict 是一个有序的字典,它像普通字典一样支持键值对操作,只是保留了键的顺序。实现 OrderedDict 的关键在于以下两点:

  1. 继承 dict:自动拥有内置字典类型的所有操作,所有键值对存放在 OrderedDict 对象自身中——self 就是一个 {}
  2. 引入额外数据结构:引入额外的有序数据结构,让其作为一种外部参考来维护键的顺序。

数据结构有很多种,到底该使用哪一种来维持键的有序性?由于字典是一种基于哈希表(hash table)的高性能结构,最擅长在 O(1) 的时间复杂度下完成键值对的存取操作。因此,OrderedDict 所需的用于保存键顺序的额外结构,首先应满足性能要求——“维护顺序”的过程不能拖慢字典的原操作。

为了达到这个目标,OrderedDict 同时使用了两个数据结构:一个双向链表和另一个字典。

  1. 双向链表:有序结构,根据链表节点可以方便地在链表中新增或删除成员(时间复杂度为 O(1)),节点所保存的内容为 OrderedDict 的键名。
  2. 另一个字典:在链表中查询一个节点,通常需要按序遍历完所有节点,平均时间复杂度是 O(n),这显然不满足性能需求,因此 OrderedDict 引入了另一个字典作为链表的索引,使用键可快速拿到链表节点(时间复杂度 O(1))。

整个数据结构如下图所示:

图:OrderedDict 内部数据结构示意图,包含三大数据结构:self(保存键值对的字典自身)、self.root...(有序双向链表)、self._map(链表索引字典)

下面以 __setitem__ 方法为例,详细看看 OrderedDict 如何完成键值对的写操作,以下是相关代码:

    def __setitem__(self, key, value,
                    dict_setitem=dict.__setitem__, proxy=_proxy, Link=_Link):
        'od.__setitem__(i, y) <==> od[i]=y'
        if key not in self:
            self.__map[key] = link = Link()  # 1
            root = self.__root
            last = root.prev
            link.prev, link.next, link.key = last, root, key  # 2
            last.next = link
            root.prev = proxy(link)  # 3
        dict_setitem(self, key, value)  # 4
  1. 创建一个新的链表节点,并将其存放到 self.__map 中,之后可以通过 key 来快速读取该节点;
  2. 修改新节点 link 的前后节点,将其插入到 root 前,也就是作为尾部节点加入链表;
  3. 修改另外两个相关节点 last(原尾节点)root(根节点),至此完成整套链表操作;
  4. 修改自身字典中的对应键值对。

假设执行代码 d["aa"] = 4,往字典中插入一个新成员,整套数据的变化如下图所示:

图:插入键值对 "aa": 4,OrderedDict 内部数据结构发生的变化

双向链表、链表索引字典,以及 OrderedDict 字典自身,都需要处理 "aa": 4 这个新成员。

__setitem__() 类似,__delitem__()(删除成员)和 pop()(弹出成员)方法除修改自身字典外,也需要调整对应键在链表和索引字典中的数据状态,在此不再赘述。

为了让 OrderedDict 在被迭代时能有序返回所有键, __iter__ 方法也需要有所调整,下面是相关代码:

def __iter__(self):
    'od.__iter__() <==> iter(od)'
    root = self.__root
    curr = root.next
    while curr is not root:
        yield curr.key
        curr = curr.next

可以看出,遍历一个 OrderedDict,实际上就是在遍历它内部的双向链表。遍历由一个 while 循环完成,它将链表中每个节点通过生成器返回,从而实现有序。

小结

通过引入额外的数据结构,OrderedDict 最终实现了有序。双向链表加索引字典的组合,最大程度降低了 OrderedDict 在数据存取时的开销,虽付出了额外存储空间,但仍维持了较好的存取性能。

有趣的细节

在阅读 OrderedDict 实现时,我发现几个有趣的细节。

1. 对 weakref 的使用

Python 语言的垃圾回收主要基于引用计数完成。引用计数算法简单高效,但唯独无法很好地处理“环形引用”。以下面这个场景举例,在操作双向链表时,向链表尾部插入新节点,需要:

  1. 将新节点的下一个节点修改为根节点(link.next = root
  2. 将根节点的上一个节点修改为新节点(link = root.prev

这将在 linkroot 对象之间创建一个环形引用,二者都将使对方的引用计数加一,最终导致无法有效被 GC 及时回收。

介于此,OrderedDict 在处理类似情况时使用了 weakref 模块。相关代码如下:

link.prev, link.next, link.key = last, root, key  # 1
last.next = link
root.prev = proxy(link)  # 2
  1. link 和 root 通过 link.next 建立了一个方向的引用关系;
  2. root 和 link 再通过 root.prev 建立另一个方向的引用关系,但这次采用 proxy(...) 修饰了 link 对象,其中 proxy 来自于 weakref 模块。

一旦对象被 weakref 模块修饰过,引用它将不会触发引用计数器的增长,这有效阻止了“环形引用”的产生,能让 GC 更及时地回收内存。

2. 传入 object() 作为默认值

同内置字典一样,OrderdedDict 也需要支持 pop 操作。pop 方法负责从字典中“弹出”一个键(key)所对应的值,如果 key 不存在,返回调用方法时传入的 default 默认值。

>>> d = {"a": 1}
>>> d.pop("a", 42)
1
>>> d.pop("c", 42)
42  # "c" 不存在,返回默认值 42

对于 OrderdedDict 而言,其在 pop 方法中,需要完成从自身字典中 pop 以及更新双向链表两件事。核心代码如下:

class OrderedDict(dict):

    __marker = object()

    def pop(self, key, default=__marker):
        marker = self.__marker
        result = dict.pop(self, key, marker)
        if result is not marker:
            # The same as in __delitem__().
            # 更新链表部分已省略 ...

你可以注意到,在 dict.pop(self, key, marker) 中,代码传入了 marker 作为 key 不存在时的默认值。marker 并不是什么魔法对象,它仅仅只是类初始化时创建的一个小 object()

为什么选择一个 object() 作为默认值?这是因为,此处需要通过 pop(...) 的返回值来严格区分“key 存在”和“key 不存在”两种情况。所以,一个绝不可能在用户字典中出现的新鲜热乎的 object() 对象,是最为理想的默认值选择。

我看见的软件设计:胖瘦客户端

2025-06-03 17:49:31

设计软件是一个不断产生疑问、解决疑问的过程。设计者们面对一个需求,会产生许许多多的疑问,在这些疑问中,有一个看似幼稚,却直击灵魂的小问题:“业务逻辑(复杂度)放在哪?” 项目每引入一个新功能,所增加的总复杂度几乎是确定的,但如何把这些复杂度分配到各模块中,其中的方式方法却变化无穷。

比如,一个采用“客户端/服务端”架构的软件,在增加某项业务功能时,工程师们可能会发现:它既可以(主要)放在服务端实现,也可以放在客户端实现。不同决策直接影响后续的分工模式、开发效率以及功能扩展性等方方面面。

此类场景中,一种常见的设计策略是 “瘦客户端,胖服务端” ,“胖/瘦”指的并非身材,而是组件所承担的职责多寡。采用“瘦客户端”设计,代表主要的业务逻辑均由服务端承担,客户端尽量简单。

让我们通过 kubectl apply 命令的故事,看看如何把“瘦客户端”理念应用在现实世界的软件中。

kubectl apply 的故事

作为当下最流行的容器编排系统,Kubernetes 最为人所熟知的设计之一,是它的声明式资源配置功能。简单来说,人们将应用的“目标运行状态”写进一份 YAML 文件,然后执行 kubectl apply,Kubernetes 便会遵循描述,将应用运行起来。

举个例子,以下是一个简单的 Nginx 应用的 Deployment 资源描述:

# test_nginx.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: nginx-deployment
  annotations:
    the_app_name: nginx  # *一个小小的注解,“后面会考”*
spec:
  replicas: 1
  selector:
    matchLabels:
      app: nginx
  template:
    metadata:
      labels:
        app: nginx
    spec:
      containers:
      - name: nginx
        image: nginx:1.7.9

对该文件执行 kubectl apply,之后稍等片刻,便可以看到 nginx-deployment 正常运行在了集群中。之后,如果想要对该资源进行任何调整,只需修改 test_nginx.yaml 文件,重新执行 apply 命令即可。

下面做一个小小的实验,来深入理解 kubectl apply 命令的能力。

首先,执行 kubectl get 来查看集群中的资源定义:

❯ kubectl get deploy -o yaml
apiVersion: v1
items:
- apiVersion: apps/v1
  kind: Deployment
  metadata:
    annotations:
      deployment.kubernetes.io/revision: "1"
      kubectl.kubernetes.io/last-applied-configuration: ...
      the_app_name: nginx  # YAML 文件中定义的注解
# ... 已省略 ...

主要观察该资源的注解(annotations)部分。

Tip:注解(annotations)是 Kubernetes 中的一个通用资源字段,保存了一些对系统运行有用的信息,它采用键值对结构,可以简单当成一个 Python 里的字典或 Go 中的 map[string]string

可以看到,之前定义在 test_nginx.yaml 文件中的注解项 the_app_name: nginx 正常出现在了资源中。除此之外,注解字段中还有几个新面孔,比如 deployment.kubernetes.io/revision 等。它们并未定义在 YAML 文件里,而是在资源被提交后,由 Kubernetes 的系统组件(比如 Deployment Controller)写入,可以被统一归为“系统注解”。

然后,我们修改 test_nginx.yaml 文件,将其中的注解 the_app_name 改个名,改成 the_name_of_app

apiVersion: apps/v1
kind: Deployment
metadata:
  name: nginx-deployment
  annotations:
    # 已删除:the_app_name: nginx
    the_name_of_app: nginx
# ...

之后重新执行 kubectl apply 命令,然后查看集群中的资源定义:

❯ kubectl get deploy -o yaml
apiVersion: v1
items:
- apiVersion: apps/v1
  kind: Deployment
  metadata:
    annotations:
      deployment.kubernetes.io/revision: "1"
      kubectl.kubernetes.io/last-applied-configuration: ...
      the_name_of_app: nginx
# ...

可以发现,改动已经生效,注解字段中的 the_app_name 成功被替换为了 the_name_of_app

回顾前面的整个 “apply -> 修改 -> 重新 apply” 的过程,会发现它非常符合直觉。如果再仔细思考,你会发现里面暗藏玄机。

比如,在最后一次执行 apply 命令时,Kubernetes 服务端接收到的 YAML 实际只有一个注解键:the_name_of_app,没有其他信息,但最终服务端决定用它来替换 the_app_name,而不是增加一个新的注解键,为什么?此外,服务端又是如何在更新注解(annotations)字段时,避开那些“系统注解”的呢?

以上这些,全都要归功于 kubectl apply 的实现。

客户端侧 apply

如前所述,kubectl apply 的职责是将一份资源定义“应用”到集群中,但它并非用本地定义完整替换服务端的资源(这样会影响到那些“系统注解”),也不是简单地打一个没头没脑的补丁(这样就无法感知到“旧注解” the_app_name 应被删除)

为了让结果符合用户预期,kubectl apply 采用了一种类似于“智能打补丁”的方式。具体来说,在每次执行 apply 命令时,kubectl 客户端会先读取以下 3 份数据:

  1. 本地文件中的资源定义(test_nginx.yaml
  2. 服务端上次被 apply 的完整资源定义(从系统注解 kubectl.kubernetes.io/last-applied-configuration 中获取)
  3. 服务端目前活跃的资源定义(kubectl get ... 看到的内容)

基于这些数据,客户端使用一种名为“三路合并(3-way merge)”的算法生成一份最符合逻辑的资源补丁对象(patch)。以前面的小实验举例,步骤如下:

  1. 客户端读取服务端的资源定义
  2. 客户端读取本地文件中的资源定义,发现注解 the_name_of_app: nginx
  3. 客户端获取服务端上一次 apply 的资源定义,发现注解 the_app_name: nginx
  4. 基于以上 3 份数据,kubectl 产生最符合逻辑的 PATCH 对象: {"the_app_name":null,"the_name_of_app":"nginx"}——删旧添新
胖客户端 -客户端侧 Apply 功能示意图

因为以上整个过程主要在客户端完成,服务端仅提供基础的读写 API 支持,采用这种工作模式的 kubectl apply 也被称为“客户端侧 apply(client-side apply)”。

客户端侧 apply 的局限性

就像前面所演示的,客户端 apply 很好地满足了用户需求。但是,随着时间的推移,越来越多的人发现这种模式存在许多局限性。最显著的,当时其羸弱的冲突处理能力。

一份资源定义在被提交到 Kubernetes 集群后,可能存在许多个修改者,比如 CLI 工具、系统 controller、第三方 operator ,等等。它们都可以采取各自偏好的方式来修改资源定义。用前面的 Deployment 再来做个简单的演示。

在 nginx-deployment 的 Deployment 资源定义中,副本数(replicas)被设置为 1。因此执行 kubectl apply 后,集群中实际运行的副本数也是 1 :

❯ kubectl get deploy/nginx-deployment
NAME               READY   UP-TO-DATE   AVAILABLE   AGE
nginx-deployment   1/1     1            1           24h

这时,假设出现了另一个修改者,他跳过了本地 YAML 文件,直接用 kubectl edit 命令,将副本数调整成了 2:

# 第二位修改者:kubectl edit
❯ kubectl edit deploy/nginx-deployment
# .. 将其中的 replicas 字段修改为 2 后保存

# 修改生效,副本数变成了 2
❯ kubectl get deploy/nginx-deployment
NAME               READY   UP-TO-DATE   AVAILABLE   AGE
nginx-deployment   2/2     2            2           24h

最后再回到 kubectl apply。在不知道副本数已改变的情况下,重新执行 kubectl apply -f test_nginx.yaml,我们会发现副本数马上变回了 1。

❯ kubectl apply -f test_nginx.yaml
deployment.apps/nginx-deployment configured
❯ kubectl get deploy/nginx-deployment
NAME               READY   UP-TO-DATE   AVAILABLE   AGE
nginx-deployment   1/1     1            1           24h

也就是说, kubectl apply 直接重置了第二位修改者对副本数的改动。换句话说,kubecl apply 无法感知和处理多方修改的冲突场景,导致其他修改者的改动丢失。

除了冲突处理能力不佳,客户端侧 apply 还有许多其他问题。比方说,虽然 apply 命令功能强大,但大部分实现都在 kubectl 中,是 kubectl 的专属命令。如果其他客户端想进行“类似 apply”的资源操作,则需要自行实现“三路合并”算法,成本相当高。

正因为以上种种问题,2018 年 3 月,社区起草了一篇名为 Apply 的项目改进提议:KEP-555 。在提议中,人们设计了一种 kubectl apply 的一种全新实现:服务端侧 apply。

服务端侧 apply

如果用一句话来总结服务端侧 apply,可以说:服务端侧 apply 将“apply”从一个客户端功能变成了一种服务端的内置功能,用户只要发起一个简单的 API 请求,便能调用 apply 算法来“应用”一份资源定义。它带来了许多显而易见的好处。

瘦客户端 - 服务端侧 Apply 功能示意图

首先,客户端 kubectl 变得更简单了,它不再需要在本地进行复杂的“三路合并”,而是抄起本地资源,丢给 apply 接口即可。同时,任何第三方脚本、服务,都可以直接使用 apply 这种方便的资源修改能力,便利至极。

其次,服务端侧 apply 极大提升了多方修改场景下的冲突探测能力。

在客户端侧 apply 方案下,Kubernetes 通过系统注解 kubectl.kubernetes.io/last-applied-configuration 保存了上一次 apply 的完整数据,以此生成“智能补丁”,一定程度上回避了部分冲突。

而服务端 apply 采用了一种更为彻底的冲突解决模式。它在系统字段 managedFields 中,存下了资源的每个字段的修改者。基于 managedFields 中的数据,服务端得以快速识别出每个可能发生数据冲突的场景,给用户提供必要的信息,以避免发生意料外的数据覆盖。

举例来说,如果是服务端 apply,那么在上一节执行最后一次 apply 命令时,服务端会直接提示数据冲突报错:

❯ kubectl apply --server-side -f test_nginx.yaml
error: Apply failed with 1 conflict: conflict with "kubectl-edit" using apps/v1: .spec.replicas
Please review the fields above--they currently have other managers. Here
# ...

此时,用户既可强制写入数据,也可放弃对冲突字段的管理权(删除该字段),总之,服务端侧 apply 给了用户气定神闲处理冲突的机会。

小结

通过将逻辑从客户端移动到服务端,Kubernetes 的 apply 功能获得了更好的冲突处理能力,也变得更为易用。在新设计中,客户端 kubectl 由胖变瘦,服务端 apiserver 从瘦变胖。

如果你想更深入地了解 kubectl apply,可以阅读以下文档:

  1. Server Side Apply Is Great And You Should Be Using It | Kubernetes
  2. Server-Side Apply | Kubernetes

软件设计建议

客户端是胖还是瘦,在于所承受的职责多少,胖瘦并无高下之分,只是各自适合的场景有所不同。选胖还是选瘦?多数情况下这答案是显而易见的,因为许多功能天然只适合某种实现。就像全局搜索,只会是“瘦客户端,胖服务端”——它依赖服务端数据库里的全部数据。

让我们头疼的,往往是那些答案不够显而易见、模棱两可的情况。这时,如何挑选更恰当的策略?以下是我的几条建议。

1. 善用服务端功能零成本复用、变更实时触达的特点

回顾 kubectl apply 的演进过程,可以发现“服务器端 apply”相比“客户端 apply”的一大优势是它能轻松支持多种不同客户端。服务器端 apply,不光 kubectl 工具能用,任何一个人直接抄起 curl 也能用,毕竟它无需任何本地计算,只需要发起一个普通的 HTTP 请求即可。

正因如此,当你在纠结应当采用“瘦客户端”还是“胖客户端”时,请向自己提一个问题:“该功能有可能(需要)被多种不同客户端使用吗?” 如果答案是肯定的,那么“瘦客户端”可能是更优的选择。

除了能“零成本”复用外,在服务端实现功能的另一个好处是变更能实时触达用户。

在许多场景中(如移动端软件开发),发布一个新客户端版本需要层层审核,变更无法实时推送到用户侧。这时,“瘦客户端,胖服务端”设计就有了很大的优势。功能有变更?只需更新一下服务端代码或配置即可。

2. 别让服务端因客户端的定制需求过载

《论语》有云:过犹不及。有些情况下,假如我们过度追求“瘦客户端”,将所有复杂度一股脑塞进服务端,会导致后者不必要的臃肿,反而催生出不好的设计。

这次我们换换口味,不说软件,用一家烤肉店来举例。

烤肉店故事:如何调味?

软件市的设计二路上新开了一家烤肉店,主打烤肉口味丰富。

为符合各类顾客的口味偏好,店内烤肉提供了多种不同风味,如甜辣、咸甜、酸辣,等等。同时,遵循“顾客至上”的原则,烤肉店采取了后厨调味的策略:顾客在点单时标记想吃的口味,后厨在备肉时调好味。 刚开始,这样的方式很受用户欢迎。

一个月后,店内生意越来越好,许多五湖四海的顾客慕名而来。这时后厨发现,更多的顾客带来了烤肉口味的爆炸性增长,一天下来,自己需要调配出几十种不同口味满足顾客,忙得眼冒金星。

面对困境,老板小 R 想到了一个天才般(才怪)的解决办法:让顾客自助调味。在每张餐桌上,摆好辣椒、番茄酱、椒盐、酱油等五花八门的调味料,后厨只负责完成对肉完成基础处理(腌点盐),客人喜欢什么口味,自己添加即可。

切换成这种模式后,后厨压力得以释放,餐厅的运作效率得到了极大提升。

识别服务端复杂度的过载风险

就像“给肉调味”,在客户端/服务端架构中,天生有一类功能是更为贴近用户和客户端的,这类功能就是针对不同用户和客户端的定制化需求

如果服务端总是一视同仁,尽全力满足所有用户和客户端的定制化需求,那么这虽然方便了客户端,自己却极易因复杂度过度增长而过载,导致后续很难维护。

因此,在软件开发过程中,开发者们需要敏锐地识别出这种过载风险。如判断某功能天生与客户端更为亲近,且不同客户端可能有不同的定制需求,那服务端最好点到即止,只提供基础功能,将更多定制逻辑交由客户端处理,切忌越俎代庖。

3. 客户端的计算力是独特的优质资源

在可供运用的计算(存储)资源层面上,服务端与客户端天生不同:

  • 服务端:计算能力强大且集中,但单价通常较昂贵,以及和用户间隔着客户端;
  • 客户端:直接触达用户,但可供调配的计算能力有限;每个用户通常独享客户端——每单位弱但数量多

这些特点将如何影响软件设计?还是通过烤肉店故事来看看。

烤肉店故事:谁来烤肉?

除了风味多种多样,设计二路上的烤肉店还有另一个杀手锏:服务员代烤肉。肉送到餐桌后,剪肉、摊肉、翻面、滋油,烤肉所需的各项劳动全都由服务员完成,顾客不需要动一个手指头。

同“后厨调味”一样,开业前一个月,这种代烤肉模式运作得非常好。但很快,老板小 R 发现这种模式难以为继。因为为了保证“代烤肉”服务的效率,店里需要为每一桌顾客配一位全职烤肉的服务员。这直接导致店内人员成本高涨,入不敷出。

发愁好几天后,小 R 又蹦出一个天才般(才怪)的想法:“为什么不让每个顾客自己动手呢?”

说干就干,第二天,烤肉店就变成了自助模式。每位来店用餐的顾客都需要自己烤肉,不再有服务员代劳。于是,烤肉店终于不用再雇佣海量服务员,很快扭亏为盈。

善用客户端的独特资源

如果用软件设计来类比,故事中烤肉店的变化,其实是一个从“瘦客户端”到“胖客户端”的变化:

  • “服务员代烤肉” = “瘦客户端”:烤肉需要人来付出劳动,而这主要由烤肉店服务员(服务端资源)完成;
  • “顾客自助烤肉” = “胖客户端”:烤肉所需的劳动,不再由烤肉店(服务端)承担,而是由每一个顾客(客户端)完成;
  • 作为客户端,顾客天生拥有“自己动手烤肉”这种计算能力,“胖客户端”设计合理利用了这种能力,将服务端(烤肉店)的烤肉需求分摊了出去。

综上所述,和服务端有所不同,客户端拥有独特的优质资源(计算/存储),并且随着用户数量增长,这种资源天然呈现出水平扩展的特点。如果软件能利用好这份资源,去采用“胖客户端”设计,往往可以出奇制胜。

“自助烤肉”的弊端

再回到烤肉店,当“代烤肉”变成“自助烤肉”后,店内支出虽然变少,但整个就餐体验也发生了天翻地覆的变化。

如果说“服务员代烤肉”提供的是一种标准化的服务,总能让顾客吃到火候恰到好处的食物,“自助烤肉”所带来的就餐体验,其实是反标准化、参差不齐的。一些擅长烤肉的顾客,确实能吃到美味的肉,但部分动手能力较差的顾客,则很有可能在焦糊味中度过一个糟糕的夜晚。

这很好揭示了一个事实:不同于服务端,客户端天生就是层次不齐、不可靠的。不同客户端因其可调配的资源不同,提供的用户体验可能天差地别。在一些特殊领域(比如电子游戏)中,客户端的这种不可靠性,会成为软件设计时的一个重要考量。

结语

以 kubectl apply 的变迁史开头,本文对软件设计时的“胖/瘦客户端”进行了简单介绍,在末尾,我总结了一些与之相关的软件设计建议。希望这些内容能对你有所启发。

文末彩蛋

虽然服务端侧 apply 很好,但它目前仍未成为 kubectl apply 的默认选项,截止到目前,人们仍需要显式传入 --server-side 选项来启用服务端侧 apply。

服务端侧 apply 的稳定版本发布于 2021 年 8 月,距今已长达四年。修改一项客户端的默认行为,四年都无法完成,维护 Kubernetes 这种巨无霸软件背后的难度,可想而知。

相关讨论:kubectl: Use Server-Side-Apply by default · Issue #3805 · kubernetes/enhancements


题图来源:Photo by Isaac N.C. on Unsplash

程序员阅读清单:我喜欢的 100 篇技术文章(41-50)

2025-03-06 07:52:36

程序员们也许是互联网上最爱分享的群体之一,他们不仅喜欢开源自己写的软件,也爱通过写文章来分享知识。从业以来,我阅读过大量技术文章,其中不乏一些佳作。这些佳作中,有些凭借深刻的技术洞见令我深受启发,也有些以庖丁解牛般的精湛手法解释一项技术,让我读后大呼过瘾。

作为“爱分享”的程序员中的一份子,我想当一次推荐人,将读过的好文章分享给大家。我给这个系列起名为 《程序员阅读清单:我喜欢的 100 篇技术文章》

受限于本人的专业与兴趣所在,清单中的文章对以下几个领域有所偏重:程序员通识、软件工程、后端开发、技术写作、Python 语言、Go 语言

下面是阅读清单的第三部分,包含第 41 到 50 篇文章。

系列索引:

  • 第一部分(1-20):链接
  • 第二部分(21-40):链接
  • 第三部分(41-50):链接

清单

41. 《抽象泄露法则》

用 AI,花 5 分钟开发一个新功能。验证时,却发现新功能在某个特殊情况下无法正常工作。为了解决这个 bug,你只能逐行排查调试。等修复好问题,一看表, 1 个小时过去了。

上面的经历对你来说是否有些似曾相识?早在 2002 年,程序员 Joel Spolsky 就敏锐地发现了这类现象,并将它们总结为:“抽象泄露法则”。软件世界是一层抽象套着另一层的千层饼,就好像 HTTP 协议下有 TCP、TCP 下有 IP,每一层抽象都声称自己是完美的:“你无需关注在我之下的任何细节”。

但事实却是,所有抽象必定泄露。而当抽象泄露时,就像要从 AI 的 1000 行代码里找到那个错误——事情非常棘手,但我们别无选择。

42. 《如何设计一个好的 API 及其重要性》

这份资料来自 Joshua Bloch(时任首席 Java 架构师)在 Google 公司的内部演讲。虽然距今已 17 年,但它读起来却没有任何过时的感觉,对现代软件开发仍具备指导价值。

Joshua 系统性地阐述了 API 设计的方方面面。包括:

  • 带着怀疑的眼光收集用户用例(use cases);
  • 写代码前,先用最简单的文字描述 API(一页纸以内),并和相关人员讨论完善;
  • 如果迟疑于是否提供某个功能,就先不要提供(后续新增比删除要简单得多);
  • API 应当和它所被使用的平台和谐共存,比如 SDK 不应被原样从一门语言搬运到另一门。

如果你之前从未深入思考过 API 设计,读读看,它极有可能改变你未来开发软件的方式。

43. 《我构建软件的原则+实践“让无效状态不可表示”》

关于软件开发原则的文章有很多,这篇的特别之处在于,作者 Kevin 着重强调了数据对于软件设计的影响。

比如,Kevin 提出在设计时,应当优先考虑数据结构而不是代码,因为前者更为重要。正如《人月神话》的作者 Fred Brooks 曾经说过:“如果提供了程序流程图,而没有表数据,我仍然会很迷惑。而给我看表数据,往往就不再需要流程图,程序结构是非常清晰的。”

Kevin 提到的另一条原则是“让无效状态不可表示”。软件的业务逻辑中,难免会存在一些“无效状态”。为了处理它们,代码常需要做一些额外工作。然而,通过调整数据结构设计,使得数据层无法表现无效状态后,程序复杂度就可以降低。《实践“让无效状态不可表示”》中有本原则的一个具体应用案例。

除了上述原则外,文章中的其他原则,比如“关注基础概念而不是具体技术”、“避免用局部简单换取全局复杂”,等等,都充满智慧。

44. 《不,不是编译器的问题,从来都不是》

一段代码的正常运行,依赖着无数隐藏在其背后的组件和库。当程序出现 bug 时,程序员不在第一时间怀疑自己的代码,而是去质疑那些久经考验的依赖库,从来不是一个明智的选择。正如文章的标题所言:“从来都不是编译器的问题。”

然而,“编译器”也是由人编写,并非真的永远正确。“编译器”一旦犯错,问题的诡异程度常常会出乎意料。在文章的后半段,常年信奉“编译器不出错”的作者,还真就遇上了一次“编译器错误”。

45. 《关于在除夕前一天换了一个洗衣机的故事》

一名程序员家中服役 6 年的洗衣机坏了,不能脱水。因为之前花大价钱换过一次排水泵,他以为这次是旧病复发,便决定置换一台新机器。可没想到的是,新洗衣机装好后同样不能脱水。

本来只是一件普普通通的糟心事,但作者显然不这么想,他在文章后半居然从洗衣机转向了软件开发。从故障码到说明书,从 debug 到选品牌,真是很有意思。相当好的观察与思考。

46.《你的函数是什么颜色?》

有人发明了一门编程语言,它非常特别,因为它的函数以颜色来区分类型。函数一共有两种颜色:“红色”和“蓝色”。函数的颜色不止影响外观,更会影响你使用它们的方式,比方说:红函数只能调用红函数,不能调用蓝函数。

虽然以上面这略为不知所云的内容开场,但这篇文章讨论的主题实际上相当严肃。在文章中,作者 Bob 分享了自己对异步编程风格一些思考(猜猜函数的“颜色”代表什么?),从回调、Promise,到线程和 await/async,均有涉及。

除了观点鞭辟入里,文章的写作质量也相当高。严肃内容间不时穿插一点作者的小幽默。对于爱好异步编程的人来说,这是一篇不可错过的佳作。

47. 《健康的文档》

程序员们是一个奇怪的群体,他们对许多事物持有矛盾态度,“文档”就是其中之一。

作为消费者时,每位程序员都希望自己所使用的每个 API、函数,接手的每个系统都能找到详尽而准确的文档。而当他摇身一变,变成生产者时,却很少愿意在“写文档”这件事上投入精力——常常是“宁编百行码,不写一行字”。

然而,文档对于软件开发的重要性毋庸置疑。正如作者提到:“每个未被记录下的东西,都等同于一种资源的浪费,会在未来带来麻烦。”通过写文档,我们将自己脑中的知识具象化,从而在未来帮助到其他人。对于个人而言,文档不仅是一种学习、交流和分享知识的工具,也是一种建立个人影响力的捷径。而对于团队来说,如果每位成员都重视文档的价值,乐于编写清晰、可靠的文档来替代无休止的会议,那么这种“文档优先”的氛围,对于团队的长期发展大有裨益。

48. 《如何像人类一样做代码评审》

一篇关于代码评审的文章,里面涵盖了许多入门和进阶经验,包括:别把评审时间花在风格与样式问题上,让工具来代劳;评论应该以“请求”的口吻,而不是“命令”;评审不是只找缺点,对于好代码应该不吝赞美,等等。

强烈推荐给每一位需要参与代码评审的程序员。

49. 《关于 Python 3.13 你需要了解的一切 - JIT 和 GIL》

Python 3.13 版本引入了许多激动人心的改动,比如基于 “copy-and-patch”技术的即时编译(JIT),以及终于去掉了全局解释器锁(GIL)的“自由线程”模式,等等。

Drew 的这篇文章介绍了以上改动。文章的写作风格非常友好,内容也很全面。既有零基础的概念科普,也有实际的代码实验与 benchmark 环节。知识多,篇幅却控制得恰到好处,推荐阅读。

50. 《入行 14 年,我还是觉得编程很难》

这是清单的第 50 篇,也标记着整个“程序员阅读清单”系列完成了一半。考虑再三,决定奉上拙作一篇,我把这作为对自己的一个小小鼓励。

编程难吗?不同的人会有不同的答案。十几岁时,还在上学的我觉得编程很难,各类算法、API 让人头晕目眩。我期望多年以后,大量的开发经验会让编程变得像吃饭一样简单。

如今十几年过去,编程好像只是变简单了那么一丁点,距离“像吃饭一样简单”还差得很远。

在这篇文章里,我分享了自己对编程这件事的一些思考与总结。比如:打造高效试错的环境至关重要,编程的精髓是“创造”,等等,希望能对你有所启发。

结语

以上就是“程序员阅读清单”第三期的全部内容,祝你阅读愉快!

题图来源:Photo by Jametlene Reskp on Unsplash