开发TEEBB时踩过的坑--Doctrine动态Mapping

开发TEEBB时踩过的坑--Doctrine动态Mapping

摘要:TEEBB(至文内容管理系统)是基于Symfony开发的,使用了Doctrine的ORM套件。 TEEBB中类型EntityType的每个字段都在Mysql中对应了一张表。

“啊,我是cgx,我现在在LA,我遇到了一班很坏很坏的人呐,VX转账300帮我回HK。”

不知道为什么写这篇时脑子里就冒出了这个梗。“You Know” TEEBB(至文内容管理系统)是基于Symfony开发的,使用了Doctrine的ORM套件。
TEEBB中类型EntityType的每个字段都在Mysql中对应了一张表。看下图:

开发TEEBB时踩过的坑--Doctrine动态Mapping 我们在“文章”类型中添加了“图像”字段,对应的会在Mysql中动态的添加一张表。 图像字段表Entity如下:

//\Teebb\CoreBundle\Entity\Fields\ReferenceImageItem 注:因篇幅原因省去getters 和 setters
/**
 * 引用图像字段在库中的存储
 * @ORM\Entity(repositoryClass="Teebb\CoreBundle\Repository\Fields\FieldRepository")
 *
 * @author Quan Weiwei <qww.zone@gmail.com>
 */
class ReferenceImageItem extends BaseFieldItem
{
    /**
     * 单向多对一关系 对应文件库的entity_id
     * @var FileManaged|null
     * @ORM\ManyToOne(targetEntity="Teebb\CoreBundle\Entity\FileManaged")
     * @ORM\JoinColumn(name="reference_file_id")
     */
    private $value;

    /**
     * 图像alt信息
     * @var string|null
     * @Gedmo\Translatable
     * @ORM\Column(type="string", length=255, nullable=true)
     */
    private $alt;

    /**
     * 图像title信息
     * @var string|null
     * @Gedmo\Translatable
     * @ORM\Column(type="string", length=255, nullable=true)
     */
    private $title;

    /**
     * 图像宽度信息
     * @var integer
     * @ORM\Column(type="integer", nullable=true)
     */
    private $width;

    /**
     * 图像高度信息
     * @var integer
     * @ORM\Column(type="integer", nullable=true)
     */
    private $height;
}

BaseFieldItem类如下:

/**
 * Field Entity基类
 *
 * @ORM\MappedSuperclass()
 *
 * @author Quan Weiwei <qww.zone@gmail.com>
 */
class BaseFieldItem
{
    /**
     * @ORM\Id()
     * @ORM\GeneratedValue()
     * @ORM\Column(type="integer")
     */
    protected $id;

    /**
     * 内容实体类型的别名 比如:'content', 'taxonomy', 'comment'等
     * @var string
     * @ORM\Column(type="string", length=255, nullable=false)
     */
    protected $types;

    /**
     * 类型实体entity, many-to-one, 多个字段值对应一个entity
     * @var object|null
     */
    protected $entity;

    /**
     * 同一字段不限制数量时,用于排序
     * @var integer
     * @ORM\Column(type="integer")
     */
    protected $delta;
}

BaseFieldItem的entity属性对应的是类型EntityType的实体Entity,字段对象使用此属性关联到具体的实体Entity对象。如果内容类型添加了图像字段entity属性关联到Content类的对象,如果是分类类型添加了图像字段则entity属性需要关联到Taxonomy类的对象。所以entity属性就需要动态mapping。
Doctrine的事件中提供了loadClassMetadata事件,可以通过此事件对字段属性进行动态mapping。

编写我们的Subscriber对此事件进行订阅:

//\Teebb\CoreBundle\Subscriber\DynamicChangeFieldMetadataSubscriber
//注:篇幅原因此类并非完整,请下载源码查看完整类实现
//此类实现的是Doctrine的EventSubscriber
use Doctrine\Common\EventSubscriber;

