目录
1、Spring Security
DispatchServlet是javaEE的规范;Filter也是javaEE的规范;Filter可以对DispatchServlet进行过滤;DispatchServlet(核心组件)、Interceptor、Controller是Spring MVC框架的组件;
Spring Security底层使用Filter对整个请求加以拦截实现安全控制;
重定向:浏览器访问服务器,比如访问的是A组件,实现的是删除帖子操作,没有什么内容需要传给页面;而重定向到的是B组件,一般B组件与A组件关联性不大,比如B组件是显示帖子的详情页面;重定向是一个新的请求,链接被重置,一般不好由开始的请求传递参数给新定向到的请求;只能用跨请求的cookie,session去传,比较麻烦。
请求转发:浏览器发给服务器一个请求,先由A处理,然后还需要转发给B进行继续处理,B处理后再返回给页面(协作处理);始终是一个请求,共用一个request;B返回的结构,由于请求没有变化,浏览器仍然认为是A的地址;如A是提交登录的表单,有可能在服务端认证失败,失败的时候需要回到登录页面,登录页面是另外的一个请求B,A与B共同完成任务;也可以在A中直接返回登录页面的模板,而转发相当于是复用了这段程序,如果B中还有别的逻辑,就优于在A中直接返回;
Spring Security重构代码
1、废弃掉之前采用的登录检查拦截器,这是简单的权限管理方案:直接将拦截器的配置类WebMvcConfig的loginRequiredInterceptor失效即可;登录授权是更高级的登陆检查,将使用Spring Security来进行登录授权;
2、增加SecurityConfig类用来授权配置(需要登录授权的页面);
3、在userService中增加权限判别方法getAuthorities,在之前的认证方案(认证用户登录状态)中构建用户认证结果(需要增加在LoginTicketInterceptor中构建),并存入SecurityContext中,以便于Security进行授权(没有使用Security的认证,自己的认证结果需要传入到Security中);在整个请求结束之后还需要增加:清除结果i) LoginTicketInterceptor类的afterCompletion ii) LoginController中的logout
4、防止CSRF攻击:如浏览器之前已经访问过服务器了,服务器已经给浏览器发过ticket,浏览器将ticket已经存在cookie中了;当浏览器向服务器发送一个请求带有表单的页面,服务器就返回一个带有表单的页面,浏览器可以填好后提交,但此时如果用户没有提交表单,而是去访问了另外一个X网站,如果网站为病毒网站,它可以窃取到浏览器中cookie中的值,此病毒网站可以伪造用户的身份去提交表单数据;Security的解决:当启动Security服务时,浏览器向服务器发送一个请求带有表单的页面,服务器就返回一个带有表单的页面是带了一个隐藏的数据 tocken作为凭证,tocken是每次请求随机生成的,X网站无法获得这个随机的tocken,就防止了CSRF攻击;
实现版主及管理员功能:版主:置顶、加精华功能;管理员:删除功能;
`type` int(11) DEFAULT NULL COMMENT '0-普通; 1-置顶;', #为帖子的类型
`status` int(11) DEFAULT NULL COMMENT '0-正常; 1-精华; 2-拉黑;', #为帖子的状态
1、mapper.xml中增加修改类型(置顶就是修改类型为1)、修改状态(加精、删除就是修改状态)的方法;
2、在dao、service层分别添加方法;
3、在Controller层添加置顶、加精、删除方法,方法中除了添加数据库的操作,还要添加触发发帖的kafka生产事件用于ElasticSearch消费相关的操作。
4、在html首页页面修改这三个的相关操作,异步更新用discuss.js中的方法实现;(给Controller传入id,Controller处理完会返回状态,从而实现功能)
//%%%%%%%%%%%%%%%%%%%%%%%%%%%%增加置顶、加精、删除的方法
//置顶:异步请求 type: 0-普通; 1-置顶;
@RequestMapping(path = "/top", method = RequestMethod.POST)
@ResponseBody
public String setTop(int id){
discussPostService.updateType(id, 1);
//触发事件同步到es中
Event event = new Event().setTopic(TOPIC_PUBLISH)
.setUserId(hostHolder.getUser().getId())
.setEntityType(ENTITY_TYPE_POST)
.setEntityId(id);
eventProducer.fireEvent(event);
return CommunityUtil.getJSONString(0);
}
//加精:异步请求 status: '0-正常; 1-精华; 2-拉黑
@RequestMapping(path = "/wonderful", method = RequestMethod.POST)
@ResponseBody
public String setWonderful(int id){
discussPostService.updateStatus(id, 1);
//触发事件同步到es中
Event event = new Event().setTopic(TOPIC_PUBLISH)
.setUserId(hostHolder.getUser().getId())
.setEntityType(ENTITY_TYPE_POST)
.setEntityId(id);
eventProducer.fireEvent(event);
// @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@计算帖子分数
String redisKey = RedisKeyUtil.getPostScoreKey();
redisTemplate.opsForSet().add(redisKey, id);
return CommunityUtil.getJSONString(0);
}
// 删除
@RequestMapping(path = "/delete", method = RequestMethod.POST)
@ResponseBody
public String setDelete(int id) {
discussPostService.updateStatus(id, 2);
// @@@@@@@@@@@@@@@@新增的,触发删帖事件;还需要增加处理事件的消费
Event event = new Event()
.setTopic(TOPIC_DELETE)
.setUserId(hostHolder.getUser().getId())
.setEntityType(ENTITY_TYPE_POST)
.setEntityId(id);
eventProducer.fireEvent(event);
return CommunityUtil.getJSONString(0);//返回一个状态传给页面,页面会做相关的判断和处理
}
Kafka消费事件:删除帖子,更新帖子的动作同步到es中,对es中的数据进行相应的更改
//=======================消费传来的触发消息:根据消息向elasticsearch服务器增加数据==============================
// 消费发帖事件
@KafkaListener(topics = {TOPIC_PUBLISH})
public void handlePublishMessage(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;
}
//得到的消息没有问题,就开始处理事件
//先查找到帖子,再将帖子保存到elasticsearch服务器
DiscussPost post = discussPostService.findDiscussPostById(event.getEntityId());
elasticsearchService.saveDiscussPost(post);
}
//@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@消费删帖事件
@KafkaListener(topics = {TOPIC_DELETE})
public void handleDeleteMessage(ConsumerRecord record) {
if (record == null || record.value() == null) {
logger.error("消息的内容为空!");
return;
}
Event event = JSONObject.parseObject(record.value().toString(), Event.class);
if (event == null) {
logger.error("消息格式错误!");
return;
}
//得到的消息没有问题,就开始处理事件
//先查找到帖子,再将帖子保存到elasticsearch服务器
elasticsearchService.deleteDiscussPost(event.getEntityId());
}
实现权限管理:
1、用到thymeleaf-extras-springsecurity5包,引入;
2、在SecurityConfig类中引入对路径实现置顶、加精和删除的权限控制;
3、在页面上仅显示有权限的按钮:使用 thymeleaf security的组件获得相关权限的判断,从而对按钮是否显示做出判断;
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter implements CommunityConstant {
@Override
public void configure(WebSecurity web) throws Exception {
web.ignoring().mvcMatchers("/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_ADMIN,
AUTHORITY_MODERATOR,
AUTHORITY_USER
)
.antMatchers(
"/discuss/top",
"/discuss/wonderful"
)
.hasAnyAuthority(//置顶与加精的权限
AUTHORITY_MODERATOR
)
.antMatchers(
"/discuss/delete",
"/data/**" //***新增data的权限管理
,"/actuator/**"
)
.hasAnyAuthority(//删除的权限
AUTHORITY_ADMIN
)
.anyRequest().permitAll()
.and().csrf().disable();//csrf配置,此处禁用掉;后面可以添加上去,对于异步的请求则需要修改html页面
//权限不够时的处理
http.exceptionHandling()
.authenticationEntryPoint(new AuthenticationEntryPoint() {
//没有登录
@Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException e) throws IOException, ServletException {
String xRequestedWith = request.getHeader("x-requested-with");
if ("XMLHttpRequest".equals(xRequestedWith)) {
//XMLHttpRequest表示一个异步请求
//XMLHttpRequest 用于在后台与服务器交换数据。这意味着可以在不重新加载整个网页的情况下,对网页的某部分进行更新。
response.setContentType("application/plain;charset=utf-8");//返回一个普通的字符串,先确保传入是json格式
PrintWriter writer = response.getWriter();
writer.write(CommunityUtil.getJSONString(403, "您还没有登录哦!"));
} else {
response.sendRedirect(request.getContextPath() + "/login");
}
}
})
.accessDeniedHandler(new AccessDeniedHandler() {
//权限不足的情况
@Override
public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException e) throws IOException, ServletException {
String xRequestedWith = request.getHeader("x-requested-with");
if ("XMLHttpRequest".equals(xRequestedWith)) {
//XMLHttpRequest表示一个异步请求
//XMLHttpRequest 用于在后台与服务器交换数据。这意味着可以在不重新加载整个网页的情况下,对网页的某部分进行更新。
response.setContentType("application/plain;charset=utf-8");//返回一个普通的字符串,先确保传入是json格式
PrintWriter writer = response.getWriter();
writer.write(CommunityUtil.getJSONString(403, "您没有访问此功能的权限!"));
} else {
response.sendRedirect(request.getContextPath() + "/denied");
}
}
});
//Security底层默认会拦截/logout请求,进行退出处理
//覆盖它默认的逻辑,才能执行我们自己的退出代码
http.logout().logoutUrl("/securitylogout");//即把拦截的路径改为其他的路径,此路径不做任何处理;相当于使拦截登出页面的动作无效
}
}
2、Redis统计网站数据
HyperLogLog:1)采用一种基数算法,用于完成独立总数的统计。2)占据空间小,无论统计多少个数据(接近 2^64 个不同元素的基数),只占12K的内存空间。3)不精确的统计算法,标准误差为 0.81%。4)HyperLogLog并不存储元素,存储元素消耗的内存空间比较大,而是给某一个有重复元素的数据集合评估需要的空间单元娄,所以它没有办法进行存储;
Bitmap:1)不是一种独立的数据结构,实际上就是字符串。2)支持按位存取数据,可以将其看成是byte数组。3)适合存储大量的连续的数据的布尔值。
UV(Unique Visitor):1)独立访客,需通过用户 IP 排重统计数据。2)每次访问都要进行统计。3)HyperLogLog ,性能好,且存储空间小。4)使用ip来统计访客:可以将未登陆的用户也统计进来。
DAU(Daily Active User):1)日活跃用户,需通过用户 ID 排重统计数据。2)访问过一次,则认为其活跃。3)Bitmap,性能好、且可以统计精确的结果。4)日活跃用户,仅统计登陆用户,且精确统计;可以使用userId来作为BItmap数据的索引值,存入状态。
实现统计独立访客及日活跃用户
记录UV数据到Redis、记录DAU数据到Redis,在每次请求的时候都需要记录,所以可以放在拦截器中实现;
1、在RedisKeyUtil类中添加单日UV,区间UV、单日DAU、区间DAU的rediskey
2、新增DataService类实现将ip计入UV的方法(传入ip,添加到redis HyperLogLog数据集中,以天为单位)、统计区间UV的方法(整理数据的key,合并数据,返回统计结果)、将指定用户计入DAU的方法(传入userId)、统计区间DAU的方法(整理数据的key,OR运算所有数据,返回统计结果);
3、新增DataInterceptor类,实现了HandlerInterceptor接口,用来在每次请求前将数据(ip、user)计入UV和DAU数据库;
4、将拦截类DataInterceptor注入WebMvcConfig拦截配置类中,使在特定路径生效;
5、新增DataController类,实现表现层的逻辑:统计页面直接返回响应页面(POST、GET)、统计网站UV(POST)、统计网站DAU(POST);传进来start和end日期,返回UV统计数据、返回DAU统计数据;
6、修改data.html页面;
7、在SecurityConfig配置类中增加对data访问的控制;只有管理员才能访问。
Service:
@Service
public class DataService {
@Autowired
private RedisTemplate redisTemplate;
private SimpleDateFormat df = new SimpleDateFormat("yyyyMMdd");//时间为某一天。以天为单位
// 将指定的IP计入UV,某一天内的redisKey均相同
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)) {//如果存到日历中的时间不大于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());//keyList需要转化为String数组
//返回统计结果
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中的数据转化为一个二维的byte数组
return connection.bitCount(redisKey.getBytes());
}
});
}
}
DataInterceptor:
@Component
public class DataInterceptor implements HandlerInterceptor {
@Autowired
private DataService dataService;
@Autowired
private HostHolder hostHolder;
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// 统计UV
String ip = request.getRemoteHost();
dataService.recordUV(ip);
// 统计DAU
User user = hostHolder.getUser();
if (user != null) {
dataService.recordDAU(user.getId());
}
return true;
}
}
DataController:
// 统计页面
//由于后面的getUV方法是post请求,又会将请求转发到此请求,所以这个请求也需要支持POST请求
@RequestMapping(path = "/data", method = {RequestMethod.GET, RequestMethod.POST})
public String getDataPage() {
//如果这里还有其他逻辑则可以被getUV复用( return "forward:/data")
return "/site/admin/data";
}
// 统计网站UV
@RequestMapping(path = "/data/uv", method = RequestMethod.POST)
public String getUV(@DateTimeFormat(pattern = "yyyy-MM-dd") Date start,
@DateTimeFormat(pattern = "yyyy-MM-dd") Date end, Model model) {
//使用@DateTimeFormat(pattern = "yyyy-MM-dd")注解:
// 页面上是一个字符串传进来需要告诉服务器是日期,且设置一个日期的格式
long uv = dataService.calculateUV(start, end);
model.addAttribute("uvResult", uv);
model.addAttribute("uvStartDate", start);//参数又传给model,便于页面日期的默认显示
model.addAttribute("uvEndDate", end);
return "forward:/data";
}
// 统计活跃用户
@RequestMapping(path = "/data/dau", method = RequestMethod.POST)
public String getDAU(@DateTimeFormat(pattern = "yyyy-MM-dd") Date start,
@DateTimeFormat(pattern = "yyyy-MM-dd") Date end, Model model) {
long dau = dataService.calculateDAU(start, end);
model.addAttribute("dauResult", dau);
model.addAttribute("dauStartDate", start);
model.addAttribute("dauEndDate", end);
return "forward:/data";//此处也可以直接return "/site/admin/data";
//“forward:”(请求的转发) 声明当前方法只能处理整个请求的一半,还需要交给其他方法继续处理请求;
// 对于客户端始终是一个请求,所以转发到的/data请求需要支持post请求
}
3、Quartz实现分布式定时任务
JDK普通线程池、JDK可执行定时任务的线程池、spring普通线程池、spring可以执行定时任务的线程池;
对于Spring线程池:1、需要先在application.properties配置文件中进行配置:线程池容量等参数;2、新增ThreadPoolConfig配置类,使用的注解有@Configuration、@EnableAsync、@EnableScheduling。
(1)集成Quartz
通过job接口定义任务;通过JobDetail接口和trigger接口来配置job;配置好之后,程序启动的时候quartz就读取配置信息,并将读到的配置信息立刻存到数据库中,自动的存到表中,以后就去读取表来执行这个任务;只要数据初始到数据库后,那些配置就不再使用了。后面只要已启动程序,就会自动读取数据库中的配置,并执行定时任务;
1、新增quartz包,包中新增AlphaJob类继承了Job接口,相当于需要实现的任务;
2、新增QuartzConfig配置类:配置JobDetail和配置trigger
3、将数据库表tables_mysql_innodb.sql导入Community中
4、Quartz底层有默认的线层池的配置,默认是读取内存,不会存到数据库;也可以自己来做配置:在application.properties中配置,可以将QuartzConfig配置类得到的信息存到数据库中;
5、可以注入Scheduler,使用其中deleteJob方法来删除配置类信息;
(2)线程知识补充
Thread.sleep()方法需要异常处理的原因:
阻塞方法不同于一般的要运行较长时间的方法。一般方法的完成只取决于它要做的事情,以及是否有足够多可用的计算资源(CPU周期和内存)。而阻塞方法的完成还取决于一些外部的事件,例如计时器到期,I/O完成,或者另一个线程的动作(释放一个锁,设置一个标志,或者将一个任务放入一个工作队列中)。一个方法在他们工作做完后即可结束,而阻塞方法较难以预测,因为他们取决于外部事件。阻塞方法可能因为等不到所等的事件而无法终止,因此令阻塞方法可取消 就非常有用。由 Thread 提供并受 Thread.sleep() 和 Object.wait() 支持的中断机制就是一种取消机制;它允许一个线程请求另一个线程停止它正在做的事情。当一个方法抛出 InterruptedException 时,它是在告诉您,如果执行该方法的线程被中断,它将尝试停止它正在做的事情而提前返回,并通过抛出 InterruptedException 表明它提前返回。 行为良好的阻塞库方法应该能对中断作出响应并抛出 InterruptedException,以便能够用于可取消活动中,而不至于影响响应。
线程状态与资源消耗:
只有runnable到running时才会占用cpu时间片,其他都会出让cpu时间片。线程的资源有不少,但应该包含CPU资源和锁资源这两类。
sleep(long mills):让出CPU资源,但是不会释放锁资源。wait():让出CPU资源和锁资源。
锁是用来线程同步的,sleep(long mills)虽然让出了CPU,但是不会让出锁,其他线程可以利用CPU时间片了,但如果其他线程要获取sleep(long mills)拥有的锁才能执行,则会因为无法获取锁而不能执行,继续等待。但是那些没有和sleep(long mills)竞争锁的线程,一旦得到CPU时间片即可运行了。
在java.lang.Thread类中,定义了线程的以下六种状态(同一个时刻线程只能有一种状态)
NEW(新建) 这个状态是指线程刚创建,但还未调用线程的start()方法进行启动,对应上图中的New状态
RUNNABLE(可运行) 这个状态是指线程处于正常运行中,对应上图中的Runnable与Running状态,若获得CPU时间片则处于Running状态,否则处于Runnable状态
BLOCKED(阻塞)这个状态是指当线程进入一个被synchronized修饰的方法或语句块(也叫临界区)时,如果锁已被其他线程所获取,则会进入此状态。对应上图中的锁定Blocked状态
WAITING(无限等待)这个状态是指当线程获取到对象锁进入临界区内,调用了锁对象的wait()方法或者thread.join()方法后进入的状态。线程进入该状态会释放锁对象,并且等待被其他的线程所唤醒,处于这种状态的线程不会被分配CPU时间片。该状态对应上图中的等待Blocked状态
TIMED_WAITING(有限等待)这个状态是指当线程调用了以下方法后进入的状态。在指定时间后由系统将它们自动唤醒,处于这种状态的线程不会被分配CPU时间片。该状态对应上图中的Blocked状态1.Thread.sleep()方法2.Object.wait()方法(有timeout参数)3.thread.join()方法(有timeout参数)
TERMINATED(死亡) 这个状态是指run()方法已执行完毕,线程进入死亡状态
(3)定时任务计算帖子分数进行热帖排行
分数计算公式:log(精华分 + 评论数*10 + 点赞数*2 + 收藏数*2) + (发布时间 – zewei纪元)
在新增帖子给一个初始的分,在加精、评论、点赞操作的时候就要更新计算帖子分数;使用Redis缓存这些操作数据,key为字符串表示帖子分数有变化,value为一个set集合存的是帖子id,即一旦有这些操作就将帖子id加入到Redis的set中。刷新帖子的分数:对同一个帖子可能会有很多重复的操作出现,只需要得到有哪些帖子的分数需要刷新,所以需要去重操作,需要的这些操作也不需要关注顺序,所以将帖子id存到set中而不是队列中;
增加定时任务:每隔一段时间就根据各项值由公式计算更新的帖子的分数:
1、在RedisUtil中增加帖子分数的key;
2、在Controller层:在新增帖子,加精、评论、点赞操作的时候给Redis添加数据;
3、新增PostScoreRefreshJob类,实现定时的任务:重新计算帖子分数,将帖子分数更新到数据库;还要将将帖子更新保存到ElasticSearch(帖子一旦有修改都需要同步到es);
4、配置QuartzConfig类:设置定时时间,设置执行PostScoreRefreshJob类中的任务等;
5、改变首页帖子展现的方法,之前是按类型、时间的顺序,现在按类型、分数、时间顺序重构;将原来选择帖子的方法增加一个参数:模式orderMode,默认为0按原来的当时排,为1就按照分数来排;修改Controller页面逻辑:当点最新的时候使用模式0,且默认访问首页为0,点最热的时候使用模式1;最后修改index.html。
PostScoreRefreshJob:
public class PostScoreRefreshJob implements Job, CommunityConstant {
Logger logger = LoggerFactory.getLogger(PostScoreRefreshJob.class);
@Autowired
private LikeService likeService;
@Autowired
private DiscussPostService discussPostService;
@Autowired
private RedisTemplate redisTemplate;
@Autowired
private ElasticsearchService elasticsearchService;
private final static Date epoch;
//static{}(即static块),会在类被加载的时候执行且仅会被执行一次,一般用来初始化静态变量和调用静态方法
//在单例设计模式中采用静态内部类中的静态域存储唯一一个实例,既保证了线程安全,又保证了懒加载
// (只有一个线程可以触发类的加载过程,且在此类加载过程中,其他线程处于阻塞状态, 等待类加载的完成。)
static {//初始化常量 放置在static静态代码块中
try {
epoch = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").parse("2017-09-01 00:00:00" );
} catch (ParseException e) {
throw new RuntimeException("初始化liu纪元失败!" + 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());//弹出的是其中一个的帖子id
}
logger.info("[任务结束] 帖子分数刷新完毕!");
}
private void refresh(int postId) {
DiscussPost post = discussPostService.findDiscussPostById(postId);
if (post == null) {//如有个帖子本来需要更新,但被管理员删了,帖子为空
logger.error("该帖子不存在: id = " + postId);
return;
}
// 是否精华
boolean wonderful = post.getStatus() == 1;
// 评论数量
int commentCount = post.getCommentCount();
// 点赞数量
long likeCount = likeService.findEntityLikeCount(ENTITY_TYPE_POST, postId);
// 计算权重
double w = (wonderful ? 75 : 0) + commentCount * 10 + likeCount * 2;
// 分数 = 帖子权重 + 距离天数
double score = Math.log10(Math.max(w, 1))
+ (post.getCreateTime().getTime() - epoch.getTime()) / (1000 * 3600 * 24);
// 更新帖子分数
discussPostService.updateScore(postId, score);
// 同步搜索数据
post.setScore(score);
elasticsearchService.saveDiscussPost(post);//更新到es
}
}
QuartzConfig:
BeanFactory是Spring容器的顶层接口;FactoryBean可以简化Bean的实例化过程:1、通过FactoryBean封装了Bean的实例化过程;2、将FactoryBean装配到Spring容器里;3、将FactoryBean注入给其他的Bean;4、该Bean得到的是FactoryBean所管理的对象实例;
// 配置 -> 数据库 -> 调用
@Configuration
public class QuartzConfig {
// FactoryBean可简化Bean的实例化过程:
// 1.通过FactoryBean封装Bean的实例化过程.
// 2.将FactoryBean装配到Spring容器里.
// 3.将FactoryBean注入给其他的Bean.
// 4.该Bean得到的是FactoryBean所管理的对象实例.
//初始化JobDetailFactoryBean比初始化JobDetail要容易的多
// 配置JobDetail
// @Bean
public JobDetailFactoryBean alphaJobDetail() {
//JobDetailFactoryBean
JobDetailFactoryBean factoryBean = new JobDetailFactoryBean();
factoryBean.setJobClass(AlphaJob.class);//声明管理的Bean类型是什么
factoryBean.setName("alphaJob");//任务名字
factoryBean.setGroup("alphaJobGroup");
factoryBean.setDurability(true);//声明任务持久保存
factoryBean.setRequestsRecovery(true);//声明此任务是可恢复的
return factoryBean;
}
// 配置Trigger(SimpleTriggerFactoryBean:简单的,比如说每十分钟执行一次,
// CronTriggerFactoryBean:复杂的,比如每个月的月底半夜两点的任务执行,表达式)
// @Bean
public SimpleTriggerFactoryBean alphaTrigger(JobDetail alphaJobDetail) {
//参数为JobDetail alphaJobDetail
SimpleTriggerFactoryBean factoryBean = new SimpleTriggerFactoryBean();
factoryBean.setJobDetail(alphaJobDetail);
factoryBean.setName("alphaTrigger");
factoryBean.setGroup("alphaTriggerGroup");
factoryBean.setRepeatInterval(3000);//设置每个任务执行间隔时间
factoryBean.setJobDataMap(new JobDataMap());
//Trigger底层要存储一些对象的状态,用默认JobDataMap类型来存
return factoryBean;
}
// 刷新帖子分数任务
@Bean
public JobDetailFactoryBean postScoreRefreshJobDetail() {
JobDetailFactoryBean factoryBean = new JobDetailFactoryBean();
factoryBean.setJobClass(PostScoreRefreshJob.class);//任务类PostScoreRefreshJob
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;
}
}
相应Controller中添加:
// @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@计算帖子分数
String redisKey = RedisKeyUtil.getPostScoreKey();
redisTemplate.opsForSet().add(redisKey, post.getId());
Service重构:
//重构
public List<DiscussPost> findDiscussPosts(int userId, int offset, int limit,int orderMode) {
if (userId == 0 && orderMode == 1) {// *******查询并在缺失的情况下使用同步的方式来构建一个缓存
return postListCache.get(offset + ":" + limit);
}
logger.debug("load post list from DB.");
return discussPostMapper.selectDiscussPosts(userId, offset, limit,orderMode);
}
//重构
public int findDiscussPostRows(int userId) {
if (userId == 0) {// *******查询并在缺失的情况下使用同步的方式来构建一个缓存
return postRowsCache.get(userId);
}
logger.debug("load post rows from DB.");
return discussPostMapper.selectDiscussPostRows(userId);
}
4、将文件上传到云服务器
(1)生成长图保存本地
生成长图工具:Wkhtmltopdf:wkhtmltopdf url file,wkhtmltoimage url file;
Java:Runtime.getRuntime().exec()
1、系统上线以后,换成linux系统,执行命令路径就不对了,所以配置类中配置,方便修改;图片生成的位置也需要改变,文件夹是手动创建的,所以也去配置;
2、启动的时候还需要检测文件是否存在,不存在的话就创建:在配置类中进行配置,配置类,在启动的时候先实例化它,会调用init方法在方法中创建目录;
3、新增ShareController类,方法share:用于对指定的htmlUrl输出长图,生成图片时间比较长,用一种异步的方式;以事件驱动:先到kafka,生产并消费:在EventConsumer中添加消费事件;方法:getShareImage:用于页面获取长图:页面以html协议形式获取磁盘中的文件;
(2)文件上传至云服务器
客户端上传:客户端将数据提交给云服务器,并等待其响应。用户上传头像时,将表单数据提交给云服务器。
服务器直传:应用服务器将数据直接提交给云服务器,并等待其响应。分享时,服务端将自动生成的图片,直接提交给云服务器。
1、在pom包中加入七牛云依赖;
2、在application.properties中配置秘钥和空间;
3、将密匙和空间注入到UserController文件中,在UserController中重构提交表单方法,主要生成凭证和文件名给表单;再在表单将信息提交给云服务器;
4、在UserController增加异步的方法:响应成功时,将数据库中的User表中的header_url信息进行更新,更新为七牛云的路径;
5、处理对应的表单:把之前上传到本地的代码清理掉,给此页面再创建一个setting.js文件实现异步上传到云服务器的逻辑;
整体逻辑:访问/setting路径:生成凭证和文件名字,设置七牛云响应信息,返回表单页面给客户端;客户端setting页面:得到model传的文件名和凭证,在上传文件框上传文件。点击提交提交按钮时:转到setting.js执行:将表单信息(文件)上传至七牛云服务器:指定上传地址,会得到一个响应的信息,由信息如果成功则更新头像访问路径:访问/user/header/url(服务端的异步方法),传递fileName给此方法,执行此方法:将数据库中的头像路径设置为云服务器中头像的路径并返回响应标志,由响应标志如果成功更新页面(更新页面头像);
Kafka消费事件:
//spring可以执行定时任务的线程池
@Autowired
private ThreadPoolTaskScheduler taskScheduler;
@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;
//%%%%%%%%%%%-----消费分享事件-------%%%%%%%%%%%%%%%%%
@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("生成长图成功");//这句先执行完
} catch (IOException e) {
logger.error("生成长图失败" + e.getMessage());
}
//启用定时器,监视图片并上传至云服务器;
//需要完成任务就关闭;还需要考虑传不成功的情况:1生成图片失败 2传图失败:网络或云服务器出现问题
UploadTask uploadTask = new UploadTask(fileName, suffix);
Future future = taskScheduler.scheduleAtFixedRate(uploadTask, 500);//每隔500ms执行一次,返回值future可以用来停止任务
uploadTask.setFuture(future);
}
//&&&&&&&&&&&&&&&&&上传到云服务器重构&&&&&&&&&&&&&&&&&&&&
//由于生成图片较慢,上传到云服务器上需要已经生成好的长图,阻塞等待也不好,
// 在这里使用定时器每个一段时间判断是否生成完成;
//在多个服务器环境中,虽然都部署了这个类,消费者有一个抢占的机制,一旦有个消息过来,只能有一台服务器占到;
//所以这个方法只有某个服务器在执行,可以用普通定时器;不必用Spring quartz定时器;
// 而之前的程序是在程序启动时就运行,在每一个服务器就要开始运行(由完成的功能需求决定)
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) {//30s内
logger.error("执行时间过长,终止任务:" + fileName);
future.cancel(true);//停止任务
return;
}
//上传失败
if (uploadTimes > 3) {
logger.error("上传次数过多,终止任务:" + fileName);
future.cancel(false);
return;
}
String filePath = wkImageStorage + "/" + fileName + suffix;
File file = new File(filePath);
if (file.exists()) {
logger.info(String.format("开始第%d次上传[%s]", ++uploadTimes, fileName));
//设置响应信息,七牛云的格式;
//通常都是异步的响应信息,成功的时候给页面传递json字符串
StringMap policy = new StringMap();
policy.put("returnBody", CommunityUtil.getJSONString(0));
//生成上传凭证
Auth auth = Auth.create(accessKey, secretKey);
String uploadToken = auth.uploadToken(shareBucketName, fileName, 3600, policy);
//上传至指定机房
UploadManager manager = new UploadManager(new Configuration(Zone.zone2()));//华南区Zone.zone2()
try {//开始上传图片
Response response = manager.put(filePath,fileName ,
uploadToken, null, "image/", false);
//处理响应结果
JSONObject json = JSONObject.parseObject(response.bodyString());
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));
// e.printStackTrace();
}
} else {
logger.info("等待图片生成[" + fileName + "]");
}
}
}
UserController增加异步的方法:
@Value注解:该注解的作用是将我们配置文件的属性读出来。Value的值有两类: ① ${ property : default_value }② #{ obj.property? :default_value }。第一个注入的是外部配置文件对应的property,第二个则是SpEL表达式对应的内容。 那个default_value,就是前面的值为空时的默认值。注意二者的不同,#{}里面那个obj代表对象。
@Value("${qiniu.key.access}")
private String accessKey;
//该注解的作用是将我们配置文件的属性读出来
// Value的值有两类:
//① ${ property : default_value }
//② #{ obj.property? :default_value }
//第一个注入的是外部配置文件对应的property,第二个则是SpEL表达式对应的内容。 那个
//default_value,就是前面的值为空时的默认值。注意二者的不同,#{}里面那个obj代表对象。
@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() {
// return "/site/setting";
// }
//……………………………云服务器重构,将头像上传至云服务器………………………………………………
@LoginRequired
@RequestMapping(path = "/setting", method = RequestMethod.GET)
public String getSettingPage(Model model) {
//上传文件名称:
//文件名称使用随机字符串:1、如果使用用户id等信息作为文件名称,上传新的图像就会覆盖之前的
// 图像,无法记录之前的图像信息;2、使用同名的传到云服务器,可以传上去,
// 但是访问的时候在一段时间之内不会立即生效,因为云服务器都有缓存
String fileName = CommunityUtil.generateUUID();
//设置响应信息,七牛云的格式;
//通常都是异步的响应信息,成功的时候给页面传递json字符串
StringMap policy = new StringMap();
policy.put("returnBody", CommunityUtil.getJSONString(0));
//生成上传凭证
Auth auth = Auth.create(accessKey, secretKey);
String uploadToken = auth.uploadToken(headerBucketName, fileName, 3600, policy);
//将数据提交给模板,模板利用这些数据重新构造表单,表单的提交方式改为异步的,提交给七牛云
model.addAttribute("uploadToken", uploadToken);
model.addAttribute("fileName", fileName);
return "/site/setting";
}
ShareController(长图入Kafka):
@Controller
public class ShareController implements CommunityConstant {
private 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;
//对指定的htmlUrl输出长图(先到kafka,生产并消费)
@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);
//生成的长图在本地磁盘路径,用户远程不能直接访问这个路径,需要通过HTTP协议来访问;
// 访问路径 还通过下级来实现访问的路径
//&&&&&&&&&&&&&&&&&上传到云服务器重构&&&&&&&&&&&&&&&&&&&&
map.put("shareUrl", shareBucketUrl + "/" + fileName);
return CommunityUtil.getJSONString(0, null, map);
}//启动后直接访问http://localhost:8080/community/share会为空
//需要传入一个参数:htmlUrl给方法:
// 直接输入http://localhost:8080/community/share?htmlUrl=https://blog.youkuaiyun.com/liuzewei2015
//页面输出{"code":0,"shareUrl":"http://localhost:8080/community/share/image/454f4c91d39243a3adfd971a05302a74"}
//直接访问生成的http://localhost:8080/community/share/image/454f4c91d39243a3adfd971a05302a74 页面即显示生成的长图
//页面获取长图(页面以html协议形式获取磁盘中的文件)
//&&&&&&&&&&&&&&&&&上传到云服务器重构时舍弃此方法&&&&&&&&&&&&&&&&&&&&
@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");//说明输出的是什么:image表示输出图片,png表示格式
File file = new File(wkImageStorage + "/"+ fileName + ".png");
try {
OutputStream out = response.getOutputStream();
FileInputStream input = new FileInputStream(file);
byte[] buffer = new byte[1024];
int b = 0;
while ((b = input.read(buffer)) != -1) {
out.write(buffer, 0, b);
}
} catch (IOException e) {
logger.error("获取长图失败");
}
}
}