ATeam社区(牛客网项目第七章)

1. Spring Security

  • 简介
    Spring Security 是一个专注于为Java应用提供身份认证和授权的框架,它的强大之处在于它可以轻松扩展以满足自定义的需求
  • 特征
    • 对身份的 认证授权 提供全面的、可扩展的支持
    • 防止各种攻击,如会话固定攻击、点击劫持、CSRF攻击等
    • 支持与Servlet API、Spring MVC等Web技术集成
      在这里插入图片描述

相关连接:官网

1.1 Spring Security 功能实现简析

在这里插入图片描述
Spring Security底层使用11个Filter(过滤器)来做权限控制,如果你没有登录,连DispatcherServlet都访问不了,就更不必谈Controller了。
Filter和DispatcherServlet是JavaEE标准,DispatcherServlet是SpringMVC来定义实现的,本质上还是遵循JavaEE的标准,而Interceptor(拦截器)和Controller是SpringMVC自己的。

推荐网站:Spring For All,这里有Spring Security的中文教程文档,写的还不错:社区 Spring Security 从入门到进阶系列教程

1.2 Spring Security的试用demo

不是在真正的项目上使用,而是简化的项目(即抽出一部分功能),来使用SpringSecurity来体验一下。
demo的目录结构:
在这里插入图片描述

  1. 引入依赖
<dependency>
	<groupId>org.springframework.boot</groupId>
	<artifactId>spring-boot-starter-security</artifactId>
</dependency>
  1. 修改实体类User
    在原本的User类继承UserDetails 接口,重写如下方法:
    在这里插入图片描述
    User类具体代码:

package com.nowcoder.community.entity;

import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;

import java.util.ArrayList;
import java.util.Collection;
import java.util.Date;
import java.util.List;

public class User implements UserDetails {

    private int id;
    private String username;
    private String password;
    private String salt;
    private String email;
    private int type;
    private int status;
    private String activationCode;
    private String headerUrl;
    private Date createTime;

    public int getId() {
        return id;
    }

    public void setId(int id) {
        this.id = id;
    }

    public String getUsername() {
        return username;
    }

    public void setUsername(String username) {
        this.username = username;
    }

    public String getPassword() {
        return password;
    }

    public void setPassword(String password) {
        this.password = password;
    }

    public String getSalt() {
        return salt;
    }

    public void setSalt(String salt) {
        this.salt = salt;
    }

    public String getEmail() {
        return email;
    }

    public void setEmail(String email) {
        this.email = email;
    }

    public int getType() {
        return type;
    }

    public void setType(int type) {
        this.type = type;
    }

    public int getStatus() {
        return status;
    }

    public void setStatus(int status) {
        this.status = status;
    }

    public String getActivationCode() {
        return activationCode;
    }

    public void setActivationCode(String activationCode) {
        this.activationCode = activationCode;
    }

    public String getHeaderUrl() {
        return headerUrl;
    }

    public void setHeaderUrl(String headerUrl) {
        this.headerUrl = headerUrl;
    }

    public Date getCreateTime() {
        return createTime;
    }

    public void setCreateTime(Date createTime) {
        this.createTime = createTime;
    }

    @Override
    public String toString() {
        return "User{" +
                "id=" + id +
                ", username='" + username + '\'' +
                ", password='" + password + '\'' +
                ", salt='" + salt + '\'' +
                ", email='" + email + '\'' +
                ", type=" + type +
                ", status=" + status +
                ", activationCode='" + activationCode + '\'' +
                ", headerUrl='" + headerUrl + '\'' +
                ", createTime=" + createTime +
                '}';
    }

    // true: 账号未过期.
    @Override
    public boolean isAccountNonExpired() {
        return true;
    }

    // true: 账号未锁定.
    @Override
    public boolean isAccountNonLocked() {
        return true;
    }

    // true: 凭证未过期.
    @Override
    public boolean isCredentialsNonExpired() {
        return true;
    }

    // true: 账号可用.
    @Override
    public boolean isEnabled() {
        return true;
    }

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        List<GrantedAuthority> list = new ArrayList<>();
        list.add(new GrantedAuthority() {
            @Override
            public String getAuthority() {
                switch (type) {
                    case 1:
                        return "ADMIN";
                    default:
                        return "USER";
                }
            }
        });
        return list;
    }

}

  1. 修改UserService类
    UserService继承UserDetailsService接口,重写loadUserByUsername方法
package com.nowcoder.community.service;

import com.nowcoder.community.dao.UserMapper;
import com.nowcoder.community.entity.User;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;

@Service
public class UserService implements UserDetailsService {

    @Autowired
    private UserMapper userMapper;

    public User findUserByName(String username) {
        return userMapper.selectByName(username);
    }

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        return this.findUserByName(username);
    }
}

  1. 在config包里,创建SecurityConfig类,配置Spring Security
    authentication:认证
    authorization:授权
package com.nowcoder.community.config;

import com.nowcoder.community.entity.User;
import com.nowcoder.community.service.UserService;
import com.nowcoder.community.util.CommunityUtil;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationProvider;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.builders.WebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.crypto.password.Pbkdf2PasswordEncoder;
import org.springframework.security.web.authentication.AuthenticationFailureHandler;
import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.security.web.authentication.logout.LogoutSuccessHandler;
import org.springframework.security.web.authentication.rememberme.InMemoryTokenRepositoryImpl;

import javax.servlet.*;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    @Autowired
    private UserService userService;

    @Override
    public void configure(WebSecurity web) throws Exception {
        // 忽略静态资源的访问
        web.ignoring().antMatchers("/resources/**");
    }

	// authentication : 认证
    // AuthenticationManager: 认证的核心接口.
    // AuthenticationManagerBuilder: 用于构建AuthenticationManager对象的工具.
    // ProviderManager: AuthenticationManager接口的默认实现类.
    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        // 内置的认证规则
        // auth.userDetailsService(userService).passwordEncoder(new Pbkdf2PasswordEncoder("12345"));

        // 自定义认证规则
        // AuthenticationProvider: ProviderManager持有一组AuthenticationProvider,每个AuthenticationProvider负责一种认证.
        // 委托模式: ProviderManager将认证委托给AuthenticationProvider.
        auth.authenticationProvider(new AuthenticationProvider() {
            // Authentication: 用于封装认证信息的接口,不同的实现类代表不同类型的认证信息.
            @Override
            public Authentication authenticate(Authentication authentication) throws AuthenticationException {
                String username = authentication.getName();// 用户传入的账号
                String password = (String) authentication.getCredentials();// 用户传入的密码

                User user = userService.findUserByName(username);
                if (user == null) {
                    throw new UsernameNotFoundException("账号不存在!");
                }

                password = CommunityUtil.md5(password + user.getSalt());
                if (!user.getPassword().equals(password)) {
                    throw new BadCredentialsException("密码不正确!");
                }

                // principal: 主要信息; credentials: 证书; authorities: 权限;
                return new UsernamePasswordAuthenticationToken(user, user.getPassword(), user.getAuthorities());
            }

            // 当前的AuthenticationProvider支持哪种类型的认证.
            @Override
            public boolean supports(Class<?> aClass) {
                // UsernamePasswordAuthenticationToken: Authentication接口的常用的实现类.
                return UsernamePasswordAuthenticationToken.class.equals(aClass);
            }
        });
    }

	//authorization:授权
    @Override
    protected void configure(HttpSecurity http) throws Exception {
		//super.configure(http);//覆盖该方法
		
        // 登录相关配置
        http.formLogin()
                .loginPage("/loginpage")//跳转到登录页面的路径,见HomeController
                .loginProcessingUrl("/login")//登录表单提交的路径,见login.html页面中的form表单提交路径
                 //.successForwardUrl()//成功时跳转到哪里。但由于我们要除了一些逻辑,跳转时还要携带一些参数,于是使用下方的.successHandler()会更灵活。
                //.failureForwardUrl()//失败时跳转到哪里。同理。
                .successHandler(new AuthenticationSuccessHandler() {
                    @Override
                    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
                        response.sendRedirect(request.getContextPath() + "/index");
                    }
                })
                .failureHandler(new AuthenticationFailureHandler() {
                    @Override
                    public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException e) throws IOException, ServletException {
                        request.setAttribute("error", e.getMessage());
                        request.getRequestDispatcher("/loginpage").forward(request, response);
                    }
                });

        // 退出相关配置
        http.logout()
                .logoutUrl("/logout")
                .logoutSuccessHandler(new LogoutSuccessHandler() {
                    @Override
                    public void onLogoutSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
                        response.sendRedirect(request.getContextPath() + "/index");
                    }
                });

        // 授权配置
        http.authorizeRequests() //当用户没有登录,那么就没有任何权限
                .antMatchers("/letter").hasAnyAuthority("USER", "ADMIN") //只要你拥有"USER", "ADMIN"中任何一个权限,你就可以访问私信"/letter"页面
                .antMatchers("/admin").hasAnyAuthority("ADMIN")
                .and().exceptionHandling().accessDeniedPage("/denied");//如果权限不匹配,就跳转到"/denied"页面

		//验证码应该在账号密码处理之前先处理,如果验证码都不对,就不用看账号密码了。所以要在验证账号密码的Filter之前增加一个验证验证码的Filter验证
        // 增加Filter,处理验证码
        http.addFilterBefore(new Filter() {
            @Override
            public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
                HttpServletRequest request = (HttpServletRequest) servletRequest;
                HttpServletResponse response = (HttpServletResponse) servletResponse;
                if (request.getServletPath().equals("/login")) {
                    String verifyCode = request.getParameter("verifyCode");
                    if (verifyCode == null || !verifyCode.equalsIgnoreCase("1234")) {
                        request.setAttribute("error", "验证码错误!");
                        request.getRequestDispatcher("/loginpage").forward(request, response);
                        return; //如果验证码不对,请求不会继续向下执行
                    }
                }
                // 让请求继续向下执行.
                filterChain.doFilter(request, response);
            }
        }, UsernamePasswordAuthenticationFilter.class); //新的这个new Filter,要在UsernamePasswordAuthenticationFilter这个Filter之前过滤

		  /*
        如果勾选了"记住我",Spring Security会往浏览器里存一个cookie,cookie里存着user的用户名,
         然后,关掉浏览器/关机,下次再访问时,浏览器把cookie传给服务器,服务器根据用户名和userService查出该用户user,
         然后,会通过SecurityContextHolder把user存入SecurityContext中,
         然后,用户访问"/index"页面时,会从SecurityContext取出user的用户名,然后显示在主页上.
         */
        // 记住我
        http.rememberMe()
                .tokenRepository(new InMemoryTokenRepositoryImpl())//如果你想把数据存到Redis/数据库里,那么就自己实现TokenRepository接口,然后.tokenRepository(tokenRepository)这样
                .tokenValiditySeconds(3600 * 24)//24小时
                .userDetailsService(userService);//必须有

    }
}

  1. index.html页面
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>首页</title>
</head>
<body>

    <h1>社区首页</h1>
    <!--欢迎信息-->
    <p th:if="${loginUser!=null}">
        欢迎你, <span th:text="${loginUser.username}"></span>!
    </p>

    <ul>
        <li><a th:href="@{/discuss}">帖子详情</a></li>
        <li><a th:href="@{/letter}">私信列表</a></li>
        <li><a th:href="@{/loginpage}">登录</a></li>
 		<!--SpringSecurity规定,退出必须使用post请求。第一个<li>是get请求。第二个<li>是post请求,post请求必须使用form表单。--
        <!--<li><a th:href="@{/loginpage}">退出</a></li>-->
        <li>
            <form method="post" th:action="@{/logout}">
                <a href="javascript:document.forms[0].submit();">退出</a>
            </form>
        </li>
    </ul>

