Loading

仿牛客网社区开发——第2章 Spring Boot实践,开发社区登录模块

发送邮件

邮箱设置 - 启动客户端SMTP服务

以新浪邮箱为例:

将设置中的POP3/SMTP服务中的服务状态设置为开启。

(SMTP的全称是“SimpleMailTransferProtocol”,即简单邮件传输协议。它是一组用于从源地址到目的地址传输邮件的规范,通过它来控制邮件的中转方式。SMTP协议属于TCP/IP协议簇,它帮助每台计算机在发送或中转信件时找到下一个目的地,SMTP服务器就是遵循SMTP协议的发送邮件服务器,不同邮件服务商均有对应的smtp服务器地址,并且这个地址会提供给大家,方便大家使用Foxmail与outlook等专业邮件管理软件时可以用的上。)

Spring Mail - 导入jar包

直接复制Maven的依赖即可,上mvnrepository.com搜Spring Mail,选择对应的版本。

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-mail</artifactId>
    <version>2.1.5.RELEASE</version>
</dependency>

Spring Mail - 邮箱参数配置

# MailProperties
spring.mail.host=smtp.sina.com
spring.mail.port=465
spring.mail.username=zhouweicheng1999@sina.com
spring.mail.password=6893139532308134
spring.mail.protocol=smtps
spring.mail.properties.mail.smtp.ssl.enable=true
#spring.mail.properties.mail.smtp.auth=true
#spring.mail.properties.mail.smtp.starttls.enable=true
#spring.mail.properties.mail.smtp.starttls.required=tru

注意,配置文件application.properties中的密码spring.mail.password需要配置为邮箱生成的授权码,否则无法发送邮件。(至于最后3行配置注释了也能正常发送,不太清楚这个有啥作用)

Spring Mail - 使用 JavaMailSender 发送邮件

分三步:

1、发送人  2、收件人  3、邮件标题和内容

  • 编写一个MailClient工具类用来发送邮件
  • 开启 logger 日志
  • 注入JavaMailSender(由 Spring 容器管理)
  • 发送人 from 从配置文件注入 username 到 Bean 中
  • 编写一个公有的方法实现发送邮件,传入参数:收件人 to,标题 subject 和内容 content
  • 构建MimeMessage
  • 通过MimeMessageHelper设置发件人,收件人,标题和内容 setText 加上第二个参数 true 表示支持 html 文本
@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 = mailSender.createMimeMessage();
            MimeMessageHelper helper = new MimeMessageHelper(mimeMessage);
            helper.setFrom(from);
            helper.setTo(to);
            helper.setSubject(subject);
            helper.setText(content, true);
            mailSender.send(helper.getMimeMessage());
        } catch (MessagingException e) {
            logger.error("发送邮件失败" + e.getMessage());
        }
    }

}

测试发送邮件(2个测试方法分别为发送普通文本发送Html文本):

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

    @Autowired
    private MailClient mailClient;

    @Qualifier("templateEngine")
    @Autowired
    private TemplateEngine templateEngine;

    @Test
    public void testTextMail() {
        mailClient.sendMail("zhouweicheng1999@qq.com", "Test", "zwcnb!");
    }

    @Test
    public void testHtmlMail() {
        Context context = new Context();
        context.setVariable("username", "周炜程");
        String content = templateEngine.process("/mail/demo", context);
        mailClient.sendMail("zhouweicheng1999@qq.com", "Html", content);
    }

}

程序根据协议访问了新浪的邮件服务器,把邮件信息提交给服务器,让服务器帮我们发送给对方。

其中生成 Html 文本步骤如下:

  • 新建一个 mail 模板
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>邮件示例</title>
</head>
<body>
    <p>欢迎你,<span style="color:red" th:text="${username}"></span>!</p>
</body>
</html>
  • 自动装配 TemplateEngine 模板引擎
  • 给模板传参用 Context,设置其参数
  • 调用模板引擎的 process 的方法,指定其模板和数据
  • 接受其生成的动态网页即字符串

开发注册功能

一次注册请求拆分成三次请求

第一次请求:用户访问注册页面,服务器接受请求返回注册页面;

第二次请求:用户填写表单,提交给服务器,服务器接受此次请求,调用对应的服务来处理用户提交的数据,如果数据有误(用户名存在,邮箱存在)就返回注册页面让用户重新填写,如果数据无误,则向用户发送一封激活邮件;

第三次请求:用户若数据有误,重复第二次请求;如果无误,用户点击激活邮件,再次访问服务器,服务器对其进行验证,(看用户状态是否是未激活,激活码是否正确)成功则激活,并且返回到登录页面 ,激活码错误或重复激活则返回到首页;

第一次请求:用户访问注册页面

编写 LoginController 来处理请求,返回注册页面

@Controller
public class LoginController implements CommunityConstant {

