From 010e895957722815e8c52e7f35b0c252afb3f8cc Mon Sep 17 00:00:00 2001 From: eltrac Date: Sun, 23 Aug 2026 12:11:14 +0800 Subject: [draft] update --- content/article/skeleton-programing.md | 105 ++++++++++++++++++++++++++++++--- 1 file changed, 97 insertions(+), 8 deletions(-) (limited to 'content/article/skeleton-programing.md') diff --git a/content/article/skeleton-programing.md b/content/article/skeleton-programing.md index 60bff357..3472eef6 100644 --- a/content/article/skeleton-programing.md +++ b/content/article/skeleton-programing.md @@ -335,7 +335,6 @@ type EntryJudge interface { (defrecord Ody [home over? crawl-queue] Odysseus (start [_this] - (println "Odysseus is finding way home to: " home) (hop [] over? home crawl-queue))) ``` @@ -343,32 +342,122 @@ type EntryJudge interface { 现在还剩下的问题是,尽管爬虫是异步的,与核心逻辑解耦了,但核心逻辑本身是串行的,一次递归执行完毕之后才执行下一个,同时也只会有一个爬取任务被传入 `crawl-queue`,根本利用不了并发资源。所以,要把 `hop` 也改成并行的。 -如果只是给 `hop` 套上一层 `go`,它的确会并发,但内部的递归逻辑就要修改了。因为 `(go (hop ...))` 返回的是 `channel` 而非 `hop` 的返回值,必须要想办法读取这些 `channel` 才行。 +本 Go 程序员写 Go 程序写得太多了,一开始也想用 `go` 让 `hop` 并行,然后便踩了坑,写出了很复杂的代码。实际上 Go 标准库里的 `future` 就足够好用。`future` 同样异步执行,返回一个引用 `ref`,之后 `deref`(或者用语法糖 `@`)就能拿到结果,如果 `future` 没有完成,`deref` 的调用者就会原地等待。 ```clojure {hl_lines=[2,"11-13"]} (defn- hop [journey over? home crawl-queue] - (go (let [journey (if (empty? journey) [home] journey)] + (future (let [journey (if (empty? journey) [home] journey)] (if (over? journey) ;; if journey's over, print the only one [journey] ;; if not, keep crawling and find fresh urls ;; start multiple hop forks and concat their result together (let [receive-chan (chan)] - (>! crawl-queue [(last journey) receive-chan]) - (let [urls (!! crawl-queue [(last journey) receive-chan]) + (let [urls (!! crawl-queue [(last journey) receive-chan]) + (let [urls (Ody 0 #(>= (last %) 5) pls) + want #{[0 1 2 3 4 5] [0 1 2 3 4 6] [0 1 2 3 4 7] [0 1 2 3 5] [0 1 2 3 6] [0 1 2 4 5] [0 1 2 4 6] [0 1 2 4 7] [0 1 2 5] [0 1 3 4 5] [0 1 3 4 6] [0 1 3 4 7] [0 1 3 5] [0 1 3 6] [0 1 4 5] [0 1 4 6] [0 1 4 7] [0 2 3 4 5] [0 2 3 4 6] [0 2 3 4 7] [0 2 3 5] [0 2 3 6] [0 2 4 5] [0 2 4 6] [0 2 4 7] [0 2 5] [0 3 4 5] [0 3 4 6] [0 3 4 7] [0 3 5] [0 3 6]}] + ;; match journey results + (is (= (set @(start ody)) want)) + ;; collect channel and count + (loop [counter 0] + (if (poll! result-chan) + (recur (inc counter)) + (is (= counter (count want)))))))) +``` + +这个测试中的 `journey` 不是 URL 向量,我把「爬取和发散 URL」变成了「累加数字」。初始 URL 变成了初始数字 `0`。测试 `Ody` 的时候我没有启动爬虫,注意我传入的管线 `pipelines`,其中 `:get-crawl` 并没有把新 URL 放进 `crawl-queue` 里再等待结果,而是对数字进行了三次加法运算,发散出 `n+1`、`n+2` 和 `n+3` 三个数字。再看 `over?` 的具体实现 `#(>= (last %) 5)`,当累加 `journey` 的最后一个数字大于等于 `5`,就算做结束。 + +由于并发的执行顺序不定,为了避免最终结果的顺序影响断言,我把它转换成了集合类型(`set`),然后与包含所有累加路径的集合对比。最后再把 `result-chan` 里的内容读出来,看看 `Ody` 是否传入了正确数量的路径结果。 + +用 Leinigen 运行测试: + +```shell +➜ ody git:(feat_core) ✗ lein test + +lein test ody.ody-test + +Ran 1 tests containing 2 assertions. +0 failures, 0 errors. +``` + +测试通过!接下来应该考虑边界条件,写更多的测试,然后还要写 `workers.clj` 的单元测试,看它能不能正常启动 N 个爬虫,能不能正确消耗 `crawl-queue` 里的任务等等。具体的就省略了。 + +总之,合理的抽象、接口和骨架式的编程不仅可以让人在开发核心功能时只关注核心逻辑,而不被技术细节困扰,还能让单元测试变得容易,不必等到整个项目近乎完成时才做集成测试。 + ### 填充血肉 +那些跟 LLM Agent 一起工作得不亦乐乎的人,其实已经可以放手,把写好的接口定义和核心业务逻辑丢给 Agent,让机器实现人没有实现的接口,在 `main` 组件中装配起来,然后就能跑了。跟 Vibe Coding 不同的是,开发者已经对软件的核心逻辑、接线点、输入输出和内部结构了如指掌,不会面对大量安全性、可读性和质量堪忧的代码发懵。开发者不会像 Vibe Coders 那样因为不理解代码而事事求助于机器,不会让自然堆积的屎山代码填满 LLM 的上下文,燃烧大把大把的美钞。再者,由于开发者对代码的理解很充分,他可以写出更清晰的提示词,甚至精确到代码行,让 Agent 消耗更少的 Token 和时间完成任务,而不必把大量的 Token 都用在「推测用户意图」上。 + +不过,LLM 生成代码的版权状态未知,我不知道 Agent 填进我仓库的代码是从谁哪里偷的,应该遵从什么开源协议,应该署谁的名字,所以我不会用 LLM 生成的代码。接下来我将手写接口实现,把所有组件都装配起来,产生可运行的软件原型。 + ## 推荐阅读 本文的部分观点来自以下书籍: -- cgit v1.2.3