</body>
</html>
  1. HomeController类
package com.nowcoder.community.controller;

import com.nowcoder.community.entity.User;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;

@Controller
public class HomeController {

    @RequestMapping(path = "/index", method = RequestMethod.GET)
    public String getIndexPage(Model model) {
        // 认证成功后,结果会通过SecurityContextHolder存入SecurityContext中.
        Object obj = SecurityContextHolder.getContext().getAuthentication().getPrincipal();
        if (obj instanceof User) {
            model.addAttribute("loginUser", obj);
        }
        return "/index";
    }

    @RequestMapping(path = "/discuss", method = RequestMethod.GET)
    public String getDiscussPage() {
        return "/site/discuss";
    }

    @RequestMapping(path = "/letter", method = RequestMethod.GET)
    public String getLetterPage() {
        return "/site/letter";
    }

    @RequestMapping(path = "/admin", method = RequestMethod.GET)
    public String getAdminPage() {
        return "/site/admin";
    }

    @RequestMapping(path = "/loginpage", method = {RequestMethod.GET, RequestMethod.POST})
    public String getLoginPage() {
        return "/site/login";
    }

    // 拒绝访问时的提示页面
    @RequestMapping(path = "/denied", method = RequestMethod.GET)
    public String getDeniedPage() {
        return "/error/404";
    }

}
  1. login.html
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>登录</title>
</head>
<body>

    <h1>登录社区</h1>

    <form method="post" th:action="@{/login}">
        <p style="color:red;" th:text="${error}">
            <!--提示信息-->
        </p>
        <p>
            账号:<input type="text" name="username" th:value="${param.username}">
        </p>
        <p>
            密码:<input type="password" name="password" th:value="${param.password}">
        </p>
        <p>
            验证码:<input type="text" name="verifyCode"> <i>1234</i>
        </p>
        <p>
            <input type="checkbox" name="remember-me"> 记住我
        </p>
        <p>
            <input type="submit" value="登录">
        </p>
    </form>

</body>
</html>

2. 权限控制

  • 登录检查
    • 之前此案有拦截器实现了登录检查,这是简单的权限管理方案,现在将其废弃
  • 授权配置
    • 对当前系统内包含所有的请求,分配访问权限(普通用户、版主、管理员)
  • 认证方案
    • 绕过Security认证流程,采用系统原来的认证方案
  • CSRF配置
    • 防止CSRF攻击的基本原理,以及表单、AJAx相关的配置

这次是在实际的项目中引入Spring Security,进行更改

注:

牛客课程助教 V 助教 回复 Eric.Lee

  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,将这句话删掉。

Eric.Lee 回复 牛客课程助教 : 那对于下一次请求,Security是通过用户请求中带的cookie找到SecurityContextHolder中的保存的对应用户信息和权限的吗?
2020-01-22 17:17:14

牛客课程助教 V 助教 : SecurityContextHolder 底层默认采用Session存数据, 而Session依赖于Cookie.
2020-02-10 12:14:55

2.1 登录检查

之前此案有拦截器实现了登录检查,这是简单的权限管理方案,现在将其废弃
废弃原来在WebMvcConfig的拦截器设置
在这里插入图片描述

2.2 授权配置

对当前系统内包含的所有请求,分配访问全是(普通用户、版主、管理员)

  1. 在CommunityConstant接口中,新增几个属性
    /**
     * 权限:普通用户
     */
    String AUTHORITY_USER = "user";

    /**
     * 权限:管理员
     */
    String AUTHORITY_ADMIN = "admin";

    /**
     * 权限:版主
     */
    String AUTHORITY_MODERATOR = "moderator";
  1. 在config包中新建一个类SecurityConfig
package com.ateam.community.config;

import com.ateam.community.util.CommunityConstant;
import com.ateam.community.util.CommunityUtil;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.builders.WebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.security.web.access.AccessDeniedHandler;

import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.io.PrintWriter;

@Configuration
public class SecurityConfig  extends WebSecurityConfigurerAdapter implements CommunityConstant {

    @Override
    public void configure(WebSecurity web) throws Exception {
        web.ignoring().antMatchers("/resources/**");
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {

        // 授权
        http.authorizeRequests()
                .antMatchers(
                        "/user/setting",
                        "/user/upload",
                        "/user/update/password",
                        "/discuss/add",
                        "/comment/add/**",
                        "/letter/**",
                        "/notice/**",
                        "/like",
                        "follow",
                        "/unfollow"
                )
                .hasAnyAuthority(
                        AUTHORITY_ADMIN,
                        AUTHORITY_MODERATOR,
                        AUTHORITY_USER
                )
                .antMatchers(
                        "/discuss/top",
                        "/discuss/wonderful"
                        )
                .hasAnyAuthority(
                        AUTHORITY_MODERATOR
                )
                .antMatchers(
                        "/discuss/delete",
                        "/data/**",
                        "/actuator/**"
                )
                .hasAnyAuthority(
                        AUTHORITY_ADMIN
                )
                .anyRequest().permitAll() // 除了上述路径,其他请求都允许
                .and().csrf().disable(); //禁用CSRF
                 //这里取消了Spring Security的防止csrf的功能,因为老师懒得改所有异步请求让它们都有tocken,但这个功能如果有就必须所有地方都有



        // 授权不够时的处理
        http.exceptionHandling()
                .authenticationEntryPoint(new AuthenticationEntryPoint() {      // 没有登录时的处理
                 	// 没有登录.authenticationEntryPoint()是配没有登录时怎么处理
                    // 处理思路:同步请求跳转到登录页面;异步请求拼接一个json字符串返回
                    @Override
                    public void commence(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, AuthenticationException e) throws IOException, ServletException {
                        String xRequestedWith = httpServletRequest.getHeader("x-requested-with");//由浏览器的相应头里的字段,判断是同步还是异步请求
                        if ("XMLHttpRequest".equals(xRequestedWith)) {
                            // 是异步请求
                            httpServletResponse.setContentType("application/plain;charset=utf-8");
                            PrintWriter writer = httpServletResponse.getWriter();
                            writer.write(CommunityUtil.getJSONString(403,"你还没有登录"));
                        } else {
                            httpServletResponse.sendRedirect(httpServletRequest.getContextPath() + "/login");
                        }
                    }
                })
                .accessDeniedHandler(new AccessDeniedHandler() {    // 授权不够时的处理
               		 // 权限不足.accessDeniedHandler()是配权限不足时怎么处理
                    @Override
                    public void handle(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, AccessDeniedException e) throws IOException, ServletException {
                        String xRequestedWith = httpServletRequest.getHeader("x-requested-with");
                        if ("XMLHttpRequest".equals(xRequestedWith)) {
                            // 是异步请求
                            httpServletResponse.setContentType("application/plain;charset=utf-8");
                            PrintWriter writer = httpServletResponse.getWriter();
                            writer.write(CommunityUtil.getJSONString(403,"你没有此功能的权限!"));
                        } else {
                            httpServletResponse.sendRedirect(httpServletRequest.getContextPath() + "/denied");
                        }
                    }
                });

        // Security 底层默认会拦截/logout请求,进行退出处理
        // 覆盖其默认的逻辑,才能执行我们自己的退出代码
        http.logout().logoutUrl("securitylogout"); //让它去拦截一个我们不用的路径就可以了

    }
}
  1. 修改UserService
    添加如下方法:
    public Collection<? extends GrantedAuthority> getAuthorities(int userId) {

        User user = this.findUserById(userId);

        ArrayList<GrantedAuthority> list = new ArrayList<>();
        list.add(new GrantedAuthority() {
            @Override
            public String getAuthority() {
                switch (user.getUserType()) {
                    case 1 :
                        return AUTHORITY_ADMIN;
                    case 2 :
                        return AUTHORITY_MODERATOR;
                    default:
                        return AUTHORITY_USER;
                }
            }
        });
        return list;
    }

2.3 认证方案

绕过Security认证流程,采用系统原来的认证方案
修改LoginTicketInterceptor.java,修改了方法:preHandle、afterCompletion

package com.ateam.community.controller.interceptor;

@Component
public class LoginTicketInterceptor implements HandlerInterceptor {

    @Autowired
    private UserService userService;

    @Autowired
    private HostHolder hostHolder;

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {

        // 从cookie中获取凭证
        String ticket = CookieUtil.getValue(request, "ticket");
        if (ticket != null) {
            // 查询凭证
            LoginTicket loginTicket = userService.findLoginTicket(ticket);
            // 检测凭证是否有效
            if (loginTicket != null && loginTicket.getStatus() == 0 && loginTicket.getExpired().after(new Date())) {
                // 更加凭证查询用户
                User user = userService.findUserById(loginTicket.getUserId());
                // 在本次请求中持有用户
                hostHolder.setUser(user);

                // 构建用户认证的结果,并存入 SecurityContext,以便Security进行授权
                Authentication authentication = new UsernamePasswordAuthenticationToken(
                        user, user.getPassword(), userService.getAuthorities(user.getId()));
                SecurityContextHolder.setContext(new SecurityContextImpl(authentication));
            }
        }
        return true;
    }


    @Override
    public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
        User user = hostHolder.getUser();
        if (user != null && modelAndView != null) {
            modelAndView.addObject("loginUser", user);
        }
    }


    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
        hostHolder.clear();
        //LoginController类的logout()方法中才有这句话。
        //可以不要这行代码
        SecurityContextHolder.clearContext();
    }
}

