在项目开发时,特别是后台管理功能里,数据搜索几乎是无处不在的。
发现问题
例如我们在开发一个项目时,需要在后台增加一个用户搜索的功能。以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类我们也可以增加关联多表相关的代码,来实现更复杂的查询需求。
总结
本文从发现问题入手,描述了一些常见的问题和不足,然后提出了一种基于模型搜索器的解决方案,并通过代码示例阐释了实现思路。最后还对该方案进行了进一步的扩展和优化。
通过这样的方案,使得数据查询的开发更加高效、可维护,同时还能有效防范安全隐患,是一种不错的实践。