现在的位置: 首页 > 程序设计> 正文
Java深度解析之安全与对象序列化RMI
2011年08月02日 程序设计 暂无评论 ⁄ 被围观 4,879+

本系列的文章转载自infoq专栏合集《Java深度历险》,10篇文章分别由浅到深描述了Java的多个细节,具体包括
1. Java 字节代码的操纵    2. Java类的加载、链接和初始化  3. Java线程:基本概念、可见性与同步
4. Java垃圾回收机制与引用类型  5. Java泛型   6. Java注解  7. Java反射与动态代理
8. Java I/O   9. Java安全  10. Java对象序列化与RMI,
相信通过十个主题的学习,读者能对Java的这几个方面有较深入的理解。enjoy it!

Java 安全

安全性是Java应用程序的非功能性需求的重要组成部分,如同其它的非功能性需求一样,安全性很容易被开发人员所忽略。当然,对于Java EE的开发人员来说,安全性的话题可能没那么陌生,用户认证和授权可能是绝大部分Web应用都有的功能。类似Spring Security这样的框架,也使得开发变得更加简单。本文并不会讨论Web应用的安全性,而是介绍Java安全一些底层和基本的内容。

认证
用户认证是应用安全性的重要组成部分,其目的是确保应用的使用者具有合法的身份。
Java安全中使用术语主体(Subject)来表示访问请求的来源。一个主体可以是任何的实体。一个主体可以有多个不同的身份标识(Principal)。比如一个应用的用户这类主体,就可以有用户名、身份证号码和手机号码等多种身份标识。除了身份标识之外,一个主体还可以有公开或是私有的安全相关的凭证(Credential),包括密码和密钥等。

典型的用户认证过程是通过登录操作来完成的。在登录成功之后,一个主体中就具备了相应的身份标识。Java提供了一个可扩展的登录框架,使得应用开发人员可以很容易的定制和扩展与登录相关的逻辑。登录的过程由LoginContext启动。在创建LoginContext的时候需要指定一个登录配置(Configuration)的名称。该登录配置中包含了登录所需的多个LoginModule的信息。每个LoginModule实现了一种登录方式。当调用LoginContext的login方法的时候,所配置的每个LoginModule会被调用来执行登录操作。如果整个登录过程成功,则通过getSubject方法就可以获取到包含了身份标识信息的主体。开发人员可以实现自己的LoginModule来定制不同的登录逻辑。

每个LoginModule的登录方式由两个阶段组成。第一个阶段是在login方法的实现中。这个阶段用来进行必要的身份认证,可能需要获取用户的输入,以及通过数据库、网络操作或其它方式来完成认证。当认证成功之后,把必要的信息保存起来。如果认证失败,则抛出相关的异常。第二阶段是在commitabort方法中。由于一个登录过程可能涉及到多个LoginModule。LoginContext会根据每个LoginModule的认证结果以及相关的配置信息来确定本次登录是否成功。LoginContext用来判断的依据是每个LoginModule对整个登录过程的必要性,分成必需、必要、充分和可选这四种情况。如果登录成功,则每个LoginModule的commit方法会被调用,用来把身份标识关联到主体上。如果登录失败,则LoginModule 的abort方法会被调用,用来清除之前保存的认证相关信息。

在LoginModule进行认证的过程中,如果需要获取用户的输入,可以通过CallbackHandler和对应的Callback来完成。每个Callback可以用来进行必要的数据传递。典型的启动登录的过程如下:

1
2
3
4
5
6
public Subject login() throws LoginException {    
    TextInputCallbackHandler callbackHandler = new TextInputCallbackHandler();    
    LoginContext lc = new LoginContext("SmsApp", callbackHandler);    
    lc.login();    
    return lc.getSubject();
}
public Subject login() throws LoginException {    
    TextInputCallbackHandler callbackHandler = new TextInputCallbackHandler();    
    LoginContext lc = new LoginContext("SmsApp", callbackHandler);    
    lc.login();    
    return lc.getSubject();
}

这里的SmsApp是登录配置的名称,可以在配置文件中找到。该配置文件的内容也很简单。

1
2
3
SmsApp {    
    security.login.SmsLoginModule required;
};
SmsApp {    
    security.login.SmsLoginModule required;
};

