干货 | 用DDD(领域驱动设计)和ADT(代数数据类型)提升代码质量

tarts:时序抗攻击类型系统 挞 这是一个旨在构建可证明不受时间攻击影响的系统的项目。 这是通过使用依赖类型和 Curry-Howard 同构让程序本身作为其时间恒定性的证明来实现的。 目前唯一编写的函数是相等,证明只是执行相同数量的基本布尔逻辑运算,但这个概念可以扩展到任何可以表示为原始布尔逻辑运算序列的函数。 笔记: 现在一切都相当糟糕,因为这仍然是非常早期的 pre-alpha 在 THOTCON 2015 上接受展示! 立即下载

作者简介

 

工业聚,携程高级前端开发专家,react-lite, react-imvc, farrow 等开源项目作者。

很多开发者都有一个迷思,认为项目里的代码质量和可维护性的持续下降,主要根源在于时间紧迫、需求变动频繁。如果产品需求更加明确,并给予足够的开发时间,开发团队可以长期保证代码质量和可维护性。

今天介绍的DDD(领域驱动设计)和ADT(代数数据类型)模型,给出了另一部分的答案:代码质量持续下降,开发团队也要负主要责任。

如果没有采用更合理的开发模型,项目的代码质量将随着时间和复杂度的增加而急剧下降。再明确的产品需求,再多的开发时间,也很难阻止代码库的腐坏。

由于 DDD 和 ADT 都是很大的主题,很难在一篇技术文章中充分展开。因此,本文主要简要介绍它们之间的关系以及背后的思想,希望能够带给大家一些启发。感兴趣的同学,后续可以查阅更深入的材料。

主要内容分成以下几个部分:

  • 代码质量的评估方式

  • DDD 的概念与定义

  • ADT 的概念与定义

  • 案例:用 DDD + ADT 做数据建模

  • 案例:用 DDD + ADT 做流程建模

  • 总结

一、代码质量的评估方式

067ea8f78580808d1b1fe9fbc746c24d.png

不管做哪种提升和优化,最关键的起始步骤总是寻找衡量的标准和方法。提升代码质量也不例外。

1.1 代码质量的评估标准

  • 可拓展性(Extensibility)

  • 可维护性(Maintainability)

  • 可读性(Readability)

  • 可测试性(Testability)

  • etc.

上述指标是在一个相对宏观的层面评估代码,偏向定性指标。

给定两段代码,开发可以判断出哪一个可拓展性更好,但具体好多少,却不容易有一致的判断。特别是可读性这个指标,非常依赖开发者的经验和主观偏好。

有一些定量指标,比如一些 Lint 规则、循环引用检测等,对提升代码质量也有帮助。但这类定量指标的不足之处在于,它们在一个非常微观的层面做代码评估,并不关心代码要解决的问题是什么。

这类定量指标,容易失效甚至带来反效果。对处理具体问题的帮助也有限,主要服务于统一代码风格、约束写法、规避常见反模式。

我们希望有一种评估标准,既能在宏观定性层面帮助我们考虑问题,又能在微观定量层面帮助我们解决问题。

1.2 几种代码质量的提升方法

  • SOLID Principles

  • Clean Code Principles

  • Design Patterns

  • Low Coupling, High Cohesion

  • etc.

有很多编程原则可以提升代码质量,其中最著名的两个或许是 SOLID 和设计模式 (Design Patterns),它们已经出现至少二三十年,在今天依然被开发者们津津乐道。

单一职责、开闭原则、接口分离原则、观察者模式、订阅模式、解释器模式、装饰器模式、低耦合、高内聚……这些词汇在程序员群体里耳熟能详,历久弥新。

不过:

  • 有些开发者认为编程语言已经发展出了很多新特性,很多问题已经变了,原始的设计模式已经不适用于现在的情况。

  • 很多开发者发现,严格地遵从和推广这些编程原则,反而让代码更加难以理解、难以维护。

大家发现了编程原则背后的认知偏差——相关性不等于因果。

从高质量的代码中提炼的特征,跟代码质量之间有一定的相关性,但不意味着是因果关系:高质量代码都有某些特征,不意味着有某些特征的代码就是高质量的代码。

11eaa217e72652b8647b4384df6efea8.png

如上图所示,假设最大的圈是整个代码宇宙,里面的小绿圈是优质代码,绿圈之外则是低质量代码,黄圈则是符合 SOLID 等编程原则的代码。

我们可以看到,在这种关系中:

  • 优质代码大部分具备 SOLID 等编程原则的特征(所以我们能从中提炼出 SOLID特征)

  • 但还有更多符合 SOLID 等特征的代码是低质量代码

  • 有一小部分代码不符合 SOLID 特征,但也是优质代码

因此,盲目地在代码库里推广某种代码特征,往往会带来反效果。

1.3 当前代码指南的不足之处

目前的代码质量评估模型和代码指南,仍有以下几个不足之处:

  • Subjective,依赖开发者主观经验

  • Unclear,表述模糊,不够清晰

  • Hindsight,对已写就的代码做事后评估,对写代码本身缺乏建设性指导

  • Imprecise,不够精确,不够准确

  • External,围绕代码表面的形式,忽视问题的本质特征,或者假设问题已经被解决

  • etc.

与上面的不足相对的,我们可能想要的是:

  • Objective,更加客观的,所有理性的开发者都有一致的认知

  • Clear,表述清晰明确

  • Insight,在写代码之前或写代码之时就能帮助洞察问题

  • Precise,精确的代码评估标准

  • Internal,围绕问题本质出发,不仅仅是代码的编写形式

  • etc.

优雅的代码,不只是某种写法或者编程技巧,而是更深入地认识问题的本质后,所带来的副产品。

因此,代码怎么写,怎么写出高质量的代码,离不开提高对问题的理解水平。

编写高质量代码的指导模型,不能只关注代码怎么写的这一阶段,还需要有怎么认识问题的前置阶段。

二、DDD 的概念与定义

8fb7ad15c266f58cdc1aac088fffe411.png

DDD(领域驱动设计)是2003年开始流行的一种开发模式,它的含义非常广。

领域驱动设计,不只是关注代码怎么写,更关注写代码之前的活动,比如产研流程和协作。

偏向写代码的 DDD,叫战术型 DDD;偏向产研流程和协作的 DDD,叫战略性 DDD。但它们的核心是一致的,后面我们会展开讨论。在此之前,让我们看看,朴素的产研模式。