    @GetMapping("/register")
    public String getRegisterPage() {
        return "/site/register";
    }
}

注意模板文件的路径不要写错。

修改注册页面和主页,使用 Thymeleaf 语法

  • 修改路径,把相对路径用 @{} 包起来
  • 修改 index.html 头部中的首页和注册的路径;提取头部复用,增加 th:fragment 取一个别名,在 register、login 等页面中引用提取的头部(语法如下)
<header class="bg-dark sticky-top" th:fragment="header"...>
<header class="bg-dark sticky-top" th:replace="index::header"...>

第二次请求:用户填写表单

引入 jar 包,生成随机字符串等处理

<dependency>
    <groupId>org.apache.commons</groupId>
    <artifactId>commons-lang3</artifactId>
    <version>3.9</version>
</dependency>

引入这个包主要是后面需要调用 StringUtils.isBlank 方法(判断为 null、空串、空格等都为空)。

修改项目域名,暂时为本地路径(后面再进行修改)

# conmunity
community.path.domain=http://localhost:8080

建立一个工具类,方便生成随机字符串和处理加密的工作

  • MD5 加密因为会生成一个固定的加密后的字符串,不安全,所以采用在用户密码后拼接一些字符串(就是 User 类中的 salt 属性)的方法。
public class CommunityUtil {

    // 生成随机字符串
    public static String generateUUID() {
        return UUID.randomUUID().toString().replaceAll("-", "");
    }

    // MD5加密
    public static String md5(String key) {
        if(StringUtils.isBlank(key)) {
            return null;
        }
        return DigestUtils.md5DigestAsHex(key.getBytes());
    }
}

编写 UserService 对注册业务进行处理

  • 编写注册方法,返回类型为 Map,用来保存处理的结果
  • 如果 user 为空直接抛异常
  • 账号为空或已存在密码为空邮箱为空或已被注册的问题进行判断提示
  • 验证全部通过后,执行注册,设置用户的详细信息。userid 设置的是自增长,不需要手动添加,并且添加成功后 MyBatis 会自动给 Java 中 user 对应的属性(id)赋上值(之前在 application.properties 中已经作了如下配置)
mybatis.configuration.use-generated-keys=true
  • 调用 UserMapper 把用户插入到数据库
  • 然后进行发送激活邮件的流程(具体步骤见上一小节)
  • 最后返回 map
@Service
public class UserService implements CommunityConstant {

    @Autowired
    private UserMapper userMapper;

    @Autowired
    private MailClient mailClient;

    @Autowired
    private TemplateEngine templateEngine;

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

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

    public User findUserById(int id) {
        return userMapper.selectById(id);
    }

    public Map<String, Object> register(User user) {
        Map<String, Object> 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());
        // http://localhost:8080/community/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);

        return map;
    }

}

注意其中域名项目路径从配置文件中获取值,以便后期修改。

编写Controller处理用户提交数据的请求

  • 通过判断 map 是否为空,得知注册是否成功
  • 注册成功则携带信息和跳转路径,跳转至中转页面;失败则携带错误信息(xxxMsg)返回注册页面
@PostMapping("/register")
public String register(Model model, User user) {
    Map<String, Object> map = userService.register(user);

    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";
    }

}

operate-result.html 中主要内容如下(只截取了关键部分)

<!-- 内容 -->
<div class="main">
    <div class="container mt-5">
        <div class="jumbotron">
            <p class="lead" th:text="${msg}">您的账号已经激活成功,可以正常使用了!</p>
            <hr class="my-4">
            <p>
                系统会在 <span id="seconds" class="text-danger">8</span> 秒后自动跳转,
                您也可以点此 <a id="target" th:href="@{${target}}" class="text-primary">链接</a>, 手动跳转!
            </p>
        </div>
    </div>
</div>

注册成功激活成功都会跳转至该页,不过它们各自携带的信息和路径不同。

修改表单数据

  • 修改表单提交路径
<form class="mt-5" method="post" th:action="@{/register}"...>
  • 给每一个 input 框取一个 name,声明数据的名字,name 要和 User 属性名对应,SpringMVC 基于同名原则把值传给 User 类中对应的属性
  • 如果是注册失败返回主页,这时主页就需要显示刚才已经填好的信息;如果是直接访问,就显示空
  • 账号有问题(usernameMsg 不为空)就显示账号错误信息,密码、邮箱同理
  • 错误信息是否显示依靠样式 is-invalid,因此需要动态拼接

以账号举例:

<div class="form-group row">
    <label for="username" class="col-sm-2 col-form-label text-right">账号:</label>
    <div class="col-sm-10">
        <input type="text" th:class="|form-control ${usernameMsg!=null?'is-invalid':''}|"
               th:value="${user!=null?user.username:''}"
               id="username" name="username" placeholder="请输入您的账号!" required>
        <div class="invalid-feedback" th:text="${usernameMsg}">
            该账号已存在!
        </div>
    </div>
