码农戏码


  • 首页

  • 归档

  • 分类

  • 标签

  • 关于

  • 在线工具

  • 搜索

程序员成长职级

发表于 2021-04-02
字数统计: 2k | 阅读时长 ≈ 6

之前与老同事叙旧,一同事讲起现在公司在压缩人员,另一同事马上讲谁让你们把系统搞得那么好,几年都出不了个二级Bug,想想真是悲壮

有时想程序员这群可爱的人,自命清高,但不过是群工具人而已;技术人特别像古时的武将,君主开疆拓土时,那真是座上宾,可一旦休战,杯酒释兵权都是佳话

习武之人有三重境界,见自己,见天地,见众生;这三重境界很适合技术人

对于程序员,很多人都梦想成长一名架构师,程序员怎么成长,在成长的途径中,要打哪些怪,经验值怎么分布,知道这些后,就能有的放矢

最近听了阿里P9关于架构师成长之路,我特地记录一下,对照一下自己的成长

职级

整体打怪升级路径:工程师 - 高级工程师 - 技术专家 - 初级架构师 - 中级架构师 - 高级架构师

工程师P5(1~3年)

特点:求指导

重点:基础(环境、工具、流程)

技巧与误区:

  1. 碎片化时间,系统化学习
  2. 经典书籍系统学习:运行环境、编程语言、网络基础
  3. 三大坑:编译原理,XXX内核代码、XX算法代码

要想打好基础能力,首先要明确什么才是真正的“基础能力”。我的观点是“基础能力是指工作任务相关的基础能力,不是整个计算机技术的基础能力”,核心就是“工作相关”,千万不要单纯照搬别人口中的基础能力

高级工程师P6(2~5年)

特点:独挡一面(从需求设计到设计,编码完成整个流程)

重点:积累经验(业务、套路【缓存,分库分表】、原理【redis,netty,看源码主要是了解怎么实现】)

技巧与误区:

  1. 掌握基础原理:JVM,开源软件等
  2. 学习套路:分库分表、缓存、SOLID、设计模式、MVP等
  3. 贪大求全,看了很多,但都是蜻蜓点水

技术专家P7(4~8年)

特点:领域专家

重点:技术深度+宽度(深度【不仅知道reactor,还得知道怎么实现,redis与netty的reactor有什么区别】、全面、业界)

技巧与误区:

  1. 熟悉核心源码:成熟的开源软件,Memcache、Redis、Nginx、Netty等
  2. 业界交流:参加技术大会,关注大厂技术
  3. 生搬硬套,直接拷贝大厂技术,以防水土不服

初级架构师P8(5~10年)

特点:构建普通系统【指导20人内开发的系统】

重点:方法论(复杂度驱动,风险驱动,领域驱动)

技巧与误区:

  1. 架构对比:Redis vs Memcache,Nginx vs Apache,Vue vs React等
  2. 架构重构:尝试去重构已有的系统
  3. 过分依赖以往成功经验

中级架构师P9(8+年)

特点:构建复杂系统【100人开发的系统】

重点:技术本质

  1. 理论:CAP
  2. 算法:如Flink算法原理
  3. 原理:cpu cache line

技巧与误区:

  1. 技术理论:CAP、BASE\分布式快照算法等
  2. 技术原理:磁盘(kaffa)、CPU和内存(Disruptor)等
  3. 好大喜功,过度设计,炫技式设计

高级架构师P10(10+年)

特点:可以创建架构模式,如google大数据三大论文

重点:创造

  1. 业务
  2. 技术
  3. 文化

从这个职级年限看,我这天赋太一般,职场规划也不行,现在大厂已经壮大,量级巨大,获取知识途径也更方便更多,所以对于当今程序员需要了解的知识面也更广更深,如果说以前北大青岛之类拉低了java程序员的门槛,那现在一些知识平台拉大程序员间的差距,不学则退

晋升

了解程序员进阶title之后,后面就是了解怎么能晋升,最好能快速晋升

三大原则

一、主动原则:主动做事

主动规划工作任务,不能一味服从命令听指挥,领导指哪打哪,成为职场工具人

主动跟别人了解更信息,不只是做好自己本职工作,还了解业务上下游,业务价值、上线后效果、没有达到预期的原因、机房部署

二、成长原则:不断挖掘成长点

误区:以为事情做得多,自然就能晋升;以为事情做得好,自然就晋升,把任务做完,保证效率和质量,拿到好绩效,但不一定晋升,因为不同级别的能力要求是有本质的区别的,而不仅仅是熟练度的区别,把事情做好,只能说明你已经熟练掌握当前级别所要求的能力,但并不一定意味着你的能力就自动达到下一职级的要求

不管事情做好了还是没有做好,都应该多做复盘总结,找到可以提升优化的点

三、价值原则:学习为公司产出价值的技能

公司设计职级体系的初衷,是为了衡量不同员工的能力级别,然后根据级别来制定相应的薪酬、福利、管理等制度,同时鼓励员工尽量提升自己的能力,为公司产出更大的价值

让能力为公司产出价值的人,比空有一身能力的人更容易晋升,优先学能为公司产出价值的技能

晋升逻辑

绩效关注的是业务结果,晋升关注的是能力提升;除了达到上述每个职级的技能要求,还有以下两条晋升逻辑

第一条逻辑:提前做下一级别的事

其实职级与本身能力是否匹配,有很多条件,就像你的薪资是否匹配当前能力一样,很难完美匹配,大多时候都是能力超过应有的福利;很多时候你的能力回报通常在下一份工作中兑现

职级也一样,当你做了下一次职级的事情,就更有机会晋升

第二条逻辑:做好当前级别的事

由第一条逻辑引出捷径:晋升通过之后,立刻跟主管要求安排下一级别的工作来快速晋升,但在能胜任下一级别工作前,必须先做好当前级别的事

任何级别都有三层水平:基础、熟练和精通

基础意味着会做,标志是能够独立完成;熟练意味着做好,标志是掌握最佳实践;精通意味着优化,标志是创造新的经验

晋升步骤

  1. 按照晋升原则的指导,在当前级别拿到好结果,为公司创造价值,同时把当前级别要求的能力提升到精通程度,成为晋升备选人
  2. 到了精通程度,对照下一级别要求提升自己能力,为可能的晋升做好准备
  3. 主动寻找工作机会,尝试做下一级别事情,继续拿到好的结果,向主管证明具备下一级的能力
  4. 拿到工作结果申请晋升,介绍做过的事情,展示相关能力和结果,证明自己具备了下一级别要求能力

现在都讲究终身学习,学习为了什么?不是为了学习而学习,更多的是为了成长,如果只是一味地完成职场岗位本职工作,只能永远是棵随时被替换的螺丝钉,我们最根本的目标是成长,通过职场工作提升自己,成长自己。也许你很不屑晋升,认为那有很多厚黑学,但它是成长的催化剂,更与自己身利益息息相关,以终为始,事半功倍

DDD之Repository

发表于 2021-03-20
字数统计: 4.3k | 阅读时长 ≈ 15

之前的DDD文章中也指出过,现在从理论角度对于repository是错误,但一直没有摸索出最佳实践,都是当DAO使用,区别在于repository是领域层,也没有深入思考过

最近再次温习《DDD第二弹》时,看到了这个评论

domain service不应该直接调用repository,这打破了我对repository的认知,对此让我不得不纠结一下repository,在之前的学习中,从没有听到此规则,repository与domain service都是领域层的,为什么两都不能相互调用呢?

从源头重新梳理一下repository的知识,重新翻阅Eric Evans的《领域驱动设计》和Vaughn Vernon的《实现领域驱动设计》

repository

repository是在《领域驱动设计》第六章领域对象的生命周期提出

factory用来创建领域对象,而repository就是在生命周期的中间和末尾使用,来提供查找和检索持久化对象并封装庞大基础设施的手段

这句话就把repository的职责讲清楚了:

  1. 提供查找和检索对象
  2. 协调领域和数据映射层

在现有技术范畴中,都使用DAO方式,为什么还需要引入repository呢?

尽管repository和factory本身并不是来源于领域,但它们在领域设计中扮演着重要的角色。这些结构提供了易于掌握的模型对象处理方式,使model-driven design更完备

领域驱动设计的目标是通过关注领域模型(而不是技术)来创建更好的软件。假设开发人员构造了一个SQL查询,并将它传递给基础设施层中的某个查询服务,然后再根据得到的表行数据的结果集提取出所需信息,最后将这些信息传递给构造函数或factory。开发人员执行这一连串操作的时候,早已不再把模型当作重点了。我们很自然地会把对象看作容器来放置查询出来的数据,这样整个设计就转向了数据处理风格。虽然具体的技术细节有所不同,但问题仍然存在–客户处理的是技术,而不是模型概念

在DDD思想中,领域模型是最重要的,所有的一切手段都是为了让团队专注于模型,屏蔽一切非模型的技术细节,这样也才能做到通用语言,交流的都是模型

VS DAO

有人总结DDD就是分与合,分是手段、合是目的;对于DDD战略来讲,就是通过分来形成各个上下文界限,在各个上下文中,再去合,很类似归并算法

而聚合就是最小的合,repository相对dao,是来管理聚合,管理领域对象生命周期

  1. 为客户提供简单的模型,可用来获取持久化对象并管理生命周期
  2. 使应用程序和领域设计与持久化技术(多种数据库策略甚至是多个数据源)解耦
  3. 体现对象访问的设计决策
  4. 可以很容易将它们替换为“哑实现”,以便在测试中使用(通常使用内存中的集合)

而DAO的核心价值是封装了拼接SQL、维护数据库连接、事务等琐碎的底层逻辑,让业务开发可以专注于写代码。但是在本质上,DAO的操作还是数据库操作,DAO的某个方法还是在直接操作数据库和数据模型,只是少写了部分代码,并且可以操作任意表对象;在Uncle Bob的《代码整洁之道》一书里,作者用了一个非常形象的描述:

  • 硬件(Hardware):指创造了之后不可(或者很难)变更的东西。数据库对于开发来说,就属于”硬件“,数据库选型后基本上后面不会再变,比如:用了MySQL就很难再改为MongoDB,改造成本过高。
  • 软件(Software):指创造了之后可以随时修改的东西。对于开发来说,业务代码应该追求做”软件“,因为业务流程、规则在不停的变化,我们的代码也应该能随时变化。
  • 固件(Firmware):即那些强烈依赖了硬件的软件。我们常见的是路由器里的固件或安卓的固件等等。固件的特点是对硬件做了抽象,但仅能适配某款硬件,不能通用。所以今天不存在所谓的通用安卓固件,而是每个手机都需要有自己的固件。

从上面的描述我们能看出来,数据库在本质上属于”硬件“,DAO 在本质上属于”固件“,而我们自己的代码希望是属于”软件“。但是,固件有个非常不好的特性,那就是会传播,也就是说当一个软件强依赖了固件时,由于固件的限制,会导致软件也变得难以变更,最终让软件变得跟固件一样难以变更

