浅析前端 DDD 框架 Remesh

大厂技术  高级前端  Node进阶

点击上方 程序员成长指北,关注公众号

回复1,加入高级Node交流群

1. 什么是 DDD

DDD(Domain-Driven Design):领域驱动设计。首先需要了解,所谓的「领域」,其实不仅仅在于程序表现形式,更适合说是对特定业务的描述,通常由该业务的垂直协作方共同确定,比如产品需求、系统架构、程序代码,由一群“专业的”人承接,这意味着其中的每一个人,可能都是该「领域」内的专家,而「领域模型」成了他们之间的「通用语言」,或者说,「领域知识」让彼此能够坐在一起讨论问题,再换句话说,产品也可以使用此通用语言来“组织代码”。这也是 DDD 的战略意义。

e595cb99579006f137258fd9ead67ebb.png

MVC 与 DDD

这里有必要谈及一下后端传统的 MVC 架构,通常会采用一种「贫血模型」,即将「行为」和「状态」拆分至不同的对象中,也就是我们常说的 POJO 和 Service,这样做的好处是,在开发业务代码时,心智负担较小,对于简单业务效率很高。往往开发人员会形成一种惯性思维,接到需求时,先三下五除二,把实体和服务先定义好。可是我们仔细想想,这种方式其实违背了面向对象的本质,将一个对象的状态和行为强行拆分,变成了面向过程开发,Service 中的逻辑随着业务复杂度提升,变得失控。当业务复杂度膨胀至一定程度,甚至会产生牵一发而动全身的风险。

而 DDD 带来的「充血模型」,完全规避了这个问题,POJO 和 Service 变成 Domain,可以理解为高内聚(对于 Domian 的设计不是本文导论的重点),Domain 的 State 仅由其 Action 完成,禁止任何外部的直接修改,将业务风险收敛至领域内部,一切将变得井然有序。

DDD 优势

  • 业务层面:基于领域知识的通用语言,快速交付,极低的协作成本

  • 架构层面:利于结构布局,利于资源聚焦

  • 代码层面:复杂度治理

DDD 弊端

主要在于战术层面

  • 心智负担大,对现有业务拆解成本大,对团队要求较高

  • 缺少战术意义上的最佳实践,从头造轮子难度较大

  • 简单业务下,效率很低(缺少开箱即用的框架)

对前端的思考

