乾貨:構建複雜的 Eloquent 搜索過濾

最近,我須要在開發的事件管理系統中實現搜索功能。 一開始只是簡單的幾個選項 (經過名稱,郵箱等搜索),到後面參數變得愈來愈多。php

今天,我會介紹整個過程以及如何構建靈活且可擴展的搜索系統。若是你想查看代碼,請訪問 Git 倉庫 。html

咱們將創造什麼

咱們公司須要一種跟蹤咱們與世界各地客戶舉辦的各類活動和會議的方式。咱們目前的惟一方法是讓每位員工在 Outlook 日程表上存儲會議的詳細信息。可拓展性較差!前端

咱們須要公司的每一個人均可以訪問,來查看咱們客戶的被邀請的詳細信息以及他們的RSVP(國際縮用語:請回復)狀態。laravel

這樣,咱們能夠經過上次與他們互動的數據來肯定哪些用戶能夠邀請來參加將來的活動。git

使用高級搜索過濾器查找的截圖github

查找用戶

經常使用過濾用戶的方法:編程

  • 經過姓名,電子郵件,位置
  • 經過用戶工做的公司
  • 被邀請參加特定活動的用戶
  • 參加過特定活動的用戶
  • 邀請及已參加活動的用戶
  • 邀請但還沒有回覆的用戶
  • 答應參加但未出席的用戶
  • 分配給銷售經理的用戶

雖然這個列表不算完整,但可讓咱們知道須要多少個過濾器。這將是個挑戰!設計模式

前端的條件過濾的截圖。api

模型及模型關聯

在這個例子中咱們回用到不少模型:bash

  • 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 類的代碼。這顯然是一個須要解決的問題。

其實,根據咱們前面介紹的命名規則, 咱們很容易把這段條件判斷的代碼改爲動態的:

App\UserSearch\Filters\Name

App\UserSearch\Filters\City

就是結合命名空間和過濾條件的名稱,動態地建立過濾類(固然,要對接收到的過濾條件參數作適當的處理)。

大概就是這個思路,下面是具體實現:

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,測試和實驗。

如何解決多條件高級搜索,我但願你能留下你的想法、建議和評論。

文章轉自:learnku.com/laravel/t/2…
更多文章:learnku.com/laravel/c/t…

相關文章
相關標籤/搜索