最近,我需要在开发的事件管理系统中实现搜索功能。 一开始只是简单的几个选项 (通过名称,邮箱等搜索),到后面参数变得越来越多。
今天,我会介绍整个过程以及如何构建灵活且可扩展的搜索系统。如果你想查看代码,请访问 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', '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类上进行修改。 因此,对于每个可用选项,我都应该有一个专用的Filter类。
先从 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,测试和实验。
如何解决多条件高级搜索,我希望你能留下你的想法、建议和评论。
© 著作权归作者所有
发表评论