2.1 朴素产研模型:需求驱动设计

d4a97a1ae57b91fb7589c7bf577823aa.png

我们可以把这种模式称之为——需求驱动设计。

它的一般过程是,产品经理跟业务同事们沟通以及做市场调研分析,挖掘出产品需求,并制作成产品需求文档(PRD),然后开发根据 PRD 中的需求描述,提供代码实现。

在这种模式中,业务们有自己的业务用语,产品们也有自己的产品用语,开发工程师有自己的技术用语。它们之间有一定的关联,但并不足够紧密和强烈。

不会有人要求开发写代码时一定要用业务用语里的名词和概念。

从领域驱动设计的角度看,这个模式的问题就在于,不像一个团队,而是三个团队。

开发之间主要通过技术用语交流,消费的是上游产品用语的材料。开发很难确定自己接到的需求,是不是业务真正的需求,自己理解的逻辑,跟业务同事理解的是不是一样的。

在这种情况下,开发会强烈希望需求是尽可能真实的、明确的、稳定的。开发会很反感上游交付的是不确定的需求,代码频繁修改。

当遇到跟产品难以通过沟通达成共识的时候,开发会想要直接跟业务沟通,了解业务的一手需求。

在需求驱动设计的模式里,需求的生命周期很短,基本上需求发布上线不久之后,它们就被遗弃了。很少有产品团队能够始终维护完整的产品文档,跟线上的功能一一对应。往往在产品经理换人之后,需要通过读代码来反推产品逻辑。

这里问题就来了,代码是在技术用语的上下文里编写的,它们其实不容易反映产品里的概念,更加不用说业务概念了。

2.2 领域驱动设计的核心思想与关键过程

308c25b81ed89c6ed519fce37ef092b9.png

领域驱动设计的产研模型里,所凸显的重点是——知识。

如果说,需求是关于 How、要怎么做的,知识就是关于 What 和 Why 的,是什么,为什么。

知识和需求,也不冲突。需求就来自知识。

我们也可以把领域驱动设计,叫做——知识驱动开发。它强调的是,从领域知识到代码实现的过程。

不管是战术型 DDD,还是战略性 DDD,或者其它 DDD 的方面,都围绕知识驱动开发这个核心思想展开。把握住了核心,不容易被 DDD 里诸多复杂概念所迷惑。

46559068fbfa49c509637bfbdff4ef04.png

领域驱动设计的关键过程,是解决团队中不同岗位和角色之间,交流语言不统一的问题。

上图中的领域专家,大部分情况下指业务人员。但也不尽然如此,DDD 里的领域专家,不是一个岗位,而是一个角色。在特定领域里更专业、懂得更多、更权威,他就成了领域专家的角色。

通过构建团队统一用语,开发可以更为确定产品需求是真需求,自己理解的逻辑,跟领域专家或业务同事们的理解是一致的。因为大家都用同一套词汇,相同的定义,彼此有共识基础。

领域驱动设计和需求驱动设计的差异,可以从它们的会议形式上管中窥豹。

c092c72a4e09e160d2aede7de5181509.png

需求驱动设计的会议,通常是产品经理将已完稿的 PRD 进行讲解,前置的知识提取、梳理和定义等阶段,大体已经成形。

82fd2306769a78bd681381008277bc59.png

而领域驱动设计的会议,有所不同。如上图是一种被称之为事件风暴 (Event Storming) 的会议模式。领域专家、开发工程师等多个角色共同在一块长条白板上,基于时间轴和事件进行领域知识的表达和建构。

在这个过程中,业务领域里的实体、事件、流程、关系等诸多概念被提出、定义和明确,在场所有人都对此有共识。每一次事件风暴会议形成的词汇和术语,都作为下一次会议或者工作交流中的统一用语和语料。

2.3 小结

  • 高质量的代码来自对问题的正确认知,很难在不理解问题的基础上优雅地解决问题

  • 代码的写法、风格、模式等手段,建立在正确的认知基础上才能达到最佳的效果

  • 忽视提高对问题的认知水平,盲目地运用代码技巧、设计模式,往往让代码更糟糕

  • DDD 的核心思想是,以领域专家为核心,建立统一用语,确保知识和需求的可靠传递

三、ADT 的概念与定义

采用领域驱动设计的战略部分,可以优化团队协作模式,确保从知识到代码的过程中,知识部分是基于良好定义的共识所得到的,需求因而更有概率是真实需求。

而领域驱动设计的战术部分,则是反过来:从代码到知识。

db1b4d8770ef1c3ebc1fe38346ccc162.png

简单地说,DDD 要求代码中必须使用“团队统一用语”里的词汇和概念。


代码应当忠诚地反映领域知识。代码里的变量名、方法名以及核心逻辑,应当反映领域知识里的定义。代码编写不是随意的,而是每一个掌握了领域知识的开发者,都能写出大体一致的代码,而非五花八门的多样性实现。

如果说需求驱动设计,要求代码满足产品需求的外延定义。即在输入/输出的功能层面满足产品需求。

那么领域驱动设计,要求代码满足领域知识的内涵定义。不仅在输入/输出的黑盒层面满足功能要求,在代码细节的白盒层面也满足领域知识的定义。

这要求我们必须知道,如何把领域知识翻译成代码实现。

代数数据类型(ADT, Algebraic Data Types)是支撑上述需求的关键编程特性。

3.1 领域和领域知识的定义

首先我们需要精确定义什么是领域(Domain),什么是领域知识(Domain Knowledge)。

  • Domain(领域)是一系列关联问题构成的集合

  • Domain Knowledge(领域知识)是一系列关联问题涉及的所有真命题 (true proposition) 的集合

3c21afb90f15a952fd168b5afdb6f2cd.png

所有领域,都由一些关键概念节点构成,领域知识则是这些概念节点之间的关系的定义,表达为逻辑上的真命题。

作为命题的领域知识,在产品需求里被表述为一系列领域规则/业务规则。

产研团队常说的业务逻辑,可以被翻译为更严格的数学逻辑的表达形式。

3.2 柯里-霍华德同构

用代码表达领域知识的原理——柯里-霍华德同构(Curry–Howard isomorphism)。

