Su propio convertidor JSON o un poco más sobre ExpressionTrees



La serialización y la deserialización son operaciones típicas que el desarrollador moderno trata como triviales. Nos comunicamos con bases de datos, generamos solicitudes HTTP, recibimos datos a través de la API REST y, a menudo, ni siquiera pensamos en cómo funciona. Hoy sugiero escribir mi serializador y deserializador para JSON para averiguar qué hay debajo del capó.

Descargo de responsabilidad


Como la última vez , lo notaré: escribiremos un serializador primitivo, se podría decir, una bicicleta. Si necesita una solución llave en mano, use Json.NET . Estos chicos lanzaron un producto maravilloso que es altamente personalizable, puede hacer mucho y ya está resolviendo los problemas que surgen al trabajar con JSON. Usar tu propia solución es realmente genial, pero solo si necesitas el máximo rendimiento, una personalización especial o si te gustan las bicicletas como a mí me gustan.

Área temática


El servicio para convertir de JSON a una representación de objeto consta de al menos dos subsistemas. Deserializer es un subsistema que convierte JSON (texto) válido en una representación de objeto dentro de nuestro programa. La deserialización implica la tokenización, es decir, analizar JSON en elementos lógicos. El serializador es un subsistema que realiza la tarea inversa: convierte la representación de datos de objetos en JSON.

El consumidor suele ver la siguiente interfaz. Lo simplifiqué deliberadamente para resaltar los principales métodos que se usan con mayor frecuencia.

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

"Bajo el capó", la deserialización incluye la tokenización (análisis de un texto JSON) y la construcción de algunas primitivas que facilitan la creación de una representación de objeto más adelante. Para fines de capacitación, omitiremos la construcción de primitivas intermedias (por ejemplo, JObject, JProperty de Json.NET) e inmediatamente escribiremos datos en el objeto. Esto es un inconveniente, ya que reduce las opciones de personalización, pero es imposible crear una biblioteca completa dentro del marco de un artículo.

Tokenización


Permítame recordarle que el proceso de tokenización o análisis léxico es un análisis del texto con el objetivo de obtener una representación diferente y más estricta de los datos contenidos en él. Típicamente, esta representación se llama tokens o tokens. Para analizar JSON, debemos resaltar las propiedades, sus valores, los símbolos del principio y el final de las estructuras, es decir, los tokens que se pueden representar como JsonToken en el código.

JsonToken es una estructura que contiene un valor (texto), así como un tipo de token. JSON es una notación estricta, por lo que todos los tipos de tokens se pueden reducir a la siguiente enumeración . Por supuesto, sería genial agregar al token sus coordenadas en los datos entrantes (fila y columna), pero la depuración está más allá del alcance de la implementación, lo que significa que JsonToken no contiene estos datos.

Entonces, la forma más fácil de analizar el texto en tokens es leer cada carácter secuencialmente y compararlo con patrones. Necesitamos entender qué significa este o aquel símbolo. Es posible que la palabra clave (verdadero, falso, nulo) comience con este carácter, es posible que este sea el comienzo de la línea (carácter de comillas), y tal vez este carácter en sí sea un token ([,], {,}). La idea general se ve así:

 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; ... } } 

Al mirar el código, parece que puede leer e inmediatamente hacer algo con los datos leídos. No necesitan ser almacenados, deben ser enviados inmediatamente al consumidor. Por lo tanto, un cierto IEnumerator pide, que analizará el texto en pedazos. En primer lugar, esto reducirá la asignación, ya que no necesitamos almacenar resultados intermedios (una matriz de tokens). En segundo lugar, aumentaremos la velocidad del trabajo: sí, en nuestro ejemplo, la entrada es una cadena, pero en una situación real, será reemplazada por Stream (de un archivo o una red), que leemos secuencialmente.

