333

序文: リレーショナル データベースを使用する MVC アーキテクチャでリポジトリ パターンを使用しようとしています。

最近、PHP で TDD の学習を始めましたが、データベースがアプリケーションの残りの部分と密接に結合されすぎていることに気付きました。リポジトリについて読み、IoC コンテナを使用してコントローラに「注入」しました。とてもクールなもの。しかし、ここでは、リポジトリの設計に関するいくつかの実際的な質問があります。次の例を考えてみましょう。

<?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: メソッドが多すぎる

このクラスは今のところ見栄えがしますが、実際のアプリではもっと多くのメソッドが必要であることを私は知っています。例えば:

  • findAllByNameAndStatus
  • 検索AllInCountry
  • findAllWithEmailAddressSet
  • findAllByAgeAndGender
  • findAllByAgeAndGenderOrderByAge
  • 等。

ご覧のとおり、可能なメソッドの非常に長いリストが存在する可能性があります。そして、上記のフィールド選択の問題を追加すると、問題は悪化します。以前は、通常、このすべてのロジックをコントローラーに正しく配置していました。

<?php

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

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

私のリポジトリアプローチでは、これで終わりたくありません:

<?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()、、、、など)のみを含める必要があると思われます。しかし、特定のルックアップを実行するにはどうすればよいでしょうか? Specification Patternについて聞いたことがありますが、これは ( を介して) レコードのセット全体を削減するだけのように思えます。これは、データベースからプルしている場合、明らかにパフォーマンスに大きな問題があります。remove()find()findAll()IsSatisfiedBy()

ヘルプ?

明らかに、リポジトリを扱うときは少し考え直す必要があります。これをどのように処理するのが最善かを誰かが教えてくれますか?

4

11 に答える 11

53

私の経験に基づいて、ここにあなたの質問に対するいくつかの答えがあります:

Q:不要なフィールドを戻すにはどうすればよいですか?

A:私の経験からすると、これは要するに、完全なエンティティとアドホック クエリを処理するということです。

完全なエンティティはUserオブジェクトのようなものです。プロパティやメソッドなどがあります。コードベースの第一級市民です。

アドホック クエリはいくつかのデータを返しますが、それ以上のことはわかりません。アプリケーション内でデータが渡されるときは、コンテキストなしで行われます。それはUserですか?いくつUserかのOrder情報が添付されていますか?私たちは本当に知りません。

私は完全なエンティティで作業することを好みます。

多くの場合、使用しないデータを戻すことは正しいですが、これにはさまざまな方法で対処できます。

  1. エンティティを積極的にキャッシュして、データベースから一度だけ読み取り料金を支払うようにします。
  2. エンティティのモデリングにより多くの時間を費やして、エンティティを適切に区別できるようにします。(大きなエンティティを 2 つの小さなエンティティに分割することなどを検討してください)
  3. エンティティの複数のバージョンを持つことを検討してください。Userバックエンド用の と、おそらくUserSmallAJAX 呼び出し用の を使用できます。10 個のプロパティを持つものもあれば、3 つのプロパティを持つものもあります。

アドホック クエリを使用することの欠点:

  1. 多くのクエリで基本的に同じデータが得られます。たとえば、 を使用すると、多くの呼び出しに対してUser本質的に同じことを書くことになります。select *1 回の呼び出しで 10 個中 8 個のフィールドを取得し、1 回で 10 個中 5 個を取得し、1 回で 10 個中 7 個を取得します。これが悪い理由は、リファクタリング/テスト/モックするのは殺人だからです。
  2. 時間の経過とともに、コードについて高いレベルで推論することは非常に難しくなります。User「なぜそんなに遅いのですか?」のようなステートメントの代わりに。1 回限りのクエリを追跡することになるため、バグ修正は小規模でローカライズされる傾向があります。
  3. 基盤となるテクノロジーを置き換えるのは本当に難しいです。現在すべてを MySQL に保存していて、MongoDB に移行したい場合、100 個のアドホック コールを置き換えるのは、少数のエンティティを置き換えるよりもはるかに困難です。

Q:リポジトリにメソッドが多すぎます。