f6f12a4c18aa01e8c99102eb3d4bcb04.png

  • 命题即类型,证明即程序 (Propositions as Types, Proofs as Programs)

  • Type-Driven Development(类型驱动开发),用类型去表达领域知识(领域里的真命题)

  • 符合类型的所有值,都是该类型所表征的命题的证明 (Witness)

  • 真命题:至少有一个值的类型

  • 假命题:没有任何值的类型

柯里-霍华德同构,给出了将命题翻译为类型的方式,它提供了将领域知识翻译成代码实现的通用方式。

特别是对应着 And 关系的 Product Type,和对应着 Or 关系的 Sum Type,它们两个构成了代数数据类型(ADT)。

后续我们将演示,如何用 Product Type 和 Sum Type 做数据建模和流程建模,跟领域知识/业务规则一一对应起来。

值得一提的是,在柯里-霍华德同构中,真命题和假命题,并不是对应着 Boolean Type 的 true 和 false。而是类型所允许的值的数量(size),0个值为假命题,至少1个值为真命题。

也就是说,所有能运行的代码在这个意义上都是真命题,抛错等行为才意味着碰到程序的假命题。

3.3 Bug 的定义和高质量代码的判别标准

通过柯里-霍华德同构,Bug 的定义,以及高质量的代码的判别标准,也有了更清晰的陈述。

483f42d40e80c0f37856bb27bf89b144.png

在将领域逻辑翻译成代码逻辑的过程中,如果发生匹配错乱,领域里的命题跟代码里的命题不一致,Bug 就发生了。

  • 领域里的真命题(领域知识),代码里却是假命题 (Error/Crash/Halt)

  • 领域里的假命题,代码里却是真命题 (Illegal-States/Unexpected-Behaviors)

所有能跑的程序都是程序意义上的真命题,但不意味着是业务意义上的真命题,这种不匹配被称之为非法状态(Illegal-States)、非法操作(Illegal-Operations)或不预期的行为(Unexpected Behaviors),是应当被避免的。

c68be1ad059eb1b3176ee290ef9b8a9d.png

所谓的高质量代码,与 Bug 的定义相反,领域里的命题跟代码里的命题一致。

  • 领域里的真命题(领域知识),代码里也是真命题

  • 领域里的假命题,代码里也是假命题

这意味着,代码里运行着的程序和数据,需要都是有业务意义的。更少的非法状态、非法操作和不预期的行为。

3.4 类型论 (Type Theory) 的基础知识

In type theory, every term has a type. A term and its type are often written together as "term : type".

类型论和集合论在某些方面很类似,它们都是对合集 (collection) 的不同建模。在这里,合集 (collection) 是指一堆事物的聚合。

在类型论中,事物被称之为项 (term),它有且只有一个类型 (type)。而类型 (type),可以有零到任意多个项 (term)。其中一些典型的类型,列举如下:

  • 空类型 (Empty type),0 个项;

  • 单元类型 (Unit type), 1 个项;

  • 布尔类型 (Boolean type), 两个项,true 或者 false;

  • 自然数类型 (Natural Numbers type),无限多个项,从 0 开始。

  • etc.

如上,我们例举了一些基础类型,它们的 term 的数量也标记了出来,跟集合论里的元素数量类似。

3.5 代数数据类型 (Algebraic Data Types)

In type theory, an algebraic data type is a kind of composite type, i.e., a type formed by combining other types.

所谓的代数数据类型 (ADT),指的是多个类型组合成新的类型的方式,这些组合方式拥有一些代数特征。

由前面的柯里-霍华德同构,我们知道,逻辑的 Or,对应的类型是 Sum type,表达的是互斥的、或的关系。而逻辑的 And,对应的类型是 Product type,表达的是并存的,与的关系。

其中,Sum 含义是相加,Product是指相乘,描述的是 Sum type 和 Product type 的项的数量,跟它的组成部分的类型的项数量的关系。

  • Sum type: size(A | B) = size(A) + size(B)

  • Product type: size(A & B) = size(A) * size(B)

如上,size(..) 函数可以获取特定 type 所允许的 term 的数量。

那么,Sum type 对应的就是加法,因为 A 和 B 在这里是“或”的关系,是互斥的,不会一起出现,所以要么 A,要么 B。那所有可能性就是 A 的可能数量,加上 B 的可能数量。

// sum types
type Value = string | number;


const a: Value = 'John';
const b: Value = 70;

而 Product type 对应的是乘法,因为 A 和 B 在这里是“与”的关系,是并存的,会一起出现,所以既有 A,也有 B。那就进入 A 和 B 的组合可能性,每一个 A 都能搭配所有 B,有 A 个 B,即 A * B。

// product types
type Person = { name: string; age: number };
// type Person = { name: string } & { age: number }


const person: Person = { name: 'John', age: 70 };

如上,对象的字段之间是 product type 关系。product type 不是具体的某个类型,而是一系列类型,只要它们背后的 size 关系是乘法相关的。同理,sum type 指的是那些背后的 size 关系是加法相关的类型。

只需要了解到目前的程度,代数数据类型 (ADT) 已经可以优化我们的代码质量了。接下来,我们来看看它在数据建模和流程建模上的应用。

四、案例:用 DDD + ADT 做数据建模

假设有以下用户信息的领域规则(业务定义):

  • 用户要么是已登录用户,要么是未登录用户(游客)

  • 游客拥有随机的昵称

  • 已登录用户拥有昵称、Email 信息

  • Email 信息要么是已验证的 Email,要么是未验证的 Email

  • 已验证的 Email 有验证时间戳

  • 用户信息通过 Http API 获取

它们被描述为一组自然语言描述的规则,其实也可以提取出一些逻辑语言描述的命题。比如:

  • 登录用户拥有昵称——true,真命题

  • 未登录用户拥有 Email 信息——false,假命题

  • 未验证的 Email 有时间戳——false,假命题

  • etc

因此,领域知识未必是直白的逻辑语言,但它们总是可以提炼出逻辑命题,这些命题就是我们翻译到类型的指引。

4.1 常见的数据建模

type UserInfo = {
  // 当用户未登录时,id 为空字符串
  id: string;
  // 当用户未登录时,name 为随机生成的昵称
  name: string;
  // 当用户未登录时,email 为空字符串
  email: string;
  // 用户是否登录
  isLogin: boolean;
  // 当邮箱未验证时,这个字段为 false
  isEmailVerified: boolean;
  // 当邮箱已验证时,这个字段为验证时间戳,否则为空字符串
  emailVerifiedAt: string;
};


