用户相关的URL已经编写完了4个,这次继续来编写/account/register,也就是用户注册功能。不过首先还是看一下已经编写好的界面吧:
目前界面
登录页面:

登录失败页面:

登出页面:

虽然没什么图片和炫酷设计,不过对于一个内部工具来说足够了。
下边继续编写用户注册功能,这一套编写好之后,其实未来的项目也可以直接拿过来使用。
数据对象设计
到目前为止没有用户注册功能,你可能会问猪哥是怎么登陆成功的,当然是我手工用Bcyrpt把密码塞到数据库里的,所以才能通过验证。编写好注册功能之后就不需要这么办了。
用户注册功能其实与其他的读写数据库功能没有什么不同,也是一个完整的CRUD操作,在此之前,还是需要好好设计一下,先来了解一下各种数据对象。
关于各种数据对象,我在CSDN上找了一张图:

原文里写了各种数据对象的定义,我就不一一贴过来了。
针对用户注册,这里也需要先简单的设计一下这些类:

最下边是三个PO类,由于User有外键连接到另外两个表,所以通过获取User对象就可以得到已经存放在数据库内的某个用户的全部信息。目前获取用户的接口是UserAuthService,看来可以将其名称修改成UserService了。
然后从前端来看,目前用户提交的数据应该包含username,password1,password2,email,这个很显然需要一个VO来验证并且存储经过验证之后的数据,就用UserRegistration作为这个VO。
经过验证之后的数据,如果是新用户,那么还需要一个BO对象,这个对象应该是一个新的用户,其中应该包含一个新建的User对象,一个新建的UserInfo对象,以及已经确定的已经存在的List<Role>对象,即用户相关的全部内容,最后交给UserService来进行持久化。反过来说,UserService也需要返回一个UserData对象,未来在修改用户信息等场景的时候,就可以传递UserData对象给控制器。
完善PO类 - 编写UserInfo类
目前已经有了User和Role这两个Entity类,也就是属于这里的PO类,即直接可以对应数据库记录的类。这两个类还需要实现Serializable接口,并且添加一个serialVersionUID如下:
private static final long serialVersionUID = 1L;
我将User类的serialVersionUID设置为1L,Role为2L。
之后为了取回密码等操作,实际上我打算还需要获取用户的电子邮箱地址,甚至未来可能还需要获取用户的其他信息,但是又不想增加User表的内容,那么这里可以再添加一个PO类,即UserInfo,让其与User是强力一对一的关系,二者同生共死,以后有额外的非关键信息,都可以添加在UserInfo表中。
所以我们还需要第三个类,就是UserInfo类,与User一对一的关系,目前就先在其中只添加一个字段email:
package cc.conyli.fms.entity;
import javax.persistence.*;
import javax.validation.constraints.Email;
import javax.validation.constraints.NotEmpty;
import java.io.Serializable;
import java.util.Objects;
@Entity
@Table(name="userinfo")
public class UserInfo implements Serializable {
private static final long serialVersionUID = 3L;
@Id
@GeneratedValue(strategy = GenerationType.SEQUENCE)
@Column(name = "id")
private Integer id;
@NotEmpty(message = "邮箱不能为空")
@Column(name = "email",nullable = false)
@Email(message = "邮箱格式不正确")
private String email;
public UserInfo() {
}
public UserInfo(String email) {
this.email = email;
}
public String getEmail() {
return email;
}
public void setEmail(String email) {
this.email = email;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
UserInfo userInfo = (UserInfo) o;
return id.equals(userInfo.id);
}
@Override
public int hashCode() {
return Objects.hash(id);
}
@Override
public String toString() {
return "UserInfo{" +
"id=" + id +
", email='" + email + '\'' +
'}';
}
}
完善PO类 - User类中添加映射关系
然后在User中,需要添加一个OneToOne映射对应UserInfo类。Hibernate映射一对一的方式有很多,我们这里采用比较简单的,给User额外添加一个外键列的方式,也即User是主表,UserInfo是从表,二者的一对一关系主要由User表来控制。由于我们要求每个用户至少要提供电子邮件地址,也就是说这个外键列不能为null,而且必须unique,以保证一对一关系。修改User类添加一个新的属性:
@OneToOne(fetch = FetchType.LAZY,cascade = CascadeType.ALL, optional = false)
@JoinColumn(unique = true)
private UserInfo userInfo;
Hibernate的@OneToOne注解默认的FetchType是EAGER,由于不是在全部情况下都需要UserInfo的数据,比如验证的时候就不需要,只有在修改或者重置密码的时候才需要,所以将其设置为LAZY;由于二者是强的一对一关系,所以把cascade设置为ALL;optional表示这一列不能为空。下边用@JoinColumn注解表示这是外键列,设置unique属性,这样就完成了外键列的设置。我的旧博客里关于Hibernate一对一映射的总结还是相当有用的。
再给User类补上Getter和Setter方法,就完成了目前三个PO类的准备工作。此时必须把数据库里User、Role表都清空或者干脆删除,再重新启动项目,Hibernate就会自动创建表格。如果不清空,Hibernate会在添加外键列的时候报错,因为添加列的时候无法满足约束条件。在重新建表之后,可以手工塞入几个数据来测试一下,我目前的设计是:Role暂时分为ADMIN,SUPERUSER,USER三种。等编写到具体功能的时候再想想看如何对应这些角色。
继续往下之前,因为我们开始区分了PO,VO,BO,就重新整理一下包,目前所有的PO位于cc.conyli.fms.entity包下,我们创建cc.conyli.fms.dataobject包,然后在其中创建entity包用于存放PO,创建valueobject存放VO类,businessobject存放BO类。利用IDEA的Refactor功能就可以很方便的完成。
编写BO类
在cc.conyli.fms.dataobject包中创建businessobject包,然后创建UserData类,这个类主要由业务层来使用,负责把数据传递给控制器层或者将其持久化。
这个类实际上就是对User及其相关信息的一个封装,而且是一次性将全部信息都取出来。与目前UserService.loadUserByUsername(String s)返回的User不同,后者由于是懒加载,并不需要加载UserInfo信息。编写如下:
package cc.conyli.fms.dataobject.businessobject;
import cc.conyli.fms.dataobject.entity.Role;
import cc.conyli.fms.dataobject.entity.User;
import cc.conyli.fms.dataobject.entity.UserInfo;
import java.util.List;
import java.util.Objects;
import java.util.stream.Collectors;
public class UserData {
private final User user;
private final List<Role> roles;
private final UserInfo userInfo;
public UserData(User user, List<Role> roles, UserInfo userInfo) {
this.user = user;
this.roles = roles;
this.userInfo = userInfo;
}
public String getUsername() {
return user.getUsername();
}
public String getPassword() {
return user.getPassword();
}
public List<Role> getRoles() {
return roles;
}
public List<String> getRoleNames() {
return roles.stream().map(Role::getRole).collect(Collectors.toList());
}
public String getEmail() {
return userInfo.getEmail();
}
public User getUser() {
return user;
}
public UserInfo getUserInfo() {
return userInfo;
}
@Override
public String toString() {
return "UserData{" +
"user=" + user +
", roles=" + roles +
", userInfo=" + userInfo +
'}';
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
UserData userData = (UserData) o;
return user.equals(userData.user);
}
@Override
public int hashCode() {
return Objects.hash(user);
}
}
这个类设计成不可变的类,要么由业务层从数据库中取出装配好,要么由控制器通过数据验证之后,交给业务层进行持久化。判断两个UserData是否相同的标准是其中包含的User类是否相同,而最终还是判断用户id是否相同。
业务层添加组装UserData的方法
目前的UserService类有一个方法,返回实现了UserDetails接口的User类对象,专门用于配合Spring Security进行验证,现在给这个业务类添加属于我们自己的方法了:
@Transactional
public UserData getUserDataByUsername(String username) throws RuntimeException {
User user = userDao.findByUsername(username);
if (user == null) {
throw new RuntimeException("用户不存在");
} else {
return new UserData(user, user.getRoles(), user.getUserInfo());
}
}
这个方法中要注意一点,不能像同一个类中的loadUserByUsername方法一样抛出UsernameNotFoundException异常,因为UsernameNotFoundException是一个Spring Security中的验证异常,出现这个异常会直接导致Spring Security取消当前的认证状态,即使已经登录也会被取消登录。这里直接就抛出普通的RuntimeException即可,这个错误也像之前的404错误一样被捕捉并且被Thymeleaf渲染到我们编写的error.html中。
然后就可以把UserService注入到AccountController啦:
private final UserService userService;
@Autowired
public AccountController(UserService userService) {
this.userService = userService;
}
简单测试
在AccountController中加入一个测试方法:
@RequestMapping("/test")
public String test() {
UserData userData = userService.getUserDataByUsername("minkolee");
System.out.println(userData.getRoleNames());
return "redirect:/";
}
访问这个路径就可以看到打印出的用户信息,如果将用户名换成不存在的用户,就可以看到如下图所示的错误页面:

编写Role与UserInfo的Dao类
这两个Dao类就是两个接口,直接继承CrudRepository接口即可:
public interface RoleDao extends CrudRepository<Role, Integer> {
Role findByRole(String roleName);
}
另外一个UserInfoDao不需要什么特殊方法:
public interface UserInfoDao extends CrudRepository<UserInfo, Integer> {
}
然后将两个Dao类都注入到业务层中:
private final UserDao userDao;
private final RoleDao roleDao;
private final UserInfoDao userInfoDao;
@Autowired
public UserService(UserDao userDao, RoleDao roleDao, UserInfoDao userInfoDao) {
this.userDao = userDao;
this.roleDao = roleDao;
this.userInfoDao = userInfoDao;
}
现在业务层就用了持久化这三个东西的能力了。当然,实际上只需要持久化一个User对象即可,Hibernate会自动帮我们进行持久化,但还得编写查询Role对象的方法比如getRoleByRoleName,用于给控制器层组装UserData,方法这里就省略了。至于和User强一对一关系的UserInfo,目前不需要单独查询。
业务层添加保存UserData的方法
这个方法将UserData中的数据拆解并且保存即可:
@Transactional
public UserData saveUserData(UserData userData) {
User user = userData.getUser();
userDao.save(user);
return userData;
}
由于Role对象一定是已经存在的,所以不会重复保存。与User强一对一关系的UserInfo在类中已经设置过cascade = CascadeType.ALL,所以会自动保存,因此这个方法中实际需要保存的只有一个User对象。
简单测试
修改一下刚才用于测试的/account/test控制器,组装一个UserData对象,然后尝试保存:
@RequestMapping("/test")
public String test() {
System.out.println("组装一个Userdata");
User user = new User("gugugu" + System.currentTimeMillis(), passwordEncoder.encode(String.valueOf(System.currentTimeMillis())));
Role role = userService.getRoleByRoleName("USER");
List<Role> roles = new ArrayList<>();
roles.add(role);
user.setRoles(roles);
UserInfo userInfo = new UserInfo(String.valueOf(System.currentTimeMillis()) + "@sina.com");
user.setUserInfo(userInfo);
UserData userData = new UserData(user, roles, userInfo);
System.out.println(userData);
userService.saveUserData(userData);
return "redirect:/";
}
每次访问这个控制器,都会保存一个UserData全套数据到数据库中,可见成功编写了。这里组装UserData的代码显然会多次重复用到,就将其提取到两个私有方法中即可:
@RequestMapping("/test")
public String test() {
UserData userData = userService.saveUserData(
assembleUserData(
"gugu" + String.valueOf(System.currentTimeMillis()),
String.valueOf(System.currentTimeMillis()),
"ADMIN",
String.valueOf(System.currentTimeMillis()) + "@sina.com")
);
System.out.println("保存后的UserData是:");
System.out.println(userData);
return "redirect:/";
}
//组装UserData的私有方法,接受一个Role名称的集合
private UserData assembleUserData(String username, String password, List<String> roleNames, String email) {
User user = new User(username, passwordEncoder.encode(password));
List<Role> roles = roleNames.stream().map(userService::getRoleByRoleName).collect(Collectors.toList());
UserInfo userInfo = new UserInfo(email);
user.setRoles(roles);
user.setUserInfo(userInfo);
return new UserData(user, roles, userInfo);
}
//组装UserData的私有方法,接受单个Role名称
private UserData assembleUserData(String username, String password, String roleName, String email) {
List<String> roleNames = new ArrayList<>();
roleNames.add(roleName);
return assembleUserData(username, password, roleNames, email);
}
接下来准备编写用户注册功能的剩余部分,包括VO,前端页面,控制器表单验证等。