这里声明了使用security.login.SmsLoginModule这个登录模块,而且该模块是必需的。配置文件可以通过启动程序时的参数java.security.auth.login.config来指定,或修改JVM的默认设置。下面看看SmsLoginModule的核心方法login和commit。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public boolean login() throws LoginException {    
    TextInputCallback phoneInputCallback = new TextInputCallback("Phone number: ");    
    TextInputCallback smsInputCallback = new TextInputCallback("Code: ");    
    try {        
        handler.handle(new Callback[] {phoneInputCallback, smsInputCallback});    
    } catch (Exception e) {        
        throw new LoginException(e.getMessage());    
    }     
    String code = smsInputCallback.getText();    
    boolean isValid = code.length() > 3; //此处只是简单的进行验证。   
    if (isValid) {        
        phoneNumber = phoneInputCallback.getText();    
    }    
    return isValid;
}
public boolean commit() throws LoginException {    
    if (phoneNumber != null) {        
    subject.getPrincipals().add(new PhonePrincipal(phoneNumber));       
    return true;    
}    
    return false;
}
public boolean login() throws LoginException {    
    TextInputCallback phoneInputCallback = new TextInputCallback("Phone number: ");    
    TextInputCallback smsInputCallback = new TextInputCallback("Code: ");    
    try {        
        handler.handle(new Callback[] {phoneInputCallback, smsInputCallback});    
    } catch (Exception e) {        
        throw new LoginException(e.getMessage());    
    }     
    String code = smsInputCallback.getText();    
    boolean isValid = code.length() > 3; //此处只是简单的进行验证。   
    if (isValid) {        
        phoneNumber = phoneInputCallback.getText();    
    }    
    return isValid;
}
public boolean commit() throws LoginException {    
    if (phoneNumber != null) {        
    subject.getPrincipals().add(new PhonePrincipal(phoneNumber));       
    return true;    
}    
    return false;
}

这里使用了两个TextInputCallback来获取用户的输入。当用户输入的编码有效的时候,就把相关的信息记录下来,此处是用户的手机号码。在commit方法中,就把该手机号码作为用户的身份标识与主体关联起来。

权限控制
在验证了访问请求来源的合法身份之后,另一项工作是验证其是否具有相应的权限。权限由Permission及其子类来表示。每个权限都有一个名称,该名称的含义与权限类型相关。某些权限有与之对应的动作列表。比较典型的是文件操作权限FilePermission,它的名称是文件的路径,而它的动作列表则包括读取、写入和执行等。Permission类中最重要的是implies方法,它定义了权限之间的包含关系,是进行验证的基础。

权限控制包括管理和验证两个部分。管理指的是定义应用中的权限控制策略,而验证指的则是在运行时刻根据策略来判断某次请求是否合法。策略可以与主体关联,也可以没有关联。策略由Policy来表示,JDK提供了基于文件存储的基本实现。开发人员也可以提供自己的实现。在应用运行过程中,只可能有一个Policy处于生效的状态。验证部分的具体执行者是AccessController,其中的checkPermission方法用来验证给定的权限是否被允许。在应用中执行相关的访问请求之前,都需要调用checkPermission方法来进行验证。如果验证失败的话,该方法会抛出AccessControlException异常。 JVM中内置提供了一些对访问关键部分内容的访问控制检查,不过只有在启动应用的时通过参数-Djava.security.manager启用了安全管理器之后才能生效,并与策略相配合。

与访问控制相关的另外一个概念是特权动作。特权动作只关心动作本身所要求的权限是否具备,而并不关心调用者是谁。比如一个写入文件的特权动作,它只要求对该文件有写入权限即可,并不关心是谁要求它执行这样的动作。特权动作根据是否抛出受检异常,分为PrivilegedActionPrivilegedExceptionAction。这两个接口都只有一个run方法用来执行相关的动作,也可以向调用者返回结果。通过AccessController的doPrivileged方法就可以执行特权动作。