</div>

第三次请求:激活注册账号

定义常量接口

public interface CommunityConstant {

    // 激活成功
    int ACTIVATION_SUCCESS = 0;
    // 重复激活
    int ACTIVATION_REPEAT = 1;
    // 激活失败
    int ACTIVATION_FAILURE = 2;

}

编写激活用户 Service 方法

  • 先从数据库中获取该 user 的信息
  • 如果其状态 status==1 表示已经激活,返回重复激活(1)
  • 否则如果激活码正确,更新用户状态为已经激活,返回激活成功(0)
  • 否则返回激活失败(2)
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;
    }
}

编写激活用户 Controller 方法

  • @PathVariable 从路径中获取变量值
  • 调用 Service 的激活方法根据返回值往 model 里放入不同的信息和跳转路径
@GetMapping("/activation/{userId}/{code}")
public String activation(Model model, @PathVariable("userId") int userId, @PathVariable("code") String code) {
    int activation = userService.activation(userId, code);
    if (activation == ACTIVATION_SUCCESS) {
        model.addAttribute("msg", "激活成功,您的账号已经可以正常使用了!");
        model.addAttribute("target", "/login");
    } else if (activation == ACTIVATION_REPEAT) {
        model.addAttribute("msg", "无效操作,该账号已经激活过了!");
        model.addAttribute("target", "/index");
    } else {
        model.addAttribute("msg", "激活失败,您提供的激活码不正确!");
        model.addAttribute("target", "/index");
    }
    return "/site/operate-result";
}

编写方法处理 login 请求

@GetMapping("/login")
public String getLoginPage() {
    return "/site/login";
}

注意点:

  1. 激活成功后别忘了修改数据库中用户的 status 状态为1(已激活)
  2. 域名和项目路径从配置文件中获取,避免写死在 Java 代码中

会话管理

Cookie

https://developer.mozilla.org/zh-CN/docs/Web/HTTP/Cookies

HTTP Cookie(也叫 Web Cookie 或浏览器 Cookie)是服务器发送到用户浏览器并保存在本地的一小块数据,它会在浏览器下次向同一服务器再发起请求时被携带并发送到服务器上。通常,它用于告知服务端两个请求是否来自同一浏览器,如保持用户的登录状态。Cookie 使基于无状态的 HTTP 协议记录稳定的状态信息成为了可能

Cookie 主要用于以下三个方面:

  • 会话状态管理(如用户登录状态、购物车、游戏分数或其它需要记录的信息)
  • 个性化设置(如用户自定义设置、主题等)
  • 浏览器行为跟踪(如跟踪分析用户行为等)
  1. 浏览器第一次向服务器发起请求,服务器接受请求,并且响应头部带上 cookie;
  2. 浏览器接收到服务器的响应,在本地创建 cookie;
  3. 浏览器第二次向服务器发起请求会带上之前的 cookie。

示例代码:

@GetMapping("/cookie/set")
@ResponseBody
public String setCookie(HttpServletResponse response) {
    Cookie cookie = new Cookie("code", CommunityUtil.generateUUID());
    cookie.setPath("/community/alpha");
    cookie.setMaxAge(60 * 10);
    response.addCookie(cookie);
    return "set cookie";
}

@GetMapping("/cookie/get")
@ResponseBody
public String getCookie(@CookieValue("code") String cookie) {
    System.out.println(cookie);
    return "get cookie";
}

可以设置 cookie 的生效路径、超时时间(以秒为单位)。cookie默认存在内存里,浏览器关闭就失效。一旦设置了超时时间,就会存在硬盘里,超时则失效。

另外可以在浏览器 F12 的 Headers 里看到请求头或响应头上携带了 cookie 数据。

Session

Session 存放在服务器端,比存在客户端的 Cookie 更加安全。但是会增加服务器的内存压力。

Session 存在服务器,浏览器对服务器是多对一的关系,怎么对应?依赖 Cookie。将 sessionid 存在 Cookie 里面发送给浏览器,浏览器下次放请求时,也会携带 sessionid,再在服务器中根据 sessionid 找对应的 Session。

示例代码:

@GetMapping("/session/set")
@ResponseBody
public String setSession(HttpSession session) {
    session.setAttribute("name", "zwc");
    session.setAttribute("age", "23");
    return "set session";
}

@GetMapping("/session/get")
@ResponseBody
public String getSession(HttpSession session) {
    System.out.println(session.getAttribute("name"));
    System.out.println(session.getAttribute("age"));
    return "get session";
}

这里测试了一下,先用谷歌浏览器访问了 /session/set 后,再用另一个浏览器(不同的会话)访问 /session/get 路径,打印的值为 null。证明确实是通过 JSESSIONID 来找到对应的 Session。