A:通話を統合する以外に、これを回避する方法はまったく見たことがありません。リポジトリ内のメソッド呼び出しは、アプリケーション内の機能に実際にマップされます。機能が多いほど、データ固有の呼び出しが多くなります。機能をプッシュバックして、同様の呼び出しを 1 つにマージすることができます。

結局のところ、複雑さはどこかに存在しなければなりません。リポジトリ パターンを使用して、一連のストアド プロシージャを作成する代わりに、リポジトリ インターフェイスにプッシュしました。

ときどき自分に言い聞かせなければならないことがあります。

于 2013-04-23T22:33:47.237 に答える
28

次のインターフェイスを使用します。

  • Repository- エンティティの読み込み、挿入、更新、および削除
  • Selector- リポジトリ内のフィルターに基づいてエンティティを検索します
  • Filter- フィルタリング ロジックをカプセル化します

Repositoryはデータベースに依存しません。実際、永続性は指定されていません。SQL データベース、xml ファイル、リモート サービス、宇宙から来た宇宙人など、何でもRepositoryかまいSelectorませんLIMITEntities最後に、セレクターは永続性から1 つ以上をフェッチします。

サンプルコードは次のとおりです。

<?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 つの実装:

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が使用するということSqlFilterです。ジェネリックを具体的なものにSqlSelectorFilterAdapter適応させます。FilterSqlFilter

クライアント コードはFilterオブジェクト (汎用フィルター) を作成しますが、セレクターの具体的な実装では、これらのフィルターは SQL フィルターに変換されます。

などの他のセレクターの実装は、特定の;を使用してInMemorySelectorから に変換されます。そのため、すべてのセレクターの実装には独自のフィルター アダプターが付属しています。FilterInMemoryFilterInMemorySelectorFilterAdapter

この戦略を使用すると、私のクライアント コード (bussines レイヤー内) は、特定のリポジトリまたはセレクターの実装を気にしません。

/** @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();

PSこれは私の実際のコードを単純化したものです

于 2016-08-18T09:45:31.077 に答える
3

これらは私が見たいくつかの異なる解決策です。それぞれ一長一短はありますが、決めるのはあなたです。

問題 #1: フィールドが多すぎる

これは、特にIndex-Only Scansを考慮する場合に重要な側面です。この問題に対処するには、2 つの解決策があります。関数を更新して、返される列のリストを含むオプションの配列パラメーターを受け取ることができます。このパラメーターが空の場合、クエリ内のすべての列が返されます。これは少し奇妙かもしれません。パラメータに基づいて、オブジェクトまたは配列を取得できます。すべての関数を複製して、同じクエリを実行する 2 つの異なる関数を作成することもできますが、一方は列の配列を返し、もう一方はオブジェクトを返します。

public function findColumnsById($id, array $columns = array()){
    if (empty($columns)) {
        // use *
    }
}

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

問題 #2: メソッドが多すぎる

私は 1 年前にPropel ORMで簡単に作業しましたが、これはその経験から覚えていることに基づいています。Propel には、既存のデータベース スキーマに基づいてクラス構造を生成するオプションがあります。テーブルごとに 2 つのオブジェクトを作成します。最初のオブジェクトは、現在リストされているものと同様のアクセス機能の長いリストです。findByAttribute($attribute_value). 次のオブジェクトは、この最初のオブジェクトから継承します。この子オブジェクトを更新して、より複雑な getter 関数を組み込むことができます。

別の解決策は、__call()未定義の関数を実行可能なものにマップするために使用することです。メソッドは、 findById__callと findByName を別のクエリに解析できます。

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]
    }
}

これが少なくともいくつかの助けになることを願っています。

于 2013-04-25T14:58:58.557 に答える
1

このような場合、データ リポジトリの複雑さを増やさずに大規模なクエリ言語を提供するには、graphQLが適していると思います。

ただし、今のところgraphQLを使用したくない場合は、別の解決策があります。プロセス間、この場合はサービス/コントローラーとリポジトリ間でデータを運ぶためにオブジェクトが使用されるDTOを使用します。

エレガントな答えは既に上で提供されていますが、より単純で、新しいプロジェクトの出発点として役立つと思われる別の例を挙げてみます。

コードに示されているように、CRUD 操作に必要なメソッドは 4 つだけです。このfindメソッドは、オブジェクト引数を渡すことにより、一覧表示と読み取りに使用されます。バックエンド サービスは、URL クエリ文字列または特定のパラメーターに基づいて、定義済みのクエリ オブジェクトを構築できます。

クエリ オブジェクト ( SomeQueryDto) は、必要に応じて特定のインターフェイスを実装することもできます。複雑さを加えることなく、後で簡単に拡張できます。

<?php

interface SomeRepositoryInterface
{
    public function create(SomeEnitityInterface $entityData): SomeEnitityInterface;
    public function update(SomeEnitityInterface $entityData): SomeEnitityInterface;
    public function delete(int $id): void;

    public function find(SomeEnitityQueryInterface $query): array;
}

class SomeRepository implements SomeRepositoryInterface
{
    public function find(SomeQueryDto $query): array
    {
        $qb = $this->getQueryBuilder();

        foreach ($query->getSearchParameters() as $attribute) {
            $qb->where($attribute['field'], $attribute['operator'], $attribute['value']);
        }

        return $qb->get();
    }
}

/**
 * Provide query data to search for tickets.
 *
 * @method SomeQueryDto userId(int $id, string $operator = null)
 * @method SomeQueryDto categoryId(int $id, string $operator = null)
 * @method SomeQueryDto completedAt(string $date, string $operator = null)
 */
