使用 Symfony 5 和 PHP 8 构建 RESTful API
由 Mux 主办的 DEV 全球展示挑战赛:展示你的项目!
Symfony 是一个功能齐全的模块化 PHP 框架,可用于构建各种应用程序,从传统的 Web 应用程序到小型微服务组件。
先试试水
安装 PHP 8 和 PHP Composer 工具。
# choco php composer
安装Symfony CLI,并检查系统要求。
# symfony check:requirements
Symfony Requirements Checker
~~~~~~~~~~~~~~~~~~~~~~~~~~~~
> PHP is using the following php.ini file:
C:\tools\php80\php.ini
> Checking Symfony requirements:
....................WWW.........
[OK]
Your system is ready to run Symfony projects
Optional recommendations to improve your setup
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
* intl extension should be available
> Install and enable the intl extension (used for validators).
* a PHP accelerator should be installed
> Install and/or enable a PHP accelerator (highly recommended).
* realpath_cache_size should be at least 5M in php.ini
> Setting "realpath_cache_size" to e.g. "5242880" or "5M" in
> php.ini* may improve performance on Windows significantly in some
> cases.
Note The command console can use a different php.ini file
~~~~ than the one used by your web server.
Please check that both the console and the web server
are using the same PHP version and configuration.
根据建议信息,请调整php.ini文件中的 PHP 配置。示例应用程序将使用 Postgres 作为数据库,请确保pdo_pgsql已pgsql启用相关模块。
最后,您可以通过以下命令确认已启用的模块。
# php -m
创建一个新的Symfony项目。
# symfony new rest-sample
// a classic website application
# symfony new web-sample --full
默认情况下,它将创建一个简单的 Symfony 框架项目,仅包含核心内核配置,这非常适合启动一个轻量级的 Restful API 应用程序。
或者,您也可以使用 Composer 创建它。
# composer create-project symfony/skeleton rest-sample
//start a classic website application
# composer create-project symfony/website-skeleton web-sample
进入生成的项目根文件夹,启动应用程序。
# symfony server:start
[WARNING] run "symfony.exe server:ca:install" first if you want to run the web server with TLS support, or use "--no-
tls" to avoid this warning
Tailing PHP-CGI log file (C:\Users\hantsy\.symfony\log\499d60b14521d4842ba7ebfce0861130efe66158\79ca75f9e90b4126a5955a33ea6a41ec5e854698.log)
Tailing Web Server log file (C:\Users\hantsy\.symfony\log\499d60b14521d4842ba7ebfce0861130efe66158.log)
[OK] Web server listening
The Web server is using PHP CGI 8.0.10
http://127.0.0.1:8000
[Web Server ] Oct 4 13:33:01 |DEBUG | PHP Reloading PHP versions
[Web Server ] Oct 4 13:33:01 |DEBUG | PHP Using PHP version 8.0.10 (from default version in $PATH)
[Web Server ] Oct 4 13:33:01 |INFO | PHP listening path="C:\\tools\\php80\\php-cgi.exe" php="8.0.10" port=61738
你好,Symfony
创建一个简单的类,用于 HTTP 响应中的资源实体。
class Post
{
private ?string $id = null;
private string $title;
private string $content;
//getters and setters.
}
使用工厂模式创建一个新的 Post 实例。
class PostFactory
{
public static function create(string $title, string $content): Post
{
$post = new Post();
$post->setTitle($title);
$post->setContent($content);
return $post;
}
}
让我们创建一个简单的控制器类。
要使用最新的 PHP 8 属性来配置路由规则,请在项目配置中应用以下更改。
- 打开config/packages/doctrine.yaml 文件,删除
doctrine/orm/mapping/App/type或更改其值。attribute - 打开composer.json 文件,将 PHP 版本更改为
>=8.0.0.
要将响应正文渲染成 JSON 字符串,请使用 <JSON> 标签JsonReponse包裹响应。
#[Route(path: "/posts", name: "posts_")]
class PostController
{
#[Route(path: "", name: "all", methods: ["GET"])]
function all(): Response
{
$post1 = PostFactory::create("test title", "test content");
$post1->setId("1");
$post2 = PostFactory::create("test title", "test content");
$post2->setId("2");
$data = [$post1->asArray(), $post2->asArray()];
return new JsonResponse($data, 200, ["Content-Type" => "application/json"]);
//return $this->json($data, 200, ["Content-Type" => "application/json"]);
}
}
第一个参数JsonReponse接受一个数组作为数据,因此在类中添加一个函数Post来实现此目的。
class Post{
//...
public function asArray(): array
{
return [
'id' => $this->id,
'title' => $this->title,
'content' => $this->content
];
}
}
运行应用程序,用于curl测试/posts端点。
# curl http://localhost:8000/posts
Symfony 提供了一个简单的框架AbstractController,其中包含多个功能,可以简化响应并采用容器和依赖注入管理。
在上述控制器中,继承自AbstractController,只需调用$this->json即可渲染 JSON 格式的响应,无需在渲染响应之前将数据转换为数组。
class PostController extends AbstractController
{
function all(): Response
{
//...
return $this->json($data, 200, ["Content-Type" => "application/json"]);
}
}
连接到数据库
Doctrine 是一个流行的 ORM 框架,它深受现有 Java ORM 工具(例如 JPA 规范和 Hibernate 框架)的启发。Doctrine 有两个核心组件:`Database` 和 `Database`。doctrine/dbal前者doctrine/orm是用于数据库操作的底层 API,如果您了解 Java 开发,可以将其视为JDBC层。后者是高级 ORM 框架,其公共 API 与 JPA/Hibernate 类似。
将 Doctrine 安装到项目中。
# composer require symfony/orm-pack
# composer require --dev symfony/maker-bundle
该软件包是一个虚拟的 Symfony 包,它将安装一系列软件包和基本配置。
打开.env项目根文件夹中的文件,编辑DATABASE_URL值,设置数据库名称、用户名、密码以进行连接。
DATABASE_URL="postgresql://user:password@127.0.0.1:5432/blogdb?serverVersion=13&charset=utf8"
使用以下命令生成 docker compose 文件模板。
# php bin/console make:docker:database
我们将其更改为以下内容,以便在开发环境中启动 Postgres 数据库。
version: "3.5" # specify docker-compose version, v3.5 is compatible with docker 17.12.0+
# Define the services/containers to be run
services:
postgres:
image: postgres:${POSTGRES_VERSION:-13}-alpine
ports:
- "5432:5432"
environment:
POSTGRES_DB: ${POSTGRES_DB:-blogdb}
# You should definitely change the password in production
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-password}
POSTGRES_USER: ${POSTGRES_USER:-user}
volumes:
- ./data/blogdb:/var/lib/postgresql/data:rw
- ./pg-initdb.d:/docker-entrypoint-initdb.d
我们将使用UUID作为主键的数据类型,并uuid-ossp在 Postgres 启动时添加一个脚本以启用扩展。
-- file: pg-initdb.d/ini.sql
SET search_path TO public;
DROP EXTENSION IF EXISTS "uuid-ossp";
CREATE EXTENSION "uuid-ossp" SCHEMA public;
打开config/packages/test/doctrine.yaml文件,注释掉该dbname_suffix行。我们使用 Docker 容器来引导数据库,以确保应用程序在开发环境和生产环境中的行为一致。
现在启动应用程序,并确保控制台中没有异常,这意味着数据库连接成功。
symfony server:start
启动应用程序之前,请确保数据库正在运行。运行以下命令在 Docker 中启动 Postgres 数据库。
# docker compose up postgres
# docker ps -a # to list all containers and make the postgres is running
构建数据模型
现在我们将构建后续章节中将要使用的实体。我们正在构建一个简单的博客系统模型,它包含以下概念。
- A
Post在博客系统中发布了一篇文章。 - A
Comment显示特定帖子下的评论。 - 该通用功能
Tag可应用于不同的帖子,它按主题、类别等对帖子进行分类。
你可以凭记忆勾勒出模型关系,也可以使用一些图形数据建模工具。
- 帖子和评论之间存在
one-to-many关联 - 帖子和标签之间存在
many-to-many关联
通过 Doctrine 可以轻松地将想法转换为实际代码Entity。运行以下命令即可创建 `<object>`Post和Comment` Tag<object>` 实体。
在 Doctrine ORM 2.10.x 和 Dbal 3.x 中,UUID 类型的 ID 生成器已被弃用。我们将切换到 Uuid 形式symfony\uid。
先安装symfony\uid。
# composer require symfony/uid
简单来说,您可以使用以下命令快速创建实体。
# php bin/console make:entity # following the interactive steps to create them one by one.
最后,我们在src/Entity文件夹中得到了三个实体。按照预期修改它们。
// src/Entity/Post.php
#[Entity(repositoryClass: PostRepository::class)]
class Post
{
#[Id]
//#[GeneratedValue(strategy: "UUID")
//#[Column(type: "string", unique: true)]
#[Column(type: "uuid", unique: true)]
#[GeneratedValue(strategy: "CUSTOM")]
#[CustomIdGenerator(class: UuidGenerator::class)]
private ?Uuid $id = null;
#[Column(type: "string", length: 255)]
private string $title;
#[Column(type: "string", length: 255)]
private string $content;
#[Column(name: "created_at", type: "datetime", nullable: true)]
private DateTime|null $createdAt = null;
#[Column(name: "published_at", type: "datetime", nullable: true)]
private DateTime|null $publishedAt = null;
#[OneToMany(mappedBy: "post", targetEntity: Comment::class, cascade: ['persist', 'merge', "remove"], fetch: 'LAZY', orphanRemoval: true)]
private Collection $comments;
#[ManyToMany(targetEntity: Tag::class, mappedBy: "posts", cascade: ['persist', 'merge'], fetch: 'EAGER')]
private Collection $tags;
public function __construct()
{
$this->createdAt = new DateTime();
$this->comments = new ArrayCollection();
$this->tags = new ArrayCollection();
}
//other getters and setters
}
// src/Entity/Comment.php
#[Entity(repositoryClass: CommentRepository::class)]
class Comment
{
#[Id]
//#[GeneratedValue(strategy: "UUID")]
#[Column(type: "uuid", unique: true)]
#[GeneratedValue(strategy: "CUSTOM")]
#[CustomIdGenerator(class: UuidGenerator::class)]
private ?Uuid $id = null;
#[Column(type: "string", length: 255)]
private string $content;
#[Column(name: "created_at", type: "datetime", nullable: true)]
private DateTime|null $createdAt = null;
#[ManyToOne(targetEntity: "Post", inversedBy: "comments")]
#[JoinColumn(name: "post_id", referencedColumnName: "id")]
private Post $post;
public function __construct()
{
$this->createdAt = new DateTime();
}
//other getters and setters
}
//src/Entity/Tag.php
#[Entity(repositoryClass: TagRepository::class)]
class Tag
{
#[Id]
//#[GeneratedValue(strategy: "UUID")
//#[Column(type: "string", unique: true)]
#[Column(type: "uuid", unique: true)]
#[GeneratedValue(strategy: "CUSTOM")]
#[CustomIdGenerator(class: UuidGenerator::class)]
private ?Uuid $id = null;
#[Column(type: "string", length: 255)]
private ?string $name;
#[ManyToMany(targetEntity: Post::class, inversedBy: "tags")]
private Collection $posts;
public function __construct()
{
$this->posts = new ArrayCollection();
}
}
同时,它Repository为这些实体生成了三个类。
// src/Repository/PostRepsoitory.php
class PostRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Post::class);
}
}
// src/Repository/CommentRepsoitory.php
class CommentRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Comment::class);
}
}
//src/Repository/TagRepository.php
class TagRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Tag::class);
}
}
您可以使用 Doctrine 迁移生成迁移文件,以在生产环境中维护数据库架构。
运行以下命令生成迁移文件。
# php bin/console make:migration
执行后,会在migrations文件夹中生成一个迁移文件,其命名类似于Version20211104031420。这是一个继承自 的简单类AbstractMigration,其中的up函数用于升级到此版本,而down函数用于降级到先前版本。
自动对数据库应用迁移。
# php bin/console doctrine:migrations:migrate
# return to prev version
# php bin/console doctrine:migrations:migrate prev
# migrate to next
# php bin/console doctrine:migrations:migrate next
# These alias are defined : first, latest, prev, current and next
# certain version fully qualified class name
# php bin/console doctrine:migrations:migrate FQCN
Doctrine 软件包还包含一些用于维护数据库和模式的命令。例如:
# php bin/console doctrine:database:create
# php bin/console doctrine:database:drop
// schema create, drop, update and validate
# php bin/console doctrine:schema:create
# php bin/console doctrine:schema:drop
# php bin/console doctrine:schema:update
# php bin/console doctrine:schema:validate
添加示例数据
创建自定义命令以加载一些示例数据。
# php bin/console make:command add-post
它会在src/CommandAddPostCommand文件夹下生成一个文件夹。
#[AsCommand(
name: 'app:add-post',
description: 'Add a short description for your command',
)]
class AddPostCommand extends Command
{
public function __construct(private EntityManagerInterface $manager)
{
parent::__construct();
}
protected function configure(): void
{
$this
->addArgument('title', InputArgument::REQUIRED, 'Title of a post')
->addArgument('content', InputArgument::REQUIRED, 'Content of a post')
//->addOption('option1', null, InputOption::VALUE_NONE, 'Option description')
;
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$title = $input->getArgument('title');
if ($title) {
$io->note(sprintf('Title: %s', $title));
}
$content = $input->getArgument('content');
if ($content) {
$io->note(sprintf('Content: %s', $content));
}
$entity = PostFactory::create($title, $content);
$this ->manager->persist($entity);
$this ->manager->flush();
// if ($input->getOption('option1')) {
// // ...
// }
$io->success('Post is saved: '.$entity);
return Command::SUCCESS;
}
}
Doctrine由 Symfony服务容器EntityManagerInterface管理,用于数据持久化操作。
运行以下命令将帖子添加到数据库。
# php bin/console app:add-post "test title" "test content"
! [NOTE] Title: test title
! [NOTE] Content: test content
[OK] Post is saved: Post: [ id =1ec3d3ec-895d-685a-b712-955865f6c134, title=test title, content=test content, createdAt=1636010040, blishedAt=]
测试库
PHPUnit是 PHP 世界中最流行的测试框架,Symfony 与 PHPUnit 紧密集成。
运行以下命令安装 PHPUnit 和 Symfony测试包。该测试包将安装测试 Symfony 组件所需的所有必要软件包,并添加 PHPUnit 配置,例如phpunit.xml.dist。
# composer require --dev phpunit/phpunit symfony/test-pack
这是一个用纯 PHPUnit 编写的简单测试示例。
class PostTest extends TestCase
{
public function testPost()
{
$p = PostFactory::create("tests title", "tests content");
$this->assertEquals("tests title", $p->getTitle());
$this->assertEquals("tests content", $p->getContent());
$this->assertNotNull( $p->getCreatedAt());
}
}
Symfony 提供了一些特定的基类(KernelTestCase,WebTestCase等),以简化 Symfony 项目中的测试工作。
以下是一个测试示例Repository。PostRepository该示例KernelTestCase包含引导应用程序内核的设施并提供服务容器。
class PostRepositoryTest extends KernelTestCase
{
private EntityManagerInterface $entityManager;
private PostRepository $postRepository;
protected function setUp(): void
{
//(1) boot the Symfony kernel
$kernel = self::bootKernel();
$this->assertSame('test', $kernel->getEnvironment());
$this->entityManager = $kernel->getContainer()
->get('doctrine')
->getManager();
//(2) use static::getContainer() to access the service container
$container = static::getContainer();
//(3) get PostRepository from container.
$this->postRepository = $container->get(PostRepository::class);
}
protected function tearDown(): void
{
parent::tearDown();
$this->entityManager->close();
}
public function testCreatePost(): void
{
$entity = PostFactory::create("test post", "test content");
$this->entityManager->persist($entity);
$this->entityManager->flush();
$this->assertNotNull($entity->getId());
$byId = $this->postRepository->findOneBy(["id" => $entity->getId()]);
$this->assertEquals("test post", $byId->getTitle());
$this->assertEquals("test content", $byId->getContent());
}
}
在上述代码中,setUp函数会启动应用程序内核,启动后,会创建一个测试范围的服务容器。然后从服务容器中获取EntityManagerInterface所需信息。PostRepository
该testCreatePost函数会持久化一个Post实体,并根据 id 查找此帖子,并验证标题和内容字段。
目前,PHPUnit 不包含 PHP 8 属性支持,测试代码类似于旧版 JUnit 4 代码风格。
创建 PostController:公开您的第一个 REST API
与其他 MVC 框架类似,我们可以通过 SymfonyController组件暴露 RESTful API。遵循 REST 规范,我们计划为博客系统创建以下 API。
GET /posts获取所有帖子。GET /posts/{id}根据 ID 获取单个帖子,如果找不到,则返回状态码 404POST /posts从请求体创建一个新的帖子,将新的帖子 URI 添加到响应头中Location,并返回状态码 201。DELETE /posts/{id}按 ID 删除单个帖子,返回状态 204。如果找不到该帖子,则返回状态 404。- ...
运行以下命令创建控制器框架。按照交互式指南创建一个名为 . 的控制器PostController。
# php bin/console make:constroller
在 IDE 中打开src/Controller/PostController.php 。
Route在类级别添加属性和两个函数:一个用于获取所有帖子,另一个用于通过 ID 获取单个帖子。
#[Route(path: "/posts", name: "posts_")]
class PostController extends AbstractController
{
public function __construct(private PostRepository $posts)
{
}
#[Route(path: "", name: "all", methods: ["GET"])]
function all(): Response
{
$data = $this->posts->findAll();
return $this->json($data);
}
}
启动应用程序,尝试访问http://localhost:8000/posts,如果直接在 JSON 视图中渲染模型,将会抛出循环依赖异常。有一些方法可以避免这个问题,最简单的方法是在渲染 JSON 视图之前断开双向依赖关系。在`and`上添加一个Ignore属性。Comment.postTag.posts
//src/Entity/Comment.php
class Comment
{
#[Ignore]
private Post $post;
}
//src/Entity/Tag.php
class Tag
{
#[Ignore]
private Collection $posts;
}
测试控制器
如前几节所述,要测试 Controller/API,请创建一个测试类来扩展它WebTestCase,它提供了大量处理请求和断言响应的功能。
运行以下命令创建测试框架。
# php bin/console make:test
按照交互式步骤创建测试基础WebTestCase。
class PostControllerTest extends WebTestCase
{
public function testGetAllPosts(): void
{
$client = static::createClient();
$crawler = $client->request('GET', '/posts');
$this->assertResponseIsSuccessful();
//
$response = $client->getResponse();
$data = $response->getContent();
//dump($data);
$this->assertStringContainsString("Symfony and PHP", $data);
}
}
如果尝试运行测试,将会失败。目前没有任何可用于测试的数据。
准备用于测试的数据
该doctrine/doctrine-fixtures-bundle功能用于填充测试所需的样本数据,并dama/doctrine-test-bundle确保在每次测试运行之前恢复数据。
安装doctrine/doctrine-fixtures-bundle并dama/doctrine-test-bundle。
composer require --dev doctrine/doctrine-fixtures-bundle dama/doctrine-test-bundle
创建一个新的Fixture。
# php bin/console make:fixtures
在该load函数中,持久化一些用于测试的数据。
class AppFixtures extends Fixture
{
public function load(ObjectManager $manager): void
{
$data = PostFactory::create("Building Restful APIs with Symfony and PHP 8", "test content");
$data->addTag(Tag::of( "Symfony"))
->addTag( Tag::of("PHP 8"))
->addComment(Comment::of("test comment 1"))
->addComment(Comment::of("test comment 2"));
$manager->persist($data);
$manager->flush();
}
}
手动运行命令将示例数据加载到数据库中。
# php bin/console doctrine:fixtures:load
将以下扩展配置添加到 中phpunit.xml.dist,这样每次运行测试时,数据都会被清除并重新创建。
<extensions>
<extension class="DAMA\DoctrineTestBundle\PHPUnit\PHPUnitExtension"/>
</extensions>
运行以下命令执行PostControllerTest.php。
# php .\vendor\bin\phpunit .\tests\Controller\PostControllerTest.php
分页结果
许多 Web 应用程序都提供输入框,用于输入关键词并对搜索结果进行分页。假设请求中提供了一个关键词,用于匹配文章标题或内容;一个偏移量,用于设置分页的偏移位置;以及一个限制,用于设置每页元素的大小上限。请创建一个函数PostRepository,该函数接受关键词、偏移量和限制作为参数。
public function findByKeyword(string $q, int $offset = 0, int $limit = 20): Page
{
$query = $this->createQueryBuilder("p")
->andWhere("p.title like :q or p.content like :q")
->setParameter('q', "%" . $q . "%")
->orderBy('p.createdAt', 'DESC')
->setMaxResults($limit)
->setFirstResult($offset)
->getQuery();
$paginator = new Paginator($query, $fetchJoinCollection = false);
$c = count($paginator);
$content = new ArrayCollection();
foreach ($paginator as $post) {
$content->add(PostSummaryDto::of($post->getId(), $post->getTitle()));
}
return Page::of ($content, $c, $offset, $limit);
}
首先,使用 `DynamicQuery` 创建一个动态查询createQueryBuilder,然后创建一个 DoctrinePaginator实例来执行该查询。该实例Paginator实现了 `DataSource`Countable接口,并使用`Count` 方法count获取元素总数。最后,我们使用一个自定义Page对象来包装查询结果。
class Page
{
private Collection $content;
private int $totalElements;
private int $offset;
private int $limit;
#[Pure] public function __construct()
{
$this->content = new ArrayCollection();
}
public static function of(Collection $content, int $totalElements, int $offset = 0, int $limit = 20): Page
{
$page = new Page();
$page->setContent($content)
->setTotalElements($totalElements)
->setOffset($offset)
->setLimit($limit);
return $page;
}
//
//getters
}
自定义 ArgumentResolver
在此PostController,让我们改进服务于该路由的函数/posts,使其接受类似/posts?q=Symfony&offset=0&limit=10 的查询参数,并确保这些参数是可选的。
#[Route(path: "", name: "all", methods: ["GET"])]
function all(Request $req): Response
{
$keyword = $req->query->get('q')??'';
$offset = $req->query->get('offset')??0;
$limit = $req->query->get('limit')??10;
$data = $this->posts->findByKeyword($keyword, $offset, $limit);
return $this->json($data);
}
它能用,但查询参数的处理方式有点不太美观。如果能像路由路径参数那样处理它们就更好了。
我们可以创建一个自定义函数ArgumentResolver来解析绑定的查询参数。
首先创建一个注解/属性类来标识需要由此类解析的查询参数ArgumentResolver。
#[Attribute(Attribute::TARGET_PARAMETER)]
final class QueryParam
{
private null|string $name;
private bool $required;
/**
* @param string|null $name
* @param bool $required
*/
public function __construct(?string $name = null, bool $required = false)
{
$this->name = $name;
$this->required = $required;
}
//getters and setters
}
创建自定义ArgumentResolver实现内置功能ArgugmentResolverInterface。
class QueryParamValueResolver implements ArgumentValueResolverInterface, LoggerAwareInterface
{
public function __construct()
{
}
private LoggerInterface $logger;
/**
* @inheritDoc
*/
public function resolve(Request $request, ArgumentMetadata $argument)
{
$argumentName = $argument->getName();
$this->logger->info("Found [QueryParam] annotation/attribute on argument '" . $argumentName . "', applying [QueryParamValueResolver]");
$type = $argument->getType();
$nullable = $argument->isNullable();
$this->logger->debug("The method argument type: '" . $type . "' and nullable: '" . $nullable . "'");
//read name property from QueryParam
$attr = $argument->getAttributes(QueryParam::class)[0];// `QueryParam` is not repeatable
$this->logger->debug("QueryParam:" . $attr);
//if name property is not set in `QueryParam`, use the argument name instead.
$name = $attr->getName() ?? $argumentName;
$required = $attr->isRequired() ?? false;
$this->logger->debug("Polished QueryParam values: name='" . $name . "', required='" . $required . "'");
//fetch query name from request
$value = $request->query->get($name);
$this->logger->debug("The request query parameter value: '" . $value . "'");
//if default value is set and query param value is not set, use default value instead.
if (!$value && $argument->hasDefaultValue()) {
$value = $argument->getDefaultValue();
$this->logger->debug("After set default value: '" . $value . "'");
}
if ($required && !$value) {
throw new \InvalidArgumentException("Request query parameter '" . $name . "' is required, but not set.");
}
$this->logger->debug("final resolved value: '" . $value . "'");
//must return a `yield` clause
yield match ($type) {
'int' => $value ? (int)$value : 0,
'float' => $value ? (float)$value : .0,
'bool' => (bool)$value,
'string' => $value ? (string)$value : ($nullable ? null : ''),
null => null
};
}
public function supports(Request $request, ArgumentMetadata $argument): bool
{
$attrs = $argument->getAttributes(QueryParam::class);
return count($attrs) > 0;
}
public function setLogger(LoggerInterface $logger)
{
$this->logger = $logger;
}
}
在运行时,它会调用该supports函数来检查当前请求是否满足要求,如果满足要求,则调用该resovle函数。
在该supports函数中,我们检查参数是否带有注解QueryParam,如果存在,则从请求查询字符串中解析参数。
现在将用于处理/posts端点的函数更改为以下内容。
#[Route(path: "", name: "all", methods: ["GET"])]
function all(#[QueryParam] $keyword,
#[QueryParam] int $offset = 0,
#[QueryParam] int $limit = 20): Response
{
$data = $this->posts->findByKeyword($keyword || '', $offset, $limit);
return $this->json($data);
}
运行应用程序并使用. 测试/postscurl。
# curl http://localhost:8000/posts
{
"content":[
{
"id":"1ec3e1e0-17b3-6ed2-a01c-edecc112b436",
"title":"Building Restful APIs with Symfony and PHP 8"
}
],
"totalElements":1,
"offset":0,
"limit":20
}
按 ID 获取帖子
按照上一节的设计,添加另一个功能来PostController映射路线/posts/{id}。
class PostController extends AbstractController
{
//other functions...
#[Route(path: "/{id}", name: "byId", methods: ["GET"])]
function getById(Uuid $id): Response
{
$data = $this->posts->findOneBy(["id" => $id]);
if ($data) {
return $this->json($data);
} else {
return $this->json(["error" => "Post was not found by id:" . $id], 404);
}
}
}
运行该应用程序,并尝试访问http://localhost:8000/posts/{id},它将抛出如下异常。
App\Controller\PostController::getById(): Argument #1 ($id) must be of type Symfony\Component\Uid\Uuid, string given, cal
led in D:\hantsylabs\symfony5-sample\rest-sample\vendor\symfony\http-kernel\HttpKernel.php on line 156
URI 中的值id是一个字符串,不能直接使用Uuid。
Symfony 提供了ParamConverter将请求属性转换为目标类型的功能。我们可以创建自定义函数ParamConverter来实现此目的。
自定义 ParamConverter
在src/Request/UuidParamCovnerter文件夹下创建一个新类。
class UuidParamConverter implements ParamConverterInterface
{
public function __construct(private LoggerInterface $logger)
{
}
/**
* @inheritDoc
*/
public function apply(Request $request, ParamConverter $configuration): bool
{
$param = $configuration->getName();
if (!$request->attributes->has($param)) {
return false;
}
$value = $request->attributes->get($param);
$this->logger->info("parameter value:" . $value);
if (!$value && $configuration->isOptional()) {
$request->attributes->set($param, null);
return true;
}
$data = Uuid::fromString($value);
$request->attributes->set($param, $data);
return true;
}
/**
* @inheritDoc
*/
public function supports(ParamConverter $configuration): bool
{
$className = $configuration->getClass();
$this->logger->info("converting to UUID :{c}", ["c" => $className]);
return $className && $className == Uuid::class;
}
}
在上述代码中,
-
该
supports函数用于检查执行环境是否符合要求 -
apply执行转换的函数。如果返回supportsfalse,则跳过此转换步骤。
创建帖子
遵循 REST 约定,定义以下规则以提供一个端点来处理请求。
- 请求符合 HTTP 动词/HTTP 方法:
POST - 请求匹配路由端点:/posts
- 将请求头
Content-Type值设置为application/json,并使用请求体以 JSON 格式保存请求数据。 - 如果成功,则返回
CREATED(201) HTTP 状态代码,并将响应标头Location值设置为新创建的帖子的 URI。
#[Route(path: "", name: "create", methods: ["POST"])]
public function create(Request $request): Response
{
$data = $this->serializer->deserialize($request->getContent(), CreatePostDto::class, 'json');
$entity = PostFactory::create($data->getTitle(), $data->getContent());
$this->posts->getEntityManager()->persist($entity);
return $this->json([], 201, ["Location" => "/posts/" . $entity->getId()]);
}
它重写了父类的方法,以便从父类posts->getEntityManager()获取数据,你也可以直接注入或修改它来进行持久化操作。Doctrine主要用于构建查询条件和执行自定义查询。EntityManagerObjectManagerEntityManagerInterfacePostControllerRepository
创建一个测试函数来验证PostControllerTest文件中的有效性。
public function testCreatePost(): void
{
$client = static::createClient();
$data = CreatePostDto::of("test title", "test content");
$crawler = $client->request(
'POST',
'/posts',
[],
[],
[],
$this->getContainer()->get('serializer')->serialize($data, 'json')
);
$this->assertResponseIsSuccessful();
$response = $client->getResponse();
$url = $response->headers->get('Location');
//dump($data);
$this->assertNotNull($url);
$this->assertStringStartsWith("/posts/", $url);
}
转换请求体
Request我们还可以使用注解/属性,通过引入自定义注解/属性来消除处理对象的原始代码ArgumentResolver。
创建Body 属性。
#[Attribute(Attribute::TARGET_PARAMETER)]
final class Body
{
}
然后创建一个BodyValueResolver。
class BodyValueResolver implements ArgumentValueResolverInterface, LoggerAwareInterface
{
public function __construct(private SerializerInterface $serializer)
{
}
private LoggerInterface $logger;
/**
* @inheritDoc
*/
public function resolve(Request $request, ArgumentMetadata $argument)
{
$type = $argument->getType();
$this->logger->debug("The argument type:'" . $type . "'");
$format = $request->getContentType() ?? 'json';
$this->logger->debug("The request format:'" . $format . "'");
//read request body
$content = $request->getContent();
$data = $this->serializer->deserialize($content, $type, $format);
// $this->logger->debug("deserialized data:{0}", [$data]);
yield $data;
}
/**
* @inheritDoc
*/
public function supports(Request $request, ArgumentMetadata $argument): bool
{
$attrs = $argument->getAttributes(Body::class);
return count($attrs) > 0;
}
public function setLogger(LoggerInterface $logger)
{
$this->logger = $logger;
}
该supports方法会简单地检测方法参数是否带有Body属性注释,然后应用resolve该方法将请求正文内容反序列化为类型化对象。
运行应用程序并通过/posts测试端点。
curl -v http://localhost:8000/posts -H "Content-Type:application/json" -d "{\"title\":\"test title\",\"content\":\"test content\"}"
> POST /posts HTTP/1.1
> Host: localhost:8000
> User-Agent: curl/7.55.1
> Accept: */*
> Content-Type:application/json
> Content-Length: 47
>
< HTTP/1.1 201 Created
< Cache-Control: no-cache, private
< Content-Type: application/json
< Date: Sun, 21 Nov 2021 08:42:49 GMT
< Location: /posts/1ec4aa70-1b21-6bce-93f8-b39330fe328e
< X-Powered-By: PHP/8.0.10
< X-Robots-Tag: noindex
< Content-Length: 2
<
[]
异常处理
Symfony 内核提供了一个事件机制,可以Exception在Controller类中引发异常,并在自定义EventListener或中处理它们EventSubscriber。
例如,创建一个PostNotFoundException。
class PostNotFoundException extends \RuntimeException
{
public function __construct(Uuid $uuid)
{
parent::__construct("Post #" . $uuid . " was not found");
}
}
创建一个事件监听器来捕获此异常,并按预期处理该异常。
class ExceptionListener implements LoggerAwareInterface
{
private LoggerInterface $logger;
public function __construct()
{
}
public function onKernelException(ExceptionEvent $event)
{
// You get the exception object from the received event
$exception = $event->getThrowable();
$data = ["error" => $exception->getMessage()];
// Customize your response object to display the exception details
$response = new JsonResponse($data);
// HttpExceptionInterface is a special type of exception that
// holds status code and header details
if ($exception instanceof PostNotFoundException) {
$response->setStatusCode(Response::HTTP_NOT_FOUND);
} else if ($exception instanceof HttpExceptionInterface) {
$response->setStatusCode($exception->getStatusCode());
$response->headers->replace($exception->getHeaders());
} else {
$response->setStatusCode(Response::HTTP_INTERNAL_SERVER_ERROR);
}
// sends the modified response object to the event
$event->setResponse($response);
}
public function setLogger(LoggerInterface $logger)
{
$this->logger = $logger;
}
}
ExceptionListener在config/service.yml文件中注册此项。
App\EventListener\ExceptionListener:
tags:
- { name: kernel.event_listener, event: kernel.exception, priority: 50 }
它表示将event.exception事件绑定到ExceptionListener,并priority在执行时设置顺序。
运行以下命令以显示所有已注册的EventListener/ EventSubscribers 在事件kernel.exception上。
php bin/console debug:event-subscriber kernel.exception
将getById函数改为以下内容。
#[Route(path: "/{id}", name: "byId", methods: ["GET"])]
function getById(Uuid $id): Response
{
$data = $this->posts->findOneBy(["id" => $id]);
if ($data) {
return $this->json($data);
} else {
throw new PostNotFoundException($id);
}
}
添加测试以验证帖子是否未找到并返回 404 状态码。
public function testGetANoneExistingPost(): void
{
$client = static::createClient();
$id = Uuid::v4();
$crawler = $client->request('GET', '/posts/' . $id);
//
$response = $client->getResponse();
$this->assertResponseStatusCodeSame(404);
$data = $response->getContent();
$this->assertStringContainsString("Post #" . $id . " was not found", $data);
}
再次运行应用程序,并尝试通过不存在的 ID 访问单个帖子。
curl http://localhost:8000/posts/1ec3e1e0-17b3-6ed2-a01c-edecc112b438 -H "Accept: application/json" -v
> GET /posts/1ec3e1e0-17b3-6ed2-a01c-edecc112b438 HTTP/1.1
> Host: localhost:8000
> User-Agent: curl/7.55.1
> Accept: application/json
>
< HTTP/1.1 404 Not Found
< Cache-Control: no-cache, private
< Content-Type: application/json
< Date: Mon, 22 Nov 2021 03:57:51 GMT
< X-Powered-By: PHP/8.0.10
< X-Robots-Tag: noindex
< Content-Length: 69
<
{"error":"Post #1ec3e1e0-17b3-6ed2-a01c-edecc112b438 was not found."}
请从我的Github获取完整源代码。
文章来源:https://dev.to/hantsy/-building-restful-apis-with-symfony-5-and-php-8-1p2e