5

何年もの間、私は同じコードを(進化を伴って)何度も何度も再実装してきましたが、それを抽象化するためのクリーンで効率的な方法を見つけることはありませんでした。

このパターンは、サービスレイヤーの基本的な「find [Type] s」メソッドであり、選択クエリの作成をサービス内の1つのポイントに抽象化しますが、より使いやすいプロキシメソッドをすばやく作成する機能をサポートします(PostServivce :: getPostByIdの例を参照)。 ()以下の方法)。

残念ながら、これまでのところ、私はこれらの目標を達成することができませんでした:

  1. 個別の再実装によって発生するエラーの可能性を減らします
  2. オートコンプリートのために、有効/無効なパラメーターオプションをIDEに公開します
  3. DRYの原則に従う

私の最新の実装は通常、次の例のようになります。このメソッドは、条件の配列とオプションの配列を取り、これらからDoctrine_Queryを作成して実行します(今日、これをほとんど書き直したので、タイプミスや構文エラーが発生する可能性があります。直接のカットアンドペーストではありません)。

class PostService
{
    /* ... */

    /**
     * Return a set of Posts
     *
     * @param Array $conditions Optional. An array of conditions in the format
     *                          array('condition1' => 'value', ...)
     * @param Array $options    Optional. An array of options 
     * @return Array An array of post objects or false if no matches for conditions
     */
    public function getPosts($conditions = array(), $options = array()) {
        $defaultOptions =  = array(
            'orderBy' => array('date_created' => 'DESC'),
            'paginate' => true,
            'hydrate' => 'array',
            'includeAuthor' => false,
            'includeCategories' => false,
        );

        $q = Doctrine_Query::create()
                        ->select('p.*')
                        ->from('Posts p');

        foreach($conditions as $condition => $value) {
            $not = false;
            $in = is_array($value);
            $null = is_null($value);                

            $operator = '=';
            // This part is particularly nasty :(
            // allow for conditions operator specification like
            //   'slug LIKE' => 'foo%',
            //   'comment_count >=' => 1,
            //   'approved NOT' => null,
            //   'id NOT IN' => array(...),
            if(false !== ($spacePos = strpos($conditions, ' '))) {
                $operator = substr($condition, $spacePost+1);
                $conditionStr = substr($condition, 0, $spacePos);

                /* ... snip validate matched condition, throw exception ... */
                if(substr($operatorStr, 0, 4) == 'NOT ') {
                  $not = true;
                  $operatorStr = substr($operatorStr, 4);
                }
                if($operatorStr == 'IN') {
                    $in = true;
                } elseif($operatorStr == 'NOT') {
                    $not = true;
                } else {
                    /* ... snip validate matched condition, throw exception ... */
                    $operator = $operatorStr;
                }

            }

            switch($condition) {
                // Joined table conditions
                case 'Author.role':
                case 'Author.id':
                    // hard set the inclusion of the author table
                    $options['includeAuthor'] = true;

                    // break; intentionally omitted
                /* ... snip other similar cases with omitted breaks ... */
                    // allow the condition to fall through to logic below

                // Model specific condition fields
                case 'id': 
                case 'title':
                case 'body':
                /* ... snip various valid conditions ... */
                    if($in) {
                        if($not) {
                            $q->andWhereNotIn("p.{$condition}", $value);
                        } else {
                            $q->andWhereIn("p.{$condition}", $value);
                        }
                    } elseif ($null) {
                        $q->andWhere("p.{$condition} IS " 
                                     . ($not ? 'NOT ' : '') 
                                     . " NULL");
                    } else {
                        $q->andWhere(
                            "p.{condition} {$operator} ?" 
                                . ($operator == 'BETWEEN' ? ' AND ?' : ''),
                            $value
                        );
                    }
                    break;
                default:
                    throw new Exception("Unknown condition '$condition'");
            }
        }

        // Process options

        // init some later processing flags
        $includeAuthor = $includeCategories = $paginate = false;
        foreach(array_merge_recursivce($detaultOptions, $options) as $option => $value) {
            switch($option) {
                case 'includeAuthor':
                case 'includeCategories':
                case 'paginate':
                /* ... snip ... */
                    $$option = (bool)$value;
                    break;
                case 'limit':
                case 'offset':
                case 'orderBy':
                    $q->$option($value);
                    break;
                case 'hydrate':
                    /* ... set a doctrine hydration mode into $hydration */ 
                    break;
                default:
                    throw new Exception("Invalid option '$option'");
            }
        }

        // Manage some flags...
        if($includeAuthor) {
            $q->leftJoin('p.Authors a')
              ->addSelect('a.*');
        } 

        if($paginate) {
            /* ... wrap query in some custom Doctrine Zend_Paginator class ... */
            return $paginator;
        }

        return $q->execute(array(), $hydration);
    }

