干货:构建复杂的 Eloquent 搜索过滤

文章转发自专业的Laravel开发者社区,原始连接: https://learnku.com/laravel/t...

最近,我须要在开发的事件管理系统中实现搜索功能。 一开始只是简单的几个选项 (经过名称,邮箱等搜索),到后面参数变得愈来愈多。php

今天,我会介绍整个过程以及如何构建灵活且可扩展的搜索系统。若是你想查看代码,请访问 Git 仓库 。html

咱们将创造什么

咱们公司须要一种跟踪咱们与世界各地客户举办的各类活动和会议的方式。咱们目前的惟一方法是让每位员工在 Outlook 日程表上存储会议的详细信息。可拓展性较差!前端

咱们须要公司的每一个人均可以访问,来查看咱们客户的被邀请的详细信息以及他们的RSVP(国际缩用语:请回复)状态。laravel

这样,咱们能够经过上次与他们互动的数据来肯定哪些用户能够邀请来参加将来的活动。git

使用高级搜索过滤器查找的截图github

查找用户

经常使用过滤用户的方法:编程

  • 经过姓名,电子邮件,位置
  • 经过用户工做的公司
  • 被邀请参加特定活动的用户
  • 参加过特定活动的用户
  • 邀请及已参加活动的用户
  • 邀请但还没有回复的用户
  • 答应参加但未出席的用户
  • 分配给销售经理的用户

虽然这个列表不算完整,但可让咱们知道须要多少个过滤器。这将是个挑战!设计模式

前端的条件过滤的截图。api

模型及模型关联

在这个例子中咱们回用到不少模型:app

  • User ---  表明被邀请参加活动的用户。一个用户能够参加不少活动。
  • Event --- 表明我公司举办的活动。活动能够有多个。
  • Rsvp ---  表明用户对活动邀请的回复。一个用户对一个活动的回复是一对一的。
  • Manager ---  一个用户能够对应多个我公司的销售经理.

搜索的需求

在开始代码以前,我想先把搜索的需求明确一下。也就是说我要很清楚地知道我要对哪些数据作搜索功能。

下面就是一个例子:

{
    "name": "Billy",
    "company": "Google",
    "city": "London",
    "event": "key-note-presentation-new-york-05-2016",
    "responded": true,
    "response": "I will be attending",
    "managers": [
        "Tom Jones",
        "Joe Bloggs"
    ],
}

总结一下上面数据想表达的搜索的条件:

客人的名字是 'Billy',来自 'Google' 公司,目前居住在 'London',已经对 'key-note-presentation-new-york-05--2016' 的活动邀请作出了回复,而且回复的内容是 'I will be attending',负责跟进这位客人的销售经理是 'Tom Jones' 或者 'Joe Bloggs'。

开始 --- 最佳实践

我是一个坚决不移的极简主义者,我坚信少便是多。下面就让咱们以最简单的方式探索出解决这个需求的最佳实践。

首先,在  routes.php 文件中添加以下代码:

Route::post('/search', 'SearchController@filter');

接下来,建立  SearchController.

php artisan make:controller SearchController

添加前面路由中明确的 filter()  方法:

<?php

namespace App\Http\Controllers;
use App\User;
use App\Http\Requests;
use Illuminate\Http\Request;
use App\Http\Controllers\Controller;

class SearchController extends Controller
{
    public function filter(Request $request, User $user)
    {
        // 
    }
}

因为咱们须要在 filter 方法中处理请求提交的数据,因此我把 Request 类作了依赖注入。Laravel 的服务容器 会解析这个依赖,咱们能够在方法中直接使用 Request 的实例,也就是 $request。User 类也是一样道理,咱们须要从中检索一些数据。

这个搜索需求有一点比较麻烦的是,每一个参数都是可选的。因此咱们要先写一系列的条件语句来判断每一个参数是否存在:

这是我初步写出来的代码:

public function filter(Request $request, User $user)
{
    // 根据姓名查找用户
    if ($request->has('name')) {
        return $user->where('name', $request->input('name'))->get();
    }

    // 根据公司名查找用户
    if ($request->has('company')) {
        return $user->where('company', $request->input('company'))
            ->get();
    }

    // 根据城市查找用户
    if ($request->has('city')) {
        return $user->where('city', $request->input('city'))->get();
    }

    // 继续根据其余条件查找

    // 再无其余条件,
    // 返回全部符合条件的用户。
    // 在实际项目中须要作分页处理。
    return User::all();
}

