过了两天重新再读,有点语无伦次;不过确实我的真情实感。
有一段时间没写博客了,简单谈谈最近对代码结构的一些思考。事实上,我总觉得这些问题有一点共性,但也并没有做过实证研究,所以也仅仅是我个人的感想罢了。
两年之前接到第一个项目的时候,当时啥也不知道,就一通乱写,把所有的代码都放在一个包里;似乎把所有的代码都放在同一个包里会让人更有安全感。那个时候也不知道为啥,对模块化有一种奇怪的抵触,好像把代码分割之后就会失去对它的控制。
一年多之前学软工二,那是我最沉迷OOP的时期。意识到模块化的好处之后,我急于将所有的代码都分开,希望每一处的代码都符合Best Practice。当时买了Grady Booch的那本《面向对象分析与设计》,惊叹于面向对象的划时代的奇思妙想,甚至将其上升到了哲学的高度,奉为圭臬。那个时候我觉得什么都可以抽象成对象(事实上,OOP还真的有这个能力),并且在台上大言不惭地号称要在前端中引入OOP。那个时候我特别喜欢vue-class-component,只要能跟class沾点边,就会觉得特别激动;那叫什么,皈依者狂热?我的代码里充斥着各种符合面向对象原则的细粒度组件(尤其是SRP)和设计模式的应用。
现在想想还是挺羞愧的,那个时候我还不知道世界上有反模式和冗余的存在,并不是在所有情况下模式都适合。随着时间推移,我逐渐意识到,对象只不过是一种抽象方式罢了,对象和class也不是一回事,并且注解(或者装饰器,随便叫什么吧)也跟OOP没啥关系,更趋向于一个元编程的概念。JS也天生就和class有点不对付。
不知不觉过了一年多,在这期间我开始使用粗粒度的组件,减少组件通信的损耗;并且一些失败的项目经验让我特别推崇代码层面的隔离(事实上我是DDD的拥护者,尤其是最近学了两门关于服务的课程),对那种中心化的store(指vuex)非常抵触。秉着“如无必要,勿增实体”的指导原则,我是非常不希望vuex出现在项目里的。我希望能够只通过组件间的通信就能完成必要的通信,不过我不反对用vuex存一些类似于token的全局共享的数据,虽然我觉得用localStorage或者语言层面的闭包就能完成这个任务。
最近我在思考DDD与前端的关系。由于前端目前的组件化趋势,似乎特别适合服务化,并且用DDD的思路去指导设计。而且,服务化也能满足微前端的一些要求。参考了后端的一些代码结构,我觉得似乎前端也可以搞出一套类似的代码结构。于是我模仿着设计了一个(以Vue为例,事实上我也就稍微了解一点Vue):
├─api ├─components ├─infra │ ├─assets │ ├─interfaces │ ├─plugins │ ├─router │ ├─store │ ├─utils │ └─... └─views以对应于DDD的几个指导性的层级(基础设施层具体是什么定位似乎存在一些争议)。按逻辑层次的从下到上说:
infra就是基础设施层,对后端来说,可能是全局共享的工具函数utils和一些配置文件,比如数据库配置的mapper等等。
而对于前端来说,可能是全局共享的工具函数utils和全局共享的静态资源assets(比如图片、字体),路由router和状态管理store,一些第三方的插件配置plugins(按我的习惯,可能会把组件库配置、ajax请求配置等等放在这里)。如果有使用ts的习惯,这里可能还会放一些全局都会用到的类型定义,比如有些组件库的作者可能会缺少一些类型定义或者干脆没有提供类型定义,就可以在这里写一下。
api相当于domain层吧,主要就是对领域进行划分,并且提供相应的访问接口。对后端来说,可能是数据操作的repo,用于ORM的entity,以及承载值的VO等等。
而对于前端来说,顾名思义,可能是一些与后端或第三方通信以获取数据的接口。我个人的喜好是把接口和接口的契约在一起定义,分开也行,这就对应于VO了。
关于entity,后面会有更多的讨论。
components相当于app层,对domain层提供的“服务”进行组合,提供一些“服务能力”。我个人认为,具体的一些业务逻辑,包括调用domain层服务,以及为了调用可能需要的一些数据转换逻辑,都应该在这里实现。这也意味着,组件可能应该职责单一一些,同时尽可能做到“无状态”。无状态并不意味着组件内部无状态(我并不追求纯函数式的组件),而是与外部交互时的无状态,减少一些隐式的耦合(比如和路由、和状态管理vuex等等耦合),尽可能通过参数props进行传递。
当然,这也意味着更细的组件粒度,以及随之而来的更高的通信成本和开发成本(除非单个组件超过了500行,已经影响可读性了;这个时候分离势在必行)。适当权衡相应的成本,或者在重构时进行,我觉得都是可行的。
views相当于controller,处理和服务消费者(即前端界面的使用者)的交互。
整体的页面应该更关注交互细节,把业务逻辑更多地交给组件去做,相当于是服务编排的思路。
然后的具体命名肯定是个人喜好和项目要求了。但是这么设计,似乎又不符合结构扁平化的惯例,所以一般用的时候可能还是把infra目录拆开,把里面的内容放到和componets、views平级的目录里。
这里有一个很有意思的地方,我也和几个人讨论过,就是后端的entity到底对应于前端的哪个部分。我想了很久,感觉entity整体上还是更倾向于是一个和实现(也就是ORM)耦合的东西,在前端可能没有一个特别明确的对应关系。
但是在前端定义一些“实体”(也就是实体的类型定义)似乎也是有意义的,比如我可能会有一个比较复杂的活动实体,和后端约定实体内容,就在这里定义一个全局的实体:
// @/api/activity/entities/activity.ts export interface Activity { id: number; name: string; startTime: string; endTime: string; startDate: string; endDate: string; location: string; }在具体使用的时候,这个实体定义大概率会有冗余字段,因为具体的数据操作大概率只是使用它的一部分罢了。然后我很可能会有一个创建活动的表单组件(这几乎是必然的,总要有CRUD,不然为什么要定义这个实体呢?),在那个表单组件以及数据请求里,可能就可以对这个实体进行一些裁剪,或者根据组件的实际情况,在中间加一些转换逻辑以及配套的类型定义:
// @/components/activity/add.ts type AddActivityFormOverwriteData = "id"; interface AddActivityForm extends Omit<Activity, AddActivityFormOverwriteData> { id: string; } // @/api/activity/add.ts // 这里用了一个抽象的ajax来进行数据请求 export async function addActivity(req: AddActivityRequest): Promise<AddActivityResponse> { const data = await ajax.post("/activity", req); return data; } export type AddActivityRequest = Omit<Activity, "id">; export interface AddActivityResponse { id: number; }我认为这种写法基本上是符合SOA中的所谓合约集中化的,不过这也需要相当的团队合作,需要前端也参与到领域设计中来。而如果按照正常的前后端分离的开发流程,前后端之间只通过接口联系,前端如果仅凭接口就进行了这个层面的抽象,很有可能会导致不必要的复用,进而导致整个代码的脆弱,因为此时的“实体”只是对接口的拼接罢了,任何一个接口的变更都可能会导致这个实体的变化,随之而来的就是无法预料的连锁反应。
其实说了半天,这也不过就是SOA中所谓的先有合约还是先有逻辑的问题罢了,通过逻辑生成的合约有其天然的耦合性和脆弱性。这也就导致了这种写法可能并不适用于全场景,而更适用于那种偏敏捷的全栈团队,需要更高的前端参与度;当然这只是我个人的一些想法。
思考到这一步的时候,我很悲哀地发现,Vue似乎需要非常强的设计才能实现这一切,并且没有任何强制性的手段来约束团队成员遵守这一开发方式。于是我将目光转向了Angular,它似乎能满足我的要求,在框架层面提供了约束,强迫service与components分离。我并不否认Vue的优秀之处,只是我感觉在当前这一阶段,Angular似乎更满足我对一个工程化项目代码结构的要求。下一个项目,我决定使用Angular。