// Http API 获取用户信息
type JsonResponse = {
  error?: string;
  // 当 error 为空时,这个字段为用户信息
  data?: UserInfo;
};

本案例中的用户信息的数据建模,其实非常简单,大部分开发者都习以为常,很容易写出像上面那种代码,并且不觉得有任何问题。代码简洁、清晰并且直观,注释完整。即便不能说是优质代码,起码是不坏的代码。

4.2 基于 DDD + ADT 的领域类型建模

采用 DDD + ADT 的模型,用户信息的类型大致如下:

type VerifiedEmailInfo = {
  type: 'VerifiedEmailInfo';
  email: string;
  verifiedAt: string;
};


type UnverifiedEmailInfo = {
  type: 'UnverifiedEmailInfo';
  email: string;
};


type EmailInfo = VerifiedEmailInfo | UnverifiedEmailInfo;


type LoginUserInfo = {
  type: 'LoginUserInfo';
  id: string;
  name: string;
  emailInfo: EmailInfo;
};


type GuestUserInfo = {
  type: 'GuestUserInfo';
  name: string;
};


type UserInfo = LoginUserInfo | GuestUserInfo;


type ErrorResponse = {
  type: 'ErrorResponse';
  error: string;
};


type DataResponse = {
  type: 'DataResponse';
  data: UserInfo;
};


type JsonResponse = ErrorResponse | DataResponse;

我们一口气写了 9 个类型,看起来复杂了许多,更多的嵌套,更多的重复字段(如 email, name 等),一行注释都没有,代码行数也翻倍了。

然而,上面的代码,更加符合业务规则的描述,更加准确地匹配了领域知识里的 And 和 Or 的关系。如果说它看起来不如第一种简单,那这个复杂度也是领域知识里自身的复杂度,更简单的定义某种意义上是——过度简化。

此外,代码的复杂度,跟代码的长度,不是必然关系。表面上的简单,和实质的简单,也不是一回事儿。

4f2e5b5612368c2587454e3ef717b15e.png

表面上,左侧拥有更少的嵌套,更少的字段,更少的类型,更简短的代码,在形式上无疑是比右侧的更简单的。

9a12314a0ae4699e3d7517dd01c9e365.png

实际上,当我们把类型之间的关系标记出来时,我们发现,左侧其实拥有更多的乘法,而右侧则包含几个加法。多个乘法复杂度的类型,允许的 term 的可能性更多,size 更大。因此,左侧的 term size 大于右侧的。

4.3 非法状态对代码库的腐坏

当类型的命题空间,大于领域规则的命题空间,所多出来的部分,就是非法状态的空间。

非法状态将持续腐坏我们的代码库,从以下几个方面可见一斑。

1)非法状态鼓励错误的代码

const handleLoginUser = (userInfo: UserInfo) => {
  // 直接访问 email,而没有先判断是否 isLogin
  console.log('login user email', userInfo.email);
}

对象的字段之间是 product type 的并存关系,即便是未登录的用户,也有 email 字段,只是为空字符串。开发者需要主动的、自觉的记得判断是否登录,否则将产生错误隐患。

2)非法状态导致过度防御性编程,增加代码复杂度和代码量

const handleLoginUser = (userInfo: UserInfo) => {
  // 防御性编程,判断是否已登录
  if (!userInfo.isLogin) {
    return;
  }
  console.log('login user email', userInfo.email);
}


const handleLoginUser1 = (userInfo: UserInfo) => {
  // 防御性编程,判断是否已登录
  if (!userInfo.isLogin) {
    return;
  }
  console.log('login user email', userInfo.email);
}

在所有消费 UserInfo 的函数或者方法中,必须添加防御性逻辑,手动验证和排除非法状态,然后再消费数据。

也就是说,定义类型时所节省的复杂度,在类型消费的所有地方,都额外增加了防御性代码。数据的类型定义只有一处,但数据的消费却可以有很多处。相较之下,整体代码量更多,代码复杂度更高。

3)非法状态带来更多逻辑不同步

很多防御性判断,是在迭代过程中,一点点累积起来的。一开始往往没有收口到通用的函数里,它们重复地出现在多个消费数据的函数里。当需要更新某个防御性判断时,需要开发者记得在所有修改的地方,都改一遍。任何遗漏,都带来防御逻辑的不一致。

正因如此,Don't Repeat Yourself (DRP) 才作为一个最佳实践的指导原则被提出。在这个场景中,DRP 属于治标的做法,治本的做法则是从源头解决,用更精确的类型定义,减少不必要的防御性判断。

4)非法状态带来更差的性能

所有消费数据的函数和方法,都需要带上特定的防御性逻辑。尽管我们可以通过 DRP 原则,将重复的防御性逻辑,收口在一处。但它们仍需在各个消费函数中被调用。

这些消费函数彼此互相调用时,防御性逻辑将被重复执行。即便上一个函数已经调用过,下一个函数依然要进行防御。因为它无法判断是否会被独立的调用,它自身需要做好防御性逻辑的内聚。

因此,非法状态将带来更差的性能,数据验证工作在代码运行期间反反复复地被计算。

4.4 知识和代码同构的巨大收益

与非法状态相反,当代码里的类型更加忠实地反映领域知识,非法状态被减少或消除,它们难以被构造和传播。领域知识被编码到类型里,由 type-checker 进行约束。

从以下几个方面带来巨大收益:

1)拒绝错误的代码

0430c5f7565a89b1b8a7b96ec36a688b.png

UserInfo 是一个 Sum type,有两种可能性 LoginUserInfo 和 GuestUserInfo。其中 GuestUserInfo 完全符合领域规则描述,只有一个 name 属性,而没有 emailInfo。

因此,user.email 无法通过类型检查。必须先证明 user 属于登录用户,才能访问 emailInfo 属性。

9eaff6b942238090ca93f4f358db379e.png

如上,当 user.type === LoginUserInfo 时,我们可以访问 emailInfo。而 user.type === GuestUserInfo 时,emailInfo 是不能访问的。

当我们正确地用 Sum type 表达领域知识里的 Or 的关系,我们更难写出错误的代码。

2)减少不必要的防御性逻辑及其运算开销

1a0403ffc4c598675b346f9772186e55.png

我们可以直接使用 Sum type 中,我们感兴趣的部分类型,构造我们的函数。如上所示,处理登录用户时,我们直接用 LoginUserInfo 类型,不必在函数内部防御是否登录。LoginUserInfo 存在,已经意味着已登录。

