业务开发的痛点
-
现在写的软件怎么样?
- 现有的代码我不满意,我知道它乱,但不知如何入手解决
- 听说过业务架构整体优雅,允许局部腐化,你见过吗?我没见过
- 复杂度高,越来越不可控
- 新的产品需求文档(PRD)的不断涌入
-
如何让你的代码可以成为领域?
- 系统能否支持以不同视角从不同维度梳理业务?
- 代码可以反应PRD的内容吗?
- 如何让产品和技术融合在一起?
- 代码的结构化,是什么意思?
-
业务代码和技术代码能解耦?
- 业务与技术二者相互独立
- 我想使用外包的东西,作为代码库的我,不太相信其质量
- 在我们这个技术团队的大家庭里,有的人明显适合业务领域开发,有的适合技术性系统开发,如何人员分层管理?
- 想从代码里捋业务思路,醒醒吧!你看代码只想问问“晚上吃什么?”,得到的确是“我吃了一个朴朴(非广告)买的鸡蛋与米饭用铁锅做出来的蛋炒饭”
-
业务不确定
- 如何优雅地解决:业务逻辑的扩展,业务模型的扩展,业务流程的扩展
- 你曾经的if语句你敢碰吗?它的逻辑遍布世界
- 经常有新需求要我加字段,甚至加表,加得研发自己都不认识了
- 又要响应千奇百怪的个性化需求,又要保持自身不腐化
-
研发痛点
- 如何让研发拿到需求立刻就知道代码写在哪里,不各显神通地造轮子造概念
- 不要跟我讲各种方法论,架构思想,我只想知道这个PRD怎么好地实现
业务开发的复杂性来源
- 根本来源
- 业务场景多,差异大
- 个性化需求多
- 业务术语多,每个术语可能都对应一大堆字段、逻辑和流程
- 业务流程长,任何一个节点错误都会造成整体bug
- 自己公司是B2B项目,每个行业每个企业都有不同的业务诉求
- 附属来源
- 缺乏顶层设计,造成的代码随意
- 千人千面的代码风格和设计
- 没有顶层逻辑,那个站出来的男人(女人)是谁
- 业务和技术的耦合,代码本身无法反映业务本质
- 代码自身的质量差与可解释性差
- 环境因素(团队规模、人员流动、项目流动)
- 缺乏顶层设计,造成的代码随意
DDD
-
你知道DDD吗? DDD(领域驱动设计),不要因为其高大上的名称而觉得遥不可及(不过真的是隐晦难懂!)。DDD概念来源于2004年著名建模专家eric evans发表的他最具影响力的书籍:《domain-driven design –tackling complexity in the heart of software》(中文译名:领域驱动设计—软件核心复杂性应对之道)一书。,书中提出了“领域驱动设计(简称 ddd)”的概念。
- 领域驱动设计一般分为两个阶段:
1.以一种领域专家、设计人员、开发人员都能理解的“通用语言”作为相互交流的工具,在不断交流的过程中发现和挖出一些主要的领域概念,然后将这些概念设计成一个领域模型;
2.由领域模型驱动软件设计,用代码来表现该领域模型。领域需求的最初细节,在功能层面通过领域专家的讨论得出。
请参考这里为自己补充弹药:DDD(领域驱动设计)总结
- 领域驱动设计一般分为两个阶段:
-
DDD是真正解决业务问题的架构思想:
- 把业务设计和业务开发统一,产品和研发统一
- 统一在domain层,DDD的精华在这一层
- 业务专家角色,在互联网领域大部分是缺失的,实际上是谁更懂谁就专家
- 业务和技术解耦
- 本质上,DDD是把技术从业务中剥离:让业务成为中心,技术成为附属品
- 技术是为业务服务的
- domain层,是业务核心:不要把业务模型和规则逃逸、泄露到其他层
- 以领域为核心的分层架构,技术手段通过倒置依赖进行隔离
- 是面向业务的设计和编程,不是面向数据库的编程,也不是面向技术实现的编程(做好一个业务,不是技术越高大上也不是数据库使劲增加字段)
- 把业务逻辑集中到domain一层,使得产品和研发能有一个共同的代码交流场所
- 本质上,DDD是把技术从业务中剥离:让业务成为中心,技术成为附属品
- 改变过去
Service + 数据库
技术驱动开发模式- 从业务出发,让代码来解释业务(提高代码业务表达能力)
- 从核心概念的模型出发
- 把业务设计和业务开发统一,产品和研发统一
你能写出DDD吗
DDD是一个架构思想不是一个现有的框架。代码层面缺乏了足够的约束,导致DDD在实际应用中上手门槛很高,理解上容易产生偏差。
- 框架易学,思想难学(就像当初面向过程转面向对象的时候,满满的疑惑)
- DDD的最佳实践太少,没有标准
- 从上面看下来,DDD只给出了结果,并没给我过程指导,最开始的时候总是那么多的可能性延伸出更多的未知性
- DDD核心诉求是业务架构和系统架构形成绑定关系,但它缺乏面向领域的架构体系:不足以支撑复杂项目需求
- DDD落实到代码考验的是研发的面向对象思维
- 但长期的面向数据库编程思维,造成书本里学到的面向对象思维能力退化严重
- 研发落地,很容易走回到老路
- DDD的概念一个个都要去理解,生硬且困惑
- DDD的建模,拜托你拿过奖吗?
DDD实践
赖老师给我布置了一个任务,想把业务中那些权重低且不影响核心流程的事件提取出来(如Sms、数据统计、文件转换等需要异步处理等事件)并处理,而你需要写个服务或者工具去处理这些任务。从我接到这个任务时,一切都是0。DDD的学习在过程中资料更是应接不暇,有可以支持复杂项目的框架也有为踏进DDD大门的实践项目,我们要取其精华,去其糟粕!
- 主观与客观的碰撞
- 以DDD架构思想为本,面向复杂业务场景架构设计
- 通过代码框架提供足够约束,提供一个好的DDD实现环境
- 降低DDD上手门槛,为研发减负,防止落地偏差
- 降低复杂度,让业务资产可复用
- 帮助解决业务的不确定性
- 业务逻辑、流程、逻辑模型、数据模型的扩展、多态体系
- 框架本身可扩展
- 扩展业务包(热部署),框架本身ClassLoader机制的业务隔离
- 以DDD架构思想为本,面向复杂业务场景架构设计
思想与实践-顶层设计的打造
-
DomianModel:领域,DDD的核心
-
DomianService:领域服务。一个完整的业务活动的完成,比如数据统计。 数据统计在任务中算是一个步骤挺繁多的过程,包括:过滤、数据校验、来源分类等步骤,在此对应DomainStep。
-
DomainStep:步骤,一个业务由多个步骤组成。 步骤,将业务细节隐藏而把业务活动拆分出来的抽象。(Divide-and-Conquer思想)
科普小课堂:分治法(Divide-and-Conquer),字面意思是“分而治之”,就是把一个复杂的1问题分成两个或多个相同或相似的子问题,再把子问题分成更小的子问题直到最后子问题可以简单地直接求解,原问题的解即子问题的解的合并,这个思想是很多高效算法的基础,例如排序算法(快速排序,归并排序),傅里叶变换(快速傅里叶变换)等。
分治法的基本思想:将一个难以直接解决的大问题,分割成一些规模较小的相同问题,以便各个击破,分而治之。有步骤肯定需要进行步骤的编排来保证其执行顺序.在不同业务场景下,步骤顺序的不同,步骤的分类不同.例如:询价行为,在B2B中由步骤(A, B, C, D)完成的;而B2C场景,接单是由步骤(C, D, E, F, H)组成的。所以DomainStep是一个可编排的服务。
既然有了步骤也需要进行步骤编排,有些是可以预先计算的,有些是动态计算的,例如:在C处需要调用预分拣API,根据返回结果才能决定后续步骤,动态编排例子将由后续给出(这里大致思路是使用拓扑排序进行步骤编排)。
当步骤编排完成后需要StepExecute(步骤执行器)去执行步骤。在步骤执行过程中是有可能由异常抛出来的,而之前执行的步骤是需要回滚的,则可拓展DomianStep其子类RevokableDomainStep实现业务回滚操作。这些机制是StepExecute实现的一种Saga Pattern。
-
DomainExtension:拓展点,实现业务的多态。
如何实现不同场景下对步骤的不同编排?在同一个业务范畴内,不同场景的执行逻辑可能不同,例如:发起询价,有的采购员要求0期货报价,有的要求价格范围内报价等等?
以上的需求,其实本质就是业务的多态。这就刚好引出DomainExtension(拓展点):业务范畴确定,但需在不同场景执行逻辑不同的业务功能点,即业务的多态。扩展点的使用收敛在DomainAbility,通过其tags进行多级分类管理。
- 根据整体设计,扩展机制也需要分层:
- DomainExtension:最底层的扩展点,解决业务执行逻辑的不确定性
- LayoutStepExt:步骤编排扩展点,解决业务流程的不确定性
- ModelAttachmentExt:解决业务模型的不确定性,可以简单理解为如何解决多业务场景下数据库字段的问题
- 根据整体设计,扩展机制也需要分层:
-
IdentityResolver:业务身份匹配器,扩展点机制,实现了业务多态,但运行一项业务需要根据DomainModel判断该业务是否属于自己,同时与某一个扩展点实现进行绑定,从而完成扩展点的定位机制。
本质上,IdentityResolver相当于把之前散落在各处的某个业务逻辑的if判断条件进行收敛,使得这些业务判断显式化,有形化,并有了个名字。DomainExtension相当于把if后面的code block显式化,有形化,并可以进行组织分工。科普小课堂:列举业务场景的维度:某个商家的订单、出库时是否越库、订单项是否包含违禁品等
-
Pattern:业务模式,根据上面IdentityResolver+DomainExtension组成的有效内容,可以任意维度叠加的水平业务。 每个Pattern+DomianExtension可以有独立的Spring上下文,通过上下文的隔离,让不同模块之间的 Bean 的引用无法直接进行,达到模块在运行时的隔离。
-
DomainAbility:能力,DomainStep细粒度的存在。DomainStep与DomainExtension之间连接的介质,从而为Step提供管理调用DomainExtension的功能。
抽象顶层设计
- 接下来,是你需要做的工作:
- 梳理业务
- 业务抽象
- 提炼扩展点DomainExtension
- 步骤 DomainStep
- 模型扩展 ModelAttachmentExt
- 实现自己的DomainModel,建立自己业务的领域模型
- 异常机制
- 错误码规范
- 核心域和支撑域及交互
领域与Domain Primitive
领域模型清晰与否决定设计的好坏,好的领域内部结构清晰,演化成本低。
-
设计领域模型的一般步骤如下:
- 从需求中划分领域与界限上下文、上下文之间的关系
- 实体、值对象的划分,关联的关系形成聚合
- repository的设计,提供实体与值对象的创建方式(需经过防腐层)
- 在工程中实践领域模型,并在实践中检验模型的合理性,倒推模型中不足的地方并重构。
-
说到领域就不得不提一下Domain Primitive,从上面的InquiryMain中看出,其不单有属性也同样存在行为且所有抽离出来的方法要做到无状态,如:验证,创建,过滤等。(贫血模型 to 充血模型)
- DP是一个传统意义上的Value Object,拥有Immutable的特性
- DP是一个完整的概念整体,拥有精准定义
- DP使用业务域中的原生语言
- DP可以是业务域的最小组成部分、也可以构建复杂组合
请参考这里为自己补充弹药:阿里技术专家详解 DDD 系列- Domain Primitive
思考一下:相信很多人接触面向对象之前都接触过面向过程,重看面向过程,它的编程思想围绕着事件,分析出解决问题的方案步骤再利用函数实现步骤。在我们日常的代码中,早已习惯了贫血模型,它作为技术细节给我们带来更多的简洁,代码编写过程中也无需过多思考复用与行为,只需要一股脑的交由调用者去处理。如果将一个贫血模型从维度上将其看成与int,char等,那纵观整贫血模型整个调用过程已经是半面向过程半面向对象的结果。当然并不是所有的对象都有无状态行为,所以在对象的设计中,我们尽量对其进行深入思考来发掘行为与动作而不是想一时的轻松。
在领域的设计上肯定不会一帆风顺,需在业务上不断磨合才能打造出圆润光滑的实现。以询价为例子:InquiryMain作为一个实体
@Domain(code = InquiryDomain.ID, name = "询价核心域")
public class InquiryDomain {
public static final String ID = "core";
}
复制代码
@Getter
@Log4j
public class InquiryMain implements InquiryDomainModel {
private Long id;
private String inquiryNo;
// ...
@Setter
private String activity;
@Setter
private String step;
private OfferIntegrin offerIntegrin; // 实体
private InquiryItemIntegrin inquiryItemIntegrin; // 作为值对象
private InquiryMain validate() throws Exception {
// 模型本身的基础校验
return this;
}
public IDomainModel createWith(Object objectBean) {
// 验证
// 转换
return null;
}
public List<String> filterOffer(Predicate predicate) {
return new ArrayList<>();
}
// ...
}
复制代码
-
实体 当可用标识(不是属性)去区分一个对象,那这个对象可以成为实体。实体具备可持久化,存在业务逻辑。 在对实体的设计中不应该有太多的属性,而是去寻找关联
-
值对象 在我们习惯面向数据库实现代码后对于建模很容易将所有对象看成实体。然而作为值对象它具备不变性、等同性与可替换性,值对象的存在可以更好地精简设计,所以我们要理清楚实体与值对象的附属关系。从DDD的值对象定义可看出,值对象在模型概念上是可公用的,作为一个模型,它不允许外界修改它已创建好的值。如询价单里,询价明细价格、数量等固定后在后面的一系列操作后都不能从外界更改其值,那么询价明细的价格与数量可以视为值对象中的属性。
实体与值对象还是相对好理解,且直接与业务挂钩,我们可以根据业务的划分来进行实体与值对象的建模,但是聚合就需要我们对业务规则作为参考帮助我们技术设计。
- 聚合与聚合根 每个聚合既然有一个标识一个边界,那就表明在技术设计中以聚合为基本单位去处理事务,故聚合就是一个大家庭,它们共同承担共进退的义务。 外界聚合访问本聚合由于边界的隔离只能从聚合根获取。
领域服务与步骤
@DomainService(domain = InquiryDomain.ID)
public class SubmitInquiryService implements BaseService<InquiryMain, ServiceException> {
@Override
public boolean handler(InquiryMain objectBean) throws ServiceException {
return false;
}
}
复制代码
- 由此可看出Service
- 它既不是实体,也不是值对象的范畴,他拥有重要的领域行为或操作
- 服务是无状态的
- 服务的操作设计领域
领域服务存在的意义就是协助领域对象完成某个操作,而操作过程中的所有状态都会在领域对象里,对于为服务的开发人员创建领域服务应该是件很简单的事情。
@Step
public class InquiryPersistStep implements IDomainStep<InquiryMain, ServiceException> {
@Autowired
private InquiryRepository inquiryRepository;
@Override
public void execute(InquiryMain model) throws ServiceException {
}
@Override
public String activityCode() {
return Steps.Inquiry.InquiryPersist;
}
@Override
public String stepCode() {
return Steps.Inquiry.Activity;
}
}
复制代码
-
领域事件 当领域经过经过服务的操作过程中其所发生的事可以看为领域事件,领域事件是对领域内发生的活动进行的建模。 比如发起询价,是需要通知品牌报价方的,这个“你有一份询价单可以报价”就是一个领域事件。
- 创建领域事件要保持两个特征
- 它是不可变的
- 领域事件应该携带与事件发生时相关的上下文数据信息,但是并不是整个聚合根的状态数据。比如通知报价方报价后的时候不用将整个聚合的询价单传递,而是将报价数据传递就行。
对于领域事件的实现,是可以借助事件驱动设计(Event Driven Architecture) 在微服务内实现领域事件,按照“DDD“一个事务只更新一个聚合根”的原则,可以考虑引入消息中间件,通过异步化的方式,对微服务内不同的聚合根采用不同的事务。但由于引入中间件的实现极其复杂,故采用Step进行解释。
- 创建领域事件要保持两个特征
-
步骤是领域服务的更细粒度的存在,将询价这个业务活动进行拆解,抽象成一些业务步骤。 在DDD的介绍中,使用EDA去处理一个事件,二者区别
- 相同点
- 都实现了开闭原则
- 但都面临粒度粗细的设计问题
- 不同点
- Step的调试和排障更方便,在StepExecute执行Step保留了调用栈
- Step更方便实现之间的依赖关系、顺序,而EDA下事件的分发机制会很复杂,可能破坏它带来的收益
- Step,结合业务身份,很容易实现不同场景下的编排
- Step支持动态编排
有Step的执行可以异步等,故StepExecute也需要支持多线程操作,也可引入消息中间件来执行。但引入消息中间件对于赖老师分配我的任务存在间隙,故选择弃置,同样支持多线程操作反而适合我。而关于Step的操作方式(顺序还是多线程)与编排可由DomainAbility来管理。为了微服务架构中保证数据一致性同样需要引入事务在StepExecute中,选择的是Saga的策略补偿机制。 请参考这里为自己补充弹药:Pattern: Saga
- 相同点
防腐层
数据流转
由于外部数据进入,需要防腐层为顶层设计提供保障,对外部上下文的访问进行一次转义(技术选型:MapStruct)
- 有以下几种情况会考虑引入防腐层:[3]
- 需要将外部上下文中的模型翻译成本上下文理解的模型。
- 不同上下文之间的团队协作关系,如果是供奉者关系,建议引入防腐层,避免外部上下文变化对本上下文的侵蚀。
- 该访问本上下文使用广泛,为了避免改动影响范围过大。
- 如果内部多个上下文对外部上下文需要访问,那么可以考虑将其放到通用上下文中。
最后
领域设计的顶层设计相对简单,但建立一个适合业务的领域模型却很困难。在开发的过程中是在不断细化,随着多次测试人员、开发人员、产品经理的磨合才能达到最佳的效果。因此领域模型的创建与业务密不可分这也导致他人的设计不一定适合自己。领域模型的实施促进团队的交流,同样领域模型的概念帮助我们去理解领域驱动的设计,实现一些高内聚、低耦合的代码实现。
DDD只是整个任务设计中的很小一部分。除了领域模型设计之外,要落地一个系统,我们还有非常多的其他设计要做,比如数据库设计、缓存设计、框架选型、高并发解决方案(由于任务的特性,暂不考虑)、一致性选型、性能压测方案、监控报警方案等。上面这些都需要我们平时的大量学习和积累。作为一个合格的开发人员,我觉得除了要会DDD领域驱动设计,还要会上面这么多的技术能力,确实是非常不容易的。当然上面文章由于侧重点不同埋下了许多坑,如任务的具体描述,技术选型,步骤重排,数据一致性等问题还没得到充分的解释。这个等后续肯定填上,但是解决思路我会尽我努力达到最好。
在上篇文章中我与DDD第一次邂逅提到了DomainStep,DomainStep作为领域服务的更细粒度的存在,将业务活动进行拆解,抽象成一些业务步骤。由于业务的隔离,在顶层设计中应给予Step足够的包容性适应开发人员实现的步骤。
提供注解@Step:
public @interface Step {
/**
* 对应{@link Service} value
*/
@AliasFor(annotation = Service.class, attribute = "value") String value() default "";
/**
* 该步骤的名称.
*/
String name() default "";
/**
* 该步骤依赖哪些其他步骤.
* 即,被依赖的步骤先执行,才能执行本步骤
*/
Class<? extends DomainStep>[] replyOn() default {};
}
复制代码
在Step中replyOn放置DomainStep class,以这个为依据对Step进行编排。
编排
课堂小知识:拓扑排序。将有向图中的顶点以线性方式进行排序,是指对于任何连接自顶点u到顶点v的有向边uv,在最后的排序结果中,顶点u总是出现在顶点v的前面。
例如,图的顶点可能代表将要被执行的任务,边代表一个任务必须在另一个任务之前执行。在该应用场景中,一个拓扑排序结果就是一个有效的任务序列。
一个有向图能进行拓扑排序的充要条件是,它是一个有向无环图(Directed Acyclic Graph)。
课堂小知识:有向无环图(DAG)。在一个图中,如果一个有向图无法从某个顶点出发经过若干条边回到该点,则这个图是一个有向无环图。
任意DAG至少有一个拓扑排序结果,并且已知算法可以在线性时间内为任意DAG产生一个拓扑排序结果。
在关于拓扑排序的问题上存在一个线性时间解。若有向图中存在n个结点,则我们可以在O(n)时间内得到其拓扑排序,或在O(n)时间内确定该图不是有向无环图,也就是说对应的拓扑排序不存在。
我们可获得拓扑排序结果:[1,2,5,3,6,4]、[1,2,5,3,6,4]
以上做个简单的了解,在顶层设计中StepExecute执行Step步骤应可以串行或并行执行。步骤的编排要解决的是排序出步骤执行顺序与以分层形式的可并行步骤。
-
思路
- 将所有入度的值放置在数组中,遍历数组寻找入度为0的元素,即同一遍历得出入度为0的Step为同一层次.一次执行直到所有Step都提取出来。
-
伪代码
// inDegree 为入度数量对应Step的数组
private List<StepLayer> topologicalSort() {
while (已检出的Step个数<=总个数>) {
// 初始化
List<Step> list = new ArrayList();
StepLayer stepLayer = new StepLayer();
stepLayer.setSteps(new ArrayList<>());
for (int i = 0; i < inDegree.length; i++) {
if (判断入度数) {
检出个数++;
list.add(step)
}
}
for (String s : list) {
StepLayer.add(s)
}
result.add(stepLayer);
}
return result;
}
复制代码
在进行Step的编排后一定要释放不需要的内存,Step的编排只需要一次(果然是渣男),保存最终结果即可。 同样,除了动态编排Step也可以直接静态输入Step的编排顺序,当然要做好开发人员之间的文档沟通。
执行
在顶层设计中提供了抽象类StepExecute来执行Step,在执行Step的过程出现异常时暂时采用补偿机制,即Step的子类RevokableDomainStep存在方法rollback()。
/**
* 支持回滚的 activity step
*/
public interface RevokableDomainStep<Model extends DomainModel,
Ex extends RuntimeException> extends IDomainStep<Model, Ex> {
/**
* 执行本步骤的回滚操作,进行操作矫正.
*
* 尽可能的处理好影响,Sagas模式并不能严格保证一致性
*
* @param model 领域模型
* @param cause {@link IDomainStep#execute(IDomainModel)}执行过程中抛出的异常,即回滚原因
*/
void rollback(@NotNull Model model, @NotNull Ex cause);
}
复制代码
以层级作为执行单元,即为其异步执行(在真实的开发中大部分都是一个Step一层),为了在日志中能显性输出并行的Step的日志需要借助MDC来作为线程的标识,让开发人员能快速定位。
private void asyncExecuteStep(SchedulingTaskExecutor taskExecutor, Step step, Model model) {
Map<String, String> mdcContext = MDC.getCopyOfContextMap();
taskExecutor.execute(() -> {
MDC.setContextMap(mdcContext);
try {
step.execute(model);
} finally {
MDC.clear();
}
});
}
复制代码
在执行Step的过程中最重要的自然时异常的处理,这个关系到Step是否能正常结束。执行过程中会碰到哪些异常:线程池 full、不需要回滚的业务异常、强制回滚的异常等
// 异常类型的判定,是否是需要执行回滚的异常
private Class resolveStepExType() {
// 判定这个类是否是动态代理目标类
Class thisClass=获取动态代理目标类;
ResolvableType stepsExecType = ResolvableType.forClass(thisClass);
ResolvableType templateType = stepsExecType.getSuperType();
// StepExecute也会跟随业务的多态而改变,从其基类开始寻找
while (templateType.getGenerics().length == 0) {
templateType = templateType.getSuperType();
}
// 找到了Step的泛型定义,然后找Step的Ex泛型的具体类型
ResolvableType stepType = templateType.getGeneric(0);
// Step实现多个接口的场景
for (ResolvableType stepInterfaceType : stepType.getInterfaces()) {
if (DomainStep.class.isAssignableFrom(stepInterfaceType.resolve())) {
return stepInterfaceType.getGeneric(1).resolve();
}
}
...
// should never happen
log.error("Cannot tell Step.Ex type for {}", this.getClass());
return null;
}
复制代码
总结
上述使用拓扑排序、Saga机制、栈基本实现了Step的执行,随着业务的复杂度增加可能这一套需要淘汰更新。对于现在的需求,没有强一致性的、时间敏感度高等要求大大为我降低了门槛。
探索的道路需要不断的修改,技术为业务服务,在学习的同时也需要实践。每次使用曾经学习的东西完成新的东西都有一种开悟的感觉,引用 邓宁-克鲁格的心理效应来说,在我们这个年纪其实就是开悟之坡。
作者:Mercury
链接:https://juejin.cn/post/6956572939936350244
来源:掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
。