很明显,上面的代码逻辑是错误的。

首先,它只会根据一个条件去检索用户表,而后就返回了。因此,经过上面的代码逻辑,咱们根本没法得到姓名为 'Billy', 并且住在 'London' 的用户。

实现这种目的的一种方式是嵌套条件:

// 根据用户名搜索用户
if ($request->has('name')) {
    // 是否还提供了 'city' 搜索参数
    if ($request->has('city')) {
        // 基于用户名及城市搜索用户
        return $user->where('name', $request->input('name'))
            ->where('city', $request->input('city'))
            ->get();
    }
    return $user->where('name', $request->input('name'))->get();
}

我确信你能够看到这在两个或者三个参数的时候起做用,可是一旦咱们添加更多选项,这将会难以管理。

改进咱们的搜索 api

因此咱们如何让这个生效,而同时不会由于嵌套条件而变得疯狂?

咱们可使用 User 模型继续重构,来使用 builder 而不是直接返回模型。

public function filter(Request $request, User $user)
{
    $user = $user->newQuery();

    // 根据用户名搜索用户
    if ($request->has('name')) {
        $user->where('name', $request->input('name'));
    }

    // 根据用户公司信息搜索用户
    if ($request->has('company')) {
        $user->where('company', $request->input('company'));
    }

    // 根据用户城市信息搜索用户
    if ($request->has('city')) {
        $user->where('city', $request->input('city'));
    }

    // 继续执行其余过滤

    // 得到并返回结果
    return $user->get();
}

好多了!咱们如今能够将每一个搜索参数作为修饰符添加到从  $user->newQuery() 返回的查询实例中。

咱们如今能够根据全部的参数来作搜索了, 再多参数都不怕.

一块儿来实践吧:

$user = $user->newQuery();

// 根据姓名查找用户
if ($request->has('name')) {
    $user->where('name', $request->input('name'));
}

// 根据公司名查找用户
if ($request->has('company')) {
    $user->where('company', $request->input('company'));
}

// 根据城市查找用户
if ($request->has('city')) {
    $user->where('city', $request->input('city'));
}

// 只查找有对接我公司销售经理的用户
if ($request->has('managers')) {
    $user->whereHas('managers', function ($query) use ($request) {
        $query->whereIn('managers.name', $request->input('managers'));
    });
}

// 若是有 'event' 参数
if ($request->has('event')) {

    // 只查找被邀请的用户
    $user->whereHas('rsvp.event', function ($query) use ($request) {
        $query->where('event.slug', $request->input('event'));
    });
    
    // 只查找回复邀请的用户( 以任何形式回复均可以 )
    if ($request->has('responded')) {
        $user->whereHas('rsvp', function ($query) use ($request) {
            $query->whereNotNull('responded_at');
        });
    }

    // 只查找回复邀请的用户( 限制回复的具体内容 )
    if ($request->has('response')) {
        $user->whereHas('rsvp', function ($query) use ($request) {
            $query->where('response', 'I will be attending');
        });
    }
}

// 最终获取对象并返回
return $user->get();

搞定,棒极了!

是否还须要重构?

经过上面的代码咱们实现了业务需求,能够根据搜索条件返回正确的用户信息。可是咱们能说这是最佳实践吗?显然是不能。

如今是经过一系列条件判断的嵌套来实现业务逻辑,并且全部的逻辑都在控制器里,这样作真的合适吗?

这多是一个见仁见智的问题,最好仍是结合本身的项目,具体问题具体分析。若是你的项目比较小,逻辑相对简单,并且只是一个短时间需求的项目,那么就没必要纠结这个问题了,直接照上面的逻辑写就行了。 

然而,若是你是在构建一个比较复杂的项目,那么咱们仍是须要更加优雅且扩展性好的解决方案。

编写新的搜索 api

当我要写一个功能接口的时候,我不会马上去写核心代码,我一般会先想一想我要怎么用这个接口。这可能就是俗称的「面向结果编程」(或者说是「结果导向思惟」)。

「在你写一个组件以前,建议你先写一些要用这个组件的测试代码。经过这种方式,你会更加清晰地知道你究竟要写哪些函数,以及传哪些必要的参数,这样你才能写出真正好用的接口。

