Java——开发中通用的方法和准则(二)

开发中通用的方法和准则

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包发布才是万全之策。

相关推荐
袁震19 分钟前
HarmonyOS 应用包体积优化与上架自检实战:从 76.2MB 到 3.6MB
java·华为·性能优化·harmonyos
江屿风23 分钟前
C++OJ题经验总结(竞赛)5
开发语言·c++
溪语流沙26 分钟前
【Python项目实战】虚拟环境与依赖管理:venv / pip / requirements.txt实操
开发语言·python·pip
神威难绷泪26 分钟前
Linux应用软件编程:线程分离属性 互斥机制 同步机制 死锁
linux·开发语言·算法·线程
zcmodeltech40 分钟前
反应装置模型控制系统设计与实现:多设备协同联动方案
java·网络·数据库·stm32·嵌入式硬件·能源·制造
研☆香1 小时前
数组方法 splice讲解 拓展
开发语言·前端·javascript
码视野1 小时前
基于 Spring Boot + Vue3 的【城市地下燃气管网微泄漏感知与相邻地下空间燃爆预警中台】设计与实现(含PRD/三端高保真源码/大屏)
java·前端·人工智能·spring boot·后端
峥嵘life1 小时前
Android16 系统 APEX 模块说明
android·大数据·开发语言
2601_962071571 小时前
数据库系统架构与DBMS功能探微:现代信息时代数据管理的关键
java·开发语言·数据库