handleLoginUser1 调用了 handleLoginUser,handleLoginUser2 又调用了 handleLoginUser1。它们都没有额外的防御性逻辑代码。

d9c4fe0125c880617df1ba591671bf86.png

最外部调用 handleLoginUser2 时,才进行 sum type 分流判断。某种程度上,可以理解为,防御性代码被隔离到最外部的函数调用中。内部函数编写和组合时,代码更短、更安全、更少冗余开销。

3)代码更加容易阅读和维护

相比传统模式,领域知识被放到注释里,描述字段之间的协同关系的业务含义。DDD + ADT 的领域知识,就在类型里。

我们不必去猜测 user.isLogin, user.name, user.emailInfo 彼此之间的依赖关系,猜测有多少种可能的组合和场景。

type EmailInfo = VerifiedEmailInfo | UnverifiedEmailInfo;
type UserInfo = LoginUserInfo | GuestUserInfo;
type JsonResponse = ErrorResponse | DataResponse;

我们可以很直观地看到,两种就是两种,没有注释也能正确理解。不仅开发者理解,编译器也理解,并在每次消费 Sum type 时,约束开发者使其难以忘记和误解。

4.5 小结

  • 领域知识里 Or 的关系,被曲解为 And,类型由加法复杂度,变成乘法复杂度

  • 代码上能写出来的值 (value) 的数量 (terms size),大于领域知识里的真命题的需求

  • 代码里的真命题(多出来的值),是领域里的假命题,它们成了非法状态 (Illegal-States)

  • 所有消费数据的地方,都需要做防御性判断,排除非法状态,否则就导致程序出现 BUG

  • 系统的可维护性,跟非法状态在代码库里的泄漏程度成反比,泄漏越多,越难以维护和预测

c90621281306c59677b7475a5796b590.png

不健康的代码状态空间,非法状态和副作用随机分布。需要靠开发者付出更多额外的努力、写更多的注释、更多的防御性代码……,才能缓解代码腐坏的进程。然而,没有从源头解决问题,治标不治本,最终非法状态的蔓延,将很容易超出开发团队的掌控能力。特别是在人员流失和更替的过程中,领域知识在上一任开发者的脑海里,随着上一任的离去而丢失,在下一任开发者在脑海里重新建立领域知识的过程中,代码库可能已经加速腐坏。

通过 DDD + ADT,我们可以构建更健康的代码状态空间,用更精确和反映领域知识的类型,将非法状态的防御性判断逐层隔离到边界。让我们的核心代码变得简单可靠,领域知识被编码到类型里,由编译器的 type-checker 进行保证。即便开发者产生更替,编译器依然可以做出正确的提示。

390c44942c1c6876e2c6573ad917de5e.png

通过 ADT 让非法状态无法被表示出来,从根源上优化代码质量。

五、案例:用 DDD + ADT 做数据建模

假设有以下领域规则:

  • 用户发帖有 3 个阶段:草稿、审核、发布

  • 草稿不能跳过审核直接发布

  • 草稿可以提交审核

  • 审核通过后可以发布

  • 审核中的帖子不能修改

  • 审核不通过退回草稿阶段

5.1 常见的流程建模

class Post {
  constructor(
    private isDraft: boolean,
    private isReviewing: boolean,
    private isPublished: boolean,
    private content: string
) {}
  edit(content: string) {
    if (!this.isDraft) {
      throw new Error('Post is not in draft stage');
    }
    this.content = content;
  }
  review() {
    if (!this.isDraft) {
      throw new Error('Post is not in draft stage');
    }
    this.isDraft = false;
    this.isReviewing = true;
  }
  publish() {
    if (!this.isReviewing) {
      throw new Error('Post is not in reviewing stage');
    }
    this.isReviewing = false;
    this.isPublished = true;
  }
  reject() {
    if (!this.isReviewing) {
      throw new Error('Post is not in reviewing stage');
    }
    this.isReviewing = false;
    this.isDraft = true;
  }
}

很多开发者很自然地编写出了上述代码逻辑。当调用 edit 方法编辑内容时,会先判断是否处于草稿阶段。每个相关方法内,都有状态验证。

问题在于,这种做法跟业务规则不是同构的。在业务规则中,编辑、审核、发布、退回等操作,不完全是并存的,而是随着草稿阶段、审核阶段、发布阶段而变化。但在 Post Class 中,edit, review, publish 和 reject 等方法并存,是 product type 的关系,没有忠实地体现领域知识。

因此,Post 的实例,存在很多非法操作 (Illegal Operations)。每一次方法调用,都需要调用者提前判断当前阶段,否则调用 edit 等方法将抛出错误。有意无意地忘记提前防御判断,Bug 将蔓延在代码库里。

当我们把各个方法里的防御性逻辑,从 throw error 改成静默,即只在符合条件时执行操作,否则什么都不做。那么,非法操作 (Illegal Operations)将变成不预期行为 (Unexpected Behaviors)。也就是说,所有方法调用,我们都不确定是否产生了效用,常常仍需额外的判断逻辑去确认。

非法操作一旦存在,不管以何种方式隐瞒不报,都会持续腐坏代码库。

5.2 基于 DDD + Class 的忠实流程建模

export class DraftPost {
  constructor(private content: string) {}
  edit(content: string) {
    this.content = content;
  }
  review() {
    return new ReviewingPost(this.content);
  }
}


class ReviewingPost {
  constructor(private content: string) {}
  publish() {
    return new PublishedPost(this.content);
  }
  reject() {
    return new DraftPost(this.content);
  }
  approve() {
    return new PublishedPost(this.content);
  }
}


class PublishedPost {
  constructor(private content: string) {}
  getContent() {
    return this.content;
  }
}

如上所示,我们定义了三个 Class,分别表达三个阶段的 Post,对于每个阶段,所允许的方法都精确对应了领域规则。只有 DraftPost 拥有 edit 方法,是可编辑的;只有 ReviewingPost 拥有 publish 方法,是可发布的。

2c2d453e8e395eceac9bf58213b13bfa.png

当我们获取到 DraftPost 实例,我们可以编辑它,但不能跳过审核直接 publish 发布它。

597a04618c995ea242ddca48f3b038db.png

当我们获取到 ReviewingPost 的实例,我们可以发布它,但不能编辑它。

ee2d68c8546a8497096aa307f5c83070.png

