Seu próprio conversor JSON ou um pouco mais sobre o ExpressionTrees



Serialização e desserialização são operações típicas que o desenvolvedor moderno trata como triviais. Nós nos comunicamos com bancos de dados, geramos solicitações HTTP, recebemos dados por meio da API REST e geralmente nem pensamos em como isso funciona. Hoje, sugiro escrever meu serializador e desserializador para o JSON para descobrir o que está por trás.

Isenção de responsabilidade


Como da última vez , observarei: escreveremos um serializador primitivo, poderíamos dizer, uma bicicleta. Se você precisar de uma solução pronta para usar, use o Json.NET . Esses caras lançaram um produto maravilhoso, altamente personalizável, que pode fazer muito e está resolvendo problemas que surgem ao trabalhar com o JSON. Usar sua própria solução é muito legal, mas apenas se você precisar de desempenho máximo, personalização especial ou se gosta de motos da maneira que eu gosto.

Área de assunto


O serviço para converter de JSON em uma representação de objeto consiste em pelo menos dois subsistemas. O desserializador é um subsistema que transforma JSON (texto) válido em uma representação de objeto dentro de nosso programa. A desserialização envolve a tokenização, ou seja, a análise do JSON em elementos lógicos. O serializador é um subsistema que executa a tarefa inversa: transforma a representação de objeto dos dados em JSON.

O consumidor costuma ver a seguinte interface. Eu o simplifiquei deliberadamente para destacar os principais métodos mais usados.

public interface IJsonConverter { T Deserialize<T>(string json); string Serialize(object source); } 

“Sob o capô”, a desserialização inclui tokenização (análise de um texto JSON) e construção de algumas primitivas que facilitam a criação de uma representação de objeto posteriormente. Para fins de treinamento, pularemos a construção de primitivas intermediárias (por exemplo, JObject, JProperty do Json.NET) e gravaremos imediatamente os dados no objeto. Isso é um sinal de menos, pois reduz as opções de personalização, mas é impossível criar uma biblioteca inteira dentro da estrutura de um artigo.

Tokenização


Deixe-me lembrá-lo de que o processo de tokenização ou análise lexical é uma análise do texto com o objetivo de obter uma representação diferente e mais rigorosa dos dados nele contidos. Normalmente, essa representação é chamada de tokens ou tokens. Para fins de análise de JSON, devemos destacar as propriedades, seus valores, os símbolos do início e do fim das estruturas - ou seja, tokens que podem ser representados como JsonToken no código.

JsonToken é uma estrutura que contém um valor (texto), bem como um tipo de token. JSON é uma notação estrita; portanto, todos os tipos de tokens podem ser reduzidos para a próxima enumeração . Obviamente, seria bom adicionar ao token suas coordenadas nos dados recebidos (linha e coluna), mas a depuração está além do escopo da implementação, o que significa que o JsonToken não contém esses dados.