另外课程里还提到了,分布式环境下一般不用 Session 来记录状态。举个例子,如果浏览器第一次访问负载均衡服务器 Nginx,此时分配给了服务器1来处理,这时服务器1中保存了对应的 Session。等浏览器下次访问时,有可能此时服务器1正忙碌,Nginx 把请求给服务器2,这时服务器2中并没有之前保存的 Session,则状态就丢失了。

针对如上问题,分布式环境下使用 Session 有几种解决方案:

  1. 粘性 Session:在 Nginx 中提供一致性哈希策略,可以保持用户 ip 进行 hash 值计算固定分配到某台服务器上,负载也比较均衡,其问题是假如有一台服务器挂了,这台服务器上的 Session 也丢失了。并且其实 Nignx 不能保证负载均衡。如果此时服务器1的用户大量访问服务器1,使得服务器1比较忙碌,这时因为用户和各个服务器都有一定的绑定关系,Nginx 也不好把他们分配到其它服务器上进行访问(结合老师讲解和自己的理解)。
  2. 同步 Session:当某一台服务器存了 Session 后,同步到其它服务器中,其问题是同步 Session 到其它服务器会对服务器性能产生影响,并且服务器之间耦合性较强,服务器之间应该相对独立。
  3. 共享 Session:单独用一台服务器用来存 Session,其它服务器都向这台服务器获取 Session,性能会有瓶颈。并且如果这台服务器挂了,Session 就全部丢失(相当于还是集中式的问题)。
  4. 把 Session 存到关系型数据库中:因为该类数据库一般都是存储到硬盘,效率不是很高。
  5. Redis 集中管理 Session(主流方法):Redis 为内存数据库,读写效率高,并可在集群环境下做高可用。

生成验证码

导入 jar 包

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

编写 Kaptcha 配置类

  • 通过 @Bean 注解声明一个 Bean,这个 Bean 将会被 Spring 容器所管理、装配。在服务启动的时候就能够被自动的装配到容器里,通过容器就能得到这个实例
  • 注意各个配置的名字不要写错
@Configuration
public class KaptchaConfig {

    @Bean
    public Producer kaptchaProducer() {
        Properties properties = new Properties();
        // 图片宽、高
        properties.setProperty("kaptcha.image.width", "100");
        properties.setProperty("kaptcha.image.height", "50");
        // 字体大小、颜色、包含字符、长度
        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;
    }

}

编写请求返回验证码图片

服务器给客户端返回登录页面时,附带一个图片的路径,然后客户端再次发起获取图片的请求。所以不是直接写在处理 /login 请求的方法(多次请求)

  • 需要把验证码生成的文本保存在服务器,来校验验证码是否输入正确,需要跨请求,因此设置 Session 来保存验证码(后续再使用 Redis 来优化)
  • 先生成验证码文本,再把验证码文本传入生成图片的方法得到验证码图片
  • 将图片输出给浏览器。注意使用字节流
  • 流可以不用手动关闭,Response 是由 SpringMVC 维护的,最终自动会关
@GetMapping("/kaptcha")
public void kaptcha(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());
    }
}

实现刷新效果

这里主要是写的前端的代码

  • 修改图片访问路径,添加 id 属性(方便使用 jQuery 来调用)以及引用 js 方法
<div class="col-sm-4">
    <img th:src="@{/kaptcha}" id="kaptcha" style="width:100px;height:40px;" class="mr-2"/>
    <a href="javascript:refresh_kaptcha();" class="font-size-12 align-bottom">刷新验证码</a>
</div>
  • 使用 jQuery 写一个刷新验证码的方法
  • global.js 中定义一个全局变量项目工程名,方便修改
var CONTEXT_PATH = "/community";
  • "...?p="+Math.random(); 防止浏览器误以为请求路径没变而不发送请求导致无法刷新
<script>
    function refresh_kaptcha() {
        var path = CONTEXT_PATH + "/kaptcha?p=" + Math.random();
        $("#kaptcha").attr("src", path);
    }
</script>

开发登录、退出功能

创建实体类 LoginTicket

为了实现用户可以在多个请求间维持登录状态,服务器可以记住浏览器的用户信息,创建 LoginTicket 表,用户登录后,服务器生成一个 ticket 凭据,同时保存用户的 user_id,通过 user_id 可以进一步查询到用户的详细信息。服务器把 ticket 凭据用 Cookie 返回给浏览器,浏览器下次请求时就会带上 ticket,通过 ticket 就能找到该用户的登录信息。其实这和 Session 的效果差不多,只不过是将用户的登录信息存在了数据库中。后续会进一步优化,使用 Redis 来保存。

user_id 用户的 id 标识

ticket 凭证 随机字符串 唯一标识

status 状态 0:有效 1:失效

expired 凭证过期时间

public class LoginTicket {

    private int id;
    private int userId;
    private String ticket;
    private int status;
    private Date expired;
    
    ...
        
}

