Hoje queria falar do perigo que ás vezes representa extender objectos dinâmicamente e de como isso representou uma utilização indevida de memória no projecto em que trabalho correntemente. Consideremos uma classe normal:
class Bola
end
Se agora nós quisermos marshalizar (http://en.wikipedia.org/wiki/Marshalling_%28computer_science%29) uma instância da mesma, a sua representação é
relativamente minimal. Observemos:
require 'yaml'
b = Bola.new
b.to_yaml
--- !ruby/object:Bola {}
(vou apresentar os resultados em formato Yaml porque é mais legível e serve o propósito do post melhor). Portanto, a representação do objecto é relativamente sucinta. Adicionemos-lhe agora uns quantos atributos:
class Bola
attr_accessor :material, :marca, :dono
def initialize
end
end
instanciemos de novo o objecto:
require 'yaml'
b = Bola.new
b.to_yaml
--- !ruby/object:Bola {}
A mesma representação obtida anteriormente. Apesar de a classe ter sido extendida, um objecto que não tenha esses atributos preenchidos não os irá marshalizar. Passemos então ao caso de uso:
b.material = "cautchu"
b.to_yaml
--- !ruby/object:Bola
material: cautchu
E aqui temos: o valor do nosso atributo preenchido é usado na representação marshalizada do nosso objecto b. O valor do nosso atributo não é só usado, ele próprio é também marshalizado:
"cautchu".to_yaml
--- cautchu
Imaginemos então que um dos nossos atributos é preenchido, em vez de com um valor de um tipo "primitivo" de ruby, com um objecto de uma classe criada por nós:
class Dono
attr_accessor :nome
def initialize(nome)
@nome = nome
end
end
b.dono = Dono.new("Ronaldinho")
b.to_yaml
--- !ruby/object:Bola
material: cautchu
dono: !ruby/object:Dono
nome: Ronaldinho
Interessante. Experimentemos algo agora totalmente alucinado:
class Dono
attr_accessor :bola
def initialize(nome, bola)
@nome = nome
@bola = bola
end
end
b.dono = Dono.new("Pelé", b)
b.to_yaml
--- &2151878980 !ruby/object:Bola
material: cautchu
dono: !ruby/object:Dono
nome: Pelé
bola: *2151878980
Graças a Deus, houve reconhecimento da referência circular neste caso. De forma curiosa, admitamos. Foi atribuído um endereço ao nosso b, que depois é referenciado dentro do dono. No entanto, vamos insistir:
class Dono
attr_accessor :nome, :bolas
def initialize(nome, bola)
@nome = nome
@bolas = [Bola.new, Bola.new]
@bolas.each { |b| b.dono = self }
@bolas << bola
end
end
b.dono = Dono.new("Pelé", b)
b.to_yaml
--- &2152946720 !ruby/object:Bola
dono: &2153008060 !ruby/object:Dono
nome: Pelé
bolas:
- !ruby/object:Bola
dono: *2153008060
- !ruby/object:Bola
dono: *2153008060
- *2152946720
Começamos a notar um padrão aqui: esse endereço pode ser uma outra coisa qualquer... continuemos:
class Dono < String
attr_accessor :bolas
def initialize(nome, bola)
super(nome)
@bolas = [Bola.new, Bola.new]
@bolas.each { |b| b.dono = self }
@bolas << bola
end
end
b.dono = Dono.new("Pelé", b)
b.to_yaml
--- &2165175520 !ruby/object:Bola
dono: !ruby/string:Dono
str: Pelé
bolas: &2165264600
- !ruby/object:Bola
dono: !ruby/string:Dono
str: Pelé
bolas: *2165264600
- !ruby/object:Bola
dono: !ruby/string:Dono
str: Pelé
bolas: *2165264600
- *2165175520
E agora adensou. Pelos vistos, um objecto tem uma representação marshalizada por defeito. Se for um PORO, é utilizado o id do objecto. No caso de o valor ser uma String, mesmo que extendida, a sua representação vai conter elementos da mesma.
Onde poderá isto ser um problema, perguntarão vocês? A resposta é: em todo o sítio onde a sua representação é marshalizada/yamlizada: serialização, filas de mensagens, etc... Uma representação interminavelmente extensa pode dar origem a uma falha inesperada. Isso aconteceu com o nosso projecto na semana passada: A nossa aplicação Rails utiliza a gem Delayed Jobs para gerir as filas de mensagens. Quando "retardamos" um procedimento, o que a gem faz é criar um registo para a tarefa, com um campo chamado handler onde é guardada a representação yamlizada do método a chamar, dos seus argumentos e do âmbito onde o método é chamado, geralmente um objecto. Este registo é posteriormente persistido na base de dados. Para o tal campo handler é utilizado o tipo TEXT (usamos MySql, já agora). Este tipo é erroneamente interpretado como sem-limite, ou seja, adapta-se ás circunstâncias, mas é errado: tem um limite de 65000 bytes. Quando retardamos uma chamada a um procedimento de um modelo (utilizamos ActiveRecord), esse modelo é yamlizado nesse handler. Felizmente no caso do ActiveRecord, as associações não são yamlizadas, somente os atributos directos e as chaves estrangeiras (ver http://apidock.com/ruby/Object/to_yaml_properties para entender como a gem Delayed Jobs consegue isso). Logo, na maior parte dos casos (salvo data serializada), tipos simples. Agora, vamos imaginar que um destes campos é uma string. Mas na minha lógica de negócio, essa String é extendida mais ou menos na forma descrita nos exemplos acima (mas com mais alguns níveis de profundidade, obviamente). Vamos dizer que atribuímos essa String extendida a um dos campos do nosso modelo ActiveRecord. É uma String. É persistida com sucesso. Mas é a representação marshalizada idêntica? Pois, não é... Imaginemos que temos um modelo AR chamado Bola (tema recorrente, não gosto de usar o nome "Teste" para testes), e que a nossa bola guarda o nome do seu dono num VARCHAR com limite 255, mapeado para uma String na lógica de negócio. numa situação Normal:
b = Bola.new(:dono => Dono.new("Pelé", b))
b.save
b.to_yaml
--- !ruby/ActiveRecord:Bola
attributes:
id: 1
dono: !ruby/string:Dono
str: Pelé
bolas: &2165264600
- !ruby/object:Bola
dono: !ruby/string:Dono
str: Pelé
bolas: *2165264600
- !ruby/object:Bola
dono: !ruby/string:Dono
str: Pelé
bolas: *2165264600
- *2165175520
b.reload
b.to_yaml
--- !ruby/ActiveRecord:Bola
attributes:
id: 1
dono:Pelé
Pois é, duas representações diferentes, dependendo de como a String é instanciada. Acontece então que ocorreu um caso em que a representação yamlizada do objecto sobre o qual determinada chamada tinha sido retardada era simplesmente incomportável pelo campo da base de dados. Não foi motivo de queixa para a base de dados, ela simplesmente guardou a capacidade que suportava. O pior foi quando o script do delayed job tentou executar essa tarefa: obviamente, não conseguiu parsear a representação yamlizada porque era inválida. A representação era deveras gigantesca. Não vou entrar em pormenores sobre o porquê, porque aí teria que vos contar algumas inconsistências da classe extendida de String utilizada (uma pergunta retórica: alguém conhece alguma estrutura de dados para colecções em que os seus elementos guardam um ponteiro para a colecção a que pertencem? Isso foi parte do problema.) Fui falar com o meu colega que escreveu este código. Ele viu a representação e achou que a solução seria passar de TEXT para MEDIUMTEXT ou LONGTEXT (TEXT com mais capacidade). Portanto, se tá gordo, estica, que concerteza não engorda mais... Defendeu a sua ideia quanto baste, porque não queria admitir que a sua preciosa refactorização das definições da aplicação tinha umas quantas fugas de memória. Como disse, colecções em que elementos apontam para a colecção, como será que um garbage collector interage com isto... Então resolvi o problema forçando a conversão para String na atribuição de valores desse tipo extendido... criando um outro problema que foi a propagação de chamadas ao to_s... Para além de a) não ter a garantia que o próximo desenvolvedor não chegue lá e simplesmente se esqueça da conversão e b) persistir o problema se algum dia tiver que retardar a chamada a uma rotina de uma dessas Strings extendidas. Uma grande salganhada proporcionada pela meta-programação de Ruby, uma ferramenta óptima em teoria mas uma fonte de fugas de memória, bugs não-rastreáveis e mau desenho de arquitectura geral. Podemos argumentar que a culpa não é tanto da ferarmenta em si mas do desenvolvedor que falseia o seu uso. Eu diria que cada caso é cada caso.