2.4 CSRF配置

csrf是其他用户获得客户端的cookie和ticket从而访问了服务器,security可以生成TOKEN数据,是隐藏的,防止csrf攻击。
csrf攻击原理和Spring Security的解决方式。
在这里插入图片描述

  1. 对于form表单,Spring Security会自动生成防止CSFR的token
    在这里插入图片描述
  2. 对于异步请求,必须自己手写防止CSFR的token
    示例:
    引入依赖
<!--    thymeleaf security-->
<dependency>
    <groupId>org.thymeleaf.extras</groupId>
    <artifactId>thymeleaf-extras-springsecurity5</artifactId>
</dependency>

在需要提交异步请求页面,加上如下代码(被注释的那2行)
index.html

<head>
	<meta charset="utf-8">
	<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">

<!--	访问该页面时,在此处产生CSRF令牌-->
<!--	<meta name="_csrf" th:content="${_csrf.token}">-->
<!--	<meta name="_csrf_header" th:content="${_csrf.headerName}">-->

	<link rel="icon" href="https://static.nowcoder.com/images/logo_87_87.png"/>
	<link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/css/bootstrap.min.css" crossorigin="anonymous">
	<link rel="stylesheet" th:href="@{/css/global.css}" />
	<title>ATeam-首页</title>
</head>

对应的js,index.js

	// 发送AJAX请求之前,将CSRF令牌设置到请求的消息头中.
//    var token = $("meta[name='_csrf']").attr("content");
//    var header = $("meta[name='_csrf_header']").attr("content");
//    $(document).ajaxSend(function(e, xhr, options){
//        xhr.setRequestHeader(header, token);
//    });

注:
一般需要对所有的异步请求都要配,不然就是不安全的,无法通过。
这里老师为了省事,不做CSRF配置,那么在授权的时候需要.and().csrf().disable();

3. 置顶、加精、删除

  • 功能实现
    • 点击“置顶”,修改帖子的类型
    • 点击“加精”、“删除”,修改帖子的状态
  • 权限管理
    • 版主可以执行“置顶”、“加精”操作
    • 管理员可以执行“删除”操作
  • 按钮显示
    • 版主可以看到“置顶”、“加精”按钮
    • 管理员可以看到“删除”按钮

2.1 功能实现

  1. 数据层
    在dao包下DiscussPostMapper中增加下述方法
    int updateDiscussType(int id, int discussType);

    int updateStatus(int id, int status);

在mapper中discuss-mapper.xml中,增加相应的SQL

    <update id="updateDiscussType" >
        update discuss_post
        set discuss_type = #{discussType}
        where id = #{id}
    </update>

    <update id="updateStatus">
        update discuss_post
        set status = #{status}
        where id = #{id}
    </update>
  1. 服务层
    在service包下DiscussPostService类中新增方法

    public int updateDiscussType(int id, int discussType) {
        return discussPostMapper.updateDiscussType(id, discussType);
    }

    public int updateStatus(int id, int status) {
        return discussPostMapper.updateStatus(id, status);
    }
  1. 视图层
    在controller包下DiscussPostController中,新增方法
   // 置顶
    @RequestMapping(value = "/top", method = RequestMethod.POST)
    @ResponseBody
    public String setTop(int id) {
        discussPostService.updateDiscussType(id,1);

        // 触发发帖事件
        Event event = new Event()
                .setTopic(TOPIC_PUBLISH)
                .setUserId(hostHolder.getUser().getId())
                .setEntityType(ENTITY_TYPE_POST)
                .setEntityId(id);
        eventProducer.fireEvent(event);

        return CommunityUtil.getJSONString(0);
    }


    // 加精
    @RequestMapping(value = "/wonderful", method = RequestMethod.POST)
    @ResponseBody
    public String setWonderful(int id) {
        discussPostService.updateStatus(id,1);

        // 触发发帖事件
        Event event = new Event()
                .setTopic(TOPIC_PUBLISH)
                .setUserId(hostHolder.getUser().getId())
                .setEntityType(ENTITY_TYPE_POST)
                .setEntityId(id);
        eventProducer.fireEvent(event);

        return CommunityUtil.getJSONString(0);
    }


    // 删除
    @RequestMapping(value = "/delete", method = RequestMethod.POST)
    @ResponseBody
    public String setDelete(int id) {
        discussPostService.updateStatus(id,2);

        // 触发删帖事件
        Event event = new Event()
                .setTopic(TOPIC_DELETE)
                .setUserId(hostHolder.getUser().getId())
                .setEntityType(ENTITY_TYPE_POST)
                .setEntityId(id);
        eventProducer.fireEvent(event);

        return CommunityUtil.getJSONString(0);
    }

在event包下EventConsumer类中消费删帖事件


    // 消费删帖事件
    @KafkaListener(topics = {TOPIC_DELETE})
    public void handleDeleteMessage(ConsumerRecord record) {

        if (record == null || record.value() == null) {
            logger.error("消息的内容为空!");
            return;
        }
        // 利用fastjson将json字符串转化为Event对象
        Event event = JSONObject.parseObject(record.value().toString(), Event.class);
        if (event == null) {
            logger.error("消息格式错误!");
            return;
        }

        elasticsearchService.deleteDiscussPost(event.getEntityId());
    }

在discuss.js文件中,为置顶、加精、删除绑定3个js单击事件。

$(function (){
    $("#topBtn").click(setTop);
    $("#wonderfulBtn").click(setWonderful);
    $("#deleteBtn").click(setDelete);
});

// 置顶
function setTop() {
    $.post(
        CONTEXT_PATH + "/discuss/top",
        {"id":$("#postId").val()},
        function(data) {
            data = $.parseJSON(data);
            if(data.code == 0) {
               $("#topBtn").attr("disabled","disabled");
            } else {
                alert(data.msg);
            }
        }
    );
}

// 加精
function setWonderful() {
    $.post(
        CONTEXT_PATH + "/discuss/wonderful",
        {"id":$("#postId").val()},
        function(data) {
            data = $.parseJSON(data);
            if(data.code == 0) {
                $("#wonderfulBtn").attr("disabled","disabled");
            } else {
                alert(data.msg);
            }
        }
    );
}

// 删除
function setDelete() {
    $.post(
        CONTEXT_PATH + "/discuss/delete",
        {"id":$("#postId").val()},
        function(data) {
            data = $.parseJSON(data);
            if(data.code == 0) {
                location.href = CONTEXT_PATH + "/index"; //跳转到首页
            } else {
                alert(data.msg);
            }
        }
    );
}

2.2 权限管理

权限管理包括两部分内容:
1、服务端要拒绝没有权限的用户访问该功能。
2、客户端要,页面上不显示该用户没有权限访问的功能。

版主可以执行“置顶”、“加精”操作, 管理员可以执行“删除”操作
在SecurityConfig下配置权限。

  				.antMatchers(
                        "/discuss/top",// 置顶
                        "/discuss/wonderful"// 加精
                        )
                .hasAnyAuthority(
                        AUTHORITY_MODERATOR
                )
                .antMatchers(
                        "/discuss/delete",// 删除 
                )
                .hasAnyAuthority(
                        AUTHORITY_ADMIN
                )

版主可以执行“置顶”、“加精”按钮,管理员可以看到“删除”按钮,其他用户看不到以上按钮
在html页面上,增加xmlns:sec=“http://www.thymeleaf.org/extras/spring-security”,在每个按钮处,引入权限控制.
修改discuss-detail.html页面

<!doctype html>
<html lang="en" xmlns:th="http://www.thymeleaf.org" xmlns:sec="http://www.thymeleaf.org/extras/spring-security">


<!--只有moderator权限的用户才能看到该按钮:sec:authorize="hasAnyAuthority('moderator')"-->

<div class="float-right">
		<input type="hidden" id="postId" th:value="${post.id}">
		<button type="button" class="btn btn-danger btn-sm" id="topBtn"
			th:disabled="${post.discussType==1}" sec:authorize="hasAnyAuthority('moderator')">置顶</button>
		<button type="button" class="btn btn-danger btn-sm" id="wonderfulBtn"
			th:disabled="${post.status==1}" sec:authorize="hasAnyAuthority('moderator')">加精</button>
		<button type="button" class="btn btn-danger btn-sm" id="deleteBtn"
			th:disabled="${post.status==2}" sec:authorize="hasAnyAuthority('admin')">删除</button>
</div>

4. Redis高级数据类型

  • HyperLogLog
    • 采用一种基数算法,用于完成独立总数的统计
    • 占据空间小,无论统计多少个数据,只占12K的内存空间
    • 不精确的统计算法,标准误差为0.81%
  • Bitmap
    • 不是一种独立的数据结构,实际上就是字符串
    • 支持按位存取数据,可以将其看成是byte数组
    • 适合存储大量的连续的数据的布尔值

该节测试HyperLogLog和Bitmap的使用,还是在test包下RedisTests类中

package com.ateam.community;

import com.ateam.community.util.CommunityUtil;
import com.ateam.community.util.RedisKeyUtil;
import org.apache.kafka.common.protocol.types.Field;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.dao.DataAccessException;
import org.springframework.data.redis.connection.RedisConnection;
import org.springframework.data.redis.connection.RedisStringCommands;
import org.springframework.data.redis.core.*;
import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.context.junit4.SpringRunner;

import javax.swing.*;
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Calendar;
import java.util.Date;


With(SpringRunner.class)
@SpringBootTest
@ContextConfiguration(classes = CommunityApplication.class)//配置类
public class RedisTests {

    @Autowired
    private RedisTemplate redisTemplate;


	
	 /*HyperLogLog*/

    // 统计20万个重复数据的独立总数。相当于20万访问量,你想统计总共有多少uv(unique visitor,独立IP:是指独立用户/独立访客)
    // 统计20万个重复数据的独立总数
    @Test
    public void testHyperLogLog(){
        String redis = "test:hll:01";

        for (int i = 0; i < 100000; i++) {
            redisTemplate.opsForHyperLogLog().add(redis,i);
        }

        for (int i = 0; i < 100000; i++) {
            int r = (int) (Math.random() * 100000 + 1);
            redisTemplate.opsForHyperLogLog().add(redis, r);
        }

        long size = redisTemplate.opsForHyperLogLog().size(redis);
        System.out.println(size);
    }