class DynamicChangeFieldMetadataSubscriber implements EventSubscriber
{
    /**
     * @param LoadClassMetadataEventArgs $args
     * @throws MappingException
     */
    public function loadClassMetadata(LoadClassMetadataEventArgs $args)
    {
        $classMetadata = $args->getClassMetadata();
        $className = $classMetadata->getName();

        //如果 $className 是以下类型则进行动态字段映射修改
        $modifyEntityIemArray = [
            BooleanItem::class,
            DatetimeItem::class,
            DecimalItem::class,
            EmailItem::class,
            FloatItem::class,
            IntegerItem::class,
            LinkItem::class,
            ListFloatItem::class,
            ListIntegerItem::class,
            ReferenceContentItem::class,
            ReferenceFileItem::class,
            ReferenceImageItem::class,
            ReferenceTaxonomyItem::class,
            ReferenceUserItem::class,
            StringFormatItem::class,
            StringItem::class,
            TextFormatItem::class,
            TextFormatSummaryItem::class,
            TextItem::class,
            CommentItem::class
        ];

        if (in_array($className, $modifyEntityIemArray)) {
            $this->modifyFieldEntityClassMetaData($classMetadata, $this->getFieldConfiguration(), $this->getTargetContentClassName());
        }
    }

    /**
     * 动态修改ClassMetadata
     * @param ClassMetadata $classMetadata
     * @param FieldConfiguration|null $fieldConfiguration
     * @param string|null $entityClassName content entity全类名
     * @throws MappingException
     */
    private function modifyFieldEntityClassMetaData(ClassMetadata $classMetadata, ?FieldConfiguration $fieldConfiguration, ?string $entityClassName)
    {
        if ($fieldConfiguration == null || $entityClassName == null) {
            return;
        }
        //设置字段表名
        $fieldAlias = $fieldConfiguration->getFieldAlias();
        $classMetadata->setPrimaryTable(['name' => $fieldConfiguration->getBundle() . '__field_' . $fieldAlias]);

        /**@var FieldDepartConfigurationInterface $fieldDepartConfiguration * */
        $fieldDepartConfiguration = $fieldConfiguration->getSettings();

        $doctrineType = $fieldDepartConfiguration->getType();

        //映射字段的entity属性
        if (!$classMetadata->hasAssociation('entity')) {
            $classMetadata->mapManyToOne([
                'fieldName' => 'entity',
                'targetEntity' => $entityClassName,
                'cascade' => ['remove', 'persist'],
                'joinColumns' => [
                    [
                        'name' => 'entity_id',
                        'referencedColumnName' => 'id',
                        'nullable' => false,
                    ]
                ]
            ]);
        }

        //处理引用内容、分类、用户类型的value属性mapping
        if ($doctrineType === 'entity' && !$classMetadata->hasAssociation('value')) {

            if (!method_exists($fieldDepartConfiguration, 'getReferenceTargetEntity')) {
                throw new \RuntimeException(sprintf('Reference field configuration "%s" must define "getReferenceTargetEntity" method.', get_class($fieldDepartConfiguration)));
            }

            $classMetadata->mapManyToOne([
                'fieldName' => 'value',
                'targetEntity' => $fieldDepartConfiguration->getReferenceTargetEntity(),
                'cascade' => ['remove', 'persist'],
                'joinColumns' => [
                    [
                        'name' => 'reference_entity_id',
                        'referencedColumnName' => 'id',
                        'nullable' => true,
                    ]
                ]
            ]);
        }

        //对于非引用类型value属性的映射
        if ($doctrineType !== 'entity' && !$classMetadata->hasField('value')) {
            //动态Mapping字段value,
            $fieldMapping = array(
                'fieldName' => 'value',
                'columnName' => 'field_value',
                'type' => $doctrineType,
                'nullable' => true
            );

            //字段的Entity全类名
            $fieldEntityClassName = $classMetadata->getName();
            if (in_array($fieldEntityClassName, [StringFormatItem::class, StringItem::class])) {
                //如果是文本类型需要设置数据库length
                if (method_exists($fieldDepartConfiguration, 'getLength')) {
                    $fieldMapping['length'] = $fieldDepartConfiguration->getLength();
                }
            }
            if (in_array($fieldEntityClassName, [DecimalItem::class])) {
                //如果是小数类型则需要添加precision,scale
                if ($doctrineType == 'decimal') {
                    $fieldMapping['precision'] = $fieldDepartConfiguration->getPrecision();
                    $fieldMapping['scale'] = $fieldDepartConfiguration->getScale();
                }
            }

            $classMetadata->mapField($fieldMapping);
        }
    }

    public function getSubscribedEvents(): array
    {
        return [
            Events::loadClassMetadata,
        ];
    }
}

我们还需要在Symfony中对此类进行些许配置:

<service id="teebb.core.event.dynamic_field_mapping_subscriber" public="true"
                 class="Teebb\CoreBundle\Subscriber\DynamicChangeFieldMetadataSubscriber">
      <!--此处需要使用doctrine.event_subscriber tag 名称-->
      <tag name="doctrine.event_subscriber"/>
</service>

