092023.6

前端业务建模的内涵

我们抛开前端的实现,去理解和整理业务建模的具体内容,我梳理出这些概念:业务实体、业务事件、业务逻辑、流程阶段、交互、视图。

业务实体

实体一般用名词表示。一项业务可能包含多个业务实体,一个业务实体可以理解为一个用于表达清晰内容的对象,一般是一个由多个字段组成的对象。业务实体的作用是定义实体包含哪些字段,以及字段本身的基本元数据,这些元数据包含但不限于:该字段的类型、该字段是否是多个值、该字段是否包含字段解释说明、该字段是否有某些特殊的标记、该字段是否一定要依赖另外的字段等等。这些基础信息被定义在实体中。

业务事件

事件一般用动词表示。业务事件是在业务实体的基础上的真实业务表达。实际上,我们在讲“业务”时,往往是指一个“业务事件”,例如我所在的投融行业,“投资”“融资”就是对应的事件,在投融行业,你需要去了解一次投资/融资行为,都会发生哪些事件,这些事件是由谁,在什么时间、地点发起,持续了多久时间,过程中都必须发生哪些事情。当然,对于业务事件这个概念本身,它不包含“过程中发生了哪些事”,因为这些事处于业务事件之外,只是与它有强烈关联罢了。在建模时,我们的系统中,往往是以围绕业务事件为中心进行的,特别是在前端,因为前端涉及用户的交互,而在界面上一次性呈现的,往往是这一业务事件的全部,而几乎不会以某个业务实体为单位进行独立呈现。这也就意味着,在前端,实际比在后端要进行更复杂的串联思考。

事件边界

如上所述,一个业务事件往往囊括多个业务实体,比如一次投资行为,要囊括投资方、被投方、投资团队(业务人员)、基金方(如果存在SPV),同时要囊括该次投资的交易信息、各类合同文件等等,而其中的每一个实体,往往右内涵自己更细的实体描述,例如被投方要包含被投的公司/主体、其管理层人员、上市信息、已有的投资人等等信息。不过,我们仍然要为一次投资事件划定边界,该事件包含了上述实体,但这一关系也可能仅仅是引用关系,而不是从属关系;另一方面,该事件还会有自己的流程,我们都知道投资流程非常复杂,设计的钱、人、机构都很多,但是,这些信息往往并不是该次投资的本体信息,它们是附带出来的,我们可以在项目里面加一个流程的引用,也可以在流程里加一个项目的引用,都是可以的,因此,这两者就有比较明确的边界,我们建模的时候不需要把流程的问题考虑在该事件内。

总而言之,“业务事件”虽然用动词表示,但是往往具有名词意义。比如,我现在要“做一次投资”,这里的“投资”本身虽然是动词,但在“做一次”的动词前提下,它就是名词。而名词的建模往往具备通用的方法论。

业务逻辑

业务逻辑本质上是业务规则,所谓规则,就是一条一条的前提与指令集合,这在本栏目的其他文章中有解释。所谓“前提与指令”,就是“当A的时候,应该B“,A是前提,B是指令。而指令在前端是非常难以固定化的,因为在前端,指令除了变更数据、状态,往往还要求UI层面作出响应,而后端在这一点上就轻松很多,常常只需要变更数据或状态即可。因此,在前端,这一部分不得不分开,虽然它们原本应该耦合在一起,但是从前端的特性出发,我们必须把它们原子化拆分,其中“前提”往往被集中管理,而“指令”则被分散,有的不设计UI层面,只涉及数据和状态,就可以和“前提”并且放置,而涉及UI层面的,只能在视图层中管理。而真正要执行该逻辑时,我们必须从多个地方取出“前提”和“指令”,然后进行运行。

另外,“前提”和“前提”之间往往还有一定的先后、嵌套顺序,但是常常“指令”又仅对单一“前提”进行响应,这也就意味着在管理上其实需要非常小心,即使做了非常强的封装,也必须考虑分散管理和集中使用之间的冲突所带来的不确定性。而大部分前端系统出现业务层面的bug,都是因为这个管理和使用的不当造成的。

我们要探索一种合理且高效的业务逻辑管理模式,在这种模式下,前提和前提之间的顺序关系可以很好维护,与此同时,前提和指令具有绑定关系,有这样的机制的话,当逻辑开始运行,它们就必须按照特定的顺序,以及在特定的节点上执行特定指令,如此运行,这一就可以避免很多问题。

流程阶段

流程往往是针对事件的,可以说它是事件的衍生品,当一个业务(事件)发生时,如果它没有特殊的需要,就不需要流程,它仅仅成为一个事件的记录。但是,如果这一业务需要不同层面的人在不同的情况下参与进来,最为常见的参与方式就是审批,当然还有补充信息、修订错误、线下处理某些事情后记录到线上等等。流程分阶段,人员在这个事件中的出场和离场也可能随着阶段的迁跃而发生。同时,非常重要的一点是,事件中所包含的实体的字段,在不同阶段可能会附加对应的业务逻辑。例如,在阶段1时字段a不需要填写,但是到了阶段2就必须填写。

狭义的流程往往只发生在事件出现之后,但是广义的流程从事件出现之前就开始了。比如我们现在准备开始投资一家公司了,那么我们其实在开始之前就已经做了非常多准备工作,例如收集该公司的各种信息,与该公司的一些高层进行了接触等等。但是这些准备阶段的工作记录,常常因为我们不确定是不是准备作出投资计划,而不知道把这些信息放在哪里,也就是“在事件开始之前”,这些信息已经有了,但是因为“事件还没有开始”,所以,这些信息放在哪里呢?不管放在哪里,一旦我们准备开始投资这家公司,在系统中去创建这次投资事件时,这些信息就会马上拉取出来,作为该事件的一些信息。也就是说,对于投资事件的流程而言,在事件开始之前就已经存在了,只是它是概念上的,而非真正存在。

基于“广义流程”的理解,我们的事件业务逻辑的管理,就可以被关联到流程的阶段上,因为这个事件从还未开始到事件结束,都处在流程中,而大部分逻辑都可以被确定在某个流程阶段。(当然,有些逻辑是很底层的,应该直接在实体或事件模型中写死,例如某一个字段具有特定的业务性质,它的值一定是另外两个字段计算而来,这种逻辑就不属于流程上的逻辑,而是底层逻辑。)

交互