由于写接口的目的是简化使用组件的代码,而不是简化接口自身的代码。」 ( 摘自: c2.com

根据个人经验,这个方法能帮助我写出可读性更强,更加优雅的程序。还有一个很大的额外收获就是,经过这种阶段性的验收测试,我能更好地抓住商业需求。所以,我能够很自信地说我写的程序能够很好地知足市场的需求,具备很高商业价值。

如下添加到搜索功能的代码中,我但愿个人搜索 api 是这样写的:

return UserSearch::apply($filters);

这样有着很好的可读性。 根据经验, 若是我查阅代码能想看文章的句子同样,那会很是美妙。像刚刚的状况下:

搜索用户时加上一个过滤器再返回搜索结果。

这对技术人员和非技术人员都有意义。

我想我须要新建一个 UserSearch 类,还须要一个静态的 apply 函数来接收过滤条件。让我开始吧:

<?php
namespace App\Search;
use Illuminate\Http\Request;
class UserSearch
{
    public static function apply(Request $filters)
    {
        // 返回搜索结果
    }
}

最简单的方式,让咱们把控制器中的代码复制到 apply 函数中:

<?php

namespace App\UserSearch;

use App\User;
use Illuminate\Http\Request;

class UserSearch
{
    public static function apply(Request $filters)
    {
        $user = (new User)->newQuery();

        // 基于用户名搜索
        if ($filters->has('name')) {
            $user->where('name', $filters->input('name'));
        }

        // 基于用户的公司名搜索
        if ($filters->has('company')) {
            $user->where('company', $filters->input('company'));
        }

        // 基于用户的城市名搜索
        if ($filters->has('city')) {
            $user->where('city', $filters->input('city'));
        }

        // 只返回分配了销售经理的用户
        if ($filters->has('managers')) {
            $user->whereHas('managers', 
                function ($query) use ($filters) {
                    $query->whereIn('managers.name', 
                        $filters->input('managers'));
                });
        }

   
        // 搜索条件中是否包含 'event’ ?
        if ($filters->has('event')) {

            // 只返回被邀请参加了活动的用户
            $user->whereHas('rsvp.event', 
                function ($query) use ($filters) {
                    $query->where('event.slug', 
                        $filters->input('event'));
                });

        
            // 只返回以任何形式答复了邀请的用户
            if ($filters->has('responded')) {
                $user->whereHas('rsvp', 
                    function ($query) use ($filters) {
                        $query->whereNotNull('responded_at');
                    });
            }

          
            // 只返回以某种方式答复了邀请的用户
            if ($filters->has('response')) {
                $user->whereHas('rsvp', 
                    function ($query) use ($filters) {
                        $query->where('response', 
                            'I will be attending');
                    });
            }
        }

        // 返回搜索结果
        return $user->get();
    }
}

咱们作了一系列的改变。 首先, 咱们将在控制器中的 $request 变量改名为 filters 来提升可读性。

其次,因为 newQuery() 方法不是静态方法,没法经过 User 类静态调用,因此咱们须要先建立一个 User 对象,再调用这个方法:

$user = (new User)->newQuery();

调用上面的 UserSearch 接口,对控制器的代码进行重构:

<?php

namespace App\Http\Controllers;

use App\Http\Requests;
use App\Search\UserSearch;
use Illuminate\Http\Request;
use App\Http\Controllers\Controller;

class SearchController extends Controller
{
    public function filter(Request $request)
    {
        return UserSearch::apply($request);
    }
}

好多了,是否是?把一系列的条件判断交给专门的类处理,使控制器的代码简介清新。

下面进入见证奇迹的时刻

在这篇文章的例子中,一共有 7 个过滤条件,可是现实的状况是更多更多。因此在这种状况下,只用一个文件来处理全部的过滤逻辑,就显得差强人意了。扩展性很差,并且也不符合 S.O.L.I.D. principles 原则。目前,apply()  方法须要处理这些逻辑:

  • 检查参数是否存在
  • 把参数转成查询条件
  • 执行查询

若是我想增长一个新的过滤条件,或者修改一下现有的某个过滤条件的逻辑,我都要不停地修改 UserSearch 类,由于全部过滤条件的处理都在这一个类里,随着业务逻辑的增长,会有点尾大不掉的感受。因此对每一个过滤条件单独建个类文件是很是有必要的。

先从 Name 条件开始吧。可是,就像咱们前面讲的,仍是想一下咱们须要怎样使用这种单一条件过滤的接口。

我但愿能够这样调用这个接口:

$user = (new User)->newQuery();
$user = static::applyFiltersToQuery($filters, $user);
return $user->get();

不过这里再使用 $user 这个变量名就不合适了,应该用 $query 更有意义。

public static function apply(Request $filters)
{
    $query = (new User)->newQuery();

    $query = static::applyFiltersToQuery($filters, $query);

    return $query->get();
}

而后把全部条件过滤的逻辑都放到 applyFiltersToQuery() 这个新接口里。

下面开始建立第一个条件过滤类:Name.

<?php

namespace App\UserSearch\Filters;

class Name
{
    public static function apply($builder, $value)
    {
        return $builder->where('name', $value);
    }
}

在这个类里定义一个静态方法 apply(),这个方法接收两个参数,一个是 Builder 实例,另外一个是过滤条件的值( 在这个例子中,这个值是 'Billy' )。而后带着这个过滤条件返回一个新的 Builder 实例。

接下来是 City 类:

<?php

namespace App\UserSearch\Filters;

class City
{
    public static function apply($builder, $value)
    {
        return $builder->where('city', $value);
    }
}

如你所见,City 类的代码逻辑跟 Name 类相同,只是过滤条件变成了 'city'。让每一个条件过滤类都只有一个简单的 apply() 方法,并且方法接收的参数和处理的逻辑都相同,咱们能够把这当作一个协议,这一点很重要,下面我会具体说明。

为了确保每一个条件过滤类都能遵循这个协议,我决定写一个接口,让每一个类都实现这个接口。

<?php

namespace App\UserSearch\Filters;

use Illuminate\Database\Eloquent\Builder;

interface Filter
{
    /**
     * 把过滤条件附加到 builder 的实例上
     * 
     * @param Builder $builder
     * @param mixed $value
     * @return Builder $builder
     */
    public static function apply(Builder $builder, $value);
}

我为这个接口的方法写了详细的注释,这样作的好处是,对于每个实现这个接口的类,我均可以利用个人 IDE ( PHPStorm ) 自动生成一样的注释。

下面,分别在 Name 和 City 类中实现这个 Filter 接口:

<?php

namespace App\UserSearch\Filters;

use Illuminate\Database\Eloquent\Builder;

class Name implements Filter
{

    /**
     * 把过滤条件附加到 builder 的实例上
     *
     * @param Builder $builder
     * @param mixed $value
     * @return Builder $builder
     */
    public static function apply(Builder $builder, $value)
    {
        return $builder->where('name', $value);
    }
}

以及

<?php

namespace App\UserSearch\Filters;

use Illuminate\Database\Eloquent\Builder;

class City implements Filter
{

    /**
     * 把过滤条件附加到 builder 的实例上
     *
     * @param Builder $builder
     * @param mixed $value
     * @return Builder $builder
     */
    public static function apply(Builder $builder, $value)
    {
        return $builder->where('city', $value);
    }
}

完美。如今已经有两个条件过滤类完美地遵循了这个协议。把个人目录结构附在下面给你们参考一下:

这是到目前为止关于搜索的文件结构。

我把全部的条件过滤类的文件放在一个单独的文件夹里,这让我对已有的过滤条件一目了然。

使用新的过滤器

如今回过头来看 UserSearch 类的 applyFiltersToQuery() 方法,发现咱们能够再作一些优化了。

首先,把每一个条件判断里构建查询语句的工做,交给对应的过滤类去作。

// 根据姓名搜索用户
if ($filters->has('name')) {
    $query = Name::apply($query, $filters->input('name'));
}

// 根据城市搜索用户
if ($filters->has('city')) {
    $query = City::apply($query, $filters->input('city'));
}

如今根据过滤条件构建查询语句的工做已经转给各个相应的过滤类了,可是判断每一个过滤条件是否存在的工做,仍是经过一系列的条件判断语句完成的。并且条件判断的参数都是写死的,一个参数对应一个过滤类。这样我每增长一个新的过滤条件,我都要从新修改 UserSearch 类的代码。这显然是一个须要解决的问题。

其实,根据咱们前面介绍的命名规则, 咱们很容易把这段条件判断的代码改为动态的:

AppUserSearchFiltersName

AppUserSearchFiltersCity

就是结合命名空间和过滤条件的名称,动态地建立过滤类(固然,要对接收到的过滤条件参数作适当的处理)。

大概就是这个思路,下面是具体实现:

private static function applyFiltersToQuery(
                           Request $filters, Builder $query) {
    foreach ($filters->all() as $filterName => $value) {

        $decorator =
            __NAMESPACE__ . '\\Filters\\' . 
                str_replace(' ', '', ucwords(
                    str_replace('_', ' ', $filterName)));

        if (class_exists($decorator)) {
            $query = $decorator::apply($query, $value);
        }

    }

    return $query;
}

下面逐行分析这段代码:

foreach ($filters->all() as $filterName => $value) {

遍历全部的过滤参数,把参数名(好比 city)赋值给变量 $filterName,参数值(好比 London)复制给变量 $value

$decorator =
            __NAMESPACE__ . '\\Filters\\' . 
                str_replace(' ', '', ucwords(
                    str_replace('_', ' ', $filterName)));

这里是对参数名进行处理,将下划线改为空格,让每一个单词都首字母大写,而后去掉空格,以下例子:

"name"            => App\UserSearch\Filters\Name,\
"company"         => App\UserSearch\Filters\Company,\
"city"            => App\UserSearch\Filters\City,\
"event"           => App\UserSearch\Filters\Event,\
"responded"       => App\UserSearch\Filters\Responded,\
"response"        => App\UserSearch\Filters\Response,\
"managers"        => App\UserSearch\Filters\Managers

若是有参数名是带下划线的,好比 has_responded,根据上面的规则,它将被处理成 HasResponded,所以,其相应的过滤类的名字也要是这个。

if (class_exists($decorator)) {

这里就是要先肯定这个过滤类是存在的,再执行下面的操做,不然在客户端报错就尴尬了。

$query = $decorator::apply($query, $value);

这里就是神奇的地方了,PHP 容许把变量 $decorator 做为类,并调用其方法(在这里就是 apply() 方法了)。如今再看这个接口的代码,发现咱们再次实力证实了磨刀不误砍柴工。如今咱们能够确保每一个过滤类对外响应一致,内部又能够分别处理各自的逻辑。

最后的优化

如今 UserSearch 类的代码应该已经比以前好多了,可是,我以为还能够更好,因此我又作了些改动,这是最终版本:

<?php

namespace App\UserSearch;

use App\User;
use Illuminate\Http\Request;
use Illuminate\Database\Eloquent\Builder;

class UserSearch
{
    public static function apply(Request $filters)
    {
        $query = 
            static::applyDecoratorsFromRequest(
                $filters, (new User)->newQuery()
            );

        return static::getResults($query);
    }
    
    private static function applyDecoratorsFromRequest(Request $request, Builder $query)
    {
        foreach ($request->all() as $filterName => $value) {

            $decorator = static::createFilterDecorator($filterName);

            if (static::isValidDecorator($decorator)) {
                $query = $decorator::apply($query, $value);
            }

        }
        return $query;
    }
    
    private static function createFilterDecorator($name)
    {
        return return __NAMESPACE__ . '\\Filters\\' . 
            str_replace(' ', '', 
                ucwords(str_replace('_', ' ', $name)));
    }
    
    private static function isValidDecorator($decorator)
    {
        return class_exists($decorator);
    }

    private static function getResults(Builder $query)
    {
        return $query->get();
    }

}

我最后决定去掉 applyFiltersToQuery() 方法,是由于感受跟接口的主要方法名 apply() 有点冲突了。

并且,为了贯彻执行单一职责原则,我把原来 applyFiltersToQuery() 方法里比较复杂的逻辑又作了拆分,为动态建立过滤类名称,和确认过滤类是否存在的判断,都写了单独的方法。

这样,即使要扩展搜索接口,我也不须要再去反复修改 UserSearch 类里的代码了。须要增长新的过滤条件吗?简单,只要在 App\UserSearch\Filters 目录下建立一个过滤类,并使之实现 Filter 接口就 OK 了。

结论

咱们已经把一个拥有全部搜索逻辑的巨大控制器方法保存成一个容许打开过滤器的模块化过滤系统,而不须要添加修改核心代码。 像评论里 @rockroxx所建议的,另外一个重构的方案是把全部方法提取到 trait 并将 User  设置成  const  而后由 Interface 实现。

class UserSearch implements Searchable {
    const MODEL = App\User;
    use SearchableTrait;
}

若是你很好的理解了这个设计模式,你能够 利用多态代替多条件

代码会提交到 GitHub 你能够 fork,测试和实验。

如何解决多条件高级搜索,我但愿你能留下你的想法、建议和评论。

相关文章
相关标签/搜索