关于数据库:PHP中的正确存储库模式设计?

Proper Repository Pattern Design in PHP?

前言:我正试图在关系数据库的MVC体系结构中使用存储库模式。

我最近开始用PHP学习TDD,我意识到我的数据库与应用程序的其余部分耦合得太紧密了。我已经阅读过有关存储库的内容,并使用IOC容器将其"注入"到我的控制器中。很酷的东西。但是现在有一些关于存储库设计的实际问题。考虑下面的例子。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
<?php

class DbUserRepository implements UserRepositoryInterface
{
    protected $db;

    public function __construct($db)
    {
        $this->db = $db;
    }

    public function findAll()
    {
    }

    public function findById($id)
    {
    }

    public function findByName($name)
    {
    }

    public function create($user)
    {
    }

    public function remove($user)
    {
    }

    public function update($user)
    {
    }
}

问题1:字段太多

所有这些查找方法都使用全选字段(SELECT *方法)。然而,在我的应用程序中,我总是试图限制我得到的字段的数量,因为这通常会增加开销并降低速度。对于使用这种模式的人,您如何处理这种情况?

问题2:方法太多

虽然这个类现在看起来不错,但我知道在现实世界中,我需要更多的方法。例如:

  • findallbyname和status
  • 芬兰国家
  • 查找电子邮件地址集
  • 查找日期和性别
  • findallbyage和genderorderbyage
  • 等。

正如您所看到的,可能有一个非常,非常长的可能方法列表。然后,如果你加入上面的字段选择问题,问题会恶化。在过去,我通常只是把所有这些逻辑放在我的控制器中:

1
2
3
4
5
6
7
8
9
10
11
12
<?php

class MyController
{
    public function users()
    {
        $users = User::select('name, email, status')
            ->byCountry('Canada')->orderBy('name')->rows();

        return View::make('users', array('users' => $users));
    }
}

使用我的存储库方法,我不希望以这种方式结束:

1
2
3
4
5
6
7
8
9
10
11
12
<?php

class MyController
{
    public function users()
    {
        $users = $this->repo->get_first_name_last_name_email_username_status_by_country_order_by_name('Canada');

        return View::make('users', array('users' => $users))
    }

}

问题3:无法匹配接口

我看到了将接口用于存储库的好处,因此我可以交换我的实现(用于测试或其他目的)。我对接口的理解是,它们定义了一个实现必须遵循的契约。这非常好,直到您开始向存储库中添加其他方法,如findAllInCountry()。现在我需要更新我的接口来拥有这个方法,否则,其他实现可能没有这个方法,这可能会破坏我的应用程序。这让人觉得很疯狂……一个尾巴摇狗的案例。

规格模式?

这使我相信知识库应该只有固定数量的方法(如save()remove()find()findAll()等)。但是我该如何运行特定的查找呢?我听说过规范模式,但在我看来,这只会减少一组完整的记录(通过IsSatisfiedBy()),如果从数据库中提取数据,显然会出现重大的性能问题。

帮助?

显然,我需要在使用存储库时重新考虑一些事情。有人能告诉我们如何最好地处理这件事吗?


我想我会设法回答我自己的问题。以下只是解决我原来问题1-3的一种方法。好的。

免责声明:在描述模式或技术时,我可能并不总是使用正确的术语。很抱歉。好的。目标:

  • 创建一个基本控制器的完整示例,用于查看和编辑Users
  • 所有代码必须是完全可测试和可模拟的。
  • 控制器不应该知道数据存储在哪里(意味着可以更改)。
  • 显示SQL实现的示例(最常见)。
  • 为了获得最佳性能,控制器应该只接收不需要额外字段的数据。
  • 实现应该利用某种类型的数据映射器以便于开发。
  • 实现应该能够执行复杂的数据查找。

解决方案

我将持久存储(数据库)交互分为两类:R(读)和CUD(创建、更新、删除)。我的经验是,读操作实际上是导致应用程序速度减慢的原因。虽然数据操作(CUD)实际上速度较慢,但发生的频率要低得多,因此也就不那么令人担心了。好的。

