程序员的核心能力 - 引擎式思维


在编程技术飞速发展的今天,一方面大家的开发效率越来越高,但另一方面也越来越焦虑,内心的独白是“我每天拼死拼活的学了一堆东西,但现在学的几年之后就过时了,那时程序员这碗青春饭就吃不下去了…” 这是一个很现实的问题,但发现它后仍置之不理的话,几年后你就会陷入困境。那该如何解决呢?有人说做几年技术之后转型管理,这是完全可行的,但不是今天我们要聊的话题。如果是继续做技术,你需要找到一些核心的技术能力。它们使你更快速地工作,产生更高质量的成果,不容易被后来者超越,最最重要的一点是,不会因为某项技术的过时而失去价值。我希望通过接下来的几篇文章,和大家分享我的想法,今天讲的第一项核心能力,我称它为“引擎式思维”。

当你写程序的时候,除了要解决眼前的问题,你有没有想过将来是否会需要解决类似的问题。把你的解决方案从解决一个问题扩展到解决一组问题是一项非常重要的能力,也往往是区分新人与资深技术人员的一条分界线。大家都知道写游戏的时候,会先写一个核心引擎,它把显示游戏场景、粒子系统、物理模拟等游戏中最普遍的问题抽象在一个代码库中,这部分的逻辑会在游戏中被不断重用,甚至在多个游戏中重用。在有了引擎之后,上层的游戏开发人员就可以把精力集中在游戏内容的制作上。把你代码中普适的、可重用的部分与具体业务逻辑分离的思想,就是引擎式思维。在游戏中我们叫它引擎(Engine),在应用开发中我们叫它框架(Framework),在基础服务里我们又叫它架构(Architecture),但它们背后的思想是相同的。

有些新手可能会觉得引擎、框架这些东西都是大牛开发的,我们只要会用就好了。但其实事情不论大小,只要有套路可寻,都可以用引擎式思维去解决。在你日常要解决的问题中,粗看可能各不相同,但这时你若退后一步,从一个更高地视角去发现问题之间的共同点,把解决方案中通用的部分从具体的问题中抽离出来,这时你就有了自己的框架。这是程序员的一种核心能力,它不会因为技术的日新月异而过时,但你也不可能一跃而蹴,它会随着你技术能力的成熟而逐渐精进。就如同武侠小说里的内功,难以速成,但却比学习任何具体的招式都更重要。

从一个例子讲起

引擎未必是庞大而复杂的,它只是一种思想,也可以被用于解决小问题。我们来看个例子,假如有一天团队开始抓代码质量,所有文件在提交之前都要做语法检查。这就需要利用 git hook 写一个 precommit 脚本,它会在每次 git commit 之前运行。不了解 git hook 的同学戳这里

你用 Python 写了第一版的 precommit 脚本,代码如下,简单清晰没毛病。

def precommit():
    files = get_commit_files()
    for fname in files:
        if fname.endswith('.py'):
            pylint(fname)
        elif fname.endswith('.js'):
            jslint(fname)

过了两天,你团队的代码质量又进步了,对所有的 Python 模块加上了单元测试,这时就要求所有 Python 文件在提交前还需要跑对应的单元测试,如果单元测试不通过,就让提交失败。所以你改了一下 precommit 脚本

def precommit():
    files = get_commit_files()
    for fname in files:
        if fname.endswith('.py'):
            if py_unittest(fname) == 'failed': 
               sys.exit(1)
            pylint(fname)
        elif fname.endswith('.js'):
            jslint(fname)

虽然比之前稍丑了一些,但可读性还是可以的。大家在尝到了 precommit 脚本的甜头之后,更加变本加厉,各种需求接踵而来

  1. “加上 Javascript 的单元测试吧”
  2. “图片提交之前能不能用 TinyPNG 压缩一下”
  3. “data.py 是数据文件,不需要运行单元测试”
  4. “我们另一个 git repo 也想用你的 precommit 脚本,但要把 Python 的单元测试先禁掉”

