目录
1.引言
本节介绍一种与"接口"相关的设计思想;基于接口而非实现编程,它非常重要且在平时的开发中经常被用到。
2.接口的多种理解方式
"基于接口而非实现编程"设计思想的英文描述是:"program to an interface, not an implementation"在理解这个设计思想的时候,我们不要一开始就与具体的编程语言挂钩,否则会局限在编语言的"接口"语法(如Java中的接口语法)中。这个设计思想最早出现在1994年出版的Erich Gamma 等4人合著的 Design Patterns: Elements of Reusable Object-Oriented Sofware 一书中。它先于很多编程语言诞生(如Java语言诞生于1995年),是一种抽象、泛化的设计思想。
实际上,理解这个设计思想的关键,就是理解其中的"接口"两字。还记得我们在前面讲到的"接口"的定义吗?从本质上来看,"接口"就是一组"协议"或"约定",是功能提供者提供给使用者的一个"功能列表"。"接口"在不同的应用场景下会有不同的解读,如服务端与客户端之间的"接口",类库提供的"接口",甚至,一组通信协议也可以称为"接口"。不过,这些对"接口"的理解都是偏上层和偏抽象的理解,与实际的代码编写关系不大。落实到具体的代码编写上,"基于接口而非实现编程"设计思想中的"接口"可以被理解为编程语言中的接口或抽象类。
应用这个设计思想能够有效地提高代码质量,之所以这么说,是因为面向接口而非实现编程可以将接口和实现分离,封装不稳定的实现,暴露稳定的接口。上游系统面向下游系统提供的接口编程,不依赖不稳定的实现细节,这样,当实现发生变化时,上游系统的代码基本不需要改动,以此降低耦合性,提高扩展性。
实际上,"基于接口而非实现编程"设计思想的另一个表述方式是"基于抽象而非实现编程"。后者其实更能体现这个设计思想的设计初衷。在软件开发中,比较大的挑战是如何应对需求的不断变化。抽象、顶层和脱离具体某一实现的设计能够提高代码的灵活性,从而可以更好地应对未来的需求变化。好的代码设计,不但能够应对当下的需求,而且在将来需求发生变化时,仍然能够在不破坏原有代码设计的情况下灵活应对。而抽象恰恰就是提高代码的扩展性、灵活性和可维护性的有效手段。
3.设计思想实战应用
我们通过一个具体的例子来介绍其如何应用"基于接口而非实现编程"设计思想,报设系统中多处涉及图片的处理和存铺相关逻辑、图片经过处理之后,被上传到阿里云中。为了代码复用,我们将图片存储相关的代码逻辑封装为统一的AliyunlmgeStore类,供整个系统使用。具体的代码实现如下。
java
public class AliyunImageStore {
//.省略属性,构造函数等..
public void createBucketIfNotExisting(String bucketName){
//..省略创建bucket的代码逻辑,失败时会抛出异常
}
public String generateAccessToken(){
//...省路生成access Token的代码逻辑
}
public String uploadToAliyun(Image image, String bucketName, String accessToken){
//...上传图片到阿里云
}
public Image downloadFromAliyun(String url, String accessToken){
//...从阿里云下载图片
}
}
//AliyunImageStore类的使用示例
public class ImageProcessingJob{
private static final String BUCKET_NAME = "ai_images_bucket";
//...省略其他无关代码.
public vid process(){
Image image = ...;//处理图片,并封装为Image类的对象
AliyunImageStore imageStore = new AliyunImageStore(/*省略参数*/);
imagestore.createBucketIfNotExisting(BUCKET_NAME);
String accessToken = imageStore.generateAccessToken();
imagestore.uploadToAliyun(image, BUCKET_NAME, accessToken);
}
}
图片的整个上传流程包含3个步骤:创建bucket(可以简单理解为存储目录)、生成accessToken访问凭证、携带 access Token 上传图片到指定的 bucket。
上述代码简单、结构清晰,完全能够满足将图片存储到阿里云的业务需求。不过,软件开发中唯一不变的就是变化。过了一段时间,如果我们自建了私有云,不再将图片存储到阿里云,而是存储到自建私有云上,那么,为了满足这一需求变化,我们应该如何修改代码呢?我们需要重新设计实现一个存储图片到私有云的PrivateImageStore 类,并用它替换项目中所有用到 AliyunImageStore 类的地方。为了尽量减少替换过程中的代码改动,PivatelmageSiore类中需要定义与 AliyunImageStore 类相同的 public 方法,并且按照上传私有云的逻辑重新实现。但是,这样做存在下列两个问题。
第一个问题: AliyunImageStore 类中有些函数的命名暴露了实现细节,如uploadToAliyun()和downloadFromAliyun()。如果我们在开发这个功能时没有接口意识、抽象思维,那么这种暴飞实现细节的命名方式并不足为奇,毕竟最初我们只需要考虑将图片存储到阿里云上。如果我们把这种包含"aliyun"字眼的方法照搬到 PrvateImageStore 类中,那么显然是不合适的。如果在新类中重新命名uploadToAliyun()、downloadFromAliyun()这些方法,就意味者需要修改项目中所有用到这两个方法的代码,需要修改的地方可能很多。
第二个问题: 将图片存储到阿里云的流程与存储到私有云的流程可能并不完全一我。例如, 在使用阿里云进行图片的上传和下载的过程中,需要生成access Token,而私有云不需要access Token。因此。AliyunImageStore类中定义的generateAccessToken()方法不能照搬到PrivateImageStore类中,在使用AliyunImageStore类上传、下载图片的时候,用到了generateAccessToken()方法,如果要改为私有云的图片上传、下载流程,那么这些代码都需要进行调整。
那么,上述这两个问题应该如何解决呢?根本的解决方法是,在代码编写的一开始,就要遵循基于接口而非实现编程的设计思想。具体来讲,我们需要做到以下3点。
-
函数的命名不能暴露任何实现细节。例如,前面提到的uploadToAliyun()就不符合此要求,应该去掉"aliyun"这样的字眼,改为抽象的命名方式,如upload()。
-
封装具体的实现细节。例如,与阿里云相关的特殊上传(或下载)流程不应该暴露给调用者。我们应该对上传(或下载)流程进行封装,对外提供一个包含所有上传(或下载)细节的方法,供调用者使用。
-
为实现类定义抽象的接口。具体的实现类依赖统一的接口定义。使用者依赖接口而不是具体的实现类进行编程。
按照上面这个思路,我们将代码进行重构。重构后的代码如下所示。
java
public interface ImageStore {
String upload(Image image, String bucketName);
Image download(String url);
}
public class AliyunImageStore implements ImageStore{
//...省路属性、构造的等..
public String upload(Image image, String bucketName) {
createBucketIfNotExisting(bucketName);
String accessToken=generateAccessToken();
//1...省略上传图片到阿里云的代码逻辑.
}
public Image download(String url){
String accessToken = generateAccessTokcn();
//...省略从阿里云中下线图片的代码逻辑..
}
private void createBucketIMotExisting(String bucketName){
//...省略创建bucket的代码逻辑,失败时会出异常。.
}
private String generateAccessToken(){
//...省路生成accessToken的代码逻辑.
}
}
//上传和下载流程改变:私有云不需要支持access Token
public class PrivateImageStore implements ImageStore{
pubiic String upload(Image image, string bucketName){
createBucketINotExisting(bucketName);
//1.省略上传图片到私有云的代码逻辑...
}
public Image download(String url){
//..,省略从私有云中下载图片的代码逻辑.
}
private void cresteBucketIfotExisting(string bucketName){
//...省略创建bucket的代码逻辑,失败时会抛出异常//Imagestore接口的使用示例
}
}
public class ImageProcessingJob{
private static final String BUCKET_NAME = "ai_images_bucket;
//...省略其他无关代码.
public void process(){
Image image = ...;//处理图片,并封装为Image类的对象
ImageStore imageStore = new Privatelmagestore(...);
imagestore.upload(image, BUCKET_NAME);
}
}
在定义接口时,很多工程师希望通过实现类来反推接口的定义,即先把实现类写好,再看实现类中有哪些方法,并照搬到接口定义中。如果按照这种思考方式,就有可能导致接口定义不够抽象、依赖具体的实现。这样的接口设计新没有意义了,不过,如果读者认为这种思考方式顺畅,那么可以接受, 但要注意,在将实现类中的方法搬移到接口定义中时,要有选择性地进行搬移,不要搬移与具体实现相关的方法,如AliyunImageStore类中的generateAccessToken()方法就不应该被搬移到接口中。
总结一下,在编写代码时,我们一定要有抽象意识、封装意识和接口意识。接口定义不暴露任何实现细节。接口定义只表明做什么,不表明怎么做。而且,在设计接口时,我好细思考接口的设计是否通用,是否能够在将来某一天替换接口实现时,不需要改动任何定义。
4.避免滥用接口
看了上面的讲解,读者可能有如下疑问:为了满足这个设计思想,是不是需要给每个实现类都定义对应的接口?是不是任何代码都要只依赖接口,不依赖实现编程呢?
做任何事情都要讲求一个"度"。如果过度使用这个设计思想,非要给每个类都定义接口,接口"满天飞",那么会产生不必要的开发负担。关于什么时候应该为某个类定义接口,以及什么时候不需要定义接口,我们进行权衡的根本还是"基于接口而非实现编程"设计思想产生的初衷。
"基于接口而非实现编程"设计思想产生的初衷是,将接口和实现分离,封装不稳定的实现,暴露稳定的接口。上游系统面向接口而非实现编程,不依赖不稳定的实现细节,这样,当实现发生变化时,上游系统的代码基本不需要做改动,以此降低代码的耦合性,提高代码的扩
从这个设计思想的产生初衷来看,如果在业务场景中,某个功能只有一种实现方式,未来也不可能被其他实现方式替换,那么没有必要为其设计接口,也没有必要基于接口编程,直接使用实现类即可。还有,基于接口而非实现编程的另一种表述是基于抽象而非实现编程,即便某个功能的实现方式未来可能变化,如果不会有两种实现方式同时在被使用,就可以在原实现类中进行实现方式的修改。函数本身也是一种抽象,它封装了实现细节,只要函数定义足够抽象,不用接口也可以满足基于抽象而非实现的设计思想要求。
5.思考题
在本节最终重构之后的代码中,尽管我们通过接口隔离了两个具体的类现。但是,项目中很地方都是通过类似下面的方式使用接口。这就会产生一个问题:如果需要替换图片存储方式,那么还是需要修改很多代码。对此,读者有什么好的实现思路吗?
java
//Imagestore的使用示例
public class ImageprocessingJob{
private static final String BUCKET_NAME = "ai_images_bucket";
//...省略其他无关代码.
public void process(){
Image image = ...;//处理图片,并封装为Image类的对象
ImageStore imageStore = new PrivateImageStore(/*省赂构造函数*/);
imageStore.upload(image, BUCKET_NAME);
}
}