Skip to main content

模型驱动设计(第二版)

· 56 min read
赵雨森

一、需求背景和目标

参考链接:

主要考虑解决以下问题:

1. 实体拖拽和替换问题(来自内部和工行)

  1. 拖拽一个实体就会添加多个逻辑和数据结构,复用性差、负担很重
  2. 已拖拽出的实体要替换实体或属性,变更起来很粘滞
  3. 带有复杂关系的多个实体需要在同一个页面完成增删改查,需要很多修改的步骤,原来拖出来的也不好用

2. 常见的典型问题(下面是资产预算中遇到的)

  1. 页面表格如何更方便的添加字段,比如额外的一些 format 或计算字段?而不需要考虑多个地方的修改
  2. 表格分页请求和合计总数的数据怎么一起处理问题
  3. PageOf 和 ScopeOf 非常难用
  4. 树型列表等组件构建数据的 Builder 机制

3. 不必要的理解成本

  1. 前后端多层逻辑调用、多层变量等

4. 页面表格和 Excel 的导入导出汇总点问题

  1. 相同数据的页面展示、Excel 导入导出的汇总处理

二、思考方向和设计方法

1. 从技术经验进行抽象和封装

  • 原来视图和数据的关系是直接把前后端框架硬搬上来处理的,少一些良好的抽象封装设计,低代码本身就是要减轻开发者的负担
  • 思考各种组件的接口设计
  • 学习领域驱动设计的充血模型

2. 从用户视角寻找路径(更重要)

  • OutSystems 的视图和数据的关系并不接近用户认知(可能是有包袱问题),然后我们又只抄了70分(没有落地 Record 类型和拖拽替换等功能、Aggregate组件人家可以放页面下)
  • 收集复杂场景,亲自搭建并录屏绘制体验地图,分析用户行为和用户意图之间的 gap
  • 我理解的声明式设计就是求解 minn需求数(用户意图产品使用姿势)2\min \sum_n^{需求数} (用户意图 - 产品使用姿势)^2

3. 学习竞品

  • Mendix 的绑定模型机制很容易上手,值得参考

4. 需要额外注意的点

  • 读写分离问题
  • 先定义返回类型(模型)还是直接从查询推断

三、复杂场景分析

0. 场景概况

方案自定义视图模型方案数据流方案数据流交互示意图
资产预算的增删改查和实时计算有示例有示例有示例
资产预算的新员工数据推送不算是有示例
一对多案例有示例有示例
多对多案例有示例有示例
树型列表展示有示例有示例

1. 资产预算的增删改查和实时计算(第三题)

o. 需求介绍

请大家了解场景题目第三题

其他参考链接:

主要特点
  • 读写分离
  • 前后端都有求和过程,要求的计算方式一致

为了简化描述,实体去除了 createdTime, updatedTime, createdBy, updatedBy 几个字段。

classDiagram direction RL class Version { id: Long year: Integer versionCode: Integer startDate: Date endDate: Date notify: Boolean remark: String status: VersionStatus } class Department { id: Long versionId: Long deptId: String deptName: String parentId: String deptLevel: Integer fullPathName: String costCenterId: String costCenterDefaultId: String costId: String bu: String buName: String state: Integer costName: String } class Budget { id: Long departmentId: Long fillingPerson: String approverSecond: String financePerson: String approverFirst: String open: Boolean status: BudgetStatus repurchase: Boolean } class Asset { id: Long versionId: Long category: String name: String price: Decimal premium: Decimal premiumN: Decimal } class AssetBudget { id: Long budgetId: Long assetId: Long purchaseReason: String month1: Integer month2: Integer month3: Integer month4: Integer month5: Integer month6: Integer month7: Integer month8: Integer month9: Integer month10: Integer month11: Integer month12: Integer } Department "*" --> "1" Version : versionId Asset "*" --> "1" Version : versionId Budget "1" --> "1" Department : departmentId Department "*" --> "1" Department : parentId AssetBudget "*" --> "1" Budget : budgetId AssetBudget "*" --> "1" Asset : assetId

a. 雏形:回顾上一版设计

先从各种组件的接口设计、充血模型开始思考:

问题点
  • 数据查询需要的其他筛选、排序的条件如何传递?数据查询的切片、筛选、排序的需求来源共有三种:
    • 业务自身的需求处理,一般在后端逻辑能直接处理掉
    • 前端组件需求。比如表格的分页大小、选择框的输入过滤等(开发者不太关心具体细节但又是必需的)
    • 前端上下文需求,比如筛选框的值、页面 url 的一些条件、弹窗上下文等
  • 表格中的数据如何传递给弹窗表单内?
优点
  • 组件数据源绑定实体或视图模型接近用户认知,有明确从实体或视图模型推导视图结构的意思。

a. 自定义视图模型方案

在组件中补充筛选和排序功能。

