diff options
| author | eltrac <hi@eltr.ac> | 2026-08-22 10:28:06 +0800 |
|---|---|---|
| committer | eltrac <hi@eltr.ac> | 2026-08-22 10:28:06 +0800 |
| commit | a684a88043c29a4218e3197594688dfab60e6909 (patch) | |
| tree | dcdb17f4e99beb89a8d94302de3a6e96edfb8646 | |
| parent | ade8950810174142a184edd7f62d1b52d9b2c74c (diff) | |
[draft] new
| -rw-r--r-- | content/article/skeleton-programing.md | 172 |
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,面向对象编程 |
