Refactoring English: Month 8

Michael Lynch

重构英语:第 8 个月

原文由 Michael Lynch 发布,订阅该博客

一句话总结

也许,并非每个人都想加入我的焦点小组。

本月亮点

  • 我发现,并非所有购买了抢先体验版的读者都愿意就初稿提供反馈。
  • 我梳理了时间的去向,并思考如何减少时间浪费。
  • 我花了 10 小时从零重写了一个当初耗时 300 小时搭建的网页应用。
  • 我继续用 Gleam 学习函数式编程,但可能有点“作弊”。

目标完成度

每个月初,我都会定下希望完成的目标。以下是本月的完成情况:

与至少 10 位未曾交流过的读者沟通

  • 结果:给七位读者发了邮件,收到三封回复,进行了一次实时对话
  • 评分:C

坐下来统计之前,我以为回复率要低得多,其实不少读者都有回应。问题在于我联系得还不够多。

我可能在每封邮件上花了太多时间,总想写出独特、明显不是 AI 生成的内容,结果一头扎进对方的博客一看就是一小时。

清理营销创意积压

  • 结果:完成了预定计划的约 70%
  • 评分:C

这些任务大部分都完成了,不过一年前录的一期访谈还没整理发布,希望能尽快完成。

发布《Refactoring English》的新章节

我对这一章的成稿很满意,但也知道在社交媒体上分享会是一场赌博。结果是在 Lobsters 上反响不错,但在 Hacker News 上毫无水花

《Refactoring English》数据

指标2025 年 6 月2025 年 7 月变化
独立访客6,5748,061+1,487 (+23%)
预售收入$597.24$800.04+$202.80 (+34%)
咨询收入$242.45$0.00-$242.45 (-100%)
赞助收入$48.25$48.25$0.00 (0%)
总收入$887.94$848.29-$39.65 (-4%)

网站访问量有所上升,这算是个好消息,毕竟本月并没有特别受欢迎的新文章。我把这看作一个积极的信号:仅靠现有的试读章节,就能维持不错的访问量。

总收入略有下降,只是因为付费咨询从一单降到了零,变化不大。更让我高兴的是,预售收入相比 6 月增长了 34%。

如果大家喜欢的只是感觉上的买书呢?

在我逐一联系读者并与他们视频通话时,有一句话被反复提及:“我还没开始读。”

无论是几天前刚购买的人,还是已经拥有这本书好几个月的人,都会这么说。

我最担心的是,这本书成了“维生素而非止痛药”。人们把好写作看作重要但不紧急的事。有人会在面试前去读一本叫“How to Nail Your Next Coding Interview”的书,但提升写作这种事,却很容易被无限期推迟。

我之所以在动笔前就预售这本书,部分原因就是想看看是否真的有足够多的人愿意为它付费。事实证明是有的,而且至今仍有人在购买,但我担心这个信号或许没有我想象中那么有预测性。

会不会我的书就是那种让人们买来获得一种“已在为写作投资”的感觉、却不必真正花时间去做任何事的东西?会不会它就像 Planet Fitness 那家知名的连锁健身房——据说它的盈利就靠那些办了卡却从不去锻炼的人?

如果我写的是一本“Planet Fitness 式”的书呢?
照片由 Mike Mozart 拍摄,依据 CC-BY-2.0 许可使用

就算我真能成为书里的 Planet Fitness,那也不可持续。要让这本书在财务上可行,我指望的是读过并喜欢它的人的口碑推荐。我希望受欢迎的博主们会引用我的书,说它是帮助他们提升写作的资源。当有开发者说想提高写作水平时,我希望大家会第一时间推荐我的书。

也许顾客并不想加入我的焦点小组

我预售这本书的另一个原因是,我以为预购的读者会特别积极地在写作过程中提供反馈。

和同样是顾客的朋友们聊过之后,大多数人都说,他们预购只是为了支持这个项目,想等书完成后再读,并不一定想参与焦点小组或对初稿提意见。他们只想读最终成品,这完全可以理解。

我应该继续逐一联系大家