Java安全使用了保护域的概念。每个保护域都包含一组类、身份标识和权限,其意义是在当访问请求的来源是这些身份标识的时候,这些类的实例就自动具有给定的这些权限。保护域的权限既可以是固定,也可以根据策略来动态变化。ProtectionDomain类用来表示保护域,它的两个构造方法分别用来支持静态和动态的权限。一般来说,应用程序通常会涉及到系统保护域和应用保护域。不少的方法调用可能会跨越多个保护域的边界。因此,在AccessController进行访问控制验证的时候,需要考虑当前操作的调用上下文,主要指的是方法调用栈上不同方法所属于的不同保护域。这个调用上下文一般是与当前线程绑定在一起的。通过AccessController的getContext方法可以获取到表示调用上下文的AccessControlContext对象,相当于访问控制验证所需的调用栈的一个快照。在有些情况下,会需要传递此对象以方便在其它线程中进行访问控制验证。

考虑下面的权限验证代码:

1
2
3
4
5
6
7
8
9
Subject subject = new Subject();
ViewerPrincipal principal = new ViewerPrincipal("Alex");
subject.getPrincipals().add(principal);
Subject.doAsPrivileged(subject, new PrivilegedAction<Object>() {    
    public Object run() {       
        new Viewer().view();        
        return null;   
    }
}, null);
Subject subject = new Subject();
ViewerPrincipal principal = new ViewerPrincipal("Alex");
subject.getPrincipals().add(principal);
Subject.doAsPrivileged(subject, new PrivilegedAction<Object>() {    
    public Object run() {       
        new Viewer().view();        
        return null;   
    }
}, null);

这里创建了一个新的Subject对象并关联上身份标识。通常来说,这个过程是由登录操作来完成的。通过Subject的doAsPrivileged方法就可以执行一个特权动作。Viewer对象的view方法会使用AccessController来检查是否具有相应的权限。策略配置文件的内容也比较简单,在启动程序的时候通过参数java.security.auth.policy指定文件路径即可。

1
2
3
grant Principal security.access.ViewerPrincipal "Alex" {
    permission security.access.ViewPermission "CONFIDENTIAL";
}; //这里把名称为CONFIDENTIAL的ViewPermission授权给了身份标识为Alex的主体。
grant Principal security.access.ViewerPrincipal "Alex" {
    permission security.access.ViewPermission "CONFIDENTIAL";
}; //这里把名称为CONFIDENTIAL的ViewPermission授权给了身份标识为Alex的主体。

加密、解密与签名
构建安全的Java应用离不开加密和解密。Java的密码框架采用了常见的服务提供者架构,以提供所需的可扩展性和互操作性。该密码框架提供了一系列常用的服务,包括加密、数字签名和报文摘要等。这些服务都有服务提供者接口(SPI),服务的实现者只需要实现这些接口,并注册到密码框架中即可。比如加密服务Cipher的SPI接口就是CipherSpi。每个服务都可以有不同的算法来实现。密码框架也提供了相应的工厂方法用来获取到服务的实例。比如想使用采用MD5算法的报文摘要服务,只需要调用MessageDigest.getInstance("MD5")即可。

加密和解密过程中并不可少的就是密钥(Key)。加密算法一般分成对称和非对称两种。对称加密算法使用同一个密钥进行加密和解密;而非对称加密算法使用一对公钥和私钥,一个加密的时候,另外一个就用来解密。不同的加密算法,有不同的密钥。对称加密算法使用的是SecretKey,而非对称加密算法则使用PublicKeyPrivateKey。与密钥Key对应的另一个接口是KeySpec,用来描述不同算法的密钥的具体内容。比如一个典型的使用对称加密的方式如下:

1
2
3
4
5
6
7
8
KeyGenerator generator = KeyGenerator.getInstance("DES");
SecretKey key = generator.generateKey();
saveFile("key.data", key.getEncoded());
Cipher cipher = Cipher.getInstance("DES");
cipher.init(Cipher.ENCRYPT_MODE, key);
String text = "Hello World";
byte[] encrypted = cipher.doFinal(text.getBytes());
saveFile("encrypted.bin", encrypted);
KeyGenerator generator = KeyGenerator.getInstance("DES");
SecretKey key = generator.generateKey();
saveFile("key.data", key.getEncoded());
Cipher cipher = Cipher.getInstance("DES");
cipher.init(Cipher.ENCRYPT_MODE, key);
String text = "Hello World";
byte[] encrypted = cipher.doFinal(text.getBytes());
saveFile("encrypted.bin", encrypted);

加密的时候首先要生成一个密钥,再由Cipher服务来完成。可以把密钥的内容保存起来,方便传递给需要解密的程序。