He preparado el código JsonTokenizer , que se puede encontrar aquí . La idea es la misma: el tokenizador va secuencialmente a lo largo de la línea, tratando de determinar a qué se refiere el símbolo o su secuencia. Si logramos comprender, entonces creamos un token y transferimos el control al consumidor. Si aún no está claro, sigue leyendo.

Preparándose para deserializar objetos


Muy a menudo, una solicitud para convertir datos de JSON es una llamada al método genérico Deserialize, donde TOut es el tipo de datos con el que se deben asignar los tokens JSON. Donde Tipo es : es hora de aplicar Reflection and ExpressionTrees . Los conceptos básicos de trabajar con ExpressionTrees, así como por qué las expresiones compiladas son mejores que la reflexión "desnuda", describí en un artículo anterior sobre cómo hacer su AutoMapper . Si no sabe nada sobre Expression.Labmda.Compile (), le recomiendo leerlo. Me parece que el ejemplo del mapeador resultó bastante comprensible.

Entonces, el plan para crear un deserializador de objetos se basa en el conocimiento de que podemos obtener tipos de propiedad del tipo TOut en cualquier momento, es decir, la colección PropertyInfo . Al mismo tiempo, los tipos de propiedad están limitados por la notación JSON: números, cadenas, matrices y objetos. Incluso si no nos olvidamos de nulo, esto no es tanto como podría parecer a primera vista. Y si para cada tipo primitivo nos veremos obligados a crear un deserializador separado, entonces para las matrices y los objetos podemos hacer clases genéricas. Si piensa un poco, todos los serializadores-deserializadores (o convertidores ) se pueden reducir a la siguiente interfaz:

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

El código de un convertidor de tipos primitivos fuertemente tipado es lo más simple posible: extraemos el JsonToken actual del tokenizador y lo convertimos en un valor mediante análisis. Por ejemplo, float.Parse (currentToken.Value). Eche un vistazo a BoolConverter o FloatConverter , nada complicado. A continuación, si necesita un deserializador para bool? o flotante ?, también se puede agregar.

Deserialización de matriz


El código de clase genérico para convertir una matriz de JSON también es relativamente simple. Está parametrizado por el tipo de elemento que podemos extraer Type.GetElementType () . Determinar que un tipo es una matriz también es simple: Type.IsArray . La deserialización de la matriz se reduce a decir tokenizer.MoveNext () hasta que se alcanza un token de tipo ArrayEnd. La deserialización de los elementos de la matriz es la deserialización del tipo de elemento de la matriz, por lo tanto, al crear un ArrayConverter, se le pasa el deserializador del elemento.

A veces hay dificultades con la creación de instancias de implementaciones genéricas, por lo que le diré de inmediato cómo hacerlo. Reflection le permite crear tipos genéricos en tiempo real, lo que significa que podemos usar el tipo creado como argumento para Activator.CreateInstance. Aprovecha esto:

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

Al finalizar los preparativos para crear un deserializador de objetos, puede colocar todo el código de infraestructura asociado con la creación y el almacenamiento de deserializadores en la fachada de JConverter . Será responsable de todas las operaciones de serialización y deserialización de JSON y está disponible para los consumidores como un servicio.

Deserialización de objeto


Permítame recordarle que puede obtener todas las propiedades de tipo T de esta manera: typeof (T) .GetProperties (). Para cada propiedad, puede extraer PropertyInfo.PropertyType , lo que nos dará la oportunidad de crear un IJsonConverter escrito para serializar y deserializar datos de un tipo en particular. Si el tipo de propiedad es una matriz, crearemos una instancia de ArrayConverter o encontraremos una adecuada entre las existentes. Si el tipo de propiedad es un tipo primitivo, entonces los deserializadores (convertidores) ya están creados para ellos en el constructor JConverter.

El código resultante se puede ver en la clase genérica ObjectConverter . Se crea un activador en su constructor, las propiedades se extraen de un diccionario especialmente preparado y para cada uno de ellos se crea un método de deserialización: Acción <TObject, JsonTokenizer>. Es necesario, en primer lugar, para asociar inmediatamente el IJsonConverter con la propiedad deseada, y en segundo lugar, para evitar el boxeo al extraer y escribir tipos primitivos. Cada método de deserialización sabe qué propiedad del objeto saliente se registrará, el deserializador del valor se escribe estrictamente y devuelve el valor exactamente en la forma en que se necesita.

