circle/doctrine-rest-driver

将REST API当作本地数据库使用

1.3.6 2019-07-12 13:32 UTC

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}
  • UPDATE 查询将转换为 PUT 请求。
    • URL 有以下格式: {apiHost}/{pathToApi}/{tableName}/{id}
  • DELETE 操作将保持不变。
    • URL 有以下格式: {apiHost}/{pathToApi}/{tableName}/{id}
  • SELECT 查询变为 GET 请求。
    • URL 有以下格式: {apiHost}/{pathToApi}/{tableName}/{id}(如果请求单个实体)或 {apiHost}/{pathToApi}/{tableName}(如果请求所有实体)

让我们通过实现一些控制器方法来观察驱动程序的实际运行情况。在这个例子中,我们假设我们已经配置了 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_pagepage 的参数键也可以在配置文件中设置

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 文件中列出,按他们首次贡献的时间排序。