5 coisas que você não sabia sobre... Serialização de Objetos Java

Você achou que dados serializados eram seguros? Achou errado.

Serialização de Objetos Java é tão fundamental na programação em Java que é fácil nem pensar no assunto. Mas, como muitos aspectos da plataforma Java, a Serialização favorece aqueles que vão atrás. No primeiro artigo desta nova série, Ted Neward apresenta cinco razões para olhar novamente para a API de Serialização de Objetos Java, incluindo truques (e código) para refatoração, criptografia e validação de dados serializados.

Ted Neward, Principal, Neward & Associates

Ted Neward é consultor da ThoughtWorks, uma empresa de consultoria global, e diretor da Neward & Associates, onde atua como consultor, mentor, professor e apresentador do Java, .NET, XML Services e outras plataformas. Ele mora perto de Seattle, no estado de Washington.



26/Abr/2010

Há alguns anos, enquanto trabalhava com uma equipe de software escrevendo um aplicativo na linguagem Java, passei pela gratificante experiência de aprender um pouco mais que o programador médio sobre a Serialização de Objetos Java.

Sobre esta série

Então você acha que sabe programação em Java? A verdade é que a maioria dos desenvolvedores arranha a superfície da plataforma Java, aprendendo apenas o suficiente para fazer seu trabalho. Nesta série, Ted Neward vai além da funcionalidade básica da plataforma Java para descobrir fatos pouco conhecidos que podem ajudar você a resolver os mais complicados desafios de programação.

Mais ou menos um ano atrás, o desenvolvedor responsável por gerenciar as configurações específicas de usuário de um aplicativo decidiu armazená-las em uma Hashtable, e depois serializar a Hashtable no disco para persistência Quando um usuário alterava suas configurações, a Hashtable era apenas gravada novamente em disco.

Era um sistema de configurações elegante e aberto, mas tudo veio abaixo quando a equipe decidiu migrar de Hashtable para HashMap da biblioteca Java Collections.

As formas em disco de Hashtable e HashMap são diferentes e incompatíveis. Fora executar algum tipo de utilitário de conversão de dados sobre cada configuração de usuário persistente (um trabalho monumental), parecia que Hashtable seria o formato de armazenamento do aplicativo pelo resto de seus dias.

A equipe se sentia paralisada, mas apenas porque os membros não sabiam algo crucial (e um tanto obscuro) sobre a Serialização Java: ela foi criada para permitir a evolução de tipos ao longo do tempo. Uma vez que mostrei a eles como fazer a substituição de serialização automática, a transição para HashMap procedeu como planejado.

Este artigo é o primeiro de uma série dedicada a desvendar curiosidades úteis sobre a plataforma Java — coisas obscuras que podem ajudar a solucionar desafios de programação em Java.

A Serialização de Objetos Java é uma ótima API com a qual começar, porque ela existe desde o começo: JDK 1.1. As cinco coisas que você aprenderá sobre a Serialização neste artigo devem convencê-lo a olhar novamente até mesmo para as APIs Java padrão.

Serialização Java Básica 101

A Serialização de Objetos Java, introduzida como parte do conjunto de recursos revolucionários que formou o JDK 1.1, serve como um mecanismo para transformar um gráfico de objetos Java em um array de bytes para armazenamento ou transmissão, de modo que tal array de bytes possa depois ser transformada novamente em um gráfico de objetos Java.

Essencialmente, a ideia da Serialização é a de "congelar" o gráfico de objetos, movê-lo (para o disco, pela rede, ou o que quer que seja) e depois "descongelar" o gráfico de volta para objetos Java utilizáveis. Tudo isto acontece mais ou menos por mágica, graças às classes ObjectInputStream/ObjectOutputStream, metadados de fidelidade completa e a disposição dos programadores em "entrar" nesse processo através da marcação das suas classes com a interface de marcador Serializable.

A Listagem 1 mostra uma classe Person implementando Serializable.

Listagem 1. Person Serializável
package com.tedneward;