CUD(创建、更新、删除)很容易。这将涉及使用实际模型,然后将这些模型传递给我的Repositories,以便持久化。注意,我的存储库仍将提供一个读取方法,但只是用于对象创建,而不是显示。以后再谈。好的。

读不容易。这里没有模型,只重视对象。如果愿意,可以使用数组。这些对象可以表示单个模型,也可以表示多个模型的混合,实际上是任何东西。它们本身并不是很有趣,但是它们是如何产生的。我用的是我所说的Query Objects。好的。代码:用户模型

让我们从我们的基本用户模型开始。请注意,根本没有ORM扩展或数据库内容。纯粹的模特荣耀。添加getter、setter、validation等等。好的。

1
2
3
4
5
6
7
8
9
class User
{
    public $id;
    public $first_name;
    public $last_name;
    public $gender;
    public $email;
    public $password;
}

存储库接口

在创建用户存储库之前,我想创建我的存储库界面。这将定义存储库必须遵循的"契约",以供我的控制器使用。记住,我的控制器将不知道数据实际存储在哪里。好的。

请注意,我的存储库将只包含这三种方法。save()方法负责创建和更新用户,这取决于用户对象是否设置了ID。好的。

1
2
3
4
5
6
interface UserRepositoryInterface
{
    public function find($id);
    public function save(User $user);
    public function remove(User $user);
}

SQL存储库实现

现在来创建接口的实现。如前所述,我的示例将使用SQL数据库。请注意,使用数据映射器可以防止编写重复的SQL查询。好的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
class SQLUserRepository implements UserRepositoryInterface
{
    protected $db;

    public function __construct(Database $db)
    {
        $this->db = $db;
    }

    public function find($id)
    {
        // Find a record with the id = $id
        // from the 'users' table
        // and return it as a User object
        return $this->db->find($id, 'users', 'User');
    }

    public function save(User $user)
    {
        // Insert or update the $user
        // in the 'users' table
        $this->db->save($user, 'users');
    }

    public function remove(User $user)
    {
        // Remove the $user
        // from the 'users' table
        $this->db->remove($user, 'users');
    }
}

查询对象接口

现在,通过存储库处理CUD(创建、更新、删除),我们可以将重点放在R(读取)。查询对象只是某种数据查找逻辑的封装。它们不是查询生成器。通过像存储库一样抽象它,我们可以更容易地更改它的实现和测试。查询对象的一个例子可能是AllUsersQueryAllActiveUsersQuery,甚至是MostCommonUserFirstNames。好的。

您可能会想,"难道我不能在我的存储库中为这些查询创建方法吗?"是的,但这就是我不这么做的原因:好的。

  • 我的存储库用于处理模型对象。在现实世界的应用程序中,如果我想列出我的所有用户,为什么我需要获得password字段?
  • 存储库通常是特定于模型的,但是查询通常涉及多个模型。那么,您将方法放在什么存储库中呢?
  • 这使我的存储库非常简单,而不是一个膨胀的方法类。
  • 所有查询现在都组织到自己的类中。
  • 实际上,此时,存储库的存在只是为了抽象我的数据库层。

对于我的示例,我将创建一个查询对象来查找"alluser"。界面如下:好的。

1
2
3
4
interface AllUsersQueryInterface
{
    public function fetch($fields);
}

查询对象实现

在这里,我们可以再次使用数据映射器来帮助加速开发。注意,我允许对返回的数据集字段进行一次调整。这就是我想要处理执行的查询的范围。记住,我的查询对象不是查询生成器。它们只执行特定的查询。但是,因为我知道在许多不同的情况下,我可能会经常使用这个字段,所以我给自己指定字段的能力。我从不想退回我不需要的田地!好的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class AllUsersQuery implements AllUsersQueryInterface
{
    protected $db;

    public function __construct(Database $db)
    {
        $this->db = $db;
    }

    public function fetch($fields)
    {
        return $this->db->select($fields)->from('users')->orderBy('last_name, first_name')->rows();
    }
}