1
2
3
4
5
6
byte[] keyData = getData("key.data");
SecretKeySpec keySpec = new SecretKeySpec(keyData, "DES");
Cipher cipher = Cipher.getInstance("DES");
cipher.init(Cipher.DECRYPT_MODE, keySpec);
byte[] data = getData("encrypted.bin");
byte[] result = cipher.doFinal(data);
byte[] keyData = getData("key.data");
SecretKeySpec keySpec = new SecretKeySpec(keyData, "DES");
Cipher cipher = Cipher.getInstance("DES");
cipher.init(Cipher.DECRYPT_MODE, keySpec);
byte[] data = getData("encrypted.bin");
byte[] result = cipher.doFinal(data);

解密的时候先从保存的文件中得到密钥编码之后的内容,再通过SecretKeySpec获取到密钥本身的内容,再进行解密。

报文摘要的目的在于防止信息被有意或无意的修改。通过对原始数据应用某些算法,可以得到一个校验码。当收到数据之后,只需要应用同样的算法,再比较校验码是否一致,就可以判断数据是否被修改过。相对原始数据来说,校验码长度更小,更容易进行比较。消息认证码(Message Authentication Code)与报文摘要类似,不同的是计算的过程中加入了密钥,只有掌握了密钥的接收者才能验证数据的完整性。

使用公钥和私钥就可以实现数字签名的功能。某个发送者使用私钥对消息进行加密,接收者使用公钥进行解密。由于私钥只有发送者知道,当接收者使用公钥解密成功之后,就可以判定消息的来源肯定是特定的发送者。这就相当于发送者对消息进行了签名。数字签名由Signature服务提供,签名和验证的过程都比较直接。

1
2
3
4
5
6
7
8
9
10
11
12
Signature signature = Signature.getInstance("SHA1withDSA");
KeyPairGenerator keyGenerator = KeyPairGenerator.getInstance("DSA");
KeyPair keyPair = keyGenerator.generateKeyPair();
PrivateKey privateKey = keyPair.getPrivate();
signature.initSign(privateKey);
byte[] data = "Hello World".getBytes();
signature.update(data);
byte[] signatureData = signature.sign(); //得到签名
PublicKey publicKey = keyPair.getPublic();
signature.initVerify(publicKey);
signature.update(data);
boolean result = signature.verify(signatureData); //进行验证
Signature signature = Signature.getInstance("SHA1withDSA");
KeyPairGenerator keyGenerator = KeyPairGenerator.getInstance("DSA");
KeyPair keyPair = keyGenerator.generateKeyPair();
PrivateKey privateKey = keyPair.getPrivate();
signature.initSign(privateKey);
byte[] data = "Hello World".getBytes();
signature.update(data);
byte[] signatureData = signature.sign(); //得到签名
PublicKey publicKey = keyPair.getPublic();
signature.initVerify(publicKey);
signature.update(data);
boolean result = signature.verify(signatureData); //进行验证

验证数字签名使用的公钥可以通过文件或证书的方式来进行发布。

安全套接字连接
在各种数据传输方式中,网络传输目前使用较广,但是安全隐患也更多。安全套接字连接指的是对套接字连接进行加密。加密的时候可以选择对称加密算法。但是如何在发送者和接收者之间安全的共享密钥,是个很麻烦的问题。如果再用加密算法来加密密钥,则成为了一个循环问题。非对称加密算法则适合于这种情况。私钥自己保管,公钥则公开出去。发送数据的时候,用私钥加密,接收者用公开的公钥解密;接收数据的时候,则正好相反。这种做法解决了共享密钥的问题,但是另外的一个问题是如何确保接收者所得到的公钥确实来自所声明的发送者,而不是伪造的。为此,又引入了证书的概念。证书中包含了身份标识和对应的公钥。证书由用户所信任的机构签发,并用该机构的私钥来加密。在有些情况下,某个证书签发机构的真实性会需要由另外一个机构的证书来证明。通过这种证明关系,会形成一个证书的链条。而链条的根则是公认的值得信任的机构。只有当证书链条上的所有证书都被信任的时候,才能信任证书中所给出的公钥。

