开发中通用的方法和准则
11、养成良好习惯,显式声明UID
我们编写一个实现了Serializable接口(序列化标志接口)的类,Eclipse马上就会给一个黄色警告:需要增加一个Serial Version ID。为什么要增加?它是怎么计算出来的?有什么用?本章就来解释该问题。
类实现Serializable接口的目的是为了可持久化,比如网络传输或本地存储,为系统的分布和异构部署提供先决支持条件。若没有序列化,现在我们熟悉的远程调用、对象数据库都不可能存在,我们来看一个简单的序列化类:
java
public class Person implements Serializable{
private String name;
/*name属性的getter/setter方法省略*/
}
这是一个简单JavaBean,实现了Serializable接口,可以在网络上传输,也可以本地存储然后读取。这里我们以Java消息服务(Java Message Service)方式传递该对象(即通过网络传递一个对象),定义在消息队列中的数据类型为ObjectMessage,首先定义一个消息的生产者(Producer),代码如下:
java
public class Producer {
public static void main(String[] args) throws Exception {
Person person = new Person();
person.setName("混世魔王");
//序列化,保存到磁盘上
SerializationUtils.writeObject(person);
}
}
这里引入了一个工具类SerializationUtils,其作用是对一个类进行序列化和反序列化,并存储到硬盘上(模拟网络传输),其代码如下:
java
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)那里,并进行反序列化,生成实例对象,代码如下:
java
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),即类的版本定义的,它可以显式声明也可以隐式声明。显式声明格式如下:
java
private static final long serialVersionUID = XXXXXL;
而隐式声明则是我不声明,你编译器在编译的时候帮我生成。生成的依据是通过包名、类名、继承关系、非私有的方法和属性,以及参数、返回值等诸多因子计算得出的,极度复杂,基本上计算出来的这个值是唯一的。
serialVersionUID如何生成已经说明了,我们再来看看serialVersionUID的作用。JVM在反序列化时,会比较数据流中的serialVersionUID与类的serialVersionUID是否相同,如果相同,则认为类没有发生改变,可以把数据流load为实例对象;如果不相同,对不起,我JVM不干了,抛个异常InvalidClassException给你瞧瞧。这是一个非常好的校验机制,可以保证一个对象即使在网络或磁盘中"滚过"一次,仍能做到"出淤泥而不染",完美地实现类的一致性。
但是,有时候我们需要一点特例场景,例如:我的类改变不大,JVM是否可以把我以前的对象反序列化过来?就是依靠显式声明serialVersionUID,向JVM撒谎说"我的类版本没有变更",如此,我们编写的类就实现了向上兼容。我们修改一下上面的Person类,代码如下:
java
public class Person implements Serializable{
private static final long serialVersionUID = 55799L;
/*其他保持不变*/
}
刚开始生产者和消费者持有的Person类版本一致,都是V1.0,某天生产者的Person类版本变更了,增加了一个"年龄"属性,升级为V2.0,而由于种种原因(比如程序员疏忽、升级时间窗口不同等)消费端的Person还保持为V1.0版本,代码如下:
java
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标识的属性是不变量,也就是说只能赋值一次,不能重复赋值,但是在序列化类中就有点复杂了,比如有这样一个类:
java
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变量另外一种赋值方式:通过构造函数赋值。代码如下:
java
public class Person implements Serializable{
private static final long serialVersionUID = 91282334L;
//不变量初始不赋值
public final String name;
//构造函数为不变量赋值
public Person(){
name="混世魔王";
}
}
这也是我们常用的一种赋值方式,可以把这个Person类定义为版本V1.0,然后进行序列化,看看有什么问题没有,序列化的代码如下所示:
java
public class Serialize {
public static void main(String[] args) {
//序列化以持久保存
SerializationUtils.writeObject(new Person());
}
}
Person的实例对象保存到了磁盘上,它是一个贫血对象(承载业务属性定义,但不包含其行为定义),我们做一个简单的模拟,修改一下name值代表变更,要注意的是serialVersionUID保持不变,修改后的代码如下:
java
public class Person implements Serializable{
private static final long serialVersionUID = 91282334L;
//不变量初始不赋值
public final String name;
//构造函数为不变量赋值
public Person(){
name="德天使";
}
}
此时Person类的版本是V2.0,但serialVersionUID没有改变,仍然可以反序列化,其代码如下:
java
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类为例来说明,代码如下:
java
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的返回值也改变了,代码如下:
java
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类文件大很多,有兴趣的读者可以自己写个Helloword程序检验一下,其体积确实膨胀了不少。
总结一下,反序列化时final变量在以下情况下不会被重新赋值:
- 通过构造函数为final变量赋值。
- 通过方法返回值为final变量赋值。
- final修饰的属性不是基本类型。
14、使用序列化类的私有方法巧妙解决部分属性持久化问题
部分属性持久化问题看似很简单,只要把不需要持久化的属性加上瞬态关键字(transient关键字)即可。这是一种解决方案,但有时候行不通。例如一个计税系统和人力资源系统(HR系统)通过RMI(Remote MethodInvocation,远程方法调用)对接,计税系统需要从HR 系统获得人员的姓名和基本工资,以作为纳税的依据,而HR系统的工资分为两部分:基本工资和绩效工资,基本工资没什么秘密,根据工作岗位和年限自己都可以计算出来,但绩效工资却是保密的,不能泄露到外系统,很明显这是两个相互关联的类。先来看薪水类Salary类的代码:
java
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类是关联关系,代码如下:
java
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对象进行序列化,把人员和工资信息传递到计税系统中,代码如下:
java
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);
}
}
在通过网络传送到计税系统后,进行反序列化,代码如下:
java
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类稍做修改,看看如何控制序列化和反序列化,代码如下:
java
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"转换为"壹",不过,开源世界是不会提供此工具类的,因为它太贴合中国文化了,要转换还是得自己动手写,代码片段如下:
java
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)等),使用脚本语言则可以很好地简化这一过程,我们写一个简单公式来模拟一下,代码如下:
java
function formula(var1,var2){
return var1 + var2 * factor;
}
这就是一个简单的脚本语言函数,可能你会很疑惑:factor(因子)这个变量是从哪儿来的?它是从上下文来的,类似于一个运行的环境变量。该JavaScript保存在C:/model.js中。下一步Java需要调用JavaScript公式,代码如下:
java
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会一直运行,这也是模拟生产系统的在线变更状况。运行结果如下:
java
输入参数是:1,2
运算结果:3
此时,保持JVM的运行状态,我们修改一下formula函数,代码如下:
java
function formula(var1,var2){
return var1 + var2 - factor;
}
其中,乘号变成了减号,计算公式发生了重大改变。回到JVM中继续输入,运行结果如下。
java
输入参数是:1,2
运算结果:2
修改Java代码,JVM没有重启,输入参数也没有任何改变,仅仅改变脚本函数即可产生不同的结果。这就是脚本语言对系统设计最有利的地方:可以随时发布而不用重新部署;这也是我们Javaer最喜爱它的地方---即使进行变更,也能提供不间断的业务服务。
Java 6不仅仅提供了代码级的脚本内置,还提供了一个jrunscript命令工具,它可以在批处理中发挥最大效能,而且不需要通过JVM解释脚本语言,可以直接通过该工具运行脚本。想想看,这是多么大的诱惑力呀!而且这个工具是可以跨操作系统的,脚本移植就更容易了。但是有一点需要注意:该工具是实验性的,在以后的JDK中会不会继续提供就很难说了。
17、慎用动态编译
动态编译一直是Java的梦想,从Java 6版本它开始支持动态编译了,可以在运行期直接编译.java文件,执行.class,并且能够获得相关的输入输出,甚至还能监听相关的事件。不过,我们最期望的还是给定一段代码,直接编译,然后运行,也就是空中编译执行(on-the-fly),来看如下代码:
java
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是一个简单的二元操作符,它是用来判断一个对象是否是一个类实例的,其操作类似于>=、==,非常简单,我们来看段程序,代码如下:
java
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().isDateInstance("")
编译通不过?非也,编译通过了,返回值是false,T是个String类型,与Date之间没有继承或实现关系,为什么''tinstanceof Date''会编译通过呢?那是因为Java的泛型是为编码服务的,在编译成字节码时,T已经是Object类型了,传递的实参是String类型,也就是说T的表面类型是Object,实际类型是String,那''t instanceof Date''这句话就等价于''Object instance of Date''了,所以返回false 就很正常了。
就这么一个简单的instanceof,你答对几个?
19、断言绝对不是鸡肋
在防御式编程中经常会用断言(Assertion)对参数和环境做出判断,避免程序因不当的输入或错误的环境而产生逻辑异常,断言在很多语言中都存在,C、C++、Python都有不同的断言表示形式。在Java中的断言使用的是assert关键字,其基本的用法如下:
java
assert <布尔表达式>
assert <布尔表达式> : <错误信息>
在布尔表达式为假时,抛出AssertionError错误,并附带了错误信息。assert的语法较简单,有以下两个特性:
- (1)assert默认是不启用的
我们知道断言是为调试程序服务的,目的是为了能够快速、方便地检查到程序异常,但Java在默认条件下是不启用的,要启用就需要在编译、运行时加上相关的关键字,这就不多说,有需要的话可以参考一下Java规范。 - (2)assert抛出的异常AssertionError是继承自Error的
断言失败后,JVM会抛出一个AssertionError错误,它继承自Error,注意,这是一个错误,是不可恢复的,也就表示这是一个严重问题,开发者必须予以关注并解决之。
assert虽然是做断言的,但不能将其等价于if...else...这样的条件判断,它在以下两种情况不可使用:
- (1)在对外公开的方法中
我们知道防御式编程最核心的一点就是:所有的外部因素(输入参数、环境变量、上下文)都是"邪恶"的,都存在着企图摧毁程序的罪恶本源,为了抵制它,我们要在程序中处处检验,满地设卡,不满足条件就不再执行后续程序,以保护主程序的正确性,处处设卡没问题,但就是不能用断言做输入校验,特别是公开方法。我们来看一个例子:
java
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的布尔表达式中不能执行逻辑代码,否则会因为环境不同而产生不同的逻辑,例如:
java
public void doSomething(List list,Object element){
assert list.remove(element):"删除元素 " + element + " 失败";
/*业务处理*/
}
这段代码在assert启用的环境下,没有任何问题,但是一旦投入到生产环境,就不会启用断言了,而这个方法也就彻底完蛋了,list的删除动作永远都不会执行,所以也就永远不会报错或异常,因为根本就没有执行嘛!
以上两种情况下不能使用assert,那在什么情况下能够使用assert呢?一句话:按照正常执行逻辑不可能到达的代码区域可以放置assert。具体分为三种情况:
- (1)在私有方法中放置assert作为输入参数的校验在私有方法中可以放置assert校验输入参数,因为私有方法的使用者是作者自己,私有方法的调用者和被调用者之间是一种弱契约关系,或者说没有契约关系,其间的约束是依靠作者自己控制的,因此加上assert可以更好地预防自己犯错,或者无意的程序犯错。
- (2)流程控制中不可能达到的区域
这类似于JUnit的fail方法,其标志性的意义就是:程序执行到这里就是错误的,例如:
java
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访问的关键字。
关于常量接口(类)我们来看一个例子,首先定义一个常量类:
java
public class Constant {
//定义人类寿命极限
public final static int MAX_AGE = 150;
}
这是一个非常简单的常量类,定义了人类的最大年龄,我们引用这个常量,代码如下:
java
public class Client {
public static void main(String[] args) {
System.out.println("人类寿命极限是:" + Constant.MAX_AGE);
}
}
运行的结果非常简单(结果省略)。目前的代码编写都是在"智能型"IDE工具中完成的,下面我们暂时回溯到原始时代,也就是回归到用记事本编写代码的年代,然后看看会发生什么奇妙事情(为什么要如此,稍后会给出答案)。
修改常量Constant类,人类的寿命增加了,最大能活到180岁,代码如下:
java
public class Constant {
//定义人类寿命极限
public final static int MAX_AGE = 180;
}
然后重新编译:javac Constant,编译完成后执行:javaClient,大家想看看输出的极限年龄是多少岁吗?
输出的结果是:"人类寿命极限是: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包发布才是万全之策。