交互是真正意义上的“动词”,所有的文档中有关交互的描述都是“要干什么”“应该怎样”“必须如何”,例如“字段A大于10时,弹出提示语xxxx“,其中”弹出提示语“就是一个交互行为。而这一整段话就是一个业务逻辑,但是注意,它是一个“业务的交互逻辑”,因为它是基于业务的某个前提,执行交互的某个指令。

在前端,处理交互其实是很麻烦的,因为它一半具有视图层面的意义,一半具有非视图层面(操作数据)的意义。因此,我们要想办法把交互从中解放出来。我们需要建立交互模型,它一半提供给视图层,让视图去调用它,作为视图层的响应,另一半它要读取和操作数据,使得业务发生演进。

另外,下面我们会讲到,视图本质上是抽象的状态的具体表现,在交互模型中,我们可以提前定义好作为视图抽象的状态,并且在面对某个与视图有关的交互指令时,操作其状态,从而达到与视图层完全没关系的效果。

视图

视图是前端最熟悉的部分,也是业务系统最无关紧要的部分,因为对于非常多的业务系统而言,其核心价值在于数据,即使没有前端的界面,只要你有手段让我准确获得数据,我也是可以接受的。当然,如果没有前端的界面,数据的创建、补充、修订,审批的进行,文件的上传,都是非常麻烦的,所以,不能因为它对于业务无关紧要就直接否定它的存在。

视图的本质是抽象的状态的具体表现,也就是著名的公式ui=f(state),在业务系统中,我们需要对业务数据状态化,使“业务的数据+界面的状态”成为一个大状态,交给视图,视图拿到这个大状态之后,按照不同端的特征进行渲染,提供人机交互的入口,调用交互模型的方法,响应新的大状态的变更。

在很多情况下,视图具有相似性,对视图进行建模,可以有效的解决此类复用问题,例如在PC上有一个罗列字段的列表,在APP上也有一个同样的列表,只不过它们的长相不同,但是它们都是一个列表,有label和value两个部分,value部分还需要进行一些格式化处理,以及一些操作上的按钮。这些相同的部分,我们可以在模型(用抽象类更好)中撰写,再在两端各自实现界面的呈现。

00:10:31 已有0条回复

DDD理念在C端产品前端开发中的应用初探

我在以往的文章中讨论前端实施基于DDD理念的应用架构,往往是立足于复杂的业务系统(往往是B端应用),我认为只有围绕一个处理复杂业务为中心而搭建的系统,才需要从DDD的理念去思考和实践,但在最近,(基于我的切身体会)我发现这一想法是错误的,DDD不仅复杂的B端业务系统的有用工具,而且在C端常规的业务应用中,也具备指导作用

之前我之所以强调只有B端业务需要DDD,是因为我觉得DDD本身所带来的复杂性很强,需要沉淀的东西很多,对于开发而言具有阻碍性,会拖慢开发进度,特别是在追求快速迭代的C端会拖慢进度。同时,我也认为C端应用不存在“业务”的概念,也就无所谓用DDD去解决业务核心复杂性。但是,当我现在开始参与C端应用开发时,我发现,即使是C端应用,它也是有业务的。当一个应用是以用户完成某种目标,需要在多方数据中进行博弈时,就开始拥有一定的业务属性。所以,现在来看,除了类似微博、新闻、短视频等这类纯粹娱乐的应用之外,其他大部分应用都具备业务性质,大到类似淘宝一类的电商平台,小到政府的一个办事小程序,甚至一个乘车码,背后都有多方数据在博弈,都具备业务,甚至一旦这种博弈的强度提升(例如加入风控相关的逻辑),其业务复杂度就会呈指数级增长。

因此,我现在改变了我的想法,我认为,DDD作为一种指导架构设计、技术实现、设计研发的理念,无论在B端C端还是哪里,只要系统具备业务性质,都具备指导意义。

1 应用架构设计

在其他文章中我已经聊了很多有关基于DDD来设计应用架构的内容,这里就不再赘述。我现在想要讲的是一些更微观的东西。

C端应用是否应该在一开始,就进行复杂的架构设计?

我认为需要。C端应用在早期一定是追求快速迭代的,因此会积累非常多的债务。当债务积累到一定程度时,一定会阻碍开发迭代的效率,因此,应该重构。不过,按照《重构》的讲法,重构应该持续,时时刻刻在重构,但是这种只存在理想情况下。我们这里所指的重构,一定是在一个阶段,对整个项目代码进行刮骨疗伤式的全部重来。但是,我们会遇到一个非常巨大的问题,就是原有的业务逻辑可能在重写时,被疏忽而遗漏掉。而前端用来对抗这种不确定性的武器不多。

一种方案是老代码完全不要动,甚至继续迭代,虽然痛苦,但是起码能保证业务逻辑没有被破坏。在将来有机会,再利用新架构,对这块业务进行推倒重来的重构,通过灰度、AB等方式逐步迁移。手段有多种,例如微前端、MPA(多页应用,不同URL跳转)、在新架构下写一个可支持原始代码运行的沙盒工具……

另一种则是直接全部重写,且不必去考虑原始实现,翻出产品文档,按照文档的逻辑重新实现,像写一个新功能一样。而这样做,意味着我们可以在线上跑两套代码,相互验证,也是不错的选择。

如果说B端系统像一座笨重的基地,通过不断的在原来的基础上叠加呈现堆积如山的模式来实现系统的扩张,那么C端应用就像一张星链网,通过不断增加数量以铺张出更大的面积的模式来实现业务的扩张,两者的一个是纵向发展一个是横向发展,看上去,纵向发展的B端系统更需要架构,来实现部分和整体的融洽,而横向发展的C端应用似乎可以分布式存在,甚至可以各自独立。但当我们回到开发层面,我们就会发现,这种分布式扩张的模式,更难把握架构设计,因为它们看上去可以独立,但是它们大部分又要共通,如果不通,当某些部分需要升级时,不可能到所有节点上去一个一个升级。

因此,把薄薄的星链,按照厚厚的基地来设计,才能在源头解决将来的很多问题。

有人会说,你前面不是讲C端要快速迭代,前期要快吗?是,但并不意味着要快,就不能按DDD来设计,也不是说按DDD来实现,就一定是慢。在架构设计中,有些东西我们必须去做,而如何做好这些,却各有各的不同。

