(学习打卡2)重学Java设计模式之六大设计原则
前言:听说有本很牛的关于Java设计模式的书——重学Java设计模式,然后买了(*^▽^*)
开始跟着小傅哥学Java设计模式吧,本文主要记录笔者的学习笔记和心得。
打卡!打卡!
六大设计原则
单一职责原则、开闭原则、里氏替换原则、迪米特法则、接口隔离原则、依赖倒置原则。
(引读:这里的节奏是,先说一下概念定义,然后是模拟场景,最后是反例、正例。)
一、单一职责原则
1、定义
单一职责原则,它规定一个类应该只有一个发生变化的原因。
为什么?
因为如果开发的一个功能不是一次性的,当一个Class类负责超过两个及以上职责时,当需求不断迭代、实现类持续扩张,就会出现难以维护、不好扩展、测试难度大和上线风险高等问题。
2、模式场景
一个视频网站用户分类的例子:
- 访问用户,只能看480P的高清视频,有广告
- 普通会员,可以看720P的超清视频,有广告
- VIP会员,付费的大哥,可以看1080P的蓝光视频,无广告
3、违背原则方案(反例)
根据上面的需求,直接编码,实现一个最简单的基本功能:根据不同的用户类型,判断用户可以观看的视频类型。
public class VideoUserService {
public void serveGrade(String userType){
if ("VIP用户".equals(userType)){
System.out.println("VIP用户,视频1080P蓝光");
} else if ("普通用户".equals(userType)){
System.out.println("普通用户,视频720P超清");
} else if ("访客用户".equals(userType)){
System.out.println("访客用户,视频480P高清");
}
}
}
如上,这一个类包含着多个不同的行为,多种用户职责,如果在这样的类上继续扩展功能就会显得很臃肿。比如再加一个“超级VIP会员”,可以超前点播,按上面的实现方式,只能继续ifelse。这样的代码结构每次迭代,新需求的实现都可能会影响到其他逻辑。
4、单一职责原则改善代码(正例)
视频播放是视频网站的核心功能,当完成核心功能的开发后,就需要不断地完善用户权限,才能更好运营网站。其实就是不断建设用户权益,根据不同的用户类型提供差异化服务。
为了满足不断迭代的需求,就不能向上面一样把所有职责行为混为一谈,而是应该提供一个上层的接口类,对不同的差异化用户给出单独的实现类,拆分各自的职责。
(1)定义接口
public interface IVideoUserService {
// 视频清晰级别;480P、720P、1080P
void definition();
// 广告播放方式;无广告、有广告
void advertisement();
}
定义出上层接口IVideoUserService,统一定义需要实现的功能,包括视频清晰级别接口definition()、广告播放方式接口advertisement()。然后三种不同类型的用户就可以分别实现自己的服务类,做到职责统一。
(2)实现类
1)访问用户,只能看480P的高清视频,有广告
public class GuestVideoUserService implements IVideoUserService {
public void definition() {
System.out.println("访客用户,视频480P高清");
}
public void advertisement() {
System.out.println("访客用户,视频有广告");
}
}
2)普通会员,可以看720P的超清视频,有广告
public class OrdinaryVideoUserService implements IVideoUserService {
public void definition() {
System.out.println("普通用户,视频720P超清");
}
public void advertisement() {
System.out.println("普通用户,视频有广告");
}
}
3)VIP会员,付费的大哥,可以看1080P的蓝光视频,无广告
public class VipVideoUserService implements IVideoUserService {
public void definition() {
System.out.println("VIP用户,视频1080P蓝光");
}
public void advertisement() {
System.out.println("VIP用户,视频无广告");
}
}
5、易扩展示例
假设有新的需求如下:7天试用VIP会员,可以试用看1080P的蓝光视频,但是有广告。
// 7天试用VIP用户
public class TryVipVideoUserService implements IVideoUserService {
public void definition() {
System.out.println("7天试用VIP用户,视频1080P蓝光");
}
public void advertisement() {
System.out.println("7天试用VIP用户,视频有广告");
}
}
在项目开发的过程中,尽可能保证接口的定义、类的实现以及方法开发保持单一职责,对项目后期的迭代和维护是很好的。
二、开闭原则
1、定义
在面向对象编程领域中,开闭原则规定软件的对象、类、模块和函数对扩展应该是开放的,但是对于修改是封闭的。
这就意味着应该用抽象定义结构,用具体实现扩展细节,以此确保软件系统开发和维护过程的可靠性。
开闭原则的核心思想可以理解为面向抽象编程。
小结:对扩展是开放的,对修改是封闭的。
2、模拟场景
对于外部调用方,只要能体现出面向抽象编程,定义出接口并实现其方法,即不修改原有方法体,只通过继承方式进行扩展,都可以体现出开闭原则。
(1)场景案例
计算三种形状的面积,长方形、三角形,圆形。其中圆的π=3.14,但后续由于π的取值精度不适用于后面的场景,需要再扩展,接下来模拟这个场景来体现开闭原则。
(2)定义接口
public interface ICalculationArea {
/**
* 计算面积,长方形
*
* @param x 长
* @param y 宽
* @return 面积
*/
double rectangle(double x, double y);
/**
* 计算面积,三角形
* @param x 边长x
* @param y 边长y
* @param z 边长z
* @return 面积
*
* 海伦公式:S=√[p(p-a)(p-b)(p-c)] 其中:p=(a+b+c)/2
*/
double triangle(double x, double y, double z);
/**
* 计算面积,圆形
* @param r 半径
* @return 面积
*
* 圆面积公式:S=πr²
*/
double circular(double r);
}
(3)实现类
特别地,这里的π取3.14D,这也是要扩展精度的方法和体现开闭原则的地方。
public class CalculationArea implements ICalculationArea {
private final static double π = 3.14D;
public double rectangle(double x, double y) {
return x * y;
}
public double triangle(double x, double y, double z) {
double p = (x + y + z) / 2;
return Math.sqrt(p * (p - x) * (p - y) * (p - z));
}
public double circular(double r) {
return π * r * r;
}
}
3、违背原则方案
如果不考虑开闭原则,也不考虑整个工程服务的使用情况,直接改π值。
private final static double π = 3.141592653D;
4、开闭原则改善代码
更好的做法,按照开闭原则。继承父类,扩展需要的方法,同保留原有的方法,新增自己需要的方法。它的主要目的是不能因为个例需求的变化二改变预定的实现类。
public class CalculationAreaExt extends CalculationArea {
private final static double π = 3.141592653D;
@Override
public double circular(double r) {
return π * r * r;
}
}
扩展后的方法满足了π精度变化的需求,需要使用此方法的用户可以直接调用。而其他的方法,也不影响继续使用。
三、里氏替换原则
1、定义
(1)里氏替换原则由芭芭拉·利斯科夫,1987年发表的文章《数据抽象和层次》提出:继承必须确保超类所拥有的性质在子类中仍然成立。
(2)举个例子:如果S是T的子类,S extends T,那么所有的T类对象都可以在不破坏程序的情况下被S类的对象替换。
简单来说,子类可以扩展父类的功能,但是不能改变父类原有的功能。
也就是说:当子类继承父类时,除添加新的方法且完成新增功能外,尽量不要重写父类的方法。这句话有四点含义:
- 子类可以实现父类的抽象方法,但不能覆盖和重写父类的非抽象方法。
- 子类可以增加自己特有的方法
- 当子类的方法重载父类的方法时,方法的输入形参要比父类的方法更宽松。
- 当子类的方法实现父类的方法(重写、重载或实现抽象方法)时,方法的输出或返回值要比父类发方法更严格或与父类的方法相等。
(3)里氏替换原则的作用
- 里氏替换原则是实现开闭原则的方式之一。
- 解决继承中重写父类造成的可复用性变差的问题。
- 类的扩展不会给已有系统引入新错误,降低了代码出错的可能性。
2、模拟场景
不同种类的银行卡,如储蓄卡、信用卡等都具备一定的消费功能,但又有所不同,假设构建一个银行系统。
3、违背原则方案
储蓄卡和信用卡在使用功能上类似,都有支付、提现、还款、充值等功能。也有不同,例如支付,储蓄卡做的是账户扣款动作,信用卡做的是生成贷款单动作。下面模拟先有储蓄卡的类,之后继承储蓄卡类的基本功能来实现信用卡的功能。
(1)储蓄卡类
/**
* 模拟储蓄卡功能
*/
public class CashCard {
private Logger logger = LoggerFactory.getLogger(CashCard.class);
/**
* 提现
*
* @param orderId 单号
* @param amount 金额
* @return 状态码 0000成功、0001失败、0002重复
*/
public String withdrawal(String orderId, BigDecimal amount) {
// 模拟支付成功
logger.info("提现成功,单号:{} 金额:{}", orderId, amount);
return "0000";
}
/**
* 储蓄
*
* @param orderId 单号
* @param amount 金额
*/
public String recharge(String orderId, BigDecimal amount) {
// 模拟充值成功
logger.info("储蓄成功,单号:{} 金额:{}", orderId, amount);
return "0000";
}
/**
* 交易流水查询
* @return 交易流水
*/
public List<String> tradeFlow() {
logger.info("交易流水查询成功");
List<String> tradeList = new ArrayList<String>();
tradeList.add("100001,100.00");
tradeList.add("100001,80.00");
tradeList.add("100001,76.50");
tradeList.add("100001,126.00");
return tradeList;
}
}
(2)信用卡类
/*
* 模拟信用卡功能
*/
public class CreditCard extends CashCard {
private Logger logger = LoggerFactory.getLogger(CashCard.class);
@Override
public String withdrawal(String orderId, BigDecimal amount) {
// 校验
if (amount.compareTo(new BigDecimal(1000)) >= 0){
logger.info("贷款金额校验(限额1000元),单号:{} 金额:{}", orderId, amount);
return "0001";
}
// 模拟生成贷款单
logger.info("生成贷款单,单号:{} 金额:{}", orderId, amount);
// 模拟支付成功
logger.info("贷款成功,单号:{} 金额:{}", orderId, amount);
return "0000";
}
@Override
public String recharge(String orderId, BigDecimal amount) {
// 模拟生成还款单
logger.info("生成还款单,单号:{} 金额:{}", orderId, amount);
// 模拟还款成功
logger.info("还款成功,单号:{} 金额:{}", orderId, amount);
return "0000";
}
@Override
public List<String> tradeFlow() {
return super.tradeFlow();
}
}
信用卡的功能实现是继承了储蓄卡类后,进行方法重写。这种继承父类方式的好处是复用了父类的核心功能逻辑,但也破坏了原有的方法。此时的信用卡类不满足里氏替换原则。因为此时的子类不能承担原父类的功能,直接给储蓄卡使用。
4、里氏替换原则改善代码
(1)抽象银行卡类
该类提供了卡的基本属性:卡号、开卡时间。三个核心方法:正向入账 +钱,逆向入账 -钱
/**
* 抽象银行卡类
*/
public abstract class BankCard {
private Logger logger = LoggerFactory.getLogger(BankCard.class);
private String cardNo; // 卡号
private String cardDate; // 开卡时间
public BankCard(String cardNo, String cardDate) {
this.cardNo = cardNo;
this.cardDate = cardDate;
}
abstract boolean rule(BigDecimal amount);
// 正向入账,+ 钱
public String positive(String orderId, BigDecimal amount) {
// 入款成功,存款、还款
logger.info("卡号{} 入款成功,单号:{} 金额:{}", cardNo, orderId, amount);
return "0000";
}
// 逆向入账,- 钱
public String negative(String orderId, BigDecimal amount) {
// 入款成功,存款、还款
logger.info("卡号{} 出款成功,单号:{} 金额:{}", cardNo, orderId, amount);
return "0000";
}
/**
* 交易流水查询
*
* @return 交易流水
*/
public List<String> tradeFlow() {
logger.info("交易流水查询成功");
List<String> tradeList = new ArrayList<String>();
tradeList.add("100001,100.00");
tradeList.add("100001,80.00");
tradeList.add("100001,76.50");
tradeList.add("100001,126.00");
return tradeList;
}
public String getCardNo() {
return cardNo;
}
public String getCardDate() {
return cardDate;
}
}
(2)储蓄卡类实现
储蓄卡类继承了抽象银行卡父类,实现的核心功能包括规则过滤rule()、体现withdrawal()、储蓄recharge()和新增的扩展方法风控校验checkRisk()。
/**
* 储蓄卡
*/
public class CashCard extends BankCard {
private Logger logger = LoggerFactory.getLogger(CashCard.class);
public CashCard(String cardNo, String cardDate) {
super(cardNo, cardDate);
}
boolean rule(BigDecimal amount) {
return true;
}
/**
* 提现
*
* @param orderId 单号
* @param amount 金额
* @return 状态码 0000成功、0001失败、0002重复
*/
public String withdrawal(String orderId, BigDecimal amount) {
// 模拟支付成功
logger.info("提现成功,单号:{} 金额:{}", orderId, amount);
return super.negative(orderId, amount);
}
/**
* 储蓄
*
* @param orderId 单号
* @param amount 金额
*/
public String recharge(String orderId, BigDecimal amount) {
// 模拟充值成功
logger.info("储蓄成功,单号:{} 金额:{}", orderId, amount);
return super.positive(orderId, amount);
}
/**
* 风险校验
*
* @param cardNo 卡号
* @param orderId 单号
* @param amount 金额
* @return 状态
*/
public boolean checkRisk(String cardNo, String orderId, BigDecimal amount) {
// 模拟风控校验
logger.info("风控校验,卡号:{} 单号:{} 金额:{}", cardNo, orderId, amount);
return true;
}
}
这样的实现方式满足了里氏替换原则,既实现抽象类的抽象方法,又没有破坏父类中原有的方法。
(3)信用卡类实现
信用卡类可以继承储蓄卡,也可以继承抽象银行卡父类,无论哪种实现都需遵从里氏替换原则,不能破坏父类原有的方法。
/**
* 信用卡
*/
public class CreditCard extends CashCard {
private Logger logger = LoggerFactory.getLogger(CreditCard.class);
public CreditCard(String cardNo, String cardDate) {
super(cardNo, cardDate);
}
boolean rule2(BigDecimal amount) {
return amount.compareTo(new BigDecimal(1000)) <= 0;
}
/**
* 提现,信用卡贷款
*
* @param orderId 单号
* @param amount 金额
* @return 状态码
*/
public String loan(String orderId, BigDecimal amount) {
boolean rule = rule2(amount);
if (!rule) {
logger.info("生成贷款单失败,金额超限。单号:{} 金额:{}", orderId, amount);
return "0001";
}
// 模拟生成贷款单
logger.info("生成贷款单,单号:{} 金额:{}", orderId, amount);
// 模拟支付成功
logger.info("贷款成功,单号:{} 金额:{}", orderId, amount);
return super.negative(orderId, amount);
}
/**
* 还款,信用卡还款
*
* @param orderId 单号
* @param amount 金额
* @return 状态码
*/
public String repayment(String orderId, BigDecimal amount) {
// 模拟生成还款单
logger.info("生成还款单,单号:{} 金额:{}", orderId, amount);
// 模拟还款成功
logger.info("还款成功,单号:{} 金额:{}", orderId, amount);
return super.positive(orderId, amount);
}
}
小结:使用继承要遵从里氏替换原则。继承作为面向对象的重要特征,给开发带来很大便利,但也有可能给代码带来入侵性,降低可移植性。里氏替换的目的是使用约定的方式,让使用继承的代码具备良好的扩展性和兼容性。
四、迪米特法则原则
1、定义
迪米特法则又称最少知道原则,是指一个对象类对其他对象类来说,知道的越少越好。两个类之间不要有过多的耦合关系,保持最少关联性。
迪米特法则经典语录:只和朋友通信,不和陌生人说话。也就是说,有内在关联的类要内聚,没有直接关系的类要低耦合。
2、模拟场景
模拟学生、老师和校长的关系来说明迪米特法则。校长对接老师,老师管着学生,如果校长想知道一个班的分数,应该是直接问老师要,还是跟每一位学生要再进行统计?正常来说,是直接跟老师要。如果我们在实际开发中忽略了这种真实情况就会开发出逻辑错误的程序。
3、违背原则方案
(1)先简单定义一个学生信息类,有学生姓名、考试排名、总分。
public class Student {
private String name; // 学生姓名
private int rank; // 考试排名(总排名)
private double grade; // 考试分数(总分)
// get和set方法...
}
(2)再定义老师类,初始化学生信息、提供基本的信息获取接口
public class Teacher {
private String name; // 老师名称
private String clazz; // 班级
private static List<Student> studentList; // 学生
public Teacher() {
}
public Teacher(String name, String clazz) {
this.name = name;
this.clazz = clazz;
}
static {
studentList = new ArrayList<>();
studentList.add(new Student("花花", 10, 589));
studentList.add(new Student("豆豆", 54, 356));
studentList.add(new Student("秋雅", 23, 439));
studentList.add(new Student("皮皮", 2, 665));
studentList.add(new Student("蛋蛋", 19, 502));
}
public static List<Student> getStudentList() {
return studentList;
}
public String getName() {
return name;
}
public String getClazz() {
return clazz;
}
}
(3)定义校长类,校长管理全局,在校长类中获取学生人数、总分、平均分
/*
* 校长
/
public class Principal {
private Teacher teacher = new Teacher("丽华", "3年1班");
// 查询班级信息,总分数、学生人数、平均值
public Map<String, Object> queryClazzInfo(String clazzId) {
// 获取班级信息;学生总人数、总分、平均分
int stuCount = clazzStudentCount();
double totalScore = clazzTotalScore();
double averageScore = clazzAverageScore();
// 组装对象,实际业务开发会有对应的类
Map<String, Object> mapObj = new HashMap<>();
mapObj.put("班级", teacher.getClazz());
mapObj.put("老师", teacher.getName());
mapObj.put("学生人数", stuCount);
mapObj.put("班级总分数", totalScore);
mapObj.put("班级平均分", averageScore);
return mapObj;
}
// 总分
public double clazzTotalScore() {
double totalScore = 0;
for (Student stu : teacher.getStudentList()) {
totalScore += stu.getGrade();
}
return totalScore;
}
// 平均分
public double clazzAverageScore(){
double totalScore = 0;
for (Student stu : teacher.getStudentList()) {
totalScore += stu.getGrade();
}
return totalScore / teacher.getStudentList().size();
}
// 班级人数
public int clazzStudentCount(){
return teacher.getStudentList().size();
}
}
小结:以上方案违背了迪米特法则(最小知道),这里所有班级都让校长类统计,代码臃肿,不利于维护和扩展。
4、迪米特法则改善代码
使用迪米特法则,把原来违背迪米特法则的服务接口交给老师类。这样每个老师会提供相应的功能,校长类只需调用即可,而不需要了解每个学生的分数。
(1)学生信息类不变
public class Student {
private String name; // 学生姓名
private int rank; // 考试排名(总排名)
private double grade; // 考试分数(总分)
// get和set方法...
}
(2)老师类:我们要把校长要的信息交给老师类去管理
public class Teacher {
private String name; // 老师名称
private String clazz; // 班级
private static List<Student> studentList; // 学生
public Teacher() {
}
public Teacher(String name, String clazz) {
this.name = name;
this.clazz = clazz;
}
static {
studentList = new ArrayList<>();
studentList.add(new Student("花花", 10, 589));
studentList.add(new Student("豆豆", 54, 356));
studentList.add(new Student("秋雅", 23, 439));
studentList.add(new Student("皮皮", 2, 665));
studentList.add(new Student("蛋蛋", 19, 502));
}
// 总分
public double clazzTotalScore() {
double totalScore = 0;
for (Student stu : studentList) {
totalScore += stu.getGrade();
}
return totalScore;
}
// 平均分
public double clazzAverageScore(){
double totalScore = 0;
for (Student stu : studentList) {
totalScore += stu.getGrade();
}
return totalScore / studentList.size();
}
// 班级人数
public int clazzStudentCount(){
return studentList.size();
}
public String getName() {
return name;
}
public String getClazz() {
return clazz;
}
}
(3)校长类:直接调用老师类的接口获取相应信息
public class Principal {
private Teacher teacher = new Teacher("丽华", "3年1班");
// 查询班级信息,总分数、学生人数、平均值
public Map<String, Object> queryClazzInfo(String clazzId) {
// 获取班级信息;学生总人数、总分、平均分
int stuCount = teacher.clazzStudentCount();
double totalScore = teacher.clazzTotalScore();
double averageScore = teacher.clazzAverageScore();
// 组装对象,实际业务开发会有对应的类
Map<String, Object> mapObj = new HashMap<>();
mapObj.put("班级", teacher.getClazz());
mapObj.put("老师", teacher.getName());
mapObj.put("学生人数", stuCount);
mapObj.put("班级总分数", totalScore);
mapObj.put("班级平均分", averageScore);
return mapObj;
}
}
(4)自测
public class ApiTest {
private Logger logger = LoggerFactory.getLogger(ApiTest.class);
@Test
public void test_Principal() {
Principal principal = new Principal();
Map<String, Object> map = principal.queryClazzInfo("3年1班");
logger.info("查询结果:{}", JSON.toJSONString(map));
}
}
五、接口隔离原则
1、定义
《代码整洁之道》的作者Robert C.Martin 于2002年给“接口隔离原则”的定义是:客户端不应该被迫依赖于它不使用的方法。
2、模拟场景
举个某农药游戏中英雄技能的例子,这里为了说明问题简化了一下,与真实游戏有出入。
假设由我们来开发英雄技能的功能,游戏中有很多英雄,可以分为射手、战士、刺客等,每个英雄有三个技能。
3、违背原则方案
(1)定义技能接口,实现的英雄都需要实现这个接口,进而实现自己的技能
/**
* 英雄技能
*/
public interface ISkill {
//灼日之矢
void doArchery();
// 隐袭
void doInvisible();
// 技能沉默
void doSilent();
// 眩晕
void doVertigo();
}
(2)后羿实现了三个技能,眩晕技能不需要实现
/**
* 后羿
*/
public class HeroHouYi implements ISkill{
@Override
public void doArchery() {
System.out.println("后裔的灼日之矢");
}
@Override
public void doInvisible() {
System.out.println("后裔的隐身技能");
}
@Override
public void doSilent() {
System.out.println("后裔的沉默技能");
}
@Override
public void doVertigo() {
// 无此技能的实现(眩晕)
}
}
(3)廉颇实现了三个技能,射箭技能不需要实现
/**
* 廉颇
*/
public class HeroLianPo implements ISkill{
@Override
public void doArchery() {
// 无此技能的实现(射箭)
}
@Override
public void doInvisible() {
System.out.println("廉颇的隐身技能");
}
@Override
public void doSilent() {
System.out.println("廉颇的沉默技能");
}
@Override
public void doVertigo() {
System.out.println("廉颇的眩晕技能");
}
}
小结:以上,每个英雄都有一个和自己无关的接口实现方法,不符合接口隔离原则。不仅无法控制外部调用,还需要维护对应的接口文档来说明这个接口不需要实现,非常麻烦。
4、接口隔离原则改善代码
按照接口隔离原则的约定,在确保合理的情况下,把接口细分。也就是把技能拆分出来,每个英雄按需继承和实现。
(1)分别定义四个技能接口ISkillArchery、ISkillInvisible、ISkillSilent、ISkillVertigo
/**
* 技能:射箭
*/
public interface ISkillArchery {
//灼日之矢
void doArchery();
}
/**
* 技能:隐身
*/
public interface ISkillInvisible {
// 隐袭
void doInvisible();
}
/**
* 技能:沉默
*/
public interface ISkillSilent {
// 技能沉默
void doSilent();
}
/**
* 技能:眩晕
*/
public interface ISkillVertigo {
// 眩晕
void doVertigo();
}
有了四个技能细分的接口,英雄的类可以自由组合实现
(2)英雄后羿实现
public class HeroHouYi implements ISkillArchery, ISkillInvisible, ISkillSilent {
@Override
public void doArchery() {
System.out.println("后裔的灼日之矢");
}
@Override
public void doInvisible() {
System.out.println("后裔的隐身技能");
}
@Override
public void doSilent() {
System.out.println("后裔的沉默技能");
}
}
(3)英雄廉颇实现
public class HeroLianPo implements ISkillInvisible, ISkillSilent, ISkillVertigo {
@Override
public void doInvisible() {
System.out.println("廉颇的隐身技能");
}
@Override
public void doSilent() {
System.out.println("廉颇的沉默技能");
}
@Override
public void doVertigo() {
System.out.println("廉颇的眩晕技能");
}
}
这两个英雄的类都按需实现了自己需要的技能接口,这样的实现方式就可以避免一些本身不属于自己的技能还需要不断地用文档的方式进行维护,同时提高了代码的可靠性,在别人接手或者修改时,可以降低开发成本和维护风险。
六、依赖倒置原则
1、定义
依赖倒置原则是Robert C.Martin于1996年在C++Report上发表的文章中提出的。
依赖倒置原则是指在设计代码架构时,高层模块不应该依赖于底层模块,二者都应该依赖于抽象。抽象不应该依赖于细节,细节应该依赖于抽象。
依赖倒置原则是实现开闭原则的重要途径之一,它降低了类之间的耦合,提高了系统的稳定性和可维护性,同时这样的代码一般更易读,且便于传承。
2、模拟场景
模拟一个抽奖的系统,有随机抽奖、权重抽奖等
3、违背原则方案
最直接的方式,按不同的抽奖逻辑定义不同的接口,让外部的服务调用。
(1)定义抽奖用户类
一个普通的对象类,包括了用户姓名和对应的用户权重,方便满足不同的抽奖方式。
public class BetUser {
private String userName; // 用户姓名
private int userWeight; // 用户权重
// 此处略写了getter和setter方法
}
接下来在一个类用两个方法实现两种不同的抽奖逻辑
public class DrawControl {
// 随机抽取指定数量的用户,作为中奖用户
public List<BetUser> doDrawRandom(List<BetUser> list, int count) {
// 集合数量很小直接返回
if (list.size() <= count) return list;
// 乱序集合
Collections.shuffle(list);
// 取出指定数量的中奖用户
List<BetUser> prizeList = new ArrayList<>(count);
for (int i = 0; i < count; i++) {
prizeList.add(list.get(i));
}
return prizeList;
}
// 权重排名获取指定数量的用户,作为中奖用户
public List<BetUser> doDrawWeight(List<BetUser> list, int count) {
// 按照权重排序
list.sort((o1, o2) -> {
int e = o2.getUserWeight() - o1.getUserWeight();
if (0 == e) return 0;
return e > 0 ? 1 : -1;
});
// 取出指定数量的中奖用户
List<BetUser> prizeList = new ArrayList<>(count);
for (int i = 0; i < count; i++) {
prizeList.add(list.get(i));
}
return prizeList;
}
}
该类包括了两个方法,随机抽奖和权重抽奖:
- 随机抽奖,这里是把集合中的元素使用Collections.shuffle(list) 进行乱序,之后随机选取三个元素。
- 权重抽奖,这里是使用list.sort 的方法,并按自定义排序,最终选择权重最高的前三名作为中奖用户。
(2)测试类
public class ApiTest {
private Logger logger = LoggerFactory.getLogger(ApiTest.class);
@Test
public void test_DrawControl(){
List<BetUser> betUserList = new ArrayList<>();
betUserList.add(new BetUser("花花", 65));
betUserList.add(new BetUser("豆豆", 43));
betUserList.add(new BetUser("小白", 72));
betUserList.add(new BetUser("笨笨", 89));
betUserList.add(new BetUser("丑蛋", 10));
DrawControl drawControl = new DrawControl();
List<BetUser> prizeRandomUserList = drawControl.doDrawRandom(betUserList, 3);
logger.info("随机抽奖,中奖用户名单:{}", JSON.toJSON(prizeRandomUserList));
List<BetUser> prizeWeightUserList = drawControl.doDrawWeight(betUserList, 3);
logger.info("权重抽奖,中奖用户名单:{}", JSON.toJSON(prizeWeightUserList));
}
}
小结:这样的实现方式,扩展性和可维护性都差。当业务发展需要不断调整和新增时,对于调用方来说需要新增调用接口的代码;对于服务类来说,随着接口数量的增加,代码行数会不断暴增,最后难于维护。
4、依赖倒置原则改善代码
为了良好的扩展性,使用依赖倒置、面向对象编程的方式实现。
public class BetUser {
private String userName; // 用户姓名
private int userWeight; // 用户权重
// 此处略写了getter和setter方法
}
(1)抽奖接口
这里只有一个抽奖接口,接口包括了需要传输的list集合,以及中奖用户数量。
/* 抽奖接口
*/
public interface IDraw {
// 获取中奖用户接口
List<BetUser> prize(List<BetUser> list, int count);
}
(2)随机抽奖实现
随机抽奖逻辑和上面一样,只是放到了接口实现中
/* 随机抽取指定数量的用户,作为中奖用户
*/
public class DrawRandom implements IDraw {
@Override
public List<BetUser> prize(List<BetUser> list, int count) {
// 集合数量很小直接返回
if (list.size() <= count) return list;
// 乱序集合
Collections.shuffle(list);
// 取出指定数量的中奖用户
List<BetUser> prizeList = new ArrayList<>(count);
for (int i = 0; i < count; i++) {
prizeList.add(list.get(i));
}
return prizeList;
}
}
(3)权重抽奖实现
权重抽奖逻辑和上面一样,只是放到了接口实现中
/* 权重排名获取用户中奖名单,指定数量
*/
public class DrawWeightRank implements IDraw {
@Override
public List<BetUser> prize(List<BetUser> list, int count) {
// 按照权重排序
list.sort((o1, o2) -> {
int e = o2.getUserWeight() - o1.getUserWeight();
if (0 == e) return 0;
return e > 0 ? 1 : -1;
});
// 取出指定数量的中奖用户
List<BetUser> prizeList = new ArrayList<>(count);
for (int i = 0; i < count; i++) {
prizeList.add(list.get(i));
}
return prizeList;
}
}
这样一来,任何一种抽奖都有自己的实现类,既可以不断完善,也可以不断新增。
(4)创建抽奖服务
public class DrawControl {
private IDraw draw;
public List<BetUser> doDraw(IDraw draw, List<BetUser> betUserList, int count) {
return draw.prize(betUserList, count);
}
}
在这个类中体现了依赖倒置的重要性,可以把任何一种抽奖逻辑传递给这个类。这样实现的好处是可以不断扩展。但不需要在外部新增调用接口,降低了一套代码的维护成本,提高了可扩展性和可维护性。
特别地,这里把实现逻辑的接口作为参数传递,这在一些框架源码中会经常遇到。
(5)测试
public class ApiTest {
private Logger logger = LoggerFactory.getLogger(ApiTest.class);
@Test
public void test_DrawControl() {
List<BetUser> betUserList = new ArrayList<>();
betUserList.add(new BetUser("花花", 65));
betUserList.add(new BetUser("豆豆", 43));
betUserList.add(new BetUser("小白", 72));
betUserList.add(new BetUser("笨笨", 89));
betUserList.add(new BetUser("丑蛋", 10));
DrawControl drawControl = new DrawControl();
List<BetUser> prizeRandomUserList = drawControl.doDraw(new DrawRandom(), betUserList, 3);
logger.info("随机抽奖,中奖用户名单:{}", JSON.toJSON(prizeRandomUserList));
List<BetUser> prizeWeightUserList = drawControl.doDraw(new DrawWeightRank(), betUserList, 3);
logger.info("权重抽奖,中奖用户名单:{}", JSON.toJSON(prizeWeightUserList));
}
}
这里新增了实现抽奖的入参new DrawRandom()、new DrawWeightRank(),在这两个抽奖功能逻辑作为入参后,扩展起来会非常方便。
以这种抽象接口为基准搭建起来的框架结构会更加稳定,算程已经建设好了,外部只需实现自己的算子即可,最终把算子交给算程处理。