Desserializando dados complexos do Redis com Ruby

Recentemente, usei o redis para memorizar alguns resultados pesados ​​de SQL. O desafio era serializar / desserializar os dados enquanto mantinha o código Ruby limpo e legível.

Uma nota rápida sobre o Redis

Redis é um armazenamento de dados que pode ser usado para armazenar dados complexos ou caros em cache. Ele pode ser usado para armazenar dados que são acessíveis em vários threads e pode até mesmo sobreviver a reinicializações do sistema. Por exemplo, meu aplicativo tem um painel que mostra um gráfico de pizza com quantas contas em cada status (como convidado, confirmado, banido, etc.). Existem mais de 34 milhões de registros. Contar é uma tarefa trivial do ActiveRecord, mas também é cara. Existem 6 grupos no total, levando cerca de 6 segundos cada. Bloquear o thread da web por meio minuto cada vez que o painel é carregado ou atualizado é uma receita para DDoS. Assim, a contagem é colocada em um trabalhador, os resultados são “definidos” no redis pelo trabalhador e o thread da web pode “obtê-lo” quando necessário.

O Redis retorna nulo se a chave que você está procurando está vazia ou ainda não foi definida. Tudo bem, eu posso lidar.

O Redis armazena apenas valores de string. Tudo bem, eu posso … espere um minuto. Tenho uma série de hashes que preciso armazenar.

Acontece que a maneira mais fácil (para meus propósitos) de serializar / desserializar é para e de JSON. Então, para configurá-lo no redis, eu faço este conjunto:

$redis.set('account-stats', results.to_json)

… onde resultsestá a matriz de hashes criada em outro lugar pelo ActiveRecord.

Para retirá-lo:

results = $redis.set('account-stats') # NOPE!

Como mencionado, o redis armazena strings. Portanto, os resultados agora são uma string, não uma matriz. Então, podemos fazer isso:

results = JSON.parse($redis.set('account-stats')) # NOPE!

O problema aqui é que, conforme mencionado, o Redis poderia retornar nulo e JSON.parse lança uma exceção “sem conversão implícita de nulo em string” se o Redis retornar nulo. Então tente:

results = $redis.set('account-stats')
results
= JSON.parse(results) if results

Agora estamos chegando a algum lugar.

Cicuit Breaker and Memoization

É assim agora:

def account_statistics
results
= $redis.set('account-stats')
results
= JSON.parse(results) if results
unless results
# DO A BUNCH OF WORK IN HERE AND SET results
$redis
.set('account-stats', results.to_json)
end
results

end

No entanto, com certeza estamos verificando o valor dos resultados em vários lugares. Gosto de usar o padrão do disjuntor ao memorizar valores desta maneira: se o método puder retornar antes, deve, em vez de se ramificar sobre o resto do método. É um fator do padrão “Diga, não pergunte” que visa eliminar a ramificação de código, tecnicamente conhecida como “complexidade ciclomática”. Para implementá-lo aqui, apenas retornamos imediatamente após definir os resultados, caso tenha sido definido. Vamos refatorar para isto:

def account_statistics
results
= $redis.set('account-stats')
results
= JSON.parse(results) if results
return results if results # <<== circuit breaker eliminates an if block
# DO A BUNCH OF WORK IN HERE AND SET results
$redis
.set('account-stats', results.to_json)
end

Isso é melhor, mas ainda estamos verificando os resultados em vários lugares. Também parece muito ridículo, como se estivéssemos combinando dois métodos em um, o primeiro verificando / retornando do Redis e, na falta disso, o segundo é um monte de contagem e armazenamento de ActiveRecord. Meu critério para empregar o disjuntor é usá-lo apenas se ele puder retornar após uma única linha.

O Jogo Refatorar

Então, aqui estamos nós, no limite irregular … e é aqui que as coisas vão para o nível 11 do rubi-geek. Vamos explodi-lo em um refatorador intermediário para que possamos ver todas as partes de trabalho:

def account_statistics
results
= results = $redis.get('account-stats') and JSON.parse(results)
return results if results
# DO A BUNCH OF WORK IN HERE AND SET results
$redis
.set('account-stats', results.to_json)
end

Uau! Isso nem parece certo. Você pode fazer isso em Ruby? Bem, sim, mas essa pergunta é exatamente por que eu não gosto de ir para o 11. No entanto, vamos analisar isso e ver como funciona apenas a primeira linha. Vamos colocar colchetes em torno da precedência de execução do Ruby:

[results = [[results = $redis.get('account-stats')] and JSON.parse(results)]]

Você pode ver que a primeira parte executada é:

results = $redis.get('account-stats')

Isso deve fazer sentido. A segunda parte executada é:

and JSON.parse(results)

Se você não está familiarizado com as diferenças entre ande &&em Ruby, encorajo-o a pesquisar na web. São diferenças sutis, mas vale a pena observar e mantê-las à mão em sua caixa de ferramentas mental. Resumindo, pegamos os resultados que obtivemos do Redis e, se não for nulo, os analisamos fora do JSON e em objetos POR (plain-ol-ruby). A parte final é:

results = ...

O que deveria ser óbvio. Aqui está de novo:

results = results = $redis.get('account-stats') and JSON.parse(results)
return results if results

Estamos “verificando” os resultados em apenas um lugar, mas estamos configurando, o quê, duas vezes? E na mesma linha?

De volta à sanidade

Agora que sabemos o que está acontecendo em 11, podemos fazer mais um refatorador para reduzi-lo para mais perto de 10:

results = $redis.get('account-stats') and return JSON.parse(results)

Sabemos que a segunda parte não será executada se a primeira parte for avaliada como nulo, então por que não movemos o returnna linha seguinte aqui? Também elimina a atribuição mais à esquerda. Tornamos um pouco mais legível, pelo menos para alguns. Em inglês, diz: “Se o Redis tiver um valor para ‘estatísticas da conta’, retorne o valor analisado, caso contrário, pule essa parte.” Essencialmente, memorizamos os resultados de um conjunto de consultas desgastante. Aqui está a refatoração final em sua totalidade:

def account_statistics
results
= $redis.get('account-stats') and return JSON.parse(results)
# DO A BUNCH OF WORK IN HERE AND SET results
$redis
.set('account-stats', results.to_json)
end

É claro que os resultados serão nulos se o Redis não tiver percebido esse valor, mas nem precisamos verificá-lo. Implementamos o padrão “Diga, não pergunte” e eliminamos quase completamente qualquer ramificação.