在转到控制器之前,我想展示另一个例子来说明这有多强大。也许我有一个报告引擎,需要为AllOverdueAccounts创建一个报告。这对我的数据映射器来说可能很棘手,在这种情况下,我可能需要编写一些实际的SQL。没问题,这个查询对象可能是这样的:好的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class AllOverdueAccountsQuery implements AllOverdueAccountsQueryInterface
{
    protected $db;

    public function __construct(Database $db)
    {
        $this->db = $db;
    }

    public function fetch()
    {
        return $this->db->query($this->sql())->rows();
    }

    public function sql()
    {
        return"SELECT...";
    }
}

这很好地将我的报告逻辑保存在一个类中,并且很容易测试。我可以根据自己的心意来模仿它,甚至可以完全使用不同的实现。好的。控制器

现在,有趣的部分是把所有的部分放在一起。注意,我使用的是依赖注入。通常依赖项被注入到构造函数中,但实际上我更喜欢直接注入到我的控制器方法(路由)中。这使控制器的对象图最小化,我发现它更清晰。注意,如果您不喜欢这种方法,只需使用传统的构造函数方法。好的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
class UsersController
{
    public function index(AllUsersQueryInterface $query)
    {
        // Fetch user data
        $users = $query->fetch(['first_name', 'last_name', 'email']);

        // Return view
        return Response::view('all_users.php', ['users' => $users]);
    }

    public function add()
    {
        return Response::view('add_user.php');
    }

    public function insert(UserRepositoryInterface $repository)
    {
        // Create new user model
        $user = new User;
        $user->first_name = $_POST['first_name'];
        $user->last_name = $_POST['last_name'];
        $user->gender = $_POST['gender'];
        $user->email = $_POST['email'];

        // Save the new user
        $repository->save($user);

        // Return the id
        return Response::json(['id' => $user->id]);
    }

    public function view(SpecificUserQueryInterface $query, $id)
    {
        // Load user data
        if (!$user = $query->fetch($id, ['first_name', 'last_name', 'gender', 'email'])) {
            return Response::notFound();
        }

        // Return view
        return Response::view('view_user.php', ['user' => $user]);
    }

    public function edit(SpecificUserQueryInterface $query, $id)
    {
        // Load user data
        if (!$user = $query->fetch($id, ['first_name', 'last_name', 'gender', 'email'])) {
            return Response::notFound();
        }

        // Return view
        return Response::view('edit_user.php', ['user' => $user]);
    }

    public function update(UserRepositoryInterface $repository)
    {
        // Load user model
        if (!$user = $repository->find($id)) {
            return Response::notFound();
        }

        // Update the user
        $user->first_name = $_POST['first_name'];
        $user->last_name = $_POST['last_name'];
        $user->gender = $_POST['gender'];
        $user->email = $_POST['email'];

        // Save the user
        $repository->save($user);

        // Return success
        return true;
    }

    public function delete(UserRepositoryInterface $repository)
    {
        // Load user model
        if (!$user = $repository->find($id)) {
            return Response::notFound();
        }

        // Delete the user
        $repository->delete($user);

        // Return success
        return true;
    }
}

最后的想法:

这里需要注意的重要事项是,当我修改(创建、更新或删除)实体时,我正在处理真实的模型对象,并通过我的存储库执行持久性。好的。

但是,当我显示(选择数据并将其发送到视图)时,我不处理模型对象,而是处理简单的旧值对象。我只选择需要的字段,它的设计是为了最大限度地提高数据查找性能。好的。

我的存储库保持非常干净,相反,这种"混乱"被组织到我的模型查询中。好的。

我使用数据映射器来帮助开发,因为为常见任务编写重复的SQL是荒谬的。但是,您绝对可以在需要的地方编写SQL(复杂的查询、报告等)。当你这样做的时候,它会很好地隐藏在一个适当命名的类中。好的。

我很想听听你对我的方法的看法!好的。

2015年7月更新:好的。