    // 将3组数据合并,再统计合并后的 重复数据 的独立总数
    // 相当与你知道每天的访问量数据,你想知道这3天的独立uv
    @Test
    public void testHyperLogLogUnion(){
        String redis2 = "test:hll:02";

        for (int i = 1; i <= 10000 ; i++) {
            redisTemplate.opsForHyperLogLog().add(redis2,i);
        }

        String redis3 = "test:hll:03";
        for (int i = 5001; i <= 15000 ; i++) {
            redisTemplate.opsForHyperLogLog().add(redis3,i);
        }

        String redis4 = "test:hll:04";
        for (int i = 10001; i <= 20000 ; i++) {
            redisTemplate.opsForHyperLogLog().add(redis4,i);
        }

        String unionKey = "test:hll:union";
        redisTemplate.opsForHyperLogLog().union(unionKey,redis2,redis3,redis4);


        long size = redisTemplate.opsForHyperLogLog().size(unionKey);
        System.out.println(size);

    }

  /*Bitmap*/

    // 统计一组数据的布尔值。一年里,签到了就设为1,否则默认为0,然后统计一年里前到的数量。
    // 统计一组数据的布尔值
    @Test
    public void testBitMap(){
        String redisKey = "test:bm:01";

        // 记录 // 未设置的默认为false
        redisTemplate.opsForValue().setBit(redisKey,1,true);
        redisTemplate.opsForValue().setBit(redisKey,4,true);
        redisTemplate.opsForValue().setBit(redisKey,7,true);
        redisTemplate.opsForValue().setBit(redisKey,9,true);

        // 查询
        System.out.println(redisTemplate.opsForValue().getBit(redisKey,0));
        System.out.println(redisTemplate.opsForValue().getBit(redisKey,4));
        System.out.println(redisTemplate.opsForValue().getBit(redisKey,7));
        System.out.println(redisTemplate.opsForValue().getBit(redisKey,9));

        // 统计
        Object obj = redisTemplate.execute(new RedisCallback() {
            @Override
            public Object doInRedis(RedisConnection redisConnection) throws DataAccessException {

                return redisConnection.bitCount(redisKey.getBytes());
            }
        });

        System.out.println(obj);
    }

    // 统计3组数据的布尔值,并对这3组数据做OR运算
    @Test
    public void testBitMapOperation(){
        String redisKey2 = "test:bm:02";
        // 记录
        redisTemplate.opsForValue().setBit(redisKey2,0,true);
        redisTemplate.opsForValue().setBit(redisKey2,1,true);
        redisTemplate.opsForValue().setBit(redisKey2,2,true);

        String redisKey3 = "test:bm:03";
        // 记录
        redisTemplate.opsForValue().setBit(redisKey3,2,true);
        redisTemplate.opsForValue().setBit(redisKey3,3,true);
        redisTemplate.opsForValue().setBit(redisKey3,4,true);

        String redisKey4 = "test:bm:04";
        // 记录
        redisTemplate.opsForValue().setBit(redisKey4,4,true);
        redisTemplate.opsForValue().setBit(redisKey4,5,true);
        redisTemplate.opsForValue().setBit(redisKey4,6,true);


        String redisKey = "test:bm:or";
        Object obj = redisTemplate.execute(new RedisCallback() {
            @Override
            public Object doInRedis(RedisConnection redisConnection) throws DataAccessException {
                redisConnection.bitOp(RedisStringCommands.BitOperation.OR,
                        redisKey.getBytes(), redisKey2.getBytes(), redisKey3.getBytes(), redisKey4.getBytes());
                return redisConnection.bitCount(redisKey.getBytes());
            }
        });

        System.out.println(obj);

		//下面输出7个true
        System.out.println(redisTemplate.opsForValue().getBit(redisKey, 0));
        System.out.println(redisTemplate.opsForValue().getBit(redisKey, 1));
        System.out.println(redisTemplate.opsForValue().getBit(redisKey, 2));
        System.out.println(redisTemplate.opsForValue().getBit(redisKey, 3));
        System.out.println(redisTemplate.opsForValue().getBit(redisKey, 4));
        System.out.println(redisTemplate.opsForValue().getBit(redisKey, 5));
        System.out.println(redisTemplate.opsForValue().getBit(redisKey, 6));
    }

5. 网站数据统计

  • UV(Unique Vistor)
    • 独立访客,需通过用户IP排重统计数据
    • 每次访问都需要进行统计
    • HyperLogLog,性能好,且访问空间小
  • DAU(Daily Active User)
    • 日活跃用户,通过用户ID排重统计数据
    • 访问过一次,则认为其活跃
    • Bitmap,性能好,且可以统计精确的结果
      在这里插入图片描述
  1. 网站数据统计是基于Redis,在RedisUtil类中,新建key
public class RedisKeyUtil {

    private static final String SPLIT = ":";

    private static final String PREFIX_UV = "uv";
    private static final String PREFIX_DAU = "dau";



    // 单日uv
    public static String getUVKey(String date) {
        return PREFIX_UV + SPLIT + date;
    }

    // 区间UV
    public static String getUVKey(String startDate, String endDate) {
        return PREFIX_UV + SPLIT + startDate + SPLIT + endDate;
    }

    // 单日DAU
    public static String getDAUKey(String date) {
        return PREFIX_DAU + SPLIT + date;
    }

    // 区间DAU
    public static String getDAUKey(String startDate, String endDate) {
        return PREFIX_DAU + SPLIT + startDate + SPLIT + endDate;
    }
}
  1. 在service包下,新建一个类DataService
package com.ateam.community.service;

import com.ateam.community.util.RedisKeyUtil;
import org.apache.kafka.common.protocol.types.Field;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.dao.DataAccessException;
import org.springframework.data.redis.connection.RedisConnection;
import org.springframework.data.redis.connection.RedisStringCommands;
import org.springframework.data.redis.core.RedisCallback;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;


import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Calendar;
import java.util.Date;



@Service
public class DataService {

    @Autowired
    private RedisTemplate redisTemplate;

    private SimpleDateFormat sdf = new SimpleDateFormat("yyyyMMdd");

    // 将指定的IP计入UV
    public void recordUV(String ip) {
        String redisKey = RedisKeyUtil.getUVKey(sdf.format(new Date()));
        redisTemplate.opsForHyperLogLog().add(redisKey,ip);
    }

    // 统计指定日期范围内的UV
    public long calculateUV(Date start, Date end) {
        if (start == null || end == null) {
            throw new IllegalArgumentException("参数不能为空");
        }

        // 整理日期范围内的key
        ArrayList<String> keyList = new ArrayList<>();
        Calendar calendar = Calendar.getInstance();
        calendar.setTime(start);
        while (!calendar.getTime().after(end)) {
            String key = RedisKeyUtil.getUVKey(sdf.format(calendar.getTime()));
            keyList.add(key);
            calendar.add(Calendar.DATE,1);
        }

        // 合并这些数据
        String redisKey = RedisKeyUtil.getUVKey(sdf.format(start), sdf.format(end));
        redisTemplate.opsForHyperLogLog().union(redisKey,keyList.toArray());

        // 返回统计结果
        return redisTemplate.opsForHyperLogLog().size(redisKey);
    }


    // 将指定用户计入DAU
    public void recordDAU(int userId){
        String redisKey = RedisKeyUtil.getDAUKey(sdf.format(new Date()));
        redisTemplate.opsForValue().setBit(redisKey,userId,true);

    }

    // 统计指定日期范围内的DAU
    public long calculateDAU(Date start, Date end) {

        if (start == null || end == null) {
            throw new IllegalArgumentException("参数不能为空");
        }

        // 整理日期范围内的key
        ArrayList<byte[]> keyList = new ArrayList<>();
        Calendar calendar = Calendar.getInstance();
        calendar.setTime(start);
        while (!calendar.getTime().after(end)) {
            String key = RedisKeyUtil.getDAUKey(sdf.format(calendar.getTime()));
            keyList.add(key.getBytes());
            calendar.add(Calendar.DATE,1);
        }

        // 进行OR运算
        return (long) redisTemplate.execute(new RedisCallback() {
            @Override
            public Object doInRedis(RedisConnection redisConnection) throws DataAccessException {
                String redisKey = RedisKeyUtil.getDAUKey(sdf.format(start), sdf.format(end));
                redisConnection.bitOp(RedisStringCommands.BitOperation.OR,
                        redisKey.getBytes(),keyList.toArray(new byte[0][0])); //转成byte[0][0]格式
                return redisConnection.bitCount(redisKey.getBytes());

            }
        });
    }


}

  1. 编写拦截器DataInterceptor
package com.ateam.community.controller.interceptor;

import com.ateam.community.entity.User;
import com.ateam.community.service.DataService;
import com.ateam.community.util.HostHolder;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;



@Component
public class DataInterceptor implements HandlerInterceptor {

    @Autowired
    private DataService dataService;

    @Autowired
    private HostHolder hostHolder;

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {

        // 统计UV
        String ip = request.getRemoteHost();
        dataService.recordUV(ip);

        // 统计DAU
        User user = hostHolder.getUser();
        if (user != null) {
            dataService.recordDAU(user.getId());
        }
        return true;
    }
}

在WebMvcConfig中,配置该拦截器

package com.ateam.community.config;

import com.ateam.community.controller.interceptor.*;
import org.springframework.beans.factory.annotation.Autowired;

import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

@Configuration
public class WebMvcConfig implements WebMvcConfigurer {

    @Autowired
    private AlphaInterceptor alphaInterceptor;

    @Autowired
    private LoginTicketInterceptor loginTicketInterceptor;

//    @Autowired
//    private LoginRequiredInterceptor loginRequiredInterceptor;

    @Autowired
    private MessageInterceptor messageInterceptor;

    @Autowired
    private DataInterceptor dataInterceptor;

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(alphaInterceptor)
                .excludePathPatterns("/**/*.css","/**/*.js","/**/*.png","/**/*.jpg","/**/*.jpeg")//通配符
                .addPathPatterns("/register","/login");

        registry.addInterceptor(loginTicketInterceptor)
                .excludePathPatterns("/**/*.css","/**/*.js","/**/*.png","/**/*.jpg","/**/*.jpeg");//通配符

        // 这是自己写的登录认证,现在由springSecurity来管理,这个废弃
//        registry.addInterceptor(loginRequiredInterceptor)
//                .excludePathPatterns("/**/*.css","/**/*.js","/**/*.png","/**/*.jpg","/**/*.jpeg");//通配符

