36. 热点问题-持久层

先创建封装数据的VO类:

@Data
public class QuestionListItemVO {

    private Integer id;
    private String title;
    private Integer status;
    private Integer hits;

}
复制代码

在持久层接口QuestionMapper中添加抽象方法:

@Repository
public interface QuestionMapper extends BaseMapper<Question> {

    /**
     * 查询点击量最多的问题的列表
     *
     * @return 点击量最多的问题的列表
     */
    List<QuestionListItemVO> findMostHits();

}
复制代码

并在QuestionMapper.xml中配置映射:

<select id="findMostHits"
        resultType="cn.tedu.straw.portal.vo.QuestionListItemVO">
    SELECT
        id, title, status, hits
    FROM
        question
    WHERE
        is_public=1 AND is_delete=0
    ORDER BY
        hits DESC, id DESC
    LIMIT
        0, 10
</select>
复制代码

单元测试:

@Slf4j
@SpringBootTest
public class QuestionMapperTests {

    @Autowired
    QuestionMapper mapper;

    @Test
    void findMostHits() {
        List<QuestionListItemVO> questions
                = mapper.findMostHits();
        log.debug("question count={}", questions.size());
        for (QuestionListItemVO question : questions) {
            log.debug(">>> {}", question);
        }
    }

}
复制代码

37. 热点问题-业务层

在业务层接口IQuestionService中添加抽象方法:

/**
 * 查询点击数量最多的问题的列表,将从缓存中获取列表,如果缓存中没有数据,会从数据库中查询数据并更新缓存
 *
 * @return 点击数量最多的问题的列表
 */
List<QuestionListItemVO> getMostHits();

/**
 * 查询点击数量最多的问题的缓存列表,当缓存被清空后,可能获取到空的列表
 *
 * @return 点击数量最多的问题的缓存列表
 */
List<QuestionListItemVO> getCachedMostHits();
复制代码

QuestionServiceImpl中实现:

private List<QuestionListItemVO> questions = new CopyOnWriteArrayList<>();

@Override
public List<QuestionListItemVO> getMostHits() {
    if (questions.isEmpty()) {
        synchronized (CacheSchedule.LOCK_CACHE_QUESTION) {
            if (questions.isEmpty()) {
                questions.addAll(questionMapper.findMostHits());
            }
        }
    }
    return questions;
}

@Override
public List<QuestionListItemVO> getCachedMostHits() {
    return questions;
}
复制代码

并在计划任务中添加新的清除缓存任务:

@Autowired
private IQuestionService questionService;

public static final Object LOCK_CACHE_QUESTION = new Object();

@Scheduled(initialDelay = 1 * 60 * 1000, fixedRate = 1 * 60 * 1000)
public void clearQuestionCache() {
    synchronized (LOCK_CACHE_QUESTION) {
        questionService.getCachedMostHits().clear();
        log.debug("clear question cache ...");
    }
}
复制代码

为了便于学习时修改数据后缓存能更快清空,暂时将计划任务的周期调整为1分钟。

单元测试:

@Test
void getMostHits() {
    List<QuestionListItemVO> questions = service.getMostHits();
    log.debug("question count={}", questions.size());
    for (QuestionListItemVO question : questions) {
        log.debug(">>> {}", question);
    }
}
复制代码

38. 热点问题-控制器层

// http://localhost:8080/api/v1/questions/hits
@GetMapping("hits")
public R<List<QuestionListItemVO>> mostHits() {
    return R.ok(questionService.getMostHits());
}
复制代码

39. 前端页面

注意:此前开发“我要提问”时,创建的Vue对象时,设置的id覆盖范围太大,应该将此前设置的id调整到仅覆盖“提问”的表单,否则,此次将创建Vue对象的范围将在此前范围的子级,将无法正常使用。

question/create.html中,先找到显示“热点问题”的列表,在其父级添加id="mostHitQuestions",在被遍历的标签及子级添加Vue的绑定:

<div id="mostHitQuestionsApp" class="container-fluid bg-light mt-5">
    <h4 class="m-2 p-2 font-weight-light"><i class="fa fa-list" aria-hidden="true"></i> 热点问题</h4>
    <div v-for="question in questions" class="list-group list-group-flush">
        <a href="../question/detail.html" class="list-group-item list-group-item-action">
            <div class="d-flex w-100 justify-content-between">
                <h6 class="mb-1 text-dark" v-text="question.title">equals和==的区别是啥?</h6>
            </div>
            <div class="row">
                <div class="col-6">
                    <small class="mr-2">1条回答</small>
                    <small v-text="question.statusText"
                           v-bind:class="[question.statusClass]">已解决</small>
                </div>
                <div class="col-6 text-right">
                    <small><span v-text="question.hits">10</span>浏览</small>
                </div>
            </div>
        </a>
    </div>
</div>
复制代码

然后,创建**/js/commons/most_hits.js**文件,编写测试数据绑定:

let mostHitQuestionsApp = new Vue({
    el: '#mostHitQuestionsApp',
    data: {
        questions: [
            { id: 1, title: '第1个问题', status: 0, hits: 20, statusText: '未回复', statusClass: "text-warning" },
            { id: 3, title: '第2个问题', status: 2, hits: 42, statusText: '已解决', statusClass: "text-success" },
            { id: 7, title: '第3个问题', status: 0, hits: 67, statusText: '未回复', statusClass: "text-warning" },
            { id: 10, title: '第4个问题', status: 1, hits: 35, statusText: '未解决', statusClass: "text-info" },
            { id: 17, title: '第5个问题', status: 1, hits: 16, statusText: '未解决', statusClass: "text-info" },
        ]
    }
}
复制代码

然后,在create.html中引用以上js文件,即可看到测试效果。

然后,在most_hits.js中补全数据访问:

let mostHitQuestionsApp = new Vue({
    el: '#mostHitQuestionsApp',
    data: {
        questions: [
            { id: 1, title: '第1个问题', status: 0, hits: 20, statusText: '未回复', statusClass: "text-warning" },
            { id: 3, title: '第2个问题', status: 2, hits: 42, statusText: '已解决', statusClass: "text-success" },
            { id: 7, title: '第3个问题', status: 0, hits: 67, statusText: '未回复', statusClass: "text-warning" },
            { id: 10, title: '第4个问题', status: 1, hits: 35, statusText: '未解决', statusClass: "text-info" },
            { id: 17, title: '第5个问题', status: 1, hits: 16, statusText: '未解决', statusClass: "text-info" },
        ]
    },
    methods: {
        loadMostHitQuestions: function () {
            $.ajax({
                url: '/api/v1/questions/hits',
                success: function (json) {
                    let questions = [];
                    let statusTexts = ['未回复', '未解决', '已解决'];
                    let statusClasses = ['text-warning', 'text-info', 'text-success'];
                    for (let i = 0; i < json.data.length; i++) {
                        questions[i] = json.data[i];
                        questions[i].statusText = statusTexts[questions[i].status];
                        questions[i].statusClass = statusClasses[questions[i].status];
                    }
                    mostHitQuestionsApp.questions = questions;
                }
            });
        }
    },
    created: function () {
        this.loadMostHitQuestions();
    }
});
复制代码

40. 显示主页

static下的index.html移动到templates下。

SystemController中添加:

@GetMapping("/index.html")
public String index() {
    return "index";
}
复制代码

SecurityConfig中,将/index.html从白名单中移除,要求必须登录才可以访问主页!

可以发现,在“主页”和“我要提问”页面,都存在相同的区域:顶部的标签导航,右侧的热点问题列表。如果在2个页面都单独处理,就会出现重复的代码!

Thymeleaf框架可以将页面中的某个部分设置为“碎片(fragment)”,在其它页面中可以直接引用该碎片,就不必编写重复的代码了!设置碎片的代码是在标签是添加th:fragment="自定义名称",在其它页面,通过th:replace="碎片所在页面的视图名称::碎片名称"即可引用碎片!

以“显示顶部标签导航”为例,在index.html中,为原有标签添加th:fragment="nav_tags"

<div th:fragment="nav_tags" class="container-fluid" id="navTagsApp">
    <div class="nav font-weight-light">
        <a href="../tag/tag_question.html" class="nav-item nav-link text-info"><small>全部</small></a>
        <a v-for="tag in tags" href="../tag/tag_question.html" class="nav-item nav-link text-info"><small
                v-text="tag.name">Java基础</small></a>
    </div>
</div>
复制代码

create.html中使用th:replace="index::nav_tags"即可:

<div th:replace="index::nav_tags"></div>
复制代码

41. 我的问答列表-持久层

(a) 分析需要执行的SQL语句

如果需要显示当前登录的用户的问答列表,需要执行的SQL语句大致是:

select * from question where user_id=? order by created_time desc
复制代码

最终在页面中显示列表时,还需要显示每个问题的标签,关于标签,在question_tag中已经存储了“问题”与“标签”的对应关系,所以,需要显示标签名称时,可以通过关联查询得到各标签的名称,例如:

select * 
from question 
left join question_tag on question.id=question_tag.question_id
left join tag on question_tag.tag_id=tag.id
where user_id=? 
order by created_time desc
复制代码

另外,在question表中,在每次发表提问时,还使用tag_ids记录了每个问题的标签的id列表,这是一种冗余的记录,其优点是“只需要查1张表就可以知道该问题有哪些标签”,缺点在于:

  1. 存储了冗余的数据,额外占用了存储空间;
  2. 数据更新更加麻烦,如果修改标签,则2张数据表都需要调整;
  3. 如果只查1张表,只能查出标签的id,无法显示标签的名称!

关于以上问题的分析:

  1. 额外占用的空间不大,在查询时却能提升查询效率(对于关联3张表的查询,只需要查询1张表肯定更加高效);
  2. 需要同时修改2张表效率确实更低,但是,从用户的使用角度来看,修改标签的概率更低,但是显示列表的概率更高,所以,相比之下应该优先考虑显示时的效率,修改的概率是次要的;
  3. 由于标签是相对固定的数据,此前的设计中就已经使用了缓存,相比关联查询3张表而言,只查1张表并结合内存中的缓存数据来得到完整数据,后者的效率更高一些。

综合来看,更加合理的解决方案应该是:只查question这1张表即可,当查出数据后,根据结果中的tagIds再从内存缓存的标签列表中取出各标签数据即可!

(b) 在接口中定义抽象方法

最终,向客户端响应的数据中必须包括若干个Tag对象,所以需要创建对应的VO类:

@Data
public class QuestionVO {

    private Integer id;
    private String title;
    private String content;
    private Integer userId;
    private String userNickName;
    private Integer status;
    private Integer hits;
    private Integer isPublic;
    private Integer isDelete;
    private LocalDateTime createdTime;
    private LocalDateTime modifiedTime;
    private String tagIds;
    private List<TagVO> tags;

}
复制代码

在持久层接口QuestionMapper中添加抽象方法:

/**
 * 查询某用户的问题列表
 *
 * @param userId 用户的id
 * @return 该用户的问题列表
 */
List<QuestionVO> findListByUserId(Integer userId);
复制代码

© 配置抽象方法的映射

QuestionMapper.xml中配置映射:

<resultMap id="QuestionVOMap" type="cn.tedu.straw.portal.vo.QuestionVO">
    <id column="id" property="id" />
    <result column="title" property="title" />
    <result column="content" property="content" />
    <result column="user_nick_name" property="userNickName" />
    <result column="user_id" property="userId" />
    <result column="created_time" property="createdTime" />
    <result column="status" property="status" />
    <result column="hits" property="hits" />
    <result column="is_public" property="isPublic" />
    <result column="modified_time" property="modifiedTime" />
    <result column="is_delete" property="isDelete" />
    <result column="tag_ids" property="tagIds" />
</resultMap>

<select id="findListByUserId" resultMap="QuestionVOMap">
    SELECT
        *
    FROM
        question
    WHERE
        user_id=#{userId}
    ORDER BY
        created_time DESC
</select>
复制代码

(d) 测试

QuestionMapperTestes中测试:

@Test
void findListByUserId() {
    Integer userId = 9;
    List<QuestionVO> questions
            = mapper.findListByUserId(userId);
    log.debug("question count={}", questions.size());
    for (QuestionVO question : questions) {
        log.debug(">>> {}", question);
    }
}
复制代码

测试输出结果例如:

question count=3

>>> QuestionVO(id=3, title=什么是线程安全问题, content=当创建多个线程后,对电脑运行的安全会有影响吗?会不会让电脑烧坏了?<br>, userId=9, userNickName=野原新之助, status=0, hits=101, isPublic=1, isDelete=0, createdTime=2020-07-23T20:42:34, modifiedTime=2020-07-23T20:42:34, tagIds=3, 15, tags=null)

>>> QuestionVO(id=2, title=什么是继承, content=<p>参考网上的说法,答案如下,请老师评估是否正确:<br></p><p>继承是一种利用已有类,快速创建新的类的机制。</p><p>被继承的类称之为父类,或超类,或基类,继承自其它类的类称之为子类,或派生类。</p><p>Java语言只能单继承,也就是说:每个类只能有1个直接父类。</p><p>如果某个类没有显式的继承另一个类,则默认继承自Object类。</p><p>当子类继承了父类后,将得到父类中所有成员,但是,需要注意:</p><ol><li>从数据存在的角度来看,私有成员也是可以得到的,但是,从实际使用来看,除非使用反射,否则,父类中的私有成员对于子类是不可见的;</li><li>构造方法不存在继承的说法,并且,如果父类中不存在无参数构造方法,子类需要显式的声明构造方法;</li><li>父类中的静态成员也不存在继承的说法,但是,通过子类的类名或子类的对象可以调用。<br></li></ol>, userId=9, userNickName=野原新之助, status=0, hits=123, isPublic=1, isDelete=0, createdTime=2020-07-23T20:41:21, modifiedTime=2020-07-23T20:41:21, tagIds=2, 1, 15, tags=null)

>>> QuestionVO(id=1, title=写Java HelloWorld时需要注意什么, content=<p>需要注意的问题有:</p><ol><li>安装好JDK;</li><li>配置好环境变量;</li><li>不要出现明显的语法错误,例如关键字的拼写、符号的使用;</li><li>使用System.out.println()输出字符串时,特殊的符号需要转义。<br></li></ol>, userId=9, userNickName=野原新之助, status=0, hits=161, isPublic=1, isDelete=0, createdTime=2020-07-23T20:36:24, modifiedTime=2020-07-23T20:36:24, tagIds=1, 15, tags=null)
复制代码

42. 我的问答列表-业务层

(a) xx

(b) 在接口中定义抽象方法

IQuestionService中添加抽象方法(暂不考虑Tags的问题):

List<QuestionVO> getQuestionsByUserId(Integer userId);
复制代码

© 实现业务

在处理标签数据时,使用Map再做一个缓存对象,使用标签的id作为Key,标签对象TagVO作为Value,后续,就可以根据idMap对象中获取对应的TagVO了!

所以,在处理标签数据的业务接口ITagService中添加抽象方法:

/**
* 根据标签的id从缓存中获取标签对象
*
* @param tagId 标签的id
* @return 标签对象
*/
TagVO getTagVOById(Integer tagId);

/**
 * 获取缓存的标签的Map集合
 *
 * @return 缓存的标签的Map集合
 */
Map<Integer, TagVO> getCachedTagsMap();
复制代码

在处理标签数据的业务实现类TagServiceImpl中声明缓存对象:

/**
 * 缓存的标签Map集合
 */
private Map<Integer, TagVO> tagsMap = new ConcurrentHashMap<>();
复制代码

线程安全问题的前提:

  • 存在多个线程;
  • 多个线程同时处于运行状态;
  • 多个线程会访问到同一个数据;
  • 多个线程对这同一个数据都有“写”操作。

当以上4个条件全部满足时,就需要考虑如何解决线程安全问题了!

尽量不要将数据声明为全局的属性,可能导致线程安全问题,例如:在某Service实现类中声明了全局属性,由于Spring是使用单例模式管理对象的,所以,在整个项目运行期间,该Service类的对象只会存在1个,则类中的全局属性也只有1个,若干个线程访问时,用到的都是同一个全局属性,就可能存在线程安全问题!所以,能不声明为全局变量就不要声明为全局变量,如果一定需要使用,需要评估该全局变量是否可能存在修改,例如在Service中装配的持久层对象就不会被修改,只是用于调用方法的,就不存在线程安全问题,如果是List集合,或某些表现数值的数据,就可能存在写入的操作,就存在线程安全问题,在写入时,必须使用互斥锁!

HashMap是多线程不安全的,HashTable是安全的,但是,HashTable的处理效率低下,建议使用ConcurrentHashMap

然后,原有的缓存标签数据的过程中,将原本获取到的标签数据逐一添加到以上Map中:

@Override
public List<TagVO> getTags() {
    // 判断有没有必要锁住代码
    if (tags.isEmpty()) {
        // 锁住代码
        synchronized (CacheSchedule.LOCK_CACHE) {
            // 判断有没有必要重新加载数据
            if (tags.isEmpty()) {
                tags.addAll(tagMapper.findAll());
                log.debug("create tags cache ...");
                log.debug(">>> tags : {}", tags);

                for (TagVO tag : tags) {
                    tagsMap.put(tag.getId(), tag);
                }
                log.debug("create tags map cache ...");
                log.debug(">>> tags map : {}", tagsMap);
            }
        }
    }
    return tags;
}

@Override
public Map<Integer, TagVO> getCachedTagsMap() {
    return tagsMap;
}
复制代码

再重写接口中的抽象方法,实现“根据标签id获取TagVO对象”:

@Override
public TagVO getTagVOById(Integer tagId) {
    // 如果缓存数据不存在,调用以上方法从数据库中读取数据并缓存下来
    if (tagsMap.isEmpty()) {
        getTags();
    }
    // 从缓存的Map中取出数据
    TagVO tag = tagsMap.get(tagId);
    // 返回
    return tag;
}
复制代码

CacheSchedule计划任务中补充清除原标签缓存时一并清除Map中的缓存:

@Scheduled(initialDelay = 10 * 60 * 1000, fixedRate = 10 * 60 * 1000)
public void clearCache() {
    synchronized (LOCK_CACHE) {
        tagService.getCachedTags().clear();
        tagService.getCachedTagsMap().clear();
        log.debug("clear tags cache ...");
        userService.findCachedTeachers().clear();
        log.debug("clear teacher cache ...");
    }
}
复制代码

QuestionServiceImpl中实现以上抽象方法:

@Autowired
private ITagService tagService;

@Override
public List<QuestionVO> getQuestionsByUserId(Integer userId) {
    // 调用持久层方法查询问题列表,该列表中的数据只有标签的id,并不包括标签数据
    List<QuestionVO> questions = questionMapper.findListByUserId(userId);
    // 遍历以上列表,取出每个问题中记录的标签的ids,并根据这些id从缓存中取出TagVO封装到QuestionVO对象中
    for (QuestionVO question : questions) {
        // 取出标签的id
        String tagIdsStr = question.getTagIds(); // 1, 2, 3
        // 拆分
        String[] tagIds = tagIdsStr.split(", ");
        // 创建用于存放若干个标签的集合
        question.setTags(new ArrayList<>());
        // 遍历数组,从缓存中找出对应的TagVO
        for (String tagId : tagIds) {
            // 从缓存中取出对应的TagVO
            Integer id = Integer.valueOf(tagId);
            TagVO tag = tagService.getTagVOById(id);
            // 将取出的TagVO添加到QuestionVO对象中
            question.getTags().add(tag);
        }
    }
    // 返回
    return questions;
}
复制代码

(d) 测试

QuestionServiceTests中测试:

@Test
void getQuestionsByUserId() {
    Integer userId = 11;
    List<QuestionVO> questions = service.getQuestionsByUserId(userId);
    log.debug("question count={}", questions.size());
    for (QuestionVO question : questions) {
        log.debug(">>> {}", question);
    }
}
复制代码

43. 我的问答列表-业务层-分页重构

PageHelper框架提供了便捷的分页处理!只需要在调用MyBatis持久层的查询方法之前,配置分页参数,即可实现注入Limit子句实现分页查询,对原有的持久层代码没有任何入侵,并且,在返回结果中,会自动添加分页相关的各项参数。

首先,应该添加PageHelper框架所需的依赖:

<dependency>
    <groupId>com.github.pagehelper</groupId>
    <artifactId>pagehelper-spring-boot-starter</artifactId>
    <version>1.2.13</version>
</dependency>
复制代码

关于PageHelper的使用:

  • 应该在业务层使用;
  • 业务方法必须使用PageInfo<?>类型作为返回值,其中泛型就是需要查询的数据的实体类或VO类(也可以理解为这里的泛型是List集合中的元素类型);
  • 调用PageHelper时需要指定“当前页面”和“查询多少条数据”,这2个参数可以声明为抽象方法的参数,“查询多少条数据”也可以理解为“每页显示多少条数据”,是相对固定的值,可以直接写死,或写成配置值等。

基本以上规则,将业务接口中原有的抽象方法改为:

/**
 * 获取某用户某页的问题列表
 *
 * @param userId 用户的id
 * @param page   页码
 * @return 匹配的问题列表
 */
PageInfo<QuestionVO> getQuestionsByUserId(Integer userId, Integer page);
复制代码

本次将把“每页显示多少条数据”设置为配置,所以,在抽象方法中并不将其声明为参数。

然后,将业务层实现类的业务方法的声明改为与接口一致,在实现时,在调用持久层方法之前配置分页参数:

// 设置分页参数
PageHelper.startPage(page, 2);
// 调用持久层方法查询问题列表,该列表中的数据只有标签的id,并不包括标签数据
List<QuestionVO> questions = questionMapper.findListByUserId(userId);
复制代码

最后,返回匹配类型的结果:

// 返回
return new PageInfo<>(questions);
复制代码

完整代码如下:

@Override
public PageInfo<QuestionVO> getQuestionsByUserId(Integer userId, Integer page) {
    // 设置分页参数
    PageHelper.startPage(page, 2);
    // 调用持久层方法查询问题列表,该列表中的数据只有标签的id,并不包括标签数据
    List<QuestionVO> questions = questionMapper.findListByUserId(userId);
    // 遍历以上列表,取出每个问题中记录的标签的ids,并根据这些id从缓存中取出TagVO封装到QuestionVO对象中
    for (QuestionVO question : questions) {
        // 取出标签的id
        String tagIdsStr = question.getTagIds(); // 1, 2, 3
        // 拆分
        String[] tagIds = tagIdsStr.split(", ");
        // 创建用于存放若干个标签的集合
        question.setTags(new ArrayList<>());
        // 遍历数组,从缓存中找出对应的TagVO
        for (String tagId : tagIds) {
            // 从缓存中取出对应的TagVO
            Integer id = Integer.valueOf(tagId);
            TagVO tag = tagService.getTagVOById(id);
            // 将取出的TagVO添加到QuestionVO对象中
            question.getTags().add(tag);
        }
    }
    // 返回
    return new PageInfo<>(questions);
}
复制代码

完成后,即可执行单元测试:

@Test
void getQuestionsByUserId() {
    Integer userId = 11;
    Integer page = 0;
    PageInfo<QuestionVO> pageInfo = service.getQuestionsByUserId(userId, page);
    log.debug("page info >>> {}", pageInfo);
}
复制代码

测试无误后,在application.properties中添加关于“每页显示多少条数据”的配置:

project.question-list.page-size=2
复制代码

QuestionServiceImpl中添加:

@Value("${project.question-list.page-size}")
private Integer pageSize;
复制代码

最后,将以上pageSize应用于PageHelper.start()方法中作为参数即可。

44. 我的问答列表-控制器层

(a) 处理异常

如果在业务层抛出新的(从未处理过的)异常,需要进行处理。

(b) 设计请求

请求路径:http://localhost:8080/api/v1/questions/my?page=1

请求方式:GET

请求参数:Integer page,用户的id

响应结果:PageInfo<QuestionVO>

© 处理请求

QuestionController中添加处理请求的方法:

// http://localhost:8080/api/v1/questions/my
@GetMapping("/my")
public R<PageInfo<QuestionVO>> getMyQuestions(Integer page,
       @AuthenticationPrincipal UserInfo userInfo) {
    if (page == null || page < 1) {
        page = 1;
    }
    PageInfo<QuestionVO> questions = questionService.getQuestionsByUserId(userInfo.getId(), page);
    return R.ok(questions);
}
复制代码

(d) 测试

打开浏览器,输入URL后测试。

45. 我的问答列表-前端页面

参考此前显示列表的方式来显示“我的问答列表”,关于Vue的使用:

  • v-for:用于遍历当前标签及其所有子级标签,配置的参数意义可参考Java中的增强for循环;
  • v-text:用于绑定某标签中显示的文本信息;
  • v-html:用于绑定某标签中填充的HTML源代码;

另外,在“我的问答列表”中,每一个问题都有对应的图片,取出**/img/tag/**文件夹中与当前问题第1个Tag Id匹配的图片即可,也就是说,第1个Tag Id就是图片的文件名。

关于主页的“我的问答列表”下方的分页按钮,尽量完成。