当我们获取到 PublishPost 的实例,我们既不能编辑,也不能重复发布它。

5148175abfcbcfb1c35c21e2691d54bf.png

业务规则描述的流程,被编码到各个阶段的 Post 的方法调用的传递中,由编译器的 type-checker 去约束。

5.3 基于 DDD + ADT 的忠实流程建模

type DraftPost = {
  type: 'DraftPost';
  content: string;
}


type ReviewingPost = {
  type: 'ReviewingPost';
  content: string;
}


type PublishedPost = {
  type: 'PublishedPost';
  content: string;
}


const edit = (post: DraftPost, newContent: string): DraftPost => {
  return {
    ...post,
    content: newContent
  }
}


const review = (post: DraftPost): ReviewingPost => {
  return {
    type: 'ReviewingPost',
    content: post.content
  }
}


const approve = (post: ReviewingPost): PublishedPost => {
  return {
    type: 'PublishedPost',
    content: post.content
  }
}


const reject = (post: ReviewingPost): DraftPost => {
  return {
    type: 'DraftPost',
    content: post.content
  }
}

除了用 Class 进行流程建模以外,面向数据的 ADT 也能做到,两者在流程建模的表达上是等价的。区别在于,数据和行为不再被放到一起,而是分开定义。但我们的 edit 只接受 DraftPost 数据,因而表达了只有草稿阶段才能编辑。review, approve, reject 等函数同理。

5.4 小结

  • 将互斥的操作放到一起并存,关系从 Or 变成了 And,从加法复杂度变成乘法复杂度

  • 代码上能调用的函数/方法的数量 (terms size),大于领域知识里的真命题的实际需求

  • 代码里的真命题(多出来的方法调用),是领域里的假命题,它们成了非法操作 (Illegal-Operations)

  • 所有调用方法的地方,都需要做防御性判断,排除非法调用,否则可能导致程序抛错和出 Bug

  • 系统的可维护性,跟非法操作在代码库里的泄漏程度成反比,泄漏越多,越难以维护和预测

ed9ce181b49cd6f2228cc251440f203f.png

采用 DDD + ADT 的模型,让非法操作不能被调用,从根源上优化代码质量。

六、总结

不管是用户信息的数据建模,还是 Post 的流程建模,都是很常见的业务需求。它们的逻辑并不复杂,甚至在本文中还做了简化。即便如此,我们可以看到,大部分开发者下意识的代码实现,都包含着诸多隐患。存在很多难以消除的非法状态,以及难以管理的非法操作,不断地侵蚀着代码库。

可以想象,随着项目迭代,持续泄漏的非法状态和非法操作,将指数级地增加项目复杂度,让代码库难以理解、难以阅读和难以维护。犯错是被鼓励的,领域知识被写在不可运行和检查的注释中,代码的性能、逻辑一致性等诸多指标得不到保障。

而 DDD + ADT 模式,可以从根源上改变现状,优化代码库的整体质量。

  • 运用领域驱动设计 (DDD),建立团队统一用语,获得可靠的领域知识,挖掘真实需求

  • 运用代数数据类型 (ADT),对领域知识进行一比一建模,获得可靠的代码设计

  • DDD+ADT:从知识中可以推导出代码,从代码中可以推导出知识,知识与代码的同构

  • 核心技巧:多用 Sum type,少用 Product type,减少非法状态和非法操作的泄漏

49583dba306fd6f2d576f999e77deb4a.png

不仅从产品功能和行为的外延意义上,满足了业务需求;从代码的逻辑细节等内涵意义上,也满足了业务知识的要求。

让我们再回顾一下,我们想要的代码质量提升模型:

  • Objective,更加客观的,所有理性的开发者都有一致的认知

  • Clear,表述清晰明确

  • Insight,在写代码之前或写代码之时就能帮助洞察问题

  • Precise,精确的代码评估标准

  • Internal,围绕问题本质出发,不仅仅是代码的编写形式

ADT+DDD 模式,更好地达到了上述目标。它们不仅仅是在代码工程领域的经验总结,背后其实有着一个世纪的学术积累与沉淀。

用 Phillip Wadle 的话来说,ADT 不是发明的,而是发现的。大部分开发者使用的编程语言及其特性,主要都是人类的发明。现在邀请大家来用人类所发现的语言特性。

ef65a61a080242fd8e9c712fab5d8426.png

  • 1874 年,康托尔建立集合论

  • 1901年,罗素发现集合论里的罗素悖论

  • 1903年,罗素用类型的思想,试图克服悖论,被视为类型论的起源之一

  • 1934~1969年,柯里和霍华德分别发现,类型论和逻辑演绎系统隐含的对应关系

  • 1936年,图灵发表图灵机模型

  • 1972年,C语言诞生

  • 1977~1980年,代数数据类型 (ADT) 出现在函数式编程语言中

  • 1995年,前端的 JavaScript 语言诞生

  • 2000年,SOLID Principles 被归纳发表

  • 2003年,领域驱动设计(DDD)被提出

  • 2012年,前端的 TypeScript 语言诞生(本文的示例语言)

  • 2015年,Rust 语言 1.0 版本发布

  • 2022年,今天,我们的文章发表时间点。

每一行 DDD + ADT 的建模代码,背后都承载着历史的厚重、闪耀着人类理性的光辉。

【推荐阅读】

ae2ac44f15052a7fb47a4a66f2746a9f.png

 “携程技术”公众号

  分享,交流,成长

B-Tree数据存储 SQLITE数据存储1. B-Tree的优势  在SQLite中数据库、表、索引都是通过B-Tree和B+Tree的形式进行存储的。如果采用传统的二叉树查找树、红黑树的查找深度一般为log2(N) \log{_2}(N)(其中N表示的是整个树的关键字的数目),在数据库中如果存储的资源过多,就会导致查找一个数据过程中出现较多的磁盘I/O操作,而过多的磁盘操作就会导致整个查找的效率较低,而基于二叉树的一 阅读详情

相关推荐

基于Mind+、MOTT实现人工智能

@基于Mind+、MOTT实现人工智能 人工智能实验 一、实验目的及要求 【一】实现人脸识别 【二】实现云数据库应用 【三】实现测温数据mqtt上传至物联网云平台 二、实验原理与内容 基于二哈实现多人人脸学习和识别 基于TinywebDB实现云数据库应用 基于EasyIoT实现mqtt数据上传。 三、实验软硬件环境 硬件:掌控板 软件:Mind+ 四、实验过程(实验步骤、记录、数据、分析) 1.基于二哈实现多人人脸学习和识别 ①在Mind+扩展上的传感器选项选择HUSKYLENS AI 摄像头 ②配置哈士