举个软件很容易被“固化”的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
private OrderDAO orderDAO;
public Long addOrder(RequestDTO request) {
// 此处省略很多拼装逻辑
OrderDO orderDO = new OrderDO();
orderDAO.insertOrder(orderDO);
return orderDO.getId();
}
public void updateOrder(OrderDO orderDO, RequestDTO updateRequest) {
orderDO.setXXX(XXX); // 省略很多
orderDAO.updateOrder(orderDO);
}
public void doSomeBusiness(Long id) {
OrderDO orderDO = orderDAO.getOrderById(id);
// 此处省略很多业务逻辑
}

在上面的这段简单代码里,该对象依赖了DAO,也就是依赖了DB。虽然乍一看感觉并没什么毛病,但是假设未来要加一个缓存逻辑,代码则需要改为如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
private OrderDAO orderDAO;
private Cache cache;
public Long addOrder(RequestDTO request) {
// 此处省略很多拼装逻辑
OrderDO orderDO = new OrderDO();
orderDAO.insertOrder(orderDO);
cache.put(orderDO.getId(), orderDO);
return orderDO.getId();
}
public void updateOrder(OrderDO orderDO, RequestDTO updateRequest) {
orderDO.setXXX(XXX); // 省略很多
orderDAO.updateOrder(orderDO);
cache.put(orderDO.getId(), orderDO);
}
public void doSomeBusiness(Long id) {
OrderDO orderDO = cache.get(id);
if (orderDO == null) {
orderDO = orderDAO.getOrderById(id);
}
// 此处省略很多业务逻辑
}

这时,你会发现因为插入的逻辑变化了,导致在所有的使用数据的地方,都需要从1行代码改为至少3行。而当你的代码量变得比较大,然后如果在某个地方你忘记了查缓存,或者在某个地方忘记了更新缓存,轻则需要查数据库,重则是缓存和数据库不一致,导致bug。当你的代码量变得越来越多,直接调用DAO、缓存的地方越来越多时,每次底层变更都会变得越来越难,越来越容易导致bug。这就是软件被“固化”的后果。

所以,我们需要一个模式,能够隔离我们的软件(业务逻辑)和固件/硬件(DAO、DB),让我们的软件变得更加健壮,而这个就是Repository的核心价值


这也是上面所述的第二点:协调领域和数据映射层

如果说DAO是低层抽象,那么Repository是高层抽象,也更衬托出repository的本质:管理领域的生命周期,不管数据来源于何方,只要把聚合根完整地构建出来就可以

data model与domain model

数据模型与领域模型,按照Robert在《整洁架构》里面的观点,领域模型是核心,数据模型是技术细节。然而现实情况是,二者都很重要

数据模型负责的是数据存储,其要义是扩展性、灵活性、性能

而领域模型负责业务逻辑的实现,其要义是业务语义显性化的表达,以及充分利用OO的特性增加代码的业务表征能力

调用关系

对于domain service不要调用repository,这个规则我不太明白,只能请教作者了,为什么要这样限制? 作者回复:

Domain Service是业务规则的集合,不是业务流程,所以Domain Service不应该有需要调用到Repo的地方。如果需要从另一个地方拿数据,最好作为入参,而不是在内部调用。DomainService需要是无状态的,加了Repo就有状态了。

我一般的思考方式是:domainService是规则引擎,appService才是流程引擎。Repo跟规则无关

业务规则与业务流程怎么区分?

有个很简单的办法区分,业务规则是有if/else的,业务流程没有

作者这样回答,我还是觉得太抽象了,在domain service拿数据太常见,还在看DDD第四讲时,作者有个示例是用domain service直接调用repository的,以此为矛再次追问作者

这儿的domain service是直接使用repo的,如果里面的数据都使用入参,结构就有些怪啊

在这个例子里确实是有点问题的(因为当时的关注点不是在这个细节上),一个更合理的方法是在AppService里查到Weapon,然后performAttack(Player, Monster, Weapon)。如果嫌多个入参太麻烦,可以封装一个AttackContext的集合对象。

为什么要这么做?最直接的就是DomainService变得“无副作用”。如果你了解FP的话,可以认为他像一个pure function(当然只是像而已,本身不是pure的,因为会变更Entity,但至少不会有内存外的调用)。这个更多是一个选择,我更倾向于让DomainService无副作用(在这里副作用是是否有持久化的数据变更)。

如果说Weapon无非是提供一些数据而已,那么我们假设扩展一下,每次attack都会降低Weapon的durability,那你在performAttack里面如果用了repo,是不是应该调用repo.save(weapon)?那为什么不直接在完成后直接用UserRepo.save(player)、MonsterRepo.save(monster)?然后再延伸一下,如果这些都做了,还要AppService干啥?这个Service到底是“业务规则”还是“业务流程”呢?

从另一个角度来看,有的时候也不需要那么教条。DomainService不是完全不能用Repo,有时候一些复杂的规则肯定是要从”某个地方“拿数据的,特别是“只读”型的数据。但是我说DomainService不要调用repo时的核心思考是不希望大家在DomainService里有“副作用”。

对于这种限制,我现在只能想到domain service要纯内存操作,不依赖repository可以提升可测试性

性能安全

这是在落地时,很多人都会想到的问题

性能

查询聚合与性能的平衡,比如Order聚合根,但有时只想查订单主信息,不需要明细信息,但repository构建Order都全部查出来了,怎么办?在《实现领域驱动设计》中,也是不推荐这么干的,使用延迟加载,很多人也觉得这应该是设计问题,不能依赖延迟加载

对此问题请教了作者:

在业务系统里,最核心的目标就是要确保数据的一致性,而性能(包括2次数据库查询、序列化的成本)通常不是大问题。如果为了性能而牺牲一致性,就是捡了芝麻漏了西瓜,未来基本上必然会触发bug。

如果性能实在是瓶颈,说明你的设计出了问题,说明你的查询目标(主订单信息)和写入目标(主子订单集合)是不一致的。这个时候一个通常的建议是用CQRS的方式,Read侧读取的可能是另一个存储(可能是搜索、缓存等),然后写侧是用完整的Aggregate来做变更操作,然后通过消息或binlog同步的方式做读写数据同步。

这也涉及到业务类型,比如电商,一个订单下的订单明细是很少量的,而像票税,一张巨额业务单会有很多很多的订单明细,真要构建一个完整的聚合根相当吃内存

对象追踪

repostiory都是操作的聚合根,每次保存保存大多只会涉及部分数据,所以得对变化的对象进行追踪

《实现领域驱动设计》中提到两种方法:

  1. 隐式读时复制(Implicit Copy-on-Read)[Keith & Stafford]:在从数据存储中读取一个对象时,持久化机制隐式地对该对象进行复制,在提交时,再将该复制对象与客户端中的对象进行比较。详细过程如下:当客户端请求持久化机制从数据存储中读取一个对象时,该持久化机制一方面将获取到的对象返回给客户端,一方面立即创建一份该对象的备份(除去延迟加载部分,这些部分可以在之后实际加载时再进行复制)。当客户端提交事务时,持久化机制把该复制对象与客户端中的对象进行比较。所有的对象修改都将更新到数据存储中。
  2. 隐式写时复制Implicit Copy-on-Write)[Keith & Stafford]:持久化机制通过委派来管理所有被加载的持久化对象。在加载每个对象时,持久化机制都会为其创建一个微小的委派并将其交给客户端。客户端并不知道自己调用的是委派对象中的行为方法,委派对象会调用真实对象中的行为方法。当委派对象首次接收到方法调用时,它将创建一份对真实对象的备份。委派对象将跟踪发生在真实对象上的改变,并将其标记为“肮脏的”(dirty)。当事务提交时,该事务检查所有的“肮脏”对象并将对它们的修改更新到数据存储中。

以上两种方式之间的优势和区别可能会根据具体情况而不同。对于你的系统来说,如果两种方案都存在各自的优缺点,那么此时你便需要慎重考虑了。当然,你可以选择自己最喜欢的方式,但是这不见得是最安全的选择。
无论如何,这两种方式都有一个相同的优点,即它们都可以隐式地跟踪发生在持久化对象中的变化,而不需要客户端自行处理。这里的底线是,持久化机制,比如Hibernate,能够允许我们创建一个传统的、面向集合的资源库。
另一方面,即便我们能够使用诸如Hibernate这样的持久化机制来创建面向集合的资源库,我们依然会遇到一些不合适的场景。如果你的领域对性能要求非常高,并且在任何一个时候内存中都存在大量的对象,那么持久化机制将会给系统带来额外的负担。此时,你需要考虑并决定这样的持久化机制是否适合于你。当然,在很多情况下,Hibernate都是可以工作得很好的。因此,虽然我是在提醒大家这些持久化机制有可能带来的问题,但这并不意味着你就不应该采用它们。对任何工具的使用都需要多方位权衡

《DDD第二弹》中也提到 业界有两个主流的变更追踪方案:这两个方案只是上面两种方案另取的两外名字而已,意思是一样的

  1. 基于Snapshot的方案:当数据从DB里取出来后,在内存中保存一份snapshot,然后在数据写入时和snapshot比较。常见的实现如Hibernate
  2. 基于Proxy的方案:当数据从DB里取出来后,通过weaving的方式将所有setter都增加一个切面来判断setter是否被调用以及值是否变更,如果变更则标记为Dirty。在保存时根据Dirty判断是否需要更新。常见的实现如Entity Framework

Snapshot方案的好处是比较简单,成本在于每次保存时全量Diff的操作(一般用Reflection),以及保存Snapshot的内存消耗。
Proxy方案的好处是性能很高,几乎没有增加的成本,但是坏处是实现起来比较困难,且当有嵌套关系存在时不容易发现嵌套对象的变化(比如子List的增加和删除等),有可能导致bug。

由于Proxy方案的复杂度,业界主流(包括EF Core)都在使用Snapshot方案。这里面还有另一个好处就是通过Diff可以发现哪些字段有变更,然后只更新变更过的字段,再一次降低UPDATE的成本。

安全

设计聚合时,聚合要小,一是事务考虑,二是安全性考虑。当并发高时,对聚合根操作时,都需要增加乐观锁

Reference

一文教你认清领域模型和数据模型

第三讲 - Repository模式

Java异常实践

发表于 2021-03-16
字数统计: 925 | 阅读时长 ≈ 3

对于Java理论在《Java异常处理》中已经阐述了,看看理论如何指导落地

现流行的文章SpringBoot如何优雅处理异常,落地的确方便,使用AOP统一处理异常,但只是处理了api层次的异常

应用中抛出异常有两种方式:

  1. 带有ErrorCode的异常
  2. 明确类型的异常

对于controller层,也是面向用户的,需要error code,所以采用第一种方式

