# community **Repository Path**: piweijun/community ## Basic Information - **Project Name**: community - **Description**: 讨论社区项目 - **Primary Language**: Unknown - **License**: Not specified - **Default Branch**: master - **Homepage**: None - **GVP Project**: No ## Statistics - **Stars**: 0 - **Forks**: 0 - **Created**: 2021-07-19 - **Last Updated**: 2021-08-19 ## Categories & Tags **Categories**: Uncategorized **Tags**: None ## README # Community 项目问题汇总 ## 功能模块设计 ### 一、注册模块 #### 1. 激活码邮件 * 邮箱设置:注册一个邮箱作为主机,然后打开SMTP服务; * Spring Email * 引入依赖 * 在配置文件中写好邮箱参数application.properties ```properties # MailProperties spring.mail.host=smtp.qq.com spring.mail.port=465 spring.mail.username=2424646639@qq.com spring.mail.password=qxwselvzoxdaebhf #授权码在邮箱的设置中获取; spring.mail.protocol=smtps spring.mail.properties.mail.smtp.ssl.enable=true ``` * MailClient类,代理使用腾讯邮箱 ```java package com.dedsec.community.util; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.mail.javamail.JavaMailSender; import org.springframework.mail.javamail.MimeMessageHelper; import org.springframework.stereotype.Component; import javax.mail.MessagingException; import javax.mail.internet.MimeMessage; /** * 邮件客户端 * 委托腾讯去发邮件 */ @Component public class MailClient { private static final Logger logger = LoggerFactory.getLogger(MailClient.class); @Autowired private JavaMailSender mailSender; /** * 直接从配置文件中注入发件人信息 */ @Value("${spring.mail.username}") private String from; public void sendMail(String to, String subject, String content){ try{ //构件MimeMessage模版 MimeMessage message = mailSender.createMimeMessage(); //使用帮助类构件详细内容 MimeMessageHelper helper = new MimeMessageHelper(message); helper.setFrom(from); helper.setTo(to); helper.setSubject(subject); helper.setText(content,true); //支持html发送 mailSender.send(helper.getMimeMessage()); }catch (MessagingException e){ logger.error("发送邮件失败"+e.getMessage()); } } } ``` * 还可以发模版引擎的html邮件; #### 2. 注册功能 * 注册页面编写 * 一些工具类 **MailClient,发邮件的功能模块,可以发送不同的内容给指定邮箱:MIME;** ```java package com.dedsec.community.util; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.mail.javamail.JavaMailSender; import org.springframework.mail.javamail.MimeMessageHelper; import org.springframework.stereotype.Component; import javax.mail.MessagingException; import javax.mail.internet.MimeMessage; /** * 邮件客户端 * 委托腾讯去发邮件 */ @Component public class MailClient { private static final Logger logger = LoggerFactory.getLogger(MailClient.class); @Autowired private JavaMailSender mailSender; /** * 直接从配置文件中注入发件人信息 */ @Value("${spring.mail.username}") private String from; public void sendMail(String to, String subject, String content){ try{ //构件MimeMessage模版 MimeMessage message = mailSender.createMimeMessage(); //使用帮助类构件详细内容 MimeMessageHelper helper = new MimeMessageHelper(message); helper.setFrom(from); helper.setTo(to); helper.setSubject(subject); helper.setText(content,true); //支持html发送 mailSender.send(helper.getMimeMessage()); }catch (MessagingException e){ logger.error("发送邮件失败"+e.getMessage()); } } } ``` **随机字符串生成模块:用于密码加密、生成激活连接等等;** ```java package com.dedsec.community.util; import org.apache.commons.lang3.StringUtils; import org.springframework.util.DigestUtils; import java.util.UUID; public class CommunityUtil { /** * 生成随机字符串 */ public static String generateUUID(){ return UUID.randomUUID().toString().replaceAll("-", " "); } /** * MD5加密 * 任何密码会经过salt处理 * 例如:password + salt -> fieubuhbdsyfae */ public static String md5(String key){ if (StringUtils.isBlank(key)){ return null; } return DigestUtils.md5DigestAsHex(key.getBytes()); } } ``` * UserService代码逻辑 ```java /** * 用户注册 * @param user * @return */ public Map register(User user){ Map map = new HashMap<>(); //判断空值 if (user == null) throw new IllegalArgumentException("用户信息不能为空"); if (StringUtils.isBlank(user.getUsername())){ map.put("usernameMsg","账号不能为空"); return map; } if (StringUtils.isBlank(user.getPassword())){ map.put("passwordMsg","密码不能为空"); return map; } if (StringUtils.isBlank(user.getEmail())){ map.put("emailMsg","邮箱不能为空"); return map; } //验证账号 User u = userMapper.selectByName(user.getUsername()); if (u != null){ map.put("usernameMsg","该账号已存在"); return map; } //验证邮箱 u = userMapper.selectByEmail(user.getEmail()); if (u != null){ map.put("emailMsg","邮箱已经被注册"); return map; } // 开始注册合法用户 user.setSalt(CommunityUtil.generateUUID().substring(0,5)); //随机加盐 user.setPassword(CommunityUtil.md5(user.getPassword() + user.getSalt())); //加密密码 user.setType(0); //普通用户 user.setStatus(0); //用户状态 user.setActivationCode(CommunityUtil.generateUUID()); //用户激活码 user.setHeaderUrl(String.format("http://images.nowcoder.com/head/%dt.png",new Random().nextInt(1000))); // 随机牛客头像 user.setCreateTime(new Date()); userMapper.insertUser(user); //记录 //发送激活邮件 Context context = new Context(); context.setVariable("email",user.getEmail()); String url = domain + contextPath + "/activation/" + user.getId() + "/" +user.getActivationCode(); //这里的userid是mybatis回填的 context.setVariable("url",url); String content = templateEngine.process("/mail/activation",context); mailClient.sendMail(user.getEmail(),"激活您的账号",content); return map; } ``` * LoginController的注册功能函数 ```java @RequestMapping(path = "/register", method = RequestMethod.POST) //传入对象的时候,字段可以与方法中的对象字段绑定,这是SpringMVC做到的事情 public String register(Model model, User user){ Map map = userService.register(user); //map为空,代表无异常信息,注册成功 if (map == null || map.isEmpty()){ model.addAttribute("msg","注册成功,我们已经向您的邮箱发送了激活邮件,请激活!"); model.addAttribute("target","/index"); return "/site/operate-result"; }else{ model.addAttribute("usernameMsg",map.get("usernameMsg")); model.addAttribute("passwordMsg",map.get("passwordMsg")); model.addAttribute("emailMsg",map.get("emailMsg")); return "/site/register"; } } ``` * UserService的账号激活功能 * 成功激活 * 多次点击,无意义的激活 * 伪造的激活 **定义一个常量接口,存储对应的激活操作结果** ```java package com.dedsec.community.util; public interface CommunityConstant { /** * 激活成功 */ int ACTIVATION_SUCCESS = 0; /** * 重复激活 */ int ACTIVATION_REPEAT = 1; /** * 激活失败 */ int ACTIVATION_FAILURE = 2; } ``` UserService中的用户激活逻辑: ```java /** * 用户激活 * @param userId * @param code * @return */ public int activation(int userId, String code){ User user = userMapper.selectById(userId); if (user.getStatus() == 1){ return ACTIVATION_REPEAT; }else if (user.getActivationCode().equals(code)){ userMapper.updateStatus(userId,1); return ACTIVATION_SUCCESS; }else{ return ACTIVATION_FAILURE; } } ``` LoginController中用户激活的交互 ```java @RequestMapping(path = "/activation/{userId}/{code}", method = RequestMethod.GET) public String activation(Model model, @PathVariable("userId") int userId, @PathVariable("code") String code){ int result = userService.activation(userId,code); switch (result){ case ACTIVATION_SUCCESS: model.addAttribute("msg","激活成功,账号可以正常使用"); model.addAttribute("target","/login"); break; case ACTIVATION_REPEAT: model.addAttribute("msg","账号已经激活"); model.addAttribute("target","/index"); break; case ACTIVATION_FAILURE: model.addAttribute("msg","激活失败,激活码有误"); model.addAttribute("target","/index"); break; } return "/site/operate-result"; } ``` ### 二、登录模块 #### 1. 为什么要会话管理 * HTTP**是无状态、有会话的**:多次请求之间没有关联,导致HTTP无状态,**但HTTP使用了头部扩展,使用HTTP cookies,让每一次会话共享上下文信息;** * 测试Cookie (HTTP) ```java @RequestMapping("/cookie/set") @ResponseBody public String setCookie(HttpServletResponse response){ //生成cookie Cookie cookie = new Cookie("code","haha"); //设置cookie的有效范围(并不一定所有的访问方法都需要cookie) cookie.setPath("/community/test"); //cookie默认关闭浏览器就消失,但可以设置生存时间,使它存储在硬盘中 cookie.setMaxAge(60 * 10); //发送cookie response.addCookie(cookie); return "set cookie"; } @RequestMapping(value = "/cookie/get",method = RequestMethod.GET) @ResponseBody //从cookie中取一个key使用,而不是cookie的全部内容 public String getCookie(@CookieValue("code") String code){ System.out.println(code); return "get cookie"; } ``` * 测试Session (Java EE) 验证身份之后,将用户状态数据存储在session中,然后将sessionid放入cookie,便于下次访问; ```java @RequestMapping(value = "/session/set",method = RequestMethod.GET) @ResponseBody public String setSession(HttpSession session){ session.setAttribute("id",1); session.setAttribute("name","Test"); return "set session"; } @RequestMapping(value = "/session/get",method = RequestMethod.GET) @ResponseBody public String getSession(HttpSession session){ System.out.println(session.getAttribute("id")); System.out.println(session.getAttribute("name")); return "get session"; } ``` * 分布式部署,session存在的问题 * **存在的问题:**在分布式部署的情况下,基于代理服务器实现的负载均衡,可能导致session失效; * **解决方案:** * 粘性session:同一个ip分给同一个服务器,不完美,这并不是真的负载均衡; * session同步:每个服务器都存储一份session数据; * 最好的解决方式:数据库做集群,存储session;**例如使用NoSQL存储Redis;** IMG_D4DFE9F46467-1 #### 2. kaptcha验证码 * 获取验证码图片的kaptcha ```java @RequestMapping(path = "/kaptcha",method = RequestMethod.GET) 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()); } } ``` * 验证码的配置类 ```java package com.dedsec.community.config; import com.google.code.kaptcha.Producer; import com.google.code.kaptcha.impl.DefaultKaptcha; import com.google.code.kaptcha.util.Config; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import java.util.Properties; @Configuration public class KaptchaConfig { @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","0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ"); 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; } } ``` #### 3. 登录、退出登录 * userService登录 ```java /** * 用户登录 * @param username * @param password * @param expiredSecond * @return */ public Map login(String username, String password, int expiredSecond){ Map map = new HashMap<>(); //空值处理 if (StringUtils.isBlank(username)){ map.put("usernameMsg","账号不能为空"); return map; } if (StringUtils.isBlank(password)){ map.put("passwordMsg","密码不能为空"); return map; } //验证账号 User user = userMapper.selectByName(username); if (user == null){ map.put("usernameMsg","账号不存在"); return map; } if (user.getStatus() == 0){ map.put("usernameMsg","账号未激活"); return map; } //对明文密码加密,然后和数据库中对比 password = CommunityUtil.md5(password + user.getSalt()); if (!user.getPassword().equals(password)){ map.put("passwordMsg","密码错误"); return map; } //账号验证通过,生成登录凭证 LoginTicket loginTicket = new LoginTicket(); loginTicket.setUserId(user.getId()); loginTicket.setTicket(CommunityUtil.generateUUID()); loginTicket.setStatus(0); loginTicket.setExpired(new Date(System.currentTimeMillis() + expiredSecond * 1000)); loginTicketMapper.insertLoginTicket(loginTicket); //返回凭证 map.put("ticket",loginTicket.getTicket()); return map; } ``` * UserController登录交互 ```java /** * 登录 * @param username * @param password * @param code * @param rememberme 记住我 * @param model 用于存储注入数据的model SpringMVC帮你处理数据 * @param session 登录时,验证码存储在了Session中 * @param response * @return */ @RequestMapping(path = "/login",method = RequestMethod.POST) public String login(String username, String password, String code, boolean rememberme, Model model, HttpSession session, HttpServletResponse response){ String kaptcha = (String) session.getAttribute("kaptcha"); if (StringUtils.isBlank(kaptcha) || StringUtils.isBlank(code) || !kaptcha.equalsIgnoreCase(code)){ model.addAttribute("codeMsg","验证码错误"); return "/site/login"; } //检查账号密码 int expiredSecond = rememberme ? REMEBER_EXPIRED_SECOND : DEFAULT_EXPIRED_SECOND; Map map = userService.login(username,password,expiredSecond); //包含ticket才算成功 if (map.containsKey("ticket")){ System.out.println(map.get("ticket")); Cookie cookie = new Cookie("ticket",map.get("ticket").toString()); cookie.setPath(contextPath); cookie.setMaxAge(expiredSecond); response.addCookie(cookie); return "redirect:/index"; }else{ model.addAttribute("usernameMsg",map.get("usernameMsg")); model.addAttribute("passwordMsg",map.get("passwordMsg")); return "/site/login"; } } ``` #### 4. 拦截器 * 显示登录信息,这个请求不能全部耦合在所有Controller中,**可以使用拦截器解决这个问题**; Controller.interceptor.AlphaInteceptor 拦截器的主要功能 ```java package com.dedsec.community.controller.interceptor; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.stereotype.Component; import org.springframework.web.servlet.HandlerInterceptor; import org.springframework.web.servlet.ModelAndView; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; @Component public class AlphaInterceptor implements HandlerInterceptor { private static final Logger logger = LoggerFactory.getLogger(AlphaInterceptor.class); /** * 在controller之前执行 * @param request * @param response * @param handler * @return * @throws Exception */ @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { logger.debug("preHandle:" + handler.toString()); return true; } /** * 在controller之后执行,模版引擎之前 * @param request * @param response * @param handler * @param modelAndView * @throws Exception */ @Override public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception { logger.debug("postHandle:" + handler.toString()); return; } /** * 模版引擎之后执行 * @param request * @param response * @param handler * @param ex * @throws Exception */ @Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception { logger.debug("afterHandle:" + handler.toString()); } } ``` * config.WebMvcConfig:配置好拦截器的生效范围 ```java package com.dedsec.community.config; import com.dedsec.community.controller.interceptor.AlphaInterceptor; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Configuration; import org.springframework.web.servlet.config.annotation.InterceptorRegistry; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; @Configuration public class WebMvcConfig implements WebMvcConfigurer { @Autowired private AlphaInterceptor alphaInterceptor; @Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(alphaInterceptor) .excludePathPatterns("/**/*.css","/**/*.js","/**/*.png","/**/*.jpg","/**/*.jpeg") .addPathPatterns("/register","/login"); } } ``` * 显示登录状态信息 每次请求都会检查登录状态信息,显然不能把这个逻辑写在所有的请求中; 该如何利用这个拦截器的功能,实现这一功能呢? ![IMG_E01859D83331-1](Community%20%E9%A1%B9%E7%9B%AE%E9%97%AE%E9%A2%98%E6%B1%87%E6%80%BB.assets/IMG_E01859D83331-1.jpeg) * 在登录成功之后:我们会将生成的登录凭证Ticket存储在Cookie中,发送给浏览器; * 浏览器每次访问一个请求,都会挟带Cookie(当然是Cookie生效的Path),Cookie中存储着本次登录的Ticket,Ticket可以找到对应的userId; * 拦截器对每个请求进行拦截,检查Cookie中是否有Ticket,若有,就把Ticket对应的User放入Model交付给Template渲染; * 上图中的一系列流程,在服务器端,是被一个线程所处理,**如果多个用户同时访问某个请求,我们应该考虑线程隔离;** ThreadLocal * 拦截器配置: ```java package com.dedsec.community.controller.interceptor; import com.dedsec.community.entity.LoginTicket; import com.dedsec.community.entity.User; import com.dedsec.community.service.UserService; import com.dedsec.community.util.CookieUtil; import com.dedsec.community.util.HostHolder; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; import org.springframework.web.servlet.HandlerInterceptor; import org.springframework.web.servlet.ModelAndView; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import java.util.Date; @Component public class LoginTicketInterceptor implements HandlerInterceptor { @Autowired private UserService userService; @Autowired private HostHolder hostHolder; /** * 通过Cookie得到ticket * @param request * @param response * @param handler * @return * @throws Exception */ @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); //检查凭证是否有效 //1.不为空 //2.激活态 //3.未过期 if (loginTicket != null && loginTicket.getStatus() == 0 && loginTicket.getExpired().after(new Date())){ //查询登录用户 User user = userService.findUserById(loginTicket.getUserId()); //本次请求持有用户(考虑多线程隔离) hostHolder.setUsers(user); } } return true; } /** * 在模版引擎之前,将该线程的User放入Model * @param request * @param response * @param handler * @param modelAndView * @throws Exception */ @Override public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception { User user = hostHolder.getUser(); if (user != null && modelAndView != null){ modelAndView.addObject("loginUser",user); } } /** * 清理数据 * @param request * @param response * @param handler * @param ex * @throws Exception */ @Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception { hostHolder.clear(); } } ``` * 拦截器注册 ```java package com.dedsec.community.config; import com.dedsec.community.controller.interceptor.AlphaInterceptor; import com.dedsec.community.controller.interceptor.LoginTicketInterceptor; import com.mysql.cj.log.Log; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Configuration; import org.springframework.web.servlet.config.annotation.InterceptorRegistry; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; @Configuration public class WebMvcConfig implements WebMvcConfigurer { @Autowired private AlphaInterceptor alphaInterceptor; @Autowired private LoginTicketInterceptor loginTicketInterceptor; @Override public void addInterceptors(InterceptorRegistry registry) { /* 测试拦截器的注册 */ registry.addInterceptor(alphaInterceptor) .excludePathPatterns("/**/*.css","/**/*.js","/**/*.png","/**/*.jpg","/**/*.jpeg") .addPathPatterns("/register","/login"); /* 登录凭证拦截器的注册 */ registry.addInterceptor(loginTicketInterceptor) .excludePathPatterns("/**/*.css","/**/*.js","/**/*.png","/**/*.jpg","/**/*.jpeg"); } } ``` ### 三、账号设置 #### 1. 上传头像 * POST请求 * 表单:enctype = "multipart/form-data" * SpringMVC:通过MultipartFile上传文件 * 因为通过SpringMVC来进行MultipartFile文件上传和请求头像,所以在Controller层处理这些逻辑 * 上传头像到服务器 UserController ```java /** * 上传头像 * @param headerImage * @param model * @return */ @RequestMapping(path = "/upload", method = RequestMethod.POST) public String uploadHeader(MultipartFile headerImage, Model model){ if (headerImage == null){ model.addAttribute("error","未选择图片"); return "/site/setting"; } String fileName = headerImage.getOriginalFilename(); //处理带后缀的图片,无后缀则不用管 String suffix = fileName.substring(fileName.lastIndexOf(".")); if (StringUtils.isBlank(suffix)){ model.addAttribute("error","图片不符合格式"); return "/site/setting"; } //生成随机文件名 fileName = CommunityUtil.generateUUID() + suffix; //确定文件存放的路径 File dest = new File(uploadPath + "/" + fileName); //把图像内容写入文件 try { headerImage.transferTo(dest); } catch (IOException e) { logger.error("上传文件失败" + e.getMessage()); throw new RuntimeException("上传文件失败,服务器发生异常",e); } //更新当前用户头像的路径 //是 web访问的路径 User user = hostHolder.getUser(); //访问到的是获取头像的Controller //相当于请求转发 String headUrl = domain + contextPath + "/user/header/" + fileName; userService.updateHeader(user.getId(),headUrl); return "redirect:/index"; } ``` * 请求一个头像图片 UserController ```java /** * 向服务器请求头像 * @param fileName * @param response */ @RequestMapping(path = "/header/{fileName}",method = RequestMethod.GET) public void getHeader(@PathVariable("fileName") String fileName, HttpServletResponse response){ //从服务器中找到图片 fileName = uploadPath + "/" + fileName; //解析后缀 String suffix = fileName.substring(fileName.lastIndexOf(".")); //响应图片 response.setContentType("image/" + suffix); //开启输入流、输出流 try (OutputStream os = response.getOutputStream();FileInputStream fis = new FileInputStream(fileName)) { //缓冲区 byte[] buffer = new byte[1024]; int b = 0; //游标 //不为 -1 代表读到了数据 while ((b = fis.read(buffer)) != -1){ //写入缓冲区 os.write(buffer,0,b); } } catch (IOException e) { logger.error("读取头像失败" + e.getMessage()); } } ``` #### 2. 检查登录状态 * 不能让用户直接通过url访问一些功能 * 使用拦截器: * 在方法前自定义注解 * 拦截所有请求,只处理带有该注解的方法 * 自定义注解是什么? * 常用的原注解: ```java @Target //声明自定义的注解可以作用在什么类型上(类、方法、属性) @Retention //自定义注解有效时间(编译时、运行时) @Document //生成文档 @Inherited //子类需不需要继承父类的注解? ``` * 如何读取注解:反射 ```java Method.getDeclaredAnnotations() Method.getAnnotation(Class annotationClass) ``` * 给需要进行“登录状态检查的方法(URL)”打上注解,拦截器将会去拦截每一个方法,在preHandler中检查该方法是否需要“检查登录状态” * 声明一个注解: LoginRequired ```java package com.dedsec.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 { } ``` * 定义拦截器 LoginRequiredInterceptor ```java package com.dedsec.community.controller.interceptor; import com.dedsec.community.annotation.LoginRequired; import com.dedsec.community.util.HostHolder; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; import org.springframework.web.method.HandlerMethod; import org.springframework.web.servlet.HandlerInterceptor; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import java.lang.reflect.Method; @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; } } ``` 最后不要忘记完成对拦截器的注册; ### 四、帖子 #### 1. 实现一个过滤敏感词的算法 * 根据敏感词表,实现一个树结构; * 文件流读入敏感词;然后构建敏感树; * 过滤特殊符号;API实现的判断是否为东亚字符、符号字符等... * StringBuilder结构,记录合法字符,替换不合法字符段为*; ```java /** * 过滤敏感词 * @param text 待过滤的的文本 * @return 过滤后的文本 */ public String filter(String text){ if (StringUtils.isBlank(text)) return null; //指针1 TrieNode tempNode = rootNode; //指针2 int begin = 0; //指针3 int position = 0; //结果 StringBuilder sb = new StringBuilder(); while (position < text.length()){ char c = text.charAt(position); //跳过符号 if (isSymbol(c)){ //若指针1在根节点 if (tempNode == rootNode){ sb.append(c); begin++; } // 无论符号在开头还是中间,指针3都向下走1步 position++; continue; } //非符号,检查下级节点 tempNode = tempNode.getSubNode(c); if (tempNode == null){ //以begin开头的字符串不是敏感词 sb.append(text.charAt(begin)); //进入下一个位置 position = ++ begin; //重新指向根节点 tempNode = rootNode; }else if (tempNode.isKeywordEnd()){ //发现敏感词 sb.append(REPLACEMENT); //进入下一个位置 begin = ++ position; //重新指向根节点 tempNode = rootNode; }else{ //继续检查下一个字符 position++; } } //最后一批字符计入结果 sb.append(text.substring(begin)); return sb.toString(); } ``` #### 2. 发布帖子 * 异步的JavaScript与XML,AJAX;虽然是XML,但是目前用JSON更多; 在HTML中引入Jquery,然后使用ajax发送请求: ```JavaScript function send(){ $.post( "/community/alpha/ajax", {"name":"张三","age":"23"}, function(data){ console.log(typeof(data)); console.log(data); data = $.parseJSON(data); console.log(typeof(data)); console.log(data.code); console.log(data.msg); } ); } ``` #### 3. 帖子详情 * 简单的三层开发; #### 4. 事务管理 * 首先要明白数据库中的事务定义:ACID; * 且要清楚并发所带来的问题:第一类丢失修改、第二类丢失修改、脏读、不可重读、幻影读; * 其次要明白实现事务的方式:悲观锁 or 乐观锁; * 悲观锁:X锁与S锁 * 乐观锁:版本号、时间戳,CAS? #### 5. Spring 事务管理 * 声明式事务管理:加点配置做管理;(粗暴控制整体) * 编程式事务管理:通过编程解决;(精细化控制局部) * 事务传播 * 事务传播机制:业务方法A可能调用业务方法B,A、B都可能被事务管理,那么以谁的级别为准? * Required:支持当前事务;A调B,B被A调用,所以支持的是外部事务;如果不存在外部事务,那么就创建新事务; * Requires_new:创建新事务,并且暂停外部事务;A调B,B无视A的事务,B按照自己的事务去执行; * Nested:如果当前存在外部事务,则嵌套在该外部事务中执行;A调B,A有事务,B的事务嵌套在A中执行,都有独立的提交与回滚;否则不存在外部事务,那么就和Required; ```java /** * 声明式事务 * @return */ @Transactional(isolation = Isolation.READ_COMMITTED, propagation = Propagation.REQUIRED) public Object save1(){ //1.新增用户 User user = new User(); user.setUsername("alpha"); user.setSalt(CommunityUtil.generateUUID().substring(0,5)); user.setPassword(CommunityUtil.md5("123" + user.getSalt())); user.setEmail("alpha@qq.com"); user.setHeaderUrl("http://images.nowcoder.com/head/12t.png"); user.setCreateTime(new Date()); userMapper.insertUser(user); //2.新增帖子 DiscussPost post = new DiscussPost(); post.setUserId(user.getId()); post.setTitle("Hello"); post.setContent("新人报道"); post.setCreateTime(new Date()); //3.报错,测试事务是否回滚 Integer.valueOf("abc"); return "ok"; } ``` ```java /** * 编程式事务 * @return */ public Object save2(){ //设置事务隔离级别 transactionTemplate.setIsolationLevel(TransactionDefinition.ISOLATION_READ_COMMITTED); //设置传播行为 transactionTemplate.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRED); //回调接口 return transactionTemplate.execute(new TransactionCallback() { @Override public Object doInTransaction(TransactionStatus transactionStatus) { //1.新增用户 User user = new User(); user.setUsername("beta"); user.setSalt(CommunityUtil.generateUUID().substring(0,5)); user.setPassword(CommunityUtil.md5("123" + user.getSalt())); user.setEmail("beta@qq.com"); user.setHeaderUrl("http://images.nowcoder.com/head/11t.png"); user.setCreateTime(new Date()); userMapper.insertUser(user); //2.新增帖子 DiscussPost post = new DiscussPost(); post.setUserId(user.getId()); post.setTitle("你好"); post.setContent("老人来了"); post.setCreateTime(new Date()); //3.报错,测试事务是否回滚 Integer.valueOf("abc"); return "ok"; } }); } ``` #### 6. 显示评论 * 评论帖子、评论“评论”,我们需要对评论的业务做一个完整的抽象,才能保证评论可以在任何地方复用; * 根据下面图示,在Controller返回数据时,要进行一个拼装; ```java @RequestMapping(path = "/detail/{discussPostId}", method = RequestMethod.GET) public String getDiscussPost(@PathVariable("discussPostId") int discussPostId, Model model, Page page) { // 帖子 DiscussPost post = discussPostService.findDiscussPostById(discussPostId); model.addAttribute("post", post); // 作者 User user = userService.findUserById(post.getUserId()); model.addAttribute("user", user); // 评论分页信息 page.setLimit(5); page.setPath("/discuss/detail/" + discussPostId); page.setRows(post.getCommentCount()); // 评论: 给帖子的评论 // 回复: 给评论的评论 // 评论列表 List commentList = commentService.findCommentByEntity( ENTITY_TYPE_POST, post.getId(), page.getOffset(), page.getLimit()); // 评论VO列表 List> commentVoList = new ArrayList<>(); if (commentList != null) { for (Comment comment : commentList) { // 评论VO Map commentVo = new HashMap<>(); // 评论 commentVo.put("comment", comment); // 作者 commentVo.put("user", userService.findUserById(comment.getUserId())); // 回复列表 List replyList = commentService.findCommentByEntity( ENTITY_TYPE_COMMENT, comment.getId(), 0, Integer.MAX_VALUE); // 回复VO列表 List> replyVoList = new ArrayList<>(); if (replyList != null) { for (Comment reply : replyList) { Map replyVo = new HashMap<>(); // 回复 replyVo.put("reply", reply); // 作者 replyVo.put("user", userService.findUserById(reply.getUserId())); // 回复目标 User target = reply.getTargetId() == 0 ? null : userService.findUserById(reply.getTargetId()); replyVo.put("target", target); replyVoList.add(replyVo); } } commentVo.put("replys", replyVoList); // 回复数量 int replyCount = commentService.findCommentCount(ENTITY_TYPE_COMMENT, comment.getId()); commentVo.put("replyCount", replyCount); commentVoList.add(commentVo); } } model.addAttribute("comments", commentVoList); return "/site/discuss-detail"; } ``` * 一个帖子的Comment由两个ViewObject组成,首先是针对于帖子的回复;其次是针对每个“针对帖子的回复”的回复;在查询的时候,要根据不同的实体类型来进行查询,所以我们需要指定对应的常量; ```java package com.dedsec.community.util; public interface CommunityConstant { /** * 实体类型:帖子 */ int ENTITY_TYPE_POST = 1; //整个帖子需要知道,自己有多少评论 /** * 实体类型:评论 */ int ENTITY_TYPE_COMMENT = 2; //每个评论需要知道,自己有多少回复 //按照这两种不同的类别,查询对应的数据库 } ``` #### 7. 添加评论 * 这是一个事务的开发,第一步,添加评论;第二步,更新评论数量; ```java @Transactional(isolation = Isolation.READ_COMMITTED, propagation = Propagation.REQUIRED) public int addComment(Comment comment){ if (comment == null){ throw new IllegalArgumentException("参数不能为空"); } /* 添加评论 */ comment.setContent(HtmlUtils.htmlEscape(comment.getContent())); comment.setContent(sensitiveFilter.filter(comment.getContent())); int rows = commentMapper.insertComment(comment); /* 更新帖子的评论数量,整体不计入回复的评论数量 */ if (comment.getEntityType() == ENTITY_TYPE_POST){ int count = commentMapper.selectCountByEntity(comment.getEntityType(),comment.getEntityId()); discussPostService.updateCommentCount(comment.getEntityId(),count); } return rows; } ``` ### 五、私信列表 #### 1. 私信列表 * 查出当前用户拥有的消息列表,然后每一个条目显示最后一次交流的Message; * 查出用户拥有的总私信对话数目; * 查出用户未读的私信数目; #### 2. 发送私信与设置已读 * 设置已读的实现逻辑很简单,通过获取到的消息列表如果当前的message的toid是当前用户且status = 1,那么就是未读消息,把它加入消息list,然后统一做一个更新处理; * 发送私信:私信的添加; ### 六、统一处理异常 #### 1. 原理 * 无论哪一层次抛出异常,都会最终汇聚在表现层,所以可以在Controller层处理所有的异常; #### 2. 常用的简洁方案 * Template直接放一个error文件夹,然后有对应错误状态码的html文档就行; #### 3. 异常日志处理 * @ControllerAdvice:对Controller类下的方法进行报错的统一处理; * @ExceptionHandler:用于修饰方法;处理Controller的异常; * 一个统一的异常处理类型 * 同步请求:用户发起请求,等到响应再执行下一步操作;比如,请求一个页面; * 异步请求:用户发请请求,不等响应直接执行下一步操作;比如,发送消息等; ```java package com.dedsec.community.controller.advice; import com.dedsec.community.entity.Comment; import com.dedsec.community.util.CommunityUtil; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.http.HttpRequest; import org.springframework.stereotype.Controller; import org.springframework.web.bind.annotation.ControllerAdvice; import org.springframework.web.bind.annotation.ExceptionHandler; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import java.io.IOException; import java.io.PrintWriter; @ControllerAdvice(annotations = Controller.class) public class ExceptionAdvice { private static final Logger logger = LoggerFactory.getLogger(ExceptionAdvice.class); @ExceptionHandler({Exception.class}) public void handleException(Exception e, HttpServletRequest request, HttpServletResponse response) throws IOException { logger.error("服务器发生异常:" + e.getMessage()); for (StackTraceElement element : e.getStackTrace()){ logger.error(element.toString()); } //在这里区分了同步请求与异步请求的异常处理方式 String xRequestedWith = request.getHeader("x-requested-with"); if ("XMLHttpRequest".equals(xRequestedWith)){ response.setContentType("application/plain;charSet=utf-8"); PrintWriter writer = response.getWriter(); writer.write(CommunityUtil.getJSONString(1,"服务器异常")); }else{ response.sendRedirect(request.getContextPath() + "/error"); } } } ``` #### 4. 统一记录日志(AOP) * 定义一个调用方法前的日志记录,即可跟踪用户调用了什么样子的方法; ```java @Aspect @Component public class ServiceLogAspect { private static final Logger logger = LoggerFactory.getLogger(ServiceLogAspect.class); /** * 定义切点,在所有的Service方法中,进行通知 */ @Pointcut("execution(* com.dedsec.community.service.*.*(..))") public void pointCut(){ } @Before("pointCut()") public void before(JoinPoint joinPoint){ //日志格式:用户[1.2.3.4],在[XXX]时间,访问了[com.nowcoder.community.service.xxx()]方法; //获取request,得到IP ServletRequestAttributes attributes = (ServletRequestAttributes)RequestContextHolder.getRequestAttributes(); HttpServletRequest request = attributes.getRequest(); String ip = request.getRemoteHost(); String now = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(new Date()); String target = joinPoint.getSignature().getDeclaringTypeName() + "." + joinPoint.getSignature().getName(); logger.info(String.format("用户[%s],在[%s],访问了[%s].",ip,now,target)); } } ``` ### 七、引入Redis,实现一些功能 #### 0. Redis的使用心得 * 设计一个合理的Key,根据参数拼接Key来使用Redis; * Redis不同的数据类型,可以实现不同的骚操作; * 使用Redis的目的就是为了性能; * 当系统重启,Redis中的数据就会消失,如何保证?分布式;Redis备份数据; #### 1. Spring整合Redis * 引入依赖 * 配置Redis * 通过RedisTemplate访问Redis #### 2. 点赞功能 * 考虑问题:性能问题,同时有很多人给一个人点赞,所以点赞的数据存储在redis中可以提升性能; * 本项目的点赞目标:帖子、评论; * 首先需要一个生成Redis Key的工具,方便我们复用Key * 关于查询 点赞的数据以Key-Value的形式存储在Redis中,所以我们需要每次传入一个正确的对应的Key,获取到我们存储在Redis中的值; 系统中,有两种不同的点赞: * 对于帖子的点赞: ```bash like:entity:1:{postId} ``` * 对于评论的点赞: ```bash like:entity:2:{commentId|replyId} ``` * 所以需要一个拼接Key用于查询的工具 ```java public class RedisKeyUtil { private static final String SPLIT = ":"; private static final String PREFIX_ENTITY_LIKE = "like:entity"; //生成某个实体的赞 //like:entity:entityType:entityId -> set {userId} //知道谁给我点赞、并且可以用于统计Id public static String getEntityLikeKey(int entityType, int entityId){ return PREFIX_ENTITY_LIKE + SPLIT + entityType + SPLIT + entityId; } } ``` * 同时我们要注意,如何给前端页面当前点赞信息,例如:一个人不能点多个赞 ```bash 127.0.0.1:6379[11]> smembers like:entity:1:234 1) "151" ``` 所以,每一个点赞的Key对应的Redis数据结构是集合!Set * 点赞的Service实现 ```java package com.dedsec.community.service; import com.dedsec.community.util.RedisKeyUtil; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.stereotype.Service; @Service public class LikeService { @Autowired private RedisTemplate redisTemplate; /** * 点赞 * @param userId 谁点赞 * @param entityType 点赞了什么类型的实体 * @param entityId 被点赞实体的id */ public void like(int userId, int entityType, int entityId){ String entityLikeKey = RedisKeyUtil.getEntityLikeKey(entityType,entityId); boolean ismember = redisTemplate.opsForSet().isMember(entityLikeKey, userId); //一次点赞记录,再一次同一用户的点赞是取消点赞 if (ismember){ redisTemplate.opsForSet().remove(entityLikeKey,userId); }else{ redisTemplate.opsForSet().add(entityLikeKey,userId); } } /** * 查询某实体的点赞数量 * @param entityType * @param entityId * @return */ public long findEntityLikeCount(int entityType, int entityId){ String entityLikeKey = RedisKeyUtil.getEntityLikeKey(entityType,entityId); return redisTemplate.opsForSet().size(entityLikeKey); } /** * 查询某人对某实体的点赞状态 * @param userId * @param entityType * @param entityId * @return */ public int findEntityLikeStatus(int userId, int entityType, int entityId){ String entityLikeKey = RedisKeyUtil.getEntityLikeKey(entityType,entityId); return redisTemplate.opsForSet().isMember(entityLikeKey,userId) ? 1 : 0; } } ``` * 点赞的控制器 ```java package com.dedsec.community.controller; import com.dedsec.community.annotation.LoginRequired; import com.dedsec.community.entity.User; import com.dedsec.community.service.LikeService; import com.dedsec.community.util.CommunityUtil; import com.dedsec.community.util.HostHolder; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Controller; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMethod; import org.springframework.web.bind.annotation.ResponseBody; import java.util.HashMap; import java.util.Map; @Controller public class LikeController { @Autowired private LikeService likeService; @Autowired private HostHolder hostHolder; @RequestMapping(path = "/like", method = RequestMethod.POST) @ResponseBody @LoginRequired public String like(int entityType, int entityId){ User user = hostHolder.getUser(); //点赞 likeService.like(user.getId(),entityType,entityId); //数量 long likeCount = likeService.findEntityLikeCount(entityType,entityId); //状态 int likeStatus = likeService.findEntityLikeStatus(user.getId(),entityType,entityId); //封装成数据返回 Map map = new HashMap<>(); map.put("likeCount",likeCount); map.put("likeStatus",likeStatus); return CommunityUtil.getJSONString(0,null,map); } } ``` * 我收到的赞 * 统计一个用户的赞,那么需要重构一下上述的点赞方法;再多一个以User为Key,获赞数量的Value; * 这样重构之后,点赞的功能都变为了事务型代码; #### 3. 关注、取关 * 关注的目标有:人、帖子、话题等...所以抽象为Entity; * 规划Redis中的Key: ```bash //某个用户的关注 //userId代表谁持有这个关注,这个关注是一个有序的集合 //zset是一个排序集合,可以应付一些业务需求变化 //followee:userId:entityType -> zset(entityId,currentTime) ``` ```tex //某个用户拥有的粉丝 //entityId代表某个实体持有这个粉丝,这个粉丝是一个有序的集合 //follower:entityType:entityId -> zset(userId,currentTime) ``` * 关注的事务 ```java 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.getFolloweeKey(entityType,entityId); operations.multi(); operations.opsForZSet().add(followeeKey,entityId,System.currentTimeMillis()); operations.opsForZSet().add(followerKey,userId,System.currentTimeMillis()); return operations.exec(); } }); } ``` #### 4. 关注列表的实现 * 如果是查询关注,那么就需要当前的userId,entityType指明查找的是: * 关注的人 * 关注的帖子 * ... * 如果是查询粉丝,那么就需要当前的entityType,entityId指明查找的是: * 某人的粉丝列表 * 某帖子的关注列表 * ....的关注列表 ```java // 查询某个用户关注的人 public List> findFollowees(int userId, int offset, int limit){ String followeeKey = RedisKeyUtil.getFolloweeKey(userId, ENTITY_TYPE_USER); Set targetIds = redisTemplate.opsForZSet().reverseRange(followeeKey,offset,offset+limit-1); if (targetIds == null){ return null; } List> list = new ArrayList<>(); for (Integer targetId : targetIds){ Map map = new HashMap<>(); User user = userService.findUserById(targetId); map.put("user",user); Double score = redisTemplate.opsForZSet().score(followeeKey,targetId); map.put("followTime", new Date(score.longValue())); list.add(map); } return list; } // 查询某个用户的粉丝 public List> findFollowers(int userId, int offset, int limit){ String followerKey = RedisKeyUtil.getFollowerKey(ENTITY_TYPE_USER,userId); Set targetIds = redisTemplate.opsForZSet().reverseRange(followerKey,offset,offset+limit-1); if (targetIds == null){ return null; } List> list = new ArrayList<>(); for (Integer targetId : targetIds){ Map map = new HashMap<>(); User user = userService.findUserById(targetId); map.put("user",user); Double score = redisTemplate.opsForZSet().score(followerKey,targetId); map.put("followTime", new Date(score.longValue())); list.add(map); } return list; } ``` ### 八、优化登录模块 #### 1. 优化的方向 * 使用Redis存储验证码 * 验证码频繁访问、对性能要求高; * 验证码不需要永久保存; * 分布式部署的时候,存在Session共享的问题; * 使用Redis存储登录凭证 * 每次处理请求,都需要查登录凭证,访问频率很高; * 使用Redis缓存用户信息 * 每次处理请求,都需要根据凭证查询用户信息,访问频率非常高; #### 2. 优化1:Redis存储验证码 tomcat的Session存在内存,那么还算快,但是可以用Redis优化; * Key设计 ```java //登录验证码 //kpatcha:{owner} -> {kaptcha} public static String getKaptchaKey(String owner){ return PREFIX_KAPTCHA + SPLIT + owner; } ``` * 代码更新 ```java /** * 获取验证码 * 得到的验证码存储浏览器与服务器交互的session中,在login中再次拿出来验证 * * 8.10更新:验证码不再存储在Session中,通过用户"获取验证码"的操作,下发一个验证码凭证, * 该凭证用于唯一生成一个对应的RedisKey,去寻找到存储在Redis中,60秒过期的验证码 * * @param response * @param */ @RequestMapping(path = "/kaptcha",method = RequestMethod.GET) public void getKaptcha(HttpServletResponse response/*, HttpSession session*/){ //生成验证码 String text = kaptchaProducer.createText(); BufferedImage image = kaptchaProducer.createImage(text); /* //验证码实际值存入Session session.setAttribute("kaptcha",text);*/ //验证码的归属 String kaptchaOwner = CommunityUtil.generateUUID(); Cookie cookie = new Cookie("kaptchaOwner",kaptchaOwner); //60s 验证码失效 cookie.setMaxAge(60); 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); } catch (IOException e) { logger.error("响应验证码失败" + e.getMessage()); } } ``` #### 3. 优化2:Redis存储登录的凭证 * LoginTicket这个东西最初我们把它设计在数据库当中,但是我们客户端每次发起请求,通过LoginTicket找对应的User时,要访问两次数据库;这是很慢的; * 所以我们把LoginTicet的获取重构,将它存储在Redis中; ```java //ticket:{loginticket} public static String getTicketKey(String ticket){ return PREFIX_TICKET + SPLIT + ticket; } ``` * 修改登录 ```java /** * 用户登录 * 8.10 账号验证通过之后,原先loginTicket存储在数据库的方式修改为了存储在缓存中; * @param username * @param password * @param expiredSecond * @return */ public Map login(String username, String password, int expiredSecond){ Map map = new HashMap<>(); //空值处理 if (StringUtils.isBlank(username)){ map.put("usernameMsg","账号不能为空"); return map; } if (StringUtils.isBlank(password)){ map.put("passwordMsg","密码不能为空"); return map; } //验证账号 User user = userMapper.selectByName(username); if (user == null){ map.put("usernameMsg","账号不存在"); return map; } if (user.getStatus() == 0){ map.put("usernameMsg","账号未激活"); return map; } //对明文密码加密,然后和数据库中对比 password = CommunityUtil.md5(password + user.getSalt()); if (!user.getPassword().equals(password)){ map.put("passwordMsg","密码错误"); return map; } //账号验证通过,生成登录凭证 LoginTicket loginTicket = new LoginTicket(); loginTicket.setUserId(user.getId()); loginTicket.setTicket(CommunityUtil.generateUUID()); loginTicket.setStatus(0); loginTicket.setExpired(new Date(System.currentTimeMillis() + expiredSecond * 1000)); //loginTicketMapper.insertLoginTicket(loginTicket); /* 登录凭证存入Redis */ String RedisKey = RedisKeyUtil.getTicketKey(loginTicket.getTicket()); redisTemplate.opsForValue().set(RedisKey,loginTicket); //返回凭证 map.put("ticket",loginTicket.getTicket()); return map; } /** * 退出登录 * 8.10 退出登录时,会清除对应的登录凭证 * @param ticket */ public void logout(String ticket){ //loginTicketMapper.updateStatus(ticket,1); String redisKey = RedisKeyUtil.getTicketKey(ticket); LoginTicket loginTicket = (LoginTicket) redisTemplate.opsForValue().get(redisKey); loginTicket.setStatus(1); redisTemplate.opsForValue().set(redisKey,loginTicket); } /** * 查询登录凭证 * 8.10 从Redis中获取登录凭证 * @param ticket * @return */ public LoginTicket findLoginTicket(String ticket){ //return loginTicketMapper.selectByTicket(ticket); String redisKey = RedisKeyUtil.getTicketKey(ticket); return (LoginTicket) redisTemplate.opsForValue().get(redisKey); } ``` #### 4. 优化3:Redis缓存的用户信息 * 页面涉及很多关于用户信息的查询,如果可以将用户对象直接缓存在内存当中,那么必然优化很大; * 首先需要三个方法,处理不同的情况:命中、初始化、清除 UserService ```java // 1. 优先从缓存中取用户数据 private User getCache(int userId){ String redisKey = RedisKeyUtil.getUserKey(userId); return (User) redisTemplate.opsForValue().get(redisKey); } // 2. 缓存未命中,则初始化缓存数据 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. 数据变更时,清除缓存数据 private void clearCache(int userId){ String redisKey = RedisKeyUtil.getUserKey(userId); redisTemplate.delete(redisKey); } /** * 8.10 用户查询优化,先看缓存中是否命中,随后再查数据库中User数据 * @param id * @return */ public User findUserById(int id){ //return userMapper.selectById(id); User user = getCache(id); if (user == null){ user = initCache(id); } return user; } ``` ### 九、整合Kafka,实现一些功能 #### 1. 阻塞队列 * BlockingQueue:一个Java原生的API; * 解决线程的通信问; * 阻塞方法:put、take; * 生产者消费者模式 * 生产者:产生数据的线程; * 消费者:使用数据的线程; * 用阻塞队列,在两个线程间起一个缓冲的作用:生产太快,阻塞生产者;消费太快,阻塞消费者;防止CPU被白白浪费,阻塞可以降低CPU资源的消耗; * 实现类: * ArrayBlockingQueue:数组实现; * LinkedBlockingQueue:链表实现; * PriorityBlockingQUeue、SynchronousQueue、DelayQueue; IMG_B8BF3F83388B-1 #### 2. Kafka * 简介: * Kafka是一个分布式的流媒体平台; * 应用:消息系统、日志收集、用户行为追踪、流式处理; * Kafka特点: * 高吞吐量、消息持久化、高可靠性、高扩展型; * Kafka术语: * Broker(Kafka集群的一个服务器)、Zookeeper(管理集群); * Topic(存放消息的位置)、Partition(对存放消息位置的一个分区)、Offset(消息存放的分区的索引); * Leader Replica(主副本)、Follower Replica(随从副本); IMG_E5F99DBCB261-1 #### 3. 启动Kafka * 启动zookeeper ```bash /Users/liushengwei/devtool/kafka_2.13-2.8.0/bin sh zookeeper-server-start.sh /Users/liushengwei/devtool/kafka_2.13-2.8.0/config/zookeeper.properties ``` * 启动kafka ```bash /Users/liushengwei/devtool/kafka_2.13-2.8.0/bin sh kafka-server-start.sh /Users/liushengwei/devtool/kafka_2.13-2.8.0/config/server.properties ``` * 创建topic ```java sh kafka-topics.sh --create --bootstrap-server localhost:9092 --replication-factor 1 --partitions 1 --topic test ``` * 查看topic ```bash liushengwei@LiuShenweideMBP bin % sh kafka-topics.sh --list --bootstrap-server localhost:9092 #topics: __consumer_offsets comment follow like test ``` * 生产者与消费者通信测试 ```bash sh kafka-console-producer.sh --broker-list localhost:9092 --topic test >hello >world ``` ```bash liushengwei@LiuShenweideMBP bin % sh kafka-console-consumer.sh --bootstrap-server localhost:9092 --topic test --from-beginning hello world ``` #### 4. Spring整合Kafka * 引入依赖 * 配置Kafka * 访问Kafka * 生产者:kafkaTemplate.sent(topic,data); * 消费者:@KafkaListener(topics = {"test"}) ```java //用这个方法去处理消息 public void handleMessage() ``` * 使用样例: ```java @SpringBootTest public class KafkaTest { @Autowired private KafkaProducer producer; @Test public void testKafka(){ producer.sendMessage("test","你好"); producer.sendMessage("test","在吗"); try { Thread.sleep(1000 * 10); } catch (InterruptedException e) { e.printStackTrace(); } } } /** * 消费者 */ @Component class KafkaProducer{ @Autowired private KafkaTemplate kafkaTemplate; public void sendMessage(String topic, String content){ kafkaTemplate.send(topic,content); } } /** * 消费者 */ @Component class KafkaConsumer{ //指定消费者接受的主题 @KafkaListener(topics = {"test"}) public void handleMessage(ConsumerRecord record){ System.out.println(record.value()); } } ``` #### 5. 发送系统通知 * 触发事件: * 评论后; * 点赞后; * 发布后; * 处理事件: * 封装事件对象; * 开发事件的生产者; * 开发事件的消费者; IMG_0983515784CA-1 * 封装事件 ```java /** * 消息触发的事件 */ @ToString public class Event { private String topic; private int userId; private int entityType; private int entityId; private int entityUserId; /* 支持事件的扩展性 */ private Map data = new HashMap<>(); public String getTopic() { return topic; } public Event setTopic(String topic) { this.topic = topic; return this; } public int getUserId() { return userId; } public Event setUserId(int userId) { this.userId = userId; return this; } public int getEntityType() { return entityType; } public Event setEntityType(int entityType) { this.entityType = entityType; return this; } public int getEntityId() { return entityId; } public Event setEntityId(int entityId) { this.entityId = entityId; return this; } public int getEntityUserId() { return entityUserId; } public Event setEntityUserId(int entityUserId) { this.entityUserId = entityUserId; return this; } public Map getData() { return data; } public Event setData(String key, Object value) { this.data.put(key,value); return this; } } ``` * 再看看Controller层,对于事件的触发,这里以点赞事件为例子: ```java /** * 利用Kafka实现了通知 * @param entityType * @param entityId * @param entityUserId * @return */ @RequestMapping(path = "/like", method = RequestMethod.POST) @ResponseBody @LoginRequired public String like(int entityType, int entityId, int entityUserId, int postId){ User user = hostHolder.getUser(); //点赞 likeService.like(user.getId(),entityType,entityId,entityUserId); //数量 long likeCount = likeService.findEntityLikeCount(entityType,entityId); //状态 int likeStatus = likeService.findEntityLikeStatus(user.getId(),entityType,entityId); Map map = new HashMap<>(); map.put("likeCount",likeCount); map.put("likeStatus",likeStatus); //触发点赞事件 if (likeStatus == 1){ Event event = new Event() .setTopic(TOPIC_LIKE) .setUserId(hostHolder.getUser().getId()) .setEntityType(entityType) .setEntityId(entityId) .setEntityUserId(entityUserId) .setData("postId",postId); //生产者触发事件 eventProducer.fireEvent(event); } return CommunityUtil.getJSONString(0,null,map); } ``` 可见,event不是一个流向数据库的数据实体,它的作用是封装topic信息和对应事件的实体数据信息,生产者奖它塞入kafka的某一个topic的队列中,等待消费者处理; 所以,在触发事件的地方,注入生产者,并封装对应topic和数据信息为Event对象,让生产者fireEvent就行; * 消费者对于事件如何处理? ```java @Component public class EventConsumer implements CommunityConstant { private static final Logger logger = LoggerFactory.getLogger(EventConsumer.class); @Autowired private MessageService messageService; /** * 消费三个不同主题的数据,注解表明这个消费者监听着三个主题 * @param record */ @KafkaListener(topics = {TOPIC_LIKE,TOPIC_FOLLOW,TOPIC_COMMENT}) public void handleCommentMessage(ConsumerRecord record){ if (record == null || record.value() == null){ logger.error("消息内容为空!"); return; } Event event = JSONObject.parseObject(record.value().toString(),Event.class); if (event == null){ logger.error("消息格式错误!"); return; } // 发送站内通知,利用Event封装的事件信息,构造一个来自系统的推送 Message message = new Message(); message.setFromId(SYSTEM_USER_ID); message.setToId(event.getEntityUserId()); message.setConversationId(event.getTopic()); message.setCreateTime(new Date()); Map content = new HashMap<>(); content.put("userId",event.getUserId()); content.put("entityType",event.getEntityType()); content.put("entityId",event.getEntityId()); if (!event.getData().isEmpty()){ for (Map.Entry entry : event.getData().entrySet()){ content.put(entry.getKey(),entry.getValue()); } } message.setContent(JSONObject.toJSONString(content)); //消费者把Event的信息封装为了Message,存入到了持久层,这样用户下次获取,就能获取提示信息; messageService.addMessage(message); } } ``` ### 十、整合Elasticsearch #### 1. ES入门 * 分布式的、Restful风格的搜索引擎; 规定了钱后端的交互形式,通过HTTP就可以访问; * 支持对各种类型的数据的检索; * 搜索速度快,可以提供实时的搜索服务; * 搜索引擎的步骤: ```tex 1. 提交数据到搜索引擎 2. 搜索引擎对于数据分词处理、索引处理、加快效率; ``` * 某些搜索引擎在上面两个步骤或多或少都有性能问题,ES在这两个方面都很快; * 便于水平扩展,每一秒可以处理PB级的海量数据; 方便加集群,有了集群,就能处理PB级的海量数据; * 术语: * 索引:相当于MySQL中的数据库;**6.0以后,索引对应一张表;** * 类型:相当于MySQL中的表;6.0以后慢慢开始废弃; * 文档:**相当于MySQL中表的一行;JSON结构;** * 字段:**相当于MySQL中的一列;** * 集群:多台ES服务器组合在一起,成为集群; * 节点:集群的一台服务器是一个节点; * 分片:对索引的进一步划分,一个索引拆分为多个片,去存,提高并发能力; * 副本:副本是对分片的备份,就可以利用备份来恢复; #### 2. ES相关配置 * elasticsearch.yml ```yml # ---------------------------------- Cluster ----------------------------------- # # Use a descriptive name for your cluster: # cluster.name: dedsec # # ------------------------------------ Node ------------------------------------ # # Use a descriptive name for the node: # #node.name: node-1 # # Add custom attributes to the node: # #node.attr.rack: r1 # # ----------------------------------- Paths ------------------------------------ # # Path to directory where to store the data (separate multiple locations by comma): # path.data: /Users/liushengwei/devtool/temp/elasticsearch/data # # Path to log files: # path.logs: /Users/liushengwei/devtool/temp/elasticsearch/logs # #禁止机器学习 xpack.ml.enabled: false ``` * 查看ElasticSearch集群的健康状态、节点状态、索引状态 ```bash curl -X GET "localhost:9200/_cat/health?v" curl -X GET "localhost:9200/_cat/nodes?v" curl -X GET "localhost:9200/_cat/indices?v" ``` * 建立索引、删除索引 ```bash curl -X PUT "localhost:9200/test" curl -X DELETE "localhost:9200/test" ``` #### 3. Spring 整合 ES * 引入依赖 * 配置Elasticsearch * Spring Data Elasticsearch * Netty:Redis和ES的冲突问题: ```java @PostConstruct public void init(){ // 解决netty启动冲突的问题:redis 和 es // es源码 : netty4untils.setAvailableProcessors() System.setProperty("es.set.netty.runtime.available.processors","false"); } ``` * ES 出现FileNotFountException怎么办? ```tex 1. 权限原因,但我没有遇到过,可能在linux环境下会出现这样的状态; 2. 仔细观察报错信息中有提示机器学习之类的东西,这是6版本以上es会出现的错误,通过上文配置文件的禁用就行; ``` * Spring整合ES出现错误怎么办? ```tex 1. 首先查看maven帮你下载的es版本是多少; 2. 在Spring2.1.x版本,利用es6.3.x的时候,是正常的,配置类中的cluster-name和cluster-node都没有过时; 3. 在Spring2.5.x版本,上述配置都是过期的,而且maven引入的依赖是es7.1.x,所以要根据新版的方式来处理配置; ``` 编写配置类,返回一个client类给Ioc容器: ```java @Configuration public class ElasticSearchConfig { @Bean public RestHighLevelClient esRestClient(){ RestHighLevelClient client = new RestHighLevelClient( RestClient.builder( //在这里配置你的elasticsearch的情况 new HttpHost("127.0.0.1", 9200, "http") ) ); return client; } } ``` * 存储在ES中的实体类型,要用注解进行配置,形成映射 ```java @Getter @Setter @ToString @Document(indexName = "discusspost") @Setting(shards = 6,replicas = 3) public class DiscussPost { @Id private int id; @Field(type = FieldType.Integer) private int userId; /** * 对于title字段,应该将其拆分为最多的单词,然后去搜索 * ik_smart 会洞察用户的意图 */ @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; } ``` * ES基本操作 ```java @SpringBootTest public class ElasticsearchTests { @Autowired private DiscussPostMapper discussMapper; @Autowired private DiscussPostRepository discussRepository; @Autowired private ElasticsearchRestTemplate esTemplate; /** * 单条添加 */ @Test public void testInsert(){ discussRepository.save(discussMapper.selectDiscussPostById(241)); discussRepository.save(discussMapper.selectDiscussPostById(242)); discussRepository.save(discussMapper.selectDiscussPostById(243)); } /** * 批量添加 */ @Test public void insertList(){ discussRepository.saveAll(discussMapper.selectDiscussPosts(101,0,100)); discussRepository.saveAll(discussMapper.selectDiscussPosts(102,0,100)); discussRepository.saveAll(discussMapper.selectDiscussPosts(103,0,100)); discussRepository.saveAll(discussMapper.selectDiscussPosts(111,0,100)); discussRepository.saveAll(discussMapper.selectDiscussPosts(112,0,100)); discussRepository.saveAll(discussMapper.selectDiscussPosts(131,0,100)); discussRepository.saveAll(discussMapper.selectDiscussPosts(132,0,100)); discussRepository.saveAll(discussMapper.selectDiscussPosts(133,0,100)); discussRepository.saveAll(discussMapper.selectDiscussPosts(134,0,100)); } /** * 修改数据 */ @Test public void update(){ DiscussPost discussPost = discussMapper.selectDiscussPostById(231); discussPost.setContent("新人使劲灌水"); discussRepository.save(discussPost); } /** * 删除 */ @Test public void delete(){ discussRepository.deleteById(231); //删除所有数据 discussRepository.deleteAll(); } @Test public void testSearchByRepository(){ 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("").postTags(""), new HighlightBuilder.Field("content").preTags("").postTags("") ).build(); SearchHits result = esTemplate.search(searchQuery, DiscussPost.class); result.forEach(item -> System.out.println(item)); } } ``` #### 4. 实现社区搜索功能 * 搜索服务 * 将帖子保存在Elasticsearch服务器; * 从服务器搜索帖子、删除帖子; * 发布事件 * 发布帖子,将帖子异步提交到ES服务器; * 增加评论,将帖子异步提交到ES服务器; * 显示结果 * 在Controller处理请求; * 为了将新发布的帖子,同步到ES的服务器,有用到了Kafka来处理异步的事件; 发帖事件: ```java /** * 8.16 更新:增加了发帖时,异步将发帖事件交付给kafka去执行,由消费者将帖子同步到ES服务器 * @param title * @param content * @return */ @RequestMapping(value = "/add",method = RequestMethod.POST) @ResponseBody public String addDiscussPost(String title, String content){ User user = hostHolder.getUser(); if (user == null){ return CommunityUtil.getJSONString(403,"未登录"); } DiscussPost post = new DiscussPost(); post.setUserId(user.getId()); post.setTitle(title); post.setContent(content); post.setCreateTime(new Date()); discussPostService.addDiscussPost(post); //触发发帖事件 Event event = new Event() .setTopic(TOPIC_PUBLISH) .setUserId(user.getId()) .setEntityType(ENTITY_TYPE_POST) .setEntityId(post.getId()); //报错情况,将来统一处理 return CommunityUtil.getJSONString(0,"发布成功"); } ``` 可见,Kafka生产者、消费者模型,可以运用在系统的很多地方;共性是:**有一些业务强调异步进行,那么就可以考虑由Kafka去处理;** 在对应的消费者中,处理逻辑; ```java @Component public class EventConsumer implements CommunityConstant { private static final Logger logger = LoggerFactory.getLogger(EventConsumer.class); @Autowired private MessageService messageService; @Autowired private DiscussPostService discussPostService; @Autowired private ElasticsearchService elasticsearchService; @KafkaListener(topics = TOPIC_PUBLISH) public void handlePublishMessage(ConsumerRecord record){ if (record == null || record.value() == null){ logger.error("消息内容为空!"); return; } Event event = JSONObject.parseObject(record.value().toString(),Event.class); if (event == null){ logger.error("消息格式错误!"); return; } DiscussPost post = discussPostService.findDiscussPostById(event.getEntityId()); elasticsearchService.saveDiscussPost(post); } } ``` ### 十一、网站安全 #### 1. 重构登录验证模块 * 之前的登录模块太简单,现在废弃; * 总结一下需要拦截的路径 ```tex 评论模块: /comment/add/{discussPostId} 添加评论 帖子模块: /discuss/add 发布帖子 /discuss/detail/{discussPostId} 帖子详情 关注模块: /follow 关注 /unfollow 取消 主页模块: /index 获取主页信息 点赞模块 /like 点赞 登录模块 /register 注册 /activation/{userId}/{code} 激活 /kaptcha 验证码获取 /login 登录 /logout 登出 消息模块 /letter/list 私信列表 /letter/detail/{conversationId} 私信详情(对话) /letter/send 发送私信 /notice/list 三类通知列表 /notice/detail/{topic} 通知详情 搜索模块 /search 搜索关键词 用户模块 /setting 账号设置页面 /upload 头像上传 /header/{fileName} 向服务器获取头像 /profile/{userId} 用户详情页获取 /followees/{userId} 用户关注列表 /followers/{userId} 用户粉丝列表 /updatepd/{userId} 更新密码 ``` #### 2. 授权配置 * 对当前系统内的所有请求,分配访问权限:普通用户、版主、管理员; * SpringSecurity如何获得用户的权限? 首先在UserService实现权限查找的逻辑: ```java public Collection getAuthorities(int userId){ User user = findUserById(userId); List 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; } ``` 然后,在登录验证的拦截器中,验证User的ticket,在此时此刻将用户权限放在SecurityContextHolder中: ```java @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); //检查凭证是否有效 //1.不为空 //2.激活态 //3.未过期 if (loginTicket != null && loginTicket.getStatus() == 0 && loginTicket.getExpired().after(new Date())){ //查询登录用户 User user = userService.findUserById(loginTicket.getUserId()); //本次请求持有用户(考虑多线程隔离) hostHolder.setUsers(user); //构建用户认证的结果,并存入SecurityContextHolder,便于Security授权; Authentication authentication = new UsernamePasswordAuthenticationToken( user, user.getPassword(), userService.getAuthorities(user.getId()) ); SecurityContextHolder.setContext(new SecurityContextImpl(authentication)); } } return true; } ``` #### 3. 认证方案 * 绕过Security认证流程、采用系统原有的认证方案; ```java /* filter在dispacherservlet之前,所以如果我们拦截logout,就不会继续往下执行; 我们需要覆盖他的逻辑,执行我们自己的logout方法 在这里我们欺骗框架说我们的logout是/securitylogout,这样他就会放行logout */ http.logout().logoutUrl("/securitylogout"); ``` #### 4. CSRF配置 * 防止CSRF攻击的基本原理,以及表单、AJAX相关的配置; * Spring Security自带; * 配置完成之后,表单就会多出token ```html