例如,我们必然会写数据请求的部分,毕竟在后端看来,“前端不过就是渲染界面而已“,没有数据来源,实在是巧妇难为无米之炊。可以说,如今的前端应用,99.9%都会有数据请求这个部分,剩下的0.1%可能是古老的php或jsp输出的网站。那么,如何去设计数据请求和数据源管理,就是八仙过海各显神通了。

再例如,通用的底层UI组件库,每个项目组都有一套,那么如何去设计这套组件库,就各执一词,各自把牛B吹上天,最后在业务中用时,仍然是不断的改来改去。

我们需要遵循一种理念,让这些共通的东西在项目设计之初就稳定下来,不是最好的才是最好的,合适的才是最好的。一个团队一定有自己的特殊性,在团队内能形成一种无形的设计模式,就必须要去遵循它,即使在别人看来很奇怪。

我们要找到一种感觉,即在项目的架构层面,可以去避免将来可能出现的问题。如果去避免呢?很简单,就是无论将来的技术发生什么变化,这些复杂的代码都可以被再次使用。没错,只要我们的代码设计为脱离具体环境,是一种纯粹的业务、逻辑的时候,就可以再将来被重复使用,即使可能将来有更好的实现方式,但是起码用上这些老代码不需要什么成本就可以继续使用它们的逻辑。

例如数据请求,当我们从vue2升级到vue3时,这部分代码不需要被修改就可以直接使用。例如UI组件库,当我们需要web和小程序拥有相同样式时,不需要修改即可使用需要的组件。一旦我们的架构设计为这种脱离了具体环境的设计,就可以在将来立于不败之地。

2 从细节去重新审视自己

让我们现在来想象一个场景,需求里是这么描述的:“当库存不足时,界面上需要展示库存不足的提示“。很常见的一个描述对吧。现在问题来了,这里”展示提示“我们是怎么实现的呢?80%的人是不是如下:

<StockTips :isShow="isStockTipsShow"></StockTips>

我们用一个isStockTipsShow状态来控制提示的是否展示,然后就是对isStockTipsShow进行定义或变更:

isStockTipsShow.value = stock === 0;

或者:

get isStockTipsShow() {
  return this.data.stock === 0;
}

总而言之,我们在不断考虑用于控制“展示不展示”的实现。但是,你有没有想过一个问题,当我们将来需要将“展示提示“的条件进行修改,例如”当库存不足,且用户参与过某活动,且系统中存在某种情况“,那么我们如何为其提供这种控制逻辑呢?

如果此时,我们换一种视角,我们创建一个属性,名为 isStockEmpty 而且将它从vue组件的状态中脱离出来,和vue没有任何关系。于此同时,我们甚至把“提示”这个看上去是UI层面的东西,也变成了一种抽象的符号,与vue无关,例如这样:

subscribe(isStockEmpty, StockEmptyTips) 

而这个代码,脱离了任何环境,都运行正常。至于在UI上是不是要展示这个提示,则完全是在UI层去实现它。

呃……isStockTipsShow 和 isStockEmpty 有区别吗?有!本质的区别。

isStockTipsShow 是在想我这个界面如何去控制,而 isStockEmpty 是在想从业务上讲我什么情况下代表没有库存了。当时间经历了很久,我们需要将vue2迁移到vue3使用时,就会发现,前一种思维想要改动这个逻辑是万万不敢,而后一种思维则是对组件随便改,但可复用的业务逻辑绝对不改。我们通过写vue代码去适应业务,而非写业务代码去适配vue组件。

你看,一个简简单单的命名,从业务的角度去命名,和从UI组件的角度去命名,就会对我们将来带来可怕的差别。其本质,是思维上的差异。

3 模块

将有关业务的琐琐碎碎集中在一起,就是一个业务模块,但很让人崩溃的是,模块往往没有单一的出口,你不可能从单一出口使用这个业务,而必须在使用这个模块的外围应用(可能是另外一个模块)选择不同的出口进行使用,这里最复杂的,就是我们需要对这些出口进行编排。而往往,业务模块会被多个外围应用使用,否则就没有必要作为模块存在。不同的外围应用调用相通出口时,则会让应用中编排业务的调用变得混乱。而且还会遇到嵌套中的模块,同时依赖同一模块,更加复杂的编排和依赖关系。

而一种工作流(workflow)的模式,则可以解决这种编排的难以琢磨问题。

业务数据和状态的变更,总是存在于某种业务的流转之中,而这个流转模型,可以确保业务流转在被不同模块调用时保持一致性。工作流模式在前端如何去实践我自己也在探索中,我只是认为,能够确保被不同地方调用的同时,还能确保业务的一致性,是一种很酷的模式。

4 代码量

我最近发现,tree shaking并不好用,控制代码量,使得线上代码体积减少,其实是很难的一件事。但是,……但是这和DDD有什么关系?

当我们采取一种架构策略时,在具体代码实现上,常常会因为架构本质的区别而导致最终的代码量差距巨大。架构的理想程度,常常和代码体积成正比,因为,要实现某种理想的架构,代码分散、粘合、为了实现模式的故意使然、符合架构的文件目录结构设计等等,都会增加最终的代码量。

因此,我们不应该追求最理想化,我们必须向现实低头,过渡理想化,并不是最好,相反,过渡一定带来代价。在实现DDD架构时,我们应该保持C端应用本身的轻量化特征,取精华的部分。

00:08:58 已有0条回复
282023.5

让vscode支持按package.json exports中规定的路径查找引入

新版本的nodejs支持在package.json中使用exports字段导出特定的路径,具体的使用方法就不说了,总之对于webpack而言,要开启resolve.conditionNames,而对于vscode而言,则也需要处理才能支持按exports指定的路径查找。

vscode的提示主要还是依赖typescirpt的设定,但对于我们不需要引入typescript的项目,可以用jsconfig.json替代tsconfig.json来使用。在项目目录下创建jsconfig.json,并写入:

{
    "compilerOptions": {
        "module": "nodenext"
    }
}

同时,为了让vscode认定当前的环境需要使用import字段而非required字段,需要在当前项目的package.json中增加:

"type": "module",

这样就能让vscode支持在import时,识别到package.json中的exports配置。

02:14:15 已有0条回复
252023.5

前端使用流(Stream)

062023.5

像AI一样学习和思考

