-
-
划分职责识别类
-
定义属性与方法
-
定义类之间的交互关系
-
组装类并提供执行入口
-
-
-
面向对象的编程有三大特性:封装、继承和多态。
-
两个面向对象的核心理念:
-
“Program to an ‘interface’, not an ‘implementation’.”
-
使用者不需要知道数据类型、结构、算法的细节。
-
使用者不需要知道实现细节,只需要知道提供的接口。
-
利于抽象、封装、动态绑定、多态。
-
符合面向对象的特质和理念。
-
-
“Favor ‘object composition’ over ‘class inheritance’.”
-
继承需要给子类暴露一些父类的设计和实现细节。
-
父类实现的改变会造成子类也需要改变。
-
我们以为继承主要是为了代码重用,但实际上在子类中需要重新实现很多父类的方法。
-
继承更多的应该是为了多态。
-
-
-
-
-
多用组合少用继承
我们知道,大部分鸟都会飞,那我们可不可以在 AbstractBird 抽象类中,定义一个 fly() 方法呢?答案是否定的。尽管大部分鸟都会飞,但也有特例,比如鸵鸟就不会飞。鸵鸟继承具有 fly() 方法的父类,那鸵鸟就具有“飞”这样的行为,这显然不符合我们对现实世界中事物的认识。当然,你可能会说,我在鸵鸟这个子类中重写(override)fly() 方法,让它抛出 UnSupportedMethodException 异常不就可以了吗?具体的代码实现如下所示:
public class AbstractBird { //...省略其他属性和方法... public void fly() { //... } } public class Ostrich extends AbstractBird { //鸵鸟 //...省略其他属性和方法... public void fly() { throw new UnSupportedMethodException("I can't fly.'"); } }
这种设计思路虽然可以解决问题,但不够优美。因为除了鸵鸟之外,不会飞的鸟还有很多,比如企鹅。对于这些不会飞的鸟来说,我们都需要重写 fly() 方法,抛出异常。这样的设计,一方面,徒增了编码的工作量;另一方面,也违背了我们之后要讲的最小知识原则(Least Knowledge Principle,也叫最少知识原则或者迪米特法则),暴露不该暴露的接口给外部,增加了类使用过程中被误用的概率。
-
组合相比继承有哪些优势?
针对“会飞”这样一个行为特性,我们可以定义一个 Flyable 接口,只让会飞的鸟去实现这个接口。对于会叫、会下蛋这些行为特性,我们可以类似地定义 Tweetable 接口、EggLayable 接口。我们将这个设计思路翻译成 Java 代码的话,就是下面这个样子:
public interface Flyable { void fly(); } public interface Tweetable { void tweet(); } public interface EggLayable { void layEgg(); } public class Ostrich implements Tweetable, EggLayable {//鸵鸟 //... 省略其他属性和方法... @Override public void tweet() { //... } @Override public void layEgg() { //... } } public class Sparrow implements Flyable, Tweetable, EggLayable {//麻雀 //... 省略其他属性和方法... @Override public void fly() { //... } @Override public void tweet() { //... } @Override public void layEgg() { //... } }
不过,我们知道,接口只声明方法,不定义实现。也就是说,每个会下蛋的鸟都要实现一遍 layEgg() 方法,并且实现逻辑是一样的,这就会导致代码重复的问题。那这个问题又该如何解决呢?
我们可以针对三个接口再定义三个实现类,它们分别是:实现了 fly() 方法的 FlyAbility 类、实现了 tweet() 方法的 TweetAbility 类、实现了 layEgg() 方法的 EggLayAbility 类。然后,通过组合和委托技术来消除代码重复。具体的代码实现如下所示:
public interface Flyable { void fly(); } public class FlyAbility implements Flyable { @Override public void fly() { //... } } //省略Tweetable/TweetAbility/EggLayable/EggLayAbility public class Ostrich implements Tweetable, EggLayable {//鸵鸟 private TweetAbility tweetAbility = new TweetAbility(); //组合 private EggLayAbility eggLayAbility = new EggLayAbility(); //组合 //... 省略其他属性和方法... @Override public void tweet() { tweetAbility.tweet(); // 委托 } @Override public void layEgg() { eggLayAbility.layEgg(); // 委托 } }
我们知道继承主要有三个作用:表示 is-a 关系,支持多态特性,代码复用。而这三个作用都可以通过其他技术手段来达成。比如 is-a 关系,我们可以通过组合和接口的 has-a 关系来替代;多态特性我们可以利用接口来实现;代码复用我们可以通过组合和委托来实现。所以,从理论上讲,通过组合、接口、委托三个技术手段,我们完全可以替换掉继承,在项目中不用或者少用继承关系,特别是一些复杂的继承关系。
-
如何判断该用组合还是继承?
前面我们讲到继承可以实现代码复用。利用继承特性,我们把相同的属性和方法,抽取出来,定义到父类中。子类复用父类中的属性和方法,达到代码复用的目的。但是,有的时候,从业务含义上,A 类和 B 类并不一定具有继承关系。比如,Crawler 类和 PageAnalyzer 类,它们都用到了 URL 拼接和分割的功能,但并不具有继承关系(既不是父子关系,也不是兄弟关系)。仅仅为了代码复用,生硬地抽象出一个父类出来,会影响到代码的可读性。如果不熟悉背后设计思路的同事,发现 Crawler 类和 PageAnalyzer 类继承同一个父类,而父类中定义的却只是 URL 相关的操作,会觉得这个代码写得莫名其妙,理解不了。这个时候,使用组合就更加合理、更加灵活。具体的代码实现如下所示:
public class Url { //...省略属性和方法 } public class Crawler { private Url url; // 组合 public Crawler() { this.url = new Url(); } //... } public class PageAnalyzer { private Url url; // 组合 public PageAnalyzer() { this.url = new Url(); } //.. }
还有一些特殊的场景要求我们必须使用继承。如果你不能改变一个函数的入参类型,而入参又非接口,为了支持多态,只能采用继承来实现。比如下面这样一段代码,其中 FeignClient 是一个外部类,我们没有权限去修改这部分代码,但是我们希望能重写这个类在运行时执行的 encode() 函数。这个时候,我们只能采用继承来实现了。
public class FeignClient { // Feign Client框架代码 //...省略其他代码... public void encode(String url) { //... } } public void demofunction(FeignClient feignClient) { //... feignClient.encode(url); //... } public class CustomizedFeignClient extends FeignClient { @Override public void encode(String url) { //...重写encode的实现...} } // 调用 FeignClient client = new CustomizedFeignClient(); demofunction(client);
尽管有些人说,要杜绝继承,100% 用组合代替继承,但是我的观点没那么极端!之所以“多用组合少用继承”这个口号喊得这么响,只是因为,长期以来,我们过度使用继承。还是那句话,组合并不完美,继承也不是一无是处。只要我们控制好它们的副作用、发挥它们各自的优势,在不同的场合下,恰当地选择使用继承还是组合,这才是我们所追求的境界。
-
-
-
使用描述性的名称
-
别害怕长名称,长而具有描述性的名称,要比短而令人费解的名称好,要比描述性的长注释好。通过阅读名字,就可以知道它为什么存在,它是什么,它做什么,它怎么用。
// before int d; // elapsed time in days // after int elapsedTimeInDays;
// before public List<int[]> getThem() { List<int[]> list1 = new ArrayList<int[]>(); for (int[] x : theList){ if (x[0] == 4) list1.add(x); } return list1; } // after public List<Cell> getFlaggedCells() { List<Cell> flaggedCells = new ArrayList<Cell>(); for (Cell cell : gameBoard){ if (cell.isFlagged()) flaggedCells.add(cell); } return flaggedCells; }
-
做有意义的区分
-
尽量不以数字系列命名,如下:
public static void copyChars(char a1[], char a2[]) { for (int i = 0; i < a1.length; i++) { a2[i] = a1[i]; } }
可以修改如下:
public static void copyChars(char source[], char destination[]) { for (int i = 0; i < source.length; i++) { destination[i] = source[i]; } }
-
-
使用读的出的名字
-
编程是种社会活动。一个可以读的名字,能使人的记忆和交流更方便。
// before class DtaRcrd102 { private Date genymdhms; private Date modymdhms; private final String pszqint = "102"; /* ... */ }; // after class Customer { private Date generationTimestamp; private Date modificationTimestamp;; private final String recordId = "102"; /* ... */ };
-
-
使用可搜索的名字
尽量不要用单字母和数字作为名字,很难被搜索。如英文字母e,是最常用的字母。 单字母可以用于局部变量。换而言之,名字的长短最好与它作用范围的大小成正比。 如果变量在代码的多处被使用,应该取一个好搜索的名字。
// before for (int j=0; j<34; j++) { s += (t[j]*4)/5; } // after int realDaysPerIdealDay = 4; const int WORK_DAYS_PER_WEEK = 5; int sum = 0; for (int j=0; j < NUMBER_OF_TASKS; j++) { int realTaskDays = taskEstimate[j] * realDaysPerIdealDay; int realTaskWeeks = (realdays / WORK_DAYS_PER_WEEK); sum += realTaskWeeks; }
-
-
-
-
方法名
-
方法名应该是个动词。对属性访问器、修改器和断言,应该加上get、set、is前缀。
string name = employee.getName(); customer.setName("mike"); if (paycheck.isPosted())...
-
重载构造器时,使用描述参数的静态工厂方法。
// before Complex fulcrumPoint = new Complex(23.0); // after, this is better Complex fulcrumPoint = Complex.FromRealNumber(23.0);
-
-
只做一件事
-
如果函数只是做了该函数名下同一抽象层上的步骤,则函数只做了一件事。
-
要判断函数是否不止做了一件事,就是要看是否能再拆出一个函数。
-
-
添加有意义的语境
需要用良好命名的类、函数或者名称空间,来为读者提供语境。如果没有,就只能加前缀了。
没语境的代码:
private void printGuessStatistics(char candidate, int count) { String number; String verb; String pluralModifier; if (count == 0) { number = "no"; verb = "are"; pluralModifier = "s"; } else if (count == 1) { number = "1"; verb = "is"; pluralModifier = ""; } else { number = Integer.toString(count); verb = "are"; pluralModifier = "s"; } String guessMessage = String.format( "There %s %s %s%s", verb, number, candidate, pluralModifier ); print(guessMessage); }
有语境的代码:
public class GuessStatisticsMessage { private String number; private String verb; private String pluralModifier; public String make(char candidate, int count) { createPluralDependentMessageParts(count); return String.format( "There %s %s %s%s", verb, number, candidate, pluralModifier ); } private void createPluralDependentMessageParts(int count) { if (count == 0) { thereAreNoLetters(); } else if (count == 1) { thereIsOneLetter(); } else { thereAreManyLetters(count); } } private void thereAreManyLetters(int count) { number = Integer.toString(count); verb = "are"; pluralModifier = "s"; } private void thereIsOneLetter() { number = "1"; verb = "is"; pluralModifier = ""; } private void thereAreNoLetters() { number = "no"; verb = "are"; pluralModifier = "s"; } }
-
参数
-
如果函数需要两个、三个或者三个以上参数,就说明其中一些参数应该封装成类了。
-
将参数的顺序编码进函数名,减轻记忆参数顺序的负担,例如,assertExpectedEqualsActual(expected, actual)。
-
-
读写分离
-
if (set("username", "unclebob")) { ... } 的含义模糊不清。应该改为:
if (attributeExists("username")) { setAttribute("username", "unclebob"); ... }
-
-
使用异常机制
try { deletePage(); xxx(); yyy(); } catch (Exception e) { log(e->getMessage()); }
-
try/catch代码块丑陋不堪,所以最好把try和catch代码块的主体抽离出来,单独形成函数。
try { do(); } catch (Exception e) { handle(); }
public void delete(Page page) { try { deletePageAndAllReferences(page); } catch (Exception e) { logError(e); } } private void deletePageAndAllReferences(Page page) throws Exception { deletePage(page); registry.deleteReference(page.name); configKeys.deleteKey(page.name.makeKey()); } private void logError(Exception e) { logger.log(e.getMessage()); }
-
给出异常发生的环境说明
应创建信息充分的错误信息,并和异常一起传递出去。在消息中,包括失败的操作和失败类型。如果你的应用程序有日志系统,传递足够的信息给catch块,并记录下来。
-
依调用者需要定义异常类
// bad ACMEPort port = new ACMEPort(12); try { port.open(); } catch(DeviceResponseException e) { reportPortError(e); logger.log("Device response exception",e); } catch(ATM1212UnlockedException e) { reportPortError(e); logger.log("Unlock exception",e); } catch(GMXError e) { reportPortError(e); logger.log("Device response exception",e); } finally { // ..... }
通过打包调用API,确保它返回通过用异常类型,从而简化代码:
// good LocalPort port = new LocalPort(12); try { port.open(); } catch(PortDeviceFailure e) { reportError(e); logger.log(e.getMessage(),e); } finally { // ..... } public class LocalPort{ private ACMEPort innerPort; public LocalPort(int portNumber){ innerPort = new ACMEPort(portNumber); } public open() { try { innerPort.open(); } catch(DeviceResponseException e) { // 自定义的异常类 throw new PortDeviceFailure(e); } catch(ATM1212UnlockedException e) { throw new PortDeviceFailure(e); } catch(GMXError e) { throw new PortDeviceFailure(e); } } }
将第三方API打包是个良好的实践手段。当你打包一个第三方API,你就降低了对它的依赖。
-
-
消除重复
-
两个方法提取共性到新方法中,新方法分解到另外的类里,从而提升其可见性。 模板方法模式是消除重复的通用技巧。
// 原始逻辑 public class VacationPolicy() { public void accrueUSDivisionVacation() { //do x; //do US y; //do z; } public void accrueEUDivisionVacation() { //do x; //do EU y; //do z; } } // 模板方法模式重构之后 abstract public class VacationPolicy { public void accrueVacation() { x(); y(); z(); } private void x() { //do x; } abstract protected void y() { } private void z() { //do z; } } public class USVacationPolicy extends VacationPolicy { protected void y() { //do US y; } } public class EUVacationPolicy extends VacationPolicy { protected void y() { //do EU y; } }
-
-
短小
public static String testableHtml(PageData pageData, boolean includeSuiteSetup) throws Exception { WikiPage wikiPage = pageData.getWikiPage(); StringBuffer buffer = new StringBuffer(); if (pageData.hasAttribute("Test")) { if (includeSuiteSetup) { WikiPage suiteSetup = PageCrawlerImpl.getInheritedPage( SuiteResponder.SUITE_SETUP_NAME, wikiPage ); if (suiteSetup != null) { WikiPagePath pagePath = suiteSetup.getPageCrawler().getFullPath(suiteSetup); String pagePathName = PathParser.render(pagePath); buffer.append("!include -setup .") .append(pagePathName) .append("\n"); } } WikiPage setup = PageCrawlerImpl.getInheritedPage("SetUp", wikiPage); if (setup != null) { WikiPagePath setupPath = wikiPage.getPageCrawler().getFullPath(setup); String setupPathName = PathParser.render(setupPath); buffer.append("!include -setup .") .append(setupPathName) .append("\n"); } } buffer.append(pageData.getContent()); if (pageData.hasAttribute("Test")) { WikiPage teardown = PageCrawlerImpl.getInheritedPage("TearDown", wikiPage); if (teardown != null) { WikiPagePath tearDownPath = wikiPage.getPageCrawler().getFullPath(teardown); String tearDownPathName = PathParser.render(tearDownPath); buffer.append("\n") .append("!include -teardown .") .append(tearDownPathName) .append("\n"); } if (includeSuiteSetup) { WikiPage suiteTeardown = PageCrawlerImpl.getInheritedPage( SuiteResponder.SUITE_TEARDOWN_NAME, wikiPage ); if (suiteTeardown != null) { WikiPagePath pagePath = suiteTeardown.getPageCrawler().getFullPath (suiteTeardown); String pagePathName = PathParser.render(pagePath); buffer.append("!include -teardown .") .append(pagePathName) .append("\n"); } } } pageData.setContent(buffer.toString()); return pageData.getHtml(); }
这容易看懂吗?来改善一次:
public static String renderPageWithSetupsAndTeardowns(PageData pageData, boolean isSuite) throws Exception { boolean isTestPage = pageData.hasAttribute("Test"); if (isTestPage) { WikiPage testPage = pageData.getWikiPage(); StringBuffer newPageContent = new StringBuffer(); includeSetupPages(testPage, newPageContent, isSuite); newPageContent.append(pageData.getContent()); includeTeardownPages(testPage, newPageContent, isSuite); pageData.setContent(newPageContent.toString()); } return pageData.getHtml(); }
这个好点了。但还是太长了,可以再改善一下:
public static String renderPageWithSetupsAndTeardowns( PageData pageData, boolean isSuite) throws Exception { if (isTestPage(pageData)) includeSetupAndTeardownPages(pageData, isSuite); return pageData.getHtml(); }
-
switch语句
switch天生要做许多事。
public Money calculatePay(Employee e) throws InvalidEmployeeType { switch (e.type) { case COMMISSIONED: return calculateCommissionedPay(e); case HOURLY: return calculateHourlyPay(e); case SALARIED: return calculateSalariedPay(e); default: throw new InvalidEmployeeType(e.type); } }
以上代码有四个问题:
- 太长,而且出现新雇员类型时,它会变更长
- 它做了很多事
- 它违背了单一权责原则(Single Responsibility Principle,SRP)
- 它违反了开放闭合原则(Open Closed Principle,OCP),每次添加新类型时,就要修改它。
这个问题的解决方案,就是将switch埋到抽象工厂下,不让任何人看到。
public abstract class Employee { public abstract boolean isPayday(); public abstract Money calculatePay(); public abstract void deliverPay(Money pay); } ----------------- public interface EmployeeFactory { public Employee makeEmployee(EmployeeRecord r) throws InvalidEmployeeType; } ----------------- public class EmployeeFactoryImpl implements EmployeeFactory { public Employee makeEmployee(EmployeeRecord r) throws InvalidEmployeeType { switch (r.type) { case COMMISSIONED: return new CommissionedEmployee(r) ; case HOURLY: return new HourlyEmployee(r); case SALARIED: return new SalariedEmploye(r); default: throw new InvalidEmployeeType(r.type); } } }
-
无副作用
尽量不要在函数中,更改参数的值。
public class UserValidator { private Cryptographer cryptographer; public boolean checkPassword(String userName, String password) { User user = UserGateway.findByName(userName); if (user != User.NULL) { String codedPhrase = user.getPhraseEncodedByPassword(); String phrase = cryptographer.decrypt(codedPhrase, password); if ("Valid Password".equals(phrase)) { Session.initialize(); return true; } } return false; } }
Session.initialize(); 就是这一行产生的副作用。
-
分割指令与询问
函数要么做什么事情,要么回答什么问题。不要混淆。
// 如果有这样子一个函数 public boolean set(String attribute, String value); // 被这样子调用,是很让人疑惑的 if (set("username", "unclebob"))... // 应该这样子写: if (attributeExists("username")) { setAttribute("username", "unclebob"); ... }
-
-
-
用代码来阐述
-
创建一个与注释所言同一事物的函数即可。
// check to see if the employee is eligible for full benefits if ((employee.falgs & HOURLY_FLAG) && (employee.age > 65)) // 下面这个更好 if (employee.isEligibleForFullBenefits())
-
-
能用函数或者变量表示就别用注释:
// does the module from the global list <mod> // depend on the subsystem we are part of? if (smodule.getDependSubsystems().contains(subSysMod.getSubSystem()) // 可以改为 ArrayList moduleDependees = smodule.getDependSubsystems(); String ourSubSystem = subSysMod.getSubSystem(); if (moduleDependees.contains(ourSubSystem))
-
-
-
对象:暴露行为(接口),隐藏数据(私有变量)。
-
数据结构:没有明显的行为(接口),暴露数据。如DTO(Data Transfer Objects)、Entity。
-
数据抽象
- 具象点
public class Point { public double x; public double y; }
- 抽象点
public interface Point { double getX(); double getY(); void setCartesian(double x, double y); double getR(); double getTheta(); void setPolar(double r, double theta); }
抽象点的好处在于,它不仅仅是在变量之上放了一个函数层那么简单。
隐藏关乎抽象。
也就是为了让用户不需要了解数据的实现就能操作数据本体。
-
-
-
每个测试一个断言
- 每个测试函数有且仅有一个断言语句,每个测试函数中只测试一个概念。
-
整洁的测试依赖于FIRST规则
-
fast: 测试代码应该能够快速运行,因为我们需要频繁运行它。
-
independent: 测试应该相互独立,某个测试不应该依赖上一个测试的结果,测试可以以任何顺序进行。
-
repeatable: 测试应可以在任何环境中通过。
-
self-validating: 测试应该有bool值输出,不应通过查看日志来确认测试结果,不应手工对比两个文本文件确认测试结果。
-
timely: 及时编写测试代码。单元测试应该在生产代码之前编写,否则生产代码会变得难以测试。
-
-
整洁的测试
三个要素:可读性,可读性和可读性。
public void testGetPageHieratchyAsXml() throws Exception { crawler.addPage(root, PathParser.parse("PageOne")); crawler.addPage(root, PathParser.parse("PageOne.ChildOne")); crawler.addPage(root, PathParser.parse("PageTwo")); request.setResource("root"); request.addInput("type", "pages"); Responder responder = new SerializedPageResponder(); SimpleResponse response = (SimpleResponse) responder.makeResponse( new FitNesseContext(root), request); String xml = response.getContent(); assertEquals("text/xml", response.getContentType()); assertSubString("<name>PageOne</name>", xml); assertSubString("<name>PageTwo</name>", xml); assertSubString("<name>ChildOne</name>", xml); } public void testGetPageHieratchyAsXmlDoesntContainSymbolicLinks() throws Exception { WikiPage pageOne = crawler.addPage(root, PathParser.parse("PageOne")); crawler.addPage(root, PathParser.parse("PageOne.ChildOne")); crawler.addPage(root, PathParser.parse("PageTwo")); PageData data = pageOne.getData(); WikiPageProperties properties = data.getProperties(); WikiPageProperty symLinks = properties.set(SymbolicPage.PROPERTY_NAME); symLinks.set("SymPage", "PageTwo"); pageOne.commit(data); request.setResource("root"); request.addInput("type", "pages"); Responder responder = new SerializedPageResponder(); SimpleResponse response = (SimpleResponse) responder.makeResponse( new FitNesseContext(root), request); String xml = response.getContent(); assertEquals("text/xml", response.getContentType()); assertSubString("<name>PageOne</name>", xml); assertSubString("<name>PageTwo</name>", xml); assertSubString("<name>ChildOne</name>", xml); assertNotSubString("SymPage", xml); } public void testGetDataAsHtml() throws Exception { crawler.addPage(root, PathParser.parse("TestPageOne"), "test page"); request.setResource("TestPageOne"); request.addInput("type", "data"); Responder responder = new SerializedPageResponder(); SimpleResponse response = (SimpleResponse) responder.makeResponse( new FitNesseContext(root), request); String xml = response.getContent(); assertEquals("text/xml", response.getContentType()); assertSubString("test page", xml); assertSubString("<Test", xml); }
重构后:
public void testGetPageHierarchyAsXml() throws Exception { makePages("PageOne", "PageOne.ChildOne", "PageTwo"); submitRequest("root", "type:pages"); assertResponseIsXML(); assertResponseContains( "<name>PageOne</name>", "<name>PageTwo</name>","<name>ChildOne</name>" ); } public void testSymbolicLinksAreNotInXmlPageHierarchy() throws Exception { WikiPage page = makePage("PageOne"); makePages("PageOne.ChildOne", "PageTwo"); addLinkTo(page, "PageTwo", "SymPage"); submitRequest("root", "type:pages"); assertResponseIsXML(); assertResponseContains( "<name>PageOne</name>", "<name>PageTwo</name>", "<name>ChildOne</name>" ); assertResponseDoesNotContain("SymPage"); } public void testGetDataAsXml() throws Exception { makePageWithContent("TestPageOne", "test page"); submitRequest("TestPageOne", "type:data"); assertResponseIsXML(); assertResponseContains("test page", "<Test"); }
重构后,这些测试呈现了构造-操作-检验模式。每个测试都该这样子。
-
-
-
类应该短小
-
为修改而组织
-
类应当对扩展开放,对修改封闭(开放闭合原则)。
-
在理想系统中,我们通过扩展系统而非修改现有代码来添加新特性。
-
-
-
-
将系统的构造与使用分开
-
依赖注入(DI),控制反转(IoC)是分离构造与使用的强大机制。
-
有时应用程序也需要负责确定何时创建对象,我们可以使用抽象工厂模式让应用自行控制何时创建对象,但构造能力在工厂实现类里,与使用部分分开。
-
将关注的方面分离开,是软件技艺中最古老也是最重要的设计技巧。 然而大多数程序,都没有做分离处理,如下:
public Service getService() { if (service == null) service = new MyServiceImpl(...); // Good enough default for most cases? return service; }
这段代码虽然是所谓的延时构造、赋值,有一些好处。但是我们也得到 MyServiceImpl 构造函数所需要的一切的硬编码依赖。
-
-
-
-
防御并发代码问题的原则与技巧
-
遵循单一职责原则。分离并发代码与非并发代码。
-
限制临界区数量、限制对共享数据的访问。
-
避免使用共享数据,使用对象的副本。
-
线程尽可能地独立,不与其他线程共享数据。
public class X { private int lastIdUsed; public int getNextId() { return ++lastIdUsed; } }
如果两个线程**享这个实例对象,那么getNextId返回的结果就有可能不是我们想要的。
-
-
-
-
学习log4j
-
日志等级
-
TRACE 在线调试
默认情况下,即不打印到终端也不输出到文件。对程序的运行效率几乎不产生影响。
-
DEBUG 终端查看、在线调试
默认情况下,打印到终端输出,但是不会归档到日志文件。因此,一般用于开发者在程序当前启动窗口上,查看日志的流水信息。
-
INFO 报告程序进度和状态信息
一般这种情况都是一次性的,不会大量反复输出。
-
WARNING 警告信息
程序处理中遇到非法数据或者某种可能的错误。该错误是一过性的、可恢复的,不会影响程序的继续运行,程序仍处在正常状态。
-
ERROR 状态错误
该错误发生后程序仍然可以运行,但是极有可能运行在某种非正常的状态下,导致无法完成全部既定的功能。
-
FATAL 致命错误
表明程序遇到了致命的错误,必须马上终止运行。
Log4j建议只使用四个级别,优先级从高到低分别是 ERROR > WARN > INFO > DEBUG
-
-
-
-
-
Extract Method (提炼函数)
-
你有一段代码可以被组织在一起并独立出来,将这段代码放进一个独立函数中,并让函数名称解释该函数的用途。
void printOwing(double amount) { printBanner(); // print details System.out.println("name:" + _name); System.out.println("amount" + _amount); }
void printOwing(double amount) { printBanner(); printDetails(amount); } // 提炼函数 void printDetails(double amount) { System.out.println("name" + _name); System.out.println("amount" + amount); }
注意目标代码中的局部变量。如果需要对局部变量进行再次赋值并使用,提炼出的函数可以使用参数以及返回值。
-
-
Replace Nested Conditional with Guard Clauses(以卫语句取代嵌套表达式)
double getPayAmount() { double result; if (_isDead) { result = deadAmount(); } else { if (_isSeparted) { result = separatedAmount(); } else { if (_isRetired) result = retiredAmount(); else result = normalPayAmount(); } } }
使用卫语句:
double getPayAmount() { if (_isDead) return deadAmount(); if (_isSeparated) return separatedAmount(); if (_isRetired) return retiredAmount(); return normalPayAmount(); }
条件表达式通常有两种表现形式,一种是所有分支都是正常行为,另一种是只有一种是正常行为,其他都是特殊情况。 对于第一种就应该使用if-else语句,而对于第二种就应当使用卫语句。卫语句通常从函数中返回或抛异常。
-
引入参数对象:
function amountInvoiced(startDate, endDate) // ====> function amountInvoiced(dateRange)
-
以对象取代基本类型:
order.filter(o => 'high' === o.priority || 'rush' === o.priority) // =====> order.filter(o => o.priority.hightThen(new Priority('normal')))
-
拆分循环:
let averageAge = 0 let totalSalary = 0 for (const p of people) { averageAge += p.age totalSalary = p.salary } averageAge /= people.length // ======> let averageAge = 0 for (const p of people) { averageAge += p.age } averageAge /= people.length let totalSalary = 0 for (const p of people) { totalSalary = p.salary }
也许上面的代码对于很多人来说都是一种坏味道, 甚至我现在也这样觉得, 因为理论上多执行了一次循环, 但是往往在很多的场景中, 循环并不是性能的瓶颈, 分开书写之后能带来更好的维护性和可读性, 也能方便的拆分代码逻辑。
-
以多态取代条件表达式:
switch(bird.type) { case 'a': return 'a type' case 'b': return 'b type' default: return 'default' } // ====> class AType { getType() { return 'a type' } } class BType { getType() { return 'b type' } }
-
将查询函数和修改函数分离:
function getTotalOutstandingAndSendBill() { const result = customer.invoices.reduce((total, each) => each.amount + total, 0) sendBill() return result; } // ====> function totalOutstanding() { return customer.invoices.reduce((total, each) => each.amount + total, 0) } function sendBill() { sendBill() }
原书中有一句话我印象深刻, 明确表现出有副作用与无副作用两种函数之间的差异, 是个很好的想法。 任何有返回值的函数, 都不应该有看得到的副作用–命令与查询分离。
-
-
代 码 => What, How & Details
-
文档 / 书 => What, How & Why
可见,代码并不会告诉你 Why,看代码只能靠猜测或推导来估计 Why,是揣测,不准确,所以会有很多误解。而且,我们每个人都知道,Why 是能让人一通百通的东西,也是能让人醍醐灌顶的东西。
-
如何阅读源代码
-
首先,在阅读代码之前,我建议你需要有下面的这些前提再去阅读代码,这样你读起代码来会很顺畅。
-
基础知识。相关的语言和基础技术的知识。
-
软件功能。你先要知道这个软件完成的是什么样的功能,有哪些特性,哪些配置项。你先要读一遍用户手册,然后让软件跑起来,自己先用一下感受一下。
-
相关文档。读一下相关的内部文档,Readme 也好,Release Notes 也好, Design 也好,Wiki 也好,这些文档可以让你明白整个软件的方方面面。如果你的软件没有文档,那么,你只能指望这个软件的原作者还在,而且他还乐于交流。
-
代码的组织结构。也就是代码目录中每个目录是什么样的功能,每个文档是干什么的。如果你要读的程序是在某种标准的框架下组织的,比如:Java 的 Spring 框架,那么恭喜你,这些代码不难读了。
-
-
接下来,你要了解这个软件的代码是由哪些部分构成的,我在这里给你一个列表,供你参考。
-
接口抽象定义。任何代码都会有很多接口或抽象定义,其描述了代码需要处理的数据结构或者业务实体,以及它们之间的关系,理清楚这些关系是非常重要的。
-
模块粘合层。我们的代码有很多都是用来粘合代码的,比如中间件(middleware)、Promises 模式、回调(Callback)、代理委托、依赖注入等。这些代码模块间的粘合技术是非常重要的,因为它们会把本来平铺直述的代码给分裂开来,让你不容易看明白它们的关系。
-
业务流程。这是代码运行的过程。一开始,我们不要进入细节,但需要在高层搞清楚整个业务的流程是什么样的,在这个流程中,数据是怎么被传递和处理的。一般来说,我们需要画程序流程图或者时序处理图。
-
具体实现。了解上述的三个方面的内容,相信你对整个代码的框架和逻辑已经有了总体认识。这个时候,你就可以深入细节,开始阅读具体实现的代码了。对于代码的具体实现,一般来说,你需要知道下面一些事实,这样有助于你在阅读代码时找到重点。
-
代码逻辑。代码有两种逻辑,一种是业务逻辑,这种逻辑是真正的业务处理逻辑;另一种是控制逻辑,这种逻辑只是用控制程序流转的,不是业务逻辑。比如:flag 之类的控制变量,多线程处理的代码,异步控制的代码,远程通讯的代码,对象序列化反序列化的代码等。这两种逻辑你要分开,很多代码之所以混乱就是把这两种逻辑混在一起了(详情参看《编程范式游记》)。
-
出错处理。根据二八原则,20% 的代码是正常的逻辑,80% 的代码是在处理各种错误,所以,你在读代码的时候,完全可以把处理错误的代码全部删除掉,这样就会留下比较干净和简单的正常逻辑的代码。排除干扰因素,可以更高效地读代码。
-
数据处理。只要你认真观察,就会发现,我们好多代码就是在那里倒腾数据。比如 DAO、DTO,比如 JSON、XML,这些代码冗长无聊,不是主要逻辑,可以不理。
-
重要的算法。一般来说,我们的代码里会有很多重要的算法,我说的并不一定是什么排序或是搜索算法,可能会是一些其它的核心算法,比如一些索引表的算法,全局唯一 ID 的算法、信息推荐的算法、统计算法、通读算法(如 Gossip)等。这些比较核心的算法可能会非常难读,但它们往往是最有技术含量的部分。
-
底层交互。有一些代码是和底层系统的交互,一般来说是和操作系统或是 JVM 的交互。因此,读这些代码通常需要一定的底层技术知识,不然,很难读懂。
总结一下,阅读代码的方法如下:
-
一般采用自顶向下,从总体到细节的“剥洋葱皮”的读法。
-
画图是必要的,程序流程图,调用时序图,模块组织图……
-
代码逻辑归一下类,排除杂音,主要逻辑才会更清楚。
-
debug 跟踪一下代码是了解代码在执行中发生了什么的最好方式。
-
-
-
-
-
-
日志工具
项目中仅使用抽象日志框架来处理日志,不得直接使用Log4J或LogBack,建议使用Slf4J。具体的日志框架实现,建议使用LogBack。
在Maven项目的依赖中,如果间接依赖了不同的日志框架,需要exclude掉,或者它们仅能出现在test的scope中。
以使用Slf4j和LogBack为例,如果有第三方库依赖了Apache Commons Logging或者Log4J框架,则可进行如下配置:
-
添加Slf4J对其他框架的支持
<dependency> <groupId>org.slf4j</groupId> <artifactId>jcl-over-slf4j</artifactId> <version>1.7.5</version> </dependency> <dependency> <groupId>org.slf4j</groupId> <artifactId>log4j-over-slf4j</artifactId> <version>1.7.5</version> </dependency>
-
排除掉第三方库中的依赖,以Apache Commons Logging为例,在
<dependency>
中添加如下内容:
<exclusions> <exclusion> <groupId>commons-logging</groupId> <artifactId>commons-logging</artifactId> </exclusion> </exclusions>
-
-
Logger
在使用
Logger
时,需要用如下方式在类中定义Logger对象:private static final Logger logger = LoggerFactory.getLogger(xxx.class);
如果有确定的日志名,则可以直接指定日志名:
private static final Logger logger = LoggerFactory.getLogger("日志名");
输出日志时,不得使用字符串拼接的方式,即:
logger.info("foo" + bar);
需要使用以下方式,避免不必要的
toString
和字符串拼接操作:logger.info("foo{}", bar);
Slf4J的方法支持可变参数,可以添加任意数量的{},且无需在输出日志前进行isXXXEnabled判断。
如果使用Apache Commons Logging或者Log4J,需要在输出之前进行判断,例如:
if (logger.isInfoEnabled()) { logger.info("foo"); }
建议对
ERROR
和WARN
级别以外的日志都增加此判断。为了保证异常信息能够输出完整的异常堆栈,必须使用支持Throwable参数的方法输出异常,同时选择
ERROR
级别输出:logger.error("处理XXX业务时发生异常", e);
使用
+
拼接描述和异常对象,这样会丢失异常堆栈,因此严禁使用以下方法:logger.error("处理XXX业务时发生异常" + e);
-
日志文件
-
日志分为以下几类:
- 业务日志
- 错误日志,统一将
ERROR
级别的日志汇总到一个日志中 - 摘要日志,以
-digest
作为主文件名后缀 - 统计日志,以
-stat
作为主文件名后缀
-
日志文件可以有两种扩展名:
- xxx.log
- xxx.Nd.log,
N
表示天数,用来标识日志的保存天数
未标识保存天数的日志文件,运维可以按需要任意清除。
-
日志必须按照一定规则进行滚动,以避免单个日志文件过大,影响性能。
日志可以按天或者按小时滚动,滚动后的日志文件后缀名结尾方式如下:
- 按天滚动,xxx.log.20131213
- 按小时滚动,xxx.log.20131213_12
综上,一个按天滚动、需要保存7天的摘要日志完整文件名是这样的:
xxx-digest.7d.log.20131213
-
-
日志格式
日志需要遵循一定的格式,方便人与机器阅读和分析。日志一般由以下几部分组成:
- 时间,一般精确到毫秒
- 日志级别,即
ERROR
、WARN
、INFO
、DEBUG
、TRACE
- 唯一ID,一般是贯穿所有上下游系统的唯一ID,如果没有的话也可以是本系统内部的标识
- 线程,输出日志的线程名称
- 日志名称,一般是类名(可以做些限制,避免过长),也可以是特定的名称
- 具体内容
-
业务日志
业务日志的格式建议如下:
时间 级别 [唯一ID][线程] 日志名 - 内容
例如:
2013-12-09 18:17:30,110 WARN [1049754451][http-nio-8081-exec-6] c.b.c.b.s.i.XXXServiceImpl - 请求XXX业务处理,传入的参数为空。
-
摘要日志
大多数摘要日志都是按类型输出到不同文件中的,因此文件就代表了含义,不需要日志名。格式建议如下:
时间 级别 [唯一ID][线程] - 内容
不同的类型,内容有所不同,一般摘要可以有以下几类:
- 数据层访问摘要,文件名一般为
xxx-dal-digest.log
- 调用下游服务摘要,文件名一般为
xxx-sal-digest.log
- 对上游提供服务摘要,文件名一般为
xxx-service-digest.log
- Web页面访问摘要,文件名一般为
xxx-page-digest.log
一般的摘要日志内容中要包含以下内容:
- 记录的对象
- 结果,成功或失败
- 耗时
- 参数,用
,
分隔,可选 - 返回值,用
,
分隔,可选
因此,完整的摘要日志格式会是这样的:
时间 级别 [唯一ID][线程] - [(对象,结果,耗时)(参数)(返回值)]
忽略的内容用
-
填充。如果摘要是由系统框架级统一输出的,选择同样的级别输出,则可以忽略“级别”一项。
-
数据层访问摘要
每次数据库操作建议都记录摘要,以便后续分析系统处理请求过程中的瓶颈,借业务判断数据库状态等等。
2013-12-09 15:40:55,092 INFO [1049754451][http-nio-8081-exec-6] - [(UserDao.findByNameAndType, Y, 10ms)(foo,bar)(-)]
-
对上游提供服务摘要
对上游提供服务的摘要中,最好能够记录下是哪个上游系统发起的请求,因此需要增加一项。
以REST风格的服务为例,其实质相当于处理了一次HTTP请求,可以采用如下格式:
时间 级别 [唯一ID][线程][上游系统] - [(HTTP方法,请求URL,对应处理方法,结果,总耗时,业务处理耗时,结果渲染耗时)(参数)(HTTP结果响应码,返回值)]
例如:
2013-12-09 15:40:55,578 INFO [1049754451][http-nio-8081-exec-6][systemA] - [(POST,/user,UserController.find,Y,3ms,2ms,1ms)(foo,bar)(200,0)]
RPC风格的服务,则可以使用如下格式:
时间 级别 [唯一ID][线程][上游系统] - [(处理方法,结果,耗时)(参数)(返回值)]
- 数据层访问摘要,文件名一般为
-
统计日志
统计日志时对某些资源一段时间内使用情况的汇总,因此与具体的请求或线程无关,格式较为简单:
时间 - [(统计项1)(统计项2)]
例如对系统LOAD和内存的统计:
2013-12-13 16:08:05,532 - [(2.15)(367,64400,0,0,2486)]
-
日志内容
本节主要描述业务日志的内容,一般根据业务内容进行定义,但需要遵循以下要求:
- 日志内容要完整,至少要能通过日志了解业务的情况。
- 日志文件需要合理的拆分,不能全部放置于一个文件中。
- 日志文件内容尽量不要重复输出,避免浪费存储空间。
- 日志要有合理的日志级别,生产环境一般只打开
INFO
及以上的日志级别。- 正常的业务日志记录,使用
INFO
级别。 - 可接受的或者可预见的业务错误,使用
WARN
级别。 - 系统异常或者严重的业务错误,使用
ERROR
级别。 - 仅供调试使用的日志,使用
DEBUG
级别。
- 正常的业务日志记录,使用
出于安全角度的考虑,以下内容严禁完整出现在日志中,可以选择截取部分显示:
- 身份证号码
- 手机号码
- 银行卡号码
关于信用卡,以下内容严禁出现在日志中:
- 信用卡有效期
- 信用卡CVV
-