通过逐一联系顾客并安排实时通话,我发现有些读者非常热情,很愿意提供反馈。虽然目前只找到了少数几位,但他们给出的反馈都非常宝贵。

找到这些热情读者最有效的方式就是一封一封地发邮件,所以我会继续这么做。

时间都去哪了?

大约左右,我都会回顾上个月的成果,然后想:“等等,上个月怎么就只做了这么点?时间都花哪了?”

七月就是这样的一个月。我按正常的全职时长工作了,但回过头来看,却在想:“怎么一个月就只完成了一个新章节?”

所以,来回顾一下,在没完成月度目标的时间里,我的时间都花在了哪里。

在章节上投入过度

几个月前,我意识到自己花了太多时间雕琢文字,而不是在章节达到 80-90 分时就交给读者。我的解决办法是为每章设定时间上限,在预算内能写多少就写多少。

七月写的邮件那一章,我的预算是五小时,实际却花了 17.5 小时。部分原因是刻意为之——设定目标后我意识到,这一章很适合作为网站上的独立试读章节。而先写成试读、再整合回书里,需要花更长时间。

但我现在写的这一章也在犯同样的毛病。已经花了 6.5 小时,超过了 6 小时的预算,估计至少还要 3 小时才能达到可以分享给读者的程度。我想这既是我打磨得太多,也是因为我给这本书最重要、也是第一章的预算定得太低了。

解决办法:为会作为公开试读的章节额外预留时间,并严格将写作时间控制在既定范围内。

课外的博客文章

我喜欢在刚学到东西时就通过写博客记录下来,但就算每周花 20 小时写博客,也写不完所有想写的题材。

我的一些博客文章能吸引可能成为书的读者的那类人,而另一些则可能不会。我必须有意识地权衡,在“纯粹出于兴趣”的文章上投入多少时间。

上个月,我发布了一篇新博文《将 ZFS 存储池从 RAIDZ1 迁移到 RAIDZ2》。我知道它对书没什么帮助,但当时以为只需几小时就能写完,而且想把一个还没见人好好讲清楚的东西解释清楚。

实际上,那篇 ZFS 文章花了我七小时,对于一篇“兴趣使然”的文章来说投入过大了,尤其是在当月没有更能吸引眼球的《Refactoring English》章节可分享的情况下。

解决办法:对非图书相关的博文更挑剔一些,优先写那些也能吸引潜在图书读者的主题。

不良的社交媒体习惯

我发现自己一感到无聊或对艰深的写作、编程提不起劲时,就会去刷社交媒体。我告诉自己就看一眼马上回去工作,结果却被一篇文章吸进去。如果发了评论,就会不停地刷有没有人回复。

通常如果文章在 Hacker News 或 reddit 上火了,我会专门留一天来回复评论。我的 ZFS 文章并没有大火,但我还是花了一整天回评论。

我曾用过一个叫 LeechBlockNG 的浏览器扩展来克制不良的社交媒体习惯,但它似乎会泄露内存、拖慢整个浏览器,所以就禁用了。现在我又重新启用了它,暂时还没发现内存泄漏,也许这次能行得通。

解决办法:再给效率类浏览器扩展一次机会,给那些浪费时间的网站增加一些阻碍。

从睡眠中断中恢复

我的幼儿睡不好,导致我和妻子也睡不好。

睡眠中断本身可能还不是最大的问题。更关键的是,我会拿睡眠不好当借口来偷懒,比如跳过写作或去刷社交媒体。心里会想:“昨晚睡得这么差,今天就不用那么拼了吧!”实际上我本可以保持 80-90% 的效率,却用睡眠不好来为自己开脱。

解决办法:不再把睡眠中断当作偷懒的借口。

拖延付费编辑工作

作为《Refactoring English》提供的服务之一,我一直在做编辑工作。

我喜欢帮别人改博客文章,但这很耗费心力。改自己的文章已经很难,给别人改就更难。改自己的东西时,我可以凭感觉改,不必解释为什么这么改;而给其他博主提修改意见时,就必须说清我在初稿中看到的不足,以及为什么我的建议更好。