前端通过映射关系给出更好用户体验的提示语,也有很多项目都是controller层直接拼接出提示语,前端直接展示

所以一般会定义一个接口ErrorCode

1
2
3
4
interface ErrorCode {
String getErrorCode();
String getMessage();
}

具体的实现可以通过enum

1
2
3
4
5
6
7
8
@Getter
enum ApiErrorCode implements ErrorCode {

USER_NOT_FOUND("10000","用户不存在");

private String errorCode;
private String message;
}

再定义一个统一异常

1
2
3
4
5
6
public class ApiException extends RuntimeException {

public ApiException(ErrorCode errorCode) {

}
}

在aop拦截时,直接拦截此异常就行

api层次的异常可以这么处理,那业务层呢?很多时候都是缺失设计的,这也在上篇说过exception从语法层面看很简单,但要设计一个好的异常是很难的

大多项目直接把api exception拿来当做business exception使用

对于一个良好的业务接口,应该采用第二种方法:细致的异常

1
User login(String username, String password) throws UserNotFoundException, PasswordNotMatchException;

已检查异常要比错误返回码(许多老式的语言中使用)好很多。迟早(或许不久),人们将不能检查一个错误返回值;使用编译程序来实施正确的错误处理是一件好事。同参数与返回值一样,这样的已检查异常对一个对象的API来说是整体的一个不可分割部分

这样的接口更丰富,也更面向对象,可也给客户端带来的麻烦,缺点在上篇已经阐述

对可恢复的情况使用已检查异常,对程序错误使用运行时异常

在大多项目中,其实业务层抛出异常后,通常会“可恢复”吗?大多数情况也需要用户手工干预,系统无法自行恢复,比如UserNotFoundException, PasswordNotMatchException系统能怎么处理,无非还是得给用户重新输入用户名和密码

在controller层去调用service方法,也只能如此处理

1
2
3
4
5
6
7
8
9
Response login(String username,String password) {
try {
userService.login(username,password);
}catch(UserNotFoundException ue) {
throw new ApiException(ApiErrorCode.USER_NOT_FOUND);
}catch(PasswordNotMatchException pe){
throw new ApiException(ApiErrorCode.Password_Not_Match);
}
}

这样处理也就理论化了,带来了多少优点呢?这些缺点不正是checked exception被嘟囔的地方吗

那我们把业务异常也定义为runtime exception,这样减少客户端压力,想处理就处理,不想处理,我们也可在拦截器中兜底

不过抛出运行期异常,减少客户端的压力,但也带来了接口不明确的困惑

非检查型异常的最大风险之一就是它并没有按照检查型异常采用的方式那样自我文档化。除非 API 的创建者明确地文档化将要抛出的异常,否则调用者没有办法知道在他们的代码中将要捕获的异常是什么

总结起来,还是那句话,异常语法很简单,但设计好异常不易;现在技术快速发展,通过技术手段可以达到更大的便捷性,但不能只有技术手段而忽略设计,没有设计的代码称不上好代码,可以取舍,但不能全舍

Java异常处理

发表于 2021-03-15
字数统计: 3.6k | 阅读时长 ≈ 12

Java异常,本身知识体系很简单,但要设计好异常,却不是易事

