基于ThinkPHP里模型搜索器的高效数据查询解决方案

在项目开发时,特别是后台管理功能里,数据搜索几乎是无处不在的。

发现问题

​例如我们在开发一个项目时,需要在后台增加一个用户搜索的功能。以ThinkPHP(下面简称tp)为例,如果在没有使用开源的后台开发框架或者追求快速开发时,我们可能会采用下面这段代码:

public function index()
    {
        //姓名关键字搜索
        $name = $this->request->get('name');
        //手机号搜索
        $mobile = $this->request->get('mobile');
        $paginate = User::where('mobile',$mobile)
            ->where('name', 'like', "%{$name}%")
            ->paginate();
        return json($paginate->toArray());
    }

这种解决方法的弊端就是一个查询页面就要写一段代码。写的多了,你就会发现,你的编码时间被这些不断重复的代码所占据,枯燥且乏味。于是你肯定会想办法把这个查询功能提取出来,统一写成一个方法。

于是乎,我们就想到了再新建一个控制器父类,写一个统一的index方法,让控制器子类继承:

class MyController extends BaseController
{
    // 模型名称
    protected Model $model;

    public function index()
    {
        //搜索参数
        $params = $this->request->get('params/a', []);
        //搜索操作符
        $operators = $this->request->get('operators/a', []);
        //排序
        $order = $this->request->get('order/a', []);

        //构建查询
        $query = $this->model->where(function ($query) use ($params, $operators) {
            //遍历搜索参数
            foreach ($params as $field => $value) {
                $op = $operators[$field] ?? '=';
                if ('like' === $op) {
                    $value = "%{$value}%";
                }
                //搜索条件
                $query->where($field, $op, $value);
            }
        });

        //排序
        if ($order) {
            $query->order($order['field'], $order['sort'] ?? 'asc');
        }

        //搜索数据并分页
        $paginate = $query->paginate();
        return json($paginate->toArray());
    }
}

在这个index方法里,我们约定了查询相关参数,params数组表示准备查询的字段名和查询值,operators数组表示字段查询方式(例如:like,in等),order数组表示排序。

使用这个通用的方法,我们就不需要为每个数据列表页面都写一份数据搜索的代码了,大大提升了开发效率。目前一些开源后台系统也是使用的这个方式,而且同时加入add\edit\delete等方法的统一方法。

但我们还是能意识到这个方式的不足之处。很多使用无论是查询还是增删都有特殊的情况,比如要关联查询,某些字段搜索需要特殊处理等,有些页面不允许存在index\add\edit\delete里的某些方法等等。

遇到这些问题,很多人可能会在选择在统一的方法上加上许多if来分别判断,这导致了这些方法代码越来越长,而且if过多也容易造成理解困难,逻辑混乱,特别是随着项目越来越大,特殊情况越来越多,人员变动等因素,最终一堆难以维护的代码;另外一种方法就是在子类里覆盖重写方法,这种方法比上面的容易维护,但是很多时间我们子类覆盖的代码80%其实都和父类是差不多了的,这其实也是一种代码上的冗余,而且假如有些页面我并不需要index\add\edit\delete里的某些方法,就产生了安全隐患。另外通用的查询方法里并没有限制查询字段,也同样存在安全隐患。

解决方案

在tp的模型类里有一个搜索器的概念,是用于封装字段(或者搜索标识)的查询条件表达式。因此我们完全可以利用搜索器来实现数据查询的功能。比如在User模型类中增加搜索器

    public function searchNameAttr($query, $value, $data)
    {
        $query->where('name', 'like', "%{$value}%");
    }

    public function searchMobileAttr($query, $value, $data)
    {
        $query->where('mobile', $value);
    }

接着我们写一个利用搜索器来进行统一搜索的 Searcher 类,代码如下:

<?php

namespace app\library;

use think\Model;
use think\Request;
use think\db\Query;
use think\helper\Str;
use think\db\BaseQuery;

class Searcher
{
    // 模型实例
    protected Model $model;
    // 查询实例
    protected Query $query;
    // 查询参数
    protected array $params;
    // 排序参数
    protected array $order;
    // 每页数量
    protected int $pageSize = 10;

    /**
     * 静态工厂方法,根据请求实例化Searcher类
     *
     * @param Request $request 请求对象
     * @return static
     */
    public static function make(Request $request): static
    {
        // 获取查询参数、排序参数和每页数量
        $params = $request->get('params/a', []);
        $order = $request->get('order/a', []);
        $pageSize = $request->get('pageSize/d', 10);

        // 实例化Searcher类并设置参数
        $instance = new static();
        $instance->params($params)->order($order)->pageSize($pageSize);

        return $instance;
    }

    /**
     * 设置模型
     *
     * @param string $abstract 模型类名
     * @return static
     */
    public function model(string $abstract): static
    {
        $this->model = new $abstract();
        return $this;
    }

