Fms-Java版开发实录:08 用户注册功能 - VO编写及表单验证

Fms-Java版开发实录:08 用户注册功能 - VO编写及表单验证

最近看完了《美国众神》这本小说,相信对于程序员来说,一定存在一个解耦之神。

这次把VOBOPO都用上了,体现了分层解耦的思想:

  1. 用户提交数据和控制器之间通过VO交换数据
  2. 控制器和业务层之间通过BO交换数据
  3. 业务层和持久层之间通过PO交换数据

这两天在看《美国众神》,相信对于程序员来说,一定存在一个解耦之神。

VO类的编写

VO类仅仅用于控制器中,用于获取用户的输入。对于用户注册来说,我们希望获得用户名、电子邮件、密码和确认密码这四个字段,而且都需要有验证。先把UserRegistration类写起来:

package cc.conyli.fms.dataobject.valueobject;

import org.hibernate.validator.constraints.Length;
import javax.validation.constraints.Email;
import javax.validation.constraints.NotEmpty;

public class UserRegistration {

    @NotEmpty(message = "用户名不能为空")
    @Length(max = 150, message = "用户名长度最多为150个字符")
    private String username;

    @NotEmpty(message = "电子邮件不能为空")
    @Email(message = "电子邮件格式不正确")
    private String email;
    
    @NotEmpty(message = "密码不能为空")
    @Length(max = 128, message = "密码最长为128位")
    private String password1;

    @NotEmpty(message = "密码不能为空")
    @Length(max = 128, message = "密码最长为128位")
    private String password2;

    ......
}

可以看到,这四个字段都通过引入Validator进行了一些基础的的验证,很显然还需要一些额外的验证,比如用户是否已经存在,以及两个密码是否相等,这些工作可以放到控制器中去做,其实也可以编辑自行设置的Validator,不过目前看来还不需要。

用户从前端POST过来的数据,就会被组装成一个经过验证的UserRegistration对象,控制器再将这个对象转换成UserData交给后端,所以控制器的逻辑非常重要。

控制器和页面的编写

控制器对应的路径是/account/register,对这个路径的GET请求将返回一个空白表单,而POST请求就会执行注册动作。先来编写比较简单的GET请求。

GET请求的控制器

AccountController中新添加一个方法:

@GetMapping("/register")
public String registerPage(@ModelAttribute("userRegistration") UserRegistration userRegistration, Model model) {
    model.addAttribute("title", "用户注册");
    return "/account/register";
}

这个方法很简单,就是往视图传递了一个title属性和一个新的空的UserRegistration对象,其键名是@ModelAttribute注解中的参数userRegistration。接下来我们需要来编写用户注册页面,也就是templates/account/register.html

用户注册页面

用户注册页面,依然准备采用与之前login.html相同的中央布局,不过这次需要简单的修改一下headforLoginandLogout这个fragment中的样式:

.form-signin input[type="text"] {
    margin-bottom: -1px;
    border-bottom-right-radius: 0;
    border-bottom-left-radius: 0;
}

.form-signin input[type="password"] {
    margin-bottom: -1px;
    border-top-left-radius: 0;
    border-top-right-radius: 0;
}

.form-signin input[type="email"] {
    margin-bottom: -1px;
    border-top-left-radius: 0;
    border-top-right-radius: 0;
}

这些样式可以让那些input框都集中在一起。接下来的关键就是使用Thymeleaf来渲染页面及对应的错误:

<!doctype html>
<html lang="zh" xmlns:th="http://www.thymeleaf.org">
<head th:replace="/shared/base::headforLoginandLogout(${title})"></head>
<body class="text-center">

<main class="form-signin">
    <form action="/account/auth" th:action="@{/account/register}" method="post" th:object="${userRegistration}">
        <h1 class="h3 mb-3 fw-normal">用户注册</h1>

        <div class="form-floating">
            <input type="text" class="form-control" th:field="*{username}" id="username"
                   placeholder="用户名">
            <label for="username">用户名</label>
            <div th:if="${#fields.hasErrors('username')}" class="alert alert-danger" role="alert" th:errors="*{username}"></div>
        </div>
        <div class="form-floating">
            <input type="email" class="form-control" th:field="*{email}" id="email"
                   placeholder="电子邮件">
            <label for="email">电子邮件</label>
            <div th:if="${#fields.hasErrors('email')}" class="alert alert-danger" role="alert" th:errors="*{email}"></div>
        </div>
        <div class="form-floating">
            <input type="password" class="form-control" th:field="*{password1}" id="password1"
                   placeholder="密码">
            <label for="password1">密码</label>
            <div th:if="${#fields.hasErrors('password1')}" class="alert alert-danger" role="alert" th:errors="*{password1}"></div>
        </div>
        <div class="form-floating">
            <input type="password" class="form-control" th:field="*{password2}" id="password2"
                   placeholder="确认密码">
            <label for="password2">确认密码</label>
            <div th:if="${#fields.hasErrors('password2')}" class="alert alert-danger" role="alert" th:errors="*{password2}"></div>
        </div>
        <p class="mt-2 mb-2 text-muted">注册后请<a href="mailto:liyiming@sinochem.com"
                                                style="text-decoration: none">联系管理员</a>激活账号。</p>
        <button class="w-100 btn btn-lg btn-primary mt-3" type="submit">提交</button>
        <p class="mt-3 mb-3 text-muted">Copyrights &copy;2021
            <a href="https://conyli.cc" target="_blank" style="text-decoration: none">https://conyli.cc</a>
        </p>
        <p class="mt-3 mb-3 text-muted">All Rights Reserved</p>
    </form>
