65.9K
CodeProject 正在变化。 阅读更多。
Home

在 Symfony2 中编写自定义 ParamConverter

emptyStarIconemptyStarIconemptyStarIconemptyStarIconemptyStarIcon

0/5 (0投票)

2015年2月3日

CPOL

4分钟阅读

viewsIcon

16129

如何编写自定义 ParamConverter

引言

PHP 框架 Symfony2 有一个非常棒的组件 ParamConverter,它允许将 URL 中的参数转换为 PHP 变量。当开箱即用的功能不够用时,你应该手动扩展它。在这篇文章中,我将展示如何在 Symfony2 中编写你自己的自定义 ParamConverter

假设我们有两个实体,它们之间是“一对多”的关系。 例如

  • 国家和城市
  • 地区和村庄

关键在于城镇或村庄的名称不是唯一的。 毕竟,在不同的国家,有相同名称的城市。 例如,敖德萨不仅在乌克兰,也在得克萨斯州(美国)。 前苏联村庄“Pervomayskoe”的常见名称在乌克兰的许多地区都能找到,俄罗斯、摩尔多瓦和哈萨克斯坦也存在同名村庄。 为了识别特定的村庄,我们需要指定完整的地址

  • 乌克兰,克里米亚,辛菲罗波尔区,Pervomayskoe 村;
  • 哈萨克斯坦,阿克纠宾斯克州,卡尔加林斯基区,Pervomayskoe 村;
  • 俄罗斯,莫斯科州,伊斯特拉区,Pervomayskoe 村。

例如,我们只取两个级别:地区和村庄。 因此,这将更容易和更直观,其余的可以以相同的方式扩展。 让我们想象一下,对于某个地区,您需要制作一个网站,其中包含有关每个地区和每个地区每个村庄的信息。 关于每个村庄的信息显示在一个单独的页面上。 主要要求是页面的地址应该是可读的,并且由地区的“slug”(slug 是实体在 URL 中的人类可读表示)和村庄的“slug”组成。 以下是应该在“站点地址 / 地区 slug / 村庄 slug”模式下运行的 URL 示例

  • example.com/yarmolynetskyi/antonivtsi
  • example.com/yarmolynetskyi/ivankivtsi
  • example.com/vinkovetskyi/ivankivtsi

yarmolynetskyi — 我地区 Yarmolyntsi 地区的 slug。 vinkovetskyi — Vinkivtsi 地区。 ivankivtsi — Ivankivtsi 村,它位于两个地区。 antonivtsi — Antonivtsi 是另一个村庄。

让我们用最少数量的字段来描述两个实体,以用于此示例。

namespace AppBundle\Entity;
 
use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Validator\Constraints as DoctrineAssert;
 
/**
 * @ORM\Entity()
 * @ORM\Table(name="districts")
 * @DoctrineAssert\UniqueEntity("slug")
 */
class District
{
    /**
     * @ORM\Id
     * @ORM\Column(type="integer")
     * @ORM\GeneratedValue
     */
    private $id;
 
    /**
     * @ORM\OneToMany(targetEntity="Village", mappedBy="district", 
     * cascade={"persist", "remove"}, orphanRemoval=true)
     * @ORM\JoinColumn(onDelete="CASCADE")
     */
    private $villages;
 
    /**
     * @ORM\Column(type="string", unique=true, length=100, nullable=false)
     */
    private $slug;
 
    // Setters and getters for fields
}
namespace AppBundle\Entity;
 
use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Validator\Constraints as DoctrineAssert;
 
/**
 * @ORM\Entity(repositoryClass="AppBundle\Repository\VillageRepository")
 * @ORM\Table(name="villages",
 *   uniqueConstraints={@ORM\UniqueConstraint(name="unique_village_slug_for_district", 
 *   columns={"district_id", "slug"})}
 * )
 * @DoctrineAssert\UniqueEntity(fields={"district", "slug"}, errorPath="slug")
 */
class Village
{
    /**
     * @ORM\Id
     * @ORM\Column(type="integer")
     * @ORM\GeneratedValue
     */
    private $id;
 
    /**
     * @ORM\ManyToOne(targetEntity="District", inversedBy="villages")
     * @ORM\JoinColumn(nullable=false, onDelete="CASCADE")
     */
    private $district;
 
    /**
     * @ORM\Column(type="string", length=100, nullable=false)
     */
    private $slug;
 
    // Setters and getters for fields
}

DistrictController 中添加一个操作来显示村庄的页面。

namespace AppBundle\Controller;
 
use Symfony\Bundle\FrameworkBundle\Controller\Controller;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Route;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Method;
 