        registry.addInterceptor(messageInterceptor)
                .excludePathPatterns("/**/*.css","/**/*.js","/**/*.png","/**/*.jpg","/**/*.jpeg");//通配符

        registry.addInterceptor(dataInterceptor)
                .excludePathPatterns("/**/*.css","/**/*.js","/**/*.png","/**/*.jpg","/**/*.jpeg");//通配符
    }
}

  1. 在controller包下,新建DataController类,来处理请求
package com.ateam.community.controller;

import com.ateam.community.service.DataService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;

import java.util.Date;


@Controller
public class DataController {

    @Autowired
    private DataService dataService;

    // 统计页面
    @RequestMapping(value = "/data", method = {RequestMethod.GET,RequestMethod.POST})
    public String getDataPage() {
        return "/site/admin/data";
    }

    // 统计网站UV
    @RequestMapping(value = "/data/uv", method = RequestMethod.POST)
     //客户端传的是个日期的字符串,Spring接受这个字符串转为Date,但你要告诉它这个日期的字符串是什么格式的
    public String getUV(@DateTimeFormat(pattern = "yyyy-MM-dd") Date start,
                        @DateTimeFormat(pattern = "yyyy-MM-dd") Date end,
                        Model model) {
        long uv = dataService.calculateUV(start, end);
        model.addAttribute("uvResult",uv);
        model.addAttribute("uvStartDate",start);
        model.addAttribute("uvEndDate",end);

        return "forward:/data";
    }

    // 统计网站UV
    @RequestMapping(value = "/data/dau", method = RequestMethod.POST)
    public String getDAU(@DateTimeFormat(pattern = "yyyy-MM-dd") Date start,
                        @DateTimeFormat(pattern = "yyyy-MM-dd") Date end,
                        Model model) {
        long dau = dataService.calculateDAU(start, end);
        model.addAttribute("dauResult",dau);
        model.addAttribute("dauStartDate",start);
        model.addAttribute("dauEndDate",end);

        return "forward:/data";
    }
}

  1. 处理admin包下data.html页面
<!-- 内容 -->
<div class="main">
	<!-- 网站UV -->
	<div class="container pl-5 pr-5 pt-3 pb-3 mt-3">
		<h6 class="mt-3"><b class="square"></b> 网站 UV</h6>
		<form class="form-inline mt-3" method="post" th:action="@{/data/uv}">
			<input type="date" class="form-control" required name="start" th:value="${#dates.format(uvStartDate,'yyyy-MM-dd')}"/>
			<input type="date" class="form-control ml-3" required name="end" th:value="${#dates.format(uvEndDate,'yyyy-MM-dd')}"/>
			<button type="submit" class="btn btn-primary ml-3">开始统计</button>
		</form>
		<ul class="list-group mt-3 mb-3">
			<li class="list-group-item d-flex justify-content-between align-items-center">
				统计结果
				<span class="badge badge-primary badge-danger font-size-14" th:text="${uvResult}">0</span>
			</li>
		</ul>
	</div>
	<!-- 活跃用户 -->
	<div class="container pl-5 pr-5 pt-3 pb-3 mt-4">
		<h6 class="mt-3"><b class="square"></b> 活跃用户</h6>
		<form class="form-inline mt-3" th:action="@{/data/dau}" method="post">
			<input type="date" class="form-control" required name="start" th:value="${#dates.format(dauStartDate,'yyyy-MM-dd')}"/>
			<input type="date" class="form-control ml-3" required name="end" th:value="${#dates.format(dauEndDate,'yyyy-MM-dd')}"/>
			<button type="submit" class="btn btn-primary ml-3">开始统计</button>
		</form>
		<ul class="list-group mt-3 mb-3">
			<li class="list-group-item d-flex justify-content-between align-items-center">
				统计结果
				<span class="badge badge-primary badge-danger font-size-14" th:text="${dauResult}">0</span>
			</li>
		</ul>
	</div>				
</div>

  1. 配置权限
    我们这个数据查看页面也是需要一定的权限才能打开,所以我们需要对权限进行一个管理,如果权限不到位,无法访问,默认为管理员有权限
				 .antMatchers(
                        "/discuss/delete",
                        "/data/**" // 网站数据统计
                )
                .hasAnyAuthority(
                        AUTHORITY_ADMIN
                )

6. 任务执行和调度

  • JDK线程池
    • ExecutorService
    • ScheduledExecutorService
  • Spring线程池
    • ThreadPoolTaskExecutor
    • ThreadPoolTaskScheduler
  • 分布式定时任务
    • Spring Quartz
      在这里插入图片描述

6.1 几类线程池的简介

下面4种线程池,在分布式环境下都会出现问题。因为两台服务器都是每隔x分钟执行一次,同时执行Scheduler定时任务,容易产生冲突。即使不冲突,也不应该执行两次,只执行一次就可以了。定时任务的相关数据存在服务器的内存中,多台服务器存有多份数据。

  • JDK 线程池
    ExcecutorService
    ScheduledExecutorService
  • Spring 线程池
    ThreadPoolTaskExecutor
    ThreadPoolTaskScheduler
    在这里插入图片描述

用Quartz实现在分布式条件下执行Scheduler定时任务,就没有问题。因为定时任务的相关数据保存在同一台数据库里,只有一份定时任务的数据。如果出现同时执行定时任务,数据库会加锁让多个服务器排队访问,不会产生冲突。并且可以一个服务器访问完数据,就改数据为"已完成",那么后进来的Quartz看到任务已完成,就不会再次完成任务了。

分布式定时任务

6.2 JDK线程池 和 Spring线程池 的测试

以下配置都是与Spring 线程池有关
在application.properties文件中

# TaskExecutionProperties
# 浏览器的访问,用这个ThreadPoolTaskExecutor,浏览器有多少访问无法预判的。
spring.task.execution.pool.core-size=5
spring.task.execution.pool.max-size=15
spring.task.execution.pool.queue-capacity=100

# TaskSchedulingProperties
# 服务器的访问,用这个ThreadPoolTaskScheduler,服务器多久执行一次任务,执行什么任务,用几个线程,这些是可以预判的。所有不需要配置core-size、max-size之类,直接写需要几个线程就好。
spring.task.scheduling.pool.size=5

在config包中新建一个了ThreadPoolConfig

package com.ateam.community.config;

import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.annotation.EnableScheduling;

@Configuration
@EnableScheduling
@EnableAsync//这个参数是为了AlphaService.java里的execute1()方法上的@Async注解生效
public class ThreadPoolConfig {//不加这个配置类(配置类的名字无所谓)和@EnableScheduling注解,那么ThreadPoolTaskScheduler就无法注入(@Autowired),即无法初始化,无法得到一个ThreadPoolTaskScheduler的对象
}


在AlphaService.java中,新建方法

@Service
public class AlphaService {
    // 让该方法在多线程环境下,被异步的调用。即该方法和主线程是并发执行的
    @Async
    public void execute1() {
        logger.debug("execute1");
    }

    /*@Scheduled(initialDelay = 10000, fixedRate = 1000)*/ //这两个参数默认单位为毫秒
    public void execute2() {
        logger.debug("execute2");
    }

}

在test包下,新建测试类ThreadPoolTests

package com.ateam.community;

import com.ateam.community.service.AlphaService;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler;
import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.context.junit4.SpringRunner;
import org.springframework.web.bind.annotation.ResponseStatus;

import java.util.Date;
import java.util.concurrent.*;


@RunWith(SpringRunner.class)
@SpringBootTest
@ContextConfiguration(classes = CommunityApplication.class)
public class ThreadPoolTests {

    private static final Logger logger = LoggerFactory.getLogger(ThreadPoolTests.class);

    // JDK 普通线程池
    private ExecutorService executorService = Executors.newFixedThreadPool(5);

    // JDK 可执行定时任务的线程池
    private ScheduledExecutorService scheduledExecutorService = Executors.newScheduledThreadPool(5);

    // spirng 的 线程池
    // 普通
    @Autowired
    private ThreadPoolTaskExecutor taskExecutor;
    // 定时
    @Autowired
    private ThreadPoolTaskScheduler taskScheduler;

    @Autowired
    private AlphaService alphaService;

    // 定时睡眠
    private void sleep(long m) {
        try {
            Thread.sleep(m);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }

    // 1.JDK 普通线程池
    @Test
    public void testExecutorsService(){
        Runnable task = new Runnable() {
            @Override
            public void run() {
                logger.debug("Hello,ExecutorService");
            }
        };

        for (int i = 0; i < 10 ; i++) {
            executorService.submit(task);
        }

        sleep(10000);
    }

    // 2.JDK 定时任务线程池
    @Test
    public void testScheduledExecutorService(){
        Runnable task = new Runnable() {
            @Override
            public void run() {
                logger.debug("hello,ScheduledExecutorService");
            }
        };

        scheduledExecutorService.scheduleAtFixedRate(task,10000,1000, TimeUnit.MILLISECONDS);

        sleep(300000);
    }

    // spring 普通的线程池
    //在application.properties里配置TaskExecutionProperties,会使得Spring普通线程池比JDK普通线程池更灵活一些
    @Test
    public void testThreadPoolTaskExecutor(){
        Runnable task = new Runnable() {
            @Override
            public void run() {
                logger.debug("hello,ScheduledExecutorService");
            }
        };

        for (int i = 0; i < 10; i++) {
            taskExecutor.submit(task);
        }

        sleep(10000);
    }


    // spring 定时任务的线程池
    //在application.properties里配置TaskSchedulingProperties
    @Test
    public void testThreadPoolTaskScheduler(){
        Runnable task = new Runnable() {
            @Override
            public void run() {
                logger.debug("hello,ScheduledExecutorService");
            }
        };

        Date startTime = new Date(System.currentTimeMillis() + 10000);
        taskScheduler.scheduleAtFixedRate(task,startTime,1000);
        sleep(300000);

    }

      //Spring普通线程池(简化)
//    @Test
//    public void testThreadPoolTaskExecutorSimple(){
//
//
//        for (int i = 0; i < 20; i++) {
//            alphaService.execute1();
//        }
//        sleep(10000);

//    }

    // Spring 定时任务线程池(简化)
     //一旦执行,自动会掉alphaService.execute2()
    @Test
    public void testThreadPoolTaskSchedulerSimple(){
        sleep(30000);
    }

}

6.3 分布式定时任务的测试

分布式定时任务 - Spring Quartz
往community数据库中导入Quartz的表tables_mysql_innodb.sql。
在这里插入图片描述
Spring Quartz的几个接口:
在这里插入图片描述
Scheduler接口:Quartz核心调度工具,所有由Quartz调度的任务都是通过这个接口去调用的,不需要我们取写。
Job接口:定义一任务。其中execute()方法写明要做的事。
JobDetai接口:配置Job,名字、组、描述等配置信息。
Trigger接口:配置Job什么时候运行,以什么样的频率反复运行。

总结:Job接口定义一个任务,通过JobDetail和Trigger接口来配置这个Job。配置好以后,程序启动时,Quartz会读取配置信息,把它读到的信息立刻存到数据库里,存到那些表里。以后通过读取表来执行任务。只要数据初始化到数据库以后,JobDetail和Trigger的配置就不再使用了。就是说,JobDetail和Trigger的配置只在第一次启动时使用一下。

重要的几个表:
在这里插入图片描述

