好奇的探索者,理性的思考者,踏实的行动者。
Table of Contents:
有些人喜欢炫耀自己写了多少多少万行的代码,仿佛代码的数量是衡量编程水平的标准。然而,如果你总是匆匆写出代码,却从来不回头去推敲,修改和提炼,其实是不可能提高编程水平的。有位文豪说得好:“看一个作家的水平,不是看他发表了多少文字,而要看他的废纸篓里扔掉了多少。”
就像文学作品一样,代码是不可能一蹴而就的。灵感似乎总是零零星星,陆陆续续到来的。任何人都不可能一笔呵成,就算再厉害的程序员,也需要经过一段时间,才能发现最简单优雅的写法。有时候你反复提炼一段代码,觉得到了顶峰,没法再改进了,可是过了几个月再回头来看,又发现好多可以改进和简化的地方。这跟写文章一模一样,回头看几个月或者几年前写的东西,你总能发现一些改进。
并不是语言提供什么,你就一定要把它用上的。实际上你只需要其中很小的一部分功能,就能写出优秀的代码。
人们都讨厌“面条代码”(spaghetti code),因为它就像面条一样绕来绕去,没法理清头绪。
优雅代码的样子
从代码结构上来看,优雅的代码看起来就像是一些整整齐齐,套在一起的盒子。如果跟整理房间做一个类比,就很容易理解。如果你把所有物品都丢在一个很大的抽屉里,那么它们就会全都混在一起。你就很难整理,很难迅速的找到需要的东西。但是如果你在抽屉里再放几个小盒子,把物品分门别类放进去,那么它们就不会到处乱跑,你就可以比较容易的找到和管理它们。
从代码逻辑大体上来看,是枝丫分明的树状结构(tree)。这是因为程序所做的几乎一切事情,都是信息的传递和分支。避免代码里出现只有一个分支的if语句,它看起来就会像这个样子:
if (...) {
if (...) {
...
} else {
...
}
} else if (...) {
...
} else {
...
}
注意到了吗?在我的代码里面,if语句几乎总是有两个分支。它们有可能嵌套,有多层的缩进,而且else分支里面有可能出现少量重复的代码。然而这样的结构,逻辑却非常严密和清晰。
有些人吵着闹着要让程序“模块化”,结果他们的做法是把代码分部到多个文件和目录里面,然后把这些目录或者文件叫做“module”。这是因为他们其实并不理解什么叫做“模块”,肤浅的把代码切割开来,分放在不同的位置,其实非但不能达到模块化的目的,而且制造了不必要的麻烦。
真正的模块化,并不是文本意义上的,而是逻辑意义上的。实际上一种很好的模块化方法早已经存在,它的名字叫做函数。每一个函数都有明确的输入(参数)和输出(返回值),同一个文件里可以包含多个函数,所以你其实根本不需要把代码分开在多个文件或者目录里面,同样可以完成代码的模块化。我可以把代码全都写在同一个文件里,却仍然是非常模块化的代码。
想要达到很好的模块化,你需要做到以下几点:
* 避免写太长的函数。
* 制造小的工具函数
如果你仔细观察代码,就会发现其实里面有很多的重复。这些常用的代码,不管它有多短,提取出去做成函数,都可能是会有好处的。有些帮助函数也许就只有两行,然而它们却能大大简化主要函数里面的逻辑。
* 每个函数只做一件简单的事情
有些人喜欢制造一些“通用”的函数,既可以做这个又可以做那个,它的内部依据某些变量和条件,来“选择”这个函数所要做的事情。
有些人以为写很多注释就可以让代码更加可读,然而却发现事与愿违。注释不但没能让代码变得可读,反而由于大量的注释充斥在代码中间,让程序变得障眼难读。而且代码的逻辑一旦修改,就会有很多的注释变得过时,需要更新。修改注释是相当大的负担,所以大量的注释,反而成为了妨碍改进代码的绊脚石。
也许可以帮助你大大减少写注释的必要:
使用有意义的函数和变量名字。如果你的函数和变量的名字,能够切实的描述它们的逻辑,那么你就不需要写注释来解释它在干什么。比如:
// put elephant1 into fridge2
put(elephant1, fridge2);
由于我的函数名put,加上两个有意义的变量名elephant1和fridge2,已经说明了这是在干什么(把大象放进冰箱),所以上面那句注释完全没有必要。
局部变量应该尽量接近使用它的地方
有些人喜欢在函数最开头定义很多局部变量,然后在下面很远的地方使用它,会让人产生疑惑,中间有没有被别人改他的值呢
局部变量名字应该简短。
把复杂的表达式提取出去,做成中间变量。有些人听说“函数式编程”是个好东西,也不理解它的真正含义,就在代码里使用大量嵌套的函数。像这样:
Pizza pizza = makePizza(crust(salt(), butter()), topping(onion(), tomato(), sausage()));
这样的代码一行太长,而且嵌套太多,不容易看清楚。其实训练有素的函数式程序员,都知道中间变量的好处,不会盲目的使用嵌套的函数。他们会把这代码变成这样:
Crust crust = crust(salt(), butter()); Topping topping = topping(onion(), tomato(), sausage()); Pizza pizza = makePizza(crust, topping);
这样写,不但有效地控制了单行代码的长度,而且由于引入的中间变量具有“意义”,步骤清晰,变得很容易理解。
比如:
每一个条件都对齐,里面的逻辑就很清楚了
if (someLongCondition1() &&
someLongCondition2() &&
someLongCondition3() &&
someLongCondition4()) {
...
}
再比如:
把格式字符串单独放在一行,而把它的参数放在另外一样,这样逻辑就更加清晰。
log.info("failed to find file {} for command {}, with exception {}",
file, command, exception);
i++,++i,i--,--i
)这种自增减操作表达式其实是历史遗留的设计失误。它们含义蹊跷,非常容易弄错。它们把读和写这两种完全不同的操作,混淆缠绕在一起,把语义搞得乌七八糟。含有它们的表达式,结果可能取决于求值顺序,所以它可能在某种编译器下能正确运行,换一个编译器就出现离奇的错误。
o(++i)
,可以拆成 i += 1; foo(i);
拆开之后的代码,含义完全一致,却清晰很多。到底更新是在取值之前还是之后,一目了然。
很多语言允许你在某种情况下省略掉花括号,比如C,Java都允许你在if语句里面只有一句话的时候省略掉花括号:
if (...)
action1();
咋一看少打了两个字,多好。可是这其实经常引起奇怪的问题。比如,你后来想要加一句话action2()到这个if里面,于是你就把代码改成:
if (...)
action1();
action2();
不要盲目依赖操作符优先级,因为完全弄清楚的人是很少的
比如2 << 7 - 2 * 3
循环语句(for,while)里面出现return是没问题的,然而如果你使用了continue或者break,就会让循环的逻辑和终止条件变得复杂,难以确保正确。
出现continue或者break的原因,往往是对循环的逻辑没有想清楚。如果你考虑周全了,应该是几乎不需要continue或者break的。如果你的循环里出现了continue或者break,你就应该考虑改写这个循环。改写循环的办法有多种:
如果出现了continue,你往往只需要把continue的条件反向,就可以消除continue。
如果出现了break,你往往可以把break的条件,合并到循环头部的终止条件里,从而去掉break。
有时候你可以把break替换成return,从而去掉break。
如果以上都失败了,你也许可以把循环里面复杂的部分提取出来,做成函数调用,之后continue或者break就可以去掉了。
下面我对这些情况举一些例子。
情况1:下面这段代码里面有一个continue:
List<String> goodNames = new ArrayList<>();
for (String name: names) {
if (name.contains("bad")) {
continue;
}
goodNames.add(name);
...
}
使用continue,它不是在告诉你什么时候“做”一件事,而是在告诉你什么时候“不做”一件事。
它们依靠“控制流”来描述“不做什么”,“跳过什么”,结果到最后你也没搞清楚它到底“要做什么”。
可以改成如下方式:
List<String> goodNames = new ArrayList<>();
for (String name: names) {
if (!name.contains("bad")) {
goodNames.add(name);
...
}
}
goodNames.add(name);和它之后的代码全部被放到了if里面,多了一层缩进,然而continue却没有了。你再读这段代码,就会发现更加清晰。因为它是一种更加“正面”地描述。它说:“在name不含有'bad'这个词的时候,把它加到goodNames的链表里面……”
情况2:for和while头部都有一个循环的“终止条件”,那本来应该是这个循环唯一的退出条件。如果你在循环中间有break,它其实给这个循环增加了一个退出条件。你往往只需要把这个条件合并到循环头部,就可以去掉break。
比如下面这段代码:
while (condition1) { ... if (condition2) { break; } }
可以改为:
while (condition1 && !condition2) {
...
}
情况3:很多break退出循环之后,其实接下来就是一个return。这种break往往可以直接换成return。
比如:
```
public boolean hasBadName(List
boolean result = false;
for (String name: names) {
if (name.contains("bad")) {
result = true;
break;
}
}
return result;
}
改为:
public boolean hasBadName(List
for (String name: names) {
if (name.contains("bad")) {
return true; //这样就不用再往下看了
}
}
return false;
}
```
如果有更加直接,更加清晰的写法,就选择它,即使它看起来更长,更笨,也一样选择它。比如,Unix命令行有一种“巧妙”的写法是这样:
command1 && command2 && command3
由于Shell语言的逻辑操作a && b具有“短路”的特性,如果a等于false,那么b就没必要执行了。这就是为什么当command1成功,才会执行command2,当command2成功,才会执行command3。同样,
command1 || command2 || command3
操作符||也有类似的特性。上面这个命令行,如果command1成功,那么command2和command3都不会被执行。如果command1失败,command2成功,那么command3就不会被执行。
这比起用if语句来判断失败,似乎更加巧妙和简洁,所以有人就借鉴了这种方式,在程序的代码里也使用这种方式。比如他们可能会写这样的代码:
if (action1() || action2() && action3()) {
...
}
你看得出来这代码是想干什么吗?action2和action3什么条件下执行,什么条件下不执行?也许稍微想一下,你知道它在干什么:“如果action1失败了,执行action2,如果action2成功了,执行action3”。然而那种语义,并不是直接的“映射”在这代码上面的。比如“失败”这个词,对应了代码里的哪一个字呢?你找不出来,因为它包含在了||的语义里面,你需要知道||的短路特性,以及逻辑或的语义才能知道这里面在说“如果action1失败……”。每一次看到这行代码,你都需要思考一下,这样积累起来的负荷,就会让人很累。
其实,这种写法是滥用了逻辑操作&&
和||
的短路特性。这两个操作符可能不执行右边的表达式,原因是为了机器的执行效率,而不是为了给人提供这种“巧妙”的用法。这两个操作符的本意,只是作为逻辑操作,它们并不是拿来给你代替if语句的。
上面的代码写成笨一点的办法,就会清晰很多:
if (!action1()) {
if (action2()) {
action3();
}
}
每个if语句都有两个分支的理由是:如果if的条件成立,你做某件事情;但是如果if的条件不成立,你应该知道要做什么另外的事情。不管你的if有没有else,你终究是逃不掉,必须得思考这个问题的。其实包含了使代码可靠的一种通用思想:穷举所有的情况,不漏掉任何一个。
一种省略else分支的情况是这样:
String s = ""; if (x < 5) { s = "ok"; }
写这段代码的人,脑子里喜欢使用一种缺省值的做法。s缺省为null,如果x<5,那么把它改变(mutate)成“ok”。这种写法的缺点是,当x<5不成立的时候,你需要往上面看,才能知道s的值是什么。这还是你运气好的时候,因为s就在上面不远。很多人写这种代码的时候,s的初始值离判断语句有一定的距离,中间还有可能插入一些其它的逻辑和赋值操作。这样的代码,把变量改来改去的,看得人眼花,就容易出错。
现在比较一下我的写法:
String s;
if (x < 5) {
s = "ok";
} else {
s = "";
}
当然,由于这个情况比较简单,你还可以把它写成这样:
String s = x < 5 ? "ok" : "";
对于更加复杂的情况,我建议还是写成if语句为好。
正确地对所有的“可能性”进行推理,就是写出无懈可击代码的核心思想
错误处理是一个古老的问题,可是经过了几十年,还是很多人没搞明白。Unix的系统API手册,一般都会告诉你可能出现的返回值和错误信息。比如,Linux的read系统调用手册里面有如下内容:
RETURN VALUE On success, the number of bytes read is returned... On error, -1 is returned, and errno is set appropriately. ERRORS EAGAIN, EBADF, EFAULT, EINTR, EINVAL, ...
很多初学者,都会忘记检查read的返回值是否为-1,觉得每次调用read都得检查返回值真繁琐,不检查貌似也相安无事。这种想法其实是很危险的。如果函数的返回值告诉你,要么返回一个正数,表示读到的数据长度,要么返回-1,那么你就必须要对这个-1作出相应的,有意义的处理。千万不要以为你可以忽视这个特殊的返回值,因为它是一种“可能性”。代码漏掉任何一种可能出现的情况,都可能产生意想不到的灾难性结果。
对于Java来说,这相对方便一些。Java的函数如果出现问题,一般通过异常(exception)来表示。你可以把异常加上函数本来的返回值,看成是一个“union类型”。比如:
String foo() throws MyException {
...
}
这里MyException是一个错误返回。你可以认为这个函数返回一个union类型:{String, MyException}。任何调用foo的代码,必须对MyException作出合理的处理,才有可能确保程序的正确运行。Union类型是一种相当先进的类型,目前只有极少数语言(比如Typed Racket)具有这种类型,我在这里提到它,只是为了方便解释概念。掌握了概念之后,你其实可以在头脑里实现一个union类型系统,这样使用普通的语言也能写出可靠的代码。
由于Java的类型系统强制要求函数在类型里面声明可能出现的异常,而且强制调用者处理可能出现的异常,所以基本上不可能出现由于疏忽而漏掉的情况。但有些Java程序员有一种恶习,使得这种安全机制几乎完全失效。每当编译器报错,说“你没有catch这个foo函数可能出现的异常”时,有些人想都不想,直接把代码改成这样:
try { foo(); } catch (Exception e) {}
或者最多在里面放个log,或者干脆把自己的函数类型上加上throws Exception
,这样编译器就不再抱怨。这些做法貌似很省事,然而都是错误的,你终究会为此付出代价。
如果你把异常catch了,忽略掉,那么你就不知道foo其实失败了。这就像开车时看到路口写着“前方施工,道路关闭”,还继续往前开。这当然迟早会出问题,因为你根本不知道自己在干什么。
catch异常的时候,你不应该使用Exception这么宽泛的类型。你应该正好catch可能发生的那种异常A。使用宽泛的异常类型有很大的问题,因为它会不经意的catch住另外的异常(比如B)。你的代码逻辑是基于判断A是否出现,可你却catch所有的异常(Exception类),所以当其它的异常B出现的时候,你的代码就会出现莫名其妙的问题,因为你以为A出现了,而其实它没有。这种bug,有时候甚至使用debugger都难以发现。
如果你在自己函数的类型加上throws Exception
,相当于把这个锅甩给调用者了。那么你就不可避免的需要在调用它的地方处理这个异常,如果调用它的函数也写着throws Exception
,这毛病就传得更远。我的经验是,尽量在异常出现的当时就作出处理。否则如果你把它返回给你的调用者,它也许根本不知道该怎么办了。
另外,try { ... } catch
里面,应该包含尽量少的代码。比如,如果foo和bar都可能产生异常A,你的代码应该尽可能写成:
try { foo(); } catch (A e) {...} try { bar(); } catch (A e) {...}
而不是
try {
foo();
bar();
} catch (A e) {...}
第一种写法能明确的分辨是哪一个函数出了问题,而第二种写法全都混在一起。明确的分辨是哪一个函数出了问题,有很多的好处。比如,如果你的catch代码里面包含log,它可以提供更加精确的错误信息,这样会大大地加速你的调试过程。
尽量不要产生null指针。尽量不要用null来初始化变量,函数尽量不要返回null。如果你的函数要返回“没有”,“出错了”之类的结果,尽量使用Java的异常机制。虽然写法上有点别扭,然而Java的异常,和函数的返回值合并在一起,基本上可以当成union类型来用。比如,如果你有一个函数find,可以帮你找到一个String,也有可能什么也找不到,你可以这样写:
public String find() throws NotFoundException { if (...) { return ...; } else { throw new NotFoundException(); } }
Java的类型系统会强制你catch这个NotFoundException,所以你不可能像漏掉检查null一样,漏掉这种情况。
Java的try...catch语法相当的繁琐和蹩脚,所以如果你足够小心的话,像find这类函数,也可以返回null来表示“没找到”。这样稍微好看一些,因为你调用的时候不必用try...catch。很多人写的函数,返回null来表示“出错了”,这其实是对null的误用。“出错了”和“没有”,其实完全是两码事。“没有”是一种很常见,正常的情况,比如查哈希表没找到,很正常。“出错了”则表示罕见的情况,本来正常情况下都应该存在有意义的值,偶然出了问题。如果你的函数要表示“出错了”,应该使用异常,而不是null。
不要把null放进“容器数据结构”里面。所谓容器(collection),是指一些对象以某种方式集合在一起,所以null不应该被放进Array,List,Set等结构,不应该出现在Map的key或者value里面。把null放进容器里面,是一些莫名其妙错误的来源。因为对象在容器里的位置一般是动态决定的,所以一旦null从某个入口跑进去了,你就很难再搞明白它去了哪里,你就得被迫在所有从这个容器里取值的位置检查null。你也很难知道到底是谁把它放进去的,代码多了就导致调试极其困难。
解决方案是:如果你真要表示“没有”,那你就干脆不要把它放进去(Array,List,Set没有元素,Map根本没那个entry),或者你可以指定一个特殊的,真正合法的对象,用来表示“没有”。
函数调用者:明确理解null所表示的意义,尽早检查和处理null返回值,减少它的传播。null很讨厌的一个地方,在于它在不同的地方可能表示不同的意义。有时候它表示“没有”,“没找到”。有时候它表示“出错了”,“失败了”。有时候它甚至可以表示“成功了”,…… 这其中有很多误用之处,不过无论如何,你必须理解每一个null的意义,不能给混淆起来。
如果你调用的函数有可能返回null,那么你应该在第一时间对null做出“有意义”的处理。比如,上述的函数find,返回null表示“没找到”,那么调用find的代码就应该在它返回的第一时间,检查返回值是否是null,并且对“没找到”这种情况,作出有意义的处理。
“有意义”是什么意思呢?我的意思是,使用这函数的人,应该明确的知道在拿到null的情况下该怎么做,承担起责任来。他不应该只是“向上级汇报”,把责任踢给自己的调用者。如果你违反了这一点,就有可能采用一种不负责任,危险的写法:
public String foo() {
String found = find();
if (found == null) {
return null;
}
}
当看到find()返回了null,foo自己也返回null。这样null就从一个地方,游走到了另一个地方,而且它表示另外一个意思。如果你不假思索就写出这样的代码,最后的结果就是代码里面随时随地都可能出现null。到后来为了保护自己,你的每个函数都会写成这样(真是恶性传播呀):
public void foo(A a, B b, C c) {
if (a == null) { ... }
if (b == null) { ... }
if (c == null) { ... }
...
}
函数作者:明确声明不接受null参数,当参数是null时立即崩溃。不要试图对null进行“容错”,不要让程序继续往下执行。如果调用者使用了null作为参数,那么调用者(而不是函数作者)应该对程序的崩溃负全责。上面的例子之所以成为问题,就在于人们对于null的“容忍态度”。
上面这种“保护式”的写法,试图“容错”,试图“优雅的处理null”,其结果是让调用者更加肆无忌惮的传递null给你的函数。到后来,你的代码里出现一堆堆nonsense的情况,null可以在任何地方出现,都不知道到底是哪里产生出来的。谁也不知道出现了null是什么意思,该做什么,所有人都把null踢给其他人。最后这null像瘟疫一样蔓延开来,到处都是,成为一场噩梦。
1. 过度工程即将出现的一个重要信号,就是当你过度的思考“将来”,考虑一些还没有发生的事情,还没有出现的需求。比如,“如果我们将来有了上百万行代码,有了几千号人,这样的工具就支持不了了”,“将来我可能需要这个功能,所以我现在就把代码写来放在那里”,“将来很多人要扩充这片代码,所以现在我们就让它变得可重用”……
这就是为什么很多软件项目如此复杂。实际上没做多少事情,却为了所谓的“将来”,加入了很多不必要的复杂性。眼前的问题还没解决呢,就被“将来”给拖垮了。人们都不喜欢目光短浅的人,然而在现实的工程中,有时候你就是得看近一点,把手头的问题先搞定了,再谈以后扩展的问题。
2. 另外一种过度工程的来源,是过度的关心“代码重用”。很多人“可用”的代码还没写出来呢,就在关心“重用”。为了让代码可以重用,最后被自己搞出来的各种框架捆住手脚,最后连可用的代码就没写好。如果可用的代码都写不好,又何谈重用呢?很多一开头就考虑太多重用的工程,到后来被人完全抛弃,没人用了,因为别人发现这些代码太难懂了,自己从头开始写一个,反而省好多事。
世界上有两种“没有bug”的代码。一种是“没有明显的bug的代码”,另一种是“明显没有bug的代码”。
第一种情况,由于代码复杂不堪,加上很多测试,各种coverage,貌似测试都通过了,所以就认为代码是正确的。
第二种情况,由于代码简单直接,就算没写很多测试,你一眼看去就知道它不可能有bug。你喜欢哪一种“没有bug”的代码呢?
防止过度工程的原则如下:
先把眼前的问题解决掉,解决好,再考虑将来的扩展问题。
先写出可用的代码,反复推敲,再考虑是否需要重用的问题。
System.out.println(1);
System.out.println(2);
……
System.out.println(10);
我想只要学过基础语法,都会采用下面的形式。
for (int i = 1; i <= 10; i++) {
System.out.println(i);
}
当我们经常写一些重复性代码时,我们就要注意看能否将其抽取出来成为一个方法,如:
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
改成
private static void threadSleep(int millis) {
try {
Thread.sleep(millis);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
现在我们看一个类
public class Person {
private String name;
private int age;
// Setter & Getter ...
}
我们新建一些Person类实例,并进行一些操作:
Person person = new Person();
person.setName("jack");
person.setAge(18);
Person person2 = new Person();
person2.setName("rose");
person2.setAge(17);
.....
System.out.printf("Name: %s, Age:%d\n", person.getName(), person.getAge());
System.out.printf("Name: %s, Age:%d\n", person2.getName(), person2.getAge());
.....
观察这些代码,其实有很大的DRY改造空间,首先可以添加一个构造方法
public Person(String name, int age) {
this.name = name;
this.age = age;
}
其次,可以添加一个toString()方法
public String toString() {
return String.format("Name: %s, Age: %d", name, age);
}
这样的话,上面的代码就可以改成下面的形式。
Person person = new Person("jack", 18);
Person person2 = new Person("rose", 17);
......
System.out.println(person.toString());
System.out.println(person2.toString());
......
上面的代码我们其实还是有改善空间,就是利用容器类
List<Person> list = new ArrayList<>();
list.add(new Person("jack", 18));
list.add(new Person("rose", 17));
......
list.forEach(p -> System.out.println(p));
这里我们可以看到,基本上我们写代码只写有变化的代码,而尽量不写机械性重复性的代码,其实后面我们就会知道,这就叫专注于业务逻辑,所谓业务逻辑就是你这个项目中,与别的项目都不一样的地方,必须由你亲自去编写实现的部分。
其实容器类很大程度上也是为了帮助我们编写代码而被设计出来的,首先让我们不必为每一个对象起名字(省去了person,person2,...等变量),然后又为批量操作提供了可能性。像是这样一系列有用的类组合起来可以称之为类库。
设计模式是经过长时间编码之后,经过系统性的总结所提出的针对某一类问题的最佳解决方案,又称之为最佳实践。
public static boolean updatePassword(String username, String password, String newpassword) {
Connection conn = null;
PreparedStatement stmt = null;
ResultSet rs = null;
boolean success = false;
try {
conn = beginTransaction();
stmt = conn.prepareStatement("select id, password from user where username = ?");
stmt.setString(1, username);
rs = stmt.executeQuery();
if (rs.next()) {
if (rs.getString("password").equals(password)) {
PreparedStatement stmt2 = null;
try {
stmt2 = conn.prepareStatement("update user set password = ? where id = ?");
stmt2.setString(1, newpassword);
stmt2.setLong(2, rs.getLong("id"));
success = stmt2.executeUpdate() > 0;
} finally {
safeClose(stmt2);
}
}
}
commitTransaction(conn);
return success;
} catch (SQLException e) {
rollbackTransaction(conn);
throw new RuntimeException(e);
} finally {
safeClose(rs);
safeClose(stmt);
safeClose(conn);
}
}
上面是一个简单的数据库事务,虽然只有一个查询和一个更新,但是想要将其继续简化却并不容易,虽然其中有关于业务逻辑的部分只是少量几行代码,但是初始化,异常,提交,回滚操作让我们很难抽取出一个合适的方法来。虽然我们已经抽取出了 beginTransaction
,commitTransaction
,rollbackTransaction
,safeClose
等方法,但是仍嫌繁琐。
我们发现之所以我们难以抽取方法,主要是因为流程,因为里面牵扯到流程控制,而流程控制一般是由我们程序员来控制的,所以也就必然需要我们手动编码来完成。难道真的就不能继续简化了吗?这就是需要设计模式的时候了。
public static boolean updatePassword(String username, String password, String newpassword) {
return connection(conn -> statement(conn, "select id, password from user where username = ?", stmt -> {
stmt.setString(1, username);
return resultSet(stmt, rs -> {
if (rs.next()) {
if (rs.getString("password").equals(password)) {
long id = rs.getLong("id");
return statement(conn, "update user set password = ? where id = ?", stmt2 -> {
stmt2.setString(1, newpassword);
stmt2.setLong(2, id);
return stmt2.executeUpdate() == 1;
});
}
}
return false;
});
}));
}
可以看到,所有的conn,stmt,rs的开启和关闭,事务的提交和回滚都不用自己手动编写代码进行操作了,之所以可以达到这个效果,就是因为使用了模板方法设计模式,核心就是通过回调方法传递想对资源进行的操作,然后将控制权交给另一个方法,让这个方法掌握流程控制,然后适当的时候回调我们的代码(也就是我们自己写的业务逻辑相关的代码)。