Java异常如何使用,尤其checked exception,好些语言(c#,python)都没有此类型异常,只有unchecked exception;对于java为什么有checked exception,是不是设计过渡,在java初期被讨论了很多回,以及如何使用异常也被讨论了很多次,最近我在落地DDD时,又思考到此问题,不得不再翻回这个老问题,翻阅《Effective java》、《J2EE设计开发编程指南》这些经典

按普世标准,处理异常最佳实践有:

  • 【强制】异常不要用来做流程控制,条件控制。说明:异常设计的初衷是解决程序运行中的各种意外情况,且异常的处理效率比条件判断方式要低很多
  • 异常应该只用于异常的情况下:它们永远不应该用于正常的控制流,设计良好的API不应该强迫它的客户端为了正常的控制流而使用异常
  • 对可恢复情况使用受检异常,对编程错误使用运行时异常
  • 抛出与抽象相对应的异常
  • 每个方法抛出的异常都要有文档
  • 优先使用标准异常

再来看看前人的论述:

在使用UseCase来描述一个场景的时候,有一个主事件流和n个异常流。异常流可能发生在主事件流的过程,而try语句里面实现的是主事件流,而catch里面实现的是异常流,在这里Exception不代表程序出现了异常或者错误,Exception只是面向对象化的业务逻辑控制方法。如果没有明白这一点,那么我认为并没有真正明白应该怎么使用Java来正确的编程。

而我自己写的程序,会自定义大量的Exception类,所有这些Exception类都不意味着程序出现了异常或者错误,只是代表非主事件流的发生的,用来进行那些分支流程的流程控制的。例如你往权限系统中增加一个用户,应该定义1个异常类,UserExistedException,抛出这个异常不代表你插入动作失败,只说明你碰到一个分支流程,留待后面的catch中来处理这个分支流程。传统的程序员会写一个if else来处理,而一个合格的OOP程序员应该有意识的使用try catch 方式来区分主事件流和n个分支流程的处理,通过try catch,而不是if else来从代码上把不同的事件流隔离开来进行分别的代码撰写

很多人喜欢定义方法的返回类型为boolean型的,当方法正确执行,没有出错的时候返回true,而方法出现出现了问题,返回false。这在Java编程当中是大错而特错的!

方法的返回值只意味着当你的方法调用要返回业务逻辑的处理结果的。如果业务逻辑不带处理结果,那么就是void的,不要使用返回值boolean来代表方法是否正确执行。

1
boolean login(String username, String password);

很多人喜欢用boolean返回,如果是true,就是login了,如果false就是没有登陆上。其实是错误的。还有的人定义返回值为int型的,例如如果正确返回就是0,如果用户找不到就是-1,如果密码不对,就是-2

1
int login(String username, String password);

然后在主程序里面写一个if else来判断不同的流程

1
2
3
4
5
6
7
int logon = UserManager.login(xx,xx);;  
if (logon ==0); {
...
} else if (logon == 1); {
...
} else if (logon ==2); {
..}

这是面向过程的编程逻辑,不是面向对象的编程逻辑。

应该这样来写:

1
User login(String username, String password); throws UserNotFoundException, PasswordNotMatchException;

主程序这样写:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
try {  
UserManager.login(xx,xx);;
....
用户登陆以后的主事件流代码

} catch (UserNotFoundException e); {

...

用户名称没有的事件处理,例如产生一个提示用户注册的页面

} catch (PasswordNotMatchException e); {

....

密码不对的事件处理,例如forward到重新登陆的页面
}


看到这个示例,似乎明显违背了最佳实践的第一条:不要用来流程控制

如果这不是流程控制,那这种写法与流程控制有什么区别呢?再进一步,什么时候使用异常呢?

什么时候使用异常

在异常最佳实践中:异常只用于异常情况下!

需要捕捉的异常也有两种,一种是自己的程序抛出的,一种是系统抛出的

什么叫做程序抛出的异常,什么叫做系统抛出的异常,你能明确界定吗?FileNotFoundException你说算是系统异常呢?还是程序异常?站在某些程序员的角度,他会觉得是系统异常,不过像我喜欢看JDK源代码的人来说,我对Sun的程序什么情况下抛出FileNotFoundException很清楚,这些代码对我来说,和我自己写的代码能有什么不同吗?对我来说,FileNotFoundException就是程序异常。既然JDK可以抛出异常,凭什么我就不能抛出异常?

站在底层程序员的角度来看,根本没有什么系统异常可言,否则的话,还不如不要定义任何异常得了,干脆就是函数调用返回值,你说为什么Sun不定义0,1,2这样的返回值,而是抛出异常呢?Java程序无非就是一堆class,JDK的class可以抛异常,我写的class为什么不能抛出?

异常不异常的界定取决于你所关注的软件层面,例如你是应用软件开发人员,你关心的是业务流程,那么你就应该捕获业务层异常,你就应该定义业务层异常,向上抛出业务层异常。如果是底层程序员,你就应该定义和抛出底层异常。要不要抛出异常和抛出什么异常取决你站在什么软件层面了,离开这个前提,空谈异常不异常是没有意义的

因为0,1,2这样的值表达的含义不够丰富,但是作为返回值,又不合理。
————函数有它的本身的返回值。

因此,返回一个异常,其实就是一个封装完好的,返回的对象。这个对象Type不是在函数名的前面说明,而是在一个更加特别的地方,函数的后面说明。这就是异常的本质————非正常的返回值。这个返回值,为什么不能用传统的方法处理呢?因为Object x=method();表明它只能接受某一个特定的对象,如果出现Exception的对象,就会报错。因此需要catch来接手处理这样的返回值。

checked与unchecked选择

对于何时抛出异常,上面的论述大致已经清楚,使用Exception的关键是,你站在什么样的角度来看这个问题,这也得看大家对异常写法的习惯,异常并不只是单单的异常,在OO中,异常也是方法返回值的一部分

Java正统观点认为:已检查异常应该是标准用法,运行时异常表明编程错误,这也正如上面的例子,方法申明异常表明了有这些异常情况,那业务调用方需要考虑这些情况,但是检查异常引起了几个问题

  1. 太多的代码:开发人员将会因为不得不捕捉他们无法合理地处理的已检查异常(属于“某个东西出了可怕错误”种类)并编写忽略(忍受)它们的代码而感到无力。
  2. 难以读懂的代码:捕捉不能被正确地处理的异常并重新抛出它们没有执行一点有用的功能,反而会使查找实际做某件事的代码变得更困难
  3. 异常的无休止封装:一个已检查异常要么必须被捕捉,要么必须在一个遇到它的那个方法的抛出子句中被声明。这时要么重新抛出数量不断增长的异常,或者说捕捉低级异常,要么重新抛出被封装在一个较高级的新异常中的它们
  4. 易毁坏的方法签名
  5. 已检查异常对接口不一定管用

异常受检的本质并没有为程序员提供任何好处,它反而需要付出努力,还使程序更为复杂

被一个方法单独抛出的受检异常,会给程序员带 来非常高的额外负担。如果这个方法还有其他的受检异常,它被调用的时候一定已经出现在一个try块中,所以这个异常只需要别外一个catch块

非检查型异常的最大风险之一就是它并没有按照检查型异常采用的方式那样自我文档化。除非 API 的创建者明确地文档化将要抛出的异常,否则调用者没有办法知道在他们的代码中将要捕获的异常是什么

Rod Johnson采取了一种比eckel 稍正统的观点,因为Johnson认为已检查异常有一定用武之地,在一个异常相当于来自方法的一个可替代返回值得地方,这个异常无疑应该被检查,并且该语言能帮助实施这一点就再好不过了。但是觉得传统的java方法过分强调了已检查异常。


使用Checked Exception还是UnChecked Exception的原则,我的看法是根据需求而定。

如果你希望强制你的类调用者来处理异常,那么就用Checked Exception;
如果你不希望强制你的类调用者来处理异常,就用UnChecked。

那么究竟强制还是不强制,权衡的依据在于从业务系统的逻辑规则来考虑,如果业务规则定义了调用者应该处理,那么就必须Checked,如果业务规则没有定义,就应该用UnChecked。

还是拿那个用户登陆的例子来说,可能产生的异常有:

IOException (例如读取配置文件找不到)
SQLException (例如连接数据库错误)
ClassNotFoundException(找不到数据库驱动类)

NoSuchUserException
PasswordNotMatchException

以上3个异常是和业务逻辑无关的系统容错异常,所以应该转换为RuntimeException,不强制类调用者来处理;而下面两个异常是和业务逻辑相关的流程,从业务实现的角度来说,类调用者必须处理,所以要Checked,强迫调用者去处理。

在这里将用户验证和密码验证转化为方法返回值是一个非常糟糕的设计,不但不能够有效的标示业务逻辑的各种流程,而且失去了强制类调用者去处理的安全保障。

至于类调用者catch到NoSuchUserException和PasswordNotMatchException怎么处理,也要根据他自己具体的业务逻辑了。或者他有能力也应该处理,就自己处理掉了;或者他不关心这个异常,也不希望上面的类调用者关心,就转化为RuntimeException;或者他希望上面的类调用者处理,而不是自己处理,就转化为本层的异常继续往上抛出来。


Checked Exception与UnChecked Exception:

  1. 抛出Checked Exception,给直接客户施加一个约束,必须处理,但也是一种自由,客户可分门别类的处理不同异常;
    UnChecked Exception则给直接客户以自由,但也是一种欺瞒,因为客户不知道将要发生什么,所有的处理将是系统默认的处理(如打印堆栈到控制台,对开发者、用户都返回一样的内容,不管别人懂与不懂)。
    二者的选择其实是约束与自由的权衡。
  2. “对可恢复的情况使用已检查异常,对程序错误使用运行时异常。”而不是一咕脑的全抛出Checker Exception,这服务提供者是友好的
  3. 所以,若不需要客户依据不同异常采取不同后续行为,那么抛出UnChecked Exception是友好的;但若客户需要根据不同异常类采取不同行动,抛出Checked Exception是友好的。

对于checked exception转unchecked exception,大家都有共识,只是偏执于两方哪一方多些,在前期还是后期

Rod Johnson在spring的data access exception中就是个好示例,一是把异常细分化,更明确具体异常;二是把检查异常SQLException都转化为了unchecked exception

ErrorCode

异常对代码和开发、维护及管理一个应用的人都有用是至关重要的

对于开发、维护人

异常消息串具有有限的价值:当这些消息串出现在日志文件中时,他们对解释问题可能是有帮助的,但它们将无法使调用代码正确地做出反应,并且不能依靠它们本身来把它们显示给用户。当不同的问题可能需要不同的动作时,相应的异常应该被建模为一个公用超类的独立子类。有时,该超类应该是抽象的。现在,调用代码将可自由地在相关的细节级别上捕捉异常

已检查异常要比错误返回码(许多老式的语言中使用)好很多。迟早(或许不久),人们将不能检查一个错误返回值;

使用编译程序来实施正确的错误处理时一件好事。同参数和返回值一样,这样的已检查异常对一个对象的api来说是整体的不可分部分

用户

应该通过在异常中包括错误代码来处理

1
2
3
String getErrorCode();

String getMessage();

在spring早期代码中,就有ErrorCoded接口定义这两个方法,errorCode能够把为终端用户而计划的错误与为开发人员而计划的错误消息区分开。getMessage()用户记录日志,并且是面向开发人员


架构与架构师2

发表于 2021-02-24
字数统计: 1.6k | 阅读时长 ≈ 5

最近闲了,看了几次李运华关于架构的视频,不尽再次反问架构是什么?架构师的职责是什么?

对于这两个问题,之前也总结过一篇《架构和架构师》,再结合他的专栏文章和视频,补充一下

架构

李运华给架构的定义:软件架构指软件系统的顶层结构,缩句成架构指结构,而结构的修饰语蕴含了太多东西,抽象不够直白

这个定义里面蕴含了作者介绍的系统和子系统、模块与组件、框架与架构三组常见的概念

系统泛指由一群有关联的个体组成,根据某种规则运作,能完成个别元件不能单独完成的工作的群体。它的意思是“总体”“整体”或“联盟”

软件模块(Module)是一套一致而互相有紧密关连的软件组织。它分别包含了程序和数据结构两部分。现代软件开发往往利用模块作为合成的单位。模块的接口表达了由该模块提供的功能和调用它时所需的元素。模块是可能分开被编写的单位。这使它们可再用和允许人员同时协作、编写及研究不同的模块。

软件组件定义为自包含的、可编程的、可重用的、与语言无关的软件单元,软件组件可以很容易被用于组装应用程序中。

从逻辑的角度来拆分系统后,得到的单元就是“模块”;从物理的角度来拆分系统后,得到的单元就是“组件”。

划分模块的主要目的是职责分离;划分组件的主要目的是单元复用。其实,“组件”的英文 component 也可翻译成中文的“零件”一词,“零件”更容易理解一些,“零件”是一个物理的概念,并且具备“独立且可替换”的特点。

软件框架(Software framework)通常指的是为了实现某个业界标准或完成特定基本任务的软件组件规范,也指为了实现某个软件组件规范时,提供规范所要求之基础功能的软件产品

软件架构指软件系统的“基础结构”,创造这些基础结构的准则,以及对这些结构的描述

这么多的概念,在不理解的情况下,最多能记忆一小时吧,其实就算是理解了,最多也就记忆一天。因为这些概念比较虚,离我们具体coding有点远,但学习新知识又都是从定义起始,定义不理解时,就实践,再回看定义,可架构太大了,短时间没能力也没条件去架构设计

而且这定义可是一位阿里P9级别多年经验总结归纳出来的,得行多少路,抽象了多少回,才有的认知,所以我也不打算靠记忆了,不过对于模块和组件的认知很独到

虽然架构定义众家纷说,但对于如何描述架构还是有共识的,那就是“4+1视图”,在《架构和架构师》也描述了,也就是说架构的确需要从各角度观察和考虑

想来还是喜欢ISO/IEC 42010:20072 中对架构有如下定义

The fundamental organization of a system, embodied in its components, their relationships to each other and the environment, and the principles governing its design and evolution. 

这里定义了架构的三要素:

  • 职责明确的模块或者组件
  • 组件间明确的关联关系
  • 约束和指导原则

即架构是一种结构,是由组件(Components)+ 组件之间的关系 + 指导原则组成的

在《code review》中也提了,万事要以降低代码复杂度为大计,先Review设计实现思路,然后Review设计模式,接着Review成形的骨干代码,最后Review完成的代码;到了架构设计更是得如此,落地到代码层面,也就是要解决代码要如何被组织的问题,以业务为核心,解耦外部依赖,分离业务复杂度和技术复杂度


架构师

架构说清楚了,那架构师呢?在《架构和架构师》中,也说了

【优秀程序员的价值,不在于其所掌握的几招屠龙之术,而是在细节中见真著】,那么架构师则不仅要有屠龙刀,还得有绣花针

但如何拥有屠龙刀和绣花针呢?架构师在国内,大多时候可能不是个岗位,而是个角色。大厂还有架构师一说,小厂难得有专职架构师,所以架构师职能还得多多取经大牛,学习一下大牛

架构师能力模型

这三部分好似是任何一个职业的三条腿,要像走向人生巅峰迎娶白富美,这三条腿都得硬,越往上走,尤其管理和业务

技术只是技术人的最基本敲门砖,初级阶段以技术为重,往上走时,重点就得向后偏移,不能一直安静地码代码

架构设计过程

这个过程,回顾最近几个系统设计的确是这样的

  1. 业务方提出一个业务,刚开始可能只是个目标,轮廓
  2. 与业务方、产品不停的交流,交流得越深入,需求就越明确
  3. 理解业务并明确需求后,划分模块,不管是传统画ER图,还是4色建模,找出实体以及他们的关系
  4. 模块确定后,就是再深入细节,模块内部的业务流程,模块之间的交互
  5. 最后整理,确定技术选型,输出设计方案

之后,在架构落地过程中,随着业务进化,不停地演化架构,这些像上面说的宏观面的屠龙刀,绣花针就体现在细节,有时细节决定成败,架构师需要去识别哪些细节会影响到架构,以防后面不停地打补丁

code review

发表于 2021-02-19
字数统计: 2.1k | 阅读时长 ≈ 7

也不知code review是从哪年开始流行的,我的职场经历从刚开始完全没有到1对1,再到团队式review

一、Review Meeting

优点:

  1. 团队内新技术/新观点的交流Meeting、项目开发思路、解决方案讨论,偏头脑风暴式;
  2. 各类项目都适合进行;

缺点:

  1. 依赖于主持者(项目owner)的素质、时间成本高(为会议上所有人时间的总和);
  2. 集体评审的代码行数有限;

二、Single Review

优点:

  1. 更偏重与具体的代码评审,人员分散参与,评审代码行数有保证;
  2. 时间自由,reviewer什么时候进行评审时间可自控;

缺点:

  1. 依赖reviewer的技术水平,代码提交合并前强审核,只适用于重要项目或核心模块;

why

为什么需要code review,其实在任何行业,基本都是大厂带给整个行为最佳实践,code review就是其中一种实践

The biggest thing that makes Google’s code so good is simple: Code Review.

At Google, no code, for any product, for any project, gets checked in until it gets a positive review.

code review的好处可以罗列出很多很多,设计、结构、编码方方面面

代码有这几种级别:1)可编译,2)可运行,3)可测试,4)可读,5)可维护,6)可重用。
通过自动化测试的代码只能达到第3)级,而通过Code Review的代码少会在第4)级甚至更高

Code Review主要是让你的代码可以更好的组织起来,有更易读,有更高的维护性,同时可以达到知识共享,找到bug只是其中的副产品

以我个人经验看,code review更多是技术及业务知识的分享,甚至可以相互结合,理论分享与code的结合

比如check list与最佳实践结合

how

code review有点类似TDD,但强于TDD,这儿的强于不是说功能性,而在于落地层面,只要大家坐一起,指点江山,就可以完成了,当然效果另说

怎么更好地落地code review呢?或者说code review需要review些什么?code?

每个团队都有各自的情况,所以并不是随便拿一份review check list对照就做好,至少侧重点不同

比如人家团队人员素质普遍高一些,那人家的checklist可能就少了些基础知识点;团队职责不同,checklist也可能会相应不同,基础架构的checklist肯定跟业务线的不一样,各个不同业务线的也不同,需要根据团队情况制定合适的checklist

