项目进阶,构建安全高效的企业服务
1. Spring Security
- 简介
Spring Security 是一个专注于为Java应用提供身份认证和授权的框架,它的强大之处在于它可以轻松扩展以满足自定义的需求 - 特征
- 对身份的 认证 和 授权 提供全面的、可扩展的支持
- 防止各种攻击,如会话固定攻击、点击劫持、CSRF攻击等
- 支持与Servlet API、Spring MVC等Web技术集成
相关连接:官网
1.1 Spring Security 功能实现简析
Spring Security底层使用11个Filter(过滤器)来做权限控制,如果你没有登录,连DispatcherServlet都访问不了,就更不必谈Controller了。
Filter和DispatcherServlet是JavaEE标准,DispatcherServlet是SpringMVC来定义实现的,本质上还是遵循JavaEE的标准,而Interceptor(拦截器)和Controller是SpringMVC自己的。
推荐网站:Spring For All,这里有Spring Security的中文教程文档,写的还不错:社区 Spring Security 从入门到进阶系列教程
1.2 Spring Security的试用demo
不是在真正的项目上使用,而是简化的项目(即抽出一部分功能),来使用SpringSecurity来体验一下。
demo的目录结构:
- 引入依赖
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
- 修改实体类User
在原本的User类继承UserDetails 接口,重写如下方法:
User类具体代码:
package com.nowcoder.community.entity;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Date;
import java.util.List;
public class User implements UserDetails {
private int id;
private String username;
private String password;
private String salt;
private String email;
private int type;
private int status;
private String activationCode;
private String headerUrl;
private Date createTime;
public int getId() {
return id;
}
public void setId(int id) {
this.id = id;
}
public String getUsername() {
return username;
}
public void setUsername(String username) {
this.username = username;
}
public String getPassword() {
return password;
}
public void setPassword(String password) {
this.password = password;
}
public String getSalt() {
return salt;
}
public void setSalt(String salt) {
this.salt = salt;
}
public String getEmail() {
return email;
}
public void setEmail(String email) {
this.email = email;
}
public int getType() {
return type;
}
public void setType(int type) {
this.type = type;
}
public int getStatus() {
return status;
}
public void setStatus(int status) {
this.status = status;
}
public String getActivationCode() {
return activationCode;
}
public void setActivationCode(String activationCode) {
this.activationCode = activationCode;
}
public String getHeaderUrl() {
return headerUrl;
}
public void setHeaderUrl(String headerUrl) {
this.headerUrl = headerUrl;
}
public Date getCreateTime() {
return createTime;
}
public void setCreateTime(Date createTime) {
this.createTime = createTime;
}
@Override
public String toString() {
return "User{" +
"id=" + id +
", username='" + username + '\'' +
", password='" + password + '\'' +
", salt='" + salt + '\'' +
", email='" + email + '\'' +
", type=" + type +
", status=" + status +
", activationCode='" + activationCode + '\'' +
", headerUrl='" + headerUrl + '\'' +
", createTime=" + createTime +
'}';
}
// true: 账号未过期.
@Override
public boolean isAccountNonExpired() {
return true;
}
// true: 账号未锁定.
@Override
public boolean isAccountNonLocked() {
return true;
}
// true: 凭证未过期.
@Override
public boolean isCredentialsNonExpired() {
return true;
}
// true: 账号可用.
@Override
public boolean isEnabled() {
return true;
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
List<GrantedAuthority> list = new ArrayList<>();
list.add(new GrantedAuthority() {
@Override
public String getAuthority() {
switch (type) {
case 1:
return "ADMIN";
default:
return "USER";
}
}
});
return list;
}
}
- 修改UserService类
UserService继承UserDetailsService接口,重写loadUserByUsername方法
package com.nowcoder.community.service;
import com.nowcoder.community.dao.UserMapper;
import com.nowcoder.community.entity.User;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;
@Service
public class UserService implements UserDetailsService {
@Autowired
private UserMapper userMapper;
public User findUserByName(String username) {
return userMapper.selectByName(username);
}
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
return this.findUserByName(username);
}
}
- 在config包里,创建SecurityConfig类,配置Spring Security
authentication:认证
authorization:授权
package com.nowcoder.community.config;
import com.nowcoder.community.entity.User;
import com.nowcoder.community.service.UserService;
import com.nowcoder.community.util.CommunityUtil;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationProvider;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.builders.WebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.crypto.password.Pbkdf2PasswordEncoder;
import org.springframework.security.web.authentication.AuthenticationFailureHandler;
import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.security.web.authentication.logout.LogoutSuccessHandler;
import org.springframework.security.web.authentication.rememberme.InMemoryTokenRepositoryImpl;
import javax.servlet.*;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
private UserService userService;
@Override
public void configure(WebSecurity web) throws Exception {
// 忽略静态资源的访问
web.ignoring().antMatchers("/resources/**");
}
// authentication : 认证
// AuthenticationManager: 认证的核心接口.
// AuthenticationManagerBuilder: 用于构建AuthenticationManager对象的工具.
// ProviderManager: AuthenticationManager接口的默认实现类.
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
// 内置的认证规则
// auth.userDetailsService(userService).passwordEncoder(new Pbkdf2PasswordEncoder("12345"));
// 自定义认证规则
// AuthenticationProvider: ProviderManager持有一组AuthenticationProvider,每个AuthenticationProvider负责一种认证.
// 委托模式: ProviderManager将认证委托给AuthenticationProvider.
auth.authenticationProvider(new AuthenticationProvider() {
// Authentication: 用于封装认证信息的接口,不同的实现类代表不同类型的认证信息.
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
String username = authentication.getName();// 用户传入的账号
String password = (String) authentication.getCredentials();// 用户传入的密码
User user = userService.findUserByName(username);
if (user == null) {
throw new UsernameNotFoundException("账号不存在!");
}
password = CommunityUtil.md5(password + user.getSalt());
if (!user.getPassword().equals(password)) {
throw new BadCredentialsException("密码不正确!");
}
// principal: 主要信息; credentials: 证书; authorities: 权限;
return new UsernamePasswordAuthenticationToken(user, user.getPassword(), user.getAuthorities());
}
// 当前的AuthenticationProvider支持哪种类型的认证.
@Override
public boolean supports(Class<?> aClass) {
// UsernamePasswordAuthenticationToken: Authentication接口的常用的实现类.
return UsernamePasswordAuthenticationToken.class.equals(aClass);
}
});
}
//authorization:授权
@Override
protected void configure(HttpSecurity http) throws Exception {
//super.configure(http);//覆盖该方法
// 登录相关配置
http.formLogin()
.loginPage("/loginpage")//跳转到登录页面的路径,见HomeController
.loginProcessingUrl("/login")//登录表单提交的路径,见login.html页面中的form表单提交路径
//.successForwardUrl()//成功时跳转到哪里。但由于我们要除了一些逻辑,跳转时还要携带一些参数,于是使用下方的.successHandler()会更灵活。
//.failureForwardUrl()//失败时跳转到哪里。同理。
.successHandler(new AuthenticationSuccessHandler() {
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
response.sendRedirect(request.getContextPath() + "/index");
}
})
.failureHandler(new AuthenticationFailureHandler() {
@Override
public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException e) throws IOException, ServletException {
request.setAttribute("error", e.getMessage());
request.getRequestDispatcher("/loginpage").forward(request, response);
}
});
// 退出相关配置
http.logout()
.logoutUrl("/logout")
.logoutSuccessHandler(new LogoutSuccessHandler() {
@Override
public void onLogoutSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
response.sendRedirect(request.getContextPath() + "/index");
}
});
// 授权配置
http.authorizeRequests() //当用户没有登录,那么就没有任何权限
.antMatchers("/letter").hasAnyAuthority("USER", "ADMIN") //只要你拥有"USER", "ADMIN"中任何一个权限,你就可以访问私信"/letter"页面
.antMatchers("/admin").hasAnyAuthority("ADMIN")
.and().exceptionHandling().accessDeniedPage("/denied");//如果权限不匹配,就跳转到"/denied"页面
//验证码应该在账号密码处理之前先处理,如果验证码都不对,就不用看账号密码了。所以要在验证账号密码的Filter之前增加一个验证验证码的Filter验证
// 增加Filter,处理验证码
http.addFilterBefore(new Filter() {
@Override
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
HttpServletRequest request = (HttpServletRequest) servletRequest;
HttpServletResponse response = (HttpServletResponse) servletResponse;
if (request.getServletPath().equals("/login")) {
String verifyCode = request.getParameter("verifyCode");
if (verifyCode == null || !verifyCode.equalsIgnoreCase("1234")) {
request.setAttribute("error", "验证码错误!");
request.getRequestDispatcher("/loginpage").forward(request, response);
return; //如果验证码不对,请求不会继续向下执行
}
}
// 让请求继续向下执行.
filterChain.doFilter(request, response);
}
}, UsernamePasswordAuthenticationFilter.class); //新的这个new Filter,要在UsernamePasswordAuthenticationFilter这个Filter之前过滤
/*
如果勾选了"记住我",Spring Security会往浏览器里存一个cookie,cookie里存着user的用户名,
然后,关掉浏览器/关机,下次再访问时,浏览器把cookie传给服务器,服务器根据用户名和userService查出该用户user,
然后,会通过SecurityContextHolder把user存入SecurityContext中,
然后,用户访问"/index"页面时,会从SecurityContext取出user的用户名,然后显示在主页上.
*/
// 记住我
http.rememberMe()
.tokenRepository(new InMemoryTokenRepositoryImpl())//如果你想把数据存到Redis/数据库里,那么就自己实现TokenRepository接口,然后.tokenRepository(tokenRepository)这样
.tokenValiditySeconds(3600 * 24)//24小时
.userDetailsService(userService);//必须有
}
}
- index.html页面
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>首页</title>
</head>
<body>
<h1>社区首页</h1>
<!--欢迎信息-->
<p th:if="${loginUser!=null}">
欢迎你, <span th:text="${loginUser.username}"></span>!
</p>
<ul>
<li><a th:href="@{/discuss}">帖子详情</a></li>
<li><a th:href="@{/letter}">私信列表</a></li>
<li><a th:href="@{/loginpage}">登录</a></li>
<!--SpringSecurity规定,退出必须使用post请求。第一个<li>是get请求。第二个<li>是post请求,post请求必须使用form表单。--
<!--<li><a th:href="@{/loginpage}">退出</a></li>-->
<li>
<form method="post" th:action="@{/logout}">
<a href="javascript:document.forms[0].submit();">退出</a>
</form>
</li>
</ul>
</body>
</html>
- HomeController类
package com.nowcoder.community.controller;
import com.nowcoder.community.entity.User;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
@Controller
public class HomeController {
@RequestMapping(path = "/index", method = RequestMethod.GET)
public String getIndexPage(Model model) {
// 认证成功后,结果会通过SecurityContextHolder存入SecurityContext中.
Object obj = SecurityContextHolder.getContext().getAuthentication().getPrincipal();
if (obj instanceof User) {
model.addAttribute("loginUser", obj);
}
return "/index";
}
@RequestMapping(path = "/discuss", method = RequestMethod.GET)
public String getDiscussPage() {
return "/site/discuss";
}
@RequestMapping(path = "/letter", method = RequestMethod.GET)
public String getLetterPage() {
return "/site/letter";
}
@RequestMapping(path = "/admin", method = RequestMethod.GET)
public String getAdminPage() {
return "/site/admin";
}
@RequestMapping(path = "/loginpage", method = {RequestMethod.GET, RequestMethod.POST})
public String getLoginPage() {
return "/site/login";
}
// 拒绝访问时的提示页面
@RequestMapping(path = "/denied", method = RequestMethod.GET)
public String getDeniedPage() {
return "/error/404";
}
}
- login.html
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>登录</title>
</head>
<body>
<h1>登录社区</h1>
<form method="post" th:action="@{/login}">
<p style="color:red;" th:text="${error}">
<!--提示信息-->
</p>
<p>
账号:<input type="text" name="username" th:value="${param.username}">
</p>
<p>
密码:<input type="password" name="password" th:value="${param.password}">
</p>
<p>
验证码:<input type="text" name="verifyCode"> <i>1234</i>
</p>
<p>
<input type="checkbox" name="remember-me"> 记住我
</p>
<p>
<input type="submit" value="登录">
</p>
</form>
</body>
</html>
2. 权限控制
- 登录检查
- 之前此案有拦截器实现了登录检查,这是简单的权限管理方案,现在将其废弃
- 授权配置
- 对当前系统内包含所有的请求,分配访问权限(普通用户、版主、管理员)
- 认证方案
- 绕过Security认证流程,采用系统原来的认证方案
- CSRF配置
- 防止CSRF攻击的基本原理,以及表单、AJAx相关的配置
这次是在实际的项目中引入Spring Security,进行更改
注:
牛客课程助教 V 助教 回复 Eric.Lee
- Security提供了认证和授权两个功能,我们在DEMO里也做了演示,而在项目中应用时,我们并没有使用它的 认证功能,而单独的使用了它的授权功能,所以需要对认证的环节做一下特殊的处理,以保证授权的正常进行;
- Security的所有功能,都是基于Filter实现的,而Filter的执行早于Interceptor和Controller,关于Security的Filter原理,可以参考http://www.spring4all.com/article/458;
- 我们的解决方案是,在Interceptor中判断登录与否,然后人为的将认证结果添加到了SecurityContextHolder里。这里要注意,由于Interceptor执行晚于Filter,所以认证的进行依赖于前一次请求的Interceptor处理。比如,我登录成功了,然后请求自行重定向到了首页。在访问首页时,认证Filter其实没起作用,因为这个请求不需要权限,然后执行了Interceptor,此时才将认证结果加入SecurityContextHolder,这时你再访问/letter/list,可以成功,因为在这次请求里,Filter根据刚才的认证结果,判断出来你有了权限;
- 退出时,需要将SecurityContextHolder里面的认证结果清理掉,这样下次请求时,Filter才能正确识别用户的权限;
- LoginTicketInterceptor中的afterCompletion中其实不用清理SecurityContextHolder,将这句话删掉。
Eric.Lee 回复 牛客课程助教 : 那对于下一次请求,Security是通过用户请求中带的cookie找到SecurityContextHolder中的保存的对应用户信息和权限的吗?
2020-01-22 17:17:14
牛客课程助教 V 助教 : SecurityContextHolder 底层默认采用Session存数据, 而Session依赖于Cookie.
2020-02-10 12:14:55
2.1 登录检查
之前此案有拦截器实现了登录检查,这是简单的权限管理方案,现在将其废弃
废弃原来在WebMvcConfig的拦截器设置
2.2 授权配置
对当前系统内包含的所有请求,分配访问全是(普通用户、版主、管理员)
- 在CommunityConstant接口中,新增几个属性
/**
* 权限:普通用户
*/
String AUTHORITY_USER = "user";
/**
* 权限:管理员
*/
String AUTHORITY_ADMIN = "admin";
/**
* 权限:版主
*/
String AUTHORITY_MODERATOR = "moderator";
- 在config包中新建一个类SecurityConfig
package com.ateam.community.config;
import com.ateam.community.util.CommunityConstant;
import com.ateam.community.util.CommunityUtil;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.builders.WebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.security.web.access.AccessDeniedHandler;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.io.PrintWriter;
@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",
"/user/update/password",
"/discuss/add",
"/comment/add/**",
"/letter/**",
"/notice/**",
"/like",
"follow",
"/unfollow"
)
.hasAnyAuthority(
AUTHORITY_ADMIN,
AUTHORITY_MODERATOR,
AUTHORITY_USER
)
.antMatchers(
"/discuss/top",
"/discuss/wonderful"
)
.hasAnyAuthority(
AUTHORITY_MODERATOR
)
.antMatchers(
"/discuss/delete",
"/data/**",
"/actuator/**"
)
.hasAnyAuthority(
AUTHORITY_ADMIN
)
.anyRequest().permitAll() // 除了上述路径,其他请求都允许
.and().csrf().disable(); //禁用CSRF
//这里取消了Spring Security的防止csrf的功能,因为老师懒得改所有异步请求让它们都有tocken,但这个功能如果有就必须所有地方都有
// 授权不够时的处理
http.exceptionHandling()
.authenticationEntryPoint(new AuthenticationEntryPoint() { // 没有登录时的处理
// 没有登录.authenticationEntryPoint()是配没有登录时怎么处理
// 处理思路:同步请求跳转到登录页面;异步请求拼接一个json字符串返回
@Override
public void commence(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, AuthenticationException e) throws IOException, ServletException {
String xRequestedWith = httpServletRequest.getHeader("x-requested-with");//由浏览器的相应头里的字段,判断是同步还是异步请求
if ("XMLHttpRequest".equals(xRequestedWith)) {
// 是异步请求
httpServletResponse.setContentType("application/plain;charset=utf-8");
PrintWriter writer = httpServletResponse.getWriter();
writer.write(CommunityUtil.getJSONString(403,"你还没有登录"));
} else {
httpServletResponse.sendRedirect(httpServletRequest.getContextPath() + "/login");
}
}
})
.accessDeniedHandler(new AccessDeniedHandler() { // 授权不够时的处理
// 权限不足.accessDeniedHandler()是配权限不足时怎么处理
@Override
public void handle(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, AccessDeniedException e) throws IOException, ServletException {
String xRequestedWith = httpServletRequest.getHeader("x-requested-with");
if ("XMLHttpRequest".equals(xRequestedWith)) {
// 是异步请求
httpServletResponse.setContentType("application/plain;charset=utf-8");
PrintWriter writer = httpServletResponse.getWriter();
writer.write(CommunityUtil.getJSONString(403,"你没有此功能的权限!"));
} else {
httpServletResponse.sendRedirect(httpServletRequest.getContextPath() + "/denied");
}
}
});
// Security 底层默认会拦截/logout请求,进行退出处理
// 覆盖其默认的逻辑,才能执行我们自己的退出代码
http.logout().logoutUrl("securitylogout"); //让它去拦截一个我们不用的路径就可以了
}
}
- 修改UserService
添加如下方法:
public Collection<? extends GrantedAuthority> getAuthorities(int userId) {
User user = this.findUserById(userId);
ArrayList<GrantedAuthority> list = new ArrayList<>();
list.add(new GrantedAuthority() {
@Override
public String getAuthority() {
switch (user.getUserType()) {
case 1 :
return AUTHORITY_ADMIN;
case 2 :
return AUTHORITY_MODERATOR;
default:
return AUTHORITY_USER;
}
}
});
return list;
}
2.3 认证方案
绕过Security认证流程,采用系统原来的认证方案
修改LoginTicketInterceptor.java,修改了方法:preHandle、afterCompletion
package com.ateam.community.controller.interceptor;
@Component
public class LoginTicketInterceptor implements HandlerInterceptor {
@Autowired
private UserService userService;
@Autowired
private HostHolder hostHolder;
@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;
}
@Override
public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
User user = hostHolder.getUser();
if (user != null && modelAndView != null) {
modelAndView.addObject("loginUser", user);
}
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
hostHolder.clear();
//LoginController类的logout()方法中才有这句话。
//可以不要这行代码
SecurityContextHolder.clearContext();
}
}
2.4 CSRF配置
csrf是其他用户获得客户端的cookie和ticket从而访问了服务器,security可以生成TOKEN数据,是隐藏的,防止csrf攻击。
csrf攻击原理和Spring Security的解决方式。
- 对于form表单,Spring Security会自动生成防止CSFR的token
- 对于异步请求,必须自己手写防止CSFR的token
示例:
引入依赖
<!-- thymeleaf security-->
<dependency>
<groupId>org.thymeleaf.extras</groupId>
<artifactId>thymeleaf-extras-springsecurity5</artifactId>
</dependency>
在需要提交异步请求页面,加上如下代码(被注释的那2行)
index.html
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<!-- 访问该页面时,在此处产生CSRF令牌-->
<!-- <meta name="_csrf" th:content="${_csrf.token}">-->
<!-- <meta name="_csrf_header" th:content="${_csrf.headerName}">-->
<link rel="icon" href="https://static.nowcoder.com/images/logo_87_87.png"/>
<link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/css/bootstrap.min.css" crossorigin="anonymous">
<link rel="stylesheet" th:href="@{/css/global.css}" />
<title>ATeam-首页</title>
</head>
对应的js,index.js
// 发送AJAX请求之前,将CSRF令牌设置到请求的消息头中.
// var token = $("meta[name='_csrf']").attr("content");
// var header = $("meta[name='_csrf_header']").attr("content");
// $(document).ajaxSend(function(e, xhr, options){
// xhr.setRequestHeader(header, token);
// });
注:
一般需要对所有的异步请求都要配,不然就是不安全的,无法通过。
这里老师为了省事,不做CSRF配置,那么在授权的时候需要.and().csrf().disable();
3. 置顶、加精、删除
- 功能实现
- 点击“置顶”,修改帖子的类型
- 点击“加精”、“删除”,修改帖子的状态
- 权限管理
- 版主可以执行“置顶”、“加精”操作
- 管理员可以执行“删除”操作
- 按钮显示
- 版主可以看到“置顶”、“加精”按钮
- 管理员可以看到“删除”按钮
2.1 功能实现
- 数据层
在dao包下DiscussPostMapper中增加下述方法
int updateDiscussType(int id, int discussType);
int updateStatus(int id, int status);
在mapper中discuss-mapper.xml中,增加相应的SQL
<update id="updateDiscussType" >
update discuss_post
set discuss_type = #{discussType}
where id = #{id}
</update>
<update id="updateStatus">
update discuss_post
set status = #{status}
where id = #{id}
</update>
- 服务层
在service包下DiscussPostService类中新增方法
public int updateDiscussType(int id, int discussType) {
return discussPostMapper.updateDiscussType(id, discussType);
}
public int updateStatus(int id, int status) {
return discussPostMapper.updateStatus(id, status);
}
- 视图层
在controller包下DiscussPostController中,新增方法
// 置顶
@RequestMapping(value = "/top", method = RequestMethod.POST)
@ResponseBody
public String setTop(int id) {
discussPostService.updateDiscussType(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(0);
}
// 加精
@RequestMapping(value = "/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(0);
}
// 删除
@RequestMapping(value = "/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(0);
}
在event包下EventConsumer类中消费删帖事件
// 消费删帖事件
@KafkaListener(topics = {TOPIC_DELETE})
public void handleDeleteMessage(ConsumerRecord record) {
if (record == null || record.value() == null) {
logger.error("消息的内容为空!");
return;
}
// 利用fastjson将json字符串转化为Event对象
Event event = JSONObject.parseObject(record.value().toString(), Event.class);
if (event == null) {
logger.error("消息格式错误!");
return;
}
elasticsearchService.deleteDiscussPost(event.getEntityId());
}
在discuss.js文件中,为置顶、加精、删除绑定3个js单击事件。
$(function (){
$("#topBtn").click(setTop);
$("#wonderfulBtn").click(setWonderful);
$("#deleteBtn").click(setDelete);
});
// 置顶
function setTop() {
$.post(
CONTEXT_PATH + "/discuss/top",
{"id":$("#postId").val()},
function(data) {
data = $.parseJSON(data);
if(data.code == 0) {
$("#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 == 0) {
$("#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 == 0) {
location.href = CONTEXT_PATH + "/index"; //跳转到首页
} else {
alert(data.msg);
}
}
);
}
2.2 权限管理
权限管理包括两部分内容:
1、服务端要拒绝没有权限的用户访问该功能。
2、客户端要,页面上不显示该用户没有权限访问的功能。
版主可以执行“置顶”、“加精”操作, 管理员可以执行“删除”操作
在SecurityConfig下配置权限。
.antMatchers(
"/discuss/top",// 置顶
"/discuss/wonderful"// 加精
)
.hasAnyAuthority(
AUTHORITY_MODERATOR
)
.antMatchers(
"/discuss/delete",// 删除
)
.hasAnyAuthority(
AUTHORITY_ADMIN
)
版主可以执行“置顶”、“加精”按钮,管理员可以看到“删除”按钮,其他用户看不到以上按钮
在html页面上,增加xmlns:sec=“http://www.thymeleaf.org/extras/spring-security”
,在每个按钮处,引入权限控制.
修改discuss-detail.html页面
<!doctype html>
<html lang="en" xmlns:th="http://www.thymeleaf.org" xmlns:sec="http://www.thymeleaf.org/extras/spring-security">
<!--只有moderator权限的用户才能看到该按钮:sec:authorize="hasAnyAuthority('moderator')"-->
<div class="float-right">
<input type="hidden" id="postId" th:value="${post.id}">
<button type="button" class="btn btn-danger btn-sm" id="topBtn"
th:disabled="${post.discussType==1}" sec:authorize="hasAnyAuthority('moderator')">置顶</button>
<button type="button" class="btn btn-danger btn-sm" id="wonderfulBtn"
th:disabled="${post.status==1}" sec:authorize="hasAnyAuthority('moderator')">加精</button>
<button type="button" class="btn btn-danger btn-sm" id="deleteBtn"
th:disabled="${post.status==2}" sec:authorize="hasAnyAuthority('admin')">删除</button>
</div>
4. Redis高级数据类型
- HyperLogLog
- 采用一种基数算法,用于完成独立总数的统计
- 占据空间小,无论统计多少个数据,只占12K的内存空间
- 不精确的统计算法,标准误差为0.81%
- Bitmap
- 不是一种独立的数据结构,实际上就是字符串
- 支持按位存取数据,可以将其看成是byte数组
- 适合存储大量的连续的数据的布尔值
该节测试HyperLogLog和Bitmap的使用,还是在test包下RedisTests类中
package com.ateam.community;
import com.ateam.community.util.CommunityUtil;
import com.ateam.community.util.RedisKeyUtil;
import org.apache.kafka.common.protocol.types.Field;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.dao.DataAccessException;
import org.springframework.data.redis.connection.RedisConnection;
import org.springframework.data.redis.connection.RedisStringCommands;
import org.springframework.data.redis.core.*;
import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.context.junit4.SpringRunner;
import javax.swing.*;
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Calendar;
import java.util.Date;
With(SpringRunner.class)
@SpringBootTest
@ContextConfiguration(classes = CommunityApplication.class)//配置类
public class RedisTests {
@Autowired
private RedisTemplate redisTemplate;
/*HyperLogLog*/
// 统计20万个重复数据的独立总数。相当于20万访问量,你想统计总共有多少uv(unique visitor,独立IP:是指独立用户/独立访客)
// 统计20万个重复数据的独立总数
@Test
public void testHyperLogLog(){
String redis = "test:hll:01";
for (int i = 0; i < 100000; i++) {
redisTemplate.opsForHyperLogLog().add(redis,i);
}
for (int i = 0; i < 100000; i++) {
int r = (int) (Math.random() * 100000 + 1);
redisTemplate.opsForHyperLogLog().add(redis, r);
}
long size = redisTemplate.opsForHyperLogLog().size(redis);
System.out.println(size);
}
// 将3组数据合并,再统计合并后的 重复数据 的独立总数
// 相当与你知道每天的访问量数据,你想知道这3天的独立uv
@Test
public void testHyperLogLogUnion(){
String redis2 = "test:hll:02";
for (int i = 1; i <= 10000 ; i++) {
redisTemplate.opsForHyperLogLog().add(redis2,i);
}
String redis3 = "test:hll:03";
for (int i = 5001; i <= 15000 ; i++) {
redisTemplate.opsForHyperLogLog().add(redis3,i);
}
String redis4 = "test:hll:04";
for (int i = 10001; i <= 20000 ; i++) {
redisTemplate.opsForHyperLogLog().add(redis4,i);
}
String unionKey = "test:hll:union";
redisTemplate.opsForHyperLogLog().union(unionKey,redis2,redis3,redis4);
long size = redisTemplate.opsForHyperLogLog().size(unionKey);
System.out.println(size);
}
/*Bitmap*/
// 统计一组数据的布尔值。一年里,签到了就设为1,否则默认为0,然后统计一年里前到的数量。
// 统计一组数据的布尔值
@Test
public void testBitMap(){
String redisKey = "test:bm:01";
// 记录 // 未设置的默认为false
redisTemplate.opsForValue().setBit(redisKey,1,true);
redisTemplate.opsForValue().setBit(redisKey,4,true);
redisTemplate.opsForValue().setBit(redisKey,7,true);
redisTemplate.opsForValue().setBit(redisKey,9,true);
// 查询
System.out.println(redisTemplate.opsForValue().getBit(redisKey,0));
System.out.println(redisTemplate.opsForValue().getBit(redisKey,4));
System.out.println(redisTemplate.opsForValue().getBit(redisKey,7));
System.out.println(redisTemplate.opsForValue().getBit(redisKey,9));
// 统计
Object obj = redisTemplate.execute(new RedisCallback() {
@Override
public Object doInRedis(RedisConnection redisConnection) throws DataAccessException {
return redisConnection.bitCount(redisKey.getBytes());
}
});
System.out.println(obj);
}
// 统计3组数据的布尔值,并对这3组数据做OR运算
@Test
public void testBitMapOperation(){
String redisKey2 = "test:bm:02";
// 记录
redisTemplate.opsForValue().setBit(redisKey2,0,true);
redisTemplate.opsForValue().setBit(redisKey2,1,true);
redisTemplate.opsForValue().setBit(redisKey2,2,true);
String redisKey3 = "test:bm:03";
// 记录
redisTemplate.opsForValue().setBit(redisKey3,2,true);
redisTemplate.opsForValue().setBit(redisKey3,3,true);
redisTemplate.opsForValue().setBit(redisKey3,4,true);
String redisKey4 = "test:bm:04";
// 记录
redisTemplate.opsForValue().setBit(redisKey4,4,true);
redisTemplate.opsForValue().setBit(redisKey4,5,true);
redisTemplate.opsForValue().setBit(redisKey4,6,true);
String redisKey = "test:bm:or";
Object obj = redisTemplate.execute(new RedisCallback() {
@Override
public Object doInRedis(RedisConnection redisConnection) throws DataAccessException {
redisConnection.bitOp(RedisStringCommands.BitOperation.OR,
redisKey.getBytes(), redisKey2.getBytes(), redisKey3.getBytes(), redisKey4.getBytes());
return redisConnection.bitCount(redisKey.getBytes());
}
});
System.out.println(obj);
//下面输出7个true
System.out.println(redisTemplate.opsForValue().getBit(redisKey, 0));
System.out.println(redisTemplate.opsForValue().getBit(redisKey, 1));
System.out.println(redisTemplate.opsForValue().getBit(redisKey, 2));
System.out.println(redisTemplate.opsForValue().getBit(redisKey, 3));
System.out.println(redisTemplate.opsForValue().getBit(redisKey, 4));
System.out.println(redisTemplate.opsForValue().getBit(redisKey, 5));
System.out.println(redisTemplate.opsForValue().getBit(redisKey, 6));
}
5. 网站数据统计
- UV(Unique Vistor)
- 独立访客,需通过用户IP排重统计数据
- 每次访问都需要进行统计
- HyperLogLog,性能好,且访问空间小
- DAU(Daily Active User)
- 日活跃用户,通过用户ID排重统计数据
- 访问过一次,则认为其活跃
- Bitmap,性能好,且可以统计精确的结果
- 网站数据统计是基于Redis,在RedisUtil类中,新建key
public class RedisKeyUtil {
private static final String SPLIT = ":";
private static final String PREFIX_UV = "uv";
private static final String PREFIX_DAU = "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;
}
// 单日DAU
public static String getDAUKey(String date) {
return PREFIX_DAU + SPLIT + date;
}
// 区间DAU
public static String getDAUKey(String startDate, String endDate) {
return PREFIX_DAU + SPLIT + startDate + SPLIT + endDate;
}
}
- 在service包下,新建一个类DataService
package com.ateam.community.service;
import com.ateam.community.util.RedisKeyUtil;
import org.apache.kafka.common.protocol.types.Field;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.dao.DataAccessException;
import org.springframework.data.redis.connection.RedisConnection;
import org.springframework.data.redis.connection.RedisStringCommands;
import org.springframework.data.redis.core.RedisCallback;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Calendar;
import java.util.Date;
@Service
public class DataService {
@Autowired
private RedisTemplate redisTemplate;
private SimpleDateFormat sdf = new SimpleDateFormat("yyyyMMdd");
// 将指定的IP计入UV
public void recordUV(String ip) {
String redisKey = RedisKeyUtil.getUVKey(sdf.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
ArrayList<String> keyList = new ArrayList<>();
Calendar calendar = Calendar.getInstance();
calendar.setTime(start);
while (!calendar.getTime().after(end)) {
String key = RedisKeyUtil.getUVKey(sdf.format(calendar.getTime()));
keyList.add(key);
calendar.add(Calendar.DATE,1);
}
// 合并这些数据
String redisKey = RedisKeyUtil.getUVKey(sdf.format(start), sdf.format(end));
redisTemplate.opsForHyperLogLog().union(redisKey,keyList.toArray());
// 返回统计结果
return redisTemplate.opsForHyperLogLog().size(redisKey);
}
// 将指定用户计入DAU
public void recordDAU(int userId){
String redisKey = RedisKeyUtil.getDAUKey(sdf.format(new Date()));
redisTemplate.opsForValue().setBit(redisKey,userId,true);
}
// 统计指定日期范围内的DAU
public long calculateDAU(Date start, Date end) {
if (start == null || end == null) {
throw new IllegalArgumentException("参数不能为空");
}
// 整理日期范围内的key
ArrayList<byte[]> keyList = new ArrayList<>();
Calendar calendar = Calendar.getInstance();
calendar.setTime(start);
while (!calendar.getTime().after(end)) {
String key = RedisKeyUtil.getDAUKey(sdf.format(calendar.getTime()));
keyList.add(key.getBytes());
calendar.add(Calendar.DATE,1);
}
// 进行OR运算
return (long) redisTemplate.execute(new RedisCallback() {
@Override
public Object doInRedis(RedisConnection redisConnection) throws DataAccessException {
String redisKey = RedisKeyUtil.getDAUKey(sdf.format(start), sdf.format(end));
redisConnection.bitOp(RedisStringCommands.BitOperation.OR,
redisKey.getBytes(),keyList.toArray(new byte[0][0])); //转成byte[0][0]格式
return redisConnection.bitCount(redisKey.getBytes());
}
});
}
}
- 编写拦截器DataInterceptor
package com.ateam.community.controller.interceptor;
import com.ateam.community.entity.User;
import com.ateam.community.service.DataService;
import com.ateam.community.util.HostHolder;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
@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中,配置该拦截器
package com.ateam.community.config;
import com.ateam.community.controller.interceptor.*;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
public class WebMvcConfig implements WebMvcConfigurer {
@Autowired
private AlphaInterceptor alphaInterceptor;
@Autowired
private LoginTicketInterceptor loginTicketInterceptor;
// @Autowired
// private LoginRequiredInterceptor loginRequiredInterceptor;
@Autowired
private MessageInterceptor messageInterceptor;
@Autowired
private DataInterceptor dataInterceptor;
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(alphaInterceptor)
.excludePathPatterns("/**/*.css","/**/*.js","/**/*.png","/**/*.jpg","/**/*.jpeg")//通配符
.addPathPatterns("/register","/login");
registry.addInterceptor(loginTicketInterceptor)
.excludePathPatterns("/**/*.css","/**/*.js","/**/*.png","/**/*.jpg","/**/*.jpeg");//通配符
// 这是自己写的登录认证,现在由springSecurity来管理,这个废弃
// registry.addInterceptor(loginRequiredInterceptor)
// .excludePathPatterns("/**/*.css","/**/*.js","/**/*.png","/**/*.jpg","/**/*.jpeg");//通配符
registry.addInterceptor(messageInterceptor)
.excludePathPatterns("/**/*.css","/**/*.js","/**/*.png","/**/*.jpg","/**/*.jpeg");//通配符
registry.addInterceptor(dataInterceptor)
.excludePathPatterns("/**/*.css","/**/*.js","/**/*.png","/**/*.jpg","/**/*.jpeg");//通配符
}
}
- 在controller包下,新建DataController类,来处理请求
package com.ateam.community.controller;
import com.ateam.community.service.DataService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import java.util.Date;
@Controller
public class DataController {
@Autowired
private DataService dataService;
// 统计页面
@RequestMapping(value = "/data", method = {RequestMethod.GET,RequestMethod.POST})
public String getDataPage() {
return "/site/admin/data";
}
// 统计网站UV
@RequestMapping(value = "/data/uv", method = RequestMethod.POST)
//客户端传的是个日期的字符串,Spring接受这个字符串转为Date,但你要告诉它这个日期的字符串是什么格式的
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";
}
// 统计网站UV
@RequestMapping(value = "/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";
}
}
- 处理admin包下data.html页面
<!-- 内容 -->
<div class="main">
<!-- 网站UV -->
<div class="container pl-5 pr-5 pt-3 pb-3 mt-3">
<h6 class="mt-3"><b class="square"></b> 网站 UV</h6>
<form class="form-inline mt-3" method="post" th:action="@{/data/uv}">
<input type="date" class="form-control" required name="start" th:value="${#dates.format(uvStartDate,'yyyy-MM-dd')}"/>
<input type="date" class="form-control ml-3" required name="end" th:value="${#dates.format(uvEndDate,'yyyy-MM-dd')}"/>
<button type="submit" class="btn btn-primary ml-3">开始统计</button>
</form>
<ul class="list-group mt-3 mb-3">
<li class="list-group-item d-flex justify-content-between align-items-center">
统计结果
<span class="badge badge-primary badge-danger font-size-14" th:text="${uvResult}">0</span>
</li>
</ul>
</div>
<!-- 活跃用户 -->
<div class="container pl-5 pr-5 pt-3 pb-3 mt-4">
<h6 class="mt-3"><b class="square"></b> 活跃用户</h6>
<form class="form-inline mt-3" th:action="@{/data/dau}" method="post">
<input type="date" class="form-control" required name="start" th:value="${#dates.format(dauStartDate,'yyyy-MM-dd')}"/>
<input type="date" class="form-control ml-3" required name="end" th:value="${#dates.format(dauEndDate,'yyyy-MM-dd')}"/>
<button type="submit" class="btn btn-primary ml-3">开始统计</button>
</form>
<ul class="list-group mt-3 mb-3">
<li class="list-group-item d-flex justify-content-between align-items-center">
统计结果
<span class="badge badge-primary badge-danger font-size-14" th:text="${dauResult}">0</span>
</li>
</ul>
</div>
</div>
- 配置权限
我们这个数据查看页面也是需要一定的权限才能打开,所以我们需要对权限进行一个管理,如果权限不到位,无法访问,默认为管理员有权限
.antMatchers(
"/discuss/delete",
"/data/**" // 网站数据统计
)
.hasAnyAuthority(
AUTHORITY_ADMIN
)
6. 任务执行和调度
- JDK线程池
- ExecutorService
- ScheduledExecutorService
- Spring线程池
- ThreadPoolTaskExecutor
- ThreadPoolTaskScheduler
- 分布式定时任务
- Spring Quartz
- Spring Quartz
6.1 几类线程池的简介
下面4种线程池,在分布式环境下都会出现问题。因为两台服务器都是每隔x分钟执行一次,同时执行Scheduler定时任务,容易产生冲突。即使不冲突,也不应该执行两次,只执行一次就可以了。定时任务的相关数据存在服务器的内存中,多台服务器存有多份数据。
- JDK 线程池
ExcecutorService
ScheduledExecutorService - Spring 线程池
ThreadPoolTaskExecutor
ThreadPoolTaskScheduler
用Quartz实现在分布式条件下执行Scheduler定时任务,就没有问题。因为定时任务的相关数据保存在同一台数据库里,只有一份定时任务的数据。如果出现同时执行定时任务,数据库会加锁让多个服务器排队访问,不会产生冲突。并且可以一个服务器访问完数据,就改数据为"已完成",那么后进来的Quartz看到任务已完成,就不会再次完成任务了。
分布式定时任务
- Spring Quartz:网址
6.2 JDK线程池 和 Spring线程池 的测试
以下配置都是与Spring 线程池有关
在application.properties文件中
# TaskExecutionProperties
# 浏览器的访问,用这个ThreadPoolTaskExecutor,浏览器有多少访问无法预判的。
spring.task.execution.pool.core-size=5
spring.task.execution.pool.max-size=15
spring.task.execution.pool.queue-capacity=100
# TaskSchedulingProperties
# 服务器的访问,用这个ThreadPoolTaskScheduler,服务器多久执行一次任务,执行什么任务,用几个线程,这些是可以预判的。所有不需要配置core-size、max-size之类,直接写需要几个线程就好。
spring.task.scheduling.pool.size=5
在config包中新建一个了ThreadPoolConfig
package com.ateam.community.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.annotation.EnableScheduling;
@Configuration
@EnableScheduling
@EnableAsync//这个参数是为了AlphaService.java里的execute1()方法上的@Async注解生效
public class ThreadPoolConfig {//不加这个配置类(配置类的名字无所谓)和@EnableScheduling注解,那么ThreadPoolTaskScheduler就无法注入(@Autowired),即无法初始化,无法得到一个ThreadPoolTaskScheduler的对象
}
在AlphaService.java中,新建方法
@Service
public class AlphaService {
// 让该方法在多线程环境下,被异步的调用。即该方法和主线程是并发执行的
@Async
public void execute1() {
logger.debug("execute1");
}
/*@Scheduled(initialDelay = 10000, fixedRate = 1000)*/ //这两个参数默认单位为毫秒
public void execute2() {
logger.debug("execute2");
}
}
在test包下,新建测试类ThreadPoolTests
package com.ateam.community;
import com.ateam.community.service.AlphaService;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler;
import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.context.junit4.SpringRunner;
import org.springframework.web.bind.annotation.ResponseStatus;
import java.util.Date;
import java.util.concurrent.*;
@RunWith(SpringRunner.class)
@SpringBootTest
@ContextConfiguration(classes = CommunityApplication.class)
public class ThreadPoolTests {
private static final Logger logger = LoggerFactory.getLogger(ThreadPoolTests.class);
// JDK 普通线程池
private ExecutorService executorService = Executors.newFixedThreadPool(5);
// JDK 可执行定时任务的线程池
private ScheduledExecutorService scheduledExecutorService = Executors.newScheduledThreadPool(5);
// spirng 的 线程池
// 普通
@Autowired
private ThreadPoolTaskExecutor taskExecutor;
// 定时
@Autowired
private ThreadPoolTaskScheduler taskScheduler;
@Autowired
private AlphaService alphaService;
// 定时睡眠
private void sleep(long m) {
try {
Thread.sleep(m);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
// 1.JDK 普通线程池
@Test
public void testExecutorsService(){
Runnable task = new Runnable() {
@Override
public void run() {
logger.debug("Hello,ExecutorService");
}
};
for (int i = 0; i < 10 ; i++) {
executorService.submit(task);
}
sleep(10000);
}
// 2.JDK 定时任务线程池
@Test
public void testScheduledExecutorService(){
Runnable task = new Runnable() {
@Override
public void run() {
logger.debug("hello,ScheduledExecutorService");
}
};
scheduledExecutorService.scheduleAtFixedRate(task,10000,1000, TimeUnit.MILLISECONDS);
sleep(300000);
}
// spring 普通的线程池
//在application.properties里配置TaskExecutionProperties,会使得Spring普通线程池比JDK普通线程池更灵活一些
@Test
public void testThreadPoolTaskExecutor(){
Runnable task = new Runnable() {
@Override
public void run() {
logger.debug("hello,ScheduledExecutorService");
}
};
for (int i = 0; i < 10; i++) {
taskExecutor.submit(task);
}
sleep(10000);
}
// spring 定时任务的线程池
//在application.properties里配置TaskSchedulingProperties
@Test
public void testThreadPoolTaskScheduler(){
Runnable task = new Runnable() {
@Override
public void run() {
logger.debug("hello,ScheduledExecutorService");
}
};
Date startTime = new Date(System.currentTimeMillis() + 10000);
taskScheduler.scheduleAtFixedRate(task,startTime,1000);
sleep(300000);
}
//Spring普通线程池(简化)
// @Test
// public void testThreadPoolTaskExecutorSimple(){
//
//
// for (int i = 0; i < 20; i++) {
// alphaService.execute1();
// }
// sleep(10000);
// }
// Spring 定时任务线程池(简化)
//一旦执行,自动会掉alphaService.execute2()
@Test
public void testThreadPoolTaskSchedulerSimple(){
sleep(30000);
}
}
6.3 分布式定时任务的测试
分布式定时任务 - Spring Quartz
往community数据库中导入Quartz的表tables_mysql_innodb.sql。
Spring Quartz的几个接口:
Scheduler接口:Quartz核心调度工具,所有由Quartz调度的任务都是通过这个接口去调用的,不需要我们取写。
Job接口:定义一任务。其中execute()方法写明要做的事。
JobDetai接口:配置Job,名字、组、描述等配置信息。
Trigger接口:配置Job什么时候运行,以什么样的频率反复运行。
总结:Job接口定义一个任务,通过JobDetail和Trigger接口来配置这个Job。配置好以后,程序启动时,Quartz会读取配置信息,把它读到的信息立刻存到数据库里,存到那些表里。以后通过读取表来执行任务。只要数据初始化到数据库以后,JobDetail和Trigger的配置就不再使用了。就是说,JobDetail和Trigger的配置只在第一次启动时使用一下。
重要的几个表:
- 引入依赖
<!-- quartz-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-quartz</artifactId>
</dependency>
- 新建一个包quartz
在quartz包下,新建一个类AlphaJob
package com.ateam.community.quartz;
import org.quartz.Job;
import org.quartz.JobExecutionContext;
import org.quartz.JobExecutionException;
public class AlphaJob implements Job {
@Override
public void execute(JobExecutionContext context) throws JobExecutionException {
System.out.println(Thread.currentThread().getName() + ": execute a quartz job.");
}
}
- 配置Quartz
在config包下,新建一个类QuartzConfig类
package com.ateam.community.config;
import com.ateam.community.quartz.AlphaJob;
import com.ateam.community.quartz.PostScoreRefreshJob;
import org.quartz.JobDataMap;
import org.quartz.JobDetail;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.quartz.JobDetailFactoryBean;
import org.springframework.scheduling.quartz.SimpleTriggerFactoryBean;
// 配置 -> 数据库 -> 调用
//这个配置仅仅是第一次被读取到,信息被初始化到到数据库里。以后,Quartz都是访问数据库去得到这些信息,不再去访问这个配置类。前提是配置了application.properties的QuartzProperties。如果没配置,那么这些配置是存到内存中,不是存到数据库中的。
@Configuration
public class QuartzConfig {
//FactoryBean可简化Bean的实例化过程:
//1.通过FactoryBean封装Bean的实例化过程
//2.将FactoryBean装配到Spring容器中
//3.将FactoryBean注入给其他的Bean
//4.该Bean得到的是FactoryBean所管理的对象实例
// 配置JobDetail
// @Bean
public JobDetailFactoryBean alphaJobDetail() {
JobDetailFactoryBean factoryBean = new JobDetailFactoryBean();
factoryBean.setJobClass(AlphaJob.class);
factoryBean.setName("alphaJob");//不要和别的任务名字重复。
factoryBean.setGroup("alphaJobGroup");//多个任务可以同属于一组
factoryBean.setDurability(true);//任务是持久保存吗?true代表,哪怕这个任务不需要了,它的触发器都没有了,也不用删除这个任务,要一直保存着
factoryBean.setRequestsRecovery(true);//任务是不是可恢复的。
return factoryBean;
}
// 配置Trigger(SimpleTriggerFactoryBean,CronTriggerFactoryBean)
//SimpleTriggerFactoryBean能搞定每10分钟触发一次,这种简单场景。CronTriggerFactoryBean能搞定每周五晚上10点触发一次,这种复杂场景,cron表达式。
// @Bean
public SimpleTriggerFactoryBean alphaTrigger(JobDetail alphaJobDetail) {//参数JobDetail alphaJobDetail的变量名,必须和JobDetailFactoryBean的函数名字一致
SimpleTriggerFactoryBean factoryBean = new SimpleTriggerFactoryBean();
factoryBean.setJobDetail(alphaJobDetail);
factoryBean.setName("alphaTrigger");er取个名字
factoryBean.setGroup("alphaTriggerGroup");
factoryBean.setRepeatInterval(3000);//3000毫秒=3秒
factoryBean.setJobDataMap(new JobDataMap());//Trigger底层要存储Job的一些状态,你用哪个对象来存,你要指定这个对象。这里指定了默认的类型"new JobDataMap()"
return factoryBean;
}
}
在application.properties中,进行Quartz的相关配置
# 这个不配置,Quartz也会起作用,因为Spring对它做了默认的配置。使得它会读取我们在QuartzConfig中配置的JobDetail和Trigger。
# 但如果不配置这些,那么Quartz会从内存读(我们配置的)数据,而不是从数据库读数据,就会在分布式运行时出问题
# QuartzProperties
spring.quartz.job-store-type=jdbc
spring.quartz.scheduler-name=communityScheduler
spring.quartz.properties.org.quartz.scheduler.instanceId=AUTO
spring.quartz.properties.org.quartz.jobStore.class=org.quartz.impl.jdbcjobstore.JobStoreTX
spring.quartz.properties.org.quartz.jobStore.driverDelegateClass=org.quartz.impl.jdbcjobstore.StdJDBCDelegate
spring.quartz.properties.org.quartz.jobStore.isClustered=true
spring.quartz.properties.org.quartz.threadPool.class=org.quartz.simpl.SimpleThreadPool
spring.quartz.properties.org.quartz.threadPool.threadCount=5
- 在test包下,新建一个测试类QuartzTests
@RunWith(SpringRunner.class)
@SpringBootTest
@ContextConfiguration(classes = CommunityApplication.class)//配置类
public class QuartzTests {
@Autowired
private Scheduler scheduler;
@Test
public void testDeleteJob(){
try {
boolean b = scheduler.deleteJob(new JobKey("alphaJob", "alphaJobGroup"));//删除一个Job,即删除数据库里Job相关的数据。new JobKey(Job名字, Job的组的名字),这两个参数唯一的确定一个Job
System.out.println(b);
} catch (SchedulerException e) {
e.printStackTrace();
}
}
}
7. 热帖排行
如何计算帖子的分数:
一般的帖子,时间越久,量化的分数越低,而点赞和回复的数量越多,量化的分数越高。一般对点赞回复增加的分数做一个log取对数,增加刚刚发布的时候回复点赞的影响,随着时间的推移,时间的负面作用体现,分数下降,这点和实际的情况相似。
本小节将要做的2个事:
- 在发帖、点赞、评论、加精时,把该帖子id放入Redis的Set集合里,然后设置定时任务,每隔5分钟,把这些帖子id挨个取出,然后重新计算它们的分数score,然后更新数据库和elasticsearch里的discusspost的score的值。
- 修改了selectDiscussPosts()及其对应的一条线(Mapper、Dao、Service、Controller、thymeleaf页面),加了int orderMode这个参数。然后,可以按照帖子的热度排序。可以切换用"最新/热度"排序
7.1 数据层改动
在RedisUtil类中,增加获得key的方法
public class RedisKeyUtil {
private static final String SPLIT = ":";
private static final String PREFIX_POST = "post";
// 帖子分数
public static String getPostScoreKey() {
return PREFIX_POST + SPLIT + "score";
}
}
在dao包下DiscussPostMapper类中,修改selectDiscussPosts方法,增加更新分数的方法
//orderMode默认是0,表示按照时间先后来排,最新的排在前面。orderMode为1时,表示按照热度来排,就是按照帖子的分数来排。
List<DiscussPost> selectDiscussPosts(int userId, int offset, int limit, int orderMode);
int updateScore(int id, double score);
在mapper包下discuss-mappe.xml中,修改对应的SQL
<select id="selectDiscussPosts" resultType="com.ateam.community.entity.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 discuss_type desc, create_time desc
</if>
<if test="orderMode==1">
order by discuss_type desc, score desc, create_time desc
</if>
limit #{offset}, #{limit}
</select>
<update id="updateScore">
update discuss_post
set score = #{score}
where id = #{id}
</update>
7.2 定时计算帖子分数
- 发帖(帖子要有个初始分数,越新的帖子分数越高)、点赞、评论、加精,都要重新计算帖子的分数。
在DiscussPostController.java,加精,发帖,方法中,修改代码
// 加精
@RequestMapping(value = "/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(0);
}
@RequestMapping(value = "/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(0,"发布成功");
}
在CommentControlle和LikeController中,判断式评论帖子和点赞帖子,才修改,这里以点赞为例
2. 在quartz包下,新建一个类PostScoreRefreshJob,来定时刷新帖子分数
package com.ateam.community.quartz;
import com.ateam.community.entity.DiscussPost;
import com.ateam.community.service.DiscussPostService;
import com.ateam.community.service.ElasticsearchService;
import com.ateam.community.service.LikeService;
import com.ateam.community.util.CommunityConstant;
import com.ateam.community.util.RedisKeyUtil;
import org.quartz.Job;
import org.quartz.JobExecutionContext;
import org.quartz.JobExecutionException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.BoundSetOperations;
import org.springframework.data.redis.core.RedisTemplate;
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.Date;
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;
// ATeam社区纪元
private static final Date epoch;
static {
try {
epoch = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").parse("2014-09-01 00:00:00");
} catch (ParseException e) {
throw new RuntimeException("初试化ATeam纪元失败!" + e.getMessage());
}
}
@Override
public void execute(JobExecutionContext context) 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("[任务结束] 帖子分数刷洗完毕");
}
private void refresh(int postId) {
DiscussPost post = discussPostService.findDiscussPostById(postId);
if (post == null) {
logger.error("该帖子不存在:id = " + 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);
}
}
- 在config包下QuartzConfig类中,配置刚才的定时任务
package com.ateam.community.config;
import com.ateam.community.quartz.AlphaJob;
import com.ateam.community.quartz.PostScoreRefreshJob;
import org.quartz.JobDataMap;
import org.quartz.JobDetail;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.quartz.JobDetailFactoryBean;
import org.springframework.scheduling.quartz.SimpleTriggerFactoryBean;
// 配置 -> 数据库 -> 调用
@Configuration
public class QuartzConfig {
//FactoryBean可简化为Bean的实例化过程:
//1.通过FactoryBean封装Bean的实例化过程
//2.将FactoryBean装配到Spring容器中
//3.将FactoryBean注入给其他的Bean
//4.该Bean得到的是FactoryBean所管理的对象实例
// 刷新帖子分数的任务
// 配置JobDetail
@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;
}
// 配置Trigger(SimpleTriggerFactoryBean,CronTriggerFactoryBean)
@Bean
public SimpleTriggerFactoryBean postScoreRefreshTrigger(JobDetail postScoreRefreshJobDetail) {
SimpleTriggerFactoryBean factoryBean = new SimpleTriggerFactoryBean();
factoryBean.setJobDetail(postScoreRefreshJobDetail);
factoryBean.setName("postScoreRefreshTrigger");
factoryBean.setGroup("communityTriggerGroup");
factoryBean.setRepeatInterval(1000 * 60 * 60); // 1h刷新一次
factoryBean.setJobDataMap(new JobDataMap());
return factoryBean;
}
}
- 修改有使用到selectDiscussPosts方法的代码
找到那些地方使用过selectDiscussPosts方法,然后逐一修改。
- 最新/最热
先去HomeController再去index.html。刷新页面时,没有orderMode参数,于是要在Controller里把orderMode默认设为0,然后传给thymeleaf页面,于是显示"最新"。
@RequestMapping(value = "/index", method = RequestMethod.GET)
public String getIndexPage(Model model, Page page,
@RequestParam(name = "orderMode", defaultValue = "0") 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);
ArrayList<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);
// 获取每个帖子赞的数量
long likeCount = likeService.findEntityLikeCount(ENTITY_TYPE_POST,post.getId());
map.put("likeCount", likeCount);
discussPosts.add(map);
}
}
model.addAttribute("discussPosts",discussPosts);
model.addAttribute("orderMode",orderMode);
return "/index";
}
index.html
8. 生成长图
- wkhtmltopdf
- wkhtmltopdf url file
- wkhtmltoimage url file
- java
- Runtime.getRuntime().exec()
- Runtime.getRuntime().exec()
8.1 wkhtmltopdf
wkhtmltopdf目录结构:(wkhtmltopdf网址)
配置环境变量
- 命令行使用wk
# 实际应该在路径“D:\wkhtmltopdf\bin”来执行软件wkhtmltopdf 的命令,因为我们在环境变量中配置了该路径,于是就在哪里都可以输入该命令。
# 把网页转为pdf,存到文件夹里,文件夹不会自动生成,需要你手动创建,生成的pdf文件需要你自己命名。
C:\Users\dell>wkhtmltopdf https://www.nowcoder.com E:/data/wk/wk-pdfs/1.pdf
# 把网页转为图片
C:\Users\dell>wkhtmltoimage https://www.nowcoder.com E:\打码相关软件\wkhtmltopdf\my-data\wk-images\E:/data/wk/wk-images/1.png
C:\Users\dell>wkhtmltoimage --quality 75 https://www.nowcoder.com E:/data/wk/wk-images/2.png # --quality 75,表示把图片压缩到原有质量的75%,这样做是为了减小图片所占用的空间(MB)
- 使用java调用wk
Runtime.getRuntime.exec()
在test包下,新建一个类WKTests
public class WKTests {
public static void main(String[] args){
String cmd = "D:\\wkhtmltopdf\\bin\\wkhtmltoimage --quality 75 http://localhost:8080/community/index E:\\data\\wk\\wk-images\\4.png";
try {
//Runtime执行命令,只是把命令提交给本地的操作系统,剩下的事由操作系统来执行。Java不会等操作系统,Java会直接执行下一行。于是会先输出ok,后生成图片。
//即main函数和生成图片是异步的,是并发的.
Runtime.getRuntime().exec(cmd);
System.out.println("ok.");
} catch (IOException e) {
e.printStackTrace();
}
}
}
8.2 项目实现生成长图
实现的功能:
- 把网页(根据url) 生成长图 保存到本地:http://localhost:8080/community/share?htmlUrl=https://www.nowcoder.com
- 把本地的图片通过一个url展现到网页上:http://localhost:8080/community/share/image/图片不包括后缀名的图片名(即UUID)
wk相关配置:
application.properties
# wk
# 网页转pdf/图片 #这两个是我们自定义的配置,因为这两个路径在上线前后会路径不一样,所以要做成可配置的路径
#上线后,wkhtmltopdf软件的wkhtmltoimage命令的安装路径
wk.image.command=D:/wkhtmltopdf/bin/wkhtmltoimage
#上线后,wkhtmltopdf软件生成的图片的存放位置
wk.image.storage=E:/data/wk/wk-images
在config包下,新建一个类WKConfig
//这里的@Configuration不是为了配置,而是使得该类在程序开始执行时就初始化该类为Bean,然后,为了在程序一开始就执行一次init()方法.
@Configuration
public class WKConfig {
private static final Logger logger = LoggerFactory.getLogger(WKConfig.class);
@Value("${wk.image.storage}")
private String wkImageStorage;
//Spring的@PostConstruct注解在方法上,表示此方法是在Spring实例化该Bean之后马上执行此方法,之后才会去实例化其他Bean,并且一个Bean中@PostConstruct注解的方法可以有多个。
@PostConstruct
public void init() {
// 创建WK图片目录
File file = new File(wkImageStorage);
if (!file.exists()) {
file.mkdir();
logger.info("创建WK图片目录:" + wkImageStorage);
}
}
}
在controller包下,新建一个类ShareController类
@Controller
public class ShareController implements CommunityConstant {
private static final Logger logger = LoggerFactory.getLogger(ShareController.class);
@Autowired
private EventProducer eventProducer;
@Value("${community.path.domain}")
private String domain;
@Value("${server.servlet.context-path}")
private String contextPath;
@Value("${wk.image.storage}")
private String wkImageStorage;
@RequestMapping(value = "share", method = RequestMethod.GET)
@ResponseBody
public String share(String htmlUrl) {
// 文件名
String fileName = CommunityUtil.generateUUID();
// 事件
// 异步生成长图
Event event = new Event()
.setTopic(TOPIC_SHARE)
.setData("htmlUrl",htmlUrl)
.setData("fileName",fileName)
.setData("suffix",".png");
eventProducer.fireEvent(event);
Map<String, Object> map = new HashMap<>();
map.put("shareUrl", domain + contextPath + "/share/image/" + fileName);
return CommunityUtil.getJSONString(0,null, map);
}
// 获取长图
@RequestMapping(value = "/share/image/{fileName}", method = RequestMethod.GET)
public void getShareImage(@PathVariable("fileName") String fileName, HttpServletResponse response) {
if (StringUtils.isBlank(fileName)) {
throw new IllegalArgumentException("文件名不能为空!");
}
response.setContentType("image/png");
File file = new File(wkImageStorage + "/" + fileName + ".png");
try (ServletOutputStream os = response.getOutputStream();
FileInputStream fis = new FileInputStream(file)
) {
byte[] buffer = new byte[1024];
int len = 0;
while ((len = fis.read(buffer)) != -1) {
os.write(buffer,0,len);
}
} catch (IOException e) {
logger.error("获取图片失败:" + e.getMessage());
}
}
}
在EventConsumer类中,新增消费分享事件
@Component
public class EventConsumer implements CommunityConstant {
private static final Logger logger = LoggerFactory.getLogger(Event.class);
@Autowired
private MessageService messageService;
@Autowired
private DiscussPostService discussPostService;
@Autowired
private ElasticsearchService elasticsearchService;
@Value("${wk.image.storage}")
private String wkImageStorage;
@Value("${wk.image.command}")
private String wkImageCommand;
// 消费分享事件
@KafkaListener(topics = {TOPIC_SHARE})
public void handleShareMessage(ConsumerRecord record) {
if (record == null || record.value() == null) {
logger.error("消息的内容为空!");
return;
}
// 利用fastjson将json字符串转化为Event对象
Event event = JSONObject.parseObject(record.value().toString(), Event.class);
if (event == null) {
logger.error("消息格式错误!");
return;
}
String htmlUrl = (String) event.getData().get("htmlUrl");
String fileName = (String) event.getData().get("fileName");
String suffix = (String) event.getData().get("suffix");
String cmd = wkImageCommand + " --quality 75 "
+ htmlUrl + " " + wkImageStorage + "/" + fileName + suffix;
try {
Runtime.getRuntime().exec(cmd);
logger.info("生成长图成功:" + cmd);
} catch (IOException e) {
logger.error("生成长图失败:" + e.getMessage());
}
}
}
9. 优化网站性能
只有本地缓存和DB(数据库)时。如果是热门帖子这种数据,两个服务器都从数据库取出热门帖子,然后更新到本地缓存里,同一份数据在两个服务器都存了一份,这没有问题。如果是和用户相关的问题,比如用户登录凭证,在服务器1上用户是登录状态,在服务器2上的本地缓存里没有该凭证,用户就没有登录状态了,这就不行了。
这种情况,可以用Redis解决,两个服务器都从Redis里获取用户的登录状态。
本地缓存空间小,Redis缓存空间比较大。大部分请求会被这两级拦截下来。
如果本地缓存和Redis里都没有请求的数据,那么会从数据库里取得数据,然后送到app(service组件)里,然后再从app把从db取到的数据更新到本地缓存和Redis。
缓存的数据淘汰机制是,基于时间和使用频率来淘汰。
变化不那么频繁的数据,我们才使用缓存。如果是帖子的最新,那不能缓存,更新太快,所以这节课来缓存帖子的最热排序,因为这个排序是一段时间才更新一次分数的,所以在两次更新分数之间,帖子的热门排名是不变的。
- 导入依赖
<!-- caffeine-->
<dependency>
<groupId>com.github.ben-manes.caffeine</groupId>
<artifactId>caffeine</artifactId>
<version>2.7.0</version>
</dependency>
- caffeine配置
# caffeine #都是自定义的配置
# posts是帖子的意思,如果要缓存评论可以caffeine.comments
# 第一个表示缓存15页帖子, 第二个表示,存到缓存里的数据到3分钟,自动就会被清理掉,这叫自动淘汰。 还有一种主动淘汰,是帖子更新了,会淘汰掉缓存中的这个帖子。
# 这里只有自动淘汰,没有主动淘汰,因为我们是一页一页缓存的,如果一个帖子更新了,把这一页的帖子都刷掉,不合适
# 就是说,这一页帖子,评论点赞之类的数量,在这3分钟内会有一定延迟,和真实数量对不上,但不影响使用。
caffeine.posts.max-size=15
caffeine.posts.expire-seconds=180
- 测试caffeine
在test包下,新建一个测试类CaffeineTests
@RunWith(SpringRunner.class)
@SpringBootTest
@ContextConfiguration(classes = CommunityApplication.class)//配置类
public class CaffeineTests {
@Autowired
private DiscussPostService discussPostService;
@Test
public void initDataForTest() {
for (int i = 0; i < 300000; i++) {
DiscussPost post = new DiscussPost();
post.setUserId(111);
post.setTitle("互联网求职暖春计划");
post.setContent("今年的就业形势,确实不容乐观。过了个年,仿佛跳水一般,整个讨论区哀鸿遍野!19届真的没人要了吗?!18届被优化真的没有出路了吗?!大家的“哀嚎”与“悲惨遭遇”牵动了每日潜伏于讨论区的牛客小哥哥小姐姐们的心,于是牛客决定:是时候为大家做点什么了!为了帮助大家度过“寒冬”,牛客网特别联合60+家企业,开启互联网求职暖春计划,面向18届&19届,拯救0 offer!");
post.setCreateTime(new Date());
post.setScore(Math.random() * 2000);
discussPostService.addDiscussPost(post);
}
}
@Test
public void testCache(){
System.out.println(discussPostService.findDiscussPosts(0,0,10,1));
System.out.println(discussPostService.findDiscussPosts(0,0,10,1));
System.out.println(discussPostService.findDiscussPosts(0,0,10,1));
System.out.println(discussPostService.findDiscussPosts(0,0,10,0));
}
}
- 项目中运用caffeine
在service包下,修改DiscussPostService类中的相关代码
@Service
public class DiscussPostService {
private static final Logger logger = LoggerFactory.getLogger(DiscussPostService.class);
@Resource
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
// LoadingCache:同步缓存,如果缓存内没有,要来读取的线程排队等待,Caffeine去把数据取到缓存里,然后挨个去读取缓存里的这个数据。我们用这个。
// AsyncLoadingCache:异步缓存,支持多个线程并发的同时读取同一数据
// 缓存都是按照key缓存value
// 帖子列表缓存
private LoadingCache<String, List<DiscussPost>> postListCache;
// 帖子总数缓存
private LoadingCache<Integer, Integer> postRowsCache;
@PostConstruct
private void init() {
// 初试化帖子列表缓存
postListCache = Caffeine.newBuilder()
.maximumSize(maxSize)
.expireAfterAccess(expireSeconds, TimeUnit.SECONDS)
.build(new CacheLoader<String, List<DiscussPost>>() {
//当尝试从缓存中取数据的时候,caffeine会看看缓存里有没有数据,有就返回,没有就调用下面的load()方法从数据库中取出该数据
//所以load()方法要告诉caffeine怎么从数据库中取得该数据
@Override
public @Nullable List<DiscussPost> load(@NonNull String key) throws Exception {
if (key.length() == 0) {
throw new IllegalArgumentException("参数错误");
}
String[] params = key.split(":");
if (params == null || params.length != 2) {
throw new IllegalArgumentException("参数错误");
}
int offset = Integer.parseInt(params[0]);
int limit = Integer.parseInt(params[1]);
// 二级缓存:Redis - > mysql
logger.debug("load post list from DB");
return discussPostMapper.selectDiscussPosts(0,offset,limit,1);
}
});
// 初试化帖子总数缓存
postRowsCache = Caffeine.newBuilder()
//这里本来应该单独在application.properties里配置的,
//如:caffeine.posts-count.max-size和caffeine.posts-count.expire-seconds。但老师懒的单独配,复用缓存帖子的也不出错,所以这样了。
.maximumSize(maxSize)
.expireAfterAccess(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) {
//userId和orderMode是一定的,那么就把剩下的两个变化的量组合为key,中间用什么隔开都可以,如用冒号隔开
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) {
//这里其实不需要userId作为key,因为这里的userId永远是0,但是又必须要有key,所以只能这样,一直用0作为key。
return postRowsCache.get(userId);
}
logger.debug("load post rows from DB");
return discussPostMapper.selectDiscussPostRows(userId);
}
}
- 性能测试
可以使用压力测试工具模拟多个用户同时访问服务器,进行测试。
推荐使用Jmeter:Apache JMeter - Download Apache JMeter网址