post
你应该意识到我要说什么了,像互联网这种可以快到一天一个迭代的开发速度,如果没有一个好的模式,可能用不了多久,新加的功能就把你的 createPost 搞成了800行。当然,我也并不是要讲设计模式。代码中的设计模式,完全依赖于程序员本人,我们要思考的是从框架层面提供最简单的写法。
让我们再回到哲学角度去分析一下业务逻辑。
我们所谓的逻辑,其实就是对一个 具体过程的描述 。在上面这个例子里,过程无非就是添加标签,全文扫描。描述一个过程,有两个必备点:
- 干什么
- 顺序
顺序为什么是必备的?某天上面发了文件说标题里带 XXX 的文章都不能发,于是你不得不在函数一开始时就进行检测,这时就必须指定顺序。
如果我们用左右表示会互相影响的顺序,从上下表示互不相干的顺序,把上面的最初的流程图重画一下:
这是一棵树。如果我们再加个功能,添加的标签如果是某个热门标签,那么我们就把这篇文章放到网站的热门推荐里。这棵树会变成什么样子呢:
是的,事实上人类思维中的任何过程,都可以画成一棵树。有条件的循环可以拆解成递归,最终也是一棵树。但重点并不是树本身,重点是上面这个例子演化的过程,从一开始最简单的需求,到加上一点新功能,再到加上一些恶心的特殊情况,这恰恰就是真实世界中 web 开发的缩影。真实世界中的变化更加频繁可怕。其中最可怕的是,很多时候我们的程序结构、用到的设计模式,都是适用于当前的业务模型的。而某天业务模型变化了,代码质量又不够好的话,就可能遇到牵一发动全身,大厦将倾的噩梦。几乎每个大公司都有一个“运行时间长,维护的工程师换了一批又一批”的项目。Amazon曾经有个工程师描述维护这种项目的感觉:“climb the shit mountain”。
回到之前的话题,在逻辑处理上,我们的理想是写出的代码即短,又具有极高的可维护性和可扩展性。
更具体一点,可维护性,就是代码和代码结构,能最大程度地反映业务逻辑。最好我的代码结构在某种程度上看来和我们流程图中的树一样。这样我读代码,就几乎能理解业务逻辑。而可扩展性,就是当出现变化时,我能在完成变化时,能尽量少地去修改之前的代码。同样的,如果我们能保障代码和代码结构能和流程图尽量一致,那么在修改时,图上怎么改,我们代码就怎么改。这也就是理论上能达到的最小修改度了。综上,我们用什么样的系统模型能把代码变得像树形结构一样?
很简单,事件系统就可以做到。我们把都一个业务逻辑当做事件来触发,而具体需要执行的操作单做监听器,那么上面的代码就可以写成:
// emitter 是事件中心
emitter.on("post.create", function savePost(){...})
emitter.on("post.create", function createTags(){...}, {before:"savePost"})
emitter.on("post.create", function scanSensitiveWords( post ){
if( system.scanSensitiveWords( post ) ){
return new Error("you have sensitive words in post.")
}, {block:all})
emitter.on("post.create", function scanPopTags(){...})
//执行创建文章操作
emitter.fire("post.create", {...args})
这样看来,每个操作的代码变得职责单一,整体结构也非常工整。值得注意的是,在这段伪码里,我们用了 `{before:"savePost"}` 这样的参数来表示操作的顺序,看起来也和逻辑本身的描述一致。
让我们回到可维护性和可扩展性来检查这种写法。首先在可维护性上,代码职责变得很清晰,并且与流程描述一致。不过也有一个问题,就是操作的执行顺序已经无法给人宏观上的印象,必须把每个监听器的顺序参数拼起来,才能得到整体的顺序。
在可扩展性上,无路是新增还是删除操作,对应到代码上都是删除或新增相应的一段,不会影响到其他操作代码。我们甚至可以把这些代码拆分到不同的文件中,当做不同的模块。这样在增减功能时,就能通过增删文件来实现,这也为实现一个文件级的模块管理器提供了基础技术。
至此,除了无法在执行顺序上有一个宏观印象这个问题,似乎我们得到了理想的描述逻辑的方式。那我们现在来攻克这最后一个问题。拿目前的这段伪码和之前的比较,不难发现,之前代码需要被执行一遍才能较好地得到其中函数的执行顺序,才能拿到一个调用栈。而现在的这段代码,我只要实现一个简单的 emitter,将代码执行一遍,就已经能得到所有的监听器信息了。这样我就能通过简单的工具来得到这个宏观的执行顺序,甚至以图形化的方式展现出来。得到的这张图,不就是我们一模一样的流程图吗?!
不知道你有没有意识到,我们已经打开了一扇之前不能打开的门!在之前的代码中,我们是通过函数间的调用来组织逻辑的,这和我们现在的方式有一个很大的区别,那就是:用来封装业务逻辑的函数,和系统本身提供的其他函数,没有任何可以很好利用的区别,即使我们能得到函数的调用栈,这个调用栈用图形化的方式打印出来也没有意义,因为其中会参杂太多的无用函数信息,特别是当我们还用了一些第三方类库时。打印的结果可能是这样:
而现在,我们用来表述业务的某个逻辑,就是事件。而相应的操作,就是监听器。监听器无论是触发还是注册,都是通过 emitter 提供的函数,那么我们只需要利用 emitter,就能打印出只有监听器的调用栈。而监听器的调用栈,就是我们的流程图。
代码结构可图形化,并且是有意义的可图形化,这扇大门一旦打开,门后的财富是取之不尽的。我们从 开发、测试、监控 三个方面来看我们能从中获得什么。
在开发阶段,我们可以通过调用栈生成图,那通过图来生成代码还会难吗?对于任何一份流程图,我们都能轻易地直接生成代码。然后填空就够了。在调试时、我们可以制作工具实时地打印出调用栈,甚至可以将调用时保存的传入传出值拿出来直接查看。这样一旦出现问题,你就可以直接根据当前保存的调用栈信息排查问题,而再无需去重现它。同理,繁琐的断点,四处打印的日志都可以告别了。
测试阶段,既然能生成代码,再自动生成测试用例也非常容易。我们可以通过工具直接检测调用栈是否正确,也可以更细致地给定输入值,然后检测各个监听器的传入传出值是否正确。
同样很容想到监控,我们可以默认将调用栈的数据建构作为日志保留,再用系统的工具去扫描、对边,就能自动实现对业务逻辑本身的监控。
总结一下上述,用事件系统去描述逻辑、流程,使得我们代码结构和逻辑,能达到一个非常理想的对应程度。这个对应程度使得代码里的调用栈信息就能表述逻辑。而这个调用栈所能产生的巨大价值,一方面在于可图形化,另一方面则在于能实现测试、监控等一系列工程领域的自动化。
到这里,我们已经得到了两种理想的表达方式来分别表述数据和逻辑。下面真正激动人心的时刻到了,我们来关注现实中的技术,看是否真的能够做出一个框架,让我们能用一种革命性的方式来写应用?
理想到现实
首先来看数据描述语言和和数据持久化。你可能早已一眼看出 `User -[create]-> Post` 这样的伪码是来自图数据库 Neo4j 的查询语言 cypher 。在这里我对不熟悉的读者科普一下。Neo4j 是用 java 写的开源图数据库。图数据本身是以图的方式去存储数据。
例如同样对于 User 这样一个模型,在 关系型数据库中就是一张表,每一行是一个 user 的数据。在图数据库中就是一堆节点,每个节点是一个 user。当我们又有了 Post 这个模型时,如果要表示用户创建了 Post 这样一个关系的话,在关系型数据库里通常会建立一个中间表,存上相应 user 和 post 的 id。也或者直接在 user 或 post 表里增加一个字段,存上相应的id。不同的方案适用于不同的场景。而 在图数据库中要表达 user 和 post 的关系,就只有一种方式,那就是创建一个 user 到 post 的名为 CREATED 的 关系。这个关系还可以有属性,比如 {createdAt:2016,client:"web"} 等。
你可以看出图数据和关系型数据库在使用上最大的区别是,它让你完全根据真实的逻辑去关联两个数据。而关系型数据库则通常在使用时就已经要根据使用场景、性能等因素做出不同的选择。
我们再看查询语言,在 SQL 中,我们是以`SELECT ... FROM` 这样一种命令式地方式告诉数据怎样给我我要的数据。语句的内容和存数据的表结构是耦合的。例如我要找出某个 user 创建的所有 post。表结构设计得不同,那么查询语句就不同。而在 Neo4js 的查询语句 cypher 中,是以 `(User) -[CREATED] ->(Post)` 这样的 模式匹配 的语句来进行查询的。这意味着,只要你能以人类语言描述自己想要的数据,你就能自己翻译成 cypher 进行查询。
除此之外,图数据当然还有很多高级特性。但对开发者来说,模式匹配式的查询语句,才是真正革命性的技术。熟悉数据库的读者肯定有这样的疑问:
其实很多 ORM 就能实现 cypher 现在这样的表达形式,但在很多大公司里,你会发现研发团队仍然坚持手写 SQL 语句,而坚决不用 ORM。理由是,手写 SQL 无论在排查问题还是优化性能时,都是最快速的。特别是对于大产品来说,一个 SQL 就有可能节约或者损失巨额资产。所以宁愿用 “多人力、低效率” 去换 “性能和稳定”,也不考虑 ORM。那么 cypher 如何面对这个问题?
确实,cypher 可以在某种程度上理解成数据库自带的 ORM。它很难通过优化查询语句来提升性能,但可以通过其他方式。例如对耗时长的大查询做数据缓存。或者把存储分层,图数据库变成最底层,中间针对某些应用场景来使用其他的数据库做中间层。对有实力的团队来说,这个中间层甚至可以用类似于智能数据库的方式来对线上查询自动分析,自动实现中间层。事实上,这些中间技术早就已经成熟,结合上图数据库和cypher,是可以把传统的“人力密集型开发”转变为“技术密集型开发”的。
扯得略远了,我们重新回到模式匹配型的查询语句上,为什么说它是革命性的,因为它刚好满足了我们之前对数据描述的需求。任何一个开发者,只要把数据字典做出来。关于数据的工作就已经完成了。或者换个角度来说,在任何一个已有数据的系统中,只要我能在前端或者移动端中描述我想要的数据,就能开发出应用,不再需要写任何服务器端数据接口。Facebook 在 React Conf 上放出的前端 Relay 框架和 GraphQL 几乎就已经是这样的实现。
再来看逻辑部分,无论在浏览器端还是服务器端,用什么语言,实现一个事件系统都再简单不过。这里我们倒是可以进一步探索,除了之前所说的图形界面调试,测试、监控自动化,我们还能做什么?对前端来说,如果前后端事件系统可以直接打通,并且出错时通过图形化的调试工具能无需回滚直接排查,那就最好了。
例如:在创建 post 的前端组件中
//触发前端的 post.create 事件
var post = {title: "test", content: "test"}
emitter.fire("post.create").then(function(){
alert("创建成功")
}).catch(function(){
alert("创建失败")
在处理逻辑的文件中:
//可以增加前端专属的逻辑
emitter.on("post.create", function checkTest(post){
if( post.title === "test"){
console.log("this is a test blog.")
//通过 server: 这样的命名空间来触发服务器端的事件
emitter.on("post.create", function communicateWithServer(post){
console.log("communicating with server")
return emitter.fire("server:post.create", post)
得到的事件栈
在浏览器端可以打通和服务器端的事件系统,那么在服务器端呢?刚刚提到我们我们其实可以用任何自己熟悉的语言去实现事件系统,那是不是也意味着,只要事件调用栈的数据格式一致,我们就可以做一个跨语言的架构?
例如我们可以用nodejs的web框架作为服务器端入口,然后用python,用go去写子系统。只要约定好系统间通信机制,以及事件调用栈的数据格式,那么就能实现跨语言的事件系统融合。这意味你未来看到的调用栈图可能是:
跨语言的实现,本身也是一笔巨大财富。例如当我们未来想要找人一起协同完成某一个web应用时,再也不必局限于某一种语言的实现。甚至利用docker等容器技术,执行环境也不再是限制。再例如,当系统负载增大,逐渐出现瓶颈时。我们可以轻松地使用更高效的语言或者执行环境去替换掉某个业务逻辑的监听器实现。
更多的例子,举再多也举不完。当你真正自己想清楚这套架构之后,你会发现未来已经在你眼前。
到这里,对“理想”的想象和对实现技术的思考终于可以划上句号了。对熟悉架构的人来说,其实已经圆满了。但我也不想放弃来“求干货”的观众们。下面演示的,就是在框架原型下开发的简单应用。这是一个多人的todo应用。
前端基于react,后端基于koa。
前端数据(todo 列表) /public/data/todos.js
前端逻辑(todo 基本逻辑) /public/events/todo.js
前端逻辑(输入@时展示用户列表) /public/events/mention.js
后端逻辑(通知被@用户) /modules/mention.js
通过调试工具得到的创建时的调用栈和输入@符号时的调用栈
这只是一个引子,目的是为了让你宏观的感受将应用拆解为“数据+逻辑”以后能有多简单。目前这套框架已完成 50% ,实现了数据部分的设计、前后端事件融合,还有跨语言等方案正在开发中。未来将开源,期待读者关注。
终于写完了。框架只是架构的实现。这套架构几乎孕育了近两年,这其中已经开发出一款实现了部分功能,基于nodejs的服务器端原型框架。完整的框架开发目前也已经四个月了。虽然从它落地的这些前端技术、数据技术看起来,它其实是有技术基础的,应该是积累的产物。但实际上,最早的关于数据和逻辑的思路,却是在我读研时对一个“很虚”的问题的思考:什么样的系统是最灵活的系统?在很长一段时间内,对各种架构的学习中我都没有找到想要的答案,直到后来在学认知心理学和神经学的时候,我想到了人。人是目前可以理解的最具备适应性,最灵活的系统。人是怎么运作的?生理基础是什么?
认知心理学里提到曾经有一个学派认为人的任何行为都不过是对某种刺激的反射,这种刺激可以是来自内部也可以是外部。来自内部的刺激有两个重要来源,一是生理上,例如饥饿,疲惫。二则是记忆。例如,你每天起床要去工作,是因为你的过去的记忆告诉你你需要钱,或者你喜欢工作的内容。这对人来说也是一种刺激,所以你产生了去工作的动机。外部刺激就更简单,例如生理上的被火烫了,心理上被嘲讽、被表扬等等。而人的反应,就是对这些刺激而产生的多种反射的集合。例如早上起床,你的一部分反射是产生上班的动机,但是如果你生病了,你的身体和记忆就会刺激你去休息。最终你会在这两种刺激下达到一个平衡,做出反应。值得注意的是,大部分时候,人在不同时间面临相同的刺激,却做出不同的反应。并不是因为后来某些反射被删除了,而是因为后来形成了更强的反射区压制住了之前的反射。它的生理基础就是神经学中的神经递质可以互相压制。
如果我们把要打造的系统看做一个有机体,把迭代看做生长,把用户的使用看做不断的刺激。那我们是不是就能模拟人的反射过程来打造系统,从而期待系统得到像人一样的适应力?而恰恰你会发现科幻作品中的人工智能产品通常都以人的形态出现。因为我们希望我们所使用的产品,就像人一样通情达理,具有人一样的领悟能力。而要达到这样的效果,或许就是不断给给他添加人对刺激的反射规则。
思考到这一步的时候,我对应用架构的设计哲学已经基本定型。后来验证出来的,这样的系统能够极大地提高研发效率,都只是这段哲学的附加价值。其实提高研发效率的原理很简单,无论系统的需求再怎么扩展、再怎么变更,它也是遵循人本身的思维逻辑的。因此,你始终可以使用本身就模拟人类认知的系统去适应它。并且,它怎么变化,你就怎么变化。
架构这种东西,最终仍然关注在使用者身上的。所以与其和我讨论确定的技术问题,不如讨论这些更有意义。对思考架构的人来说,我认为眼界和哲学高度,最重要。
尤小右:感觉其实就是 flux 啊,但是 string-based global event bus 规模大了还是会有点坑爹的。一个事件触发的后果遍及全栈,不好 track。
答:和flux的区别在于flux的数据对象本身和对数据的操作是合在store里的。事件系统规模的问题通过两个方式控制:一是命名空间。二是事件只应用在业务逻辑个程度就够了,像“存入数据库”这种操作就不要再用事件触发。这样系统就不会乱掉,因为它只反映业务逻辑。
玉伯也叫黑侠:认识心理学那段很有趣。很关注如何让业务代码随着时间流逝不会腐化而会趋良?比如事件fire点,怎么才能可控又够用,而不会随着业务复杂而爆发式增长?(简单如seajs, 随着插件的多样化事件点都经常不够用)。还有如何让事件间彼此解耦?经常一个需求要添加多个监听,做得不好还可能影响其他功能点。
答:用事件去反映业务逻辑,而不是技术实现的逻辑”不只是这套架构对于防止事件滥用的一个建议,更是它的哲学理论的重要部分。遵守它,这套框架就能把高可扩展性和高可维护性发挥到极致。我们用一个常见的例子来说明这一点。有时候面临需求变更,我们会觉得难搞,会对产品经理说:“你这个变更影响很大,因为我的代码中xxx不是这样设计的”。而产品经理有可能不理解,因为对他来说,变更的需求可能只是一个很简单的逻辑,加上一点特殊情况而已。产生这种矛盾的关键就在于,没有找到一种能准确描述业务逻辑的方式去组织代码。如果组织代码的方式和描述业务逻辑的方式一致,那么业务逻辑上觉得改动点很简单,代码上就也会很简单。这套架构中的事件系统、包括事件拥有的顺序控制等特性,都是为了提供一种尽可能合适的方式去描述业务逻辑。只有这样,才能实现代码最少、最可读、最可扩展。它本身是为描述业务逻辑而不是技术实现逻辑而生。所以只有遵守这个规则,才能得到它带来的财富。
玉伯也叫黑侠:嗯,看明白了。感觉是将代码阶段的复杂性,前移到了业务系分阶段,如果系分阶段做得好,那么代码就会很优雅。反之,则很难说。进一步提一个无耻要求:怎么保证系分阶段的良好性呢?不少时候,写代码的过程,就是梳理业务逻辑的过程,写完后,才明白某个需求真正该怎么实现。
答:不太认同写代码的过程是梳理业务逻辑的过程。可以说写代码的过程是梳理具体技术实现的过程。如果一开始写代码的人连业务逻辑都不清楚,再好的技术和框架也无法防止他写出烂代码。基于事件的架构其实不是对系分的要求提高了,反而是降低了。因为只要求你理清楚逻辑,具体的实现写得再烂,之后都可以依赖事件系统架构本身的灵活性去完善的。就例如“发表文章后给所有被@的人发站内信”这样的逻辑,你可能一开始没有考虑发站内信的时候最好用个队列,防止请求被卡住。但只要你做到了最基础的把“发送站内”这个监听器注册到“发表文章”的事件上。未来就能在不影响任何其他代码的情况下去优化。实际上没有任何框架能帮你写好代码,即使DDD社区也是强调不断重构,只可能“降低让你写好代码的门槛”。这套架构就是屏蔽很多技术上的概念,用事件的方式让你只关注逻辑。
玉伯也叫黑侠:有没有一种让代码趋良的架构?可能刚开始写得乱糟糟,但随着做的需求越多,写的代码越多,整体可维护性反而会变得越好?比如前后端分层,让后端专注业务模型,一般来说,业务模型会逐步趋于完善和稳定,前端代码也会逐步变好。用一些约束,推动代码的良性循环。这些约束,是否就是理想应用架构的精髓?这些约束是什么?可能是某种要求比如测试覆盖率,也可能是某种强制约束比如必须通过数据改动来更新界面。roof的约束是用事件去反映业务逻辑,但这个约束更多是「道德」层面,而不是「法律」,比如如何防止「大事件」(一个事件里,一坨技术实现的逻辑代码)?如何让人羞于去写出糟糕的代码?
答:即使前后端分离,业务模型趋于稳定,也是靠开发者自身不断重构去实现的,要不然怎么会“趋于”稳定呢。架构只可能让人站到更好地平台上,用更好地方式去写好代码,不可能主动帮人把代码变好。文中架构就是通过屏蔽技术细节,让你关注业务逻辑的方式,让代码易理解,也让你能不影响业务地去升级技术。这套架构因为有一个清晰的事件调用栈数据结构,所以能很容易地做出相应的测试、监控工具保障代码质量。但要实现“法律”是不可能的。即使是Java、即使是领域驱动编程,也可以在它好的架构下写出各种糟糕的代码。毕竟编程仍然是一件需要创造力的工作。这就像硬币的两面,如果要实现法律,那工作本身必须是无需创造,完全可以按照流程由机器人生产。如果要创造力,就必然会有因人而异的品质差异。