文章目录
- 第七章、项目进阶,构建安全高效的企业服务
- 7.1、Spring Security(原型项目上加Spring Security,不是在真正的项目上更改)
- 7.3、权限控制(这是在真正的项目上更改,增加了SpringSecurity)
- 7.5、置顶、加精、删除
- 7.8、Redis高级数据类型(测试HyperLogLog和Bitmap用Java语言的使用,未修改项目代码)
- 7.10、网站数据统计(项目中使用:HyperLogLog和Bitmap用Java语言的使用)
- 7.13、任务执行和调度(只测试JDK 线程池、Spring 线程池、分布式定时任务 - Spring Quartz,未修改项目代码)
- 7.16、热帖排行
- 7.19、生成 长图(chang tu)
- 7.23、将文件上传至云服务器
- 7.27、优化网站的性能
第七章、项目进阶,构建安全高效的企业服务
7.1、Spring Security(原型项目上加Spring Security,不是在真正的项目上更改)
Spring Security底层用11个Filter来做权限控制之类,如果你没登录,你连DispatcherServlet都访问不了,就更不用说Controller了。
Filter和DispatcherServlet都是JavaEE的标准,是由SpringMVC实现的。Interpector和Controller是SpringMVC自己的。
老师建议研究SpringSecurity的源码,因为1、这个组件在SpringCloud、SpringBoot,即Spring家族中都能用,研究它,你会扩展的研究其他组件。2、它很复杂,在Spring家族中复杂程度靠前,是块硬骨头,建议先啃。
老师推荐这个网站Spring For All 玩最纯粹的技术!做最专业的 Spring 民间组织~学SpringSecurity:社区 Spring Security 从入门到进阶系列教程 | Spring For All
因为是中文的,而且文章质量普遍高。
研究源码要打断点跟一下流程。但不要每个源码都跟,光跟一下核心类就好。
比如SpringSecurity中的几个核心Filter。
重定向:
转发:
区别:
1、
前者是两个独立的功能,没有耦合,比如删除某帖子,然后重定向到主页,即查询所有帖子。
后者是需要两个组件实现一个请求,有耦合,例如图片中例子,登录表单提交到“/login”,然后发现登录失败,就携带错误信息转发给去模板页面的地址“/loginpage”,如果在Controller里,是可以把参数放model里直接转给模板的,但是http.formLogin().failureHandler()不在Controller里。转发比转给模板更灵活,因为可以复用“/loginpage”里的一些逻辑
2、地址栏的地址显示不同
SecurityConfig.java类
注释:
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")//登录表单提交到哪个路径的Controller
//.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);//验证码对了,才不会return,才会走到这里,请求才会继续向下执行.
}
}, 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代码
<!--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>
部分HomeController.java代码
@RequestMapping(path = "/index", method = RequestMethod.GET)
public String getIndexPage(Model model) {
// 认证成功后,结果会通过SecurityContextHolder存入SecurityContext中.
// return new UsernamePasswordAuthenticationToken(user, user.getPassword(), user.getAuthorities());这是存入的东西,取出principal会取出user
Object obj = SecurityContextHolder.getContext().getAuthentication().getPrincipal();
if (obj instanceof User) {
model.addAttribute("loginUser", obj);
}
return "/index";
}
login.html部分代码
<form method="post" th:action="@{/login}">
<p style="color:#ff0000;" th:text="${error}">
<!--提示信息-->
</p>
<!--name=""中的:username,password,remember-me.这三个名字是SpringSecurity固定的.-->
<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>
7.3、权限控制(这是在真正的项目上更改,增加了SpringSecurity)
牛客课程助教 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,将这句话删掉。
2020-01-21 10:26:54
Eric.Lee 回复 牛客课程助教 : 那对于下一次请求,Security是通过用户请求中带的cookie找到SecurityContextHolder中的保存的对应用户信息和权限的吗?
2020-01-22 17:17:14
牛客课程助教 V 助教 : SecurityContextHolder 底层默认采用Session存数据, 而Session依赖于Cookie.
2020-02-10 12:14:55
SecurityConfig.java
package com.nowcoder.community.config;
import com.nowcoder.community.util.CommunityConstant;
import com.nowcoder.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",
"/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",
"/data/**"
)
.hasAnyAuthority(
AUTHORITY_ADMIN
)
.anyRequest().permitAll()
//这里取消了Spring Security的防止csrf的功能,因为老师懒得改所有异步请求让它们都有tocken,但这个功能如果有就必须所有地方都有,
// 否则浏览器会认为你这里没有tocken,是个csrf攻击,导致无法访问服务器。
.and().csrf().disable();
// 权限不够时的处理
http.exceptionHandling()
.authenticationEntryPoint(new AuthenticationEntryPoint() {
// 没有登录.authenticationEntryPoint()是配没有登录时怎么处理
// 处理思路:同步请求跳转到登录页面,异步请求拼接一个json字符串返回
@Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException e) 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");//这里是get请求,因为默认是get。因为跳转到登录页面和提交表单的路径都是/login,只是请求方式有区别
}
}
})
.accessDeniedHandler(new AccessDeniedHandler() {
// 权限不足.accessDeniedHandler()是配权限不足时怎么处理
@Override
public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException e) 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");//让它去拦截一个我们不用的路径就可以了
}
}
修改了LoginTicketInterceptor.java,只列出了修改的两个方法。
@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 afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
hostHolder.clear();
//SecurityContextHolder.clearContext(); //LoginController类的logout()方法中才有这句话。
}
}
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;
}
csrf攻击原理和Spring Security的解决方式。
对于form表单,Spring Security会自动生成防止csrf的tocken。
但对于异步请求,必须自己手写防止csrf的tocken。
老师要求自己实现Spring Security的防止csrf的tocken。
index.html
<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}">-->
index.js
// 发送AJAX请求之前,将CSRF令牌设置到请求的消息头中.
//这里应该不注释然后所有异步请求代码都有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);
// });
7.5、置顶、加精、删除
权限管理包括两部分内容,1、服务端要拒绝没有权限的用户访问该功能。2、客户端要,页面上不显示该用户没有权限访问的功能。
discuss-detail.html,只写了增加的那部分
<html lang="en" xmlns:th="http://www.thymeleaf.org" xmlns:sec="http://www.thymeleaf.org/extras/spring-security">
<div class="float-right">
<!--只有moderator权限的用户才能看到该按钮:sec:authorize="hasAnyAuthority('moderator')"-->
<input type="hidden" id="postId" th:value="${post.id}">
<button type="button" class="btn btn-danger btn-sm" id="topBtn"
th:disabled="${post.type==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>
discuss.js,只写了增加的那部分
$(function(){/*jQuery的写法,和js中的Window.onload是一样的,都是在html页面加载完成以后,把按钮和点击事件绑定*/
$("#topBtn").click(setTop);
$("#wonderfulBtn").click(setWonderful);
$("#deleteBtn").click(setDelete);
});
// 置顶
function setTop() {
$.post(
CONTEXT_PATH + "/discuss/top",
{"id":$("#postId").val()},
function(data) {
data = $.parseJSON(data);//返回来的data是个JSON格式的普通字符串,可以解析成js对象
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);
}
}
);
}
SecurityConfig.java增加的部分
.antMatchers(
"/discuss/top",//置顶
"/discuss/wonderful"//加精
)
.hasAnyAuthority(
AUTHORITY_MODERATOR
)
.antMatchers(
"/discuss/delete",//删除
"/data/**"
)
.hasAnyAuthority(
AUTHORITY_ADMIN
)
7.8、Redis高级数据类型(测试HyperLogLog和Bitmap用Java语言的使用,未修改项目代码)
这节课测试HyperLogLog和Bitmap用Java语言的使用,下节课把它们整合到项目中。
RedisTests.java,只写了增加的几个方法。
package com.nowcoder.community;
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 java.util.concurrent.TimeUnit;
@RunWith(SpringRunner.class)
@SpringBootTest
@ContextConfiguration(classes = CommunityApplication.class)
public class RedisTests {
@Autowired
private RedisTemplate redisTemplate;
/*HyperLogLog*/
// 统计20万个重复数据的独立总数。相当于20万访问量,你想统计总共有多少uv(unique visitor,独立IP:是指独立用户/独立访客)
@Test
public void testHyperLogLog() {
String redisKey = "test:hll:01";
for (int i = 1; i <= 100000; i++) {
redisTemplate.opsForHyperLogLog().add(redisKey, i);
}
for (int i = 1; i <= 100000; i++) {
int r = (int) (Math.random() * 100000 + 1);
redisTemplate.opsForHyperLogLog().add(redisKey, r);
}
long size = redisTemplate.opsForHyperLogLog().size(redisKey);
System.out.println(size);
}
// 将3组数据合并, 再统计合并后的重复数据的独立总数。相当与你知道每天的访问量数据,你想知道这3天的独立uv
@Test
public void testHyperLogLogUnion() {
String redisKey2 = "test:hll:02";
for (int i = 1; i <= 10000; i++) {
redisTemplate.opsForHyperLogLog().add(redisKey2, i);
}
String redisKey3 = "test:hll:03";
for (int i = 5001; i <= 15000; i++) {
redisTemplate.opsForHyperLogLog().add(redisKey3, i);
}
String redisKey4 = "test:hll:04";
for (int i = 10001; i <= 20000; i++) {
redisTemplate.opsForHyperLogLog().add(redisKey4, i);
}
String unionKey = "test:hll:union";
redisTemplate.opsForHyperLogLog().union(unionKey, redisKey2, redisKey3, redisKey4);//可以放多组数据,不只3组,也可以传入一个String的数组,里面放多个key,unionKey汇总这多组数据的值
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);
// 查询
System.out.println(redisTemplate.opsForValue().getBit(redisKey, 0));//false //下标从0开始
System.out.println(redisTemplate.opsForValue().getBit(redisKey, 1));//true
System.out.println(redisTemplate.opsForValue().getBit(redisKey, 2));//false
// 统计
Object obj = redisTemplate.execute(new RedisCallback() {
@Override
public Object doInRedis(RedisConnection connection) throws DataAccessException {
return connection.bitCount(redisKey.getBytes());
}
});
System.out.println(obj);//3
}
// 统计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 connection) throws DataAccessException {
connection.bitOp(RedisStringCommands.BitOperation.OR,
redisKey.getBytes(), redisKey2.getBytes(), redisKey3.getBytes(), redisKey4.getBytes());
return connection.bitCount(redisKey.getBytes());
}
});
System.out.println(obj);//7
//下面输出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));
}
}
7.10、网站数据统计(项目中使用:HyperLogLog和Bitmap用Java语言的使用)
RedisKeyUtil.java
package com.nowcoder.community.util;
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;
}
// 单日活跃用户
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;
}
}
DataService.java
package com.nowcoder.community.service;
import com.nowcoder.community.util.RedisKeyUtil;
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;
import java.util.List;
@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);
}
// 统计指定日期范围内的DAU
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]));//keyList.toArray(new byte[0][0])表示要把keyList转成一个二维的byte数组
return connection.bitCount(redisKey.getBytes());
}
});
}
}
DataInterceptor.java
package com.nowcoder.community.controller.interceptor;
import com.nowcoder.community.entity.User;
import com.nowcoder.community.service.DataService;
import com.nowcoder.community.util.HostHolder;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;
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();//如127.0.0.1
dataService.recordUV(ip);
// 统计DAU
User user = hostHolder.getUser();
if (user != null) {
dataService.recordDAU(user.getId());
}
return true;
}
}
DataController.java
package com.nowcoder.community.controller;
import com.nowcoder.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(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,
//客户端传的是个日期的字符串,Spring接受这个字符串转为Date,但你要告诉它这个日期的字符串是什么格式的
@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";//post请求转发给"/data",还是post请求,所以上面的"/data"的方法要有RequestMethod.POST,即能处理post请求
//当然,这里也可以return "/site/admin/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";
}
}
/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" method="post" th:action="@{/data/dau}">
<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>
7.13、任务执行和调度(只测试JDK 线程池、Spring 线程池、分布式定时任务 - Spring Quartz,未修改项目代码)
这节课是测试几种线程池:JDK 线程池、Spring 线程池、分布式定时任务 - Spring Quartz,没有往项目中加功能之类的代码,只加了测试的代码。
下面4种线程池,在分布式环境下都会出现问题。因为两台服务器都是每隔x分钟执行一次,同时执行Scheduler定时任务,容易产生冲突。即使不冲突,也不应该执行两次,只执行一次就可以了。定时任务的相关数据存在服务器的内存中,多台服务器存有多份数据。
JDK 线程池
- ExcecutorService
- ScheduledExecutorService
Spring 线程池
- ThreadPoolTaskExecutor
- ThreadPoolTaskScheduler
用Quartz实现在分布式条件下执行Scheduler定时任务,就没有问题。因为定时任务的相关数据保存在同一台数据库里,只有一份定时任务的数据。如果出现同时执行定时任务,数据库会加锁让多个服务器排队访问,不会产生冲突。并且可以一个服务器访问完数据,就改数据为"已完成",那么后进来的Quartz看到任务已完成,就不会再次完成任务了。
分布式定时任务
- Spring Quartz
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
ThreadPoolConfig.java
package com.nowcoder.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。测试JDK 线程池 和 Spring 线程池 的类。
@Service
public class AlphaService {
// 让该方法在多线程环境下,被异步的调用。即该方法和主线程是并发执行的
@Async
public void execute1() {
logger.debug("execute1");
}
/*@Scheduled(initialDelay = 10000, fixedRate = 1000)*/ //这两个参数默认单位为毫秒
public void execute2() {
logger.debug("execute2");
}
}
ThreadPoolTests.java
package com.nowcoder.community;
import com.nowcoder.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.annotation.Async;
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 java.util.Date;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
@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);
// Spring普通线程池
@Autowired
private ThreadPoolTaskExecutor taskExecutor;
// Spring可执行定时任务的线程池
@Autowired
private ThreadPoolTaskScheduler taskScheduler;
@Autowired
private AlphaService alphaService;
private void sleep(long m) {//这个m是毫秒。封装这个方法是为了在这里捕获异常,就不用在下面每个方法中都要抛出/捕获异常了
try {
Thread.sleep(m);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
// 1.JDK普通线程池
@Test
public void testExecutorService() {
Runnable task = new Runnable() {
@Override
public void run() {
logger.debug("Hello ExecutorService");
}
};
for (int i = 0; i < 10; i++) {
executorService.submit(task);
}
// Thread thread=new Thread(task);
// thread.start();
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);//执行多次,执行前延迟10秒,每隔一秒执行一次,时间单位毫秒
scheduledExecutorService.scheduleWithFixedDelay(task,10000,1000,TimeUnit.MILLISECONDS);//执行一次,执行前延迟10秒.
sleep(30000);
}
// 3.Spring普通线程池
//在application.properties里配置TaskExecutionProperties,会使得Spring普通线程池比JDK普通线程池更灵活一些
@Test
public void testThreadPoolTaskExecutor() {
Runnable task = new Runnable() {
@Override
public void run() {
logger.debug("Hello ThreadPoolTaskExecutor");
}
};
for (int i = 0; i < 10; i++) {
taskExecutor.submit(task);
}
sleep(10000);
}
// 4.Spring定时任务线程池
//在application.properties里配置TaskSchedulingProperties
@Test
public void testThreadPoolTaskScheduler() {
Runnable task = new Runnable() {
@Override
public void run() {
logger.debug("Hello ThreadPoolTaskScheduler");
}
};
Date startTime = new Date(System.currentTimeMillis() + 10000);
taskScheduler.scheduleAtFixedRate(task, startTime, 1000);//这个方法默认以毫秒为单位
sleep(30000);
}
// 5.Spring普通线程池(简化)
@Test
public void testThreadPoolTaskExecutorSimple() {
for (int i = 0; i < 10; i++) {
alphaService.execute1();//会以多线程的方式调用该方法。把这个方法作为线程体,用线程池去调
}
sleep(10000);
}
// 6.Spring定时任务线程池(简化)
//一旦执行,自动会掉alphaService.execute2()
@Test
public void testThreadPoolTaskSchedulerSimple() {
sleep(30000);
}
}
分布式定时任务 - Spring Quartz 的测试
分布式定时任务 - Spring Quartz
往community数据库中导入Quartz的表tables_mysql_innodb.sql。
Spring Quartz的几个接口。
Scheduler接口:Quartz核心调度工具,所有由Quartz调度的任务都是通过这个接口去调的。不需要我们去写。
Job:定义一个任务。里面的execute()方法写明要做的事。
JobDetail:配置Job,名字、组、描述等配置。
Trigger:配置Job什么时候运行,以什么样的频率反复运行。
总结:Job接口定义一个任务,通过JobDetail和Trigger接口来配置这个Job。配置好以后,程序启动时,Quartz会读取配置信息,把它读到的信息立刻存到数据库里,存到那些表里。以后通过读取表来执行任务。只要数据初始化到数据库以后,JobDetail和Trigger的配置就不再使用了。就是说,JobDetail和Trigger的配置只在第一次启动时使用一下。
重要的几张表。
pom.xml
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-quartz</artifactId>
</dependency>
AlphaJob.java
package com.nowcoder.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.");
}
}
QuartzConfig.java
import com.nowcoder.community.quartz.AlphaJob;
import com.nowcoder.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 {
//BeanFactory是整个IOC容器的顶层实例化接口。
// 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");//给trigger取个名字
factoryBean.setGroup("alphaTriggerGroup");
factoryBean.setRepeatInterval(3000);//3000毫秒=3秒
factoryBean.setJobDataMap(new JobDataMap());//Trigger底层要存储Job的一些状态,你用哪个对象来存,你要指定这个对象。这里指定了默认的类型"new JobDataMap()"
return factoryBean;
}
}
application.properties
# 这个不配置,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
QuartzTests.java,用来删除Job
package com.nowcoder.community;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.quartz.JobKey;
import org.quartz.Scheduler;
import org.quartz.SchedulerException;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.context.junit4.SpringRunner;
@RunWith(SpringRunner.class)
@SpringBootTest
@ContextConfiguration(classes = CommunityApplication.class)
public class QuartzTests {
@Autowired
private Scheduler scheduler;
@Test
public void testDeleteJob() {
try {
boolean result = scheduler.deleteJob(new JobKey("alphaJob", "alphaJobGroup"));//删除一个Job,即删除数据库里Job相关的数据。new JobKey(Job名字, Job的组的名字),这两个参数唯一的确定一个Job
System.out.println(result);
} catch (SchedulerException e) {
e.printStackTrace();
}
}
}
7.16、热帖排行
这节课做了两件事:
1、在发帖、点赞、评论、加精时,把该帖子id放入Redis的Set集合里,然后设置定时任务,每隔5分钟,把这些帖子id挨个取出,然后重新计算它们的分数score,然后更新数据库和elasticsearch里的discusspost的score的值。
2、修改了selectDiscussPosts()及其对应的一条线(Mapper、Dao、Service、Controller、thymeleaf页面),加了int orderMode这个参数。然后,可以按照帖子的热度排序。可以切换用"最新/热度"排序。
//orderMode默认是0,表示按照时间先后来排,最新的排在前面。orderMode为1时,表示按照热度来排,就是按照帖子的分数来排。
List<DiscussPost> selectDiscussPosts(int userId, int offset, int limit, int orderMode);
发帖(帖子要有个初始分数,越新的帖子分数越高)、点赞、评论、加精,都要重新计算帖子的分数。
DiscussPostController.java,以该类的"加精"方法举例。
// 加精
@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(0);
}
PostScoreRefreshJob.java,就是定时任务的那个任务。
package com.nowcoder.community.quartz;
import com.nowcoder.community.entity.DiscussPost;
import com.nowcoder.community.service.DiscussPostService;
import com.nowcoder.community.service.ElasticsearchService;
import com.nowcoder.community.service.LikeService;
import com.nowcoder.community.util.CommunityConstant;
import com.nowcoder.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;
// 牛客纪元
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而是e.printStackTrace();,那么会有编译错误
throw new RuntimeException("初始化牛客纪元失败!", e);
}
}
@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;
// 分数 = 帖子权重 + 距离天数
//Math.max(w, 1),log10(x)是条y轴右侧的上升曲线,即x不能小于等于0。如果x在0-1之间,那么算出来log10(x)是个负数,不好,所以x至少为1.
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.java中增加的部分,就是对定时任务的相关配置,比如配置的哪个任务,被配置的任务多久执行一次。
package com.nowcoder.community.config;
import com.nowcoder.community.quartz.AlphaJob;
import com.nowcoder.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所管理的对象实例.
// 刷新帖子分数任务
@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);//5分钟
factoryBean.setJobDataMap(new JobDataMap());
return factoryBean;
}
}
找到在哪些地方使用某方法:
1、
2、用Ctrl+Shift+F也是可以的。
关于为什么要走Controller再走thymeleaf页面,而不是直接走thymeleaf页面
可能有一些逻辑需要处理,然后才跳到thymeleaf页面。比如7.16课的一小时整到一小时40秒处。
先去HomeController再去index.html。刷新页面时,没有orderMode参数,于是要在Controller里把orderMode默认设为0,然后传给thymeleaf页面,于是显示"最新"。
HomeController.java
@RequestMapping(path = "/index", method = RequestMethod.GET)//"xxx/参数"这个方式传参,必须用post请求方式;get请求方式适合用"xxx?yyy=参数"这种方式传参
public String getIndexPage(Model model, Page page,
//下方专门加了个defaultValue = "0"。因为不点击最新/最热的时候,光刷新主页,是不会把orderMode传进来的。
//这时就默认按照最新的顺序排序
@RequestParam(name = "orderMode", defaultValue = "0") int orderMode) {
// 方法调用钱,SpringMVC会自动实例化Model和Page,并将Page注入Model.
// 所以,在thymeleaf中可以直接访问Page对象中的数据.
page.setRows(discussPostService.findDiscussPostRows(0));
page.setPath("/index?orderMode=" + orderMode);
。。。。。。
model.addAttribute("orderMode", orderMode);
return "/index";
}
index.html
<!-- 筛选条件 -->
<ul class="nav nav-tabs mb-3">
<li class="nav-item">
<a th:class="|nav-link ${orderMode==0?'active':''}|" th:href="@{/index(orderMode=0)}">最新</a>
</li>
<li class="nav-item">
<a th:class="|nav-link ${orderMode==1?'active':''}|" th:href="@{/index(orderMode=1)}">最热</a>
</li>
</ul>
7.19、生成 长图(chang tu)
wkhtmltopdf
- wkhtmltopdf url file
- wkhtmltoimage url file
命令行使用wk:
# 实际应该在路径“E:\打码相关软件\wkhtmltopdf\bin”来执行软件wkhtmltopdf 的命令,因为我们在环境变量中配置了该路径,于是就在哪里都可以输入该命令。
# 把网页转为pdf,存到文件夹里,文件夹不会自动生成,需要你手动创建,生成的pdf文件需要你自己命名。
C:\Users\dell>wkhtmltopdf https://www.nowcoder.com E:\打码相关软件\wkhtmltopdf\my-data\wk-pdfs\1.pdf
# 把网页转为图片
C:\Users\dell>wkhtmltoimage https://www.nowcoder.com E:\打码相关软件\wkhtmltopdf\my-data\wk-images\1.png
C:\Users\dell>wkhtmltoimage --quality 75 https://www.nowcoder.com E:\打码相关软件\wkhtmltopdf\my-data\wk-images\2.png # --quality 75,表示把图片压缩到原有质量的75%,这样做是为了减小图片所占用的空间(MB)
java
- Runtime.getRuntime().exec()
IDEA和Java使用wk:
把网页生成 长图保存到本地:http://localhost:8080/community/share?htmlUrl=https://www.nowcoder.com
把本地的图片通过一个url展现在网页上:http://localhost:8080/community/share/image/图片不包括后缀的图片名(即UUID)
WkTests.java
package com.nowcoder.community;
import java.io.IOException;
public class WkTests {
//@Test的执行会要求你根据pom.xml运行对应软件,如mysql。但main函数不会,main函数和整个项目是分开的,你不开启项目要求的软件,也可以成功执行。
//可以成功执行.
public static void main(String[] args) {
String cmd = "E:/打码相关软件/wkhtmltopdf/bin/wkhtmltoimage --quality 75 https://www.nowcoder.com E:\\打码相关软件\\wkhtmltopdf\\my-data\\wk-images/3.png";
try {
//Runtime执行命令,只是把命令提交给本地的操作系统,剩下的事由操作系统来执行。Java不会等操作系统,Java会直接执行下一行。于是会先输出ok,后生成图片。
//即main函数和生成图片是异步的,是并发的.
Runtime.getRuntime().exec(cmd);
System.out.println("ok.");
} catch (IOException e) {
e.printStackTrace();
}
}
}
application.properties
# wk #网页转pdf/图片 #这两个是我们自定义的配置,因为这两个路径在上线前后会路径不一样,所以要做成可配置的路径
#上线后,wkhtmltopdf软件的wkhtmltoimage命令的安装路径
wk.image.command=d:/work/wkhtmltopdf/bin/wkhtmltoimage
#上线后,wkhtmltopdf软件生成的图片的存放位置
wk.image.storage=d:/work/data/wk-images
WkConfig.java
package com.nowcoder.community.config;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Configuration;
import javax.annotation.PostConstruct;
import java.io.File;
//这里的@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);
}
}
}
ShareController.java
package com.nowcoder.community.controller;
import com.nowcoder.community.entity.Event;
import com.nowcoder.community.event.EventProducer;
import com.nowcoder.community.util.CommunityConstant;
import com.nowcoder.community.util.CommunityUtil;
import org.apache.commons.lang3.StringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.ResponseBody;
import javax.servlet.http.HttpServletResponse;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.OutputStream;
import java.util.HashMap;
import java.util.Map;
@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;
@Value("${qiniu.bucket.share.url}")
private String shareBucketUrl;
@RequestMapping(path = "/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);
map.put("shareUrl", shareBucketUrl + "/" + fileName);
return CommunityUtil.getJSONString(0, null, map);
}
// 获取长图
@RequestMapping(path = "/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 {
OutputStream os = response.getOutputStream();
FileInputStream fis = new FileInputStream(file);
byte[] buffer = new byte[1024];
int b = 0;
while ((b = fis.read(buffer)) != -1) {
os.write(buffer, 0, b);
}
} catch (IOException e) {
logger.error("获取长图失败: " + e.getMessage());
}
}
}
EventConsumer.java
@Component
public class EventConsumer implements CommunityConstant {
private static final Logger logger = LoggerFactory.getLogger(EventConsumer.class);
@Autowired
private MessageService messageService;
@Autowired
private DiscussPostService discussPostService;
@Autowired
private ElasticsearchService elasticsearchService;
@Value("${wk.image.command}")
private String wkImageCommand;
@Value("${wk.image.storage}")
private String wkImageStorage;
// 消费分享事件
@KafkaListener(topics = TOPIC_SHARE)
public void handleShareMessage(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;
}
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());
}
}
}
7.23、将文件上传至云服务器
学习调用七牛云的Java的API:Java SDK_SDK 下载_对象存储 - 七牛开发者中心
一种类型的资源一个空间,该项目应该有两个空间,header和share:七牛云 - 对象存储 - 空间管理
客户端上传
- 客户端将数据提交给云服务器,并等待其响应。
- 用户上传头像时,将表单数据提交给云服务器。
pom.xml
<dependency>
<groupId>com.qiniu</groupId>
<artifactId>qiniu-java-sdk</artifactId>
<version>7.2.23</version>
</dependency>
application.properties
# qiniu # 自定义的配置。不要把这些写死在程序里,配置在这里,以后密钥之类的换了,你可以在这里直接更改。
# 配置七牛云的密钥和空间
qiniu.key.access=你的AK
qiniu.key.secret=你的SK
# bucket桶,其实就是空间的意思,七牛云把它的存储空间叫bucket
qiniu.bucket.header.name=community_header
quniu.bucket.header.url=http://pvghrij81.bkt.clouddn.com
qiniu.bucket.share.name=community_share
qiniu.bucket.share.url=http://pvghvvuzm.bkt.clouddn.com
UserController.java只写增加的部分
@Controller
@RequestMapping("/user")
public class UserController implements CommunityConstant {
private static final Logger logger = LoggerFactory.getLogger(UserController.class);
@Autowired
private UserService userService;
@Autowired
private HostHolder hostHolder;
@Autowired
private LikeService likeService;
@Autowired
private FollowService followService;
@Value("${qiniu.key.access}")
private String accessKey;
@Value("${qiniu.key.secret}")
private String secretKey;
@Value("${qiniu.bucket.header.name}")
private String headerBucketName;
@Value("${quniu.bucket.header.url}")
private String headerBucketUrl;
@LoginRequired
@RequestMapping(path = "/setting", method = RequestMethod.GET)
public String getSettingPage(Model model) {
// 上传文件名称
String fileName = CommunityUtil.generateUUID();
// 设置响应信息
StringMap policy = new StringMap();
policy.put("returnBody", CommunityUtil.getJSONString(0));//成功返回code:0。不是这个值就认为失败。和七牛云异步操作(让七牛云返回个json字符串回来),不是同步(要七牛云返回个网页回来)
// 生成上传凭证
Auth auth = Auth.create(accessKey, secretKey);
String uploadToken = auth.uploadToken(headerBucketName, fileName, 3600, policy);//这个uploadToken3600秒,即1个小时后过期。uploadToken是上传凭证
model.addAttribute("uploadToken", uploadToken);
model.addAttribute("fileName", fileName);
return "/site/setting";
}
// 更新头像路径
@RequestMapping(path = "/header/url", method = RequestMethod.POST)
@ResponseBody
public String updateHeaderUrl(String fileName) {
if (StringUtils.isBlank(fileName)) {
return CommunityUtil.getJSONString(1, "文件名不能为空!");
}
String url = headerBucketUrl + "/" + fileName;
userService.updateHeader(hostHolder.getUser().getId(), url);
return CommunityUtil.getJSONString(0);
}
}
setting.html
<!--上传到七牛云-->
<form class="mt-5" id="uploadForm">
<div class="form-group row mt-4">
<label for="head-image" class="col-sm-2 col-form-label text-right">选择头像:</label>
<div class="col-sm-10">
<div class="custom-file">
<input type="hidden" name="token" th:value="${uploadToken}"><!--token和key,这两个name是固定的。-->
<input type="hidden" name="key" th:value="${fileName}">
<input type="file" class="custom-file-input" id="head-image" name="file" lang="es" required="">
<label class="custom-file-label" for="head-image" data-browse="文件">选择一张图片</label>
<div class="invalid-feedback">
该账号不存在!
</div>
</div>
</div>
</div>
<div class="form-group row mt-4">
<div class="col-sm-2"></div>
<div class="col-sm-10 text-center">
<button type="submit" class="btn btn-info text-white form-control">立即上传</button>
</div>
</div>
</form>
setting.js
$(function(){
$("#uploadForm").submit(upload);//当我点击表单的提交按钮,触发提交事件时,这个事件由upload函数来处理.
});
function upload() {
//$.ajax()简化后是$.post(),提交文件时要用没有简化且功能更强大的$.ajax()
$.ajax({
url: "http://upload-z1.qiniup.com",
method: "post",
//在提交文件时,下面两项就都应该写出来设置为false
processData: false,//不要把表单的内容转换成字符串。默认情况下,会把表单内容转为字符串提交给服务器
contentType: false,//这里按理应该设置上传类型,如html。这里写false表示,不让jQuery设置上传类型。设为false,浏览器会给自动设置一个区分开变界的字符串。
data: new FormData($("#uploadForm")[0]),//$("#uploadForm")是jQuery对象,new FormData()里面要传参js对象,于是$("#uploadForm")[0],得到一个dom
success: function(data) {//七牛云直接返回的JSON数据
if(data && data.code == 0) {
// 更新头像访问路径
$.post(
CONTEXT_PATH + "/user/header/url",
{"fileName":$("input[name='key']").val()},
function(data) {
data = $.parseJSON(data);//而我们的Controller返回的是,格式是JSON的字符串。这里把格式是JSON的字符串转为js对象
if(data.code == 0) {
window.location.reload();
} else {
alert(data.msg);
}
}
);
} else {
alert("上传失败!");
}
}
});
//这里必须要有"return false",如果没有这句,它执行完这个js函数还会尝试提交表单,但表单上你没写action,这里就会有问题.
//"return false"是指,不要再往下提交了,因为上面的逻辑已经把请求处理了。即到此为止,不再向下执行底层原有的事件了。
return false;
}
服务器直传
- 应用服务器将数据直接提交给云服务器,并等待其响应。
- 分享时,服务端将自动生成的图片,直接提交给云服务器。
ShareController.java
@Controller
public class ShareController implements CommunityConstant {
private static final Logger logger = LoggerFactory.getLogger(ShareController.class);
@Autowired
private EventProducer eventProducer;
@Value("${wk.image.storage}")
private String wkImageStorage;
@Value("${qiniu.bucket.share.url}")
private String shareBucketUrl;
@RequestMapping(path = "/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);
map.put("shareUrl", shareBucketUrl + "/" + fileName); //就改了下这里
return CommunityUtil.getJSONString(0, null, map);//直接把这个JSON格式的字符串展现在页面上.
}
// 废弃
// 获取长图
/*
@RequestMapping(path = "/share/image/{fileName}", method = RequestMethod.GET)
public void getShareImage(@PathVariable("fileName") String fileName, HttpServletResponse response) {
。。。
}
*/
}
EventConsumer.java的新增部分
@Component
public class EventConsumer implements CommunityConstant {
private static final Logger logger = LoggerFactory.getLogger(EventConsumer.class);
@Value("${wk.image.command}")
private String wkImageCommand;
@Value("${wk.image.storage}")
private String wkImageStorage;
@Value("${qiniu.key.access}")
private String accessKey;
@Value("${qiniu.key.secret}")
private String secretKey;
@Value("${qiniu.bucket.share.name}")
private String shareBucketName;
//这里用ThreadPoolTaskScheduler而不是用Quartz,却不用担心分布式的问题,的原因是:消费者Consumer已经解决了多台机器多次重复执行某任务的问题.
//如果有多个服务器,虽然每台服务器上都有消费者,但一个任务被生产者发出来,只会被一个消费者抢占然后消费.
//而我们定时任务"taskScheduler.scheduleAtFixedRate(task, 500);"的执行,是在消费者的消费方法里执行的,所以和消费的方法一样,只会在某一个服务器上执行多次任务
//即哪个服务器上的消费者抢到了消息,那么这个定时任务在这一个服务器上执行多次任务,和其他服务器不产生关联
@Autowired
private ThreadPoolTaskScheduler taskScheduler;
// 消费分享事件
@KafkaListener(topics = TOPIC_SHARE)
public void handleShareMessage(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;
}
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());
}
// 启用定时器,监视该图片,一旦生成了,则上传至七牛云.
UploadTask task = new UploadTask(fileName, suffix);
Future future = taskScheduler.scheduleAtFixedRate(task, 500);//每隔半秒钟执行一遍。Future里封装了任务的状态,还可以用来停止定时器。
task.setFuture(future);//停止定时器应该在run()方法里停止,在达成某个条件之后,于是,要把返回的future传入UploadTask类的对象task里
}
/*
以下情况导致上传失败,但上传失败了,不能够线程就不停止了,线程不停止的话,时间久了,会有很多线程因为这种原因不停止,服务器就被撑爆了。
所以要考虑到这些情况,即使出现这些极端情况,也一定要停掉定时器。于是要增加两个属性(开始时间,上传次数):
1.图片一直无法生成到本地
2.网络不好无法上传图片/七牛云的服务器挂了,无法上传图片
*/
class UploadTask implements Runnable {
// 文件名称
private String fileName;
// 文件后缀
private String suffix;
// 启动任务的返回值
private Future future;
// 开始时间
private long startTime;
// 上传次数
private int uploadTimes;
public UploadTask(String fileName, String suffix) {
this.fileName = fileName;
this.suffix = suffix;
this.startTime = System.currentTimeMillis();
}
public void setFuture(Future future) {
this.future = future;
}
@Override
public void run() {
// 生成失败
if (System.currentTimeMillis() - startTime > 30000) {//30秒,还没上传成功,大概率是生成图片失败
logger.error("执行时间过长,终止任务:" + fileName);
future.cancel(true);//这行代码用来终止任务.
return;
}
// 上传失败
if (uploadTimes >= 3) {//上传3次,还不成功,大概率是网络不好/服务器挂了。文件不存在时不进行上传操作
logger.error("上传次数过多,终止任务:" + fileName);
future.cancel(true);
return;
}
String path = wkImageStorage + "/" + fileName + suffix;
File file = new File(path);
if (file.exists()) {
logger.info(String.format("开始第%d次上传[%s].", ++uploadTimes, fileName));
// 设置响应信息
StringMap policy = new StringMap();
policy.put("returnBody", CommunityUtil.getJSONString(0));
// 生成上传凭证
Auth auth = Auth.create(accessKey, secretKey);
String uploadToken = auth.uploadToken(shareBucketName, fileName, 3600, policy);//凭证uploadToken过期时间,3600秒,1小时
// 指定上传机房
UploadManager manager = new UploadManager(new Configuration(Zone.zone1()));//zone1()是上传到华北地区了。即setting.js里的指定上传到的服务器域名“url: "http://upload-z1.qiniup.com",”
try {
// 开始上传图片
Response response = manager.put(
path, fileName, uploadToken, null, "image/" + suffix, false);//第三个参数是上传文件类型,变量mime,值“image/.png”
// 处理响应结果
JSONObject json = JSONObject.parseObject(response.bodyString());//把返回的JSON格式字符串转为JSON对象
if (json == null || json.get("code") == null || !json.get("code").toString().equals("0")) {
logger.info(String.format("第%d次上传失败[%s].", uploadTimes, fileName));
} else {
logger.info(String.format("第%d次上传成功[%s].", uploadTimes, fileName));
future.cancel(true);
}
} catch (QiniuException e) {
logger.info(String.format("第%d次上传失败[%s].", uploadTimes, fileName));
}
} else {//如果文件不存在就什么也不做,过一会这个定时任务又会被调一次,再来上传
logger.info("等待图片生成[" + fileName + "].");
}
}
}
}
7.27、优化网站的性能
只有本地缓存和DB时。如果是热门帖子这种数据,两个服务器都从数据库取出热门帖子,然后更新到本地缓存里,同一份数据在两个服务器都存了一份,这没有问题。如果是和用户相关的问题,比如用户登录凭证,在服务器1上用户是登录状态,在服务器2上的本地缓存里没有该凭证,用户就没有登录状态了,这就不行了。
可以用Redis解决,两个服务器都从Redis里获取用户的登录状态。
本地缓存空间小,Redis缓存空间比较大。大部分请求会被这两级拦截下来。
如果本地缓存和Redis里都没有请求的数据,那么会从数据库里取得数据,然后送到app(service组件)里,然后再从app把从db取到的数据更新到本地缓存和Redis。
缓存的数据,会基于时间和使用频率来淘汰。
变化不那么频繁的数据,我们才使用缓存。如果是帖子的最新,那不能缓存,更新太快,所以这节课来缓存帖子的最热排序,因为这个排序是一段时间才更新一次分数的,所以在两次更新分数之间,帖子的热门排名是不变的。
application.properties
# caffeine #都是自定义的配置 #post是帖子的意思,如果要缓存评论可以caffeine.comment
# 第一个表示缓存15页帖子 # 第二个表示,存到缓存里的数据到3分钟,自动就会被清理掉,这叫自动淘汰。 还有一种主动淘汰,是帖子更新了,会淘汰掉缓存中的这个帖子。
# 这里只有自动淘汰,没有主动淘汰,因为我们是一页一页缓存的,如果一个帖子更新了,把这一页的帖子都刷掉,不合适
# 就是说,这一页帖子,评论点赞之类的数量,在这3分钟内会有一定延迟,和真实数量对不上,但不影响使用。
caffeine.posts.max-size=15
caffeine.posts.expire-seconds=180
DiscussPostService.java
package com.nowcoder.community.service;
import com.github.benmanes.caffeine.cache.CacheLoader;
import com.github.benmanes.caffeine.cache.Caffeine;
import com.github.benmanes.caffeine.cache.LoadingCache;
import com.nowcoder.community.dao.DiscussPostMapper;
import com.nowcoder.community.entity.DiscussPost;
import com.nowcoder.community.util.SensitiveFilter;
import org.checkerframework.checker.nullness.qual.NonNull;
import org.checkerframework.checker.nullness.qual.Nullable;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.web.util.HtmlUtils;
import javax.annotation.PostConstruct;
import java.util.List;
import java.util.concurrent.TimeUnit;
@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
//LoadingCache:同步缓存,如果缓存内没有,要来读取的线程排队等待,Caffeine去把数据取到缓存里,然后挨个去读取缓存里的这个数据。我们用这个。
//AsyncLoadingCache:异步缓存,支持多个线程并发的同时读取同一数据
// 缓存都是按照key缓存value
// 帖子列表缓存
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>>() {
@Nullable
@Override
//当尝试从缓存中取数据的时候,caffeine会看看缓存里有没有数据,有就返回,没有就调用下面的load()方法从数据库中取出该数据
//所以load()方法要告诉caffeine怎么从数据库中取得该数据
public 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()
//这里本来应该单独在application.properties里配置的,如:caffeine.posts-count.max-size和caffeine.posts-count.expire-seconds。但老师懒的单独配,复用缓存帖子的也不出错,所以这样了。
.maximumSize(maxSize)
.expireAfterWrite(expireSeconds, TimeUnit.SECONDS)
.build(new CacheLoader<Integer, Integer>() {
@Nullable
@Override
public 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);//userId和orderMode是一定的,那么就把两个变化的量组合为key,中间用什么隔开都可以,如用冒号隔开
}
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);//这里其实不需要userId作为key,因为这里的userId永远是0,但是又必须要有key,所以只能这样,一直用0作为key。
}
logger.debug("load post rows from DB.");
return discussPostMapper.selectDiscussPostRows(userId);
}
}
CaffeineTests.java
package com.nowcoder.community;
import com.nowcoder.community.entity.DiscussPost;
import com.nowcoder.community.service.DiscussPostService;
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.test.context.ContextConfiguration;
import org.springframework.test.context.junit4.SpringRunner;
import java.util.Date;
@RunWith(SpringRunner.class)
@SpringBootTest
@ContextConfiguration(classes = CommunityApplication.class)
public class CaffeineTests {
@Autowired
private DiscussPostService postService;
@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);
postService.addDiscussPost(post);
}
}
@Test
public void testCache() {
System.out.println(postService.findDiscussPosts(0, 0, 10, 1));
System.out.println(postService.findDiscussPosts(0, 0, 10, 1));
System.out.println(postService.findDiscussPosts(0, 0, 10, 1));
System.out.println(postService.findDiscussPosts(0, 0, 10, 0));
}
}
压力测试工具是模拟客户端有很多人同时访问服务器。
压力测试工具:Apache JMeter - Download Apache JMeter