极端情况,团队中无人能识别好代码,每次都是流水帐式看代码,那团队人员得流动一下了

如何code review,结合why谈谈一些点

不要挑毛病

这就是上面的图中显示的,尤其团队式review,一群坐在下面的人

  1. 命名不太好啦
  2. 空格太多了
  3. 方法太长了
  4. 编码没格式化啊
  5. 这循环用lambda一行解决问题
  6. 实现的不是产品要的
  7. 这设计有问题啊

对1~4点,这些纯粹是浪费时间,一个团队的时间是宝贵的,来review这些,极大的浪费

因此需要明确两点:

  • Code review 不应该承担发现代码错误的职责
  • Code review 不应该成为保证代码风格和编码标准的手段

【管理工具化、工具流程化】指导方针,这儿可以引入checkstyle工具,让团队统一code sytle,新人加入团队时的培训指南中,并加入到CI中,检查失败直接构建失败

再引入sonar识别常见质量问题和安全问题,这样提高code review的质量

第5点:这也很典型,从code review层面讲,这也不应该是code review的职责,但从知识分享角度讲,这的确是,怎么办呢?使用流还是经典的for循环最好,如果团队成员对同一段代码有不同的意见,那么开发人员应该如何进行修改,结束审阅,并将代码推送到生产中?

解决这个问题最好能有一套最佳实践标准,明确什么情况使用流式,什么情况使用传统方式,其实这很难,真这样搞最佳实践会成为一本谁也学不完的手册,那只能说“这要看情况”,未尝不可,但需要有前提,团队中需要有一名裁决者来决定最终方案,而不能陷入长时间的争论

好比service能不能跨业务调用dao,这也是无对错,需要是的团队的一致性和最初的决策方案,不必每次code review时无休争论

6~7两点,这是最坑的,浪费了开发时间,也对代码作者造成极大打击,为什么到此时才发现,所以需要在开始前就得对功能设计和架构设计进行review,不能只看结果,得看起始与过程

保证正确性

这是code review的前提条件,如上述的6、7两点,不应该出现,一个优秀的工程师需要能够独当一面,能够在系统角度实现局部的良好设计,通过合理的测试方法论验证结果。能够用合理的数据结构、算法实现功能

在技术驱动的团队里,即使需求很紧急,对于关键的功能,核心成员也会耐心地审视架构设计和实现方案,如何控制熵,如何用更合理的方式实现,如何考虑到未来的变化。
技术驱动的团队里,应该持续进行对设计的调整和代码的微小重构与改良,时刻在整个系统的设计和表现(performance)角度审视自己的工作。这也是“系统思考”的核心。
大部分的代码的熵增大难以控制,并不是因为没有好的架构,而是因为迭代中忽略了系统性的思考和审视,而是用局部的解决方案解决问题,在反复迭代后,复杂度过高导致控制熵变得异常困难。这是code review比较难解决的

分享

从上面所述,code review虽然能发现代码中的一些错误,但不应该是他的核心价值。正好在《DDD总结》中所述,“降低代码复杂度”是所有方法实践论的终极目标。降低复杂度、易于扩展是我们的目标。那么code review也应该是为实现这个目标的手段,因此code review需要去review设计的合理性(如实现方法,数据结构,设计模式,扩展性考虑等),是否存在大量重复代码等

如何达到这些呢?需要发挥团队力量,三个臭皮匠顶过一个诸葛亮,代码终究是需要人去看的,通过与他人的交流,去寻求最佳实践,交流前提就是去分享自我,包括设计思想和实现路径

小到与一个人分享,也就是一对一code reivew,这样让review的开发人员了解代码的设计和实现,即能得到别人的指导,又能传递自我,并且能互为backup,方便后期维护,减少项目风险

大到与团队分享,产生技术氛围,让好的知识、设计在团队中分享,实现整体团队的成长和整体效益最大化

也鉴于要去把代码与人分享,就更容易让大家写出更具可读性的代码,提高可维护性,随便也让别人发现除功能逻辑外的一些技术逻辑:比如数据库连接是否忘记关闭,线程池是否正确使用等等,也加强了checklist的广度和深度

when

什么时候code review,大多数时候都是在上线前才做这件事,但理论最佳时间应该在提测前,以防测试完成后,又要对代码做变动

在实践时,可以拿出专门时间进行,以错开迭代发布的紧张期


除了上述的方法论,team leader还要在如何更好地code review,让团队更有意愿地参与上花心思,让团队成为一个学习型组织,有工程师文化的组织

面向对象是什么

发表于 2021-02-08
字数统计: 3k | 阅读时长 ≈ 11

近两年设计了几个系统,不管是直接使用传统设计ER图,还是使用4C建模,但在做架构评审时,ER却都是重中之重,让人不得不深思,编程思想经过了一代代发展,为什么还在围绕ER,在远古时代,没有OO,没有DDD,但为什么延续至今的伟大软件也比比皆是

带着这个问题,需要回头看看,结构化编程为什么不行?面向对象因何而起,到底解决了什么问题?

《架构整洁之道》也特别介绍了面向对象编程,面向对象究竟是什么,大多从三大特性:封装、继承、抽象说起,但其实这三种特性并不是面向对象语言特有

结构化编程

提到结构化编程就自然想到其中的顺序结构:代码按照编写的顺序执行,选择结构: if/else,而循环结构: do/while

虽然这些对每个程序员都很熟悉,但其实在结构化编程之间还有非结构化编程,也就是goto语句时代,没有if else、while,一切都通过goto语句对程序控制,它可以让程序跑到任何地方执行,这样当代码规模变大之后,就几乎难以维护

编程是一项难度很大的活动。因为一个程序会包含非常多的细节,远超一个人的认知能力范围,任何一个细微的错误都会导致整个程序出现问题。因此需要将大问题拆分成小问题,逐步递归下去,这样,一个大问题就会被拆解成一系列高级函数的组合,而这些高级函数各自再拆分成一系列低一级函数,一步步拆分下去,每一个函数都需要按照结构化编程方式进行开发,这也是现在常被使用的模块功能分解开发方式

结构化编程中,各模块的依赖关系太强,不能有效隔离开来,一旦需求变动,就会牵一发而动全身,关联的模块由于依赖关系都得变动,那么组织大规模程序就不是它的强项

面向对象

正因为结构化编程的弊端,所以有了面向对象编程,可以更好的组织程序,相对结构局部性思维,我们有了更宏观视角:对象

封装

把一组相关联的数据和函数圈起来,使圈外的代码只能看见部分函数,数据则完全不可见;如类中的公共函数和私有成员变量

提取一下关键字:

  1. 数据,完全不可见
  2. 函数,只能看见
  3. 相关联

这些似乎就是我们追求的高内聚,也是常提的充血模型,如此看,在实践中最基本的封装都没有达成

到处是贫血模型,一个整体却分成两部分:满是大方法的上帝类service与只有getter和setter的model

service对外提供接口,model传输数据,数据库固化数据,哪有封装性,行为与数据割裂了

怎么才能做到一个高内聚的封装特性呢?

设计一个类,先要考虑其对象应该提供哪些行为。然后,我们根据这些行为提供对应的方法,最后才是考虑实现这些方法要有哪些字段

并且对于这些字段尽可能不提供getter 和 setter,尤其是 setter

暴露getter和setter,一是把实现细节暴露出来了;二是把数据当成了设计核心

方法的命名,体现的是你的意图,而不是具体怎么做

1
2
3
4
5
6
7
8
9
// 修改密码 
public void setPassword(final String password) {
this.password = password;
}

// 修改密码
public void changePassword(final String password) {
this.password = password;
}

把setter改成具体的业务方法名,把意图体现出来,将意图与实现分离开来,这是一个优秀设计必须要考虑的问题

构建一个内聚的单元,我们要减少这个单元对外的暴露,也就是定义中的【只能看到的函数】

这句话的第一层含义是减少内部实现细节的暴露,它还有第二层含义,减少对外暴露的接口

最小化接口暴露。也就是,每增加一个接口,你都要找到一个合适的理由。

总结:
基于行为进行封装,不要暴露实现细节,最小化接口暴露

继承

先看继承定义:

继承(英语:inheritance)是面向对象软件技术当中的一个概念。这种技术使得复用以前的代码非常容易,能够大大缩短开发周期,降低开发费用
继承就是子类继承父类的特征和行为,使得子类对象(实例)具有父类的属性和方法,或子类从父类继承方法,使得子类具有父类相同的行为

从定义看,继承就是为了复用,把一些公共代码放到父类,之后在实现子类时,可以少写一些代码,消除重复,代码复用

继承分为两类:实现继承与接口继承

1
2
3
Child object = new Child();

Parent object = new Child();

但有个设计原则:组合优于继承Composition-over-inheritance

为什么不推荐使用继承呢?

继承意味着强耦合,而高内聚低耦合才符合我们的道,但其实并不是说不能使用继承,对于行为需要使用组合,而数据还得使用继承

这样解释似乎不够形象,再进一步讲,继承也违背了《SOLID》中的OCP,继承虽然可以通过子类扩展新的行为,但因为子类可能直接依赖父类实现,导致一个变更可能会影响所有子类。也就是讲继承虽然能Open for extension,但很难做到Closed for modification

借用阿里大牛的示例:

有个游戏,基本规则就是玩家装备武器去攻击怪物

  • 玩家(Player)可以是战士(Fighter)、法师(Mage)、龙骑(Dragoon)
  • 怪物(Monster)可以是兽人(Orc)、精灵(Elf)、龙(Dragon),怪物有血量
  • 武器(Weapon)可以是剑(Sword)、法杖(Staff),武器有攻击力
  • 玩家可以装备一个武器,武器攻击可以是物理类型(0),火(1),冰(2)等,武器类型决定伤害类型
1
2
3
4
5
6
7
8
9
10
11
12
13
public abstract class Player {
Weapon weapon
}
public class Fighter extends Player {}
public class Mage extends Player {}
public class Dragoon extends Player {}

public abstract class Weapon {
int damage;
int damageType; // 0 - physical, 1 - fire, 2 - ice etc.
}
public Sword extends Weapon {}
public Staff extends Weapon {}

攻击规则如下:

  • 兽人对物理攻击伤害减半
  • 精灵对魔法攻击伤害减半
  • 龙对物理和魔法攻击免疫,除非玩家是龙骑,则伤害加倍
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
public class Player {
public void attack(Monster monster) {
monster.receiveDamageBy(weapon, this);
}
}

public class Monster {
public void receiveDamageBy(Weapon weapon, Player player) {
this.health -= weapon.getDamage(); // 基础规则
}
}

public class Orc extends Monster {
@Override
public void receiveDamageBy(Weapon weapon, Player player) {
if (weapon.getDamageType() == 0) {
this.setHealth(this.getHealth() - weapon.getDamage() / 2); // Orc的物理防御规则
} else {
super.receiveDamageBy(weapon, player);
}
}
}

