牛客一些总结

Java项目开发与性能优化实践

项目感悟

1.开发首页

1.thymeleaf

<li th:class="|page-item ${page.current==1?'disabled':''}|">
class表示什么意思

th:class="|page-item ${page.current==1?'disabled':''}|" 表示根据条件判断动态设置class属性的值。在这个例子中,通过${page.current==1?'disabled':''}表达式来判断当前页是否为第一页,如果是第一页,则添加 disabled 类名,否则不添加。
用于初始页和末页不允许在进行点击下一页

2.激活账号

1.业务层UserService

  • 发送激活邮件到qq
//激活的邮件
Context context =new Context();
context.setVariable("email",user.getEmail());
//http://localhost:8080/community/activation/101/code 激活链接
String url =domain+contextPath+"/activation/"+user.getId()+"/"+user.getActivationCode();
context.setVariable("url",url);
String content = templateEngine.process("/mail/activation", context);
mailClient.sendMail(user.getEmail(),"激活账号",content);

这段代码主要是用于发送用户激活账号的邮件。它使用了 Thymeleaf 模板引擎来生成邮件内容,并调用邮件客户端(mailClient)来发送邮件。
首先,代码创建了一个 Context 对象,用于存储模板中需要渲染的变量值。在这个例子中,通过 context.setVariable 方法将用户的邮箱地址和激活链接设置到了 context 中。
接下来,代码使用了 Thymeleaf 模板引擎来生成邮件内容。具体地,它调用了 templateEngine.process 方法,并传入了模板路径和 context 对象,以获取渲染后的 HTML 内容。
最后,代码调用了邮件客户端的 sendMail 方法来发送邮件,将邮件内容和用户邮箱地址作为参数传递给该方法。
总的来说,这段代码的作用是根据用户的邮箱地址和激活链接,生成一封包含激活链接的邮件,并将该邮件发送给用户。通过这种方式,用户可以点击邮件中的激活链接来激活他们的账号。

image-20231126125208675

2.cookie和session

@CookieValue 是Spring框架提供的注解,用于在方法参数上获取指定名称的Cookie值。

当使用@CookieValue("code")时,它会从请求中获取名为"code"的Cookie的值,并将其赋值给使用该注解的方法参数。

为什么Session在分布式情况下尽量少使用

因为负载均衡无法保证同个用户的多次请求都能到同一台服务器,而session只在第一次请求的服务器中

怎么解决

image-20231126131349282

3.验证码

http://code.google.com/archive/p/kaptcha/

注意:

1.Producer是Kaptcha的核心接口

2.DefaultKaptcha是Kaptcha核心接口的默认实现类

3.Spring Boot没有为Kaptcha提供自动配置

引入依赖

<dependency>
   <groupId>com.github.penggle</groupId>
   <artifactId>kaptcha</artifactId>
   <version>2.3.2</version>
</dependency>

书写配置


@Configuration
public class KaptchaConfig {

    /**
         * 手动创建properties.xml配置文件对象*
         * 设置验证码图片的样式,大小,高度,边框,字体等
         */
    @Bean
    public Producer kaptchaProducer() {
        Properties properties = new Properties();
        properties.setProperty("kaptcha.image.width", "100");
        properties.setProperty("kaptcha.image.height", "40");
        properties.setProperty("kaptcha.textproducer.font.size", "32");
        properties.setProperty("kaptcha.textproducer.font.color", "0,0,0");
        properties.setProperty("kaptcha.textproducer.char.string", "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYAZ");
        properties.setProperty("kaptcha.textproducer.char.length", "4");
        properties.setProperty("kaptcha.noise.impl", "com.google.code.kaptcha.impl.NoNoise");

        DefaultKaptcha kaptcha = new DefaultKaptcha();
        Config config = new Config(properties);
        kaptcha.setConfig(config);
        return kaptcha;
    }

}

使用(注意生成的文本要放入session,等待验证用户的输入)

//生成验证码
    @GetMapping("/kaptcha")
    public void getKaptcha(HttpServletResponse response /*, HttpSession session */)  {
        //生成验证码
        String text=kaptchaProducer.createText();
        BufferedImage image=kaptchaProducer.createImage(text);

//        //将验证码存入session
        session.setAttribute("kaptcha",text);

        //将图片输出给浏览器
        response.setContentType("image/png");
        try {
            OutputStream os = response.getOutputStream();
            ImageIO.write(image,"png",os);
        } catch (IOException e) {
           logger.error("响应验证码失败:"+e.getMessage());
        }
    }

验证码前端

<img th:src="@{/kaptcha}" id="kaptcha" style="width:100px;height:40px;" />
<a href="javascript:refresh_kaptcha();">刷新验证码</a>
	

异步获取验证码的,用js来发送请求

<script>
    function refresh_kaptcha(){
        //用?带个参数欺骗浏览器,让其认为是个新路径
        var path = CONTEXT_PATH + "/kaptcha?p=" + Math.random();
        $("#kaptcha").attr("src", path);
	}
</script>

3.登陆验证

image-20231126133511876

实体类型

@Data
public class LoginTicket {

    private int id;
    private int userId;
    private String ticket;
    private int status;
    private Date expired;
}
  1. 判断验证码是否正确
  2. 判断登录是否成功
  3. 成功就发送一个带有登录凭证的cookie给浏览器
  4. 不成功就重新登录

3.拦截器实现

image-20230118164557688

拦截器demo思想

  1. 拦截器需实现HandlerInterceptor接口而配置类需实现WebMvcConfigurer接口。
  2. preHandle方法在Controller之前执行,若返回false,则终止执行后续的请求。
  3. postHandle方法在Controller之后、模板页面之前执行。
  4. afterCompletion方法在模板之后执行。
  5. 通过addInterceptors方法对拦截器进行配置

1. 创建拦截器类,实现HandlerInterceptor接口

  • handle就是在执行的方法,也就是拦截的目标
@Component
public class AlphaInterceptor implements HandlerInterceptor {

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

    //再controller前执行
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        logger.debug("preHandle"+handler.toString());
        return true;
    }
    //在controller后执行
    @Override
    public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
        logger.debug("postHandle"+handler.toString());
    }
    //在模板引擎后执行
    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
        logger.debug("afterCompletion"+handler.toString());
    }
}

2. 创建拦截器配置类,实现WebMvcConfigurer接口

@Configuration
public class WebMvcConfig implements WebMvcConfigurer {
    @Autowired
    private AlphaInterceptor alphaInterceptor;
@Override
public void addInterceptors(InterceptorRegistry registry) {
    registry.addInterceptor(alphaInterceptor)
            .excludePathPatterns("/**/*.css","/**/*.js","/**/*.png","/**/*.jpg","/**/*.jpeg") //不拦截静态资源
            .addPathPatterns("/register","/login"); //只拦截部分请求
}

1. 首先创建两个工具类降低耦合

Request获取Cookie工具类,获取凭证ticket多线程工具类

/**
 * 处理请求体的cookie逻辑
 */
public class CookieUtil {

    public static String getValue(HttpServletRequest request, String name){
        if(request==null||name==null){
            throw new IllegalArgumentException("参数为空!");
        }

        Cookie[] cookies = request.getCookies();
        if(cookies!=null){
            for (Cookie cookie:cookies){
                if(cookie.getName().equals(name)){
                    return cookie.getValue();
                }
            }
        }
        return null;
    }
}

注意:

  • ThreadLocal采用线程隔离的方式存放数据,可以避免多线程之间出现数据访问冲突。

  • ThreadLocal提供set方法,能够以当前线程为key存放数据。get方法,能够以当前线程为key获取数据。

  • ThreadLocal提供remove方法,能够以当前线程为key删除数据。


因为用户登录后,需要把用户信息放入内存之中,而web时多线程的环境,每个用户都会有一个线程

为了避免线程之间干扰,需要采用ThreadLocal进行线程隔离

Hostholder就是在这里实现的,显示登陆信息处
/**
 * 持有用户信息,用于代替session对象
 */
@Component
public class HostHolder {

    private ThreadLocal<User> users=new ThreadLocal<>();

    public void setUsers(User user){
        users.set(user);
    }
    public User getUser(){
        return users.get();
    }
    public void clear(){
        users.remove();
    }

//    这段代码是使用 ThreadLocal 来保存和获取当前用户的信息。ThreadLocal 是一个线程局部变量,它提供了线程级别的变量存储,
//    每个线程都可以独立地访问自己的副本,互不干扰。在这段代码中,setUsers 方法用于将当前用户信息存储到 ThreadLocal 中,
//    而 getUser 方法则用于获取当前线程对应的用户信息。当您调用 setUsers 方法时,会将用户信息存储在当前线程的 ThreadLocal 对象中。
//    而在需要获取当前用户信息的地方,可以调用 getUser 方法来从 ThreadLocal 中获取当前线程对应的用户信息。
//    这样就可以确保在多线程环境下,每个线程都能独立地管理自己的用户信息,避免了线程间的数据共享和竞争条件。
//    总之,通过 ThreadLocal 可以实现在多线程环境下,方便地获取和管理当前用户信息,保证了线程安全和数据隔离。
}

2. 创建登录凭证拦截器(等同于Controller层)

  1. preHandle: 在进入controller之前,把请求拦下,判断是否有凭证,有的话根据凭证查出用户,存入ThreadLocal
  2. postHandle:controller处理完之后,到视图之前,把ThreadLocal中的用户存入ModelAndView给前端调用
  3. afterCompletion: 最后把ThreadLocal中的当前user删除

@Component
public class LoginTickerInterceptor 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.setUsers(user);
                //2023年11月16日16点43分  spring Security 授权认证
                //构建用户认证结果,并存入SecurityContext中,以便Security进行授权
                 Authentication authentication = new UsernamePasswordAuthenticationToken(
                        user, user.getPassword(), userService.getAuthorites(user.getId()));
                SecurityContextHolder.setContext(new SecurityContextImpl(authentication));
            }
        }

        return true;
    }

    @Override
    public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
        /*
        代码检查"user"对象是否不为null且"modelAndView"对象也不为null。这是为了确保当前用户已登录且视图对象可用。
在满足上述条件的情况下,代码将"user"对象作为"loginUser"的属性添加到"modelAndView"中。这样,当渲染视图时,视图就可以通过"loginUser"来访问用户信息。
总的来说,这段代码的作用是将当前登录用户的信息添加到视图中,以便在前端页面中展示或使用。这种方式常用于在页面上显示当前用户的相关信息,比如用户名、头像等
         */
        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();
        SecurityContextHolder.clearContext();
      //        afterCompletion方法会在请求处理完成后、视图渲染之后被调用。具体来说,它会在DispatcherServlet完全处理完请求后被调用,
//        即在返回响应给客户端之前执行。在afterCompletion方法中,你可以进行一些清理工作,比如释放资源、记录日志等。
//        这个方法通常用于一些必须在请求处理结束后执行的操作。
    }
}

编写拦截配置类

/**

 * 拦截器配置类
	*/
	@Configuration
	public class WebMvcConfig implements WebMvcConfigurer {

	@Autowired
	private LoginTicketInterceptor loginTicketInterceptor;

	@Override
	public void addInterceptors(InterceptorRegistry registry) {
	    registry.addInterceptor(loginTicketInterceptor)
	            .excludePathPatterns("/**/*.css","/**/*.js","/**/*.png","/**/*.jpg","/**/*.jpeg");
	}
	}

6.6 拦截未登录页面访问(采用注解)

当前情况下,没登录也能够访问/user/setting,想要不让其访问,可以使用之前的那种拦截器,这里采用注解的方法

image-20230118222722899

常用的元注解:

@Target:注解作用目标(方法or类)
@Retention:注解作用时间(运行时or编译时)
@Document:注解是否可以生成到文档里
@Inherited**:注解继承该类的子类将自动使用@Inherited修饰
注意: 若有2个拦截器,拦截器执行顺序为注册在WebMvcConfig配置类中的顺序

1. 写一个注解@LoginRequired
package com.nowcoder.community.annotation;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

/**
 * 注解式拦截器,只有当检查拦截是否已经登陆
 */
@Target(ElementType.METHOD) //标记在方法上
@Retention(RetentionPolicy.RUNTIME)  //该注解在运行时有效
public @interface LoginRequired {

}
2. 给需要的方法加上注解

(如这里的设置用户和给用户上传头像是要在登陆情况下才可以操作的,因此这里拦截验证是登陆了)

/**

 * 跳转设置页面
 * @return
	*/
	@LoginRequired
	@GetMapping("/setting")
	public String getUserPage() {
	return "/site/setting";
	}

/**
*上传头像
*/
@LoginRequired
@PostMapping("/upload")
public String uploadHeader(MultipartFile headerImage, Model model) {

}

3. 写拦截器

拦截有注解,并且没登陆的那些请求



/**
 * @LoginRequired的拦截器实现
 */
@Component
public class LoginRequiredInterceptor implements HandlerInterceptor {
    @Autowired
    private HostHolder hostHolder;

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        //判断拦截的是否为方法
        if (handler instanceof HandlerMethod) {
            HandlerMethod handlerMethod = (HandlerMethod) handler;
            //获取拦截到的方法对象
            Method method = handlerMethod.getMethod();
            //获取注解
            LoginRequired loginRequired = method.getAnnotation(LoginRequired.class);
            //如果这个方法被@LoginRequired注解,并且未登录,跳转并拦截!
            if (loginRequired != null && hostHolder.getUser() == null) {
                response.sendRedirect(request.getContextPath()+"/login");
                return false;
            }
        }
        return true;
    }
}

4. 注册到拦截器配置类


/**
 * 拦截器配置类
 */
@Configuration
public class WebMvcConfig implements WebMvcConfigurer {

    @Autowired
    private LoginTicketInterceptor loginTicketInterceptor;
    @Autowired
    private LoginRequiredInterceptor loginRequiredInterceptor;

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
      
        registry.addInterceptor(loginRequiredInterceptor)
                .excludePathPatterns("/**/*.css","/**/*.js","/**/*.png","/**/*.jpg","/**/*.jpeg");
    }
}


使用Redis优化登录

image-20230123232122785

1.验证码优化

之前验证码使用kaptcha生成后,就将字符存入了session中,等待验证

//生成验证码
String text = kaptchaProducer.createText();
BufferedImage image = kaptchaProducer.createImage(text);
//验证码存入session,用于验证用户输入是否正确
session.setAttribute("kaptcha",text);

如果使用分布式,分布式session会出现问题,即每台服务器生成的kaptcha不一致,无法验证,这里使用redis存储

image-20230124000825092

  • 生成一个uuid后作为key存入redis, value为验证码的正确答案
1.1配置redis前缀
// 验证码
private static final String PREFIX_KAPTCHA = "kaptcha";
/**
* 登录验证码
* @param owner
* @return
*/
public static String getKaptchaKey(String owner) {
    return PREFIX_KAPTCHA + SPLIT + owner;
}

1.2 优化LoginController验证码相关代码(优化前是存在session中的)
/**
     * 验证码生成
     * @param response
     */
@GetMapping("/kaptcha")
public void getKaptcha(HttpServletResponse response){
    //生成验证码
    String text = kaptchaProducer.createText();
    BufferedImage image = kaptchaProducer.createImage(text);

    //优化前:将验证码存入session.....
    //session.setAttribute("kaptcha",text);

    //优化后:生成验证码的归属传给浏览器Cookie
    String kaptchaOwner = CommunityUtil.generateUUID();
    Cookie cookie = new Cookie("kaptchaOwner", kaptchaOwner);
    cookie.setMaxAge(60); //s
    cookie.setPath(contextPath);
    response.addCookie(cookie);

    //优化后:将验证码存入Redis
    String redisKey = RedisKeyUtil.getKaptchaKey(kaptchaOwner);
    redisTemplate.opsForValue().set(redisKey, text, 60 , TimeUnit.SECONDS);


    //将图片输出到浏览器
    response.setContentType("image/png");
    try {
        OutputStream os = response.getOutputStream();
        ImageIO.write(image,"png",os);
        os.flush();
    } catch (IOException e) {
        logger.error("响应验证码失败:"+e.getMessage());
    }
}