日常开发中比较常接触的就是HTTPS,即安全的HTTP连接。大部分用Java程序访问采用HTTPS网站时出现的错误都与证书链条相关。有些网站采用的不是由正规安全机构签发的证书,或是证书已经过期。如果必须访问这样的HTTPS网站的话,可以提供自己的套接字工厂和主机名验证类来绕过去。另外一种做法是通过keytool工具把证书导入到系统的信任证书库之中。

1
2
3
4
5
URL url = new URL("https://localhost:8443");
SSLContext context = SSLContext.getInstance("TLS");
context.init(new KeyManager[] {}, new TrustManager[] {new MyTrustManager()}, new SecureRandom());HttpsURLConnection connection = (HttpsURLConnection) url.openConnection();
connection.setSSLSocketFactory(context.getSocketFactory());
connection.setHostnameVerifier(new MyHostnameVerifier());
URL url = new URL("https://localhost:8443");
SSLContext context = SSLContext.getInstance("TLS");
context.init(new KeyManager[] {}, new TrustManager[] {new MyTrustManager()}, new SecureRandom());HttpsURLConnection connection = (HttpsURLConnection) url.openConnection();
connection.setSSLSocketFactory(context.getSocketFactory());
connection.setHostnameVerifier(new MyHostnameVerifier());

这里的MyTrustManager实现了X509TrustManager接口,但是所有方法都是默认实现。而MyHostnameVerifier实现了HostnameVerifier接口,其中的verify方法总是返回true。

参考资料

Java对象序列化与RMI

对于一个存在于Java虚拟机中的对象来说,其内部的状态只保持在内存中。JVM停止之后,这些状态就丢失了。在很多情况下,对象的内部状态是需要被持久化下来的。提到持久化,最直接的做法是保存到文件系统或是数据库之中。这种做法一般涉及到自定义存储格式以及繁琐的数据转换。对象关系映射(Object-relational mapping)是一种典型的用关系数据库来持久化对象的方式,也存在很多直接存储对象的对象数据库。对象序列化机制(object serialization)是Java语言内建的一种对象持久化方式,可以很容易的在JVM中的活动对象和字节数组(流)之间进行转换。除了可以很简单的实现持久化之外,序列化机制的另外一个重要用途是在远程方法调用中,用来对开发人员屏蔽底层实现细节。

基本的对象序列化
由于Java提供了良好的默认支持,实现基本的对象序列化是件比较简单的事。待序列化的Java类只需要实现Serializable接口即可。Serializable仅是一个标记接口,并不包含任何需要实现的具体方法。实现该接口只是为了声明该Java类的对象是可以被序列化的。实际的序列化和反序列化工作是通过ObjectOuputStreamObjectInputStream来完成的。ObjectOutputStream的writeObject方法可以把一个Java对象写入到流中,ObjectInputStream的readObject方法可以从流中读取一个Java对象。在写入和读取的时候,虽然用的参数或返回值是单个对象,但实际上操纵的是一个对象图,包括该对象所引用的其它对象,以及这些对象所引用的另外的对象。Java会自动帮你遍历对象图并逐个序列化。除了对象之外,Java中的基本类型和数组也是可以通过 ObjectOutputStream和ObjectInputStream来序列化的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
try {
    User user = new User("Alex", "Cheng");
    ObjectOutputStream output = new ObjectOutputStream(new FileOutputStream("user.bin"));
    output.writeObject(user);
    output.close();
} catch (IOException e) {
    e.printStackTrace();
}
 
try {
    ObjectInputStream input = new ObjectInputStream(new FileInputStream("user.bin"));
    User user = (User) input.readObject();
    System.out.println(user);
} catch (Exception e) {
    e.printStackTrace();
}
try {
    User user = new User("Alex", "Cheng");
    ObjectOutputStream output = new ObjectOutputStream(new FileOutputStream("user.bin"));
    output.writeObject(user);
    output.close();
} catch (IOException e) {
    e.printStackTrace();
}

try {
    ObjectInputStream input = new ObjectInputStream(new FileInputStream("user.bin"));
    User user = (User) input.readObject();
    System.out.println(user);
} catch (Exception e) {
    e.printStackTrace();
}

上面的代码给出了典型的把Java对象序列化之后保存到磁盘上,以及从磁盘上读取的基本方式。 User类只是声明了实现Serializable接口。

