移动 web 最佳实践根据 DDD/Clean Architecture 思想对项目进行分层架构 - V2EX
V2EX = way to explore
V2EX 是一个关于分享和探索的地方
现在注册
已注册用户请  登录
mcuking
V2EX    前端开发

移动 web 最佳实践根据 DDD/Clean Architecture 思想对项目进行分层架构

  •  
  •   mcuking
    mcuking 2019-10-08 12:06:53 +08:00 4097 次点击
    这是一个创建于 223 天前的主题,其中的信息可能已经有所发展或是发生改变。

    最近笔者在使用 DDD / Clean Architecture 思想开发公司内部使用的 CRM,觉得这种分层架构可以解决目前遇到的问题,所以决定对目前开源的移动端最佳实践项目进行重构,下面是该项目关于分层架构方面的说明。想了解更多内容请查看源码,欢迎 Star: https://github.com/mcuking/mobile-web-best-practice

    应用介绍

    首先介绍下本项目的应用,是一个交互简洁的 Todo 应用,应用取名叫 Memo,即 Memory 的简写,参考了微软的 To Do 以及 Listify、Trello 等应用。不过最大的不同是,项目并不依赖后端,而是使用浏览器提供的 indexDB 进行数据的存储,可以保证数据的绝对安全。另外更新应用也不会清除原来的数据,除非将应用卸载。效果图如下:

    架构分层

    目前前端开发主要是以单页应用为主,当应用的业务逻辑足够复杂的时候,总会遇到类似下面的问题:

    • 业务逻辑过于集中在视图层,导致多平台无法共用本应该与平台无关的业务逻辑,例如一个产品需要维护 mobile 和 PC 两端,或者同一个产品有 web 和 react native 两端;

    • 产品需要多人协作时,每个人的代码风格和对业务的理解不同,导致业务逻辑分布杂乱无章;

    • 对产品的理解停留在页面驱动层面,导致实现的技术模型与实际业务模型出入较大,当业务需求变动时,技术模型很容易被摧毁;

    • 过于依赖前端框架,导致如果重构进行框架切换时,需要重写所有业务逻辑并进行回归测试。

    针对上面所遇到的问题,笔者学习了一些关于 DDD (领域驱动设计)、Clean Architecture 等知识,并收集了类似思想在前端方面的实践资料,形成了下面这种前端分层架构:

    其中 View 层想必大家都很了解,就不在这里介绍了,重点介绍下下面三个层的含义:

    Services 层

    Services 层是用来对底层技术进行操作的,例如封装 AJAX 请求,操作浏览器 cookie、locaStorage、indexDB,操作 native 提供的能力(如调用摄像头等),以及建立 Websocket 与后端进行交互等。

    其中又可细分出来一个 translator 层,主要是对后端提供的接口进行数据的转换修正,例如接口返回的数据命名不规范或格式有问题等等,一般以纯函数形式存在。下面以本项目实际代码为例进行讲解。

    向后端获取 quote 数据:

    export class CommonService implements ICommonService { @m({ maxAge: 60 * 1000 }) public async getQuoteList(): Promise<IQuote[]> { const { data: { list } } = await http({ method: 'post', url: '/quote/getList', data: {} }); return list; } } 

    向客户端日历中同步任务数据:

    export class NativeService implements INativeService { // 同步到日历 @p() public syncCalendar(params: SyncCalendarParams, onSuccess: () => void): void { const cb = async (errCode: number) => { const msg = NATIVE_ERROR_CODE_MAP[errCode]; Vue.prototype.$toast(msg); if (errCode !== 6000) { this.errorReport(msg, 'syncCalendar', params); } else { await onSuccess(); } }; dsbridge.call('syncCalendar', params, cb); } ... } 

    向 indexDB 存储任务数据:

    export class NoteService implements INoteService { public async create(payload: INote, notebookId: number): Promise<void> { const db = await createDB(); const notebook = await db.getFromIndex('notebooks', 'id', notebookId); if (notebook) { notebook.notes.push(payload); await db.put('notebooks', notebook); } } ... } 

    这里我们可以拓宽下思路,当后端 API 仍在开发的时候,我们可以使用 indexDB 等本地存储技术进行模拟,建立一个 note-indexDB 服务,先提供给上层 Interactors 层进行调用,当后端 API 开发好后,就可以创建一个 note-server 服务,来替换之前的服务。只要保证前后两个服务对外暴露的接口一致,另外与上层的 Interactors 层没有过度耦合,即可实现快速切换。

    Entities 层

    实体 Entity 是领域驱动设计的核心概念,它是领域服务的载体,它定义了业务中某个个体的属性和方法。例如本项目中 Note 和 Notebook 都是实体。区分一个对象是否是实体,主要是看他是否有唯一的标志符(例如 id )。下面是本项目的实体 Note:

    export default class Note { public id: number; public name: string; public deadline: Date | undefined; ... constructor(note: INote) { this.id = note.id; this.name = note.name; this.deadline = note.deadline; ... } public get isExpire() { if (this.deadline) { return this.deadline.getTime() < new Date().getTime(); } } public get deadlineStr() { if (this.deadline) { return formatTime(this.deadline); } } } 

    通过上面的代码可以看到,这里主要是以实体本身的属性以及派生属性为主,当然实体本身也可以具有方法,只是本项目中还没有涉及。至于 DDD 中的聚合等概念,也由于项目业务没有涉及,在这里就不作说明了,有兴趣的可以参考下面列出来的笔者翻译的文章:可扩展的前端#2--常见模式(译)

    另外笔者认为并不是所有的实体都应该按上面那样封装成一个类,如果某个实体本身业务逻辑很简单,就没有必要进行封装,例如本项目中 Notebook 实体就没有做任何封装,而是直接在 Interactors 层调用 Services 层提供的 API。

    Interactors 层

    Interactors 层是负责处理业务逻辑的层,主要是由业务用例组成。下面是本项目中 Note 的 Interactors 层提供的对 Note 的增删改查以及同步到日历等业务:

    class NoteInteractor { constructor( private noteService: INoteService, private nativeService: INativeService ) {} public async saveNote(payload: INote, notebookId: number, isEdit: boolean) { try { if (isEdit) { await this.noteService.edit(payload, notebookId); } else { await this.noteService.create(payload, notebookId); } } catch (error) { throw error; } } public async getNote(notebookId: number, id: number) { try { const note = await this.noteService.get(notebookId, id); if (note) { return new Note(note); } } catch (error) { throw error; } } ... public async changeSyncStatus( notebookId: number, id: number, status: boolean ) { try { const note = await this.getNote(notebookId, id); if (note) { note.isSync = status; await this.saveNote(note, notebookId, true); } } catch (error) { throw error; } } public async syncCalendar(params: SyncCalendarParams, notebookId: number) { const noteId = params.id; try { await this.nativeService.syncCalendar(params, async () => { await this.changeSyncStatus(notebookId, noteId, true); }); } catch (error) { throw error; } } } 

    通过上面的代码可以看到,Sevices 层提供的类的实例主要是通过 Interactors 层的类的构造函数获取到,这样就可以达到两层之间解耦,实现快速切换 service 的目的了,当然这个和依赖注入 DI 还是有些差距的,不过已经满足了我们的需求。另外,Interactors 层还可以获取 Entities 层提供的类,构造成实例提供给 View 层。

    当然这种分层架构并不是银弹,其主要适用的场景是:实体关系复杂,而交互相对模式化,例如企业软件领域。相反实体关系简单而交互复杂多变就不适合这种分层架构了。

    在具体业务开发实践中,这种领域模型以及实体一般都是有后端同学确定的,我们需要做的是,和后端的领域模型保持一致,但不是一样。例如同一个功能,在前端只是一个简单的按钮,而在后端则可能相当复杂。

    然后需要明确的是,架构和项目文件结构并不是等同的,文件结构是你从视觉上分离应用程序各部分的方式,而架构是从概念上分离应用程序的方式。你可以在很好地保持相同架构的同时,选择不同的文件结构方式。没有完美的文件结构,因此请根据项目的不同选择适合你的文件结构。

    最后引用蚂蚁金服数据体验技术的《前端开发-领域驱动设计》文章中的总结作为结尾:

    要明白,驱动领域层分离的目的并不是页面被复用,这一点在思想上一定要转化过来。领域层并不是因为被多个地方复用而被抽离。它被抽离的原因是:

    • 领域层是稳定的(页面以及与页面绑定的模块都是不稳定的)
    • 领域层是解耦的(页面是会耦合的,页面的数据会来自多个接口,多个领域)
    • 领域层具有极高复杂度,值得单独管理(view 层处理页面渲染以及页面逻辑控制,复杂度已经够高,领域层解耦可以轻 view 层。view 层尽可能轻量是我们架构师 cnfi 主推的思路)
    • 领域层以层为单位是可以被复用的(你的代码可能会抛弃某个技术体系,从 vue 转成 react,或者可能会推出一个移动版,在这些情况下,领域层这一层都是可以直接复用)
    • 为了领域模型的持续衍进(模型存在的目的是让人们聚焦,聚焦的好处是加强了前端团队对于业务的理解,思考业务的过程才能让业务前进)

    推荐几个相关的类库:

    react-clean-architecture

    business-rules-package

    ddd-fe-demo

    推荐几篇相关文章:

    前端架构-让重构不那么痛苦(译)

    可扩展的前端#1--架构基础(译)

    可扩展的前端#2--常见模式(译)

    领域驱动设计在互联网业务开发中的实践

    前端开发-领域驱动设计

    领域驱动设计在前端中的应用

    luoway
        1
    luoway  
       2019-10-08 12:24:07 +08:00
    看这代码,楼主之前写 Java 的?
    mcuking
        2
    mcuking  
    OP
       2019-10-09 10:04:18 +08:00
    哈哈哈,不是啊,这是 typescript,不过正在学 java 和安卓
    关于     帮助文档     自助推广系统     博客     API     FAQ     Solana     1108 人在线   最高记录 6679       Select Language
    创意工作者们的社区
    World is powered by solitude
    VERSION: 3.9.8.5 25ms UTC 23:25 PVG 07:25 LAX 15:25 JFK 18:25
    Do have faith in what you're doing.
    ubao msn snddm index pchome yahoo rakuten mypaper meadowduck bidyahoo youbao zxmzxm asda bnvcg cvbfg dfscv mmhjk xxddc yybgb zznbn ccubao uaitu acv GXCV ET GDG YH FG BCVB FJFH CBRE CBC GDG ET54 WRWR RWER WREW WRWER RWER SDG EW SF DSFSF fbbs ubao fhd dfg ewr dg df ewwr ewwr et ruyut utut dfg fgd gdfgt etg dfgt dfgd ert4 gd fgg wr 235 wer3 we vsdf sdf gdf ert xcv sdf rwer hfd dfg cvb rwf afb dfh jgh bmn lgh rty gfds cxv xcv xcs vdas fdf fgd cv sdf tert sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf shasha9178 shasha9178 shasha9178 shasha9178 shasha9178 liflif2 liflif2 liflif2 liflif2 liflif2 liblib3 liblib3 liblib3 liblib3 liblib3 zhazha444 zhazha444 zhazha444 zhazha444 zhazha444 dende5 dende denden denden2 denden21 fenfen9 fenf619 fen619 fenfe9 fe619 sdf sdf sdf sdf sdf zhazh90 zhazh0 zhaa50 zha90 zh590 zho zhoz zhozh zhozho zhozho2 lislis lls95 lili95 lils5 liss9 sdf0ty987 sdft876 sdft9876 sdf09876 sd0t9876 sdf0ty98 sdf0976 sdf0ty986 sdf0ty96 sdf0t76 sdf0876 df0ty98 sf0t876 sd0ty76 sdy76 sdf76 sdf0t76 sdf0ty9 sdf0ty98 sdf0ty987 sdf0ty98 sdf6676 sdf876 sd876 sd876 sdf6 sdf6 sdf9876 sdf0t sdf06 sdf0ty9776 sdf0ty9776 sdf0ty76 sdf8876 sdf0t sd6 sdf06 s688876 sd688 sdf86