/**
 * 登录功能
 * @param username
 * @param password
 * @param code 验证码
 * @param rememberme 是否勾选记住我
 * @param model
 * @param response 用于浏览器接受cookie
 * @return
 */
@PostMapping(path = "/login")
public String login(String username, String password, String code, boolean rememberme,
                    Model model, HttpServletResponse response,
                    @CookieValue("kaptchaOwner") String kaptchaOwner){
    //优化前:首先检验验证码(从session取验证码)
    //String kaptcha = (String) session.getAttribute("kaptcha");

    String kaptcha = null;
    // 优化后:从redis中获取kaptcha的key
    if(!StringUtils.isBlank(kaptchaOwner)){
        String redisKey = RedisKeyUtil.getKaptchaKey(kaptchaOwner);
        //获取redis中的验证码答案
        kaptcha = (String) redisTemplate.opsForValue().get(redisKey);
        System.out.println(kaptcha);
    }


    if(StringUtils.isBlank(kaptcha) || StringUtils.isBlank(code) || !kaptcha.equalsIgnoreCase(code)){
        //空值或者不相等
        model.addAttribute("codeMsg","验证码不正确");
        return "site/login";
    }

    /*
     * 1.验证用户名和密码(重点)
     * 2.传入浏览器cookie=ticket
     */
    int expiredSeconds=rememberme?REMEMBER_EXPIRED_SECONDS:DEFAULT_EXPIRED_SECONDS;
    Map<String, Object> map = loginService.login(username, password, expiredSeconds);

    //登录成功
    if(map.containsKey("ticket")){
        Cookie cookie = new Cookie("ticket",map.get("ticket").toString());
        cookie.setPath(contextPath);
        cookie.setMaxAge(expiredSeconds);
        response.addCookie(cookie);
        return "redirect:/index";
    }else{
        //登陆失败
        model.addAttribute("usernameMsg",map.get("usernameMsg"));
        model.addAttribute("passwordMsg",map.get("passwordMsg"));
        return "/site/login";
    }

}

2.登录凭证优化

之前在登录凭证拦截器中,每次用户访问都需要查询一次数据库,效率太低

 //从request中获取cookie 凭证
String ticket = CookieUtil.getValue(request, "ticket");

if (!StringUtils.isBlank(ticket)) {
    // 查询凭证
    LoginTicket loginTicket = userService.findLoginTicket(ticket);
}

直接使用redis,mysql中的凭证表无需使用

2.1配置redis前缀
// 登录凭证
private static final String PREFIX_TICKET = "ticket";
/**
* 登录凭证
* @param ticket
* @return
*/
public static String getTicketKey(String ticket) {
    return PREFIX_TICKET + SPLIT + ticket;
}

2.2优化LoginService中相关代码

废弃LoginTicket数据库表,使用redis

  • 登录时
 //生成登录凭证(相当于记住我这个功能==session)
LoginTicket ticket = new LoginTicket();
ticket.setUserId(user.getId());
ticket.setTicket(CommunityUtil.generateUUID());
ticket.setStatus(0); //有效
//当前时间的毫秒数+过期时间毫秒数
ticket.setExpired(new Date(System.currentTimeMillis() + expiredSeconds * 1000));
Date date = new Date();

// 优化前:loginTicketMapper.insertLoginTicket(ticket);

// 优化后:loginticket对象放入redis中
String redisKey = RedisKeyUtil.getTicketKey(ticket.getTicket());
// opsForValue将ticket对象序列化为json字符串
redisTemplate.opsForValue().set(redisKey, ticket);

  • 登出时(不直接删,是因为需要保留用户的登录记录,比如可以用于查看用户的每月登录次数)
/**
* 登出
* @param ticket 登录凭证
*/
public void logout(String ticket) {
    //优化前:找到数据库中的ticket,把状态改为1

    //优化后:loginticket对象从redis中取出后状态设为1后放回
    String redisKey = RedisKeyUtil.getTicketKey(ticket);
    LoginTicket loginTicket = (LoginTicket) redisTemplate.opsForValue().get(redisKey);
    loginTicket.setStatus(1);
    //放回
    redisTemplate.opsForValue().set(redisKey,loginTicket);
}

  • 通过凭证号找到凭证
 /**
     * 通过凭证号找到凭证
     *
     * @param ticket
     * @return
     */
public LoginTicket findLoginTicket(String ticket) {
    //        return loginTicketMapper.selectOne(new LambdaQueryWrapper<LoginTicket>()
    //                .eq(LoginTicket::getTicket, ticket));

    //redis优化后:从redis中取出
    String redisKey = RedisKeyUtil.getTicketKey(ticket);
    return (LoginTicket) redisTemplate.opsForValue().get(redisKey);

}

3.缓存用户信息

每次请求都需要根据凭证来获取用户信息,访问的频率非常高

// 检查凭证是否有效
if (loginTicket != null && loginTicket.getStatus() == 0 && loginTicket.getExpired().after(new Date())) {
    // 根据凭证查询用户
    User user = userService.findUserById(String.valueOf(loginTicket.getUserId()));
    // 把用户存入ThreadLocal
    hostHolder.setUser(user);
}

  1. 优先从缓存中取值
  2. 取不到时,从数据库中取,初始化缓存数据(redis存值)
  3. 数据变更时清除缓存(也可更新缓存,但是多线程时有并发的问题)
3.1 配置
private static final String PREFIX_USER = "user";

/**
* 用户缓存
* @param userId
* @return
*/
public static String getUserKey(int userId) {
    return PREFIX_USER + SPLIT + userId;
}

3.2修改
// 1.优先从缓存中取值
private User getCache(int userId) {
    String redisKey = RedisKeyUtil.getUserKey(userId);
    return (User) redisTemplate.opsForValue().get(redisKey);
}
// 2.取不到时初始化缓存数据(redis存值)
private User initCache(int userId) {
    User user = userMapper.selectById(userId);
    String redisKey = RedisKeyUtil.getUserKey(userId);

    redisTemplate.opsForValue().set(redisKey, user, 3600, TimeUnit.SECONDS);
    return user;
}
// 3.数据变更时清除缓存(删除redis的key)
private void clearCache(int userId) {
    String redisKey = RedisKeyUtil.getUserKey(userId);
    redisTemplate.delete(redisKey);
}

public User findUserById(String id) {
//        return userMapper.selectById(Integer.parseInt(id));
    //优先从缓存中取值
    User user = getCache(Integer.parseInt(id));
    if(user == null){
        //取不到时,从数据库中取,然后初始化缓存数据(redis存值)
        user = initCache(Integer.parseInt(id)); //乌鱼子,忘了写user=找bug找了一小时
    }

    return user;
}

/**
* 激活账号
*
* @param userId
* @param activationCode
* @return
*/
public int activate(int userId, String activationCode) {
    //根据userid获取用户信息
    User user = userMapper.selectById(userId);

    if (user.getStatus() == 1) {
        //已经激活,则返回重复
        return ACTIVATION_REPEAT;
    } else if (user.getActivationCode().equals(activationCode)) {
        //如果未激活,判断激活码是否相等
        //激活账号
        user.setStatus(1);
        //            userMapper.updateById(user);
        //redis优化后
        clearCache(userId);
        return ACTIVATION_SUCCESS;
    } else {
        //不相等
        return ACTIVATION_FAILURE;
    }
}

/**
 * 更新用户头像路径
 *
 * @param userId
 * @param headerUrl
 * @return
 */
public int updateHeaderUrl(int userId, String headerUrl) {
    User user = new User();
    user.setId(userId);
    user.setHeaderUrl(headerUrl);
    int rows = userMapper.updateById(user);
    clearCache(userId);
    return rows;
}

4.其他知识

1.js知识

$.post() 是 jQuery 中的一个 AJAX 方法,用于向服务器发送 POST 请求。它接受三个参数:

  • url:需要请求的 URL 地址。
  • data:需要发送给服务器的数据。可以是字符串或 JavaScript 对象。
  • callback:请求成功后执行的回调函数。

下面是一个简单的示例:

javascriptCopy Code$.post("/api/login", {username: "user123", password: "pass123"}, function(data, status){
    console.log("Data: " + data + "\nStatus: " + status);
});

在这个示例中,$.post() 方法向 /api/login 发送了一个 POST 请求,并且传递了两个参数:用户名和密码。请求成功后,回调函数会打印服务器返回的数据和请求状态。

需要注意的是,$.post() 方法默认使用 JSON 数据格式进行数据交换。如果需要使用其他格式,比如表单格式,需要手动设置请求头。

$ 是 jQuery 中的一个全局函数/对象,它是 jQuery 库的核心部分。通过 $ 函数,你可以使用 jQuery 提供的各种功能和方法来操作 HTML 元素、处理事件、发送 AJAX 请求等。

在 jQuery 中,$ 函数有多种用法:

  1. 选择器:可以使用 $ 函数来选取 HTML 元素,类似于 CSS 选择器。例如,$("#myElement") 会选取 id 为 “myElement” 的元素。
  2. DOM 操作:可以使用 $ 函数来操作选中的元素。例如,$("#myElement").addClass("highlight") 会给 id 为 “myElement” 的元素添加一个名为 “highlight” 的 CSS 类。
  3. 事件处理:可以使用 $ 函数来绑定事件处理程序。例如,$("#myButton").click(function() { ... }) 会在 id 为 “myButton” 的按钮被点击时执行指定的函数。
  4. AJAX 请求:可以使用 $ 函数来发送 AJAX 请求。例如,$.ajax(...) 用于发送异步请求。
  5. 动画效果:可以使用 $ 函数来创建动画效果。例如,$("#myElement").fadeIn() 会使 id 为 “myElement” 的元素以淡入效果显示出来。

需要注意的是,$ 函数实际上是 jQuery 函数的别名。因此,$jQuery 可以互换使用。

5.事务

1.Spring声明式事务

方法:

**1.通过XML配置 **

2.通过注解@Transaction,如下:

/* REQUIRED: 支持当前事务(外部事务),如果不存在则创建新事务
 * REQUIRED_NEW: 创建一个新事务,并且暂停当前事务(外部事务)
 * NESTED: 如果当前存在事务(外部事务),则嵌套在该事务中执行(独立的提交和回滚),否则就会和REQUIRED一样
 * 遇到错误,Sql回滚  (A->B)
 */
@Transactional(isolation = Isolation.READ_COMMITTED, propagation = Propagation.REQUIRED)


  • image-20231126160659409
Spring编程式事务

控制粒度更低,比如一个方法要访问10次数据库,只有5次需要保证事务,就可以用编程式来控制,声明式会10次全都放入事务中

方法: 通过TransactionTemplate组件执行SQL管理事务,如下:

  public Object save2(){
      transactionTemplate.
          setIsolationLevel(TransactionDefinition.ISOLATION_READ_COMMITTED);
      transactionTemplate.
          setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRED);
      
      //回调函数
      return transactionTemplate.execute(new TransactionCallback<Object>() {
          @Override
          public Object doInTransaction(TransactionStatus status) {
              User user = new User();
              user.setUsername("Marry");
              user.setSalt(CommunityUtil.generateUUID().substring(0,5));
              user.setPassword(CommunityUtil.md5("123123")+user.getSalt());
              user.setType(0);
              user.setHeaderUrl("http://localhost:8080/2.png");
              user.setCreateTime(new Date());
              userMapper.insertUser(user);
              //设置error,验证事务回滚
              Integer.valueOf("abc");
              return "ok"; }
      });
 }

这是一个 Java 方法的示例代码。根据代码内容,可以看出这是一个保存用户信息的方法,使用了事务处理。

代码解释如下:

transactionTemplate.setIsolationLevel(TransactionDefinition.ISOLATION_READ_COMMITTED):设置事务的隔离级别为读已提交。即在当前事务中,只能读取其他事务已经提交的数据,而不能读取其他事务未提交的数据。
transactionTemplate.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRED):设置事务的传播行为为必须存在事务。即如果当前没有事务,则创建一个新的事务;如果当前已存在事务,则加入到该事务中。
transactionTemplate.execute(new TransactionCallback<Object>() {...}):执行事务,其中回调函数定义了在事务中需要执行的操作。
User user = new User():创建一个 User 对象。
.....
userMapper.insertUser(user):将 User 对象插入到数据库中。
Integer.valueOf("abc"):人为地引发一个异常,用于验证事务回滚。
返回 "ok"。
整个方法使用了事务模板(TransactionTemplate)来管理事务,并通过回调函数的方式执行数据库操作。如果在执行过程中发生异常,事务会回滚,数据库操作也会被撤销。

6.统一异常处理

异常都会扔到表现层中,所以只要处理Controller层就行了

image-20231126182019582

1.将404.html或500.html放在templates/error下
注意:springboot默认在templates资源路径下面新建error目录,添加404.html和500.html页面就会自动配置上错误页面自动跳转

2.定义一个控制器通知组件,处理所有Controller所发生的异常

//annotations只扫描带Controller注解的Bean
@ControllerAdvice(annotations = Controller.class)
public class ExceptionAdvice {
    public static final Logger logger = LoggerFactory.getLogger(ExceptionAdvice.class);

    //发生异常时会被调用
    @ExceptionHandler
    public void handleException(Exception e, HttpServletRequest request, HttpServletResponse response) throws IOException {
        logger.error("服务器发生异常:" + e.getMessage());

        // 循环打印异常栈中的每一条错误信息并记录
        for (StackTraceElement element : e.getStackTrace()) {
            logger.error(element.toString());
        }

        // 判断请求返回的是一个页面还是异步的JSON格式字符串
        String xRequestedWith = request.getHeader("x-requested-with");
        // XMLHttpRequest: Json格式字符串
        if ("XMLHttpRequest".equals(xRequestedWith)) {
            // 要求以JSON格式返回
            response.setContentType("application/plain;charset=utf-8");
            PrintWriter writer = response.getWriter();
            writer.write(CommunityUtil.getJSONString(1, "服务器异常!"));
        } else {
            //普通请求直接重定向到错误页面 
            response.sendRedirect(request.getContextPath() + "/error");
        }
    }
}

一些思考

7.统一处理日志

  • 为什么不用@ExceptionHandler异常处理来做日志

    没有异常的时候,也需要做日志

  • 为什么不用拦截器做日志

    拦截器只能拦截Controller层,Service和Dao可能也需要做日志

  • 为什么不在每个写个日志类放入Spring中,在需要写日志的时候直接用

    耦合性太高,日志属于系统需求,和业务需求应该分开


img

img

编译,类装载,运行时,都能进行织入

  • 我们想要插入的代码放在**切面(Aspect)**中

  • 切面中的代码放入目标对象的过程称为织入(Weaving)

  • 切面中的代码织入目标对象的位置称为连接点(Joinpoint)

  • Pointcut用来指明切面中的代码要放到目标对象的哪些地方(连接点)

  • **通知(Advice)**指明织入到目标对象时的逻辑(在连接点的前后左右这些)

常见的使用场景有:权限检查、记录日志、事务管理

  • 连接点(Joinpoint):目标对象上织入代码的位置叫做joinpoint
  • Pointcut:是用来定义当前的横切逻辑准备织入到哪些连接点上 (如service所有方法)
  • 通知(Advice):用来定义横切逻辑,即在连接点上准备织入什么样的逻辑
  • 切面(Aspect):是一个用来封装切点和通知的组件
  • 织入(Weaving):就是将方面组件中定义的横切逻辑,织入到目标对象的连接点的过程