在默认的序列化实现中,Java对象中的非静态和非瞬时域都会被包括进来,而与域的可见性声明没有关系。这可能会导致某些不应该出现的域被包含在序列化之后的字节数组中,比如密码等隐私信息。由于Java对象序列化之后的格式是固定的,其它人可以很容易的从中分析出其中的各种信息。对于这种情况,一种解决办法是把域声明为瞬时的,即使用transient关键词。另外一种做法是添加一个serialPersistentFields? 域来声明序列化时要包含的域。从这里可以看到在Java序列化机制中的这种仅在书面层次上定义的契约。声明序列化的域必须使用固定的名称和类型。在后面还可以看到其它类似这样的契约。虽然Serializable只是一个标记接口,但它其实是包含有不少隐含的要求。下面的代码给出了 serialPersistentFields的声明示例,即只有firstName这个域是要被序列化的。

1
2
3
private static final ObjectStreamField[] serialPersistentFields = { 
    new ObjectStreamField("firstName", String.class) 
};
private static final ObjectStreamField[] serialPersistentFields = { 
    new ObjectStreamField("firstName", String.class) 
};

自定义对象序列化
基本的对象序列化机制让开发人员可以在包含哪些域上进行定制。如果想对序列化的过程进行更加细粒度的控制,就需要在类中添加writeObject和对应的 readObject方法。这两个方法属于前面提到的序列化机制的隐含契约的一部分。在通过ObjectOutputStream的 writeObject方法写入对象的时候,如果这个对象的类中定义了writeObject方法,就会调用该方法,并把当前 ObjectOutputStream对象作为参数传递进去。writeObject方法中一般会包含自定义的序列化逻辑,比如在写入之前修改域的值,或是写入额外的数据等。对于writeObject中添加的逻辑,在对应的readObject中都需要反转过来,与之对应。

在添加自己的逻辑之前,推荐的做法是先调用Java的默认实现。在writeObject方法中通过ObjectOutputStream的defaultWriteObject来完成,在readObject方法则通过ObjectInputStream的defaultReadObject来实现。下面的代码在对象的序列化流中写入了一个额外的字符串。

1
2
3
4
5
6
7
8
9
private void writeObject(ObjectOutputStream output) throws IOException {
    output.defaultWriteObject();
    output.writeUTF("Hello World");
}
private void readObject(ObjectInputStream input) throws IOException, ClassNotFoundException {
    input.defaultReadObject();
    String value = input.readUTF();
    System.out.println(value);
}
private void writeObject(ObjectOutputStream output) throws IOException {
    output.defaultWriteObject();
    output.writeUTF("Hello World");
}
private void readObject(ObjectInputStream input) throws IOException, ClassNotFoundException {
    input.defaultReadObject();
    String value = input.readUTF();
    System.out.println(value);
}

序列化时的对象替换
在有些情况下,可能会希望在序列化的时候使用另外一个对象来代替当前对象。其中的动机可能是当前对象中包含了一些不希望被序列化的域,比如这些域都是从另外一个域派生而来的;也可能是希望隐藏实际的类层次结构;还有可能是添加自定义的对象管理逻辑,如保证某个类在JVM中只有一个实例。相对于把无关的域都设成transient来说,使用对象替换是一个更好的选择,提供了更多的灵活性。替换对象的作用类似于Java EE中会使用到的传输对象(Transfer Object)。

考虑下面的例子,一个订单系统中需要把订单的相关信息序列化之后,通过网络来传输。订单类Order引用了客户类Customer。在默认序列化的情况下,Order类对象被序列化的时候,其引用的Customer类对象也会被序列化,这可能会造成用户信息的泄露。对于这种情况,可以创建一个另外的对象来在序列化的时候替换当前的Order类的对象,并把用户信息隐藏起来。

1
2
3
4
5
6
7
8
9
10
private static class OrderReplace implements Serializable {
    private static final long serialVersionUID = 4654546423735192613L;
    private String orderId;
    public OrderReplace(Order order) {
        this.orderId = order.getId();
    }
    private Object readResolve() throws ObjectStreamException {
        //根据orderId查找Order对象并返回
    }
}
private static class OrderReplace implements Serializable {
    private static final long serialVersionUID = 4654546423735192613L;
    private String orderId;
    public OrderReplace(Order order) {
        this.orderId = order.getId();
    }
    private Object readResolve() throws ObjectStreamException {
        //根据orderId查找Order对象并返回
    }
}

