在过去对框架的设计中,我收到过的最有用的建议是:“不要一开始就根据现有的技术去整合和改进。而是先搞清楚你觉得最理想的框架应该是怎样的,再根据现在的技术去评估,的确实现不了时再妥协。这样才能做出真正有意义的框架。”在这篇文章里,就让我们按照这样一条建议来探索一下现在的 web 框架最终可以进化成的样子,你绝对会被惊艳到。
前端,还是从前端说起。前端目前的现状是,随着早期的 Backbone,近期的 Angular、React 等框架的兴起,前端在 模块化、组件化 两个方向上已经形成了一定的行业共识。在此基础上,React 的 FLUX、Relay 则是进一步的对前端应用架构的探索。这些技术在目前国内的大公司、大团队内部实际上都落地得非常好,因为很容易和公司内部已有的后端技术栈结合。而且这些纯前端框架的配套技术方案一般比较成熟,例如在支付宝确定使用 React,其实有一部分原因是它兼容 IE8,并且有服务器端渲染方案来加速首屏。
相比之下,像 Meteor 这类从前到后包办的框架就较难落地。虽然能极大地提高开发效率,整体架构非常先进,但架构的每一个层级往往不容易达到行业内的顶尖标准。特别是在服务器端,对大公司来说,通常都有适合自己业务的服务器集群、数据库方案,并且经受过考验。因此当一个团队一上手就要做面向十万级、甚至百万级用户的产品时,是不太愿意冒风险去尝试的。反而是个人开发者、创业型的团队会愿意去用,因为确实能在短时间内高效地开发出可用的产品出来。包括像 Leancloud 提出的这类型的服务,也是非常受欢迎的。
这种现状,就是理想和现实的一个争论。Meteor 的方式能满足我对开发效率的理想,而团队已有的技术方案能保障稳定。能否整合其中的优势,不妨让我们进一步来细化一下对框架的希望:
- 有强大的前后端一致的数据模型层
- 代码可以可以复用。例如我有一个 User 模型,当我创建一个新的 user 时,user 上的字段验证等方法是前后端通用的,由框架自动帮我区别前后端环境。
- 数据模型和前端框架没有耦合,但可以轻松结合。这样在前端渲染型的框架进一步升级时,不影响我的业务逻辑代码。
- 由数据模型层提供自动的数据更新机制。例如我在前端要获取 id 为 1 的用户,并且如果服务器端数据有更新的话,就自动帮我更新,不需要我自己去实现轮询。我希望的代码写法是:
var user = new User({id:1}); user.pull(); user.watch();
实际上,Meteor已经能实现绝大部分上述功能。但这不是软文。我要强调两点我不希望的:
- 我不希望这个数据模型层去包含业务逻辑,也就是我创建的user对象,我不希望它提供 login、logout 等 api。
- 我也不希望数据模型层自动和任何ORM框架绑定,提供任何 SQL 或 NoSQL 的数据支持。
看到这两点你可能心中大打问号,这两点不正是高效的精髓吗?前后端逻辑复用,屏蔽数据库细节。别急,让我们重新用“理想的方式”来思考一下“逻辑”和“数据持久化”这两件事。
我们以这样一个问题开头: 任何一个应用,我们的代码最少能少到什么程度?
这算半个哲学问题。任何人想一想都会得到同一个答案:最少也就少到和应用本身的描述一一对应而已了。什么是应用描述?或者说什么是应用?我们会这样描述一个博客:“用户可以登录、退出。用户登录后可以发表文章。发表文章时可以添加相应的标签。”
抽象一下描述,答案很简单:数据,和逻辑。
如果你在一个流程要求严格的公司,应用描述就是prd或系分文档。应用的数据就是数据字典,应用的逻辑就是流程图的总和:
流程图
那么代码最少能怎么写呢?数据很简单,参照数据字典,我们来用一种即使是产品经理都能掌握的伪码来写:
//描述字段 User : { name : string } Post : { title : string, content : text } Tag : { name : string } //描述关系 User -[created]-> Post Post -[has]-> Tag
这里为了进一步帮助读者从已有的技术思维中跳出来,我想指出这段伪码和数据库字段描述有一个很大的区别,那就是:我不关心 User 和 Post 中间的关联关系到底是在两者的字段中都创建一个字段来保存对方的id,还是建立一个中间表。我只关心我描述它时的逻辑就够了。数据描述的代码,最简也就简单到这个程度了。
那么逻辑呢?我们先用按常规方式试试?
class User{ createPost( content, tags=[] ){ var post = new Post({content:content}) post.setTags( tags.map(tagName=>{ return new Tag(tagName)} ) ) return post } }
好像还不错,如果今天产品经理说我们增加一个 @ 功能,如果文章里 @ 某个用户,那么我们就发个站内信给他。
class User{ createPost( content, tags=[] ){ var post = new Post({content:content}) post.setTags( tags.map(tagName=>{ return new Tag(tagName)} ) ) if( at.scan(content) ){ at.getUser(content).forEach( atUser =>{ system.mail( atUser ) }) } return 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。
前端逻辑(输入@时展示用户列表) /public/events/mention.js
这只是一个引子,目的是为了让你宏观的感受将应用拆解为“数据+逻辑”以后能有多简单。目前这套框架已完成 50% ,实现了数据部分的设计、前后端事件融合,还有跨语言等方案正在开发中。未来将开源,期待读者关注。
终于写完了。框架只是架构的实现。这套架构几乎孕育了近两年,这其中已经开发出一款实现了部分功能,基于nodejs的服务器端原型框架。完整的框架开发目前也已经四个月了。虽然从它落地的这些前端技术、数据技术看起来,它其实是有技术基础的,应该是积累的产物。但实际上,最早的关于数据和逻辑的思路,却是在我读研时对一个“很虚”的问题的思考:什么样的系统是最灵活的系统?在很长一段时间内,对各种架构的学习中我都没有找到想要的答案,直到后来在学认知心理学和神经学的时候,我想到了人。人是目前可以理解的最具备适应性,最灵活的系统。人是怎么运作的?生理基础是什么?
认知心理学里提到曾经有一个学派认为人的任何行为都不过是对某种刺激的反射,这种刺激可以是来自内部也可以是外部。来自内部的刺激有两个重要来源,一是生理上,例如饥饿,疲惫。二则是记忆。例如,你每天起床要去工作,是因为你的过去的记忆告诉你你需要钱,或者你喜欢工作的内容。这对人来说也是一种刺激,所以你产生了去工作的动机。外部刺激就更简单,例如生理上的被火烫了,心理上被嘲讽、被表扬等等。而人的反应,就是对这些刺激而产生的多种反射的集合。例如早上起床,你的一部分反射是产生上班的动机,但是如果你生病了,你的身体和记忆就会刺激你去休息。最终你会在这两种刺激下达到一个平衡,做出反应。值得注意的是,大部分时候,人在不同时间面临相同的刺激,却做出不同的反应。并不是因为后来某些反射被删除了,而是因为后来形成了更强的反射区压制住了之前的反射。它的生理基础就是神经学中的神经递质可以互相压制。
如果我们把要打造的系统看做一个有机体,把迭代看做生长,把用户的使用看做不断的刺激。那我们是不是就能模拟人的反射过程来打造系统,从而期待系统得到像人一样的适应力?而恰恰你会发现科幻作品中的人工智能产品通常都以人的形态出现。因为我们希望我们所使用的产品,就像人一样通情达理,具有人一样的领悟能力。而要达到这样的效果,或许就是不断给给他添加人对刺激的反射规则。
思考到这一步的时候,我对应用架构的设计哲学已经基本定型。后来验证出来的,这样的系统能够极大地提高研发效率,都只是这段哲学的附加价值。其实提高研发效率的原理很简单,无论系统的需求再怎么扩展、再怎么变更,它也是遵循人本身的思维逻辑的。因此,你始终可以使用本身就模拟人类认知的系统去适应它。并且,它怎么变化,你就怎么变化。
架构这种东西,最终仍然关注在使用者身上的。所以与其和我讨论确定的技术问题,不如讨论这些更有意义。对思考架构的人来说,我认为眼界和哲学高度,最重要。