Symfony serializer - set circular reference global

2019-04-06 07:01发布

问题:

Is there any way to set the circular reference limit in the serializer component of Symfony (not JMSSerializer) with any config or something like that?

I have a REST Application with FOSRestBundle and some Entities that contain other entities which should be serialized too. But I'm running into circular reference errors.

I know how to set it like this:

$encoder    = new JsonEncoder();
$normalizer = new ObjectNormalizer();

$normalizer->setCircularReferenceHandler(function ($object) {
     return $object->getName();
});

But this has to be done in more than one controller (overhead for me). I want to set it globally in the config (.yml) e.g. like this:

framework: 
    serializer:
        enabled: true
        circular_limit: 5

Found no serializer API reference for this so I wonder is it possible or not?

回答1:

The only way I've found is to create your own object normalizer to add the circular reference handler.

A minimal working one can be:

<?php

namespace AppBundle\Serializer\Normalizer;

use Symfony\Component\Serializer\Normalizer\ObjectNormalizer;
use Symfony\Component\PropertyAccess\PropertyAccessorInterface;
use Symfony\Component\PropertyInfo\PropertyTypeExtractorInterface;
use Symfony\Component\Serializer\Mapping\Factory\ClassMetadataFactoryInterface;
use Symfony\Component\Serializer\NameConverter\NameConverterInterface;

class AppObjectNormalizer extends ObjectNormalizer
{
    public function __construct(ClassMetadataFactoryInterface $classMetadataFactory = null, NameConverterInterface $nameConverter = null, PropertyAccessorInterface $propertyAccessor = null, PropertyTypeExtractorInterface $propertyTypeExtractor = null)
    {
        parent::__construct($classMetadataFactory, $nameConverter, $propertyAccessor, $propertyTypeExtractor);

        $this->setCircularReferenceHandler(function ($object) {
            return $object->getName();
        });
    }
}

Then declare as a service with a slithly higher priority than the default one (which is -1000):

<service
    id="app.serializer.normalizer.object"
    class="AppBundle\Serializer\Normalizer\AppObjectNormalizer"
    public="false"
    parent="serializer.normalizer.object">

    <tag name="serializer.normalizer" priority="-500" />
</service>

This normalizer will be used by default everywhere in your project.



回答2:

For a week have I been reading Symfony source and trying some tricks to get it work (on my project and without installing a third party bundle: not for that functionality) and I finally got one. I used CompilerPass (https://symfony.com/doc/current/service_container/compiler_passes.html)... Which works in three steps:

1. Define build method in bundle

I choosed AppBundle because it is my first bundle to load in app/AppKernel.php.

src/AppBundle/AppBundle.php

<?php

namespace AppBundle;

use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\HttpKernel\Bundle\Bundle;

class AppBundle extends Bundle
{
    public function build(ContainerBuilder $container)
    {
        parent::build($container);
        $container->addCompilerPass(new AppCompilerPass());
    }
}

2. Write your custom CompilerPass

Symfony serializers are all under the serializer service. So I just fetched it and added to it a configurator option, in order to catch its instanciation.

src/AppBundle/AppCompilerPass.php

<?php

namespace AppBundle;

use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Reference;



class AppCompilerPass implements CompilerPassInterface
{
    public function process(ContainerBuilder $container)
    {
        $container
            ->getDefinition('serializer')
            ->setConfigurator([
                new Reference(AppConfigurer::class), 'configureNormalizer'
            ]);
    }
}

3. Write your configurer...

Here, you create a class following what you wrote in the custom CompilerPass (I choosed AppConfigurer)... A class with an instance method named after what you choosed in the custom compiler pass (I choosed configureNormalizer).

This method will be called when the symfony internal serializer will be created.

The symfony serializer contains normalizers and decoders and such things as private/protected properties. That is why I used PHP's \Closure::bind method to scope the symfony serializer as $this into my lambda-like function (PHP Closure).

Then a loop through the nomalizers ($this->normalizers) help customize their behaviours. Actually, not all of those nomalizers need circular reference handlers (like DateTimeNormalizer): the reason of the condition there.

src/AppBundle/AppConfigurer.php

<?php

namespace AppBundle;



class AppConfigurer
{
    public function configureNormalizer($normalizer)
    {
        \Closure::bind(function () use (&$normalizer)
        {
            foreach ($this->normalizers as $normalizer)
                if (method_exists($normalizer, 'setCircularReferenceHandler'))
                    $normalizer->setCircularReferenceHandler(function ($object)
                    {
                        return $object->getId();
                    });
        }, $normalizer, $normalizer)();
    }
}

Conclusion

As said earlier, I did it for my project since I dind't wanted FOSRestBundle nor any third party bundle as I've seen over Internet as a solution: not for that part (may be for security). My controllers now stand as...

<?php

namespace StoreBundle\Controller;

use Sensio\Bundle\FrameworkExtraBundle\Configuration\Method;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Route;
use Symfony\Bundle\FrameworkBundle\Controller\Controller;



class ProductController extends Controller
{
    /**
     *
     * @Route("/products")
     *
     */
    public function indexAction()
    {
        $em = $this->getDoctrine()->getManager();
        $data = $em->getRepository('StoreBundle:Product')->findAll();
        return $this->json(['data' => $data]);
    }

    /**
     *
     * @Route("/product")
     * @Method("POST")
     *
     */
    public function newAction()
    {
        throw new \Exception('Method not yet implemented');
    }

    /**
     *
     * @Route("/product/{id}")
     *
     */
    public function showAction($id)
    {
        $em = $this->getDoctrine()->getManager();
        $data = $em->getRepository('StoreBundle:Product')->findById($id);
        return $this->json(['data' => $data]);
    }

    /**
     *
     * @Route("/product/{id}/update")
     * @Method("PUT")
     *
     */
    public function updateAction($id)
    {
        throw new \Exception('Method not yet implemented');
    }

    /**
     *
     * @Route("/product/{id}/delete")
     * @Method("DELETE")
     *
     */
    public function deleteAction($id)
    {
        throw new \Exception('Method not yet implemented');
    }

}