public class Person
    implements java.io.Serializable
{
    public Person(String fn, String ln, int a)
    {
        this.firstName = fn; this.lastName = ln; this.age = a;
    }

    public String getFirstName() { return firstName; }
    public String getLastName() { return lastName; }
    public int getAge() { return age; }
    public Person getSpouse() { return spouse; }

    public void setFirstName(String value) { firstName = value; }
    public void setLastName(String value) { lastName = value; }
    public void setAge(int value) { age = value; }
    public void setSpouse(Person value) { spouse = value; }

    public String toString()
    {
        return "[Person: firstName=" + firstName + 
            " lastName=" + lastName +
            " age=" + age +
            " spouse=" + spouse.getFirstName() +
            "]";
    }    

    private String firstName;
    private String lastName;
    private int age;
    private Person spouse;

}

Uma vez que Person tenha sido serializada, é bem simples gravar um gráfico de objetos em disco e ler o gráfico de novo, como demonstrado por este teste de unidade JUnit 4.

Listagem 2. Desserializando Person
public class SerTest
{
    @Test public void serializeToDisk()
    {
        try
        {
            com.tedneward.Person ted = new com.tedneward.Person("Ted", "Neward", 39);
            com.tedneward.Person charl = new com.tedneward.Person("Charlotte",
                "Neward", 38);

            ted.setSpouse(charl); charl.setSpouse(ted);

            FileOutputStream fos = new FileOutputStream("tempdata.ser");
            ObjectOutputStream oos = new ObjectOutputStream(fos);
            oos.writeObject(ted);
            oos.close();
        }
        catch (Exception ex)
        {
            fail("Exception thrown during test: " + ex.toString());
        }
        
        try
        {
            FileInputStream fis = new FileInputStream("tempdata.ser");
            ObjectInputStream ois = new ObjectInputStream(fis);
            com.tedneward.Person ted = (com.tedneward.Person) ois.readObject();
            ois.close();
            
            assertEquals(ted.getFirstName(), "Ted");
            assertEquals(ted.getSpouse().getFirstName(), "Charlotte");

            // Clean up the file
            new File("tempdata.ser").delete();
        }
        catch (Exception ex)
        {
            fail("Exception thrown during test: " + ex.toString());
        }
    }
}

O que você viu até agora não é novo ou empolgante — é Serialização Básica 101 — mas é um bom ponto inicial. Vamos usar Person para descobrir cinco coisas que você provavelmente não sabia sobre a Serialização de Objetos Java.


1. A Serialização permite a refatoração

A Serialização permite um certo grau de variação de classe, de modo que mesmo após a refatoração, ObjectInputStream ainda a lê sem problemas.

As coisas críticas que a especificação de Serialização de Objetos Java pode gerenciar automaticamente são:

  • Acrescentar novos campos a uma classe
  • Alterar os campos de estático para não estático
  • Alterar os campos de temporário para não temporário

Fazer o contrário (de não estático para estático, ou de não temporário para temporário) ou excluir campos requer modificações adicionais, dependendo do grau necessário de compatibilidade com versões anteriores.

Refatorando uma classe serializada

Sabendo que a Serialização permite a refatoração, vejamos o que acontece quando decidimos adicionar um novo campo à classe Person.

PersonV2, mostrado na Listagem 3, introduz um campo para sexo na classe Person original.

Listagem 3. Adicionando um novo campo a Person serializada
enum Gender
{
    MALE, FEMALE
}

public class Person
    implements java.io.Serializable
{
    public Person(String fn, String ln, int a, Gender g)
    {
        this.firstName = fn; this.lastName = ln; this.age = a; this.gender = g;
    }
  
    public String getFirstName() { return firstName; }
    public String getLastName() { return lastName; }
    public Gender getGender() { return gender; }
    public int getAge() { return age; }
    public Person getSpouse() { return spouse; }

    public void setFirstName(String value) { firstName = value; }
    public void setLastName(String value) { lastName = value; }
    public void setGender(Gender value) { gender = value; }
    public void setAge(int value) { age = value; }
    public void setSpouse(Person value) { spouse = value; }

    public String toString()
    {
        return "[Person: firstName=" + firstName + 
            " lastName=" + lastName +
            " gender=" + gender +
            " age=" + age +
            " spouse=" + spouse.getFirstName() +
            "]";
    }    

    private String firstName;
    private String lastName;
    private int age;
    private Person spouse;
    private Gender gender;
}

