• 1. 如何开发高质量代码 重构、原则、模式、测试 新锐国际 高级系统架构师 张俊 pesome@gmail.com 2008 年9月5日
  • 2. 内容安排第一个实例(亢龙有悔)重构(龙战与野)原则(潜龙勿用)模式(飞龙在天)测试(鱼越于渊)如何开发高 质量代码
  • 3. 第一个实例(亢龙有悔)
  • 4. 3个基本类(Class Diagram和Sequence Diagram如下图) Movie 影片,有3种计算方式(儿童片、新片、普通片) Rent 对一个影片的租借 Customer 客户一次租借多个影片,statement负责打印租借信息 一个简单的影片租借业务要新增的功能 增加一种html的打印方式 增加一种影片类型(外国片Foreign)源代码打包如下,请在eclipse中 Import项目
  • 5. public class Movie { public static final int CHILDRENS = 2; public static final int REGULAR = 0; public static final int NEW_RELEASE = 1; public Movie(String title, int priceCode) { this.title = title; this.priceCode = priceCode; }我们来看代码代码存在的问题 功能都在statement函数中实现 很难看清业务逻辑 任何变化都会导致对statement函数的修改 无法进行有效的单元测试public String statement() { double totalAmount = 0; int frequentRenterPoints = 0; String result = "Rental Record for " + getName() + "\n"; for (Rental each : rentals) { double thisAmount = 0; switch (each.getMovie().getPriceCode()) { case Movie.REGULAR: …. case Movie.NEW_RELEASE: thisAmount += each.getDaysRented() * 3; … case Movie.CHILDRENS: … } frequentRenterPoints++; if ((each.getMovie().getPriceCode() == Movie.NEW_RELEASE) && each.getDaysRented() > 1) frequentRenterPoints++; result += "\t" + each.getMovie().getTitle() + "\t" + String.valueOf(thisAmount) + "\n"; totalAmount += thisAmount; } result += "Amount owed is " + String.valueOf(totalAmount) + "\n"; return result; }变化是永恒的!但变化是有方向的!你如何增加功能? 拷贝statement,修改形成htmlStatement? 增加movie的static final int,增加swich的case?
  • 6. 让我们来重构吧(对statement方法重构)Statement函数太长,功能太多,需要重构 each不便于理解,使用Rename(Alt+Shift+R)更名为rental 使用Extract Method,将计算费用的switch块抽取到单独的函数中,命名为getCharge谁都能写出能运行的代码,写出人能方便理解的代码才是好程序员。代码是最好的注释,好的命名是关键double thisAmount参数多余 先增加double thisAmount 临时变量 使用Chang Method Signature将thisAmount参数remove
  • 7. 继续重构(方法移到该放的地方,可以测试了)getCharge方法函数只依赖于Rental对象,很显然不应该在Customer对象中 选择getCharge方法,使用Move Method,移到Rental对象中单一责任原则-- 一个类只负责自己该处理的事情getCharge方法分离出来了,可以增加对应的单元测试代码了 在MovieTest类增加对getCharge的测试(自动化测试,不在是打印到控制台,然后人工检查结果了) 执行测试,绿条出现,测试通过 小步重构原则– 每次重构一小步,并马上进行单元测试,以确保没有影响任何功能,能马上修正
  • 8. 分清变化方向,进一步重构getCharge方法Movie类型将会增加,Movie的变化不应该导致Rent代码的修改 Rent类应该代理Movie的getCharge方法,由Movie类去实现并独立演变 将Rent的getCharge方法体Extract Method,任意命名一个,然后使用Move Method放到Movie类,move时命名为getCharge 参数为Rent对象,但实际只依赖其getDaysRented()方法,可使用Change Method Signature将参数改为int类型,减少对Rent对象的依赖会产生编译错误,手工修改代码,改为使用daysRented参数 运行单元测试,确保通过 最小知识原则– 对其它对象尽可能少了解,可以有效降低耦合
  • 9. 再回来抽取statement方法中的积点业务和getCharge处理一样,将点数计算Extract Method到getFrequentRenterPoints方法中 增加int frequentRenterPoints = 1;使用Change Method Signature 删除int参数,statement方法中要变成+= 同样将新方法move到Rent对象中 对新方法实现可进行重构 同样先Extract Method方法体到任意函数,然后Move Method到Movie中,命名为getFrequentRenterPoints 同样使用Change Method Signature,将对Rent的引用改为int参数 记得运行单元测试 新的Sequence Diagram如右所示
  • 10. 从statement方法中Extract总计算逻辑先从循环中使用Replace Temp with Query(注:eclipse3.4仍不支持该重构方法,手工完成)去除临时变量thisAmount,变为rent.getCharge() 同样把totalAmount临时变量变为getTotalCharge方法(因为变量在循环中改变,需要复制循环) 同样方法抽取getTotalFrequentRenterPoints方法 同样将新方法move到Rent对象中 对新方法实现可进行重构 又可以对总计算逻辑增加测试了 运行单元测试 现在statement方法职责很单一,只是处理打印逻辑临时变量不易理解和重用 虽然增加了循环次数,但提高了可读性和可测试性(微小的性能损失是值得的)
  • 11. 进一步重构Movie类(switch语句->多态)现在计算逻辑都移到了Movie类中,变化也封装到了Movie类中 增加Movie类将需要修改switch语句,不符合开闭原则 可使用多态分离逻辑,隔离变化 通常的想法是Movie作为abstract,多个Movie子类来实现getCharge方法,但Movie的类型实际是可变的开闭原则- 对扩展开放,对修改关闭 (实现的扩展,抽象层/接口修改的关闭)组合优先原则- 优先使用组合而不是继承(Has-A优于Is-A)由一组类来处理计算逻辑,Movie类持有抽象引用(这其实就是State模式/Strategy模式)2种模式都是通过持有抽象引用,将实现推移到下层,但state模式更强调规律的状态变化
  • 12. 使用State模式来重构创建abstract类Price,abstract方法getCharge(int daysRented) 创建3个子类ChildrensPrice,NewReleasePrice,RegularPrice 将switch中的case逻辑分别移到相应的子类实现中 修改Movie类,构造函数改为引用Price类型,并使用price.getCharge方法 abstract Class- 单继承,能抽取相同实现 interface – 能implements多个,只提炼抽象,不能抽取相同实现逻辑将getFrequentRenterPoints方法抽取到Price类中,子类默认返回1,由NewReleasePrice子类覆写该方法 修改MovieTest中的编译错,改为new Price子类 执行单元测试,绿条通过
  • 13. 我们可以增加功能了,回顾现在新的Movie类型的增加,无需修改movie类了,新增ForeignPrice类并实现getCharge方法即可 增加对新类的测试方法,并执行测试我们现在符合开闭原则了,新增Movie类型无需对Movie类进行修改新建htmlStatement方法,并实现html打印功能,现在只有打印逻辑了 这样对Customer类进行了修改,又不符合开闭原则了 我们也有办法,增加Printer类,然后将打印逻辑分别封装到子类中,这样再新增打印方法也无需修改customer类了。但我们先不这么做,因为…不过度设计原则- 不要为了不确定的变化而过度抽象,在变化来临时再重构实例回顾 我们练会了降龙十八掌第一式 亢龙有悔 最常用的重构方法 Rename Extract Method Change Method Signature Move Replace Temp with Query 进行了单元测试 了解了一些重要的原则 开闭原则(松耦合,可维护性的关键) 单一责任原则 最小知识原则 组合优先原则 使用了State/Strategy模式
  • 14. 重构(龙战与野)
  • 15. 为什么要重构Martin Fowler的经典书籍《重构-改善现有代码质量》中有如下定义: 在不改变软件之可观察行为前提下,提高其可理解性,降低其修改成本 什么是可观察行为?软件功能未变,集成测试/单元测试 是观察的手段 可理解性--软件是给人看的,方便理解的软件才便于修改和维护 降低修改成本--通过降低耦合,理清逻辑,遵循原则,使用模式等手段使得软件易于变化 通过重构可以: 改进设计—一次做对所有事情很难,而软件是一直变化的,重构来维持代码形态 易于理解—代码是最好的文档,好的命名和结构能让程序更准确说出自己的用途 更快的修复bug —逻辑分离,测试保证,可快速排除正确代码,从而找到bug 提高效率—减少调试时间,快速做正确的事情 提升技能—在重构过程中感受代码之美,对原则、模式等能有更深的理解 软件的三项职责: 完成功能、应对变化、易于理解
  • 16. 何时重构重构的三个时机: 添加功能时—新功能不容易增加时就应该考虑重构 修改错误时—难以理解和定位错误时,重构并添加单元测试 代码复审时—复审人员应该和开发人员一起坐下来重构 熟悉代码的坏味道,并知道这里需要重构,代码的坏味道(部分): 重复代码—拷贝粘贴再修改的痕迹(提取公共逻辑) 过长的方法—函数体超过50行,实现了多个逻辑(Extract Method) 过大的类—类做了太多的事情(Extract Class) 过长的参数列—方法难于使用和理解(参数Object化) 变化导致太多修改—变化引起多个类的修改(Move方法到该放的地方) 依恋情节—一个类的实现大量引用另一个类的数据(Move方法) Switch语句—对不同类别使用switch(使用多态) 临时变量—临时变量不易于理解和抽取(Replace Temp with Query) 过度设计—过多的抽象和代理(先简单设计,变化来临时再重构) 不用的方法—子类不需要使用父类的方法(改变类层次) 违背了一些原则—后面会详细讲原则及违背带来的影响
  • 17. 如何重构重构方法用的最多的就是前面例子使用的,这里不再讲更多的,想了解更多请看《重构-改善现有代码质量》 重构的注意事项: 定期重构,比如每天完成功能后,重构好比每天洗碗, 重构一定要在单元测试充分的情况下进行 小步重构,一次改变一点,然后进行测试,发现错误,迅速修复 先进行简单重构,如Extract Method,Rename等,然后进行大型重构,如抽象,抽取类等 重构无定法,关键就是理解软件的美,知道什么是优雅的代码,什么是耦合强,难理解和难于变化的代码 企业需要对重构提供支持重构在一定意义上的确会花更多的时间,但从长远上看提高了软件质量,会降低后续开发和维护成本 通过代码复审,加强软件质量,有经验的复审员,发现问题应该和代码作者一起进行Pair Program(XP中的结对编程)来重构 鼓励结对编程,至少在项目初期进行高低搭配,让普通程序员迅速了解重构提升编码技能
  • 18. 原则(潜龙勿用)
  • 19. 原则是什么质量低的软件存在的问题: 难于变化—一处变化导致其它很多部分,特别是无关部分的修改 难于重用—系统关联性过多,导致很难分离可重用部分 不必要的复杂—设计过于复杂,不利于当前编码 不必要的重复—同样的逻辑多处出现,未进行抽象的统一 难于理解—命名杂乱,结构混乱,难于阅读和理解 难于测试和验证—过多依赖其它系统,缺乏完善测试体系,难于验证 原则就是为了解决这些问题,总结出来的原则 面向对象五大原则: 开闭原则(OCP) 单一职责原则(SRP) 里氏替换原则(LSP) 依赖倒置原则(DIP) 接口隔离原则(ISP)
  • 20. 开闭原则(OCP)对修改关闭,对扩展开放 开闭原则可以说是面向对象可重用的基石,其它原则某种程度就是为了实现开闭原则 通过遵循开闭原则,可以获得灵活性、可重用性、可维护性 如何做到OCP,关键是抽象 Client Server Client (Interface) Client Strategy Server 如果需要使用多种Server,则需要修改Client的代码如果需要使用多种Server,只需增加新的Client Strategy实现对修改关闭肯定是有条件的,也不可能应对所有变化 变化是有方向的,所以要预测变化,并设计在最有可能变化的地方满足OCP的结构 先假设变化不会发生,但在变化来临时,通过重构,创建抽象来隔离变化 让变化来的更早更快—完善的单元测试、持续集成、尽早交付
  • 21. 单一职责原则(SRP)简单的讲就是一个类(一个方法)只有一个职责 深层讲就是一个类只有一个引起变化的原因 这里职责就是“引起变化的原因”,所以我们做设计一定要做到心中有变化 如果一个类有了过多的职责,就会产生耦合,进而导致难于变化和重用 比如我们第一部分的例子,最早的代码,rental类就是承担了太多的职责(包括不该自己承担的) Domain Object Persistence Dao持久化与业务对象职责需要分离,因为业务是不断变化的,而持久化石稳固的如何分离职责 一是靠重构,随时调整代码,将职责放到最适合的地方 二是靠抽象,比如Strategy模式(将计算职责和逻辑分离)、Factory模式(将对象创建职责分离)
  • 22. 里氏替换原则( LSP )子类能够替换基类(声明为基类,将子类对象传入) 例如List l=new ArrayList(); 这里List为接口 这样实现了在运行期而不是编译期绑定实际对象,提高了程序的可扩展,例如可以方便的使用List l=new LinkedList();而无需改变其它代码 这里替换后需要保证的是行为(功能)而不是实现 一个违反LSP的例子: (因为子类不能完全替代基类,而需要使用instanceof 这样增加新的子类,需要更改if else,从而违反了OCP if(a instanceof SubType1 … else if (a instanceof SubType2… Sub Type1 Super Type再次强调Is-A和Has-A Is-A是针对行为的,只有满足LSP的才是真正的Is-A,也就是继承关系 Has-A就更简单,更灵活,通过组合和代理来处理实现 引申出组合优先原则-优先使用组合而不是继承,除非是真正的Is-A关系 Sub Type1
  • 23. 依赖倒置原则( DIP )高层模块不应该依赖于底层,都应该依赖于抽象 抽象不应该依赖于具体,具体应该依赖于抽象 DIP原则是面向对象与面向过程的重要区别,也是Framework设计的核心原则 High Level依赖于Low Level的细节(持有其引用),明显降低了可重用性和可扩展性 High Level Low Level新增Low Level对High Level产生影响好莱坞原则-Don’t call us, we ‘ll call you. 底层不直接调用高层逻辑,而是实现高层接口(留下电话号码) 具体而言 不应该持有指向具体类的引用,除非该类不变(String类) 任何类都不应该从具体类派生 任何方法都不应该覆写基类已经实现的方法 High Level High Level(Interface)都依赖于High Level抽象,Low Level实现抽象,high level调用抽象 Low Level
  • 24. 接口隔离原则( ISP )尽量分离多组接口,使用多个专门的接口而不是fat接口(单一的总接口) 这里组也是按行为来分,比如open和close是一组 分离接口,有利于避免“接口污染”—子类实现与自身无关的接口,客户调用自己不需要的接口 ISP有利于降低耦合,提高重用性 确实需要使用其它接口时,使用组合和代理来调用 LKP有利于降低耦合 在编程中的实践是尽可能降低成员变量和函数的可见性,能是private的尽量用private,尽量少使用public(一旦public就意味着接口的公开,再改动就对已有代码造成影响) Door(Interface) Open() Close() Alert() Stop() Door(Interface) Open() Close()最小知识原则(LKP)—对象对其它对象应该尽可能少的了解 Alarm(Interface) Alert() Stop()
  • 25. 模式(飞龙在天)
  • 26. 什么是模式模式就是前人经验的积累和升华,对一些通用解决方案的总结 设计模式主要应用面向对象原则,通过继承/接口/代理来组合对象,形成灵活度高、扩展性好的方案 学好模式能更深入的理解面向对象原则,快速解决一些常见问题,与项目组成员用模式语言进行沟通 主要分为3大类: 创建模式 单例(Singleton)模式、工厂(Factory)模式、原型(Prototype)模式、构建者(Builder)模式、对象池(Object Pool)模式 行为模式 观察者(Observer)模式、策略(Strategy)模式、状态(State)模式、模板方法(Template Method)模式、命令(Command)模式、中间人(Mediator)模式、责任链(Chain of Responsibility)模式 结构模式 代理(Proxy)模式、适配器(Adapter)模式、装饰(Decorator)模式、门面(Facade)模式、桥梁(Bridge)模式
  • 27. 单例模式(图示来自Head First Design Pattern)JVM中有且仅有一个特定类的实例 只提供了一个私有的构造函数和一个静态方法,该方法返回唯一的类实例 Spring中bean默认都是单例(针对一个bean定义唯一实例) 严谨的延迟加载单例模式的实现 只在第一次调用getInstance时初始化,需要进行同步,不要直接在getInstance方法上同步
  • 28. 工厂(Factory)模式负责创建对象,客户端与工厂交互,隐藏了对象层次抽象 AbstractProduct=Factory.create(“type”); 这样实现了面向接口/抽象编程 进一步演变为抽象工厂模式,在工厂上再进行抽象,具体工厂对应具体的产品
  • 29. 原型(Prototype)模式使用对象的copy方法来创建一个类及子类的多个实例 实例代码 在Spring中可以通过scope=“prototype“声明bean为prototype,这样每次调用getBean方法都会获得一个新的实例拷贝 public abstract class Message { private String sender; public Message makeCopy() { try { Message copy = this.getClass().newInstance(); copy.setSender(this.sender); return copy; } catch (InstantiationException e) { return null; } catch (IllegalAccessException e) { return null; } } } public class EmailMessage extends Message {
  • 30. 构建者(Builder)模式构建完整对象,Builder是接口或抽象类,子类决定构建产品各部分的实现 构建者模式与工厂模式最主要区别在于,工厂应对产品层次的抽象,而构建者产品为具体类,主要是构建各部分实现的抽象 形象的比喻:汽车工厂负责生产轿车、商务车、跑车等,而汽车构建者负责建造方向盘、轮胎、地盘。。最终形成完整的产品
  • 31. 观察者(Observer)模式在被观察对象状态改变时,需要接收到通知时使用 Subject负责observer的注册和管理,然后调用注册的Observer的抽象方法notigy来通知所有observer
  • 32. 策略(Strategy)模式针对计算逻辑的不同,由子类去实现具体的计算逻辑,可以实现灵活替换 比如税率计算,有固定金额,有百分比,就可以采用策略模式,隐藏计算逻辑
  • 33. 状态(State)模式状态模式和策略模式从结构上看非常类似,都是将实现逻辑向下推移给子类 策略模式强调算法的不同,而状态强调对象自身的状态,有着更深层次的状态变迁逻辑 重构时,经常会使用2个模式之一,提取抽象,将实现逻辑下移
  • 34. 模板方法(Template Method)模式由抽象类实现计算的骨架(公共部分),然后由子类实现细节 广泛用于Spring DAO相关代码中,如JdbcTemplate, HibernateTemplate HibernateTemplate有doExecute方法,先获得session,然后调用action.doInHibernate(sessionToExpose)这一模板方法,最后做关闭session等后续处理 这样实际处理只需new HibernateCallBack对象,并提供具体的doInHibernate实现,避免了每次处理复杂的Session打开与关闭逻辑 protected Object doExecute(HibernateCallback action, boolean enforceNewSession, boolean enforceNativeSession) throws DataAccessException { … Session session =… Object result = action.doInHibernate(sessionToExpose); catch (HibernateException ex) {… finally {… public Object get(…{… return executeWithNativeSession(new HibernateCallback() { public Object doInHibernate(Session session) throws HibernateException {…
  • 35. 代理(Proxy)模式取代原有对象,改变目标对象行为,然后调用目标对象方法,完成原有逻辑 很方便的添加/改变行为,使用非常广泛 实例代码,ImageProxy实现Image并持有一个Image的引用 public interface Image { public void showImage(); } public class ImageProxy implements Image private String imageFilePath; private Image proxifiedImage; public ImageProxy(String imageFilePath) { this.imageFilePath= imageFilePath; } public void showImage() { proxifiedImage = new HighResolutionImage(imageFilePath); proxifiedImage.showImage(); } }
  • 36. 适配器(Adapter)模式针对客户端要求接口与Adaptee提供接口不一致的情形,创建实现客户端接口的Adapter(继承Adaptee或聚合它) 适配器模式适用于一些旧代码改造中,通过代理将新的接口适配到原有client上,减少对原有代码的改动 适配器模式在实际生活中也运用广泛
  • 37. 装饰(Decorator)模式不改变原接口的情况下给对象增加新的功能,decorator持有原对象的引用 在java.io中使用非常广泛,如InputStream in=new BufferedInputStream(new FileInputStream(“file”) 这样对read方法可以层层装饰 public abstract class InputStream Public class FileInputStream extends InputStream public class FilterInputStream extends InputStream { protected volatile InputStream in; protected FilterInputStream(InputStream in) { this.in = in; } Public class BufferedInputStream extends FilterInputStream {
  • 38. 门面(Facade)模式封装复杂的逻辑给客户端提供简便的接口 非常简单的模式,但很有效 体现了“最小知识原则”,也体现了用高层逻辑对底层实现的封装 常用于Service层,整合业务,给展现层提供便于使用的接口
  • 39. 测试(鱼跃于渊)
  • 40. 测试流程 通过测试降低bug率 单元测试提供软件可验证的行为,便于维护和变更 单元测试可提高开发效率(有效减少调试时间和界面点击时间) 单元测试与重构是不可分的 通过单元测试和有效降低耦合 我们应该把单元测试提到和可运行代码同等的位置
  • 41. JUnit最老牌的Java单元测试框架,最新是4.5,注解方式 Eclipse已经集成,可以方便运行junit,通常是“红-绿-红-绿”的过程 语法简单,常用的有 assertEquals assertTrue assertNotNull Spring与Junit的集成 Spring提供了AbstractTransactionalDataSourceSpringContextTests 能够自动装配bean-setAutowireMode(AUTOWIRE_BY_NAME)按名称装配 提供了对事务的支持 setDefaultRollback(false)则在整个测试结束后不回滚 使用Junit要点 测试覆盖率要高 只依赖于assert,不要出现测试通过还要人工看输出或数据库记录的情况 再次强调与重构的关系,有完备的测试才能进行重构 数据库相关测试是一个难点,需要在程序准备数据还是依赖数据库已有数据做选择,后者节省时间,但可能导致测试不可重入