装配逻辑
最理想的拆分后又组合的方式是“粘贴”到一起。就像你把一块砖,劈开了。拼装回去就是把两半又放一起。也就是我们可以有一个通用的函数 integrate(comp1, comp2, .. compN)。很多组件化系统都是这么吹捧自己的,比如说父类与子类,最终拼装出结果,这个拼装就是继承规则。比如说 OSGi,一堆 bundle,不用管他们是什么,都可以用同样的方式拼装起来。然而,我们都知道业务逻辑拆分出 git 仓库之后,往往是没有办法用通用的函数组合回去的。
比如说我们需要描述两个模块提供的按钮,哪个在哪个的左边。比如说我们要拼装两条促销规则,要说明哪个用了之后哪个就不能用了。这些业务上的空间组合,时间组合是必不可少的。
任何向你推销通用的组合式组件系统的人,最终都会搞出一个配置文件给你写“装配逻辑”。这样的模块装配文件就是没事找事,本来可以用大家都懂的 javascript 解决的问题,要变成用 XML 换了花来写。
文件做“集成”
业务逻辑会拆分成
- 文件
- 文件夹
- Git 仓库
Git 仓库负责了拆分。文件的作用就是“集成”。比如最最常用的方式
function functionA() {
funcitonB();
functionC();
}
我们把 functionA 放在 A 中,functionB 和 functionC 放在 B/C 中。这就是最常用的“编排”集成方式。通过把它们放在一个文件中,我们声明了 functionB 要发生在 functionC 之前,说明了时间上的顺序规则。
如果我们用的是 React 写界面,可以声明
function functionA() {
return <ul><li><functionB/></li><li><functionC/></li></ul>
}
这个就说明了在界面上,functionB 要放在 functionC 的上一行。通过一个文件,说明了空间上的顺序规则。 这也说明了文件的主要意义,它是用来把逻辑在时间和空间上做集成的。 说明在空间上,多个Git仓库是如何组合的,哪个在左哪个在右。 说明在时间上,多个Git仓库是互相交互的,哪个在前哪个在后。
如果两行代码,写在同一个文件,和写两个文件里,没有啥区别。 那说明这两行代码没有必要强行塞到同一个文件里。比如你写了一个 User.ts,有用户可以发招租的界面样式代码,也有用户买外卖的处理过程代码。 这些没有强烈时空上集成规则的两个东西都强行塞到 User.ts 里,实际上就没有利用好“文件”的特殊性质,是对稀缺注意力资源的浪费。
我们如果拆出了 B 和 C 这两个 Git 仓库。理论上只有两种把 B 和 C 集成起来的办法。
装配逻辑放顶上:顶层编排
引入一个 A 仓库,它依赖了 B 和 C。
集成”文件”定义在依赖的最顶层里。
装配逻辑放底下:依赖倒置
引入一个 A 仓库,提供接口。由 B 和 C 实现这个接口,插入到 A 上。就像主板和显卡那样,A 就是主板,B 和 C 就是显卡。
在 B 和 C 上面还需要一个仓库 D 把大家都包括进去,但是这个仓库里可以没有业务逻辑了。 集成”文件”定义在依赖的最底层。
不清楚 A/B/C/D 都是啥没关系。下面我们来看一些代码,然后就应该明白了。
如何实现依赖倒置?
要实现依赖倒置有运行时和编译时两大类做法,无数种具体实现。这里列举几种代表性的。
运行时:组合函数
以 TypeScript 示例。
我们可以在 A 中写这样的一个接口
function functionA(b: () => void, c: () => void) {
b();
c();
}
然后在 B 和 C 中写两个函数
function functionB() {
console.log('b');
}
function functionC() {
console.log('c');
}
然后在 D 中组装,添加 A,B,C 三个 Git 仓库做为依赖,并调用 functionA:
const functionA = require('A');
const functionB = require('B');
const functionC = require('C');
function functionD() {
functionA(functionB, functionC);
}
运行时:组合对象
以 TypeScript 示例。
我们可以在 A 中写这样的一个接口
function functionA(b: { doSomething(): void; }, c: { doSomething(): void; }) {
b.doSomething();
c.doSomething();
}
然后在 B 和 C 中写两个对象
const b = {
doSomething: function() {
console.log('b');
}
}
const c = {
doSomething: function() {
console.log('c');
}
}
然后在 D 中组装,添加 A,B,C 三个 Git 仓库做为依赖,并调用 functionA:
const functionA = require('A');
const b = require('B');
const c = require('C');
function functionD() {
functionA(b, c);
}
编译时:模板
以 C++ 为例。
我们可以在 A 中写这样的一个接口
template<typename TB, typename TC>
void functionA() {
TB::doSomething();
TC::doSomething();
}
然后在 B 和 C 中写两个类
#include <iostream>
class ClassB {
public:
static void doSomething() {
std::cout << 'b' << std::endl;
}
};
#include <iostream>
class ClassC {
public:
static void doSomething() {
std::cout << 'c' << std::endl;
}
};
然后在 D 中组装,添加 A,B,C 三个 Git 仓库做为依赖,并调用 functionA:
#include "A.hpp"
#include "B.hpp"
#include "C.hpp"
void functionD() {
functionA<ClassB, ClassC>();
}
编译时:函数替换
以 TypeScript 为例
我们可以在 A 中写这样的一个接口
class A {
public a() {
this.b();
this.c();
}
public b() {
throw new Error('not implemented');
}
public c() {
throw new Error('not implemented');
}
}
然后在 B 和 C 的 Git 仓库中“覆盖” ClassA 的实现
// 在同样的文件夹和文件名中定义
class A {
@override
public b() {
console.log('b');
}
}
// 在同样的文件夹和文件名中定义
class A {
@override
public c() {
console.log('c');
}
}
然后在 D 中组装,添加 A,B,C 三个 Git 仓库做为依赖:
{
"name": "@someOrg/D",
"version": "0.0.1",
"dependencies": {
"@someOrg/A": "0.0.1",
"@someOrg/B": "0.0.1",
"@someOrg/C": "0.0.1"
}
}
然后需要用编译工具,在编译 D 的时候,因为 B/C 中的 ClassA 与 A中的 ClassA 同文件夹且同文件名,替换 ClassA 中的函数。 这样就达到了和 C++ 模板类似的效果。
运行时:Vue 插槽
Vue 的插槽和 TypeScript 函数组合是类似的。
我们可以在 A 中写这样的一个接口
<!-- A.vue -->
<div class="container">
<header>
<slot name="header"></slot>
</header>
<footer>
<slot name="footer"></slot>
</footer>
</div>
然后在 B 和 C 中写两个组件
<!-- B.vue -->
<div>b</div>
<!-- C.vue -->
<div>c</div>
然后在 D 中组装,添加 A,B,C 三个 Git 仓库做为依赖,调用 A 组件:
<A>
<template #header>
<B/>
</template>
<template #footer>
<C/>
</template>
</A>
编译时:页面模板替换
和函数替换一样,也可以对页面模板做替换。
我们可以在 A 中写这样的一个接口
<!-- A.tm -->
<template #default>
<div class="container">
<header>
<B/>
</header>
<footer>
<C/>
</footer>
</div>
</template>
<template #B>
</template>
<template #C>
</template>
然后在 B 和 C 中覆盖 A.tm 页面模板中的子模板
<!-- 在同样的文件夹和文件名中定义 -->
<override #B>
<div>b</div>
</override>
<!-- 在同样的文件夹和文件名中定义 -->
<override #C>
<div>c</div>
</override>
然后在 D 中组装,添加 A,B,C 三个 Git 仓库做为依赖:
{
"name": "@someOrg/D",
"version": "0.0.1",
"dependencies": {
"@someOrg/A": "0.0.1",
"@someOrg/B": "0.0.1",
"@someOrg/C": "0.0.1"
}
}
然后需要用编译工具,在编译 D 的时候,因为 B/C 中的 A.tm 与 A中的 A.tm 同文件夹且同文件名,替换 A.tm 中的子模板。 这样就达到了和 C++ 模板类似的效果。
显式组合与隐式组合
显式组合需要在代码中定义新的函数或者类,由新定义的函数或者类来组装原有的东西。例如
const functionA = require('A');
const functionB = require('B');
const functionC = require('C');
function functionD() {
functionA(functionB, functionC);
}
优点是组装非常灵活,可以表达任意复杂的逻辑。缺点是组装非常灵活,导致实际运行时的装配关系很难阅读代码得知。
隐式组合是指:
{
"name": "@someOrg/D",
"version": "0.0.1",
"dependencies": {
"@someOrg/A": "0.0.1",
"@someOrg/B": "0.0.1",
"@someOrg/C": "0.0.1"
}
}
仅仅声明 Git 仓库之间的依赖关系。由编译工具,根据同文件夹,同文件名做函数和模板替换。这样的隐式组合缺点是依赖特殊编译工具链,好处是搞不出花样,仅仅实现了依赖倒置的目的,而不具有二次动态装配的能力(以及随之而来的理解成本)。