普通视图

发现新文章,点击刷新页面。
昨天以前Python 工匠

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

2026年3月20日 17:31

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年2月10日 12:04

使用 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

现在是 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() 对象,是最为理想的默认值选择。

设计服务端软件配置的 4 条建议

2022年5月13日 10:50

在设计和开发服务端(后端)软件时,配置文件是一个绕不开的话题。

配置文件是一种用于存放各类可配置项的特殊文件。每个软件都会预设一些默认配置,但这些默认值不可能适用于所有情况。因此,到了不同的环境中,我们常常需要用配置文件对其进行扩展和修改。

拿版本控制软件 git 举例。大部分用户的 home 目录(~)都存放着一份 .gitconfig 配置文件,里面写着自己的用户名和邮箱地址:

[user]
    name = piglei
    email = piglei2007@gmail.com

就像软件的任何一个主要功能一样,配置文件也会极大的影响软件的使用体验。良好的设计能让软件变得更易用,糟糕的设计则会带来许多意想不到的问题,将用户拒之门外。

在这篇文章中,我整理了 4 条关于“软件配置文件”的设计建议,希望能对你有所帮助。

1. 最好不给配置也能用

在网络世界里,每过一天,人们的耐心似乎就又比前一天减少了一丁点。一个制作精良的短视频,如果 3 秒钟之内无法抓住你,你的右手拇指就会条件反射般将它划走。

现在,假设你开发了一个非常有用的工具软件,并发布到了网上。软件的功能非常全面,所有人在使用它之前,需要编辑一份包含 20 项配置的配置文件。你猜,有多大比例的潜在用户会直接掉头走掉?

当我们想要一件东西时,总是一刻也不想等。因此,初始的配置过程麻烦与否,会强烈影响人们尝试软件的决心。在这方面,我认为最好的体验是:无需提供任何配置,便能直接使用软件 80% 以上的功能。

假如无法做到这一点,我们也应该试着从以下几个方面着手,尽量降低用户的配置成本,压缩从“开始安装”到“可使用”之间的等待时间。

1.1 预设合理的默认值

为了将必须由用户提供的配置项,压缩到最少。你得给软件的所有配置项预设一个足够合理的默认值。这些默认值应当能让尽可能多的用户满意。

举个例子,假如你的软件依赖一个可用的 Redis 服务。那么请将该项配置的默认值设为约定俗成的缺省地址: redis_url: redis://localhost:6379/0。这样,当用户的机器上刚好跑着一个 Redis 服务时,便可免去调整这个配置项的麻烦。

1.2 延迟部分配置项

软件复杂到一定程度后,可配置项也常常会多到令人咋舌。许多配置在开发者的眼里,常常都显得特别关键,无法通过预设默认值来简化。此时,想让用户少填一些配置,似乎成了不可能完成的任务。

面对这种情况,我们可以尝试把配置项拆分为两种类型:最少配置项其他配置项。用户在使用软件时,只需提供一份包含最少配置项的文件。剩下的配置,可延迟到使用软件时,通过友好的交互界面要求用户填写。

举个例子,你提供的软件包含一个 Web 站点。要使用它,用户只需提供 1 个配置项:MySQL 数据库地址即可。之后,他可以通过浏览器访问站点,逐步补完其他功能所需的剩余配置项。

2. 永远别默认“管理员密码”

在给配置项预设默认值时,有一点很容易做过头——将关键的安全类配置设置固定的默认值。举个例子,你的项目在第一次启动时,会创建一个具有最高权限的内置管理员角色。为了降低用户的配置成本,该角色的密码被硬编码了一个默认值:

# 重要:请在你的配置文件里修改该默认密码,否则将会带来严重
# 的安全风险。
SUPER_USER_PASSWORD = "proje2p#admin@321"

一旦系统设置了一个默认密码,那么 90% 以上的用户绝对不会去修改它——无论提醒文字说的多危险都没用。当软件传播开来后,这个固定密码会给许多黑客提供便利,带来灾难性的后果。

除了密码以外,任何秘钥类的配置也都不应该提供固定的默认值。你应该总是要求用户手动配置它们。或者,在软件第一次启动时,生成一个随机值也行。做法如下所示:

# 成功安装软件后,弹出提示文字:

恭喜,你已经成功安装了 nice_software。因为你没有在配置里提供
管理员密码,该值已经被设置为随机值:fuiwe2shdvwi23

在之后的流程里,你可以再次要求用户输入该随机密码进行验证,并提供一个新密码。这样也能有效降低安全风险。

除了密码和秘钥外,Web 服务启动时默认绑定的网卡设备,也很容易制造出安全漏洞。假如某软件在启动时,默认绑定所有的网卡接口,那么当用户在携带公网 IP 的机器上安装软件时,服务可能会在毫无准备的情况下被暴露给外网,风险极高。具体可参考 Redis 配置文件中的例子

3. 描述性文本格式优先

在一些使用了动态编程语言(比如 Python、Ruby、Lua 等)的项目中,常常会出现一种独特的配置文件格式:源代码文件

举个例子,假如你有一个项目,是用 Django 框架开发的,那么你在分发项目时,完全可以支持用户提供一个 Python 脚本来作为配置文件。

# 配置文件 my_settings.py 内容
CONTACT_USERNAME = 'contact_us'
CREATE_DEFAULT_ADMIN = True
ATTACHMENTS_DIR = '~/.attachments'

# 启动项目
$ start_project -s ./my_settings.py

第一眼看上去,这种做法非常不错。如果你四处转转,甚至能发现不少开源项目都在这么干——比如 Sentry 在使用 PythonGitLab 在使用 Ruby。在灵活性方面,源码和常见的描述性文本文件(YAML、JSON 等)有着天壤之别。采用源代码格式,意味着用户能通过编写代码,折腾出令人眼花缭乱的复杂配置。

但问题是,对于配置文件来说,灵活性(或者说“可编程性”) 并不是最重要的评价指标。要评价一份配置文件设计的好坏,是否“简单易懂”、是否“易于修改”,每个配置项是否有详尽的说明才是最重要的。而对比源代码和普通描述性文本文件,你会发现前者在这些维度上并不占优。

比如,不论你选了用哪门编程语言作为配置文件格式。当用户想使用你的软件时,就非得了解一些基本语法和数据结构不可。这很可能就会把一些不热衷于此的人拒之门外。

用源代码作为配置,在某些角度上还会鼓励用户把复杂的逻辑塞进配置文件里。有时这不算什么问题,但也有些时候——尤其是当用户需要同时管理许多份配置文件时,这些藏在配置里的复杂代码逻辑,会让维护变得尤其困难。

如上所述,在满足项目需求的前提下,你应该尽量选择简单的描述性文本格式作为配置文件——YAML、TOML、JSON 都行。它们虽然不如代码灵活,但胜在上手简单。

另外,即使同为文本格式,灵活性也各有差异。当你需要从多种格式中挑选一个时,选更简单、更灵活的那个就行。也就是说,如果 TOML 和 YAML 都可以,那就用 TOML 吧。

配置文件结构

在采用了文本格式作为配置文件后,一般项目的配置会形成类似下图这种三层结构:

说明:

  1. 最左侧的主配置模块:由项目开发者维护,负责提供默认配置,同时也包含绝大多数“智能”的配置生成与简化逻辑
  2. 中间部分是用户在使用软件时,需要提供的个性化配置文件
  3. 如环境异常复杂,用户亦可针对具体场景,开发工具来辅助管理和操作配置文件,降低维护成本

4. 部分或完全支持环境变量

The 12-factor App 是一个非常流行的应用架构建议。它推荐使用环境变量来作为应用配置来源,并认定这就是最好的配置管理模式——比任何文件格式都要好。

和配置文件比起来,环境变量确实有着一些天生的优势:比如门槛低、易于修改、操作系统无关性,等等。在 Kubernetes 等云原生平台上,使用环境变量配置服务尤其方便。

正因如此,除了支持从文件中读取配置项以外,你的软件最好也允许用户通过环境变量来修改配置项。

一个示例的配置文件 sample.TOML

contact_username = 'piglei'

同样的配置项,也可通过环境变量来设置:

export CONTACT_USERNAME="piglei"

要实现这个功能,你可以直接在项目中读取环境变量的值:

# config.py
import os

# 优先读取环境变量,而后是配置文件
contact_username = os.environ.get('CONTACT_USERNAME', toml_config['contact_username'])

如果你觉得手动读取环境变量太麻烦,每种编程语言一般都有各自流行的配置管理库,其中一些就支持同时管理文件和环境变量里的配置。比如 Python 的 dynaconf、Go 的 viper 模块,都非常好用。

总结

本文分享了一些设计服务端软件配置的经验之谈,我认为其中最重要的一点,就是认识到:用户总是希望立马用上软件,而不是先折腾半天配置。

希望我们能更积极地思考如何优化配置设计,为软件增添更多色彩。

用 Python 编程 13 年后,我把经验写成了 400 页的书

2022年3月2日 09:21

我写了一本 Python 语言的编程进阶书:《Python工匠:案例、技巧与工程实践》,本文是关于这本书背后的故事与一些致谢。

cover

我一直觉得编程某种意义上是一门“手艺”,因为优雅而高效的代码,就如同完美的工艺品一样让人赏心悦目。就像一位用锤子敲敲打打了十几年的铁匠,总结出一套“如何锻造出锋利的刀”的经验之谈——我在代码世界也“敲敲打打”了十几年,沉淀下来的便是这本《Python工匠》。

我将自己这些年掌握的技巧、经验与编程建议,去粗取精后悉数写入书中,希望它能帮读者朋友们完成从初学者到工匠的跃迁。

书目前已经正式上市,欢迎点击购买

本文主要内容来自书中的“前言”部分,略有删改。

结缘 Python

我初次接触 Python 是在 2008 年末。那时临近大学毕业,我凭着在学校里学到的一丁点儿 Java 知识四处求职。我从大学所在的城市南昌出发去了北京,借宿在一位朋友的出租屋里,他当时在巨鲸音乐网上班,用的主要编程语言正是 Python。

得知我正在寻找一份 Java 相关的工作,那位朋友跟我说:“写 Java 代码有啥意思啊?Python 比 Java 好玩多了,而且功能还特别强大,连 Google 都在用 !”

在他的热情“传道”下,我对 Python 语言产生了好奇心,于是找了一份当时最流行的开源教程 Dive into Python,开始学起 Python 来。

实话实说,之前在学校用 Java 和 C 语言编程时,我很少体会到编程的快乐,也从未期待过自己将来要以写代码为生。但神奇的是,在学了一些 Python 的基础知识,并用它写了几个小玩意儿以后,我突然意识到原来自己很喜欢编程,并开始期待找到一份以 Python 为主要编程语言的开发工作——也许这就是我和 Python 之间的缘分吧!

幸运的是,在当时的 CPyUG(中国 Python 用户组)邮件组里,正好有一家南昌的公司在招聘全职 Python 程序员。看到这个消息后,我立马做出了决定:结束短暂的“北漂”生活,回到学校准备该职位的面试。后来,我成功通过了面试,最终在那家公司谋得了一份 Python 开发的实习工作,并从此开启了后来十余年的 Python 编程生涯。

为什么写这本书

回顾自己的从业经历,我从中发现一件有意思的事:编程作为一项技能,或者说一门手艺, 给新手带来的“蜜月期”非常短暂。

一开始,我们对一门编程语言只是略懂些皮毛,只要能用它实现想要的功能,就会非常开心。假如再学会语言的一些高级用法,比如 Python 里的装饰器,把它应用在了项目代码里,我们便整天乐得合不拢嘴。

但欢乐的时光总是特别短暂,一些类似的遭遇似乎总会不可避免地降临到每个人头上。

在接手了几个被众人称为“坑”的老项目,或是亲手写了一些无人敢接手的代码后;在整日忙着修 bug,每写一个新功能就引入三个新 bug 后……夜深人静之时,坐在电脑前埋头苦干的我们总有那么一些瞬间会突然意识到:编程最初带给我们的快乐已悄然远去,写代码这件事现在变得有些痛苦。更有甚者,一想到项目里的烂代码,每天起床后最想干的一件事就是辞职。

造成上面这种困境的原因是多方面的,而其中最主要、最容易被我们直观感受到的问题就是:烂代码实在是太多了。 后来,在亲历了许多个令人不悦的项目之后,我才慢慢看清楚:即便两个人实现同一个功能,最终效果看上去也一模一样,但代码质量却可能有着云泥之别。

好代码就像好文章,语言精练、层次分明,让人读了还想读;而烂代码则像糊成一团的意大利面条,处处充斥着相似的逻辑,模块间的关系错综复杂,多看一眼都令人觉得眼睛会受伤。

在知道了“代码也分好坏”以后,我开始整日琢磨怎么才能把代码写得更好。我前前后后读过一些书——《代码大全》《重构》《设计模式》《代码整洁之道》——毫无疑问,它们都是领域内首屈一指的经典好书,我从中学到了许多知识,至今受益匪浅。

这些领域内的经典图书虽好,却有个问题:它们大多是针对 Java 这类静态类型语言所写的, 而 Python 这门动态类型的脚本语言又和 Java 大不一样。这些书里的许多理念和例子,假如直接套用在 Python 里,效果不尽如人意。

于是,话又说回来,要写出好的 Python 代码,究竟得掌握哪些知识呢?在我看来,问题的答案可分为两大部分。

  • 第一部分:语言无关的通用知识,比如变量的命名原则、写注释时的注意事项、写条件分支语句的技巧,等等。这部分知识放之四海而皆准,可以运用在各种编程语言上,不光是 Python。
  • 第二部分:与 Python 语言强相关的知识,比如自定义容器类型来改善代码、在恰当的时机抛出异常、活用生成器改善循环、用装饰器设计地道的 API,等等。

当然,上面这种回答显然过于简陋,略去了太多细节。

为了更好地回答“如何写出好的 Python 代码”这个问题,从 2016 年开始,我用业余时间写作了一系列相关的技术文章,起名为“Python 工匠”——正是这十几篇文章构成了本书的骨架。此外,本书注重故事、注重案例的写作风格也与“Python 工匠”系列一脉相承。

如果你也像我一样,曾被烂代码所困,终日寻求写好 Python 程序的方法,那么我郑重地将本书推荐给你。这是我多年的经验汇集,相信会给你一些启发。

推荐语

致谢

在我写作“Python 工匠”系列的过程中,许多媒体转发了我的文章,帮助提高了整个系列的影响力。它们是“腾讯技术工程”知乎专栏董伟明(@dongweiming)的 Python 年度榜单, 以及以下微信公众号:“蓝鲸”“Python 猫”“Python 编程时光”“Python 开发者”“腾讯 NEXT学院”等。由于名单过长,如果你的媒体也曾转发过“Python 工匠”系列,但没有出现在上面的列表中,还请见谅。

感谢我的前同事与朋友们。当我在朋友圈转发“Python 工匠”系列文章时,他们总是毫不吝惜地给予我赞美与鼓励。虽然受之有愧,但我的确深受鼓舞。

特别感谢我在腾讯蓝鲸团队的所有同事与领导,他们在我写作“Python 工匠”系列的过程中, 提供了许多积极反馈,并且不遗余力地转发文章。这些善意的举动,为本书漫长而充满磨炼的写作过程,注入了强大的驱动力。

感谢参与审阅本书初稿的所有人。他们中有些是我相识多年的同事与朋友,更多则是我从未谋面的“网友”。因慕名各位在开源世界的贡献,我邀请他们审阅本书内容,无一例外, 所有人都爽快地答应了我的请求,并围绕本书的内容和结构提出了许多精准的修改意见和建议。他们是赖信涛(@laixintao)李者璈(@Zheaoli)林志衡(@onlyice)王川(@fantix)laike9m冯世鹏(@fengsp)伊洪(@yihong0618)明希(@frostming)李卫辉(@liwh)

尾声

《Python 工匠》是我的第一本书,因此,它对我而言意义重大。虽然在写作过程中,我已经竭尽所能地让它容易阅读、容易理解,并努力做到只将那些“最有用”的知识置入其中。但我也深知自身能力有限,无法处处做到完美。假如你在阅读本书时,对内容有所疑问或发现错漏,欢迎通过邮件与我交流(我的邮箱是:piglei2007@gmail.com)。

人们在讨论 Python 时,常常评价它是一门实用的编程语言。在我看来,“实用”这个评价其实相当高。所以,希望在几个月后,大家在谈到 Python 相关的书籍时,也能有一两个声音说:“《Python 工匠》是一本实用的编程书。”那样对我而言便是最好的鼓励了。