2.AOP是如何实现的

img

  • AspectJ是一门新的语言,在编译期织入代码
  • Spring AOP 纯Java ,通过代理 运行时织入代码

img

  • JDK动态代理需要目标类实现了接口才行
  • CGLib 采用创建子类来进行代理

3.AOP切面编程demo

先导包

org.springframework.boot
spring-boot-starter-aop

  • @Aspect代表这个类是个切面
  • @Pointcut定义一下织入的位置
@Component
@Aspect
public class DemoAspect {
	// 返回值 包名.类名.方法名(方法参数)  *表示所有 ..表示全部参数
    @Pointcut("execution(* com.qiuyu.demonowcoder.service.*.*(..))")
    public void pointcut(){}

    //切点方法之前执行(常用)
    @Before("pointcut()")
    public void before(){
        System.out.println("before");
    }
    
    @After("pointcut()")
    public void after(){
        System.out.println("after");
    }
    
    /**返回值以后执行**/
    @AfterReturning("pointcut()")
    public void afterRetuning() {
        System.out.println("afterRetuning");
    }

    /**抛出异常以后执行**/
    @AfterThrowing("pointcut()")
    public void afterThrowing() {
        System.out.println("afterThrowing");
    }
    
    /**切点的前和后都可以执行**/
    @Around("pointcut()")
    public Object around(ProceedingJoinPoint joinPoint) throws Throwable{
        System.out.println("around before");
        Object obj = joinPoint.proceed();
        System.out.println("around after");
        return obj;
    }
}


4.AOP实现统一记录日志

实现需求 :用户ip[1.2.3.4],在[时间],访问了[com.qiuyu.service.xxx()].

package com.qiuyu.aspect;

@Component
@Aspect
public class ServiceLogAspect {
    public static final Logger logger = LoggerFactory.getLogger(ServiceLogAspect.class);
    @Pointcut("execution(* com.qiuyu.service.*.*(..))")
    public void pointCut(){}

    @Before("pointCut()")
    public void before(JoinPoint joinPoint){
        // 用户ip[1.2.3.4],在[时间],访问了[com.qiuyu.service.xxx()].
        // 通过RequestContextHolder工具类获取request
        ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
        HttpServletRequest request = attributes.getRequest();
        // 通过request.getRemoteHost获取当前用户ip
        String ip = request.getRemoteHost();
        String time = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(new Date());
        /**
         * joinPoint.getSignature().getDeclaringTypeName()-->得到类名com.qiuyu.service.*
         * joinPoint.getSignature().getName() -->方法名
         */
        String target = joinPoint.getSignature().getDeclaringTypeName() + "." +joinPoint.getSignature().getName();
        // String.format()加工字符串
        logger.info(String.format("用户[%s],在[%s],访问了[%s]业务.", ip, time, target));
    }

}

RequestContextHolder.getRequestAttributes()..RequestContextHolder 是 Spring 提供的一个工具类,用于在当前线程中持有 request 和 response 对象。getRequestAttributes() 方法用于获取当前线程绑定的请求属性。
(ServletRequestAttributes) RequestContextHolder.getRequestAttributes():将获取到的请求属性强制类型转换为 ServletRequestAttributes 类型,以便后续可以获取到 HttpServletRequest 对象。
HttpServletRequest request = attributes.getRequest():通过 ServletRequestAttributes 对象获取到当前请求的 HttpServletRequest 对象。
String ip = request.getRemoteHost():使用 HttpServletRequest 对象的 getRemoteHost() 方法获取客户端的 IP 地址。需要说明的是,getRemoteHost() 方法获取的是客户端发送请求的 IP 地址,但是这种方式并不总是准确的,特别是在客户端通过代理服务器(如 CDN、反向代理等)发送请求时,实际的客户端 IP 可能会被隐藏或伪装。
因此,虽然这段代码可以用来获取客户端的 IP 地址,但在生产环境中,为了确保准确性,建议结合反向代理服务器等手段来获取真实的客户端 IP 地址。

8. redis入门

image-20230122174432771

  • Key都是String类型 Value支持多种数据结构

  • 快照方式存储(rdb),体积小,但是不适合实时去做,速度较慢,适合几个小时做一次

  • 日志方式存储(aof),体积大,每执行一个redis目录就以日志方式存一次,适合实时去做,恢复的时候把所有命令跑一遍

  • Redis命令
    基础命令
    redis-cli 连接redis

  • select [0-11] 选择使用的库,redis一共12个库

  • flushdb 把数据刷新(删除)

  • keys * 查看所有的key

  • keys test* 查看test开头的key

  • type test:teachers 查看某个key的类型

  • exists test:teachers 查看某个key是否存在

  • del test:teachers 删除某个key

  • expire test:ids 5 五秒后key过期,删除


string字符串类型操作

redis中两个单词之间的分割不是驼峰也不是下划线,建议使用冒号:

set test:count 1 #添加数据,设置test:count的值为1,1的类型为字符串
get test:count #获取数据,得到结果为"1"
incr test:count #指定数据加一,结果为(integer) 2
decr test:count #指定数据减一,结果为(integer) 1

hash哈希操作

Redis hash 是一个 string 类型的 field(字段) 和 value(值) 的映射表,hash 特别适合用于存储对象

127.0.0.1:6379> hset test:user id 1
(integer) 1
127.0.0.1:6379> hset test:user username zhangsan
(integer) 1
127.0.0.1:6379> hget test:user id
“1”
127.0.0.1:6379> hget test:user username
“zhangsan”

list列表操作

redis中的list为双端队列,左右都可存取

127.0.0.1:6379> lpush test:ids 101 102 103 #左侧依次放入
(integer) 3
127.0.0.1:6379> llen test:ids #列表长度
(integer) 3
127.0.0.1:6379> lindex test:ids 0 #根据索引查找
“103”
127.0.0.1:6379> lrange test:ids 0 2 #查看索引范围内的元素

  1. “103”
  2. “102”
  3. “101”
    127.0.0.1:6379> rpush test:ids 100 #右端插入
    (integer) 4
    127.0.0.1:6379> lpop test:ids #左侧弹出一个元素
    “103”
    127.0.0.1:6379> rpop test:ids #右侧弹出一个元素
    “100”
set集合操作

Redis 的 Set 是 String 类型的无序集合。集合成员是唯一的,这就意味着集合中不能出现重复的数据。

127.0.0.1:6379> sadd test:teachers aaa bbb ccc #放入集合
3
127.0.0.1:6379> scard test:teachers #查看个数
3
127.0.0.1:6379> spop test:teachers #随机弹出一个
ccc
127.0.0.1:6379> smembers test:teachers #查看所有元素
bbb
aaa

sorted set

Redis 有序集合和集合(set)一样也是 string 类型元素的集合,且不允许重复的成员。

不同的是每个元素都会关联一个 double 类型的分数。redis 正是通过分数来为集合中的成员进行从小到大的排序。

127.0.0.1:6379> zadd test:students 10 aaa 20 bbb 30 ccc 40 ddd 50 eee # 插入需要写分数
5
127.0.0.1:6379> zcard test:students #查看个数
5
127.0.0.1:6379> zscore test:students bbb #查看指定的元素的分数
20
127.0.0.1:6379> zrank test:students bbb #查看指定元素的排名(从0开始)
1
127.0.0.1:6379> zrange test:students 0 2 #按照分数,由小到大排序,第0-2个
aaa
bbb
ccc

Spring整合Redis

导包
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
配置
spring:
  #redis
  redis:
    database: 11 #16个库用哪个
    host: localhost
    port: 6379

自带的RedisTemplate为Objtct,Object类型 我们这里使用String,Object就行,所以自己写配置类


package com.qiuyu.config;

@Configuration
public class RedisConfig {

    @Bean
    public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory redisConnectionFactory) {
        RedisTemplate<String,Object> template = new RedisTemplate<>();
        template.setConnectionFactory(redisConnectionFactory);


        Jackson2JsonRedisSerializer jackson2JsonRedisSerializer = new Jackson2JsonRedisSerializer(Object.class);
        ObjectMapper om = new ObjectMapper();
        om.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
        om.enableDefaultTyping(ObjectMapper.DefaultTyping.NON_FINAL);
        jackson2JsonRedisSerializer.setObjectMapper(om);

        StringRedisSerializer stringRedisSerializer = new StringRedisSerializer();

        // key采用String的序列化方式
        template.setKeySerializer(stringRedisSerializer);
        // hash的key也采用String的序列化方式
        template.setHashKeySerializer(stringRedisSerializer);
        // value序列化方式采用jackson
        template.setValueSerializer(jackson2JsonRedisSerializer);
        // hash的value序列化方式采用jackson
        template.setHashValueSerializer(jackson2JsonRedisSerializer);


        //让配置生效
        template.afterPropertiesSet();
        return template;
    }
}


使用
@SpringBootTest
@RunWith(SpringRunner.class)
public class RedisTest {
    @Autowired
    private RedisTemplate redisTemplate;

    @Test
    public void testRedis(){
        String redisKey = "test:redis";
        redisTemplate.opsForValue().set(redisKey,1);
        System.out.println(redisTemplate.opsForValue().get(redisKey));
    }


	//Hash
    @Test
    public void testHash(){
        String redisKey = "test:redis2";
        redisTemplate.opsForHash().put(redisKey,"id",6);
        redisTemplate.opsForHash().put(redisKey,"username","qiuyu");
        Object id = redisTemplate.opsForHash().get(redisKey, "id");
        Object username = redisTemplate.opsForHash().get(redisKey, "username");
        System.out.println(id);
        System.out.println(username);
    }

    //List
    @Test
    public void testList(){
        String redisKey = "test:redis3";
        redisTemplate.opsForList().leftPush(redisKey,101);
        redisTemplate.opsForList().leftPush(redisKey,102);
        redisTemplate.opsForList().leftPush(redisKey,103);

        System.out.println(redisTemplate.opsForList().size(redisKey));
        System.out.println(redisTemplate.opsForList().index(redisKey,0));
        System.out.println(redisTemplate.opsForList().range(redisKey,0,2));

        System.out.println(redisTemplate.opsForList().rightPop(redisKey));
        System.out.println(redisTemplate.opsForList().rightPop(redisKey));
        /*
        3
        103
        [103, 102, 101]
        101
        102
         */
    }

    //Set
    @Test
    public void testSet(){
        String redisKey = "test:redis4";
        redisTemplate.opsForSet().add(redisKey,"bbb","ccc","aaa");

        System.out.println(redisTemplate.opsForSet().size(redisKey));
        System.out.println(redisTemplate.opsForSet().pop(redisKey));
        System.out.println(redisTemplate.opsForSet().members(redisKey));
        /*
        3
        bbb
        [aaa, ccc]
         */
    }
	
    //Zset
    @Test
    public void testZSet(){
        String redisKey = "test:redis5";
        redisTemplate.opsForZSet().add(redisKey,"aaa",80);
        redisTemplate.opsForZSet().add(redisKey,"bbb",90);
        redisTemplate.opsForZSet().add(redisKey,"ccc",60);
        redisTemplate.opsForZSet().add(redisKey,"ddd",100);
        redisTemplate.opsForZSet().add(redisKey,"eee",50);

        System.out.println(redisTemplate.opsForZSet().size(redisKey));
        System.out.println(redisTemplate.opsForZSet().score(redisKey,"bbb"));
        System.out.println(redisTemplate.opsForZSet().rank(redisKey,"bbb"));
        System.out.println(redisTemplate.opsForZSet().reverseRank(redisKey,"bbb"));
        System.out.println(redisTemplate.opsForZSet().range(redisKey,0,2));
        System.out.println(redisTemplate.opsForZSet().reverseRange(redisKey,0,2));
        /*
        5
        90.0
        3
        1
        [eee, ccc, aaa]
        [ddd, bbb, aaa]
         */
    }
	
    //Keys操作
    @Test
    public void testKeys(){
        redisTemplate.delete("aaa");
        System.out.println(redisTemplate.hasKey("aaa"));
        redisTemplate.expire("test:redis",10, TimeUnit.SECONDS);
    }
	
    //多次复用Key
    @Test
    public void testBoundOperations(){
        String redisKey = "test:count3";
        BoundValueOperations operations = redisTemplate.boundValueOps(redisKey);
        operations.set(1);
        //报错
//        operations.increment();
//        operations.increment();
//        operations.increment();
        System.out.println(operations.get());
    }

    //编程式事务
    @Test
    public void testTransaction(){
        Object obj = redisTemplate.execute(new SessionCallback() {
            @Override
            public Object execute(RedisOperations operations) throws DataAccessException {
                String redisKey = "test:tx";
                //启用事务
                operations.multi();

                operations.opsForSet().add(redisKey,"zhangsan");
                operations.opsForSet().add(redisKey,"lisi");
                operations.opsForSet().add(redisKey,"wangwu");

                //redis会把这些操作放在队列中.提交事务时才执行,所以此时还没有数据
                System.out.println(operations.opsForSet().members(redisKey));

                //提交事务
                return operations.exec();
            }
        });

        System.out.println(obj);
        //[]
        //[1, 1, 1, [lisi, zhangsan, wangwu]]
    }

}


9.点赞功能(Redis+ajax)

image-20230122233708401

点赞/取消点赞

1.工具类

用于获取统一格式化的Key

package com.qiuyu.utils;

public class RedisKeyUtil {
    private static final String SPLIT = ":";
    private static final String PREFIX_ENTITY_LIKE = "like:entity";
    /**
     * 某个实体的赞
     * key= like:entity:entityType:entityId -> set(userId)
     */
    public static String getEntityLikeKey(int entityType, int entityId){
        return PREFIX_ENTITY_LIKE + SPLIT + entityType + SPLIT + entityId;
    }
}


2.Service
package com.qiuyu.service;

@Service
public class LikeService {
    @Autowired
    private RedisTemplate redisTemplate;

    // 点赞 (记录谁点了哪个类型哪个留言/帖子id)
    public void like(int userId, int entityType, int entityId){
        String entityLikeKey = RedisKeyUtil.getEntityLikeKey(entityType, entityId);
       //entityTypep判断是给帖子还是留言点的赞, entityId确定了该帖子或则留言的id,也唯一确定了点的是具体某一条的赞
        //判断like:entity:entityType:entityId 是否有对应的 userId
       //userId存这个的原因是某个id只能具体定位到某个人身上,可以用来计算有多少不同的人点赞了
        Boolean isMember = redisTemplate.opsForSet().isMember(entityLikeKey, userId);

        // 第一次点赞,第二次取消点赞
        if (isMember){
            // 若已被点赞(即entityLikeKey里面有userId)则取消点赞->将userId从中移除
            redisTemplate.opsForSet().remove(entityLikeKey, userId);
        }else {
            redisTemplate.opsForSet().add(entityLikeKey, userId);
        }
    }

    // 查询某实体(帖子、留言)点赞的数量 --> scard like:entity:1:110
    public long findEntityLikeCount(int entityType, int entityId){
        String entityLikeKey = RedisKeyUtil.getEntityLikeKey(entityType, entityId);
        return redisTemplate.opsForSet().size(entityLikeKey);
    }

    // 显示某人对某实体的点赞状态
    public int findEntityLikeStatus(int userId, int entityType, int entityId){
        String entityLikeKey = RedisKeyUtil.getEntityLikeKey(entityType, entityId);
        // 1:已点赞 , 0:赞
       //opsForSet().isMember(entityLikeKey, userId) 是 RedisTemplate 的操作方法之一,用于判//断一个元素是否在 Set(集合)数据结构中。
        return redisTemplate.opsForSet().isMember(entityLikeKey, userId) ? 1 : 0;
    }
}