A Serialização usa um hash calculado baseado em quase tudo em um dado arquivo de origem — nomes de métodos, nomes de campos, tipos de campos, modificadores de acesso e tudo mais — e compara o valor desse hash ao valor do hash na sequência serializada.

Para convencer o tempo de execução Java de que os dois tipos são na verdade o mesmo, a segunda versão as versões subsequentes de Person devem ter o mesmo hash de versão de serialização (armazenado como o campo serialVersionUID final estático privado) que o primeiro. O que precisamos, assim, é do campo serialVersionUID, que é calculado executando o comando do JDK serialver contra a versão original (ou V1) da classe Person.

Assim que tivermos o serialVersionUID de Person, não só podemos criar objetos PersonV2 a partir dos dados serializados do objeto original (onde aparecem os campos novos, eles serão configurados por padrão para qualquer que seja o valor padrão para o campo, geralmente "null"), mas o contrário também é verdadeiro: podemos desserializar objetos originais de Person a partir de dados de PersonV2, sem complicações extras.


2. A Serialização não é segura

Costuma ser uma surpresa desagradável para desenvolvedores Java que o formato binário de Serialização seja totalmente documentado e inteiramente reversível. Aliás, fazer o dump do conteúdo do fluxo binário serializado para o console já é suficiente para descobrir como é a classe e o que ela contém.

Isso apresenta algumas implicações perturbadoras com relação à segurança. Ao fazer chamadas de método remotas via RMI, por exemplo, quaisquer campos privados nos objetos sendo transmitidos pelo fio aparecem no fluxo do soquete quase como texto puro, o que claramente viola até o mais simples dos critérios de segurança.

Felizmente, a Serialização nos dá a capacidade de "enganchar" o processo de serialização e proteger (ou ocultar) os dados de campo antes da serialização e depois da desserialização. Isto é possível fornecendo um método writeObject em um objeto Serializable.

Ocultando dados serializados

Suponha que os dados sensíveis na classe Person fossem o campo de idade; afinal, uma dama nunca revela a sua idade e um cavalheiro nunca conta. Podemos ocultar esses dados girando os bits uma vez para a esquerda antes da serialização, e então uma vez de volta após a desserialização. (Eu vou deixar a seu encargo desenvolver um algoritmo mais seguro; este aqui é apenas para servir de exemplo.)

Para "enganchar" o processo de serialização, nós implementamos um método writeObject em Person, e para "enganchar" o processo de desserialização, implementamos um método readObject na mesma classe. É importante colocar os detalhes corretamente em ambos — se o modificador de acesso, parâmetros ou nome forem diferentes do que está mostrado na Listagem 4, nosso código vai fracassar silenciosamente, e a idade de Person será visível para qualquer um que olhe.

Listagem 4. Ocultando dados serializados
public class Person
    implements java.io.Serializable
{
    public Person(String fn, String ln, int a)
    {
        this.firstName = fn; this.lastName = ln; this.age = a;
    }

    public String getFirstName() { return firstName; }
    public String getLastName() { return lastName; }
    public int getAge() { return age; }
    public Person getSpouse() { return spouse; }
    
    public void setFirstName(String value) { firstName = value; }
    public void setLastName(String value) { lastName = value; }
    public void setAge(int value) { age = value; }
    public void setSpouse(Person value) { spouse = value; }

    private void writeObject(java.io.ObjectOutputStream stream)
        throws java.io.IOException
    {
        // "Encrypt"/obscure the sensitive data
        age = age << 2;
        stream.defaultWriteObject();
    }

    private void readObject(java.io.ObjectInputStream stream)
        throws java.io.IOException, ClassNotFoundException
    {
        stream.defaultReadObject();

        // "Decrypt"/de-obscure the sensitive data
        age = age << 2;
    }
    
    public String toString()
    {
        return "[Person: firstName=" + firstName + 
            " lastName=" + lastName +
            " age=" + age +
            " spouse=" + (spouse!=null ? spouse.getFirstName() : "[null]") +
            "]";
    }      

    private String firstName;
    private String lastName;
    private int age;
    private Person spouse;
}

