MoreRSS

site iconRamsay Leung修改

软件工程师,蚂蚁金服 - 微信 - AWS,使用Emacs 与Linux 
请复制 RSS 到你的阅读器,或快速订阅到 :

Inoreader Feedly Follow Feedbin Local Reader

Ramsay Leung的 RSS 预览

一个自学习的Telegram广告拦截机器人

2025-08-29 14:45:00

English Version

1 序言

我花了一周末时间,写了一个自学习的 Telegram 广告拦截机器人 @BayesSpamSniperBot (https://t.me/BayesSpamSniperBot),项目开源在:https://github.com/ramsayleung/bayes_spam_sniper

1.1 Telegram

Telegram 是一个流行的即时通讯软件,类似微信,Whatsapp,已有超过10亿用户,支持许多强大的功能,如聊天记录云存储,支持Linux, Mac, Windows, Android, IOS, Web 多个平台,客户端都是开源,类似微信公众号的频道功能(Channel),还有我见过的最强大的机器人系统。

2 缘起

平时我跑步和做饭都习惯会听播客,而《软件那些事儿1是我最喜欢的播客之一,主持人是栋哥 2, 我也因为喜欢栋哥的节目,趁机加了栋哥的电报频道。

栋哥的电报频道汗牛充栋 3主要是用来发布播客信息, 之前打开过一段时间的留言功能,没有想到引来了一堆的币圈的用户来发广告,因此将评论功能就关了:

另外一个我关注的频道 Ray Tracing 4也在吐槽币圈的广告,不堪其忧:

3 黑客与画家

常见的 Telegram 广告机器人是大多是基于关键字的,通过匹配关键字进行文本拦截,非常容易被发垃圾广告的人绕过。

被绕过的话主要是靠管理员人工删除。

这不禁让我想起了保罗.格雷厄姆在《黑客与画家》一书在2002年介绍的情况:

当时电子邮件兴起,也有非常多的垃圾邮件,常见的垃圾广告拦截方式是关键字匹配+邮件地址黑名单,但是既低效也容易被绕过。

保罗.格雷厄姆就创造性地使用贝叶斯算法(Bayesian Theorem)实现了一个广告拦截器 5, 效果竟然出奇地好。

对于 Telegram 的垃圾广告而言,这不是类似的问题嘛?

那我岂不是可以用类似的解决方案来解决 Telegram 广告的问题嘛

3.1 贝叶斯定理

提起概率算法,最经典的例子莫过于「抛硬币」这一古典概率——每次抛掷都是独立事件,前一次的结果不会影响下一次的概率。

然而,现实中的很多场景并不能像抛硬币那样无限重复,事件之间也往往并非相互独立。

这时候,贝叶斯定理就显示出其独特的价值。

它是一种「由果溯因」的概率方法,用于在已知某些证据的条件下,更新我们对某一假设的置信程度。

换句话说,贝叶斯算法能够根据不断出现的新证据,动态调整对某个事件发生概率的估计。

简单来说,就像人脑的学习过程:我们原本有一个初步认知,在获得新信息之后,会据此修正原有的看法,进而调整下一步的行动。

保罗·格雷厄姆就是通过贝叶斯定理,不断地根据已被标记为垃圾广告或者非垃圾广告的邮件,对新出现的邮件进行分类,判断其是否为垃圾广告。

如果想更直观地理解贝叶斯定理,推荐两个讲解清晰、生动易懂的视频:

4 架构设计

Telegram Bot 支持两种与 Telegram 服务器交互的模式,分别是:

  1. Webhook: Telegram 服务器会在 Bot 收到新消息时主动回调此前 Bot 注册的地址,Bot Server 只需要处理回调的消息

  2. Long Polling: Bot Server 一直轮询 Telegram 服务器,看是否有新消息,有就处理,本机器人使用的是此模式

4.0.1 消息分析

Bot Server 收到消息之后,会派发到单独的 telegram_bot_worker 处理,然后根据预训练的模型判断是否是垃圾广告,如果是,调用 Bot API 删除消息。

4.0.2 封禁并训练

Bot Server 收到消息之后,会派发到单独的 telegram_bot_worker 处理, telegram_bot_worker 会调用 bot API 删除消息并封禁用户,并插入一条训练数据,标记为垃圾广告(spam)

保存训练数据会触发 hook, 创建一个训练消息,投递到消息队列 training, 会有另外的 worker classifier_trainer 订阅 training 消息,并使用新消息重新训练和更新模型

使用队列和后台进程 classifier_trainer 来训练任务而非直接使用 telegram_bot_worker 主要是为了返回 Bot请求与训练模型解耦,否则随着模型规模的增大,训练时间会越来越长,响应时间会越来越长。

解耦后就易于水平扩展了,在设计上为后续性能优化和扩展预留空间。

5 Why Rails

看了我项目源代码的朋友,难免会浮起疑问,为什么使用 Ruby on Rails 实现的?

因为我工作中会有用到JVM系的编程语言(Java/Kotlin/Scala)和 Rust, 所以我对 Java/Rust 相当熟悉,又觉得模型训练可能对性能要求很高,所以最开始的原型 8我是用 Rust 实现的,大概就花了半个多小时。

但是当我想把原型扩展成 Telegram 机器人时,就发现需要处理相当多与机器人交互的逻辑,主要涉及到 API 与数据库操作,其中大部分都是和模型无关的,因此我又想到了 Ruby on Rails。

论单个工程师做产品原型,就我个人而言,实在是没有比 Ruby on Rails 更高效的框架了,因此我就切换到 Ruby on Rails 去。

Rails 8 的新特性,把 Rails 向所谓的「一人全栈框架」又推进了不少,通过关系型数据库内置对消息队列 Solid Queue 的支持,甚至不再需要类似 Redis 这样的存储来支持队列实现。

架构设计中的队列和后台进程,只需要几行代码就实现了,甚至不需要额外的配置,如果队列不存在,框架会自动创建:

1
2
3
4
5
6
7
8
class ClassifierTrainerJob < ApplicationJob
  # Job to train classifier asynchronously
  queue_as :training

  def perform(group_id, group_name)
    SpamClassifierService.rebuild_for_group(group_id, group_name)
  end
end

得益于 Rails 强大的 ORM 框架,内置各种生命周期的 hook, 对新插入训练数据后触发后台进程重新训练模型的代码也只有寥寥几行:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
class TrainedMessage < ApplicationRecord
  # Automatically train classifier after creating/updating a message
  after_create :retrain_classifier
  after_destroy :retrain_classifier

  def retrain_classifier
    # For efficiency, we could queue this as a background job
    ClassifierTrainerJob.perform_later(group_id, group_name)
  end
end

在 Rails 各种内置强大工具的加持下,我只用了一天时间就把整个机器人的功能给实现出来了。

看到这里,有朋友可能会担心性能,觉得 Ruby 性能不行,并且还是动态语言,不好维护。

我持有的观点还是和之前的博文《编程十年的感悟9一样:

先跑起来再说,先做个原型跑起来,有用户愿意用你的产品再说, 当运行速度成为瓶颈时,你的业务肯定非常大了,肯定有足够的资源招一打程序员把项目优化成 Rust/C++, 甚至是汇编。

没有用户,谈性能只是个伪命题。

至于动态语言一时爽,代码维护火葬场,我也是相当认同的。

因此我在为团队选型时我绝对不会考虑动态语言,只会上编译型的语言, 甚至是Rust这种强类型,但是现在只有我一个人来做原型,我自己是什么顺手就用什么的。

5.1 Vibe Coding?

Vibe Coding等AI编程概念可谓是铺天盖地,甚嚣尘上,难免会有朋友好奇我这个项目是否 Vibe Coding生成的。

答案是,我尝试了几个小时之后,直接放弃了, Claude 4 和 Gemini 2.5 Pro 都试过了。

开始是使用 Rust + Cloudflare Worker 的技术栈,Rust + Cloudflare Worker 是个相当小众的领域,训练语料少,Vibe Coding 出来的代码编译无法通过

后面换成 Ruby on Rails, 问题还更严重了,Ruby 是弱类型的动态语言,语法写起来和英语一样,Rails 又还有很多黑魔法,所以到运行时才报错,代码生成省下来的开发时间,debug过程全补回来了。

另外一个是 Vibe Coding 生成的代码很多都是没有设计的,比如把 ClassifierTrainedMessage 的类耦合在一起,在 Classifier 里面持久化 TrainedMessage

又直接在 telegram_bot_worker 进程里面,接收到训练信息马上同步训练新模型,训练完再返回调用命令的结果,完全没考虑解耦接收训练语料和模型训练。

只能说 Vibe Coding 非常适合 Rust 这样的强类型编译型语言,生成的出来的代码起码要编译通过,保证质量的下限。

而对于那些说「一行代码都不用写/改,就能做出一个APP」的言论,此时我脑海不禁升起疑问?

究竟是代码好到一行都不用改?还是开发者看不出症结所在,所以一行都不改?

6 设计理念

开发完原型,在机器人整体功能可用之后,脑中又有不少的想法冒出来,当时就马不停蹄地给机器人加上, 因此机器人就支持快十个命令,还支持私聊和群聊的不同模式。

加着加着,连我自己都疑惑起来:这么多的功能,有点像国内的各种大而全的App了,我不禁对此产生疑问:

真的会有用户用这么多功能么?真的有用户会用这些功能嘛?太多功能不是也会有额外的心智负担嘛?

我最喜欢的广告拦截器 Ublock Origin 10拦截效果非常好,但是使用起来却非常简单,易上手。

想起《软件设计的哲学11里面提到的设计理念,接口应该是简单易用的,但是功能可以是复杂丰富的。

因此我只能忍痛把此前新增的,但与核心功能无关的命令都删掉;

此外考虑到可能绝大多数的用户都没有技术背景,也可能不知道命令怎么用,因此将命令尽可能地优化成按钮,用户可以直接点击,改善易用性:

我还希望可以支持多语言,比如根据用户的系统语言,自动切换到中文或者英文,这个就需要不同语言的文案。

telegram_botter.rb 这个核心服务类里面有超过60%的代码都是为了此类易用性改进而引入的。

简单留给用户,复杂留给开发

6.1 如何使用

只需两步,机器人就可以自动工作。

  • 将机器人(@BayesSpamSniperBot)添加到您的群组
  • 给予机器人管理员权限(删除消息(delete message ),封禁用户权限(ban user ))

完成这两步后,机器人不仅会自动开始工作,自动识别群内广告,然后删除文本消息,如果发送垃圾广告超过3次,将会被封禁;

还会随着社区的使用(通过 /markspam/feedspam ),变得越来越智能

此机器人的设计理念就是最小化打扰管理员与用户,提供简单的操作命令,并最大可能地自动化, 所以本机器人只提供以下三个命令(支持"/“开头自动补全):

6.1.1 /markspam

删除垃圾消息并封禁用户, 需要管理员权限。

在某条你想封禁的信息下回复 /markspam, 机器人就会自动把该条消息删除被封禁用户.

(消息也被删除)

与常见的群管理机器人不同,这条命令不仅会删除垃圾消息并封禁用户, 因为这条消息还被管理员标记成垃圾广告,有非常高的置信度,所以系统就会以这条垃圾广告为训练数据,对模型进行实时更新。

下次类似的发言不仅会被识别,所有使用本机器人的群组都会受益,也会把类似的文本标记成垃圾广告

6.1.2 /listspam

查看封禁账户列表, 需要管理员权限。

查看已封禁的用户列表,并主动解封。

6.1.3 /feedspam

投喂垃圾信息来训练,无任何权限要求,可私聊投喂或在群组内投喂.

私聊投喂:

群组内投喂:

7 Eating your own dog food

在软件开发领域,有这么一句俗话,Eating your own dog food(吃你自己的狗粮),大意是你自己的开发的东西,要自己先用起来。

因此我建了一个自己的频道:菠萝油与天光墟 12用于测试,可惜订阅者寥寥, 就吸引不来太多的发垃圾广告的用户,所以欢迎大家订阅或者进来发广告,以吸引更多的发垃圾广告的用户。

在我这个频道,每个人都有自由发言的权利(美中不足只是次数受限)

既然没有人来我的频道发广告,苦于没有训练数据,我只能主动出击,赤膊上阵,割肉喂鹰去加了各种币圈群,黄色群,主动去看各种广告了:

自从开发了这个机器人之后,我对广告的看法就变了,以前在别的群看到广告就烦,现在在别的群看到广告就很开心, 这都是宝贵的训练数据,要趁着还没被删,赶紧记录下来。

7.1 八仙过海的垃圾广告

别人故事里的算法效果总是出奇的好,到自己实际运行的时候,总是发现会有这样那样的 case 没有覆盖,总有各种意外惊喜

许多在 Telegram 发广告的用户都是久经考验的反拦截器斗士了。

虽然关键词封禁效率不高,但是那些能让我们见到的广告说明已经是绕过关键词拦截的。

比如:

在 币圈 想 赚 钱,那 你 不关 注 这 个 王 牌 社 区,真的太可惜了,真 心 推 荐,每 天 都 有 免 费 策 略

又或者

这人简-介挂的 合-约-报单群组挺牛的ETH500点,大饼5200点! + @BTCETHl6666

前者通过空格分隔来绕过关键词,后者通过添加标点符号来绕过关键词。

与英文等基于拉丁字母的语言天然通过空格分词不同,中文使用贝叶斯算法进行统计时,需要先进行分词

the fox jumped over the lazy dog

我们的中文就不一样了

「我们的中文就不一样了」就会被分词成「我们 | 的 | 中文 | 就 | 不 | 一样 | 了」, 然后才能对词频进行统计。

但是像广告 在 币圈 想 赚 钱,那 你 不关 注 这 个 王 牌 社 区,真的太可惜了,真 心 推 荐,每 天 都 有 免 费 策 略 , 空格除了会影响关键字匹配,也会影响分词,这句话的分词结果就会变成:

在 | | 币圈 | | 想 | | 赚 | | 钱 | , | 那 | | 你 | | 不 | 关 | | 注 | | 这 | | 个 | | 王 | | 牌 | | 社 | | 区 | , | 真的 | 太 | 可惜 | 了 | , | 真 | | 心 | | 推 | | 荐 | , | 每 | | 天 | | 都 | | 有 | | 免 | | 费 | | 策 | | 略

这人简-介挂的 合-约-报单群组挺牛的ETH500点,大饼5200点! + @BTCETHl6666 也会被分词成:

这人简 | - | 介挂 | 的 | | 合 | - | 约 | - | 报单 | 群组 | 挺 | 牛 | 的 | ETH500 | 点 | , | 大饼 | 5200 | 点 | ! | | + | | @ | BTCETHl6666

未经处理的训练数据就会影响模型的结果,可见训练数据的质量也非常重要,因此我就对训练语料做了相应的预处理:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
# Step 1: 处理 anti-spam 分隔符
# 把中英文之间的非中英文及数字去掉,即 "合-约" -> "合约"
previous = ""
while previous != cleaned
  previous = cleaned.dup
  cleaned = cleaned.gsub(/([一-龯A-Za-z0-9])[^一-龯A-Za-z0-9\s]+([一-龯A-Za-z0-9])/, '\1\2')
end

# Step 2: 处理中文字符 anti-spam 空格
# 处理 "想 赚 钱" -> "想赚钱" case
previous = ""
while previous != cleaned
  previous = cleaned.dup
  # 匹配中文汉字之间的一个或多个空格,然后删除掉
  cleaned = cleaned.gsub(/([一-龯])(\s+)([一-龯])/, '\1\3')
end

# Step 3: 增加汉字与英文之间的空格
# 以及帮助分词算法如(jieba)更好地分词, e.g., "社区ETH" -> "社区 ETH"
cleaned = cleaned.gsub(/([一-龯])([A-Za-z0-9])/, '\1 \2')
cleaned = cleaned.gsub(/([A-Za-z0-9])([一-龯])/, '\1 \2')

# Step 4: 删除多余的空格(多个空格缩减个一个)
cleaned = cleaned.gsub(/\s+/, ' ').strip

预处理之后, 在 币圈 想 赚 钱,那 你 不关 注 这 个 王 牌 社 区,真的太可惜了,真 心 推 荐,每 天 都 有 免 费 策 略 就会变成 在币圈想赚钱那你不关注这个王牌社区真的太可惜了真心推荐每天都有免费策略 (这里把合法的逗号也去掉了,我觉得相较过多标点符号对分词的影响,把标点去掉分词结果反而是能接受的), 分词结果是:

在 | 币圈 | 想 | 赚钱 | 那 | 你 | 不 | 关注 | 这个 | 王牌 | 社区 | 真的 | 太 | 可惜 | 了 | 真心 | 推荐 | 每天 | 都 | 有 | 免费 | 策略

这人简-介挂的 合-约-报单群组挺牛的ETH500点,大饼5200点! + @BTCETHl6666 就会变成 这人简介挂的合约报单群组挺牛的 ETH500 点大饼 5200 点! + @BTCETHl6666 ,分词结果是:

这 | 人 | 简介 | 挂 | 的 | 合约 | 报单 | 群组 | 挺 | 牛 | 的 | | ETH500 | | 点 | 大饼 | | 5200 | | 点 | ! | | + | | @ | BTCETHl6666

7.1.1 广告新花样

广告看多了,不得不感慨发广告的人的创造力。

因为在消息发垃圾广告会被广告拦截器拦截,他们创新性地玩出了新花样:

消息发的都是正常的文本,但是头像和用户名都是广告,这样广告拦截器就无法工作了,真的是太有创意了。

对手这么有创意,我也因地制宜地建立对用户名的训练模型,检测的时候消息文本的模型和用户名的模型都过一次, 只要有任何一个认为是垃圾广告,那就禁掉。

更进一步的可以对头像做OCR提取文本,再增加一个对头像的训练模型,不过OCR成本挺高的,就先不搞了。

7.2 优化

没有用户的话,做啥优化也没有必要,毕竟过早的优化是万恶之源, 因此我就把想法先做成原型,搞出来再说,但这不意味着这个原型没有优化的空间。

脑海中还是有不少优化的点的:

  1. jieba 分词的效果可能不是最好的,后续可以使用效果更好的分词器进行优化;或者是添加自己的词库。
  2. 每次有训练消息都进行重新训练,效率稍低,可以增加 batching 机制:有新消息时,等待5分钟或者等到100条消息再处理
  3. 现在整个模型都是在内存中计算,计算完就持久化成 DB, 可以在内存和数据库之间增加一层缓存来优化性能
  4. 贝叶斯算法可能效果不够好,换个复杂的机器学习模型

但是这些优化点都算是 Good to have, 不是 Must have, 后面遇到实际问题再进行优化好了。

8 实战效果

使用变换之后的垃圾广告词进行发送:

成功被检测出来,自动删除了:

有朋友可能会说,这只是卖家秀,为什么别人在我群里发的广告还是没有被识别?

因为贝叶斯算法本质是个概率算法,如果它没有见过类似的广告,那么它就没法判断是否垃圾广告 :(

稍安勿躁,你需要做只是使用 /markspam 删除消息并封禁用户,就可以帮助训练这个bot, 所有使用这个 bot 的用户都会因此受益

9 结语

我相当享受这种从发现问题、灵光一现,到构建原型,再到最终打磨出一个完整项目的创造过程。

虽然这完全是「用爱发电」——代码开源,还得自掏腰包租服务器,物质上毫无回报。

但每当看到机器人成功拦截广告的那一刻,那种创造的喜悦,就足以令我回味无穷。

推荐阅读

qrcode_gh_e06d750e626f_1.jpg 公号同步更新,欢迎关注👻

一本读了八年还没读完的书

2025-08-05 01:00:00

1 缘起

正如我在之前博客文章《这些年走过的路:从广州到温哥华1提到的那样,我在大二暑假的时候因缘际会,获得了去一家在深圳的初创公司实习的 Offer。

实习的两个多月时间也快就过去了,我也顺利拿到了 Return Offer,公司也非常有人情味地给实习生办了个欢送典礼。

当时实习的导师,也是这家公司的副总裁,加州州立大学的刘颖教授2,在欢送典礼上给我们几个实习生每人都赠送了一本书作为临别礼物。(可惜换了几次手机,已经找不回当初手捧着书的合照了)

他说这是一本可以帮助我们了解程序本质,以及学习抽象的好书,这本书就叫《计算机程序的构造和解释3(Structure and Interpretation of Computer Programs, 简称 SICP,下文使用 SICP 代称)

收到这本书时,我并未料到它会成为一场长达八年的拉锯战。

2 好书不愉悦

我在2016年收到这本书,从2017年开始阅读,中间中断了好几次又重新拾起,而时至今日也只读了一半,即五个章节中的前三章。

翻看自己一直以来阅读这本书的日记,总有种看胡适先生一直在打牌的留学日记一样的感受:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
** DOING Read The Structure And Interpretation of Computer Programs

   Read SICP everyday, at least 1 hour

   :LOGBOOK:
   CLOCK: [2017-04-03 Mon 20:26]--[2017-04-03 Mon 21:25] =>  0:59
   CLOCK: [2017-03-14 Tue 23:15]--[2017-03-14 Tue 23:40] =>  0:25
   CLOCK: [2017-03-14 Tue 22:45]--[2017-03-14 Tue 23:10] =>  0:25
   CLOCK: [2017-03-12 Sun 15:46]--[2017-03-12 Sun 16:11] =>  0:25
   CLOCK: [2017-03-12 Sun 15:13]--[2017-03-12 Sun 15:38] =>  0:25
   :END:

   <2022-10-08 Sat>
   #+begin_comment
   读了6年了,还是没有读完,重新开始读
   #+end_comment

   <2025-05-25 Sun>
   读了8年,还是没有读完,又开始读

看到这里,可能没有读过 SICP 可能会奇怪,为什么读一本书要这么久,如果蜻蜓点水,水过鸭背那样子读完一本书,自然只需要不停地翻页即可。

而 SICP 为了帮助你掌控书中讲解的知识和要点,会有大量的习题,并且把非常多额外的知识点都嵌入到习题中,以练带学。

就数量而言,章节一有46道习题,章节二有97道习题,章节三有82道习题。 如果跳过这些习题,这本书的内容不仅少了一半,而且也失去其精髓,可谓是买椟还珠。

此外,习题不仅数量多,还有相当难度,我每天花一到两个小时阅读,只能完成1-2道习题。

习题完成情况:

  • 章节一: 43/46
  • 章节二: 88/97
  • 章节三: 72/82
  • 章节四: TODO
  • 章节五: TODO

我把所有的题解都放到了 GitHub 项目: https://github.com/ramsayleung/sicp_solution, 并为大部分的题解都配套了单元测试,以验证其正确性,还加上了 GitHub Action 作 CI.

经年累月,我的题解代码和笔记都接近一万行了,这也能侧面说明我为什么读得这么慢了:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
> tokei .
===============================================================================
Language            Files        Lines         Code     Comments       Blanks
===============================================================================
Markdown                1           65            0           46           19
Org                    70         2757         2163            0          594
Racket                 71         3976         3086          256          634
Scheme                 77         2479         1898          110          471
===============================================================================
Total                 219         9277         7147          412         1718
===============================================================================

毕竟大部分真正让人进步的阅读,读起来都不是愉悦的:

世之奇伟、瑰怪、非常之观,常在于险远,而人之所罕至焉,故非有志者不能至也。

—-王安石《游褒禅山记》

3 主旨

如果笼统地概括整本书,“无非”是「抽象」,通过使用一门非常简单的语言 Scheme, 以及几个非常简单的操作 cons(构造一个序对), car(取出序对的第一个值), cdr(取出序对的第二个值):

1
2
3
4
5
6
> (cons 1 2)
'(1 . 2)
> (car (cons 1 2))
1
> (cdr (cons 1 2))
2

构造出各种数据结构,如链表,队列,哈希表以及更复杂的组合数据结构; 探寻各种概念,如递归,闭包,高阶函数,赋值,流,程序优化等;

而后两章更进一步,第四章介绍如何实现一个 Scheme 简单的解释器,一个简单的 Prolog 解释器;而第五章介绍计算机体系结构的 CPU 设计,编译器,垃圾回收等。

从括号中的几个简单函数,到最后造出整个计算机体系,有种《道德经》里道生万物的感觉:

道生一,一生二,二生三,三生万物。

4 计算机科学与工程

我本科专业读的是软件工程(Software Engineering ),翻看当初的专业培养计划,从计算机导论开始入门,到程序设计基础,面向对象程序设计基础,数据结构,操作系统,计算机组成原理,再到计算机网络,汇编与编译原理,数据库原理到软件工程等。

学习完这些课程,可以成为一个合格的软件工程师,但广义的计算机专业还有一门专业,叫计算机科学(Computer Science),我一直很疑惑两者之间的差别是什么,就我所见过的不同学校的培养计划里面,两者的课程都非常相似。

而在阅读这本1984年麻省理工就出版的计算机科学的教材时,我找到了我想要的答案。

最初的计算机科学是数学,电子工程和软件设计的交叉学科,计算机科学的学生需要兼备这三者的专业知识。

而三位作者也是高屋建瓴,在数学,电子工程和软件领域旁征博引,各种知识信手拈来, 如练习3.59关于微积分的内容,通过流来处理幂等数积分,练习3.60-3.62都是关于积分的内容。

如3.5章里面,通过流来描述信号处理系统中的「信号」, 练习3.73用程序的流(stream)来表示电流或者电压在时间序列上的值,用以模拟电子线路。

如 3.3章里面,通过程序来建立数字电路的反门,与门,或门,再通过这样的电子元件建立起半加器, 再通过多个半加器实现全加器,实现二进制的加法,从程序到模拟电路,再用模拟电路来构造计算机的处理器。

不同学科的知识在一本书中融会贯通,再配合这个 eval-apply 表达式的配图,总有一种太极的感觉,难免让我有种读计算机哲学书的感觉:

5 优美的括号

书中那些非常有趣或者优美的代码

5.1 图形构造:从点到面的抽象

第二章介绍了复合的数据结构时,就提到了如何去画图,先画点,再画线,然后要求完成习题连线成面,构造出图形。

新的习题再要求通过变换,组合图形,构造新的复杂图形:

最后把类似的变换应用到图片上:

5.2 蒙特卡罗模拟来计算 π

所谓的蒙特卡罗模拟(Monte Carlo Simulation)是一种通过随机采样和统计计算来求解的数值方法,通过大量随机实验模拟不确定性,从而估算复杂系统的可能结果。

用人话来说就是不断地试,在试错的过程中逼近确定解,试的次数越多,结果越准确。

书中就介绍了一种通过蒙特卡罗模拟来计算 π 的方法, 就像通过随机撒豆子估算圆的面积,概率统计将抽象的π转化为可计算的实验:

举例来说,6/π^2 是随机选取的两个整数之间没有公因子(也就是说,它们的最大公因子是1)的概率。我们可以利用这一事实做出π的近似值。

完全读不懂这段话,没理解是怎么可以算出π的近似值的。

查阅资料后得知:

随机选取两个正整数,它们互质(即最大公约数GCD为1)的概率是 \(\frac{6}{\pi^2}\) , 所谓的 互质指两个数没有公共因子(如8和15互质,但8和12不互质,因为公约数为4)。

这一结论源自数论中的经典定理(涉及黎曼ζ函数),我们只需利用其概率公式反推π即可。

而用蒙特卡罗模拟步骤来计算 \({\pi}\):

随机实验:重复多次随机选取两个整数,检查它们的GCD是否为1。

例如:

  • (3, 5) → GCD=1(计数+1)
  • (4, 6) → GCD=2(不计数)

统计概率:

若总实验次数为 N,其中 k 次GCD=1,则互质概率的估计值为 \(\frac{k}{N}\)

关联π:

根据数论结论 \(\frac{k}{N} \approx \frac{6}{\pi^2}\),解得 \(\pi \approx \sqrt{\frac{6N}{k}}\)。

当直接计算π困难时,可通过概率实验间接逼近。

这里利用了数论中的概率规律,将π与随机事件联系起来。(高等数学对于我来说已是雪泥鸿爪,更遑论数论的知识了)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
#lang racket

(define (estimate-pi trials)
  (sqrt (/ 6 (monte-carlo trials cesaro-test))))

(define (cesaro-test)
  (= (gcd (rand) (rand)) 1))

(define (monte-carlo trials experiment)
  (define (iter trials-remaining trial-passed)
    (cond ((= trials-remaining 0)
           (/ trials-passed trials))
          ((experiment)
           (iter (- trials-remaining 1) (+ trials-passed 1)))
          (else
           (iter (- trials-remaining 1) trials-passed))))
  (iter trials 0))

这里的蒙特卡罗实现真的是优雅,它将数学理论(互质概率)转化为寥寥数行的递归实验。

而基于流的实现更是优美:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
(define (monte-carlo experiment-stream passed failed)
  (define (next passed failed)
    (cons-stream
     (/ passed (+ passed failed))
     (monte-carlo
      (stream-cdr experiment-stream)
      passed
      failed)))
  (if (stream-car experiment-stream)
      (next (+ passed 1) failed)
      (next passed (+ failed 1))))

6 尾声:括号里的计算机哲学

直到今天,我也只读完前三章。有时我会问自己:这本书究竟给了我什么?

它没有教我实用的编程技巧,也无关面试刷题。

但是我好像又抓住了一些东西,尤如用手拢过一团烟雾,张开手,并未见留下什么,但是手上还残留着它的气味。

如今,MIT 的课程已用 Python 替代 Scheme,但 SICP 的价值从未褪色。

SICP 不是一本教你「如何编程」的书,而是一把钥匙,是一座桥,连接着工程的实用与科学的纯粹。

那些括号中的表达式,最终在我脑中化成了某种朦胧却持久的气味:

一种对抽象本质的直觉

推荐阅读

qrcode_gh_e06d750e626f_1.jpg 公号同步更新,欢迎关注👻

从在加拿大退货失败的一件小事思考系统设计

2025-06-01 02:00:00

1 前言

前天刚写完《软件设计的哲学》,满脑子还萦绕着模块耦合和接口抽象, 结果昨天就撞上一个现实中的“设计陷阱”——一次耗时数小时却无解的「退货」噩梦。

今天趁着周末,决定把这场荒诞遭遇拆解出来,既当吐槽,也当案例分析.

2 来龙去脉

前段时间搬了家,自然就需要重新办理宽带,一直用的是 Telus 家的家庭宽带服务,他们家的宽带服务也支持从一个住址迁移到另外一个住址, 就预约了 Telus 技术人员上门安装。

技术人员上门安装完宽带之后,就需要测试一下 WI-FI 能否正常使用,就问我们的路由器在哪,他接上处理一下。

问题就来了:

我们的路由器之前是舍友设置的,还不是常见的一体路由器,而是分体式路由器,有三个不同的组件。

而舍友在搬完家后就回国休假了,我还真不知道怎么搞这路由器,各个接口尝试了小半个小时也没反应,师傅也没见识过,自然也不晓得弄。

这个又是一个非常经典的软件开发问题:

「在我的机器上能跑,换个环境就挂了」

但是一直没网也不是办法,然后师傅建议我可以把他随身带的 Telus 路由器买下来,等我舍友回来后把网络设置好,再把路由器还回来,Telus支持30天无理由退货。

听起来也只能这么搞了。

舍友休了几周假回来之后,几分钟不到,很快就把这个路由器就设置起来了:

剩下的就是把路由器还给 Telus, 已经过了几周,30天的免责退货时间所剩不多了。

3 退货流程

因为设备不是通过网购买的,没法直接在网上退单,也不是门店买的,无法直接拿去门店退,退货的流程是打电话给 Telus 的客服,问他们要退货指引。

我就给 Telus 的客服打电话,解释清楚情况后,客服说给我账户对应的邮箱发个邮件,里面有指引和退货码,我需要去 Canada Post(加拿大邮政)把路由器寄回去。

电话里客服说已经给我发邮件了,但是我说没有收到(此处为后面埋下伏笔),于是我提供另外一个邮箱,成功收到了。

因为 Canada Post 最近在为涨薪闹罢工,客服提到我需要去另外一家快递公司 Purolator 寄快递。

剩下要做就是把路由器打包,然后寄出来(这么容易就好了), 再把快递单号告知 Telus, 退货流程就算结束了。

4 坑来了

4.1 邮政罢工

因为加拿大邮政罢工,所以只能去 Purolator 寄,但是去到 Purolator后,人家反馈:

你这个退货码是给加拿大邮政的,我们不认哦,你要给个我们家的退货码。

我只能去再打电话给 Telus 客服要退货码,花费了15分钟,终于打通了,解释完一番之后,他们说给我的邮箱发了新的 Puralator 退货码,我等了一分钟,说没有收到,然后让给我另外的一个邮箱也发一次指引,还是没有收到,然后客服说邮件会在24-48小时内到达..

但挂电话后再等了一个小时还是没有收到.

4.2 邮箱收不到email

只能再打电话给 Telus 的客服,又等了10几分钟终于接通了,这次换了个客服,这位客服说我们不支持 Purolator,你可以等加拿大邮政罢工结束之后再寄。

我也很无语,怎么你们的回复还不一致的,就和客服说,我怎么知道罢工什么时候结束呢,30天马上就要到了嘛。

客服说,的确很有道理,这样吧,你可以去尝试使用用加拿大邮政寄下,然后我把情况记录一下,到时超过30天也可以免责退款。

然后我追问到,那罢工结束时退货也是用相同的退货码么?这个退货码有过期时间么?邮件没写哦。

客服说,那以防万一,我再给你邮箱发个新的退货码吧。

我着实是怕了,不知道为什么一直没有收到邮件,就让客服把我账号对应的邮箱地址读出来, 客服就把我邮箱的逐个地址读出来。

前面部分听着没问题嘛,我还在寻思是什么问题,只是听着听着,怎么我邮箱还有我不认识的部分,就打开 Telus 的APP 修改, 然后被气得差点要吐血了:

我的邮箱地址是 [email protected], 然后为了标记不同的公司,我用了《两个鲜为人知的Gmail地址技巧》 提到的加号技巧来注册 Telus 账号:

[email protected]

之前用了一年多还是好好的,不然我也无法注册和验证邮箱成功。

但是现在 Telus 作了变更,直接把邮箱地址中的加号去掉了,变成了 [email protected], 变成一个完全不同的邮箱, 肯定是不可能收到邮件的。

花费了近一下午,打了5-6次电话,和不同的客服沟通和练习口语,最后的结果就是隔天再去加拿大邮政试试,不行就等他们罢工结束再寄。

5 糟糕设计的代价

这次经历虽然令人沮丧,但也印证了软件工程的一条铁律:

糟糕的设计最终会让所有人付出代价——无论是用户还是开发者。

讽刺的是,人们总希望通过「学习别人的错误」来避免踩坑,但现实中,我们往往被迫为别人的设计缺陷买单。

5.1 单点故障与「Happy Path」陷阱

电话退货这个操作虽然看似落后,但是总体来说还是可以用的,在不出问题的前提下。

Telus 的退货流程设计暴露了一个典型的系统脆弱性:

强依赖单一服务提供商(Canada Post) ,且未设计降级方案(如备用物流或线下门店退货)。

这种「Happy Path Only」的思维,本质上是对分布式系统设计原则的违背:

任何外部服务都可能失败,而系统必须对此容错。

让快递直接成为业务系统的「单点」故障,只考虑 Happy Path, 没有考虑异常场景,甚至发过来的退货邮件指引,都可以看出他们是把 Canada Post 写死在邮件。

5.2 向后兼容性:一个被忽视的底线

退货强依赖加拿大邮政这个还可以说成是产品设计的问题,但是直接把我邮箱地址给改掉这个,就一定是程序员的锅了。

此外,我的邮箱地址在 APP 中显示的是 [email protected], 只有在修改邮箱地址的时候,才会显示出 [email protected] 这也是我一直没有发现的原因。

但最令人匪夷所思的是邮箱地址的非兼容性变更:系统直接静默移除了存量用户邮箱中的加号:

[email protected] -> [email protected] ,导致邮件发送失败。

这种粗暴的修改方式违反了最基本的向后兼容性原则,而问题的暴露方式(APP显示与修改界面不一致)进一步说明:

其系统内部还存在的数据状态不一致性问题

合理的变更方式应该是:

  1. 增量控制:
    • 禁止新用户注册或修改时使用特殊符号,但保留存量数据, 保证增量用户地址正确
    • 存量用户修改邮箱地址时,禁止使用带特殊符号的邮箱地址
  2. 存量迁移:
    • 通过离线数仓,查询出所有带特殊符号的邮箱地址,通过异步任务批量通知受影响用户(避免阻塞主流程)
    • 提供自动清理特殊符号的“一键修复”功能(需用户确认)。
  3. 监控兜底:
    • 建立异常邮箱地址的监控或者报表,直到存量问题归零。

虽然这做法非常繁琐,但是却可以保证系统升级绝对不影响用户。

系统设计与维护就是如此:开始做的时候成本很低,越到后期成本越高。

6 个人感悟

除去别人的设计错误之外,我还有些额外的个人感悟:

虽然 Gmail 支持邮箱地址中增加一个 + 这样的功能,但是并不是所有的公司都支持这特性的,重要的邮件还是不能使用这个「奇技淫巧」。

此外,我另外提供的邮箱也无法收到邮件,可能是我的邮箱太长了,导致客服没有拼对我的邮箱,所以最好还是准备一个短的,包含数字的备用邮箱地址,方便电话沟通时提供给对方。

整个故事再次印证了《软件设计的哲学》中的道理:

所有偷懒的设计,终将以更高的成本偿还

当然, 谁来还就是后话了

qrcode_gh_e06d750e626f_1.jpg 公号同步更新,欢迎关注👻

软件设计的哲学

2025-05-30 15:39:00

1 前言

知道这本书是因为在 Hacker News 上有人提问:你读过最好的技术书是什么 1?

最高赞的书是 Design Data Intensive Application(DDIA, 即《数据密集型应用系统设计2), 我觉得 DDIA 也担得起这个赞誉,然后最高赞的回答顺势提到了 A Philosophy Of Software Design 3, 想来能与 DDIA 齐名的书,肯定不会差得哪里去。

作者是 John Ousterhout, 斯坦福大学的教授,TCL 编程语言的创造者(Redis 的初始化版本就是用 TCL 写的),共识算法 Raft 的作者之一.

这本书并不厚,全书只有200多页,读起来也并不费劲。

而这本书的主旨,开篇就点出来了:

This book is about how to design software systems to minimize their complexity.

本书讲述如何设计软件系统以最小化其复杂度

而软件工程的本质就是如何管理复杂度,全书围绕如何降低软件复杂性提出的思考和解决方案, 主要围绕抽象,异常,文档,一致性,设计原则这五个方向。

许多原则我看着都深有共鸣,尤其在设计过相当多的系统之后,犯过许多错误之后,才会意识到这些原则的重要之处。

很多原则看上去说的和没说一样,但只有踩过坑,实践起来都知道是金科玉律, 除了道出「软件设计」的真谛之外, 这本书其他论点也可谓字字珠玑.

关于谨慎暴露过多的配置给用户,尽量让程序动态计算各种参数值,尽量提供默认参数。

开发软件时,开发者主动承担一些额外痛苦,从而减少用户的痛苦。

When developing a module, look for opportunities to take a little bit of extra suffering upon yourself in order to reduce the suffering of your users.

关于接口设计的原则:

模块拥有简单的接口比简单的实现更重要。

it’s more important for a module to have simple interface than a simple implementation

关于异常处理的洞见:

解决问题的最好方式是避免出现问题。

The best way to eliminate exception handling complexity is to define your APIs so that there are no exceptions to handle: define errors out of existence

归根结底,减少 Bug 的最佳方法是让软件更简单(少即是多)

Overall, the best way to reduce bugs is to make software simpler.

2 抽象

所谓的抽象,用我自己的话来说的就是把复杂的东西简单地呈现出来。

2.1 模块深度

为了直观地感受一个模块设计是否足够抽象,作者提出一个模块深度的概念:

矩形的表层长度即是接口的复杂程度,而矩形的面积代表模块实现的功能,好的模块应该是深的(deep), 这意味着它有简单的接口,但是内部有复杂且丰富的实现.

例如 Unix 的文件读写接口:

1
2
3
4
5
6
7
8
9
int open(const char* path, int flags, mode_t permissions);

ssize_t read(int fd, void* buffer, size_t count);

ssize_t write(int fd, const void* buffer, size_t count);

off_t lseek(int fd, off_t offset, int referencePosition);

int close(int fd);

接口非常简单,但是其内部的实现可能需要成千上万行的代码, 需要支持文件目录的读写,文件权限,读写缓冲区,磁盘读写等等功能,这就是「深的」模块。

与其相反的就是浅的模块(shallow), 接口很复杂,但是功能却很简单。

2.2 信息的漏与藏

实现抽象的关键手段就是辨别出信息的重要程度,对于不重要的信息,就要对用户隐藏起来,关键的信息,就要暴露给用户, 实现「去粗存精,开箱即用」。

一个典型的例子就是参数配置,把参数暴露给用户,除非用户非常熟悉这个系统,不然他也不知道怎么算, 不需要用户关注的参数就提供默认值,能程序动态计算就由程序自己来算.

我很反感的一种设计就是引入一个配置系统,系统的运行参数都要由工程师配置,美其名是提供灵活度。

但这不仅引入额外的系统依赖(须知复杂度的根源就来自依赖与不明确),还大大增加了的运维成本, 更何况这样的配置还无法自适应,换种机型又要重新配置,导致配置越来越复杂。

除非是业务的黑名单或者白名单,系统的运行参数能用默认的就用默认,能动态计算就动态计算。

想想TCP/IP 的重试延迟时长如果不是动态计算,那么配置什么值比较合适,网络畅通和网络延迟又该是什么值, 开始恢复时和开始堵塞时又应该是什么值的呢?

3 异常

异常处理是系统复杂度的关键来源之一,异常就是一种特殊的分支,系统为了处理特殊 case难免需要写很多额外的逻辑。

而作者提出的降低异常处理来系统复杂度影响的方法,就是优化设计,减少必须处理异常的地方。

解决一个问题最好的方法是避免其发生,听起来很空洞或者是很不可思议,作者举出来的例子就是 Java 的 substring(int beginIndex, int endIndex) 用于截取子字符串的接口, 如果 endIndex 超出字符长度,Java 就会抛出一个 IndexOutOfBoundException, 调用方就是需要考虑越界的问题。

但是如果 Java 的 substring 接口本身可以像 Python 那样支持越界,返回一个空字符串,那么调用方就完全不需要考虑越界导致的异常

另外一个例子是作者设计的TCL脚本中的 unset 指令,原意是用来删除一个变量,因为他最初的设想是变量如果不存在,用户不可能调用 unset 的,那么当 unset 操作的变量不存在,那么就会抛出异常。

但是很多用户就是用 unset 来清理可能被初始化或者未初始化的变量,现在的设计就意味用户还需要包一层 try/catch 才能使用 unset.

意识到这个设计错误之后,作者对 unset 的语义作了稍微的修正,用 unset 来确保指定的变量不再存在(如果变量本身不存在,那么它什么都不需要做)

更经典的例子就是 Windows 下面删除一个文件,相信使用过 Windows 的朋友尝试删除文件时都会遇到这样的弹窗:「文件已被打开,无法删除,请重试」

用户只能费尽心思去找打开这个文件的进程,然后把它杀掉再尝试删除,甚至只能通过关机重启来尝试删除文件。

但是 Unix 的处理方式就更优雅,它允许用户删除已经被其他进程打开的文件,它会对该文件做标记,让用户看来它已经被删除了,但是在打开它的进程结束前文件对应的数据都会一直存在。

只有在进程结束后,文件数据才会被删除掉,这样用户在删除文件时就不需要担心文件是否被使用。

通过优化以上的设计,减少需要用户处理的异常,这也是一个「去粗留精」的过程, 减少用户需要感知的内容。

4 注释

本书用了好几个章节来介绍文档与注释的重要性,命名的重要性,如何写好注释和起好名字。

好的文档可以大幅改善一个系统的设计,因为文档的作用就是把「对用户重要的,但是无法直接从代码中得知的关键信息告知用户」, 相当于帮用户把一个系统的关键信息给找出来。

不是有这么一句话: 程序员都讨厌写文档,但是更痛恨其他程序员不写文档。

而注释就是离源码最近的文档.

程序员不写注释的借口大概有这么几个(可惜它们都是不成立的), 常见的借口与它们不成立的原因可见:

4.1 好的代码是自解释的

如果用户必须阅读方法源码才能使用它,那就没有抽象,你相当于把实现的所有复杂度都直接暴露给用户。

若想通过抽象隐藏复杂性,注释必不可少

4.2 我没有时间写注释

如果你一直把写代码的优先级置于写注释之上,那么你会一直没有时间写注释, 因为一个项目结束之后总会有新的项目到来,如果你一直把写注释的优先级放在代码之后,那么你永远都不会去写注释。

写注释实际并不需要那么多的时间

4.3 注释都会过期的啦

注释虽然难免会过期,但是保持与代码一致也并不会花费太多时间。

只有大幅需要修改代码时才需要更新注释,更何况,只有每次都不更新注释,注释才会难免过期

4.4 我见过的注释都很烂,我为啥还要写

别人的注释写得不好,那不正说明你可以写出好的注释嘛。

不能用别人的低标准来要求自己嘛。

4.5 注释的原则

说起接口注释和文档,我一直觉得我描述下接口功能和使用场景,已经比绝大多数的同行做得好了。

在和现在的 L7 大佬一起工作之后,着实被他的文档所震撼。

不知道是因为其对代码质量和文档都有非常高的要求,还是读博士时训练出来的写作能力, 其对接口的功能,使用场景以及异常的描述都非常详尽,甚至包括代码使用示例,质量与 JDK 源码的注释不相上下, 原来真的有程序员花这么多精力写代码注释的。

4.5.1 注释应当描述代码中不明显的内容

注释应当描述代码中不明显的内容,

简单来说,就是要描述代码为什么要这么做,而不是描述代码是怎么做的,这相当于是把代码换成注释再写一次。

4.5.2 注释先行

很多程序员都习惯在写完代码之后才写注释,作者反其道而行, 作者推荐在定义完函数或者模块接口之后,不要马上动手写实现, 而是在这个时候在接口上把接口注释写下来,这相当于是在脑海把模块的设计再过一次。

写完代码再写注释,设计思路已经记不大清了,脑中更多的是实现细节,既容易把实现写成注释,又容易陷入「写完代码就不写注释」的陷阱。

5 一致性

前文提到,系统的复杂度来自于两个方面「依赖」与「不明确」, 而「一致性」就是让系统的行为更加清晰明确。

它意味着相似的事情以相似的方式处理,不同的事情以不同的方式处理。

即所谓的「规圆矩方」,通过规范约束降低随意性,以及「一法通,万法通」,统一模式提升可维护性,让行为可预期。

一个系统的一致性一般体现在以下方面:

  1. 命名(驼峰还是下划线)
  2. 代码风格(缩进,空格还是tab)
  3. 设计模式(使用特定的设计模式解决特定的问题)

当然,还有通过「一致性」降低系统复杂度,走得比较极端的:

之前还在微信支付的时候,除上述的要求外,还要求后端只能使用一种语言(C++, Golang/JavaScript就别想了), 存储组件只能使用微信内部研发的KV(使用MySql需要向总经理申请)等等的要求.

6 设计原则

6.1 通用设计

好的设计应该是通用的,优先采用通用设计而非特殊场景的定制化方案,这个是减少复杂度和改善软件系统的根本原则。

过度定制通常是成为软件复杂度增加的首要诱因。

通用设计可以降低系统的整体复杂度(更少处理特殊分支的逻辑), 更深的模块(接口简单,功能丰富), 隐藏非关键信息.

文中提到的例子就是文本编辑器的文字插入与删除操作:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// 反例:过度定制(绑定特殊场景), 实现删除键功能
class TextEditor {
    void handleBackspaceKey() { // 耦合UI事件
        if (cursorPosition > 0) {
            text.deleteCharAt(cursorPosition - 1);
            cursorPosition--;
        }
    }
}

// 正例:通用设计(解耦核心逻辑)
class Text {
    void delete(int start, int end) { // 纯文本操作
        content.delete(start, end);
    }
}

class UI {
    void onBackspacePressed() {
        text.delete(cursor.position(), cursor.position() + 1); // 调用通用API
        cursor.moveLeft();
    }
}

通过 delete(int start, int end) 既可以实现删除键功能,也可以实现选中并删除的功能。

6.2 性能

在设计系统的时候,一般不需要太多地考虑性能的问题,因为简单,通用的系统要做性能优化通常都是比较容易; 相反而言,深度定制的系统因为耦合了定义逻辑,要优化性能并没有那么容易。

6.3 设计两次

Design it twice

因为很难一次就把事情做到极致, 那就再来一次, 设计时把能想到的选项都列下来.

反直觉的是,第一直觉通常不是最优的, 所以不要只考虑一种设计方案,无论它看起来多么合理,多对比下其他方案总没有害处的。

只用第一直觉的方案,其实你是在低估自己的潜力,你错失了找到更好方案的机会。

这也是我在写设计方案时候的做法,把自己能想到的,和同事讨论出来的所有方案都写上,然后分析各种方案的优劣, 最好的方案可能并不在原有方案列表里面,而是其中几个方案的合体。

6.4 大局观

做任何事都要有大局观, 编程也不例外,战略编程优于战术编程(Strategic Programming over Tactical Programming);

虽然我们一直说「又不是不能跑」,但是我们对代码的要求,不能是「能跑就行啦」.

再者就是要和扁鹊他大哥治病一样,把功夫都做在前期,防范于未然,修补错误成本往往也越往后越高,病入膏肓之后,扁鹊来了也要提桶跑路:

治不了,等死吧,告辞

7 代码整洁之道vs软件设计哲学

本书的作者对《代码整洁之道》(Clean Code)4 的作者(Robert C. Martin, 即 Uncle Bob)的诸多观点作了反驳

7.1 函数拆分

比如关于什么时候应该拆分一个函数,Uncle Bob 的观点是,基于函数的代码行数,一个函数需要相当短,甚至10行都有太长了。

Uncle Bob 原话:

In the book Clean Code1, Robert Martin argues that functions should be broken up on length alone. He says that functions should be extremely short, and that even 10 lines is too long.

而本书作者 John 的观点是: 每个函数应只做一件事,并完整地做好

函数的接口应当简洁,这样调用者无需记住大量信息就能正确使用它。

函数应当具备深度:其接口应远比实现更简单。如果一个函数满足以上所有特性,那么它的长度通常并不重要。

除非能让整个系统更简单,否则不应拆分函数

7.2 文档注释

Uncle Bob 认为需要给函数「注释始终是一种失败(Comments are always failures)」

如果我们的编程语言足够富有表现力,或者如果我们有能力用好这些语言来传达意图,那么我们就不太需要注释——甚至可能完全不需要.

注释的正确用途,是弥补我们无法用代码清晰表达的缺陷……注释始终是一种失败

If our programming languages were expressive enough, or if we had the talent to subtly wield those languages to express our intent, we would not need comments very much — perhaps not at all.

he proper use of comments is to compensate for our failure to express ourselves in code…. Comments are always failures.

而 John 的观点是

但注释并非失败的表现。

它们提供的信息与代码截然不同,而这些信息目前无法通过代码本身来表达。

注释的作用之一,正是让人无需阅读代码即可理解其含义

甚至直接反驳其观点:

I worry that Martin’s philosophy encourages a bad attitude in programmers, where they avoid comments so as not to seem like failures.

7.3 网上对线

所以也难怪 Uncle Bob 和 John Ousterhout 几个月前直接在网上论坛来了一次 对线 (辩论) 5

然后有看热闹不嫌事大的播主,把两人邀请到直播上,让他们直接面对面再来了一次对线

对应的Youtube视频: https://www.youtube.com/watch?v=3Vlk6hCWBw0

两位的书我都看过,我个人的感觉是《代码整洁之道》更适合入门的工程师,它可以教你如何写出好的「代码片段」; 而《软件设计的哲学》更适合需要做系统设计的工程师,它指导你如何设计好的「软件」。

考虑到两位作者的背景和作品,我可以说两位的差别可以说是 以编程为生的人与以写编程相关的东西为生的人

8 总结

全书读完,我觉得《软件设计的哲学》绝对是配得上最好的技术书籍之一的赞誉。

但是不同的人读起来可能会有不同的感觉,其中的许多原则真的是做过设计,踩过坑才会有所共鸣, 否则会觉得其泛泛其谈。

当然,我也不是完全同意书中的所有观点的。

比如书中提到的会导致代码意图不「明显」的其中一种做法是声明的类型与初始化的类型不一致的情况:

1
2
3
4
5
private List<Message> incomingMessageList;

...

incomingMessageList = new ArrayList<Message>();

上面声明的是 List<Message>, 实际使用的 ArrayList<Message>, 这可能会误导用户,因为意图不清晰,阅读代码的人可能不确定是否需要使用 List 或者 ArrayList, 最好是声明和初始化都换成相同的类型。

但是 List 是接口, ArrayList 是接口的具体实现,这个就是非常标准的面向对象编程中的多态,这并不什么问题。

但瑕不掩瑜,全书读完,把书盖上后,我有种齿颊留香, 余音绕梁的感觉,书里有很多「熟悉的味道」,总是让我想起经手过的项目中种种的好代码和「坏」代码.

推荐阅读

qrcode_gh_e06d750e626f_1.jpg 公号同步更新,欢迎关注👻

重新造轮子系列(六):构建工具

2025-04-21 09:18:00

项目 GitHub 地址: Build Manager

1 前言

以 C 语言为例,一个程序通常由多个源文件 .c 组成, 每个源文件需要先编译成目标文件 .o, 再链接成最终的可执行文件。

如果只改动了其中一个源文件的内容,理想情况只需要重新编译并重新链接改动文件,而非从头构建整个项目(所谓的增量编译)。

但是如果手动管理这些依赖关系,随着源文件的增多,很容易就会变得无法维护。

类似的问题在软件开发中比比皆是,以我们此前实现的「模板引擎」为例,如果我们正使用静态网站生成器来构建个人博客(Hugo 或者 Jekyll), 当我们修改了某篇文章时,系统只需要重新生成该页面,而不必重新编译整个网站。

但如果我们调整了调整了网站的模板,那么所有依赖该模板的页面都需要重新渲染,而手动处理这些依赖关系既繁琐又容易出错。

因此我们需要一个构建工具(build tool或者叫 build maanger).

2 需求

构建工具的核心理念就是自动化上述的操作:

  1. 定义依赖关系, 比如 main.o 依赖于 main.cheader.h
  2. 检测变更,可以通过时间戳或者内容的哈希来判断文件是否「过时」
  3. 按需执行,只重新生成受影响的目标

从经典的 make, 到现代构建系统如 Bazel, 尽管它们的实现方式各异,但是基本都遵循着这一思路。

所以我们会参考 make, 从零实现一个类 make 的构建工具,核心功能包括:

  1. 构建规则(rule):描述目标(target), 依赖(dependency)和生成命令(recipe)
  2. 依赖图(DAG): 避免循环依赖并确定构建顺序
  3. 增量编译:仅重新生成「过期」的目标
  4. 变量与通配符(如 @TARGET% ) 以提高灵活性.

3 设计

3.1 构建规则

构建工具的输入是一系列的规则,每个规则必需包含三个关键要素,分别是:

  1. 目标,即构建命令生成的最终结果
  2. 依赖,即目标依赖于哪些文件
  3. 生成命令:具体的命令,用于描述如何将依赖生成出目标。

make 的规则文件 Makefile 举例:

1
2
target: dependencies
    recipe

以一个 C 语言程序的构建规则为例:

1
2
hello: utils.c main.c utils.h
        gcc main.c utils.c -o hello

其中 hello 是构建目标, utils.c main.c utils.h 是多个依赖文件, gcc main.c utils.c -o hello 是生成命令.

Makefilemake 专属的配置文件格式,我们可以使用 JSON 或者 YAML 作为配置文件,避免重复造轮子,只要要表达列表和嵌套关系。

那么为什么 make 不使用 JSON 或者 YAML 作为配置文件呢?因为 make 被造出来的时候(1976年),JSON 和 YAML 离诞生还有几十年呢.

3.2 依赖图

以前学数据结构的时候,难免会觉得图(graph) 这个数据结构真的没有什么用,除了刷题和面试会被问到.

但是在开发这个构建工具的时候,会发现图是必不可少的数据结构,准确来说是有向无环图(directed acyclic graph, DAG).

在构建工具中,每个构建规则(target: dependencies)定义了文件之间的依赖关系,这些关系天然形成一个有向无环图(DAG)。

例如:

  • A 依赖于 B 和 C( A → B, A → C
  • B 依赖于 D( B → D

此时,*构建顺序必须满足依赖的先后关系*:D 必须在 B 之前构建,B 和 C 必须在 A 之前构建。

而拓扑排序的作用,正是将图中的节点排序,保证每个节点在其依赖之后被执行。

而如果依赖图中存在环(例如 A → B → A),拓扑排序会失败.

拓扑排序的经典算法有 Kahn 算法(基于入度)与 DFS 算法, 以 Kahn 算法为例, 步骤如下:

  1. 先初始化一个队列,存入所有入度(in degree)为0的节点(无依赖节点)
  2. 依次处理队列中的节点,并将其人图中「移除」,更新后续节点的入度
  3. 若最终未处理所有节点,则说明存在环

我曾经写过一篇关于拓扑排序的英文博客,有兴趣可以移步阅读.

3.3 过期检测

增量编译的关键是仅重建「过期 」的目标,那么要怎么找到「过期」的目标呢?

最简单方式就是使用时间来作为判断标准,假如我们的源文件在上一次构建之后发生了修改, 那么我们就可以认为其对应的目标「过期」了,需要重新构建。

那么我们就需要记录上一次是什么时候构建的,然后再把文件最近的修改时间(last modification timestamp)作为比较, 用额外的文件来记录也太繁琐了,为此我们可以取一下巧:

把目标的生成时间作为上一次的构建时间,那么只要依赖的 last modification timestamp 大于目标的 last modification timestamp, 那么我们就可以认为其「过期」了。

这个就是 make 的实现方式,但是时间并不是总是可靠的,尤其是在网络环境下。

所以像 bazel 这样的现代构建系统,使用的就是源文件的哈希值来作为比较的标识: 即文件内容哈希值发生了变化,那么就认为发生内容变更,目标「过期」,需要重新生成。

3.4 设计模式

上文已经提到,我们构建工具的核心功能是解析构建规则, 构建依赖图,增量编译,变量与通配符匹配,那么我们可以很容易地写出对应的实现原型:

1
2
3
4
loadConfig(): rules
buildGraph(rules): graph
variableExpand(graph)
incrementalBuild(graph)

那么要如何实现上面的原型呢?在面向对象的编程思路里,要不使用继承,或者是组合,而两者对应的设计模式分别对应模板方法(Template Method)策略模式 (Strategy Pattern)

模板方法的核心思想是继承与流程固化,在父类中定义算法的整体骨架(不可变的执行流程),将某些步骤的具体实现延迟到子类,通过 继承 扩展行为。

而策略模式核心思想是组合 + 运行时替换,将算法的每个可变部分抽象为独立策略(接口),通过 组合 的方式注入到主类中。

System Design By Example 原书使用的是模板方法,其实现可谓是充分展示了继承的不足:紧耦合,新增功能需要创建新子类,导致类爆炸,各种类变量在继承链传递,真的是无法维护,最后甚至「丧心病狂」地实现了八层继承,真的是完美诠释了 Fragile base class 的 code smell.

在体现到维护与扩展 template method 代码的痛苦之后,我最终选择了策略模式,因为其可以实现不同策略之间的松耦合,每个策略可以独立修改和扩展,不影响其他组件;易于测试,每个策略可被单独测试。

此外,构建工具需求可能会很多样,比如支持不同的增量编译算法(时间戳与内容哈希),支持不同的配置格式(Makefile/JSON/YAML), 策略模式不需要改写核心代码即可支持这些变体,并且支持不同策略的组合。

为了方便对比两者实现的差别,我把 template methodstrategy pattern 的实现都保留了。

3.5 自动变量

make 支持在 Makefile 中使用自动变量(Automatic Variables)来指代目标或者依赖,而无需显示将目标或者依赖名写出来,其变量含义如下:

假设目标是 output: main.o utils.o

变量 含义 示例
% 通配符, 表示匹配任意非空字符串,通常用于模式规则(Pattern Rules)中 %.o: %.c 匹配任意 .c 文件生成 .o
$@ 目标文件名 output
$^ 所有依赖文件 main.o utils.o
$< 第一个依赖文件 main.o

这些自动变量可以极大简化 Makefile 的编写,避免重复输入文件名, 只不过 $@ 这样的格式有点难以理解,我们可以定义自己的自动变量:

我们的自动变量 make 变量 含义
% % 通配符, 表示匹配任意非空字符串
@TARGET $@ 目标文件名
@DEPENDENCIES $^ 所有依赖文件
@DEP[0] $< 第一个依赖文件
@DEP[n-1] n 个依赖文件

4 实现

在介绍完设计细节,实现就没有太多需要提及的内容,根据入口函数以及单元测试就能理解个七七八八了。

5 示例

假设我们的 src 目录有如下的文件:

1
2
3
4
5
6
> tree src
src
├── Makefile
├── main.c
├── utils.c
└── utils.h

main.c 内容如下:

1
2
3
4
5
6
#include "utils.h"

int main() {
  print_message("Hello from Makefile!");
  return 0;
}

Makefile 的内容如下:

1
2
3
4
5
6
7
8
hello: utils.c main.c utils.h
        gcc main.c utils.c -o hello
varexpand_hello: utils.c main.c
        gcc $^ -o $@
clean:
        rm -f hello
cleanvar:
        rm -rf varexpand_hello

通过 hellovarexpand_hello 目标可分别生成 hellovarexpand_hello 的目标文件:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
> make hello
gcc main.c utils.c -o hello

> ./hello
Message: Hello from Makefile!

> make varexpand_hello
gcc utils.c main.c -o varexpand_hello

> ./varexpand_hello
Message: Hello from Makefile!

Makefile 相同含义的构建规则 build_c_app.yml 如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
- target: hello
  depends:
  - src/utils.c
  - src/utils.h
  - src/main.c
  recipes:
  - "gcc src/main.c src/utils.c -o hello"
- target: varexpand_hello
  depends:
  - src/utils.c
  - src/main.c
  recipes:
  - "gcc @DEPENDENCIES -o @TARGET"
- target: clean
  depends: []
  recipes:
  - "rm -rf hello"
- target: cleanvar
  depends: []
  recipes:
  - "rm -rf varexpand_hello"
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
> npx tsx driver.ts build_c_app.yml # 未指定目标,构建第一个目标,对齐 make
gcc src/main.c src/utils.c -o hello

> npx tsx driver.ts build_c_app.yml hello # 生成 hello
target: hello is up to date, skipping execute the recipe

> ./hello
Message: Hello from Makefile!

> npx tsx driver.ts build_c_app.yml varexpand_hello # 生成 varexpand_hello
gcc src/utils.c src/main.c -o varexpand_hello

> ./varexpand_hello
Message: Hello from Makefile!

测试增量编译,重新构建 hello 目标

1
2
> npx tsx driver.ts build_c_app.yml hello
target: hello is up to date, skipping execute the recipe

修改 main.c 源码:

1
2
3
4
5
6
#include "utils.h"

int main() {
  print_message("Hello from build_c_app.yml!");
  return 0;
}

重新编译及运行 hello 目标:

1
2
3
4
5
> npx tsx driver.ts build_c_app.yml hello
gcc src/main.c src/utils.c -o hello

> ./hello
Message: Hello from build_c_app.yml!

6 总结

终于又造了一个轮子,完成了这个类 make 的构建工具:

除了核心的依赖管理和增量编译,还实现了自动变量替换(如 @TARGET)、通配符规则和策略模式的灵活扩展。

写到这里总会忍不住地想起Unix的 KISS 原则, 即 Keep it simple, stupid, 复杂的工具往往由简单的概念组合而成

回到本系列的目录

7 参考

重新造轮子系列(五):模板引擎

2025-04-15 13:59:00

项目 GitHub 地址: Page Template

1 前言

在现代网站开发里,内容与表现的分离已经成为基本准则(Separation of content and presentation), 比如 HTML 就是负责内容展现,而 CSS 就是负责页面的样式。

而手动更新和编写 HTML 也是一件费时费力并且容易出错的工作,尤其是需要同时修改多个页面的时候, 因此有聪明的程序员就发明了名为静态网页生成器(static site generator)的技术,可以按需生成网页。

事实上,互联网上的大多数页面都是通过某种形式的静态网页生成器生成出来的。

而静态网页生成器的核心就是「模板引擎」,在过去三十年,诞生过无数的模板引擎, 甚至有位加拿大的程序员为了更方便记录谁访问了他的简历,他还发明了一门编程语言来做模板引擎的活,这就是「世界上最好的编程语言:PHP」。

PHP 可以算是 Web时代的王者之一,凭借着 LAMP(Linux, Apache, MySql, PHP) 架构不断开疆扩土,攻城掠地,而PHP本身也不断有新的框架被造出来,为谁是最好的「模板引擎」打得头破血流。

虽然关于「模板引擎」的战争至今仍未停歇,但细分下来,「模板引擎」可以分成三个主要的流派:

1.1 嵌入式语法

在 Markdown/HTML 这样的标识语言里面嵌入编程语言,使用 <% %> 等符号来标记代码与文本内容,其中的代表包括 Javascript 的 EJS, Ruby 的 ERB, 以及 Python 的 Jinja:

1
2
3
4
<!-- 用特殊标记混合JavaScript与HTML -->
<% if (user) { %>
  <h1><%= user.name %></h1>
<% } %>

其优点就是可以直接使用嵌入的编程语言,功能强大,学习成本低,缺点就是模板很容易变成混杂内容和逻辑的「屎山」代码

1.2 自定义语法

不嵌入现成的编程语言,而是自己开发一套 mini 编程语言,或者叫 DSL(domain specifc language), 代表有 GitHub Page 用到的 Jekyll, 还有 Golang 开发的著名静态网页生成器 Hugo, 都是使用自定义的语法:

1
2
3
4
{% comment %} 自创模板语法 {% endcomment %}
{% for post in posts %}
  {{ post.title | truncate: 30 }}
{% endfor %}

优点就是语法简洁,缺点就是发展下去,可以又是自己造了一个新的编程语言,功能还不如通用的编程语言强大

1.3 HTML指令

不再在 HTML 中嵌入编程语言或DSL,取而代之的是直接给 HTML 定义特定的属性,不同的属性代表不同的含义,但是使用的还是标准 HTML.

最著名的就是 Vuejs:

1
2
3
4
<!-- 用特殊属性实现逻辑 -->
<div v-if="user">
  <h1>{{ user.name }}</h1>
</div>

优点是保持HTML的合法性与简洁,不需要额外的 parser, 缺点就是指令功能受限,不如内嵌编程语言强大,生态工具较少, 灵活性差。

本文的模板引擎就会以这个流派为范式进行开发。

1.4 特例之PHP

分析完三种流派,就会奇怪 PHP 究竟是属于哪个流派呢?

1
2
3
4
5
6
<h1><?php echo $title; ?></h1>
<ul>
  <?php foreach ($items as $item) { ?>
    <li><?php echo $item; ?></li>
  <?php } ?>
</ul>

其实 PHP 本质就是流派二,只是这门专门用于「模板引擎」的 mini 语言,最后演化成了一门专门的编程语言,只是这个编程语言最擅长的还是网页开发,即是做「模板引擎」。

所以 PHP 是从流派二演化成流派一。

2 目标

可能不是所有的朋友都了解 Vue,所以在设计我们的模板引擎之前,先来明确一下需求与目标(scope).

假设我们有如下的 JSON 数据:

1
2
3
{
    names: ['Johnson', 'Vaughan', 'Jackson']
}

如果有如下的模板:

1
2
3
4
5
6
7
8
<html>
  <body>
    <p>Expect three items</p>
    <ul z-loop="item:names">
      <li><span z-var="item"/></li>
    </ul>
  </body>
</html>

那么 names 就会被赋值给 item, 然后每一个变量都会被展开成 <span>{item}</span>, 所以上面的模板就会被展开成:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
<html>
  <body>
    <p>Expect three items</p>
    <ul>
      <li><span>Johnson</span></li>
      <li><span>Vaughan</span></li>
      <li><span>Jackson</span></li>
    </ul>
  </body>
</html>

而不同的指令会有不同的效果,如上的 z-loop 就是遍历一个数组,而 z-if 就是判断一个变量是否为 true, 为 true 则输出,否则则不输出.

如有数据:

1
2
3
4
{
    "showThis": true,
    "doNotShowThis": false
}

和模板:

1
2
3
4
5
6
<html>
  <body>
    <p z-if="showThis">This should be shown.</p>
    <p z-if="doNotShowThis">This should <em>not</em> be shown.</p>
  </body>
</html>

就会被渲染成:

1
2
3
4
5
<html>
  <body>
    <p>This should be shown.</p>
  </body>
</html>

我们可以先支持以下的指令集:

指令集 含义
z-loop 循环遍历数组生成元素内容
z-if 条件渲染,值为false时移除元素
z-var 将变量值输出到元素内容
z-num 直接输出数字值到元素内容

3 设计思路

3.1 stack frame

模板引擎的核心是将「数据」+「模板」渲染成页面,那么数据要如何保存呢?以什么数据结构和变量形式来处理呢?

最简单的方式肯定就是使用全局变量的 HashMap 来保存所有的变量,但是如果存在两个同名的变量,那么 HashMap 这种数据结构就不适用。

更何况,可变的全局变量可谓是万恶之源,不知道有多少 bug 都是源自可变的全局变量。

在编译原理,保存变量的标准做法就是使用 stack frame, 每次进入一个函数就创建一个新的栈(stack), 每次函数调用都有自己的独立的栈,可以理解成每个栈就是一个 HashMap, 而每创建一个栈就是向 List 里面 push 一个新的 HashMap, 同一个函数里面不能有同名的变量,那能保证栈里面的值是唯一。

谈及变量和 stack frame, 编程语言中有个 作用域(scoping) 的概念, 定义了变量会怎么被程序访问到。

主要有两种作用域,分别被称为:

词法作用域(Lexical/Static Scoping): 在编译时就将变量给解析确定了下来,大部分编程语言使用的都是语法作用域,比如 Javascript, C/C++, Rust, Golang, Swift, Java 这个名单还可以很长.

因为其性能更优,并且行为是相当明确的,不需要分析运行时代码再来确定,如:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
let x = 10; // 全局变量

function foo() {
  console.log(x); // 词法作用域,问题绑定全局变量 x
}

function bar() {
  let x = 20; // 局部变量,不会影响 foo 中的 x
  foo(); // 调用 foo(), 仍然需要访问全局变量
}

foo(); // 输出: 10 (全局变量)
bar(); // 输出: 10 (还是全局变量,而非局部变量)

另外一种作用域是动态作用域(Dynamic Scoping): 在运行时通过遍历调用栈来确定变量的值,现在已经很少有编程语言使用了,比如是 Perl4, Bash, 或者是 Emacs Lisp:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
#!/bin/bash

x="global"

foo() {
  echo "$x"  # x 的值取决于谁来调用 `foo`, 运行时决定
}

bar() {
  local x="local"  # 动态作用域: 会影响 foo 的值
  foo
}

foo  # 输出: "global" (x 是全局变量)
bar  # 输出: "local"  (x 是 bar 函数的局部变量)

也就是 foox 的值还取决于调用方的栈,因为在 bar 里面调用 foo 时, bash 解释器会把 bar 的栈一并传给 foo, 所以 foo 就以最近栈中 x 的值为准。

这种作用域实现方式虽然简单,但是对于程序员 debug 来说简直是噩梦,所以在现代编程语言基本绝迹了。

话虽如此,但是对于模板引擎而言,动态作用域却是主流选择,主要是因为:

  1. 模板的特性需求:循环/条件语句需要运行时创建临时变量
  2. 隔离性要求:避免不同模板间的变量污染
  3. 异常处理:未定义变量可返回 undefined/null 而非报错

因此我们的模板引擎也会使用动态作用域来保存变量,即 List<HashMap<String, String>> 的数据结构.

3.2 vistor pattern

确定好如何保存变量之后,下一个问题就是如何遍历并且生成模板。

解析HTML之后生成的是 DOM(Document Object Model) 结构, 本质是多叉树遍历,按照指令处理栈的变量,然后再把 HTML 输出, 如下:

1
2
3
4
5
6
7
8
function traverse(node) {
  if (node.type === 'text') console.log(node.data);
  else {
    console.log(`<${node.name}>`);
    node.children.forEach(traverse);
    console.log(`</${node.name}>`);
  }
}

实现是很简单,但是我们把「遍历逻辑」和「不同指令对应的逻辑」耦合在一起了,很难维护。

并且我们现在只支持4个指令,或者未来要增加其他指令,只要在 traverse 里面再增加 if-else 逻辑,基本没有扩展性。

所以我们需要优化的点就是,把「遍历逻辑」和「指令逻辑」分开,这样就易于我们扩展新指令。

要解耦,想想有啥设计模式合适,遍寻23种设计模式,访问者(Vistor)模式 就很合适用来做解耦遍历逻辑和指令逻辑.

不了解 Vistor 模式的同学可以先看下这篇文章, 而Rust 非常著名的序列化框架 Serde 就通过 Vistor 模式可以让用户自定义如何序列化或反序列化某种类型的数据。

3.3 接口设计

既然选定了 Vistor 模式,那么就让我们来设计具体的接口。

Vistor 接口类,接受某个 DOM 元素作为根节点,然后通过 walk 函数遍历给定的节点,或者节点为空则遍历根节点:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
import { Node, NodeWithChildren } from "domhandler"
export abstract class Visitor {
  private root: Node;
  constructor(root: Node) {
    this.root = root;
  }

  walk(node: Node = null) {
    if (node === null) {
      node = this.root
    }

    if (this.open(node)) {
      node.children.forEach(child => {
        this.walk(child)
    });
    }
    this.close(node);
  }

  // handler to be called when first arrive at a node
  abstract open(node: Node): boolean;

  // handler to be called when finished with a node
  abstract close(node: Node): void;
}

其中的 open 函数用于在进入一个节点时被调用,相当于是在前序位置被调用,返回值来表现是否需要遍历其子节点;而 close 函数在离开一个节点前,即相当于后序位置被调用。

关于二叉树的前序位置和后序位置,可见这篇讲解二叉树算法的文章

Vistor 算法里面的关键即是实现「遍历逻辑」与「每个节点处理逻辑」的解耦,遍历逻辑我们已经实现在 Vistor 基类了,现在就需要实现一个具体的子类来表示节点的处理逻辑:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
export enum HandlerType {
  If = 'z-if',
  Loop = 'z-loop',
  Num = 'z-num',
  Var = 'z-var',
}

const HANDLERS: Record<HandlerType, NodeHandler> = {
  [HandlerType.If]: new IfHandler(),
  [HandlerType.Loop]: new LoopHandler(),
  [HandlerType.Num]: new NumHandler(),
  [HandlerType.Var]: new VarHandler(),
}

export class Expander extends Visitor {
  public env: Env;
  private handlers: Record<HandlerType, NodeHandler>
  private result: string[]
  constructor(root: Node, vars: Object) {
    super(root);
    this.env = new Env(vars);
    this.handlers = HANDLERS;
    this.result = [];
  }

  open(node: Node): boolean {
    if (node.type === 'text') {
      const textNode = node as Text;
      this.output(textNode.data);
      return false;
    } else if (this.hasHandler(node as Element)) {
      return this.getHandler(node as Element).open(this, node);
    } else {
      this.showTag(node as Element, false);
      return true;
    }
  }

  close(node: Node): boolean {
    if (node.type === 'text') {
      return;
    }
    if (node.type === 'tag' && this.hasHandler(node as Element)) {
      this.getHandler(node as Element).close(this, node);
    } else {
      this.showTag(node as Element, true);
    }
  }

  // 判断是否有 z-* 属性对应的指令处理器
  hasHandler(node: Element): boolean {
    for (const name in node.attribs) {
      if (name in this.handlers) {
        return true;
      }
    }
    return false;
  }

  getHandler(node: Element) {
    const possible = Object.keys(node.attribs)
      .filter(name => name in this.handlers)
    assert(possible.length === 1, 'Should be exactly one handler');
    return this.handlers[possible[0]];
  }

  // 将 tag 标签及属性输出到 output 去,但排除 `z-` 开头的指令
  showTag(node: Element, closing: boolean) {}

  output(text: string) {
    this.result.push((text === undefined) ? 'UNDEF' : text);
  }

Expander 的逻辑也并不复杂,每次遍历到一个 DOM 元素的时候,通过元素类似执行对应的操作,如果是 z- 开头的指令,就看下能否找到对应指令的处理器:

仔细观察代码会发现,不同的指令对应的处理器实现了 NodeHandler 接口,定义在前序位置和后序位置处理节点的逻辑,并按指令名保存在 HANDLER 中:

1
2
3
4
export interface NodeHandler {
  open(expander: Expander, node: Element): boolean;
  close(expander: Expander, node: Element): void;
}

这就意味着,如果需要增加一个新的指令,该指令处理器只需要实现 NodeHandler 接口,并添加到 HANDLER 即可,不需要改动其他的已有代码,我们就实现了「遍历逻辑」与「指令逻辑」的解耦。

4 实现

4.1 支持的指令集

不同的指令集的差别只是如何实现 openclose 逻辑,我就不一一赘述了,已支持的指令集及实现列表如下:

指令 作用 实现
z-if 条件渲染,值为false时移除元素 z-if.ts
z-include 引入外部HTML文件内容 z-include.ts
z-iteration 数字迭代,生成序列内容 z-iteration.ts
z-literal 保留元素原始属性不解析 z-literal.ts
z-loop 循环遍历数组生成元素内容 z-loop.ts
z-num 直接输出数字值到元素内容 z-num.ts
z-snippet 定义可复用的HTML片段 z-snippet.ts
z-trace 打印变量值到控制台(调试用) z-trace.ts
z-var 将变量值输出到元素内容 z-var.ts

4.2 示例

假设有数据如下:

1
2
3
4
5
6
7
8
const vars = {
    "firstVariable": "firstValue",
    "secondVariable": "secondValue",
    "variableName": "variableValue",
    "showThis": true,
    "doNotShowThis": false,
    "names": ["Johnson", "Vaughan", "Jackson"]
};

4.2.1 z-num

1
2
3
4
5
<html>
  <body>
    <p><span z-num="123"/></p>
  </body>
</html>

模板展开如下:

1
2
3
4
5
<html>
  <body>
    <p><span>123</span></p>
  </body>
</html>

4.2.2 z-var

1
2
3
4
5
<html>
  <body>
    <p><span z-var="variableName"/></p>
  </body>
</html>

模板展开如下:

1
2
3
4
5
<html>
  <body>
    <p><span>variableValue</span></p>
  </body>
</html>

4.2.3 z-if

1
2
3
4
5
6
<html>
  <body>
    <p z-if="showThis">This should be shown.</p>
    <p z-if="doNotShowThis">This should <em>not</em> be shown.</p>
  </body>
</html>

模板展开如下:

1
2
3
4
5
6
<html>
  <body>
    <p>This should be shown.</p>

  </body>
</html>

4.2.4 z-loop

1
2
3
4
5
6
7
8
<html>
  <body>
    <p>Expect three items</p>
    <ul z-loop="item:names">
      <li><span z-var="item"/></li>
    </ul>
  </body>
</html>

模板展开如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
<html>
  <body>
    <p>Expect three items</p>
    <ul>
      <li><span>Johnson</span></li>

      <li><span>Vaughan</span></li>

      <li><span>Jackson</span></li>
    </ul>
  </body>
</html>

4.2.5 z-include

1
2
3
4
5
6
<html>
  <body>
    <p><span z-var="variableName"/></p>
    <div z-include="simple.html"></div>
  </body>
</html>

simple.html :

1
2
3
4
<div>
  <p>First</p>
  <p>Second</p>
</div>

模板展开如下:

1
2
3
4
5
6
7
8
9
<html>
  <body>
    <p><span>variableValue</span></p>
    <div>
  <p>First</p>
  <p>Second</p>
</div>
  </body>
  </html>

更多的示例可见

5 总结

模板引擎的本质,是帮我们把重复的页面结构抽离出来,而内容与表现的分离(Separation of content and presentation),可以让我们以数据来填充变化的内容。

这是程序员对「Don’t Repeat Yourself」原则最直观的践行。

三十年来,开发者们创造了无数种实现方案,但核心思路始终围绕着前文提到的三种基本模式。

如今即便在最流行的 Vue 或 React 框架中,无论你写的是 JSX 或是 v-* 指令,背后的思路仍万变不离其宗,本质上仍在沿用模板引擎的思想。

而这种「结构复用,数据驱动」的理念,也早已成为Web开发的根基。

回到本系列的目录

6 参考