class DistrictController extends Controller
{
    /**
     * Show village page of some district
     *
     * @Route("/{districtSlug}/{villageSlug}", name="district_village_show")
     * @Method({"GET"})
     */
    public function villageAction()
    {
        // ...
    }
}

现在让我们考虑一下如何完成我们的任务。 第一种选择:您需要在 VillageRepository 中创建一个新方法,该方法将在表 `districts` 和 `villages` 之间执行 JOIN 操作,并根据村庄的 slug 和其地区的 slug 查找村庄。

namespace AppBundle\Repository;
 
use AppBundle\Entity\Village;
use Doctrine\ORM\EntityRepository;
 
class VillageRepository extends EntityRepository
{
    /**
     * Find village by its slug and slug of its district
     *
     * @param string $districtSlug District slug
     * @param string $villageSlug  Village slug
     *
     * @return Village|null
     */
    public function findBySlugAndDistrictSlug($districtSlug, $villageSlug)
    {
        $qb = $this->createQueryBuilder('v');
 
        return $qb->join('v.district', 'd')
                  ->where($qb->expr()->eq('v.slug', ':village_slug'))
                  ->andWhere($qb->expr()->eq('d.slug', ':district_slug'))
                  ->setParameters([
                      'district_slug' => $districtSlug,
                      'village_slug'  => $villageSlug
                  ])
                  ->getQuery()
                  ->getOneOrNullResult();
    }
}

控制器中的方法将如下所示

/**
 * @param string $districtSlug District slug
 * @param string $villageSlug  Village slug
 *
 * @return Response
 *
 * @Route("/{districtSlug}/{villageSlug}", name="district_village_show")
 */
public function villageAction($districtSlug, $villageSlug)
{
    $villageRepository = $this->getDoctrine()->getRepository('AppBundle:Village');
 
    $village = $villageRepository->findBySlugAndDistrictSlug($districtSlug, $villageSlug);
 
    if (null === $village) {
        throw $this->createNotFoundException('No village was found');
    }
 
    return $this->render('district/show_village.html.twig', [
        'village' => $village
    ]);
}

在数据库中搜索所需项目的代码部分可以通过使用 Symfony 注解 — @ParamConverter 来替换。 此注解的其中一个特点是,如果未找到实体,则会自动调用异常。 我们不需要额外检查任何东西,这意味着更少的代码,这意味着更干净的代码。

/**
 * @param District $district District
 * @param Village  $village  Village
 *
 * @return Response
 *
 * @Route("/{districtSlug}/{villageSlug}", name="district_village_show")
 *
 * Just an example, but it will not do what we need!!!
 * @ParamConverter("district", class="AppBundle:District", options={"mapping": {"districtSlug" = "slug"}})
 * @ParamConverter("village", class="AppBundle:Village", options={"mapping": {"villageSlug" = "slug"}})
 */
public function villageAction($district, $village)
{
    return $this->render('district/show_village.html.twig', [
        'village' => $village
    ]);
}

默认情况下,ParamConverter 将 URL 中的参数映射到指定类的 ID 字段。 如果输入参数不是 ID,则需要通过映射选项进一步指定它。 但是,事实上,上述代码并不能完成我们需要的功能! 第一个转换器将找到正确的地区,并将其保存到变量 $district 中。 第二个转换器将找到具有指定 slug 的第一个村庄,并将其保存到变量 $village 中。 在查询(ParamConverter 执行以查找实体)中是 LIMIT 1,这就是为什么只会找到具有最小 ID 的一个对象。 这不是我们需要的。 对于名称相同的村庄,slug 也相同。 在这种情况下,每次都将仅找到数据库中具有实际 slug 的第一个村庄。

继续。 开箱即用的 ParamConverter 允许将多个字段映射到一个实体,例如 @ParamConverter("village", options={"mapping": {"code": "code", "slug": "slug"}}),但前提是这些字段属于此实体。 在我们的例子中,两个 slug 属于不同的实体。 我希望这个结构开箱就能工作

/**
 * @param Village $village Village
 *
 * @return Response
 *
 * Just an example, but this code doesn't work!!!
 * @Route("/{districtSlug}/{villageSlug}", name="district_village_show")
 * @ParamConverter("village", class="AppBundle:Village", 
 * options={"mapping": {"villageSlug" = "slug", "districtSlug" = "district.slug"}})
 */
public function villageAction($village)
{
    return $this->render('district/show_village.html.twig', [
        'village' => $village
    ]);
}  

