Conteúdo


Cinco coisas que você não sabia sobre...

Serialização de Objeto Java

Você achou que dados serializados fossem seguros? Pense direito.

Comments

Conteúdos da série:

Esse conteúdo é a parte # de # na série: Cinco coisas que você não sabia sobre...

Fique ligado em conteúdos adicionais dessa série.

Esse conteúdo é parte da série:Cinco coisas que você não sabia sobre...

Fique ligado em conteúdos adicionais dessa série.

Alguns anos atrás, enquanto trabalhava com uma equipe de software gravando um aplicativo em linguagem Java, experimentei o benefício de saber um pouco mais que um programador mediano sobre Serialização de Objeto Java.

Um ano e pouco atrás, um desenvolvedor responsável por gerenciar as configurações do usuário de acordo com o aplicativo decidiu armazená-las em uma Hashtable e, então, serializar a Hashtable no disco para persistência. Quando um usuário alterava suas configurações, a Hashtable era simplesmente regravada de volta no disco.

Esse era um sofisticado sistema de configurações aberto, mas ruiu quando a equipe decidiu migrar da Hashtable para HashMap a partir da biblioteca Coleções Java.

As formas de disco de Hashtable e HashMap são diferentes e incompatíveis. Com a falta de execução de alguns tipos de utilitários de conversão de dados em cada uma das configurações de usuário persistidas (uma tarefa monumental), parecia que a Hashtable seria o formato de armazenamento do aplicativo para sempre.

A equipe ficou perplexa, mas apenas porque não sabia algo crucial (e até obscuro) sobre a Serialização Java: ela foi desenvolvida para permitir a evolução dos tipos ao longo do tempo. Depois que mostrei como fazer uma substituição de serialização automática, a transição para HashMap continuou conforme planejado.

Este artigo é o primeiro em uma série dedicada a revelar curiosidades úteis sobre a plataforma Java — coisas obscuras que são úteis para solucionar desafios da programação Java.

A Serialização de Objeto Java é uma excelente API para começarmos, pois ela existe desde o início: JDK 1.1. As cinco coisas que você irá aprender sobre Serialização neste artigo devem convencê-lo a olhar com mais atenção até para APIs Java padrão.

Serialização Java 101

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

Essencialmente, a ideia de Serialização é "congelar" o gráfico do objeto, mover o gráfico (para o disco, através de uma rede ou qualquer outra coisa) e, depois "descongelar" o gráfico novamente para torná-lo um objeto Java utilizável. Tudo isso acontece mais ou menos como num passe de mágica, graças às classes ObjectInputStream/ObjectOutputStream, aos metadados de fidelidade total e à boa vontade dos programadores de "aceitarem" esse processo, identificando suas classes com a interface de marcador Serializable.

A Listagem 1 mostra uma classe Person implementando Serializable.

Listagem 1. Pessoa 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;

}

Após Person ser serializado, é muito simples gravar um gráfico do objeto no disco e lê-lo novamente, conforme demonstrado pelo teste de unidade JUnit 4.

Listagem 2. Desserialização de Pessoa
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");

            // Limpe o arquivo
            new File("tempdata.ser").delete();
        }
        catch (Exception ex)
        {
            fail("Exception thrown during test: " + ex.toString());
        }
    }
}

Nada do que você viu até agora é novo ou interessante — trata-se da Serialização 101, — mas é um bom começo. Vamos utilizar Person para descobrir as cinco coisas que você provavelmente ainda não sabia sobre Serialização de Objeto Java.

1. A serialização permite refatoração

A Serialização permite uma certa quantidade de variação de classe, de modo que, mesmo após a refatoração, ObjectInputStream continue lendo bem.

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

  • Incluir novos campos em uma classe
  • Alterar os campos de estáticos para não estáticos
  • Alterar os campos de temporários para não temporários

O caminho inverso (de não estático para estático ou não temporário para temporário) ou a exclusão de campos requer um sistema de mensagens adicional, dependendo do grau de compatibilidade com versões anteriores exigido.

Refatorando uma classe serializada

Sabendo que a serialização permite refatoração, vamos ver o que acontece quando decidimos incluir um novo campo em uma classe Person.

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

Listagem 3. Incluindo um novo campo em uma 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 utiliza um hash calculado com base em tudo que há em um determinado arquivo de origem — nomes de método, nomes de campo, tipos de campo, modificadores de acesso, você escolhe — e compara esse valor do hash com o valor do hash no fluxo serializado.

Para convencer o Java Runtime de que os dois tipos são, na verdade, o mesmo, a segunda versão e as subsequentes de Person devem ter o mesmo hash de versão de serialização (armazenado como o campo serialVersionUID final estático privado) que a primeira. Portanto, o que nós precisamos é do campo serialVersionUID, que é calculado executando-se o comando JDK serialver com relação à versão original (ou V1) da classe Person.

Quando tivermos o serialVersionUID de Person, não só poderemos criar objetos PersonV2 a partir dos dados serializados do objeto original (onde os novos campos aparecerem, eles serão padronizados para qualquer que seja o valor padrão para um campo, geralmente "null"), mas o oposto também é acontece: podemos desserializar os objetos Person originais a partir dos dados PersonV2 sem problemas.

2. Serialização não é segura