Se quisermos ver os dados obscurecidos, podemos simplesmente olhar para o fluxo/arquivo de dados serializados. E, como o formato está totalmente documentado, é possível ler o conteúdo do fluxo serializado mesmo que a classe não esteja disponível.


3. Dados serializados podem ser assinados e lacrados

A dica anterior presume que você quer ocultar dados serializados, não os criptografar ou verificar se foram modificados. Embora criptografia e gerenciamento de assinatura sejam certamente possíveis usando writeObject e readObject, há uma maneira melhor.

Se for necessário criptografar e assinar um objeto inteiro, a maneira mais simples é colocá-lo em um wrapper javax.crypto.SealedObject e/ou java.security.SignedObject. Ambos são serializáveis, então colocar seu objeto no wrapper SealedObject cria uma espécie de "caixa de presente" em torno do objeto original. É necessária uma chave simétrica para fazer a criptografia, e a chave deve ser gerenciada independentemente. Da mesma forma, é possível usar SignedObject para verificação de dados, e novamente a chave simétrica deve ser gerenciada independentemente.

Juntos, esses dois objetos permitem que você lacre e assine dados serializados sem precisar se estressar com os detalhes de verificação de assinatura digital ou criptografia. Legal, né?


4. A Serialização pode colocar um proxy no seu fluxo

De tempo em tempo, uma classe contém um elemento essencial dos dados a partir do qual o resto dos campos da classe podem ser derivados ou recuperados. Nesses casos, serializar todo o objeto é desnecessário. Seria possível marcar os campos como temporários, mas a classe ainda precisaria produzir código explicitamente para verificar se um campo foi inicializado a cada vez que um método a acessasse.

Como a preocupação principal é a serialização, é melhor nomear um peso-mosca ou proxy para entrar no fluxo. Fornecer um método writeReplace na Person original permite que um tipo diferente de objeto seja serializado em seu lugar; da mesma forma, se um método readResolve é encontrado durante a desserialização, ele é chamado para fornecer um objeto de substituição de volta ao responsável pela chamada.

Empacotando e desempacotando o proxy

Juntos, os métodos writeReplace e readResolve permitem que uma classe Person empacote um PersonProxy com todos os seus dados (ou alguns subconjuntos centrais deles), coloque-o no fluxo e desempacote o pacote depois quando for desserializado.

Listagem 5. Você me completa, eu substituo você
class PersonProxy
    implements java.io.Serializable
{
    public PersonProxy(Person orig)
    {
        data = orig.getFirstName() + "," + orig.getLastName() + "," + orig.getAge();
        if (orig.getSpouse() != null)
        {
            Person spouse = orig.getSpouse();
            data = data + "," + spouse.getFirstName() + "," + spouse.getLastName() + ","  
              + spouse.getAge();
        }
    }

    public String data;
    private Object readResolve()
        throws java.io.ObjectStreamException
    {
        String[] pieces = data.split(",");
        Person result = new Person(pieces[0], pieces[1], Integer.parseInt(pieces[2]));
        if (pieces.length > 3)
        {
            result.setSpouse(new Person(pieces[3], pieces[4], Integer.parseInt
              (pieces[5])));
            result.getSpouse().setSpouse(result);
        }
        return result;
    }
}