public class Dragon extends Monster {
@Override
public void receiveDamageBy(Weapon weapon, Player player) {
if (player instanceof Dragoon) {
this.setHealth(this.getHealth() - weapon.getDamage() * 2); // 龙骑伤害规则
}
// else no damage, 龙免疫力规则
}
}

如果此时,要增加一个武器类型:狙击枪,能够无视一切防御,此时需要修改

  1. Weapon,扩展狙击枪Gun
  2. Player和所有子类(是否能装备某个武器)
  3. Monster和所有子类(伤害计算逻辑)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class Monster {
public void receiveDamageBy(Weapon weapon, Player player) {
this.health -= weapon.getDamage(); // 老的基础规则
if (Weapon instanceof Gun) { // 新的逻辑
this.setHealth(0);
}
}
}

public class Dragon extends Monster {
public void receiveDamageBy(Weapon weapon, Player player) {
if (Weapon instanceof Gun) { // 新的逻辑
super.receiveDamageBy(weapon, player);
}
// 老的逻辑省略
}
}

由此可见,增加一个规则,几乎链路上的所有类都得修改一遍,越往后业务越复杂,每一次业务需求变更基本要重写一次,这也是为什么建议尽量不要违背OCP,最核心的原因就是现有逻辑的变更可能会影响一些原有代码,导致一些无法预见的影响。这个风险只能通过完整的单元测试覆盖来保障,但在实际开发中很难保障UT的覆盖率

也由此可见继承的确不是代码复用的好方式

从设计原则角度看,继承不是好的复用方式;从语言特性看,也不是鼓励的做法。一是像Java,只能单继承,一旦被继承就再也无法被其他继承,而且java中有Variable Hiding的局限性

比如现在添加一个业务规则:

  • 战士只能装备剑
  • 法师只能装备法杖
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Data
public class Fighter extends Player {
private Sword weapon;
}

@Test
public void testEquip() {
Fighter fighter = new Fighter("Hero");

Sword sword = new Sword("Sword", 10);
fighter.setWeapon(sword);

Staff staff = new Staff("Staff", 10);
fighter.setWeapon(staff);

assertThat(fighter.getWeapon()).isInstanceOf(Staff.class); // 错误了
}

其实只是修改了父类的weapon,并没有修改子类的;由此编程语言的强类型无法承载业务规则。

继承并不是复用的唯一方法,如ruby中有mixin机制

多态

多态(Polymorphism)按字面的意思就是“多种状态”。在面向对象语言中,接口的多种不同的实现方式即为多态

在上一讲,接口继承更多是多态特性

只使用封装和继承的编程方式,称之为基于对象编程,而只有把多态加进来,才能称之为面向对象编程,有了多态,才将基于对象与面向对象区分开;有了多态,软件设计才有了更大的弹性

多态虽好,但想要运用多态,需要构建出一个抽象,构建抽象需要找出不同事物的共同点,这也是最有挑战地方。在构建抽象上,接口扮演着重要角色:一接口将变的部分和不变部分隔离开来,接口是约定,约定是不变的,变化的是各自的实现;二接口是一个边界,系统模块间通信重要的就是通信协议,而接口就是通信协议的表达

1
2
3
ArrayList<> list = new ArrayList();

List<> list = new ArrayList();

二者之间的差别就在于变量的类型,是面向一个接口,还是面向一个具体的实现类;看似没什么意义,但在《SOLID》中可以发现,几乎所有原则都需要基于接口编程,才能达到目的

而这也就是多态的威力

就java这门语言,继承与多态相互依存,但对于其他语言并不是如此

总结

除了结构化编程和面向对象编程,现在还有函数式编程,然通过上面的阐述,回到开篇的问题,我应该是把编程语言与编程范式搞混了,像结构化编程、面向对象编程是一种编程范式,而具体的C、Java其实是编程语言,对于编程语言是年轻的,的确在很多伟大软件之后才诞生,但编程范式是一直存在的,面向对象范式并不是java之后才有

更不是C语言不能创造伟大软件,语言不过是工具,而最最重要的是思维方式,最近思考为什么TDD,DDD这些驱动式开发都很难,关键还是思维方式的转变

为什么都要看ER图呢,这里面又常被混淆的概念:数据模型与领域模型,下一篇再分解

Reference

《架构整洁之道》

《软件之美》

再议DDD分层

发表于 2021-01-22
字数统计: 520 | 阅读时长 ≈ 1

之前整理过《DDD分层》 以及《分层架构》

最近看网友讨论,整理一些有亮点的地方

现在分层架构+整洁架构似乎是个万金油组合了

之前DDD的标准分层结构:

右边传统分层,左边经过DIP改进型,两者有什么区别呢?

眼尖的人可以看出来,两者确实差了不少

线条1:application到infrastructure被反转了

线条2:这条线没有了,在MVC里面这线是常见的,applicaton与domain没分开,但DDD中这条线是不推荐的,就算在松散分层架构中也一般不使用,除非简单的CRUD项目

线条3:也被反转了,这其实类似CQRS中的Q部分


以上来源于群友的讨论,真的是世上无难事,只怕有心人;这点区别真没留意过

这图来源于阿里大牛殷浩之手,《阿里DDD四弹》中进行过总结,DTOAssembler放在了application层,有些不太合理

在《分层架构》中thrift的TService,为了不与controller重复,所以需要一个application service,此时thrift与controller可以有相同的业务请求

也就是说controller对外有多种输入,但对应用层来说都是同一个user case,如果放在应用层内转化,是不是应该层为了同一个use case要爆露多过方法

适配层做三件事:

  1. 协议适配(如采用controller,通过 @RequestMapping 注解和 JSON 序列化)
  2. 参数规则验证(如,不能为空、手机是数字并且11位、邮箱要有@之类简单验证)
  3. 为调用下层(应用层)转化输入(assembler)

如果说分4层的话:

  1. controller (assembler、转化)
  2. appliction
  3. domain
  4. repository(convertor、转化)

应用层是真正的业务入口,是很披的一层:

  1. 用来协调领域操作 这里一般看系统架构不一样会有所不同,主要再分为同步调用的方式,和步骤事件的方式。
  2. 关注点分离的操作(如日志、通知等)

application service编排业务,domain service编排领域

阿里DDD四弹拜读

发表于 2021-01-11
字数统计: 6.2k | 阅读时长 ≈ 21

阿里殷浩大牛写了DDD系统文章,现在已经更新到每四篇,有很多异于常规的地方,收获良多,总结一下

Domain primitive

对于DDD第一讲,作者介绍的Domain primitive,开始有些反感的,对DDD理论也深知一二,但从没听过有这概念,所以觉得作者是挂羊头卖狗肉的,但读完发现原来是对Value object的升华,牛人就是不一样

Domain Primitive的概念和命名来自于Dan Bergh Johnsson & Daniel Deogun的书 Secure by Design,特意找到了这本书的章节,https://livebook.manning.com/book/secure-by-design/chapter-5/1,有兴趣可以看看

对于DP,在《代码大全》中指出过类似概念:ADT

抽象数据类型ADT是指一些数据以及对这些数据所进行的操作的集合

关于使用ADT的建议:

  1. 把常见的底层数据类型创建为ADT并且使用这些ADT,而不再使用底层数据类型
  2. 把像文件这样的常用对象当成ADT
  3. 简单的事物也可以当做ADT:这样可以提高代码的自我说明能力,让代码更容易修改。
  4. 不要让ADT依赖于其存储介质

ADT就是业务上的最小类型,不要去使用编程语言提供的基础类型

在之前的DDD文章中,也指出很多时候的重构不过是大方法拆分成小方法,更SRP一些,其实也什么意义,DDD带来的好处是业务语义显现化,而DP就是一种手段

使用DP后代码遵循了 DRY 原则和单一性原则,作者从四个维度提出DP带来的好处:接口的清晰度(可阅读性)、数据验证和错误处理、业务逻辑代码的清晰度、和可测试性

在实际项目中碰到一个有意义的问题,我们通过OCR接受识别的增值税发票信息

之前的接口是receiveInvoice(String invoiceCode,String invoiceNo,String checkCode,…),接受OCR给的发票结构化信息

发票号码和发票代码是有业务语义,是业务的最小类型,invoiceCode可以从String升级为InvoiceCode

接口变成:receiveInvoice(InvoiceCode invoiceCode,InvoiceNo invoiceNo,CheckCode checkCode),把业务最小单元提取出来了,接口清晰度,业务语义也显现了。可有个有意思的地方,OCR会出错的,一个正常的发票号码是8位,但会被识别成9位,业务上不能是InvoiceNo,可得固化存储这个识别结果,因此这个入口不能过于语义,总不能来一个WrongInvoiceNo

这儿我可能有个误区,把DP作为有语义的数据验证工具类使用了,可DP应该是Value object的升华,得在domain层使用,参数校验还得用Validate

定义

Domain Primitive 是一个在特定领域里,拥有精准定义的、可自我验证的、拥有行为的 Value Object 。

  1. DP是一个传统意义上的Value Object,拥有Immutable的特性
  2. DP是一个完整的概念整体,拥有精准定义
  3. DP使用业务域中的原生语言
  4. DP可以是业务域的最小组成部分、也可以构建复杂组合

原则

  1. 将隐性的概念显性化(Make Implicit Concepts Explicit)
  2. 将隐性的上下文显性化(Make Implicit Context Explicit)
  3. 封装多对象行为(Encapsulate Multi-Object Behavior)

VS Value Object

在 Evans 的 DDD 蓝皮书中,Value Object 更多的是一个非 Entity 的值对象

在Vernon 的 DDD红皮书中,作者更多的关注了Value Object的Immutability、Equals方法、Factory方法等

Domain Primitive 是 Value Object 的进阶版,在原始 VO 的基础上要求每个 DP 拥有概念的整体,而不仅仅是值对象。在 VO 的 Immutable 基础上增加了 Validity 和行为。当然同样的要求无副作用(side-effect free)

VS DTO


应用架构

这一篇算是正常篇,很多文章都是这样的,也是以银行转账为例,但分析得更特彻

应用架构,意指软件系统中固定不变的代码结构、设计模式、规范和组件间的通信方式

一个好的架构应该需要实现以下几个目标:

  1. 独立于框架:架构不应该依赖某个外部的库或框架,不应该被框架的结构所束缚
  2. 独立于UI:前台展示的样式可能会随时发生变化
  3. 独立于底层数据源:无论使用什么数据库,软件架构不应该因不同的底层数据储存方式而产生巨大改变
  4. 独立于外部依赖:无论外部依赖如何变更、升级,业务的核心逻辑不应该随之而大幅变化
  5. 可测试:无论外部依赖什么样的数据库、硬件、UI或服务,业务的逻辑应该都能够快速被验证正确性