如果 ParamConverter 检测到我们希望将 districtSlug 映射到 District 实体的 slug 字段,该字段映射到 Village 实体的 district 字段,即连接表 villagesdistricts,那就太好了。 但不幸的是,现在无法开箱即用。 但有机会编写自定义 ParamConverter。 这是一个完整的转换器,我们需要的,行间有注释。

namespace AppBundle\Request\ParamConverter;
 
use AppBundle\Entity\Village;
use Doctrine\Common\Persistence\ManagerRegistry;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\ParamConverter;
use Sensio\Bundle\FrameworkExtraBundle\Request\ParamConverter\ParamConverterInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
 
class DistrictVillageParamConverter implements ParamConverterInterface
{
    /**
     * @var ManagerRegistry $registry Manager registry
     */
    private $registry;
 
    /**
     * @param ManagerRegistry $registry Manager registry
     */
    public function __construct(ManagerRegistry $registry = null)
    {
        $this->registry = $registry;
    }
 
    /**
     * {@inheritdoc}
     *
     * Check, if object supported by our converter
     */
    public function supports(ParamConverter $configuration)
    {
        // If there is no manager, this means that only Doctrine DBAL is configured
        // In this case we can do nothing and just return
        if (null === $this->registry || !count($this->registry->getManagers())) {
            return false;
        }
 
        // Check, if option class was set in configuration
        if (null === $configuration->getClass()) {
            return false;
        }
 
        // Get actual entity manager for class
        $em = $this->registry->getManagerForClass($configuration->getClass());
 
        // Check, if class name is what we need
        if ('AppBundle\Entity\Village' !== $em->getClassMetadata($configuration->getClass())->getName()) {
            return false;
        }
 
        return true;
    }
 
    /**
     * {@inheritdoc}
     *
     * Applies converting
     *
     * @throws \InvalidArgumentException When route attributes are missing
     * @throws NotFoundHttpException     When object not found
     */
    public function apply(Request $request, ParamConverter $configuration)
    {
        $districtSlug = $request->attributes->get('districtSlug');
        $villageSlug  = $request->attributes->get('villageSlug');
 
        // Check, if route attributes exists
        if (null === $districtSlug || null === $villageSlug) {
            throw new \InvalidArgumentException('Route attribute is missing');
        }
 
        // Get actual entity manager for class
        $em = $this->registry->getManagerForClass($configuration->getClass());
 
        /** @var \AppBundle\Repository\VillageRepository $villageRepository Village repository */
        $villageRepository = $em->getRepository($configuration->getClass());
 
        // Try to find village by its slug and slug of its district
        $village = $villageRepository->findBySlugAndDistrictSlug($districtSlug, $villageSlug);
 
        if (null === $village || !($village instanceof Village)) {
            throw new NotFoundHttpException(sprintf('%s object not found.', $configuration->getClass()));
        }
 
        // Map found village to the route's parameter
        $request->attributes->set($configuration->getName(), $village);
    }
}

自定义 ParamConverter 应该实现 ParamConverterInterface。 应实现两个方法

  • supports — 检查当前请求是否可以由转换器处理
  • apply — 执行所有必要的转换

在 Symfony 网站上关于自定义 ParamConverter 的信息最少。 有一个建议是将 DoctrineParamConverter 作为您的基本类。

为了使其工作,还需要将转换器声明为带有标签 request.param_converter 的服务

services:
    app.param_converter.district_village_converter:
        class: AppBundle\Request\ParamConverter\DistrictVillageParamConverter
        tags:
            - { name: request.param_converter, converter: district_village_converter }
        arguments:
            - @?doctrine 

参数 @?doctrine 意味着,如果未配置实体管理器,则会忽略它。 为了控制转换器的顺序,我们可以使用选项优先级(有关此内容,请参阅 Symfony 网站上的文档)。 或者您可以在注解中指定转换器的名称 converter: district_village_converter。 如果您只想运行我们的转换器,那么在注解中写下它的名称,其他转换器将被忽略此请求。

现在我们的动作代码看起来像这样

/**
 * @param Village $village Village
 *
 * @return Response
 *
 * @Route("/{districtSlug}/{villageSlug}", name="district_village_show")
 * @Method({"GET"})
 *
 * @ParamConverter("village", class="AppBundle:Village", converter="district_village_converter")
 */
public function villageAction(Village $village)
{
    return $this->render('district/show_village.html.twig', [
        'village' => $village
    ]);
}

正如您所看到的,我们已经将负责从控制器在数据库中查找对象的代码移动到转换器。 代码变得更干净,更令人愉快。

© . All rights reserved.