在过去几十年里,我们行业无数人都在奉行一条理想,即“让机器像人一样思考”。但是今年,2023年,我想情况已经发生变化了,我们可以反过来奉行另外一条方法论,即“像AI一样学习和思考”。

ChatGPT等LLM应用的出现,让我们突然发现AI已经跨越了“智能”的门槛,上个月发布的GPT-4将拥有比之前任何一代AI都更智能的通过图灵测试的能力,可以说,在这个世界上,大部分人类无法和GPT-4的应用进行智力的对抗,可以说它对任何一个人类个体或群体形成了无限碾压,当然,人类目前还有一个法宝,就是“创意”,GPT-4仍然还是机器,它的主动创意仍无法和甚至当个人类个体抗衡。不过,随着社会上抵制的声音越来越大,我却从另外一个角度去思考,人类的智能未来有没有可能让渡给AI,并由此出现物理层面的社会共治。

紧接着上面的思路,我们先需要明确,人类作为地球上最智能的存在,拥有计算能力超强的大脑,因而非常强大。但是,我们也可以看到,历史是螺旋式上升,人类大脑的容量以及发达程度,是在历史长河中随着时间不断成长的,而不是物理上不变的。也就是说,即使原始人社会拥有现代社会完全相同的知识,也无法做到当代社会的智能,因为他们的大脑在物理上不如当代人类。人类的大脑在智能方面,核心功能是处理信息,但被处理的信息并不一定存储于大脑内部,甚至80%以上的信息来自外部,而且大脑并不持久化存储信息,它和CPU缓存是一样的,持久化信息必须依赖于其他物体。人类的知识是分布式存储的,人类社会的计算也是分布式的,能够形成人类社会,本质上是这种分布式计算的调度具有持续性,当这种持续性被破坏,这一文明就会面临灭亡。人类为了维护这一持续性,最好的办法是通过降低大脑的智能计算消耗来达到持久的演进。而AI从另外一个侧面,可以承接这一职责。在未来社会,智能方面的职责交由AI,人类保留创意,同时,社会权利让渡一部分给AI,这是必然的情况。当然,对于不少当代人而言,对这一预测会感到恐惧,因为他们会认为AI可以利用这部分权力对自己进行剥削。但我认为,这种让渡的目的就像当代社会法律中的权利和义务一样,是一种依存关系,如果不让渡权利,则无法获得对应的利益。

回到主题,AI已经具备了基础的思考能力。

在LLM迭代过程中,人们发现它涌现了多项智能。所谓“涌现”是指在人们设计之外,AI自己出现了一些我们未曾想到的能力,这证明我们已经完全不知道AI内部是怎么运行的了。ChatGPT涌现的能力主要有三项:知识积累、上下文学习(ICL)、思维链(CoT)。知识积累的结果就是AI具备基本的常识,能对和人类交互过程中遇到的信息进行甄别,理解人类表达的内容,成为脱离肉体的人类可交流对象。上下文学习的结果是AI可以在具体语境中自学习,按照语境下的设计完成任务,最核心的点在于AI具备了举一反三的能力,例如它的知识储备只有A1,但是在举一反三的加持下,它可以给出A2, A3...等等。思维链即预示AI具备了推理能力,我看到网上有一个故事:

我在某地向南走了1公里,再向东走了1公里,再向北走了1公里,回到了原地,此时我看到了一只熊,这只熊是什么颜色?

我看到这个故事的时候,一脸懵逼,走了一走,跟熊的颜色有什么关联性?我一度怀疑,这是无聊的人为了捉弄ChatGPT故意设计的错逻辑,但是没想到ChatGPT给出了答案:白色,并且做出了解释,“向南走了1公里,再向东走了1公里,再向北走了1公里,回到了原地”说明你此时处于北极点,而在北极看到的熊大概率是北极熊。

好,那么现在让我们换一个场景,我们现在把自己的身份换成物理学家,把一个我们未解的问题作为故事告诉AI,就像我们作为一个知识储备不足或者脑子没反应过来的普通人给它讲上面那个走来走去看到熊的故事一样,或许,作为物理学家的你,此刻就可能得到这个未解问题的答案。我们作为人类,在思维链上有些时候可能不及AI。AI并不是物理学家,也没有研究能力,但它能利用已有的知识储备和思维链能力,预测出一种可能的结果,当然,这个预测的准确性会根据不同场景而不同。

前段时间,大学封杀了ChatGPT,教育界对此感到害怕,当下,我们对教育的认知无外乎“传道授业解惑”,而以考试分数为导向的教育,可能更集中的把“知识传授”作为核心目标,至于知识传授外,学生在思想政治和品德上的修为全靠自己。AI时代的到来可能打破这一教育习惯。

我们从ChatGPT身上可以看到,它自身的知识储备并不多,40G的模型容量除了知识储备外,还要容纳各种算法,很难说它已经具备了比一个普通人更多的知识。但是,我们会认为它在知识上的全面性比普通人大得多得多。我们可以打一个不恰当的比方,如果它储存了1k的知识,但是却能使用1M的知识。而这里的储存,不是简单的“压缩”,1M无论如何不可能压缩到1k。对于LLM而言,它储存的知识是元知识、常识,而其他可被使用的知识是基于元知识的推理结论。这就像我们只储备了三大定律,却能使用所有物理学理论一样。因为所有的理论都是基于最原始的定律发展推理而来,因此,掌握了最原始的定律,就掌握了全部理论。

我们人类终其一生,是无法掌握全部知识,甚至是自己所处行业的全部知识。知识这种东西,它是动态的,而人类大脑是缓存,长期不用就会忘记,如果靠大脑来存储知识,那么终其一生都无法存储其中的一部分,会非常痛苦。但如果我们按照AI的方式存储元知识,同时掌握了行业知识的推理方法,那么就能架构出一个壳,这个壳在未运行时空空如也,而运行起来之后,就能基于元知识和未被遗忘的知识快速被充满。这样,我们自己也能使用1k的容量,具备1M的解决问题的能力。

未来的教育,必然会发生翻天覆地的变化。人类学习本身,不是“学习知识”,而是“学习方法”。而“方法”就是“模型”,是那个运行起来就可以被快速填满的壳。况且,即使我们想偷懒,也可以直接问ChatGPT,我们以往认为,只有自己掌握的知识才是真正的知识,并且杜绝使用辅助工具作为自己的知识。但是未来,类似ChatGPT一类的工具,将会成为人类大脑的延伸,你可以说它不属于人体,但是它会是人体的体外器官,而这个,就像如今的手机一样。

