TSantos Serializer Library

build scrutinizer code_coverage docs

Welcome to TSantos Serializer documentation page! This page will show you how to install and use the TSantos Serializer library.

Introduction

With the growth of micro-services a good serializer tool should be use to expose your data over the internet. Such tools should be able to read data (commonly PHP objects) and encode it to any format to be consumed by API client. TSantos Serializer was built focused on performance and APIs which requires fast responses, low CPU and low memory usage as non-functional requirements. Moreover, this library tries to do all this process without loosing the benefits of data-mapping and flexible configurations.

Summary

Installation

Composer

Open a command console, enter your project directory and execute:

$ composer require tsantos/serializer

This command requires you to have Composer installed globally, as explained in the installation chapter of the Composer documentation.

Symfony Applications

If your application is based on Symfony Framework, you can easily install this library through the Official Bundle.

Usage

All the (de-)serialization processes needs an instance of SerializerInterface which can be easily created through the SerializerBuilder. All the examples in this page will consider that the serializer instance is already created in a way like this:

$serializer = (new SerializerBuilder())
    ->build();

Serialize Objects

The simplest way to serialize an object is pass a data to SerializerInterface::serialize method:

$post = new App\Entity\Post(100, 'Post Title');
$encoded = $serializer->serialize($post);
echo $encoded; // {"id":100, "name":"Post Title"}
Collections

You can serialize a collection of objects in the same way you serialize a single object:

$comments = ... ;
$encoded = $serializer->serialize($comments);
echo $encoded; // [{"content":"Comment 1"}, {"content":"Comment 2"}]
Handling Circular Reference

Circular references occurs when an object A serializes an object B which in turns serializes the A again. This library is able to detect such situation and throws the CircularReferenceException exception. In addition you can control how many times the same object will be serialized before the exception is thrown:

try {
    $post = ... ;
    $context = (new SerializationContext())->setCircularReferenceCount(3);
    $encoded = $serializer->serialize($post, $context);
    echo $encoded; // {"id":100, "name":"Post Title"}
} catch (\TSantos\Serializer\Exception\CircularReferenceException $e) {
}

Deserialize Objects

The inverse operation (e.g: deserialization) is quite simple as serializing objects. You just need to provide the type and format of the data being deserialized:

$json = '{"id":100, "name":"Post Title"}';
$post = $serializer->deserialize($json, Post::class, 'json');
echo get_class($post); // App\Entity\Post
Object Instantiator

The serializer will instantiate a new class based on the type parameter of the method SerializerInterface::deserialize. By default it uses the Doctrine Object instantiator to create new instances but you can define your own implementation and configure the serializer to use it:

use TSantos\Serializer\ObjectInstantiator\ObjectInstantiatorInterface;

class MyObjectInstantiator implements ObjectInstantiatorInterface
{
    public function create(string $type, array $data, DeserializationContext $context)
    {
        return new $type();
    }
}

and then:

$serializer = (new SerializerBuilder())
    ->setObjectInstantiator(new MyObjectInstantiator())
    ->build();
Targeting the Deserialization

The serializer can populate the data into an existing object instead of instantiate a fresh instance:

$json = '{"name":"Post Title"}';
$post = ...;
$context = (new DeserializationContext())->setTarget($post);
$post = $serializer->deserialize($json, Post::class, 'json', $post);

Normalizers

Normalizers are powerful services that handles a specific data type and returns its handled version.

Built-in Normalizers
ObjectNormalizer:
Is the most important normalizer in this library. It can receive the object being serialized and normalize it to array.
CollectionNormalizer:
This normalizer iterates over a collection of objects and serializes each of them.
JsonNormalizer:
This normalizer checks whether the object being serialized implements the JsonSerializable interface and call the method jsonSerialize to normalized the data.

Custom normalizers can be easily added to serializer:

class AuthorNormalizer implements NormalizerInterface
{
    public function normalize($data, SerializationContext $context)
    {
        return $data->getUsername();
    }

    public function supportsNormalization($data, SerializationContext $context): bool
    {
        return $data instanceof Author;
    }
}

and then:

$builder = (new SerializerBuilder())
    ->addNormalizer(new AuthorNormalizer())
    ->build();

Encoders

Encoders are services that encodes a normalized data into a specific format and vice-versa.

Built-in Encoders
JsonEncoder:
Encodes and decode data in JSON format.

Event Listeners

Event listeners gives you the ability to hook into a serialization process. They ables you the opportunity to change the data before and after a serialization process.:

$builder = (new SerializerBuilder())
    ->addListener(Events::PRE_SERIALIZATION, function (PreSerializationEvent $event) {
        /** @var Post $post */
        $post = $event->getObject();
        $post->setSummary('modified summary');
    })
    ->build();
Event Subscribers

Instead of adding listener through closures, you can add event subscribers to add listeners to serializer:

class MyEventSubscriber implements EventSubscriberInterface
{
    public static function getListeners(): array
    {
        return [
            Events::PRE_SERIALIZATION => 'onPreSerialization',
            Events::POST_SERIALIZATION => 'onPostSerialization',
            Events::PRE_DESERIALIZATION => 'onPreDeserialization',
            Events::POST_DESERIALIZATION => 'onPostDeserialization',
        ];
    }

    public function onPreSerialization(PreSerializationEvent $event): void {}
    public function onPostSerialization(PostSerializationEvent $event): void {}
    public function onPreDeserialization(PreDeserializationEvent $event): void {}
    public function onPostDeserialization(PostDeserializationEvent $event): void {}
}

and then:

$builder = (new SerializerBuilder())
    ->addSubscriber(new MyEventSubscriber())
    ->build();
Events
Events::PRE_SERIALIZATION:
Listeners have the opportunity to change the state of the object before the serialization.
Events::POST_SERIALIZATION::
Listeners have the opportunity to change the array generated by de serialization.
Events::PRE_DESERIALIZATION::
Listeners have the opportunity to change the array generated by the deserialization.
Events::POST_DESERIALIZATION::
Listeners have the opportunity to do some validations after the deserialization finishes.

Caching

The serializer can cache two types of information:

  1. the generated hydrator classes
  2. the class metadata.
Hydrator Cache

You should provide the location where the generated serializer classes will be stored. Defaults to /tmp/serializer/hydrators:

$serializer = (new SerializerBuilder())
    ->setHydratorDir(__DIR__ . '/var/cache/serializer/hydrators')
    ->build();
Metadata Cache

To avoid parsing all classes to read its metadata data all the time, the serializer can cache the metadata and use it on the subsequent request:

$serializer = (new SerializerBuilder())
    ->setMetadataCacheDir(__DIR__ . '/var/cache/serializer/metadata')
    ->build();

Built-in metadata cache strategies:

FileCache:
Will be automatically configured when provide a directory like the bellow example.
DoctrineCacheAdapter:

Any class implementing Cache interface of Doctrine

$serializer = (new SerializerBuilder())
    ->setMetadataCache(new DoctrineCacheAdapter(
        new \Doctrine\Common\Cache\RedisCache(...)
    ))
    ->build();
PsrCacheAdapter:

Any class implementing CacheItemPoolInterface interface.

$serializer = (new SerializerBuilder())
    ->setMetadataCache(new PsrCacheAdapter(
        $psrCache
    ))
    ->build();

Hydrator Generation

This library generates PHP classes (e.g: hydrator) that will convert objects to array representation and vice-versa. Those classes are automatically generated based on you class mapping and stored in somewhere defined in your project. Therefore, to avoid unnecessary I/O to generate those classes, you can configure the strategy when generating them.

FileNotExists:

This strategy will generate the hydrators only if they don’t exist in filesystem. Good for development environments.

$serializer = (new SerializerBuilder())
    ->setHydratorGenerationStrategy(HydratorLoader::AUTOGENERATE_FILE_NOT_EXISTS)
    ->build();
Always:

The hydrators will be generated regardless of its existence. Good for debugging.

$serializer = (new SerializerBuilder())
    ->setHydratorGenerationStrategy(HydratorLoader::AUTOGENERATE_ALWAYS)
    ->build();
Never:

The serializer will never check the hydrator’s existence and will never generate them. This strategy improves the performance in production environment.

$serializer = (new SerializerBuilder())
    ->setHydratorGenerationStrategy(HydratorLoader::AUTOGENERATE_NEVER)
    ->build();

Mapping

Telling to serializer how it should transform your data is a very important step to assert that the transformation will not serialize the data with a wrong data type.

Zero User Mapping

TSantos Serializer will do its better to extract the must important metadata information from your class by reading its structure without the needing add custom annotations on every single property of your class. All you need to do is to write classes with good type-hint parameters and proper return types:

namespace App\Entity;

class Post
{
    private $id;
    private $title;
    private $comments;
    private $author;
    public function __construct(int $id) { ... } // mutator
    public function setTitle(string $title): { ... } // mutator
    public function addComment(Comment $comment) { ... } // mutator
    public function getAuthor(): Author { ... } // accessor
}

The serializer is smart enough to extract the data types from the property’s mutators and accessors and will (de-)serialize them respecting those types.

The previous example is a good start point because you don’t need to worry about the boring process of mapping all properties of all classes you want to serialize and is enough to cover the must use cases. However, you don’t have any control of what data should be serialized and when it should be serialized. That’s why you should use the mapping mechanism if you want a refined control over the serialization process.

The Serializer Builder

Before going ahead with mapping options, lets see how you should use the Serializer Builder to tell the serialize where are your mapping information:

$builder = new SerializerBuilder();

$serializer = $builder
  ->addMetadataDir('App\Document', '/project/config/serializer') // yaml metadata files
  ->addMetadataDir('App\Entity', '/project/src/Entity') // annotation metadata files
  ->build();

Note

Because the builder accepts many metadata directories, you can mix the supported mapping formats in the same serializer instance.

Note