编写对应的 LoginTicketMapper

实现三个方法

  1. login_ticket 表中插入数据
  2. 通过 ticket 查询数据
  3. 更新用户凭据状态

也可以通过注解的形式来实现 Mapper 对应的 sql,本次 Mapper 中就通过这种方式实现 sql 语句。

注意,不要忘了加 @Options(useGeneratedKeys = true, keyProperty = "id") 指定表中的 id 为自增长并赋值给实体类中的 id 属性。因为 application.properties 中的自增主键的配置对注解形式的 sql 是无效的,所以必须要加。

注解中也可以使用 <if> 判断,不过要麻烦点(需要加一对 <script> 标签指明其为脚本),具体见下(注意 <script></script> 把整个 sql 语句包含起来):

@Mapper
public interface LoginTicketMapper {

    @Insert({
            "insert into login_ticket(user_id,ticket,status,expired) ",
            "values(#{userId},#{ticket},#{status},#{expired})"
    })
    @Options(useGeneratedKeys = true, keyProperty = "id")
    int insertLoginTicket(LoginTicket loginTicket);

    @Select({
            "<script>",
            "select id,user_id,ticket,status,expired ",
            "from login_ticket where ticket=#{ticket} ",
            "<if test=\"ticket!=null\">",
            "and 1=1",
            "</if>",
            "</script>"
    })
    LoginTicket selectByTicket(String ticket);

    @Update({
            "update login_ticket set status=#{status} ",
            "where ticket=#{ticket}"
    })
    int updateStatus(@Param("ticket") String ticket, @Param("status") int status);

}

编写 Service 层处理登录业务

和注册的业务差不多,先是判断各种验证不通过的情况。确认没问题后,生成登录凭证,别忘了把 ticket 放到 map 里返回给表现层,最终要通过 Cookie 返回给浏览器。

    public Map<String, Object> login(String username, String password, long expiredSeconds) {
        Map<String, Object> 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() + expiredSeconds * 1000));
        loginTicketMapper.insertLoginTicket(loginTicket);

        map.put("ticket", loginTicket.getTicket());
        return map;
    }

这里评论区说要把 expiredSeconds 设置为 long 类型,因为乘上 1000 后会溢出。

编写 Controller 层处理请求

请求路径可以相同,此时方法要不同,例如 get 和 post。这里 /login 的 get 请求返回登录页面,post 请求处理登录流程。

  • 先比较验证码是否正确(注意要忽略大小写equalsIgnoreCase)。老师说到这是为了提高性能,尽量减少访问数据库。验证码不正确就回到登录页面
  • 再检查账号和密码是否正确,调用 Service 层的方法,根据传回的 map 中是否 containsKey("ticket") 判断是否登录成功
  • 如果登录成功,把 ticket 通过 Cookie 传给浏览器
  • 根据用户是否选择记住我来选择超时时间。这里在常量类中再定义 2 个常量,如下:
  • 登录成功最后要重定向到首页
// 默认状态的登录凭证的超时时间
int DEFAULT_EXPIRED_SECONDS = 3600 * 12;

// 记住状态的登录凭证的超时时间
int REMEMBER_EXPIRED_SECONDS = 3600 * 24 * 100;

处理请求的方法如下:

@PostMapping("/login")
public String login(String username, String password, String code, boolean rememberme,
                    Model model, HttpServletResponse response, HttpSession session) {
    String kaptcha = (String) session.getAttribute("kaptcha");
    if(StringUtils.isBlank(kaptcha) || StringUtils.isBlank(code) || !kaptcha.equalsIgnoreCase(code)) {
        model.addAttribute("codeMsg", "验证码不正确!");
        return "/site/login";
    }
    int expiredSeconds = rememberme ? REMEMBER_EXPIRED_SECONDS : DEFAULT_EXPIRED_SECONDS;
    Map<String, Object> map = userService.login(username, password, expiredSeconds);

    if(!map.containsKey("ticket")) {
        model.addAttribute("usernameMsg", map.get("usernameMsg"));
        model.addAttribute("passwordMsg", map.get("passwordMsg"));
        return "/site/login";
    }

    Cookie cookie = new Cookie("ticket", map.get("ticket").toString());
    cookie.setPath(contextPath);
    cookie.setMaxAge(expiredSeconds);
    response.addCookie(cookie);

    return "redirect:/index";
}

重定向默认是 get 请求。

修改页面

和 register.html 中类似。

  1. 修改表单提交路径和 post 方式
  2. 给每个 <input> 标签添加 name 属性,值和类的属性名一一对应
  3. 如果有错误返回登录页面,需要默认显示之前填写的值
  4. <input> 标签中的 class 根据是否有错误而选择是否添加 is-invalid,并且下面的 div 中要显示错误信息

“记住我”的标签中,需要修改 th:checked="${param.rememberme}"。这里通过 true 或者 false 也能判断是否勾中,true 即 checked