  1. 引入依赖
<!--    quartz-->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-quartz</artifactId>
</dependency>
  1. 新建一个包quartz
    在quartz包下,新建一个类AlphaJob
package com.ateam.community.quartz;

import org.quartz.Job;
import org.quartz.JobExecutionContext;
import org.quartz.JobExecutionException;


public class AlphaJob implements Job {
    @Override
    public void execute(JobExecutionContext context) throws JobExecutionException {
        System.out.println(Thread.currentThread().getName() + ": execute a quartz job.");
    }
}

  1. 配置Quartz
    在config包下,新建一个类QuartzConfig类
package com.ateam.community.config;

import com.ateam.community.quartz.AlphaJob;
import com.ateam.community.quartz.PostScoreRefreshJob;
import org.quartz.JobDataMap;
import org.quartz.JobDetail;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.quartz.JobDetailFactoryBean;
import org.springframework.scheduling.quartz.SimpleTriggerFactoryBean;


// 配置 -> 数据库 -> 调用
//这个配置仅仅是第一次被读取到,信息被初始化到到数据库里。以后,Quartz都是访问数据库去得到这些信息,不再去访问这个配置类。前提是配置了application.properties的QuartzProperties。如果没配置,那么这些配置是存到内存中,不是存到数据库中的。
@Configuration
public class QuartzConfig {

    //FactoryBean可简化Bean的实例化过程:
    //1.通过FactoryBean封装Bean的实例化过程
    //2.将FactoryBean装配到Spring容器中
    //3.将FactoryBean注入给其他的Bean
    //4.该Bean得到的是FactoryBean所管理的对象实例


    // 配置JobDetail
//    @Bean
    public JobDetailFactoryBean alphaJobDetail() {
        JobDetailFactoryBean factoryBean = new JobDetailFactoryBean();
        factoryBean.setJobClass(AlphaJob.class);
        factoryBean.setName("alphaJob");//不要和别的任务名字重复。
        factoryBean.setGroup("alphaJobGroup");//多个任务可以同属于一组
        factoryBean.setDurability(true);//任务是持久保存吗?true代表,哪怕这个任务不需要了,它的触发器都没有了,也不用删除这个任务,要一直保存着
        factoryBean.setRequestsRecovery(true);//任务是不是可恢复的。
        return factoryBean;
    }

    // 配置Trigger(SimpleTriggerFactoryBean,CronTriggerFactoryBean)
    //SimpleTriggerFactoryBean能搞定每10分钟触发一次,这种简单场景。CronTriggerFactoryBean能搞定每周五晚上10点触发一次,这种复杂场景,cron表达式。
//    @Bean
    public SimpleTriggerFactoryBean alphaTrigger(JobDetail alphaJobDetail) {//参数JobDetail alphaJobDetail的变量名,必须和JobDetailFactoryBean的函数名字一致
        SimpleTriggerFactoryBean factoryBean = new SimpleTriggerFactoryBean();
        factoryBean.setJobDetail(alphaJobDetail);
        factoryBean.setName("alphaTrigger");er取个名字
        factoryBean.setGroup("alphaTriggerGroup");
        factoryBean.setRepeatInterval(3000);//3000毫秒=3秒
        factoryBean.setJobDataMap(new JobDataMap());//Trigger底层要存储Job的一些状态,你用哪个对象来存,你要指定这个对象。这里指定了默认的类型"new JobDataMap()"

        return factoryBean;
    }


}

在application.properties中,进行Quartz的相关配置


# 这个不配置,Quartz也会起作用,因为Spring对它做了默认的配置。使得它会读取我们在QuartzConfig中配置的JobDetail和Trigger。
# 但如果不配置这些,那么Quartz会从内存读(我们配置的)数据,而不是从数据库读数据,就会在分布式运行时出问题
# QuartzProperties
spring.quartz.job-store-type=jdbc
spring.quartz.scheduler-name=communityScheduler
spring.quartz.properties.org.quartz.scheduler.instanceId=AUTO
spring.quartz.properties.org.quartz.jobStore.class=org.quartz.impl.jdbcjobstore.JobStoreTX
spring.quartz.properties.org.quartz.jobStore.driverDelegateClass=org.quartz.impl.jdbcjobstore.StdJDBCDelegate
spring.quartz.properties.org.quartz.jobStore.isClustered=true
spring.quartz.properties.org.quartz.threadPool.class=org.quartz.simpl.SimpleThreadPool
spring.quartz.properties.org.quartz.threadPool.threadCount=5
  1. 在test包下,新建一个测试类QuartzTests
@RunWith(SpringRunner.class)
@SpringBootTest
@ContextConfiguration(classes = CommunityApplication.class)//配置类
public class QuartzTests {


    @Autowired
    private Scheduler scheduler;

    @Test
    public void testDeleteJob(){
        try {
            boolean b = scheduler.deleteJob(new JobKey("alphaJob", "alphaJobGroup"));//删除一个Job,即删除数据库里Job相关的数据。new JobKey(Job名字, Job的组的名字),这两个参数唯一的确定一个Job
            System.out.println(b);
        } catch (SchedulerException e) {
            e.printStackTrace();
        }


    }
}

7. 热帖排行

在这里插入图片描述
如何计算帖子的分数:
一般的帖子,时间越久,量化的分数越低,而点赞和回复的数量越多,量化的分数越高。一般对点赞回复增加的分数做一个log取对数,增加刚刚发布的时候回复点赞的影响,随着时间的推移,时间的负面作用体现,分数下降,这点和实际的情况相似。

本小节将要做的2个事:

  1. 在发帖、点赞、评论、加精时,把该帖子id放入Redis的Set集合里,然后设置定时任务,每隔5分钟,把这些帖子id挨个取出,然后重新计算它们的分数score,然后更新数据库和elasticsearch里的discusspost的score的值。
  2. 修改了selectDiscussPosts()及其对应的一条线(Mapper、Dao、Service、Controller、thymeleaf页面),加了int orderMode这个参数。然后,可以按照帖子的热度排序。可以切换用"最新/热度"排序

7.1 数据层改动

在RedisUtil类中,增加获得key的方法

public class RedisKeyUtil {

    private static final String SPLIT = ":";
    private static final String PREFIX_POST = "post";


    // 帖子分数
    public static String getPostScoreKey() {
        return PREFIX_POST + SPLIT + "score";
    }
}

在dao包下DiscussPostMapper类中,修改selectDiscussPosts方法,增加更新分数的方法

    //orderMode默认是0,表示按照时间先后来排,最新的排在前面。orderMode为1时,表示按照热度来排,就是按照帖子的分数来排。
    List<DiscussPost> selectDiscussPosts(int userId, int offset, int limit, int orderMode);
    
    int updateScore(int id, double score);

在mapper包下discuss-mappe.xml中,修改对应的SQL

 <select id="selectDiscussPosts" resultType="com.ateam.community.entity.DiscussPost">
        select <include refid="selectFields"></include>
        from discuss_post
        where status != 2
        <if test="userId != 0">
            and user_id = #{userId}
        </if>
        <if test="orderMode==0">
            order by discuss_type desc, create_time desc
        </if>
        <if test="orderMode==1">
            order by discuss_type desc, score desc, create_time desc
        </if>
        limit #{offset}, #{limit}
    </select>
    
    <update id="updateScore">
        update discuss_post
        set score = #{score}
        where id = #{id}
    </update>

7.2 定时计算帖子分数

  1. 发帖(帖子要有个初始分数,越新的帖子分数越高)、点赞、评论、加精,都要重新计算帖子的分数。
    在DiscussPostController.java,加精,发帖,方法中,修改代码

    // 加精
    @RequestMapping(value = "/wonderful", method = RequestMethod.POST)
    @ResponseBody
    public String setWonderful(int id) {
        discussPostService.updateStatus(id,1);

        // 触发发帖事件
        Event event = new Event()
                .setTopic(TOPIC_PUBLISH)
                .setUserId(hostHolder.getUser().getId())
                .setEntityType(ENTITY_TYPE_POST)
                .setEntityId(id);
        eventProducer.fireEvent(event);

        // 计算帖子分数
        String redisKey = RedisKeyUtil.getPostScoreKey();
        redisTemplate.opsForSet().add(redisKey,id);

        return CommunityUtil.getJSONString(0);
    }

  @RequestMapping(value = "/add", method = RequestMethod.POST)
    @ResponseBody
    public String addDiscussPost(String title, String content) {
        User user = hostHolder.getUser();
        if (user == null) {
            return CommunityUtil.getJSONString(403,"你还没有登录哦!");
        }

        DiscussPost post = new DiscussPost();
        post.setUserId(user.getId());
        post.setTitle(title);
        post.setContent(content);
        post.setCreateTime(new Date());

        discussPostService.addDiscussPost(post);

        // 触发发帖事件
        Event event = new Event()
                .setTopic(TOPIC_PUBLISH)
                .setUserId(user.getId())
                .setEntityType(ENTITY_TYPE_POST)
                .setEntityId(post.getId());
        eventProducer.fireEvent(event);

        // 计算帖子分数
        String redisKey = RedisKeyUtil.getPostScoreKey();
        redisTemplate.opsForSet().add(redisKey,post.getId());

        // 报错额情况,将来回一种处理
        return CommunityUtil.getJSONString(0,"发布成功");
    }

在CommentControlle和LikeController中,判断式评论帖子和点赞帖子,才修改,这里以点赞为例
在这里插入图片描述
2. 在quartz包下,新建一个类PostScoreRefreshJob,来定时刷新帖子分数

package com.ateam.community.quartz;

import com.ateam.community.entity.DiscussPost;
import com.ateam.community.service.DiscussPostService;
import com.ateam.community.service.ElasticsearchService;
import com.ateam.community.service.LikeService;
import com.ateam.community.util.CommunityConstant;
import com.ateam.community.util.RedisKeyUtil;
import org.quartz.Job;
import org.quartz.JobExecutionContext;
import org.quartz.JobExecutionException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.BoundSetOperations;
import org.springframework.data.redis.core.RedisTemplate;

import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.Date;

public class PostScoreRefreshJob implements Job, CommunityConstant {