public class Person
    implements java.io.Serializable
{
    public Person(String fn, String ln, int a)
    {
        this.firstName = fn; this.lastName = ln; this.age = a;
    }

    public String getFirstName() { return firstName; }
    public String getLastName() { return lastName; }
    public int getAge() { return age; }
    public Person getSpouse() { return spouse; }

    private Object writeReplace()
        throws java.io.ObjectStreamException
    {
        return new PersonProxy(this);
    }
    
    public void setFirstName(String value) { firstName = value; }
    public void setLastName(String value) { lastName = value; }
    public void setAge(int value) { age = value; }
    public void setSpouse(Person value) { spouse = value; }   

    public String toString()
    {
        return "[Person: firstName=" + firstName + 
            " lastName=" + lastName +
            " age=" + age +
            " spouse=" + spouse.getFirstName() +
            "]";
    }    
    
    private String firstName;
    private String lastName;
    private int age;
    private Person spouse;
}

Observe que PersonProxy precisa rastrear todos os dados de Person. Isto geralmente significa que o proxy precisa ser uma classe interna de Person para ter acesso a seus campos privados. O Proxy também precisará, em alguns momentos, rastrear outras referências do objeto e as serializar manualmente, como o cônjuge de Person.

Este truque é um dos poucos que não precisa ser balanceado entre leitura/gravação. Por exemplo, uma versão de uma classe que tenha sido refatorada para um tipo diferente pode fornecer um método readResolve para fazer a transição silenciosa de um objeto serializado para um novo tipo. Semelhantemente, ela pode empregar o método writeReplace para tomar classes antigas e serializá-las em novas versões.


5. Confie, mas valide

Seria bom presumir que os dados no fluxo serializado são sempre os mesmos dados que foram gravados no fluxo originalmente. Mas, como um ex-presidente dos Estados Unidos disse uma vez, a política mais segura é a de "confiar, mas verificar".

No caso de objetos serializados, isso significa validar os campos para assegurar que tenham valores legítimos após a desserialização, "só por garantia". Podemos fazer isso implementando a interface ObjectInputValidation e substituindo o método validateObject(). Se algo parece errado quando for chamado, lançamos uma InvalidObjectException.


Para concluir

A Serialização de Objetos Java é mais flexível do que a maioria dos desenvolvedores Java pensa, nos dando amplas oportunidades de resolver situações espinhosas.

Felizmente, pérolas de código como essas estão espalhadas por toda a JVM. É só uma questão de conhecê-las e de deixá-las ao alcance para quando um quebra-cabeça aparecer.

A seguir na série 5 coisas: Coletas Java. Até lá, divirta-se retorcendo a Serialização para seu gênio do mau!


Download

DescriçãoNomeTamanho
Sample code for this article5things1-src.zip10KB

Recursos

Aprender

Discutir

Comentários

developerWorks: Conecte-se

Los campos obligatorios están marcados con un asterisco (*).


Precisa de um ID IBM?
Esqueceu seu ID IBM?


Esqueceu sua senha?
Alterar sua senha

Ao clicar em Enviar, você concorda com os termos e condições do developerWorks.

 


A primeira vez que você entrar no developerWorks, um perfil é criado para você. Informações no seu perfil (seu nome, país / região, e nome da empresa) é apresentado ao público e vai acompanhar qualquer conteúdo que você postar, a menos que você opte por esconder o nome da empresa. Você pode atualizar sua conta IBM a qualquer momento.

Todas as informações enviadas são seguras.

Elija su nombre para mostrar



Ao se conectar ao developerWorks pela primeira vez, é criado um perfil para você e é necessário selecionar um nome de exibição. O nome de exibição acompanhará o conteúdo que você postar no developerWorks.

Escolha um nome de exibição de 3 - 31 caracteres. Seu nome de exibição deve ser exclusivo na comunidade do developerWorks e não deve ser o seu endereço de email por motivo de privacidade.

Los campos obligatorios están marcados con un asterisco (*).

(Escolha um nome de exibição de 3 - 31 caracteres.)

Ao clicar em Enviar, você concorda com os termos e condições do developerWorks.

 


Todas as informações enviadas são seguras.


static.content.url=http://www.ibm.com/developerworks/js/artrating/
SITE_ID=80
Zone=Tecnologia Java
ArticleID=485685
ArticleTitle=5 coisas que você não sabia sobre... Serialização de Objetos Java
publish-date=04262010