我发现自己一直在拖延编辑工作,即使一天内能做完,也会拖上好几天。而在拖延编辑工作时,我自己的写作也会跟着被拖延,因为我想优先完成客户的工作。

解决办法:意识到拖延编辑工作会浪费大量时间,尽快着手处理。

业余项目

用 10 小时以静态网站生成器重写一个曾耗时 300 小时的 Vue 应用

2019 年,我尝试做过一个叫 What Got Done 的产品。它能让团队成员相互分享每周的工作总结。

What Got Done 是一个让人们分享每周工作进展的网站。

在 Google 工作时,内部有一个叫 Snippets 的工具,功能和 What Got Done 一样。我很喜欢它,离开 Google 后也一直坚持写周报,即便当时是一个人工作。

我始终没能为 What Got Done 找到客户,于是将其开源,在过去六年里作为业余项目维护着。

最初我用 Vue、Firestore 和 AppEngine 搭建了 What Got Done,而现在我已经非常不喜欢这些技术。我花了很长时间把 Firestore 换成 SQLite、把 AppEngine 换成 fly.io,但 Vue 一直留着,让开发体验很不愉快。

每周我都会在 What Got Done 上发布更新,同时越发怀念用 VS Code 和 Hugo 写博客的工作流。于是某个周末,我干脆用 Hugo 把 What Got Done 重新实现成了一个简单的静态网站,现在托管在 weeks.mtlynch.io 上。

我用 Hugo 将 What Got Done 重新实现为一个可生成的静态网站。

六年里,我大概花了约 300 小时将 What Got Done 实现并维护为一个 Go + Vue + SQLite + fly.io 的应用。而用简单的 Markdown 文件加 Hugo 重写成静态网站,只花了 10 小时。

因为新版本只是一个自用应用,我可以加入一些个性化功能,比如从 git 提交记录自动预填周报。当然,作为一个纯静态网站,托管、维护和备份都比原来那个前端、后端、数据库各一套技术栈的完整网页应用要简单、便宜几个数量级——它只需要源码管理即可。

逐步关停 What Got Done

我不想永远维护 What Got Done,尤其是在我自己都不再使用的情况下。

尽管 What Got Done 只有少数活跃用户,我还是不愿抛弃那些曾使用我产品的人,所以我尽量把 What Got Done 的下线体验做得友好一些:

  • 我在网站上宣布 What Got Done 将于年底停止运行。
  • 增加了一项让用户以 Markdown 格式导出文章的功能。
    • 反正我也要把自己的数据迁移到 Hugo,顺手就把这个功能做进了网页应用,让所有用户都能导出。
  • 增加了一项让用户设置转发地址的功能,以便在 What Got Done 关闭后继续访问。
    • 例如,我已将自己的个人主页 whatgotdone.com/michael 设置为永久重定向到 weeks.mtlynch.io

用 Gleam 编写的 AIM 日志解析器的进展

我仍在通过摆弄一个解析高中和大学时期旧 AIM 日志的小工具来学习 Gleam 编程语言。最基础的日志看起来是这样的:

Session Start (AIM - DumbAIMScreenName:Jane): Mon Sep 12 18:44:17 2005
[18:44] Jane: hi
[18:55] Me: hey whats up
Session Close (Jane): Mon Sep 12 18:56:02 2005

解析时间戳

六月时,我的解析器已经能在基础层面工作,能把上面的日志提取出发件人和消息内容,如下所示:

[
  Message(sender: "Jane", body: "hi"),
  Message(sender: "Me", body: "hey whats up"),
]

七月我取得了进展,解析器现在能理解时间戳了,这有点棘手,因为需要把会话元数据中的日期和消息中简单的 HH:MM 信息结合起来。因此,我的日志解析器现在能把上面的日志转换成这样:

[
  Message(
    timestamp: must_parse_rfc3339("2005-09-12T18:44:00-04:00"),
    sender: "Jane",
    body: "hi",
  ),
  Message(
    timestamp: must_parse_rfc3339("2005-09-12T18:55:00-04:00"),
    sender: "Me",
    body: "hey whats up",
  ),
]