我收到的赞

image-20230123013725378

  • 如果要查询某个人的被点赞数量,需要查到这个人的所有帖子,然后把每个帖子点赞数加起来,有点麻烦
  • 我们可以添加一个维度,点赞的时候在redis中记录被点赞用户的被点赞个数,即对点赞功能进行重构,执行点赞和增加被点赞用户的点点赞数量两条操作数据库的语句,这时候最好使用事务来实现
1.工具类

获取统一格式的key

k:v = like:user:userId -> set(userId)

public class RedisKeyUtil {
    ...
    private static final String PREFIX_USER_LIKE = "like:user";
    ...
    /**
     * 某个用户收到的赞
     * @param userId
     * @return
     */
    public static String getUserLikeKey(int userId){
        return PREFIX_USER_LIKE + SPLIT + userId;
    }
}

2.Service

一是更新帖子/评论点赞数 二是更新用户的被点赞数

使用事务进行控制

/**
 * 点赞 (记录谁点了哪个类型哪个留言/帖子id)
 * 同时给用户的点赞数加一
 * 因为要进行多个操作,采用事务
 * @param userId
 * @param entityType
 * @param entityId
 * @param entityUserId 实体的作者的Id,这里在页面直接传进来,不然使用数据库查太慢了
 */
public void like(int userId, int entityType, int entityId, int entityUserId){
    redisTemplate.execute(new SessionCallback() {
        @Override
        public Object execute(RedisOperations operations) throws DataAccessException {
            String entityLikeKey = RedisKeyUtil.getEntityLikeKey(entityType, entityId);
            String userLikeKey = RedisKeyUtil.getUserLikeKey(entityUserId);
            //查询需要在事务之外
            Boolean isMember = redisTemplate.opsForSet().isMember(entityLikeKey, userId);

            //开启事务
            operations.multi();

            // 第一次点赞,第二次取消点赞
            if (isMember){
                // 若已被点赞,实体类移除点赞者,实体作者点赞数-1
                redisTemplate.opsForSet().remove(entityLikeKey, userId);
                redisTemplate.opsForValue().decrement(userLikeKey);
            }else {
                redisTemplate.opsForSet().add(entityLikeKey, userId);
                redisTemplate.opsForValue().increment(userLikeKey);
            }

            //提交事务
            return operations.exec();
        }
    });

}

//查询用户的点赞数
public long findUserLikeCount(int userId){
    String userLikeKey = RedisKeyUtil.getUserLikeKey(userId);
    Long count = (Long) redisTemplate.opsForValue().get(userLikeKey);
    return count == null ? 0 : count;
}

10.关注功能(Redis+ajax)

image-20230123194114222

关注/取关

1.工具类
package com.qiuyu.utils;

public class RedisKeyUtil {

    // 关注
    private static final String PREFIX_FOLLOWEE = "followee";
    // 粉丝
    private static final String PREFIX_FOLLOWER = "follower";

    /**
     * 某个用户关注的实体(用户,帖子)
     * followee:userId:entityType --> zset(entityId, date)
     为何要用zset呢,还有key的设计问题
     */
    public static String getFolloweeKey(int userId, int entityType) {
        return PREFIX_FOLLOWEE + SPLIT + userId + SPLIT + entityType;
    }

    /**
     * 某个实体拥有的用户粉丝
     * follower:entityType:entityId -->zset(userId, date)
     */
    public static String getFollowerKey(int entityType, int entityId) {
        return PREFIX_FOLLOWER + SPLIT +entityType + SPLIT +entityId;
    }

}


2.Service
package com.qiuyu.service;

@Service
public class FollowService {
    @Autowired
    private RedisTemplate redisTemplate;

    /**
     * 关注某个实体
     * @param userId
     * @param entityType
     * @param entityId
     */
    public void follow(int userId, int entityType, int entityId) {
        redisTemplate.execute(new SessionCallback(){
            @Override
            public Object execute(RedisOperations operations) throws DataAccessException {
                String followeeKey = RedisKeyUtil.getFolloweeKey(userId, entityType);
                String followerKey = RedisKeyUtil.getFollowerKey(entityType, entityId);

                operations.multi();

                /**
                 * System.currentTimeMillis()->用于获取当前系统时间,以毫秒为单位
                 * 关注时,首先将实体(用户或帖子)id添加用户关注的集合中,再将用户id添加进实体粉丝的集合中
                 */
                redisTemplate.opsForZSet().add(followeeKey,entityId,System.currentTimeMillis());
                redisTemplate.opsForZSet().add(followerKey,userId,System.currentTimeMillis());

                return operations.exec();
            }
        });
    }

    /**
     * 取消关注
     * @param userId
     * @param entityType
     * @param entityId
     */
    public void unfollow(int userId, int entityType, int entityId) {
        redisTemplate.execute(new SessionCallback(){

            @Override
            public Object execute(RedisOperations operations) throws DataAccessException {
                String followeeKey = RedisKeyUtil.getFolloweeKey(userId, entityType);
                String followerKey = RedisKeyUtil.getFollowerKey(entityType, entityId);

                operations.multi();

                //关注时,首先将实体(用户或帖子)id移除用户关注的集合中,再将用户id移除进实体粉丝的集合中
                redisTemplate.opsForZSet().remove(followeeKey,entityId);
                redisTemplate.opsForZSet().remove(followerKey,userId);


                return operations.exec();
            }
        });
    }

    /**
     * 某个用户的关注的实体数量
     * @param userId
     * @param entityType
     * @return
     */
    public long findFolloweeCount(int userId, int entityType) {
        String followeeKey = RedisKeyUtil.getFolloweeKey(userId, entityType);
        return redisTemplate.opsForZSet().zCard(followeeKey);
    }

    /**
     * 查询某个实体的粉丝数
     * @param entityType
     * @param entityId
     * @return
     */
    public long findFollowerCount(int entityType, int entityId) {
        String followerKey = RedisKeyUtil.getFollowerKey(entityType, entityId);
        return redisTemplate.opsForZSet().zCard(followerKey);
    }


    /**
     * 当前用户是否关注了该实体
     * userId->当前登录用户  entityType->用户类型 entityId->关注的用户id
     * @param userId
     * @param entityType
     * @param entityId
     * @return
     */
    public boolean hasFollowed(int userId, int entityType, int entityId) {
        String followeeKey =RedisKeyUtil.getFolloweeKey(userId, entityType);
        //查下score是否为空
        return redisTemplate.opsForZSet().score(followeeKey, entityId) != null;
    }
}


15.kafka

1.阻塞队列

使用原生的jdk方法来实现消息队列

image-20230124164106518

image-20230124170800225

术语解释

  • Broker 服务器(译:中间人)
  • Zookeeper 用于管理集群
  • Topic 消息存放的位置
  • Partition 对Topic进行了分区,提高并发能力
  • Offset 消息在Partition分区内的索引
  • Leader Replica 主副本
  • Follower Replica 从副本,如果主副本挂了,选用一个从副本使用

下载
https://kafka.apache.org/downloads

配置
zookeeper.properties
设置数据路径 dataDir=D:/MyCodeEnv/kafka/data/zookeeper
server.properties
log.dirs=D:/MyCodeEnv/kafka/data/kafka-logs
命令

#启动zookeeper
zookeeper-server-start.bat …/…/config/zookeeper.properties
#启动kafka
kafka-server-start.bat …/…/config/server.properties

#创建一个主题topic 名为test
kafka-topics.bat --create --bootstrap-server localhost:9092 --replication-factor 1 --partitions 1 --topic test

#查看创建的主题
kafka-topics.bat --list --bootstrap-server localhost:9092
test

#创建生产者,发送消息
kafka-console-producer.bat --broker-list localhost:9092 --topic test

hello
world

#创建消费者,接受消息
kafka-console-consumer.bat --bootstrap-server localhost:9092 --topic test --from-beginning

hello
world

3.Spring整合Kafka

image-20230124194727010

引入依赖
  1. ​ org.springframework.kafka
  2. ​ spring-kafka
配置
spring:
  #kafka
  kafka:
    bootstrap-servers: localhost:9092
    producer:
      key-serializer: org.apache.kafka.common.serialization.StringSerializer
      value-serializer: org.apache.kafka.common.serialization.StringSerializer
    consumer:
      group-id: test-consumer-group #根据comsumer.properties配置文件中填写
      enable-auto-commit: true #是否自动提交消费者的偏移量
      auto-commit-interval: 3000 #3秒提交一次
      key-deserializer: org.apache.kafka.common.serialization.StringDeserializer
      value-deserializer: org.apache.kafka.common.serialization.StringDeserializer

测试代码
@SpringBootTest
@RunWith(SpringRunner.class)
@ContextConfiguration(classes = CommunityApplication.class)
public class KafkaTest {
    @Autowired
    private KafkaProducer kafkaProducer;
    @Test
    public void testKafka() throws InterruptedException {
        kafkaProducer.sendMessage("test1","hello1");
        kafkaProducer.sendMessage("test1","world1");

        Thread.sleep(5000);
    }
}

@Component
class KafkaProducer{
    @Autowired
    private KafkaTemplate kafkaTemplate;

    public void sendMessage(String topic, String content){
        kafkaTemplate.send(topic,content);
    }
}

@Component
class KafkaComsumer{
    @KafkaListener(topics = {"test1"})
    public void handleMessage(ConsumerRecord record){
        System.out.println(record.value());
    }
}

发送系统通知功能(点赞关注评论)

1.编写Kafka消息队列事件Event实体类

注意这里set返回Event是为了使用链式编程

加入一个Map是为了可以扩展数据

package com.qiuyu.bean;

/**
 * Kafka消息队列事件(评论、点赞、关注事件
 */
@Getter
public class Event {
    // Kafka必要的主题变量
    private String topic;
    private int userId;
    // 用户发起事件的实体类型(评论、点赞、关注类型)
    private int entityType;
    // 用户发起事件的实体(帖子、评论、用户)id
    private int entityId;
    // 被发起事件的用户id(被评论、被点赞、被关注用户)
    private int entityUserId;
    // 其他可扩充内容对应Comment中的content->显示用户xxx评论、点赞、关注了xxx
    private Map<String,Object> data = new HashMap<>();

    //返回Event方便链式调用
    public Event setTopic(String topic) {
        this.topic = topic;
        return this;
    }

    public Event setUserId(int userId) {
        this.userId = userId;
        return this;
    }

    public Event setEntityType(int entityType) {
        this.entityType = entityType;
        return this;
    }

    public Event setEntityId(int entityId) {
        this.entityId = entityId;
        return this;
    }

    public Event setEntityUserId(int entityUserId) {
        this.entityUserId = entityUserId;
        return this;
    }
    // 方便外界直接调用key-value,而不用再封装一下传整个Map集合
    public Event setData(String key,Object value) {
        this.data.put(key, value);
        return this;
    }

    @Override
    public String toString() {
        return "Event{" +
                "topic='" + topic + '\'' +
                ", userId=" + userId +
                ", entityType=" + entityType +
                ", entityId=" + entityId +
                ", entityUserId=" + entityUserId +
                ", data=" + data +
                '}';
    }
}


2.编写Kafka生产者
package com.qiuyu.event;

@Component
public class EventProducer {
    @Autowired
    private KafkaTemplate kafkaTemplate;

    public void fireEvent(Event event){
        // 将事件发布到指定的主题,内容为event对象转化的json格式字符串
        kafkaTemplate.send(event.getTopic(), JSONObject.toJSONString(event));
    }
}

3.编写Kafka消费者

消费者调用了一些Service,之前使用AOP实现了调用Service时获取request的功能

因为是消费者调用的,所以会空指针异常,需要去处理一下

/**
 * 2023年11月14日10点40分,点赞,评论,关注的消费者
 */
@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;

    //使用 @KafkaListener 注解标记了一个方法,该方法用于监听名为 "TOPIC_COMMENT"、"TOPIC_FOLLOW" 和 "TOPIC_LIKE" 的 Kafka 主题。
//    ConsumerRecord 是 Kafka 客户端 API 中的一个类,它代表了从 Kafka 主题中消费到的一条消息记录。ConsumerRecord 包含了以下几个重要的属性:
//    Topic 和 Partition 信息:记录了这条消息所属的主题和分区。
//    Offset(偏移量):消息在分区中的偏移量,用于标识消息在分区中的位置,可用于实现精确的消息定位和重放。
//    Key:消息的键值,可以用于对消息进行分区和路由。
//    Value:消息的实际内容,即你在消费者端需要处理的数据部分。
//    Timestamp:消息的时间戳,表示消息被创建或被发送到 Kafka 的时间。
    @KafkaListener(topics = {TOPIC_COMMENT,TOPIC_FOLLOW,TOPIC_LIKE})
    public void handleCommentMessage(ConsumerRecord record){
        if(record==null||record.value()==null){
            logger.error("消息内容为空!");
            return;
        }
//        这段代码使用了 JSONObject 的 parseObject 方法,将 ConsumerRecord 中的消息内容解析为一个 JSON 对象,
//        并且将其转换为一个名为 Event 的 Java 对象。
//        假设 record.value().toString() 返回的是一个符合 JSON 格式的字符串,
//        比如 {"id": 123, "name": "example"}。然后 JSONObject.parseObject 方法会将这个 JSON 字符串解析为一个 Java 对象,
//        类型为 Event,即使它的属性与 JSON 中的键值对相匹配。这样你就可以在代码中直接操作 event 对象,而不需要手动解析 JSON 字符串。
        Event event = JSONObject.parseObject(record.value().toString(), Event.class);
        if(event==null){
            logger.error("消息格式有误!");
            return;
        }

        Message message = new Message();
        message.setFromId(SYSTEM_USER_ID);
        // Message表中ToId设置为被发起事件的用户id
        message.setToId(event.getEntityUserId());
        // ConversationId设置为事件的主题(点赞、评论、关注)
        message.setConversationId(event.getTopic());
        message.setStatus(0);
        message.setCreateTime(new Date());

        // 设置content为可扩展内容,封装在Map集合中,用于显示xxx评论..了你的帖子
        HashMap<String, Object> content = new HashMap<>();
        content.put("userId", event.getUserId());
        content.put("entityId", event.getEntityId());
        content.put("entityType", event.getEntityType());

        // 将event.getData里的k-v存到context这个Map中,再封装进message
        // Map.Entry是为了更方便的输出map键值对,Entry可以一次性获得key和value者两个值
        // 其实就是把俩map合并
        if (!event.getData().isEmpty()) {
            for (Map.Entry<String, Object> entry : event.getData().entrySet()) {
                content.put(entry.getKey(), entry.getValue());
            }
        }

        // 将content(map类型)转化成字符串类型封装进message
        message.setContent(JSONObject.toJSONString(content));
        messageService.addMessage(message);

    }
4.在CommunityConstant添加Kafka主题静态常量
public interface CommunityConstant {
     /**
     * Kafka主题: 评论
     */
    String TOPIC_COMMENT = "comment";
    /**
     * Kafka主题: 点赞
     */
    String TOPIC_LIKE = "like";
    /**
     * Kafka主题: 关注
     */
    String TOPIC_FOLLOW = "follow";
    /**
     * 系统用户ID
     */
    int SYSTEM_USER_ID = 1;
}


5.处理触发评论事件CommentController
 /**
     * 添加回复
     * @param discussPostId
     * @param comment
     * @return
     */