当ORM对数据进行存取时会自动dispatch loadClassMetadata事件,我们在需要动态Mapping时对Container中的teebb.core.event.dynamic_field_mapping_subscriber进行动态修改即可,如下:

//\Teebb\CoreBundle\Controller\SubstanceDBALOptionsTrait

$dynamicFieldMappingSubscriber = $container->get('teebb.core.event.dynamic_field_mapping_subscriber');

//动态修改字段entity的mapping
$dynamicFieldMappingSubscriber->setFieldConfiguration($field);
$dynamicFieldMappingSubscriber->setTargetContentClassName($contentClassName);

这样就可以实现ORM的动态Mapping。

特别提醒:
当我们转为生产环境并添加字段时,会发现动态Mapping并没有生效!
 

prod.log日志报的错误是这样的:

request.CRITICAL: Uncaught PHP Exception Doctrine\DBAL\Exception\InvalidFieldNameException: "An exception occurred while executing 'SELECT * FROM content__field_ri_qi WHERE (entity_id = ?) AND (types = ?) ORDER BY delta ASC' with params [1, "article"]:  SQLSTATE[42S22]: Column not found: 1054 Unknown column 'entity_id' in 'where clause'" at /Users/quanweiwei/Repository/php/teebbstudios/teebb/vendor/doctrine/dbal/lib/Doctrine/DBAL/Driver/AbstractMySQLDriver.php line 79 {"exception":"[object] (Doctrine\\DBAL\\Exception\\InvalidFieldNameException(code: 0): An exception occurred while executing 'SELECT * FROM content__field_ri_qi WHERE (entity_id = ?) AND (types = ?) ORDER BY delta ASC' with params [1, \"article\"]:\n\nSQLSTATE[42S22]: Column not found: 1054 Unknown column 'entity_id' in 'where clause' at /Users/quanweiwei/Repository/php/teebbstudios/teebb/vendor/doctrine/dbal/lib/Doctrine/DBAL/Driver/AbstractMySQLDriver.php:79)\n[previous exception] [object] (Doctrine\\DBAL\\Driver\\PDO\\Exception(code: 42S22): SQLSTATE[42S22]: Column not found: 1054 Unknown column 'entity_id' in 'where clause' at /Users/quanweiwei/Repository/php/teebbstudios/teebb/vendor/doctrine/dbal/lib/Doctrine/DBAL/Driver/PDO/Exception.php:18)\n[previous exception] [object] (PDOException(code: 42S22): SQLSTATE[42S22]: Column not found: 1054 Unknown column 'entity_id' in 'where clause' at /Users/quanweiwei/Repository/php/teebbstudios/teebb/vendor/doctrine/dbal/lib/Doctrine/DBAL/Driver/PDOStatement.php:115)"} []

 日志提示没有找到entity_id列,我们明明已经动态mapping了,为什么? 原因是这样的:

当Doctrine从dev环境转入prod环境,第一次生成缓存时,会把所有Entity的注解都读取并生成Proxy类,以此提升性能,我们的字段Entity也进行了缓存,当对数据进行存取时会使用缓存中的Proxy类而不使用动态mapping。如下图:

 

通过修改doctrine.yaml对auto_generate_proxy_classes的设置可以修复此问题:

doctrine:
    orm:
        auto_generate_proxy_classes: EVAL #使用EVAL不用将生成的proxy类存入本地磁盘

修改之后请记得删除缓存!!!

硬核的删除缓存的方法: 项目var目录下,删除所有东西!!!

特别提醒:
如果是一般开发请设计好Entity类,尽量避免使用动态Mapping!!!

OK,至此这个坑算是踩过去了。但是你知道的ORM开发上很方便,但是性能上很呵呵。

在TEEBB的1.x版本我将会用更好的办法提升性能。PEACE! 

更多文章

PHP内容管理系统TEEBB的设计构思

2021-01-06

嗨,昨天介绍了至文内容管理系统(TEEBB),点这里看昨天的内容,今天我们聊聊这个“东西”该怎么设计! 开发这个系统的初衷就是要“高扩展高灵活”,毕竟客户“巴巴”的需求各不相同。OK,废话不多说直接上结构图!

开源PHP内容管理系统(TEEBB)介绍

2021-01-06

嗨,今天介绍一款我自己开发的内容管理系统(CMS):至文内容管理系统(简称:TEEBB) 之前朋友问我:"互联网的意义在哪里?",我回答:"信息传输的快"。你在北京发布一篇文章,在海南的朋友"立马"就可以看到。我想这就是互联网的意义。