summaryrefslogtreecommitdiff
path: root/content/article
diff options
context:
space:
mode:
authoreltrac <hi@eltr.ac>2026-08-22 10:28:06 +0800
committereltrac <hi@eltr.ac>2026-08-22 10:28:06 +0800
commita684a88043c29a4218e3197594688dfab60e6909 (patch)
treedcdb17f4e99beb89a8d94302de3a6e96edfb8646 /content/article
parentade8950810174142a184edd7f62d1b52d9b2c74c (diff)
[draft] new
Diffstat (limited to 'content/article')
-rw-r--r--content/article/skeleton-programing.md172
1 files changed, 172 insertions, 0 deletions
diff --git a/content/article/skeleton-programing.md b/content/article/skeleton-programing.md
new file mode 100644
index 00000000..b6414fe0
--- /dev/null
+++ b/content/article/skeleton-programing.md
@@ -0,0 +1,172 @@
++++
+title = '骷髅编程'
+slug = ''
+tags = []
+categories = '代码炼金术'
+date = 2026-08-22T08:58:47+08:00
+draft = true
+toc = true
++++
+
+软件架构的工作,除了搞清楚应该设计多少、什么样的组件来实现需求,还应该考虑组件之间的依赖关系。SOLID 原则之一的「依赖反转原则」是指数据流向应该与依赖的方向相反,比方说,数据从后端流向前端,但后端不应该依赖前端,而是要让前端调用后端提供的接口。此外,组件之间有抽象和具体之分,核心业务逻辑就是高度抽象的,而数据库逻辑(用什么样的数据库、用什么 ORM、具体怎么实现)就是相当具体的,业务逻辑不应该依赖数据库逻辑,这怎么做到呢?
+
+比方说,我的业务逻辑是:不断接收某个源喂过来的数据,如果某条数据满足条件,就把它存在数据库里。如果我要编写这整个链路,难道不是要引用数据库组件吗?我怎么做到不直接依赖组件?
+
+## 接口和 `vtable`
+
+接下来我将分别演示在 OOP[^1] 语言中和在函数式编程语言中,如何实现「不依赖数据库组件,但描述存储操作」。我使用的语言是 Go 和 Clojure,其中 Go 不是纯面向对象语言(缺少继承机制),Clojure 也不是纯函数式语言(但我也不会写 Haskell 啊),但用到的思想和语言特性是可迁移的。
+
+### 接口是抽象的
+
+接口定义其实比核心业务逻辑还要抽象,因为接口只定义了一系列函数签名,而没有包含任何具体实现,而核心业务逻辑还包含对相关函数的实现。依赖关系应该从具体的逐渐指向抽象的,核心业务逻辑依赖最抽象的接口便符合这个原则。
+
+我们先定义一个接口:
+
+```go
+package interfaces
+
+type EntryStorage interface {
+ GetEntry(id int) Entry
+ PutEntry(entry Entry)
+ DeleteEntry(id int)
+}
+```
+
+接下来业务逻辑只要依赖这个接口就好了:
+
+```go {hl_lines=[3,6,11]}
+package receiver
+
+import "example.com/interfaces"
+
+type Engine struct {
+ Storage EntryStorage
+}
+
+// ReceiveEntry 检查数据条目是否合格,如果合格就存储
+// 并返回 true,如果不合格就返回 false
+func (e *Engine) ReceiveEntry(entry Entry) bool {
+ if (isQualifiedEntry(entry)) {
+ e.Storage.PutEntry(entry)
+ return true
+ }
+ return false
+}
+```
+
+此时,我们还没有编写 `EntryStorage` 的具体实现,我们不知道它用什么数据库,甚至不知道它用不用数据库,`EntryStorage` 完全可以把数据条目放进内存里、写进磁盘文件里、上传到某个云服务,甚至直接丢弃。
+
+我们==不需要编写具体实现也能测试这个软件==,测试用例可以这样写:
+
+```go
+package receiver
+
+import "test"
+
+// 就地实现一个假的存储
+// 不仅因为我们不需要真的存储
+// 还因为测试业务逻辑时,本就不应该关心存储有没有问题
+// 不能让持久层的问题也让业务逻辑的测试用例失败
+type FakeStorage struct {
+ data []Entry
+}
+
+func (s *FakeStorage) GetEntry(id int) {
+ return s.data[id]
+}
+
+func (s *FakeStorage) PutEntry(entry Entry) {
+ s.data = append(s.data, entry)
+}
+
+func (s *FakeStorage) DeleteEntry(id int) {
+ s.data = append(s.data[:id], s.data[id+1:]...)
+}
+// 至此,我们已经写完了实现 EntryStorage 接口的必要函数
+// FakeStorage 现在可以直接注入到业务逻辑中进行测试了
+// 你或许会觉得把 id 当成 Slice 的索引不太合适
+// 但这仅仅是测试而已,只要不影响测试用例的有效性就好
+
+func TestReceiveEntryQualified() {
+ // 装配出完整的业务逻辑引擎
+ storage := FakeStorage{ data: make([]Entry, 0) }
+ engine := Engine{ Storage: storage }
+
+ // 注入假数据并测试
+ qualifiedEntry := makeQualifiedFakeEntry()
+ ok := engine.ReceiveEntry(entry)
+
+ if !ok {
+ t.Errorf(`ReceiveEntry(qualifiedEntry) = %v, want true, nil`, ok)
+ }
+}
+```
+
+如果想要手动启动一个能够运行的实例,也不必着急实现数据库逻辑,同样的 `FakeStorage` 也可以在启动时喂给业务逻辑,它不知道也不关心数据最后去了哪里。有关接口的更多用法,可以阅读我的另一篇文章《[Are We Interfacing Yet?](/article/locally-remotely-unknowingly/)》。
+
+### 函数是一等公民
+
+在函数式编程语言中,函数可以作为值传递,像这样:
+
+```js
+element.onclick = (event) => {
+ alert("我被点击了!")
+}
+```
+
+这段 JavaScript 代码给 DOM 元素的 `onclick` 字段赋值,赋值的内容是一个函数定义。当元素被点击时,它就会执行 `onclick()` 函数,也就执行了我们传递给它的代码。这种函数一般也叫作回调函数(callback)。在支持函数作为一等公民的动态类型语言中,完全可以通过传递回调函数来实现接口能做到的事情——如果不在乎类型安全的话。
+
+我们假设 `engine` 有一个 `:storage` 字段,这个字段是映射(map)类型,里面存的全是函数。
+
+```clojure
+(def storage {:get-entry entry-storage/get-entry
+ :put-entry entry-storage/put-entry
+ :delete-entry entry-storage/delete-entry})
+(def engine {:storage storage})
+```
+
+注意,这只是**假设**,我们实际上还没有构造这个 `storage` 常量,因为 `entry-storage` 这个命名空间还不存在,我们还没有实现具体的数据库逻辑。这个 `storage` 是在装配的过程中被喂给业务逻辑的。
+
+跳过接口定义,直接开始写业务逻辑。在此之前要说明 Clojure 的一些语法,Clojure 可以用关键词取出映射中的某个值,就像这样:`(:get-entry storage)`——这段表达式会返回 `storage` 当中键为 `:get-entry` 的值,在这里返回的就是一个函数引用。要调用这个函数,就要这样写 `((:get-entry storage) args ...)`。
+
+```clojure
+(defn receive-entry [engine entry]
+ (let [storage (:storage engine)]
+ (if (qualified? entry)
+ (do
+ ((:put-entry storage) entry)
+ true)
+ false)))
+```
+
+同样地,如果要测试这个程序,只需要传入一个假的 `storage`。甚者,由于不需要实现接口,也就不需要实现接口中所有的函数,我们可以让 `fake-storage` 只包含这个测试文件用到的函数。再甚,我们都不需要真的实现任何形式的存储。
+
+```clojure
+(def fake-storage {:put-entry #(println "Stored!")})
+(def fake-engine {:storage fake-storage})
+(def entry (make-qualified-fake-entry))
+
+(defn test-receive-entry-qualified []
+ (is (receive-entry fake-engine entry)))
+;; 这个测试用例太短了可能让人难以理解
+;; is 是 Clojure 的断言语句,传入的是 true 就表示测试通过
+;; 这里 receive-entry 收到的是合格的 entry
+;; 照理来说就应该返回 true,所以直接把它的返回值放进 is 里
+```
+
+这样一个包含函数引用的映射,叫作 `vtable`。这么做当然有明显的坏处,没有类型定义,我们无法确定得到的 `storage` 映射是否真的包含我们需要的函数引用,也不确定得到的函数的函数签名(形参和返回值)如我们预期的一样。这看起来的确是动态类型语言的缺陷,但如你所见,我们已经写了测试用例了,倘若我们的 `vtable` 真的有问题,它会呈现在单元测试的结果里。只要写了覆盖率高且合格的测试,就不必担心类型问题会导致软件健壮性下降;反之,不写测试用例,紧紧依靠编译器的类型判断的静态语言程序,也不见得有多安全。
+
+而且你看看没有接口和类型定义的 Clojure 代码有多简短吧!
+
+##
+
+## 推荐阅读
+
+本文的部分观点来自以下书籍:
+
+- 《[整洁架构之道](/library/2026/clean-architecture/)》,Robert C. Martin
+- 《[Functional Design](/library/2026/functional-design/)》,Robert C. Martin
+
+## Footnotes
+
+[^1]: Objective-Oriented Programing,面向对象编程