Geralmente é uma surpresa desagradável para os desenvolvedores de Java quando o formato binário de Serialização está completamente documentado e é totalmente reversível. Na verdade, descarregar o conteúdo do fluxo serializado binário no console é suficiente para descobrir com o que a classe se parece e o que ela contém.

Isso tem algumas implicações desagradáveis com relação à segurança. Ao fazer chamadas de método remoto por RMI, por exemplo, quaisquer campos privados nos objetos sendo enviados através de conexão aparecem no fluxo do soquete praticamente como texto simples, o que viola claramente até as questões mais simples de segurança.

Felizmente, a Serialização nos dá a capacidade de "prender" o processo de Serialização e proteger (ou obscurecer) os dados do campo antes da serialização e depois da desserialização. Isso pode ser feito fornecendo um método writeObject em um objeto Serializable.

Obscurecendo dados serializados

Suponha que os dados sensíveis na classe Person fossem o campo de idade; afinal de contas, uma dama nunca revela sua idade e um cavalheiro nunca conta. Podemos obscurecer esses dados girando os bits uma vez para a esquerda antes da serialização e depois girando-os de volta após a desserialização. (Vou deixar você desenvolver um algoritmo mais seguro; esse é apenas um exemplo.)

Para "prender" o processo de serialização, vamos implementar um método writeObject em Person; e para "prender" o processo de desserialização, vamos implementar um método readObject na mesma classe. É importante obter os detalhes corretos sobre ambos — se o modificador de acesso, os parâmetros ou o nome forem diferentes daqueles mostrados na Listagem 4, o código falhará em silêncio e a idade de Person ficará visível para todos que visualizarem.

Listagem 4. Obscurecendo 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
    {
        // "Criptografe"/oculte os dados sensíveis
        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 precisarmos ver os dados obscurecidos, podemos sempre consultar o fluxo/arquivo de dados serializados. E como o formato está completamente documentado, é possível ler o conteúdo do fluxo serializado sem que a classe fique disponível.

3. Os dados serializados podem ser assinados e selados

A dica anterior supõe que você queira obscurecer dados serializados, não queira criptografá-los ou queira assegurar que eles não sejam modificados. Embora a criptografia criptográfica e o gerenciamento de assinatura certamente estejam utilizando writeObject e readObject, há outra maneira melhor.

Se você precisar criptografar e assinar um objeto inteiro, a coisa mais simples a se fazer é colocá-lo em um wrapper javax.crypto.SealedObject e/ou java.security.SignedObject. Ambos são serializáveis, portanto, o agrupamento de seu objeto em SealedObject cria um tipo de "caixa de presente" ao redor do objeto original. Você precisa de uma chave simétrica para fazer a criptografia e a chave deve ser gerenciada independentemente. Da mesma forma, é possível utilizar SignedObject para verificação de dados e, mais uma vez, a chave simétrica deve ser gerenciada independentemente.

Juntos, esses dois objetos permitem selar e assinar dados serializados sem precisar se preocupar com os detalhes da verificação de assinatura digital ou criptografia. Simples, não?

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

De tempos em tempos, uma classe contém um elemento central de dados do qual o restante dos campos da classe pode ser derivado ou recuperado. Nesses casos, é desnecessário serializar completamente o objeto. Você poderia marcar os campos como temporários, mas a classe ainda teria que produzir código explicitamente para verificar se um campo foi inicializado cada vez que um método o acessou.

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

Compactando e descompactando o proxy

Juntos, os métodos writeReplace e readResolve permitem que uma classe Person compacte um PersonProxy com todos os seus dados (ou algum subconjunto central dele), coloque-o em um fluxo e abra o pacote posteriormente quando ele for desserializado.

Listagem 5. Você me completa, eu te substituo
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 tem que rastrear todos os dados de Person. Geralmente, isso significa que o proxy precisará ser uma classe interna de Person para ter acesso a campos privados. Às vezes, o Proxy também precisa rastrear outras referências do objeto e serializá-las manualmente, como o cônjuge de Person.

Essa manobra é uma das que não precisam ser balanceadas por leitura/gravação. Por exemplo, uma versão de uma classe que foi refatorada para um tipo diferente poderia fornecer um método readResolve para fazer silenciosamente a transição de um objeto serializado para um novo tipo. Da mesma forma, ela poderia empregar o método writeReplace para usar classes antigas e serializá-las em novas versões.

5. Confie, mas valide

Seria perfeito assumir que os dados no fluxo serializado são sempre os mesmos que os dados gravados originalmente no fluxo. Porém, como um antigo presidente dos Estados Unidos declarou certa vez, a política mais segura é "confiar, mas verificar".

No caso dos objetos serializados, isso significa validar os campos para garantir que eles contenham valores legítimos após a desserialização, "só por precaução". Isso pode ser feito implementando a interface ObjectInputValidation e substituindo o método validateObject(). Se alguma coisa parecer inadequada quando ela for chamada, lançaremos uma InvalidObjectException.

Conclusão

A Serialização de Objeto Java é mais flexível do que a maioria dos desenvolvedores de Java acha, o que nos dá a oportunidade de eliminar situações complicadas.

Felizmente, essas qualidades da codificação estão todas espalhadas pela JVM. É apenas uma questão de conhecê-las e de mantê-las à mão no caso de alguma dificuldade.


Recursos para download


Temas relacionados


Comentários

Acesse ou registre-se para adicionar e acompanhar os comentários.

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