这是很多架构的目标,但想想,一个架构是这样,那还剩下什么,框架没了,数据库没了,对于习惯了CRUD的程序员,什么都没了,我们的架构为了什么。真的就是那个模型,一个软件之所以是这个软件的核心模型,也就是domain

DDD是一种设计范式,主张以领域模型为中心驱动整个软件的设计。在DDD中,业务分析和领域建模是软件开发的关键活动。它不关心软件的架构是怎样的。随着技术的发展,我们可能在新版本中更换软件的架构,但是只要业务没有变更,领域模型就是稳定的,无需改动。

事务脚本弊端

一、可维护性能差

可维护性 = 当依赖变化时,有多少代码需要随之改变

二、可拓展性差

可扩展性 = 做新需求或改逻辑时,需要新增/修改多少代码

三、可测试性能差

可测试性 = 运行每个测试用例所花费的时间 * 每个需求所需要增加的测试用例数量

破坏原则

一、单一原则

二、依赖反转原则

三、开放封闭原则

DDD

如果今天能够重新写这段代码,考虑到最终的依赖关系,我们可能先写Domain层的业务逻辑,然后再写Application层的组件编排,最后才写每个外部依赖的具体实现。这种架构思路和代码组织结构就叫做Domain-Driven Design(领域驱动设计,或DDD)。所以DDD不是一个特殊的架构设计,而是所有Transction Script代码经过合理重构后一定会抵达的终点

这段话一针见血,很多时候在讨论DDD时,只是学习战术,什么实体、值对象、聚合,可在做项目时,引入了这些器,其他还不是CRUD,谁真正想过domain,并且以此驱动开发

DDD架构能有效解决传统架构中问题:

  1. 高可维护性:当外部依赖变更时,内部代码只用变更跟外部对接的模块,其他业务逻辑不变
  2. 高可扩展性:做新功能时,绝大部分代码都能利用,仅需要增加核心业务逻辑即可
  3. 高可测试性:每个拆分出来的模块都符合单一性原则,绝大部分不依赖框架,可以快速的单元测试,做到100%覆盖
  4. 代码结构清晰:通过POM module可以解决模块间的依赖关系,所有外接模块都可以单独独立成jar包被复用。当团队形成规范后,可以快速的定位到相关代码

作者的模块划分和依赖关系,细分析之后发现了些妙处

Infrastructure模块包含了Persistence、Messaging、External等模块。比如:Persistence模块包含数据库DAO的实现,包含Data Object、ORM Mapper、Entity到DO的转化类等。Persistence模块要依赖具体的ORM类库,比如MyBatis。如果需要用Spring-Mybatis提供的注解方案,则需要依赖Spring

Persistence从infrastructure剥离出来,解决了之前碰到的循环依赖问题(repository接口在domain层,但现实在infra层,可从maven module依赖讲,domain又是依赖infra模块的)

对于为什么拆分得这么细,是不是也解决这个问题,特意请教了作者,作者回复:

这块儿可拆可不拆,拆的好处是每个模块职责比较简单,但不拆问题也不大的
domain没必要依赖infra啊?domain里自带Repository接口,所以从maven角度来看,infra是依赖domain
infra只依赖application、domain即可。这里面还有一个Start的模块,把infra依赖进来,然后Spring的DI会自动注入的
这里,domain或app依赖的外部的接口而已,这个一般是独立的jar包
这个也可以是ACL里面的Facade,但是具体的调用实现还是infra

之前也思考过,的确得DIP,domain在最下层,infra在上面,但有个问题,把对外部依赖的接口都是放在infra里面的,所以倒置不了,以及之前在《DDD分层》里面提到的

DDD引入repository放在了领域层,一是对应聚合根的概念,二是抽象了数据库访问,,但DDD限界上下文可能不仅限于访问数据库,还可能访问同样属于外部设备的文件、网络与消息队列。为了隔离领域模型与外部设备,同样需要为它们定义抽象的出口端口,这些出口端口该放在哪里呢?如果依然放在领域层,就很难自圆其说。例如,出口端口EventPublisher支持将事件消息发布到消息队列,要将这样的接口放在领域层,就显得不伦不类了。倘若不放在位于内部核心的领域层,就只能放在领域层外部,这又违背了整洁架构思想

对于这段话,发给作者,让他点评了一下,作者回复:

EventPublisher接口就是放在Domain层,只不过namespace不是xxx.domain,而是xxx.messaging之类的
这里面2个概念,一个是maven module怎么搞,一个是什么是Domain层。这两个不是同一件事
Repository、eventpublisher接口再Domain这个module里,但是他们从理论上是infra层的。

我一般的理解:从外部收到的,属于interface层,比如RPC接口、HTTP接口、消息里面的消费者、定时任务等,这些需要转化为Command、Query、Event,然后给到App层。
App主动能去调用到的,比如DB、Message的Publisher、缓存、文件、搜索这些,属于infra层

所以消息相关代码可能会同时存在2层里。这个主要还是看信息的流转方式,都是从interface-》Application-〉infra


Repository模式

这一篇对repository有了更深的了解,之前对repository的认知和实践都太浮浅

  1. repository只能操作聚合根
  2. repository类似dao,当作dao使用
  3. repository是领域层,但从菱形架构中得知,保持领域层的纯洁,放到南向网关

对repository的认知和实践也就这些了,在实践时基本当成dao使用,当然也碰到了想不通的问题

第一点:数据加载与性能平衡问题

repository操作的对象是聚合根,因此加载一个聚合根,就得是一个完整的聚合根,可是有时我们只想加载部分数据,怎么办?
很多人指出依赖懒加载方式来解决,但也有人指出这是通过技术解决设计问题,我也迷茫,到底怎么办呢?写两个方法吧

1
2
3
findOrder(OrderId id);//获取完整的order聚合

findOrderWithoutItems(OrderId id);//只取order不取明细

特别的别扭吧

第二点:更新数据时,只从聚合根操作,那到了repository怎么知道具体操作哪个对象

可能又需要类似第一点,写多个方法了

这些都是很麻烦很现实的问题,为了domain纯洁性,为了DIP而特意加上不合格的repository,是不是更麻烦了呢?

很多时候,其实技术、框架、依赖三方都不怎么变,变得恰恰是domain,产品需求一日三变,难道我们努力的方向有问题?

repository价值

对于这个问题,就是为了DIP吧,作者又重新对比了DAO

DAO的核心价值是封装了拼接SQL、维护数据库连接、事务等琐碎的底层逻辑,让业务开发可以专注于写代码,但是在本质上,DAO的操作还是数据库操作,DAO的某个方法还是在直接操作数据库和数据模型,只是少写了部分代码

在Uncle Bob的《代码整洁之道》一书里,作者用了一个非常形象的描述:

  • 硬件(Hardware):指创造了之后不可(或者很难)变更的东西。数据库对于开发来说,就属于”硬件“,数据库选型后基本上后面不会再变,比如:用了MySQL就很难再改为MongoDB,改造成本过高。
  • 软件(Software):指创造了之后可以随时修改的东西。对于开发来说,业务代码应该追求做”软件“,因为业务流程、规则在不停的变化,我们的代码也应该能随时变化。
  • 固件(Firmware):即那些强烈依赖了硬件的软件。我们常见的是路由器里的固件或安卓的固件等等。固件的特点是对硬件做了抽象,但仅能适配某款硬件,不能通用。所以今天不存在所谓的通用安卓固件,而是每个手机都需要有自己的固件

从上面的描述我们能看出来,数据库在本质上属于“硬件”,DAO 在本质上属于“固件”,而我们自己的代码希望是属于“软件”。但是,固件有个非常不好的特性,那就是会传播,也就是说当一个软件强依赖了固件时,由于固件的限制,会导致软件也变得难以变更,最终让软件变得跟固件一样难以变更

比如我们使用的mybaties,有各种mapper,原来放在dao中,现在放在repository,换汤不换药

我们需要一个模式,能够隔离我们的软件(业务逻辑)和固件/硬件(DAO、DB),让我们的软件变得更加健壮,而这个就是Repository的核心价值

此刻,对菱形架构有点反思,是不是repository就应该是领域层,就是与外界数据来源的隔离,不关心具体是不是数据库,io,都是repository

作者把DTOAssembler放在了application层,是不是有点不太合理,至少不太符合分层架构,应该放在controller中呢?

从使用复杂度角度来看,区分了DO、Entity、DTO带来了代码量的膨胀(从1个变成了3+2+N个)。但是在实际复杂业务场景下,通过功能来区分模型带来的价值是功能性的单一和可测试、可预期,最终反而是逻辑复杂性的降低。

repository规范

传统Data Mapper(DAO)属于“固件”,和底层实现(DB、Cache、文件系统等)强绑定,如果直接使用会导致代码“固化”。所以为了在Repository的设计上体现出“软件”的特性,主要需要注意以下三点:

  1. 接口名称不应该使用底层实现的语法,insert,select,update,delete是sql语法
  2. 出入参不应该使用底层数据格式,操作的是Aggregate Root,避免底层实现逻辑渗透到业务代码中的强保障
  3. 应该避免所谓的“通用”repository模式

Change-Tracking 变更追踪

这是很多文章没提过的,这也解决了上面的第二点问题

对于第一点问题,也特意请教了作者,作者这样回复:

在业务系统里,最核心的目标就是要确保数据的一致性,而性能(包括2次数据库查询、序列化的成本)通常不是大问题。如果为了性能而牺牲一致性,就是捡了芝麻漏了西瓜,未来基本上必然会触发bug。

如果性能实在是瓶颈,说明你的设计出了问题,说明你的查询目标(主订单信息)和写入目标(主子订单集合)是不一致的。这个时候一个通常的建议是用CQRS的方式,Read侧读取的可能是另一个存储(可能是搜索、缓存等),然后写侧是用完整的Aggregate来做变更操作,然后通过消息或binlog同步的方式做读写数据同步。


领域层设计规范

这一讲,对领域层讲解得很充分,大牛就是大牛,DDD只是一个外衣,遮挡不了牛人内涵的韵美,这个系列如果不与DDD联系,就取名牛人叫你怎么写代码,也是相当优秀

一直以来我也认为DDD的基础是面向对象思想,我相对认为DDD与面向对象两者交集很大,重合度很高,结果作者这篇让我认知更深刻了,尤其以游戏为示例,让我更加佩服了,毕竟我在游戏业混了很久,一直自认写的代码还不错,也碰到文章指出的一些问题,可没再深入思考更优解决方案

继承

为什么Composition-over-inheritance?以前只知道继承是强耦合,我们的道是高内聚低耦合,所以不要多过使用继承。之前同事讲过行为要多Composition,但数据层面还得inheritance

作者从OCP角度再次解释这个问题

继承虽然可以通过子类扩展新的行为,但因为子类可能直接依赖父类的实现,导致一个变更可能会影响所有对象

继承虽然能Open for extension,但很难做到Closed for modification。所以今天解决OCP的主要方法是通过Composition-over-inheritance,即通过组合来做到扩展性,而不是通过继承