问题点
  • 拉平的数据再次 submit 的时候比较困难,相当于读和写要 transform 两次,而且用户自己建模型很容易遗忘一些关键字段
  • 数据处理和组件太耦合,简单的筛选有引导用户在组件上配的倾向,复杂的场景又需要用户自己先建个一样的模型并实现逻辑,操作过于繁琐
优点
  • Flat 的数据结构和表格字段是对齐的,相当于提前设计了模型

b. 数据流方案

c. 小结

  • 针对组件的接口抽象很重要
  • 老的框架表单没有做抽象,应该把 submit 等行为封装起来。按钮行为可以根据所在位置做推断。
  • 弹窗最好支持类似子页面的局部命名空间
  • 支持组件下挂变量,减少中间变量
  • 利用组件之间的依赖关系推断同步异步的处理问题

2. 资产预算的新员工数据推送

o. 需求介绍

classDiagram direction RL class Version { id: Long year: Integer versionCode: Integer startDate: Date endDate: Date notify: Boolean remark: String status: VersionStatus } class Department { id: Long versionId: Long deptId: String deptName: String parentId: String deptLevel: Integer fullPathName: String costCenterId: String costCenterDefaultId: String costId: String bu: String buName: String state: Integer costName: String } class Asset { id: Long versionId: Long category: String name: String price: Decimal premium: Decimal premiumN: Decimal } class HeadCount { departmentId: Long type: String position: String rank: String region: String departure: Double hcEnd: Integer hc1: Integer hc2: Integer hc3: Integer hc4: Integer hc5: Integer hc6: Integer hc7: Integer hc8: Integer hc9: Integer hc10: Integer hc11: Integer hc12: Integer } class StandardEquipmentRaw { deptId: String employeeType: String position: String rank: String equipment1: String equipment2: String equipment3: String equipment4: String equipment5: String equipment6: String equipment7: String region: String } class StandardEquipment { deptId: String employeeType: String position: String rank: String equipment: String region: String } class BuyBackEquipment { deptId: String type: String position: String rank: String equipment: String price: Decimal } Department "*" --> "1" Version : versionId Asset "*" --> "1" Version : versionId HeadCount "*" --> "1" Department: departmentId StandardEquipment ..> StandardEquipmentRaw

a. 过程式 + 函数式 API 方案

这里有一个可 Run 的例子

b. 数据流方案

下面是一个用 LINQ 模拟 SQL 的方案,另一种带控制流和前面的类似。

c. 小结

  • 考虑到要像 OutSystems 和 Mendix 引入(一对一, 一对多,多对多)的关系,才能方便推断多实体的场景。但要注意x对x关系(cardinality/multiplicity)是 association 的子集,不能涵盖全部,是一种辅助手段。另外要注意多外键实体属性的配置
  • 该例中有一个控制流相对比函数式好理解的 case

3. 商品订单一对多、多对多等场景

o. 需求介绍

用户在创建了下面多个实体之后,想直接拖出来合适的增删改查页:

classDiagram direction RL class Category { id: Long parentId: Long name: String status: Boolean priority: Integer } class Brand { id: Long name: String logo: String status: Boolean detail: String } class Product { id: Long name: String categoryId: Long brandId: Long status: Boolean detail: String } class ProductSpecItem { id: Long productId: Long specs: String price: Decimal barcode: String image: String amount: Integer status: Boolean detail: String } class Order { id: Long orderNo: Long status: String totalCount: Integer totalAmount: Decimal createdTime: DateTime paymentId: Long customerId: Long remark: String } class OrderProductItem { id: Long orderId: Long productSpecId: Long count: Integer remark: String } class Payment { id: Long orderId: Long platform: String amount: String status: String } Category "*" --> "1" Category : parentId Product "*" --> "1" Category : categoryId Product "*" --> "1" Brand : brandId ProductSpecItem "*" --> "1" Product : productId OrderProductItem "*" --> "1" Order : orderId OrderProductItem "*" --> "1" ProductSpecItem : productSpecId Payment "1" --> "1" Order : orderId

目前我们实体属性配置了外键之后,默认拖出来的就类似一对一和多对一的处理。

  • 一种交互是:虽然一对多和多对一在数据库关系上是一样的,但为了表示是否带出子级,引入一对多和多对一做区分,然后自动做推断。参考 TypeORMJHipster 等框架;
  • 另一种交互是:每次拖拽出实体的时候,让用户勾选连带的实体。
