在 Symfony2 中编写自定义 ParamConverter





0/5 (0投票)
如何编写自定义 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
字段,即连接表 villages
和 districts
,那就太好了。 但不幸的是,现在无法开箱即用。 但有机会编写自定义 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
]);
}
正如您所看到的,我们已经将负责从控制器在数据库中查找对象的代码移动到转换器。 代码变得更干净,更令人愉快。