案例描述如下:
1.影片租赁店目前提供普通影片,新片,儿童影片三种类型影片供顾客租赁,不同影片类型拥有不同价格码
2.计费规则按照影片类型和租期有所不同
3.提供常客积分制度,积分对影片类型和租期有一定要求
要求:计算顾客的消费金额并打印租赁详单
Movie(影片)
/**
* 影片类,分为不同类型价格不同
*
* @author lune
* @create 2017-11-14 17:40
*/
public class Movie {
public static final int REGULAR = 0; //普通影片
public static final int NEW_RELEASE = 1; //新片
public static final int CHILDRENS = 2; //儿童影片
private String title; //影片名
private int priceCode; //价格码
public Movie(String title, int priceCode) {
this.title = title;
this.priceCode = priceCode;
}
public String getTitle() {
return title;
}
public int getPriceCode() {
return priceCode;
}
public void setPriceCode(int priceCode) {
this.priceCode = priceCode;
}
}
Rental(租赁)
/**
* 租赁类,用于绑定某个顾客租赁的影片,包含影片名和租期
*
* @author lune
* @create 2017-11-14 17:35
*/
public class Rental {
private Movie movie; //租赁的影片
private int daysRented; //租期
public Rental(Movie movie, int daysRented) {
this.movie = movie;
this.daysRented = daysRented;
}
public Movie getMovie() {
return movie;
}
public int getDaysRented() {
return daysRented;
}
}
Customer(顾客)
/**
* 顾客类,可进行多种租赁,需要打印详单
*
* @author lune
* @create 2017-11-14 17:35
*/
public class Customer {
private String name; //顾客名
private Vector rentals = new Vector(); //租赁列表
public Customer(String name) {
this.name = name;
}
public String getName() {
return name;
}
//添加租赁信息
public void addRental(Rental rental) {
rentals.addElement(rental);
}
//打印详单方法
public String statement() {
double totalAmount = 0; //总金额
int frequentRenterPoints = 0; //积分点
Enumeration items = rentals.elements(); //用户所有租赁列表
String result = getName() + " 的租赁详单如下 :" + "\n";
//循环遍历租赁影片,计算消费金额
while (items.hasMoreElements()) {
double thisAmount = 0; //当前单个租赁金额
Rental each = (Rental) items.nextElement();
//租赁计费规则
switch (each.getMovie().getPriceCode()) {
case Movie.REGULAR: //普通片,起步价为2元,租期超过2天的部分每天1.5元
thisAmount += 2;
if (each.getDaysRented() > 2)
thisAmount += (each.getDaysRented() - 2) * 1.5;
break;
case Movie.NEW_RELEASE: //新片,每天3元
thisAmount += each.getDaysRented() * 3;
break;
case Movie.CHILDRENS: //儿童片,起步价1.5元,租期超过3天的部分每天1.5元
thisAmount += 1.5;
if (each.getDaysRented() > 3)
thisAmount += (each.getDaysRented() - 3) * 1.5;
break;
}
frequentRenterPoints++; //每借一张加1个积分点
//积分累加条件:新版本的片子,借的时间大于1天
if ((each.getMovie().getPriceCode() == Movie.NEW_RELEASE)
&& each.getDaysRented() > 1) {
frequentRenterPoints++;
}
//添加详单
result += "\t" + each.getMovie().getTitle() + "\t" +
"\t"
+ String.valueOf(thisAmount) + "\n";
totalAmount += thisAmount;
}
//添加脚注
result += "总金额为: \t" + String.valueOf(totalAmount) + "\n";
result += "您本次消费获取: " + String.valueOf(frequentRenterPoints)
+ " 个积分点";
return result;
}
}
其中Statement()方法实现了根据计费规则计算顾客消费金额以及打印租赁详单的功能。打印详单直接采用生成字符串的形式,statement()主要功能:打印详单,消费金额计算,积分计算。
如下建立测试类:
/**
* 主方法,用于运行顾客租赁影片程序
*
* @author lune
* @create 2017-11-14 17:35
*/
public class Run {
public static void main(String[] args){
Movie movie1 = new Movie("福尔摩斯",0);
Movie movie2 = new Movie("雷神 ",1);
Movie movie3 = new Movie("熊出没 ",2);
Rental ren1 = new Rental(movie1,8);
Rental ren2 = new Rental(movie2,3);
Rental ren3 = new Rental(movie3,5);
Customer cus = new Customer("lune");
cus.addRental(ren1);
cus.addRental(ren2);
cus.addRental(ren3);
System.out.println(cus.statement());
}
}
确实是能完成并且能顺利打印,结果运行如下:
即使很顺利的完成了基本功能,但是这样设计代码存在极大的隐患,在更多大型的项目中如果只是简单的实现功能,在后期需求变更很容易就出现代码大量修改甚至重写,根本原因就是前期代码设计不合理。
例如本案例,重新观察案例描述:
红色标注的地方都有一个共同点,就是具有不确定性因素,即可能会变化的地方,考虑如下情况:
规则一.不仅采用字符串格式打印,还需要提供以html格式打印
除了statement()方法,还需要提供一个htmlStatement()方法,而且不能复用任何代码,需要将statement()中的代码大部分复制,修改其中打印的部分,因此计算消费和积分代码将重复出现。
规则二.在第1种情况的基础上,需求决定修改,更改计费规则。
因此,不仅要修改statement()中的计费部分代码,还需要在htmlStatement()中修改部分代码,此时思考,为什么不将计费部分代码提取出来,这样就不需要在两个打印方法中进行重复的修改。
规则三.在第1种情况的基础上,修改影片分类规则。
影片分类的改变,将会影响积分的计算方式,因此又要进行重复的修改。
在这里因为有两种打印方法,一旦计费和积分需求改变,就不得不进行两次修改,从而保证两种打印方法的一致。在大型项目中有很多类似的情况,往往两个方法核心相同,只是不同的方式体现,如果不将方法中容易变动的部分提取,就经常进行修改,一旦疏忽,将会导致只修改其中一个方法,从而导致不一致。
下面进行重构。
1.建立测试环境
第一步永远是建立测试环境,因为重构的规则是在不改变原有代码的功能基础上,优化代码使之设计合理。因此,每一步重构都需要进行测试,不能导致输出和原有代码输出不一致,此案例就采用上述Run类。
2.提取易变动、可复用的部分代码
在statement()方法中计费代码和积分计算代码容易因为需求而变动,并且可以在多种打印方法中进行复用,因此最好单独提取出来。
修改后的statement()部分如下:
//循环遍历租赁影片,计算消费金额
while (items.hasMoreElements()) {
double thisAmount = 0; //当前单个租赁金额
Rental each = (Rental) items.nextElement();
//抽取计费代码
thisAmount = amountFor(each);
//抽取积分计算代码
frequentRenterPoints += getFrequentRenterPoints(each);
//添加详单
result += "\t" + each.getMovie().getTitle() + "\t" +
"\t"
+ String.valueOf(thisAmount) + "\n";
totalAmount += thisAmount;
}
//租赁计费规则
private double amountFor(Rental rental){
double thisAmount = 0;
switch (rental.getMovie().getPriceCode()) {
case Movie.REGULAR: //普通片,起步价为2元,租期超过2天的部分每天1.5元
thisAmount += 2;
if (rental.getDaysRented() > 2)
thisAmount += (rental.getDaysRented() - 2) * 1.5;
break;
case Movie.NEW_RELEASE: //新片,每天3元
thisAmount += rental.getDaysRented() * 3;
break;
case Movie.CHILDRENS: //儿童片,起步价1.5元,租期超过3天的部分每天1.5元
thisAmount += 1.5;
if (rental.getDaysRented() > 3)
thisAmount += (rental.getDaysRented() - 3) * 1.5;
break;
}
return thisAmount;
}
//积分计算规则
private int getFrequentRenterPoints(Rental rental){
//积分累加条件:新版本的片子,借的时间大于1天
if ((rental.getMovie().getPriceCode() == Movie.NEW_RELEASE)
&& rental.getDaysRented() > 1) {
return 2;
}
return 1; //每借一张加1个积分点
}
修改后要进行测试。
规则三:明确模块职责
很明显发现amountFor(rental)和getFrequentRenterPoints(rental)方法完全使用来自Rental类的信息,没有使用来自Customer类的信息。并且从现实生活中来看,计算消费金额以及计算顾客积分点并不是顾客需要做的事情,因此,根据职责分配,这两个方法明显放错了位置。
修改后如下:
public class Rental {
private Movie movie; //租赁的影片
private int daysRented; //租期
public Rental(Movie movie, int daysRented) {
this.movie = movie;
this.daysRented = daysRented;
}
public Movie getMovie() {
return movie;
}
public int getDaysRented() {
return daysRented;
}
//租赁计费规则
public double amountFor(){
double thisAmount = 0;
switch (getMovie().getPriceCode()) {
case Movie.REGULAR: //普通片,起步价为2元,租期超过2天的部分每天1.5元
thisAmount += 2;
if (getDaysRented() > 2)
thisAmount += (getDaysRented() - 2) * 1.5;
break;
case Movie.NEW_RELEASE: //新片,每天3元
thisAmount += getDaysRented() * 3;
break;
case Movie.CHILDRENS: //儿童片,起步价1.5元,租期超过3天的部分每天1.5元
thisAmount += 1.5;
if (getDaysRented() > 3)
thisAmount += (getDaysRented() - 3) * 1.5;
break;
}
return thisAmount;
}
//积分计算规则
public int getFrequentRenterPoints(){
//积分累加条件:新版本的片子,借的时间大于1天
if ((getMovie().getPriceCode() == Movie.NEW_RELEASE)
&& getDaysRented() > 1) {
return 2;
}
return 1; //每借一张加1个积分点
}
}
修改后要进行测试,此时发现连入参都省略了。
再思考,计费方法真的应该放在Rental类中实现吗?先看下面规则四
规则四:运用多态取代switch
public double amountFor(){
double thisAmount = 0;
switch (getMovie().getPriceCode()) {
case Movie.REGULAR: //普通片,起步价为2元,租期超过2天的部分每天1.5元
thisAmount += 2;
if (getDaysRented() > 2)
thisAmount += (getDaysRented() - 2) * 1.5;
break;
case Movie.NEW_RELEASE: //新片,每天3元
thisAmount += getDaysRented() * 3;
break;
case Movie.CHILDRENS: //儿童片,起步价1.5元,租期超过3天的部分每天1.5元
thisAmount += 1.5;
if (getDaysRented() > 3)
thisAmount += (getDaysRented() - 3) * 1.5;
break;
}
return thisAmount;
}
很明显的发现,Rental中的amountFor()方法采用switch语句,条件为
getMovie().getPriceCode(),也就是说Rental类的方法,以Movie类的属性作为switch语句中的判断,可以理解为,Rental类受Movie类易变化的属性影响。这很明显不是我们想要的结果,因为我们要做的是尽量降低类与类之间的耦合。
因此switch使用的规则为:最好不要在另一个对象的属性基础上运用switch语句。如果不得不使用,也应该在对象自己的数据上使用,而不是在别人的数据上使用。
影片类型是可能变化的,因此不应该将Moive对象中易变的影片类型传给Rental对象,根据switch的规则,应该在Movie对象中使用,因为变化的是Moive对象中的影片类型。因此将计费方法挪到Movie类(规则三提到的位置错误)。将Rental对象的租期传给Moive对象。
Movie类中的getCharge()方法:
public double getCharge(int daysRented){
double thisAmount = 0;
switch (getPriceCode()) {
case REGULAR: //普通片,起步价为2元,租期超过2天的部分每天1.5元
thisAmount += 2;
if (daysRented > 2)
thisAmount += (daysRented - 2) * 1.5;
break;
case NEW_RELEASE: //新片,每天3元
thisAmount += daysRented * 3;
break;
case CHILDRENS: //儿童片,起步价1.5元,租期超过3天的部分每天1.5元
thisAmount += 1.5;
if (daysRented > 3)
thisAmount += (daysRented - 3) * 1.5;
break;
}
return thisAmount;
}
Rental类中的amountFor()方法:
public double amountFor(){
return getMovie().getCharge(daysRented);
}
说道这里,回归到规则三的主题"运用多态取代Switch",多种影片类型,每种拥有自己特有的计费方式,因此完全可以建立Moive的多个子类,每种实现自己特定的计费方法,终于可以开始说继承了,如下图:
这么做其实是不行的,如果现在有一个movie对象,是新片,过了一段时间就要更改类型,改为普通片怎么办?一个影片在自己的生命周期内是可以修改自己的分类的,但是一个对象却不能改变自己所属的类。这时需要用到"状态模式",如下图:
实质就是定义了一个State(状态)接口,在这个接口内,影片的不同状态(类型)都有一个对应的方法,实现这个接口便拥有了不同的影片类型,因此,只需要在movie对象中引用price对象,修改引用就可以随时改变自己的分类。开始动手修改代码:
此时,movie对象不再需要priceCode来指定自己的分类,而是通过price对象来表示,因此在Price中指定getPriceCode()方法。
public interface Price {
int getPriceCode();
double getCharge(int daysRented);
}
class RegularPrice implements Price {
@Override
public int getPriceCode() {
return Movie.REGULAR;
}
@Override
public double getCharge(int daysRented) {
double thisAmount = 2;
if (daysRented > 2)
thisAmount += (daysRented - 2) * 1.5;
return thisAmount;
}
}
public class NewReleasePrice implements Price {
@Override
public int getPriceCode() {
return Movie.NEW_RELEASE;
}
@Override
public double getCharge(int daysRented) {
return daysRented * 3;
}
}
public class ChildrenPrice implements Price {
@Override
public int getPriceCode() {
return Movie.CHILDRENS;
}
@Override
public double getCharge(int daysRented) {
double thisAmount = 1.5;
if (daysRented > 3)
thisAmount += (daysRented - 3) * 1.5;
return thisAmount;
}
}
再看Movie类:
private Price price;
public Movie(String title,int priceType) {
this.title = title;
setPriceType(priceType);
}
public int getPriceType() {
return price.getPriceCode();
}
public void setPriceType(int priceType) {
switch (priceType) {
case REGULAR:
price = new RegularPrice();
break;
case CHILDRENS:
price = new ChildrenPrice();
break;
case NEW_RELEASE:
price = new NewReleasePrice();
break;
}
}
public double getCharge(int daysRented){
return price.getCharge(daysRented);
}
在构造函数中指定类型码,通过类型码创建不同的price对象,进行不同的计费操作,当要获取影片类型时,通过price对象获取,改变影片类型则重新创建不同的price对象。
第一个案例就到这里,如有不足,欢迎指出!