一旦在我们的体外拥有一个可以存储知识和提供运算的器官,那么,我们自己的大脑就可以被解放,对于人,特别是年轻人而言,不需要学习太多的知识,只需要学习底层知识和基础逻辑,让GPT类的体外器官帮我们解决需要知识的地方,而学习,应该去掌握各种“方法”。可能不少人不理解,举个例子,你知道“思想的方法”吗?这个事情我是很感兴趣,先贤是怎样思想的,我们自己应该怎样思想?如果掌握了真正的“思想的方法”,那么人类将拥有无限潜力。但是由于这个领域还比较年轻(2000多年),所以目前还有很多“思想的方法”论,即“哲学”。掌握了“思想的方法”,那么我们就会真正的“思想”,做一些人类更有意义的事情,而不是在寻找生存的方法。

现实一点的讲,未来10年,中国教育需要有较大的改革,降低知识传授的比重,提升方法论的教学,而且重点是非形式主义的方法论教学。

20:22:38 已有0条回复
242023.4

Rich text to image

232023.4

通向AGI之路:大型语言模型(LLM)技术精要

092023.4

从贝叶斯概率问题想开

如果一定的条件可以改变一个结果(事件发生)的概率,那么我们需要问一个问题:在事实不变的情况下,为何结果不同?这里我的本质是在问:结果是否为一个事实?很显然,答案是“不是”。这让我突然很容易理解很多以前无法理解的问题。例如双缝干涉实验,在这个实验中“事实”是什么?“结果”是什么?我们观察到的,最终是“结果”,而非“事实”。同理,在人类社会中,“事实”总是不变的,而“结果”是概率的坍缩。而概率的本质,就是不确定性,就是熵。我们人类社会,包括我们作为个体的人,都是在对抗这种不确定性。如今大火的AI,技术本质上,也是概率。可以说,在我想明白这个问题之后的人生中,几乎所有的与人类相关的问题的本质,都是概率问题。或许也正是因为概率的存在,我们相信平行宇宙的存在。

不过回到本质上,概率不能替代事实,概率会影响结果,而通过对抗不确定性,可以控制概率(或者不叫控制,而是通过阶段性结果让让不确定性发生坍缩),让原本未知的概率暴露在我们的已知中,从而失去其中部分概率的活性,让我们可以选择不同的条件,进而得到对自己更好的结果。而控制概率的方法(消除不确定性的方法)最主要的途径就是:获取有用信息!所以,在另一个层面上,我们这个宇宙的一切,由“物质(含能量)”和“信息”组成,其中“物质”是事实,而“信息”是使概率坍缩的要素,最终得到一个“结果”,这个“结果”才构成我们的现有宇宙,无论在哪一个维度上。

从我们社会的人个体的角度出发,我们要掌握从海量信息中,找出“有用的信息”,使我们未知的概率进行坍缩,基于坍缩后的概率进行重新选择,进而获得我们更有益的方向发展。

我在思考一个问题:“抛硬币游戏中,获得硬币正面和反面的结果,概率各自为50%”,在这个假设下,我们通过事实进行检验,抛1万次,事实正好符合这一假设。既然基于概率的假设能够得到相对准确的结果,那么是否“事实决定了概率”,且“概率是一定的(即条件不变的情况下,结果是一定的)”?很显然,没有任何证据证明“事实决定了概率”,也无证据证明“概率是一定的”。“事实”是固定的,不变的,而“概率”是被赋予的,被谁赋予,我们并不知道。“概率”是否是“一定的”,这我更无法进行辩论,但从客观观察来看,它确实是固定的。我们平日里讲“本来胜出的概率是50%,但是现在发生了XX情况,现在胜出的概率是90%”,并非指单一概率,而是条件概率,对于50%和90%而言,都是在特定条件下,但是可以肯定的是,这两个概率,是固定的,你不会说“我胜出的概率既是50%,也是30%”。也就是说,概率是先验的,是不管统计是否存在都一定存在的客观,硬币不会因为不进行统计就不会抛1万次有大概5000次正面朝上。

最后,回到最开始,一个最严重的问题:基于概率的结果所带来的影响,是否是“事实”?这个问题我是在问,如果假设我们前一刻的因,所带来的果又会成为下一个果的因,那么是否意味着宿命论和轮回?这个问题如果能回答,那么就能回答平行宇宙以及等等相关的问题。我们来举一个例子,我们买彩票,是否在我们选定了彩票号码的那一刻,我们是否中奖的结果就已经决定了?再来一点更猛的,是否一开始,所有参与这次开奖的人是否中奖的结果,都在同一时刻被决定了。(因为我们在买完号码之后,不能更改号码,只能等待开奖。)按照我们今天的这个假设,结果并不是一开始就决定的,而是在概率的流动过程中,渐渐坍缩出来的。对与买彩票是否中奖这件事,它因为我们购买彩票的动作发生在彩票开奖之前,所以,我们是无法根据概率的坍缩调整其条件的,因此,无法基于条件选择而影响结果。

概率复杂度是由结果的数量来决定的,结果的数量(可能性)越多,得到某一结果的概率越小(事件发生的次数与总次数的比值)。但是,概率的大小并不是由其数量决定,而是由什么决定呢?这里我不得而知。虽然从从概率学的角度,我们可以计算概率,或者通过实验统计获得概率,但是为什么其结果如此,并没有证据。这里,我的意思是,概率大小是客观的,不随我们的计算和实验发生变化,因此,不是由后验的理论和实践决定的。在宇宙的尺度,是由哪一只看不见的手决定了概率的大小,以我们目前的技术是无法验证的。

还有一个事情就是蝴蝶效应,我们现在做一个思想实验,假设,现在事件A有A1, A2, A3三个结果,事件A发生的时候,恰恰B事件也发生了,我们此刻不知道B事件是否与A事件有关联,于是我们开始反复做实验,并得到事件B的概率:

P(B)=P(A1)P(B|A1) + P(A2)P(B|A2) + P(A3)P(B|A3)

是否意味着,A和B一定存在某种概率上的联系,如果是“一定”的,那么是否意味着隔壁家的猫走丢了,和我今晚吃了红烧肉之间一定存在某种联系?如果这种联系,被我们以某种技术提前知悉,是否意味着我们可以通过概率的手段,实现很多看上去不可能做到的事。