直接购买:

购买《Python 工匠》(京东)

下载样章:

访问《Python 工匠》的图灵社区主页,可下载样章 PDF 文件试读。

久等了,我的新书《Python工匠》开始预售

2022年2月15日 07:13

cover

引子

2008 年末,北京奥运会刚刚结束后不久。我——一名计算机专业的大四学生,因为一位朋友的极力推荐,开始试着把 Python 作为自己的第一编程语言。大学毕业后,我顺利成为了一名 Python 程序员,先后在搜狐和赶集网工作过,后来加入了深圳腾讯,目前担任蓝鲸工具 PaaS 平台的负责人。

在这十几年的职业生涯里,我参与过许多项目的后端开发与架构设计工作。虽然每个项目的复杂程度、活跃用户量各不相同,但它们大都使用 Python 语言编写。

在我看来,Python 是一门“易于上手,难于精通”的编程语言——虽然许多人都能用 Python 编写出可运行的程序,但只有少数人,掌握了写出高质量 Python 代码的诀窍。

而在现实世界中,代码质量常常会决定项目的成败。我见过许多被寄予厚望的大项目,前期光彩夺目,后期却躺在烂代码堆上逐渐腐败,缓慢走向死亡。我也参与过许多老项目,亲手偿还过代码质量方面的“技术债”,助它们重获生机。

“Python 工匠”的由来

慢慢地,我积累的 Python 编程经验越来越多,于是有了一份想把它们分享出去的冲动。后来,实在压抑不住那份冲动,便试着把这些经验落笔成了文章,日积月累,开源的技术文章系列:“Python 工匠” 就此诞生。

2020 年中,我开始思考如何把“Python 工匠”系列文章,升级成一本以“Python 进阶”为主题的编程书。虽然在当时的市面上,Python 进阶图书可谓汗牛充栋,又有《流畅的 Python》、《Effective Python》等珠玉在前,似乎没有再多一本“中文 Python 进阶书”的必要。

但也许因为自己是湖南人,骨子里有股“蛮”劲吧——总觉得自己写的东西独一份,能给读者一些不一样的体验。下定决心后,开始闭门写作,一年后终于成稿,之后便看着书稿在传统出版流程中缓慢流动。

今天,我很高兴地告诉大家,图书《Python 工匠:案例、技巧与工程实践》已经走完了出版前的所有准备工作,马上就要上市了。你在京东上就可预订本书,现在下单还可享受 8 折优惠。

图书简介

《Python 工匠:案例、技巧与工程实践》是一本编程进阶图书,旨在帮你掌握如何用 Python 写出结构清晰、易于维护的好代码。但与传统进阶书不同,它不是一本大而全的“语言高级特性参考手册”,而更像是一份“答卷”,一份我针对问题 “如何写好 Python 代码?” 交出的答卷。

答卷中的所有知识点,大体可分为两大部分:

  • 第一部分:语言无关的通用知识,比如变量的命名原则、写注释时的注意事项、写条件分支语句的技巧,等等。这部分知识放之四海而皆准,可以运用在各种编程语言上,不光是 Python。
  • 第二部分:与 Python 语言强相关的知识,比如自定义容器类型来改善代码、在恰当的时机抛出异常、活用生成器改善循环、用装饰器设计地道的 API,等等。

在表达形式方面,就像副标题“案例、技巧与工程实践”所说,全书的知识点会通过好几种不同的形式展现。你会读到一些深入语言核心的概念释疑,也会读到一些贴近工程实践的编程建议,在某些章节,你甚至还会发现一些像电子游戏一样有趣的案例故事。希望它们能为你带来些许不一样的感受。

当然,要了解一本书,看再多介绍文字,都不如读一段书中的内容来得真切。你可以访问《Python 工匠》的图灵社区主页,在“随书下载”部分找到《Python 工匠》的样章 PDF 文件。不妨先读读看。

“这本书适合我吗?”

在我看来,《Python 工匠》最适合以下人群阅读:

  • 以 Python 为主要开发语言的工程师
  • 工作中需要写一些 Python 代码的工程师
  • 有其他语言编程经验、想学习如何写出高效 Python 代码的工程师
  • 任何爱好编程、喜欢 Python 语言的读者

全书内容以进阶知识为主,里面虽有少量基础知识讲解,但并不全面,描述得也并不详尽。正因如此,假如你从未有过任何编程经验,我并不建议你通过本书来入门 Python。

在 Python 入门学习方面,我推荐由人民邮电出版社图灵公司出版的《Python 编程:从入门到实践》。当你对 Python 有了一些了解、打好基础后,再回过头来阅读本书,相信彼时你可以获得更好的阅读体验。


好了,关于《Python 工匠》的预售信息先介绍到这。感谢所有朋友一路以来对“Python 工匠”的支持,期待你拿到书后,第一时间与我分享你的阅读体验。再会!

技术写作二三事:原创情结