拖拽意图与周边实体的关系表格表单详情选择框
拖拽 CategoryCategory多对一Category树型表格
或某一级的单层表格
表单带选择父级选择框单层详情
可能带出父级文本+子级表格
树型选择框
拖拽 Brand单层表格单层表单单层详情单层选择框
拖拽 ProductProduct多对一Category
Product多对一Brand
Product一对多ProductSpecItem
带出 Category, Brand
可能带出 ProductSpecItem 的细节
带出 Category, Brand 选择框
可能带出同步添加修改 ProductSpecItem 的表格
带出 Category, Brand
可能带出 ProductSpecItem 的细节
单层选择框
拖拽 ProductSpecItemProductSpecItem多对一Product单层表格单层表单单层详情单层选择框
拖拽 OrderOrder一对多OrderProductItem
Order一对一Payment
带出支付信息带出商品批量表格带出商品和支付信息单层选择框
拖拽 OrderProductItemOrderProductItem多对一Order----
拖拽 PaymentPayment一对一Order带出订单信息-带出订单信息单层选择框

a. 自定义视图模型方案(一对多)

创建商品时,希望一并创建商品规格。

问题点
  • 表格、表单、详情变成了绑定一致。万一表单和详情想显示子项,表格不想显示子项,强制带出来会做多余的计算
优点
  • 针对商品+规格项这类场景,增删改查封装在了一块。

b. 数据流方案(一对多)

a. 自定义视图模型方案(多对多)

创建订单时,希望一并创建订单商品项。

问题点
  • 这里缺点比较明显,从实际场景,增删改查的几种结构都不太想约束在一种数据结构上。

b. 数据流方案(多对多)

4. 树型列表展示

树型实体是一种特殊的自关联实体,这边单独展示讲:

a. 自定义视图模型方案

b. 数据流方案

五、基本结论

  • 自定义视图模型方案不是很可取,主要原因是:
    • 60-70%的场景读写的结构是分离的,约束在一起反而增加了负担
    • 不能很方便的表达传递页面上下文的查询需求(原来至少还是个函数,用户可以自定义输入参数)
  • 数据流方案更接近用户意图
    • 数据库查询和内存查询对用户来说只要学习一套操作 API
    • 分页和树 LazyLoad 等需求可以在组件中追加,开发者负担小
    • 最重要的一点是查询流和表单流很清晰,前端组件是一种显式依赖。另外私有查询可以下放到组件单元上打开,公共的用户再自建逻辑

1. 数据流方案的主要技术依赖项

语言相关

  • 引入 LINQ 支持数据库查询和内存查询的设计
  • LINQ 分析数据库计算和内存计算,前端计算和后端计算
  • 嵌套结构和 Mendix 固定的一对多、多对多模式都不是很灵活,采用 structural typing 解决这类问题:{ assetBudget, asset, totalCount, totalAmount }

前端组件相关

  • 页面中引入区块或套件的概念(类似子页面),能做局部命名空间管理,同时也能访问上层命名空间
  • 组件下支持挂变量,减少中间变量
  • 针对组件的接口再做一些优化,特别是原来表单没有做抽象,应该把 submit 等行为封装起来。按钮行为可以根据所在位置做推断
  • 前端异步依赖的分析能力
  • 组件事件的 pipe 机制(可选)

2. 当前问题的解决策略

a. 实体拖拽和替换问题(来自内部和工行)

  1. 拖拽一个实体就会添加多个逻辑和数据结构,复用性差、负担很重
    • 数据结构通过 Structural typing 解决
    • 根据前面数据流的分析:多个逻辑并不一定要直接干掉,建议是由页面需求出发产生一个链路的展示引导用户快速定位具体逻辑。全局逻辑部分私有,不要都挂在一颗树上,类似事件逻辑那种形式
  2. 已拖拽出的实体要替换实体或属性,变更起来很粘滞
    • IDE 增加字段和原实体的联动功能
  3. 带有复杂关系的多个实体需要在同一个页面完成增删改查,需要很多修改的步骤,原来拖出来的也不好用

b. 常见的典型问题(下面是资产预算中遇到的)

  1. 页面表格如何更方便的添加字段,比如额外的一些 format 或计算字段?而不需要考虑多个地方的修改
    • 目前可以用去 PageOf + transform 和批量赋值的函数来解决
    • 后面引入了 Structural typing 可以添加个 with 函数更方便
  2. 表格分页请求和合计总数的数据怎么一起处理问题
    • 目前得按两个逻辑去解决
  3. PageOf 很难用
    • 前端组件直接调用两个后端逻辑
    • 引入 Structural typing{ list: List<T>, count: Integer }
  4. ScopeOf 非常难用
    • 表格中隐藏 scope.item
    • 框架帮用户析构出来,用户不感知
  5. 树型列表等组件构建数据的 Builder 机制
    • 提供高阶函数构建树的形式
    • 提供函数模板

c. 不必要的理解成本

  1. 前后端多层逻辑调用、多层变量等
    • 多层变量用组件下挂变量的方式解决

d. 页面表格和 Excel 的导入导出汇总点问题

  1. 相同数据的页面展示、Excel 导入导出的汇总处理