有人在评论中问我,我是在哪里结束了这一切的。好吧,实际上不远。说实话,我还是不太喜欢仓库。我发现对于基本的查找(特别是如果您已经使用了ORM)来说,它们会有点过分杀伤力,并且在处理更复杂的查询时会很混乱。好的。

我通常使用ActiveRecord样式的ORM,所以大多数情况下,我只在整个应用程序中直接引用这些模型。但是,在我有更复杂查询的情况下,我将使用查询对象使这些查询更可重用。我还应该注意到,我总是将我的模型注入我的方法中,使它们更容易在我的测试中模拟。好的。好啊。


根据我的经验,以下是您的一些问题的答案:

问:我们如何处理带回我们不需要的领域?

答:根据我的经验,这实际上可以归结为处理完整的实体和特殊的查询。

一个完整的实体就像一个User对象。它有属性和方法等。它是代码库中的头等公民。

特殊查询返回一些数据,但除此之外我们什么都不知道。当数据在应用程序中传递时,它是在没有上下文的情况下完成的。它是一个User吗?一个附加了一些Order信息的User?我们真的不知道。

我更喜欢与完整的实体合作。

您是对的,您经常会带回您不使用的数据,但您可以通过各种方式解决这一问题:

  • 积极地缓存实体,这样您只需从数据库中支付一次读取价格。
  • 花更多的时间为你的实体建模,这样它们之间就有了很好的区别。(考虑将一个大实体拆分为两个小实体等。)
  • 考虑拥有实体的多个版本。后端可以有一个User,Ajax调用可以有一个UserSmall。一个可能有10个属性,一个可能有3个属性。
  • 使用特殊查询的缺点是:

  • 在许多查询中,最终得到的数据基本相同。例如,使用User,您最终将为许多调用编写基本相同的select *。一个调用将获得10个字段中的8个,一个调用将获得10个字段中的5个,一个调用将获得10个字段中的7个。为什么不用一个10取10的呼叫来替换所有呼叫?这是不好的原因,因为重新考虑/测试/嘲笑是谋杀。
  • 随着时间的推移,很难对代码进行高层次的推理。而不是像"为什么User这么慢?"您最终会跟踪一次性查询,因此错误修复往往很小,而且是本地化的。
  • 要取代底层技术真的很难。如果你现在把所有的东西都存储在MySQL中,并且想转移到MongoDB,那么替换100个临时调用要比替换少数实体困难得多。
  • 问:我的存储库中的方法太多了。

    A:除了整合电话,我真的没见过其他方法。存储库中的方法调用实际上映射到应用程序中的功能。功能越多,特定于数据的调用就越多。您可以推回功能并尝试将类似的调用合并为一个。

    一天结束时的复杂性必须存在于某个地方。使用存储库模式,我们将其推送到存储库接口中,而不是创建一组存储过程。

    有时我不得不对自己说,"好吧,它必须给某个地方!没有银弹。"


    我使用以下接口:

    • Repository—加载、插入、更新和删除实体
    • Selector—在存储库中查找基于过滤器的实体
    • Filter—封装过滤逻辑

    我的Repository是数据库不可知论者;实际上它没有指定任何持久性;它可以是任何东西:SQL数据库、XML文件、远程服务、来自外层空间的外星人等。对于搜索能力,Repository构造了一个Selector,可以过滤、分类和计数。最后,选择器从持久性中获取一个或多个Entities

    以下是一些示例代码:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    <?php
    interface Repository
    {
        public function addEntity(Entity $entity);

        public function updateEntity(Entity $entity);

        public function removeEntity(Entity $entity);

        /**
         * @return Entity
         */

        public function loadEntity($entityId);

        public function factoryEntitySelector():Selector
    }


    interface Selector extends \Countable
    {
        public function count();

        /**
         * @return Entity[]
         */

        public function fetchEntities();

        /**
         * @return Entity
         */

        public function fetchEntity();
        public function limit(...$limit);
        public function filter(Filter $filter);
        public function orderBy($column, $ascending = true);
        public function removeFilter($filterName);
    }

    interface Filter
    {
        public function getFilterName();
    }

    然后,一个实现:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    class SqlEntityRepository
    {
        ...
        public function factoryEntitySelector()
        {
            return new SqlSelector($this);
        }
        ...
    }

    class SqlSelector implements Selector
    {
        ...
        private function adaptFilter(Filter $filter):SqlQueryFilter
        {
             return (new SqlSelectorFilterAdapter())->adaptFilter($filter);
        }
        ...
    }
    class SqlSelectorFilterAdapter
    {
        public function adaptFilter(Filter $filter):SqlQueryFilter
        {
            $concreteClass = (new StringRebaser(
                'Filter\', 'SqlQueryFilter\'))
                ->rebase(get_class($filter));

            return new $concreteClass($filter);
        }
    }

    其思想是,通用Selector使用Filter,而实现SqlSelector使用SqlFilterSqlSelectorFilterAdapter使用通用Filter来适应具体的SqlFilter

    客户机代码创建Filter对象(即通用过滤器),但在选择器的具体实现中,这些过滤器在SQL过滤器中转换。

    其他选择器实现,如InMemorySelector,使用其特定的InMemorySelectorFilterAdapter,从Filter转换为InMemoryFilter;因此,每个选择器实现都有自己的过滤器适配器。

    使用这个策略,我的客户机代码(在业务层)不关心特定的存储库或选择器实现。

    1
    2
    3
    4
    5
    /** @var Repository $repository*/
    $selector = $repository->factoryEntitySelector();
    $selector->filter(new AttributeEquals('activated', 1))->limit(2)->orderBy('username');
    $activatedUserCount = $selector->count(); // evaluates to 100, ignores the limit()
    $activatedUsers = $selector->fetchEntities();

    P.S.这是对我真正代码的简化


    我会在这上面加上一点,因为我目前正试图自己掌握所有这些。

    第1和2

    这是一个完美的地方,让你的ORM做起重。如果您使用的是实现某种ORM的模型,那么您可以使用它的方法来处理这些事情。如果需要的话,可以使用自己的orderby函数来实现雄辩的方法。使用雄辩,例如:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    class DbUserRepository implements UserRepositoryInterface
    {
        public function findAll()
        {
            return User::all();
        }

        public function get(Array $columns)
        {
           return User::select($columns);
        }

    你要找的似乎是一个ORM。没有理由您的存储库不能基于一个。这将需要用户进行雄辩的扩展,但我个人并不认为这是一个问题。

    然而,如果你确实想避免ORM,那么你就必须"滚动自己的"才能得到你想要的。

    α3

    接口不应该是硬性和快速的需求。有些东西可以实现一个接口并添加到其中。它不能做的是未能实现该接口所需的功能。您还可以扩展类之类的接口来保持干燥。

    也就是说,我刚刚开始领会,但这些认识帮助了我。


    这些是我见过的不同的解决方案。他们每个人都有利弊,但这是由你来决定的。

    问题1:字段太多

    这是一个重要的方面,尤其是当您考虑到只包含索引的扫描时。我看到了两种解决这个问题的方法。您可以更新函数以接受可选数组参数,该参数将包含要返回的列的列表。如果此参数为空,则返回查询中的所有列。这可能有点奇怪;根据参数,您可以检索对象或数组。您还可以复制所有函数,这样您就有两个不同的函数运行同一个查询,但一个函数返回一个列数组,另一个函数返回一个对象。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    public function findColumnsById($id, array $columns = array()){
        if (empty($columns)) {
            // use *
        }
    }

    public function findById($id) {
        $data = $this->findColumnsById($id);
    }

    问题2:方法太多

    一年前,我曾短暂地与推进ORM合作过,这是基于我能从中记忆到的经验。推进可以根据现有的数据库模式生成其类结构。它为每个表创建两个对象。第一个对象是一个很长的访问函数列表,类似于您当前列出的;findByAttribute($attribute_value)。下一个对象继承自第一个对象。您可以更新这个子对象来构建更复杂的getter函数。

    另一种解决方案是使用__call()将未定义的函数映射到可操作的对象。您的__call方法将能够将findbyid和findbyname解析为不同的查询。

    1
    2
    3
    4
    5
    6
    public function __call($function, $arguments) {
        if (strpos($function, 'findBy') === 0) {
            $parameter = substr($function, 6, strlen($function));
            // SELECT * FROM $this->table_name WHERE $parameter = $arguments[0]
        }
    }

    我希望这至少能帮助一些什么。


    我只能对我们(在我公司)处理这件事的方式发表评论。首先,对我们来说,性能不是太大的问题,但是拥有干净/正确的代码是。

    首先,我们定义模型,例如使用ORM创建UserEntity对象的UserModel。当从模型加载UserEntity时,将加载所有字段。对于引用外部实体的字段,我们使用适当的外部模型来创建相应的实体。对于这些实体,数据将按需加载。现在你最初的反应可能是…????……!!!!让我举个例子,举个例子:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    class UserEntity extends PersistentEntity
    {
        public function getOrders()
        {
            $this->getField('orders'); //OrderModel creates OrderEntities with only the ID's set
        }
    }

    class UserModel {
        protected $orm;

        public function findUsers(IGetOptions $options = null)
        {
            return $orm->getAllEntities(/*...*/); // Orm creates a list of UserEntities
        }
    }

    class OrderEntity extends PersistentEntity {} // user your imagination
    class OrderModel
    {
        public function findOrdersById(array $ids, IGetOptions $options = null)
        {
            //...
        }
    }

    在我们的例子中,$db是一个能够加载实体的ORM。模型指示ORM加载一组特定类型的实体。ORM包含一个映射,并使用该映射将该实体的所有字段注入到该实体中。但是,对于外部字段,只加载这些对象的ID。在这种情况下,OrderModel创建的OrderEntity只具有引用订单的ID。当PersistentEntity::getFieldOrderEntity调用时,实体指示它的模型将所有字段延迟加载到OrderEntity中。与一个用户实体关联的所有OrderEntity都被视为一个结果集,并将立即加载。

    这里的神奇之处在于,我们的模型和ORM将所有数据注入到实体中,而实体仅为PersistentEntity提供的通用getField方法提供包装函数。总而言之,我们总是加载所有字段,但在必要时会加载引用外部实体的字段。只加载一堆字段并不是真正的性能问题。但是,加载所有可能的外国实体将导致性能大幅下降。

    现在开始加载基于WHERE子句的特定用户集。我们提供了一个面向对象的类包,允许您指定可以粘合在一起的简单表达式。在示例代码中,我将其命名为GetOptions。它是用于选择查询的所有可能选项的包装器。它包含一个WHERE子句、GROUPBY子句以及其他所有内容的集合。我们的where子句非常复杂,但显然您可以轻松地生成一个简单的版本。

    1
    2
    3
    4
    5
    $objOptions->getConditionHolder()->addConditionBind(
        new ConditionBind(
            new Condition('orderProduct.product', ICondition::OPERATOR_IS, $argObjProduct)
        )
    );

    这个系统最简单的版本是将查询的where部分作为字符串直接传递给模型。

    我对这个相当复杂的回答感到抱歉。我试图尽可能快速、清晰地总结我们的框架。如果您还有其他问题,请随时问他们,我会更新我的答案。

    编辑:另外,如果您确实不想立即加载某些字段,可以在ORM映射中指定一个延迟加载选项。因为所有字段最终都是通过getField方法加载的,所以您可以在最后一分钟调用该方法时加载一些字段。这在PHP中不是一个很大的问题,但我不推荐其他系统使用。


    我同意@Ryan1234,您应该在代码中传递完整的对象,并且应该使用通用查询方法来获取这些对象。

    1
    Model::where(['attr1' => 'val1'])->get();

    对于外部/端点使用,我非常喜欢graphql方法。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    POST /api/graphql
    {
        query: {
            Model(attr1: 'val1') {
                attr2
                attr3
            }
        }
    }

    我建议https://packagist.org/packages/prettus/l5-repository作为供应商来实现存储库/标准等…在拉腊维尔5: D