给出账号标签的代码示例:

<div class="form-group row">
    <label for="username" class="col-sm-2 col-form-label text-right">账号:</label>
    <div class="col-sm-10">
        <input type="text" th:class="|form-control ${usernameMsg!=null?'is-invalid':''}|"
               th:value="${param.username}"
               id="username" name="username" placeholder="请输入您的账号!" required>
        <div class="invalid-feedback" th:text="${usernameMsg}">
            该账号不存在!
        </div>
    </div>
</div>

需要特别说明:上面的 Controller 层的 login 方法的参数中,如果不是类似 User 那种对象类型,而是这种普通参数,如 String 或其它基本类型,SpringMVC 不会把它们存到 Model 里。因此无法直接获取到 username 等值

一般有两种方法可以获取到:

  1. 手动将这些值放入 Model
  2. 因为是请求携带的参数,是存在 request 中的,thymeleaf 支持通过 param.username 获取到(相当于 request.getParameter(username) )。当程序执行到 html 时,request 还没有销毁,请求还没有结束,所以在页面上也可以通过 request 取到值

退出功能

退出功能很简单,就是将状态 status 改成 1。不过注意别忘了修改 index.html 中的 <header> 中的“退出登录”的超链接地址为 @{/logout}。

以下分别为 Service 层和 Controller 层中的退出功能的方法:

public void logout(String ticket) {
    loginTicketMapper.updateStatus(ticket, 1);
}

@CookieValue 别忘了加

@GetMapping("/logout")
public String logout(@CookieValue("ticket") String ticket) {
    userService.logout(ticket);
    return "redirect:/login";
}

注意点:

  1. 验证密码时同样要和 salt 拼接后通过 md5 加密后再和数据库中的 password 比较
  2. 超时时间在计算毫秒时注意溢出的问题
  3. ticket 在 Service 层中存到 Map 里返回给 Controller 层,再通过 Cookie 传给浏览器
  4. 验证验证码是否正确的时候,因为之前存到了 Session 里,同样要加上 HttpSession 参数,从中取之前存的验证码
  5. Controller 层的退出方法别忘了加 @CookieValue 注解
  6. 注意何时使用重定向和转发

此外,我在测试时也想到了这个问题。如果一个用户退出登录后,再次登录,那 login_ticket 表中就又多了一条该用户的记录,而不是把之前记录中的状态从 1 改成 0。后来看了评论区,原因如下:

显示登录信息

拦截器示例

  • 定义拦截器实现 HandlerInterceptor 接口,并实现其三个方法
  • preHandle:在 Controller 之前执行,return false 表示取消本次请求
  • postHandle:在 Controller 之后执行,TemplateEngine 之前执行
  • afterCompletion:在 TemplateEngine 之后执行
@Component
public class AlphaInterceptor implements HandlerInterceptor {

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

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
        logger.debug("pre: " + handler.toString());
        return true;
    }

    @Override
    public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) {
        logger.debug("post: " + handler.toString());
    }

    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) {
        logger.debug("after: " + handler.toString());
    }
}

其中 handler 是拦截的目标,在这里也就是 getLoginPage() 方法。

配置拦截器,指定排除的路径或的路径:

  • addInterceptor():添加要配置的拦截器
  • excludePathPatterns():排除不要拦截的路径,多是静态资源
  • addPathPatterns():添加需要拦截的路径

默认拦截所有路径,可以加上 excludePathPatterns 来排除不拦截的路径。

这里我进行了测试,如果只写 excludePathPatterns,则除了指定的路径都拦截。加上 addPathPatterns 后,只拦截其中指定的路径,其它路径都不拦截(估计是使得默认拦截所有路径失效了)

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

拦截器应用

登录的一次请求过程

这里省略了查到 ticket 后再次访问 user 表查到用户信息的步骤。

编写一个工具类从请求中来获取 Cookie 的值

因为是实现拦截器的接口(规范),不能随便传入其它参数,所以只能从原生的 request 对象中获取。

为了方便,定义了一个工具类来获取指定的 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 来保存用户信息,可以使得线程之间是隔离的,并且该线程运行期间可以随时随处获取到,比较方便。用户发来的每一次请求启动的线程都会保存用户的信息,当请求结束,保存的用户信息会被清除掉。

这里不在 request 中保存用户信息的原因如下:

@Component
public class HostHolder {

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

    public void setUser(User user) {
        users.set(user);
    }

    public User getUser() {
        return users.get();
    }

    public void clear() {
        users.remove();
    }

}

我对这个类的思考是,因为 Spring 默认每个类都是单例的,容器里只有一份对象,所以这里的 ThreadLocal<User> 也只有一份。但因为每个线程都维护了一个 ThreadLocalMap,那么内部结构应该如下所示。虽然 ThreadLocal 只有一份,但是 Map 有多个,不同的 Map 虽然 Key 相同,但是 Value(User)不同。(纯个人理解,不确保正确)