这个替换对象类OrderReplace只保存了Order的ID。在Order类的writeReplace方法中返回了一个OrderReplace对象。这个对象会被作为替代写入到流中。同样的,需要在OrderReplace类中定义一个readResolve方法,用来在读取的时候再转换回 Order类对象。这样对调用者来说,替换对象的存在就是透明的。

1
2
3
private Object writeReplace() throws ObjectStreamException {
    return new OrderReplace(this);
}
private Object writeReplace() throws ObjectStreamException {
    return new OrderReplace(this);
}

序列化与对象创建
在通过ObjectInputStream的readObject方法读取到一个对象之后,这个对象是一个新的实例,但是其构造方法是没有被调用的,其中的域的初始化代码也没有被执行。对于那些没有被序列化的域,在新创建出来的对象中的值都是默认的。也就是说,这个对象从某种角度上来说是不完备的。这有可能会造成一些隐含的错误。调用者并不知道对象是通过一般的new操作符来创建的,还是通过反序列化所得到的。解决的办法就是在类的readObject方法里面,再执行所需的对象初始化逻辑。对于一般的Java类来说,构造方法中包含了初始化的逻辑。可以把这些逻辑提取到一个方法中,在readObject方法中调用此方法。

版本更新
把一个Java对象序列化之后,所得到的字节数组一般会保存在磁盘或数据库之中。在保存完成之后,有可能原来的Java类有了更新,比如添加了额外的域。这个时候从兼容性的角度出发,要求仍然能够读取旧版本的序列化数据。在读取的过程中,当ObjectInputStream发现一个对象的定义的时候,会尝试在当前JVM中查找其Java类定义。这个查找过程不能仅根据Java类的全名来判断,因为当前JVM中可能存在名称相同,但是含义完全不同的Java 类。这个对应关系是通过一个全局惟一标识符serialVersionUID来实现的。通过在实现了Serializable接口的类中定义该域,就声明了该Java类的一个惟一的序列化版本号。JVM会比对从字节数组中得出的类的版本号,与JVM中查找到的类的版本号是否一致,来决定两个类是否是兼容的。对于开发人员来说,需要记得的就是在实现了Serializable接口的类中定义这样的一个域,并在版本更新过程中保持该值不变。当然,如果不希望维持这种向后兼容性,换一个版本号即可。该域的值一般是综合Java类的各个特性而计算出来的一个哈希值,可以通过Java提供的serialver命令来生成。在Eclipse中,如果Java类实现了Serializable接口,Eclipse会提示并帮你生成这个serialVersionUID。

在类版本更新的过程中,某些操作会破坏向后兼容性。如果希望维持这种向后兼容性,就需要格外的注意。一般来说,在新的版本中添加东西不会产生什么问题,而去掉一些域则是不行的。

序列化安全性
前面提到,Java对象序列化之后的内容格式是公开的。所以可以很容易的从中提取出各种信息。从实现的角度来说,可以从不同的层次来加强序列化的安全性。

  • 对序列化之后的流进行加密。这可以通过CipherOutputStream来实现。
  • 实现自己的writeObject和readObject方法,在调用defaultWriteObject之前,先对要序列化的域的值进行加密处理。
  • 使用一个SignedObjectSealedObject来封装当前对象,用SignedObject或SealedObject进行序列化。
  • 在从流中进行反序列化的时候,可以通过ObjectInputStream的registerValidation方法添加ObjectInputValidation接口的实现,用来验证反序列化之后得到的对象是否合法。

RMI
RMI(Remote Method Invocation)是Java中的远程过程调用(Remote Procedure Call,RPC)实现,是一种分布式Java应用的实现方式。它的目的在于对开发人员屏蔽横跨不同JVM和网络连接等细节,使得分布在不同JVM上的对象像是存在于一个统一的JVM中一样,可以很方便的互相通讯。之所以在介绍对象序列化之后来介绍RMI,主要是因为对象序列化机制使得RMI非常简单。调用一个远程服务器上的方法并不是一件困难的事情。开发人员可以基于Apache MINA或是Netty这样的框架来写自己的网络服务器,亦或是可以采用REST架构风格来编写HTTP服务。但这些解决方案中,不可回避的一个部分就是数据的编排和解排(marshal/unmarshal)。需要在Java对象和传输格式之间进行互相转换,而且这一部分逻辑是开发人员无法回避的。RMI的优势在于依靠Java序列化机制,对开发人员屏蔽了数据编排和解排的细节,要做的事情非常少。JDK 5之后,RMI通过动态代理机制去掉了早期版本中需要通过工具进行代码生成的繁琐方式,使用起来更加简单。