最后,作为普通人,作为在这个社会中生存的个体,我们要尝试利用概率,发现概率与结果的规律,并克服人类本能里面的直觉,以获得超越普通常识和人伦约束的顺应自然。

总结一下,本文涉及的一些点:

  • 客观世界=物质(含能量)+信息
  • 事实,即“客观世界”,是特定的,客观的,唯一的
  • 因为时间的存在,客观世界存在状态,状态是流动的,发散的,持续演进的
  • 在这一流动中取一个客观世界状态的截面,就是“结果”
  • 同一事实对应不同结果,结果是不唯一的,多态的,但其本质是事实的表象
  • 结果是由概率坍缩而成的
  • 概率的本质是不确定性、可能性,即“熵”
  • 概率是客观的,先验的,不以是否统计而存在
  • 概率大小的决定因素目前未知,从宇宙尺度上存在某种规定概率大小的力量
  • 信息是消除不确定性的唯一要素,且只有有用信息能达到这一目的
  • 虽然单一截面上的概率是确定的,先验的,不以是否统计而存在
  • 但流动过程中,概率会不断叠加,因此,由于时间的存在,概率在流动中整体表现为动态的,某一结果并不是概率的简单累积
  • 存在一种假设,某一“果”是另一“果”的“因”,即“结果n=结果n-1*概率”
  • 由于信息能消除不确定性,因此在“结果n-1”上通过获取有用信息进行积极选择,可以使“结果n”的可预见性更清晰
  • 人类无法改变事实和概率本身,但可以通过选择来获得不同结果,通过这种方式来叠加概率,以达到拥有符合自身预期的客观世界流动方向的目的
  • 单一个体和社会群体之间存在某种联系,以使得不同单一个体的选择趋势客观世界流向趋于一致
  • 单一个体要掌握获取有用信息的可靠渠道来让自己消除不确定性
  • “通过有用信息消除不确定性并进行选择”的本质是利用条件概率,即找出“基于事件A的结果An发生事件B的概率”的合理n
  • 上述这种行为的本质,是一种利用概率的技术
20:15:13 已有0条回复
222023.3

asciinema 录制命令行工具

162023.2

跨界面数据同步更新:基于抽象数据源的前端数据管理模式

在一个应用中,同一个数据源被用在不同场景下,会被反复请求,数据更新后不太容易同步更新关联数据源,我们通过抽象数据源,提出了一套可充分管理数据的系统化方案。

前端开发中,从后端提供的接口请求数据和提交数据是非常基础的工作,但在我们很多业务中,我们会发现跨界面的数据同步更新成为非常棘手的问题。我认为,由于很大一部分前端对数据管理的方式非常粗糙,给后续很多开发工作和产品体验都带来了问题,要解决这一问题,我们需要从更深的层面去思考“前端数据管理”的设计问题。本文将结合我们腾讯投资项目组的实战经验,聊一聊我对这个问题的思考和实现方式。

背景

前端开发逐渐成熟,但于此同时也存在诸多问题。具体到本文的场景下,成熟是指我们的开发模块化、逻辑分离化,有很好的分层趋势,而诸多问题是指由于前端编程本身的一些局限以及生态的不成熟,在分层趋势下无法优秀的处理跨层级的通信传递问题。

我们以tapd的一个例子为例来看看。当我们在任务列表中更新了某个单的状态时,可以通过一个快捷弹窗进行更新,更新后新状态会被同步到列表中,这个体验非常好:

 

但是,当我们打开单的详情,在详情界面更新状态时,在点击关闭之后,状态却没有同步到列表,这个体验就不那么如人意:

 

当然,对于tapd团队来讲,他们可以有办法解决这个问题,甚至这个情况可能是他们的一个bug,漏掉了一段代码。但是,我们不针对tapd的这个情况,我们要思考的是,当我们在列表和详情两个页面进行操作时,如何在这两个界面之间同步数据的更新呢?

我们经常在一个页面,甚至跨层级的组件之间有类似的操作。在一个页面的某个角落里面进行了某个编辑,提交之后,要同步更新页面大老远的另外一个角落的数据,因为它们是关联的,可能其中一个是依赖另外一个进行计算得到的数据,当被依赖的数据发送变化时,依赖者应该及时更新,否则界面上就会出现数据不一致的问题,是明显的业务错误。

但是我们在开发时,往往这两个地方是分两个组件开发的,而且很有可能这两个组件之间跨了十万八千里的层级,想要轻易通信没那么容易。面对这样的场景,我们应该如何去思考,如何去找到一种合理的方式呢?

备选方案

愚公移山法

例如上面的两个组件我们称为A和B,其中B组件内可能发生更新,更新后需要同步更新A组件。我们通过在A上暴露出一个方法,通过组件网络,层层传递,把这个方法传入到B中,当B完成更新后,调用这个方法,这个方法会帮助A重新请求数据,并完成界面更新。

全局状态法

我们建立了一个全局状态,该状态同时被A和B使用,当B完成更新后,我们重新请求数据,把新的数据写入到全局状态,引起两个组件的同时更新。但是有一个问题,我们是否需要基于不同的业务对象ID存储无数个类似的全局状态?

事件通知法

使用类似EventBus一类的事件管理器,在B完成更新后,广播一个事件,A监听该事件,在监听回调中重新请求数据并更新界面。问题在于,我们怎么确保庞大的系统中,事件名是唯一的?

问题的本质

我们经常讲,界面是状态反映。在这中间,我们没有提到数据是什么。我们从后端拉取的数据是什么呢?是状态?是其他?这是问题其一。问题之二是,我们现有的编程技巧中,把数据请求回来作为渲染界面的依据,这种处理方式直接把数据的来源与界面进行了绑定,以至于对数据的过度依赖致使UI编程无法以纯粹的方式表达界面本身的呈现。另一方面,数据源和界面的点对点关系,忽略了数据本身的意义,破坏了数据本身的联动性,使得这种联动必须依赖于UI编程中的某种触发,而非其自己实现闭合回路。数据本身的生命形态被打破,UI编程又被数据锁死,这是导致本文所指问题的关键根源。

一种数据源管理服务层设计

万事不决,分离一层。