@PostMapping("/add/{discussPostId}")
public String addComment(@PathVariable("discussPostId") int discussPostId, Comment comment){
    comment.setUserId(hostHolder.getUser().getId());
    comment.setStatus(0);
    comment.setCreateTime(new Date());
    commentService.addComment(comment);

    //触发评论事件
    Event event = new Event()
        .setTopic(TOPIC_COMMENT)
        .setUserId(hostHolder.getUser().getId())
        .setEntityType(comment.getEntityType())
        .setEntityId(comment.getEntityId())
        .setData("postId",discussPostId); //方便之后跳到帖子上

    /**
         * event.setEntityUserId要分情况设置被发起事件的用户id
         * 1.评论的是帖子,被发起事件(评论)的用户->该帖子发布人id
         * 2.评论的是用户的评论,被发起事件(评论)的用户->该评论发布人id
         */
    if (comment.getEntityType() == ENTITY_TYPE_POST) {
        // 先找评论表对应的帖子id,在根据帖子表id找到发帖人id
        DiscussPost target = discussPostService.findDiscussPostById(comment.getEntityId());
        event.setEntityUserId(Integer.valueOf(target.getUserId()));
    } else if (comment.getEntityType() == ENTITY_TYPE_COMMENT) {
        Comment target = commentService.findCommentById(comment.getEntityId());
        event.setEntityUserId(target.getUserId());
    }
    eventProducer.fireEvent(event);

    return "redirect:/discuss/detail/"+discussPostId;
}

6.处理触发点赞事件LikeController

注意形参添加了一个postId,方便之后再通知页写跳转到具体帖子页的链接

@PostMapping("/like")
@ResponseBody
// 加了一个postId变量,对应的前端和js需要修改
public String like(int entityType, int entityId,int entityUserId, int postId){
    User user = hostHolder.getUser();

    // 点赞
    likeService.like(user.getId(), entityType,entityId,entityUserId);
    // 获取对应帖子、留言的点赞数量
    long entityLikeCount = likeService.findEntityLikeCount(entityType, entityId);
    // 获取当前登录用户点赞状态(1:已点赞 0:赞)
    int entityLikeStatus = likeService.findEntityLikeStatus(user.getId(), entityType, entityId);

    Map<String,Object> map = new HashMap<>();
    map.put("likeCount",entityLikeCount);
    map.put("likeStatus",entityLikeStatus);

    /**
         * 触发点赞事件
         * 只有点赞完后,才会调用Kafka生产者,发送系统通知,取消点赞不会调用事件
         */
    if (entityLikeStatus == 1) {
        Event event = new Event()
            .setTopic(TOPIC_LIKE)
            .setEntityId(entityId)
            .setEntityType(entityType)
            .setUserId(user.getId())
            .setEntityUserId(entityUserId)
            .setData("postId", postId);
        // 注意:data里面存postId是因为点击查看后链接到具体帖子的页面

        eventProducer.fireEvent(event);
    }

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

形参改变添加了一个postId,HTML 和 JS 也要修改

<!--对应的前端postId变量以及js的修改-->
<a href="javascript:;" th:onclick="like(this,2,[[${replyvo.reply.id}]],[[${replyvo.reply.userId}]],[[${post.id}]])" class="text-primary">
</a>
function like(btn, entityType, entityId, entityUserId, postId) {
  $.post(
      CONTEXT_PATH + "/like",
      {"entityType": entityType, "entityId": entityId, "entityUserId": entityUserId, "postId":postId},
      function(data) {
      .....}
  );}


7.处理触发关注事件FollowController
/**
 * 关注
 * @param entityType
 * @param entityId
 * @return
 */
@PostMapping("/follow")
@ResponseBody
public String follow(int entityType, int entityId) {
    followService.follow(hostHolder.getUser().getId(), entityType, entityId);

    /**
     * 触发关注事件
     * 关注完后,调用Kafka生产者,发送系统通知
     */
    Event event = new Event()
            .setTopic(TOPIC_FOLLOW)
            .setUserId(hostHolder.getUser().getId())
            .setEntityType(entityType)
            .setEntityId(entityId)
            .setEntityUserId(entityId);
    // 用户关注实体的id就是被关注的用户id->EntityId=EntityUserId
    eventProducer.fireEvent(event);

    return CommunityUtil.getJSONString(0,"已关注");
}

17.Elasticsearch

image-20230125183215195

1.术语解释

  • 索引: 对应mysql中的数据库
  • 类型: 对应mysql中的表,在7.0版本后被弃用
  • 文档: 对应一行(一条数据)
  • 字段: 对应字段
  • 分片: 把一个索引分为多个来存,提高并发能力
  • 副本: 对分片的备份

2.下载/配置

下载本体

https://www.elastic.co/cn/downloads

elasticsearch.yml

cluster.name: elastic #集群名字
path.data: D:\MyCodeEnv\elasticsearch\elasticsearch-7.17.7\data
path.logs: D:\MyCodeEnv\elasticsearch\elasticsearch-7.17.7\logs

下载中文分词插件

https://github.com/medcl/elasticsearch-analysis-ik

没看到7.17.7的版本插件,查看issus,有人说用7.17.6也行

unzip elasticsearch-analysis-ik-7.17.6.zip
vi plugin-descriptor.properties
modify elasticsearch.version=7.17.6 to elasticsearch.version=7.17.7
restart es
ok,ik is working

解压到\plugins\ik下(必须)

3.常用命令

elasticsearch.bat #打开es
curl -X GET “localhost:9200/_cat/health?v” #显示健康状态
curl -X GET “localhost:9200/_cat/nodes?v” #查看节点
curl -X GET “localhost:9200/_cat/indices?v” #查看索引

curl -X PUT “localhost:9200/test” #加入索引test(健康为yellow)
curl -X DELETE “localhost:9200/test” #删除索引

或者直接使用postman发送

  • 添加数据 POST(规范)/PUT

image-20230125194329393

4.分词搜索测试

  • 建3条数据

image-20231127121914273

全部搜索
localhost:9200/test/_search

{
“took”: 919,
“timed_out”: false,
“_shards”: {
“total”: 1,
“successful”: 1,
“skipped”: 0,
“failed”: 0
},
“hits”: {
“total”: {
“value”: 3,
“relation”: “eq”
},
“max_score”: 1.0,
“hits”: [
{
“_index”: “test”,
“_type”: “_doc”,
“_id”: “1”,
“_score”: 1.0,
“_source”: {
“title”: “互联网求职”,
“content”: “寻求一份运营的工作”
}
},
{
“_index”: “test”,
“_type”: “_doc”,
“_id”: “2”,
“_score”: 1.0,
“_source”: {
“title”: “互联网招聘”,
“content”: “招聘一位资深程序员”
}
},
{
“_index”: “test”,
“_type”: “_doc”,
“_id”: “3”,
“_score”: 1.0,
“_source”: {
“title”: “实习生推荐”,
“content”: “本人在一家互联网公司任职,可推荐实习开发岗位”
}
}
]
}
}

条件搜索
localhost:9200/test/_search?q=title:互联网
{
    "took": 20,
    "timed_out": false,
    "_shards": {
        "total": 1,
        "successful": 1,
        "skipped": 0,
        "failed": 0
    },
    "hits": {
        "total": {
            "value": 2,
            "relation": "eq"
        },
        "max_score": 2.4269605,
        "hits": [
            {
                "_index": "test",
                "_type": "_doc",
                "_id": "1",
                "_score": 2.4269605,
                "_source": {
                    "title": "互联网求职",
                    "content": "寻求一份运营的工作"
                }
            },
            {
                "_index": "test",
                "_type": "_doc",
                "_id": "2",
                "_score": 2.4269605,
                "_source": {
                    "title": "互联网招聘",
                    "content": "招聘一位资深程序员"
                }
            }
        ]
    }
}

localhost:9200/test/_search?q=content:运营实习

这里进行了分词,运营和实习

{
    "took": 2,
    "timed_out": false,
    "_shards": {
        "total": 1,
        "successful": 1,
        "skipped": 0,
        "failed": 0
    },
    "hits": {
        "total": {
            "value": 2,
            "relation": "eq"
        },
        "max_score": 2.7725885,
        "hits": [
            {
                "_index": "test",
                "_type": "_doc",
                "_id": "1",
                "_score": 2.7725885,
                "_source": {
                    "title": "互联网求职",
                    "content": "寻求一份运营的工作"
                }
            },
            {
                "_index": "test",
                "_type": "_doc",
                "_id": "3",
                "_score": 1.7940278,
                "_source": {
                    "title": "实习生推荐",
                    "content": "本人在一家互联网公司任职,可推荐实习开发岗位"
                }
            }
        ]
    }
}

多条件查询

localhost:9200/test/_search

条件写在body中

{
    "query":{
        "multi_match":{
            "query":"互联网",
            "fields":["title","content"]
        }
    }
}

结果

{
    "took": 6,
    "timed_out": false,
    "_shards": {
        "total": 1,
        "successful": 1,
        "skipped": 0,
        "failed": 0
    },
    "hits": {
        "total": {
            "value": 3,
            "relation": "eq"
        },
        "max_score": 2.6910417,
        "hits": [
            {
                "_index": "test",
                "_type": "_doc",
                "_id": "3",
                "_score": 2.6910417,
                "_source": {
                    "title": "实习生推荐",
                    "content": "本人在一家互联网公司任职,可推荐实习开发岗位"
                }
            },
            {
                "_index": "test",
                "_type": "_doc",
                "_id": "1",
                "_score": 2.2024121,
                "_source": {
                    "title": "互联网求职",
                    "content": "寻求一份运营的工作"
                }
            },
            {
                "_index": "test",
                "_type": "_doc",
                "_id": "2",
                "_score": 2.2024121,
                "_source": {
                    "title": "互联网招聘",
                    "content": "招聘一位资深程序员"
                }
            }
        ]
    }
}

5.Spring整合ES

image-20230125195745909

1.导入包
 <dependency>
     <groupId>org.springframework.boot</groupId>
     <artifactId>spring-boot-starter-data-elasticsearch</artifactId>
</dependency>

2.配置
spring:	
  #ElasticSearch
  data:
    elasticsearch:
      cluster-name: elastic
      cluster-nodes: localhost:9300

ES和Rediss底层都使用了netty,会导致冲突,需要在启动类中设置一下

package com.qiuyu;

@SpringBootApplication
public class CommunityApplication {
    @PostConstruct
    public void init(){
        //解决Netty启动冲突的问题
        System.setProperty("es.set.netty.running.available.processors","false");
    }

    public static void main(String[] args) {
        SpringApplication.run(CommunityApplication.class, args);
    }

}

18.搜索功能(Elasticsearch + Kafka)

image-20230126012440922

1.编写实体类映射到ES服务器
image-20231127123140726


//indexName = "discusspost": 指定了文档存储库的名称为 "discusspost",表示这个类的实例会被存储在名为 "discusspost" 的文档库中。
//        type = "_doc": 在早期的 Elasticsearch 版本中,文档会被组织在类型(type)内部。但是在最新的 Elasticsearch 版本中,
//        类型已经逐渐被用,所以这里的 type 实际上是一个固定的值 "_doc",表示这个类的实例将以 "_doc" 类型存储在 "discusspost" 文档库中。
//        shards = 6: 指定了该文档库的分片数量为 6。在 Elasticsearch 中,分片是文档存储的基本单位,它允许将数据分布到集群中的多个节点上,从而提高性能和可伸缩性。
//        replicas = 3: 指定了每个分片的副本数量为 3。在 Elasticsearch 中,副本用于提供容错能力和读取负载均衡。指定了这么多的副本数量可能会增加系统的稳定性和可用性。
@Data
@Document(indexName = "discusspost", type = "_doc", shards = 6,replicas = 3)
public class DiscussPost {
    @Id
    private int id;

    @Field(type= FieldType.Integer)
    private int userId;

//    type = FieldType.Text: 指定字段的类型为 Text。在 Elasticsearch 中,Text 类型用于存储长文本数据,该类型的字段会被分析器进行分词处理。
//    analyzer = "ik_max_word": 指定了在索引时使用的分词器。这里使用了名为 "ik_max_word" 的分词器,它基于 IK 分词器实现,可以将文本按照最大切分粒度进行分词。
//    searchAnalyzer = "ik_smart": 指定了在搜索时使用的分词器。这里使用了名为 "ik_smart" 的分词器,它也是基于 IK 分词器实现,
//    但相比于 "ik_max_word",它采用了更智能的切分策略。
    @Field(type = FieldType.Text,analyzer = "ik_max_word",searchAnalyzer = "ik_smart")
    private String title;

    @Field(type = FieldType.Text,analyzer = "ik_max_word",searchAnalyzer = "ik_smart")
    private String content;

    @Field(type= FieldType.Integer)
    private int type;

    @Field(type= FieldType.Integer)
    private int status;

    @Field(type= FieldType.Date)
    private Date createTime;

    @Field(type= FieldType.Integer)
    private int commentCount;

    @Field(type= FieldType.Double)
    private double score;
}

2.编写xxxRepository接口继承ElasticsearchRepository

package com.qiuyu.dao.elasticsearch;

/**
 * ElasticsearchRepository<DiscussPost, Integer>
 * DiscussPost:接口要处理的实体类
 * Integer:实体类中的主键是什么类型
 * ElasticsearchRepository:父接口,其中已经事先定义好了对es服务器访问的增删改查各种方法。Spring会给它自动做一个实现,我们直接去调就可以了。
 */
@Repository
public interface DiscussPostRepository extends ElasticsearchRepository<DiscussPost, Integer> {
}

3.测试操作Demo

package com.qiuyu;

import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.qiuyu.bean.DiscussPost;
import com.qiuyu.bean.MyPage;
import com.qiuyu.dao.DiscussPostMapper;
import com.qiuyu.dao.elasticsearch.DiscussPostRepository;
import org.elasticsearch.index.query.QueryBuilders;
import org.elasticsearch.search.fetch.subphase.highlight.HighlightBuilder;
import org.elasticsearch.search.sort.SortBuilders;
import org.elasticsearch.search.sort.SortOrder;
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.data.domain.PageRequest;
import org.springframework.data.domain.Sort;
import org.springframework.data.elasticsearch.core.*;
import org.springframework.data.elasticsearch.core.query.NativeSearchQuery;
import org.springframework.data.elasticsearch.core.query.NativeSearchQueryBuilder;
import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.context.junit4.SpringRunner;

import java.util.ArrayList;
import java.util.List;
import java.util.Optional;

@SpringBootTest
@RunWith(SpringRunner.class)
@ContextConfiguration(classes = CommunityApplication.class)
public class ElasticSearchTest {
    @Autowired
    private DiscussPostMapper discussPostMapper;
    @Autowired
    private DiscussPostRepository discussPostRepository;
    @Autowired
    private ElasticsearchRestTemplate elasticsearchRestTemplate;


    /**
     * 插入数据
     */
    @Test
    public void testInsert(){
        discussPostRepository.save(discussPostMapper.selectById(241));
        discussPostRepository.save(discussPostMapper.selectById(242));
        discussPostRepository.save(discussPostMapper.selectById(243));
    }

    /**
     * 批量插入数据
     */
    @Test
    public void testInsertList(){
        List<DiscussPost> list = discussPostMapper.selectList(new QueryWrapper<DiscussPost>()
                .lambda()
                .ge(DiscussPost::getId, 195));
        discussPostRepository.saveAll(list);
    }

    /**
     * 修改
     */
    @Test
    public void testUpdate(){
        DiscussPost discussPost = discussPostMapper.selectById(231);
        discussPost.setContent("秋雨灌水");
        discussPostRepository.save(discussPost);
    }

    /**
     * 删除
     */
    @Test
    public void testDelete(){
//        discussPostRepository.deleteById(231);
        //删除所有
        discussPostRepository.deleteAll();
    }

    /**
     * 根据id查找
     */
    @Test
    public void findById(){
        DiscussPost discussPost = discussPostRepository.findById(230).get();
        System.out.println(discussPost);
    }

    @Test
    public void testSearch(){
        NativeSearchQuery searchQuery = new NativeSearchQueryBuilder()
                .withQuery(QueryBuilders.multiMatchQuery("互联网寒冬","title","content"))
                .withSort(SortBuilders.fieldSort("type").order(SortOrder.DESC))
                .withSort(SortBuilders.fieldSort("score").order(SortOrder.DESC))
                .withSort(SortBuilders.fieldSort("createTime").order(SortOrder.DESC))
                .withPageable(PageRequest.of(0,10))
                .withHighlightFields(
                        new HighlightBuilder.Field("title").preTags("<em>").postTags("</em>"),
                        new HighlightBuilder.Field("content").preTags("<em>").postTags("</em>")
                ).build();

        SearchHits<DiscussPost> searchHits = elasticsearchRestTemplate.search(searchQuery, DiscussPost.class);
        SearchPage<DiscussPost> searchPage = SearchHitSupport.searchPageFor(searchHits, searchQuery.getPageable());
//        System.out.println(searchPage.getTotalElements());
//        System.out.println(searchPage.getTotalPages());
//        System.out.println(searchPage.getNumber());
//        System.out.println(searchPage.getSize());
//        for (SearchHit<DiscussPost> discussPostSearchHit : page) {
//            System.out.println(discussPostSearchHit.getHighlightFields()); //高亮内容
//            System.out.println(discussPostSearchHit.getContent()); //原始内容
//        }

        //封装到MyPage
        List<DiscussPost> list = new ArrayList<>();
        IPage<DiscussPost> page = new MyPage<>();

        for (SearchHit<DiscussPost> discussPostSearchHit : searchPage) {
            DiscussPost discussPost = discussPostSearchHit.getContent();
            //discussPostSearchHit.getHighlightFields() //高亮
            if (discussPostSearchHit.getHighlightFields().get("title") != null) {
                discussPost.setTitle(discussPostSearchHit.getHighlightFields().get("title").get(0));
            }
            if (discussPostSearchHit.getHighlightFields().get("content") != null) {
                discussPost.setContent(discussPostSearchHit.getHighlightFields().get("content").get(0));
            }
            //System.out.println(discussPostSearchHit.getContent());
            list.add(discussPost);
        }

        page.setRecords(list);
        page.setSize(searchPage.getSize());
        page.setTotal(searchPage.getTotalElements());
        page.setPages(searchPage.getTotalPages());
        page.setCurrent(searchPage.getNumber()+1);


        for (DiscussPost record : page.getRecords()) {
            System.out.println(record);
        }
    }
}


他这里命中高亮的部分与视频实现的不一样,可以学习。

4.Service层

package com.qiuyu.service;

@Service
public class ElasticsearchService {
    @Autowired
    private DiscussPostRepository discussRepository;
    @Autowired
    private ElasticsearchRestTemplate elasticsearchRestTemplate;

    public void saveDiscussPost(DiscussPost post) {
        discussRepository.save(post);
    }

    public void deleteDiscussPost(int id) {
        discussRepository.deleteById(id);
    }

    /**
     * Elasticsearch高亮搜索
     * @param keyword
     * @param page
     * @return
     */
    public IPage<DiscussPost> searchDiscussPost(String keyword, IPage<DiscussPost> page) {
        page.setCurrent(page.getCurrent() < 1 ? 1 : page.getCurrent());
        NativeSearchQuery searchQuery = new NativeSearchQueryBuilder()
                .withQuery(QueryBuilders.multiMatchQuery(keyword,"title","content"))
                .withSort(SortBuilders.fieldSort("type").order(SortOrder.DESC))
                .withSort(SortBuilders.fieldSort("score").order(SortOrder.DESC))
                .withSort(SortBuilders.fieldSort("createTime").order(SortOrder.DESC))
                .withPageable(PageRequest.of((int) (page.getCurrent()-1), (int) page.getSize()))
                .withHighlightFields(
                        new HighlightBuilder.Field("title").preTags("<em>").postTags("</em>"),
                        new HighlightBuilder.Field("content").preTags("<em>").postTags("</em>")
                ).build();


        SearchHits<DiscussPost> searchHits = elasticsearchRestTemplate.search(searchQuery, DiscussPost.class);
        SearchPage<DiscussPost> searchPage = SearchHitSupport.searchPageFor(searchHits, searchQuery.getPageable());


        //封装到MyPage
        List<DiscussPost> list = new ArrayList<>();
        for (SearchHit<DiscussPost> discussPostSearchHit : searchPage) {
            DiscussPost discussPost = discussPostSearchHit.getContent();
            //discussPostSearchHit.getHighlightFields() //高亮
            if (discussPostSearchHit.getHighlightFields().get("title") != null) {
                discussPost.setTitle(discussPostSearchHit.getHighlightFields().get("title").get(0));
            }
            if (discussPostSearchHit.getHighlightFields().get("content") != null) {
                discussPost.setContent(discussPostSearchHit.getHighlightFields().get("content").get(0));
            }
            //System.out.println(discussPostSearchHit.getContent());
            list.add(discussPost);
        }

        page.setRecords(list);
        page.setSize(searchPage.getSize());
        page.setTotal(searchPage.getTotalElements());
        page.setPages(searchPage.getTotalPages());
        page.setCurrent(searchPage.getNumber()+1);

        return page;
    }
}

4.修改发布帖子和增加评论Controller

发布帖子时,将帖子异步提交到Elasticsearch服务器

增加评论时,将帖子异步提交到Elasticsearch服务器(因为帖子的评论数量变了)

/**
* Kafka主题: 发布帖子(常量接口)
*/
String TOPIC_PUBILISH = "publish";

package com.qiuyu.controller;
    /**
     * 添加帖子
     */
    @PostMapping("/add")
    @ResponseBody
//    @LoginRequired
    public String addDiscussPost(String title, String content) {
		.....
            
        //触发发帖事件,让消费者将帖子存入ElasticSearch
        Event event = new Event()
                .setTopic(TOPIC_PUBLISH)
                .setUserId(user.getId())
                .setEntityType(ENTITY_TYPE_POST)
                .setEntityId(post.getId());
        eventProducer.fireEvent(event);


        //返回Json格式字符串给前端JS,报错的情况将来统一处理
        return CommunityUtil.getJSONString(0, "发布成功!");
    }

}