weixin_44606930的博客 2374

【软件开发方法论】领域驱动设计(Domain-Driven Design,简称DDD)

领域驱动设计(Domain-Driven Design,简称DDD)是一种面向对象软件开发方法论,强调将业务领域划分成为多个子领域,并在每个子领域中针对领域对象进行分析、设计和开发。DDD的核心思想是将软件开发过程中的重点从技术转向业务领域,在不同的领域中建立明确的边界,使得软件系统更加贴近实际业务需求。

AA2534193348的博客 2538

Aurix TC3XX Cache memory分析

Aurix Cache memory 分析

qwe0405080615的博客 4346

DDD领域驱动开发

文章目录DDD(Domain Driving Design)领域驱动开发架构对比微服务的问题战略设计(业务层面-业务架构)战术设计(技术层面-系统架构):DDD和微服务 DDD(Domain Driving Design)领域驱动开发 架构对比 单机架构:数据驱动架构 集中式架构:面向对象的方式(三层架构) 分布式微服务架构:应用之间解藕,解决单体用扩展性和弹性伸缩能力不足问题 微服务的问题 微服务粒度 怎样拆分 边界是什么 DDD 核心思想是通过领域驱动设计方法定义领域模型

沛沛呀、 4585

Isaacgym使用操作指南

Isaac Gym 是一个强大的仿真工具,特别适合那些需要进行大规模并行仿真和训练的机器人和强化学习任务。通过 GPU 加速、深度学习集成和丰富的物理仿真能力,Isaac Gym 能够显著提高仿真和训练效率,是机器人学和 AI 研究中的一大利器。

Tony Wey的博客 1万+

AOI检测总误判?五大常见问题与实战解决方案

通过对图像质量、检测程序、外界干扰、算法优化以及设备维护和校准的全面把控,企业可以有效降低误判率,提升AOI检测的准确性和可靠性,为工业生产提供更有力的质量保障。表现描述:在AOI检测时,元器件字符因印刷颜色深浅、模糊或受到灰尘干扰等差异,再加上元件库参数未根据实际生产优化,会使系统误将合格元件判定为不合格,严重影响生产效益和产品质量。优化元件库:着重检测关键差异字符,如型号编码等关键信息,剔除不必要字符的检测项,减少干扰,专注于关键识别的准确性。检测时,根据物料信息动态更新检测参数,使系统适应物料变化。

ALeader_AOI的博客 3494

DDD下的代码文档生成

一、背景 目前低代码生成领域非常火爆,而且市场价值在逐步上升,很多巨头都在重金投入。低代码的出现意味着程序员可以从大量重复低效的劳动中脱离出来,同时可以更好更快的支持业务解决实际问题,极大的提高了交付价值的效率。那么在DDD中实际上也有一些人尝试使用低代码的方式进行建模,意图将建模过程程序化,自动化,通过模板或者预设脚本得到建模结果。本章内容则重点讨论DDD下的代码文档生成的意义,以及这种思路将带给我们什么启示。 二、观点 2.1 观点1–手动构建 很多研究DDD的人都会遇到这个问题–是否使用低代码来降低建

程序男的专栏 973

RAGFlow源码解析-2、从源码构架ragflow v0.25.0镜像

本文详细记录了从零开始构建自定义RAGFlow镜像lt/ragflow:0.25.0的全过程。首先介绍了环境准备,包括检查Node.js、Docker版本和项目结构。然后详细说明了构建步骤:编译前端、创建自定义Dockerfile、构建镜像和验证。接着讲解了docker-compose配置修改要点,包括网络定义、MySQL依赖处理等。最后总结了部署过程中遇到的五个关键问题及解决方案,包括networks未定义、MySQL服务依赖、tiktoken文件缺失、数据库配置错误和端口映射问题。通过完整的操作流程和问

liutao841204的专栏 364

干货 | 降低复杂度提升效率,DDD在携程用车/租车订单系统重构中的实践

作者简介小白龙,携程资深后端开发工程师,关注架构落地、研发效能领域。随着历史业务不断迭代和业务场景越来越复杂,携程用车、租车(简称两车)面临历史技术债和系统复杂度越来越高带来的理解、维护、迭代困难等问题,我们开始寻求如何更有效的降低复杂度和提升效率的方法。本文描述了两车如何利用DDD(Domain-driven Design,领域驱动设计)方法论降低系统复杂度以及在重构历史系统中的取舍和思考。对于...

携程技术 2850

CA(载波聚合)

文章目录CA基本概念1. CA分类2. Pcell、Scell以及Serving CellPcell(Primary Cell)Scell(Secondary Cell)3. Pcell、Scell部署场景场景1场景2场景3场景44. CA的协议控制4.1 Camping/Connetion Establishment:4.2 CC Management/SCell Addition/Release:4.3 Handover/Measurements:4.4 SCell激活 CA基本概念 CA也就是我们中文

你又来看我了,一起学习吧 4万+

【飞控】UAV Toolbox Support Package for PX4 Autopilots安装与配置

安装配置UAV Toolbox Support Package for PX4 Autopilots硬件安装包

m0_58373406的博客 1613

日流量200亿,聊聊携程网关的架构设计

大家好,我是不才陈某~本文目录- 说在前面- 日流量200亿,携程网关的架构设计- 一、概述- 二、高性能网关核心设计- 2.1. 异步流程设计- 2.2. 流式转发&单线程- 2.3 其他优化- 三、网关业务形态- 四、网关治理- 4.1 多协议兼容- 4.2 路由模块- 4.3 模块编排- 五、总结- 说在最后:有问题可以找老架构取经- 部分历史案例日流量200亿,携程网关的架构设计方...

码猿技术专栏 1095

CloudCompare 二次开发(36)——非均匀体素降采样

使用CloudCompare与PCL编程实现的非均匀体素降采样。

点云侠的博客 745

领域驱动开发: 实例分析

1.背景介绍 领域驱动设计(Domain-Driven Design,DDD)是一种软件开发方法,它强调将业务领域的知识与软件系统紧密结合,以实现更有效的软件开发。DDD 是一种反应式、敏捷且具有可扩展性的软件开发方法,它主要面向复杂的业务领域,旨在提高软件系统的可维护性、可靠性和性能。 DDD 的核心思想是将业务领域的概念与软件系统的实现紧密结合,以便在设计和开发过程中更好地理解和解决问题。...