You need to require the Symfony Yaml component to map your classes using the YAML format

$ composer require symfony/yaml

Note

You need to require the Doctrine Annotations component to map your classes using annotations syntax

$ composer require doctrine/annotations

and then enable the annotation reader in the Serializer Builder:

$serializer = $builder
  ->enableAnnotations()
  ->build();

Options Reference

BaseClass

Define what class the generated class should extends

/**
 * @BaseClass("My\Custom\Class")
 */
class Post {}
App\Entity\Post:
    baseClass: "My\Custom\Class"
<class name="App\Entity\Post" base-class="My\Custom\Class">
ExposeAs

The serialized name

/**
 * @ExposeAs("full_name")
 */
private $fullName;
properties:
    fullName:
        exposeAs: "full_name"
<property name="fullName" type="integer" expose-as="full_name" />
Getter

The accessor method to read the value

/**
 * @Getter("getMyCustomFullName")
 */
private $fullName;
properties:
    fullName:
        getter: "getMyCustomFullName"
<property name="fullName" getter="getMyCustomFullName" />

Tip

If you omit the getter option, the serializer will try to guess the getter automatically

Groups

The list of groups that the property can be serialized

/**
 * @Groups({"web","v1"})
 */
private $fullName;
properties:
    fullName:
        groups: ["web", "v1"]
<property name="fullName" groups="web,v1" />
<!-- or -->
<property name="fullName">
    <groups>
        <value>web</value>
        <value>v1</value>
    </groups>
</property>
Options

A key/value used by metadata configurators

/**
 * @Options({"format":"Y-m-d"})
 */
private $birthday;
properties:
    birthday:
        options: {"format":"Y-m-d"}
<property name="birthday">
    <options>
        <option name="format">Y-m-d</option>
    </options>
</property>

Tip

Metadata configurators can access the property’s options to modify its behavior.

Read Only

The property cannot be deserialized

/**
 * @ReadOnly
 */
private $id;
properties:
    id:
        readOnly: true
<property name="id" read-only="true">
Read Value Filter

A filter applied to the property value before encoding

/**
 * @ReadValueFilter("strtolower($value)")
 */
private $username;
properties:
    username:
        readValueFilter: "strtolower($value)"
<property name="username" read-value-filter="strtolower($value)" />

Tip

Metadata configurators can change the read-value-filter to customize the input/output of property’s values.

Setter

The mutator method to write the value

/**
 * @Setter("setMyCustomFullName")
 */
private $fullName;
properties:
    fullName:
        getter: "setMyCustomFullName"
<property name="fullName" getter="setMyCustomFullName" />

Tip

If you omit the setter option, the serializer will try to guess the setter automatically.

Type

The data type of mapped property

/**
 * @Type("integer")
 */
private $id;
properties:
    id:
        type: "integer"
<property name="id" type="integer" />

Tip

If you omit the type, the serializer will try to guess the type automatically.

Virtual Property

Mark a method as a virtual property. Its return will be encoded within the properties data.

/**
 * @VirtualProperty
 */
public function getAge(): int
{
    ...
}
virtualProperties:
    getAge: ~
<virtual-property name="getAge" />

Tip

If you omit the type option, the serializer will try to guess the type automatically thanks to metadata configurators.

Write Value Filter

A filter applied to the property value before writing it to objects

/**
 * @WriteValueFilter("\DateTime::createFromFormat('Y-m-d', $value)")
 */
private $birthday;
properties:
    birthday:
        writeValueFilter: "\DateTime::createFromFormat('Y-m-d', $value)"
<property name="username" write-value-filter="\DateTime::createFromFormat('Y-m-d', $value)" />

Tip

Metadata configurators can change the write-value-filter to customize the input/output of property’s values.

Performance

There is no difference in terms of performance between the mapping formats. In fact, the metadata generated by the mapping will be cached and reused in the next serialization operation, so you can choose the most comfortable format for you.

Benchmark

There are a lot of other libraries that does the same job as TSantos Serializer like JMS Serializer and Symfony Serializer. However, after making some benchmark in those library, I realized that they have a considerable overhead when serializing some data.

That’s why I decided to write a different technique to serialize complex objects and the results were very satisfactory:

_images/serialization_bench.png

As you can see, TSantos Serializer is 6x and 7x faster than Symfony Serializer and JMS Serializer respectively. This benchmark was generated through the PHPBench.

The source code of this benchmark is available on GitHub so you can clone and run it by yourself.

Performance Notes

The serialization process can be separated in two main operations: compile time and runtime.

Compile time:
Operation that compiles the class metadata and generate the hydrator classes. The compile time has a considerable number of I/O operations which can reduce the performance of your application and you should avoid operations in production environment.
Runtime:
Operation that transforms the data through the hydrators. Very fast after the class metadata is already compiled.

Another important topic about performance is how the serializer will read and write data from your objects. By using explicit accessors and mutators is slightly faster then using reflection to access private/protected properties. Keep this in mind in order to boost your application.