编写 LoginTicketInterceptor 拦截器

  • preHandle():首先从 Cookie 中获取凭证,如果有 ticket,查询凭证并验证是否有效。有效则获取该用户信息,并存到 ThreadLocal 里
  • postHandle():获取之前保存的用户信息,存到 ModelAndView 里(这里注意要判断 ModelAndView 是否为 null,否则会报空指针异常)
  • afterCompletion():最后清除用户登录信息
@Component
public class LoginTicketInterceptor implements HandlerInterceptor {

    @Autowired
    private HostHolder hostHolder;

    @Autowired
    private UserService userService;

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
        // 从Cookie中获取凭证
        String ticket = CookieUtil.getValue(request, "ticket");

        if(ticket != null) {
            // 查询凭证
            LoginTicket loginTicket = userService.findLoginTicket(ticket);
            // 验证凭证是否有效
            if(loginTicket != null && loginTicket.getStatus() == 0 & loginTicket.getExpired().after(new Date())) {
                // 根据凭证查询用户
                User user = userService.findUserById(loginTicket.getUserId());
                // 从本次请求中持有用户
                hostHolder.setUser(user);
            }
        }

        return true;
    }

    @Override
    public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) {
        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) {
        hostHolder.clear();
    }
}

配置拦截器,指定拦截路径 / 排除路径

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

修改页面

因为之前 <header> 便签进行了复用,所以只需要修改 index.html 即可。

主要就是设置登录时显示“消息”和用户信息,未登录时显示“注册”和“登录”。然后修改用户头像及用户名为从之前存到 ModelAndView 里的 loginUser 中获取。

<!-- 功能 -->
<div class="collapse navbar-collapse" id="navbarSupportedContent">
    <ul class="navbar-nav mr-auto">
        <li class="nav-item ml-3 btn-group-vertical">
            <a class="nav-link" th:href="@{/index}">首页</a>
        </li>
        <li class="nav-item ml-3 btn-group-vertical" th:if="${loginUser!=null}">
            <a class="nav-link position-relative" href="site/letter.html">消息<span class="badge badge-danger">12</span></a>
        </li>
        <li class="nav-item ml-3 btn-group-vertical" th:if="${loginUser==null}">
            <a class="nav-link" th:href="@{/register}">注册</a>
        </li>
        <li class="nav-item ml-3 btn-group-vertical" th:if="${loginUser==null}">
            <a class="nav-link" th:href="@{/login}">登录</a>
        </li>
        <li class="nav-item ml-3 btn-group-vertical dropdown" th:if="${loginUser!=null}">
            <a class="nav-link dropdown-toggle" href="#" id="navbarDropdown" role="button" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
                <img th:src="${loginUser.headerUrl}" class="rounded-circle" style="width:30px;"/>
            </a>
            <div class="dropdown-menu" aria-labelledby="navbarDropdown">
                <a class="dropdown-item text-center" href="site/profile.html">个人主页</a>
                <a class="dropdown-item text-center" href="site/setting.html">账号设置</a>
                <a class="dropdown-item text-center" th:href="@{/logout}">退出登录</a>
                <div class="dropdown-divider"></div>
                <span class="dropdown-item text-center text-secondary" th:utext="${loginUser.username}">nowcoder</span>
            </div>
        </li>
    </ul>

注意点:

  1. 拦截器的拦截路径
  2. ThreadLocal 的使用与其原理
  3. 需要判断 ModelAndView 是否为空,否则会报空指针异常(这里实际测试时发现验证码无法正常显示,根据 kaptcha 方法的源码(返回类型为 void,并且没用到 Model),个人判断是因为该方法不走模板引擎,直接用原生的 request 和 response,但是拦截器也对该方法进行了拦截,调用了 ModelAndView。这边可能就没初始化该对象,所以为 null)

账号设置

编写 UserController 处理设置请求

访问账号设置页面

@GetMapping("/setting")
public String getSettingPage() {
    return "/site/setting";
}

上传头像

图片可以保存至本地服务器,也可以保存至云服务器(后面再讲)。

这里保存图片路径暂时设置为本地服务器路径。为了方便后面进行更改,在 application.properties 中进行配置

community.path.upload=D:/Java/project/community/upload

MultipartFile 是表现层对象。如果放到业务层处理,会导致表现层和业务层产生耦合。因此在 Controller 中处理上传操作。

Service 层只进行头像路径的修改操作:

public int updateHeader(int userId, String headerUrl) {
    return userMapper.updateHeader(userId, headerUrl);
}

如果上传了多个文件,则使用 MultipartFile 数组。这里只有一个文件

保存到数据库中的图片路径应使用 web 路径

