基于实践谈谈软件架构设计
What
软件架构是什么,我们可以把软件的价值分为两种
架构价值
功能价值
所谓的功能价值非常简单,这是最基础的,就是代表软件能做的功能,这个直接面向用户使用中的功能。
但是,架构价值就是另外一个纬度,关注的是软件的可维护性。架构好的软件,是方便维护和扩展的,否则反之。所以,架构其实是和开发者本身关系更大。
但是事实上,大部分人通常只关注实现功能,而不考虑架构的优化设计。
Why
通常显而易见的,功能和业务是我们的最终目标,那么架构重要吗?
我们可以把软件的生命周期作为 x 轴,以及开发功能所需要的时间作为 y 轴,那么我们可以一个规律,时间越长,开发一个功能所需要的时间是越久的。这里面,架构起到了决定性斜率的作用(如果完全不设计,那么就是指数增长)。所以,如果一个软件的生命周期很短,可以完全不考虑架构,但是如果要长期迭代,那么好的架构是未来可以迭代的必要条件。
软件 software 就是相对于 hardware 来说的。soft 说明了它的本质就是需要可变,容易变化,这是它的本质所在。
通常情况下,我们的开发需求的过程分为三个步骤
seek 找到在哪里改
coding 编码
verify 验证逻辑是否正正常,是否对其他有影响
这里面,只有其中第二步是和功能直接相关的,第一步和第三步都是架构相关的流程。所以,如果架构不好的代码,是很难找到在哪里修改的,也很难验证是否有问题,这会导致开发效率急剧下降,严重的情况甚至会出现修不完的 bug ,导致功能无法实现。
我在蚂蚁的时候和一个同事一起做一个系统,其中业务有一条文案,背后有七八个接口,十个状态,对应不同的文案。然后,测试的时候发现,总是会有 bug ,他测了一天,bug 却越来越多,就像是打地鼠,打完这个出现另外一个,打不完。后来测试来找我帮忙,我就和那个同事说了下,让他每个场景写一个用例,当用例写完了,功能就肯定没问题了。
另外,最近我在尝试使用 Cursor 给我写一些自己的个人项目,我尝试我完全不写代码,都让他给我写。我发现,复杂的项目开发,Cursor 必须遵循这样的步骤
设计拆分,拆成很小的功能模块
开发功能模块,然后让他自己测试
人工 review 一下,看看是否要重构,一般多改几轮 Cursor 写的代码就会偏离,这时候需要重构一下
重构完成,再测试验证
这样循环往复,测试用例可以说是 Cursor 最有效的帮手了,不然一个你不信任的,不那么靠谱的 ai 帮你写的代码,很容易就走偏了。
如何设计
说了很多,现在来说说如何设计的问题。我自己总结下来,首先我们需要有一个整体的设计体系,我个人总结大概是这样的
最底层是领域模型(数据结构)的设计
数据之上,是架构的一些基本原则(单一职责,依赖反转)和 测试用例
通常我们可以用这个体系来判断一个软件的设计是否合理。
领域模型
领域模型的设计是所有的基础,我们最常犯的错误就是直接使用外部依赖的数据结构。比如直接把数据库结构(或者来自后端接口的数据)当成自己的模型,或者比如在 schema-page 里面,schema 直接使用了 antd 的类型结构,这样会导致你没有一个可控的干净的模型。
另外,底层的数据结构设计不够好,可能会限制功能的是否可实现,而且这是不可改造的。我们来看一个真实的案例
1.x 的 schema-form 的最底层的数据是这样的
SchemaStore : schema 属性
SchemaStore 下面有一个或者多个 FormStore ,formStore 有 form 的 schema
FormStore 初始化的时候会遍历第一层结构,每个 field 都初始化一个 FieldStore,FieldStore 有 field 的 schema
这导致后面遇到的一些问题,比如插件实现基于 FieldStore ,但是第二层 (List 下面的 field) 就没有对应的 fieldStore 了,所以在 list 结构下插件都不生效了。多层冗余结构状态同步也是一个很大的问题,会导致维护和调试困难。另外就是不支持多级嵌套。当然,后面我们把这个重构了,FieldStore 设计去掉了。
状态机例子
我曾经做过一个功能,大概是这样的
分为三个步骤,最上面有一个步骤条,一开始是三个步骤
第一步,下面是一个 table ,可以选择某一条,选中以后进入1.5 步,也就是进度条有一半的前进
点击下一步,进入到第二步
在第二步,可以看到一个表单,这个表单选中以后,可以提交,或者也可以直接回退到第三步
一开始的实现,是三个变量来表示,一个 step,一个第一步是否选中,另外一个记录第二步选中的数据。然后,三个变量一起使用,在很多地方用到。后面产品说要加一个步骤,这时候会感觉很难受,当你感觉很难受的时候,一般就是设计出了问题。
做新的需求的时候,我就想着把这里很多状态改成一个对象来描述,最后大概这样
const stateMap: Record<STEPS, IStateMap> = {
[STEPS.INIT]: { next: STEPS.SELECT_MODEL, select: STEPS.SELECT_MODEL },
[STEPS.CONFIG_ACCESS_PREV]: { next: STEPS.CONFIG_ACCESS, select: STEPS.SELECT_MODEL },
[STEPS.SELECT_MODEL]: { next: STEPS.SELECT_MODEL_OK },
[STEPS.SELECT_MODEL_OK]: { prev: STEPS.SELECT_MODEL, next: STEPS.CONFIG_ACCESS },
[STEPS.CONFIG_ACCESS]: {
next: STEPS.ASSIGN_USER,
prev: STEPS.CONFIG_ACCESS_PREV,
cancel: STEPS.SELECT_MODEL_OK,
},
[STEPS.ASSIGN_USER]: { prev: STEPS.CONFIG_ACCESS },
};后来一想,原来这就是状态机,新增第四步骤,只需要新增一个 STEPS.ASSIGN_USER 的配置就可以了
大部分情况,我们倒是不需要考虑这么复杂的数据结构设计。但需要注意的是,不要过于依赖外部接口。
单一职责
单一职责说的是同样原因的变更应该放在一起。这个说法其实有点不好理解,我们通常简单的理解为就是一个函数做一个事情。但是,我们可以从反例来看,这样能够更加具体,通常违背单一职责的几种情况
一个函数做两种或者多种完全不同的事情
同样的数据或者逻辑,在不同的地方有各自不同的来源用法
最后一种比较少见,同一个功能,基于输入输出拆成不同的部分
一个函数做多个功能
比如 schema-form ,我实现了一个 computed 插件,一开始做的时候去是,开发者可以定义一个 computed 函数,form value 变化以后,这个函数会重新执行,得到新的 schema 结构。这个函数是纯函数,他只是返回一个 schema ,没有副作用,可以反复执行的。
后来大家提需求,说 form 变化的时候需要改 value 。当时也没仔细考虑,就在这个 computed 函数里面加了这个逻辑,后面我发现,这个 computed 就变得有点难以解释了。
正常情况下,这个函数是无副作用的纯函数,可以反复执行。
但是 value 不是,value 变化是会改 form 的值,这个是不可重复执行的。只能在 form value 有更新的时候才能执行,正常情况却不是如此,每次调用 schema 都会执行这个函数(包括初始化之类的场景)。
这就是典型的一个函数,做了两个不同的事情,有两个不同的分支,这时候就很难解释清楚了,包括写文档的时候我也觉得别扭。
还是那句话,当你觉得难受的时候,通常是有一些设计的问题所在了。
同样的逻辑分散在很多地方
还是 computed 这个场景,这个插件做的事情就是把 schema 给覆盖。所以前面一步就是有很多组件需要用到 schema ,然后我需要去拦截它,对它进行 merge 。
但在 1.X schema 中,不同的组件,schema 来源是不一样的,所以,这个 getComputed 函数也就分散在很多地方。比如我前面提到的,三层结构,有三个冗余的 schema ,然后在 list 多层嵌套,又是直接从 props 传下去的。
这里的逻辑非常分散,导致我一个一个找在哪些地方需要覆盖。
在我们的内部配套也有一个类似的场景,有一个 leads Management 平台,需要管理公司信息,这个公司的 schema 在四个地方用到,导致每次新增一个 field ,我需要找四个地方,另外加上 filter 搜素配置和数据同步逻辑,这里一共需要改6 - 7 个地方。就是一个简单的新增逻辑,我看代码看了一天,才找全所有的场景。
这种直接统一放一个地方配置就会简单很多,后面我做了这个重构,再往后新增字段只需要改一个地方就行了,这样轻松多了。
一个逻辑拆分好几个部分
这一种确实比较少见,但是在 schema-page 确实遇到了。在设计新的 reaction plugin 的时候,shaowei 提出了新的 API ,他的核心思想是数据驱动。但是在领域驱动设计模型下,这里的数据也就是依赖项,应该是最外层,而不是核心逻辑。但如果以依赖为核心,最终的 API 就有可能导致同一个 schema 的更新,需要拆分成几个不同的插件。
一个字段的 schema 如果依赖了多个数据来源,这时候每个依赖项可能需要但是实现了。
下面是对比代码示例,右边的第一个函数还是基本对等的,而且还支持 await ,整理来说更合理。但是第二个函数,就属于我都不知道怎么解释的逻辑了。第一个函数是 talentBinds.*.moduleId 变化的函数,第二个是外层 language 对应的函数,这个函数执行的时候,需要更新列表里面的所有 workflowId 数据。
<OKSchemaFormComputedPlugin
target={['talentBinds', 'workflowId']}
computed={(value, path, all) => {
const language = all?.language;
const moduleId = value?.moduleId;
const cacheKey = language && `${language}_${moduleId}`;
if (!pageStore.workflowByModuleData[cacheKey]) {
pageStore.fetchWorkflowByModule({ moduleId, language });
}
return {
props: {
options: pageStore.workflowByModuleData[cacheKey],
}
};
}}
/>const updateSchema = (language, moduleId) => {
const cacheKey = language && `${language}_${moduleId}`;
if (!pageStore.workflowByModuleData[cacheKey]) {
await pageStore.fetchWorkflowByModule({ moduleId, language });
}
return {
schema: {
...targetSchema,
props: {
...(targetSchema as ISchemaFormSelectFieldSchema).props,
options: pageStore.workflowByModuleData[cacheKey],
},
},
};
}
<OKSchemaFormReactionPlugin
dependencies={['talentBinds', '*', 'moduleId']}
target={['.', 'workflowId']}
updater={async ({ targetValue, values, targetSchema }) => {
const language = values?.language;
const moduleId = targetValue;
return updateSchema(language, moduleId)
}}
/>
<OKSchemaFormReactionPlugin
dependencies={['*', 'language']}
target={['talentBinds', '.', 'workflowId']}
updater={async ({ changedValue, targetValue, targetSchema }) => {
const language = changedValue;
const moduleIds = targetValue as string[];
const schemaList = Array.isArray(targetSchema)
? targetSchema
: [targetSchema];
const schemas = await Promise.all(
moduleIds.map((moduleId) => updateSchema(language, moduleId))
);
return {
schema: schemas,
};
}}
/>依赖反转
单一职责更关注的是组件的粒度问题,依赖反转则是核心关注依赖关系。这个概念听起来有点高端,让我们通过实际问题来看看,到底什么是依赖反转。
Caddy
我有一个字部署的服务器,放在我家里。然后它的结构大概这样的
最外层 dns 解析在 cloudflare ,它同时负责 dns 代理
然后请求转发到我家路由器的固定 ip
最终路由器转发到我的服务器上
在我的服务器上,通过 caddy 作为网关应用
Caddy 后面是一堆 docker 服务
Caddy 的配置文件很简单,比 ngnix 简单多了,大概如下
{
acme_dns cloudflare {$CF_API_TOKEN}
}
https://uptime.iling.fun:443 {
reverse_proxy uptime-kuma:3001
}每次新增一个应用,除了启动应用,我还需要改下这个配置文件,然后重启 caddy。这里就遇到一个问题,我们依赖方向通常的这样的
领域模型是最核心的,外层输入输出都应该依赖领域模型
不稳定的组件应该依赖稳定的组件
但是我这里就很麻烦,caddy 作为网关基本上是固定的,不需要变化的。但是应用层是不停变化的,经常要新增,或者我想删掉某个应用。
现在的依赖关系是 caddy 依赖应用服务,这就很难受。所谓的依赖反转,就是要把这个依赖关系给反过来。
具体实现就是这样的,我们引入一个 caddy-gen 的应用,它来统一管理配置,它来实现这个依赖反转。首先 caddy-gen 会监听所有的 docker 进程,然后从所有的 docker 进程中找有特定标签的镜像,自动生成新的 caddy 配置。
这样,所有的 docker 应用都需要配置一个 label ,比如 uptime.iling.fun 3001 ,表示这个 uptime 这个域名会直接转发到当前示服务的 3001 端口。
这样就很顺了,以后我随便新增应用,不再需要关心 caddy 的存在,这样就是非常方便扩招了。
Table plugin
最后,再看一个前端的例子,还是 schema-page 。我们有两种 ,schema-chart 的 Table 和 schema-form edit table 。所有 Table 都有插件,可以对比来看看
// Table.tsx
const { getPlugins } = usePluginManager();
const tablePlugins = getPlugins(ESchemaChartOtherType.TABLE);
const tableProps = useMemo(() => {
// Compose plugins
const composedPlugins = [defaultPlugn].concat(tablePlugins).
reduce((acc, func) => {
return { ...input, ...func(acc) };
}, input)
// Execute composed plugins
return { ...payload, ...composedPlugins(payload) };
}, [tablePlugins]);
// 注册插件
registerPlugin(ESchemaChartOtherType.TABLE, (payload) = {
return { ...payload, loading: true };
});// Table.tsx
const props this.schemaStore.plugins.getPluginProps(
EPluginName.TABLE_SUMMARY,
this.props.formPath,
this.schema?.uniqueKey,
);
return this.schemaStore.plugins.getPluginProps(
EPluginName.TABLE_SUMMARY,
this.props.formPath,
this.schema?.uniqueKey,
);
// 注册
schemaStore.plugins.registerProps(target, EPluginName.TABLE_SUMMARY, {
summaryColumns,
totalColIndex,
});左边是 chart 的实现,右边是 form 的实现,有没有发现区别。从依赖方向来说
左边 chart 的 table.tsx 是没有感知具体某个插件逻辑的,所有的插件都是注册一个函数,他们的约定是,拿到 props ,修改,然后返回
右边在 table.tsx 直接读取了插件的配置,实现了插件的功能逻辑,所以 table 是和插件混在一起的
很明显,左边是符合依赖反转的,右边则是错误的依赖示例。后面如果要删除新增插件,都需要改 core 的代码。
总结
架构设计看起来是挺复杂的事情,其实本质上还是我们如何组织代码结构的问题。领域驱动设计确实一个非常好的设计理论,可以帮我们正确的设计软件的组织结构。
最近我在尝试写一个小游戏,完全使用 Cursor ,前面尝试了三次都失败,各种策略,最终我首先让 Cursor 设计出领域模型。领域模型确定以后,后面的都是外部实现细节,可以一步一步实现,也不会出问题,因为,系统的核心设计是稳定的,外部的实现可以动态变化。通过这种方式,最终才实现了我的小游戏。