El enlace de un IJsonConverter a una propiedad es el siguiente:

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

La constante Expression.Constant se crea directamente en la expresión, que almacena una referencia a la instancia del deserializador para el valor de la propiedad. Esta no es exactamente la constante que escribimos en "C # normal", ya que puede almacenar un tipo de referencia. A continuación, el método Deserialize se recupera del tipo deserializador, que devuelve el valor del tipo deseado, y luego se llama - Expression.Call . Por lo tanto, obtenemos un método que sabe exactamente dónde y qué escribir. Queda por ponerlo en el diccionario y llamarlo cuando un token del tipo de propiedad con el nombre deseado "viene" del tokenizer. Otra ventaja es que todo funciona muy rápido.

Que rapido


Las bicicletas, como se señaló al principio, tiene sentido escribir en varios casos: si esto es un intento de comprender cómo funciona la tecnología, o si necesita lograr algunos resultados especiales. Por ejemplo, la velocidad. Puede asegurarse de que el deserializador realmente se deserialice con las pruebas preparadas (uso AutoFixture para obtener datos de prueba). Por cierto, probablemente notaste que también escribí la serialización de objetos. Pero como el artículo resultó ser bastante extenso, no lo describiré, solo daré puntos de referencia. Sí, al igual que con el artículo anterior, escribí puntos de referencia utilizando la biblioteca BenchmarkDotNet .

Por supuesto, comparé la velocidad de deserialización con Newtonsoft (Json.NET), como la solución más común y recomendada para trabajar con JSON. Además, justo en su sitio web está escrito: 50% más rápido que DataContractJsonSerializer y 250% más rápido que JavaScriptSerializer. En resumen, quería saber cuánto perdería mi código. Los resultados me sorprendieron: tenga en cuenta que la asignación de datos es casi tres veces menor, y la tasa de deserialización es aproximadamente dos veces más rápida.
MétodoMediaErrorStddevRatioAsignado
Newtonsoft75,39 ms0,3027 ms0,2364 ms1.0035,47 MB
Velo31,78 ms0.1135 ms0,1062 ms0,4212,36 MB

La comparación de la velocidad y la asignación durante la serialización de datos arrojó resultados aún más interesantes. Resulta que el serializador de bicicletas asignó casi cinco veces menos y trabajó casi tres veces más rápido. Si la velocidad realmente me molestara (realmente), sería un claro éxito.
MétodoMediaErrorStddevRatioAsignado
Newtonsoft54,83 ms0,5552 ms0,5222 ms1.0025,44 MB
Velo20,66 ms0,0484 ms0,0429 ms0,385.93 MB

Sí, al medir la velocidad, no utilicé los consejos para aumentar la productividad publicados en el sitio web de Json.NET. Tomé medidas fuera de la caja, es decir, de acuerdo con el escenario más utilizado: JsonConvert.DeserializeObject. Puede haber otras formas de mejorar el rendimiento, pero no las conozco.

Conclusiones


A pesar de la velocidad relativamente alta de serialización y deserialización, no recomendaría abandonar Json.NET en favor de mi propia solución. La ganancia en velocidad se calcula en milisegundos, y se "ahogan" fácilmente en retrasos de red, disco o código, que se encuentra jerárquicamente sobre el lugar donde se aplica la serialización. Apoyar tales soluciones propietarias es un infierno, donde solo se pueden permitir desarrolladores que estén bien versados ​​en el tema.

El alcance de tales bicicletas son aplicaciones que están completamente diseñadas con miras a un alto rendimiento, o proyectos favoritos donde entiendes cómo funciona esta o aquella tecnología. Espero haberte ayudado un poco en todo esto.

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


All Articles