    /**
     * 设置排序参数
     *
     * @param array $order 排序参数
     * @return static
     */
    public function order(array $order): static
    {
        $this->order = $order;
        return $this;
    }

    /**
     * 设置每页数量
     *
     * @param int $num 每页数量
     * @return static
     */
    public function pageSize(int $num): static
    {
        $this->pageSize = $num;
        return $this;
    }

    /**
     * 设置查询参数
     *
     * @param array $params 查询参数
     * @return static
     */
    public function params(array $params): static
    {
        $this->params = $params;
        return $this;
    }

    /**
     * 构建查询
     *
     * @return BaseQuery
     */
    public function build(): BaseQuery
    {
        /** @var Query $query */
        $query = $this->model->where(function ($query) {
            foreach ($this->params as $key => $value) {
                $searchName = 'search' . Str::studly($key) . 'Attr';
                if (method_exists($this->model, $searchName)) {
                    call_user_func([$this->model, $searchName], $query, $value, $this->params);
                }
            }
        });

        // 排序
        if (!empty($this->order)) {
            $query->order([$this->order['field'] => $this->order['sort'] ?? 'asc']);
        }

        return $query;
    }

    /**
     * 执行分页查询
     *
     * @return \think\Paginator
     */
    public function search(): \think\Paginator
    {
        return $this->build()->paginate($this->pageSize);
    }
}

这个搜索类中的build方法实现了对模型类里搜索器的查找,预防了未定义的查询条件,也省去了由前端来定义查询方式。同时make方法对查询所需的相关参数做到了统一接收。

此时我们的Index控制器,只需要调用Searcher,传入对应的模型类,就可以实现对数据的查询:

public function index()
{
    $paginate = Searcher::make($this->request)->model(User::class)->search();
    return json($paginate->toArray());
}

在控制器方法里,只需要2行代码就能解决实现对数据的查询,同时也避免了在控制器方法里对数据库的直接操作。

扩展

可能你也发现一个问题,就是模型类里类似mobile字段的“等于”查询是很常见的查询方式,为了一个简单的查询而添加搜索器,不是也造成模型类的臃肿吗?

是的,的确如此。要解决这个问题,我们可以在模型类里定义一个方法用来返回允许查询的数组,以此解决这类查询,修改后User类的代码:

class User extends Model
{
    protected $table = 'user';

    //获取允许查询的字段
    public function getSearchFields()
    {
        return [
            'name',
            'mobile',
        ];
    }

    //姓名查询
    public function searchNameAttr($query, $value, $data)
    {
        $query->where('name', 'like', "%{$value}%");
    }

}

接着调整Searcher类里build()方法的代码

public function build(): BaseQuery
    {
        /** @var Query $query */
        $query = $this->model->where(function ($query) {
            //获取允许参与搜索的字段
            $searchFields = $this->model->getSearchFields();
            foreach ($searchFields as $field => $option) {
                //没有提供这个参数,则跳过
                if (empty($this->params[$field])) {
                    continue;
                }
                $value = $this->params[$field];                       //参数值
                $method = 'search' . Str::studly($field) . 'Attr';  //先查找是否存在专有搜索器
                if (method_exists($this->model, $method)) {
                    $this->model->$method($query, $value, $this->params);
                } else {
                    //通用搜索
                    $query->where($field, '=', $value);
                }
            }
        });

        // 排序
        if (!empty($this->order)) {
            $query->order([$this->order['field'] => $this->order['sort'] ?? 'asc']);
        }

        return $query;
    }

通过调用模型类的getSearchFields来获取允许查询的字段数组,接着遍历数组,先查找是否有定义搜索器,有就使用搜索器方法,没有则调用模型的where来“等于”查询。经过如此修改后,我们就可以去掉User类里关于mobile部分的搜索器代码,减少了一些重复性的代码。

当然我们其实还可以继续优化,把getSearchFields的返回数组进行优化,以key-value的结构,定义查询字段的操作方法,例如:

    //获取允许查询的字段
public function getSearchFields()
{
    return [
        'name' => 'like', //使用like
        'mobile' => '=', //使用=
        'create_time' => 'between',//使用between
    ];
}

然后再调整Searcher类里的build方法,对查询数组进行分别解析并使用对应的where查询方法,这样我们也就可以把关于name字段的搜索器也去掉,再次减少重复性的代码,只保留一些特殊的字段查询搜索器。

同时对于Searcher类我们也可以增加关联多表相关的代码,来实现更复杂的查询需求。

总结

本文从发现问题入手,描述了一些常见的问题和不足,然后提出了一种基于模型搜索器的解决方案,并通过代码示例阐释了实现思路。最后还对该方案进行了进一步的扩展和优化。

通过这样的方案,使得数据查询的开发更加高效、可维护,同时还能有效防范安全隐患,是一种不错的实践。 ​

moufer