circle / doctrine-rest-driver
将REST API当作本地数据库使用
Requires
- php: >=5.5
- ci/restclientbundle: ^2.0.2
- doctrine/dbal: 2.*
- doctrine/orm: 2.*
- greenlion/php-sql-parser: ^4.0
Requires (Dev)
- doctrine/doctrine-bundle: ^1.6
- phpmd/phpmd: @stable
- phpunit/phpunit: @stable
- sensio/framework-extra-bundle: 3.*
- symfony/expression-language: ^3.0
- symfony/framework-bundle: 3.*
- symfony/validator: 3.*
- symfony/yaml: 3.*
README
黑羊和白羊有什么共同点?它们都产羊毛。
大巴士和小巴士有什么共同点?它们载送人们。
那么SQL数据库和REST API有什么共同点?它们都存储数据。
虽然听起来很明显,但后果是巨大的:因为REST API仅仅是数据存储后端,所以我们能够重用对象关系映射工具来访问它们。
因为我们完全没有编写编程语言的能力,所以我们尝试像Rasmus一样,一步一步地添加逻辑步骤。因此,DoctrineRestDriver应运而生。
先决条件
- 您需要Composer来下载库
安装
使用Composer将驱动添加到项目中
composer require circle/doctrine-rest-driver
更改以下doctrine dbal配置项
doctrine: dbal: driver_class: "Circle\\DoctrineRestDriver\\Driver" host: "%default_api_url%" port: "%default_api_port%" user: "%default_api_username%" password: "%default_api_password%" options: format: "json" | "YourOwnNamespaceName" | if not specified json will be used authenticator_class: "HttpAuthentication" | "YourOwnNamespaceName" | if not specified no authentication will be used
此外,您可以添加CURL特定的选项
doctrine: dbal: driver_class: "Circle\\DoctrineRestDriver\\Driver" host: "%default_api_url%" port: "%default_api_port%" user: "%default_api_username%" password: "%default_api_password%" options: format: "json" authenticator_class: "HttpAuthentication" CURLOPT_CURLOPT_FOLLOWLOCATION: true CURLOPT_HEADER: true
所有可能选项的完整列表可以在这里找到: https://php.ac.cn/manual/en/function.curl-setopt.php
默认情况下,UPDATE查询被转换为PUT以与大多数API协同工作,然而,当持久化更新后的实体时,Doctrine将比较编辑后的实体与原始数据,并创建仅包含更改字段的查询。在REST API中,这将转换为PATCH请求,因为PUT意味着即使某些属性没有更改,也要包括整个实体。
要使用PATCH而不是PUT,只需添加一个配置值
doctrine: dbal: options: use_patch: true
使用
如果您的API路由遵循以下几个约定,使用驱动非常简单
- 每个路由都必须具有相同的结构:
{apiHost}/{pathToApi}/{tableName}
- PUT/PATCH、GET(单个)和UPDATE路由需要包含一个额外的
id
:{apiHost}/{pathToApi}/{tableName}/{id}
- POST和GET(所有)必须遵循基本结构:
{apiHost}/{pathToApi}/{tableName}
不用担心,如果这不是这样:幸运的是,我们提供了一些注解,供您配置自己的路由。
响应
您的API允许以少量不同的HTTP状态码响应,被认为是成功的响应。
请注意,在某些情况下,404响应被视为“成功”的响应。这允许驱动程序反映数据库的查询,但没有数据返回,例如“/entity/1”将允许404而不会引发异常,因为从数据库中找不到实体是完全可以接受的。
以下示例显示了如何在Symfony环境中使用驱动程序。
如果您的API遵循我们的约定
首先创建您的实体
namespace CircleBundle\Entity; use Doctrine\ORM\Mapping as ORM; /** * This annotation marks the class as managed entity: * * @ORM\Entity * @ORM\Table("products") */ class Product { /** * @ORM\Column(type="integer") * @ORM\Id * @ORM\GeneratedValue(strategy="AUTO") */ private $id; /** * @ORM\Column(type="string", length=100) */ private $name; public function getId() { return $this->id; } public function setName($name) { $this->name = $name; return $this; } public function getName() { return $this->name; } }
之后,您可以使用创建的实体,就像使用数据库一样。
通过此设置,驱动程序在幕后执行了许多魔法
- 它通常使用请求正文以JSON格式发送数据
- 如果状态码与默认预期状态码匹配(GET和PUT为200,POST为201,DELETE为204),它将自动将响应映射到有效的实体
- 它将实体保存为管理 Doctrine 实体。
- 它将 INSERT 查询转换为 POST 请求以创建新数据。
- URL 有以下格式:
{apiHost}/{pathToApi}/{tableName}
- URL 有以下格式:
- UPDATE 查询将转换为 PUT 请求。
- URL 有以下格式:
{apiHost}/{pathToApi}/{tableName}/{id}
- URL 有以下格式:
- DELETE 操作将保持不变。
- URL 有以下格式:
{apiHost}/{pathToApi}/{tableName}/{id}
- URL 有以下格式:
- SELECT 查询变为 GET 请求。
- URL 有以下格式:
{apiHost}/{pathToApi}/{tableName}/{id}
(如果请求单个实体)或{apiHost}/{pathToApi}/{tableName}
(如果请求所有实体)
- URL 有以下格式:
让我们通过实现一些控制器方法来观察驱动程序的实际运行情况。在这个例子中,我们假设我们已经配置了 host
设置 (章节安装) 为 http://www.yourSite.com/api
。
<?php namespace CircleBundle\Controller; use Symfony\Bundle\FrameworkBundle\Controller\Controller; use Symfony\HttpFoundation\Response; class UserController extends Controller { /** * Sends the following request to the API: * POST http://www.yourSite.com/api/products HTTP/1.1 * {"name": "Circle"} * * Let's assume the API responded with: * HTTP/1.1 201 Created * {"id": 1, "name": "Circle"} * * Response body is "1" */ public function createAction() { $em = $this->getDoctrine()->getManager(); $entity = new CircleBundle\Entity\Product(); $entity->setName('Circle'); $em->persist($entity); $em->flush(); return new Response($entity->getId()); } /** * Sends the following request to the API by default: * GET http://www.yourSite.com/api/products/1 HTTP/1.1 * * which might respond with: * HTTP/1.1 200 OK * {"id": 1, "name": "Circle"} * * Response body is "Circle" */ public function readAction($id = 1) { $em = $this->getDoctrine()->getManager(); $entity = $em->find('CircleBundle\Entity\Product', $id); return new Response($entity->getName()); } /** * Sends the following request to the API: * GET http://www.yourSite.com/api/products HTTP/1.1 * * Example response: * HTTP/1.1 200 OK * [{"id": 1, "name": "Circle"}] * * Response body is "Circle" */ public function readAllAction() { $em = $this->getDoctrine()->getManager(); $entities = $em->getRepository('CircleBundle\Entity\Product')->findAll(); return new Response($entities->first()->getName()); } /** * After sending a GET request (readAction) it sends the following * request to the API by default: * PUT http://www.yourSite.com/api/products/1 HTTP/1.1 * {"name": "myName"} * * Let's assume the API responded the GET request with: * HTTP/1.1 200 OK * {"id": 1, "name": "Circle"} * * and the PUT request with: * HTTP/1.1 200 OK * {"id": 1, "name": "myName"} * * Then the response body is "myName" */ public function updateAction($id = 1) { $em = $this->getDoctrine()->getManager(); $entity = $em->find('CircleBundle\Entity\Product', $id); $entity->setName('myName'); $em->flush(); return new Response($entity->getName()); } /** * After sending a GET request (readAction) it sends the following * request to the API by default: * DELETE http://www.yourSite.com/api/products/1 HTTP/1.1 * * If the response is: * HTTP/1.1 204 No Content * * the response body is "" */ public function deleteAction($id = 1) { $em = $this->getDoctrine()->getManager(); $entity = $em->find('CircleBundle\Entity\Product', $id); $em->remove($entity); $em->flush(); return new Response(); } }
如果你的 API 不遵循我们的约定
现在介绍一些注解,这些注解可以帮助您配置自己的路由。务必仅在 Doctrine
实体上使用它们。所有这些注解都有相同的结构,因此我们将它们称为 DataSource
注解
@DataSource\SomeName("http://www.myRoute.com", method="POST", statusCode=200)
ROUTE
值是必需的,而方法和 statusCode 是可选的。
为了展示它们的能力,让我们使用 DataSource
注解自定义前一章的一些部分
namespace CircleBundle\Entity; use Doctrine\ORM\Mapping as ORM; use Circle\DoctrineRestDriver\Annotations as DataSource; /** * This annotation marks the class as managed entity: * * @ORM\Entity * @ORM\Table("products") * @DataSource\Select("http://www.yourSite.com/api/products/findOne/{id}") * @DataSource\Fetch("http://www.yourSite.com/api/products/findAll") * @DataSource\Insert("http://www.yourSite.com/api/products/insert", statusCodes={200}) * @DataSource\Update("http://www.yourSite.com/api/products/update/{id}", method="POST") * @DataSource\Delete("http://www.yourSite.com/api/products/remove/{id}", method="POST", statusCodes={200}) */ class Product { /** * @ORM\Column(type="integer") * @ORM\Id * @ORM\GeneratedValue(strategy="AUTO") */ private $id; /** * @ORM\Column(type="string", length=100) */ private $name; public function getId() { return $this->id; } public function setName($name) { $this->name = $name; return $this; } public function getName() { return $this->name; } }
如果您有一个可接受的状态码列表,您还可以将数组传递给 statusCode
选项
<?php namespace CircleBundle\Entity; use Doctrine\ORM\Mapping as ORM; use Circle\DoctrineRestDriver\Annotations as DataSource; /** * This annotation marks the class as managed entity: * * @ORM\Entity * @ORM\Table("products") * @DataSource\Select("http://www.yourSite.com/api/products/findOne/{id}", statusCodes={200, 203, 404}) */ class Product { // ... // }
这些注解告诉驱动程序为每个自定义配置向配置的 URL 发送请求。如果您只想为一种方法定义一个特定的路由,则不需要使用所有提供的注解。{id}
作为一个占位符,用于实体的标识符。
<?php namespace CircleBundle\Controller; use Symfony\Bundle\FrameworkBundle\Controller\Controller; use Symfony\HttpFoundation\Response; class UserController extends Controller { /** * Sends the following request to the API: * POST http://www.yourSite.com/api/products/insert HTTP/1.1 * {"name": "Circle"} * * Let's assume the API responded with: * HTTP/1.1 200 OK * {"id": 1, "name": "Circle"} * * Response body is "1" */ public function createAction() { $em = $this->getDoctrine()->getManager(); $entity = new CircleBundle\Entity\Product(); $entity->setName('Circle'); $em->persist($entity); $em->flush(); return new Response($entity->getId()); } /** * Sends the following request to the API by default: * GET http://www.yourSite.com/api/products/findOne/1 HTTP/1.1 * * which might respond with: * HTTP/1.1 200 OK * {"id": 1, "name": "Circle"} * * Response body is "Circle" */ public function readAction($id = 1) { $em = $this->getDoctrine()->getManager(); $entity = $em->find('CircleBundle\Entity\Product', $id); return new Response($entity->getName()); } /** * Sends the following request to the API: * GET http://www.yourSite.com/api/products/findAll HTTP/1.1 * * Example response: * HTTP/1.1 200 OK * [{"id": 1, "name": "Circle"}] * * Response body is "Circle" */ public function readAllAction() { $em = $this->getDoctrine()->getManager(); $entities = $em->getRepository('CircleBundle\Entity\Product')->findAll(); return new Response($entities->first()->getName()); } /** * After sending a GET request (readAction) it sends the following * request to the API by default: * POST http://www.yourSite.com/api/products/update/1 HTTP/1.1 * {"name": "myName"} * * Let's assume the API responded the GET request with: * HTTP/1.1 200 OK * {"id": 1, "name": "Circle"} * * and the POST request with: * HTTP/1.1 200 OK * {"id": 1, "name": "myName"} * * Then the response body is "myName" */ public function updateAction($id = 1) { $em = $this->getDoctrine()->getManager(); $entity = $em->find('CircleBundle\Entity\Product', $id); $entity->setName('myName'); $em->flush(); return new Response($entity->getName()); } /** * After sending a GET request (readAction) it sends the following * request to the API by default: * POST http://www.yourSite.com/api/products/remove/1 HTTP/1.1 * * If the response is: * HTTP/1.1 200 OK * * the response body is "" */ public function deleteAction($id = 1) { $em = $this->getDoctrine()->getManager(); $entity = $em->find('CircleBundle\Entity\Product', $id); $em->remove($entity); $em->flush(); return new Response(); } }
分页
Doctrine 中的查询可以使用 OFFSET 和 LIMIT 关键字进行分页。默认情况下,分页将作为请求头发送,但可以配置为将分页作为查询参数发送
doctrine: dbal: options: pagination_as_query: true
这会转换为以下内容
SELECT name FROM users LIMIT 5 OFFSET 10
到
https://api.example.com/users?per_page=5&page=3
用于 per_page
和 page
的参数键也可以在配置文件中设置
doctrine: dbal: options: per_page_param: count page_param: p
示例
需要更多示例吗?这里有一些
持久化实体
想象您有一个位于 http://www.your-url.com/api 的 REST API
typedef UnregisteredAddress { street: String, city: String } typedef RegisteredAddress { id: Int, street: String, city: String }
让我们首先配置 Doctrine
doctrine: dbal: driver_class: "Circle\\DoctrineRestDriver\\Driver" host: "http://www.your-url.com/api" port: "80" user: "" password: ""
之后,让我们构建地址实体
<?php namespace CircleBundle\Entity; use Doctrine\ORM\Mapping as ORM; /** * @ORM\Entity() * @ORM\Table("addresses") */ class Address { /** * @ORM\Column(type="integer") * @ORM\Id * @ORM\GeneratedValue(strategy="AUTO") */ private $id; /** * @ORM\Column(type="string") */ private $street; /** * @ORM\Column(type="string") */ private $city; public function setStreet($street) { $this->street = $street; return $this; } public function getStreet() { return $this->street; } public function setCity($city) { $this->city = $city; return $this; } public function getCity() { return $this->city; } public function getId() { return $this->id; } }
最后,控制器及其操作
<?php namespace CircleBundle\Controller; use Symfony\Bundle\FrameworkBundle\Controller\Controller; use Symfony\HttpFoundation\Response; class AddressController extends Controller { public function createAction($street, $city) { $em = $this->getDoctrine()->getManager(); $address = new CircleBundle\Address(); $address->setStreet($street)->setCity($city); $em->persist($address); try { $em->flush(); return new Response('successfully registered'); } catch(RequestFailedException) { return new Response('invalid address'); } } }
就这样。每次调用 createAction 时,它都会向 API 发送 POST 请求。
关联实体
让我们扩展第一个示例。现在我们想要添加一个新的实体类型 User
,该类型引用了前一个示例中定义的地址。
REST API 提供以下附加路由
typedef UnregisteredUser { name: String, password: String, address: Index } typedef RegisteredUser { id: Int, name: String, password: String, address: Index }
首先,我们需要构建一个额外的实体 "User"
namespace Circle\Entity; use Doctrine\ORM\Mapping as ORM; /** * @ORM\Entity() * @ORM\Table("users") */ class User { /** * @ORM\Column(type="integer") * @ORM\Id * @ORM\GeneratedValue(strategy="AUTO") */ private $id; /** * @ORM\Column(type="string") */ private $name; /** * @ORM\OneToOne(targetEntity="CircleBundle\Address", cascade={"persist, remove"}) */ private $address; /** * @ORM\Column(type="string") */ private $password; public function setName($name) { $this->name = $name; return $this; } public function getName() { return $this->name; } public function setPassword($password) { $this->password = $password; return $this; } public function getPassword() { return $this->password; } public function getId() { return $this->id; } public function setAddress(Address $address) { $this->address = $address; return $this; } public function getAddress() { return $this->address; } }
用户与地址有关联。所以让我们看看当我们关联它们时会发生什么
<?php namespace CircleBundle\Controller; use Symfony\Bundle\FrameworkBundle\Controller\Controller; use Symfony\HttpFoundation\Response; class UserController extends Controller { public function createAction($name, $password, $addressId) { $em = $this->getDoctrine()->getManager(); $address = $em->find("CircleBundle\Entity\Address", $addressId); $user = new User(); $user->setName($name) ->setPassword($password) ->setAddress($address); $em->persist($user); $em->flush(); return new Response('successfully registered'); } }
如果我们通过触发 createAction 设置名称为 username
、密码为 secretPassword
和 addressId 为 1
,我们的驱动程序将发送以下请求
GET http://www.your-url.com/api/addresses/1 HTTP/1.1
POST http://www.your-url.com/api/users HTTP/1.1 {"name": "username", "password":"secretPassword", "address":1}
因为我们已在用户和地址之间的关系上使用了 cascade={"remove"}
选项,所以如果拥有用户被删除,将自动发送地址的 DELETE 请求
<?php namespace CircleBundle\Controller; use Symfony\Bundle\FrameworkBundle\Controller\Controller; use Symfony\HttpFoundation\Response; class UserController extends Controller { public function remove($id = 1) { $em = $this->getDoctrine()->getManager(); $em->find('CircleBundle\Entity\User', $id); $em->remove($em); $em->flush(); return new Response('successfully removed'); } }
例如,带有 id 1
的 DELETE 请求将触发以下请求
DELETE http://www.your-url.com/api/addresses/1 HTTP/1.1
DELETE http://www.your-url.com/api/users/1 HTTP/1.1
太棒了,不是吗?
使用多个后端
在这个最后一个例子中,我们将用户和地址路由分割成两个不同的 REST API。这意味着我们需要多个管理者,这已在 Doctrine 文档中解释
doctrine: dbal: default_connection: default connections: default: driver: "pdo_mysql" host: "localhost" port: 3306 user: "root" password: "root" user_api: driver_class: "Circle\\DoctrineRestDriver\\Driver" host: "http://api.user.your-url.com" port: 80 user: "Circle" password: "CircleUsers" options: authenticator_class: "HttpAuthentication" address_api: driver_class: "Circle\\DoctrineRestDriver\\Driver" host: "http://api.address.your-url.com" port: 80 user: "Circle" password: "CircleAddresses" options: authenticator_class: "HttpAuthentication"
现在变得疯狂了:我们将尝试从两个不同的 API 读取数据并将它们持久化到 MySQL 数据库中。想象一下以下路由的用户 API
以及以下入口点的地址 API
我们希望从用户API读取用户信息,并从地址API读取地址信息。之后,我们将它们关联并持久化到我们的MySQL数据库中。
<?php namespace CircleBundle\Controller; use Symfony\Bundle\FrameworkBundle\Controller\Controller; use Symfony\HttpFoundation\Response; class UserController extends Controller { public function createAction($userId, $addressId) { $emUsers = $this->getDoctrine()->getManager('user_api'); $emAddresses = $this->getDoctrine()->getManager('address_api'); $emPersistence = $this->getDoctrine()->getManager(); $user = $emUsers->find("CircleBundle\Entity\User", $userId); $address = $emAddresses->find("CircleBundle\Entity\Address", $addressId); $user->setAddress($address); $emPersistence->persist($address); $emPersistence->persist($user); $emPersistence->flush(); return new Response('successfully persisted'); } }
正如您在请求日志中所见,请求了两个API。
GET http://api.users.your-url.com/users/1 HTTP/1.1
GET http://api.addresses.your-url.com/addresses/1 HTTP/1.1
之后,这两个实体都持久化到了默认的MySQL数据库中。
测试
要测试这个包,只需输入
make test
贡献
如果您想为此存储库做出贡献,请确保...
- 遵循现有的编码风格。
- 使用在
composer.json
中列出的代码检查工具(当您使用make
时,这些工具是免费的)。 - 为任何更改的代码添加和/或自定义单元测试。
- 在您的pull request中引用相应的issue,并附带您更改的简要描述。
所有贡献者都在 AUTHORS
文件中列出,按他们首次贡献的时间排序。