Java牛客项目课_仿牛客网讨论区_第七章

本文介绍了如何在Spring Boot项目中使用Spring Security进行权限控制,并结合Redis实现数据统计和任务调度。讲解了Spring Security的配置、权限判断、退出登录处理、Redis的HyperLogLog和Bitmap数据类型测试,以及使用Quartz实现分布式定时任务。此外,还探讨了如何在Thymeleaf页面中实现权限展示和任务执行的异步处理。
摘要由CSDN通过智能技术生成

第七章、项目进阶,构建安全高效的企业服务

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 :

  1. Security提供了认证和授权两个功能,我们在DEMO里也做了演示,而在项目中应用时,我们并没有使用它的 认证功能,而单独的使用了它的授权功能,所以需要对认证的环节做一下特殊的处理,以保证授权的正常进行;
  2. Security的所有功能,都是基于Filter实现的,而Filter的执行早于Interceptor和Controller,关于Security的Filter原理,可以参考http://www.spring4all.com/article/458;
  3. 我们的解决方案是,在Interceptor中判断登录与否,然后人为的将认证结果添加到了SecurityContextHolder里。这里要注意,由于Interceptor执行晚于Filter,所以认证的进行依赖于前一次请求的Interceptor处理。比如,我登录成功了,然后请求自行重定向到了首页。在访问首页时,认证Filter其实没起作用,因为这个请求不需要权限,然后执行了Interceptor,此时才将认证结果加入SecurityContextHolder,这时你再访问/letter/list,可以成功,因为在这次请求里,Filter根据刚才的认证结果,判断出来你有了权限;
  4. 退出时,需要将SecurityContextHolder里面的认证结果清理掉,这样下次请求时,Filter才能正确识别用户的权限;
  5. 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

  • 4
    点赞
  • 12
    收藏
    觉得还不错? 一键收藏
  • 3
    评论
Kafka是一个高性能的分布式消息队列系统,可以实现高吞吐量、低延迟的消息传递。它支持点对点和发布-订阅两种消息传递模式。在仿牛客项目中使用Kafka可以实现消息的异步处理和分布式架构。 使用Kafka的第一步是创建一个主题(topic),主题既是消息的类别,也是消息在Kafka中的存储位置。可以使用命令行工具kafka-topics.bat来创建主题。例如,可以使用以下命令来创建一个名为test的主题: bin\windows\kafka-topics.bat --create --bootstrap-server localhost:9092 --replication-factor 1 --partitions 1 --topic test 上述命令中,--bootstrap-server参数指定了Kafka服务器的地址和端口,--replication-factor参数指定了主题的副本数,--partitions参数指定了主题的分区数。创建主题后,可以向主题中发送消息,并由消费者进行消费。 要列出已经存在的主题,可以使用以下命令: kafka-topics.bat --list --bootstrap-server localhost:9092 需要注意的是,以上命令中的localhost:9092是Kafka服务器的地址和端口,根据实际情况进行修改。 总结起来,在仿牛客项目中使用Kafka,首先需要创建一个主题,然后可以使用相关命令行工具进行消息的发送和消费。这样可以实现消息的异步处理和分布式架构。<span class="em">1</span><span class="em">2</span><span class="em">3</span> #### 引用[.reference_title] - *1* *2* *3* [仿牛客论坛项目学习记录——5 Kafka 构建TB级异步消息系统](https://blog.csdn.net/dadayangpei/article/details/127173098)[target="_blank" data-report-click={"spm":"1018.2226.3001.9630","extra":{"utm_source":"vip_chatgpt_common_search_pc_result","utm_medium":"distribute.pc_search_result.none-task-cask-2~all~insert_cask~default-1-null.142^v93^chatsearchT3_1"}}] [.reference_item style="max-width: 100%"] [ .reference_list ]
评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值