可扩展的搜索组件

785

在开发 Web 应用过程中,条件搜索是一个不可避免的话题。 特别是在后台的开发中,会有很多根据条件来选筛选列表的场景。比如说用户手机号、昵称,产品的类别价格范围等等,常规方法中,我们都是根据前台不同的条件,然后进行相应的代码搜索。但是这样的扩展性很一般,特别在增加新条件的时候,需要去侵入业务代码,我们来看看传统的代码中是怎么实现的:

public function filter(Request $request, User $user) {
    $user = (new User())->newQuery();
    if ($request->has('name')) {
        return $user->where('name', $request->input('name'))->get();
    }
    if ($request->has('company')) {
        return $user->where('company', $request->input('company'));
    }
    return $user->get();
}

这样的代码没有任何问题,也可以运行的很好,但是假如我想再增加一个手机号的筛选呢?又得要来改这一段的代码,如果有涉及到关联查询,代码可能会更加繁琐,所以可以抽离一个单独的搜索组件出来。

基础

我们定义一个单独的用户搜索组件:

class UserSearch {
    public static function apply(Request $filters) {
        //TODO filter
    }
}

然后就可以在控制器中这样来使用:

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

虽然我们只是把搜索的代码移到一个单独的类中了,但是已经比之前看起来好多了。然后再进一步抽象:

public static function apply(Request $filters){
    $query = (new User)->newQuery();
    $query = static::applyFiltersToQuery($filters, $query);
    return $query->get();
}

但是这样还是需要判断很多的条件,我们可以把筛选条件抽象成接口:

interface Filter{
    public static function apply(Builder $builder, $value);
}

然后定义一系列的搜索规则:

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

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

这样就可以和表单中的请求参数对应起来:

private static function applyFiltersToQuery(Request $filters, Builder $query) {
    foreach ($filters->all() as $filterName => $value) {
        $decorator =
            __NAMESPACE__ . '\\Filters\\' . ucwords($filterName);

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

进阶

其实以上已经可以满足大部分需求了,但是我们还可以再进一步的简化。我们知道,搜索一般都是搜索特定的模型,并且会有一个独立的搜索接口,以上的代码每个模型都要写一个搜索组件,而且搜索大部分都是等于和范围查询,所以我们可以把这一段再抽象一下。定义一个模型和字符串的配置文件:

return [
    'user'  =>  User::class
];

然后定义路由,并且在控制器中实例化出模型,搜索服务只要专注于查询即可:

Route::get('{model}/search', 'SearchController@search');
class SearchController{
    public function search(Request $request, $model){
        $model = config('search.'.$model);
        $query = app($model)->newQuery();
        Search::apply($request, $query);
    }
}

然后这是我们的搜索服务:

class Search {
    public static function apply(Request $filters, Builder $query) {
        $query = static::applyFiltersToQuery($filters, $query);
        return $query->get();
    }
}

还可以再精简一下吗?可以的,想想看我们的搜索大部分都是等值搜索和范围搜索,那么我们就可定义几个常用的过滤器:

class EqualFilter implements Filter {
    public static function apply(Builder $builder, $key, $value) {
        return $builder->where($key, $value);
    }
}
class RangeFilter implements Filter {
    public static function apply(Builder $builder, $key, $value) {
        return $builder->whereBetween($key, $value);
    }
}

然后定义好一些规则,那么就可以这样来:

public static function applyFiltersToQuery(array $filters, Builder $query) {
    foreach ($filters as $filterName => $value) {
        $decorator = static::createFilterDecorator($filterName, $value);
        $query = $decorator::apply($query, $filterName, $value);
    }
    return $query;
}
private static function createFilterDecorator($filterName, $value) {
    $stub = __NAMESPACE__ . '\Filters\%sFilter';
    $filter = sprintf($stub, ucwords(str_replace('_', ' ', $filterName)));

    if (class_exists($filter)){
        return $filter;
    }
    if (is_array($value)){  //range
        return RangeFilter::class;
    }
    return EqualFilter::class;
}

这段代码的目的就是当有其他的搜索条件时候,我们可以自定义一系列的过滤器,只要名称对应上即可:

$stub = __NAMESPACE__ . '\Filters\%sFilter';
$filter = sprintf($stub, ucwords(str_replace('_', ' ', $filterName)));

总结

我们从一个非常单一巨大控制器方法中,重构到了现在可以在不修改任何核心代码的情况下增加或者删除一些过滤器。这是一个比较好的设计模式,一旦你理解了之后很多类似的问题都可以解决了。

欢迎关我的个人公众号:左手代码(公众号后台发送 jetbrains,你懂得~)