/**
* 添加回复
*/
    @PostMapping("/add/{discussPostId}")
    public String addComment(@PathVariable("discussPostId") int discussPostId, Comment comment){
        .......

        //评论帖子时
        if (comment.getEntityType() == ENTITY_TYPE_POST) {
            //触发发帖事件,让消费者将帖子存入ElasticSearch
            event = new Event()
                    .setTopic(TOPIC_PUBLISH)
                    .setUserId(hostHolder.getUser().getId())
                    .setEntityType(ENTITY_TYPE_POST)
                    .setEntityId(discussPostId);
            eventProducer.fireEvent(event);
        }

        return "redirect:/discuss/detail/"+discussPostId;
    }


5.在Kafka消费者中增加方法(消费帖子发布事件)

   /**
     * 消费发帖事件
     * @param record
     */
@KafkaListener(topics = {TOPIC_PUBLISH})
public void handlePublishMessage(ConsumerRecord record){
    if (record == null || record.value() == null) {
        logger.error("消息的内容为空!");
        return;
    }
    // 将record.value字符串格式转化为Event对象
    Event event = JSONObject.parseObject(record.value().toString(), Event.class);
    if (event == null) {
        logger.error("消息格式错误!");
        return;
    }

    //根据帖子id查询到帖子,然后放到ES中
    DiscussPost discussPost = discussPostService.findDiscussPostById(event.getEntityId());
    elasticsearchService.saveDiscussPost(discussPost);

}

6.编写SearchController类

package com.qiuyu.controller;

@Controller
public class SearchController implements CommunityConstant {
    @Autowired
    private UserService userService;
    @Autowired
    private LikeService likeService;
    @Autowired
    private ElasticsearchService elasticsearchService;

    // search?keyword=xxx
    @GetMapping("/search")
    public String search(String keyword, MyPage<DiscussPost> page, Model model) {
        // 搜索帖子
        page.setSize(10);
        page = (MyPage<DiscussPost>) elasticsearchService.searchDiscussPost(keyword, page);
        List<DiscussPost> searchResult = page.getRecords();

        // 聚合数据
        List<Map<String, Object>> discussPostVo = new ArrayList<>();
        if (searchResult != null) {
            for (DiscussPost post : searchResult) {
                Map<String, Object> map = new HashMap<>();
                // 帖子
                map.put("post", post);
                // 作者
                map.put("user", userService.findUserById(post.getUserId()));
                // 点赞数量
                map.put("likeCount", likeService.findEntityLikeCount(ENTITY_TYPE_POST, post.getId()));

                discussPostVo.add(map);
            }
        }

        model.addAttribute("discussPostVo", discussPostVo);
        // 为了页面上取的默认值方便
        model.addAttribute("keyword", keyword);
        model.addAttribute("page", page);

        page.setPath("/search?keyword=" + keyword);

        return "/site/search";
    }
}

19.权限控制

1.Spring Security

image-20230126205641914

  • 认证:判断用户是否登录
  • 授权:认证后判断用户是否有某一部分的权限,比如加精置顶

底层基于过滤器Filter

image-20230126213917191

依赖
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-security</artifactId>
</dependency>

导入直接就生效了,会生成一个默认账号密码

Using generated security password: c903823d-de73-44e9-a06d-7444d82f1c3d

2. 权限控制实现

image-20230126224852951

2.1去掉之前的登录拦截器
//    @Autowired
//    private LoginRequiredInterceptor loginRequiredInterceptor;
2.2 配置类

走自己的认证


@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 {
//        在 Spring Security 中,authorizeRequests() 方法用于定义针对不同请求路径的访问控制规则。
//        通过该方法,您可以指定哪些请求需要进行身份验证,并可以根据用户的角色或其他条件来限制其访问权限
        http.authorizeRequests()
                .antMatchers(
//                        方法用于指定要匹配的请求路径,可以使用 Ant 风格的通配符进行模式匹配。
//                        例如,.antMatchers("/letter") 表示匹配 "/letter" 路径的请求。
                    "/user/setting",
                        "/user/upload",
                        "/discuss/add",
                        "/comment/add/**",
                        "/letter/**",
                        "/notice/**",
                        "/like",
                        "/follow",
                        "/unfollow"
                )
                .hasAnyAuthority(
//                        方法用于指定需要具备的权限,可以传入一个或多个权限作为参数。
//                        例如,.hasAnyAuthority("USER","ADMIN") 表示要求用户具备 "USER" 或 "ADMIN" 权限之一。
                         AUTHORITY_ADMIN,
                        AUTHORITY_USER,
                        AUTHORITY_MODERATOR
                )
                .antMatchers(
                        "/discuss/top",
                        "/discuss/wonderful"
                )
                .hasAnyAuthority(
                        AUTHORITY_MODERATOR
                )
                .antMatchers(

                        "/discuss/delete",
                        "/data/**"
//                        "/actuator/**"
                )
                .hasAnyAuthority(
                        AUTHORITY_ADMIN
                )
                        .anyRequest().permitAll()
                //2023年11月16日18点53分  老师偷懒不想每个异步请求都去带CSRF令牌验证,直接跳过该验证了。
                //可以自己去把所有异步的请求都去实现一下。
                        .and().csrf().disable();

        //权限不足时,怎么去处理
//        http.exceptionHandling()方法的作用。它用于配置Spring Security中的异常处理策略
        http.exceptionHandling()
                .authenticationEntryPoint(new AuthenticationEntryPoint() {
                    //没有登陆时的处理方案
                    @Override
                    public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException e) throws IOException, ServletException {
                        String xRequestWith = request.getHeader("X-Requested-With");
                        if("XMLHttpRequest".equals(xRequestWith)){
                            //是异步的请求,返回json数据
                            response.setContentType("application/plain;charset=utf-8");
                            PrintWriter writer=response.getWriter();
                            writer.write(CommunityUtil.getJSONString(403,"你还没有登陆!"));
                        }else{
                            //不是异步请求,返回页面路径
                          response.sendRedirect(request.getContextPath()+"/login");
                        }
                    }
                })
                .accessDeniedHandler(new AccessDeniedHandler() {
                    //权限不足时的处理方案
                    @Override
                    public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException e) throws IOException, ServletException {
                        String xRequestWith = request.getHeader("X-Requested-With");
                        if("XMLHttpRequest".equals(xRequestWith)){
                            //是异步的请求,返回json数据
                            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("/sercurityLogout");
    }
}
2.3 编写UserService增加自定义登录认证方法,返回用户的权限,在spring Scrutiny中的
  public Collection<? extends GrantedAuthority> getAuthorites(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;
    }
2.4 编写登录凭证拦截器LoginTicketInterceptor

构建用户认证结果,并存入SecurityContext,以便于Security进行授权

@Override
/**在Controller访问所有路径之前获取凭证**/
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
    //...................................

    if (loginTicket != null && loginTicket.getStatus() == 0 && loginTicket.getExpired().after(new Date())) {
        // ...............................
        /**
         * 构建用户认证结果,并存入SecurityContext,以便于Security进行授权
         */
        Authentication authentication = new UsernamePasswordAuthenticationToken(
            user, user.getPassword(), userService.getAuthorities(user.getId()));
        SecurityContextHolder.setContext(new SecurityContextImpl(authentication));
    }
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
    // 释放线程资源
    hostHolder.clear();
    // 释放SecurityContext资源 这里删除的话.一个页面就无法进行多次异步请求了
    //SecurityContextHolder.clearContext();
}

2.5 退出登录时释放SecurityContext资源
/**
     * 退出登录功能
     * @CookieValue()注解:将浏览器中的Cookie值传给参数
     */
@GetMapping("/logout")
public String logout(@CookieValue("ticket") String ticket){
    userService.logout(ticket);

    // 释放SecurityContext资源
    SecurityContextHolder.clearContext();

    return "redirect:/login";//重定向
}

2.6 注意:防止CSRF攻击

CSRF攻击原理

  • 第三方网站拿到了你的ticket,然后发送给了服务器
  • 解决:服务器给浏览器的表单中有一个随机的token,这个无法被第三方拿走

image-20230126234338817

由于服务端SpringSecurity自带防止CSRF攻击,因此只要编写前端页面防止CSRF攻击即可 \ (常发生在提交表单时)

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

Ajax异步请求时携带该参数

function publish() {
   $("#publishModal").modal("hide");
   // 发送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);
   });
   // ...............................
}

20.网站数据统计(HyperLogLog BitMap)

image-20230127210341781

  • DAU 要求统计登录后的用户,要求精确统计,不能有误差

1.编写RedisUtil规范Key值

    // UV (网站访问用户数量---根据Ip地址统计(包括没有登录的用户))
    private static final String PREFIX_UV = "uv";
    // DAU (活跃用户数量---根据userId)
    private static final String PREFIX_DAU = "dau";
    
    /**
     * 存储单日ip访问数量(uv)--HyperLogLog ---k:时间 v:ip  (HyperLogLog)
     * 示例:uv:20220526 = ip1,ip2,ip3,...
     */
    public static String getUVKey(String date) {
        return PREFIX_UV + SPLIT + date;
    }

    /**
     * 获取区间ip访问数量(uv)
     * 示例:uv:20220525:20220526 = ip1,ip2,ip3,...
     */
    public static String getUVKey(String startDate, String endDate) {
        return PREFIX_UV + SPLIT + startDate + SPLIT + endDate;
    }

    /**
     * 存储单日活跃用户(dau)--BitMap ---k:date v:userId索引下为true  (BitMap)
     * 示例:dau:20220526 = userId1索引--(true),userId2索引--(true),....
     */
    public static String getDAUKey(String date) {
        return PREFIX_DAU + SPLIT + date;
    }

    /**
     * 获取区间活跃用户
     * 示例:dau:20220526:20220526
     */
    public static String getDAUKey(String startDate, String endDate) {
        return PREFIX_DAU + SPLIT + startDate + SPLIT + endDate;
    }