@PostMapping("/upload")
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访问路径)
    String headerUrl = domain + contextPath + "/user/header/" + fileName;
    User user = hostHolder.getUser();
    userService.updateHeader(user.getId(), headerUrl);

    return "redirect:/index";
}

"/user/header/" 中的最后一个 / 别忘了加。

获取头像

这里也需要获取文件后缀名,来设置 response 中的返回内容的类型。

@GetMapping("/header/{fileName}")
public void getHeader(@PathVariable("fileName") String fileName, HttpServletResponse response) {
    // 文件后缀
    String suffix = fileName.substring(fileName.lastIndexOf("."));
    // 响应图片
    response.setContentType("image/" + suffix);
    // 服务器存放路径
    fileName = uploadPath + "/" + fileName;

    try (
        FileInputStream fis = new FileInputStream(fileName);
        OutputStream os = response.getOutputStream();
    ) {
        byte[] buffer = new byte[1024];
        int b;
        while ((b = fis.read(buffer)) != -1) {
            os.write(buffer, 0, b);
        }
    } catch (IOException e) {
        logger.error("读取头像失败:" + e.getMessage());
    }
}

同样注意别漏加 /。

setting.html 中也进行修改:

必须是 POST 请求,enctype 必须为 multipart/form-data

<form class="mt-5" method="post" enctype="multipart/form-data" th:action="@{/user/upload}">
    <div class="form-group row mt-4">
        <label for="head-image" class="col-sm-2 col-form-label text-right">选择头像:</label>
        <div class="col-sm-10">
            <div class="custom-file">
                <input type="file" th:class="|custom-file-input ${error!=null?'is-invalid':''}|"
                       name="headerImage" id="head-image" lang="es" required="">
                <label class="custom-file-label" for="head-image" data-browse="文件">选择一张图片</label>
                <div class="invalid-feedback" th:text="${error}">
                    图片错误
                </div>
            </div>
        </div>
    </div>
    <div class="form-group row mt-4">
        <div class="col-sm-2"></div>
        <div class="col-sm-10 text-center">
            <button type="submit" class="btn btn-info text-white form-control">立即上传</button>
        </div>
    </div>
</form>

注意 name="headerImage" 与 Controller 中对应方法的对应参数名要相同。

注意点:

  1. 拼路径的时候不要漏斜杠 "/"!!!
  2. 标签中 name 的值与方法参数 / 类的属性名一一对应

检查登录状态

虽然之前的课程中已经实现了登录与否,头部是否显示相应的标签的功能。但是如果用户知道某些请求的访问路径,如设置 "/setting"、上传头像"/upload",那么用户还是能在未登录的情况下输入这些路径访问到。这显然是不允许的。因此本次课实现了对未登录用户的某些请求的拦截。显然还是用拦截器。

但是如果还和之前一样每多一个请求方法就去配置类中配置是否拦截该路径,显然比较麻烦,所以本次课采用自定义注解的方式来实现该功能。只要在需要拦截的方法上加一个 @LoginRequired 注解,就会对该方法进行拦截。

这里再简单介绍一下 4 个元注解。详细内容可以再去复习 SE 的课程。

  • @Target:自定义注解的作用域
  • @Retention:生效时间,编译还是运行等
  • @Document:这个注解应该被 javadoc 工具记录
  • @Inherited:如果某个类上的某个注解使用 @Inherited 修饰,则该类的子类将自动继承该注解。

创建自定义注解

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface LoginRequired {
}

在需要拦截的方法上加上该注解

目前只有访问设置页面 "/setting" 和上传用户头像 "/upload" (以及修改密码)的方法上需要加 @LoginRequired。

获取用户头像 "/header/{fileName}" 的请求方法上不需要加,因为未登录状态下也需要能够看到其他用户的头像。

定义拦截器

需要先判断拦截的请求是不是方法,有可能是其它静态资源。

获取 @LoginRequired 注解后,如果不为 null(说明方法上加了该注解),并且此时 User 为 null(未登录),就拦截该请求,并重定向到登录页面。

@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);
            if(loginRequired != null && hostHolder.getUser() == null) {
                response.sendRedirect(request.getContextPath() + "/login");
                return false;
            }
        }
        return true;
    }
}

配置拦截器

@Configuration
public class WebMvcConfig implements WebMvcConfigurer {

    @Autowired
    private AlphaInterceptor alphaInterceptor;

    @Autowired
    private LoginTicketInterceptor loginTicketInterceptor;

    @Autowired
    private LoginRequiredInterceptor loginRequiredInterceptor;

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(alphaInterceptor)
                .addPathPatterns("/login", "register");

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

        registry.addInterceptor(loginRequiredInterceptor)
                .excludePathPatterns("/**/*.css", "/**/*.js", "/**/*.jpg", "/**/*.png", "/**/*.jpeg");
    }
}
posted @ 2022-05-16 18:59  幻梦翱翔  阅读(648)  评论(0)    收藏  举报