Como lidar com a migração da estrutura de dados de uma maneira composicional em Haskell?
Estou tentando implementar (des) serialização de estruturas de dados em Haskell de uma maneira que:
Cuida do esquema em evolução da estrutura de dados,Permite a leitura segura de versões anteriores, se houver algum código de "correção",Não precisa manter versões antigas da definição de tipo de dados (como é o caso comsafecopyEu implementei esse mecanismo no passado de uma maneira que considero insatisfatória: há muita repetição no código, pois ele precisa atravessar toda a estrutura, mesmo que apenas uma folha seja alterada, e o código para lidar com várias versões não é passível de composição.
O que estou procurando é algo que funcione como um fluxo de patches em um VCS: a cada alteração de versão, preciso apenas escrever o código para lidar com a alteração específica (por exemplo, algum campo é transformado deText
paraInt
, existe um novo campo, algum campo é excluído ...) e, com uma parte serializada de bytes em uma versão conhecida, oload
A função aplica todos os patches para recuperar uma estrutura de dados válida.
Eu tentei escrever um código nesse sentido, mas não consigo obter algo que possa ser compilado da maneira que estou procurando (e nem sequer lidei com a questão dos tipos de soma ...). Aqui está a minha tentativa:
data Versioned (v :: Nat) a where
(:$:) :: (a -> b) -> Versioned v a -> Versioned v b
(:*:) :: Versioned v (a -> b) -> Versioned v a -> Versioned v b
Atom :: Get a -> Versioned v a
Cast :: Versioned v' a -> Versioned v a
A idéia era reificar uma estrutura aplicativa de tal maneira que fosse possível aplicar mudanças mínimas.
Isso só é possível usando alguma forma deGeneric
Mecanismo de desserialização: desserialize os bytes para um formulário genérico e aplique uma cadeia de transformador para alcançar um formato que satisfaça a corrente.
Qualquer sugestão para uma solução seria muito útil.
13-02-2017
Meu problema pode ser dividido em dois subproblemas:
Como garantir estaticamente que existem funções de desserialização para cada versão, até uma versão conhecida estaticamente?Como lidar com a migração da estrutura de dados de maneira segura e minimamente invasiva?O problema 1. gera o seguinte (código não compilado):
-- | A class instantiating a serializer/deserializer for some version
class Versionable (v :: Nat) a where
reader :: Proxy v -> Get a
writer :: Proxy v -> a -> Put
-- | Current version is a "global" constraint
type family CurrentVersion :: Nat
class VersionUpTo (v :: Nat) a
instance (Versionable 1 a) => VersionUpTo 1 a
instance (Versionable v a, VersionUpTo (v - 1) a) => VersionUpTo v a
load :: (VersionUpTo CurrentVersion a) => ByteString -> Either String [a]
load = runGet loadGetter
where
loadGetter = sequence $ repeat $ do
v <- getInt32be
case v of
1 -> reader (Proxy :: Proxy 1)
2 -> reader (Proxy :: Proxy 2)
3 -> reader (Proxy :: Proxy 3)
O problema é, obviamente, o valor dev
despachar depende deCurrentVersion a
, que levanta o seguinte problema:
load
função que lerá a versão do fluxo de bytes subjacente e despachará para a função correta do leitor, sem recorrer a enumerar explicitamente todos os casos?Mesmo seCurrentVersion
não está estaticamente no local da chamada deload
, não é conhecido no site de definição, portanto, não é possível enumerar todos os casos válidos. Parece que a única opção seria gerar os casos usando TH ...
O problema 2. é ortogonal a 1. Aqui o problema é que uma estrutura de dados digitadaT
evolui com o tempo, mas precisamos cuidar de representações antigas: poderemos desserializar qualquer versãov
doT
até oCurrentVersion
. Isso é feito facilmente, definindo umVersionable n T
para cada versão de destino, mas isso apresenta muita redundância devido às alterações entre as versõesn
en+1
geralmente são limitados a uma parte da estrutura.
Penso que a metáfora de um fluxo de patches não funciona porque na verdade retrocede: O ponto de partida é a estrutura de dados atual e precisamos adaptar as representações passadas à versão atual. Aqui estão três versões do mesmo objeto:
instance Versionable 1 Obj3 where
reader _ = doGet $ Obj3 :$: (fromInt :$: getint) :*: (fromText :$: Atom get)
instance Versionable 2 Obj3 where
reader _ = doGet $ Obj3 :$: Atom get :*: (fromText :$: Atom get)
instance Versionable 3 Obj3 where
reader _ = doGet $ Obj3 :$: Atom get :*: getf2 F2
writer _ Obj3{..} = put f31 >> put f32
Existe alguma regularidade, pois vemos que cada versão anterior é uma adaptação da versão atual.
Daí a ideia de representarreader
como reificadoApplicative
(ou talvezMonad
ic) functor ao qual podem ser aplicadas atualizações cirúrgicas para lidar com versões mais antigas. Mas então eu estou preso em como selecionar algum nó na árvore dos desserializadores atuais para aplicar alguma alteração de maneira tipicamente segura ...
13-02-2017
O ponto 2. acima parece levar a nada além de código complicado, envolvendo muita magia de nível de tipo para um benefício menor. Considere as 3 versões acima mencionadas doObj3
, idealmente, gostaria de encontrar uma maneira de escrever:
geto3 = Obj3 :$: Atom get :*: getf2 F2
instance Versionable 2 Obj3 where
reader _ = doGet $ _replaceAt (0,1) (fromText :$: Atom get) get03
instance Versionable 3 Obj3 where
reader _ = doGet $ get03
writer _ Obj3{..} = put f31 >> put f32
Onde_replaceAt :: (Int, Int) -> Versioned a -> Versioned b -> Versioned b
significa que queremos substituir algumas subárvores no índice(x,y)
no desserializador parab
, cujo tipo éVersioned a
, com o segundo argumento. Parece factível expressar isso de maneira segura, mas isso requer expor a estrutura deObj3
como um tipoT
noVersioned T
.