记录学习王争的设计模式之美 课程 笔记和练习代码,以便回顾复习,共同进步
思考题:
英文Inversion Of Control,暂时不要将它和spring框架的IOC联系到一起。
举例看什么是控制反转
public class UserServiceTest{
public static boolean doTest(){
//...
}
public static void main(String[] args){
//这部分逻辑可放到框架中
if(doTest()){
System.out.println("Test succeed.");
}else{
System.out.println("Test failed.");
}
}
}
上述代码中,所有流程由程序员控制。如果抽象出下面这个框架,再看如何利用框架实现同样的功能:
public abstract class TestCase{
public void run(){
if(doTest()){
System.out.println("Test succeed.");
}else{
System.out.println("Test failed.");
}
}
public abstract void doTest();
}
public class JunitApplication{
private static final List<TestCase> testCases = new ArrayList<>();
public static void register(TestCase testCase){
testCases.add(testCase);
}
public static final void main(String[] args){
for(TestCase case:testCases){
case.run();
}
}
}
将这个简化版的测试框架引入工程中,只需在框架预留的扩展点,也就是TestCase类的doTest()抽象方法中,填充具体的测试代码即可实现之前的功能。完全不需要写负责执行流程的main()方法
public class UserServiceTest extends TestCase{
@Override
public boolean doTest(){
//...
}
}
//注册操作还可通过配置的方式实现,不需要程序员显式调用register
JunitApplication.register(new UserServiceTest());
上述案例就是通过框架实现“控制反转”的例子。框架提供一个可扩展的代码骨架,用来组装对象、管理整个执行流程。程序员用框架开发时,只需要往预留的扩展点,添加自己业务相关的代码,就能利用框架驱动整个程序流程的执行。
这里的“控制”指的是对程序执行流程的控制,而“反转”是指在没用框架之前,程序员自己控制整个程序的执行,而用框架后,程序的执行流程通过框架控制。流程的控制权从程序员“反转”到框架。
实际上,实现控制反转的方法很多,除了上述例子所示类似模板设计模式的方法之外,还有依赖注入等方法。所以,控制反转不是一种具体的实现技巧,而是一个比较笼统的设计思想,一般用来指导框架层面的设计。
依赖注入Dependency Injection,跟控制反转相反,是一种具体的编码技巧。有个形象的说法:依赖注入是一个标价25美元,而实际只值5美分的概念。理解应用很简单。概括就是:不通过new()方法的方式在类内部创建依赖类对象,而是将依赖的类对象在外部创建好后,通过构造函数、方法参数等方式传递(或注入)给类使用。
举例说明。Notification类负责消息推送,依赖MessageSender类实现推送商品促销、验证码等消息给用户。分别用依赖注入和非依赖注入两种方式实现。
//=======非依赖注入实现方式========
public class Notification{
private MessageSender messageSender;
public Notification(){
this.messageSender = new MessageSender();//有点像硬编码
}
public void sendMessage(String cellphone,String message){
//...省略校验逻辑等...
this.messageSender.send(cellphone,message);
}
}
public class MessageSender{
public void send(String cellphone,String message){
//...
}
}
//使用Notification
Notification notification = new Notification();
//============依赖注入方式实现==========
public class Notification{
private MessageSender messageSender;
//通过构造函数将messageSender传递进来
public Notification(MessageSender messageSender){
this.messageSender = messageSender;
}
public void sendMessage(String cellphone,String message){
//...省略校验逻辑等...
this.messageSender.send(cellphone,message);
}
}
//使用Notification
MessageSender messageSender = new MessageSender();
Notification notification = new Notification(messageSender);
通过依赖注入的方式将依赖的类对象传递进来,提高了代码的扩展性,可以灵活的替换依赖的类。当然,还能继续优化,将MessageSender定义为接口,基于接口而非实现编程。
public class Notification{
private MessageSender messageSender;
//通过构造函数将messageSender传递进来
public Notification(MessageSender messageSender){
this.messageSender = messageSender;
}
public void sendMessage(String cellphone,String message){
//...省略校验逻辑等...
this.messageSender.send(cellphone,message);
}
}
public interface MessageSender{
void send(String cellphone,String message);
}
//短信发送类
public class SmsSender implements MessageSender{
@Override
public void send(String cellphone,String message){
//...
}
}
//站内信发送类
//短信发送类
public class InboxSender implements MessageSender{
@Override
public void send(String cellphone,String message){
//...
}
}
//使用Notification
MessageSender messageSender = new SmsSender();
Notification notification = new Notification(messageSender);
依赖注入是编写可测试性代码最有效的手段。
采用依赖注入实现的Notification类中,虽然不需要硬编码,在类内部通过new来创建MessageSender对象,但创建对象、组装(或注入)对象的工作仅仅是被移动到更上层的代码而已,还需要我们自己实现。
public class Demo{
public static final void main(String[] args){
MessageSender sender = new SmsSender();//创建对象
Notification notification = new Notification(sender);//依赖注入
notification.sendMessage("13510733521","短信验证码:2346");
}
}
实际的软件开发,一些项目可能会涉及非常多的类,类对象的创建和依赖注入非常复杂,而对象创建和依赖注入的工作,本身和具体业务无关,抽象为框架自动完成,也就是“依赖注入框架”。通过框架提供的扩展点,简单配置所有要创建的类对象、类和类之间的依赖关系,就能实现由框架自动创建对象、管理对象的生命周期、依赖注入等原本要程序员做的事情。
现成的依赖注入框架如Google Guice,java spring等。spring框架自称是控制反转容器(Inversion Of Control Container)。spring框架的控制反转主要是通过依赖注入实现。
依赖反转原则Dependency Inversion Principle,也叫依赖倒置原则。英文描述:High-level modules shouldn’t depend on low-level modules. Both modules should depend on abstractions. In addition, abstractions shouldn’t depend on details. Details depend on abstractions。也就是:高层模块不要依赖低层模块,应该通过抽象来互相依赖。此外,抽象不要依赖具体实现细节,具体实现细节依赖抽象。
高层和低层模块的划分:在调用链上,调用者属于高层,被调用者属于低层。这个原则主要是指导框架层面的设计。以tomcat这个servlet容器为例。tomcat是运行java web应用程序的容器。web应用程序代码只需部署到tomcat容器下,就能被tomcat容器调用执行。因此,tomcat是高层模块,web应用程序代码是低层模块。tomcat和程序代码之间没有直接的依赖关系,都依赖同一个“抽象”,也就是servlet规范。servlet规范不依赖具体的tomcat容器和应用程序的实现细节,而tomcat容器和应用程序依赖servlet规范。
spring开发的项目在tomcat中,控制权在tomcat手中,微服务兴起,tomcat内嵌到springboot项目中,控制权反转到springboot手中。
问题:
英文好几个版本:Keep It Simple and Stupid。尽量保持简单。是个万金油类型的设计原则,可用于很多场景,如iPhone的设计。那如何在代码开发中应用这条原则呢?如何落地呢?
先看案例,下面三段代码可实现同样一个功能:检查输入的字符串ipAddress是否是合法的IP字段。
一个合法的IP地址由4个数字组成,并通过"."进行分割。每组数字取值范围是0~255.第一组较特殊,不允许为0。
//第一种实现方式:使用正则表达式
public boolean isValidIpAddressV1(String ipAddress){
if(StringUtils.isBlank(ipAddress)) return false;
String regex = "^(1\\d{2}|2[0-4]\\d|25[0-5]|[1-9]\\d|[1-9])\\."
+"(1\\d{2}|2[0-4]\\d|25[0-5]|[1-9]\\d|\\d)\\."
+"(1\\d{2}|2[0-4]\\d|25[0-5]|[1-9]\\d|\\d)\\."
+"(1\\d{2}|2[0-4]\\d|25[0-5]|[1-9]\\d|\\d)$";
return ipAddress.matches(regex);
}
//第二种实现方式:使用现成的工具类
public boolean isValidIpAddressV2(String ipAddress){
if(StringUtils.isBlank(ipAddress)) return false;
String[] ipUnits = StringUtils.split(ipAddress,'_');
if(ipUnits.length != 4){
return false;
}
for(int i=0; i<4; ++i){
int ipUnitIntValue;
try{
ipUnitIntValue = Integer.parseInt(inUnits[i]);
}catch(NumberFormatException e){
return false;
}
if(ipUnitIntValue < 0 || ipUnitIntValue>255){
return false;
}
if(i==0 && ipUnitIntValue==0){
return false;
}
}
return true;
}
//第三种,不用任何工具类
public boolean isValidIpAddressV3(String ipAddress){
char[] ipChars = ipAddress.toCharArray();
int length = ipChars.length;
int ipUnitIntValue = -1;
boolean isFirstUnit = true;
int unitsCount = 0;
for(int i=0; i<length;++i){
char c = ipChars[i];
if(c=='.'){
if(ipUnitIntValue<0 || ipUnitIntValue>255) return false;
if(isFirstUnit && ipUnitIntValue==0) return false;
if(isFirstUnit) isFirstUnit=false;
ipUnitIntValue = -1;
unitsCount++;
continue;
}
if(c<'0' || c>'9'){
return false;
}
if(ipUnitIntValue == -1) ipUnitIntValue=0;
ipUnitIntValue = ipUnitIntValue*10+(c-'0');
}
if(ipUnitIntValue<0 || ipUnitIntValue>255) return false;
if(unitsCount != 3) return false;
return true;
}
第一种正则,写出来就很难,也难保证不出bug,可读性和可维护性也差;第二种使用一些现成的工具类,处理IP地址字符串;第三种,不用任何工具类,逐一处理IP地址中的字符,判断是否合法。第三种更有难度,更易出现bug,当然性能更高些。
如何选择,如果一般的业务代码,首选第二种,第三种其实是过度优化,牺牲代码的可读性,性能提升并不明显,投入产出比较低。
如果代码的逻辑复杂、实现难度大、可读性也不好,是否就一定违反KISS原则?
以KMP字符串匹配算法的代码实现为例,该算法以快速高效著称。当需要处理长文本字符串匹配问题(几百MB大小文本内容的匹配),或者字符串匹配是某个产品的核心功能(如Vim、Word等文本编辑器),又或者字符串匹配算法是系统性能瓶颈时,应该选择该算法。本身就复杂的问题,用复杂的算法解决,并不违反KISS原则。
当然,平时项目开发涉及到的字符串匹配问题,大多都是较小的文本,直接调用现成的字符串匹配方法即可。要看应用场景。
YAGNI原则的英文:You Ain’t Gonna Need It。你不会需要它。意为:不要去设计当前用不到的功能;不要编写当前用不到的代码。也就是不要做过度设计。
如用redis存储配置信息,可能以后会用zookeeper,没必要提前编写这块代码,只需要预留好扩展点即可。再比如,不要在项目中提前引入不需要依赖的开发包。如maven等配置文件,提前引入大量常用的library包,就违反YAGNI原则。
YAGNI原则讲的是“要不要做”的问题(当前不需要的就不要做),KISS原则讲的是“如何做”的问题(尽量保持简单)。
英文描述:Don’t Repeat Yourself。应用于编程,可理解为:不要写重复的代码。
三种场景:
看下面这段代码是否违反DRY原则:
public class UserAuthenticator{
public void authenticate(String username,String password){
if(!isValidUsername(username)){
//...throw InvalidUsernameException
}
if(!isValidPassword(password)){
//...throw InvalidPasswordException
}
//...省略其他代码...
}
private boolean isValidUsername(String username){
// check not null, not empty
if(StringUtils.isBlank(username)){
return false;
}
// check length:4~64
int length = username.length();
if(length < 4 || length > 64){
return false;
}
// contains only lowcase characters
if(!StringUtils.isAllLowerCase(username)){
return false;
}
// contains only a~z,0~9,dot
for(int i=0;i<length;i++){
char c = username.charAt(i);
if(!((c>='a' && c<='z') || (c>='0' && c<='9') || c=='.')){
return false;
}
}
return true;
}
private boolean isValidPassword(String password){
// check not null, not empty
if(StringUtils.isBlank(password)){
return false;
}
// check length:4~64
int length = password.length();
if(length < 4 || length > 64){
return false;
}
// contains only lowcase characters
if(!StringUtils.isAllLowerCase(password)){
return false;
}
// contains only a~z,0~9,dot
for(int i=0;i<length;i++){
char c = password.charAt(i);
if(!((c>='a' && c<='z') || (c>='0' && c<='9') || c=='.')){
return false;
}
}
return true;
}
}
有两处非常明显的重复的代码片段:isValidUsername()和isValidPassword()方法。看起来违反DRY原则,但如果重构为isValidUsernameOrPassword(),代码行数减少,但并非更好。合并后的方法,负责两个事情,违反了单一职责原则和接口隔离原则。
其实这两个方法,虽然代码实现逻辑看起来重复,但语义上并不重复。语义不重复的意思是:从功能上看,这两个方法干的是完全不重复的两件事情,一个校验用户名,一个校验密码。如果某天需要修改校验密码的逻辑,那还需要重新拆分。
如果代码实现逻辑相同,但语义不同,判定不违反DRY原则。对于包含重复代码的问题,可以抽象成更细的粒度的方法来解决。如将校验只包含az、09、dot的逻辑封装为boolean onlyContains(String str,String charlist);方法。
再看另一个例子,同一个项目代码,有两个方法:isValidIp()和checkIfIpValid()。尽管命名不同,实现逻辑不同,但功能相同,都是判断IP地址是否合法。
原因可能是两个方法由两个不同的同事开发,其中一个不知道已经有了isValidIp()方法,自己又定义并实现另一个方法。这种就违反了DRY原则,因为功能重复。需要统一调一个方法。因为假如不统一实现思路,结果有些地方用isValidIp(),另一些地方调用checkIfIpValid(),不熟悉的同事增加阅读难度,而且很疑惑。此外,如果哪天IP地址判定合法的规则改变,结果只改了其中一个,忘了改另一个,导致出现莫名其妙的bug。
下面例子,UserService中login()方法校验用户登录是否成功。
public class UserSerive{
private UserDao userDao;//IOC框架注入
public User login(String email,String password){
boolean existed = userDao.checkIfUserExisted(email,password);
if(!existed){
//...throw AuthenticationFailureException
}
User user = userDao.getUserByEmail(email);
return user;
}
}
public class UserDao{
public boolean checkIfUserExisted(String email,String password){
if(!EmailValidation.validate(email)){
//...throw InvalidEmailException...
}
if(!PasswordValidation.validate(password)){
//...throw InvalidPasswordException...
}
//...query db to check if email&password exists...
}
public User getUserByEmail(String email){
if(!EmailValidation.validate(email)){
//...throw InvalidEmailException...
}
//...query db to get user by email...
}
}
上面这段代码,没有逻辑重复和语义重复,但仍违反了DRY原则。因为代码存在执行重复。
最明显的就是在login()方法中,email校验逻辑执行两次。修改的话,只需要将校验逻辑从UserDao中移除,统一放到UserService中即可。
此外,还有一处较为隐蔽的执行重复,login()方法并不需要调checkIfUserExisted()方法,只需调一次getUserByEmail()方法,从数据库获取用户的emai、password,跟用户输入的信息对比,判断是否登录成功。
这样的优化很有必要,因为两个方法都需要查询数据库,这种IO操作较为耗时,尽量减少这类IO操作。
重构之后的代码:
public class UserSerive{
private UserDao userDao;//IOC框架注入
public User login(String email,String password){
if(!EmailValidation.validate(email)){
//...throw InvalidEmailException...
}
if(!PasswordValidation.validate(password)){
//...throw InvalidPasswordException...
}
User user = userDao.getUserByEmail(email);
if(user==null || !password.equals(user.getPassword)){
//...throw AuthenticationFailureException...
}
return user;
}
}
public class UserDao{
public boolean checkIfUserExisted(String email,String password){
//...query db to check if email&password exists...
}
public User getUserByEmail(String email){
//...query db to get user by email...
}
}
什么是代码的复用性?
首先区分三个概念:代码复用性(Code Reusability)、代码复用(Code Reuse)和DRY原则。
代码复用表示一种行为:开发新功能时,尽量复用已存在的代码。代码的可复用性表示代码可被复用的特性或能力。DRY原则是一条原则:不要写重复的代码。
区别:
如何提高可复用性?
此外,复用意识也很重要,写代码时,多思考,这部分代码是否可抽取出来,作为独立的模块、类或者方法供多处使用。
另外还有个著名的原则,“Rule Of Three”。第一次开发时,没有复用的需求,可暂时不考虑复用性。之后开发新功能,发现可复用之前代码,就重构这段代码,让其可复用。
问题:
高内聚、低耦合是一个非常重要的设计思想,能有效的提高代码的可读性和可维护性,缩小功能改动导致的代码改动范围。很多设计原则都以实现代码的高内聚、低耦合为目的,如单一职责原则、基于接口而非实现编程等。
高内聚用来指导类本身的设计,低耦合指导类与类之间的依赖关系的设计。并非完全独立不相关,高内聚有助于低耦合,低耦合又需要高内聚的支持。
指的是相近的功能应放到同一个类中,不相近的功能不要放到同一个类中。相近的功能往往会被同时修改,放到一个类,修改集中,代码易维护。
在代码中,类与类之间的依赖关系简单清晰。即使两个类有依赖关系,一个类的改动不会或很少导致依赖类的代码改动。依赖注入、接口隔离、基于接口而非实现编程,以及迪米特法则,都是为了实现低耦合。
代码耦合度高,会牵一发而动全身,改动影响多个类。高内聚、低耦合的代码结构更简单清晰,可维护性和可读性更好。
英文Law Of Demeter,另一个名字,最小知识原则,英文:The Least Knowledge Principle。英文定义:Ench unit should have only limited knowledge about other units: only units “closely” related to the current unit. Or: Each unit should only talk to its friends; Don’t talk to strangers。
也就是:不该有直接依赖的类之间,不要有依赖;有依赖关系的类之间,尽量只依赖必要的接口。
前半部分:不该有直接依赖的类之间,不要有依赖。
案例实现简化版的搜索引擎爬取网页的功能。代码包含三个主要的类。NetworkTransporter类负责底层网络通信,根据请求获取数据;HtmlDownloader类来处理URL获取网页;Document表示网页文档,后续网页内容的抽取、分词、索引都是以此为处理对象。
public class NetworkTransporter{
//省略属性和其他方法
public Byte[] send(HtmlRequest htmlRequest){
//...
}
}
public class HtmlDownloader{
private NetworkTransporter transporter;//IOC注入
public Html downloadHtml(String url){
Byte[] rawHtml = transporter.send(new HtmlRequest(url));
return new Html(rawHtml);
}
}
public class Document{
private Html html;
private String url;
public Document(String url){
this.url = url;
HtmlDownloader downloader = new HtmlDownloader();
this.html = downloader.downloadHtml(url);
}
//...
}
这段代码虽然能用,但有些缺陷。
首先,看NetworkTransporter类。作为底层网络通信类,希望尽可能通用,而不是只服务于下载HTML,不应该直接依赖太具体的发送对象HtmlRequest。应如何重构呢?假如现在去商店买东西,不会直接把钱包给收银员,让收银员自己从钱包里拿钱,而是你从钱包里把钱拿出来交给收银员。这里,HtmlRequest对象相当于钱包,里面的address和content对象相当于钱。应把address和content给NetworkTransporter。
public class NetworkTransporter{
//省略属性和其他方法
public Byte[] send(String address,Byte[] data){
//...
}
}
再看HtmlDownloader类。设计没问题,但刚修改了NetworkTransporter的send()方法,这个类也要相应的修改
public class HtmlDownloader{
private NetworkTransporter transporter;//IOC注入
public Html downloadHtml(String url){
HtmlRequest htmlRequest = new HtmlRequest(url);
Byte[] rawHtml = transporter.send(htmlRequest.getAddress(),htmlRequest.getContent().getBytes());
return new Html(rawHtml);
}
}
最后看Document类。它的问题较多,主要有三点。
改造:
public class Document{
private Html html;
private String url;
public Document(String url){
this.url = url;
this.html = downloader.downloadHtml(url);
}
//...
}
//通过工厂方法创建Document
public class DocumentFactory{
private HtmlDownloader downloader;
public DocumentFactory(HtmlDownloader downloader){
this.downloader = downloader;
}
public Document createDocument(String url){
Html html = downloader.downloadHtml(url);
return new Document(url,html);
}
}
原则的后半部分:“有依赖关系的类之间,尽量只依赖必要的接口”。如之前讲的Serialization类负责序列化和反序列化。假设在项目中,有些类只用到序列化操作,而另一些类只用到反序列化。那只用到序列化操作的类不应该依赖反序列化接口,反之亦然。
因此,我们应该将Serialization类拆分为更小粒度的类,但这样违反了高内聚的设计思想。那如何解决这个问题?通过引入两个接口就能解决该问题。也就是接口隔离原则。
public interface Serializable{
String serialize(Object object);
}
public interface Deserializable{
Object deserialize(String text);
}
public class Serialization implements Serializable,Deserializable{
@Override
public String serialize(Object object){
String serializedResult = ...;
...
return serializedResult;
}
@Override
public Object serialize(String str){
Object deserializedResult = ...;
...
return deserializedResult;
}
}
public class DemoClass1{
private Deserializable deserializer;
public Demo(Deserializable deserializer){
this.deserializer = deserializer;
}
//...
}
public class DemoClass2{
private Serializable serializer;
public Demo(Serializable serializer){
this.serializer = serializer;
}
//...
}
上述的代码思路,体现了基于接口而非实现编程的设计原则,结合迪米特法则,可以总结出一条新的设计原则,基于最小接口而非最大实现编程。