领域服务

作者讲了三种场景的领域服务

单对象策略型

这个示例,很有新意,实在的,没见过这样写的,有点不理解,特地请教了作者

为什么通过Double Dispatch来反转调用领域服务的方法

这里的问题就是你作为服务提供方,没办法保证Weapon入参一定是合法的。在这里依赖了另一个服务的提前校验,就说明Player没有做校验,那如果因为忘记或者bug没有提前校验呢?
在这里Entity的设计理念是要强保证一致性,这也是为什么要让服务通过参数注入

可能这是事务脚本思维的原因,先判断再执行,而作者的意思是执行本身就应该包含判断,是个整体,不能分两步

跨对象事务型

这个常见,领域服务就是这样来的

Player.attack(monster) 还是 Monster.receiveDamage(Weapon, Player)?放在领域服务就行了

通用组件型

这个平常大多被Utils类给取代了

但如果再增加一个跳跃能力Jumpable呢?一个跑步能力Runnable呢?如果Player可以Move和Jump,Monster可以Move和Run,怎么处理继承关系?要知道Java(以及绝大部分语言)是不支持多父类继承的,所以只能通过重复代码来实现

这个问题,最近在项目正好又碰到了,java继承没法处理,只能接口化处理

但在实体上都得实现相应接口,有些重复,对此也特地请教了作者:

MovementSystem可以共用,但这些实体类都实现Movable,也得重复实现,这个情况是不是没法避免

这个是很正常的,Movable本来就应该是说“我能够Move”,然后要Move就必须要有Position和Velocity。所以在Entity层面重复实现是必须的。只要是接口编程就必然需要这样(除非走mixin,但那个Java不支持)。没办法走Base类就是因为要避免继承,同时base类也没办法实现多父继承。
在有Mixin的语言里理论上是可以避免这种,但是Mixin有自己的问题,同时我们主流编程语言都没有mixin

OOP、ECS、DDD三者的比较:

  • 基于继承关系的OOP代码:OOP的代码最好写,也最容易理解,所有的规则代码都写在对象里,但是当领域规则变得越来越复杂时,其结构会限制它的发展。新的规则有可能会导致代码的整体重构。
  • 基于组件化的ECS代码:ECS代码有最高的灵活性、可复用性、及性能,但极具弱化了实体类的内聚,所有的业务逻辑都写在了服务里,会导致业务的一致性无法保障,对商业系统会有较大的影响。
  • 基于领域对象 + 领域服务的DDD架构:DDD的规则其实最复杂,同时要考虑到实体类的内聚和保证不变性(Invariants),也要考虑跨对象规则代码的归属,甚至要考虑到具体领域服务的调用方式,理解成本比较高。

实体类

在这个过程中,作者也随带说了些实体的规范

因为 Weapon 是实体类,但是Weapon能独立存在,Player不是聚合根,所以Player只能保存WeaponId,而不能直接指向Weapon;
这是对象之间关系的处理,都是这样推荐的,不要对象对联,使用ID关联。但聚合根可以直接对象关联

Entity只能保留自己的状态(或非聚合根的对象)。任何其他的对象,无论是否通过依赖注入的方式弄进来,都会破坏Entity的Invariance,并且还难以单测

对于为什么实体都特地加一个业务实体ID,之前学习有介绍:

身份标识(Identity,或简称为 ID)是实体对象的必要标志,换言之,没有身份标识的领域对象就不是实体。实体的身份标识就好像每个公民的身份证号,用以判断相同类型的不同对象是否代表同一个实体。身份标识除了帮助我们识别实体的同一性之外,主要的目的还是为了管理实体的生命周期。实体的状态是可以变更的,这意味着我们不能根据实体的属性值进行判断,如果没有唯一的身份标识,就无法跟踪实体的状态变更,也就无法正确地保证实体从创建、更改到消亡的生命过程。

一些实体只要求身份标识具有唯一性即可,如评论实体、博客实体或文章实体的身份标识,都可以使用自动增长的 Long 类型或者随机数与 UUID、GUID,这样的身份标识并没有任何业务含义。有些实体的身份标识则规定了一定的组合规则,例如公民实体、员工实体与订单实体的身份标识就不是随意生成的。遵循业务规则生成的身份标识体现了领域概念,例如公民实体的身份标识其实就是“身份证号”这一领域概念。定义规则的好处在于我们可以通过解析身份标识获取有用的领域信息,例如解析身份证号,可以直接获得该公民的部分基础信息,如籍贯、出生日期、性别等,解析订单号即可获知该订单的下单渠道、支付渠道、业务类型与下单日期等。

在设计实体的身份标识时,通常可以将身份标识的类型分为两个层次:通用类型与领域类型。通用类型提供了系统所需的各种生成唯一标识的类型,如基于规则的标识、基于随机数的标识、支持分布式环境唯一性的标识等。这些类型都将放在系统层代码模型的 domain 包中,可以作为整个系统的共享内核

总结

大牛写的文章就是深入浅出,丰富饱满,还是多拜读,多温故,不是因为DDD他们变得这么优秀,而是他们本身就很优秀,也许他们心中早有DDD了,只是没有DDD这个名字而已

希望有一天我也能写出这么好的文章

DDD战略战术

发表于 2020-11-15
字数统计: 1.7k | 阅读时长 ≈ 5

《DDD开篇总结》的前三篇已经阐述了几个内容

  1. DDD是什么
  2. 复杂系统的特征
  3. DDD如何应对复杂系统
  4. 模型概念
  5. 软件开发流程

但一般DDD资料中都会分为两部分讲述:战略和战术,所以按这两种分类,重新归纳整合一下

道

在讨论战略和战术前,先表述一下“道”

DDD是一种软件开发的方法论,任何方法论,都必须落实到“减少代码复杂度”

那么“道”是什么呢?

一直认为DDD的战略就是道,结果搞错了

软件开发的终极“道”就是“高内聚、低耦合”,它是任何有价值思想和方法的具象

如何才能达到这个终极道呢?

    1. DRY
    1. 分离关注点
      • 2.1. 业务和技术分离
      • 2.2. 业务和部署分离
      • 2.3. 变与不变分离
    1. 缩小依赖范围
    1. 向稳定方向依赖

战略

DDD战略主要包含统一语言和限界上下文

统一语言

在以往OO开发过程中,会经过OOA,再到OOD,复杂系统中,没有人能全方位了解系统并实现系统,术业有专工,专业人士干伟业事,这是正确的

但分工后,团队合作时沟通至关重要,,在整个系统开发过程中,是有一根主线的,那就是业务知识,业务知识在系统落地前经过层层传递,走样变形是常有的事,从开发人员经过多少次的返工情况就很清楚

如果解决这个问题呢?DDD引入了统一语言,把业务名词含义事先确定好,减少不必要的翻译过程,车同轨,书同文,行同伦

这也消除了业务与技术之间的重复,共同使用业务原语对话

代码就是文档,代码就是领域知识

1
2
userService.love(Jack, Rose) => Jack.love(Rose)
companyService.hire(company,employee) => Company.hire(employee)

界限上下文

界限上下文囊括了实现道的方方面面,如分离关注点,每个上下文围绕一个关注点,通过整洁架构让各层向稳定方向依赖,合理的划分界限,使各个上下文之间减小依赖

说白了界限上下文就是把一个大系统分而治之

界限上下文算是DDD中的核心知识点,但常被技术人员忽视,对于实用主义的程序员来讲,战术常常更吸引人,其实大到微服务,小到实体类,背后都渗透着上下文的概念

引入限界上下文的目的,不在于如何划分边界,而在于如何控制边界

Alberto Brandolini认为bounded context are a mean of safety(限界上下文意味着安全),safety的意思是being in control and no surprise,对限界上下文是可控制的,就意味着你的系统架构与组织结构都是可控的

显然,限界上下文并不是像大多数程序员理解的那样,是模块、服务、组件或子系统,而是你对领域模型、团队合作以及技术风险的控制

限界上下文是“分而治之”架构原则的体现,我们引入它的目的其实为了控制(应对)软件的复杂度,它并非某种固定的设计单元,我们不能说它就是模块、服务或组件,而是通过它来帮助我们做出高内聚低耦合的设计。只要遵循了这个设计,则限界上下文就可能成为模块、服务或组件。所以,文章《Bounded Contexts as a Strategic Pattern Beyond DDD》才会写到:“限界上下文体现的是高层的抽象机制,它并非编程语言或框架的产出工件,而是体现了人们对领域思考的本质。”

战术

对于开发人员而,战术是最实用的,比如聚合、实体、值对象、工厂、仓储、领域事件等等,
使用这些战术组件建模工具,DDD满足了软件真正的技术需求。这些战术设计工具使开发人员能够按照领域专家的思维开发软件

战略部分讲了,界限上下文的思想是核心,在战术组件中都有体现,比如实体,实体就是一个最小上下文,聚合就是相对实体大一点的上下文

但残酷的现实是,花费了大量的精力来学习这些DDD战术组件,却在实现项目中却用不上,为什么呢?因为事务脚本思维太深,分层也大多是从技术角度出发,没有抽象出领域模型,也就是没有OO抽象,没有一个完整的对象,实体都没有,像工厂,值对象也就成了水中花

这也是我们虽然常重构代码,也不过是大类变小类,大函数拆分成小函数,符合一下代码规范,但对整个项目而言,其实没有实质性改进

如何能有实质性改进,对于复杂系统如何运用上这些战术组件呢?

此时,结构性思维发挥作用了

第一步:过程分解,把一个复杂的系统按流程拆解成各个阶段和步骤,这也是事务脚本的强项

第二步:对象建模,过程性拆解虽然可以降低了开发难度,但领域知识被割裂,代码的业务语义也不明确,在这方面OO是强项,提升代码复用性和内聚性

结合这两步,自上而下的结构化分解+自下而上的面向对象建模,过程化分析更好地清理了模型之间的关系,而对象模型提升代码复用性和业务语义表达能力

总结

DDD是一套很好的方法论,有时我们常在理论纯洁性与实战性之间徘徊。这也许是初级阶段常有的纠结点

DDD有适用场景,事务脚本有存在的优势

不能因为现在人们开口闭口都是DDD,就硬要开展DDD

DDD难以落地除了本身带来了很多概念,还需要团队整体素质

软件开发没有银弹,不能偏执于一种理论,实际开发是场硬仗,像混合格斗一样,不在于一招一式是哪门哪派,制敌才是终极目标,所以需要根据自身情况进行裁剪,灵活运用

12…9
朱兴生

朱兴生

彪悍的人生需要书写

82 日志
3 分类
31 标签
© 2016 — 2021 朱兴生 | Site words total count: 212.8k
由 Hexo 强力驱动
|
主题 — NexT.Mist v5.1.4
沪ICP备18040647号-1