1), 2)都是小意思,加上就行了。3)让你有点为难,precommit 函数已经有点长了,要不这个判断写在 py_unittest 里吧。到了4),你忍不住顶了回去“我没空写,拷贝一份自己去改吧!”

这时你开始意识到这样下去不是办法,但别人的这些需求又很琐碎,让你没办法很好地组织代码。如果要系统化地解决这些问题,该怎么做呢?你发现所有的需求都可以抽象成同一个套路

如果文件名符合某个条件,就对该文件执行一个特定函数

如果让使用者来提供文件名的条件以及要执行的函数,那么你只需要构建一个引擎来做条件判断,并为要执行的函数准备参数就行了。有了这个思路后,你把每个需求抽象成了一条规则,规则包含以下几个属性

  • id: 规则的名字
  • include: 适合的文件名
  • exclude: 要剔除的文件名
  • func: 对符合条件的文件,要执行的函数
  • warning_only: 如果设为 True,在 func 执行失败时,只打印警告。否则,中断提交。默认为 False

你把代码重构成了下面这个样子

rules = [
    {
        'id': 'pylint',
        'include': ['*.py'],
        'func': pylint,
        'warning_only': True
    },
    {
        'id': 'py-unittest',
        'include': ['*.py'],
        'exclude': ['data.py'],
        'func': py_unittest
    },
    ...
]

def precommit():
    files = get_commit_files()
    run_hooks(files, rules)

run_hooks 是你整个引擎的入口。而那些具体的需求不再是引擎的一部分,而只是外部输入。更进一步,你把 rules 放在一个单独的 JSON 或是 YAML 文件中。这样别的团队可以定制自己的 rules 配置文件,然后直接使用你的 precommit 引擎,这样就完美解决了之前的第4个需求。

这里不给出 run_hooks 的具体实现,有兴趣的同学可以自己写写看。

避免过度设计

当你有了引擎思维之后,也要小心另一个极端,那就是过度设计。这里有两种情况,一种是过早地设计引擎,没有从实际的需求中去总结抽象,而是凭自己的臆测。对于一个了解业务的资深工程师来说,这样做也许是可行的,但如果你经验不足,还是别太相信你的预判。另一种过度设计是想让引擎能满足所有的需求,这可能让你的引擎变得复杂而难用。在设计引擎时,我也会使用80/20原则,让引擎能很好地解决80%的需求,对于剩下的20%,引擎需要有一种 fallback 机制,它只要做到不挡道,能让使用者自己去解决这些需求就行了。过度设计可以聊的点很多,将来可以单独发文讨论,这里就此打住。

培养引擎式思维

我是在工作几年之后,才逐渐有引擎式思维的意识。对于每个待解决的问题,开始问自己“它是不是某类问题中的一个个例,这类问题能否被系统化地解决”,而随着经验地增长,越来越多的时候我会得出肯定的答案。因为这是一个长时间累积的过程,我鼓励大家尽早地思考这个问题,对你的技术进步一定会有帮助的。

从另一个角度,如果你已经是团队中的技术领导,在与团队成员的技术讨论中,在 Code Review 时,你该教他们些什么?Coding style, 某种语言的语法糖,一个可以解决他们手头问题的第三方库?这些是必要的,但还不够。初出茅庐的小伙伴们可能为了完成布置的任务就已经疲于奔命了,或是沉醉在某项新技术的学习中,但他们未必会有意识地去思考如何搭建一个引擎来解决一类问题。在这方面,初期他们需要有好的范例去模仿,中期需要有人提醒指引,日久之后他们才会自发地造引擎,在中前期一个优秀的技术领导若给于他们这方面的帮助,就能大大加速他们核心能力的成长。我觉得这是培养团队成员的重要一环。

今天的分享就到这里,希望对大家能有所启发,欢迎留言讨论。