class SomeQueryDto
{
    /** @var array  */
    const QUERYABLE_FIELDS = [
        'id',
        'subject',
        'user_id',
        'category_id',
        'created_at',
    ];

    /** @var array  */
    const STRING_DB_OPERATORS = [
        'eq' => '=', // Equal to
        'gt' => '>', // Greater than
        'lt' => '<', // Less than
        'gte' => '>=', // Greater than or equal to
        'lte' => '<=', // Less than or equal to
        'ne' => '<>', // Not equal to
        'like' => 'like', // Search similar text
        'in' => 'in', // one of range of values
    ];

    /**
     * @var array
     */
    private $searchParameters = [];

    const DEFAULT_OPERATOR = 'eq';

    /**
     * Build this query object out of query string.
     * ex: id=gt:10&id=lte:20&category_id=in:1,2,3
     */
    public static function buildFromString(string $queryString): SomeQueryDto
    {
        $query = new self();
        parse_str($queryString, $queryFields);

        foreach ($queryFields as $field => $operatorAndValue) {
            [$operator, $value] = explode(':', $operatorAndValue);
            $query->addParameter($field, $operator, $value);
        }

        return $query;
    }

    public function addParameter(string $field, string $operator, $value): SomeQueryDto
    {
        if (!in_array($field, self::QUERYABLE_FIELDS)) {
            throw new \Exception("$field is invalid query field.");
        }
        if (!array_key_exists($operator, self::STRING_DB_OPERATORS)) {
            throw new \Exception("$operator is invalid query operator.");
        }
        if (!is_scalar($value)) {
            throw new \Exception("$value is invalid query value.");
        }

        array_push(
            $this->searchParameters,
            [
                'field' => $field,
                'operator' => self::STRING_DB_OPERATORS[$operator],
                'value' => $value
            ]
        );

        return $this;
    }

    public function __call($name, $arguments)
    {
        // camelCase to snake_case
        $field = strtolower(preg_replace('/(?<!^)[A-Z]/', '_$0', $name));

        if (in_array($field, self::QUERYABLE_FIELDS)) {
            return $this->addParameter($field, $arguments[1] ?? self::DEFAULT_OPERATOR, $arguments[0]);
        }
    }

    public function getSearchParameters()
    {
        return $this->searchParameters;
    }
}

使用例:

$query = new SomeEnitityQuery();
$query->userId(1)->categoryId(2, 'ne')->createdAt('2020-03-03', 'lte');
$entities = $someRepository->find($query);

// Or by passing the HTTP query string
$query = SomeEnitityQuery::buildFromString('created_at=gte:2020-01-01&category_id=in:1,2,3');
$entities = $someRepository->find($query);
于 2020-03-03T17:06:57.133 に答える