对程序设计的一些思考

最近在设计一个前端程序,基本上就是一个面向领域设计 DDD 的思路,主要思考了一下关于模块协作相关的内容,有点不成熟,也想听听各位大牛对此的看法。


程序设计主要就是模块之间如何相互合作,而又保持一个合理的耦合程度,每个模块只保留非常少的全局假设。

为了屏蔽一些细节,我大致总结了三种方式:动态注入,静态注入,观察者。

在一个复杂系统中,观察者模式能够有效地将模块解耦,几乎所有的大型系统都会使用该模式。

剩下两种注入模式:动态注入和静态注入,我想举两个例子。

首先是静态注入的方式,将依赖模块通过参数传递给函数

function moduleA(interfaceA) {
   interfaceA.doSomething();
	 interfaceA.doOtherThing();
}

这种方式是 moduleA 对 interfaceA 做了某些假设,interfaceA 并不是专门为 moduleA 而设计,而是一个单独的合理抽象。这样 moduleA 在使用 interfaceA 时依然要小心翼翼,熟读 interfaceA 的文档。

这种方式是我一开始最常用的一种方式,但是它会导致很多问题,1. 就是理解成本增加,我需要知道 interfaceA 的设计理念。2. 参数爆炸。因为在一个复杂程序中我不可能只依赖了 interfaceA, 还可能依赖了其他好几个模块,如果都使用参数传递进来那 moduleA 的参数简直不可接受,特别是未来如果我想增加一个依赖还要调整参数顺序,维护起来不方便。

由于这一点在看 mxgraph 源码时发现开源项目中会动态注入依赖,就像 react-dom 注入到 react 中一样,并不是在其构造方法内注入,而是在构造方法外注入。

还是上面的代码片段,我们回归一下问题本质,本质是 moduleA 需要做一个操作,这个操作具体什么模块执行的我不关心,我只需要完成这个功能即可,由外部的组合者(暂且这么称呼它把) 负责调配不同的实现。这是问题的本质,那么原来的方法注重的是:可替换的实现。新的方法将会注重:完成某种功能。

function moduleA() {
}

moduleA.prototype.work = function() {
  this.doSomeWork();
}

moduleA.prototype.doSomeWork = function () {
  throw new Error('not implementation');
}

// outside
const a = new moduleA();
a.doSomeWork = function() {
  interfaceA.doSomething();
  interfaceA.doOtherThing();
}

使用这种方式 moduleA 甚至不需要知道中间协议 interfaceA 的存在,只需要知道有个东西需要实现,外面的组合者再将对应的依赖组装好。

这样也能达到关注点分离,甚至比通过参数的依赖注入更好,因为此时 moduleA 无需对外做什么假设,对应的成本就是 moduleA 的初始化有一定的成本,我觉得比较适合在确定位置初始化的对象使用这种方法比较合适。

从OOP的观点看:

第一个注入(静态注入)被称为“constructor injection”。

第二个注入(动态注入)被称为“setter injection” (主要用来解决循环依赖的问题)。

这两种都属于Poorman注入(手动注入)。

然而,大型项目通常不会手动注入,而是引入依赖注入容器(DI container):

程序员只要在外部配置依赖,容器会自动注入对象。

除此之外,dynamic scoping或许可以作为依赖注入的替代。

1 个赞

但是我看了一些使用 setter 方法注入的例子,都是注入外部依赖,而不是注入实现,而且 Java 中做不到注入实现,像这种在外部实现某个方法恐怕只有在js这种动态语言才有,所以不是很确定是否也能称得上 setter injection.

不是很理解你这句话的意思。

比如:在Java里做依赖注入,注入的是接口的实现,注入一次之后就不会再改了。

如果说你的moduleA里有一个方法doSomeWork,需要在外部修改它的行为,这通常属于Dirty操作,很少有这么做的(尽管DI Container也可以支持)。

注意:依赖注入通常用于把不同的组件compose在一起,而不是object evolution(比如:modify existing object 或者 clone an exemplar with new behavior)。

moduleA 想调用 doSomething 和 doOtherThing 为啥不直接

import { doSomething, doOtherThing } from 'some_module';

function moduleA() {
  doSomething();
  doOtherThing();
}

这样 moduleA 就耦合了 some_module 。想让 moduleA 不必知道外部module。

总要有地方让它知道的,只是换了位置。 我觉得你这种做法的好处是在于这段代码

moduleA.prototype.doSomeWork = function () {
  throw new Error('not implementation');
}

这样保证接口一定有实现,如果不实现会被发现(扔出异常)。但是在复用性上,和直接引用模块中的函数没啥区别。

就是换了位置才能达到控制反转的目的,换位置才是学问所在啊。

那你说说换了位置之后比直接 import 好在哪里,代码量减少了,还是更好修改了,还是什么。