RMI采用的是典型的客户端-服务器端架构。首先需要定义的是服务器端的远程接口,这一步是设计好服务器端需要提供什么样的服务。对远程接口的要求很简单,只需要继承自RMI中的Remote接口即可。Remote和Serializable一样,也是标记接口。远程接口中的方法需要抛出RemoteException。定义好远程接口之后,实现该接口即可。如下面的Calculator是一个简单的远程接口。

1
2
3
public interface Calculator extends Remote {
    String calculate(String expr) throws RemoteException;
}
public interface Calculator extends Remote {
    String calculate(String expr) throws RemoteException;
}

实现了远程接口的类的实例称为远程对象。创建出远程对象之后,需要把它注册到一个注册表之中。这是为了客户端能够找到该远程对象并调用。

1
2
3
4
5
6
7
8
9
10
public class CalculatorServer implements Calculator {
    public String calculate(String expr) throws RemoteException {
        return expr;
    }
    public void start() throws RemoteException, AlreadyBoundException {
        Calculator stub = (Calculator) UnicastRemoteObject.exportObject(this, 0);
        Registry registry = LocateRegistry.getRegistry();
        registry.rebind("Calculator", stub);
    }
}
public class CalculatorServer implements Calculator {
    public String calculate(String expr) throws RemoteException {
        return expr;
    }
    public void start() throws RemoteException, AlreadyBoundException {
        Calculator stub = (Calculator) UnicastRemoteObject.exportObject(this, 0);
        Registry registry = LocateRegistry.getRegistry();
        registry.rebind("Calculator", stub);
    }
}

CalculatorServer是远程对象的Java类。在它的start方法中通过UnicastRemoteObjectexportObject把当前对象暴露出来,使得它可以接收来自客户端的调用请求。再通过Registryrebind方法进行注册,使得客户端可以查找到。

客户端的实现就是首先从注册表中查找到远程接口的实现对象,再调用相应的方法即可。实际的调用虽然是在服务器端完成的,但是在客户端看来,这个接口中的方法就好像是在当前JVM中一样。这就是RMI的强大之处。

1
2
3
4
5
6
7
8
9
10
11
12
public class CalculatorClient {
    public void calculate(String expr) {
        try {
            Registry registry = LocateRegistry.getRegistry("localhost");
            Calculator calculator = (Calculator) registry.lookup("Calculator");
            String result = calculator.calculate(expr);
            System.out.println(result);
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}
public class CalculatorClient {
    public void calculate(String expr) {
        try {
            Registry registry = LocateRegistry.getRegistry("localhost");
            Calculator calculator = (Calculator) registry.lookup("Calculator");
            String result = calculator.calculate(expr);
            System.out.println(result);
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

在运行的时候,需要首先通过rmiregistry命令来启动RMI中用到的注册表服务器。

为了通过Java的序列化机制来进行传输,远程接口中的方法的参数和返回值,要么是Java的基本类型,要么是远程对象,要么是实现了 Serializable接口的Java类。当客户端通过RMI注册表找到一个远程接口的时候,所得到的其实是远程接口的一个动态代理对象。当客户端调用其中的方法的时候,方法的参数对象会在序列化之后,传输到服务器端。服务器端接收到之后,进行反序列化得到参数对象。并使用这些参数对象,在服务器端调用实际的方法。调用的返回值Java对象经过序列化之后,再发送回客户端。客户端再经过反序列化之后得到Java对象,返回给调用者。这中间的序列化过程对于使用者来说是透明的,由动态代理对象自动完成。除了序列化之外,RMI还使用了动态类加载技术。当需要进行反序列化的时候,如果该对象的类定义在当前JVM中没有找到,RMI会尝试从远端下载所需的类文件定义。可以在RMI程序启动的时候,通过JVM参数java.rmi.server.codebase来指定动态下载Java类文件的URL。

参考资料

给我留言

留言无头像?


×
腾讯微博