btoweb/doctrine-rest-driver

将REST API当作本地数据库使用。circle/doctrine-rest-driver的分支

1.4.3 2018-12-19 10:33 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
      
services:
  rest.orm.entity_manager:
    public: false
    class: Circle\DoctrineRestDriver\Decorator\RestEntityManager
    decorates: "doctrine.orm.entity_manager"
    arguments: ["@rest.orm.entity_manager.inner"]

所有可能选项的完整列表可以在这里找到: 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请求
    • URLs 的格式如下: {apiHost}/{pathToApi}/{tableName}/{id}
  • DELETE 操作将保留
    • URLs 的格式如下: {apiHost}/{pathToApi}/{tableName}/{id}
  • SELECT 查询变为 GET 请求
    • URLs 的格式如下: {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 值是必须的,method 和 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:
          authentication_class:  "HttpAuthentication"
      address_api:
        driver_class: "Circle\\DoctrineRestDriver\\Driver"
        host:         "http://api.address.your-url.com"
        port:         80
        user:         "Circle"
        password:     "CircleAddresses"
        options:
          authentication_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文件中,按照他们首次贡献的时间排序。