2.编写DataService业务层

@Autowired
private RedisTemplate redisTemplate;

// 将Date类型转化为String类型
private SimpleDateFormat df = new SimpleDateFormat("yyyyMMdd");

/*********************** HypeLogLog*************************/
// 将指定ip计入UV---k:当前时间 v:ip
public void recordUV(String ip) {
    String redisKey = RedisKeyUtil.getUVKey(df.format(new Date()));
    redisTemplate.opsForHyperLogLog().add(redisKey, ip);
}

// 统计指定日期范围内的ip访问数UV
public long calculateUV(Date start, Date end) {
    if (start == null || end == null) {
        throw new IllegalArgumentException("参数不能为空!");
    }
    if (start.after(end)) {
        throw new IllegalArgumentException("请输入正确的时间段!");
    }
    // 整理该日期范围内的Key
    List<String> keyList = new ArrayList<>();
    Calendar calendar = Calendar.getInstance();
    calendar.setTime(start);
    while (!calendar.getTime().after(end)) {
        // 获取该日期范围内的每一天的Key存入集合
        String key = RedisKeyUtil.getUVKey(df.format(calendar.getTime()));
        keyList.add(key);
        // 日期+1(按照日历格式)
        calendar.add(Calendar.DATE, 1);
    }
    // 合并日期范围内相同的ip
    String redisKey = RedisKeyUtil.getUVKey(df.format(start), df.format(end));
    // 获取keyList中的每一列key进行合并
    redisTemplate.opsForHyperLogLog().union(redisKey, keyList.toArray());

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

/*********************** BitMap *****************************/
// 将指定用户计入DAU --k:当前时间 v:userId
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("参数不能为空!");
    }
    if (start.after(end)) {
        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());
        // 日期+1(按照日历格式)
        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]));
            return connection.bitCount(redisKey.getBytes());
        }
    });}


在DataInterceptor拦截器中调用Service(每次请求最开始调用)

package com.qiuyu.controller.interceptor;

@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 {
        // 获取请求用户的ip地址,统计UV
        String Ip = request.getRemoteHost();
        dataService.recordUV(Ip);

        // 统计DA
        User user = hostHolder.getUser();
        if (user != null) {
            dataService.recordDAU(user.getId());
        }
        return true;
    }
    
}
/***********注册拦截器*********/
@Configuration
public class WebMvcConfig implements WebMvcConfigurer {
    @Autowired
    private DataInterceptor dataInterceptor;

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

4.编写DataController用以渲染模板

  • @DateTimeFormat 告诉服务器日期的格式
package com.qiuyu.controller;

@Controller
public class DataController {
    @Autowired
    private DataService dataService;

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

    /**
     * 统计网站UV(ip访问数量)
     * @DateTimeFormat将时间参数转化为字符串
     */
    @PostMapping( "/data/uv")
    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);
        // 转发到 /data请求
        return "forward:/data";
    }
    /**
     * 统计网站DAU(登录用户访问数量)
     */
    @PostMapping("/data/dau")
    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";
    }
}

5.编写SecurityConfig进行权限控制

.antMatchers(
    "/discuss/delete",
    "/data/* *"
)
    .hasAnyAuthority(
    AUTHORITY_ADMIN
)

21.线程池(Quartz)

一些任务不是由浏览器发给服务器,服务器才去做的,比如 服务器半小时统计下数据、一小时清理下临时文件等等,这些就需要任务调度

image-20230127223626975

  1. jdk和spring的线程池,在各个服务器中都有一份,如果有定时任务,每隔一段时间,每个服务器都会进行一次任务处理
  2. Quartz将数据存储在数据库中,进行加锁来处理分布式定时任务的问题

JDK线程池

package com.qiuyu;

@SpringBootTest
@RunWith(SpringRunner.class)
public class ThreadPoolTest {
    private static final Logger logger = LoggerFactory.getLogger(ThreadPoolTest.class);

    ThreadPoolExecutor threadPoolExecutor = new ThreadPoolExecutor(
    2, // corePoolSize: 线程池的核心线程数,即线程池维护的最小线程数
    5, // maximumPoolSize: 线程池的最大线程数
    60, // keepAliveTime: 线程空闲时间,超过该时间的空闲线程会被回收
    TimeUnit.SECONDS, // 时间单位
    new LinkedBlockingQueue<>(), // workQueue: 任务队列,存放未执行的任务
    Executors.defaultThreadFactory(), // threadFactory: 线程工厂,用于创建新线程
    new ThreadPoolExecutor.AbortPolicy() // handler: 拒绝策略,当任务添加到线程池中被拒绝时的处理策略
);

    //JDK定时线程池
    private ScheduledExecutorService scheduledExecutorService = Executors.newScheduledThreadPool(5);

    private void sleep(int t) {
        try {
            Thread.sleep(t);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
    }


    /**
     * JDK普通线程池测试
     */
    @Test
    public void testJDK1(){
        for (int i = 0; i < 10; i++) {
            threadPoolExecutor.submit(()->{
                logger.debug("Hello!");
            });
        }

    }

    /**
     * JDK定时线程池测试
     */
    @Test
    public void testJDK2(){
        // 任务 多久后开始(延迟) 间隔 时间单位
        scheduledExecutorService.scheduleAtFixedRate(()->{
            logger.debug("Hello!");
        }, 10, 1,TimeUnit.SECONDS);

        sleep(30000);
    }

}

……

new ThreadPoolExecutor.AbortPolicy() // handler: 拒绝策略,当任务添加到线程池中被拒绝时的处理策略

………

在上述代码中,使用的是 AbortPolicy 拒绝策略,即 ThreadPoolExecutor.AbortPolicy()

拒绝策略定义了当任务无法提交给线程池执行时的处理方式。AbortPolicyThreadPoolExecutor 默认的拒绝策略,具体含义如下:

当任务添加到线程池被拒绝时,会抛出 RejectedExecutionException 异常,阻止任务的执行。这意味着如果线程池无法处理更多的任务,新提交的任务将被立即拒绝。

使用 AbortPolicy 拒绝策略适用于对任务处理要求非常严格的场景,例如在某些情况下,如果无法接收到所有的任务并执行,就会产生严重的问题。通过抛出异常,可以及时发现并处理这种情况。

除了 AbortPolicyThreadPoolExecutor 还提供了其他几种拒绝策略:

  • CallerRunsPolicy: 当任务添加到线程池被拒绝时,会在调用者所在的线程中执行该任务。这样做可以降低并发负载,但可能会影响主线程的性能。
  • DiscardPolicy: 当任务添加到线程池被拒绝时,会默默地丢弃该任务,不抛出任何异常。
  • DiscardOldestPolicy: 当任务添加到线程池被拒绝时,会丢弃最早提交的任务(即队列中的头部任务),然后尝试重新提交被拒绝的任务。
  • 自定义拒绝策略:你也可以实现 RejectedExecutionHandler 接口自定义拒绝策略。

选择适当的拒绝策略取决于你的业务需求和线程池的使用场景。需要根据具体情况来决定哪种拒绝策略最为合适。

Spring线程池

 spring:
    task:
      execution: #TaskExecutionProperties Spring普通线程池
        pool:
          core-size: 5 #核心线程数
          max-size: 15 #最大线程数
          queue-capacity: 100 #队列容量
      scheduling: #TaskSchedulingProperties Spring定时线程池
        pool:
          size: 5 #线程数量

定时线程池还需要写个配置类

@Configuration
@EnableScheduling
@EnableAsync
public class ThreadPoolConfig {
}

  • @EnableScheduling 表示启用定时任务
  • @EnableAsync 配置类中加入,代表开启异步
//Spring普通线程池
@Autowired
private ThreadPoolTaskExecutor threadPoolTaskExecutor;

//Spring定时线程池
@Autowired
private ThreadPoolTaskScheduler threadPoolTaskScheduler;

/**
 * Spring普通线程池
 */
@Test
public void testSpringExecutors(){
    for (int i = 0; i < 10; i++) {
        threadPoolTaskExecutor.submit(()->{
            logger.debug("hello!");
        });
    }
}

/**
 * Spring定时线程池
 */
@Test
public void testSpringExecutors2(){
    //开始进行任务的时间
    Date startTime = new Date(System.currentTimeMillis() + 5000);

    threadPoolTaskScheduler.scheduleAtFixedRate(() -> logger.debug("Hello!"), startTime, 1000);

    sleep(30000);
}
@Async
@Service
public class TestService {
    public static final Logger logger = LoggerFactory.getLogger(TestService.class);

    @Async
    public void task(){
        logger.debug("hello  " + Thread.currentThread().getName());
    }
}

 @Test
public void testSpringExecutors3(){
    for (int i = 0; i < 10; i++) {
        testService.task();
    }
}

  • @Async表示该方法异步进行,会使用Spring的普通线程池取调用
@Scheduled

不需要调用,自动就会执行

@Scheduled(initialDelay = 5000, fixedDelay = 1000)
public void task2(){
    logger.debug("hello2  " + Thread.currentThread().getName());
}

  • @Scheduled表示该方法为定时任务
  • initialDelay 延迟多久后开始(ms)
  • fixedDelay 多久执行一次(ms)
@Test
public void testSpringExecutors4(){
    for (int i = 0; i < 10; i++) {
        testService.task2();
    }
    sleep(10000);
}

Quartz线程池

0.导包
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-quartz</artifactId>
</dependency>

1.定义任务
package com.qiuyu.quartz;

public class DemoJob implements Job {
    @Override
    public void execute(JobExecutionContext jobExecutionContext) throws JobExecutionException {
        System.out.println(Thread.currentThread().getName());
    }
}

2.配置类

BeanFactory 和 FactoryBean的区别

  • BeanFactory是容器的顶层接口

  • FactoryBean用来简化Bean的实例化过程

    • 通过FactoryBean封装Bean的实例化过程

    • 将FactoryBean装配到Spring容器里

    • 将FactoryBean注入到其他的Bean

    • 该Bean得到的是FactoryBean所管理的对象实例

这里使用FactoryBean来实例化Bean

package com.qiuyu.config;

@Configuration
public class QuartzConfig {

    //配置JobDetail
    @Bean
    public JobDetailFactoryBean demoJobDetail(){
        JobDetailFactoryBean factoryBean = new JobDetailFactoryBean();
        factoryBean.setJobClass(DemoJob.class);
        factoryBean.setName("demoJob");
        factoryBean.setGroup("demoJobGroup");
        factoryBean.setDurability(true); //持久化保存
        factoryBean.setRequestsRecovery(true); //是否可以恢复
        return factoryBean;
    }

    //配置Trigger(SimpleTriggerFactoryBean, CronTriggerFactoryBean)
    //CronTriggerFactoryBean用于比如每月底执行一次这种
    @Bean
    public SimpleTriggerFactoryBean demoTrigger(JobDetail demoJobDetail){
        SimpleTriggerFactoryBean factoryBean = new SimpleTriggerFactoryBean();
        factoryBean.setJobDetail(demoJobDetail);
        factoryBean.setName("demoTrigger");
        factoryBean.setGroup("demoTriggerGroup");
        factoryBean.setRepeatInterval(3000); //多久执行一次
        factoryBean.setJobDataMap(new JobDataMap()); //存储数据的类型
        return factoryBean;
    }
}


配置后,运行Quartz,会把配置保存到数据库中,才能实现分布式部署

配置

22.热帖排行(Quartz + Redis)

image-20230128222248270

实际上线的时候可以几个小时算一次分数

Q:我们每次算分的时候需要把所有的帖子都算一遍吗?

A:太多了,太耗费时间,因为只有加精 评论 点赞会改变帖子的分数,所以我们只需要在这三个操作的时候
把当前的帖子的Id放入到Redis中,等时间一到,把这些Redis中的帖子进行计算就行了

1.编写

RedisUtil规范Key值

// 热帖分数 (把需要更新的帖子id存入Redis当作缓存)
private static final String PREFIX_POST = "post";

/**
  *  帖子分数 (发布、点赞、加精、评论时放入)
  */
public static String getPostScore() {
    return PREFIX_POST + SPLIT + "score";
}

2.处理发布、点赞、加精、评论时计算分数,将帖子id存入Key

2.1发布帖子时初始化分数
/**
  * 计算帖子分数
  * 将新发布的帖子id存入set去重的redis集合------addDiscussPost()
  */
String redisKey = RedisKeyUtil.getPostScore();
redisTemplate.opsForSet().add(redisKey, post.getId());

2.2点赞时计算帖子
/**
 * 计算帖子分数
 * 将点赞过的帖子id存入set去重的redis集合------like()
 */
if (entityType == ENTITY_TYPE_POST) {
    String redisKey = RedisKeyUtil.getPostScore();
    redisTemplate.opsForSet().add(redisKey, postId);
}
2.3评论时计算帖子分数
if (comment.getEntityType() == ENTITY_TYPE_POST) {
    /**
    * 计算帖子分数
    * 将评论过的帖子id存入set去重的redis集合------addComment()
    */
    String redisKey = RedisKeyUtil.getPostScore();
    redisTemplate.opsForSet().add(redisKey, discussPostId);
}

3.定义Quartz热帖排行Job

package com.qiuyu.quartz;

/**
 * 计算帖子的分数
 */
@Component
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-10-22 00:00:00");
        } catch (ParseException e) {
            throw new RuntimeException("初始化时间失败!", e);
        }
    }

    @Override
    public void execute(JobExecutionContext jobExecutionContext) throws JobExecutionException {
        String redisKey = RedisKeyUtil.getPostScore();
        // 处理每一个key
        BoundSetOperations operations = redisTemplate.boundSetOps(redisKey);

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

        logger.info("[任务开始] 正在刷新帖子分数" + operations.size());
        while (operations.size() > 0) {
            // 刷新每一个从set集合里弹出的postId
            this.refresh((Integer) operations.pop());
        }
        logger.info("[任务结束] 帖子分数刷新完毕!");

    }

    // 从redis中取出每一个value:postId
    private void refresh(int postId) {
        DiscussPost post = discussPostService.findDiscussPostById(postId);
        if (post == null) {
            logger.error("该帖子不存在:id = " + postId);
            return;
        }
        if (post.getStatus() == 2) {
            logger.error("帖子已被删除");
            return;
        }

        /**
         * 帖子分数计算公式:[加精(75)+ 评论数*  10 + 点赞数*  2] + 距离天数
         */
        // 是否加精帖子
        boolean wonderful = post.getStatus() == 1;
        // 点赞数量
        long liketCount = likeService.findEntityLikeCount(ENTITY_TYPE_POST, postId);
        // 评论数量
        int commentCount = post.getCommentCount();

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

        // 更新帖子分数
        discussPostService.updateScore(postId, score);

        // 同步搜索数据
        post.setScore(score);
        elasticsearchService.saveDiscussPost(post);
    }
}

4.配置Quartz的PostScoreRefreshJob

3秒刷一次

package com.qiuyu.config;


@Configuration
public class QuartzConfig {

    //配置JobDetail
    @Bean
    public JobDetailFactoryBean postScoreRefreshJobDetail(){
        JobDetailFactoryBean factoryBean = new JobDetailFactoryBean();
        factoryBean.setJobClass(PostScoreRefreshJob.class);
        factoryBean.setName("postScoreRefreshJob");
        factoryBean.setGroup("communityGroup");
        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("postScoreRefreshTrigger");
        factoryBean.setRepeatInterval(3000); //多久执行一次
        factoryBean.setJobDataMap(new JobDataMap()); //存储数据的类型
        return factoryBean;
    }
}

