项目进阶,构建安全高效的企业服务
Spring Security底层利用filter(许多专门登录、权限、退出。。。),利用javaee的规范,对整个请求进行拦截。对权限的控制比较靠前,权限不行的话到不了DispatcherServlet,更到不了controller
导包
导过包后会自动对项目进行安全管理,自带登陆页面,控制台有密码,用户名为user
怎么把它的登陆页面换成自己的?用自己数据库里的数据进行登录?
认证授权在业务层进行处理,当前用户的权限怎么体现?可以建立角色表,user的type字段(0普通,1管理员,2版主),用Security做授权时需要一个明确权限的字符串?
1、让user实体类实现UserDetails接口,实现里面的方法。
public class User implements UserDetails {
//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;
}
2、让userservice实现UserDetailsService接口
根据用户名查用户,自动判断账号密码对不对
public class UserService implements UserDetailsService {
@Override
public UserDetails loadUserByUsername(String s) throws UsernameNotFoundException {
return this.findUserByName(s);
}
}
3、利用security对项目进行授权
在配置类里面进行配置,继承WebSecurityConfigurerAdapter,重写父类方法
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
private UserService userService;
@Override
public void configure(WebSecurity web) throws Exception {
//忽略resource下的所有静态资源
web.ignoring().antMatchers("/resources/**");
}
//处理认证
//AuthenticationManager:认证的核心接口
//AuthenticationManagerBuilder:用于构建AuthenticationManager对象的工具
//ProviderManager:AuthenticationManager接口默认实现类
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
//内置的认证规则
// auth.userDetailsService(userService).passwordEncoder(new Pbkdf2PasswordEncoder("12345"));//原密码+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) {
//使用账号密码认证
return UsernamePasswordAuthenticationToken.class.equals(aClass);
}
});
}
//授权
@Override
protected void configure(HttpSecurity http) throws Exception {
//避开默认的登录页面
//登录相关配置
http.formLogin()
.loginPage("/loginpage")//告诉登陆页面是谁
.loginProcessingUrl("/login")//登录提交表单时的路径,拦截
.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里,将请求转发(在同一个请求之内,可以通过req传参)到首页
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")
.antMatchers("/admin").hasAnyAuthority("ADMIN")
.and().exceptionHandling().accessDeniedPage("/denied");
//没有权限的时候访问路径
//增加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);
//记住我
http.rememberMe()//默认将记住我放在内存里
.tokenRepository(new InMemoryTokenRepositoryImpl())
.tokenValiditySeconds(3600*24)//过期时间
.userDetailsService(userService);//下次登录根据id查出用户信息
}
}
转发和重定向的区别
重定向:
浏览器去访问A组件,但是A组件没有什么信息反馈给浏览器(例如删除),删除之后想去查询页面,但是两者又没有关系,两个独立的组件,这个时候适合重定向。地址栏变化
B的访问是浏览器自己去访问的,A只是给一个建议和路径,低耦合跳转。但是如果A想给B带个信息就不行了,因为开启了一个新的请求request(两个请求想共享数据就需要cookie或者session)。
转发:
浏览器去访问A,但是A只能处理请求的一部分,另一部分需要B去处理,(A,B共同合作),整个过程是一个请求,A可以把数据存在request里面,B再取出来。地址栏不变
总之,服务端有两个组件想要跳转,看一下两个组件是协作合作还是独立,选择转发还是重定向
例如:A是登录提交的表单,B是登录失败的页面,A失败后将请求转给B(我们在项目中直接是return模板),这样B可以复用A的代码逻辑,可以再添加其他的逻辑
Security要求退出必须是post请求
<li>
<form method="post" th:action="@{/logout}">
<!--推出提交表单,用js实现-->
<a href="javascript:document.forms[0].submit();">退出</a>
</form>
</li>
@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";
}
认证成功后,结果会通过SecurityContextHolder存入SecurityContext中
1、将登陆的拦截器注掉
2、编写配置类,继承父类WebSecurityConfigurerAdapter
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter implements CommunityConstant {
@Override
public void configure(WebSecurity web) throws Exception {
//忽略静态资源的拦截
web.ignoring().antMatchers("/resources/**");
}
@Override
protected void configure(HttpSecurity http) throws Exception {
//配置授权(哪些路径必须登录才能访问,拥有什么身份能访问)
http.authorizeRequests()
.antMatchers(
"/user/setting",
"/user/upload",
"/discuss/add",
"/comment/add/**",
"/letter/**",
"/notice/**",
"/like",
"/follow",
"/unfollow"
)
.hasAnyAuthority(
//任意一个权限都可访问
AUTHORITY_USER,
AUTHORITY_ADMIN,
AUTHORITY_MODERATOR
)
.anyRequest().permitAll();//除了这些请求之外的其他请求都可以直接访问
}
}
什么C是SRF
当你浏览器里面存有身份标识的cookie,之后你再去访问服务器想让其返回一个表单,服务器返回之后,你得到表单并没有提交,去访问了另外一个不安全的网站B,网站B含有病毒可以窃取你的cookie信息,对服务器进行表单提交,如果这是个银行账户系统,这个操作具有很大的危害性
总结:某网站利用cookie仿造你的身份去访问服务器提交表单,来达到某种目的
Security在返回表单时会隐藏一个数据token凭证(随机生成的),某网站可以窃取你的cookie,但是获取不到token,当它去提交的时候,服务器会对比cookie和token
但是异步请求会有安全隐患,因为异步请求没有表单,没法生成csrf凭证
处理发帖时异步请求
当我们访问这个页面的时候,就会生成csrf的key和value(在发异步请求时将其值取到即可)
<meta name="_csrf" th:content="${_csrf.token}">
<meta name="_csrf_header" th:content="${_csrf.headerName}">
异步请求(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);
});
你这样写了之后每一个异步请求都要处理下,不然security不通过,因为项目中有许多异步请求,不按个处理了,就注掉了。
在security里面禁用csrf,不让其走这个逻辑即可(在授权处禁用)
异步请求,局部刷新,所以需要加上@ResponseBody
将帖子同步到es,触发一下发帖事件,之后再返回一个提示信息
设置删帖事件,之后在EventConsumer里消费该事件
//消费删帖事件(删除帖子之后,也删除es里面的帖子)
@KafkaListener(topics = TOPIC_DELETE)
public void handleDeleteMessage(ConsumerRecord record){
//发了一个空消息
if(record==null || record.value()==null){
logger.error("发送消息为空!");
return;
}
//将json消息转为对象,指定字符串对应的具体类型
Event event = JSONObject.parseObject(record.value().toString(), Event.class);
//转为对象之后再判断
if(event==null){
logger.error("消息格式错误!");
return;
}
elasticsearchService.deleteDiscussPost(event.getEntityId());
}
在SecurityConfig里面增加配置置顶、加精、删除的权限(后台处理)
版主:置顶、加精
管理员:删除
.antMatchers(
"/discuss/top",
"/discuss/wonderful"
)
.hasAnyAuthority(
AUTHORITY_MODERATOR
)
.antMatchers(
"/discuss/delete"
)
.hasAnyAuthority(
AUTHORITY_ADMIN
)
实现该用户有什么权限就只能看到什么功能?(前端处理)
先导包,再在对应页面加命名空间
<dependency>
<groupId>org.thymeleaf.extras</groupId>
<artifactId>thymeleaf-extras-springsecurity5</artifactId>
</dependency>
xmlns:sec="http://www.thymeleaf.org/extras/spring-security"
<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>
HyperLogLog:可以用来统计某个网站的访问量(一个用户多次访问算一次),自动去重
Bitmap:可以用来统计一年内你每天是否上班(0,1)
// 统计20万个重复数据的独立总数.
@Test
public void testHyperLogLog() {
String redisKey = "test:hll:01";
for (int i = 1; i <= 100000; i++) {
redisTemplate.opsForHyperLogLog().add(redisKey, i);
}
long size = redisTemplate.opsForHyperLogLog().size(redisKey);
System.out.println(size);
}
// 统计一组数据的布尔值
@Test
public void testBitMap() {
String redisKey = "test:bm:02";
// 记录
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));
System.out.println(redisTemplate.opsForValue().getBit(redisKey, 1));
System.out.println(redisTemplate.opsForValue().getBit(redisKey, 2));
// 统计
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组数据做OR运算.
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());
}
});
UV关注的是访问量,而不是是否注册或登录,游客也需要记录,所以使用IP统计,而不是用户id。每次访问都记录,之后再去重。
DAU统计的是活跃用户,使用用户id统计,userid为整数将其作为索引,1表示活跃,0表示不活跃。它也是比较节约空间的。
记录是以天为单位,查询的时候可以合并查询一周的
redis的key设置两种,一种是单天的,一种是在某个区间的
redis不需要写dao层,直接写service,直接调redisTemplate
service
@Service
public class DataService {
@Autowired
private RedisTemplate redisTemplate;
//格式化日期
private SimpleDateFormat df=new SimpleDateFormat("yyyyMMdd");
//统计数据(1、记录数据 2、能够查询到)记、查
/*
* 统计UV
* */
//1、将指定IP计入UV
public void recordUV(String ip){
//先得到key
String redisKey = RedisKeyUtil.getUVKey(df.format(new Date()));
redisTemplate.opsForHyperLogLog().add(redisKey,ip);//存进redis
}
//统计指定日期范围内的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=Calendar.getInstance();//实例化抽象类对象
calendar.setTime(start);
//时间<=end才循环
while (!calendar.getTime().after(end)){
//得到key
String key = RedisKeyUtil.getUVKey(df.format(calendar.getTime()));
//将key加到集合里
keyList.add(key);
//calendar加一天
calendar.add(Calendar.DATE,1);
}
//合并这些数据,存放合并后的值
String redisKey = RedisKeyUtil.getUVKey(df.format(start), df.format(end));//得到合并后的key
redisTemplate.opsForHyperLogLog().union(redisKey,keyList.toArray());//合并存到redis
//返回统计的结果
return redisTemplate.opsForHyperLogLog().size(redisKey);
}
/*
* 统计DAU
* */
//统计单日的dau
public void recordDAU(int userId){
//得到key
String rediskey = RedisKeyUtil.getDAUKey(df.format(new Date()));
//存入redis
redisTemplate.opsForValue().setBit(rediskey,userId,true);
}
//统计某个区间的dau(在该区间内某一天登录了就算是活跃,所以要用or运算)
public long calculateDAU(Date start,Date end){
//先判断日期是否为空
if(start==null || end==null){
throw new IllegalArgumentException("参数不能为空!");
}
//将该范围内每一天的key合并整理到一个集合里
//bitmap运算需要数组,所以list集合里面存byte数组
List<byte[]> keyList=new ArrayList<>();
//利用calendar对日期做运算
Calendar calendar=Calendar.getInstance();//实例化抽象类对象
calendar.setTime(start);
//时间<=end才循环
while (!calendar.getTime().after(end)){
//得到key
String key = RedisKeyUtil.getDAUKey(df.format(calendar.getTime()));
//将key加到集合里
keyList.add(key.getBytes());
//calendar加一天
calendar.add(Calendar.DATE,1);
}
//得到合并的key
String redisKey = RedisKeyUtil.getDAUKey(df.format(start), df.format(end));
//将合并的or运算结果存入redis
return (long) redisTemplate.execute(new RedisCallback() {
@Override
public Object doInRedis(RedisConnection connection) throws DataAccessException {
connection.bitOp(RedisStringCommands.BitOperation.OR,
redisKey.getBytes(),keyList.toArray(new byte[0][0]));
return connection.bitCount(redisKey.getBytes());
}
});
}
}
表现层分为两步记录和查看,每次请求都要记录,所以要再拦截器里面实现。
@Component
public class DataInterceptor implements HandlerInterceptor {
@Autowired
private DataService dataService;
@Autowired
private HostHolder hostHolder;
//在请求之前拦截,只是记录数据,所以要返回true,让其继续向下执行
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
//统计单日的UV
//通过request得到ip
String ip = request.getRemoteHost();
dataService.recordUV(ip);//调用service统计
//统计单日的DAU
//得到当前用户,并且判断登陆后才记录
User user = hostHolder.getUser();
if (user!=null){
dataService.recordDAU(user.getId());
}
return true;
}
}
注入拦截器,使其生效
registry.addInterceptor(dataInterceptor)
.excludePathPatterns("/**/*.css","/**/*.js","/**/*.png","/**/*.jpg","/**/*.jpeg");
控制器
@Controller
public class DataController {
@Autowired
private DataService dataService;
//打开统计页面的方法,get,post请求都可处理
@RequestMapping(path = "/data",method ={RequestMethod.GET,RequestMethod.POST} )
public String getDatePage(){
return "/site/admin/data";
}
//页面上传的是日期的字符串,告诉服务器字符串的格式,他就可以帮你转化,
// 利用注解@DateTimeFormat
@RequestMapping(path = "/data/uv",method = RequestMethod.POST)
public String getUV(@DateTimeFormat(pattern = "yyyy-MM-dd") Date start,
@DateTimeFormat(pattern = "yyyy-MM-dd")Date end, Model model){
long calculateUV = dataService.calculateUV(start, end);
model.addAttribute("uvResult",calculateUV);//将统计结果存到model
//将表单的日期也存到model里面,跳转后便于页面显示
model.addAttribute("uvStartDate",start);
model.addAttribute("uvEndDate",end);
return "forward:/data";//转发(这个请求只能完成一部分,下面的部分交给这个请求去完成,即上面那个请求)
}
//统计DAU
@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
//将表单的日期也存到model里面,跳转后便于页面显示
model.addAttribute("dauStartDate",start);
model.addAttribute("dauEndDate",end);
return "forward:/data";//转发(这个请求只能完成一部分,下面的部分交给这个请求去完成,即上面那个请求)
}
}
添加这个功能只能管理员访问
.antMatchers(
"/discuss/delete",
"/data/**"
)
.hasAnyAuthority(
AUTHORITY_ADMIN
)
entity是实体,可以对帖子进行评论也可以对帖子的评论进行评论。(1代表帖子,2代表评论,3代表用户)
entityid,某个类型的具体目标。
targetid,对某个评论进行回复(具有指向性)
status,0表示正常,1表示删除禁用
有些任务并不是我们访问服务器才启动的,例如:每隔一个小时计算帖子的分数,每隔半个小时清理服务器上存的文件。
在分布式下为什么使用jdk线程池和Spring线程池会出现问题?需要使用分布式定时任务?
分布式(多个服务器,一个集群),浏览器发给Nginx请求,根据策略Nginx发给服务器,两个服务器代码都一样,对于普通代码没问题,但是对于定时任务,两个同时执行就可能出现问题。
对于QuartZ怎么解决问题呢?
jdk、spring定时任务组件是基于内存的,配置参数在内存里,即服务器1和服务器2没法进行数据共享。QuarZ的定时任务参数保存在数据库里,即便两个服务器同时执行定时任务,会通过数据库加锁的方式抢锁,只有一个线程可以访问,下个线程去访问时,先看一下任务参数是否被修改,修改了,那他就不做了。
使用spring带有的线程池时,首先需要先配置下
#spring普通线程池配置
spring.task.execution.pool.core-size=5
spring.task.execution.pool.max-size=15
spring.task.execution.pool.queue-capacity=100
#spring定时任务的线程池配置
spring.task.scheduling.pool.size=5
另外还需要一个配置类,加上相关注解,配置类、能定时执行、能异步调用
该方法被调用后多长时间被执行,每隔多长时间执行。
只要有程序在跑,他就会被执行。
@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) {
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);
}
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(30000);
}
// 3.Spring普通线程池
@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定时任务线程池
@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定时任务线程池(简化)
@Test
public void testThreadPoolTaskSchedulerSimple() {
sleep(30000);
}
}
先需要先初始化表
2、导包
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-quartz</artifactId>
</dependency>
3、
# QuartzProperties 将配置放到数据库里
spring.quartz.job-store-type=jdbc
#调度器的名字
spring.quartz.scheduler-name=communityScheduler
#调度器id自动生成
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
首先定义一个任务(Job接口execute声明我要做什么),具体做什么需要配置(JobDetail名字,组对job进行配置)(Trigger触发器,Job什么时候运行,以什么频率运行),将读取到的配置初始化到数据库,以后直接访问数据库读取配置。
BeanFactory是容器的顶层接口,
FactoryBean可简化Bean的实例化过程:
1、通过FactoryBean封装Bean的实例化过程。
2、将FactoryBean装配到Spring容器里。
3、将FactoryBean注入给其他的Bean。
4、该Bean得到的是FactoryBean所管理的对象实例
例如:
//只有第一次有用,将配置读取到数据库中,以后直接从数据库中读
@Configuration
public class QuartzConfig {
@Bean //将JobDetailFactoryBean装配到容器里
public JobDetailFactoryBean alphaJobDetail(){
return null;
}
@Bean //我这里的参数需要JobDetail,我将上面bean的方法名传进来,这里得到的是JobDetailFactoryBean管理的对象
public SimpleTriggerFactoryBean alphaTrigger(JobDetail alphaJobDetail){
return null;
}
}
使用用例
1、
public class AlphaJob implements Job {
@Override
public void execute(JobExecutionContext jobExecutionContext) throws JobExecutionException {
System.out.println(Thread.currentThread().getName()+":execute a quartz job.");
}
}
2、
//只有第一次有用,将配置读取到数据库中,以后直接从数据库中读
@Configuration
public class QuartzConfig {
//配置JobDetail
@Bean //将JobDetailFactoryBean装配到容器里
public JobDetailFactoryBean alphaJobDetail(){
JobDetailFactoryBean factoryBean=new JobDetailFactoryBean();
factoryBean.setJobClass(AlphaJob.class);
factoryBean.setName("alphajob");
factoryBean.setGroup("alphaJobGroup");
factoryBean.setDurability(true);//任务不在运行,触发器没有了也不用删,留着
factoryBean.setRequestsRecovery(true);//任务是不是可恢复的
return factoryBean;
}
//配置触发器
@Bean //我这里的参数需要JobDetail,我将上面bean的方法名传进来,这里得到的是JobDetailFactoryBean管理的对象
public SimpleTriggerFactoryBean alphaTrigger(JobDetail alphaJobDetail){
SimpleTriggerFactoryBean factoryBean=new SimpleTriggerFactoryBean();
factoryBean.setJobDetail(alphaJobDetail);//参数名与bean名一致
factoryBean.setName("alphaTrigger");
factoryBean.setGroup("alphsTriggerGroup");
factoryBean.setRepeatInterval(3000);//执行频率
factoryBean.setJobDataMap(new JobDataMap());//指定那个对象来存状态
return factoryBean;
}
}
启动后,配置就会传到数据库里面,每三秒一次
删除任务
@Test
public void testDeleteJob(){
boolean b = false;
try {
b = scheduler.deleteJob(new JobKey("alphajob", "alphaJobGroup"));
System.out.println(b);
} catch (SchedulerException e) {
e.printStackTrace();
}
}
评论、点赞、加精之后立马计算,效率比较低,启动定时任务去计算,再根据分数排列显示。
思路:
评论、点赞、加精之后不立马计算,将其丢到缓存redis里,定时计算变化的帖子,不变的不算
在新添加的帖子后面也计算分数,并将其存到redis里面
置顶直接放在最顶上,所以不用计算分数。
在加精处也计算将贴子放到redis里
评论:对帖子评论才将其帖子放到redis里
点赞:先判断是对帖子点赞才将贴子放到redis里面
写定时任务(帖子刷新)
1、Job(记录日志)
查帖子,将计算后的帖子同步到es搜索引擎中
声明常量(只需要初始化一次,所以在静态块里面初始化),方便计算
private static final Date epoch;
static {
try {
epoch=new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").parse("2014-08-01 00:00:00");
} catch (ParseException e) {
throw new RuntimeException("初始化牛客纪元失败!",e);
}
}
public class PostScoreRefreshJob implements Job, CommunityConstant{
private static final Logger logger= LoggerFactory.getLogger(PostScoreRefreshJob.class);
@Autowired
private RedisTemplate redisTemplate;
@Autowired
private DiscussPostService discussPostService;
@Autowired
private LikeService likeService;
@Autowired
private ElasticsearchService elasticsearchService;
private static final Date epoch;
static {
try {
epoch=new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").parse("2014-08-01 00:00:00");
} catch (ParseException e) {
throw new RuntimeException("初始化牛客纪元失败!",e);
}
}
@Override
public void execute(JobExecutionContext jobExecutionContext) throws JobExecutionException {
//从redis里面取值(先得到key),每一个key都要算一下,反复的操作,所以用BoundSetOperation
String redisKey = RedisKeyUtil.getPostScoreKey();
BoundSetOperations operations=redisTemplate.boundSetOps(redisKey);
//先判断一下缓存中有没有数据,没有变化就不做操作
if(operations.size()==0){
logger.info("[任务取消] 没有要刷新的帖子!");
return;
}
//使用日志记录时间中间过程
logger.info("[任务开始] 正在刷新帖子分数: "+operations.size());
while (operations.size()>0){//只要redis里面有数据就算
//集合中弹出一个值
this.refresh((Integer)operations.pop());
}
}
private void refresh(int postId) {
//先将贴子查出来
DiscussPost post = discussPostService.findDiscussDetail(postId);
//空值判断(帖子被人点赞,但是后来被管理删除)
if(post==null){
logger.error("帖子不存在: id= "+ postId);//日志记录错误提示
return;
}
//计算帖子分值(加精-1、评论数、点赞数)
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;
//分数=帖子权重+距离天数
//为了不得到负数,在权重和1之间取最大值。将时间得到的毫秒在换算为天
double score=Math.log10(Math.max(w,1)+
(post.getCreateTime().getTime()-epoch.getTime())/(1000 * 3600 * 24));
//更新帖子的分数
discussPostService.updateDiscussScore(postId, score);
//同步搜索对应帖子的数据(先重设帖子的分数,再保存到es)
post.setScore(score);
elasticsearchService.saveDiscussPost(post);
}
}
写好了定时任务还要去配置别忘了
//刷新帖子分数任务
@Bean //将JobDetailFactoryBean装配到容器里
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);//参数名与bean名一致
factoryBean.setName("postScoreRefreshTrigger");
factoryBean.setGroup("communityJobGroup");
factoryBean.setRepeatInterval(1000 * 60 *5);//执行频率
factoryBean.setJobDataMap(new JobDataMap());//指定那个对象来存状态
return factoryBean;
}
五分钟更新一次
重构之前查找帖子的mapper
//查找帖子分页显示(userId是动态需要条件,0表示不拼接,其余拼接)
List<DiscussPost> selectDiscussPosts(int userId,int offset,int limit,int orderMode);
<select id="selectDiscussPosts" resultType="DiscussPost">
select <include refid="selectFields">include>
from discuss_post
where status!=2
<if test="userId!=0">
and user_id=#{userId}
if>
<if test="orderMode==0">
order by type desc,create_time desc
if>
<if test="orderMode==1">
order by type desc,score desc, create_time desc
if>
limit #{offset},#{limit}
select>
命令:1、将模板的内容生成pdf,2、将网页生成图片
wkhtmltopdf https://www.nowcoder.com
C:\nowcoder_community\data\wk-pdfs/1.pdf
wkhtmltoimage https://www.nowcoder.com C:\nowcoder_community\data\wk-images/1.png
压缩图片75%
wkhtmltoimage --quality 75 https://www.nowcoder.com C:\nowcoder_community\data\wk-images/2.png
java中使用生成长图
package com.nowcoder.community;
import java.io.IOException;
public class WKTests {
public static void main(String[] args) {
String cmd="C:/user/soft/wk/wkhtmltopdf/bin/wkhtmltoimage --quality 75 https://www.nowcoder.com C:/nowcoder_community/data/wk-images/3.png";
try {
Runtime.getRuntime().exec(cmd);
System.out.println("ok!");
} catch (IOException e) {
e.printStackTrace();
}
}
}
操作系统执行命令和程序执行是并发、异步的。
配置一下路径和图片保存的文件路径
#wk
wk.image.command=C:/user/soft/wk/wkhtmltopdf/bin/wkhtmltoimage
wk.image.storage=C:/nowcoder_community/data/wk-images
路径是否存在,验证文件是否可以自动创建
先删除之前创建的文件夹,测试能否成功。
在服务启动时,创建一个目录。
服务器启动时,先去实例化配置类,自动调@PostConstruct,初始化一次
@Configuration
public class WKConfig {
private static final Logger logger= LoggerFactory.getLogger(WKConfig.class);
//注入路径
@Value("${wk.image.storage}")
private String wkImageStorage;
@PostConstruct
public void init(){
//创建wk图片目录
File file=new File(wkImageStorage);
if(!file.exists()){
file.mkdir();
logger.info("创建wk图片目录:" +wkImageStorage);
}
}
}
处理前端的请求(生成图片,生成一个请求允许你访问图片)
生成图片时间比较长,采用异步的方式,将事件丢给Kafka即可,不需要一直等着它去处理。。
注入域名,项目名,图片存放的位置
//消费分享事件
@KafkaListener(topics = TOPIC_SHARE)
public void handleShareMessage(ConsumerRecord record){
//发了一个空消息
if(record==null || record.value()==null){
logger.error("发送消息为空!");
return;
}
//将json消息转为对象,指定字符串对应的具体类型
Event event = JSONObject.parseObject(record.value().toString(), Event.class);
//转为对象之后再判断
if(event==null){
logger.error("消息格式错误!");
return;
}
//得到htmlUrl、文件名字、后缀
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) {
e.printStackTrace();
logger.error("生成长图失败:"+ e);
}
}
@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;
//分享的请求(异步的返回json,将要分享的功能路径传过来)
@RequestMapping(path = "/share",method = RequestMethod.GET)
@ResponseBody
public String share(String htmlUrl){
//随机生成图片的文件名
String fileName = CommunityUtil.generateUUID();
//异步生成长图 构建事件(主题:分享,携带参数存到map里,htmlUrl,文件名,后缀,)
Event event=new Event();
event.setTopic(TOPIC_SHARE)
.setData("htmlUrl",htmlUrl)
.setData("fileName",fileName)
.setData("suffix",".png");
//触发事件(处理异步事件别忘,消费事件)
eventProducer.fireEvent(event);
//给客户端返回访问图片的访问路径
//将路径存到map里
Map<String,Object> map=new HashMap<>();
map.put("shareUrl",domain+contextPath +"/share/image/"+fileName);
return CommunityUtil.getJSIONString(0,null,map);
}
//获得长图
//返回一个图片用response处理
@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());
}
}
}
1、导包
2、配置key、桶名和对应的url
3、注入在配置文件配置的信息,废弃头像上传的upload和xx
4、在setting里面写代码,成功的时候返回json字符串,code:0,只要不是返回这个就认为是失败。
新增方法,返回成功后,在user表的url更新下,异步
更新数据,要穿数据进去所以是post,异步的所以是@ResponseBody
//先判断一下参数空值
//拼接url(空间的url+文件名)
找到表单setting,将之前的注掉,
异步上传,获得id
点击提交触发表单提交事件,return false事件到此为此,不再向下,因为没有action,
不要把表单的内容转换为字符串。不让jquery去设置上传的类型,浏览器会自动配置。
异步更新头像路径,从表单里面取文件名
两级缓存,
一级缓存就是服务器的缓存,存在本地内存。本地缓存中没有就去访问DB,在更新到缓存中。
用户第一次访问落在了服务器1上,生成用户信息缓存(可能是是否登录的状态)。如果用户第二次访问服务器2,里面没有用户信息,就不会去访问DB,而直接认为你没有登陆,所以和用户相关的信息缓存到本地缓存不合适。
但是本地缓存可以放一些热门的帖子,第一次访问服务器1,没有就去数据库,同步到缓存。第二次请求如果落在了服务器2上,进行和服务器1一样的操作,以后的请求不管落在哪一个服务器上,就都有了缓存。
Redis缓存可以放用户相关联的数据。应用看redis里面没有数据,就去访问DB,并缓存到redis里面。下一次用户在访问服务器2,同样先去redis里面看有没有数据,有就直接返回。
Redis可以跨服务器。分布式缓存。本地缓存比redis缓存快。
使用两级缓存:
先去访问本地缓存、再去redis缓存、没有再去访问DB。之后再将数据更新到本地缓存和redis里面。我们要设置缓存的过期时间,提高了访问速度。
缓存基于时间和大小有一定的淘汰策略,
优化热门的帖子列表顺序缓存(数据变化的频率低,分值隔一段时间才更新一下,能保证一段时间不变),
本地缓存使用Caffeine,spring整合它是使用一个缓存管理器管理所有缓存(统一的淘汰、过期时间),每个缓存业务不同,缓存时间不同,不用spring去整合。
<dependency>
<groupId>com.github.ben-manes.caffeinegroupId>
<artifactId>caffeineartifactId>
<version>2.7.0version>
dependency>
设置参数,自定义参数,缓存帖子列表是,缓存能存多少帖子(15页),缓存过期的时间(3分钟),
主动淘汰(帖子数据发生变化,清掉缓存),自动淘汰(定时)缓存的是一页数据,如果因为一页中某一个帖子的变化将整页数据都淘汰不合适,所以不设置主动淘汰。
优化业务方法(service)DiscussPostService
缓存帖子的总行数,调用比较多
使用LoadingCache,一个缓存帖子列表,一个缓存帖子总行数。先声明,在初始化。缓存按照key-value来存值。
缓存只需要在服务启动或者首次调service初始化就可以了。唯一调用一次。
只缓存热门帖子,只缓存首页,首页用户没登陆,userId为0,缓存一页数据,key就有offset和limit有关。
缓存的是帖子列表,当用户查询自己的帖子时传入userId,这个时候是不走缓存的。当userId为0,才走缓存。因为一定要有key,所以就将userId作为key吧,虽然一定为null。
对缓存进行初始化
最大页数,过期时间,让配置生效build(匿名实现),怎么查询数据库得到数据(缓存怎么来的)
@Value("${caffeine.posts.max-size}")
private int maxSize;
@Value("${caffeine.posts.expire-seconds}")
private int expireSeconds;
//帖子列表缓存
private LoadingCache<String,List<DiscussPost>> postListCache;
//帖子总数缓存
private LoadingCache<Integer,Integer> postRowsCache;
public List<DiscussPost> findDiscussPosts(int userId,int offset,int limit,int orderMode){
//只缓存热门帖子,只缓存首页,首页用户没登陆,userId为0,缓存一页数据,key就有offset和limit有关。
if(userId==0 && orderMode==1){
return postListCache.get(offset+":"+limit);
}
logger.debug("load post list from DB.");
return discussPostMapper.selectDiscussPosts(userId,offset,limit,orderMode);
}
public int findDiscussPostRows(int userId){
//缓存的是帖子列表,当用户查询自己的帖子时传入userId,这个时候是不走缓存的。当userId为0,才走缓存。
if(userId==0){
return postRowsCache.get(userId);
}
logger.debug("load post list from DB.");
return discussPostMapper.selectDiscussPostRows(userId);
}
//初始化热门帖子、帖子总数缓存
@PostConstruct
public void init(){
//初始化帖子列表缓存
postListCache= Caffeine.newBuilder()
.maximumSize(maxSize)
.expireAfterWrite(expireSeconds, TimeUnit.SECONDS)
.build(new CacheLoader<String, List<DiscussPost>>() {
@Nullable
@Override
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]);
logger.debug("load post list from DB.");
return discussPostMapper.selectDiscussPosts(0,offset,limit,1);
}
});
//初始化帖子总数缓存
postRowsCache=Caffeine.newBuilder()
.maximumSize(maxSize)
.expireAfterWrite(expireSeconds,TimeUnit.SECONDS)
.build(new CacheLoader<Integer, Integer>() {
@Nullable
@Override
public Integer load(@NonNull Integer key) throws Exception {
logger.debug("load post list from DB.");
return discussPostMapper.selectDiscussPostRows(key);
}
});
}
先将其注掉,进行压力测试100个请求
优化后:大概是原来的1.5倍