- 编写高质量代码之Java(套装共2册)
- 秦小波 成林
- 498字
- 2023-02-10 18:28:29
第1章 Java开发中通用的方法和准则
The reasonable man adapts himself to the world;the unreasonable one persists in trying to adapt the world to himself.
明白事理的人使自己适应世界;不明事理的人想让世界适应自己。
——萧伯纳
Java的世界丰富又多彩,但同时也布满了荆棘陷阱,大家一不小心就可能跌入黑暗深渊,只有在了解了其通行规则后才能使自己在技术的海洋里遨游飞翔,恣意驰骋。
“千里之行始于足下”,本章主要讲述与Java语言基础有关的问题及建议的解决方案,例如常量和变量的注意事项、如何更安全地序列化、断言到底该如何使用等。
建议1:不要在常量和变量中出现易混淆的字母
包名全小写,类名首字母全大写,常量全部大写并用下划线分隔,变量采用驼峰命名法(Camel Case)命名等,这些都是最基本的Java编码规范,是每个Javaer都应熟知的规则,但是在变量的声明中要注意不要引入容易混淆的字母。尝试阅读如下代码,思考一下打印出的i等于多少:
public class Client {
public static void main(String[] args) {
long i = 1l;
System.out.println("i的两倍是:" + (i+i));
}
}
肯定有人会说:这么简单的例子还能出错?运行结果肯定是22!实践是检验真理的唯一标准,将其拷贝到Eclipse中,然后Run一下看看,或许你会很奇怪,结果是2,而不是22,难道是Eclipse的显示有问题,少了个“2”?
因为赋给变量i的数字就是“1”,只是后面加了长整型变量的标示字母“l”而已。别说是我挖坑让你跳,如果有类似程序出现在项目中,当你试图通过阅读代码来理解作者的思想时,此情此景就有可能会出现。所以,为了让您的程序更容易理解,字母“l”(还包括大写字母“O”)尽量不要和数字混用,以免使阅读者的理解与程序意图产生偏差。如果字母和数字必须混合使用,字母“l”务必大写,字母“O”则增加注释。
注意 字母“l”作为长整型标志时务必大写。
建议2:莫让常量蜕变成变量
常量蜕变成变量?你胡扯吧,加了final和static的常量怎么可能会变呢?不可能二次赋值的呀。真的不可能吗?看我们神奇的魔术,代码如下:
public class Client {
public static void main(String[] args) {
System.out.println("常量会变哦:" + Const.RAND_CONST);
}
}
/*接口常量*/
interface Const{
//这还是常量吗?
public static final int RAND_CONST = new Random().nextInt();
}
RAND_CONST是常量吗?它的值会变吗?绝对会变!这种常量的定义方式是极不可取的,常量就是常量,在编译期就必须确定其值,不应该在运行期更改,否则程序的可读性会非常差,甚至连作者自己都不能确定在运行期发生了何种神奇的事情。
甭想着使用常量会变的这个功能来实现序列号算法、随机种子生成,除非这真的是项目中的唯一方案,否则就放弃吧,常量还是当常量使用。
注意 务必让常量的值在运行期保持不变。
建议3:三元操作符的类型务必一致
三元操作符是if-else的简化写法,在项目中使用它的地方很多,也非常好用,但是好用又简单的东西并不表示就可以随便用,我们来看看下面这段代码:
public class Client { public static void main(String[] args) { int i = 80; String s = String.valueOf(i<100?90:100); String s1 = String.valueOf(i<100?90:100.0); System.out.println("两者是否相等:"+s.equals(s1)); } }
分析一下这段程序:i是80,那它当然小于100,两者的返回值肯定都是90,再转成String类型,其值也绝对相等,毋庸置疑的。恩,分析得有点道理,但是变量s中三元操作符的第二个操作数是100,而s1的第二个操作数是100.0,难道没有影响吗?不可能有影响吧,三元操作符的条件都为真了,只返回第一个值嘛,与第二个值有一毛钱的关系吗?貌似有道理。
果真如此吗?我们通过结果来验证一下,运行结果是:“两者是否相等:false”,什么?不相等,Why?
问题就出在了100和100.0这两个数字上,在变量s中,三元操作符中的第一个操作数(90)和第二个操作数(100)都是int类型,类型相同,返回的结果也就是int类型的90,而变量s1的情况就有点不同了,第一个操作数是90(int类型),第二个操作数却是100.0,而这是个浮点数,也就是说两个操作数的类型不一致,可三元操作符必须要返回一个数据,而且类型要确定,不可能条件为真时返回int类型,条件为假时返回float类型,编译器是不允许如此的,所以它就会进行类型转换了,int型转换为浮点数90.0,也就是说三元操作符的返回值是浮点数90.0,那这当然与整型的90不相等了。这里可能有读者疑惑了:为什么是整型转为浮点,而不是浮点转为整型呢?这就涉及三元操作符类型的转换规则:
若两个操作数不可转换,则不做转换,返回值为Object类型。
若两个操作数是明确类型的表达式(比如变量),则按照正常的二进制数字来转换,int类型转换为long类型,long类型转换为float类型等。
若两个操作数中有一个是数字S,另外一个是表达式,且其类型标示为T,那么,若数字S在T的范围内,则转换为T类型;若S超出了T类型的范围,则T转换为S类型(可以参考“建议22”,会对该问题进行展开描述)。
若两个操作数都是直接量数字(Literal,则返回值类型为范围较大者。
知道是什么原因了,相应的解决办法也就有了:保证三元操作符中的两个操作数类型一致,即可减少可能错误的发生。
建议4:避免带有变长参数的方法重载
在项目和系统的开发中,为了提高方法的灵活度和可复用性,我们经常要传递不确定数量的参数到方法中,在Java 5之前常用的设计技巧就是把形参定义成Collection类型或其子类类型,或者是数组类型,这种方法的缺点就是需要对空参数进行判断和筛选,比如实参为null值和长度为0的Collection或数组。而 Java 5引入变长参数(varags)就是为了更好地提高方法的复用性,让方法的调用者可以“随心所欲”地传递实参数量,当然变长参数也是要遵循一定规则的,比如变长参数必须是方法中的最后一个参数;一个方法不能定义多个变长参数等,这些基本规则需要牢记,但是即使记住了这些规则,仍然有可能出现错误,我们来看如下代码:
public class Client { //简单折扣计算 public void calPrice(int price,int discount){ float knockdownPrice =price * discount / 100.0F; System.out.println("简单折扣后的价格是:"+formateCurrency(knockdownPrice)); } //复杂多折扣计算 public void calPrice(int price,int... discounts){ float knockdownPrice = price; for(int discount:discounts){ knockdownPrice = knockdownPrice * discount / 100; } System.out.println("复杂折扣后的价格是:" +formateCurrency(knockdownPrice)); } //格式化成本的货币形式 private String formateCurrency(float price){ return NumberFormat.getCurrencyInstance().format(price/100); } public static void main(String[] args) { Client client = new Client(); //499元的货物,打75折 client.calPrice(49900, 75); } }
这是一个计算商品价格折扣的模拟类,带有两个参数的calPrice方法(该方法的业务逻辑是:提供商品的原价和折扣率,即可获得商品的折扣价)是一个简单的折扣计算方法,该方法在实际项目中经常会用到,这是单一的打折方法。而带有变长参数的calPrice方法则是较复杂的折扣计算方式,多种折扣的叠加运算(模拟类是一种比较简单的实现)在实际生活中也是经常见到的,比如在大甩卖期间对VIP会员再度进行打折;或者当天是你的生日,再给你打个9折,也就是俗话说的“折上折”。
业务逻辑清楚了,我们来仔细看看这两个方法,它们是重载吗?当然是了,重载的定义是“方法名相同,参数类型或数量不同”,很明显这两个方法是重载。但是再仔细瞧瞧,这个重载有点特殊:calPrice(int price,int... discounts)的参数范畴覆盖了calPrice(int price,int discount)的参数范畴。那问题就出来了:对于calPrice(49900,75)这样的计算,到底该调用哪个方法来处理呢?
我们知道Java编译器是很聪明的,它在编译时会根据方法签名(Method Signature)来确定调用哪个方法,比如calPrice(499900,75,95)这个调用,很明显75和95会被转成一个包含两个元素的数组,并传递到calPrice(int price,in.. discounts)中,因为只有这一个方法签名符合该实参类型,这很容易理解。但是我们现在面对的是calPrice(49900,75)调用,这个“75”既可以被编译成int类型的“75”,也可以被编译成int数组“{75}”,即只包含一个元素的数组。那到底该调用哪一个方法呢?
我们先运行一下看看结果,运行结果是:
简单折扣后的价格是:¥374.25。
看来是调用了第一个方法,为什么会调用第一个方法,而不是第二个变长参数方法呢?因为Java在编译时,首先会根据实参的数量和类型(这里是2个实参,都为int类型,注意没有转成int数组)来进行处理,也就是查找到calPrice(int price,int discount)方法,而且确认它是否符合方法签名条件。现在的问题是编译器为什么会首先根据2个int类型的实参而不是1个int类型、1个int数组类型的实参来查找方法呢?这是个好问题,也非常好回答:因为int是一个原生数据类型,而数组本身是一个对象,编译器想要“偷懒”,于是它会从最简单的开始“猜想”,只要符合编译条件的即可通过,于是就出现了此问题。
问题是阐述清楚了,为了让我们的程序能被“人类”看懂,还是慎重考虑变长参数的方法重载吧,否则让人伤脑筋不说,说不定哪天就陷入这类小陷阱里了。
建议5:别让null值和空值威胁到变长方法
上一建议讲解了变长参数的重载问题,本建议还会继续讨论变长参数的重载问题。上一建议的例子是变长参数的范围覆盖了非变长参数的范围,这次我们从两个都是变长参数的方法说起,代码如下:
public class Client { public void methodA(String str,Integer... is){ } public void methodA(String str,String... strs){ } public static void main(String[] args) { Client client = new Client(); client.methodA("China", 0); client.methodA("China", "People"); client.methodA("China"); client.methodA("China",null); } }
两个methodA都进行了重载,现在的问题是:上面的代码编译通不过,问题出在什么地方?看似很简单哦。
有两处编译通不过:client.methodA("China")和client.methodA("China",null),估计你已经猜到了,两处的提示是相同的:方法模糊不清,编译器不知道调用哪一个方法,但这两处代码反映的代码味道可是不同的。
对于methodA("China")方法,根据实参“China”(String类型),两个方法都符合形参格式,编译器不知道该调用哪个方法,于是报错。我们来思考这个问题:Client类是一个复杂的商业逻辑,提供了两个重载方法,从其他模块调用(系统内本地调用或系统外远程调用)时,调用者根据变长参数的规范调用,传入变长参数的实参数量可以是N个(N>=0),那当然可以写成client.methodA("china")方法啊!完全符合规范,但是这却让编译器和调用者都很郁闷,程序符合规则却不能运行,如此问题,谁之责任呢?是Client类的设计者,他违反了KISS原则(Keep It Simple, Stupid,即懒人原则),按照此规则设计的方法应该很容易调用,可是现在在遵循规范的情况下,程序竟然出错了,这对设计者和开发者而言都是应该严禁出现的。
对于client.methodA("china",null)方法,直接量null是没有类型的,虽然两个methodA方法都符合调用请求,但不知道调用哪一个,于是报错了。我们来体会一下它的坏味道:除了不符合上面的懒人原则外,这里还有一个非常不好的编码习惯,即调用者隐藏了实参类型,这是非常危险的,不仅仅调用者需要“猜测”该调用哪个方法,而且被调用者也可能产生内部逻辑混乱的情况。对于本例来说应该做如下修改:
public static void main(String[] args) {
Client client = new Client();
String[] strs = null;
client.methodA("China",strs);
}
也就是说让编译器知道这个null值是String类型的,编译即可顺利通过,也就减少了错误的发生。
建议6:覆写变长方法也循规蹈矩
在Java中,子类覆写父类中的方法很常见,这样做既可以修正Bug也可以提供扩展的业务功能支持,同时还符合开闭原则(Open-Closed Principle),我们来看一下覆写必须满足的条件:
重写方法不能缩小访问权限。
参数列表必须与被重写方法相同。
返回类型必须与被重写方法的相同或是其子类。
重写方法不能抛出新的异常,或者超出父类范围的异常,但是可以抛出更少、更有限的异常,或者不抛出异常。
估计你已经猜测出下面要讲的内容了,为什么“参数列表必须与被重写方法的相同”采用不同的字体,这其中是不是有什么玄机?是的,还真有那么一点点小玄机。参数列表相同包括三层意思:参数数量相同、类型相同、顺序相同,看上去好像没什么问题,那我们来看一个例子,业务场景与上一个建议相同,商品打折,代码如下:
public class Client { public static void main(String[] args) { //向上转型 Base base = new Sub(); base.fun(100, 50); //不转型 Sub sub = new Sub(); sub.fun(100, 50); } } //基类 class Base{ void fun(int price,int... discounts){ System.out.println("Base......fun"); } } //子类,覆写父类方法 class Sub extends Base{ @Override void fun(int price,int[] discounts){ System.out.println("Sub......fun"); } }
请问:该程序有问题吗?—编译通不过。那问题出在什么地方呢?
@Override注解吗?非也,覆写是正确的,因为父类的calPrice编译成字节码后的形参是一个int类型的形参加上一个int数组类型的形参,子类的参数列表也与此相同,那覆写是理所当然的了,所以加上@Override注解没有问题,只是Eclipse会提示这不是一种很好的编码风格。
难道是“sub.fun(100, 50)”这条语句?正解,确实是这条语句报错,提示找不到fun (int,int)方法。这太奇怪了:子类继承了父类的所有属性和方法,甭管是私有的还是公开的访问权限,同样的参数、同样的方法名,通过父类调用没有任何问题,通过子类调用却编译通不过,为啥?难道是没继承下来?或者子类缩小了父类方法的前置条件?那如果是这样,就不应该覆写,@Override就应该报错,真是奇妙的事情!
事实上,base对象是把子类对象Sub做了向上转型,形参列表是由父类决定的,由于是变长参数,在编译时,“base.fun(100, 50)”中的“50”这个实参会被编译器“猜测”而编译成“{50}”数组,再由子类Sub执行。我们再来看看直接调用子类的情况,这时编译器并不会把“50”做类型转换,因为数组本身也是一个对象,编译器还没有聪明到要在两个没有继承关系的类之间做转换,要知道Java是要求严格的类型匹配的,类型不匹配编译器自然就会拒绝执行,并给予错误提示。
这是个特例,覆写的方法参数列表竟然与父类不相同,这违背了覆写的定义,并且会引发莫名其妙的错误。所以读者在对变长参数进行覆写时,如果要使用此类似的方法,请找个小黑屋仔细想想是不是一定要如此。
注意 覆写的方法参数与父类相同,不仅仅是类型、数量,还包括显示形式。
建议7:警惕自增的陷阱
记得大学刚开始学C语言时,老师就说:自增有两种形式,分别是i++和++i,i++表示的是先赋值后加1,++i是先加1后赋值,这样理解了很多年也没出现问题,直到遇到如下代码,我才怀疑我的理解是不是错了:
public class Client {
public static void main(String[] args) {
int count =0;
for(int i=0;i<10;i++){
count=count++;
}
System.out.println("count="+count);
}
}
这个程序输出的count等于几?是count自加10次吗?答案等于10?可以非常肯定地告诉你,答案错误!运行结果是count等于0。为什么呢?
count++是一个表达式,是有返回值的,它的返回值就是count自加前的值,Java对自加是这样处理的:首先把count的值(注意是值,不是引用)拷贝到一个临时变量区,然后对count变量加1,最后返回临时变量区的值。程序第一次循环时的详细处理步骤如下:
步骤1 JVM把count值(其值是0)拷贝到临时变量区。
步骤2 count值加1,这时候count的值是1。
步骤3 返回临时变量区的值,注意这个值是0,没修改过。
步骤4 返回值赋值给count,此时count值被重置成0。
“count=count++”这条语句可以按照如下代码来理解:
public static int mockAdd(int count){
//先保存初始值
int temp =count;
//做自增操作
count = count+1;
//返回原始值
return temp;
}
于是第一次循环后count的值还是0,其他9次的循环也是一样的,最终你会发现count的值始终没有改变,仍然保持着最初的状态。
此例中代码作者的本意是希望count自增,所以想当然地认为赋值给自身就成了,不曾想掉到Java自增的陷阱中了。解决方法很简单,只要把“count=count++”修改为“count++”即可。该问题在不同的语言环境有不同的实现:C++中“count=count++”与“count++”是等效的,而在PHP中则保持着与Java相同的处理方式。每种语言对自增的实现方式各不同,读者有兴趣可以多找几种语言测试一下,思考一下原理。
下次如果看到某人T恤上印着“i=i++”,千万不要鄙视他,记住,能够以不同的语言解释清楚这句话的人绝对不简单,应该表现出“如滔滔江水”般的敬仰,心理默念着“高人,绝世高人哪”。
建议8:不要让旧语法困扰你
N多年前接手了一个除了源码以外什么都没有的项目,没需求、没文档、没设计,原创者也已鸟兽散了,我们只能通过阅读源码来进行维护。期间,同事看到一段很“奇妙”的代码,让大家帮忙分析,代码片段如下:
public class Client {
public static void main(String[] args) {
//数据定义及初始化
int fee=200;
//其他业务处理
saveDefault:save(fee);
//其他业务处理
}
static void saveDefault(){
}
static void save(int fee){
}
}
该代码的业务含义是计算交易的手续费,最低手续费是2元,其业务逻辑大致看懂了,但是此代码非常神奇,“saveDefault:save(fee)”这句代码在此处出现后,后续就再也没有与此有关的代码了,这做何解释呢?更神奇的是,编译竟然还没有错,运行也很正常。Java中竟然有冒号操作符,一般情况下,它除了在唯一一个三元操作符中存在外就没有其他地方可用了呀。当时连项目组里的高手也是一愣一愣的,翻语法书,也没有介绍冒号操作符的内容,而且,也不可能出现连括号都可以省掉的方法调用、方法级联啊!这也太牛了吧!
隔壁做C项目的同事过来串门,看我们在讨论这个问题,很惊奇地说“耶,Java中还有标号呀,我以为Java这么高级的语言已经抛弃goto语句了……”,一语点醒梦中人:项目的原创者是C语言转过来的开发人员,所以他把C语言的goto习惯也带到项目中了,后来由于经过N手交接,重构了多次,到我们这里goto语句已经被重构掉了,但是跳转标号还保留着,估计上一届的重构者也是稀里糊涂的,不敢贸然修改,所以把这个重任留给了我们。
goto语句中有着“double face”作用的关键字,它可以让程序从多层的循环中跳出,不用一层一层地退出,类似高楼着火了,来不及一楼一楼的下,goto语句就可以让你“biu~”的一声从十层楼跳到地面上。这点确实很好,但同时也带来了代码结构混乱的问题,而且程序跳来跳去让人看着就头晕,还怎么调试?!这样做甚至会隐祸连连,比如标号前后对象构造或变量初始化,一旦跳到这个标号,程序就不可想象了,所以Java中抛弃了goto语法,但还是保留了该关键字,只是不进行语义处理而已,与此类似的还有const关键字。
Java中虽然没有了goto关键字,但是扩展了break和continue关键字,它们的后面都可以加上标号做跳转,完全实现了goto功能,同时也把goto的诟病带了进来,所以我们在阅读大牛的开源程序时,根本就看不到break或continue后跟标号的情况,甚至是break和continue都很少看到,这是提高代码可读性的一剂良药,旧语法就让它随风而去吧!
建议9:少用静态导入
从Java 5开始引入了静态导入语法(import static),其目是为了减少字符输入量,提高代码的可阅读性,以便更好地理解程序。我们先来看一个不使用静态导入的例子,也就是一般导入:
public class MathUtils{ //计算圆面积 public static double calCircleArea(double r){ return Math.PI * r * r; } //计算球面积 public static double calBallArea(double r){ return 4* Math.PI * r * r; } }
这是很简单的数学工具类,我们在这两个计算面积的方法中都引入了java.lang.Math类(该类是默认导入的)中的PI(圆周率)常量,而Math这个类写在这里有点多余,特别是如果MathUtils中的方法比较多时,如果每次都要敲入Math这个类,繁琐且多余,静态导入可解决此类问题,使用静态导入后的程序如下:
import static java.lang.Math.PI; public class MathUtils{ //计算圆面积 public static double calCircleArea(double r){ return PI * r * r; } //计算球面积 public static double calBallArea(double r){ return 4 * PI * r * r; } }
静态导入的作用是把Math类中的PI常量引入到本类中,这会使程序更简单,更容易阅读,只要看到PI就知道这是圆周率,不用每次都要把类名写全了。但是,滥用静态导入会使程序更难阅读,更难维护。静态导入后,代码中就不用再写类名了,但是我们知道类是“一类事物的描述”,缺少了类名的修饰,静态属性和静态方法的表象意义可以被无限放大,这会让阅读者很难弄清楚其属性或方法代表何意,甚至是哪一个类的属性(方法)都要思考一番(当然,IDE友好提示功能是另说),特别是在一个类中有多个静态导入语句时,若还使用了*(星号)通配符,把一个类的所有静态元素都导入进来了,那简直就是恶梦。我们来看一段例子:
import static java.lang.Double.*; import static java.lang.Math.*; import static java.lang.Integer.*; import static java.text.NumberFormat.*; public class Client { //输入半径和精度要求,计算面积 public static void main(String[] args) { double s = PI * parseDouble(args[0]); NumberFormat nf = getInstance(); nf.setMaximumFractionDigits(parseInt(args[1])); formatMessage(nf.format(s)); } //格式化消息输出 public static void formatMessage(String s){ System.out.println("圆面积是:"+s); } }
就这么一段程序,看着就让人火大:常量PI,这知道,是圆周率;parseDouble方法可能是Double类的一个转换方法,这看名称也能猜测到。那紧接着的getInstance方法是哪个类的?是Client本地类?不对呀,没有这个方法,哦,原来是NumberFormate类的方法,这和formateMessage本地方法没有任何区别了—这代码也太难阅读了,非机器不可阅读。
所以,对于静态导入,一定要遵循两个规则:
不使用*(星号)通配符,除非是导入静态常量类(只包含常量的类或接口)。
方法名是具有明确、清晰表象意义的工具类。
何为具有明确、清晰表象意义的工具类?我们来看看JUnit 4中使用的静态导入的例子,代码如下:
import static org.junit.Assert.*; public class DaoTest { @Test public void testInsert(){ //断言 assertEquals("foo", "foo"); assertFalse(Boolean.FALSE); } }
我们从程序中很容易判断出assertEquals方法是用来断言两个值是否相等的,assertFalse方法则是断言表达式为假,如此确实减少了代码量,而且代码的可读性也提高了,这也是静态导入用到正确地方所带来的好处。
建议10:不要在本类中覆盖静态导入的变量和方法
如果一个类中的方法及属性与静态导入的方法及属性重名会出现什么问题呢?我们先来看一个正常的静态导入,代码如下:
import static java.lang.Math.PI; import static java.lang.Math.abs; public class Client { public static void main(String[] args) { System.out.println("PI="+PI); System.out.println("abs(100)=" +abs(-100)); } }
很简单的例子,打印出静态常量PI值,计算-100的绝对值。现在的问题是:如果我们在Client类中也定义了PI常量和abs方法,会出现什么问题?代码如下:
import static java.lang.Math.PI;
import static java.lang.Math.abs;
public class Client {
//常量名与静态导入的PI相同
public final static String PI="祖冲之";
//方法名与静态导入的相同
public static int abs(int abs){
return 0;
}
public static void main(String[] args) {
System.out.println("PI="+PI);
System.out.println("abs(100)=" +abs(-100));
}
}
以上代码中,定义了一个PI字符串类型的常量,又定义了一个abs方法,与静态导入的相同。首先说好消息:编译器没有报错,接下来是不好的消息了:我们不知道哪个属性和哪个方法被调用了,因为常量名和方法名相同,到底调用了哪一个方法呢?我们运行一下看看结果:
PI=祖冲之
abs(100)=0
很明显是本地的属性和方法被引用了,为什么不是Math类中的属性和方法呢?那是因为编译器有一个“最短路径”原则:如果能够在本类中查找到的变量、常量、方法,就不会到其他包或父类、接口中查找,以确保本类中的属性、方法优先。
因此,如果要变更一个被静态导入的方法,最好的办法是在原始类中重构,而不是在本类中覆盖。
建议11:养成良好习惯,显式声明UID
我们编写一个实现了Serializable接口(序列化标志接口)的类,Eclipse马上就会给一个黄色警告:需要增加一个Serial Version ID。为什么要增加?它是怎么计算出来的?有什么用?本章就来解释该问题。
类实现Serializable接口的目的是为了可持久化,比如网络传输或本地存储,为系统的分布和异构部署提供先决支持条件。若没有序列化,现在我们熟悉的远程调用、对象数据库都不可能存在,我们来看一个简单的序列化类:
public class Person implements Serializable{
private String name;
/*name属性的getter/setter方法省略*/
}
这是一个简单JavaBean,实现了Serializable接口,可以在网络上传输,也可以本地存储然后读取。这里我们以Java消息服务(Java Message Service)方式传递该对象(即通过网络传递一个对象),定义在消息队列中的数据类型为ObjectMessage,首先定义一个消息的生产者(Producer),代码如下:
public class Producer {
public static void main(String[] args) throws Exception {
Person person = new Person();
person.setName("混世魔王");
//序列化,保存到磁盘上
SerializationUtils.writeObject(person);
}
}
这里引入了一个工具类SerializationUtils,其作用是对一个类进行序列化和反序列化,并存储到硬盘上(模拟网络传输),其代码如下:
public class SerializationUtils { private static String FILE_NAME = "c:/obj.bin"; // 序列化 public static void writeObject(Serializable s) { try { ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream(FILE_NAME)); oos.writeObject(s); oos.close(); } catch (Exception e) { e.printStackTrace(); } } public static Object readObject(){ Object obj=null; // 反序列化 try { ObjectInput input = new ObjectInputStream(new FileInputStream(FILE_NAME)); obj = input.readObject(); input.close(); } catch (Exception e) { e.printStackTrace(); } return obj; } }
通过对象序列化过程,把一个对象从内存块转化为可传输的数据流,然后通过网络发送到消息消费者(Consumer)那里,并进行反序列化,生成实例对象,代码如下:
public class Consumer {
public static void main(String[] args) throws Exception {
// 反序列化
Person p = (Person) SerializationUtils.readObject();
System.out.println("name="+p.getName());
}
}
这是一个反序列化过程,也就是对象数据流转换为一个实例对象的过程,其运行后的输出结果为:混世魔王。这太easy了,是的,这就是序列化和反序列化典型的demo。但此处隐藏着一个问题:如果消息的生产者和消息的消费者所参考的类(Person类)有差异,会出现何种神奇事件?比如:消息生产者中的Person类增加了一个年龄属性,而消费者没有增加该属性。为啥没有增加?!因为这是个分布式部署的应用,你甚至都不知道这个应用部署在何处,特别是通过广播(broadcast)方式发送消息的情况,漏掉一两个订阅者也是很正常的。
在这种序列化和反序列化的类不一致的情形下,反序列化时会报一个InvalidClassException异常,原因是序列化和反序列化所对应的类版本发生了变化,JVM不能把数据流转换为实例对象。接着刨根问底:JVM是根据什么来判断一个类版本的呢?
好问题,通过SerialVersionUID,也叫做流标识符(Stream Unique Identifier),即类的版本定义的,它可以显式声明也可以隐式声明。显式声明格式如下:
private static final long serialVersionUID = XXXXXL;
而隐式声明则是我不声明,你编译器在编译的时候帮我生成。生成的依据是通过包名、类名、继承关系、非私有的方法和属性,以及参数、返回值等诸多因子计算得出的,极度复杂,基本上计算出来的这个值是唯一的。
serialVersionUID如何生成已经说明了,我们再来看看serialVersionUID的作用。JVM在反序列化时,会比较数据流中的serialVersionUID与类的serialVersionUID是否相同,如果相同,则认为类没有发生改变,可以把数据流load为实例对象;如果不相同,对不起,我JVM不干了,抛个异常InvalidClassException给你瞧瞧。这是一个非常好的校验机制,可以保证一个对象即使在网络或磁盘中“滚过”一次,仍能做到“出淤泥而不染”,完美地实现类的一致性。
但是,有时候我们需要一点特例场景,例如:我的类改变不大,JVM是否可以把我以前的对象反序列化过来?就是依靠显式声明serialVersionUID,向JVM撒谎说“我的类版本没有变更”,如此,我们编写的类就实现了向上兼容。我们修改一下上面的Person类,代码如下:
public class Person implements Serializable{
private static final long serialVersionUID = 55799L;
/*其他保持不变*/
}
刚开始生产者和消费者持有的Person类版本一致,都是V1.0,某天生产者的Person类版本变更了,增加了一个“年龄”属性,升级为V2.0,而由于种种原因(比如程序员疏忽、升级时间窗口不同等)消费端的Person还保持为V1.0版本,代码如下:
public class Person implements Serializable{
private static final long serialVersionUID = 5799L;
private int age;
/*age、name的getter/setter方法省略*/
}
此时虽然生产者和消费者对应的类版本不同,但是显式声明的serialVersionUID相同,反序列化也是可以运行的,所带来的业务问题就是消费端不能读取到新增的业务属性(age属性)而已。
通过此例,我们的反序列化实现了版本向上兼容的功能,使用V1.0版本的应用访问了一个V2.0版本的对象,这无疑提高了代码的健壮性。我们在编写序列化类代码时,随手加上serialVersionUID字段,也不会给我们带来太多的工作量,但它却可以在关键时候发挥异乎寻常的作用。
注意 显式声明serialVersionUID可以避免对象不一致,但尽量不要以这种方式向JVM“撒谎”。
建议12:避免用序列化类在构造函数中为不变量赋值
我们知道带有final标识的属性是不变量,也就是说只能赋值一次,不能重复赋值,但是在序列化类中就有点复杂了,比如有这样一个类:
public class Person implements Serializable{
private static final long serialVersionUID = 71282334L;
//不变量
public final String name="混世魔王";
}
这个Person类(此时V1.0版本)被序列化,然后存储在磁盘上,在反序列化时name属性会重新计算其值(这与static变量不同,static变量压根就没有保存到数据流中),比如name属性修改成了“德天使”(版本升级为V2.0),那么反序列化对象的name值就是“德天使”。保持新旧对象的final变量相同,有利于代码业务逻辑统一,这是序列化的基本规则之一,也就是说,如果final属性是一个直接量,在反序列化时就会重新计算。对这基本规则不多说,我们要说的是final变量另外一种赋值方式:通过构造函数赋值。代码如下:
public class Person implements Serializable{ private static final long serialVersionUID = 91282334L; //不变量初始不赋值 public final String name; //构造函数为不变量赋值 public Person(){ name="混世魔王"; } }
这也是我们常用的一种赋值方式,可以把这个Person类定义为版本V1.0,然后进行序列化,看看有什么问题没有,序列化的代码如下所示:
public class Serialize {
public static void main(String[] args) {
//序列化以持久保存
SerializationUtils.writeObject(new Person());
}
}
Person的实例对象保存到了磁盘上,它是一个贫血对象(承载业务属性定义,但不包含其行为定义),我们做一个简单的模拟,修改一下name值代表变更,要注意的是serialVersionUID保持不变,修改后的代码如下:
public class Person implements Serializable{
private static final long serialVersionUID = 91282334L;
//不变量初始不赋值
public final String name;
//构造函数为不变量赋值
public Person(){
name="德天使";
}
}
此时Person类的版本是V2.0,但serialVersionUID没有改变,仍然可以反序列化,其代码如下:
public class Deserialize {
public static void main(String[] args) {
//反序列化
Person p = (Person)SerializationUtils.readObject();
System.out.println(p.name);
}
}
现在问题来了:打印的结果是什么?是混世魔王还是德天使?
答案即将揭晓,答案是:混世魔王。
final类型的变量不是会重新计算吗?答案应该是“德天使”才对啊,为什么会是“混世魔王”?这是因为这里触及了反序列化的另一个规则:反序列化时构造函数不会执行。
反序列化的执行过程是这样的:JVM从数据流中获取一个Object对象,然后根据数据流中的类文件描述信息(在序列化时,保存到磁盘的对象文件中包含了类描述信息,注意是类描述信息,不是类)查看,发现是final变量,需要重新计算,于是引用Person类中的name值,而此时JVM又发现name竟然没有赋值,不能引用,于是它很“聪明”地不再初始化,保持原值状态,所以结果就是“混世魔王”了。
读者不要以为这样的情况很少发生,如果使用Java开发过桌面应用,特别是参与过对性能要求较高的项目(比如交易类项目),那么很容易遇到这样的问题。比如一个C/S结构的在线外汇交易系统,要求提供24小时的联机服务,如果在升级的类中有一个final变量是构造函数赋值的,而且新旧版本还发生了变化,则在应用请求热切的过程中(非常短暂,可能只有30秒),很可能就会出现反序列化生成的final变量值与新产生的实例值不相同的情况,于是业务异常就产生了,情况严重的话甚至会影响交易数据,那可是天大的事故了。
注意 在序列化类中,不使用构造函数为final变量赋值。
建议13:避免为final变量复杂赋值
为final变量赋值还有一种方式:通过方法赋值,即直接在声明时通过方法返回值赋值。还是以Person类为例来说明,代码如下:
public class Person implements Serializable{
private static final long serialVersionUID = 91282334L;
//通过方法返回值为final变量赋值
public final String name=initName();
//初始化方法名
public String initName(){
return "混世魔王";
}
}
name属性是通过initName方法的返回值赋值的,这在复杂类中经常用到,这比使用构造函数赋值更简洁、易修改,那么如此用法在序列化时会不会有问题呢?我们一起来看看。Person类写好了(定义为V1.0版本),先把它序列化,存储到本地文件,其代码与上一建议的Serialize类相同,不再赘述。
现在,Person类的代码需要修改,initName的返回值也改变了,代码如下:
public class Person implements Serializable{
private static final long serialVersionUID = 91282334L;
//通过方法返回值为final变量赋值
public final String name=initName();
//初始化方法名
public String initName(){
return "德天使";
}
}
上段代码仅仅修改了initName的返回值(Person类为V2.0版本),也就是说通过new生成的Person对象的 final变量值都是“德天使”。那么我们把之前存储在磁盘上的实例加载上来,name值会是什么呢?
结果是:混世魔王。很诧异,上一建议说过final变量会被重新赋值,但是这个例子又没有重新赋值,为什么?
上个建议所说final会被重新赋值,其中的“值”指的是简单对象。简单对象包括:8个基本类型,以及数组、字符串(字符串情况很复杂,不通过new关键字生成String对象的情况下,final变量的赋值与基本类型相同),但是不能方法赋值。
其中的原理是这样的,保存到磁盘上(或网络传输)的对象文件包括两部分:
(1)类描述信息
包括包路径、继承关系、访问权限、变量描述、变量访问权限、方法签名、返回值,以及变量的关联类信息。要注意的一点是,它并不是class文件的翻版,它不记录方法、构造函数、static变量等的具体实现。之所以类描述会被保存,很简单,是因为能去也能回嘛,这保证反序列化的健壮运行。
(2)非瞬态(transient关键字)和非静态(static关键字)的实例变量值
注意,这里的值如果是一个基本类型,好说,就是一个简单值保存下来;如果是复杂对象,也简单,连该对象和关联类信息一起保存,并且持续递归下去(关联类也必须实现Serializable接口,否则会出现序列化异常),也就是说递归到最后,其实还是基本数据类型的保存。
正是因为这两点原因,一个持久化后的对象文件会比一个class类文件大很多,有兴趣的读者可以自己写个Hello word程序检验一下,其体积确实膨胀了不少。
总结一下,反序列化时final变量在以下情况下不会被重新赋值:
通过构造函数为final变量赋值。
通过方法返回值为final变量赋值。
final修饰的属性不是基本类型。
建议14:使用序列化类的私有方法巧妙解决部分属性持久化问题
部分属性持久化问题看似很简单,只要把不需要持久化的属性加上瞬态关键字(transient关键字)即可。这是一种解决方案,但有时候行不通。例如一个计税系统和人力资源系统(HR系统)通过RMI(Remote Method Invocation,远程方法调用)对接,计税系统需要从HR系统获得人员的姓名和基本工资,以作为纳税的依据,而HR系统的工资分为两部分:基本工资和绩效工资,基本工资没什么秘密,根据工作岗位和年限自己都可以计算出来,但绩效工资却是保密的,不能泄露到外系统,很明显这是两个相互关联的类。先来看薪水类Salary类的代码:
public class Salary implements Serializable{ private static final long serialVersionUID = 44663L; //基本工资 private int basePay; //绩效工资 private int bonus; public Salary(int _basePay,int _bonus){ basePay = _basePay; bonus = _bonus; } /*getter/setter方法省略*/ }
Peron类与Salary类是关联关系,代码如下:
public class Person implements Serializable{ private static final long serialVersionUID =60407L; //姓名 private String name; //薪水 private Salary salary; public Person(String _name,Salary _salary){ name=_name; salary=_salary; } /*getter/setter方法省略*/ }
这是两个简单的JavaBean,都实现了Serializable接口,都具备了持久化条件。首先计税系统请求HR系统对某一个Person对象进行序列化,把人员和工资信息传递到计税系统中,代码如下:
public class Serialize {
public static void main(String[] args) {
//基本工资1000元,绩效工资2500元
Salary salary = new Salary(1000,2500);
//记录人员信息
Person person = new Person("张三",salary);
//HR系统持久化,并传递到计税系统
SerializationUtils.writeObject(person);
}
}
在通过网络传送到计税系统后,进行反序列化,代码如下:
public class Deserialize {
public static void main(String[] args) {
//技术系统反序列化,并打印信息
Person p = (Person)SerializationUtils.readObject();
StringBuffer sb = new StringBuffer();
sb.append("姓名:" + p.getName());
sb.append("\t基本工资:" + p.getSalary().getBasePay());
sb.append("\t绩效工资:" + p.getSalary().getBonus());
System.out.println(sb);
}
}
打印出的结果很简单:
姓名:张三 基本工资:1000 绩效工资:2500。
但是这不符合需求,因为计税系统只能从HR系统中获得人员姓名和基本工资,而绩效工资是不能获得的,这是个保密数据,不允许发生泄露。怎么解决这个问题呢?你可能马上会想到四种方案:
(1)在bonus前加上transient关键字
这是一个方法,但不是一个好方法,加上transient关键字就标志着Salary类失去了分布式部署的功能,它可是HR系统最核心的类了,一旦遭遇性能瓶颈,想再实现分布式部署就不可能了,此方案否定。
(2)新增业务对象
增加一个Person4Tax类,完全为计税系统服务,就是说它只有两个属性:姓名和基本工资。符合开闭原则,而且对原系统也没有侵入性,只是增加了工作量而已。这是个方法,但不是最优方法。
(3)请求端过滤
在计税系统获得Person对象后,过滤掉Salary的bonus属性,方案可行但不合规矩,因为HR系统中的Salary类安全性竟然让外系统(计税系统)来承担,设计严重失职。
(4)变更传输契约
例如改用XML传输,或者重建一个Web Service服务。可以做,但成本太高。
可能有读者会说了,你都在说别人的方案不好,你提供个优秀的方案看看!好的,这就展示一个优秀的方案。其中,实现了Serializable接口的类可以实现两个私有方法:writeObject和readObject,以影响和控制序列化和反序列化的过程。我们把Person类稍做修改,看看如何控制序列化和反序列化,代码如下:
public class Person implements Serializable{ private static final long serialVersionUID =60407L; //姓名 private String name; //薪水 private transient Salary salary; public Person(String _name,Salary _salary){ name=_name; salary=_salary; } //序列化委托方法 private void writeObject(java.io.ObjectOutputStream out) throws IOException { out.defaultWriteObject(); out.writeInt(salary.getBasePay()); } //反序列化时委托方法 private void readObject(java.io.ObjectInputStream in) throws IOException,Class-NotFoundException { in.defaultReadObject(); salary = new Salary(in.readInt(),0); } }
其他代码不做任何改动,我们先运行看看,结果为:
姓名:张三 基本工资:1000 绩效工资:0。
我们在Person类中增加了writeObject和readObject两个方法,并且访问权限都是私有级别,为什么这会改变程序的运行结果呢?其实这里使用了序列化独有的机制:序列化回调。Java调用ObjectOutputStream类把一个对象转换成流数据时,会通过反射(Reflection)检查被序列化的类是否有writeObject方法,并且检查其是否符合私有、无返回值的特性。若有,则会委托该方法进行对象序列化,若没有,则由ObjectOutputStream按照默认规则继续序列化。同样,在从流数据恢复成实例对象时,也会检查是否有一个私有的readObject方法,如果有,则会通过该方法读取属性值。此处有几个关键点要说明:
(1)out.defaultWriteObject()
告知JVM按照默认的规则写入对象,惯例的写法是写在第一句话里。
(2)in.defaultReadObject()
告知JVM按照默认规则读入对象,惯例的写法也是写在第一句话里。
(3)out.writeXX和in.readXX
分别是写入和读出相应的值,类似一个队列,先进先出,如果此处有复杂的数据逻辑,建议按封装Collection对象处理。
可能有读者会提出,这似乎不是一种优雅的处理方案呀,为什么JDK没有对此提供一个更好的解决办法呢?比如访问者模式,或者设置钩子函数(Hook),完全可以更优雅地解决此类问题。我查阅了大量的文档,得出的结论是:无解,只能说这是一个可行的解决方案而已。
再回到我们的业务领域,通过上述方法重构后,其代码的修改量减少了许多,也优雅了许多。可能你又要反问了:如此一来,Person类也失去了分布式部署的能力啊。确实是,但是HR系统的难点和重点是薪水计算,特别是绩效工资,它所依赖的参数很复杂(仅从数量上说就有上百甚至上千种),计算公式也不简单(一般是引入脚本语言,个性化公式定制),而相对来说Person类基本上都是“静态"属性,计算的可能性不大,所以即使为性能考虑,Person类为分布式部署的意义也不大。
建议15:break万万不可忘
我们经常会写一些转换类,比如货币转换、日期转换、编码转换等,在金融领域里用到最多的要数中文数字转换了,比如把“1”转换为“壹",不过,开源世界是不会提供此工具类的,因为它太贴合中国文化了,要转换还是得自己动手写,代码片段如下:
public class Client { public static void main(String[] args) { System.out.println("2 = "+toChineseNumberCase(2)); } //把阿拉伯数字翻译成中文大写数字 public static String toChineseNumberCase(int n) { String chineseNumber = ""; switch (n) { case 0:chineseNumber = "零"; case 1:chineseNumber = "壹"; case 2:chineseNumber = "贰"; case 3:chineseNumber = "叁"; case 4:chineseNumber = "肆"; case 5:chineseNumber = "伍"; case 6:chineseNumber = "陆"; case 7:chineseNumber = "柒"; case 8:chineseNumber = "捌"; case 9:chineseNumber = "玖"; } return chineseNumber; } }
这是一个简单的转换类,并没有完整实现,只是一个金融项目片段。如此简单的代码应该不会有错吧,我们运行看看,结果是:2=玖。
恩?错了?回头再来看程序,马上醒悟了:每个case语句后面少加了break关键字。程序从“case 2”后面的语句开始执行,直到找到最近的break语句结束,但可惜的是我们的程序中没有break语句,于是在程序执行的过程中,chineseNumber的赋值语句会多次执行,会从等于“贰”、等于“叁”、等于“肆”,一直变换到等于“玖”,switch语句执行结束了,于是结果也就如此了。
此类问题发生得非常频繁,但也很容易发现,只要做一下单元测试(Unit Test),问题立刻就会被发现并解决掉,但如果是在一堆的case语句中,其中某一条漏掉了break关键字,特别是在单元测试覆盖率不够高的时候(为什么不够高?在大点的项目中蹲过坑、打过仗的兄弟们可能都知道,项目质量是与项目工期息息相关的,而项目工期往往不是由项目人员决定的,所以如果一个项目的单元测试覆盖率能够达到60%,你就可以笑了),也就是说分支条件可能覆盖不到的时候,那就会在生产中出现大事故了。
我曾遇到过一个类似的事故,那是开发一个通过会员等级决定相关费率的系统,由于会员等级有100多个,所以测试时就采用了抽样测试的方法,测试时一切顺利,直到系统上线后,财务报表系统发现一个小概率的会员费率竟然出奇的低,于是就跟踪分析,发现是少了一个break,此事不仅造成甲方经济上的损失,而且在外部也产生了不良的影响,最后该代码的作者被辞退了,测试人员、质量负责人、项目经理都做了相应的处罚。希望读者能引以为戒,记住在case语句后面随手写上break,养成良好的习惯。
对于此类问题,还有一个最简单的解决办法:修改IDE的警告级别,例如在Eclipse中,可以依次点击Performaces→Java→Compiler→Errors/Warnings→Potential Programming problems,然后修改‘switch’case fall-through为Errors级别,如果你胆敢不在case语句中加入break,那Eclipse直接就报个红叉给你看,这样就可以完全避免该问题的发生了。
建议16:易变业务使用脚本语言编写
Java世界一直在遭受着异种语言的入侵,比如PHP、Ruby、Groovy、JavaScript等,这些“入侵者”都有一个共同特征:全是同一类语言—脚本语言,它们都是在运行期解释执行的。为什么Java这种强编译型语言会需要这些脚本语言呢?那是因为脚本语言的三大特征,如下所示:
灵活。脚本语言一般都是动态类型,可以不用声明变量类型而直接使用,也可以在运行期改变类型。
便捷。脚本语言是一种解释型语言,不需要编译成二进制代码,也不需要像Java一样生成字节码。它的执行是依靠解释器解释的,因此在运行期变更代码非常容易,而且不用停止应用。
简单。只能说部分脚本语言简单,比如Groovy,Java程序员若转到Groovy程序语言上,只需要两个小时,看完语法说明,看完Demo即可使用了,没有太多的技术门槛。
脚本语言的这些特性是Java所缺少的,引入脚本语言可以使Java更强大,于是Java 6开始正式支持脚本语言。但是因为脚本语言比较多,Java的开发者也很难确定该支持哪种语言,于是JCP(Java Community Process)很聪明地提出了JSR223规范,只要符合该规范的语言都可以在Java平台上运行(它对JavaScript是默认支持的),诸位读者有兴趣的话可以自己写个脚本语言,然后再实现ScriptEngine,即可在Java平台上运行。
我们来分析一个案例,展现一下脚本语言是如何实现“拥抱变化”的。咱们编写一套模型计算公式,预测下一个工作日的股票走势(如果真有,那巴菲特就羞愧死了),即把国家政策、汇率、利率、地域系数等参数输入到公式中,然后计算出明天这支股票是涨还是跌,该公式是依靠历史数据推断而来的,会根据市场环境逐渐优化调整,也就是逐渐趋向“真理”的过程,在此过程中,公式经常需要修改(这里的修改不仅仅是参数修改,还涉及公式的算法修改),如果把这个公式写到一个类中(或者几个类中),就需要经常发布重启等操作(比如业务中断,需要冒烟测试(Smoke Testing)等),使用脚本语言则可以很好地简化这一过程,我们写一个简单公式来模拟一下,代码如下:
function formula(var1,var2){
return var1 + var2 * factor;
}
这就是一个简单的脚本语言函数,可能你会很疑惑:factor(因子)这个变量是从哪儿来的?它是从上下文来的,类似于一个运行的环境变量。该JavaScript保存在C:/model.js中。下一步Java需要调用JavaScript公式,代码如下:
public static void main(String[] args) throws Exception { //获得一个JavaScript的执行引擎 ScriptEngine engine=new ScriptEngineManager().getEngineByName("javascript"); //建立上下文变量 Bindings bind=engine.createBindings(); bind.put("factor", 1); //绑定上下文,作用域是当前引擎范围 engine.setBindings(bind,ScriptContext.ENGINE_SCOPE); Scanner input = new Scanner(System.in); while(input.hasNextInt()){ int first = input.nextInt(); int sec = input.nextInt(); System.out.println("输入参数是:"+first+","+sec); //执行js代码 engine.eval(new FileReader("c:/model.js")); //是否可调用方法 if(engine instanceof Invocable){ Invocable in=(Invocable)engine; //执行js中的函数 Double result = (Double)in.invokeFunction("formula",first,sec); System.out.println("运算结果:"+result.intValue()); } } }
上段代码使用Scanner类接受键盘输入的两个数字,然后调用JavaScript脚本的formula函数计算其结果,注意,除非输入了一个非int数字,否则当前JVM会一直运行,这也是模拟生产系统的在线变更状况。运行结果如下:
输入参数是:1,2 运算结果:3
此时,保持JVM的运行状态,我们修改一下formula函数,代码如下:
function formula(var1,var2){
return var1 + var2 - factor;
}
其中,乘号变成了减号,计算公式发生了重大改变。回到JVM中继续输入,运行结果如下。
输入参数是:1,2 运算结果:2
修改Java代码,JVM没有重启,输入参数也没有任何改变,仅仅改变脚本函数即可产生不同的结果。这就是脚本语言对系统设计最有利的地方:可以随时发布而不用重新部署;这也是我们Javaer最喜爱它的地方—即使进行变更,也能提供不间断的业务服务。
Java 6不仅仅提供了代码级的脚本内置,还提供了一个jrunscript命令工具,它可以在批处理中发挥最大效能,而且不需要通过JVM解释脚本语言,可以直接通过该工具运行脚本。想想看,这是多么大的诱惑力呀!而且这个工具是可以跨操作系统的,脚本移植就更容易了。但是有一点需要注意:该工具是实验性的,在以后的JDK中会不会继续提供就很难说了。
建议17:慎用动态编译
动态编译一直是Java的梦想,从Java 6版本它开始支持动态编译了,可以在运行期直接编译.java文件,执行.class,并且能够获得相关的输入输出,甚至还能监听相关的事件。不过,我们最期望的还是给定一段代码,直接编译,然后运行,也就是空中编译执行(on-the-fly),来看如下代码:
public class Client { public static void main(String[] args) throws Exception { //Java源代码 String sourceStr = "public class Hello{public String sayHello (String name) {return \"Hello,\" + name + \"!\";}}"; //类名及文件名 String clsName = "Hello"; //方法名 String methodName = "sayHello"; //当前编译器 JavaCompiler cmp = ToolProvider.getSystemJavaCompiler(); //Java标准文件管理器 StandardJavaFileManager fm = cmp.getStandardFileManager(null,null,null); //Java文件对象 JavaFileObject jfo = new StringJavaObject(clsName,sourceStr); //编译参数,类似于javac <options>中的options List<String> optionsList = new ArrayList<String>(); //编译文件的存放地方,注意:此处是为Eclipse工具特设的 optionsList.addAll(Arrays.asList("-d","./bin")); //要编译的单元 List<JavaFileObject> jfos = Arrays.asList(jfo); //设置编译环境 JavaCompiler.CompilationTask task = cmp.getTask(null, fm, null, optionsList,null,jfos); //编译成功 if(task.call()){ //生成对象 Object obj = Class.forName(clsName).newInstance(); Class<? extends Object> cls = obj.getClass(); //调用sayHello方法 Method m = cls.getMethod(methodName, String.class); String str = (String) m.invoke(obj, "Dynamic Compilation"); System.out.println(str); } } } //文本中的Java对象 class StringJavaObject extends SimpleJavaFileObject{ //源代码 private String content = ""; //遵循Java规范的类名及文件 public StringJavaObject(String _javaFileName,String _content){ super(_createStringJavaObjectUri(_javaFileName),Kind.SOURCE); content = _content; } //产生一个URL资源路径 private static URI _createStringJavaObjectUri(String name){ //注意此处没有设置包名 return URI.create("String:///" + name + Kind.SOURCE.extension); } //文本文件代码 @Override public CharSequence getCharContent(boolean ignoreEncodingErrors) throws IOException { return content; } }
上面的代码较多,这是一个动态编译的模板程序,读者可以拷贝到项目中使用,代码中的中文注释也较多,相信读者看得懂,不多解释,读者只要明白一件事:只要是在本地静态编译能够实现的任务,比如编译参数、输入输出、错误监控等,动态编译就都能实现。
Java的动态编译对源提供了多个渠道。比如,可以是字符串(例子中就是字符串),可以是文本文件,也可以是编译过的字节码文件(.class文件),甚至可以是存放在数据库中的明文代码或是字节码。汇总成一句话,只要是符合Java规范的就都可以在运行期动态加载,其实现方式就是实现JavaFileObject接口,重写getCharContent、openInputStream、openOutputStream,或者实现JDK已经提供的两个SimpleJavaFileObject、ForwardingJavaFileObject,具体代码可以参考上个例子。
动态编译虽然是很好的工具,让我们可以更加自如地控制编译过程,但是在我目前所接触的项目中还是使用得较少。原因很简单,静态编译已经能够帮我们处理大部分的工作,甚至是全部的工作,即使真的需要动态编译,也有很好的替代方案,比如JRuby、Groovy等无缝的脚本语言。
另外,我们在使用动态编译时,需要注意以下几点:
(1)在框架中谨慎使用
比如要在Struts中使用动态编译,动态实现一个类,它若继承自ActionSupport就希望它成为一个Action。能做到,但是debug很困难;再比如在Spring中,写一个动态类,要让它动态注入到Spring容器中,这是需要花费老大功夫的。
(2)不要在要求高性能的项目使用
动态编译毕竟需要一个编译过程,与静态编译相比多了一个执行环节,因此在高性能项目中不要使用动态编译。不过,如果是在工具类项目中它则可以很好地发挥其优越性,比如在Eclipse工具中写一个插件,就可以很好地使用动态编译,不用重启即可实现运行、调试功能,非常方便。
(3)动态编译要考虑安全问题
如果你在Web界面上提供了一个功能,允许上传一个Java文件然后运行,那就等于说:“我的机器没有密码,大家都来看我的隐私吧”,这是非常典型的注入漏洞,只要上传一个恶意Java程序就可以让你所有的安全工作毁于一旦。
(4)记录动态编译过程
建议记录源文件、目标文件、编译过程、执行过程等日志,不仅仅是为了诊断,还是为了安全和审计,对Java项目来说,空中编译和运行是很不让人放心的,留下这些依据可以更好地优化程序。
建议18:避免instanceof非预期结果
instanceof是一个简单的二元操作符,它是用来判断一个对象是否是一个类实例的,其操作类似于>=、==,非常简单,我们来看段程序,代码如下:
public class Client { public static void main(String[] args) { //String对象是否是Object的实例 boolean b1 = "Sting" instanceof Object; //String对象是否是String的实例 boolean b2 = new String() instanceof String; //Object对象是否是String的实例 boolean b3 = new Object() instanceof String; //拆箱类型是否是装箱类型的实例 boolean b4 = 'A' instanceof Character; //空对象是否是String的实例 boolean b5 = null instanceof String; //类型转换后的空对象是否是String的实例 boolean b6 = (String)null instanceof String; //Date对象是否是String的实例 boolean b7 = new Date() instanceof String; //在泛型类中判断String对象是否是Date的实例 boolean b8 = new GenericClass<String>().isDateInstance(""); } } class GenericClass<T>{ //判断是否是Date类型 public boolean isDateInstance(T t){ return t instanceof Date; } }
就这么一段程序,instanceof的所有应用场景都出现了,同时问题也产生了:这段程序中哪些语句会编译通不过?我们一个一个地来解说。
"Sting"instanceof Object
返回值是true,这很正常,“String"是一个字符串,字符串又继承了Object,那当然是返回true了。
new String() instanceof String
返回值是true,没有任何问题,一个类的对象当然是它的实例了。
new Object() instanceof String
返回值是false,Object是父类,其对象当然不是String类的实例了。要注意的是,这句话其实完全可以编译通过,只要instanceof关键字的左右两个操作数有继承或实现关系,就可以编译通过。
'A' instanceof Character
这句话可能有读者会猜错,事实上它编译不通过,为什么呢?因为'A'是一个char类型,也就是一个基本类型,不是一个对象,instanceof只能用于对象的判断,不能用于基本类型的判断。
null instanceof String
返回值是false,这是instanceof特有的规则:若左操作数是null,结果就直接返回false,不再运算右操作数是什么类。这对我们的程序非常有利,在使用instanceof操作符时,不用关心被判断的类(也就是左操作数)是否为null,这与我们经常用到的equals、toString方法不同。
(String)null instanceof String
返回值是false,不要看这里有个强制类型转换就认为结果是true,不是的,null是一个万用类型,也可以说它没类型,即使做类型转换还是个null。
new Date() instanceof String
编译通不过,因为Date类和String没有继承或实现关系,所以在编译时直接就报错了,instanceof操作符的左右操作数必须有继承或实现关系,否则编译会失败。
new GenericClass<String>().isDateInstance("")
编译通不过?非也,编译通过了,返回值是false,T是个String类型,与Date之间没有继承或实现关系,为什么''t instanceof Date''会编译通过呢?那是因为Java的泛型是为编码服务的,在编译成字节码时,T已经是Object类型了,传递的实参是String类型,也就是说T的表面类型是Object,实际类型是String,那''t instanceof Date''这句话就等价于''Object instance of Date''了,所以返回false就很正常了。
就这么一个简单的instanceof,你答对几个?
建议19:断言绝对不是鸡肋
在防御式编程中经常会用断言(Assertion)对参数和环境做出判断,避免程序因不当的输入或错误的环境而产生逻辑异常,断言在很多语言中都存在,C、C++、Python都有不同的断言表示形式。在Java中的断言使用的是assert关键字,其基本的用法如下:
assert <布尔表达式> assert <布尔表达式> : <错误信息>
在布尔表达式为假时,抛出AssertionError错误,并附带了错误信息。assert的语法较简单,有以下两个特性:
(1)assert默认是不启用的
我们知道断言是为调试程序服务的,目的是为了能够快速、方便地检查到程序异常,但Java在默认条件下是不启用的,要启用就需要在编译、运行时加上相关的关键字,这就不多说,有需要的话可以参考一下Java规范。
(2)assert抛出的异常AssertionError是继承自Error的
断言失败后,JVM会抛出一个AssertionError错误,它继承自Error,注意,这是一个错误,是不可恢复的,也就表示这是一个严重问题,开发者必须予以关注并解决之。
assert虽然是做断言的,但不能将其等价于if…else…这样的条件判断,它在以下两种情况不可使用:
(1)在对外公开的方法中
我们知道防御式编程最核心的一点就是:所有的外部因素(输入参数、环境变量、上下文)都是“邪恶”的,都存在着企图摧毁程序的罪恶本源,为了抵制它,我们要在程序中处处检验,满地设卡,不满足条件就不再执行后续程序,以保护主程序的正确性,处处设卡没问题,但就是不能用断言做输入校验,特别是公开方法。我们来看一个例子:
public class Client {
public static void main(String[] args) {
StringUtils.encode(null);
}
}
//字符串处理工具类
class StringUtils{
public static String encode(String str){
assert str!=null:"加密的字符串为null";
/*加密处理*/
}
}
encode方法对输入参数做了不为空的假设,如果为空,则抛出AssertionError错误,但这段程序存在一个严重的问题,encode是一个public方法,这标志着是它对外公开的,任何一个类只要能够传递一个String类型的参数(遵守契约)就可以调用,但是Client类按照规范和契约调用enocde方法,却获得了一个AssertionError错误信息,是谁破坏了契约协定?—是encode方法自己。
(2)在执行逻辑代码的情况下
assert的支持是可选的,在开发时可以让它运行,但在生产系统中则不需要其运行了(以便提高性能),因此在assert的布尔表达式中不能执行逻辑代码,否则会因为环境不同而产生不同的逻辑,例如:
public void doSomething(List list,Object element){
assert list.remove(element):"删除元素 " + element + " 失败";
/*业务处理*/
}
这段代码在assert启用的环境下,没有任何问题,但是一旦投入到生产环境,就不会启用断言了,而这个方法也就彻底完蛋了,list的删除动作永远都不会执行,所以也就永远不会报错或异常,因为根本就没有执行嘛!
以上两种情况下不能使用assert,那在什么情况下能够使用assert呢?一句话:按照正常执行逻辑不可能到达的代码区域可以放置assert。具体分为三种情况:
(1)在私有方法中放置assert作为输入参数的校验
在私有方法中可以放置assert校验输入参数,因为私有方法的使用者是作者自己,私有方法的调用者和被调用者之间是一种弱契约关系,或者说没有契约关系,其间的约束是依靠作者自己控制的,因此加上assert可以更好地预防自己犯错,或者无意的程序犯错。
(2)流程控制中不可能达到的区域
这类似于JUnit的fail方法,其标志性的意义就是:程序执行到这里就是错误的,例如:
public void doSomething(){
int i = 7;
while(i >7){
/*业务处理*/
}
assert false:"到达这里就表示错误";
}
(3)建立程序探针
我们可能会在一段程序中定义两个变量,分别代表两个不同的业务含义,但是两者有固定的关系,例如var1=var2*2,那我们就可以在程序中到处设“桩”,断言这两者的关系,如果不满足即表明程序已经出现了异常,业务也就没有必要运行下去了。
建议20:不要只替换一个类
我们经常在系统中定义一个常量接口(或常量类),以囊括系统中所涉及的常量,从而简化代码,方便开发,在很多的开源项目中已采用了类似的方法,比如在Struts2中,org. apache.struts2.StrutsConstants就是一个常量类,它定义了Struts框架中与配置有关的常量,而org.apache.struts2.StrutsStatics则是一个常量接口,其中定义了OGNL访问的关键字。
关于常量接口(类)我们来看一个例子,首先定义一个常量类:
public class Constant {
//定义人类寿命极限
public final static int MAX_AGE = 150;
}
这是一个非常简单的常量类,定义了人类的最大年龄,我们引用这个常量,代码如下:
public class Client {
public static void main(String[] args) {
System.out.println("人类寿命极限是:" + Constant.MAX_AGE);
}
}
运行的结果非常简单(结果省略)。目前的代码编写都是在“智能型”IDE工具中完成的,下面我们暂时回溯到原始时代,也就是回归到用记事本编写代码的年代,然后看看会发生什么奇妙事情(为什么要如此,稍后会给出答案)。
修改常量Constant类,人类的寿命增加了,最大能活到180岁,代码如下:
public class Constant {
//定义人类寿命极限
public final static int MAX_AGE = 180;
}
然后重新编译:javac Constant,编译完成后执行:java Client,大家想看看输出的极限年龄是多少岁吗?
输出的结果是:“人类寿命极限是:150”,竟然没有改变为180,太奇怪了,这是为何?
原因是:对于final修饰的基本类型和String类型,编译器会认为它是稳定态(Immutable Status),所以在编译时就直接把值编译到字节码中了,避免了在运行期引用(Run-time Reference),以提高代码的执行效率。针对我们的例子来说,Client类在编译时,字节码中就写上了“150”这个常量,而不是一个地址引用,因此无论你后续怎么修改常量类,只要不重新编译Client类,输出还是照旧。
而对于final修饰的类(即非基本类型),编译器认为它是不稳定态(Mutable Status),在编译时建立的则是引用关系(该类型也叫做Soft Final),如果Client类引入的常量是一个类或实例,即使不重新编译也会输出最新值。
千万不可小看了这点知识,细坑也能绊倒大象,比如在一个We b项目中,开发人员修改一个final类型的值(基本类型),考虑到重新发布风险较大,或者是时间较长,或者是审批流程过于繁琐,反正是为了偷懒,于是直接采用替换class类文件的方式发布。替换完毕后应用服务器自动重启,然后简单测试一下(比如本类引用final类型的常量),一切OK。可运行几天后发现业务数据对不上,有的类(引用关系的类)使用了旧值,有的类(继承关系的类)使用的是新值,而且毫无头绪,让人一筹莫展,其实问题的根源就在于此。
恩,还有个小问题没有说明,我们的例子为什么不在IDE工具(比如Eclipse)中运行呢?那是因为在IDE中不能重现该问题,若修改了Constant类,IDE工具会自动编译所有的引用类,“智能”化屏蔽了该问题,但潜在的风险其实仍然存在。
注意 发布应用系统时禁止使用类文件替换方式,整体WAR包发布才是万全之策。