分页的动机
分页主要应用于数据量大的情况。假使不分页的话,所有数据全部返回,且不说加大了服务端压力,增多了用户的等待时间;其实,用户的每次使用往往也不会用到所有的数据。只需要用户像看书一样,每次浏览一页的数据即可。
分页机制
分页即是按照搜索条件、排序条件、分页条件截取出部分数据(即一页数据)用于用户浏览。那么分页的过程,无非是给定输入,给定输出的过程,分三步:
1. 输入:
输入包含过滤条件、排序条件,以及用于分页的条件(第几页,每页显示多少条)
2. 根据输入以及数据库信息,构造sql查询数据库,把结果封装为输出
2. 输出:
* 数据:当页数据、有无数据
* 页信息:当前处于第几页,有无前页,有无后页,是否是首页,是否是尾页,共多少页
* 记录信息:每页显示多少条,共多少条
实现
分页参数侵入
先了解一下这里的参数侵入是什么意思,先看一下登录用户参数侵入
先看 登录用户侵入
介绍
先看以下用户参数侵入的代码
@Controller
public class XxxController {
@RequestMapping(path = "/currentUserAction", method = RequestMethod.GET)
public String currentUserAction(A a, B b, Model model, HttpSession session) {
User user = (User) session.getAttribute("currentUser");
//从session中获取到的用户,传递到service层
XxxxResult result = xxxxService.getCurrentUserXxx(a, b, user);
model.addAttribute(result);
}
}
@Service
public class XxxxService {
@Autowired
private YyyyService yyyyService;
@Autowired
private ZzzzService zzzzService;
@Autowired
private AaaaDao aaaaDao;
@Autowired
private BbbbDao bbbbDao;
public Xxx currentUserServiceMethodA(A a, B b, User user) {
...
zzzzService.currentUserServiceMethodC(user);
...
aaaaDao.currentUserDaoMethodA(.... , user, ..);
...
bbbbDao.currentUserDaoMethodB(user);
...
yyyyService.currentUserServiceMethodD(user);
}
public Xxx currentUserServiceMethodB(User user) {
}
}
@Repository
public class AaaaDao {
@Autowired
private DdddDao ddddDao;
@Autowired
private CcccDao ccccDao;
public Aaaa currentUserDaoMethodA(User user) {
...
ddddDao.currentUserDaoMethodD(user);
...
ccccDao.currentUserDaoMethodC(user);
}
}
以上代码有什么问题呢?在写这些service和dao的时候,其实已经很明确,我这些service方法和dao方法的处理对象都是当前用户,下面我们称这种方法为当前登录用户方法
。然而当前用户user作为一个contoler方法的局部变量,必须作为参数传递给当前登录用户方法
,方法内部才能使用。所以就造成了方法链层层传递当前用户user的局面,而且方法本身无法限定Controller传递过来的user对象就是session中的登录user。每定义当前登录用户方法
,就得接收user参数,同时还得要求其上层方法也是当前登录用户方法
;每调用当前登录用户方法
,都得传递当前user。
解决
怎么解决呢?我们期望当前登录用户方法
方法内部能够获取到当前用户,而不需要接受user对象,一层层,一个个的传递。那么当前登录用户
就不能作为局部变量存在了。
使用spring Security可以这样解决该问题,代码就可以这样写了
@Controller
public class XxxController {
@RequestMapping(path = "/currentUserAction", method = RequestMethod.GET)
public String currentUserAction(A a, B b, Model model) {
XxxxResult result = xxxxService.getCurrentUserXxx(a, b);
model.addAttribute(result);
}
}
@Service
public class XxxxService {
@Autowired
private YyyyService yyyyService;
@Autowired
private ZzzzService zzzzService;
@Autowired
private AaaaDao aaaaDao;
@Autowired
private BbbbDao bbbbDao;
public Xxx currentUserServiceMethodA(A a, B b) {
...
zzzzService.currentUserServiceMethodC();
...
aaaaDao.currentUserDaoMethodA(...);
...
bbbbDao.currentUserDaoMethodB();
...
yyyyService.currentUserServiceMethodD();
}
public Xxx currentUserServiceMethodB() {
}
}
@Repository
public class AaaaDao {
@Autowired
private HttpSession session;
public Aaaa currentUserDaoMethodA() {
User currentUser = (User)SecurityContextHolder.getContext()
.getAuthentication()
.getPrincipal();
}
}
这个问题是怎么解决的呢?我们发现当前用户不再是一个局部变量了,而是全局变量,想在哪获取,就在哪获取。
其实在Spring Security中,当前用户并不真的是一个完全全局的对象,同一时间不同的登录用户获取到的是不同的用户。那spring Security是怎么做到的呢?SecurityContextHolder利用HttpFilter在请求来的时候放进去ThreadLocal,在请求走的时候移除ThreadLocal,这样就保证了我们从ThreadLocal中拿到的session,就是当前session。
这就解决了登录用户参数侵入的问题。其实这种方式也有它的缺点,下文解释。
再论 分页参数侵入
下边将需要分页查询的方法称为分页方法
,全量查询的方法称为全量方法
,统称为查询方法
。
开发中经常是这么做的,针对同一个业务往往要开发两个方法,一个是分页方法
,一个是全量方法
。
它们的一般方式为:
- 分页方法
: result getDomains(int a, int pageNum, int pageSize)
- 全量方法
: result getDomains(int a)
pageNum,pageSize就是分页参数。
而分页方法
就与当前登录用户方法
类似,有参数侵入的问题。那么能不能将分页方法
、全量方法
合二为一,取消方法的分页参数,在Controller层开始的时候,总之是整个分页查询还未开始的时候,把分页参数放入ThreadLocal,之后的每个查询自动从ThreadLocal中取分页参数,取到就分页,取不到就全量,从而解决这个问题呢?下面看看它们的异同。
* 同:经常是这样,方法链层层传递分页参数。每定义当前登录用户方法
,就得接收分页参数,同时还得要求其上层方法也是分页方法
;每调用分页方法
,都得传递分页参数。
* 异:当前登录用户在session回话期间保持不变。然而从Controller开始,可能会经历多个查询方法
,每个查询方法
都需要分页吗?每个需要分页的查询方法
都使用相同的分页参数吗?一个查询方法
也可能被A方法调用(或间接调用)的时候需要分页,在被B方法调用(或间接调用)的时候就不要分页了(这里,我们把这种情况称之为条件分页
)。
分页参数侵入能否解决?
可以解决 分页参数侵入 的问题吗? 答案是不能
虽然其比当前登录用户方法
要复杂,我们还是尝试解决这个问题。
从Controller开始,经历的多个查询方法
,可能分页,也可能不分页。
* 假如每个分页方法都可能使用不同的分页参数
因为每次调用都不同,我们需要在每次分页方法的调用前做标记(注意是标记的方法调用,不是标记的方法本身,如果标记方法本身,就把一个方法是不是分页给定死了)。
- 标记内容:1、需要分页;2、如果是条件分页
,标记内容还要包含被那些调用(或间接调用)该方法需要分页的方法
为此,为了完成查询,当用户请求第一次访问一个查询方法之前,要查找这整个查询方法调用链上的每个标记为需要设置查询参数的标记,针对每个标记,在ThreadLocal存放其分页参数。这种复杂性,而且容易出错,还不如使用带分页参数的分页方法
呢。
- 假如需要分页的方法使用的都是相同的分页参数
这样我们就可以只在ThreadLocal中放入一个分页参数,接下来,就是区分一个查询方法调用是要分页还是不要分页。
- 标记不需分页的方法调用,剩余的所有查询方法调用都是需要分页的?
这样是不可行的,因为有条件分页
这种情况,我们必须对分页做标记,而不是对不分页。
- 标记分页的方法调用,剩余的所有查询方法调用都是不需要分页的?
然而,每个需分页方法都需要做标记,这跟每个分页方法都需要传参,有什么大的区别呢?
综上,分页参数侵入问题不能解决
再来思考,从面向对象角度,传递分页参数是侵入吗?
Dao对象是用来访问数据库的,如果分页查询需要哪一页一页多少条是Dao说了算的,那么分页信息就可以作为Dao的成员变量,是Dao的属性。如果是这样,调用分页方法不用传递分页参数了,然而就需要构造出很多Dao对象,比如我需要(第1页每页10条)的,需要构造一个(第1页每页> 10条)的Dao对象,需要(第3页每页5条)的就需要构造(第1页每页10条)的Dao对象,然后个不一样的分页信息都需要构造一个Dao对象;或者每次调用Dao时,都需要Dao.setPageInfo(pageNum, pageSize),然后调用分页查询方法,还要保证setPageInfo和分页查询方法之间,不能有分页查询方法插入(想象一下并发的情况),如果是单线程还好,并发的话还得加解锁。可见分页信息不适合作为dao属性,类属性呢?就更不适合了。
以上也论证了将分页参数作为全局是不合理的。
分页信息不适合作为属性,方法执行还需要这个信息,所以通过方法参数,让调用方能够给到它所需的信息,在我看来,这不叫侵入。
在我看来,侵入指的是引入了不需要的东西,比如我只依赖借口就够了,非让我看到实现。在这里,既然需要分页,给你怎么分页的信息,不是侵入。
MyBatis
不需要传参数,每次调用查询方法如果需要分页就需要做标记。同样一个查询方法,做标记就分页,不做标记就不分页。实际MyBatis插件PageHelper(限定查询方法为Mapper方法)就是这么做的(插件同时也支持传参的做法)。下边是代码示例:
/*
调用前做标记,标记内容:需要分页,条件为pageNum,pageSize
虽然也支持传参的做法,但官方推荐的是这种方式
然而pageNum,pageSize怎么来的呢?不还得上层传过来的吗?
*/
PageHelper.startPage(pageNum, pageSize);
// 查询方法调用
List<Country> list = countryMapper.selectIf(1);
然而我并不推荐上边的方式,startPage和selectIf是两次独立的方法调用。如果不看PageHelper源码和文档,仅仅看这两行代码,我们发现不了这两个方法调用之间有什么必然的联系,而实际上它们是有联系的。就算看了文档但没看源码不了解背后的机制,哪怕就算了解背后机制,给人的感觉就是稀里糊涂的被分页了。
startPage和selectIf是两次独立的方法调用,按文档,却是被硬性要求前后要写在一起,而且都要有,不能只写startPage而没有select或select最终没执行到。
这样看来它们是强耦合的,既然是强耦合的为什么不把它们内聚呢?内聚的方式之一就是把两次方法调用变为一次方法调用,就是说变成传参的方式,以下PageHelper传参方式分页:
List<Country> list = sqlSession.selectList("x.y.selectIf", null, new RowBounds(0, 10));
//或
List<Country> list = countryMapper.selectByPageNumSize(user, 1, 10);
这样,代码更清晰了,传进方法分页参数,方法返回分页结果,很明白晓畅。
是的,需要定义两个方法,一个全量,一个分页,然而降低了耦合,我认为是值得的。又回到了最初,恩,是这样的
- Mybatis-PageHelper-Github–文档
再议当前登录用户
虽然User user = (User)SecurityContextHolder.getContext().getAuthentication().getPrincipal();
解决了当前登录用户对象传来传去的问题,但其把当前登录用户不可避免的扩展到了全局范围。导致任意代码,代码的每一层,每个模块都能获取。谁想获取当前登录用户就获取当前登录用户,我们控制不了。如果是只读的,危害没那么大。然而SecurityContextHolder
有SecurityContextHolder.clearContext()
可以清掉context,有SecurityContextHolder.setContext(context)
可以重新设置一个新的context,如果谁在代码的某个位置调用了clearContext或setContext,其他获取context的代码将得到不可预期的行为。实际上这两个方法只应该由Filter调用,然而只能靠程序员自觉遵守。