AI天才研究院 965

ssm框架搭建

这个是一个在idea上成功跑起来的ssm框架项目,保存在这里方便以后使用直接下载

IEM模型,地表粗糙度模拟

利用微波反演土壤水分时候,利用IEM模型可以实现对随机地表的模拟

DDD分架构模型介绍

同样的道理,当我们使用HTTP对服务的进行请求的时候,如果遵循了REST风格的架构风格,便可以获得由于使用了REST风格的HTTP所带来的好处。(Snapshot),但是对于请求量级比较大的情况,频繁的去创建快照也是非常消耗资源的,所以,我们可以自定义一个阈值(例如:事件数超过50个),当超过这个阈值的时候,我们在创建这个聚合状态的快照,从而获得最优的聚合创建与获取效果。,用户读取数据的时候,先查询缓存,如果不存在,再去查询模型的数据库中获取。,即:在展示数据后面,增加当前所展示的数据的创建时间。

学亮编程手记 3404

VB摄像头监控

该VB模块可对摄像头捕获的画面进行录像等,可在创建的窗口进行控制

DDD(领域驱动设计)总结

领域驱动设计总结基本概念:1.实体(entity):2.值对象(value object)3.聚合及聚合根(aggregate、aggregate root):4.工厂(factories):5.仓储(repositories):6.服务(services):7.domain事件8.DTO设计领域模型的一般步骤一些思考1. 建立完整自封闭的领域模型。2. 领域服务建模3.领域对象、领域服务以及repository之间的互相依赖 基本概念: 领域驱动设计(简称 ddd)概念来源于2004年著名建模专家e

SToNE的博客 1万+

IDEA的使用指南

IDEA基于java的使用手册,可以最快速的上手,在使用中不断的丰富自己的使用技巧.

DDD领域驱动模型设计

Domain Driven Design(领域驱动设计, DDD)是一种软件设计思想和方法论,以领域为核心构建软件设计体系,将业务模型抽象成领域模型进行拆解和封装。Domain Driven Design(领域驱动设计, DDD),不是一种架构,而是一种架构方法论,是一种拆解业务、划分业务、确定业务边界的方法,是一种领域设计思想。

Jin的随手笔记 2万+

架构设计快速入门——领域驱动设计(DDD)(C#)

在软件开发领域,为了更好地应对业务需求的复杂性和变化,开发者们一直在寻找更有效的开发方法。领域驱动设计(Domain-Driven Design,简称DDD)作为一种设计方法,强调了对业务领域的深入理解,并试图将这种理解映射到软件模型中。本文将深入探讨领域驱动设计在C#中的应用,介绍其基本概念、框架结构以及实际应用。

吾家有猫名探花的博客 5581

领域驱动开发(domain driven development)

链接:https://www.zhihu.com/question/56332619/answer/250971065 什么是领域驱动开发 将问题抽象为一个领域解决方案。并针对此领域(即抽象)进行开发的方式。 领域驱动开发解决了什么问题 解决两个问题1,变化。2,复杂度。 原则上适用于任何软件,特别适用于一些特别复杂,变化特别频繁的系统——尤其是迭代很快又很复杂的稳定

大树叶 技术专栏 3370

领域驱动设计

对领域设计进行一个整体上的介绍和分析

fpcc的专栏 993

DDD-领域驱动设计

目录一、前言二、DDD是什么?三、DDD的职责:复杂系统的应对:微服务架构:DDD架构:四、DDD相关概念:领域限界上下文(Bounded Context)统一语言五、DDD的实现:战略建模内容:划分限界上下文限界上下文之间的映射关系(Context Mapping)战术建模:内容:模块(Module):实体(Entity):值对象(Value Objects)聚合(Aggregate)领域服务(Domain Services)领域事件(Domain Events)代码分层架构:DDD落地应对的挑战:宏观上

java小兵 9083

Java 领域驱动开发(一)

领域驱动设计(DDD)提供了一套原则、模式和技巧,让领域专家、架构师、开发人员和其他团队成员可以采用这些原则共同工作,并将复杂系统分解成结构良好、协作和松散耦合的子系统。当埃里克·埃文斯在 21 世纪初引入这些概念时,从很多方面来看,这些原则远远超出了它们的时代。我们正处于单体架构的时代,面向服务的架构(SOA)作为一个概念刚开始生根发芽,而云、微服务、持续交付等甚至还未出现!虽然采用其战术方面相对容易,但 DDD 的战略方面在很大程度上仍被视为不必要的额外开销。

龙哥盟 1467

领域驱动DDD

第一阶段是单机架构:采用面向过程的设计方法,系统包括客户端UI层和数据库两层,采用C/S架构模式,整个系统围绕数据库驱动设计和开发,并且总是从设计数据库和字段开始。第二阶段是集中式架构:采用面向对象的设计方法,系统包括业务接入层、业务逻辑层和数据库层,采用经典的三层架构,也有部分应用采用传统的SOA架构。这种架构容易使系统变得臃肿,可扩展性和弹性伸缩性差。第三阶段是分布式微服务架构:随着微服务架构理念的提出,集中式架构正向分布式微服务架构演进。

一名小码农的博客 2325

领域驱动设计(DDD):软件开发的蓝图

领域驱动设计(Domain-Driven Design,简称DDD)是一种软件设计方法论,它强调的是基于项目的核心业务领域来进行软件设计和开发。DDD的目标是创建出既能反映复杂业务需求,又具有良好维护性和扩展性的软件系统。在这篇博客中,我们将深入探讨DDD的核心概念、实施策略以及如何在实际项目中应用DDD。

fudaihb的博客 1734

Java:DDD领域驱动设计深度解析(全览)

Java:DDD领域驱动设计深度解析(全览)

2401_83923080的博客 2048

最详领域驱动(DDD)实战方法

详解领域驱动DDD理论结合实战整个过程

工匠精神coding,终生学习 1876
上一篇: 干货 | 携程动态表单DynamicForm的设计与实现
下一篇: 干货 | 携程基于DPDK的高性能四层负载均衡实践
携程技术
博客等级 码龄10年 3716粉丝 324原创
评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

个

红包个数最小为10个

元

红包金额最低5元

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

抵扣说明:

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

余额充值