2025-10-02 12:55:00
我有时总会觉得自己是个「奇葩」: 在现在各种算法推荐大行其道的当下,我却偏偏不喜欢算法推荐,因为这种「千人千面」的模式,总让我担心自己会陷入「信息茧房」的焦虑之中,此外我也希望可以看到一些我兴趣之外的内容。
我算是很多年的 Twitter 老用户了,使用 Twitter 已经超过十年了,只是在马斯克收购 Twitter,新增了一个 “For You” 的推荐流之后,越来越没有兴趣看,不是吵架就是借极端观点/话题引流。
于是,我转向了Telegram频道,并为此打造了一个专属的「挖掘好帖」工具。
在最近的一年时间,我订阅了越来越多的 Telegram 频道,频道主可以向所有的订阅者广播内容,因为 Telegram 频道都是由频道主发布的,这给了我一种主动发现、主动阅读,而非被动接受算法推荐的感觉。
有频道主分享 twitter 上的内容时,也可以算是某种意义的「编辑精选」了。
当我订阅越来越多的频道之后,发现有些频道主太能聊了,一天能发几十个帖子,有些只是碎碎念,有些却是思考和精华。
所以我就在想,是否有个好用的挖宝藏(挖坟)工具,可以把频道的热贴,精华帖都列出来呢?
另外一个我高频使用的网站就是 Reddit, 不了解的 Reddit 的朋友可以理解成它是网站的百度贴吧,可以有不同的子版(subreddit),相当于不同的吧, 然后每个子版都有一个 top
的功能,可以按照过去一小时,过去24小时,过去一周,过去一年,历史全时段按帖子的顶贴数进行排序。
类似 rust
这个子版的历史精华帖:https://old.reddit.com/r/rust/top/
那么,我是否也可以对 Telegram 频道也实现类似的功能呢?
我可以把某个频道所有的帖子都爬下来,然后按点赞数,转发数,点击数按不同的时间段进行排序,
支持不同的时间段:包括一周内,一个月内,一年内,和历史所有帖子
相当于根据频道的订阅者的用手投票,挑选出不同时段最精华的帖子
仔细分析之后,我发现检索频道并构建热榜是个相当有趣的技术问题,也与频道帖子本身的产品属性有关。
在我爬取某个频道的所有帖子后,如何与频道的最新动态保持同步更新呢?
毕竟像阅读数,转发数这些数据也是一直会更新的。
但是大部分历史的帖子的阅读数据都是不怎么会变化的,比如半年前的帖子可能就不会有人去翻看,订阅者大多只会关注最新的帖子。
每次都所有帖子都重新爬取一次固然可行,但是效率太低,毕竟绝大部分的帖子的数据都是不怎么变动的,怎么平衡性能与数据的及时性呢?
有点像超简化版本的搜索引擎问题了。
查询的时候要支持不同的时间段,按不同的指标进行排序,如何保证查询的性能呢?
总不能每次查询都重新计算一次吧,这样在帖子非常多的热点频道,就会出现查询的性能瓶颈。
在仔细考量,权衡利弊之后,我取了个折衷的方案:
就这样兼顾性能,成本以及数据及时性,又不会造成过多的重复爬取。
而对于查询性能,我选择了建立物化视图(material view)的策略, 每次爬取成功之后就重新计算,更新一下 material view, 耗时可能比较长,每个频道更新视图大概需要个十几秒。
但此后所有的查询都直接指向这个预计算好的视图,数据库还可以利用缓存优化,以空间换时间,查询性能因此得到保障
使用方法非常简单:
/rank <频道链接>
,例如 /rank https://t.me/pipeapplebun
完成之后就会发消息通知用户
然后用户就可以按照点赞数,转发数,点击数查看帖子
通过这个工具,我甚至发现了一些有趣的现象:
某些频道看似有十几万订阅者,但近期帖子的点击数仅有一千多,还不到百分之一,数据真实性令人存疑。
有了这个频道检索工具,我终于能在一个频道里高效「挖坟」了,再也不用担心错过沉淀在时间线里的精华。
不管是想回顾精华,还是挖掘隐藏好帖,甚至是做简单的数据挖掘,这个机器人都能搞定,举个例子:
这次,我总算为自己、也为可能有同样需求的你们,打造了一个称手的工具。
2025-09-14 05:28:00
半个月前,我发布了一个基于贝叶斯算法的Telegram广告拦截机器人 @BayesSpamSniperBot
(https://t.me/BayesSpamSniperBot)
项目地址:https://github.com/ramsayleung/bayes_spam_sniper
系列文章:
尽管项目代码开源,但我始终以产品思维运营它。上线半个月以来,经历了故障、用户反馈与持续优化,现将这段经历分享出来。
没想到我的产品的第一个线上故障来得这么快,发布的时候直接不可用,把正常消息都给删了,用户在各种途径都向我反馈:
故障的原因是我当时一直在收集垃圾广告的数据,太专注于垃圾广告数据,而忽略了收集的正常数据, 导致垃圾广告数据过多,消息都被认为是垃圾广告,被误删了。
通过补充大量正常消息数据,重新平衡训练集,模型逐渐恢复正常识别能力。
我在《基于贝叶斯算法的Telegram广告拦截机器人(一):从问题到产品》里面提到过:
常见的 Telegram 广告机器人是大多是基于关键字的,通过匹配关键字进行文本拦截,非常容易被发垃圾广告的人绕过。
这不禁让我想起了保罗.格雷厄姆在《黑客与画家》一书在2002年介绍的情况:
当时电子邮件兴起,也有非常多的垃圾邮件,常见的垃圾广告拦截方式是关键字匹配+邮件地址黑名单,但是既低效也容易被绕过。
保罗.格雷厄姆就创造性地使用贝叶斯算法(Bayes Theorem)实现了一个广告拦截器, 效果竟然出奇地好。
但产品上线之后,我发现聊天软件消息和Email虽然都是文字,还是有很大差别的:
Email 大多时候都是长文的,内容较长,并且大多情况,一封邮件上下文本身也很完整,就有较多的内容,较高的准确度来判断是否是广告。
而 Telegram, 微信这类的即时聊天软件,聊天消息大多都不长,可能把内容分成多条消息来发,就没有完整的上下文,比如:
换U
找我
单条消息很较难准确判断是否是广告,所以对即时消息做广告拦截本身就更难, 「短文本+无上下文」是NLP中的经典难题,也是本项目最大的技术挑战。
漏删与误删是广告拦截中不可避免的矛盾权衡。
若想提高拦截率(召回率),就需降低置信度阈值,将更多疑似广告的消息拦截,但这也会增加误删正常消息的风险。
反之,若想避免误删(提高精确率),则必须提高置信度阈值,但这又会导致更多广告被漏掉。
在即时消息短小、上下文缺失的特性下,想同时实现零误删和零漏删几乎是不可能的。
权衡之下,我选择优先保证用户体验: 宁可漏删,不可误删
因为漏掉的广告,群友可以举报或由管理员手动删除;但误删的正常消息却无法恢复,对用户的伤害更大。
因此,我将拦截阈值设置为95%,即仅当模型有极高把握(>95%概率)判定为广告时才会删除。
这虽然会放过一些疑似广告,但最大程度地保障了正常聊天不被误删。
产品上线之后,很快就有用户来试用了,然后其中一个用户就提了一个非常好的优化建议。
这个警告的消息不会自动删除,如果有很多人在群里发广告,那么群里就会有一堆这样的消息,也算是对群消息的污染。
所以用户建议:
可以发这个提醒,但在几分钟后也把这个提醒消息删除掉
我觉得这是个非常好的优化体验,因为就把这个功能给加上了,提醒消息本身会在5分钟后自动删除。
倾听用户的声音是非常重要的,他们可能就会从他们的角度提出非常好的建议。
但是不要盲目听从用户的建议,比如也有用户建议:
我觉得还应该有以下功能.
- 恢复消息, 恢复用户. (让管理员恢复误删的消息和用户)
- 主动投喂正常消息. (让管理员主动投喂一些消息. 比如, 群里面昨天 的消息, 随便选一些正常的, 投喂给机器人)
恢复消息这个功能没有太大必要,并且也不实用,因为恢复消息这个功能本身就很微妙,是直接恢复被删除的消息呢,还是重新发一条新消息?
如:
- 2025-09-09 10:01:00 张三: 我今天吃了鸡翅
- 2025-09-09 10:02:00 李四:鸡翅有啥好的(被误删消息)
- 2025-09-09 10:03:00 王五:人家就喜欢吃,你管得着嘛
如果是直接恢复被删除的消息,当前时间是 2025-09-09 11:00:00
,把消息恢复之后,还有人会手动刷历史消息,查找旧消息么?
Telegram客户端不一定支持会跳转被恢复的旧消息,这意味着,你恢复误删的消息,也没人看得到。
假如是重新发一条新消息 鸡翅有啥好的
, 因为缺失了上下文,群里的人反而会疑惑,你在说什么。
解决误删问题本质是提高拦截的准确率,而非考虑如何恢复被误删消息,准确率提高了,误删就会减少, 自然就不需要考虑如何恢复消息,用户体验还会更好.
而主动投喂消息这个想法有点理所当然了。
没有任何群管理员有意愿帮忙训练这个机器人,对用户而言,他们只想要一个好用的广告拦截机器人,至于怎么开发,训练出来的,用户并不在乎。
所以用户不会有意愿和动力来优化这个机器人,不好用就再换一个好了,更何况,逐条消息收集的效率实在太慢太慢了, 所以我后面想出了一个比手工收集数据提效至少100倍的主意。
发现人难免会有误区,总会以为别人会和自己一样,之前看到发垃圾广告的人的时候,总会觉得他们是正常的用户手工发。
但是最近几天发现了一些规律,有用户把同一条消息反复发,不同的群还是发同样的内容 即使是复制粘贴也难免会多个或者少个空格,然后消息被删了还一直发同样的内容。
此外,还有一些群,内容的聊天内容都是广告,我还很奇怪,大家都在发广告,正常用户不都跑了嘛?
此时,我才意识到,发消息的都是机器人。
所以我加了个优化,计算消息内容的 hash 值,保存到数据库,并为这个字段建立索引。
后面检测消息的时候,先根据 hash 值查询,检查是否存在已有的消息,如果消息已经存在且已经被标记成广告或者正常消息,那么就无需再使用模型检测,可以直接返回之前的检测结果。
这样既提高了准确度,也优化了性能,也减少了人工干预的成本。
同一个用户如果在同一个群发了三条广告,那么就会自动被封禁掉,也就是相同的广告只要发三条,就会马上被自动封禁掉。
为什么是计算 hash 值并为该Hash值建立索引而非对完整的文本消息建立索引?
因为文本消息是变长的,并且聊天消息可能会很长,对这样的 TEXT
建立索引会产生非常大的索引结构,占用大量的磁盘空间,每次进入查找,插入和排序操作,速度都会较慢。
而 hash 值是定长且非常短(相对原始消息而言),建立索引速度非常快,此外 hash 函数保证只有相同的输入一定会产生相同的输出,而即使一个字符不一致,其计算出来的 hash 值就会不一致,就能判断内容文本不一致。
使用机器学习算法来实现一个类似的垃圾广告过滤器并不难,困难的持续收集高质量的训练数据,训练数据是非常宝贵的,毕竟数据才是核心资产。
而对于我这个产品来说,最难的是冷启动时的训练数据问题:
因为没有训练数据,模型就不准确,模型不好用就不会有人使用,自然也无法通过用户来收集垃圾广告数据,就无法良性循环, 存在一个鸡生蛋,还是蛋生鸡的问题。
所以冷启动时,我是手动加了非常多的 Telegram大群,然后人工在里面收集垃圾广告.
但是这个效率实在是太低了,我收集了快一周才只有几百条数据, 一个是我无法一直盯着各个群,另外是这种20w的大群,一般都会有几个管理员,会手工删除广告,一会没有看垃圾广告数据就会被删掉了。
这样手工收集数据实在在太痛苦了,我就在想有没有什么办法自动收集数据呢?
我本来想的是直接把我的机器人拉到这些大群里面,即使没有管理员权限无法删除消息,也可以收集数据嘛,后面才意识到 Telegram 有个规定,只有群管理员才有权限加机器人,因为我不是管理员,所以自动没有权限添加机器人。
但是 telegram 的客户端是开源的,他们提供了 tdlib 1这个跨平台的 C++ 库便于社区构建第三方的 Telegram 客户端,那么我自然可以使用这个库来登录我自己的账号,然后使用我的模型来过滤消息,然后把疑似广告的数据都收集起来,我再人工确认下。
(顺便说一下,tdlib 和 telegram-bot-api 2这两个库竟然都是同一个作者 Aliaksei Levin 3在维护,实在是太强了。)
我现在需要做的就是添加各种大群,然后程序就会自动监听并收集数据,我再人工批量确认下。
实现起来也不复杂, 200行代码就实现了这个监听消息,分析,并且收集的功能。
得益于这个自动化的数据收集程序,我1周不到就收集了近上万条的高质量训练数据了,效率实在高太多太多了。
懒惰真的是程序员的美德, 这个经历再次证明:自动化工具往往能成倍提升效率,这正是工程师价值的体现.
所谓酒香也怕巷子深,没有用户使用,代码写得再好也没有意义。从产品角度,运营推广至关重要。
作为个人开发者,我没有大量粉丝关注,也没有营销预算,因此采用了传统的推广方式:撰写博客并在相关社区分享。
我撰写了两篇双语博客文章,中文版本分享至:
英文版本发布至:
虽然推广效果有限,但这些努力为项目带来了最初的用户关注。
上线半个月,截止到目前为止, 已经有超过80个群使用过这个机器人,用户数已经比我预期要多了:
指标 | 数值 |
---|---|
GitHub Stars | 106 |
使用群组数 | 83 |
训练数据量 | 10543 |
最开心的是看到我自己的程序在这些群成功拦截垃圾广告,就很有成就感,证明我做的东西真的能用户解决问题。
这半个月的运营让我深刻体会到:产品不是代码写完就结束,而是从用户反馈中不断迭代的开始。
产品是需要持续运营的,而写代码只是产品生命周期的其中一个环节,甚至不是最耗费时间的环节。
下一步,我计划进一步优化模型准确率,并探索多语言支持,也欢迎关注我的频道或提交Issue一起讨论。
2025-08-29 14:45:00
系列文章:
我花了一周末时间,写了一个自学习的 Telegram 广告拦截机器人 @BayesSpamSniperBot
(https://t.me/BayesSpamSniperBot),项目开源在:https://github.com/ramsayleung/bayes_spam_sniper
Telegram 是一个流行的即时通讯软件,类似微信,Whatsapp,已有超过10亿用户,支持许多强大的功能,如聊天记录云存储,支持Linux, Mac, Windows, Android, IOS, Web 多个平台,客户端都是开源,类似微信公众号的频道功能(Channel),还有我见过的最强大的机器人系统。
平时我跑步和做饭都习惯会听播客,而《软件那些事儿》1是我最喜欢的播客之一,主持人是栋哥 2, 我也因为喜欢栋哥的节目,趁机加了栋哥的电报频道。
栋哥的电报频道汗牛充栋 3主要是用来发布播客信息, 之前打开过一段时间的留言功能,没有想到引来了一堆的币圈的用户来发广告,因此将评论功能就关了:
另外一个我关注的频道 Ray Tracing 4也在吐槽币圈的广告,不堪其忧:
常见的 Telegram 广告机器人是大多是基于关键字的,通过匹配关键字进行文本拦截,非常容易被发垃圾广告的人绕过。
被绕过的话主要是靠管理员人工删除。
这不禁让我想起了保罗.格雷厄姆在《黑客与画家》一书在2002年介绍的情况:
当时电子邮件兴起,也有非常多的垃圾邮件,常见的垃圾广告拦截方式是关键字匹配+邮件地址黑名单,但是既低效也容易被绕过。
保罗.格雷厄姆就创造性地使用贝叶斯算法(Bayesian Theorem)实现了一个广告拦截器 5, 效果竟然出奇地好。
对于 Telegram 的垃圾广告而言,这不是类似的问题嘛?
那我岂不是可以用类似的解决方案来解决 Telegram 广告的问题嘛
提起概率算法,最经典的例子莫过于「抛硬币」这一古典概率——每次抛掷都是独立事件,前一次的结果不会影响下一次的概率。
然而,现实中的很多场景并不能像抛硬币那样无限重复,事件之间也往往并非相互独立。
这时候,贝叶斯定理就显示出其独特的价值。
它是一种「由果溯因」的概率方法,用于在已知某些证据的条件下,更新我们对某一假设的置信程度。
换句话说,贝叶斯算法能够根据不断出现的新证据,动态调整对某个事件发生概率的估计。
简单来说,就像人脑的学习过程:我们原本有一个初步认知,在获得新信息之后,会据此修正原有的看法,进而调整下一步的行动。
保罗·格雷厄姆就是通过贝叶斯定理,不断地根据已被标记为垃圾广告或者非垃圾广告的邮件,对新出现的邮件进行分类,判断其是否为垃圾广告。
如果想更直观地理解贝叶斯定理,推荐两个讲解清晰、生动易懂的视频:
Telegram Bot 支持两种与 Telegram 服务器交互的模式,分别是:
Webhook: Telegram 服务器会在 Bot 收到新消息时主动回调此前 Bot 注册的地址,Bot Server 只需要处理回调的消息
Long Polling: Bot Server 一直轮询 Telegram 服务器,看是否有新消息,有就处理,本机器人使用的是此模式
Bot Server 收到消息之后,会派发到单独的 telegram_bot_worker
处理,然后根据预训练的模型判断是否是垃圾广告,如果是,调用 Bot API 删除消息。
Bot Server 收到消息之后,会派发到单独的 telegram_bot_worker
处理, telegram_bot_worker
会调用 bot API 删除消息并封禁用户,并插入一条训练数据,标记为垃圾广告(spam)
保存训练数据会触发 hook, 创建一个训练消息,投递到消息队列 training
, 会有另外的 worker classifier_trainer
订阅 training
消息,并使用新消息重新训练和更新模型
使用队列和后台进程 classifier_trainer
来训练任务而非直接使用 telegram_bot_worker
主要是为了返回 Bot请求与训练模型解耦,否则随着模型规模的增大,训练时间会越来越长,响应时间会越来越长。
解耦后就易于水平扩展了,在设计上为后续性能优化和扩展预留空间。
看了我项目源代码的朋友,难免会浮起疑问,为什么使用 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 这样的存储来支持队列实现。
架构设计中的队列和后台进程,只需要几行代码就实现了,甚至不需要额外的配置,如果队列不存在,框架会自动创建:
|
|
得益于 Rails 强大的 ORM 框架,内置各种生命周期的 hook, 对新插入训练数据后触发后台进程重新训练模型的代码也只有寥寥几行:
|
|
在 Rails 各种内置强大工具的加持下,我只用了一天时间就把整个机器人的功能给实现出来了。
看到这里,有朋友可能会担心性能,觉得 Ruby 性能不行,并且还是动态语言,不好维护。
先跑起来再说,先做个原型跑起来,有用户愿意用你的产品再说, 当运行速度成为瓶颈时,你的业务肯定非常大了,肯定有足够的资源招一打程序员把项目优化成 Rust/C++, 甚至是汇编。
没有用户,谈性能只是个伪命题。
至于动态语言一时爽,代码维护火葬场,我也是相当认同的。
因此我在为团队选型时我绝对不会考虑动态语言,只会上编译型的语言, 甚至是Rust这种强类型,但是现在只有我一个人来做原型,我自己是什么顺手就用什么的。
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 生成的代码很多都是没有设计的,比如把 Classifier
和 TrainedMessage
的类耦合在一起,在 Classifier
里面持久化 TrainedMessage
又直接在 telegram_bot_worker
进程里面,接收到训练信息马上同步训练新模型,训练完再返回调用命令的结果,完全没考虑解耦接收训练语料和模型训练。
只能说 Vibe Coding 非常适合 Rust 这样的强类型编译型语言,生成的出来的代码起码要编译通过,保证质量的下限。
而对于那些说「一行代码都不用写/改,就能做出一个APP」的言论,此时我脑海不禁升起疑问?
究竟是代码好到一行都不用改?还是开发者看不出症结所在,所以一行都不改?
开发完原型,在机器人整体功能可用之后,脑中又有不少的想法冒出来,当时就马不停蹄地给机器人加上, 因此机器人就支持快十个命令,还支持私聊和群聊的不同模式。
加着加着,连我自己都疑惑起来:这么多的功能,有点像国内的各种大而全的App了,我不禁对此产生疑问:
真的会有用户用这么多功能么?真的有用户会用这些功能嘛?太多功能不是也会有额外的心智负担嘛?
我最喜欢的广告拦截器 Ublock Origin 10拦截效果非常好,但是使用起来却非常简单,易上手。
想起《软件设计的哲学》11里面提到的设计理念,接口应该是简单易用的,但是功能可以是复杂丰富的。
因此我只能忍痛把此前新增的,但与核心功能无关的命令都删掉;
此外考虑到可能绝大多数的用户都没有技术背景,也可能不知道命令怎么用,因此将命令尽可能地优化成按钮,用户可以直接点击,改善易用性:
我还希望可以支持多语言,比如根据用户的系统语言,自动切换到中文或者英文,这个就需要不同语言的文案。
telegram_botter.rb 这个核心服务类里面有超过60%的代码都是为了此类易用性改进而引入的。
简单留给用户,复杂留给开发
只需两步,机器人就可以自动工作。
完成这两步后,机器人不仅会自动开始工作,自动识别群内广告,然后删除文本消息,如果发送垃圾广告超过3次,将会被封禁;
还会随着社区的使用(通过 /markspam
和 /feedspam
),变得越来越智能
此机器人的设计理念就是最小化打扰管理员与用户,提供简单的操作命令,并最大可能地自动化, 所以本机器人只提供以下三个命令(支持"/“开头自动补全):
/markspam
删除垃圾消息并封禁用户, 需要管理员权限。
在某条你想封禁的信息下回复 /markspam
, 机器人就会自动把该条消息删除被封禁用户.
(消息也被删除)
与常见的群管理机器人不同,这条命令不仅会删除垃圾消息并封禁用户, 因为这条消息还被管理员标记成垃圾广告,有非常高的置信度,所以系统就会以这条垃圾广告为训练数据,对模型进行实时更新。
下次类似的发言不仅会被识别,所有使用本机器人的群组都会受益,也会把类似的文本标记成垃圾广告
/listbanuser
查看封禁账户列表, 需要管理员权限。
查看已封禁的用户列表,并主动解封。
/listspam
查看广告消息列表, 需要管理员权限。
查看被标记为广告的消息列表,并可标记为正常。
/feedspam
投喂垃圾信息来训练,无任何权限要求,可私聊投喂或在群组内投喂.
在软件开发领域,有这么一句俗话,Eating your own dog food(吃你自己的狗粮),大意是你自己的开发的东西,要自己先用起来。
因此我建了一个自己的频道:菠萝油与天光墟 12用于测试,可惜订阅者寥寥, 就吸引不来太多的发垃圾广告的用户,所以欢迎大家订阅或者进来发广告,以吸引更多的发垃圾广告的用户。
在我这个频道,每个人都有自由发言的权利(美中不足只是次数受限)
既然没有人来我的频道发广告,苦于没有训练数据,我只能主动出击,赤膊上阵,割肉喂鹰去加了各种币圈群,黄色群,主动去看各种广告了:
自从开发了这个机器人之后,我对广告的看法就变了,以前在别的群看到广告就烦,现在在别的群看到广告就很开心, 这都是宝贵的训练数据,要趁着还没被删,赶紧记录下来。
别人故事里的算法效果总是出奇的好,到自己实际运行的时候,总是发现会有这样那样的 case 没有覆盖,总有各种意外惊喜
许多在 Telegram 发广告的用户都是久经考验的反拦截器斗士了。
虽然关键词封禁效率不高,但是那些能让我们见到的广告说明已经是绕过关键词拦截的。
比如:
在 币圈 想 赚 钱,那 你 不关 注 这 个 王 牌 社 区,真的太可惜了,真 心 推 荐,每 天 都 有 免 费 策 略
又或者
这人简-介挂的 合-约-报单群组挺牛的ETH500点,大饼5200点! + @BTCETHl6666
前者通过空格分隔来绕过关键词,后者通过添加标点符号来绕过关键词。
与英文等基于拉丁字母的语言天然通过空格分词不同,中文使用贝叶斯算法进行统计时,需要先进行分词
the fox jumped over the lazy dog
我们的中文就不一样了
「我们的中文就不一样了」就会被分词成「我们 | 的 | 中文 | 就 | 不 | 一样 | 了」, 然后才能对词频进行统计。
但是像广告 在 币圈 想 赚 钱,那 你 不关 注 这 个 王 牌 社 区,真的太可惜了,真 心 推 荐,每 天 都 有 免 费 策 略
, 空格除了会影响关键字匹配,也会影响分词,这句话的分词结果就会变成:
在 | | 币圈 | | 想 | | 赚 | | 钱 | , | 那 | | 你 | | 不 | 关 | | 注 | | 这 | | 个 | | 王 | | 牌 | | 社 | | 区 | , | 真的 | 太 | 可惜 | 了 | , | 真 | | 心 | | 推 | | 荐 | , | 每 | | 天 | | 都 | | 有 | | 免 | | 费 | | 策 | | 略
这人简-介挂的 合-约-报单群组挺牛的ETH500点,大饼5200点! + @BTCETHl6666
也会被分词成:
这人简 | - | 介挂 | 的 | | 合 | - | 约 | - | 报单 | 群组 | 挺 | 牛 | 的 | ETH500 | 点 | , | 大饼 | 5200 | 点 | ! | | + | | @ | BTCETHl6666
未经处理的训练数据就会影响模型的结果,可见训练数据的质量也非常重要,因此我就对训练语料做了相应的预处理:
|
|
预处理之后, 在 币圈 想 赚 钱,那 你 不关 注 这 个 王 牌 社 区,真的太可惜了,真 心 推 荐,每 天 都 有 免 费 策 略
就会变成 在币圈想赚钱那你不关注这个王牌社区真的太可惜了真心推荐每天都有免费策略
(这里把合法的逗号也去掉了,我觉得相较过多标点符号对分词的影响,把标点去掉分词结果反而是能接受的), 分词结果是:
在 | 币圈 | 想 | 赚钱 | 那 | 你 | 不 | 关注 | 这个 | 王牌 | 社区 | 真的 | 太 | 可惜 | 了 | 真心 | 推荐 | 每天 | 都 | 有 | 免费 | 策略
这人简-介挂的 合-约-报单群组挺牛的ETH500点,大饼5200点! + @BTCETHl6666
就会变成 这人简介挂的合约报单群组挺牛的 ETH500 点大饼 5200 点! + @BTCETHl6666
,分词结果是:
这 | 人 | 简介 | 挂 | 的 | 合约 | 报单 | 群组 | 挺 | 牛 | 的 | | ETH500 | | 点 | 大饼 | | 5200 | | 点 | ! | | + | | @ | BTCETHl6666
广告看多了,不得不感慨发广告的人的创造力。
因为在消息发垃圾广告会被广告拦截器拦截,他们创新性地玩出了新花样:
消息发的都是正常的文本,但是头像和用户名都是广告,这样广告拦截器就无法工作了,真的是太有创意了。
对手这么有创意,我也因地制宜地建立对用户名的训练模型,检测的时候消息文本的模型和用户名的模型都过一次, 只要有任何一个认为是垃圾广告,那就禁掉。
更进一步的可以对头像做OCR提取文本,再增加一个对头像的训练模型,不过OCR成本挺高的,就先不搞了。
没有用户的话,做啥优化也没有必要,毕竟过早的优化是万恶之源, 因此我就把想法先做成原型,搞出来再说,但这不意味着这个原型没有优化的空间。
脑海中还是有不少优化的点的:
但是这些优化点都算是 Good to have, 不是 Must have, 后面遇到实际问题再进行优化好了。
使用变换之后的垃圾广告词进行发送:
成功被检测出来,自动删除了:
有朋友可能会说,这只是卖家秀,为什么别人在我群里发的广告还是没有被识别?
因为贝叶斯算法本质是个概率算法,如果它没有见过类似的广告,那么它就没法判断是否垃圾广告 :(
稍安勿躁,你需要做只是使用 /markspam
删除消息并封禁用户,就可以帮助训练这个bot, 所有使用这个 bot 的用户都会因此受益
我相当享受这种从发现问题、灵光一现,到构建原型,再到最终打磨出一个完整项目的创造过程。
虽然这完全是「用爱发电」——代码开源,还得自掏腰包租服务器,物质上毫无回报。
但每当看到机器人成功拦截广告的那一刻,那种创造的喜悦,就足以令我回味无穷。
https://podcasts.apple.com/us/podcast/%E8%BD%AF%E4%BB%B6%E9%82%A3%E4%BA%9B%E4%BA%8B%E5%84%BF/id1147186605 ↩︎
https://gist.github.com/ramsayleung/5848af0177a70a01d41f624e361b1b5d ↩︎
https://ramsayleung.github.io/zh/post/2024/%E7%BC%96%E7%A8%8B%E5%8D%81%E5%B9%B4%E7%9A%84%E6%84%9F%E6%82%9F/ ↩︎
https://ramsayleung.github.io/zh/post/2025/a_philosophy_of_software_design/ ↩︎
2025-08-05 01:00:00
正如我在之前博客文章《这些年走过的路:从广州到温哥华》1提到的那样,我在大二暑假的时候因缘际会,获得了去一家在深圳的初创公司实习的 Offer。
实习的两个多月时间也快就过去了,我也顺利拿到了 Return Offer,公司也非常有人情味地给实习生办了个欢送典礼。
当时实习的导师,也是这家公司的副总裁,加州州立大学的刘颖教授2,在欢送典礼上给我们几个实习生每人都赠送了一本书作为临别礼物。(可惜换了几次手机,已经找不回当初手捧着书的合照了)
他说这是一本可以帮助我们了解程序本质,以及学习抽象的好书,这本书就叫《计算机程序的构造和解释》3(Structure and Interpretation of Computer Programs, 简称 SICP,下文使用 SICP 代称)
收到这本书时,我并未料到它会成为一场长达八年的拉锯战。
我在2016年收到这本书,从2017年开始阅读,中间中断了好几次又重新拾起,而时至今日也只读了一半,即五个章节中的前三章。
翻看自己一直以来阅读这本书的日记,总有种看胡适先生一直在打牌的留学日记一样的感受:
|
|
看到这里,可能没有读过 SICP 可能会奇怪,为什么读一本书要这么久,如果蜻蜓点水,水过鸭背那样子读完一本书,自然只需要不停地翻页即可。
而 SICP 为了帮助你掌控书中讲解的知识和要点,会有大量的习题,并且把非常多额外的知识点都嵌入到习题中,以练带学。
就数量而言,章节一有46道习题,章节二有97道习题,章节三有82道习题。 如果跳过这些习题,这本书的内容不仅少了一半,而且也失去其精髓,可谓是买椟还珠。
此外,习题不仅数量多,还有相当难度,我每天花一到两个小时阅读,只能完成1-2道习题。
习题完成情况:
我把所有的题解都放到了 GitHub 项目: https://github.com/ramsayleung/sicp_solution, 并为大部分的题解都配套了单元测试,以验证其正确性,还加上了 GitHub Action 作 CI.
经年累月,我的题解代码和笔记都接近一万行了,这也能侧面说明我为什么读得这么慢了:
|
|
毕竟大部分真正让人进步的阅读,读起来都不是愉悦的:
世之奇伟、瑰怪、非常之观,常在于险远,而人之所罕至焉,故非有志者不能至也。
—-王安石《游褒禅山记》
如果笼统地概括整本书,“无非”是「抽象」,通过使用一门非常简单的语言 Scheme
, 以及几个非常简单的操作 cons(构造一个序对)
, car(取出序对的第一个值)
, cdr(取出序对的第二个值)
:
|
|
构造出各种数据结构,如链表,队列,哈希表以及更复杂的组合数据结构; 探寻各种概念,如递归,闭包,高阶函数,赋值,流,程序优化等;
而后两章更进一步,第四章介绍如何实现一个 Scheme 简单的解释器,一个简单的 Prolog 解释器;而第五章介绍计算机体系结构的 CPU 设计,编译器,垃圾回收等。
从括号中的几个简单函数,到最后造出整个计算机体系,有种《道德经》里道生万物的感觉:
道生一,一生二,二生三,三生万物。
我本科专业读的是软件工程(Software Engineering ),翻看当初的专业培养计划,从计算机导论开始入门,到程序设计基础,面向对象程序设计基础,数据结构,操作系统,计算机组成原理,再到计算机网络,汇编与编译原理,数据库原理到软件工程等。
学习完这些课程,可以成为一个合格的软件工程师,但广义的计算机专业还有一门专业,叫计算机科学(Computer Science),我一直很疑惑两者之间的差别是什么,就我所见过的不同学校的培养计划里面,两者的课程都非常相似。
而在阅读这本1984年麻省理工就出版的计算机科学的教材时,我找到了我想要的答案。
最初的计算机科学是数学,电子工程和软件设计的交叉学科,计算机科学的学生需要兼备这三者的专业知识。
而三位作者也是高屋建瓴,在数学,电子工程和软件领域旁征博引,各种知识信手拈来, 如练习3.59关于微积分的内容,通过流来处理幂等数积分,练习3.60-3.62都是关于积分的内容。
如3.5章里面,通过流来描述信号处理系统中的「信号」, 练习3.73用程序的流(stream)来表示电流或者电压在时间序列上的值,用以模拟电子线路。
如 3.3章里面,通过程序来建立数字电路的反门,与门,或门,再通过这样的电子元件建立起半加器, 再通过多个半加器实现全加器,实现二进制的加法,从程序到模拟电路,再用模拟电路来构造计算机的处理器。
不同学科的知识在一本书中融会贯通,再配合这个 eval-apply
表达式的配图,总有一种太极的感觉,难免让我有种读计算机哲学书的感觉:
书中那些非常有趣或者优美的代码
第二章介绍了复合的数据结构时,就提到了如何去画图,先画点,再画线,然后要求完成习题连线成面,构造出图形。
新的习题再要求通过变换,组合图形,构造新的复杂图形:
最后把类似的变换应用到图片上:
所谓的蒙特卡罗模拟(Monte Carlo Simulation)是一种通过随机采样和统计计算来求解的数值方法,通过大量随机实验模拟不确定性,从而估算复杂系统的可能结果。
用人话来说就是不断地试,在试错的过程中逼近确定解,试的次数越多,结果越准确。
书中就介绍了一种通过蒙特卡罗模拟来计算 π 的方法, 就像通过随机撒豆子估算圆的面积,概率统计将抽象的π转化为可计算的实验:
举例来说,6/π^2 是随机选取的两个整数之间没有公因子(也就是说,它们的最大公因子是1)的概率。我们可以利用这一事实做出π的近似值。
完全读不懂这段话,没理解是怎么可以算出π的近似值的。
查阅资料后得知:
随机选取两个正整数,它们互质(即最大公约数GCD为1)的概率是 \(\frac{6}{\pi^2}\) , 所谓的 互质指两个数没有公共因子(如8和15互质,但8和12不互质,因为公约数为4)。
这一结论源自数论中的经典定理(涉及黎曼ζ函数),我们只需利用其概率公式反推π即可。
而用蒙特卡罗模拟步骤来计算 \({\pi}\):
随机实验:重复多次随机选取两个整数,检查它们的GCD是否为1。
例如:
统计概率:
若总实验次数为 N,其中 k 次GCD=1,则互质概率的估计值为 \(\frac{k}{N}\)
关联π:
根据数论结论 \(\frac{k}{N} \approx \frac{6}{\pi^2}\),解得 \(\pi \approx \sqrt{\frac{6N}{k}}\)。
当直接计算π困难时,可通过概率实验间接逼近。
这里利用了数论中的概率规律,将π与随机事件联系起来。(高等数学对于我来说已是雪泥鸿爪,更遑论数论的知识了)
|
|
这里的蒙特卡罗实现真的是优雅,它将数学理论(互质概率)转化为寥寥数行的递归实验。
而基于流的实现更是优美:
|
|
直到今天,我也只读完前三章。有时我会问自己:这本书究竟给了我什么?
它没有教我实用的编程技巧,也无关面试刷题。
但是我好像又抓住了一些东西,尤如用手拢过一团烟雾,张开手,并未见留下什么,但是手上还残留着它的气味。
如今,MIT 的课程已用 Python 替代 Scheme,但 SICP 的价值从未褪色。
SICP 不是一本教你「如何编程」的书,而是一把钥匙,是一座桥,连接着工程的实用与科学的纯粹。
那些括号中的表达式,最终在我脑中化成了某种朦胧却持久的气味:
一种对抽象本质的直觉
2025-06-01 02:00:00
前天刚写完《软件设计的哲学》,满脑子还萦绕着模块耦合和接口抽象, 结果昨天就撞上一个现实中的“设计陷阱”——一次耗时数小时却无解的「退货」噩梦。
今天趁着周末,决定把这场荒诞遭遇拆解出来,既当吐槽,也当案例分析.
前段时间搬了家,自然就需要重新办理宽带,一直用的是 Telus 家的家庭宽带服务,他们家的宽带服务也支持从一个住址迁移到另外一个住址, 就预约了 Telus 技术人员上门安装。
技术人员上门安装完宽带之后,就需要测试一下 WI-FI 能否正常使用,就问我们的路由器在哪,他接上处理一下。
问题就来了:
我们的路由器之前是舍友设置的,还不是常见的一体路由器,而是分体式路由器,有三个不同的组件。
而舍友在搬完家后就回国休假了,我还真不知道怎么搞这路由器,各个接口尝试了小半个小时也没反应,师傅也没见识过,自然也不晓得弄。
这个又是一个非常经典的软件开发问题:
「在我的机器上能跑,换个环境就挂了」
但是一直没网也不是办法,然后师傅建议我可以把他随身带的 Telus 路由器买下来,等我舍友回来后把网络设置好,再把路由器还回来,Telus支持30天无理由退货。
听起来也只能这么搞了。
舍友休了几周假回来之后,几分钟不到,很快就把这个路由器就设置起来了:
剩下的就是把路由器还给 Telus, 已经过了几周,30天的免责退货时间所剩不多了。
因为设备不是通过网购买的,没法直接在网上退单,也不是门店买的,无法直接拿去门店退,退货的流程是打电话给 Telus 的客服,问他们要退货指引。
我就给 Telus 的客服打电话,解释清楚情况后,客服说给我账户对应的邮箱发个邮件,里面有指引和退货码,我需要去 Canada Post(加拿大邮政)把路由器寄回去。
电话里客服说已经给我发邮件了,但是我说没有收到(此处为后面埋下伏笔),于是我提供另外一个邮箱,成功收到了。
因为 Canada Post 最近在为涨薪闹罢工,客服提到我需要去另外一家快递公司 Purolator 寄快递。
剩下要做就是把路由器打包,然后寄出来(这么容易就好了), 再把快递单号告知 Telus, 退货流程就算结束了。
因为加拿大邮政罢工,所以只能去 Purolator 寄,但是去到 Purolator后,人家反馈:
你这个退货码是给加拿大邮政的,我们不认哦,你要给个我们家的退货码。
我只能去再打电话给 Telus 客服要退货码,花费了15分钟,终于打通了,解释完一番之后,他们说给我的邮箱发了新的 Puralator 退货码,我等了一分钟,说没有收到,然后让给我另外的一个邮箱也发一次指引,还是没有收到,然后客服说邮件会在24-48小时内到达..
但挂电话后再等了一个小时还是没有收到.
只能再打电话给 Telus 的客服,又等了10几分钟终于接通了,这次换了个客服,这位客服说我们不支持 Purolator,你可以等加拿大邮政罢工结束之后再寄。
我也很无语,怎么你们的回复还不一致的,就和客服说,我怎么知道罢工什么时候结束呢,30天马上就要到了嘛。
客服说,的确很有道理,这样吧,你可以去尝试使用用加拿大邮政寄下,然后我把情况记录一下,到时超过30天也可以免责退款。
然后我追问到,那罢工结束时退货也是用相同的退货码么?这个退货码有过期时间么?邮件没写哦。
客服说,那以防万一,我再给你邮箱发个新的退货码吧。
我着实是怕了,不知道为什么一直没有收到邮件,就让客服把我账号对应的邮箱地址读出来, 客服就把我邮箱的逐个地址读出来。
前面部分听着没问题嘛,我还在寻思是什么问题,只是听着听着,怎么我邮箱还有我不认识的部分,就打开 Telus 的APP 修改, 然后被气得差点要吐血了:
我的邮箱地址是 [email protected]
, 然后为了标记不同的公司,我用了《两个鲜为人知的Gmail地址技巧》 提到的加号技巧来注册 Telus 账号:
之前用了一年多还是好好的,不然我也无法注册和验证邮箱成功。
但是现在 Telus 作了变更,直接把邮箱地址中的加号去掉了,变成了 [email protected]
, 变成一个完全不同的邮箱, 肯定是不可能收到邮件的。
花费了近一下午,打了5-6次电话,和不同的客服沟通和练习口语,最后的结果就是隔天再去加拿大邮政试试,不行就等他们罢工结束再寄。
这次经历虽然令人沮丧,但也印证了软件工程的一条铁律:
糟糕的设计最终会让所有人付出代价——无论是用户还是开发者。
讽刺的是,人们总希望通过「学习别人的错误」来避免踩坑,但现实中,我们往往被迫为别人的设计缺陷买单。
电话退货这个操作虽然看似落后,但是总体来说还是可以用的,在不出问题的前提下。
Telus 的退货流程设计暴露了一个典型的系统脆弱性:
强依赖单一服务提供商(Canada Post) ,且未设计降级方案(如备用物流或线下门店退货)。
这种「Happy Path Only」的思维,本质上是对分布式系统设计原则的违背:
任何外部服务都可能失败,而系统必须对此容错。
让快递直接成为业务系统的「单点」故障,只考虑 Happy Path, 没有考虑异常场景,甚至发过来的退货邮件指引,都可以看出他们是把 Canada Post 写死在邮件。
退货强依赖加拿大邮政这个还可以说成是产品设计的问题,但是直接把我邮箱地址给改掉这个,就一定是程序员的锅了。
此外,我的邮箱地址在 APP 中显示的是 [email protected]
, 只有在修改邮箱地址的时候,才会显示出 [email protected]
这也是我一直没有发现的原因。
但最令人匪夷所思的是邮箱地址的非兼容性变更:系统直接静默移除了存量用户邮箱中的加号:
[email protected]
-> [email protected]
,导致邮件发送失败。
这种粗暴的修改方式违反了最基本的向后兼容性原则,而问题的暴露方式(APP显示与修改界面不一致)进一步说明:
其系统内部还存在的数据状态不一致性问题
合理的变更方式应该是:
虽然这做法非常繁琐,但是却可以保证系统升级绝对不影响用户。
系统设计与维护就是如此:开始做的时候成本很低,越到后期成本越高。
除去别人的设计错误之外,我还有些额外的个人感悟:
虽然 Gmail 支持邮箱地址中增加一个 +
这样的功能,但是并不是所有的公司都支持这特性的,重要的邮件还是不能使用这个「奇技淫巧」。
此外,我另外提供的邮箱也无法收到邮件,可能是我的邮箱太长了,导致客服没有拼对我的邮箱,所以最好还是准备一个短的,包含数字的备用邮箱地址,方便电话沟通时提供给对方。
整个故事再次印证了《软件设计的哲学》中的道理:
所有偷懒的设计,终将以更高的成本偿还
当然, 谁来还就是后话了
2025-05-30 15:39:00
知道这本书是因为在 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.
所谓的抽象,用我自己的话来说的就是把复杂的东西简单地呈现出来。
为了直观地感受一个模块设计是否足够抽象,作者提出一个模块深度的概念:
矩形的表层长度即是接口的复杂程度,而矩形的面积代表模块实现的功能,好的模块应该是深的(deep), 这意味着它有简单的接口,但是内部有复杂且丰富的实现.
例如 Unix 的文件读写接口:
|
|
接口非常简单,但是其内部的实现可能需要成千上万行的代码, 需要支持文件目录的读写,文件权限,读写缓冲区,磁盘读写等等功能,这就是「深的」模块。
与其相反的就是浅的模块(shallow), 接口很复杂,但是功能却很简单。
实现抽象的关键手段就是辨别出信息的重要程度,对于不重要的信息,就要对用户隐藏起来,关键的信息,就要暴露给用户, 实现「去粗存精,开箱即用」。
一个典型的例子就是参数配置,把参数暴露给用户,除非用户非常熟悉这个系统,不然他也不知道怎么算, 不需要用户关注的参数就提供默认值,能程序动态计算就由程序自己来算.
我很反感的一种设计就是引入一个配置系统,系统的运行参数都要由工程师配置,美其名是提供灵活度。
但这不仅引入额外的系统依赖(须知复杂度的根源就来自依赖与不明确),还大大增加了的运维成本, 更何况这样的配置还无法自适应,换种机型又要重新配置,导致配置越来越复杂。
除非是业务的黑名单或者白名单,系统的运行参数能用默认的就用默认,能动态计算就动态计算。
想想TCP/IP 的重试延迟时长如果不是动态计算,那么配置什么值比较合适,网络畅通和网络延迟又该是什么值, 开始恢复时和开始堵塞时又应该是什么值的呢?
异常处理是系统复杂度的关键来源之一,异常就是一种特殊的分支,系统为了处理特殊 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 的处理方式就更优雅,它允许用户删除已经被其他进程打开的文件,它会对该文件做标记,让用户看来它已经被删除了,但是在打开它的进程结束前文件对应的数据都会一直存在。
只有在进程结束后,文件数据才会被删除掉,这样用户在删除文件时就不需要担心文件是否被使用。
通过优化以上的设计,减少需要用户处理的异常,这也是一个「去粗留精」的过程, 减少用户需要感知的内容。
本书用了好几个章节来介绍文档与注释的重要性,命名的重要性,如何写好注释和起好名字。
好的文档可以大幅改善一个系统的设计,因为文档的作用就是把「对用户重要的,但是无法直接从代码中得知的关键信息告知用户」, 相当于帮用户把一个系统的关键信息给找出来。
不是有这么一句话: 程序员都讨厌写文档,但是更痛恨其他程序员不写文档。
而注释就是离源码最近的文档.
程序员不写注释的借口大概有这么几个(可惜它们都是不成立的), 常见的借口与它们不成立的原因可见:
如果用户必须阅读方法源码才能使用它,那就没有抽象,你相当于把实现的所有复杂度都直接暴露给用户。
若想通过抽象隐藏复杂性,注释必不可少
如果你一直把写代码的优先级置于写注释之上,那么你会一直没有时间写注释, 因为一个项目结束之后总会有新的项目到来,如果你一直把写注释的优先级放在代码之后,那么你永远都不会去写注释。
写注释实际并不需要那么多的时间
注释虽然难免会过期,但是保持与代码一致也并不会花费太多时间。
只有大幅需要修改代码时才需要更新注释,更何况,只有每次都不更新注释,注释才会难免过期
别人的注释写得不好,那不正说明你可以写出好的注释嘛。
不能用别人的低标准来要求自己嘛。
说起接口注释和文档,我一直觉得我描述下接口功能和使用场景,已经比绝大多数的同行做得好了。
在和现在的 L7 大佬一起工作之后,着实被他的文档所震撼。
不知道是因为其对代码质量和文档都有非常高的要求,还是读博士时训练出来的写作能力, 其对接口的功能,使用场景以及异常的描述都非常详尽,甚至包括代码使用示例,质量与 JDK 源码的注释不相上下, 原来真的有程序员花这么多精力写代码注释的。
注释应当描述代码中不明显的内容,
简单来说,就是要描述代码为什么要这么做,而不是描述代码是怎么做的,这相当于是把代码换成注释再写一次。
很多程序员都习惯在写完代码之后才写注释,作者反其道而行, 作者推荐在定义完函数或者模块接口之后,不要马上动手写实现, 而是在这个时候在接口上把接口注释写下来,这相当于是在脑海把模块的设计再过一次。
写完代码再写注释,设计思路已经记不大清了,脑中更多的是实现细节,既容易把实现写成注释,又容易陷入「写完代码就不写注释」的陷阱。
前文提到,系统的复杂度来自于两个方面「依赖」与「不明确」, 而「一致性」就是让系统的行为更加清晰明确。
它意味着相似的事情以相似的方式处理,不同的事情以不同的方式处理。
即所谓的「规圆矩方」,通过规范约束降低随意性,以及「一法通,万法通」,统一模式提升可维护性,让行为可预期。
一个系统的一致性一般体现在以下方面:
当然,还有通过「一致性」降低系统复杂度,走得比较极端的:
之前还在微信支付的时候,除上述的要求外,还要求后端只能使用一种语言(C++, Golang/JavaScript就别想了), 存储组件只能使用微信内部研发的KV(使用MySql需要向总经理申请)等等的要求.
好的设计应该是通用的,优先采用通用设计而非特殊场景的定制化方案,这个是减少复杂度和改善软件系统的根本原则。
过度定制通常是成为软件复杂度增加的首要诱因。
通用设计可以降低系统的整体复杂度(更少处理特殊分支的逻辑), 更深的模块(接口简单,功能丰富), 隐藏非关键信息.
文中提到的例子就是文本编辑器的文字插入与删除操作:
|
|
通过 delete(int start, int end)
既可以实现删除键功能,也可以实现选中并删除的功能。
在设计系统的时候,一般不需要太多地考虑性能的问题,因为简单,通用的系统要做性能优化通常都是比较容易; 相反而言,深度定制的系统因为耦合了定义逻辑,要优化性能并没有那么容易。
Design it twice
因为很难一次就把事情做到极致, 那就再来一次, 设计时把能想到的选项都列下来.
反直觉的是,第一直觉通常不是最优的, 所以不要只考虑一种设计方案,无论它看起来多么合理,多对比下其他方案总没有害处的。
只用第一直觉的方案,其实你是在低估自己的潜力,你错失了找到更好方案的机会。
这也是我在写设计方案时候的做法,把自己能想到的,和同事讨论出来的所有方案都写上,然后分析各种方案的优劣, 最好的方案可能并不在原有方案列表里面,而是其中几个方案的合体。
做任何事都要有大局观, 编程也不例外,战略编程优于战术编程(Strategic Programming over Tactical Programming);
虽然我们一直说「又不是不能跑」,但是我们对代码的要求,不能是「能跑就行啦」.
再者就是要和扁鹊他大哥治病一样,把功夫都做在前期,防范于未然,修补错误成本往往也越往后越高,病入膏肓之后,扁鹊来了也要提桶跑路:
治不了,等死吧,告辞
本书的作者对《代码整洁之道》(Clean Code)4 的作者(Robert C. Martin, 即 Uncle Bob)的诸多观点作了反驳
比如关于什么时候应该拆分一个函数,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 的观点是: 每个函数应只做一件事,并完整地做好
函数的接口应当简洁,这样调用者无需记住大量信息就能正确使用它。
函数应当具备深度:其接口应远比实现更简单。如果一个函数满足以上所有特性,那么它的长度通常并不重要。
除非能让整个系统更简单,否则不应拆分函数
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.
所以也难怪 Uncle Bob 和 John Ousterhout 几个月前直接在网上论坛来了一次 对线 (辩论) 5
然后有看热闹不嫌事大的播主,把两人邀请到直播上,让他们直接面对面再来了一次对线
对应的Youtube视频: https://www.youtube.com/watch?v=3Vlk6hCWBw0
两位的书我都看过,我个人的感觉是《代码整洁之道》更适合入门的工程师,它可以教你如何写出好的「代码片段」; 而《软件设计的哲学》更适合需要做系统设计的工程师,它指导你如何设计好的「软件」。
考虑到两位作者的背景和作品,我可以说两位的差别可以说是 以编程为生的人与以写编程相关的东西为生的人
全书读完,我觉得《软件设计的哲学》绝对是配得上最好的技术书籍之一的赞誉。
但是不同的人读起来可能会有不同的感觉,其中的许多原则真的是做过设计,踩过坑才会有所共鸣, 否则会觉得其泛泛其谈。
当然,我也不是完全同意书中的所有观点的。
比如书中提到的会导致代码意图不「明显」的其中一种做法是声明的类型与初始化的类型不一致的情况:
|
|
上面声明的是 List<Message>
, 实际使用的 ArrayList<Message>
, 这可能会误导用户,因为意图不清晰,阅读代码的人可能不确定是否需要使用 List
或者 ArrayList
, 最好是声明和初始化都换成相同的类型。
但是 List
是接口, ArrayList
是接口的具体实现,这个就是非常标准的面向对象编程中的多态,这并不什么问题。
但瑕不掩瑜,全书读完,把书盖上后,我有种齿颊留香, 余音绕梁的感觉,书里有很多「熟悉的味道」,总是让我想起经手过的项目中种种的好代码和「坏」代码.