搜索是最常用的功能之一,具体到搜索某一种数据,通常有两种形式:
- 单个搜索框:用户输入关键词(keyword)文本,在数据的多个属性中用关键词筛选。输入简单,但结果可能不够精确。
- 多个筛选条件:为数据的多个属性分别设置筛选条件,在数据中分别用对应的筛选条件筛选。输入更复杂,但结果更精确。
我们在这里只讨论第一种搜索形式,并将其实现为通用的底层功能,以便在需要时对任意数据快速实现搜索。
前端页面:页面上设置一个单行文本框,用户可输入关键词列表,多个关键词用空格隔开。前端保证关键词列表的每个元素为非空字符串。
后端接口:接收3个参数:
keywords:List<String>,关键词列表,可为空列表。如果一条数据包含所有关键词,则该数据在搜索结果中,即多个关键词之间为AND关系。对于一个关键词,数据包含该关键词的定义是,数据的任一指定属性满足指定的与该关键词的关系(例如相等、包含)。pageNum:int,当前页码。pageSize:int,每页条数。
后端在Mapper层实现通用的按关键词搜索(SearchByKeywordsMapper接口),并留出扩展性。实体Mapper接口调用SearchByKeywordsMapper接口的方法,并传入以下信息:
keywords、pageNum、pageSize。select语句,即如何查询指定的数据。- 属性条件,即哪些属性(可多个),与关键词要满足什么关系(例如相等、包含)。
order by子句,即如何排序。
为了简化实现,我们使用PageHelper分页插件实现自动分页,并支持使用Map+简短标记(如=、like)来简化属性条件的表达。
参考代码:
/**
* 通用的按关键词搜索的Mapper(面向MySQL数据库)
* @param <T> 实体类
*/
public interface SearchByKeywordsMapper<T> {
/**
* 搜索并分页
* @param keywords 搜索关键词列表
* @param pageNum 当前页码,从1开始。
* @param pageSize 每页条数
* @return 分页对象
* @implNote 请在实体Mapper中使用{@code default}方法实现此方法,并在{@code default}方法中调用
* {@link #_search(String, List, Map, String, int, int)}方法实现搜索和分页功能。
*/
Page<T> search(List<String> keywords, int pageNum, int pageSize);
/**
* 搜索并分页
* @param select {@code select}SQL语句,可以包含连表,但不能包含{@code where}子句和{@code order by}子句。
* @param keywords 搜索关键词列表
* @param keywordConditions 单个关键词的搜索条件映射表。
* <p>其中,键为列名,例如{@code "e.name"}。</p>
* <p>值为条件,支持以下4种形式:<p>
* <ol>
* <li>{@code "="}:表示等于当前关键词,即<code>"column = #{keyword}"</code>。</li>
* <li>{@code "like"}:表示字符串包含当前关键词,即<code>"column like concat('%', #{keyword}, '%')"</code>。</li>
* <li>{@code "find_in_set"}:表示使用{@code find_in_set()}函数判断包含,即<code>"find_in_set(#{keyword}, column)"</code>。</li>
* <li>自定义条件:不含列名,关键词的占位符为<code>#{keyword}</code>,即<code>"column 自定义条件"</code>。</li>
* </ol>
* @param orderBy {@code order by}子句,不含{@code order by}关键字。{@code null}或{@code ""}表示不排序。
* @param pageNum 当前页码,从1开始。
* @param pageSize 每页条数
* @return 分页对象
*/
default Page<T> _search(
String select,
List<String> keywords,
Map<String, String> keywordConditions,
@Nullable String orderBy,
int pageNum,
int pageSize
) {
var keywordConditionsString = keywordConditions.entrySet().stream()
.map(entry -> {
var column = entry.getKey();
var condition = entry.getValue();
if (condition.equals("=")) {
return column + " = #{keyword}";
} else if (condition.equalsIgnoreCase("like")) {
return column + " like concat('%', #{keyword}, '%')";
} else if (condition.equalsIgnoreCase("find_in_set")) {
return "find_in_set(#{keyword}, " + column + ")";
} else {
return column + " " + condition;
}
})
.collect(Collectors.joining(" or "));
return _search(select, keywords, keywordConditionsString, orderBy, pageNum, pageSize);
}
/**
* 使用PageHelper分页插件执行搜索并分页
* @param select {@code select}SQL语句,可以包含连表,但不能包含{@code where}子句和{@code order by}子句。
* @param keywords 搜索关键词列表
* @param keywordConditions 单个关键词的搜索条件,其中关键词的占位符为<code>#{keyword}</code>。
* @param orderBy {@code order by}子句,不含{@code order by}关键字。{@code null}或{@code ""}表示不排序。
* @param pageNum 当前页码,从1开始。
* @param pageSize 每页条数
* @return 分页对象
*/
@Select("""
<script>
<![CDATA[ ${select} ]]>
<where>
<foreach item="keyword" collection="keywords" separator="and">
( <![CDATA[ ${keywordConditions} ]]> )
</foreach>
</where>
<if test="orderBy != null and orderBy != ''">
order by <![CDATA[ ${orderBy} ]]>
</if>
</script>
""")
Page<T> _search(
String select,
List<String> keywords,
String keywordConditions,
@Nullable String orderBy,
int pageNum,
int pageSize
);
}
// 实体Mapper
public interface EntityMapper extends SearchByKeywordsMapper<Entity> {
/**
* 搜索实体
* @param keywords 搜索关键词列表
* @param pageNum 当前页码
* @param pageSize 每页条数
* @return 分页对象
*/
default Page<Entity> search(List<String> keywords, int pageNum, int pageSize) {
return _search(
"select ...",
keywords,
Map.of(
"column1", "=",
"column2", "like"
),
"column1, column2 desc",
pageNum,
pageSize
);
}
}