Portanto, a maneira mais fácil de analisar texto em tokens é ler cada caractere seqüencialmente e compará-lo com os padrões. Precisamos entender o que esse ou aquele símbolo significa. É possível que a palavra-chave (verdadeiro, falso, nulo) comece com esse caractere, é possível que este seja o começo da linha (aspas) ou talvez esse caractere em si seja um token ([,], {,}). A ideia geral é assim:

 var tokens = new List<JsonToken>(); for (int i = 0; i < json.Length; i++) { char ch = json[i]; switch (ch) { case '[': tokens.Add(new JsonToken(JsonTokenType.ArrayStart)); break; case ']': tokens.Add(new JsonToken(JsonTokenType.ArrayEnd)); break; case '"': string stringValue = ReadString(); tokens.Add(new JsonToken(JsonTokenType.String, stringValue); break; ... } } 

Observando o código, parece que você pode ler e fazer algo imediatamente com os dados lidos. Eles não precisam ser armazenados, devem ser enviados imediatamente ao consumidor. Assim, um certo IEnumerator implora, que analisará o texto em pedaços. Em primeiro lugar, isso reduzirá a alocação, pois não precisamos armazenar resultados intermediários (uma matriz de tokens). Em segundo lugar, aumentaremos a velocidade do trabalho - sim, no nosso exemplo, a entrada é uma string, mas em uma situação real, ela será substituída pelo Stream (de um arquivo ou rede), que lemos sequencialmente.

Eu preparei o código JsonTokenizer , que pode ser encontrado aqui . A idéia é a mesma - o tokenizador segue sequencialmente a linha, tentando determinar a que o símbolo ou sua sequência se refere. Se isso for entendido, criamos um token e transferimos o controle para o consumidor. Se ainda não estiver claro, continue a ler.

Preparando para desserializar objetos


Na maioria das vezes, uma solicitação para converter dados do JSON é uma chamada ao método genérico Deserialize, em que TOut é o tipo de dados com o qual os tokens JSON devem ser mapeados. Onde está o tipo : é hora de aplicar as árvores de reflexão e expressão . Os princípios básicos do trabalho com o ExpressionTrees, bem como o porquê das expressões compiladas serem melhores do que a reflexão "vazia", ​​descrevi em um artigo anterior sobre como criar seu AutoMapper . Se você não sabe nada sobre Expression.Labmda.Compile () - eu recomendo a leitura. Parece-me que o exemplo do mapeador acabou bem compreensível.

Portanto, o plano para criar um desserializador de objetos se baseia no conhecimento de que podemos obter tipos de propriedades do tipo TOut a qualquer momento, ou seja, a coleção PropertyInfo . Ao mesmo tempo, os tipos de propriedade são limitados pela notação JSON: números, cadeias, matrizes e objetos. Mesmo se não esquecermos o nulo, isso não é o que parece à primeira vista. E se para cada tipo primitivo seremos forçados a criar um desserializador separado, então para matrizes e objetos podemos criar classes genéricas. Se você pensa um pouco, todos os serializadores-desserializadores (ou conversores ) podem ser reduzidos para a seguinte interface:

 public interface IJsonConverter<T> { T Deserialize(JsonTokenizer tokenizer); void Serialize(T value, StringBuilder builder); } 

O código de um conversor de tipos primitivos fortemente tipado é o mais simples possível: extraímos o JsonToken atual do tokenizer e o transformamos em valor analisando. Por exemplo, float.Parse (currentToken.Value). Dê uma olhada no BoolConverter ou no FloatConverter - nada complicado. Em seguida, se você precisar de um desserializador para bool? ou float?, também pode ser adicionado.

Desserialização de matriz


O código de classe genérico para converter uma matriz de JSON também é relativamente simples. É parametrizado pelo tipo de elemento que podemos extrair Type.GetElementType () . Determinar que um tipo é uma matriz também é simples: Type.IsArray . A desserialização de matriz se resume a dizer tokenizer.MoveNext () até que um token do tipo ArrayEnd seja atingido. A desserialização dos elementos da matriz é a desserialização do tipo de elemento da matriz; portanto, ao criar um ArrayConverter, o desserializador de elemento é passado para ele.

Às vezes, há dificuldades com a instanciação de implementações genéricas, por isso vou lhe dizer imediatamente como fazê-lo. O Reflection permite criar tipos genéricos em tempo real, o que significa que podemos usar o tipo criado como argumento para Activator.CreateInstance. Aproveite isso:

 Type elementType = arrayType.GetElementType(); Type converterType = typeof(ArrayConverter<>).MakeGenericType(elementType); var converterInstance = Activator.CreateInstance(converterType, object[] args); 

Preparativos finais para a criação de um desserializador de objetos, você pode colocar todo o código de infraestrutura associado à criação e armazenamento de desserializadores na fachada do JConverter . Ele será responsável por todas as operações de serialização e desserialização do JSON e estará disponível para os consumidores como um serviço.

Desserialização de objetos


Deixe-me lembrá-lo de que você pode obter todas as propriedades do tipo T assim: typeof (T) .GetProperties (). Para cada propriedade, você pode extrair PropertyInfo.PropertyType , o que nos dará a oportunidade de criar um IJsonConverter digitado para serializar e desserializar dados de um tipo específico. Se o tipo da propriedade for uma matriz, instanciamos o ArrayConverter ou encontramos uma adequada entre as existentes. Se o tipo de propriedade for um tipo primitivo, os desserializadores (conversores) já serão criados para eles no construtor JConverter.

O código resultante pode ser visualizado na classe genérica ObjectConverter . Um ativador é criado em seu construtor, as propriedades são extraídas de um dicionário especialmente preparado e para cada um deles é criado um método de desserialização - Ação <TObject, JsonTokenizer>. É necessário, em primeiro lugar, para associar imediatamente o IJsonConverter à propriedade desejada e, em segundo lugar, para evitar o boxe ao extrair e escrever tipos primitivos. Cada método de desserialização sabe qual propriedade do objeto de saída será registrada, o desserializador do valor é estritamente digitado e retorna o valor exatamente na forma em que é necessário.

A ligação de um IJsonConverter a uma propriedade é a seguinte:

 Type converterType = propertyValueConverter.GetType(); ConstantExpression Expression.Constant(propertyValueConverter, converterType); MethodInfo deserializeMethod = converterType.GetMethod("Deserialize"); var value = Expression.Call(converter, deserializeMethod, tokenizer); 

A constante Expression.Constant é criada diretamente na expressão, que armazena uma referência à instância do desserializador para o valor da propriedade. Esta não é exatamente a constante que escrevemos em "C # regular", pois ela pode armazenar um tipo de referência. Em seguida, o método Deserialize é recuperado do tipo desserializador, que retorna o valor do tipo desejado e, em seguida, é chamado - Expression.Call . Assim, obtemos um método que sabe exatamente onde e o que escrever. Resta colocá-lo no dicionário e chamá-lo quando um token do tipo Property com o nome desejado “vem” do tokenizer. Outra vantagem é que tudo funciona muito rapidamente.

Quão rápido


Bicicletas, como foi observado no início, faz sentido escrever em vários casos: se essa é uma tentativa de entender como a tecnologia funciona, ou você precisa obter alguns resultados especiais. Por exemplo, velocidade. Você pode ter certeza de que o desserializador realmente desserializa com os testes preparados (eu uso o AutoFixture para obter dados do teste). A propósito, você provavelmente notou que eu também escrevi serialização de objetos. Mas como o artigo se mostrou bastante amplo, não o descreverei, mas apenas darei referências. Sim, assim como no artigo anterior, escrevi benchmarks usando a biblioteca BenchmarkDotNet .

Obviamente, comparei a velocidade de desserialização com a Newtonsoft (Json.NET), como a solução mais comum e recomendada para trabalhar com JSON. Além disso, no site deles está escrito: 50% mais rápido que o DataContractJsonSerializer e 250% mais rápido que o JavaScriptSerializer. Em resumo, eu queria saber quanto meu código perderia. Os resultados me surpreenderam: observe que a alocação de dados é quase três vezes menor e a taxa de desserialização é cerca de duas vezes mais rápida.
MétodoMeanErroStddevRatioAlocado
Newtonsoft75,39 ms0,3027 ms0,2364 ms1,0035.47 MB
Velo31,78 ms0,11135 ms0,1062 ms0,4212,36 MB

A comparação de velocidade e alocação durante a serialização de dados produziu resultados ainda mais interessantes. Acontece que o serializador da bicicleta alocou quase cinco vezes menos e trabalhou quase três vezes mais rápido. Se a velocidade realmente me incomodasse (realmente), seria um claro sucesso.
MétodoMeanErroStddevRatioAlocado
Newtonsoft54,83 ms0,5582 ms0,5222 ms1,0025.44 MB
Velo20,66 ms0,0484 ms0,0429 ms0,385,93 MB

Sim, ao medir a velocidade, não usei as dicas para aumentar a produtividade publicadas no site Json.NET. Tirei as medidas da caixa, ou seja, de acordo com o cenário mais usado: JsonConvert.DeserializeObject. Pode haver outras maneiras de melhorar o desempenho, mas eu não as conheço.

Conclusões


Apesar da velocidade relativamente alta de serialização e desserialização, eu não recomendaria abandonar o Json.NET em favor da minha própria solução. O ganho de velocidade é calculado em milissegundos e eles "afogam-se" facilmente em atrasos, disco ou código da rede, que está localizado hierarquicamente acima do local onde a serialização é aplicada. Apoiar essas soluções proprietárias é um inferno, onde apenas os desenvolvedores que são bem versados ​​no assunto podem ser permitidos.

O escopo dessas bicicletas são aplicativos totalmente projetados com vistas ao alto desempenho ou projetos para animais de estimação nos quais você entende como essa ou aquela tecnologia funciona. Espero ter ajudado um pouco em tudo isso.

Source: https://habr.com/ru/post/pt464525/


All Articles