我认为,在我们纯粹的UI编程和数据之间缺少一层隔离,所以界面和数据源之间是直接点对点绑定的,就像电话两头一样,只能两个人参与。而我们所面临的问题需要我们像水管一样,水源来了,你可以洗手盆出水,也可以花洒出水,都是用水,但用法不同,是一对多分发式参与。

在数据源和具体界面之间,并非一对一的关系,而且很多情况下,一个界面不一定只依赖一个数据源。因此,我们应该可以有这样一种形态:界面只管取数据来用,数据从哪里来我并不关心;于此同时,数据变了,我界面跟着变。通过这种隔离,让数据本身的生命由数据层自己掌控,让UI界面编程不在乎后端接口的绑架,从而做到两端的分离。

我们举个例子来感受一下:

我现在有了一个数据,它的格式为 { name: string, count: number },那么在任何界面中,我都可以将它取出来用,至于这个数据从哪里来,此刻我们并不关心。我们的组件需要用到这个数据,因此,我们把它读取出来。当这个数据发生变化(比如提交了更新,或者websocket推送了新数据)的时候,我们的界面随之变化。我们的组件只是像某个东西索取了这份数据,并且基于typescript,我们知道这份数据的格式和类型,至于这份数据是怎么来的,是从后端接口拉取的还是从缓存读取的,是由单一接口还是多个接口组合而来的,对于我组件而言,我不关心,我只关心我能拿到这份数据。

function SomeComponent() {
  const [data] = useDataSource(SomeDataSource)
  const { name, count } = data

  return (
    <div>{name}: {count}</div>
  )
}

那么这个数据是怎么来的呢?当然是从我们的后端接口来的了。

const SomeDataSource = source(async () => {
  const data = await fetch('...')
  return data
}, { name: '', count: 0 })

这个数据可能发生变化,什么时候发生变化呢?当然是某次更新操作之后。

await postData(data)
await renew(SomeDataSource)

当执行 `renew` 时,SomeDataSource 中的请求函数会被再次执行,新拿到的数据将作为 SomeDataSource 的新数据,由于这一变更会带来 useDataSource 内部实现的状态变更,从而引起 SomeComponent 的重新渲染。

假如,我们在另外一个组件中也使用了该数据源:

function Some2Component() {
  const [data] = useDataSource(SomeDataSource)
  ...
}

那么,当 SomeDataSource 中对应数据的变化,就回同时带来 Some2Component 的重新渲染。

你可能会说,这有什么,感觉完全没什么难度。但是一旦你开始按照这种思路去设计和架构你的代码,你就会发现,数据和组件开始分离,组件专注于使用数据,而数据有了自己的生命形态。

数据源管理服务层

在腾讯投资项目中,我们建立了一层服务层,专门用于管理对应的数据源,称之为数据源管理服务层。数据源管理和数据请求是两个层面的事物,数据请求负责从服务端拉取数据,二数据源管理负责管理这些数据。“数据源”是一个抽象,例如我们有一个叫做“项目详情”的数据源,但它并不代表一个具体的接口数据,而是代表项目这个概念的数据,抽象和具体的关系,类似interface和implements的关系。这个“项目详情”数据源,可能对应无数个具体项目的数据,但是它是一个源,它内部组织着不同项目数据的存储,所以它是一个管理工具。

数据源对象维持着数据,这些数据在组件中被读取。但是,在没有组件之前,我们就可以对数据源进行编程。我们以某些具体的需求为目标,建立数据源,数据源的目标是像下游提供数据,所以它只关心输出的数据是准确的,而不关心数据从哪里来。数据源定义拉取数据时,我们可以从两个接口挑选对应数据组合起来作为数据源对应的数据。例如一个具体的项目详情,它需要包含项目的基本信息,也需要包含这个项目内的交易信息,但是对于后端而言,这两个信息是分开的,由两个接口提供,因此,我们在建这个数据源时,我们从这两个接口拿到数据,并合并为一个数据,存储在数据源中等待被使用。

projectDetail = source(async (projectId: string) => {
  const [project, transactions] = await Promise.all([
    fetch('project info', projectId),
    fetch('transactions info', projectId),
  ])
  return { ...project, transactions }
}, )

上面这段演示代码演示了我们如何创建这个数据源。当然,这不是一个标准做法,因为我们的数据可能从各种渠道来,甚至是本地持久化存储。

接下来,我们要讨论的就是“数据自己的生命循环体”。

我们在项目中创建了一个 DataService 的基类,该类内部实现了上述数据源管理的能力。我们通过一个具体的 DataService 来管理某一组有关联的数据源,它们之间构成了数据自己的生命循环体。我们来看下例子:

class ProjectService extends DataService {
  // 项目详情
  projectDetail = this.source(async (projectId: string) => {
    const [project, transactions] = await Promise.all([
      fetch('project info', projectId),
      fetch('transactions info', projectId),
    ])
    return { ...project, transactions }
  }, {})

  // 项目列表
  projectList = this.source(async () => {
    const data = await fetchProjectList()
    return data
  }, [])

  // 更新项目基本信息
  updateProject = this.action(async (projectId: string, data: IProjectData) => {
    await httpPut(`.../${projectId}`, data)
    await Promise.all([
      this.renew(this.projectDetail, projectId),
      this.renew(this.projectList),
    ])
  })
}

我们用这个最简单的例子来说明。对于项目而言,我们往往需要读取项目列表,和某个具体的项目详情,同时有更新项目基本信息,实际上,我们还会有更多,例如获取项目的某些统计数据等等。

当我们更新项目基本信息的时候,我们需要 renew 项目详情和项目列表数据。

关键的来了。

“我们现在已经对项目相关的数据了如指掌,且它们已经自成体系了。”以往,我们很难表达这种感觉,就是我们还没有去考虑交互上的点击提交等操作之前,我们的数据自己已经动起来了,而且形成了完整的闭环。即使脱离了界面的环境,这部分代码都非常有价值。对于 ProjectService 而言,它可以被实例化,以维持数据,也可以被垃圾回收,释放内存。

我们弱化了“请求”这个动作,强调了“数据”这个存在。以往我们要为一个字段的选项列表提供数据,我们常常是在组件中创建一个状态,然后用一个请求把列表拉回来,再给这个状态,把这个状态作为选项列表。但是,在我们项目中,我们强调数据,一个数据源是一个对象,因此可以被作为一件物品进行传递,在组件中我们接收这个对象,从该源对象中读取数据,过程中,我们不关心请求这个动作。

