I was looking for a way to serialize domain objects into JSON strings and back again into objects of the original class. This is essential for webservices, especially those built around the JSON-RPC specification that allow clients to make calls to their server and, for example add new personal data to their database of contacts. It makes life a lot easier when this data matches the field names and inner structure of your own domain objects. For example, a JSON string like {"name":"Matthias Noback"}
should be easily translatable to an object of class Acme\Webservice\Entity\Person
with a private property name. But how do you know which class to instantiate, when you only have the simple JSON string above?
The JSON-RPC specifies under the title "JSON Class hinting" a way you could add a clue about which class would be appropriate (and also which constructor arguments should be used), as part of your JSON object. Following this specification, the example above should look like this:
{"__jsonclass__":["Acme\\WebserviceBundle\\Entity\\Person",[]],"name":"Matthias Noback"}
We have a specification, now we need some tools. Symfony2 already gives us the beautiful Serializer
component, which is not widely used inside the framework itself (as far as I can see, nowhere?), but is an essential tool when creating webservices. The Serializer
receives a set of Encoder
objects and a set of Normalizer
objects. Encoders take care of the conversion between simple PHP data types (like null, scalar and array) and an XML or JSON string for example. Normalizers are there to convert more complicated data types (like objects) to the simple ones. Writing a custom normalizer allows you to make this conversion very specific for your own objects. Because we are going to implement the "JSON Class hinting" specification, we will create a JsonClassHintingNormalizer
.
Creating the custom Normalizer
First we implement the normalize()
method. This method receives an object of (for example) class Acme\WebserviceBundle\Entity\Person
. It will eventually return an array containing the data the may be encoded directly into JSON.
namespace Acme\WebserviceBundle\Normalizer;
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
class JsonClassHintingNormalizer implements NormalizerInterface
{
public function normalize($object, $format = null)
{
$data = array();
$reflectionClass = new \ReflectionClass($object);
$data['__jsonclass__'] = array(
get_class($object),
array(), // constructor arguments
);
foreach ($reflectionClass->getMethods(\ReflectionMethod::IS_PUBLIC) as $reflectionMethod) {
if (strtolower(substr($reflectionMethod->getName(), 0, 3)) !== 'get') {
continue;
}
if ($reflectionMethod->getNumberOfRequiredParameters() > 0) {
continue;
}
$property = lcfirst(substr($reflectionMethod->getName(), 3));
$value = $reflectionMethod->invoke($object);
$data[$property] = $value;
}
return $data;
}
}
As you can see, the $data
array will be filled by calling all the original object's getter methods that have no required arguments. The normalize()
method also adds a key called "jsonclass" to the array, which contains the class name of the object and an array of constructor arguments (I leave the implementation for this to the reader).
Next, we define the denormalize()
method. This method receives data that is decoded from a JSON string into an array.
class JsonClassHintingNormalizer implements NormalizerInterface
{
...
public function denormalize($data, $class, $format = null)
{
$class = $data['__jsonclass__'][0];
$reflectionClass = new \ReflectionClass($class);
$constructorArguments = $data['__jsonclass__'][1] ?: array();
$object = $reflectionClass->newInstanceArgs($constructorArguments);
unset($data['__jsonclass__']);
foreach ($data as $property => $value) {
$setter = 'set' . $property;
if (method_exists($object, $setter)) {
$object->$setter($value);
}
}
return $object;
}
}
It looks for the key "jsonclass" and based on it's value, it instantiates the proper class. Then it walks through all the values in the data array and tries to find a corresponding setter method for them.
Finally, the Serializer
needs some other methods, so it can ask the JsonClassHintingNormalizer
if it supports normalization and/or denormalization for the given object and/or data:
class JsonClassHintingNormalizer implements NormalizerInterface
{
...
public function supportsNormalization($data, $format = null)
{
return is_object($data) && 'json' === $format;
}
public function supportsDenormalization($data, $type, $format = null)
{
return isset($data['__jsonclass__']) && 'json' === $format;
}
}
This means the normalizer works only for the "json" format and can only denormalize if a "jsonclass" is specified.
Using Serializer
with the new Normalizer
All this may be put to work by creating an instance of Serializer
and providing it with our new normalizer and a JSON encoder:
use Symfony\Component\Serializer\Serializer;
use Symfony\Component\Serializer\Encoder\JsonEncoder;
use Acme\WebserviceBundle\Normalizer\JsonClassHintingNormalizer;
use Acme\WebserviceBundle\Entity\Person;
$serializer = new Serializer(array(
new JsonClassHintingNormalizer(),
), array(
'json' => new JsonEncoder(),
));
$person = new Person();
$person->setName('Matthias Noback');
$serialized = $serializer->serialize($person, 'json');
echo $serialized;
The result of this will be:
{"__jsonclass__":["Acme\\WebserviceBundle\\Entity\\Person",[]],"name":"Matthias Noback"}
And when we deserialize this string:
$deserialized = $serializer->deserialize($serialized, null, 'json');
var_dump($deserialized);
This is what we will get (and what we really wanted!):
object(Acme\WebserviceBundle\Entity\Person)#619 (1) {
["name":"Acme\WebserviceBundle\Entity\Person":private]=>
string(15) "Matthias Noback"
}
What about recursion?
It is fairly easy to adapt the implementation given above to allow for recursively defined objects. I will demonstrate this in another post, yet to write.
this is incredibly interesting. I try to do the same using jms serilazer. Any clue ?
Thanks Florian! The JMSSerializer offers ways to influence object construction (look in the library, there are some interfaces for it). You might also be able to influence the process using serialization event listeners.
Thanks. I tried with many different ways, (custom visitors, listeners, handlers).
I finally opted out for a type handler. Not very generic, since I know the type internals but it works! see https://github.com/docteurk...
Cool, interesting project.
for instance there is a guy on the mailing list tryign to do an encoder to just nest the data json into a key like "message": { data here }, something just like that very custom
Ah, I see. I answered your question there.
when will you write the example for a custom encoder? please :)
Of course, which encoder do you mean?
Interesting post. You might be interested to have a look at JSON-LD - an upcoming W3C standard - and it's @type element. The specs are available at http://json-ld.org/
Thanks for your suggestion! JSON-LD seems to be a great addition to the otherwise "untyped" JSON standard. It is more general then the single
__jsonclass__
property.When working on an API this is really meaningful.
thanks
change this public function supportsNormalization($data, $format = null)
{
return is_object($data) && 'json' === $format;
into
public function supportsNormalization($object, $format = null)
{
return is_object($object) && 'json' === $format;
Actually, the signature of the
supportsNormalization
method should match the one in the interface. Anyway, we don't know yet if $data is an object, it may also be an array, or a scalar value.