    private static final Logger logger = LoggerFactory.getLogger(PostScoreRefreshJob.class);

    @Autowired
    private RedisTemplate redisTemplate;

    @Autowired
    private DiscussPostService discussPostService;

    @Autowired
    private LikeService likeService;

    @Autowired
    private ElasticsearchService elasticsearchService;

    // ATeam社区纪元
    private static final Date epoch;

    static {
        try {
            epoch = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").parse("2014-09-01 00:00:00");
        } catch (ParseException e) {
            throw new RuntimeException("初试化ATeam纪元失败!" + e.getMessage());
        }
    }


    @Override
    public void execute(JobExecutionContext context) throws JobExecutionException {

        String redisKey = RedisKeyUtil.getPostScoreKey();
        BoundSetOperations operations = redisTemplate.boundSetOps(redisKey);

        if (operations.size() == 0) {
            logger.info("[任务取消] 没有需要要刷洗的帖子!");
            return;
        }

        logger.info("[任务开始] 正在刷洗帖子分数:" + operations.size());
        while (operations.size() > 0) {
            this.refresh((Integer) operations.pop());
        }
        logger.info("[任务结束] 帖子分数刷洗完毕");
    }

    private void refresh(int postId) {
        DiscussPost post = discussPostService.findDiscussPostById(postId);

        if (post == null) {
            logger.error("该帖子不存在:id = " + postId);
            return;
        }

        // 是否是精华
        boolean wonderful = post.getStatus() == 1;
        // 评论数量
        int commentCount = post.getCommentCount();
        // 点赞数量
        long likeCount = likeService.findEntityLikeCount(ENTITY_TYPE_POST,postId);

        // 计算权重
        double w = (wonderful ? 75 : 0) + commentCount * 10 + likeCount * 2;
        // 分数 = 帖子权重 + 距离天数
        double score = Math.log10(Math.max(w,1))
                + (post.getCreateTime().getTime() - epoch.getTime()) / (1000 * 3600 * 24);

        //更新帖子分数
        discussPostService.updateScore(postId,score);
        //同步搜索数据
        post.setScore(score);
        elasticsearchService.saveDiscussPost(post);

    }
}


  1. 在config包下QuartzConfig类中,配置刚才的定时任务
package com.ateam.community.config;

import com.ateam.community.quartz.AlphaJob;
import com.ateam.community.quartz.PostScoreRefreshJob;
import org.quartz.JobDataMap;
import org.quartz.JobDetail;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.quartz.JobDetailFactoryBean;
import org.springframework.scheduling.quartz.SimpleTriggerFactoryBean;


// 配置 -> 数据库 -> 调用
@Configuration
public class QuartzConfig {

    //FactoryBean可简化为Bean的实例化过程:
    //1.通过FactoryBean封装Bean的实例化过程
    //2.将FactoryBean装配到Spring容器中
    //3.将FactoryBean注入给其他的Bean
    //4.该Bean得到的是FactoryBean所管理的对象实例



    // 刷新帖子分数的任务
    // 配置JobDetail
    @Bean
    public JobDetailFactoryBean postScoreRefreshJobDetail() {
        JobDetailFactoryBean factoryBean = new JobDetailFactoryBean();
        factoryBean.setJobClass(PostScoreRefreshJob.class);
        factoryBean.setName("postScoreRefreshJob");
        factoryBean.setGroup("communityJobGroup");
        factoryBean.setDurability(true);
        factoryBean.setRequestsRecovery(true);
        return factoryBean;
    }

    // 配置Trigger(SimpleTriggerFactoryBean,CronTriggerFactoryBean)
    @Bean
    public SimpleTriggerFactoryBean postScoreRefreshTrigger(JobDetail postScoreRefreshJobDetail) {
        SimpleTriggerFactoryBean factoryBean = new SimpleTriggerFactoryBean();
        factoryBean.setJobDetail(postScoreRefreshJobDetail);
        factoryBean.setName("postScoreRefreshTrigger");
        factoryBean.setGroup("communityTriggerGroup");
        factoryBean.setRepeatInterval(1000 * 60 * 60); // 1h刷新一次
        factoryBean.setJobDataMap(new JobDataMap());

        return factoryBean;
    }
}

  1. 修改有使用到selectDiscussPosts方法的代码
    找到那些地方使用过selectDiscussPosts方法,然后逐一修改。
    在这里插入图片描述
  2. 最新/最热
    先去HomeController再去index.html。刷新页面时,没有orderMode参数,于是要在Controller里把orderMode默认设为0,然后传给thymeleaf页面,于是显示"最新"。

    @RequestMapping(value = "/index", method = RequestMethod.GET)
    public String getIndexPage(Model model, Page page,
                               @RequestParam(name = "orderMode", defaultValue = "0") int orderMode){

        // 方法调用前,SpringMVC会自动实例化Model和Page,并将Page注入Model中
        // 所以,在thymeleaf中可以直接访问Page对象中的数据
        page.setRows(discussPostService.findDiscussPostRows(0));
        page.setPath("/index?orderMode=" + orderMode);

        List<DiscussPost> list = discussPostService.findDiscussPosts(0, page.getOffset(), page.getLimit(),orderMode);
        ArrayList<Map<String, Object>> discussPosts = new ArrayList<>();
        if (list != null){
            for (DiscussPost post : list){
                HashMap<String, Object> map = new HashMap<>();
                map.put("post",post);

                User user = userService.findUserById(post.getUserId());
                map.put("user",user);

                // 获取每个帖子赞的数量
                long likeCount = likeService.findEntityLikeCount(ENTITY_TYPE_POST,post.getId());
                map.put("likeCount", likeCount);

                discussPosts.add(map);
            }
        }

        model.addAttribute("discussPosts",discussPosts);
        model.addAttribute("orderMode",orderMode);

        return "/index";
    }

index.html
在这里插入图片描述

8. 生成长图