    /* ... snip ... */
}

ピューフ

この基本機能の利点は次のとおりです。

  1. スキーマの進化に合わせて、新しい条件とオプションをすばやくサポートできます
  2. これにより、クエリにグローバル条件をすばやく実装できます(たとえば、デフォルトのtrueで'excludeDisabled'オプションを追加し、呼び出し元が明示的に別の言い方をしない限り、すべてのdisabled = 0モデルをフィルタリングします)。
  3. これにより、プロキシがfindPostsメソッドにコールバックする新しい、より使いやすいメソッドをすばやく作成できます。例えば:
class PostService
{
    /* ... snip ... */

    // A proxy to getPosts that limits results to 1 and returns just that element
    public function getPost($conditions = array(), $options()) {
        $conditions['id'] = $id;
        $options['limit'] = 1;
        $options['paginate'] = false;
        $results = $this->getPosts($conditions, $options);
        if(!empty($results) AND is_array($results)) {
            return array_shift($results);
        }
        return false;
    }

    /* ... docblock ...*/       
    public function getPostById(int $id, $conditions = array(), $options()) {
        $conditions['id'] = $id;
        return $this->getPost($conditions, $options);
    }

    /* ... docblock ...*/
    public function getPostsByAuthorId(int $id, $conditions = array(), $options()) {
        $conditions['Author.id'] = $id;
        return $this->getPosts($conditions, $options);
    }

    /* ... snip ... */
}

このアプローチの主な欠点は次のとおりです。

  • 同じモノリシックな'find[Model] s'メソッドがすべてのモデルアクセスサービスで作成され、ほとんどの場合、条件スイッチ構造とベーステーブル名のみが変更されます。
  • AND/OR条件操作を実行する簡単な方法はありません。すべての条件は明示的にANDされます。
  • タイプミスの多くの機会をもたらします
  • 規則ベースのAPIの中断に多くの機会をもたらします(たとえば、後のサービスでは、orderByオプションを指定するために別の構文規則を実装する必要があり、以前のすべてのサービスにバックポートするのが面倒になります)。
  • DRYの原則に違反します。
  • 有効な条件とオプションはIDEオートコンプリートパーサーに隠されており、オプションと条件のパラメーターでは、許可されたオプションを追跡するために長いドキュメントブロックの説明が必要です。

過去数日間、私はこの問題に対してよりOOのソリューションを開発しようとしましたが、あまりにも複雑で、使用するには制限が厳しすぎるソリューションを開発しているように感じました。

私が取り組んでいたアイデアは、次のようなものでした(現在のプロジェクトはDoctrine2 fyiになるので、少し変更します)...

namespace Foo\Service;

use Foo\Service\PostService\FindConditions; // extends a common \Foo\FindConditions abstract
use Foo\FindConditions\Mapper\Dql as DqlConditionsMapper;

use Foo\Service\PostService\FindOptions; // extends a common \Foo\FindOptions abstract
use Foo\FindOptions\Mapper\Dql as DqlOptionsMapper;

use \Doctrine\ORM\QueryBuilder;

class PostService
{
    /* ... snip ... */
    public function findUsers(FindConditions $conditions = null, FindOptions $options = null) {

        /* ... snip instantiate $q as a Doctrine\ORM\QueryBuilder ... */

        // Verbose
        $mapper = new DqlConditionsMapper();
        $q = $mapper
                ->setQuery($q)
                ->setConditions($conditions)
                ->map();

        // Concise
        $optionsMapper = new DqlOptionsMapper($q);        
        $q = $optionsMapper->map($options);


        if($conditionsMapper->hasUnmappedConditions()) {
            /* .. very specific condition handling ... */
        }
        if($optionsMapper->hasUnmappedConditions()) {
            /* .. very specific condition handling ... */
        }

        if($conditions->paginate) {
            return new Some_Doctrine2_Zend_Paginator_Adapter($q);
        } else {
            return $q->execute();
        }
    }

    /* ... snip ... */
}

そして最後に、Foo \ Service \ PostService \FindConditionsクラスのサンプル:

namespace Foo\Service\PostService;

use Foo\Options\FindConditions as FindConditionsAbstract;

class FindConditions extends FindConditionsAbstract {

    protected $_allowedOptions = array(
        'user_id',
        'status',
        'Credentials.credential',
    );

    /* ... snip explicit get/sets for allowed options to provide ide autocompletion help */
}

Foo \ Options\FindConditionsとFoo\Options \ FindOptionsは非常によく似ているため、少なくとも今のところ、どちらも共通のFoo\Options親クラスを拡張しています。この親クラスは、許可された変数とデフォルト値の初期化、設定されたオプションへのアクセス、定義されたオプションのみへのアクセスの制限、およびDqlOptionsMapperがオプションをループするためのイテレーターインターフェイスの提供を処理します。

