最近,我需要在開發的事件管理系統中實作搜尋功能。 一開始隻是簡單的幾個選項 (通過名稱,郵箱等搜尋),到後面參數變得越來越多。
今天,我會介紹整個過程以及如何建構靈活且可擴充的搜尋系統。如果你想檢視代碼,請通路 Git 倉庫 。
我們将創造什麼
我們公司需要一種跟蹤我們與世界各地客戶舉辦的各種活動和會議的方式。我們目前的唯一方法是讓每位員工在 Outlook 日程表上存儲會議的詳細資訊。可拓展性較差!
我們需要公司的每個人都可以通路,來檢視我們客戶的被邀請的詳細資訊以及他們的RSVP(國際縮用語:請回複)狀态。
這樣,我們可以通過上次與他們互動的資料來确定哪些使用者可以邀請來參加未來的活動。
使用進階搜尋過濾器查找的截圖
查找使用者
常用過濾使用者的方法:
- 通過姓名,電子郵件,位置
- 通過使用者工作的公司
- 被邀請參加特定活動的使用者
- 參加過特定活動的使用者
- 邀請及已參加活動的使用者
- 邀請但尚未回複的使用者
- 答應參加但未出席的使用者
- 配置設定給銷售經理的使用者
雖然這個清單不算完整,但可以讓我們知道需要多少個過濾器。這将是個挑戰!
前端的條件過濾的截圖。
模型及模型關聯
在這個例子中我們回用到很多模型:
- 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', '[email protected]');
接下來,建立
SearchController
.
php artisan make:controller SearchController
添加前面路由中明确的
filter()
方法:
<?php
namespace AppHttpControllers;
use AppUser;
use AppHttpRequests;
use IlluminateHttpRequest;
use AppHttpControllersController;
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 AppSearch;
use IlluminateHttpRequest;
class UserSearch
{
public static function apply(Request $filters)
{
// 傳回搜尋結果
}
}
最簡單的方式,讓我們把控制器中的代碼複制到 apply 函數中:
<?php
namespace AppUserSearch;
use AppUser;
use IlluminateHttpRequest;
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 AppHttpControllers;
use AppHttpRequests;
use AppSearchUserSearch;
use IlluminateHttpRequest;
use AppHttpControllersController;
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 AppUserSearchFilters;
class Name
{
public static function apply($builder, $value)
{
return $builder->where('name', $value);
}
}
在這個類裡定義一個靜态方法
apply()
,這個方法接收兩個參數,一個是 Builder 執行個體,另一個是過濾條件的值( 在這個例子中,這個值是 'Billy' )。然後帶着這個過濾條件傳回一個新的 Builder 執行個體。
接下來是 City 類:
<?php
namespace AppUserSearchFilters;
class City
{
public static function apply($builder, $value)
{
return $builder->where('city', $value);
}
}
如你所見,City 類的代碼邏輯跟 Name 類相同,隻是過濾條件變成了 'city'。讓每個條件過濾類都隻有一個簡單的
apply()
方法,而且方法接收的參數和處理的邏輯都相同,我們可以把這看成一個協定,這一點很重要,下面我會具體說明。
為了確定每個條件過濾類都能遵循這個協定,我決定寫一個接口,讓每個類都實作這個接口。
<?php
namespace AppUserSearchFilters;
use IlluminateDatabaseEloquentBuilder;
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 AppUserSearchFilters;
use IlluminateDatabaseEloquentBuilder;
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 AppUserSearchFilters;
use IlluminateDatabaseEloquentBuilder;
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;
}
下面逐行分析這段代碼:
周遊所有的過濾參數,把參數名(比如
city
)指派給變量
$filterName
,參數值(比如
London
)複制給變量
$value
。
$decorator =
__NAMESPACE__ . 'Filters' .
str_replace(' ', '', ucwords(
str_replace('_', ' ', $filterName)));
這裡是對參數名進行處理,将下劃線改成空格,讓每個單詞都首字母大寫,然後去掉空格,如下例子:
"name" => AppUserSearchFiltersName,
"company" => AppUserSearchFiltersCompany,
"city" => AppUserSearchFiltersCity,
"event" => AppUserSearchFiltersEvent,
"responded" => AppUserSearchFiltersResponded,
"response" => AppUserSearchFiltersResponse,
"managers" => AppUserSearchFiltersManagers
如果有參數名是帶下劃線的,比如
has_responded
,根據上面的規則,它将被處理成
HasResponded
,是以,其相應的過濾類的名字也要是這個。
這裡就是要先确定這個過濾類是存在的,再執行下面的操作,否則在用戶端報錯就尴尬了。
$query = $decorator::apply($query, $value);
這裡就是神器的地方了,PHP 允許把變量
$decorator
作為類,并調用其方法(在這裡就是 apply() 方法了)。現在再看這個接口的代碼,發現我們再次實力證明了磨刀不誤砍柴工。現在我們可以確定每個過濾類對外響應一緻,内部又可以分别處理各自的邏輯。
最後的優化
現在
UserSearch
類的代碼應該已經比之前好多了,但是,我覺得還可以更好,是以我又做了些改動,這是最終版本:
<?php
namespace AppUserSearch;
use AppUser;
use IlluminateHttpRequest;
use IlluminateDatabaseEloquentBuilder;
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
類裡的代碼了。需要增加新的過濾條件嗎?簡單,隻要在
AppUserSearchFilters
目錄下建立一個過濾類,并使之實作
Filter
接口就 OK 了。
結論
我們已經把一個擁有所有搜尋邏輯的巨大控制器方法儲存成一個允許打開過濾器的子產品化過濾系統,而不需要添加修改核心代碼。 像評論裡 @rockroxx所建議的,另一個重構的方案是把所有方法提取到 trait 并将 User 設定成 const 然後由 Interface 實作。
class UserSearch implements Searchable {
const MODEL = AppUser;
use SearchableTrait;
}
如果你很好的了解了這個設計模式,你可以 利用多态代替多條件。
代碼會送出到 GitHub 你可以 fork,測試和實驗。
如何解決多條件進階搜尋,我希望你能留下你的想法、建議和評論。
文章轉自: https:// learnku.com/laravel/t/2 5818
更多文章: https:// learnku.com/laravel/c/t ranslations