2021年12月4日 12:18

    2019 年的春节假期,我闲在家中,准备一次性刷完攒了大半年的“Python Weekly” 周报。在 6 月份的一封周报里,有一篇名为 “Python Idioms: Multiline Strings” 的文章,内容很精炼。前半部分先描述痛点:在 Python 中写多行字符串字面量,随后演示如何用 textwrap.dedent 函数来优化它。

    部分内容摘录如下:

    ... The problem is that it’s just ugly, because indenting the strings actually inserts the indentation into the string. So you have to do this:

    def create_snippet():
        code_snippet = """\
    int main(int argc, char* argv[]) {
        return 0;
    }"""
    

    With dedent, we can indent the whole multiline string according to the current scope, so that it looks like a Pythonic code block...

    巧合的是,我两个月前刚在博客上发布了“Python 工匠”的第 3 篇文章:“使用数字与字符串的技巧”。在这篇文章中,同样出现了 textwrap.dedent 函数。

    我写的内容:

    日常编码时,还有一种比较麻烦的情况。就是需要在已经有缩进层级的代码里,插入多行字符串字面量。因为多行字符串不能包含当前的缩进空格,所以,我们需要把代码写成这样:

    def main():
        if user.is_active:
            message = """Welcome, today's movie list:
    - Jaw (1975)
    - The Shining (1980)
    - Saw (2004)"""
    

    但是,这样写会破坏整段代码的缩进视觉效果,显得非常突兀。你可以用标准库 textwrap 里的 dedent 函数来解决这个问题...

    看出来了吗?我的写作思路和代码样例,与前面那篇文章如出一辙。

    现在听上去或许有点可笑,但当时我的心情相当沮丧。为什么?因为我那时是个“原创情结”特别强的人。每次写作前,我一定要确认自己没看过类似的内容,才会下笔。也就是说,假使我在写作前一天读过 “Python Idioms: Multiline Strings”,那么 textwrap.dedent 十有八九不会出现在我的文章里。

    我说不清这种拧巴的“原创情结”从何而来。似乎从十几年前,我的博客上线后的第一天起,我就已经这样了——如果有一个技术点,别人已经写的足够好了,那么我打死都不会再写一遍。但结果你们已经知道了——懂 textwrap.dedent 技巧的人远不止我一个。所谓“写出原创内容”带给我的那点虚荣感,就像小朋友吹出的肥皂泡一样,一触即破。

    表面上看来,“原创情结”对作者有好处。它让后者对自己要求更高,不至于心甘情愿沦为知识的搬运工(或更恶劣一点:沦为抄袭者)。

    但实际上,对于写作者(尤其是技术写作者)来说,“原创情结”带来的坏处远大于那一丁点好处。因为对“内容是否原创”的过度痴迷,会彻头彻尾地消灭一个人的创作冲动。“原创情结”像是一条无形的锁链,我们被它牢牢捆在“一举成名天下知”的幻梦中,无法挣脱。

    同其他领域相比,在技术写作领域追求原创性尤其困难。在 StackOverflow 上,每天都有上百万的技术大佬,慷慨分享着自己的技巧和经验。 在各种独立博客和云厂商(点名 DigitalOcean)的知识库里,每天都有无数高质量的技术文章新鲜出炉,足以让人眼花缭乱。在这样的创作密度下,要找到一片没有其他人开垦过的处女地,难度可想而知。

    在行业摸爬滚打了十几年后,我如今只有一个感触:装在自己脑子里的技巧、概念和知识,几乎没有哪一样可称得上是原创。 有时候,我以为自己写了一些颇具原创性的观点。结果过几天听英文播客时,居然从访谈嘉宾那听到了一模一样的内容——翻译成中文后,与我的原文几乎只字不差。

    假如我们诚心认为自己掌握的某项知识是原创,那或许只是因为读的还不够多、不够广、不够深。除此之外,还有一种可能,那就是我们忘记了知识来自何处。

    拿文章开头的 textwrap.dedent 来说。如果仔细回忆,我也许会想起在某个工作日的午后,我随意打开了某个开源库的代码,发现了这个小技巧,并记住了它。光阴荏苒,经过了足够长的时间后,我逐渐忘记了那段经历——这个小技巧被我据为己有,放进了大脑里的“原创知识”那一栏。

    所以,技术文章的原创性,大多数时候只是作者们的幻觉。现在的我在写东西前,不会再考虑任何“内容是否足够原创”之类的问题。我唯一遵循的原则,就是绝不有意偷窃其他人的写作成果。

    许多时候,我们想动笔写点技术文章,即不是为了硕士毕业证,也不是为了申请国家专利,我们只是想记录与分享一些朴素的经验而已。既然如此,要那么高的“原创度”顶个球用?

    如果你常年琢磨着写一些技术文章,却被“原创情结”所束缚,无法下笔。每年一到年底,打开自己的博客一看,总是空空如也。我可以尝试给你一些建议:

    • 在某种意义上,任何创作都是有价值的。因为价值是由读者定义的,和作者从哪儿(原创还是抄袭?)得到的创作灵感无关
    • 你不重要,你写的东西也没有那么重要。没人期待在你的文章里,学到任何独家秘笈,别有任何心理负担
    • 假如从内容上无法创新,也可以在形式上做一些尝试。有些文章的内容光芒璀璨,却常因原作者糟糕的写作形式和文字技巧被埋没
    • 假如遇到一个你感兴趣的主题,其他人已经写过了,但你觉得你能写的比他更好,何不试一试?

    上面这些话,有时我也会用来宽慰自己。

    所以,别再纠结于原创与否,打开电脑,随便写点啥吧。“某个诡异的线上 Bug”;“Python 的 5 个面向对象小技巧”;“详解 Kubernetes 的某个配置项” ,所有主题都是好主题。不必想着写出什么惊世佳作,坚持写,我们总会慢慢进步。

    也许某一天,你在写了许多东西以后,抬头一望,会看到一片瑰丽的新大陆,正向你徐徐展露。

    译:拥抱苦差事

    2021年10月1日 11:15

      原文:"Embrace the Grind" by Jacob 原文链接:https://jacobian.org/2021/apr/7/embrace-the-grind/

      我以前见过一种纸牌魔术,至今仍念念不忘。那魔术简单来说是这样的(为了清楚起见,我在这做了进一步的简化):找一位志愿者,让他挑选一张扑克牌放进信封并封好。然后魔术师会邀请志愿者挑选茶叶。茶叶共有好几十箱,全被塑料膜密封了起来。志愿者选定一个箱子后,撕掉包装膜,从里面挑一个同样密封着的装着茶包的袋子。而当他撕开袋子时……他选的那张牌就在里面。

      ⚠️ 如果你不想知道魔术背后的秘密,马上停止阅读。

      这魔术的秘密平淡无奇,却令我兴奋不已。首先,志愿者选牌的过程运用了迫牌技巧(force) (译注:指魔术师运用特殊手法,让选择看似随机,但最终指向同一个结果)。但从那几打箱子里选一个,则是实打实的自由选择,从箱子里挑茶包也是一样。这一步没有任何花招:魔术师不会碰到志愿者选的箱子或茶包。那张扑克牌是确确实实的躺在被密封的茶叶袋里。

      技巧全在准备工作里。在表演这个魔术前,魔术师会购买好几十箱茶叶并全部打开,拆开里面的每一袋茶叶。然后,再往每个袋子里放入一张梅花 ♣️3 并重新封上,再放回箱子里。这个过程要重复好几百遍,准备工作一共得花上许多个小时——甚至许多天。

      这魔术里唯一的“诡计”,在于它所需要的准备工作实在太无聊,太令人乏味了,以至于我们观看到表演效果时,根本想象不到会有人会为了这个简单的效果,完成那么无聊的工作。

      魔术师 Teller 写过一篇文章 “seven secrets of magic”,专门讲了这个事情。

      如果某个魔术所需的时间、金钱或练习的次数,比你(或任何一个普通人)所愿意付出的更多,那你就会被骗到。我和我的搭档 Penn,曾经从脱口秀主持人大卫莱特曼的桌子上的一顶绅士帽里,变出过 500 只活蟑螂。准备这个魔术得花上许多周。我们聘请了一位昆虫学家,他给了我们一批行动迟缓、不怕摄影镜头的蟑螂(你家橱柜下的那种在特写镜头下可待不住),还教会我们如何抓起这些虫子(不至于像小姑娘们那样尖叫)。然后我们用泡沫塑料(蟑螂无法附着的少数几种材料之一)做了一个小隔间,并想了个狡猾的办法把隔间偷偷放进了那个帽子里。为这戏法搞得这么麻烦是不是不值?对你来说,也许是,但对魔术师们来说不是。

      经常有一些做技术的新人问我成功的秘诀。其实没多少秘诀,真的,但这个秘诀——愿意做一些乏味透顶的事情,以至于看起来像魔术一样——在信息技术行业一样适用。

      我们身处一个痴迷于自动化、精简和效率的行业。Larry Wall (译注:Perl 编程语言的作者)写的“程序员的美德”,是一段有关我们的工程师文化的奠基性文字,它里面这么描述“懒惰(Laziness)”:

      懒惰:这种品质让你费尽心思降低整体能量消耗。它驱使你写出别人觉得有用的省力程序。也驱使你为你的东西编写文档,那样你就不用回答太多有关它的问题。

      我并非不同意:让程序搞定重复性的工作,是懂得编程后能做的最棒的事情之一。但是,有时候问题是没法被自动化解决的。而如果你愿意拥抱这种苦差事,那你看起来就会像一位魔术师一样。

      举个例子,我以前加入过一个团队,团队负责维护一个被太多缺陷(bug)淹没了的系统。那时,一共有大约 2000 条处于开放状态的缺陷报告,它们没有标签,没有分类,也没有排任何优先级。对于应该处理哪些问题,团队成员根本无法达成共识。他们基本上被困于随机处理 bug 的过程中——而根本弄不清楚那个 bug 是否重要……新来的缺陷报告也无法被有效分流,因为查找重复报告几乎没法做。所以,开放状态的单据总数越来越高,整个团队好几个月都停滞不前。我的任务是解决这个问题:让团队恢复运作,降低开放状态的单据数量,并找到最终将其降低为零的办法。

      于是,我用了和魔术师一样的技巧,也就是没有任何技巧:我就是把活干了。我把所有的缺陷报告打印了出来——一个议题一页纸。我把每张纸都读了一遍,找了个大房间开始叠放在地板上。我在便签纸写上标签,然后把它贴在纸堆上。我把那些纸在不同纸堆间移来移去,把议题编号写在白板上,变成长长一列;我把自己想象成了电影《会计刺客》里的主角本·阿弗莱克。我在那个房间了待了约三个星期,出来的时候,每个缺陷报告都被重新审阅、打了标签、分了类,以及定好了优先级。

      做完这件事情后,形势马上发生了逆转:我们能够立即关闭好几百张重复了的单据,新议题只需几分钟就可以完成分流,而不是一天。我记得缺陷议题的总数降到 0 花了大约一年时间,但整个过程相当顺利。大家都说,我完成了不可能完成的任务,但那么说并不对:我仅仅只是做了一些过于乏味,其他人根本不愿意做的事。

      有时,编程就像魔法一样:你念出一些神秘的咒语,一队机器人就会听命于你。但也有时候,魔法其实平凡而乏味。如果你愿意拥抱苦差事,你就可以完成不可能完成的任务。

      再见,面包旅行!

      2025年10月17日 12:00

      2012 年初,北漂的我抱着满心憧憬加入了一家创业公司,和另一位同事一起成了公司唯二的两名后端程序员。这家公司当时的产品是个名叫“遨游记”的移动 App,后来它变成了现在的 面包旅行

      在面包旅行的几年,我遇到了许多优秀又有趣的同事,写了一打又一打的代码,把一头黑发写的花白(不夸张,后来又黑回来了)。我和许多同事一起,见证了“面包旅行 Appstore 首发”、“安卓版首发”、“苹果年度推荐”等诸多高光时刻,亲身体会到了做为一名程序员最大的幸福——看到自己的代码服务了百万级的用户,为无数人的旅途带来了些许不同的体验。

      后来因为家庭原因,我选择在两年半以后离开了面包旅行。虽然我之后的工作和在线旅游行业再无半点关系,但在三里屯 SOHO 工作的那三十个月,永远是我职业生涯里最为有趣的一段经历之一。

      此时此刻,我猜在面包旅行的服务器上,应该仍然运行着我曾写过的一些代码——“POI 与航班查询”、“游记推荐管理平台”、“用户积分系统”,等等。但我想等到半年以后,等到面包旅行真正关站的那一刻,我曾写过的哪些函数和类便会迎来真正的死亡,同无数其他代码一起消失在互联网的滚滚长河之中。

      有些事情,你明知它迟早会来,但等它真的发生以后,你还是难免会感到突然,觉得难过。对我来说,面包旅行不止是一个游记 App,在它上面,凝聚着太多我和许多同事曾有过的灵感、快乐与骄傲。

      看到关站公告,心中纵有万般不舍,其实也清楚,这个小小的游记 App 能在残酷的移动互联网时代存活下来,一直运营到今日已实属不易。

      最后,让我用自己的方式和面包旅行再道个别。

      from __future__ import print_function
      
      print("Hello, breadtrip!")
      print("Bye, breadtrip!")
      

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

      2025年6月3日 17:49

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

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

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

      让我们通过 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年3月6日 07:52

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

      作为“爱分享”的程序员中的一份子,我想当一次推荐人,将读过的好文章分享给大家。我给这个系列起名为 《程序员阅读清单:我喜欢的 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

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

      2024年10月23日 08:36

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

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

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

      下面是阅读清单的第二部分,包含第 21 到 40 篇文章。

      系列索引:

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

      清单

      21. 《人生短暂》

      人生很短,到底该如何花费自己的时间?传奇投资人、程序员 Paul Graham 在文章中给出了他的建议。总结起来,一共 3 条:尽你所能地避免 bullshit 类事务,比如无用会议、网上吵架;对重要的事情不拖拉,意识到有些东西不会永远停在原地等你;珍惜你所拥有的每一滴时间。

      从任何角度看,上面这些建议都称不上有多新奇。但是,作者通过真诚地分享自身经历和感受,给内容注入了不一样的灵魂。或许你会像我一样,读后能获得一些新的感悟。

      22. 《有“产品意识”的软件工程师》

      从事程序员越久,你大概率会越来越频繁地听到一个词:“产品意识”。人人都说产品意识好,但是它看不见摸不着,到底是个什么东西?是指程序员该自己画线框图?还是说程序员应该写用户故事?

      本文作者以软件工程师的视角,对“产品意识”做了全面的解读。简单来说,产品意识就是关注产品、对产品拥有好奇心、对用户拥有同理心;有产品意识的人在做技术方案时,不光思考工程角度,更能靠全局的“产品+工程”视角思考决策。

      “产品意识”——工程师们最为强大的思维杠杆之一。

      23. 《Python 的 range 不是迭代器》

      range 是 Python 语言中最常用的内置对象之一,功能是生产一段数字序列,比如 range(10) => 0, 1, ..., 9。作为循环语句中被迭代的常客,range 常被误认为是一种迭代器(iterator)。但是,正如文章标题所说,虽然可被迭代,但 range 却并不是迭代器。

      可如果不是迭代器的话,range 究竟是什么?在文章中,作者用精要的说明和代码片段做出了解答。看起来像咬文嚼字,实则是相当重要的 Python 基础概念。

      😊 有关迭代器和可迭代对象这个主题,我也很推荐另一篇自己写的内容:《Python工匠》第六章 6.1.1 “迭代器与可迭代对象”

      24. 《有关 TLS/SSL 证书的一切》

      一篇和证书有关的科普文。

      虽是科普,但这篇和其他科普文章不太一样。你除了能读到一些轻松愉快的小故事,还会被一些不知从哪里冒出来的 shell 命令和大段伪代码“突然袭击”。看似不协调的素材,在作者的精心编排下,却如交响乐团般演奏出一段优美流畅的乐章,让人读来如沐春风。

      25. 《让困难的事情变容易》

      也许是胡说八道,但我还是想说:技术人普遍有一种“复杂崇拜”情结。实践一门技术,人们常常会踩进许多坑、遇到很多困难,但大部分人对此绝口不提,仿佛抱怨一门技术过于复杂,会显得自己能力不足似的。

      尤其,当这些技术是大家口中公认的“基础技术”(比如 DNS、HTTP)时,更是如此。技术人接受复杂、理解复杂,最终认同复杂为理所当然。

      正因如此,我很喜欢 Julia Evans 的这个分享。它指出在许多所谓的“基础技术”背后,隐藏着太多难以掌握的复杂元素。不少人都会在它们上面栽跟斗,但并非所有人都会站出来,改善现状。

      所以,我们需要让复杂事物变得更容易。针对这一点,文章挑选了几种有代表性的技术,比如 DNS、BASH、SQL 等,提供了切实可行的建议,包括:分享有用的工具和参考文档、从大的功能列表中筛选你真正使用的、展示不可见的内容,等等。

      26. 《The Hiring Post》

      作者在一家名为 Matasano 的安全公司任职。一天,他接到一份报告,其中描述了一种针对 DSA 的新型攻击手法。由于步骤复杂、条件苛刻,作者认为这种攻击方式有些不切实际,难以实施(时间以月为单位计算)。不过,他还是把报告分享到了团队中(忘了提及“不切实际”)。

      两天后,团队里一位名叫 Alex 的新人找到他,说自己完成了一个可工作的漏洞利用程序。

      Alex 非常优秀,但是,如果把时间拨回几年前,他根本不会被招进公司。他的简历平平无奇,而当时公司依赖简历和面试来招聘人才。直到后来,Matasano 公司优化了招聘策略,才挖掘出越来越多像 Alex 的人才。

      接着开篇的小故事,作者探讨了技术行业在人才招聘方面的一些问题。比方说,许多能力出众的候选人常因招聘环节不合理而无法通过面试。与之相对的是,一些善于面试、对抽象概念总能侃侃而谈的人,却能轻松拿到 offer。针对这些问题,文章给出了一些建议,比如:让候选人热身、使用接近工作场景的测试问题,等等。值得一读。

      27. 《13 年后,我如何用 Go 写 HTTP 服务》

      一篇 Go 语言方面的最佳实践类文章,只涉及标准库中的 HTTP 基建,不涉及其他第三方 Web 框架或库。作者有十余年的 Go 编程经验,经验丰富。

      文章除了展示具体的代码编写与组织技巧,也谈了一些“为什么如此处理”背后的设计考量,包括:长参数列表的函数、请求编解码处理、用闭包结合 http.Handler、E2E 测试和单元测试,等等。透过这些考量,能感受到作者多年经验与智慧的沉淀。

      28. 《Rust std fs 比 Python 更慢!?》

      一篇精彩的短篇侦探小说。

      有一天,Xuanwo 接到用户上报一个奇怪的案件:一段 Rust 实现的 Python SDK 中的文件操作代码,执行起来却比原生 Python 代码更慢。一通排查后,更离谱的事件出现,不止 Rust,甚至同样的 C 代码也比 Python 更慢。但这怎么可能,Python 语言解释器本身都是用 C 写的呀?!

      就像任何一篇精彩的侦探小说一样,最后,悬疑气氛推到最高点,凶手身份被揭露时,你会自言自语道:“意料之外,情理之中”。

      29. 《选择乏味的技术》

      作为技术人员,我们喜欢尝试新技术,这让我们感到快乐。但许多时候,比起闪闪发光的新玩意,“乏味”的技术才是更优的选择。

      当我们觉得一项技术“乏味”、痛恨它时,根本原因是我们过于了解它,无法从它身上获得任何新鲜感(比如 Django 之于我)。但别忘了,这同时也意味着我们对这项技术的每个坑都了如指掌。在项目中采用它,能让我们更容易专注在核心业务问题上。

      很喜欢本文里的“创新代币”比喻。“创新代币”是一种用来处理创造性任务的有限能力。假设你一共拥有 3 枚“创新代币”,你会如何花费它们?也许,和某个新奇的技术栈比起来,产品核心功能上的创新,更需要那枚代币。

      30. 《Python 3.10 中的结构化模式匹配》

      在 3.10 版本中,Python 新增了“结构化模式匹配”语法( match ... case)。因为看上去和 switch ... case 语句十分相似,不少人认为“结构化模式匹配”就是 switch 换皮。但事实上,它和 switch 语句有着比较大的差异,用作者的话讲:它更适合被当成“迭代式解包”来理解。

      本文发布于 2021 年(Python 3.10 发布前夕),其中简单介绍了“结构化模式匹配”的功能,并列举了一些它最适用的代码场景。在总结中,针对该语法的未来,作者持略为悲观的复杂态度。

      和“结构化模式匹配”相关的文章中,除几篇 PEP 之外,我认为这是最值得阅读的一篇。

      31. 《你想要的是模块,不是微服务》

      文章的开头很有意思。从一篇介绍微服务的文章中,作者摘抄出了微服务架的 10 条优势。随后,他逐条分析这些优势,发现其中至少有一半,可以原封不动地套用在“模块”上。

      “只关注一小块代码”、“独立开发”、“版本化”、“独立发布”——以上能力模块无一不具备。对了,此处谈及的“模块”,就是那个诞生于 20 世纪 70 年代的技术概念,也是如今所有编程语言的标配能力。

      分析完模块和微服务的相似性后,文章继续层层推进,试着回答一个重要问题:微服务架构解决的本质矛盾究竟是什么?

      32. 《我不喜欢 Go 语言默认的 HTTP Handlers》

      在编写 HTTP handler 函数时,作者意识到这类函数存在一个设计问题,它会促使人们写出有 bug 的代码。该问题大多数 Go 开发者都知道(也可能犯过):回写响应体后忘记 return,导致代码错误地继续执行。为了优化它,作者提出了一种思路。

      技术层面上,这是一篇非常简单的文章,最终方案也无非是“多封装一层”而已。不过,我喜欢作者对细节的关注,也认可文章的价值观:通过优化工具与环境,来杜绝人类犯错的可能性。

      33. 《对人类更友好的“超时”与“取消”》

      做网络编程时,“超时配置”是一个非常重要但又常常被忽视的细节。不当的超时配置就像是鞋底里的一粒沙,开始你甚至觉察不到它的存在,但随着时间累积,沙子会磨破脚底,产生巨大危害。

      “作为最常见的超时配置方式,为什么 get(url, timeout=10) 这类 API 不够好?”

      从这个问题出发,作者列举并分析了一些常见的超时 API 设计,最后详细介绍了 trio 库的相关功能。作者认为它是一种“对人类更友好”的设计。

      34. 《20 年软件工程师生涯,学到 20 件事》

      从业 20 年后,软件工程师 Justin Etheredge 回顾自己的职业生涯,总结出了 20 条经验。这些经验短小精悍、富有洞见,我读后对其中大部分都很有共鸣。

      比如其中的第 5 条:“最好的工程师像设计师一样思考”。有许多次,我在一个问题卡住,苦思冥想,寻不到最优解。但当我转换思路,学着像设计师一样站在用户(或调用方、依赖方)角度思考时,答案呼之欲出。再比如其中的第 9 条:“问‘为什么‘,永远不嫌多”——旺盛的好奇心和求知欲,正是助我们精进技术的最佳催化剂。

      35. 《为什么你的 mock 不工作》

      用 Python 写测试代码时,经常会用到 mock 模块。初次接触 mock,不少人都遇到过 mock 不生效的问题。明明用 mock.patch(...) 替换了模块,代码执行时,引用到的却依旧是原始值。

      Ned Batchelder 的这篇文章细致解释了“mock 不生效”问题。因为写的是个常见问题,所以文章中的知识点对你来说可能并不新鲜。但即便如此,我还是很推荐它。文章结构清晰、措辞准确,里面的每张示意图和每段代码,都出现得恰到好处。哪怕不为学知识,略读一遍后,也让人心情舒畅。在技术写作方面,能从中学到不少。

      同时推荐作者的另一篇文章:《Python 的名字和值》,内容与 mock 这篇有关联。

      36. 《实用的 Go:来自真实世界的编写可维护 Go 程序的建议》

      互联网上,“Go 代码可读性“方面的资料不算太多,这篇或许是你能找到的最好的之一。

      本文包含数十条与提升 Go 代码可维护性有关的建议,覆盖从变量命名到 API 设计等多项主题,十分全面。我喜欢它最重要的原因,除了其写作质量上佳之外,还在于作者为每条建议精心搭配了示例代码,这些代码使得文章内容非常容易阅读,知识很好消化。一篇干货满满的经典之作,值得每位 Go 工程师阅读。

      37. 《编写系统软件:代码注释》

      在“代码注释”这个主题上,Redis 作者 antirez 的这篇文章是我的最爱之一。通过整理 redis 项目里的所有注释,antirez 将注释一共划分成 9 类,各自承担不同功用。

      本文的独到之处,在于立足“用注释解释代码中的 ‘why?’”这条共识上,重点介绍了“教学性/指引性注释”这类不太常规的注释。文章提到,指引性注释是 redis 中数量最多的注释,充斥整个项目,人们认为 Redis 的源码可读性佳,指引性注释功不可没。

      某种程度上,这篇文章影响了我的编码习惯。再次回顾它,脑海闪过那句人们重复提及的老话: “代码主要是写给人看的,顺便被计算机执行。”

      38. 《编写易于删除,而不是易于扩展的代码》

      程序员们有一条朴素的共识:“重复代码坏,复用代码好“。这篇文章站在另一个角度,反思了这条共识。人们习惯于讨论复用的好处,却往往忽视了它的缺点:一段代码被复用越多,意味着它与更多的使用方产生了耦合关系,自然也导致它更难被修改。

      代码写出来后便需要被维护,而业务发展又会让旧代码不断过时。以这个为前提,重新思考软件项目的可维护性,会发现“易于删除”变成了一个形容代码的好特征。这篇文章或许写得没那么易读,但个中观点确能引发思考。

      39. 《如何提出好问题》

      在人际沟通中,“善于提问”是一种顶级技能( 评级:SSR✨)。在关键时刻提出一个好问题,能让沟通事半功倍,事情水到渠成。

      Julia Evans 的这篇文章,囊括了与提问有关的若干条经验和技巧,比如:向对方陈述并确认你所知道的现状;选择向谁提问;通过提问让不够显而易见的概念变得明确,等等。文章不止内容好,写作风格也是一如既往的友善、清晰易读,强力推荐。

      40. 《每天写代码》

      程序员 John Resig (JQuery 库作者) 遇上了一件烦心事。他想完成一些兴趣项目(side projects),却发现在保证全职工作效率的前提下,很难推进。他常在每个周末疯狂赶工,力求完成更多,但压力和焦虑感总是爆棚,状态难以维系。

      有一天,在他人启发下,John 决定换一种策略:每天写代码。原本用整个周末投入兴趣项目,如今拆分到每一天,花不少于 30 分钟编程。半年后,他发现新策略产生了神奇的效果,他取得了超多成果:开发多个新网站、重写若干个框架、完成大量新模块。更重要的是,曾经困扰他的焦虑感,也烟消云散。

      我很喜欢这篇文章,它是程序员版本的“日拱一卒”,John 也是一位极好的榜样。

      结语

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

      题图来源:Photo by Roman Kraft on Unsplash

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

      2024年8月26日 07:48

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

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

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

      下面是阅读清单的第一部分,包含第 1 到 20 篇文章。

      系列索引:

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

      清单

      1. 《开发者应学习的 10 件有关“学习”的事》

      学习对于任何一个人都很重要,对于软件开发者来说更是如此。这是一篇有关“学习”的科普类文章,从介绍人类记忆的工作原理开始,引出专家与新手的区别、间隔与重复的重要性等主题。

      文章中的一些观点相当具有启发性。比如“抽象和具象”:新知识对于初学者来说先是抽象的,然后通过大量例子将其具象化,最终彻底掌握后又重新变回抽象。又比如:做智力题和编程能力并没有关联性——这和我们认知中的“聪明人更会编程”大不相同。

      2. 《开发者如何管理自驱力》

      作者是一名单兵作战的开发者,分享在管理自驱力方面的心得。文章提供了许多提高自驱力的切实可行的小点子,比如:

      • 开发一个通知机器人,当自己的软件有新订阅时通知自己——外力驱动;
      • 每天的开发任务做到 90% 后停止,留到第二天完成——让新一天有盼头;
      • 为了避免自己被“今日一事无成”的罪恶感击溃,先干点高产出的正事,再做其他。

      3. 《用 Go 语言分析 10 亿行数据,从 95 秒到 1.96 秒》

      一篇很不错的 Go 语言性能优化文章,涉及到这些知识点:文件读取性能优化、生产者消费者模型优化、channel 对比 mutex、自定义 hash 算法,等等。

      作者的思维模式、用到的工具链及优化手法非常规范,整个调优过程层层递进,文章行文也很工整。非常值得一读。

      4. 《在开发高质量软件上的花费值得吗?》

      对于大多数事物而言,如果想要追求更高的质量,必然要花费更多的成本,但对软件而言是否也是如此?作者 Martin Fowler 将软件质量分为两类:外在与内在。

      由于软件的内在质量很难被外人所感知,因此花在改善内在质量上的成本常被质疑。但实际上,在内在质量上投入并不增加成本,反而能降低整体花费。文章会通过详细的分析与对比告诉你为什么。

      5. 《错误抽象》

      如果你想要建造一栋楼房,假如地基不正,最终只能收获一栋歪歪扭扭的残次品。对编程而言,抽象便是地基,良好的抽象是一切美好事物的前提。

      这篇文章探讨了复用与抽象间的关系,作者犀利地指出一个事实:对“沉没成本”的恐惧常常孕育出错误抽象,而后者将引发项目质量恶化。

      一篇短小精悍的经典之作,不容错过。

      6. 《谷歌技术写作课:编写有帮助的错误信息》

      在软件开发中,错误信息是一种极为微妙的存在,糟糕的错误信息使人沮丧,时刻提醒着我们:“魔鬼藏在细节中”。

      对此,谷歌团队提供了一份关于错误信息的写作建议,包含:精确描述、提供解决方案、面向目标读者写作、用正确的语气写作,等等。我认为这应该成为每位程序员的必修课。

      7. 《深入 Python 字典——一份“可探索”的解释》

      毫不夸张的说,网上介绍 Python 字典原理的文章多到泛滥。但这篇比较特别,它的特别主要体现在标题里的“可探索”上。

      在文章中,作者用一些 Python 代码模拟了字典数据类型。这些代码可在页面上点击执行,过程完全可视化。比如当字典中出现哈希冲突时,会有非常细致的动画,看起来妙趣横生。

      8. 《愿意让自己显得愚蠢》

      人们天生在意他人的看法,每个人都希望自己是别人眼里的“聪明人”,而不是“傻瓜”。不过,本文作者分享了一个不太常见的观点:做一些让自己显得愚蠢的事,利远大于弊。 比方说:提出愚蠢问题往往能获得对事物更深入的理解;用别人眼中的蠢办法学习,效果更好。

      9. 《我们为什么坚持使用 Ruby on Rails》

      著名的开源软件 GitLab 的大部分代码都在一个 Rails 单体项目里。GitLab 采用“模块化单体”架构,并未使用近年颇为流行的微服务架构。作者在文章中解释了 GitLab 这么做的原因:微服务架构徒增偶然复杂度,却对降低本质复杂度帮助不大。

      我很认同文章中的一句话:架构该为需求服务,而不是反过来。

      10. 《ChatGPT 是互联网的一张模糊的 JPEG 图》

      这篇文章发表于大语言模型爆发前夜:GPT-3.5 已经问世,GPT-4 蓄势待发。虽然文章的主体论调偏(有理由的)消极,但是文章中的大量精彩类比,以及作者优美的文笔,令人击节称叹。也许你不一定认同作者关于大模型的观点,但你很难不被作者字里行间所流露出的深邃思考所打动。

      阅读这篇文章时,我曾多次感叹:“怎么写得这么好?”。我将页面拖动到顶部,仔细检查作者的名字——谜底揭开:“难怪,作者是特德·姜!”

      注:特德·姜,当代美国著名科幻作家,小说作品曾获得星云奖、雨果奖等多项大奖。

      11. 《重新发明 Python notebook 时学到的教训》

      一篇与产品设计有关的总结文章。文章主角是 marimo——一个类似 Jupyter 的 Python 笔记本软件。本文所涉及的内容包括:如何利用有向无环图让笔记总是可重现;为什么强约束的简单设计优于弱约束的复杂,等等。

      我很爱读这类文章,因为由技术人写的优秀产品设计经验,如珍珠般少见。

      12. 《断点单步跟踪是一种低效的调试方法》

      曾经的我以为编程像解数学题,不同人的解法或稍有区别,但终究殊途同归。然而最近两年,我发现编程更像是画画或写作,每个人信奉着自己的道。

      云风的这篇文章的标题,坦率来说有些骇人听闻,但仔细读过后,的确能感受到一种独特的编程智慧,一种专属于有着数十年经验的编程匠人的哲思。

      13. 《作为“胶水”》

      软件工程师的日常工作除编码以外,还有大量其他事务,比如总结文档、优化工具链等,作者将这类事务统称为“胶水工作”。

      胶水工作看似不起眼,但对于项目的成败至关重要。本文指出了一个被人忽视的事实:承担更多胶水工作的有责任心的工程师,反而更不易晋升。针对这一点,作者提供了一些有用的建议。

      14. 《拥抱苦差事》

      本文以一个魔术揭秘开头,引出作者如何通过完成“苦差事”,将整个开发团队拉出泥沼的故事;之间穿插着对程序员金句“懒惰是程序员的美德”的思考。

      重读这篇文章时,我想起最近在一本书上看到的另一句话,大意是这样的:“外行人做事时渴求及时反馈与成就感,而专业人士在一切变得乏味后,仍然继续向前。”

      15. 《也许是时候停止推荐〈代码整洁之道了〉》

      作为一本经典书籍,《代码整洁之道》长期出现在各类编程书单中。但是,本文作者发现,这本出版于十几年前的书中的大量内容已经过时,其中的不少代码示例质量糟糕。

      在这篇文章中,本文作者对书中的部分 Java 代码片段进行了几乎称得上是“凶残”的 Code Review。文章观点有一定争议性,但也不乏道理。

      16. 《我在编辑时考虑的事》

      作为一名专业的技术写作者,作者 Eva 常常帮其他人编辑技术文档。久而久之,她总结出了 9 条编辑建议,比如:明确文章主题、有理由的重复,等等。

      虽然文章中的部分建议更适用于英文写作场景,但我仍然很推荐它。因为你很容易发现,这篇文章虽然信息量大,但读来非常流畅、舒服——我想这就是优秀的“编辑”带来的魔力。

      17. 《修复流行 Python 库的内存泄露问题》

      这篇文章的标题很大,但其实只是一篇短文,里面的 Python 示例代码不超过 10 行。

      在一次黑客马拉松活动中, 本文作者和同事一起定位了 py-amqp 库的一个内存泄露问题。提交 PR 后,他在 redis-py 等流行的库中发现了类似的情况。问题和 Python 中的 try/except 语句块有关,迷惑性很强。

      18. 《UI 设计原则》

      文章总结了 19 条 UI 设计原则,包括:清晰最重要、让用户有掌控感、渐进式披露,等等。我最喜欢的是第 17 条原则:“伟大的设计是隐形的”,它让我想起一些优秀的开源软件库。

      虽然名为 UI 设计,但这些原则并不只属于设计师,我认为每个人都可以从中受益。作为程序员,每当我们写下一个函数定义语句,实际就是在做一次 UI 设计。

      19. 《你的数据库技能不是“锦上添花”》

      在文章中,作者 Andrei 先分享了一个 20 年前的故事:用 MySQL 巧妙完成了一项困难的业务需求。然后引出文章主题:如今大家对数据库技能的关注度不应该这么低。

      我很认同作者对于关系数据库和 ORM 等工具的观点。有时候,当项目遇到性能问题时,分明加个索引、优化下查询就能解决,许多人却大喊着:“快点,上缓存!换 DB!”——实在大可不必。

      20. 《预估开发时间很难,但还是得做》

      在软件开发中,“估时间”是一项令人头疼的事。我们都曾有过类似的经历:拍胸脯说 3 天搞定的任务,最后足足耗费了大半个月。

      到后来,“估时间”成了到底留 1 倍还是 2 倍 buffer 的无聊游戏。但正如本文的标题所言,预估开发时间虽然难,却不可避免。这篇文章(系列)提供了一些与之相关的技巧,相信可以给你一些启发。

      结语

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

      题图来源:Photo by Farsai Chaikulngamdee on Unsplash

      人人都能写英文博客

      2024年7月5日 06:56

      时间过得很快,转眼间,2024 年的进度条已经走到了 50% 的位置。作为一名博主,我很惭愧 🥹,过去半年我只写了一篇新文章,算是相当低产。不过,虽然没写太多新文章,但我干了另一件值得记录的大事。

      在今年 2 月份,我给博客增加了“英文”板块,并在其中发表了 4 篇英文文章,几乎每一篇都获得了不错的反响:

      1. "After 14 years in the industry, I still find programming difficult"Hacker News 217 points(Top 20) 评论数 190+,reddit r/programming 773 votes(Top 1) 评论数 310+,被翻译成俄语、韩语
      2. "6 ways to improve the architecture of your Python project (using import-linter)":登上 PyCoder's Weekly 周刊,被播客节目 Python Bytes 推荐,被 Real Python 官方账号推荐
      3. "3 important things I overlooked during code reviews"reddit p/programming 90 votes(Top 5),评论数 16,Lobster 21 votes 评论数 46

      图:piglei.com 登上 Reddit /p/programming TOP1,并在上面停留了整整一天

      简单来说,在阅读量和读者反馈方面,这些英文文章成绩斐然。并且在某些维度(比如评论数量)上的数据表现,远远超我所写过的任何一篇中文文章。

      不过,也许现在屏幕前的你已经皱起了眉头,想说:“行了,行了,piglei 你这个货别显摆了,现在我知道你英文很牛逼了,能写出流利的英文文章来,满意了吧!”

      先别急着下结论,其实我的英文能力非常普通(大学六级考两次的水平)。因此,这些文章其实也并非由我从零开始写就,或许眼尖的你已经发现,它们都是由我写过的中文文章翻译而来。

      我借助了 GPT 4 和 DeepL Write 等先进工具完成了翻译,并尽全力保证译文“信雅达”,让它们读起来就像是出自一位熟练的英文写作者之手(此处有吹牛成分)。

      图:一位读者表示“根本看不出来文章是由中文翻译而来”,incredible!

      我使用的翻译方式没有什么门槛,任何一位中文写作者,都能用它来创作属于自己的英文文章。我将在本文教会你具体的方法。

      但在进入正题前,让我们先把时钟往回拨一拨,回到今年的二月份,看看究竟是什么事情,在我心里埋下了想开始“英文写作”的种子。

      开始“英文写作”的契机

      二月份的某个周五,在用谷歌搜索资料时,我无意读到一篇关于 ChatGPT 的英文文章。不读不要紧,一读吓一跳,这篇文章的内容,根我在 2022 年写的一篇中文文章《ChatGPT 正在杀死编程里的乐趣》一模一样。

      然后,我顺藤摸瓜点进作者的主页,发现了更多源自我的博客 piglei.com 的文章,比如《Python 工匠》系列,等等。

      图:名为 bo leo 用英文大量洗稿了我的博客文章

      这些文章没有注明原始出处,作者 bo leo 也从未联系过我征求授权,明显侵犯了我身为原创作者的权益。于是,我在 Medium 平台上举报了这些文章,几个小时后,它们就被下线了(也可能是被作者主动删除,因为事情在推特上被曝光)。

      这件事看似得到了圆满的解决,但我的心情却无法平静。因为在点开几篇翻译质量粗糙的“英文盗版文章”后,我在评论区发现了不少高质量的读者评论,部分观点极具启发性——若是它们出现在自己博客的正版文章的评论区,那该多好啊!

      图:《ChatGPT 正在杀死编程里的乐趣》英文盗版,右边的读者评论写得很棒

      既然那么糟糕的英文版都能获得读者认可,假如翻译质量再好一点呢?再进一步,为什么要给这些偷偷洗稿的卑鄙小人可乘之机,为什么我不干脆自己动手,直接为每一篇自己的得意之作发布对应的“官方英文版”呢?

      就这样,在一个月朗星稀的周五晚上,piglei 坐在笔记本电脑前,下决心开始自己的“英文写作”之路——当然,如果你非得要较真的话,他走上的并非真正意义上的“写作”之路,而是一条名为“LLM 英文翻译 + 工具润色”的捷径 😅。

      用 LLM 翻译初稿

      “机翻(机器翻译)”,在很长一段时间里都是“低质量翻译”的代名词。然而在 ChatGPT 3.5 等大语言模型横空出世后,“机翻”的质量跃升到了一个前所未有的高度。不论是在准确度、流畅度和专业名词翻译方面,优秀的大语言模型所生成的译文,几乎接近普通人类的翻译水准。

      因此,我选择用 LLM 来完成译文的初稿。在模型方面,我用到了 OpenAI 的 GPT-4(通过 API 调用),你也可以用其他模型来替代。

      为了尽量提升译文的质量,我编写了以下提示语(prompt)来帮助 GPT-4 更好地完成翻译:

      You are a professional English translator. I'll send you Chinese content, please translate it into American English.
      
      Requirements:
      
      - Correct any grammatical errors in the original content before translating it.
      - The English version should use a concise, direct and clear writing style.
      - The content may use markdown format, please keep the format as it is.
      
      Respond only with the translated content.
      

      之后的操作流程比较简单:把中文发给大模型,它就会吐给你一份英文。一篇文章的篇幅通常很长,你需要将其拆分为多个段落,分段完成翻译。

      一些注意事项:

      • 在翻译每个新的段落前,记得清空当前聊天的上下文,否则会被多收很多钱(按 token 计费时)
      • 不同段落的译文,对同一个专业名词的翻译需保持一致
      • 如果原文是 Markdown 格式,注意让译文维持原格式
      • 如果原文中的书名或引用段落的原始语言就是英文,那就不要用 LLM 的译文,找到被引用内容的原始英文表达

      图:使用 GPT-4 翻译一个段落

      这样重复执行多次“复制 -> 聊天 -> 粘贴”后,一篇由 LLM 完成的初稿便落到了我们的手中。

      一眼看上去,初稿的翻译质量似乎已然十分出色(前提是使用的模型能力足够强大)。但是如果细读,还是会发现文本中藏着许多小瑕疵,值得进一步优化。因此,我建议继续对初稿实施二次校对和润色。

      用 DeepL Write 润色

      假如你的英文水平非常过硬,那么你可以直接自己动手来完成润色。但是,对于我这种英文半吊子来说,借助工具是更合适的选择。工具方面,我挑选了知名翻译网站 DeepL 出品的写作助手:DeepL Write

      DeepL Write 用起来很简单,只要把原文粘贴到左侧文本框,选择语言为 English,右侧便马上会出现优化过的版本。

      图:DeepL Write 界面截图,右侧带下划线的文字是建议优化的内容

      但是请注意,虽然 DeepL Write 工具会提供一些优化建议,但它们就像 LLM 的翻译一样——并非 100% 可靠。

      所以,作为唯一的人类创作者,我们仍需亲自决定每一个词语、每一种句式。 也正是因为如此,在润色阶段,拥有优秀的英文语感非常重要,因为你要凭借这份语感,来判断哪种写法会给读者提供更优的阅读体验。

      在培养语感方面,我认为长期阅读高质量英文文章很有帮助。

      推广你的文章

      万事俱备,只欠东风。有了英文文章后,下一步便是给它找到最匹配的读者群。幸运的是,在英文世界中推广自己的文章,比在中文世界要方便太多,大多数时候,你只需要轻点小手,把文章的 URL 提交到心仪的资讯站点即可。

      目前,我尝试过以下几种渠道:

      1. Hacker News:最知名的科技类资讯站点,流量巨大
      2. Reddit r/programming:知名的编程相关资讯节点,流量很大
      3. Lobster:流行的科技资讯站点,与 HN 风格类似,流量较大,技术讨论氛围非常好
      4. PyCoder's Weekly:知名 Python 编程语言周刊,订阅量巨大

      除了以上渠道以外,你也可尝试一些契合文章调性的其他渠道,打个比方,一篇 Go 语言的技术文章,就很适合提交到 Reddit 的 /r/golang 节点上。

      ⚠️ 注意:虽说积极推广自己的文章不是什么坏事,但也请不要滥用。每次投稿前,请确保内容质量达到标准,并契合对应渠道的读者群。否则会讨人嫌哦!

      ❤️ 我喜欢这种创作模式

      如你所见,我使用的“英文写作(翻译)”方式非常非常非常简单,似乎稍微有点脑子的人就能想到。但其实,在实际上手用 GPT-4 + DeepL 完成第一篇文章前,我的心情极度忐忑。我能听到心中有个小人不停小声念叨:“机器翻译的文章,真的能让英文读者满意吗?”

      待到第一篇文章发布,收获了大量的正反馈后,我才敢真正确认,这确实是一条相当可行的创作模式。

      我个人非常喜欢这种模式。因为在这之前,我从未想过自己能用第二语言,写出被数万人喜爱的技术文章。 即便这算不上“一字一句”完成的那种真正的写作,但当你读到最终的英文成品时,会发现无论从语言、节奏还是腔调上,它都同自己的创作灵魂契合得天衣无缝,让你满心欢喜。

      结语

      我花了整整三十年,才学会如何用自己的母语写出文通字顺的文章。假如,我从现在开始学习完全用英文写作,不知还得练习多少年,才能勉强达到及格线。但如今借助 LLM 等现代化工具,我轻松实现了自己的“英文写作梦”。

      记得发布完第一篇英文文章后,深圳已经进入深夜,但因为时差原因,文章在 Reddit 和 Hacker News 的热度却在一路走高。看着 vote 数和评论数不断增长,我兴奋得完全无法入睡,几乎每隔三十分钟就要抓起手机,看一遍最新的访问数据。

      后来几经辗转,终于进入了梦乡。我已经忘了那晚梦见了什么,但我能想起的是,第二天早晨醒来后,我感受到了一种就像是刚刚学会写字时的喜悦。

      Code Review 时,曾被我忽视的 3 件重要小事

      2024年4月18日 08:00

      Code Review(代码评审)是一种流行的软件开发实践。通过在代码合入主分支前引入人工评审,能有效促进成员间的知识交流,提升软件质量。

      我以评审者的身份参与过大量代码评审。在评审一份代码时,有些事项长期处在我的关注榜头部,比如设计是否考虑到了边界情况、代码是否有合理的单测覆盖。也有一些事项,因看似无关痛痒一直未引起足够重视,直到最近,我才渐渐发现它们的重要性。

      以下是曾被我忽视的 3 件重要的小事。

      1. 命名

      小女孩千寻误入汤婆婆为神明开设的浴场。为了留在浴场内工作,千寻与汤婆婆签订了一份协议,但协议并非重点,重点是另一件看似无关紧要的小事——汤婆婆给千寻改了个名:从“千寻”改为“千”。一旦失去了原本的名字,人们便失去了逃离浴场所在的异世界的能力,甘心永世被汤婆婆所奴役。

      ——电影《千与千寻》

      程序员们对“命名”的关注程度似乎呈一个“倒 U 形”曲线。缺乏经验时,对命名的关注度很低,代码中充斥着各类不准确、不精确的名字,无法有效描述各种抽象概念。

      下面这段代码中的命名就存在不少问题:

      def get_var(u):
          """获取环境变量列表"""
          data1 = UserVarsManager.get(u)
          data2 = SiteVarsManager.get(u.site)
          return data1 + data2
      

      随着经验逐渐增加,大家对命名的关注度逐步提升。项目中的名字开始变得更具有描述性,含糊不清的名字渐渐绝迹。名字至少不会成为他人理解代码时的屏障。

      这个阶段,代码会逐渐演变成像是这样:

      def list_environment_vars(user):
          """获取环境变量列表"""
          items_user = UserVarsManager.get(user)
          items_site = SiteVarsManager.get(user.site)
          return items_user + items_site
      

      在绝大多数评审中,这绝对算是一份合格的代码,至少不大可能因为命名应发争议。

      自此之后,大部分程序员们对命名的关注度进入“倒 U 形”曲线的后半段:不再如从前那般关注命名,名字只要有一定描述性,不造成歧义就足够。我也曾经是其中一员。

      但不应在这个阶段停留太久,作为代码评审人,我们应该不断提升自己对于名字的敏感度。比方说,对于前面那份代码,也许应该提出以下评审建议:

      def list_environment_vars(user): # 1
          """获取环境变量列表"""
          items_user = UserVarsManager.get(user) # 2
          items_site = SiteVarsManager.get(user.site)
          return items_user + items_site
      
      • 评论 1: 项目中对于“环境变量”的统一缩写是 env_variables /env_vars,此处应保持一致,使用 list_env_variableslist_env_vars
      • 评论 2: UserVarsManager.get 的命名可优化,因为 Manager 是一个“万金油”名词,虽然放在各种场景下都不违和,但也是以损失名字(等同于“职责”)的精确指向性为代价,此处可考虑改用一个更精确的名字,比如:UserEnvVarsRetriever.get(user)SiteVarsManager 同理。

      虽然只是两处小改进,但是积少成多。

      每一次代码评审,必定涉及到许多新名字。但名字并非生来平等,不是所有名字都值得我们花费时间,应当尽量把关注点聚焦在那些最常被使用、最靠近用户的名字上,比如 URL 路径的资源名、数据库模型与字段名、工具函数(类方法)名,等等。

      此外,与业务直接相关的领域词汇重要程度极高。评审时,每一个关键的领域词汇都值得仔细斟酌、反复推敲。举个例子,开发一个影评功能,”用户评分“、“媒体评分”、“平均分”分别该用哪些名字表示?你绝不会想要在一个文件里看到 movie_score,在另一个文件里看到 movie_rating

      命名这件小事,虽然看似不起眼,但项目规模越大、所跨越的时间维度越长,在名字质量上的细微差别就越容易累加出不可估量的巨大影响。

      2. 指引性注释

      夏洛已经在网上织出了光彩照人四个大字,威尔伯站在金色的阳光里,真是光彩照人。自从蜘蛛开始扶助它,它就尽力活得跟它的名声相衬。夏洛的网说它是王牌猪,威尔伯尽力让自己看上去是只王牌猪;夏洛的网说它了不起,威尔伯尽力让自己看上去了不起;现在网上说它光彩照人,它尽力让自己光彩照人。

      ——《夏洛的网》

      关于注释,我向来信奉 Bob 大叔在《代码整洁之道》里的观点:“注释的恰当用法是弥补我们在用代码表达意图时遭遇的失败。” 这就是说,好代码应该总是能清晰说明自身意图,无需注释再来画蛇添足,注释只应该被用来描述那些代码之外的信息,比如解释“为什么”。

      正因如此,注释总是应该被谨慎使用。假如一段代码很难理解,第一反应不应该是补注释,而是应该去追求用一种更易理解的方式重写它。

      但随着时间的推移,我渐渐意识到,事情不能一概而论。“指引性注释”,或者说常被人们诟病为“近乎复述代码意图”的描述性文字,也有着不可替代的重要作用。

      Redis 的作者 antirez 就是“指引性注释”的忠实拥护者,他曾写过一篇文章详细分析过指引性注释在 Redis 项目中的应用。下面这段代码摘自 Redis 源码,里面就有不少“指引性注释”:

          /* Call the node callback if any, and replace the node pointer
           * if the callback returns true. */
          if (it->node_cb && it->node_cb(&it->node))
              memcpy(cp,&it->node,sizeof(it->node));
      
          /* For "next" step, stop every time we find a key along the
           * way, since the key is lexicographically smaller compared to
           * what follows in the sub-children. */
          if (it->node->iskey) {
              it->data = raxGetData(it->node);
      
              return 1;
          }
      

      在这段代码中,两段注释并未提供任何在代码之外的新信息。所以,好处是什么?

      最直观的好处,就是这些注释让代码变得更容易理解了,它们极大地降低了人们阅读代码时所需付出的心智成本。同样一份代码,在缺少指引性注释的情况下,完全理解它的行为可能得花费 10 分钟,而有了注释的帮助,时间也许就能缩短到 5 分钟甚至更短。

      当新开发者加入项目时,这些指引性注释也能助力他们更快上手。

      正因如此,在评审一份代码时,我常常会在一段复杂的代码逻辑上评论:“Nit:考虑增加一小段指引性注释,帮助理清代码行为。”(Nit=nitpick,表示“鸡蛋里挑骨头”式的并不强烈要求修改的意见)

      此外,如果一段代码曾在评审过程中引发过一些深度讨论,那么那些讨论内容,也许很适合被二次加工后,作为指引性注释加入代码中。对于理解代码来说,它们有时有奇效。

      不过,在追求“指引性注释”的路上,也要避免踩入以下几个陷阱:

      • 简单复述代码:指引性注释虽然是一种帮助理解代码的辅助性文字,但绝不能只是复述代码而已,简单来说,你可以这么理解两者在传递信息方面的风格差异:代码是一本厚厚的权威科学教材,指引性注释则是一小册面向中学生的科学启蒙读物
      • 追求“注释率”:不要在“代码注释率”指标上设置硬性要求,指引性文字也需要讲究质量,盲目追求数量只会适得其反
      • 不注重时效性:过时的注释比代码危害更大,要及时修改或删除已经过期的指引性注释

      总而言之,你可以把指引性注释当成有针对性的代码“教学文本”。审阅代码时,如果你发现一段逻辑理解起来很吃力,而代码本身也没有太多优化空间,请不要迟疑,勇敢表达出你对于“教学文本”的需求吧!

      3. 沟通方式

      “我因为鲁思和萨拉不得不离开我们而痛苦万分。而令我感觉更加痛苦的是我当时以为自己是完全孤立无援的。”

      “说真的,肯顿小姐……”我端起那个我用来放使用过的瓷器的托盘。“对那样的解雇我自然是极不赞同的。我还以为那是不言自明的。”

      ——《长日将尽》

      时至今日,仍有许多人认为软件开发是一种单打独斗的工作。一位程序员捡起一块键盘,就能源源不断地产出代码,根本不需要其他人。但事实是,程序员单打独斗的黄金时代早已过去,现代软件开发已演变成一种多人参与的协作事务。正因如此,程序员的日常充斥着各类沟通工作,参与代码评审正是其中之一。

      在代码评审时,评审者的工作内容似乎一句话就可简单概括:指出他人代码中的不足。 这听起来易如反掌,对不对?我曾经正是这么以为,所谓评审,只要做完下面的“123”即可:

      1. 找出所有可优化的点(有事说事!)
      2. 等提交者完成改动,或在讨论后确认维持原状(就事论事!)
      3. 合并代码(大功告成!)

      而现实总是和理想相去甚远,代码评审很少会像上面这样顺利。因为一旦涉及到人与人之间的沟通,尤其其中一方还在给另外一方“挑毛病”,事情又能简单到哪儿去呢?

      人类是一种神奇的智慧生物,阅读一段文字,不仅能从中获取到信息,更能从字里行间感受到情绪,有时,这份情绪甚至会盖过信息,影响他们做出判断。因此,当你在参与评审时,请谨记这一点:保持谦逊、尊重他人,无论对方的经验或背景如何。优秀的表达,能做到内容即使在批评,也能让对方感受到自己仍是被尊重的。

      让我来举一个例子。团队内来了一位新人,用他不太熟悉的 Python 语言提交了一个 PR。作为 PR 的评审人,你在代码里发现了一段冗长的循环代码,于是写下评论:

      代码比较啰嗦,建议改成列表推导式。
      

      虽然你的观点没错,但这种表达方式值得商榷。下面是这条评论的另一种写法:

      这里的循环体较简单,只有过滤和转换逻辑,很适合改成列表推导式,代码更精简。举个例子:
      
      items = [to_item(obj) for obj in objs if obj.is_valid()]
      
      参考: https://docs.python.org/3/tutorial/datastructures.html#list-comprehensions
      

      比起第一条评论,后面这条显然更不容易引起新人的抵触情绪,更可能被采纳。这就像那句老话所说:“你表达的方式跟所要表达的内容同样重要,如果不是更重要的话。”

      除了保持谦逊和尊重,还有一些其他值得采用的评审沟通小技巧:

      • 一例胜千言:有时用文字洋洋洒洒写一大堆,不如直接写几行代码,举一个实际的代码样例
      • 见什么人说什么话:对于加入团队一个月和一年的开发者,你在评审时可以(也应该)区别对待;对缺乏经验的新人,组织语言时要谨慎,尽量避免让对方感觉不被尊重,产生太多的挫败感;对熟稔的老人,语言风格就可以相对随意,言简意赅即可,不必过于啰嗦

      我相信也许大部分人在心底都认同:代码评审是一个“对事不对人”的过程,不应该把对代码的批评当成对人的否定。但这和提倡“好好说话”并不冲突。一次让双方满意的沟通,几乎等同于一次更高效的沟通。所以,改善沟通方式就能提升工作效率,你又何乐而不为呢?

      总结

      代码评审作为保障软件质量的重要手段,是大型软件开发中不可或缺的重要一环。本文总结了我作为评审的参与者,在命名、指引性注释和沟通方式三个方面的一些思考,要点如下:

      • 程序员们应当不断提高在代码评审时对于命名的敏感度
      • 检查命名的两个技巧:同类名词保持一致、用更精确的词代替那些“万金油”名字
      • 对待名字不要一视同仁,多多关注那些最重要的名字
      • 对任何一个项目,领域(业务)相关的名字最重要,值得仔细斟酌、反复推敲
      • 指引性注释虽不提供太多代码之外的信息,但也有着不可替代的作用
      • 指引性注释能大大降低人们为理解代码所付出的心智成本
      • 留意指引性注释的几个陷阱:简单复述代码、追求“注释率”、不注重时效性
      • 评审时,要勇于对复杂的代码逻辑提出补充指引性注释的请求
      • 软件开发中包含许多与沟通有关的事项,代码评审正是其中之一
      • 理想的评审是“有事说事,就事论事”的,但正因涉及人际沟通,导致现实往往偏离理想
      • 文字不光能传达信息,更是情绪的载体,而情绪往往会影响沟通的效果
      • 在评审中,永远保持谦逊、尊重他人,无论对方的经验或背景如何
      • 对于有着不同经验的待评审者,应当采取不同的沟通风格

      代码评审是一项涉及多人协作的复杂事务,里面藏着许许多多的学问。质量高的评审,对于提升质量和塑造团队氛围有着不可替代的作用。质量低下的评审,则可能沦落为形式主义,甚至让团队内部滋生矛盾和不满。

      而影响评审质量的因素,往往藏在那些不起眼的小细节、小事情中。以上这些关于“小事情”的经验之谈,希望能对你的工作有所启发。

      软件开发小段子四则

      2023年11月3日 08:25

      人物说明:

      • 大师:从事开发工作 20 年有余,精通各类编程语言,有着常人难以匹敌的软件工程经验,说话时惜字如金,有时会让人觉得有些神神叨叨(但人不坏)
      • 学徒:学习编程的时间不长,热衷于提升自己的软件开发技能,勤学好问。

      1. 1 行代码与 20 行注释

      一天,学徒问大师:“我每天都写很多代码,实现很多需求,为何编码水平却停滞不前?”

      大师回答道:“让我看看你在写些什么。”

      学徒打开电脑。大师指着屏幕上一行普通的赋值语句,说:“当你有一天意识到,需要在这前面补充 20 行注释时,你就成长了。”说完,大师转身离开了。

      piglei 说

      在编程时,人们理想中的代码应该是“自说明”的——无需任何注释也很容易理解。

      但也有些时候,程序要处理的场景过于复杂,导致我们实际上没法只用代码就将重要信息全部表达出来。此时,如何找到那几行重要的代码,并用大段注释将隐藏的知识有效表达出来,就变得很重要。

      作为一名程序员,有能力编写可读性高的代码固然很好,但若是还能信手写出言简意赅的注释与文档,更是锦上添花。

      2. 删掉注释

      一天,学徒问大师:“我每天都写很多代码,实现很多需求,为何编码水平却停滞不前?”

      大师回答道:“让我看看你的代码。”

      学徒打开电脑,大师指着屏幕上的一段注释,说:“删掉它。”

      学徒照做。沉吟片刻后,学徒大声说道:“我明白了,你是说要关注代码本身的描述性,而不是过度依赖注释来解释代码!”

      大师摇摇头,说:“不,我只是想看看你的指法,而据我观察,你甚至不能盲打。”然后,大师转身离开了。

      piglei 说

      学徒口中的“关注代码的描述性,不要过度依赖注释”诚然很对,但这次,顽皮的大师的关注点实际上在别处:基础技能(盲打)。

      在工作中,一名程序员要用到的工具多种多样。我见过一些程序员,他们就像段子中不会盲打的学徒一样,对每日使用的编辑器、IDE 和命令行工具只是略通皮毛,从未花时间系统性地学习过。

      但是,深入掌握那些日常工具,以及有意识地增进那些底层技能(比如说打字速度),实际上会对工作效率带来意想不到的丰厚回报。

      3. 微服务架构能力

      一天,学徒问大师:“我该如何提升自己的微服务架构能力?”

      大师回复道:“让我看看你的代码。”

      学徒打开电脑,大师看到项目的 utils 目录,其代码规模数倍于其他功能模块的总和。大师说道:“如果你不懂如何组织模块,那么你实际上也无法‘架构’任何其他东西。”

      然后,大师转身离开了。

      piglei 说

      设计一个大的单体项目,与设计由一堆微服务构成的分布式系统之间有许多相通之处,二者遵循一些类似的指导原则。

      比方说“单一职责原则”,该原则所回答的问题是:”应该如何设计项目的模块(或微服务),才能让我们在开发功能时,不牵扯太多无关的模块(或微服务)?“它对于两种架构风格同样有效。

      “模块化”是软件开发中最重要的指导思想之一,无关架构模式。

      4. 聪明的代码

      一天,学徒问大师:“我将 10 行 Python 代码用推导式优化成了 1 行,新代码非常漂亮,为何提的 PR(代码合并请求)却被拒绝了?”

      大师说:“你的 PR 是我拒绝的。”

      见学徒有些吃惊,大师又补充道:“我一个月前写了那 10 行代码。”

      学徒的脸有些红,不过仍不想放弃自己的 PR,于是他争辩道:“但是,就在我改动的函数旁边有个类似的函数,那里面有许多更复杂的单行推导式代码,为什么把它们合并进来?”

      “哦,那是我 10 年前写的代码。”大师答道。

      piglei 说

      “什么样的代码是好代码?”

      对于这个问题,随着“编码工龄”的增长,我们的答案会发生天翻地覆的变化。

      段子里的大师,十年前醉心于用最少的代码表现最复杂的逻辑。十年后,却更倾向于写那些平平无奇的简单代码。

      对于代码来说,“可读性”永远是第一位。将诸多逻辑压缩在一行代码中,是一种有趣的思维训练(容易让作者自我感觉良好),但常常以损害可读性为代价。

      在编程时,我们需要在“华丽的代码”和“朴实的代码”间找到一个平衡点,而据我观察,随着经验的增长,那个平衡点会持续向着“朴实”那一端移动。

      此外,我还有一句话想对学徒说:“既然没 bug,咱不如就别动那些代码了呗?”

      使用 import-linter 让你的 Python 项目架构更整洁

      2023年9月20日 16:52

      对于活跃的大型 Python 项目而言,维持架构的整洁性是一件颇具挑战的事情,这主要体现在包与包、模块与模块之间,难以保持简单而清晰的依赖关系。

      一个大型项目,通常包含数以百记的子模块,各自实现特定的功能,互相依赖。如果在架构层面上缺少设计,开发实践上没有约束,这些模块间的依赖关系,常常会发展成为一个胡乱缠绕的线团,让人难以理清头绪。

      这会带来以下问题:

      • 架构理解成本高:当新人加入项目时,会有许多关于架构的疑问,比方说:既然 common.validators 是一个低层的通用工具模块,为何要引用高层模块 workloads 中的 ConfigVar 模型?
      • 影响开发效率:想要开发新功能时,开发者难以判断代码应放在哪个包的哪个模块中,而且不同的人可能会有不同的看法,很难达成共识
      • 模块职责混乱:依赖关系很乱,基本等同于模块的职责也很乱,这意味着部分模块可能承担太多,关注了不应该关注的抽象

      如果把依赖关系画成一张图,一个架构健康的项目的图,看上去应该层次分明,图中所有依赖都是单向流动,不存在环形依赖。健康的依赖关系,有助于各个模块达成“关注点分离”的状态,维持职责单一。

      本文介绍一个治理模块间依赖关系的工具:import-linter

      import-linter 简介

      import-linter 是由 seddonym 开发的一个开源代码 Linter 工具。

      要使用 import-linter 检查依赖关系,首先需要在配置文件中定义一些“契约(contract)”。举个例子,下面是一份 import-linter 的配置文件:

      # file: .importlinter
      [importlinter]
      root_packages = 
          foo_proj
      include_external_packages = True
      
      [importlinter:contract:layers-main]
      name=the main layers
      type=layers
      layers = 
          foo_proj.client
          foo_proj.lib
      

      其中的 [importlinter:contract:layers-main] 部分,定义了一个名为 the main layers 的“分层(layers)”类型的契约,分层契约意味着高层模块可以随意导入低层模块中的内容,反之就不行。

      the main layers 设定了一个分层关系: foo_proj.client 模块属于高层,foo_proj.lib 属于低层。

      运行命令 lint-imports,工具会扫描当前项目中的所有 import 语句,构建出模块依赖关系图,并判断依赖关系是否符合配置中的所有契约。

      假如在项目中的 foo_proj/lib.py 文件里,存在以下内容:

      from foo_proj.client import Client
      

      则会导致 lint-imports 命令报错:

      $ lint-imports
      # ...
      Broken contracts
      ----------------
      
      the main layers
      ---------------
      
      foo_proj.lib is not allowed to import foo_proj.client:
      
      - foo_proj.lib -> foo_proj.client (l.1)
      

      只有当我们删除这条 import 语句后,代码才能通过检查。

      除了“分层”类型的契约以外,import-linter 还内置了两种契约:

      如果这些内置契约不能满足你的需求,你还可以编写自定义契约,详情可查阅 官方文档

      在项目中引入 import-linter

      要在项目中引入 import-linter 工具,首先需要编写好你所期望的所有契约。你可以试着从以下几个关键问题开始:

      • 从顶层观察项目,它由哪几个关键分层构成,之间的关系如何?许多项目中都存在类似 application -> services -> common -> utils 这种分层结构,将它们记录为对应契约
      • 对于某些复杂的子模块,其内部是否存在清晰的分层?如果能找到 views -> models -> utils 这种分层,将其记录为对应契约
      • 有哪些子模块满足“禁止(forbidden)”或“独立(independence)”契约?如果存在,将其记录下来

      将这些契约写入到配置文件中以后,执行 lint-imports,你会看到海量的报错信息(如果没有任何报错,那么恭喜你,项目很整洁,关掉文章去干点其他事情吧!)。它们展示了哪些导入关系违反了你所配置的契约。

      逐个分析这些报错信息,将其中不合理的导入关系添加到各契约的 ignore_imports 配置中:

      [importlinter:contract:layers-main]
      name=the main layers
      type=layers
      layers = 
          foo_proj.client
          foo_proj.lib
      ignore_imports =
          # 暂时忽略从 lib 模块中导入 client,使其不报错
          foo_proj.lib -> foo_proj.client
      

      处理完全部的报错信息以后,配置文件中的 ignore_imports 可能会包含上百条必须被忽略的导入信息,此时,再次执行 lint-imports 应该不再输出任何报错(一种虚假的整洁)。

      接下来便是重头戏,我们需要真正修复这些导入关系。

      饭要一口一口吃,修复依赖关系也需要一条一条来。首先,试着从 ignore_imports 中删除一条记录,然后执行 lint-imports,观察并分析该报错信息,尝试找到最合理的方式修复它。

      不断重复这个过程,最后就能完成整个项目的依赖关系治理。

      Tip:在删减 ignore_imports 配置的过程中,你会发现有些导入关系会比其他的更难修复,这很正常。修复依赖关系常常是一个漫长的过程,需要整个团队的持续投入。

      修复依赖关系的常见方式

      下面介绍几种常见的修复依赖关系的方式。

      为了方便描述,我们假设在以下所有场景中,项目定义了一个“分层”类型的契约,而低层模块违反契约,反过来依赖了高层模块。

      1. 合并与拆分模块

      调整依赖关系最直接的办法是合并模块。

      假设有一个低层模块 clusters,违规导入了高层模块 resources 的子模块 cluster_utils 里的部分代码。考虑到这些代码本身就和 clusters 有一定关联性,因此,你其实可以把它们直接挪到 clusters.utils 子模块里,从而消除这个依赖关系。

      如下所示:

      # 分层:resources -> clusters
      # 调整前
      resources -> clusters
      clusters -> resources.cluster_utils    # 违反契约!
      
      # 调整后
      resources -> clusters
      clusters -> clusters.utils
      

      如果被依赖的代码与所有模块间的关联都不太密切,你也可以选择将它拆分成一个新模块。

      比方说,一个低层模块 users 依赖了高层模块 marketing 中发送短信相关的代码,违反了契约。你可以选择把代码从 marketing 中拆分出来,置入一个处于更低层级的新模块 utils.messaging 中。

      # 分层:marketing -> users
      # 调整前
      marketing -> users
      users -> marketing    # 违反契约!
      
      # 分层:marketing -> users -> utils
      # 调整后
      marketing -> users
      users -> utils.messaging
      

      这样做以后,不健康的依赖关系便能得到解决。

      2. 依赖注入

      依赖注入(Dependency injection)是一种常见的解耦依赖关系的技巧。

      举个例子,项目中设置了一个分层契约:marketing -> users, 但 users 模块却直接导入了 marketing 模块中的短信发送器 SmsSender 类,违反了契约。

      # file: users.py
      
      from marketing import SmsSender    # 违反契约!
      
      class User:
          """简单的用户对象"""
      
          def __init__(self):
              self.sms_sender = SmsSender()
      
          def add_notification(self, message: str, send_sms: bool):
              """向用户发送新通知"""
              # ...
              if send_sms:
                  self.sms_sender.send(message)
      

      要通过“依赖注入”修复该问题,我们可以直接删除代码中对 SmsSender 的依赖,改为要求调用方必须在实例化 User 时,主动传入一个“代码通知器(sms_sender)”对象。

      # file: users.py
      
      class User:
          """简单的用户对象
      
          :param sms_sender: 用于发送短信通知的通知器对象
          """
      
          def __init__(self, sms_sender):
              self.sms_sender = sms_sender
      

      这样做以后,User 对“短信通知器”的依赖就变弱了,不再违反分层契约。

      添加类型注解

      但是,前面的依赖注入方案并不完美。当你想给 sms_sender 参数添加类型注解时,很快会发现自己开始重蹈覆辙:不能写 def __init__(self, sms_sender: SmsSender),那样得把删掉的 import 语句找回来。

      # file: users.py
      from typing import TYPE_CHECKING
      
      if TYPE_CHECKING:
          # 因为类型注解找回高层模块的 SmsSender,违反契约!
          from marketing import SmsSender
      

      即使像上面这样,把 import 语句放在 TYPE_CHECKING 分支中,import-linter 仍会将其当做普通导入对待(注:该行为可能会在未来发生改动,详见 Add support for detecting whether an import is only made during type checking · Issue #64,将其视为对契约的一种违反。

      为了让类型注解正常工作,我们需要在 users 模块中引入一个新的抽象:SmsSenderProtocol 协议(Protocol),替代实体 SmsSender 类型。

      from typing import Protocol
      
      class SmsSenderProtocol(Protocol):
      
          def send(message: str):
              ...
      
      
      class User:
      
          def __init__(self, sms_sender: SmsSenderProtocol):
              self.sms_sender = sms_sender
      

      这样便解决了类型注解的问题。

      Tip:通过引入 Protocol 来解耦依赖关系,其实上是对依赖倒置原则(Dependency Inversion Principle)的一种应用。依赖倒置原则认为:高层模块不应该依赖低层模块,二者都应该依赖抽象。

      关于它的更多介绍,推荐阅读我的另一篇文章:《Python 工匠:写好面向对象代码的原则(下) 》

      3. 简化依赖数据类型

      在以下代码中,低层模块 monitoring 依赖了高层模块 processes 中的 ProcService 类型:

      # file:monitoring.py
      from processes import ProcService    # 违反契约!
      
      def build_monitor_config(service: ProcService):
          """构造应用监控相关配置
      
          :param service: 进程服务对象
          """
          # ...
          # 基于 service.port 和 service.host 完成构造
          # ...
      

      经过分析后,可以发现 build_monitor_config 函数实际上只使用了 service 对象的两个字段:hostport,不依赖它的任何其他属性和方法。所以,我们完全可以调整函数签名,将其改为仅接受两个必要的简单参数:

      # file:monitoring.py
      
      def build_monitor_config(host: str, port: int):
          """构造监控相关配置
      
          :param host: 主机域名
          :param port: 端口号
          """
          # ...
      

      调用方的代码也需要进行相应修改:

      # 调整前
      build_monitor_config(svc)
      
      # 调整后
      build_monitor_config(svc.host, svc.port)
      

      通过简化函数所接收的参数类型,我们消除了模块间的不合理依赖。

      4. 延迟提供函数实现

      Python 是一门非常动态的编程语言,我们可以利用这种动态,延迟提供某些函数的具体实现,从而扭转模块间的依赖关系。

      假设低层模块 users 目前违反了契约,直接依赖了高层模块 marketing 中的 send_sms 函数。要扭转该依赖关系,第一步是在低层模块 users 中定义一个用来保存函数的全局变量,并提供一个配置入口。

      代码如下所示:

      # file: users.py
      
      SendMsgFunc = Callable[[str], None]
      # 全局变量,用来保存当前的“短信发送器”函数实现
      _send_sms_func: Optional[SendMsgFunc] = None
      
      def set_send_sms_func(func: SendMsgFunc):
          global _send_sms_func
          _send_sms_func = func
      

      调用 send_sms 函数时,判断当前是否已提供具体实现:

      # file: users.py
      
      def send_sms(message: str):
          """发送短信通知"""
          if not _send_sms_func:
              raise RuntimeError("Must set the send_sms function")
      
          _send_sms_func(message)
      

      完成以上修改后,users 不再需要从 marketing 中导入“短信发送器”的具体实现。而是可以由高层模块 marketing 来一波“反向操作”,主动调用 set_send_sms_func,将实现注入到低层模块 users 中:

      # file: marketing.py
      
      from user import set_send_sms_func
      
      def _send_msg(message: str):
          """发送短信的具体实现函数"""
          ...
      
      set_send_sms_func(_send_msg)
      

      这样便完成了依赖关系的扭转。

      变种:简单的插件机制

      除了用一个全局变量来保存函数的具体实现以外,你还可以将 API 设计得更复杂一些,实现一种类似于“插件”的注册与调用机制,满足更丰富的需求场景。

      举个简单的例子,在低层模块中,实现“插件”的抽象定义以及用来保存具体插件的注册器:

      # file: users.py
      
      from typing import Protocol
      
      class SmsSenderPlugin(Protocol):
          """由其他模块实现并注册的插件类型"""
      
          def __call__(self, message: str):
              ...
      
      class SmsSenderPluginCenter:
          """管理所有“短信发送器”插件"""
      
          @classmethod
          def register(cls, name: str, plugin: SmsSenderPlugin):
              """注册一个插件"""
              # ...
      
          @classmethod
          def call(cls, name: str):
              """调用某个插件"""
              # ...
      

      在其他模块中,调用 SmsSenderPluginCenter.register 来注册具体的插件实现:

      # file: marketing.py
      
      from user import SmsSenderPluginCenter
      
      SmsSenderPluginCenter.register('default', DefaultSender())
      SmsSenderPluginCenter.register('free', FreeSmsSender())
      

      和使用全局变量一样,插件机制同样是对依赖倒置原则的一种具体应用。上面的代码仅包含最简单的原理示意,真实的代码实现会更复杂一些,不在此文中赘述。

      5. 由配置文件驱动

      假设低层模块 users 违规依赖了高层模块 marketing 中的一个工具函数 send_sms。除了使用上面介绍的方式以外,我们也可以选择将工具函数的导入路径定义成一个配置项,置入配置文件中。

      # file:settings.py
      
      # 用于发送短信通知的函数导入路径
      SEND_SMS_FUNC = 'marketing.send_sms'
      

      users 模块中,不再直接引用 marketing 模块,而是通过动态导入配置中的工具函数的方式,来使用 send_sms 函数。

      # file: users.py
      
      from settings import SEND_SMS_FUNC
      
      def send_sms(message: str):
          func = import_string(SEND_SMS_FUNC)
          return func(message)
      

      这样也可以完成依赖关系的解耦。

      Tip:关于 import_string 函数的具体实现,可以参考 Django 框架

      6. 用事件驱动代替函数调用

      对于那些耦合关系本身较弱的模块,你也可以选择用事件驱动的方式来代替函数调用。

      举个例子,低层模块 networking 每次变独立域名数据时,均需要调用高层模块 applications 中的 deploy_networking 函数,更新对应的资源,这违反了分层契约。

      # file: networking/main.py
      
      from applications.utils import deploy_networking    # 导入高层模块,违反契约! 
      
      deploy_networking(app)
      

      该问题很适合用事件驱动来解决(以下代码基于 Django 框架的信号机制 编写)。

      引入事件驱动的第一步是发送事件。我们需要修改 networking 模块,删除其中的函数调用代码,改为发送一个类型为 custom_domain_updated 的信号:

      # file: networking/main.py
      
      from networking.signals import custom_domain_updated
      
      custom_domain_updated.send(sender=app)
      

      第二步,是在 applications 模块中新增事件监听代码,完成资源更新操作:

      # file: applications/main.py
      
      from applications.utils import deploy_networking
      from networking.signals import custom_domain_updated
      
      @receiver(custom_domain_updated)
      def on_custom_domain_updated(sender, **kwargs):
          """触发资源更新操作"""
          deploy_networking(sender)
      

      这样便完成了解耦工作。

      总结

      在依赖关系治理方面,import-linter 是一个非常有用的工具。它通过提供各种类型的“契约”,让我们得以将项目内隐式的复杂依赖关系,通过配置文件显式的表达出来。再配合 CI 等工程实践,能有效地帮助我们维持项目架构的整洁。

      如果你想在项目中引入 import-linter,最重要的工作是修复已有的不合理的依赖关系。常见的修复方式包括合并与拆分、依赖注入、事件驱动,等等。虽然手法多种多样,但最重要的事用一句话便可概括:把每行代码放在最恰当的模块中,必要时在当前模块引入新的抽象,借助它的力量来反转模块间的依赖关系。

      愿你的项目架构永远保持整洁!

      答案在代码中:“实现需求”的双重含义

      2023年7月9日 15:44

      实现“石头、剪刀、布”游戏

      一天,我在一个 Python 技术群里看到一段有意思的讨论。讨论始于这么一个需求:

      题目:写代码模拟“石头、剪刀、布”游戏。由玩家 A 和 B 随机进行 10 次游戏并打印结果。要求:用数字 0 来表示石头,1 表示剪刀,2 表示布。

      紧跟着的,是一段实现了该需求的 Python 代码。如下所示:

      import random
      
      def game():
          """生成一局随机游戏,并打印游戏结果。"""
          a = random.randint(0, 2)
          b = random.randint(0, 2)
          print(f"玩家 A:{a},玩家 B:{b}")
          if a == b:
              print("平局")
          elif a == (b + 1) % 3:
              print("玩家 B 获胜")
          else:
              print("玩家 A 获胜")
      
      if __name__ == '__main__':
          for num in range(10):
              print(f">>> Game #{num}")
              game()
      

      不难看出,代码实现需求的方式有一点巧妙,主要体现在 elif a == (b + 1) % 3 上。要推导出这行代码,原作者需要历经以下几步思考:

      1. [石头, 剪刀, 布] 分别对应 [0, 1, 2] 数字
      2. [石头, 剪刀, 布] 这个排列顺序,刚好是前一个赢过后一个,比如“石头(0)”克“剪刀(1)”,由此推导出判断语句: a == (b + 1)
      3. 到了“布”时,前一条规则回到了列表头:“布(2)”赢过“石头(0)”,由此推导出取模运算:a == (b + 1) % 3

      针对这段代码,大家当时主要争论的点是“性能”,即通过取模运算减少了分支后,对代码的执行性能有哪些影响。但我当时看到代码,脑子里冒出了另一个挥之不去的疑问:“这段代码真的实现了需求吗?”

      毫无疑问,从执行结果来看,它的确实现了需求:

      >>> Game #0
      玩家 A:2,玩家 B:1
      玩家 B 获胜
      >>> Game #1
      玩家 A:0,玩家 B:1
      玩家 A 获胜
      ...
      

      但问题的关键点在于,“实现需求”这个描述,实际上存在双重含义,而这份代码只满足了第一重。

      “实现需求”的第一重含义是字面意义,它指代码是否满足了预期中的功能,面向的对象是普通用户。在这之外,隐藏着另一重更隐蔽的面向程序员的含义:是否能通过读代码来轻松还原需求

      代码的阅读体验不尽相同。当读到好代码时,我们可以轻松在大脑中描绘出需求的样貌,每行代码和原始需求之间,就像被一根根隐形的线连接了起来。借助代码这个媒介,需求晶莹剔透地展露在我们面前,丝毫毕现。

      但是读糟糕的代码,就像是在充满污泥的池塘里寻找失物。需求藏身于浑浊的泥水里,轮廓模糊,让我们很难掌握它的踪迹。

      如上所述,“实现需求”的第二重含义,指的是代码是否能将原始需求清晰地传递给读者。从这个维度看,上面的“剪刀石头布”代码远未达到要求。

      改进“石头、剪刀、布”

      为了能更好地“实现需求”,我重写了一份“石头剪刀布”的代码。如下所示:

      import random
      
      ROCK, SCISSOR, PAPER = range(3)
      
      # 构建“赢”的基础规则:“我:对手”
      WIN_RULE = {
          ROCK: SCISSOR,
          SCISSOR: PAPER,
          PAPER: ROCK,
      }
      
      def build_rules():
          """构建完整的游戏规则"""
          rules = {}
          for k, v in WIN_RULE.items():
              rules[(k, v)] = True
              rules[(v, k)] = False
          return rules
      
      def game_v2(rules):
          """生成一局随机游戏,并打印游戏结果。"""
          a = random.choice([ROCK, SCISSOR, PAPER])
          b = random.choice([ROCK, SCISSOR, PAPER])
          print(f"玩家 A:{a},玩家 B:{b}")
      
          if a == b:
              print("平局")
          elif rules[(a, b)]:
              print("玩家 A 获胜")
          else:
              print("玩家 B 获胜")
      
      if __name__ == '__main__':
          rules = build_rules()
          for num in range(10):
              print(f">>> Game #{num}")
              game_v2(rules)
      

      新代码最主要的改动,在于将“石头剪刀布”的游戏规则显式表达了出来。 通过定义 WIN_RULE 字典,我们清晰向读者传达了整个需求中最重要的部分,也就是游戏规则本身:“石头克剪刀”、“剪刀克布”、“布克石头”。

      剩下的所有代码,基本就是对这条重要信息的补充与扩展。比如通过 build_rules() 函数,将规则扩展为可直接求值的结果表;在分支语句中,直接访问 rules 获取结果。

      不论是从哪一重含义上看,新代码都很好地实现了需求。

      相关扩展

      “石头剪刀布”的新版代码用到了“数据驱动”技巧——拿一份游戏规则表驱动了整个程序。这么做除了能让代码显式对齐需求以外,还有一些额外的好处。比方说,调整游戏规则变得很容易,修改 WIN_RULE 就行。

      除了“数据驱动”以外,编程领域中还有许多思想和规范,实际上都在为“实现需求”的第二重含义服务。

      良好的命名和结构

      在写代码时,如果对变量和函数名多些斟酌,让它们更具描述性,就能有效降低人们理解代码的成本。试着对比下面这两段代码:

      # 来自“石头剪刀布”旧版本
      a = random.randint(0, 2)
      b = random.randint(0, 2)
      
      # 来自“石头剪刀布”新版本
      a = random.choice([ROCK, SCISSOR, PAPER])
      b = random.choice([ROCK, SCISSOR, PAPER])
      

      新版本显然更好理解,更贴近“让 A 和 B 随机出拳”这个需求。与之相比,旧版本对 randint() 的使用很容易让人不明所以。

      引入额外抽象

      虽然过度抽象的代码也很糟糕,但在现实中,缺少抽象的代码还是更为常见。如果代码中缺乏抽象,需求的真相就会被淹没在无数细节中,哪怕是指甲片大点代码,也需要翻来覆去看才能懂。

      因此,程序员们要善于利用各种工具(函数、类、模块)创建出恰当的抽象,从而让需求完美融入在代码中。

      在处理一些上下文极为狭窄的小需求(比如解答一道算法题)时,人们尤其容易忽视抽象。他们倾向于只写一个函数,长篇累牍,将一切算法和逻辑一股脑塞进其中。

      针对这类小需求,我们仍需要把第二重含义放在心上。必要时,拆分出一些小函数,这会让算法更易理解,也更好维护。

      面向对象编程

      面向对象编程流行起来的一个重要原因,在于它能很好地与现实世界里的模型对应,而需求正是藏身于这些模型和它们之间的关系中。

      举个例子,在面向对象的世界里,我们可以轻松创建一个小鸭类,给它加上“嘎嘎叫”方法。读到代码的人能轻松识别我们的意图。

      class Duck:
          def __init__(self, name):
              self.name = name
      
          def quack(self):
              print(f"{self.name}: Quack!")
      
      Duck('Donald').quack()
      # 输出:Donald: Quack!
      

      但在函数式编程的世界里,同样是这只“呱呱叫”的鸭子,代码实现它的方式就更为曲折,表现需求的能力稍逊一筹。

      领域驱动设计

      《领域驱动设计:软件核心复杂性应对之道》 一书中,作者 Eric Evans 第一次提出了“Ubiquitous Language(统一语言)”概念。“统一语言”指一种在开发人员和用户间通用的精确语言体系,它由项目中用到的各式各样的领域模型构成,通常由领域专家和开发人员共同制定。

      “统一语言”对“实现需求”的第二重含义的贡献,在于它鼓励所有人使用同一套思维模型来沟通需求。借助这种统一性,开发人员最终产出的代码,就更可能贴近最原始的用户需求。

      结语

      “实现需求”之所以有着双重含义,在于代码有两类不同的消费者:普通用户和程序员。前者消费代码所实现的功能,并不关心代码本身。后者消费代码的可读性,因此代码是否能有效地自我诠释需求尤其重要。

      通过阅读代码来理解需求,就像是双腿站立于池塘中寻找一块手表。优秀的代码如同一池清水,我们透过它,一眼就能看到手表正安静地躺在池底,钻石般的表盘在阳光下熠熠生辉。

      入行 14 年,我还是觉得编程很难

      2023年2月18日 19:07

      很多年前,当我还是一名计算机专业的大四学生时,整天上网浏览各类招聘信息,想找到一个合适的程序员实习岗位。

      除了实习岗位外,我偶尔也会点进一些“高级工程师”的招聘帖里。现在回想起那些帖子,抛开让人眼花缭乱的技术名词,我印象最深的就是常出现在第一行的岗位年限要求:“本职位要求 工作经验 5 年以上”。

      作为一只一天班都没上过的小菜鸟,这些年限要求在我眼里简直长到夸张。不过,望洋兴叹之余,我有时也会在心中暗暗憧憬一下:“五年工作经验的程序员,那该多厉害啊?写代码对于他们来说,是不是像吃饭一样简单?”

      时光荏苒,一晃十几年过去了。如今回头一望,自己也成了一名有着 14 年工作经验的光荣打工人。在软件开发行业摸爬滚打这些年后,我发现很多事情,与我在大四时所想象的大不相同,比方说:

      • 随着经验增长,编程并不会变简单太多,“像吃饭一样简单”只出现在梦里
      • 给许多“大项目”写代码不光没意思,还很危险,远不如在 LeetCode 上做一道算法题有趣
      • 只从技术角度思考问题,成不了好程序员,有些东西远比技术更重要

      细想起来,这类关于编程的感触还有许多。我整理了其中 8 条,写成了这篇文章。如果其中某些观点引起了你的共鸣,我会非常高兴。

      1. 写代码很简单,但写好代码很难

      编程曾经是一项门槛很高的专业技能。从前,一个普通人想学编程,最常见的做法就是通过教材和书本学习。不过大部分编程专业书,十分艰深晦涩,对于初学者来说很不友好。因此不少人在尝到编程的乐趣前,就早早地半途而废。

      但如今,学编程正在变得越来越容易。学习不再像以前那样,只能硬啃书本,而是多了许多新途径。观看教学视频、参加 Codecademy 的交互式课程,甚至直接在 CodeCombat 通过玩游戏来学编程,每个人都能找到适合自己的学习方式。

      “妈,我真没在玩游戏,我在学编程呢!你看屏幕右边!”

      此外,编程语言也在变得越来越易用。经典的 C 和 Java 不再是大多数初学者的首选,许多更简单、更易上手的动态类型语言如今大受欢迎,与之相关的 IDE 等工具也变得越来越完善。这些因素进一步降低了编程的学习门槛。

      总而言之,编程早已褪去了它的神秘面纱,从只有少数人才能掌握的神秘技能,变成了一门人人皆可学习的普通手艺。

      但更低的学习门槛,更友好的编程语言,并不意味着人人都能写出一手好代码。如果你已经工作,参与过一些项目,那我很想问你一个问题: ”你日常接触的这些项目的代码质量如何?是好代码多,还是烂代码多?”

      不知你会怎么回答,我先来说说我的答案。

      好代码还是很少

      2010 年,我跳槽到了一家总部位于北京五道口的大型互联网公司。

      加入这家公司前,我只在十人规模的小公司待过,因此,我对新公司在各方面都有着很高的期待,尤其是软件质量方面。当时,我心里想的大概是这样:“这可是支撑了有着千万用户量的产品的‘大’项目,代码质量跟之前那些比,肯定有质的飞跃吧!”

      等到在新公司工作了一周后,我才发现自己实在是错得离谱。所谓“大”项目的代码质量同我的预期相去甚远。打开 IDE,数百行的函数和神秘的数字字面量比比皆是,开发任何一个小需求都难如登天。

      后来,在待过更多公司,接触了更多软件项目后,我总结出一个道理:不论公司多大、项目多牛,在实际工作中遇见好代码,仍然是小概率事件。

      好代码有哪些要素?

      话说回来,到底怎样的代码才算是好代码?在这方面,Martin Fowler 有一句话常被大家引用:

      “Any fool can write code that a computer can understand. Good programmers write code that humans can understand.”

      “任何傻瓜都能写出计算机能理解的代码。优秀程序员写人类能理解的代码。”

      我认为它可以作为评价好代码的原点:好代码一定是可读、易读,且容易理解的。写出好代码的第一原则,就是把人类读者放在第一位。

      除了可读性以外,评价代码好坏还有许多其他维度:

      • 贴合编程语言:是否使用了当前编程语言的推荐写法?语言特性和语法糖,使用程度是否恰到好处?
      • 易于修改:代码设计是否考虑了未来的需求变更,当变化发生时,代码是否容易随之修改?
      • API 设计合理:API 设计是否合理,易于使用?好的 API 在简单场景下使用方便,在高级场景下又可以随需求扩展。
      • 性能够用:代码性能是否满足当前业务需求,同时为未来保留了一定提升空间?
      • 避免过度设计:代码是否存在过度设计、过早优化的毛病?

      总而言之,对于任何层级的程序员来说,好代码都不是什么唾手可得的东西。要写出好代码,需要在许多维度上反复权衡、精心设计,最后再加以持续打磨。

      既然如此,假如想尽快掌握写代码这门手艺,有捷径吗?

      写好代码的捷径

      在许多层面上,我认为编程和写作非常相似。二者都是使用文本和符号来表达思想,只是方式略有不同。

      谈到写作,我想问一个关于作家的问题:“你听说过不读书的作家吗?你有没有听到过某位作家说,他从来不读其他人的作品,只读自己的东西?”。我猜答案应该是否定的吧。

      如果你去查阅相关资料,你会发现许多职业作家的日常生活,就是阅读和写作两件事在不断循环。他们每天会花大量时间阅读各类文字,然后再写作。

      同样是“文字工作者”,程序员们就很少重视阅读。但要想快速提升编程能力,阅读正是不可或缺的重要一环。除了日常工作接触到的项目以外,我们应该更多地阅读那些经典软件项目,从中学习 API 设计、模块架构和代码编写的技巧。

      不光代码和技术文档,最好再定期读一些计算机方面的专业书,保持阅读书籍的习惯。在这方面,我认为 Jeff Atwood 在 15 年前写的文章 "Programmers Don't Read Books -- But You Should(都说程序员不读书——但你应该读)",如今读来仍不过时。

      提升编程能力的捷径,就藏在“阅读 <-> 编程”这个无尽循环里。

      “一个好的程序员应该做什么?”

      2. 编程的精髓是“创造”

      在程序员的日常工作中,有很多事情会让人充满成就感,甚至情不自禁地感叹“编程真美好”。比方说,修复了一个极难定位的 Bug,用新算法将代码性能提升了一倍,等等。但在所有的这类事情当中,没有任何一件,能和“亲手创造出一件东西”相比。

      当你在编程时,创造新事物的机会实际上随处可见。因为并非只有发布一个新软件,才称得上是“创造”。写一个可复用的工具函数、设计一套清晰的数据模型,全都可以归入“创造”的范畴。

      身为程序员,保持对“创造”的热情至关重要。因为它可以帮我们:

      • 更高效地学习:学习一门新技术,最高效的方式就是用它开发一个真实项目,在创造的过程中学习,效果最好。
      • 有机会邂逅了不起的东西: 许多改变世界的开源软件,最初都是作者纯粹出于兴趣所创造,比如 Linus Torvalds 和 Linux,Guido van Rossum 和 Python。
      1989 年的圣诞假期,荷兰人 Guido van Rossum 敲下了 Python 语言的最初几行代码,Python 最初仅被期望作为 ABC 语言的继承者,但后来“吞噬”了全世界

      虽然“创造”好处多多,程序员们也有大把机会去做,但许多人常常缺少一种身为“创造者”的觉悟。就像那个广为流传的小故事所说:一位哲学家询问正在砌砖的工人,有人清楚地知道自己是在建造一座大教堂,有人却认为自己只是在砌砖。很多程序员正是“只见砖块,不见教堂”。

      将自己定位成创造者后,看待事物的方式就会发生天翻地覆的变化。举个例子,同样是给 API 增加报错提示文字,创造者们就能跳出“快速完成需求就好”的思维陷阱,向前一步,追问自己一些更重要的问题:“我想为用户创造什么样的产品体验?怎样的报错文字,更能帮助我达成该目标?”

      就像任何一个有用的编程模式一样,“创造者思维”也能成为你的职业生涯的一道巨大推进力。因此,现在就试着问自己一个问题吧——“我的下一份创造会是什么?”

      3. 打造高效试错的环境至关重要

      我曾参与开发过一个互联网产品,它设计精美,功能丰富,每天都有大量用户使用。

      但就是这么一个从市场角度看颇为成功的产品,工程质量却非常糟糕。如果你打开它的后端项目,把所有目录翻个底朝天,都找不到任何一行单元测试代码,其他自动化测试流程也是无从谈起。而业务逻辑偏偏又十分复杂,最后,项目代码间的意料耦合多如牛毛,开发一个新特性,很容易把旧功能给搞挂。

      “在忙啥呢?” “试着修复我之前修一个问题时搞出来的问题,那问题是我之前解决另一个问题搞出来的,而那个问题又是我……”

      因此,项目每次发布时,开发和产品同学全都得严阵以待,氛围十分紧张。整个发布过程也很刺激,紧急回滚时有发生。一个人在这样的环境中工作,技术成长抛开不谈,心理素质肯定能得到极大锻炼。

      编程原本是一件充满乐趣的工作,但为这样的项目编程,乐趣根本无从谈起。究竟是什么夺走了编程的乐趣?

      理想的编程体验≈“刷题”

      LeetCode 是一个著名的编程学习网站,上面提供了许多覆盖各个难度的编程题,大部分与算法相关。用户可以选择自己感兴趣的题目,直接在浏览器上编写代码(支持十几种编程语言)并执行。如果通过了全部的测试用例,则算作解答成功。

      在 LeetCode 上做题

      在 LeetCode 刷题很像在玩游戏,富有挑战性,同时也很有趣。整个做题过程,实际完美展现了一种理想化的编程体验:

      • 关注点分离:每道题目都是一个独立个体,同一时间内,开发者可以完全沉浸在一道题目中;
      • 快速获得精准反馈:开发者每次调整代码后,能通过自动化测试快速获得结果反馈;
      • 零成本试错:写出的代码语法有错误、逻辑有问题,没有任何不良后果,心理负担小。

      不过,屏幕前的你很可能觉得我在说些废话。

      “不然呢?解算法题、写小脚本,不就是这样的体验吗?有啥特别值得说的?”你很可能会继续补充道,“你知道我们公司的项目有多复杂吗?规模超大,模块巨多,你懂我意思吗?每天服务 ××× 万人,光数据库就好几套,消息队列都有三种,开发起来当然要麻烦一点咯!”

      确实,全世界的软件千差万别,开发起来不可能都像在 LeetCode 上刷题一样轻松愉快。但这并不意味着,我们不应该努力改善自己身处的编程环境,哪怕只有一点点。

      要通过改善环境来提升编程体验,可用的理念和工具包括:

      • 模块化思想:妥善设计项目中的每一个模块,降低耦合,提升正交性
      • 设计原则:微观层面上,应用那些经典的设计原则和模式,比如“SOLID”原则
      • 自动化测试:编写规范的单元测试,必要时使用 Mock 技术,用自动化测试覆盖业务关键路径
      • 缩短反馈回路:切换编译速度更快的工具,优化单测性能,竭尽全力缩短从“改完代码”到“获得反馈”的等待时间
      • 微服务架构:必要时,将大单体拆分为多个职责各异的微服务,分散复杂度
      • ……

      关注编程环境,刻意创造出允许高效试错的“代码乐园”,让工作像刷题一样轻松愉快。是经验丰富的程序员能为自身团队做出的最好贡献之一。

      4. 避开代码完美主义陷阱

      在代码质量上精益求精是好事,但也要注意别掉进完美主义的陷阱。因为编程不是艺术创作,不鼓励人们无限度地追求极致。作家大可花上数年打磨一本传世之作,但程序员在代码上钻牛角尖就很有问题。

      世间没有完美的代码。大多数时候,你的代码只要能满足当前需求,又为未来扩展留了一些空间就够了。有那么几次,我在简历上看到候选人给自己打着“代码强迫症”标签。隔着屏幕,我虽能感受到 TA 对代码质量的那份重视,但在我心底,其实更期望 TA 早已将完美主义陷阱远远甩在了后头。

      5. 技术很重要,但“人”也许更重要

      在软件开发领域,“单一职责原则”(全称为 Single responsibility principle,后简称为 SRP)是一条非常著名的设计原则。它的定义很简单,一句话就可以概括:“每个软件模块应该只有一个被修改的理由”。

      单一职责原则:能做到,并不意味着你就该这么做

      要掌握 SRP 原则,关键在于搞清楚“被修改的理由”为何物。很显然,程序是没有生命的,它自身不能也不需要主动去改变。任何修改程序的理由,都来自与之相关的人,人是导致修改的“罪魁祸首”。

      举个简单的例子。看看下面这两个类,其中哪一个违反了 SRP 原则?

      1. 一个字典数据类,支持两类操作:存数据、取数据;
      2. 一个员工资料类,支持两类操作:更新个人信息、渲染一张用户资料卡片图。

      在大多数人眼里,第一个例子没问题,但第二个例子却明显违反了 SRP 原则。要得出该结论,好像无需任何严格的分析和证明,运用一丁点直觉即可。但假如做一些正经分析,第二个例子的可疑之处,在于能为其轻松找出两个不同的修改理由:

      1. 管理员认为资料中的“个人电话”字段不能有非法号码,需增加简单的校验逻辑
      2. 某员工认为资料卡片图上的“名字”部分太小,希望加大字体

      ”It is people who request changes. And you don’t want to confuse those people, or yourself, by mixing together the code that many different people care about for different reasons.” ——“The Single Responsibility Principle”

      “是人在要求软件变更。你绝不想把那些不同人出于不同原因所关心的代码混在一起,这样只会把他们和你自己搞糊涂。”——“单一职责原则”

      理解 SRP 原则的关键,在于先理解人以及人在软件开发中所扮演的角色。

      再举一个例子。微服务架构是近些年很火的一个技术话题。但许多人在讨论它时,往往只关注技术本身,却忽视了微服务架构与人之间的关系。

      将微服务架构风格与其他东西区分开的关键,在于将大单体拆分为独立的微服务后,不同模块间的边界可以变得更清晰。跟数百人的团队一同维护着一个大单体比起来,许多小组织各自维护着独立的微服务,明显拥有更高的运作效率。

      如果缺少了特定的组织规模(也就是“人”)作为前提,空谈微服务的各种技术优势和那些花活,纯属本末倒置。

      技术当然很重要。身为技术人员,那一张张瑰丽的架构图和独具匠心的代码细节,天然吸引着我们的注意力。但是,也请千万不要对软件开发里的另一个重要因素“人”视而不见。必要时,转换一下看事情的角度(从“技术”转向“人”),那样对你大有裨益。

      6. 求知若渴是好事,但也要注意方法

      如今人人都在说“终身学习”,而程序员是一个尤其需要终身学习的职业。因为计算机技术的迭代更新非常快,某个三年前流行的框架或编程语言,很可能一个月前已经过时。

      一分钟之内会发生什么事情?Netflix 观看时间增长 70,000 小时;Snapchat 上有三百万视频被观看;Google 新增两百四十万次搜索;一个 JS 新框架被发明(这条不是真的 🤓)

      要在工作中表现得游刃有余,程序员们需要学习的东西非常多,涵盖各个层面。拿我比较熟悉的后端领域举例,一位合格的后端工程师至少需要掌握以下这些:

      一种或多种后端编程语言 / MySQL 等关系数据库 / Redis 等常见存储组件 / 设计模式 / 用户体验 / 软件工程 / 编译原理 / 操作系统 / 网络基础 / 分布式系统 / …

      虽然要学很多,但据我观察,大部分程序员其实都挺爱学习(至少不排斥),因此心态不是问题。不过有的时候,光有“求知若渴”的心态并不够,学习时,我们尤其需要关注“性价比”。

      关注学习性价比

      下面这张图,展示了学习成效和投入之间的关系。

      学习成效与投入关系图,横轴为学习投入,纵轴为学习成效

      从图中可以看到,在学习的初级阶段,投入较少时,所获得成效增长飞快。但当成效超过某个阈值后,之后再想继续提升,所需要的学习投入就会呈指数级增长。

      正因如此,我建议你在学习任何一项新事物时,先在脑海中想清楚一个问题:“我应该在上图中的哪个位置停下来?”,而不是闷头猛学。

      知识的海洋浩瀚无边,有些东西需要我们成年累月的持续学习,不断精进。也有些东西,蜻蜓点水般学到一些皮毛已绰绰有余。准确判断并分配自己有限的学习精力,甚至比努力学习本身更重要。

      挑选合适的学习资料

      有了学习目标后,下一步就是寻找合适的学习资料。在这方面,我想分享一次自己的失败经历。

      有段时间,我突然对产品交互设计产生了浓厚的兴趣,认为自己应该在这方面有所精进。于是,我精心挑选了一本领域内非常经典的专业书:《About Face 4: 交互设计精髓》,将其买回家中,满怀信心地认为自己的交互设计能力可以迅速获得提升。

      但事与愿违,当我捧着那本经典著作时,发现自己连第一章都无法顺利读完——那句老话说的没错:“隔行如隔山”。

      从这次失败中,我总结出了一点经验。那就是学习某项新东西时,我们最好挑选那些更易读,更适合“门外汉”的学习资料,不要“眼睛大,嘴巴小”,只知道奔着最经典、最权威的资料而去。

      回顾之前的经历,我觉得以下几本书非常适合门外汉学习使用,性价比极高:

      也许每个人的内心,都想成为一个博学的人,无所不知,无所不晓。但可供分配的时间的精力总是有限,我们不能,也不需要在所有领域都成为专家。

      7. 越早开始写单元测试越好

      我非常非常喜欢单元测试,我认为写单测这件事,对我的编程生涯影响极大。夸张点说,如果以“开始写单元测试”作为分界线,把我的职业生涯分割成两段,后面那段远比前面那段精彩得多。

      写单测的好处很多,比如单测可以驱动你改善代码的设计、可以作为代码的一种文档,等等。此外,完善的单元测试还是构建前面提到的“高效犯错的环境”的关键。

      我已经写过几篇关于单测的文章,比如《有关单元测试的 5 个建议》《游戏“蔚蓝山”教我的编程道理》。所以在这儿,我不打算再重复一遍。只说一句:如果到目前为止,你从未试过写单元测试,或从没重视过测试,我建议你从明天就开始写起来。

      一般情况下我不测试我的代码,但假如测的话,我在生产环境测

      8. 程序员最大的敌人是什么?

      在大多数程序员段子里,产品经理经常作为反派角色出现。他们口中的项目需求总是变个不停,一天冒出一个新想法,搞得程序员苦不堪言。

      客户每天都在不停修改需求,所以,我们决定在下次发布前,把这些需求“冻结”起来

      在这些段子的烘托下,不断修改需求的产品经理,仿佛真成了程序员们最大的仇敌。似乎只要产品不乱改需求,大家的工作环境马上就会成为乌托邦。

      虽然偶尔吐槽一两句产品经理很有意思,但我还是想一本正经的说一句:产品经理不是敌人。

      因为从某种角度来说,软件生来就是准备被修改的(不然你猜,软件为什么叫“软”件?)。这样看来,开发软件和修建房子完全不同。因为没人会在建好一栋大楼后说:“让我们把它推倒重建一遍吧!一样的楼,但是用的钢筋和水泥比之前少 30%!”

      所以,产品经理以及不稳定的需求不是程序员的敌人。并且,能否写出易于修改、适配变化的代码,是区分普通程序员和优秀程序员的重要标准之一。

      那么,程序员们最大的敌人又是什么呢?

      复杂度是最大的敌人

      就像《代码大全2》中所说:软件开发的核心问题是管理复杂度。失控的复杂度就是程序员最大的敌人。

      来看看那些导致项目复杂度不断增长的要素:

      • 不断增加的新功能: 更多的功能等于更多的代码,更多的代码通常意味着更高的复杂度
      • 对高可用的需求: 为了实现高可用,消息队列等额外的技术组件和代码被引入
      • 对高性能的需求: 为了提升性能,缓存和相关模块代码被引入,部分模块被拆分后,换成高性能语言重写
      • 一再被推迟的重构:因项目排期过于紧张,迫在眉睫的重构被一再推迟,技术债越积越多
      • 忽视自动化测试: 没人写单元测试,也没人关心测试

      终有一天,当项目的复杂度增长到一定程度后,空中会传来一声巨响。“咚!”,一个大家不愿改、不敢改的“大坑”凭空出现在了所有人的 IDE 中。

      猜猜看,究竟是谁挖下了这个坑?

      那些在降低复杂度上投入时间的团队,所负责的软件项目更容易成功

      减缓复杂度增长的过程

      虽然复杂度总是会不可避免地持续增长,但有许多实践可以减缓该过程。如果每个人都能做到以下这些事,复杂度就有可能被长期控制在合理范围内:

      • 精通当前编程语言与工具,写整洁的代码
      • 使用合适的设计模式和编程模式
      • 对重复代码零容忍,抽象库和框架
      • 适当运用整洁架构、领域驱动设计思想
      • 编写详尽的文档和注释
      • 编写规范有效的单元测试
      • 分离那些变动的与不变的

      要求看上去很多,但总结起来,核心其实就是一句话:写更好的代码

      写在最后

      2020 年,我在小组内做了一个分享,当时的 PPT 标题是《编程十年后的十个感触》。将资料分享在公司内网后,有位同事看到,评论说光看 PPT 不过瘾,希望我能将其扩展成一篇文章,我回复说没问题。如今 3 年过去了,我总算是兑现了自己的承诺。

      当年准备分享材料时,我做完整个 PPT,最后一页实在不知道该放些啥。于是灵机一动,搞了个纯白色的背景,中间打了一行黑体大字:“十年很短,编程很难”。如今,第二个十年也已快行至中途,而这句话的后半部分好像对我仍然适用——长进不大,继续加油 😅。


      文末插入一个小广告:如果你喜欢阅读这篇文章,也欢迎了解我的书:《Python 工匠:案例、技巧与工程实践》 。它专注于基础编程素养与 Python 高级技巧的结合,是一本适合许多人的 Python 编程进阶书。

      京东购买 | 豆瓣书评

      ❌
      ❌