残念ながら、これを数日間ハッキングした後、私はこのシステムの複雑さに不満を感じています。現状では、条件グループとOR条件はまだサポートされておらず、代替条件比較演算子を指定する機能は、FindConditionsを指定するときに値をラップアラウンドするFoo \ Options \ FindConditions\Comparisonクラスを作成することの完全な泥沼でした。値($conditions->setCondition('Foo', new Comparison('NOT LIKE', 'bar'));)。

誰か他の人の解決策が存在する場合はそれを使用したいのですが、私が探していることを実行するものはまだありません。

このプロセスを超えて、実際に取り組んでいるプロジェクトの構築に戻りたいのですが、終わりが見えません。

だから、スタックオーバーフラワー:-欠点を含めずに私が特定した利点を提供するより良い方法はありますか?

4

1 に答える 1

4

あなたは物事を複雑にしすぎていると思います。

私はDoctrine2を使用してプロジェクトに取り組んできました。このプロジェクトには、非常に多くのエンティティ、さまざまな用途、さまざまなサービス、カスタムリポジトリなどがあり、このようなものがかなりうまく機能することがわかりました(とにかく私にとって)。

1.クエリのリポジトリ

まず、私は通常、サービスでクエリを実行しません。Doctrine 2は、EntityRepositoryと、この正確な目的のためにエンティティごとにそれをサブクラス化するオプションを提供します。

  • 可能な限り、標準のfindOneBy...およびfindBy...スタイルのマジックメソッドを使用します。これにより、自分でDQLを作成する必要がなくなり、箱から出してすぐにうまく機能します。
  • より複雑なクエリロジックが必要な場合は、通常、リポジトリにユースケース固有のファインダーを作成します。これらはUserRepository.findByNameStartsWithそのようなものです。
  • 私は通常、「あなたがくれたどんな引数でも取ることができます!」という超派手なものは作成しません。魔法の発見者のタイプ。特定のクエリが必要な場合は、特定のメソッドを追加します。これにはもっと多くのコードを書く必要があるように思えるかもしれませんが、私はそれが物事を行う方法をはるかに単純で理解しやすいと思います。(私はあなたのファインダーコードを調べてみましたが、場所を探すのはかなり複雑でした)

つまり...

  • 教義がすでにあなたに与えているものを使ってみてください(魔法の発見者の方法)
  • カスタムクエリロジックが必要な場合は、カスタムリポジトリクラスを使用します
  • クエリタイプごとにメソッドを作成する

2.非エンティティロジックを組み合わせるためのサービス

サービスを使用して、コントローラーから使用したり、単体テストで簡単にテストしたりできるシンプルなインターフェイスの背後にある「トランザクション」を組み合わせます。

たとえば、ユーザーが友達を追加できるとします。ユーザーが他の人と友達になるたびに、通知するために他の人に電子メールが送信されます。これはあなたがあなたのサービスに持っているであろうものです。

たとえば、サービスaddNewFriendには2人のユーザーを使用するメソッドが含まれます。次に、リポジトリを使用してデータを照会し、ユーザーのフレンド配列を更新し、他のクラスを呼び出して電子メールを送信することができます。

サービスでentitymanagerを使用して、リポジトリクラスを取得したりエンティティを永続化したりできます。

3.エンティティ固有のロジックのエンティティ

最後に、エンティティに固有のビジネスロジックをエンティティクラスに直接配置するようにしてください。

この場合の簡単な例として、上記のシナリオで送信する電子メールに、ある種の挨拶が使用されている可能性があります。「HelloMr.Anderson」または「HelloMs.Anderson」。

したがって、たとえば、適切な挨拶を決定するためのロジックが必要になります。これは、エンティティクラスに含めることができるものです。たとえば、getGreetingユーザーの性別と国籍を考慮に入れて、それに基づいて何かを返すことができます。(性別と国籍はデータベースに保存されますが、挨拶自体は保存されないと仮定します。挨拶は関数のロジックによって計算されます)

また、エンティティは通常、entitymanagerまたはリポジトリのどちらも知らないはずであることも指摘しておく必要があります。ロジックがこれらのいずれかを必要とする場合、それはおそらくエンティティクラス自体に属していません。

このアプローチの利点

ここで詳しく説明したアプローチはかなりうまく機能することがわかりました。一般に、物事が何をするかについて非常に「明白」であり、複雑なクエリ動作に依存せず、物事が異なる「領域」(リポジトリ、サービス、エンティティ)に明確に分割されるため、単体テストを行うのは非常に簡単であるため、保守可能です。良い。

于 2011-07-08T20:06:23.187 に答える