4.配置Quartz的PostScoreRefreshJob

3秒刷一次

package com.qiuyu.config;


@Configuration
public class QuartzConfig {

    //配置JobDetail
    @Bean
    public JobDetailFactoryBean postScoreRefreshJobDetail(){
        JobDetailFactoryBean factoryBean = new JobDetailFactoryBean();
        factoryBean.setJobClass(PostScoreRefreshJob.class);
        factoryBean.setName("postScoreRefreshJob");
        factoryBean.setGroup("communityGroup");
        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("postScoreRefreshTrigger");
        factoryBean.setRepeatInterval(3000); //多久执行一次
        factoryBean.setJobDataMap(new JobDataMap()); //存储数据的类型
        return factoryBean;
    }
}

5.修改主页帖子显示(Service、Controller)

从之前的按照时间排序,增加一个参数orderMode

Service
/**
  * 查询没被拉黑的帖子,并且userId不为0按照type排序
  * @param userId
  * @param orderMode 0-最新 1-最热
  * @param page
  * @return
  */
public IPage<DiscussPost> findDiscussPosts(int userId, int orderMode, IPage<DiscussPost> page) {
    LambdaQueryWrapper<DiscussPost> queryWrapper = new LambdaQueryWrapper<>();
    queryWrapper
        .ne(DiscussPost::getStatus, 2)
        .eq(userId != 0, DiscussPost::getUserId, userId)
        .orderBy(orderMode == 0, false, DiscussPost::getType, DiscussPost::getCreateTime)
        .orderBy(orderMode == 1, false, DiscussPost::getType, DiscussPost::getScore, DiscussPost::getCreateTime);

    discussPostMapper.selectPage(page, queryWrapper);
    return page;
}

Controller
/**
  * 分页获取所有帖子
  * @param orderMode
  * @param page
  * @param model
  * @return
  */
@GetMapping("/index")
public String getIndexPage(@RequestParam(name = "orderMode", defaultValue = "0") int orderMode,
                           MyPage<DiscussPost> page, Model model) {
    page.setSize(10);
    page.setPath("/index?orderMode="+orderMode);

    //查询到分页的结果
    page = (MyPage<DiscussPost>) discussPostService.findDiscussPosts(0, orderMode, page);

    List<DiscussPost> list = page.getRecords();
    //因为这里查出来的是userid,而不是user对象,所以需要重新查出user
    List<Map<String, Object>> discussPorts = new ArrayList<>();
    if (list != null) {
        for (DiscussPost post : list) {
            Map<String, Object> map = new HashMap<>(15);
            map.put("post", post);
            User user = userService.findUserById(post.getUserId());
            map.put("user", user);
            discussPorts.add(map);

            //点赞数
            long entityLikeCount = likeService.findEntityLikeCount(ENTITY_TYPE_POST, post.getId());
            map.put("likeCount", entityLikeCount);

        }
    }

    model.addAttribute("discussPorts", discussPorts);
    model.addAttribute("orderMode",orderMode);
    model.addAttribute("page", page);

    return "/index";
}

24.优化网站的性能(Caffeine)

image-20230129184555096

本地缓存的效率会比分布式缓存要快,因为没有网络开销

Q:为什么登录凭证不存在本地缓存上?

A:分布式情况下用户第一次访问A服务器,登陆凭证会存在A服务器的内存中,但是下一次有可能访问的是B服务器,所以还是得用分布式服务器。也可以使用多级缓存(本地缓存+分布式缓存)

多级缓存查询过程

image-20230129190731716

第一次查询的情况(服务器1):

  1. 先到服务器1的本地缓存中查询数据,没查到

  2. 到Redis中查询数据,没查到

  3. 到DB中查询,查到了,返回给App,然后App把结果存到本地缓存和Redis中,方便下次查找

第二次查询的情况(服务器1):

  1. 到服务器1的本地缓存中查询数据,直接命中,返回结果

第二次查询的情况(服务器2):

  1. 先到服务器2的本地缓存中查询数据,没查到
  2. 到Redis中查询数据,命中,返回数据给App
  3. App把结果存到本地缓存中

对热门帖子进行本地缓存

Q:为什么不对默认最新的帖子进行缓存?

A:缓存一般存储变化不太大的数据

不使用Spring来整合Caffeine,因为Spring使用一个缓存管理器来对多个缓存进行配置,但是我们不同的缓存的配置是不同的,所以我们直接使用Caffeine

1.导入包
<dependency>
    <groupId>com.github.ben-manes.caffeine</groupId>
    <artifactId>caffeine</artifactId>
</dependency>

2.配置
# caffeine 本地缓存优化热门帖子
caffeine:
  post:
    max-size: 15 #最大页数
    expire-seconds: 180 #过期时间(s)

3.修改DiscussPostService业务层分页查询方法

  • Caffeine的核心接口Cache

  • Cache下有两个子接口,同步子接口LoadingCache 异步子接口AsyncLoadingCache

  • 项目开始的时候,再构造之前初始化Caffeine缓存(用处是,如果再缓存中找不到目标数据的话,会执行这个初始化函数)

  • 在查询热门帖子时直接调用caffeine的get方法,如果缓存中有这个key会直接返回,没有的话会执行初始化函数

package com.qiuyu.service;

@Slf4j
@Service
public class DiscussPostService {

    @Autowired
    private DiscussPostMapper discussPostMapper;
    @Autowired
    private SensitiveFilter sensitiveFilter;

    @Value("${caffeine.post.max-size}")
    private int maxSize;
    @Value("${caffeine.post.expire-seconds}")
    private int expireSeconds;

    // 帖子列表缓存
    private LoadingCache<String, MyPage<DiscussPost>> postPageCache;

    // 项目启动时初始化缓存
    @PostConstruct
    public void init() {
        // 初始化帖子列表缓存
        postPageCache = Caffeine.newBuilder()
                .maximumSize(maxSize)
                .expireAfterWrite(expireSeconds,TimeUnit.SECONDS)
                .build(new CacheLoader<String, MyPage<DiscussPost>>(){
                    @Override
                    public @Nullable MyPage<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 != 3) {
                            throw new IllegalArgumentException("参数错误!");
                        }

                        //拆分key
                        int current = Integer.valueOf(params[0]);
                        int size = Integer.valueOf(params[1]);
                        String path = params[2];

                        // 这里可用二级缓存:Redis -> mysql

                        //本地缓存中查不到,从数据库中查询,查到后会自动存入本地缓存
                        log.debug("正在从数据库加载热门帖子总数!");
                        MyPage<DiscussPost> page = new MyPage<>();
                        page.setCurrent(current);
                        page.setSize(size);
                        page.setPath(path);
                        LambdaQueryWrapper<DiscussPost> queryWrapper = new LambdaQueryWrapper<>();
                        queryWrapper
                                .ne(DiscussPost::getStatus, 2)
                                .orderByDesc( DiscussPost::getType, DiscussPost::getScore, DiscussPost::getCreateTime);

                        discussPostMapper.selectPage(page, queryWrapper);

                        return page;
                    }
                });
    }


    /**
     * 查询没被拉黑的帖子,并且userId不为0按照type排序
     *
     * @param userId
     * @param orderMode 0-最新 1-最热
     * @param page
     * @return
     */
    public MyPage<DiscussPost> findDiscussPosts(int userId, int orderMode, MyPage<DiscussPost> page) {
        //全部查询并且查的是热门帖子的话先去缓存查询
        if (userId == 0 && orderMode == 1) {
            //按照当前页和页面最大值作为Key查询
            log.debug("正在从Caffeine缓存中加载热门帖子!");
            return postPageCache.get(page.getCurrent()+":"+ page.getSize()+":"+ page.getPath());
        }

        log.debug("正在从数据库加载热门帖子总数!");
        //从数据库中查
        LambdaQueryWrapper<DiscussPost> queryWrapper = new LambdaQueryWrapper<>();
        queryWrapper
                .ne(DiscussPost::getStatus, 2)
                .eq(userId != 0, DiscussPost::getUserId, userId)
                .orderBy(orderMode == 0, false, DiscussPost::getType, DiscussPost::getCreateTime)
                .orderBy(orderMode == 1, false, DiscussPost::getType, DiscussPost::getScore, DiscussPost::getCreateTime);

        discussPostMapper.selectPage(page, queryWrapper);
        return page;
    }


25.压力测试(Jmeter)

先将缓存给注释掉,并且去掉访问Service层打印信息的那个切面

1.下载Jmeter

https://jmeter.apache.org/

2.设置线程组,设置线程数

image-20230129220020474

3.添加事件HTTP请求访问首页热门

image-20230129214403292

4.设置定时器

0-1000ms

image-20230129215155539

5.添加监听器(聚合报告)

进行测试,不断加大线程数最终为400,到达吞吐的最大值大概在每秒60个请求

image-20230129220522872

  • Label:每个 JMeter 的 element(例如 HTTP Request)都有一个 Name 属性,这里显示的就是 Name 属性的值

  • 样本:表示你这次测试中一共发出了多少个请求,如果模拟10个用户,每个用户迭代10次,那么这里显示100

  • 平均值:平均响应时间——默认情况下是单个 Request 的平均响应时间,当使用了 Transaction Controller 时,也可以以Transaction 为单位显示平均响应时间

  • 中位数:中位数,也就是 50% 用户的响应时间

  • 90% 百分位:90% 用户的响应时间(单位毫秒)

  • 最小值:最小响应时间

  • 最大值:最大响应时间

  • 异常%:本次测试中出现错误的请求的数量/请求的总数

  • 吞吐量:吞吐量——默认情况下表示每秒完成的请求数(Request per Second),当使用了 Transaction Controller 时,也可以表示类似 LoadRunner 的 Transaction per Second 数

  • 接收KB/Sec:每秒从服务器端接收到的数据量,相当于LoadRunner中的Throughput/Sec

6.测试使用Caffeine

解开注释

吞吐量到了778,是之前60的12倍左右!

image-20231127204542066

26.其他

1.单元测试

image-20230130010131257

  • @BeforeClass 在类初始化之前执行,必须是静态方法

  • @Before 在方法测试类中所有方法执行前执行

  • Before可以用来创建这个测试类自己用的数据,在After中删掉

这样就不会在测试完后数据库中一堆测试数据,而且不依赖别人的数据

断言

  • Assert.assertEquals();

  • Assert.assertNull();

  • Assert.assertFalse();

) {
// 初始化帖子列表缓存
postPageCache = Caffeine.newBuilder()
.maximumSize(maxSize)
.expireAfterWrite(expireSeconds,TimeUnit.SECONDS)
.build(new CacheLoader<String, MyPage>(){
@Override
public @Nullable MyPage load(@NonNull String key) throws Exception {
if (key == null || key.length() == 0) {
throw new IllegalArgumentException(“参数错误!”);
}
String[] params = key.split(“:”);
if (params == null || params.length != 3) {
throw new IllegalArgumentException(“参数错误!”);
}

                    //拆分key
                    int current = Integer.valueOf(params[0]);
                    int size = Integer.valueOf(params[1]);
                    String path = params[2];

                    // 这里可用二级缓存:Redis -> mysql

                    //本地缓存中查不到,从数据库中查询,查到后会自动存入本地缓存
                    log.debug("正在从数据库加载热门帖子总数!");
                    MyPage<DiscussPost> page = new MyPage<>();
                    page.setCurrent(current);
                    page.setSize(size);
                    page.setPath(path);
                    LambdaQueryWrapper<DiscussPost> queryWrapper = new LambdaQueryWrapper<>();
                    queryWrapper
                            .ne(DiscussPost::getStatus, 2)
                            .orderByDesc( DiscussPost::getType, DiscussPost::getScore, DiscussPost::getCreateTime);

                    discussPostMapper.selectPage(page, queryWrapper);

                    return page;
                }
            });
}


/**
 * 查询没被拉黑的帖子,并且userId不为0按照type排序
 *
 * @param userId
 * @param orderMode 0-最新 1-最热
 * @param page
 * @return
 */
public MyPage<DiscussPost> findDiscussPosts(int userId, int orderMode, MyPage<DiscussPost> page) {
    //全部查询并且查的是热门帖子的话先去缓存查询
    if (userId == 0 && orderMode == 1) {
        //按照当前页和页面最大值作为Key查询
        log.debug("正在从Caffeine缓存中加载热门帖子!");
        return postPageCache.get(page.getCurrent()+":"+ page.getSize()+":"+ page.getPath());
    }

    log.debug("正在从数据库加载热门帖子总数!");
    //从数据库中查
    LambdaQueryWrapper<DiscussPost> queryWrapper = new LambdaQueryWrapper<>();
    queryWrapper
            .ne(DiscussPost::getStatus, 2)
            .eq(userId != 0, DiscussPost::getUserId, userId)
            .orderBy(orderMode == 0, false, DiscussPost::getType, DiscussPost::getCreateTime)
            .orderBy(orderMode == 1, false, DiscussPost::getType, DiscussPost::getScore, DiscussPost::getCreateTime);

    discussPostMapper.selectPage(page, queryWrapper);
    return page;
}

# 25.压力测试(Jmeter)

先将缓存给注释掉,并且去掉访问Service层打印信息的那个切面

#### 1.下载Jmeter

https://jmeter.apache.org/

#### 2.设置线程组,设置线程数

[外链图片转存中...(img-v4gtwunr-1701521838712)]

#### 3.添加事件HTTP请求访问首页热门

[外链图片转存中...(img-sWkWaZTx-1701521838713)]

#### 4.设置定时器

0-1000ms

[外链图片转存中...(img-5b1KcgkG-1701521838714)]

#### 5.添加监听器(聚合报告)

进行测试,不断加大线程数最终为400,到达吞吐的最大值大概在每秒60个请求

[外链图片转存中...(img-sOeKnygd-1701521838714)]

- Label:每个 JMeter 的 element(例如 HTTP Request)都有一个 Name 属性,这里显示的就是 Name 属性的值

- 样本:表示你这次测试中一共发出了多少个请求,如果模拟10个用户,每个用户迭代10次,那么这里显示100

- 平均值:平均响应时间——默认情况下是单个 Request 的平均响应时间,当使用了 Transaction Controller 时,也可以以Transaction 为单位显示平均响应时间

- 中位数:中位数,也就是 50% 用户的响应时间

- 90% 百分位:90% 用户的响应时间(单位毫秒)

- 最小值:最小响应时间

- 最大值:最大响应时间

- 异常%:本次测试中出现错误的请求的数量/请求的总数

- 吞吐量:吞吐量——默认情况下表示每秒完成的请求数(Request per Second),当使用了 Transaction Controller 时,也可以表示类似 LoadRunner 的 Transaction per Second 数

- 接收KB/Sec:每秒从服务器端接收到的数据量,相当于LoadRunner中的Throughput/Sec

#### 6.测试使用Caffeine

解开注释

吞吐量到了778,是之前60的12倍左右!


[外链图片转存中...(img-zunCexnk-1701521838715)]

# 26.其他

### 1.单元测试

[外链图片转存中...(img-eeHhke1j-1701521838716)]

- @BeforeClass 在类初始化之前执行,必须是静态方法

- @Before 在方法测试类中所有方法执行前执行
- Before可以用来创建这个测试类自己用的数据,在After中删掉

这样就不会在测试完后数据库中一堆测试数据,而且不依赖别人的数据

断言

- Assert.assertEquals();

- Assert.assertNull();

- Assert.assertFalse();

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值