  • wkhtmltopdf
    • wkhtmltopdf url file
    • wkhtmltoimage url file
  • java
    • Runtime.getRuntime().exec()
      在这里插入图片描述

8.1 wkhtmltopdf

wkhtmltopdf目录结构:(wkhtmltopdf网址
在这里插入图片描述
配置环境变量
在这里插入图片描述

  1. 命令行使用wk
# 实际应该在路径“D:\wkhtmltopdf\bin”来执行软件wkhtmltopdf 的命令,因为我们在环境变量中配置了该路径,于是就在哪里都可以输入该命令。

# 把网页转为pdf,存到文件夹里,文件夹不会自动生成,需要你手动创建,生成的pdf文件需要你自己命名。
C:\Users\dell>wkhtmltopdf https://www.nowcoder.com E:/data/wk/wk-pdfs/1.pdf

# 把网页转为图片
C:\Users\dell>wkhtmltoimage https://www.nowcoder.com E:\打码相关软件\wkhtmltopdf\my-data\wk-images\E:/data/wk/wk-images/1.png
C:\Users\dell>wkhtmltoimage --quality 75 https://www.nowcoder.com E:/data/wk/wk-images/2.png		# --quality 75,表示把图片压缩到原有质量的75%,这样做是为了减小图片所占用的空间(MB)
  1. 使用java调用wk
    Runtime.getRuntime.exec()
    在test包下,新建一个类WKTests
public class WKTests {

  public static void main(String[] args){
      String cmd = "D:\\wkhtmltopdf\\bin\\wkhtmltoimage --quality 75 http://localhost:8080/community/index E:\\data\\wk\\wk-images\\4.png";
      try {
           //Runtime执行命令,只是把命令提交给本地的操作系统,剩下的事由操作系统来执行。Java不会等操作系统,Java会直接执行下一行。于是会先输出ok,后生成图片。
           //即main函数和生成图片是异步的,是并发的.
          Runtime.getRuntime().exec(cmd);
          System.out.println("ok.");
      } catch (IOException e) {
          e.printStackTrace();
      }
  }
}

8.2 项目实现生成长图

实现的功能:

  1. 把网页(根据url) 生成长图 保存到本地:http://localhost:8080/community/share?htmlUrl=https://www.nowcoder.com
  2. 把本地的图片通过一个url展现到网页上:http://localhost:8080/community/share/image/图片不包括后缀名的图片名(即UUID)

wk相关配置:
application.properties

# wk 
# 网页转pdf/图片  #这两个是我们自定义的配置,因为这两个路径在上线前后会路径不一样,所以要做成可配置的路径

#上线后,wkhtmltopdf软件的wkhtmltoimage命令的安装路径
wk.image.command=D:/wkhtmltopdf/bin/wkhtmltoimage
#上线后,wkhtmltopdf软件生成的图片的存放位置
wk.image.storage=E:/data/wk/wk-images

在config包下,新建一个类WKConfig

//这里的@Configuration不是为了配置,而是使得该类在程序开始执行时就初始化该类为Bean,然后,为了在程序一开始就执行一次init()方法.
@Configuration
public class WKConfig {

    private static final Logger logger = LoggerFactory.getLogger(WKConfig.class);

    @Value("${wk.image.storage}")
    private String wkImageStorage;

	 //Spring的@PostConstruct注解在方法上,表示此方法是在Spring实例化该Bean之后马上执行此方法,之后才会去实例化其他Bean,并且一个Bean中@PostConstruct注解的方法可以有多个。
    @PostConstruct
    public void init() {
        // 创建WK图片目录
        File file = new File(wkImageStorage);
        if (!file.exists()) {
            file.mkdir();
            logger.info("创建WK图片目录:" + wkImageStorage);
        }
    }
}

在controller包下,新建一个类ShareController类


@Controller
public class ShareController implements CommunityConstant {

    private static final Logger logger = LoggerFactory.getLogger(ShareController.class);

    @Autowired
    private EventProducer eventProducer;

    @Value("${community.path.domain}")
    private String domain;

    @Value("${server.servlet.context-path}")
    private String contextPath;

    @Value("${wk.image.storage}")
    private String wkImageStorage;

    @RequestMapping(value = "share", method = RequestMethod.GET)
    @ResponseBody
    public String share(String htmlUrl) {
        // 文件名
        String fileName = CommunityUtil.generateUUID();

        // 事件
        // 异步生成长图
        Event event = new Event()
                .setTopic(TOPIC_SHARE)
                .setData("htmlUrl",htmlUrl)
                .setData("fileName",fileName)
                .setData("suffix",".png");
        eventProducer.fireEvent(event);

        Map<String, Object> map = new HashMap<>();
        map.put("shareUrl", domain + contextPath + "/share/image/" + fileName);

        return CommunityUtil.getJSONString(0,null, map);
    }

	// 获取长图
    @RequestMapping(value = "/share/image/{fileName}", method = RequestMethod.GET)
    public void getShareImage(@PathVariable("fileName") String fileName, HttpServletResponse response) {
        if (StringUtils.isBlank(fileName)) {
            throw new IllegalArgumentException("文件名不能为空!");
        }

        response.setContentType("image/png");
        File file = new File(wkImageStorage + "/" + fileName + ".png");
        try (ServletOutputStream os = response.getOutputStream();
             FileInputStream fis = new FileInputStream(file)
        ) {
            byte[] buffer = new byte[1024];
            int len = 0;
            while ((len = fis.read(buffer)) != -1) {
                os.write(buffer,0,len);
            }
        } catch (IOException e) {
            logger.error("获取图片失败:" + e.getMessage());
        }
    }
}

在EventConsumer类中,新增消费分享事件

@Component
public class EventConsumer implements CommunityConstant {

    private static final Logger logger = LoggerFactory.getLogger(Event.class);

    @Autowired
    private MessageService messageService;

    @Autowired
    private DiscussPostService discussPostService;

    @Autowired
    private ElasticsearchService elasticsearchService;

    @Value("${wk.image.storage}")
    private String wkImageStorage;

    @Value("${wk.image.command}")
    private String wkImageCommand;


    // 消费分享事件
    @KafkaListener(topics = {TOPIC_SHARE})
    public void handleShareMessage(ConsumerRecord record) {
        if (record == null || record.value() == null) {
            logger.error("消息的内容为空!");
            return;
        }
        // 利用fastjson将json字符串转化为Event对象
        Event event = JSONObject.parseObject(record.value().toString(), Event.class);
        if (event == null) {
            logger.error("消息格式错误!");
            return;
        }

        String htmlUrl = (String) event.getData().get("htmlUrl");
        String fileName = (String) event.getData().get("fileName");
        String suffix = (String) event.getData().get("suffix");

        String cmd = wkImageCommand + " --quality 75 "
                + htmlUrl + " " + wkImageStorage + "/" + fileName + suffix;
        try {
            Runtime.getRuntime().exec(cmd);
            logger.info("生成长图成功:" + cmd);
        } catch (IOException e) {
            logger.error("生成长图失败:" + e.getMessage());
        }

    }

}

9. 优化网站性能

在这里插入图片描述
只有本地缓存和DB(数据库)时。如果是热门帖子这种数据,两个服务器都从数据库取出热门帖子,然后更新到本地缓存里,同一份数据在两个服务器都存了一份,这没有问题。如果是和用户相关的问题,比如用户登录凭证,在服务器1上用户是登录状态,在服务器2上的本地缓存里没有该凭证,用户就没有登录状态了,这就不行了。
在这里插入图片描述
这种情况,可以用Redis解决,两个服务器都从Redis里获取用户的登录状态。
在这里插入图片描述
本地缓存空间小,Redis缓存空间比较大。大部分请求会被这两级拦截下来。
如果本地缓存和Redis里都没有请求的数据,那么会从数据库里取得数据,然后送到app(service组件)里,然后再从app把从db取到的数据更新到本地缓存和Redis。
在这里插入图片描述
缓存的数据淘汰机制是,基于时间和使用频率来淘汰。

变化不那么频繁的数据,我们才使用缓存。如果是帖子的最新,那不能缓存,更新太快,所以这节课来缓存帖子的最热排序,因为这个排序是一段时间才更新一次分数的,所以在两次更新分数之间,帖子的热门排名是不变的。

  1. 导入依赖
<!--    caffeine-->
<dependency>
    <groupId>com.github.ben-manes.caffeine</groupId>
    <artifactId>caffeine</artifactId>
    <version>2.7.0</version>
</dependency>
  1. caffeine配置
# caffeine  #都是自定义的配置 
# posts是帖子的意思,如果要缓存评论可以caffeine.comments
# 第一个表示缓存15页帖子, 第二个表示,存到缓存里的数据到3分钟,自动就会被清理掉,这叫自动淘汰。 还有一种主动淘汰,是帖子更新了,会淘汰掉缓存中的这个帖子。
# 这里只有自动淘汰,没有主动淘汰,因为我们是一页一页缓存的,如果一个帖子更新了,把这一页的帖子都刷掉,不合适
# 就是说,这一页帖子,评论点赞之类的数量,在这3分钟内会有一定延迟,和真实数量对不上,但不影响使用。
caffeine.posts.max-size=15 
caffeine.posts.expire-seconds=180
  1. 测试caffeine
    在test包下,新建一个测试类CaffeineTests
@RunWith(SpringRunner.class)
@SpringBootTest
@ContextConfiguration(classes = CommunityApplication.class)//配置类
public class CaffeineTests {

    @Autowired
    private DiscussPostService discussPostService;

    @Test
    public void initDataForTest() {
        for (int i = 0; i < 300000; i++) {
            DiscussPost post = new DiscussPost();
            post.setUserId(111);
            post.setTitle("互联网求职暖春计划");
            post.setContent("今年的就业形势,确实不容乐观。过了个年,仿佛跳水一般,整个讨论区哀鸿遍野!19届真的没人要了吗?!18届被优化真的没有出路了吗?!大家的&ldquo;哀嚎&rdquo;与&ldquo;悲惨遭遇&rdquo;牵动了每日潜伏于讨论区的牛客小哥哥小姐姐们的心,于是牛客决定:是时候为大家做点什么了!为了帮助大家度过&ldquo;寒冬&rdquo;,牛客网特别联合60+家企业,开启互联网求职暖春计划,面向18届&amp;19届,拯救0 offer!");
            post.setCreateTime(new Date());
            post.setScore(Math.random() * 2000);
            discussPostService.addDiscussPost(post);
        }
    }

    @Test
    public void testCache(){
        System.out.println(discussPostService.findDiscussPosts(0,0,10,1));
        System.out.println(discussPostService.findDiscussPosts(0,0,10,1));
        System.out.println(discussPostService.findDiscussPosts(0,0,10,1));
        System.out.println(discussPostService.findDiscussPosts(0,0,10,0));
    }

}

  1. 项目中运用caffeine
    在service包下,修改DiscussPostService类中的相关代码
@Service
public class DiscussPostService {

    private static final Logger logger = LoggerFactory.getLogger(DiscussPostService.class);

    @Resource
    private DiscussPostMapper discussPostMapper;

    @Autowired
    private SensitiveFilter sensitiveFilter;

    @Value("${caffeine.posts.max-size}")
    private int maxSize;

    @Value("${caffeine.posts.expire-seconds}")
    private int expireSeconds;

    // Caffeine 核心接口:Cache, LoadingCache, AsyncLoadingCache
    // LoadingCache:同步缓存,如果缓存内没有,要来读取的线程排队等待,Caffeine去把数据取到缓存里,然后挨个去读取缓存里的这个数据。我们用这个。
    // AsyncLoadingCache:异步缓存,支持多个线程并发的同时读取同一数据


    // 缓存都是按照key缓存value
    // 帖子列表缓存
    private LoadingCache<String, List<DiscussPost>> postListCache;

    // 帖子总数缓存
    private LoadingCache<Integer, Integer> postRowsCache;

    @PostConstruct
    private void init() {
        // 初试化帖子列表缓存
        postListCache = Caffeine.newBuilder()
                .maximumSize(maxSize)
                .expireAfterAccess(expireSeconds, TimeUnit.SECONDS)
                .build(new CacheLoader<String, List<DiscussPost>>() {
                    //当尝试从缓存中取数据的时候,caffeine会看看缓存里有没有数据,有就返回,没有就调用下面的load()方法从数据库中取出该数据
                    //所以load()方法要告诉caffeine怎么从数据库中取得该数据
                    @Override
                    public @Nullable List<DiscussPost> load(@NonNull String key) throws Exception {
                        if (key.length() == 0) {
                            throw new IllegalArgumentException("参数错误");
                        }

                        String[] params = key.split(":");
                        if (params == null || params.length != 2) {
                            throw new IllegalArgumentException("参数错误");
                        }

                        int offset = Integer.parseInt(params[0]);
                        int limit = Integer.parseInt(params[1]);

                        // 二级缓存:Redis - > mysql

                        logger.debug("load post list from DB");
                        return discussPostMapper.selectDiscussPosts(0,offset,limit,1);
                    }
                });
        // 初试化帖子总数缓存
        postRowsCache = Caffeine.newBuilder()
                //这里本来应该单独在application.properties里配置的,
                //如:caffeine.posts-count.max-size和caffeine.posts-count.expire-seconds。但老师懒的单独配,复用缓存帖子的也不出错,所以这样了。
                .maximumSize(maxSize)
                .expireAfterAccess(expireSeconds, TimeUnit.SECONDS)
                .build(new CacheLoader<Integer, Integer>() {
                    @Override
                    public @Nullable Integer load(@NonNull Integer key) throws Exception {

                        logger.debug("load post rows from DB");
                        return discussPostMapper.selectDiscussPostRows(key);
                    }
                });
    }

    public List<DiscussPost> findDiscussPosts(int userId, int offset, int limit, int orderMode) {

        if (userId == 0 && orderMode == 1) {
            //userId和orderMode是一定的,那么就把剩下的两个变化的量组合为key,中间用什么隔开都可以,如用冒号隔开
            return postListCache.get(offset + ":" + limit);
        }

        logger.debug("load post list from DB");
        return discussPostMapper.selectDiscussPosts(userId, offset, limit,orderMode);
    }

    public int findDiscussPostRows(int userId) {
        if (userId == 0) {
            //这里其实不需要userId作为key,因为这里的userId永远是0,但是又必须要有key,所以只能这样,一直用0作为key。
            return postRowsCache.get(userId);
        }

        logger.debug("load post rows from DB");
        return discussPostMapper.selectDiscussPostRows(userId);
    }
}

  1. 性能测试
    可以使用压力测试工具模拟多个用户同时访问服务器,进行测试。
    推荐使用Jmeter:Apache JMeter - Download Apache JMeter网址
  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值