36. 热点问题-持久层
先创建封装数据的VO类:
代码语言:javascript复制@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>
单元测试:
代码语言:javascript复制@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;
}
并在计划任务中添加新的清除缓存任务:
代码语言:javascript复制@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分钟。
单元测试:
代码语言:javascript复制@Test
void getMostHits() {
List<QuestionListItemVO> questions = service.getMostHits();
log.debug("question count={}", questions.size());
for (QuestionListItemVO question : questions) {
log.debug(">>> {}", question);
}
}
38. 热点问题-控制器层
代码语言:javascript复制// 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**文件,编写测试数据绑定:
代码语言:javascript复制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中补全数据访问:
代码语言:javascript复制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语句大致是:
代码语言:javascript复制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张表就可以知道该问题有哪些标签”,缺点在于:
- 存储了冗余的数据,额外占用了存储空间;
- 数据更新更加麻烦,如果修改标签,则2张数据表都需要调整;
- 如果只查1张表,只能查出标签的id,无法显示标签的名称!
关于以上问题的分析:
- 额外占用的空间不大,在查询时却能提升查询效率(对于关联3张表的查询,只需要查询1张表肯定更加高效);
- 需要同时修改2张表效率确实更低,但是,从用户的使用角度来看,修改标签的概率更低,但是显示列表的概率更高,所以,相比之下应该优先考虑显示时的效率,修改的概率是次要的;
- 由于标签是相对固定的数据,此前的设计中就已经使用了缓存,相比关联查询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);
}
}
测试输出结果例如:
代码语言:javascript复制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,后续,就可以根据id
从Map
对象中获取对应的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个参数可以声明为抽象方法的参数,“查询多少条数据”也可以理解为“每页显示多少条数据”,是相对固定的值,可以直接写死,或写成配置值等。
基本以上规则,将业务接口中原有的抽象方法改为:
代码语言:javascript复制/**
* 获取某用户某页的问题列表
*
* @param userId 用户的id
* @param page 页码
* @return 匹配的问题列表
*/
PageInfo<QuestionVO> getQuestionsByUserId(Integer userId, Integer page);
本次将把“每页显示多少条数据”设置为配置,所以,在抽象方法中并不将其声明为参数。
然后,将业务层实现类的业务方法的声明改为与接口一致,在实现时,在调用持久层方法之前配置分页参数:
代码语言:javascript复制// 设置分页参数
PageHelper.startPage(page, 2);
// 调用持久层方法查询问题列表,该列表中的数据只有标签的id,并不包括标签数据
List<QuestionVO> questions = questionMapper.findListByUserId(userId);
最后,返回匹配类型的结果:
代码语言:javascript复制// 返回
return new PageInfo<>(questions);
完整代码如下:
代码语言:javascript复制@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);
}
完成后,即可执行单元测试:
代码语言:javascript复制@Test
void getQuestionsByUserId() {
Integer userId = 11;
Integer page = 0;
PageInfo<QuestionVO> pageInfo = service.getQuestionsByUserId(userId, page);
log.debug("page info >>> {}", pageInfo);
}
测试无误后,在application.properties中添加关于“每页显示多少条数据”的配置:
代码语言:javascript复制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就是图片的文件名。
关于主页的“我的问答列表”下方的分页按钮,尽量完成。