比 import 的好处是让模块不耦合,moduleA 不必知道其他模块的存在,moduleA 不负责额外依赖的初始化,只依赖接口,不依赖实现,这都是 IOC 的理念。在程序设计时 简单情况下 import 很容易,但是设计复杂程序必须考虑解耦的策略。

你老说理念多好多好,复杂程序用上多么有用,实际上到底好在哪里呢?真的能让程序员爽么?

针对这段代码,针对 run 函数

function run() {
  // ... 
  doSomething();
  doOtherThing();
  // ...
}

诉求很简单,我希望在 run 函数的代码块里, doSomethingdoOtherThing 是可以被调用的,若是以后更换了这两个函数的实现方式,也不需要我修改 run 里面的代码。

这就是所谓“面向接口编程”。为了实现这个效果,有很多方式,有的是“强制”保证(比如编译器会检查是否实现了接口中声明的函数),而有的仅仅是“逻辑”上的保证(不实现也能继续运行)。

现在假如 js 有这么一个功能,允许我声称,some_module 这个模块已经实现了 doSomethingdoOtherThing 这两个函数。假想代码如下。

interface some_module {
    function doSomething();
    function doOtherThing();
}

然后 some_module 的代码就可以写成

// some_module.js
interface very_useful {
    function doSomething();
    function doOtherThing();
}

function doSomething() {
  // ...
}

function doOtherThing() {
  // ...
}

此时,我需要用一种方式使用 some_module。现在又假如,js 提供了一种方式可以“注入”某个接口,也就是让某个接口中的函数可以被调用,假想代码如下

// main.js
inject very_useful; // 这样就可以使用 `very_useful` 接口中的函数了

function moduleA() {
  doSomething();
  doOtherThing();
}

这样的代码就是所谓“控制反转”,“依赖注入”。那实际上换成能跑的 js 代码怎么实现?

有两段代码 js 还不支持

interface very_useful {
    function doSomething();
    function doOtherThing();
}

inject very_useful;

把它转成可以运行的 js

import {
  doSomething,
  doOtherThing
}

from 'some_module';

这样就部分实现了 interface 和 inject,区别在哪?在于没有 very_useful 这个类型。

可能会有什么问题呢,就是如果有很多地方都引用 some_module,那么假如我修改了 some_module 这个名字,或者用另一个 module 来实现这两个函数,那么需要修改所有引用 some_module 的代码。

所以这里可以做一个 very_useful module,代码如下

// very_useful.js

import implement from 'some_module';

function doSomething() {
  implement.doSomething();
}

function doOtherThing() {
  implement.doOtherThing();
}

然后引用的地方不再引用 some_module 改为引用 very_useful

import { doSomething, doOtherThing } from 'very_useful';

function moduleA() {
  doSomething();
  doOtherThing();
}

这样修改具体实现就不再需要修改每个引用的地方了,只需要修改 very_useful 中的代码即可。
如果能做到自动生成 very_useful.js,那就是所谓自动的“依赖注入容器”了。


个人理解,抛砖引玉 :laughing:

我觉得楼主讨论这个问题实际上算是状态管理的方式,而不是模块的设计的问题。

比如要写个程序,把功能划分在 moduleA, moduleB, moduleC 三个模块,这个是模块的设计,也是 DDD 要处理的问题。

在你决定了模块划分之后,每个模块即是个状态,接下来实现状态管理通常要解决依赖的问题,还有依赖间访问的形式。

所以这是两件事。后者是非业务型的,如果不是发明创造的话只要从比较主流的方式中选一种即可,当问题具体明确的时候,只需要取舍,不需要设计。

举个例子就是比如你有 A, B 两个 domain.

function moduleA(){}
moduleA.prototype.foo = function() {}

function moduleB(){}
moduleB.prototype.bar = function() {}

这是模块设计,A, B之间没有交集,这是两个模块。然后你再考虑状态管理,和交互的形式。比如说做个依赖的配置

config = {
  "a": {module: moduleA, dependsOn: []},
  "b": {module: moduleB, dependsOn: ["a"]
}

至于模块交互的方式,有很多,function call 只是其中的一种,你也可以通过 channel 之类的概念解耦,交互的模块跨应用的时候选择就更多了,RPC, HTTP, 等等。这里应该和业务完全无关了。

上面的配置其实就是ioc的实现,只不过ioc可以借助装饰器来隐式实现上述的配置对象,所以表达的其实也是同一种意思。

本帖已经偏离我本来发帖的本意了,我初衷是希望有人能够指点一下关于注入的不同方式,@chansey97 巨巨的回复是比较接近我想要的答案。后面的回复其实在争论在js中DI是否有必要,这一点不想产生争执,所以本贴无需再回复了。

要从更高一点的角度去看问题。ioc 只是具体到一种写法,做架构的时候一般不去考虑这些的。

这个就是接口啊,不过是用函数来实现的。Java 中用 lambda 来实现没问题。doSomeWork 外部实现的基础是其定义(参数、返回值等)。