这种有价值的数据管理,在我们项目中,被独立为数据源管理服务层,它向应用中的很多地方提供数据,不单单是组件里面,还有一些计算,一些判断查询,还有模型中提供某个字段的选项列表,它形成了一套独立的服务体系。

业务模块的组织

在我们的代码中,我们习惯于将相关的代码放在一起,用以表达某种高内聚的关联关系。在我们的项目中,我们有两种组织的单元,一种称为Subject,它和具体的界面无关,甚至和应用的呈现无关,它具有纯粹的业务描述,基于DDD的理念进行建模;另一种是Module,是基于某个具体业务而组织在一起的代码,狭义的理解,你可以把一个Module理解为应用中某个模块,例如项目模块、付款模块、签署模块等等。而“数据源管理服务层”虽然被称为一层,但并不代表数据源的定义代码被集中放在一起管理,相反,我们遵循上述组织单元的原则,Subject中有自己的数据源管理服务,Module中也有自己的数据源管理服务。

业务与UI分离

数据源管理服务层是通用的,且与业务逻辑一起,被放在公共实现部分,基于这一设计,不同端不需要重复实现相同的数据请求,当然,实际上在我们实现UI时,我们会忘记“请求”这个概念,我们不“请求”,而是“索要”,向数据源索要需要的数据。感官上,我们在写组件时,很难直接与数据请求打交道,甚至脑海中不需要有“请求”的概念,只有当我们再做骨架屏,需要一个加载状态时,需要数据源管理服务层暴露一个 loading 的状态给到组件使用。

当写组件更纯粹的专注于UI和交互本身,而不是处理一大堆和请求、数据、业务逻辑相关的东西时,我们的写作会被解放出来。随着数据管理的清晰化,前后端在某种层面上形成了一种规范,例如每一个实体需要提供对应的详情、列表、编辑、更新、选项等接口,基于这一规范,开发效率也会有所提升。

跨组件数据联动

数据的动作,和前端没有直接关系,甚至不一定是前端提交数据带来的,例如系统中有一个定时机制,在特定时间会更新数据。在上面的示例代码中,我们有 projectDetail 和 projectList 两个数据源。我们往往在两个不同的界面中使用这两个数据。但当某个项目发生变化时,这两个界面都应该被实时更新。

function ListPage() {
  const service = useService(ProjectService)
  const [listData] = useDataSource(service.projectList)
  // 使用 listData
}

在列表页使用列表数据。

function DetailPage() {
  const { projectId } = useRouteParams()
  const service = useService(ProjectService)
  const [detailData] = useDataSource(service.projectDetail, projectId)
  // 使用 detailData
}

在详情页使用详情数据。

我们把系统中的所有 Service 设计为可使用单例的对象,所以上面两个组件实际上使用了相同的 ProjectService 的单例。

上面这两个页面在数据使用上,看似没有任何联系,但在 DataService 层面,它们是有联系的,当项目数据发生变化时,详情和列表都会更新。但对于组件的开发者,并不需要去关心这一点,组件开发者只需要关心自己使用了自己需要的数据。这符合 Clear Architecture 的理念,我们在写内层的实现时,不应该考虑外层的应用。

function ProjectEditForm(props) {
  const { projectId } = props
  const service = useService(ProjectService)
  
  const handleSubmit = async () => {
    const data = generatePostData()
    await service.request(service.updateProject, projectId, data)
  }

  // ...
}

在表单中更新项目基本信息。

当更新完成之后,在 ProjectService 里面,它调用 renew 来更新当前项目和项目列表的数据,从而让 ListPage 和 DetailPage 作出对应的变更。上面三个组件中,我们不需要去处理它们相互之间的联动关系,因为这个动作是由数据层完成的,而非表现层。而且,实际上,除了表现层可能用到这些数据,逻辑层也可能用到,这意味着除了界面的变更,实际上我们在逻辑层的处理也可以被重新执行。

前端项目的分层设计让不同部分的职责更单一,前端框架则负责在各个层之间建立响应式关系。

效果预览

在投资信息中,投资方和被投方有约见记录,录入约见记录是一个非常简单的操作。我们来看下,在这个录入操作下,使用上述方案的实际效果。

 

约见记录列表陈列了该公司已有的约见,当点击添加约见时,会进入到一个新页面(新路由),完成添加之后,会回到这个页面,而回来时,我们新增的约见被读取了出来。在两个页面之间进行不同的数据存取操作,虽然动作本身是分开的,但是它们存在内在的联系。在 DataService 中,将这两种内在联系绑定在一起,因此,完成提交操作时,列表数据源需要随即进行更新。但在组件开发时,我们不需要去关心这种内在联系,因为对于列表组件而言,我们不需要也不应该想着数据的更新,同时,添加表单组件也不可能穷举出自己需要联动触发哪些组件更新。对于这两个组件而言,它们只专注做自己的事情,把数据的联动交给数据源管理服务层。

结语

在腾讯投资相关系统中,经常使用这种模式。无论PC端还是APP端,使用这种模式进行开发可以让我们比较轻松的完成跨界面的数据联动。这种模式,首先将数据管理从UI编程中独立出来,建立一层数据源管理服务层,并且在该层中构建了数据自身的生命循环体;接着,借助框架的能力,让数据管理工具具备响应式能力,并通过hooks封装,让组件可以在数据发生变化时进行更新;最后,在UI编程时,开发者不需要思考自己的动作会触发其他哪些组件的变更,也不需要记着自己需要依赖其他组件的动作来重新渲染,开发者专注完成自己的实现,而把这种跨组件的联动交给数据源管理服务层和封装的hooks函数来实现。前端数据层在以往我们很少去深入思考,无非就是请求数据,但是,在这种数据管理模式下,我们实现了架构上的分层,让数据自治,把数据的管理从组件体系中分离出来,这使得数据本身更封闭更聚合,同时当为其他部分(UI或某些逻辑判断)提供服务时,具有更开放更简洁的出口。当然,这一模式在某种程度上,依赖项目代码的整体架构设计,没有分层的架构设计理念,很难适应这种模式,同时,它也依赖于某些技术上的实现,不然没法做到那么高程度的封装。不过,相信这种设计在很多业务系统中,需要跨组件跨界面实现数据联动的场景下,具有不错的借鉴意义。

09:56:33 已有0条回复