导包:spring-boot-starter-security
User实体类实现UserDetails接口,实现接口中各方法(账号、凭证是否可用过期,管理权限)
UserService实现UserDetailsService接口,实现接口方法(security检查用户是否登录时用到该接口)
新建SecurityConfig类
重定向,浏览器访问A,服务器返回302,建议访问B.一般不能带数据给B(Session和Cookie)
转发,浏览器访问A,A完成部分请求,存入Request,转发给B完成剩下请求。(有耦合)
在HomeController添加认证逻辑
首先弃用原来定义的LoginRequiredInterceptor拦截器,
然后再src/main/java/com/nowcoder/community/config包下新建SecurityConfig类,配置spring security功能
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter implements CommunityConstant {
@Override
public void configure(WebSecurity web) throws Exception {
web.ignoring().antMatchers("/resources/**");
}
@Override
protected void configure(HttpSecurity http) throws Exception {
// 授权
http.authorizeRequests()
.antMatchers(
"/user/setting",
"/user/upload",
"/discuss/add/**",
"/comment/add/**",
"/letter/**",
"/notice/**",
"/like",
"/follow",
"/unfollow"
)
.hasAnyAuthority(
AUTHORITY_USER,
AUTHORITY_ADMIN,
AUTHORITY_MODERATOR
)
.anyRequest().permitAll()
.and().csrf().disable(); //关闭csrf功能
// 权限不够时的处理
http.exceptionHandling()
.authenticationEntryPoint(new AuthenticationEntryPoint() {
// 没有登录
@Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
String xRequestedWith = request.getHeader("x-requested-with");
if ("XMLHttpRequest".equals(xRequestedWith)) {
response.setContentType("application/plain;charset=utf-8");
PrintWriter writer = response.getWriter();
writer.write(CommunityUtil.getJSONString(403, "你还没登录哦"));
} else {
response.sendRedirect(request.getContextPath() + "/login");
}
}
})
.accessDeniedHandler(new AccessDeniedHandler() {
// 权限不足
@Override
public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
String xRequestedWith = request.getHeader("x-requested-with");
if ("XMLHttpRequest".equals(xRequestedWith)) {
response.setContentType("application/plain;charset=utf-8");
PrintWriter writer = response.getWriter();
writer.write(CommunityUtil.getJSONString(403, "你没有访问此功能的权限"));
} else {
response.sendRedirect(request.getContextPath() + "/denied");
}
}
});
// Security底层默认会拦截/logout请求,进行退出处理
// 覆盖它默认的逻辑,才能执行我们自己的退出代码
http.logout().logoutUrl("/securitylogout");
}
}
然后在UserService中增加获取用户权限的方法
public Collection<? extends GrantedAuthority> getAuthorities(int userId) {
User user = this.findUserById(userId);
List<GrantedAuthority> list = new ArrayList<>();
list.add(new GrantedAuthority() {
@Override
public String getAuthority() {
switch (user.getType()) {
case 1:
return AUTHORITY_ADMIN;
case 2:
return AUTHORITY_MODERATOR;
default:
return AUTHORITY_USER;
}
}
});
return list;
}
其次修改LoginTicketInterceptor中preHandle方法,在获取用户登录凭证信息时将用户认证的结果存入SecurityContext,便于spring security的授权
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// 从cookie中获取凭证
String ticket = CookieUtil.getValue(request, "ticket");
if (ticket != null) {
// 查询凭证
LoginTicket loginTicket = userService.findLoginTicket(ticket);
// 检查凭证是否有效
if (loginTicket != null && loginTicket.getStatus() == 0 && loginTicket.getExpired().after(new Date())) {
// 根据凭证查询用户
User user = userService.findUserById(loginTicket.getUserId());
// 在本次请求中持有用户
hostHolder.setUser(user);
// 构建用户认证的结果,并存入SecurityContext,以便于Security进行授权
Authentication authentication = new UsernamePasswordAuthenticationToken(
user, user.getPassword(), userService.getAuthorities(user.getId()));
SecurityContextHolder.setContext(new SecurityContextImpl(authentication));
}
}
return true;
}
并在其afterCompletion中清除SecurityContext 的信息
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
hostHolder.clear();
SecurityContextHolder.clearContext();
}
同时在LoginController的logout方法也清除SecurityContext 的信息
@GetMapping("/logout")
public String logout(@CookieValue("ticket") String ticket) {
userService.logout(ticket);
SecurityContextHolder.clearContext();
return "redirect:/login";
}
最后在HomeController中增加返回错误页面的请求方法,即访问者权限不足时返回的页面
@RequestMapping(path = "/denied", method = RequestMethod.GET)
public String getDeniedPage() {
return "/error/404";
}
在DiscussPostMapper,DiscussPostService中增加修改帖子类型和状态的方法,即置顶、加精、删除三个功能,实现起来较简单,此处省略。
在DiscussPostController中增加置顶、加精、删除的异步请求方法
// 置顶
@RequestMapping(path = "/top", method = RequestMethod.POST)
@ResponseBody
public String setTop(int id) {
discussPostService.updateType(id, 1);
// 触发发帖事件
Event event = new Event()
.setTopic(TOPIC_PUBLISH)
.setUserId(hostHolder.getUser().getId())
.setEntityType(ENTITY_TYPE_POST)
.setEntityId(id);
eventProducer.fireEvent(event);
return CommunityUtil.getJSONString(200);
}
// 加精
@RequestMapping(path = "/wonderful", method = RequestMethod.POST)
@ResponseBody
public String setWonderful(int id) {
discussPostService.updateStatus(id, 1);
// 触发发帖事件
Event event = new Event()
.setTopic(TOPIC_PUBLISH)
.setUserId(hostHolder.getUser().getId())
.setEntityType(ENTITY_TYPE_POST)
.setEntityId(id);
eventProducer.fireEvent(event);
return CommunityUtil.getJSONString(200);
}
// 删除
@RequestMapping(path = "/delete", method = RequestMethod.POST)
@ResponseBody
public String setDelete(int id) {
discussPostService.updateStatus(id, 2);
// 触发删帖事件
Event event = new Event()
.setTopic(TOPIC_DELETE)
.setUserId(hostHolder.getUser().getId())
.setEntityType(ENTITY_TYPE_POST)
.setEntityId(id);
eventProducer.fireEvent(event);
return CommunityUtil.getJSONString(200);
}
由于删帖需要同步es服务器的数据,所以需要在EventConsumer中增加消费删帖的事件
// 消费删帖事件
@KafkaListener(topics = {TOPIC_DELETE})
public void handleDeleteMessage(ConsumerRecord record) {
if (record == null || record.value() == null) {
logger.error("消息的内容为空");
return;
}
Event event = JSONObject.parseObject(record.value().toString(), Event.class);
if (event == null) {
logger.error("消息格式错误");
return;
}
elasticsearchService.deleteDiscussPost(event.getEntityId());
}
为使得不同用户拥有不同的权限,在SecurityConfig中配置用户的权限,并处理在未登录和权限不足时的请求
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter implements CommunityConstant {
@Override
public void configure(WebSecurity web) throws Exception {
web.ignoring().antMatchers("/resources/**");
}
@Override
protected void configure(HttpSecurity http) throws Exception {
// 授权
http.authorizeRequests()
.antMatchers(
"/user/setting",
"/user/upload",
"/discuss/add",
"/comment/add/**",
"/letter/**",
"/notice/**",
"/like",
"/follow",
"/unfollow"
)
.hasAnyAuthority(
AUTHORITY_USER,
AUTHORITY_ADMIN,
AUTHORITY_MODERATOR
)
.antMatchers("/discuss/top", "/discuss/wonderful")
.hasAnyAuthority(AUTHORITY_MODERATOR)
.antMatchers("/discuss/delete")
.hasAnyAuthority(AUTHORITY_ADMIN)
.anyRequest().permitAll()
.and().csrf().disable(); //关闭csrf功能
// 权限不够时的处理
http.exceptionHandling()
.authenticationEntryPoint(new AuthenticationEntryPoint() {
// 没有登录
@Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
String xRequestedWith = request.getHeader("x-requested-with");
if ("XMLHttpRequest".equals(xRequestedWith)) {
response.setContentType("application/plain;charset=utf-8");
PrintWriter writer = response.getWriter();
writer.write(CommunityUtil.getJSONString(403, "你还没登录哦"));
} else {
response.sendRedirect(request.getContextPath() + "/login");
}
}
})
.accessDeniedHandler(new AccessDeniedHandler() {
// 权限不足
@Override
public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
String xRequestedWith = request.getHeader("x-requested-with");
if ("XMLHttpRequest".equals(xRequestedWith)) {
response.setContentType("application/plain;charset=utf-8");
PrintWriter writer = response.getWriter();
writer.write(CommunityUtil.getJSONString(403, "你没有访问此功能的权限"));
} else {
response.sendRedirect(request.getContextPath() + "/denied");
}
}
});
// Security底层默认会拦截/logout请求,进行退出处理
// 覆盖它默认的逻辑,才能执行我们自己的退出代码
http.logout().logoutUrl("/securitylogout");
}
}
为了spring security能够识别用户的认证信息,在LoginTicketInterceptor中的preHandle方法里加入SecurityContext
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// 从cookie中获取凭证
String ticket = CookieUtil.getValue(request, "ticket");
if (ticket != null) {
// 查询凭证
LoginTicket loginTicket = userService.findLoginTicket(ticket);
// 检查凭证是否有效
if (loginTicket != null && loginTicket.getStatus() == 0 && loginTicket.getExpired().after(new Date())) {
// 根据凭证查询用户
User user = userService.findUserById(loginTicket.getUserId());
// 在本次请求中持有用户
hostHolder.setUser(user);
// 构建用户认证的结果,并存入SecurityContext,以便于Security进行授权
Authentication authentication = new UsernamePasswordAuthenticationToken(user, user.getPassword(), userService.getAuthorities(user.getId()));
SecurityContextHolder.setContext(new SecurityContextImpl(authentication));
}
}
return true;
}
最后修改discuss-detail.html和discuss.js文件(发送ajax请求)
$(function () {
$("#topBtn").click(setTop);
$("#wonderfulBtn").click(setWonderful);
$("#deleteBtn").click(setDelete);
});
function like(btn, entityType, entityId, entityUserId, postId) {
$.post(
CONTEXT_PATH + "/like",
{"entityType":entityType, "entityId":entityId, "entityUserId":entityUserId, "postId":postId},
function (data) {
data = $.parseJSON(data);
if (data.code == 200) {
$(btn).children("i").text(data.likeCount);
$(btn).children("b").text(data.likeStatus==1?"已赞":"赞");
} else {
alert(data.msg);
}
}
)
}
// 置顶
function setTop() {
$.post(
CONTEXT_PATH + "/discuss/top",
{"id":$("#postId").val()},
function (data) {
data = $.parseJSON(data);
if (data.code == 200) {
$("#topBtn").attr("disabled", "disabled");
} else {
alert(data.msg);
}
}
)
}
// 加精
function setWonderful() {
$.post(
CONTEXT_PATH + "/discuss/wonderful",
{"id":$("#postId").val()},
function (data) {
data = $.parseJSON(data);
if (data.code == 200) {
$("#wonderfulBtn").attr("disabled", "disabled");
} else {
alert(data.msg);
}
}
)
}
// 删除
function setDelete() {
$.post(
CONTEXT_PATH + "/discuss/delete",
{"id":$("#postId").val()},
function (data) {
data = $.parseJSON(data);
if (data.code == 200) {
location.href = CONTEXT_PATH + "/index";
} else {
alert(data.msg);
}
}
)
}
新建DataService类进行统计操作。表现层一分为二,首先是何时记录这个值,其次是查看。记录值在拦截器写比较合适。新建DataInterceptor和DataController。
返回时使用forward转发,表明当前请求仅完成一半,还需另外一个方法继续处理请求。
为了统计网站数据,我们使用redis中的HyperLogLog和BitMap两种数据结果分别统计UV和DAU。
首先在RedisKeyUtil中增加生成UV和DAU数据中键的方法
// 单日UV
public static String getUVKey(String date) {
return PREFIX_UV + SPLIT + date;
}
// 区间UV
public static String getUVKey(String startDate, String endDate) {
return PREFIX_UV + SPLIT + startDate + SPLIT + endDate;
}
// 单日活跃用户
public static String getDAUKey(String date) {
return PREFIX_DAU + SPLIT + date;
}
// 区间活跃用户
public static String getDAUKey(String startDate, String endDate) {
return PREFIX_DAU + SPLIT + startDate + SPLIT + endDate;
}
在src/main/java/com/nowcoder/community/service包下新建DataService的业务类,生成对应的数据
@Service
public class DataService {
@Autowired
private RedisTemplate redisTemplate;
private SimpleDateFormat df = new SimpleDateFormat("yyyyMMdd");
// 将指定的IP计入UV
public void recordUV(String ip) {
String redisKey = RedisKeyUtil.getUVKey(df.format(new Date()));
redisTemplate.opsForHyperLogLog().add(redisKey, ip);
}
// 统计指定日期范围内的UV
public long calculateUV(Date start, Date end) {
if (start == null || end == null) {
throw new IllegalArgumentException("参数不能为空");
}
// 整理该日期范围内的Key
List<String> keyList = new ArrayList<>();
Calendar calendar = Calendar.getInstance();
calendar.setTime(start);
while (!calendar.getTime().after(end)) {
String key = RedisKeyUtil.getUVKey(df.format(calendar.getTime()));
keyList.add(key);
calendar.add(Calendar.DATE, 1);
}
// 合并这些数据
String redisKey = RedisKeyUtil.getUVKey(df.format(start), df.format(end));
redisTemplate.opsForHyperLogLog().union(redisKey, keyList.toArray());
// 返回统计结果
return redisTemplate.opsForHyperLogLog().size(redisKey);
}
// 将指定用户计入DAU
public void recordDAU(int userId) {
String redisKey = RedisKeyUtil.getDAUKey(df.format(new Date()));
redisTemplate.opsForValue().setBit(redisKey, userId, true);
}
// 统计指定日期范围内的UV
public long calculateDAU(Date start, Date end) {
if (start == null || end == null) {
throw new IllegalArgumentException("参数不能为空");
}
// 整理该日期范围内的Key
List<byte[]> keyList = new ArrayList<>();
Calendar calendar = Calendar.getInstance();
calendar.setTime(start);
while (!calendar.getTime().after(end)) {
String key = RedisKeyUtil.getDAUKey(df.format(calendar.getTime()));
keyList.add(key.getBytes());
calendar.add(Calendar.DATE, 1);
}
// 进行or运算
return (long) redisTemplate.execute(new RedisCallback() {
@Override
public Object doInRedis(RedisConnection connection) throws DataAccessException {
String redisKey = RedisKeyUtil.getDAUKey(df.format(start), df.format(end));
connection.bitOp(RedisStringCommands.BitOperation.OR, redisKey.getBytes(), keyList.toArray(new byte[0][0]));
return connection.bitCount(redisKey.getBytes());
}
});
}
}
为了网站能够记录用户访问量,可以新建一个DataInterceptor拦截器
@Component
public class DataInterceptor implements HandlerInterceptor {
@Autowired
private DataService dataService;
@Autowired
private HostHolder hostHolder;
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// 统计UV
String ip = request.getRemoteHost();
dataService.recordUV(ip);
// 统计DAU
User user = hostHolder.getUser();
if (user != null) {
dataService.recordDAU(user.getId());
}
return true;
}
}
然后在WebMvcConfig中配置拦截器,使其生效
在src/main/java/com/nowcoder/community/controller包下新建DataController发起请求,获得访问数据
@Controller
public class DataController {
@Autowired
private DataService dataService;
// 打开统计页面
@RequestMapping(path = "/data", method = {RequestMethod.GET, RequestMethod.POST})
public String getDataPage() {
return "/site/admin/data";
}
// 处理统计网站UV
@RequestMapping(path = "/data/uv", method = RequestMethod.POST)
public String getUV(@DateTimeFormat(pattern = "yyyy-MM-dd") Date start,
@DateTimeFormat(pattern = "yyyy-MM-dd") Date end, Model model) {
long uv = dataService.calculateUV(start, end);
model.addAttribute("uvResult", uv);
model.addAttribute("uvStartDate", start);
model.addAttribute("uvEndDate", end);
return "forward:/data";
}
// 统计活跃用户
@RequestMapping(path = "/data/dau", method = RequestMethod.POST)
public String getDAU(@DateTimeFormat(pattern = "yyyy-MM-dd") Date start,
@DateTimeFormat(pattern = "yyyy-MM-dd") Date end, Model model) {
long dau = dataService.calculateDAU(start, end);
model.addAttribute("dauResult", dau);
model.addAttribute("dauStartDate", start);
model.addAttribute("dauEndDate", end);
return "forward:/data";
}
}
最后修改data.html使数据能够显示,并在SecurityConfig中配置使得只有管理员能够访问此页面
Spring Quartz(将数据存储到数据库,分布式时可以共享数据)
FactoryBean可简化Bean的实例化过程:
Nowcoder
在RedisKeyUtil中增加生成计算帖子分数所需要的键的方法
private static final String PREFIX_POST = "post";
// 帖子分数
public static String getPostScoreKey() {
return PREFIX_POST + SPLIT + "score";
}
在发帖、对帖子评论、点赞、加精时将对应的帖子的id加入到redis中
CommentController的addComment方法
@RequestMapping(path = "/add/{discussPostId}", method = RequestMethod.POST)
public String addComment(@PathVariable("discussPostId") int discussPostId, Comment comment) {
comment.setUserId(hostHolder.getUser().getId());
comment.setStatus(0);
comment.setCreateTime(new Date());
commentService.addComment(comment);
// 触发评论事件
Event event = new Event()
.setTopic(TOPIC_COMMENT)
.setUserId(hostHolder.getUser().getId())
.setEntityType(comment.getEntityType())
.setEntityId(comment.getEntityId())
.setData("postId", discussPostId);
if (comment.getEntityType() == ENTITY_TYPE_POST) {
DiscussPost targe = discussPostService.findDiscussPostById(comment.getEntityId());
event.setEntityUserId(targe.getUserId());
// 计算帖子分数
String redisKey = RedisKeyUtil.getPostScoreKey();
redisTemplate.opsForSet().add(redisKey, discussPostId);
} else if (comment.getEntityType() == ENTITY_TYPE_COMMENT) {
Comment target = commentService.findCommentById(comment.getEntityId());
event.setEntityUserId(target.getUserId());
}
eventProducer.fireEvent(event);
if (comment.getEntityType() == ENTITY_TYPE_POST) {
// 触发发帖事件
event = new Event()
.setTopic(TOPIC_PUBLISH)
.setUserId(comment.getUserId())
.setEntityType(ENTITY_TYPE_POST)
.setEntityId(discussPostId);
eventProducer.fireEvent(event);
}
return "redirect:/discuss/detail/" + discussPostId;
}
DiscussPostController的addDiscussPost方法
@RequestMapping(path = "/add", method = RequestMethod.POST)
@ResponseBody
public String addDiscussPost(String title, String content) {
User user = hostHolder.getUser();
if (user == null) {
return CommunityUtil.getJSONString(403, "你还没有登录哦");
}
DiscussPost post = new DiscussPost();
post.setUserId(user.getId());
post.setTitle(title);
post.setContent(content);
post.setCreateTime(new Date());
discussPostService.addDiscussPost(post);
// 触发发帖事件
Event event = new Event()
.setTopic(TOPIC_PUBLISH)
.setUserId(user.getId())
.setEntityType(ENTITY_TYPE_POST)
.setEntityId(post.getId());
eventProducer.fireEvent(event);
// 计算帖子分数
String redisKey = RedisKeyUtil.getPostScoreKey();
redisTemplate.opsForSet().add(redisKey, post.getId());
// 报错的情况将来统一处理
return CommunityUtil.getJSONString(200, "发布成功");
}
DiscussPostController的setWonderful方法
@RequestMapping(path = "/wonderful", method = RequestMethod.POST)
@ResponseBody
public String setWonderful(int id) {
discussPostService.updateStatus(id, 1);
// 触发发帖事件
Event event = new Event()
.setTopic(TOPIC_PUBLISH)
.setUserId(hostHolder.getUser().getId())
.setEntityType(ENTITY_TYPE_POST)
.setEntityId(id);
eventProducer.fireEvent(event);
// 计算帖子分数
String redisKey = RedisKeyUtil.getPostScoreKey();
redisTemplate.opsForSet().add(redisKey, id);
return CommunityUtil.getJSONString(200);
}
LikeController的like方法
@RequestMapping(path = "/like", method = RequestMethod.POST)
@ResponseBody
public String like(int entityType, int entityId, int entityUserId, int postId) {
User user = hostHolder.getUser();
// 点赞
likeService.like(user.getId(), entityType, entityId, entityUserId);
// 点赞数量
long likeCount = likeService.findEntityLikeCount(entityType, entityId);
// 点赞状态
int likeStatus = likeService.findEntityLikeStatus(user.getId(), entityType, entityId);
Map<String, Object> map = new HashMap<>();
map.put("likeCount", likeCount);
map.put("likeStatus", likeStatus);
// 触发点赞事件
if (likeStatus == 1) {
Event event = new Event()
.setTopic(TOPIC_LIKE)
.setUserId(user.getId())
.setEntityType(entityType)
.setEntityId(entityId)
.setEntityUserId(entityUserId)
.setData("postId", postId);
eventProducer.fireEvent(event);
}
if (entityType == ENTITY_TYPE_POST) {
// 计算帖子分数
String redisKey = RedisKeyUtil.getPostScoreKey();
redisTemplate.opsForSet().add(redisKey, postId);
}
return CommunityUtil.getJSONString(200, null, map);
}
在src/main/java/com/nowcoder/community下新建quartz包并创建PostScoreRefreshJob类来更新帖子的分数
public class PostScoreRefreshJob implements Job, CommunityConstant {
private static final Logger logger = LoggerFactory.getLogger(PostScoreRefreshJob.class);
@Autowired
private RedisTemplate redisTemplate;
@Autowired
private DiscussPostService discussPostService;
@Autowired
private LikeService likeService;
@Autowired
private ElasticsearchService elasticsearchService;
// 牛客纪元
private static final Date epoch;
static {
try {
epoch = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").parse("2014-08-01 00:00:00");
} catch (ParseException e) {
throw new RuntimeException("初始化牛客纪元失败", e);
}
}
@Override
public void execute(JobExecutionContext jobExecutionContext) throws JobExecutionException {
String redisKey = RedisKeyUtil.getPostScoreKey();
BoundSetOperations operations = redisTemplate.boundSetOps(redisKey);
if (operations.size() == 0) {
logger.info("[任务取消] 没有需要刷新的帖子");
return;
}
logger.info("[任务开始] 正在刷新帖子分数:" + operations.size());
while (operations.size() > 0) {
this.refresh((Integer) operations.pop());
}
logger.info("[任务结束] 帖子分数刷新完毕!");
}
public void refresh(int postId) {
DiscussPost post = discussPostService.findDiscussPostById(postId);
if (post == null) {
logger.error("该帖子不存在:" + postId);
return;
}
// 是否加精
boolean wonderful = post.getStatus() == 1;
// 评论数量
int commentCount = post.getCommentCount();
// 点赞数量
long likeCount = likeService.findEntityLikeCount(ENTITY_TYPE_POST, postId);
// 计算权重
double w = (wonderful ? 75 : 0) + commentCount * 10 + likeCount * 2;
// 分数 = 帖子权重 + 距离天数
double score = Math.log10(Math.max(w, 1)) + (post.getCreateTime().getTime() - epoch.getTime()) / (1000 * 3600 * 24);
// 更新帖子的分数
discussPostService.updateScore(postId, score);
// 同步搜索数据
post.setScore(score);
elasticsearchService.saveDiscussPost(post);
}
}
新建QuartzConfig配置类
@Configuration
public class QuartzConfig {
// 刷新帖子分数任务
@Bean
public JobDetailFactoryBean postScoreRefreshJobDetail() {
JobDetailFactoryBean factoryBean = new JobDetailFactoryBean();
factoryBean.setJobClass(PostScoreRefreshJob.class);
factoryBean.setName("PostScoreRefreshJob");
factoryBean.setGroup("CommunityJobGroup");
factoryBean.setDurability(true);
factoryBean.setRequestsRecovery(true);
return factoryBean;
}
@Bean
public SimpleTriggerFactoryBean postScoreRefreshTrigger(JobDetail postScoreRefreshJobDetail) {
SimpleTriggerFactoryBean factoryBean = new SimpleTriggerFactoryBean();
factoryBean.setJobDetail(postScoreRefreshJobDetail);
factoryBean.setName("postScoreRefreshTrigger");
factoryBean.setGroup("CommunityTriggerGroup");
factoryBean.setRepeatInterval(1000 * 60 * 5);
factoryBean.setJobDataMap(new JobDataMap());
return factoryBean;
}
}
为了在主页上能够根据帖子分数显示热帖排行,我们对DiscussPostMapper的selectDiscussPosts加以修改,以及其对应的discusspost-mapper.xml中的查询语句
List<DiscussPost> selectDiscussPosts(int userId, int offset, int limit, int orderMode);
<select id="selectDiscussPosts" resultType="DiscussPost">
select <include refid="selectFields">include>
from discuss_post
where status != 2
<if test="userId != 0">
and user_id = #{userId}
if>
<if test="orderMode == 0">
order by type desc, create_time desc
if>
<if test="orderMode == 1">
order by type desc, score desc, create_time desc
if>
limit #{offset}, #{limit}
select>
然后对HomeController的getIndexPage同时修改,让每次请求携带一个orderMode
@GetMapping("/index")
public String getIndexPage(Model model, Page page, @RequestParam(defaultValue = "0", name = "orderMode") int orderMode) {
//方法调用前,SpringMVC会自动实例化Model和Page,并将Page注入Model
//所以,在thymeleaf中可以直接访问Page对象中的数据
page.setRows(discussPostService.findDiscussPostRows(0));
page.setPath("/index?orderMode=" + orderMode);
List<DiscussPost> list = discussPostService.findDiscussPosts(0, page.getOffset(), page.getLimit(), orderMode);
List<Map<String, Object>> discussPosts = new ArrayList<>();
if (list != null) {
for (DiscussPost post : list) {
HashMap<String, Object> map = new HashMap<>();
map.put("post", post);
User user = userService.findUserById(post.getUserId());
map.put("user", user);
// 获取帖子点赞数量
map.put("likeCount", likeService.findEntityLikeCount(ENTITY_TYPE_POST, post.getId()));
discussPosts.add(map);
}
}
model.addAttribute("discussPosts", discussPosts);
model.addAttribute("orderMode", orderMode);
return "/index";
}
最后修改index.html,发起请求并展示数据。
使用Caffeine来优化网站性能,减少频繁地查数据
在application.Properties中配置
# caffeine
caffeine.posts.max-size=15
caffeine.posts.expire-seconds=180
使用caffeine主要用来缓存热门的帖子,对DiscussPostService进行修改并初始化缓存
@Service
public class DiscussPostService {
private static final Logger logger = LoggerFactory.getLogger(DiscussPostService.class);
@Autowired
private DiscussPostMapper discussPostMapper;
@Autowired
private SensitiveFilter sensitiveFilter;
@Value("${caffeine.posts.max-size}")
private int maxSize;
@Value("${caffeine.posts.expire-seconds}")
private int expireSeconds;
// Caffeine核心接口:Cache, LoadingCache, AsyncLoadingCache
// 帖子列表缓存
private LoadingCache<String, List<DiscussPost>> postListCache;
// 帖子总数的缓存
private LoadingCache<Integer, Integer> postRowsCache;
@PostConstruct
public void init() {
// 初始化帖子列表缓存
postListCache = Caffeine.newBuilder()
.maximumSize(maxSize)
.expireAfterWrite(expireSeconds, TimeUnit.SECONDS)
.build(new CacheLoader<String, List<DiscussPost>>() {
@Override
public @Nullable List<DiscussPost> load(@NonNull String key) throws Exception {
if (key == null || key.length() == 0) {
throw new IllegalArgumentException("参数错误");
}
String[] params = key.split(":");
if (params == null || params.length != 2) {
throw new IllegalArgumentException("参数错误");
}
int offset = Integer.valueOf(params[0]);
int limit = Integer.valueOf(params[1]);
// 二级缓存: redis -> mysql
logger.debug("load post list from DB.");
return discussPostMapper.selectDiscussPosts(0, offset, limit, 1);
}
});
// 初始化帖子总数缓存
postRowsCache = Caffeine.newBuilder()
.maximumSize(maxSize)
.expireAfterWrite(expireSeconds, TimeUnit.SECONDS)
.build(new CacheLoader<Integer, Integer>() {
@Override
public @Nullable Integer load(@NonNull Integer key) throws Exception {
logger.debug("load post rows from DB.");
return discussPostMapper.selectDiscussPostRows(key);
}
});
}
public List<DiscussPost> findDiscussPosts(int userId, int offset, int limit, int orderMode) {
if (userId == 0 && orderMode == 1) {
return postListCache.get(offset + ":" + limit);
}
logger.debug("load post list from DB.");
return discussPostMapper.selectDiscussPosts(userId, offset, limit, orderMode);
}
public int findDiscussPostRows(int userId) {
if (userId == 0) {
return postRowsCache.get(userId);
}
logger.debug("load post rows from DB.");
return discussPostMapper.selectDiscussPostRows(userId);
}
......
......
}
最后可以通过Apache JMeter来测试网站性能
affeine.newBuilder()
.maximumSize(maxSize)
.expireAfterWrite(expireSeconds, TimeUnit.SECONDS)
.build(new CacheLoader
@Override
public @Nullable Integer load(@NonNull Integer key) throws Exception {
logger.debug("load post rows from DB.");
return discussPostMapper.selectDiscussPostRows(key);
}
});
}
public List findDiscussPosts(int userId, int offset, int limit, int orderMode) {
if (userId == 0 && orderMode == 1) {
return postListCache.get(offset + ":" + limit);
}
logger.debug("load post list from DB.");
return discussPostMapper.selectDiscussPosts(userId, offset, limit, orderMode);
}
public int findDiscussPostRows(int userId) {
if (userId == 0) {
return postRowsCache.get(userId);
}
logger.debug("load post rows from DB.");
return discussPostMapper.selectDiscussPostRows(userId);
}
......
......
}
最后可以通过Apache JMeter来测试网站性能