将词法分析和解析合并为一步

我还简化了解析器,改为单遍处理,而不是分开进行词法分析和解析。

我一开始觉得把日志先拆成 token 列表再解析会更规范、更优雅。也就是说,解析器不是直接看到一行像 Session Start (AIM - DumbAIMScreenName:Jane): Mon Sep 12 18:44:17 2005 这样的内容就去解析,而是希望词法分析器先把它变成一系列 token,比如:

[
    SessionStart,
    Word("(DumbAIMScreenName:Jane)"),
    ColonSpace,
    Word("Mon"),
    Word("Sep"),
    ...
]

但这意味着我需要一个隐藏的第一遍处理,把字符串切成词法分析器能识别的子串,比如 ["Session", " ", "Start"],而且我还得自己实现字符串切分逻辑,因为 Gleam 的内置库没有办法按子串切分字符串的同时还保留分隔符。例如,如果先按换行再按空格切分,就会得到一个字符串列表,却无法分辨分隔符到底是空格还是换行。

感觉我实际上把输入解析了三遍:一遍是自定义的字符串切分一遍是词法分析,以及一遍真正的解析。我起初以为这是因为我对函数式语言或文本解析器了解不够,总会找到更优雅的词法分析和解析方式。

我借着这份困惑,终于说服自己买了一本纸质版的Crafting Interpreters,这是我见过设计最精美的软件类书籍。

我的 Gleam 项目终于给了我一个购买纸质版Crafting Interpreters 的理由。

读完书中关于词法分析的章节后,我得出结论:我的 AIM 日志结构化程度不够,不适合做词法分析。我尝试把所有逻辑合并到一个解析器中,让它逐字符读取输入,这样感觉更简单。

逐字符解析的缺点是,Gleam 的模式匹配看起来丑了不少。在旧的实现中,我可以像这样匹配字符串模式:

  case contents {
    ["Session", "Start", ..rest] -> tokenize_list(rest, [SessionStart, ..acc])
    ["Session", "Close", ..rest] -> tokenize_list(rest, [SessionClose, ..acc])

而现在则是更长、更混乱的模式匹配

  case state.remaining_graphemes {
    [
      "S",
      "e",
      "s",
      "s",
      "i",
      "o",
      "n",
      " ",
      "S",
      "t",
      "a",
      "r",
      "t",
      ...

我是不是在把类硬塞进 Gleam?

随着解析器的推进,我发现自己在反复编写签名相同的函数:

fn parse_tokens_with_messages(
  tokens: List(Token),
  messages: List(Message),
) -> List(Message) {

我的很多函数都接受相同的参数、返回相同的值,而且随着代码增多,参数和返回值的列表也越来越长。

于是,我创建了一个 ParseState 类型,并改为传递它

type ParseState {
  ParseState(
    last_timestamp: timestamp.Timestamp,
    messages: List(Message),
    remaining_graphemes: List(String),
  )
}

fn parse_graphemes(state: ParseState) -> ParseState {

但这感觉就像把面向对象的类偷偷塞进了 Gleam。因为如果用 Go 来写,代码会是这样:

type Parser struct {
  LastTimestamp       time.Time
  Messages            []Message
  RemainingGraphemes  []rune
}

func (p Parser) Parse() {

函数式编程大佬们:我这算作弊吗?还是说这就是在函数式语言中传递状态的正确方式?

收尾

完成了什么?

经验教训

  • 并非所有《Refactoring English》的早期读者都愿意提供反馈也没关系。我可以继续联系读者,找到那些愿意更积极参与的人。
  • 评估自己浪费时间的方式并思考应对措施,总是有益的。

下月目标

  • 给 20 位未曾交流过的读者写个性化邮件。
  • 发布《Refactoring English》的新章节。
  • 完成剩余的营销任务

求助

如果你是一位想提升写作能力的开发者,或认识有此需求的人,欢迎与我联系

本文章由 muse-spark-1.2-contributor 进行翻译

评论