</main>


</body>
</html>

这个页面除了引入了之前的fragment之外,最重要的是form标签中使用了Thymeleaf引入对象的语法th:object="${userRegistration}",在form标签中引入之后,form标签内部就可以使用类似于th:field="*{username}",直接取userRegistration.username的值交给th:field渲染,其效果是给input标签添加上name="username"id="username"value=${username}三个属性。

下边一行用来显示错误信息,th:if="${#fields.hasErrors('username')}"中的#fields.hasError('username')表示判断当前页面的BindingResult中是否有username域对应的错误对象,如果有就通过th:errors="*{username}"将其渲染出来,另外三个字段也是如此。写到这里,很显然需要编写POST请求对应的控制器了。

POST请求的控制器

处理POST请求的控制器的业务逻辑是先验证数据,如果符合要求,则应当组装UserData对象交给持久层进行存储,这其中还有一些细节,我们一步一步来。

验证表单

验证表单需要在请求方法的参数中使用@Valid注解,如下:

@PostMapping("/register")
public String register(@ModelAttribute("userRegistration") @Valid UserRegistration userRegistration, BindingResult rs, Model model) {
    if (rs.hasErrors()) {
        model.addAttribute("title", "请确认注册信息");
        return "/account/register";
    } else {
        ......
    }
}

@ModelAttribute("userRegistration")的用法已经很熟悉了,就是从请求中组装UserRegistration对象,前边加上@Valid表示启用验证功能,在每个被@Valid修饰的参数之后,马上要跟上一个BindingResult参数用于接收验证结果,这二者必须如此搭配,中间不能再插入其他参数。

之后判断如果有错误,由于@ModelAttribute("userRegistration")已经把userRegistration放入到视图中,这里只需要更改一下title属性即可。

else表示数据通过验证,之后的业务逻辑就要稍微复杂点了。大概要分为这么几步:

  1. 两个密码是否相等,如果不相等,需要返回让用户再填写
  2. 密码相等的情况下,检测用户是否已经存在,如果已经存在,返回原来页面让用户再填写
  3. 如果都没有问题,默认给用户组装上Role=USER,然后进行持久化。

下边就来一点一点编写:

验证两个密码是否相同

这个比较简单,就是直接比较两个字段:

@PostMapping("/register")
public String register(@ModelAttribute("userRegistration") @Valid UserRegistration userRegistration, BindingResult rs, Model model) {
    if (rs.hasErrors()) {
        model.addAttribute("title", "请确认注册信息");
        return "/account/register";
    } else {
        if (!userRegistration.getPassword1().equals(userRegistration.getPassword2())) {
            model.addAttribute("passwordError", "请输入相同的密码");
            model.addAttribute("title", "请确认注册信息");
            return "/account/register";
        } else {
            ......
        }
    }
}

很显然,页面里要增加用来判断passwordError是否存在并渲染错误信息的标签:

<div th:if="${passwordError}" class="alert alert-danger" role="alert" th:text="${passwordError}"></div>

验证用户是否已经存在

还记得之前在UserDao中的existsByUsername(String username)方法吗,我们在UserService中添加一个方法对应这个方法:

@Service
public class UserService implements UserDetailsService {
    @Transactional
    public boolean isUserExistsByUsername(String username) {
        return userDao.existsByUsername(username);
    }
}

然后就可以继续编写控制器:

@PostMapping("/register")
public String register(@ModelAttribute("userRegistration") @Valid UserRegistration userRegistration, BindingResult rs, Model model) {
    if (rs.hasErrors()) {
        model.addAttribute("title", "请确认注册信息");
        return "/account/register";
    } else {
        // 密码是否相同
        if (!userRegistration.getPassword1().equals(userRegistration.getPassword2())) {
            model.addAttribute("passwordError", "请输入相同的密码");
            model.addAttribute("title", "请确认注册信息");
            return "/account/register";
        } else {
            // 用户是否已经存在
            if (userService.isUserExistsByUsername(userRegistration.getUsername())) {
                model.addAttribute("userExists", "该用户已经存在");
                model.addAttribute("title", "请确认注册信息");
                return "/account/register";
            } else {
                //完全没有问题了,组装UserData对象并且持久化,默认新注册用户就是USER ROLE,而且enabled=false
                UserData userData = assembleUserData(
                        userRegistration.getUsername(),
                        userRegistration.getPassword1(),
                        "USER",
                        userRegistration.getEmail());
                System.out.println("持久化之前:" + userData);
                userService.saveUserData(userData);
                System.out.println("持久化之后:" + userData);
                //注册成功后跳转到登录页面
                return "redirect:/account/login";
            }
        }
    }
}

虽然代码多了点,但逻辑很清晰,如果都没有问题,就组装一个UserData对象,交给业务层进行持久化,VO是根本不会和业务层发生关系的。

这里还需要在页面中添加上用户已经存在的错误信息:

<div th:if="${userExists}" class="alert alert-danger" role="alert" th:text="${userExists}"></div>

这样就制作好了用户注册的功能。如果想的话,其实还可以给用户的密码加上正则表达式的验证功能,以此让用户输入强密码,不过目前已经可以了。

剩下的小修改如下:

  1. 在登录页面加上跳转到注册页面的链接,以让用户可以方便的注册
  2. 注册页面的input标签上加上maxlengthrequired属性,让浏览器先在前端帮一下忙
  3. 控制器里添加逻辑,在已经登录的情况下,用户访问注册页面,跳转到首页。

最后放一张注册界面的图:
fms-register

LICENSED UNDER CC BY-NC-SA 4.0
Comment