``` * 异步请求的处理方案 异步没有表单,没有表单,怎么办?需要我们自己实现逻辑,例如发帖: 我们在页面添加这样一个来自Spring Security的信息: ```html ``` ```tex 对应html: ``` 发送请求时,携带该请求就行:**如果你启用了CSRF攻击防御,所有的异步请求都需要这样处理!!** ```javascript $(function(){ $("#publishBtn").click(publish); }); 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); }); // 获取标题和内容 var title = $("#recipient-name").val(); var content = $("#message-text").val(); // 发送异步请求(POST) $.post( CONTEXT_PATH + "/discuss/add", {"title":title,"content":content}, function(data) { data = $.parseJSON(data); // 在提示框中显示返回消息 $("#hintBody").text(data.msg); // 显示提示框 $("#hintModal").modal("show"); // 2秒后,自动隐藏提示框 setTimeout(function(){ $("#hintModal").modal("hide"); // 刷新页面 if(data.code == 0) { window.location.reload(); } }, 2000); } ); } ``` 为了方便,咱们的Demo项目就暂时不防御CSRF攻击: ```java .and().csrf().disable(); ``` #### 5. 置顶、加精、删除 * 功能实现 置顶、加精的时候,修改帖子的状态; * 权限管理 * 版主可以进行:置顶与加精的操作; * 管理员:可以执行删除操作; * 按钮显示 * 版主可以看到“置顶”与“加精”的按钮; * 管理员可以看到“删除”的按钮; * 这里给出Spring Security的权限配置,具体的增删逻辑,就不写了,注意,帖子的状态更新之后,要同步到ES服务器,所以记得触发事件; ```java .antMatchers( "/discuss/top", "discuss/wonderful" ) .hasAnyAuthority( AUTHORITY_MODERATOR ) .antMatchers( "/discuss/delete" ) .hasAnyAuthority( AUTHORITY_ADMIN ) ``` ### 十二、统计与任务 #### 1. 数据统计 * UV(Unique Visitor) * 独立访客,需通过用户IP去重统计数据; * 每次访问都要进行统计; * HyperLogLog,性能好,且存储空间小; * DAU(Daily Active User) * 日活跃用户,需通过用户ID去重统计; * 访问过一次,则认为其活跃; * Bitmap,性能好、且可以统计精确的结果; * 设计Key #### 2. 任务执行与调度 * 试想一下,我们之后要做帖子排序的功能,按照帖子的热度来排序,那么我们如何让帖子的排序是动态的呢?如果有一篇热帖,在短时间内大量人访问与点赞,我们更希望他的曝光率高一点,所以,可以尝试:**“每隔一段时间,开启一个线程,执行这个任务”**; * 那么就涉及到以下几个概念: * JDK线程池 * Spring线程池 * 分布式定时任务:Spring Quartz * 分布式部署为什么用JDK、Spring线程池容易出现问题? * JDK与Spring的线程池对于分布式部署的情况考虑存在欠缺; * 例如,我们规定一个任务执行完毕之后,清理对应的缓存,服务器集群中的节点互相不知道对方的执行状态,就有可能擅自执行缓存清理,破坏了另一个节点任务的正常执行; * JDK、Spring线程池演示展示: ```java /** * JDK线程池使用的实例 */ @SpringBootTest public class ThreadPoolTest { private static final Logger logger = LoggerFactory.getLogger(ThreadPoolTest.class); //JDK线程池 /* FixedThreadPool:固定线程数量的线程池; 他的等待队列大小被设置为了 Integer.MAX_VALUE,所以任务众多时会出现OOM异常; */ private ExecutorService executorService = Executors.newFixedThreadPool(5); /* ScheduledThreadPool:定时执行任务的线程池; 他和cachedThreadPool一样,会按照需要创造线程,可能导致OOM异常; */ private ScheduledExecutorService scheduledExecutorService = Executors.newScheduledThreadPool(5); //Spring线程池 /* Spring 普通线程池 */ @Autowired private ThreadPoolTaskExecutor taskExecutor; /* Spring 定时线程池 */ @Autowired private ThreadPoolTaskScheduler taskScheduler; @Autowired private AlphaService alphaService; private void sleep(long m){ try{ Thread.sleep(m); }catch (InterruptedException e){ e.printStackTrace(); } } /** * JDK线程池:1.普通线程池 */ @Test public void testExecutorService(){ Runnable task = new Runnable() { @Override public void run() { logger.debug("Hello ExecutorService"); } }; for (int i = 0; i < 10; i++) { executorService.submit(task); } sleep(10000); } /** * JDK线程池:2. 可执行定时任务 */ @Test public void testScheduleExecutorService(){ Runnable task = new Runnable() { @Override public void run() { logger.debug("Hello ScheduleExecutorService"); } }; /** * 同一个任务反复执行 */ scheduledExecutorService.scheduleAtFixedRate(task,10000,1000, TimeUnit.MILLISECONDS); sleep(30000); } /** * Spring线程池:3.普通线程池 */ @Test public void testThreadPoolTaskExecutor(){ Runnable task = new Runnable() { @Override public void run() { logger.debug("Hello ThreadPoolTaskExecutor"); } }; for (int i = 0; i < 10; i++) { taskExecutor.submit(task); } sleep(10000); } /** * Spring线程池:4.定时线程池 */ @Test public void testThreadPoolTaskScheduler(){ Runnable task = new Runnable() { @Override public void run() { logger.debug("Hello ThreadPoolTaskExecutor"); } }; Date startTime = new Date(System.currentTimeMillis() + 10000); taskScheduler.scheduleAtFixedRate(task,startTime,1000); sleep(30000); } /** * @Async * Spring线程池:5.这个被注解的方法,会被Spring普通线程池给处理 */ @Test public void testThreadPoolTaskExecutorsSimple(){ for (int i = 0; i < 10; i++) { alphaService.execute1(); } sleep(10000); } /** * @Scheduled(initialDelay = 10000, fixedRate = 1000) * Spring线程池:6.这个被注解的方法,会被定时任务线程池自己调用 */ @Test public void testThreadPoolTaskSchedulerSimple(){ sleep(30000); } } ``` 对应的,注解方法的声明: ```java @Service public class AlphaService { private static final Logger logger = LoggerFactory.getLogger(AlphaService.class); /** * 这个注解可以让该方法在多线程的环境下,异步执行 */ @Async public void execute1(){ logger.debug("excute1"); } /** * 被这个注解声明的方法,会被定时的线程池自动调用 */ @Scheduled(initialDelay = 10000, fixedRate = 1000) public void execute2(){ logger.debug("excute2"); } } ``` * 分布式定时任务:Spring Quarzt * 核心组件:Scheduler;所有的任务都是通过这个接口执行; * 定义任务:Job接口; * 配合接口:Job Detail;用于配置Job;第一次启动任务时,相关配置信息会被存储在数据库中; qrtz_job_details; * 触发器:Trigger;Job以什么频率来进行?触发器有关的配置,也会被存储在数据库中; * 定义Job,定制任务逻辑: ```java public class AlphaJob implements Job { @Override public void execute(JobExecutionContext jobExecutionContext) throws JobExecutionException { System.out.println(Thread.currentThread().getName() + ": execute a quartz job"); } } ``` * 配置QuartzConfig:提供JobDetail、TriggerDetail的初始化逻辑,底层可以通过这两个Detail类,实现任务的实例化,进而交付给线程池运行;同时将Detail信息和分布式任务一致性信息存储在数据库中; ```java /** * 该配置仅仅在初始化时有用,之后的信息都存储在数据库中; */ @Configuration public class QuartzConfig { /** * 提供JobDetail实例化的逻辑 * @return */ @Bean public JobDetailFactoryBean alphaJobDetail(){ JobDetailFactoryBean factoryBean = new JobDetailFactoryBean(); factoryBean.setJobClass(AlphaJob.class); factoryBean.setName("alphaJob"); factoryBean.setGroup("alphaJobGroup"); //任务持久保存 factoryBean.setDurability(true); //任务是否可恢复 factoryBean.setRequestsRecovery(true); return factoryBean; } /** * 提供SimpleTrigger(或CronTrigger)的实例化逻辑 * @param alphaJobDetail * @return */ @Bean public SimpleTriggerFactoryBean alphaTrigger(JobDetail alphaJobDetail){ SimpleTriggerFactoryBean factoryBean = new SimpleTriggerFactoryBean(); factoryBean.setJobDetail(alphaJobDetail); factoryBean.setName("alphaTrigger"); factoryBean.setGroup("alphaTriggerGroup"); factoryBean.setRepeatInterval(3000); factoryBean.setJobDataMap(new JobDataMap()); return factoryBean; } } ``` * 删除任务的方式,同样的会删除数据库中关于Detail的信息: ```java @Autowired private Scheduler scheduler; @Test public void testDeleteJob(){ try { boolean result = scheduler.deleteJob(new JobKey("alphaJob","alphaJobGroup")); System.out.println(result); } catch (SchedulerException e) { e.printStackTrace(); } } ``` #### 3. 热帖排行功能 * 热度的计算方式: ```tex Score = log(精华 + 评论数 * 10 + 点赞数 * 2 + 收藏数 * 2) + (发布事件 - 牛客纪元) ``` * 什么时候计算分数呢? * 如果在点赞、精华、评论等之后,实时的计算,将会给服务器造成较大压力,效率很低; * 所以启动定时任务来计算;热门帖子保持几小时、几小时之后,开始计算; * 在表现层,帖子发布的时候、帖子加精的时候,把对应的帖子放在Redis中,这个集合中的帖子将等待下一次的计算任务: ```java //初始化帖子分数在Redis中 String redisKey = RedisKeyUtil.getPostScoreKey(); redisTemplate.opsForSet().add(redisKey,post.getId()); ``` * 定义任务: ```java /** * 帖子得分刷新任务 */ public class PostScoreRefreshJob implements Job, CommunityConstant { private static final Logger logger = LoggerFactory.getLogger(PostScoreRefreshJob.class); @Autowired private RedisTemplate redisTemplate; @Autowired private DiscussPostService discussPostService; @Autowired private LikeService likeService; @Autowired private ElasticsearchService elasticsearchService; //纪元时间 private static final Date epoch; static { try { epoch = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").parse("2014-08-01 00:00:00"); } catch (ParseException e) { throw new RuntimeException("初始化纪元时间失败"); } } @Override public void execute(JobExecutionContext jobExecutionContext) throws JobExecutionException { String redisKey = RedisKeyUtil.getPostScoreKey(); BoundSetOperations operations = redisTemplate.boundSetOps(redisKey); if (operations.size() == 0){ logger.info("[任务取消] 没有需要刷新分数的帖子"); return; } logger.info("[任务开始] 正在刷新帖子分数:" + operations.size()); while (operations.size() > 0){ this.refresh((Integer)operations.pop()); } logger.info("[任务结束] 帖子分数刷新完毕"); } private void refresh(int postId) { DiscussPost post = discussPostService.findDiscussPostById(postId); if (post == null){ logger.error("[任务异常] 计算分数的帖子不存在"); return; } // 是否加精 boolean wonderful = post.getStatus() == 1; // 评论数 int commentCount = post.getCommentCount(); // 点赞数量 long likeCount = likeService.findEntityLikeCount(ENTITY_TYPE_POST,postId); // 权重 double w = (wonderful ? 75 : 0) + commentCount * 10 + likeCount * 2; // 分数 = 权重 + 距离天数 double score = Math.log10(Math.max(w,1)) + ((post.getCreateTime().getTime() - epoch.getTime()) / (1000 * 3600 * 24)); //更新帖子的分数 discussPostService.updateScore(postId,score); //同步ES数据 post.setScore(score); elasticsearchService.saveDiscussPost(post); } } ``` * 注册任务: ```java /** * 该配置仅仅在初始化时有用,之后的信息都存储在数据库中; */ @Configuration public class QuartzConfig { //other mission //帖子分数的任务 @Bean public JobDetailFactoryBean postScoreRefreshJobDetail(){ JobDetailFactoryBean factoryBean = new JobDetailFactoryBean(); factoryBean.setJobClass(PostScoreRefreshJob.class); factoryBean.setName("postScoreRefreshJob"); factoryBean.setGroup("communityJobGroup"); //任务持久保存 factoryBean.setDurability(true); //任务是否可恢复 factoryBean.setRequestsRecovery(true); return factoryBean; } @Bean public SimpleTriggerFactoryBean postScoreRefreshTrigger(JobDetail postScoreRefreshJobDetail){ SimpleTriggerFactoryBean factoryBean = new SimpleTriggerFactoryBean(); factoryBean.setJobDetail(postScoreRefreshJobDetail); factoryBean.setName("postScoreRefreshTrigger"); factoryBean.setGroup("communityTriggerGroup"); factoryBean.setRepeatInterval(1000 * 60 * 5); factoryBean.setJobDataMap(new JobDataMap()); return factoryBean; } } ``` * 更新“热帖排行”的查找方式 有一些Bug,关于页面的,未能实现正常的按“热度”或“最新”的状态切换,应该是分页的逻辑有一些错误; ### 十三、移动端分享 #### 1. 生成长图 * 通常分享App的时候,会将内容截个图等,发到朋友圈(但现在已经是另一种的HTML小应用的动态形式); * 如何在服务端利用HTML模版生成长途呢? * 这个功能我搁置了,我认为没有必要; ### 十四、性能优化 #### 1. 网站上传云服务器 * 上传方式: * 客户端上传:例如,项目中的头像上传,可以直接上传到云服务器; * 服务器直传:例如,上面的生成长图,直接提交给服务器; * 具体步骤: * 选取一个云服务器,为项目接入SDK; * 编写对应上传功能的逻辑; * 这一步骤的功能我也暂时搁置,主要是没有钱买服务器; #### 2. 网站性能提升 * 本地缓存: * 直接把缓存存储在应用服务器上,性能最好; * 常用的本地缓存:Ehcache、Caffeine等; * 分布式缓存: * 将数据缓存在NoSQL数据库上,跨服务器; * 常用的缓存工具:Redis; * 多级缓存: * ->一级:本地缓存 -> 二级:分布式缓存 -> DB * 避免缓存雪崩(缓存失效,大量的请求直接抵达DB)提高系统的可用性; * 与用户状态相关的数据,是不能存储在本地缓存中的;例如登录的凭证等;应该缓存在Redis中(即各种分布式缓存的方式); * 在这里,只演示如何用caffeine作为本地缓存,优化index页面的帖子展示; 设置Key: ```properties # Caffeine caffeine.posts.max-size=15 caffeine.posts.expire-seconds=180 ``` * 一般而言,优化的是Service ```java /* Caffeine的核心接口:Cache、LoadingCache、AsyncLoadingCache; */ ``` * 针对index页面,我们做对应的缓存优化处理: ```java /** * 8.19更新:引入Caffeine进行缓存帖子列表,帖子总数等 */ @Service public class DiscussPostService { private static final Logger logger = LoggerFactory.getLogger(DiscussPostService.class); @Autowired private DiscussPostMapper discussPostMapper; @Autowired private SensitiveFilter sensitiveFilter; @Value("${caffeine.posts.max-size}") private int maxSize; @Value("${caffeine.posts.expire-seconds}") private int expireSeconds; /* Caffeine的核心接口:Cache、LoadingCache、AsyncLoadingCache; */ //帖子列表的缓存 private LoadingCache> postListCache; //帖子总数的缓存 private LoadingCache postRowsCache; /** * Caffeine的初始化 */ @PostConstruct public void init(){ //初始化帖子列表缓存 postListCache = Caffeine.newBuilder() .maximumSize(maxSize) .expireAfterWrite(expireSeconds, TimeUnit.SECONDS) .build(new CacheLoader>() { @Override public @Nullable List load(@NonNull String key) throws Exception { //缓存数据的来源 if (key == null || key.length() == 0){ throw new IllegalArgumentException("Caffeine 参数错误"); } String[] params = key.split(":"); if (params == null || params.length != 2){ throw new IllegalArgumentException("Caffeine 参数错误"); } int offset = Integer.valueOf(params[0]); int limit = Integer.valueOf(params[1]); /* 在这里访问数据库之前,可以尝试访问Redis! */ logger.debug("从数据库中获取了帖子信息"); return discussPostMapper.selectDiscussPosts(0,offset,limit,1); } }); //初始化帖子总数 postRowsCache = Caffeine.newBuilder() .maximumSize(maxSize) .expireAfterWrite(expireSeconds,TimeUnit.SECONDS) .build(new CacheLoader() { @Override public @Nullable Integer load(@NonNull Integer key) throws Exception { logger.debug("从数据库中获取了帖子总数信息"); return discussPostMapper.selectDiscussPostRows(key); } }); } public List findDiscussPosts(int userId, int offset, int limit, int orderMode){ /* 暂时,只缓存首页、热门、某一页的帖子 */ if(userId == 0 && orderMode == 1){ return postListCache.get(offset+":"+limit); } logger.debug("从数据库中获取了帖子信息"); return discussPostMapper.selectDiscussPosts(userId,offset,limit,orderMode); } public int findDiscussPostRows(int userId){ if (userId == 0){ return postRowsCache.get(userId); } logger.debug("从数据库中获取了帖子总数信息"); return discussPostMapper.selectDiscussPostRows(userId); } public int addDiscussPost(DiscussPost discussPost){ if (discussPost == null){ throw new IllegalArgumentException("参数不能为空"); } //转换 HTMl标记,防止浏览器错误认为提交内容的文本是标签 discussPost.setTitle(HtmlUtils.htmlEscape(discussPost.getTitle())); discussPost.setContent(HtmlUtils.htmlEscape(discussPost.getContent())); //过滤敏感词 discussPost.setTitle(sensitiveFilter.filter(discussPost.getTitle())); discussPost.setContent(sensitiveFilter.filter(discussPost.getContent())); //插入 return discussPostMapper.insertDiscussPost(discussPost); } public DiscussPost findDiscussPostById(int id){ return discussPostMapper.selectDiscussPostById(id); } public int updateCommentCount(int id, int commentCount){ return discussPostMapper.updateCommentCount(id,commentCount); } public int updateType(int id, int type){ return discussPostMapper.updateType(id,type); } public int updateStatus(int id, int status){ return discussPostMapper.updateStatus(id,status); } public int updateScore(int id, double score){ return discussPostMapper.updateScore(id,score); } } ``` * 压力测试 可以用Jmeter去进行压力测试,对于Jmeter的配置如下: ![image-20210819192141736](Community%20%E9%A1%B9%E7%9B%AE%E9%97%AE%E9%A2%98%E6%B1%87%E6%80%BB.assets/image-20210819192141736.png) ![image-20210819192158949](Community%20%E9%A1%B9%E7%9B%AE%E9%97%AE%E9%A2%98%E6%B1%87%E6%80%BB.assets/image-20210819192158949.png) ![image-20210819192226287](Community%20%E9%A1%B9%E7%9B%AE%E9%97%AE%E9%A2%98%E6%B1%87%E6%80%BB.assets/image-20210819192226287.png) * 无缓存下的压力测试:60S,100线程,无限循环; QPS = 441 / 60 = 7.35 ![image-20210819171048955](Community%20%E9%A1%B9%E7%9B%AE%E9%97%AE%E9%A2%98%E6%B1%87%E6%80%BB.assets/image-20210819171048955.png) * 有缓存下的压力测试: 接近21倍的速度提升; QPS = 9512 / 60 = 158.53 ![image-20210819171438076](Community%20%E9%A1%B9%E7%9B%AE%E9%97%AE%E9%A2%98%E6%B1%87%E6%80%BB.assets/image-20210819171438076.png) # 附录:项目相关知识 ## MySQL ### 1. Public Key Retrieval is not allowed 如果用户使用了 sha256_password 认证,密码在传输过程中必须使用 TLS 协议保护,但是如果 RSA 公钥不可用,可以使用服务器提供的公钥;可以在连接中通过 ServerRSAPublicKeyFile 指定服务器的 RSA 公钥,或者AllowPublicKeyRetrieval=True参数以允许客户端从服务器获取公钥;但是需要注意的是 AllowPublicKeyRetrieval=True可能会导致恶意的代理通过中间人攻击(MITM)获取到明文密码,所以默认是关闭的,必须显式开启。 在连接后面加上: ```properties spring.datasource.url=jdbc:mysql://localhost:3306/community?characterEncoding=utf-8&useSSL=false&serverTimezone=Hongkong&allowPublicKeyRetrieval=true ⬆️ ``` ## Spring ### 1. AOP * Aspect Oriented Programing * 编程的角度是面向组件的横切; * Target:需要被切的组件;不直接在Target上编程; * Aspect:抽象出的代码; * Weaving:将Aspect织入Target的方式: * 编译时织入;需要特殊的编译器; * 装载时织入;需要特殊的类加载器; * 运行时织入;需要为目标生成代理对象; * Joinpoint:织入到哪? * 成员方法 * 静态对象 * ... * PointCut:具体说明将Aspect织入哪个JointPoint; * Advice:具体在方法中,织入的逻辑; IMG_14A3086ABD14-1 * 实现AOP * AspectJ:语言级别的实现,拓展了Java语言,定义了AOP愈发;编译期间就会植入代码,用于生成遵守Java规范的class文件; * Spring AOP:纯Java实现,不需要专门的编译过程,也不需要特殊的类加载器;运行时通过代理实现,织入代码,只支持方法类型的连接点; * 代理:所谓代理就是为某个对象生成代理对象,增强该对象的实现; * JDK动态代理:1. 实现代理接口; 2. SpringAOP默认采用这种方式; * CGLib动态代理:1. 没有实现代理接口,Spring会采用这种方式;2. 运行时创建一个目标对象的子类,来代替目标对象; * 测试AOP ```java @Component @Aspect public class AlphaAspect { // 1.定义切点:织入具体哪些Bean?哪些位置? // ⬇️什么返回值都行 所有组件、所有方法 (..)表示所有参数 @Pointcut("execution(* com.dedsec.community.service.*.*(..))") public void pointCut(){ } // 2.定义通知:明确的解决问题; // 连接点在五个点:开始时、结束时、返回值时、抛异常时、开始于结束时,做什么事情; // 以pointCut为切点,有效 @Before("pointCut()") public void before(){ System.out.println("before"); } @After("pointCut()") public void after(){ System.out.println("after"); } @AfterReturning("pointCut()") public void afterReturning(){ System.out.println("afterReturning"); } @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; } } ``` ## SpringMVC ### 1. SpringMVC自动会把参数注入Model ```java //这个page,就不用addAttribute了 @RequestMapping(value = "/index",method = RequestMethod.GET) public String getIndexPage(Model model, Page page){ page.setRows(discussPostService.findDiscussPostRows(0)); page.setPath("/index"); List list = discussPostService.findDiscussPosts(0,page.getOffset(),page.getLimit()); List> discussPosts = new ArrayList<>(); if (list != null){ for(DiscussPost post : list){ Map map = new HashMap<>(); map.put("post",post); map.put("user",userService.findUserById(post.getUserId())); discussPosts.add(map); } } model.addAttribute("discussPosts",discussPosts); return "/index"; } ``` ### 2. 重定向与转发 * 重定向:A、B是两个独立逻辑的组件,例如,A负责删除资源,B负责向浏览器通知一些消息;A没有什么消息通知给浏览器,所以就建议浏览器去访问B,这就是重定向; IMG_1192D324BAB4-1 * 转发:A、B两个模块需要协作处理,所以将请求转发给B,浏览器不知道B的存在;两个模块之间,存在耦合; IMG_D345ABFADBC1-1 ## 日志功能 ### logback * 一款支持Spring的日志功能 ```java /* 实现Logger接口,有五种级别的日志打印方式 */ package org.slf4j; public interface Logger { // Printing methods: public void trace(String message); public void debug(String message); public void info(String message); public void warn(String message); public void error(String message); } ``` * 日志输出 ```java package com.dedsec.community; import org.junit.jupiter.api.Test; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.boot.test.context.SpringBootTest; @SpringBootTest public class LoggerTest { private static final Logger logger = LoggerFactory.getLogger(LoggerTest.class); @Test public void testLogger(){ System.out.println(logger.getName()); logger.debug("debug log"); logger.info("info log"); logger.warn("warn log"); logger.error("error log"); } } ``` * 结果 ```tex com.dedsec.community.LoggerTest 2021-07-19 16:22:07.762 DEBUG 2163 --- [ main] com.dedsec.community.LoggerTest : debug log 2021-07-19 16:22:07.762 INFO 2163 --- [ main] com.dedsec.community.LoggerTest : info log 2021-07-19 16:22:07.762 WARN 2163 --- [ main] com.dedsec.community.LoggerTest : warn log 2021-07-19 16:22:07.762 ERROR 2163 --- [ main] com.dedsec.community.LoggerTest : error log ``` * 配置级别 ```properties # logger logging.level.com.dedsec.community=debug logging.file.name=/Users/liushengwei/IdeaProjects/community/log/community.log # 这只是将所有的日志信息输出到某一个文件中,不合理,我们应分类、分页日志; ``` 应该写一个.xml文件来配置日志,服务于上线: 在这个配置文件中,可以将日志分为不同的级别保存在不同的文件中,还有不同的回滚策略; **Resourece/logback-spring.xml** ```xml community ${LOG_PATH}/${APPDIR}/log_error.log ${LOG_PATH}/${APPDIR}/error/log-error-%d{yyyy-MM-dd}.%i.log 5MB 30 true %d %level [%thread] %logger{10} [%file:%line] %msg%n utf-8 error ACCEPT DENY ${LOG_PATH}/${APPDIR}/log_warn.log ${LOG_PATH}/${APPDIR}/warn/log-warn-%d{yyyy-MM-dd}.%i.log 5MB 30 true %d %level [%thread] %logger{10} [%file:%line] %msg%n utf-8 warn ACCEPT DENY ${LOG_PATH}/${APPDIR}/log_info.log ${LOG_PATH}/${APPDIR}/info/log-info-%d{yyyy-MM-dd}.%i.log 5MB 30 true %d %level [%thread] %logger{10} [%file:%line] %msg%n utf-8 info ACCEPT DENY %d %level [%thread] %logger{10} [%file:%line] %msg%n utf-8 debug ``` ## Spring Security JavaEE有11个Filter模块处理不同的功能,例如登录、认证、注销... Spring Security就是对整个项目的一个管理,在DispatcherServlet之前,去处理一些请求... ![IMG_BFE885F6BD88-1](Community%20%E9%A1%B9%E7%9B%AE%E9%97%AE%E9%A2%98%E6%B1%87%E6%80%BB.assets/IMG_BFE885F6BD88-1.jpeg) ### 1. 通过一个Demo了解 * 对于安全实体做一些处理: ```java @Getter @Setter @ToString public class User implements UserDetails { private int id; private String username; private String password; private String salt; private String email; private int type; private int status; private String activationCode; private String headerUrl; private Date createTime; /** * true : 账号未过期 * false : 账号过期了 * @return */ @Override public boolean isAccountNonExpired() { return true; } /** * true : 账号没有锁定 * false : 账号锁定 * @return */ @Override public boolean isAccountNonLocked() { return true; } /** * true : 凭证没过期 * @return */ @Override public boolean isCredentialsNonExpired() { return true; } /** * true : 账号可用 * @return */ @Override public boolean isEnabled() { return true; } @Override public Collection getAuthorities() { List list = new ArrayList<>(); list.add(new GrantedAuthority() { @Override public String getAuthority() { switch (type){ case 1: return "ADMIN"; default: return "USER"; } } }); return list; } } ``` * Service层添加用户load的业务逻辑 ```java @Service public class UserService implements UserDetailsService { @Autowired private UserMapper userMapper; public User findUserByName(String username) { return userMapper.selectByName(username); } @Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { return this.findUserByName(username); } } ``` * 编写SecurityConfig配置类 ```java @Configuration public class SecurityConfig extends WebSecurityConfigurerAdapter { @Autowired private UserService userService; @Override public void configure(WebSecurity web) throws Exception { //忽略所有静态资源 web.ignoring().antMatchers("/resources/**"); } /** * 认证的逻辑 * AuthenticationManager 认证的核心接口 * AuthenticationManagerBuilder 用于构建AuthenticationManager对象的工具 * ProviderManager: AuthenticationManager接口的默认实现类 * @param auth * @throws Exception */ @Override protected void configure(AuthenticationManagerBuilder auth) throws Exception { //默认的认证规则 /* 一个不匹配Community项目的密码加密方法 我们可以自定义一个认证规则 */ //auth.userDetailsService(userService).passwordEncoder(new Pbkdf2PasswordEncoder("12345")); /* 自定义认证规则: AuthenticationProvider: ProviderManager持有一组AuthenticationProvider, 每一个AuthenticationProvider负责一种认证; 举个例子,现在一个网站有多种多样的登录形式:微信、QQ、短信验证码... 所以,ProviderManager不直接去认证,而是管理一组AuthenticationProvider,分别针对不同的 登录方式,进行认证; 设计模式:委托模式,ProviderManager将认证方式委托给了AuthenticationProvider */ auth.authenticationProvider(new AuthenticationProvider() { //Authentication:用来封装认证信息的接口,不同的实现类代表不同类型的认证消息 @Override public Authentication authenticate(Authentication authentication) throws AuthenticationException { //在下面封装我们之前的登录判断逻辑: String username = authentication.getName(); String password = (String) authentication.getCredentials(); User user = userService.findUserByName(username); if (user == null){ throw new UsernameNotFoundException("账号不存在"); } password = CommunityUtil.md5(password + user.getSalt()); if (!user.getPassword().equals(password)){ throw new BadCredentialsException("密码错误"); } /* 三个参数的含义: principal:认证的主要信息,一般存user对象就完事儿了 credentials:证书,认证的凭证; authorities:用户对应的权限,还记得你在entity中实现的getAuthorities()方法吗? */ return new UsernamePasswordAuthenticationToken(user,user.getPassword(),user.getAuthorities()); } //返回当前的AuthenticationProvider支持哪种认证类型 @Override public boolean supports(Class aClass) { //UsernamePasswordAuthenticationToken:Authentication常用的实现类 //这行代码的意思是,当前支持的是账号密码认证的方式; return UsernamePasswordAuthenticationToken.class.equals(aClass); } }); } /** * 授权的逻辑 * @param http * @throws Exception */ @Override protected void configure(HttpSecurity http) throws Exception { // 登录相关的配置 http.formLogin() .loginPage("/loginpage") //配置登录页面 .loginProcessingUrl("/login") //拦截哪个请求? /* 下面认证结果的处理方式比较粗糙,建议采用handler */ //.successForwardUrl("") //成功时去哪个路径? //.failureForwardUrl("") //失败时去哪? /* 成功时,做什么逻辑? */ .successHandler(new AuthenticationSuccessHandler() { @Override public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException { response.sendRedirect(request.getContextPath() + "/index"); } }) .failureHandler(new AuthenticationFailureHandler() { @Override public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException e) throws IOException, ServletException { //绑定参数到request,再次转发到登录页面 request.setAttribute("error",e.getMessage()); request.getRequestDispatcher("/loginpage").forward(request,response); } }); // 退出账号的相关配置 http.logout() .logoutUrl("/logout") //.logoutUrl() //登出直接跳转的路径 .logoutSuccessHandler(new LogoutSuccessHandler() { @Override public void onLogoutSuccess(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, Authentication authentication) throws IOException, ServletException { httpServletResponse.sendRedirect(httpServletRequest.getContextPath() + "/index"); } }); // 授权进行配置 http.authorizeRequests() //对于某些路径,要求具有权限 .antMatchers("/letter").hasAnyAuthority("USER","ADMIN") .antMatchers("/admin").hasAnyAuthority("ADMIN") //权限不匹配、没有权限的处理方式 .and().exceptionHandling().accessDeniedPage("/denied"); // 自定义一个Filter,处理验证码 http.addFilterBefore(new Filter() { @Override public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException { HttpServletRequest request = (HttpServletRequest) servletRequest; HttpServletResponse response = (HttpServletResponse) servletResponse; if (request.getServletPath().equals("/login")){ String verifyCode = request.getParameter("verifyCode"); if (StringUtils.isBlank(verifyCode) || !verifyCode.equals("1234")){ request.setAttribute("error","验证码错误"); request.getRequestDispatcher("/loginpage").forward(request,response); return; } } //让请求继续向下走,走到下一个filter,不写这句,则请求到此结束 filterChain.doFilter(request,response); } }, UsernamePasswordAuthenticationFilter.class); // 记住我 http.rememberMe() //如果你需要存在redis中,则可以自己实现TokenRepository接口 .tokenRepository(new InMemoryTokenRepositoryImpl()) //有效时间 .tokenValiditySeconds(3600 * 24) //下一步,自动查处用户的完整信息 .userDetailsService(userService); } } ``` ### 2. CSRF攻击 * 定义: ```tex 跨站请求伪造(英语:Cross-site request forgery),也被称为 one-click attack 或者 session riding,通常缩写为 CSRF 或者 XSRF, 是一种挟制用户在当前已登录的Web应用程序上执行非本意的操作的攻击方法。跟跨网站脚本(XSS)相比,XSS 利用的是用户对指定网站的信任,CSRF 利用的是网站对用户网页浏览器的信任。 ``` * 图示: IMG_D920ED58C0EC-1 说明: * 假设服务器是一个银行服务器,用户通过浏览器使用对应的服务之后,浏览器Cookie中保存了ticket或其他验证信息; * 若用户未关闭浏览器,或者Cookie通过“Remeber me”这样的功能设置了有效的时间,那么浏览器会一直持有银行服务器该用户的验证信息; * 假设用户去访问了一个恶意网站,恶意网站针对银行服务器伪造了一系列的访问请求在前端页面,此时用户若发送了陷进请求给恶意网站X,那么恶意网站可以获取到Cookie中的ticket或其他验证信息,然后再伪造一些类似于“转账、改密码”的请求到银行服务器; * 银行服务器接收到了恶意网站的请求,并发现其持有争取的用户认证信息,为之提供相应的服务,那么这就会造成用户损失; 防范: * 检查HTTP协议中的Reference字段: image-20210817112746587 但是不排除有恶意程序攻击浏览器,伪造referer字段的可能性; * **服务端提供“Token”校验字段(有效)** 例如,服务端在对应请求的form表单中,添加一些随机数等校验字段,要求用户下次提交表单,携带对应的验证信息;