DDD 近几年在后端的落地颇有成效,社区也产出了较多的相关文章,如微软的《Tackle Business Complexity in a Microservice with DDD and CQRS Patterns》(https://learn.microsoft.com/en-us/dotnet/architecture/microservices/microservice-ddd-cqrs-patterns/) 。DDD 带来的战略意义,前端往往也能深受启发。DDD 概念早在 2003 年就已提出,也是近几年随着技术和架构的演进重新回到人们视野,并且发光发热。那么,我该什么时候使用 DDD 呢?

  • 存在复杂的业务及领域概念(如:商品、订单、履约等),代码复杂度与业务复杂度成正比,好的领域模型可以极大地降低代码复杂度,避免牵一发而动全身。

  • 想复用业务逻辑(如:多端实现),将逻辑抽离至领域层,可以帮助业务快速实现。

2. DDD 示例

通常一个商品有以下几种场景:创建、编辑、上架、审核、撤回,其对应的状态有,草稿、审核中、已上架,如果我们用传统的逻辑去写,往往是以下代码:

class Goods {
  private isDraft: boolean; // 是否草稿
  private isAuditing: boolean; // 是否审核状态
  private isPublished: boolean; // 是否已上架
  private info: any; // 商品信息
  // 状态初始化
  constructor(info: any) {
    this.isDraft = true;
    this.isAuditing = false;
    this.isPublished = false;
    this.info = info;
  }
  // 编辑操作
  edit(info: any) {
    if (!this.isDraft) {
      throw new Error('仅草稿状态下可编辑');
    }
    this.info = info;
  }
  // 上架操作
  publish() {
    if (!this.isDraft) {
      throw new Error('仅草稿状态下可上架');
    }
    this.isAuditing = true;
    this.isDraft = false;
  }
  // 审核操作
  audit(result: boolean) {
    if (!this.isAuditing) {
      throw new Error('仅在途状态下可审核');
    }
    if (!result) {
      this.isDraft = true;
    }
    this.isPublished = result;
    this.isAuditing = false;
  }
  // 撤回操作
  revert() {
    this.isAuditing = false;
    this.isDraft = true;
  }
}

这种写法只能说,无功无过,现实场景中,一个商品的状态可能非常复杂,那这个类的逻辑代码也将会变得十分庞大且复杂,那我们该如何使用 DDD 来对其进行逻辑抽离呢?且看以下写法:

// 草稿商品
class DraftGoods {
  private info: any;
  constructor(info: any) {
    this.info = info;
  }
  // 可以编辑
  edit(info: any) {
    this.info = info;
  }
  // 可以上架
  publish() {
    return new AuditingGoods(this.info);
  }
}
// 审核中商品
class AuditingGoods {
  private info: any;
  constructor(info: any) {
    this.info = info;
  }
  // 审核成功或失败
  audit(result: boolean) {
    if (result) {
      return new PublishedGoods(this.info);
    } else {
      return new DraftGoods(this.info);
    }
  }
  // 可以撤回
  revert() {
    return new DraftGoods(this.info);
  }
}
// 已上架的商品
class PublishedGoods {
  private info: any;
  constructor(info: any) {
    this.info = info;
  }
  // 获取商品信息
  getInfo() {
    return this.info;
  }
}

经过上述转化,我们将「有多个状态的实体」,变成「多个有状态的实体」,这样做的好处是,我们仅需要关心不同状态下,实体的业务逻辑操作,将代码聚焦于业务实现,真正做到了领域知识的表达,便于横向扩展。 前文说到,DDD 的战略价值目前是大于战术价值的,根本原因是目前社区缺少成熟的框架或轮子,开发者若能只专注领域模型的构建,其他的交给框架来处理,才能充分发挥 DDD 的战术优势。Remesh 便是前端 DDD 实践的产物,且看改造示例:

// 草稿商品
type DraftGoods = {
  type: 'DraftGoods';
  content: any;
};

// 审核中商品
type AuditingGoods = {
  type: 'AuditingGoods';
  content: any;
};

// 已发布商品
type PublishedGoods = {
  type: 'PublishedGoods';
  content: any;
};

// 商品领域模型
export const GoodsDomain = Remesh.domain({
  name: 'GoodsDomain',
  impl: (domain) => {
    // 商品状态,初始化草稿状态
    const GoodsState = domain.state({
      name: 'GoodsDomain',
      default: {
        type: 'DraftGoods',
        content: null
      }
    });

    // 商品查询
    const GoodsQuery = domain.query({
      name: 'GoodsQuery',
      impl: ({ get }) => {
        // 返回商品信息
        return get(GoodsState());
      }
    });

    // 编辑命令
    const EditCommand = domain.command({
      name: 'EditCommand',
      impl: ({}, info: DraftGoods) => {
        // 返回更新后的商品
        return GoodsState().new(info);
      }
    });

    // 上架命令
    const PublishCommand = domain.command({
      name: 'PublishCommand',
      impl: ({ get }) => {
        const info = get(GoodsState());
        // 上架后,返回审核中的商品
        return GoodsState().new({
          ...info,
          type: 'AuditingGoods'
        }) as AuditingGoods;
      }
    });

    // 审核命令
    const AuditCommand = domain.command({
      name: 'AuditCommand',
      impl: ({ get }, result: boolean) => {
        const info = get(GoodsState());
        if (result) {
          // 审核成功,返回已发布的商品
          return GoodsState().new({
            ...info,
            type: 'PublishedGoods'
          }) as PublishedGoods;
        } else {
          // 审核失败,返回草稿的商品
          return GoodsState().new({
            ...info,
            type: 'DraftGoods'
          }) as DraftGoods;
        }
      }
    });

    // 撤回命令
    const RevertCommand = domain.command({
      name: 'RevertCommand',
      impl: ({ get }) => {
        const info = get(GoodsState());
        // 撤回后,返回草稿的商品
        return GoodsState().new({
          ...info,
          type: 'DraftGoods'
        }) as DraftGoods;
      }
    });

    return {
      query: {
        GoodsQuery
      },
      command: {
        EditCommand,
        PublishCommand,
        AuditCommand,
        RevertCommand
      }
    };
  }
});

3. Remesh

基于 CQRS 模式的 DDD 在前端的落地框架,仅关心业务逻辑,若是想在前端尝试领域划分,Remesh 不失为一种选择。

CQRS

CQRS(Command Query Responsibility Segregation):命令查询职责分离,也就是读写分离。命令是指会对实体数据发生变化的操作,如新增、删除、修改;查询即字面理解,不会对实体数据造成改变。

通常而言,采用 CQRS 模式带来的性能收益是巨大的,而收益也往往带来挑战,比如要保证数据同步高可用,读写模型设计的双倍工作量等。CQRS 是对 DDD 的一种补充,并且有几种子模式来实现:单服务/多服务、共享模型/不同模型、共享数据源/不同数据源,根据使用场景决定。

核心概念

Domain(领域),可以简单理解为关于业务逻辑的 Component

  • State:Domain 的状态

  • Query:查询 States

  • Command:更新 States

  • Event:Domian 中可能发生的事件

  • Effect:副作用,用于发送 Query 或 Command 乍一看这结构,你会不会觉得与 Redux 等框架有点像?待我们查看其源码来做判断。

源码实现分析

我们以官网提供的 React 示例来看 Define your domain(https://github.com/remesh-js/remesh#define-your-domain)

npm install --save remesh rxjs

可以明确了解到,remesh 采用 RxJS 进行事件分发与数据流转,这也意味着,remesh 的本质在于对数据操作的高度抽象,利用 RxJS 的能力达到数据更新的效果。 我们来看 Remesh.domian 的定义:

  • Remesh.domain => RemeshDomain

  • RemeshDomain => RemeshDomainAction

let domainUid = 0
export const RemeshDomain = <T extends RemeshDomainDefinition, U extends Args<Serializable>>(
  options: RemeshDomainOptions<T, U>,
): RemeshDomain<T, U> => {

  // 优化策略:缓存无参数时的 RemeshDomainAction 实例
  let cacheForNullary: RemeshDomainAction<T, U> | null = null
  const Domain: RemeshDomain<T, U> = ((arg: U[0]) => {
    if (arg === undefined && cacheForNullary) {
      return cacheForNullary
    }
    const result: RemeshDomainAction<T, U> = {
      type: 'RemeshDomainAction',
      Domain,
      arg,
    }
    if (arg === undefined) {
      cacheForNullary = result
    }
    return result
  }) as unknown as RemeshDomain<T, U>

  // 定义 RemeshDomain 相关信息,可选参数 name, impl 等
  Domain.type = 'RemeshDomain'
  Domain.domainId = domainUid++
  Domain.domainName = options.name // 领域名称
  Domain.impl = options.impl as (context: RemeshDomainContext, arg: U[0]) => T // 具体业务实现
  Domain.inspectable = options.inspectable ?? true

  return Domain
}

接着是在前端框架中通过 hook 的方式引入 useRemeshDomain(CountDomain())

export const useRemeshDomain = function <T extends RemeshDomainDefinition, U extends Args<Serializable>>(
  domainAction: RemeshDomainAction<T, U>,
) {
  // 获取注入的 store
  const store = useRemeshStore()
  // 若 domainAction 不在暂存的集合中,会调用 createDomainStorage 进行创建,且看下文
  const domain = store.getDomain(domainAction)

  // React
  useEffect(() => {
    // 当 domain 被激活后,事件订阅开始生效,会通过 RxJS 进行事件分发
    store.igniteDomain(domainAction)
  }, [store, domainAction])

  // Vue
  onMounted(() => {
    store.igniteDomain(domainAction)
  })

  return domain
}

为了更好的解释,上述代码在 Remesh 的源码基础上稍加改动,可以看出,其主要针对不同框架的实现做了适配

// remesh-react.tsx
export const useRemeshReactContext = () => {
  const context = useContext(RemeshReactContext)

  if (context === null) {
    throw new Error(`You may forget to add <RemeshRoot />`)
  }

  return context
}

export const useRemeshStore = (): RemeshStore => {
  const context = useRemeshReactContext()
  return context.remeshStore
}

// remesh-vue.ts
export const useRemeshStore = () => {
  const store = inject(RemeshVueInjectKey)

  if (!store) {
    throw new Error('RemeshVue plugin not installed')
  }

  return store
}

可以清晰的看到,React 采用了 Context 的方式注入 store,而 Vue 采用 Provide Inject 的方式注入 store,现在我们对 React 实现进行分析,来看以下 <RemeshRoot> 的实现

export const RemeshRoot = (props: RemeshRootProps) => {
  // 可自定义 store
  const storeRef = useRef<RemeshStore | undefined>(props.store)

  if (!storeRef.current) {
    // 通常情况下会创建一个无 options 的 store
    storeRef.current = RemeshStore('options' in props ? props.options : {})
  }

  const store = storeRef.current

  const contextValue: RemeshReactContext = useMemo(() => {
    return {
      remeshStore: store,
    }
  }, [store])

  return <RemeshReactContext.Provider value={contextValue}>{props.children}</RemeshReactContext.Provider>
}

可以看一下 RemeshStore  的结构,俨然是一个 store 管理工具,实时也确实如此,remesh 后续的状态查询以及更新都是基于 store 库执行。

export const RemeshStore = (options?: RemeshStoreOptions) => {
  // ...

  return {
    name: options.name,
    getDomain, // 接上文,在 useRemeshDomain 中会调用此方法
    igniteDomain,
    query: getCurrentQueryValue,
    send,
    // ...
  }
}

调用 getDomain 时会优先判断 domainAction 是否存在于缓存中,若不存在则创建,关键的来了,我们定义在 Domian 中的 impl 方法,此时会被调用。

const createDomainStorage = <T extends RemeshDomainDefinition, U extends Args<Serializable>>(
    domainAction: RemeshDomainAction<T, U>,
  ): RemeshDomainStorage<T, U> => {
    // ...

    // domain 上下文对象,标准化创建过程,也是可以链式操作的原因(代码已简化)
    const domainContext: RemeshDomainContext = {
      state: (options) => {
        return RemeshState(options)
      },
      query: (options) => {
        return RemeshQuery(options)
      },
      event: (options: Omit<RemeshEventOptions<any, any>, 'impl'> | RemeshEventOptions<any, any>) => {
        return RemeshEvent(options)
      },
      command: (options) => {
        return RemeshCommand(options)
      },
      effect: (effect) => {
        if (!currentDomainStorage.ignited) {
          currentDomainStorage.effectList.push(effect)
        }
      },
      // ...
    }

       // domain 中 query, command, event 的具体实现
    const domain = toValidRemeshDomainDefinition(domainAction.Domain.impl(domainContext, domainAction.arg))

    // domain 的存贮对象
    const currentDomainStorage: RemeshDomainStorage<T, U> = {
      id: uid++,
      type: 'RemeshDomainStorage',
      Domain: domainAction.Domain,
      get domain() {
        return domain
      },
      arg: domainAction.arg,
      domainContext,
      domainAction,
      effectList: [],
      ignited: false,
    }

    // ...

    return currentDomainStorage
  }

关于 store 中 send、query 实现的基本状态更新以及事件机制,由于篇幅有限,不再展开描述,相信以上内容已经达到抛砖引玉的效果。

4. 总结

Remesh 采用了一种独特的方式,使 DDD 能够在前端得以应用,为了达到这一效果,开发者可能会需要适应不同的代码风格,需要熟悉领域模型的设计。

由于项目处于起步阶段,目前仍在迭代中,不建议在生产环境使用,或者说 DDD 在前端的战术设计仍未有最佳实践,但 Remesh 带来的意义是非凡的,实现方式可能多样,解决的问题永远只会是同一个,我们始终在代码优化的路上艰难前行,DDD 将来未必是一条歧路。

参考链接

用DDD(领域驱动设计)和ADT(代数数据类型)提升代码质量(https://zhuanlan.zhihu.com/p/475789952)

remesh(https://github.com/remesh-js/remesh)

从MVC到DDD的架构演进(https://zhuanlan.zhihu.com/p/456915280)

Node 社群



我组建了一个氛围特别好的 Node.js 社群,里面有很多 Node.js小伙伴,如果你对Node.js学习感兴趣的话(后续有计划也可以),我们可以一起进行Node.js相关的交流、学习、共建。下方加 考拉 好友回复「Node」即可。

   “分享、点赞、在看” 支持一波👍
  • 0
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值