目录
前言
最近在做一个前端的练习项目时需要用到后端服务,于是在学习了一些知识后,尝试用 SpringBoot + SpringDataJPA + MySQL 实现了简单的登录、注册功能,以及使用JJWT库进行Token的生成和解析。这篇文章用来记录我学习探索过程中的心得,同时也作为一篇适用于初学者的简单易懂的上手教程。
一、创建SpringBoot项目
在IDEA中新建项目,选择Spring Initializr,勾选以下依赖:
- Spring Boot DevTools:提高Spring Boot应用程序的开发效率的开发工具
- Lombok:通过注解自动生成getter、setter、构造函数等样板代码,以减少工作量,提高代码的简洁性
- Spring Web:通过注解和配置文件定义和处理HTTP请求和响应,实现URL路由、参数绑定、数据验证等功能
- Spring Data JPA:简化数据库访问,提供了一组用于常见数据库操作的方法
- MySQL Driver:MySQL数据库的驱动程序
二、配置数据库
在MySQL中新建一个数据库
在src/main/resources中找到application.properties,并在其中添加以下内容:
# 指定JDBC驱动程序的类名
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
# 指定MySQL数据库的URL、用户名、密码
spring.datasource.url=jdbc:mysql://localhost:3306/YOUR_DATABASE
spring.datasource.username=YOUR_USERNAME
spring.datasource.password=YOUR_PASSWORD
# 指定JPA在启动时根据实体类的定义自动更新表结构
spring.jpa.hibernate.ddl-auto=update
# 指定JPA的数据库方言为MySQL8
spring.jpa.database-platform=org.hibernate.dialect.MySQL8Dialect
# 指定JPA在控制台打印SQL查询语句
spring.jpa.show-sql=true
注意将YOUR_DATEBASE、YOUR_USERNAME、YOUR_PASSWORD替换为自己的数据库名称、账号、密码
三、定义实体类(Entity)
每个实体类对应数据库中的一个表,通过定义属性和相关的getter、setter方法来表示数据;实体类的属性对应表中的字段(列),实体类的对象对应表中的记录(行);程序在启动时会根据定义的实体类自动在数据库中创建对应的表
在主程序包内新建一个软件包entity,用于存放实体类
在entity下新建一个User类,用于表示账户信息
package com.wbbb.demo01.entity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import java.math.BigInteger;
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Entity
@Table(name = "user")
public class User {
@Id
@Column(name = "user_id")
private BigInteger userId;
private String username;
private String password;
private String email;
@Column(name = "create_time")
private Long createTime;
}
@Getter、@Setter、@NoArgsConstructor、@AllArgsConstructor 为Lombok的注解,为类中的每个属性生成getter和setter方法,为类生成无参和全参构造函数(参考:Lombok 看这篇就够了)
@Entity 表示这是一个实体类
@Table 用于指定表名(表名与类名相同时可以省略,但要注意有无下划线的区别)
@Id 用于指定表的主键
@Column 用于指定属性在表中对应的字段(字段名与属性名相同时可以省略)
四、定义Repository
Repository是用于访问数据库的接口,只需定义一个接口并继承JpaRepository
,即可获得内置的数据库操作方法,并且可以根据方法名自动实现对应的数据库增删改查操作
常用的内置方法参考:Spring Data JPA 之 JpaRepository
自定义方法名格式如:findByUsernameAndPassword、existsByUsernameOrEmail等
在主程序包内新建一个软件包repository,用于存放Repository接口
在repository中新建一个UserRepository接口,用于User表的增删改查操作:
package com.wbbb.demo01.repository;
import com.wbbb.demo01.entity.User;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.math.BigInteger;
@Repository
public interface UserRepository extends JpaRepository<User, BigInteger> {
User findByUsernameAndPassword(String username, String password);
boolean existsByUsername(String username);
}
@Repository表示这是一个Repository接口
定义UserRepository接口用于User表的增删改查等操作,继承JpaRepository<User, BigInteger>,其中User为表对应的实体类,BigInteger为主键的类型
findByUsernameAndPassword、existsByUsername为自定义的方法,会根据方法名的语义自动实现对应的方法
五、定义数据传输对象类(DTO)
DTO(Data Transfer Object)是一种用于传输数据的对象,它通常用于在不同层之间传递数据,通常用于将实体类的数据转换为前端或其他外部系统所需的格式
以登录接口为例,前端的请求数据格式如下:
{
"username": "xxx",
"password": "xxx"
}
前端希望收到的响应数据格式如下:
{
"code": 200,
"message": "登录成功",
"data": {
"token": "xxx",
"userId": xxx
}
}
首先,在主程序包中新建一个软件包dto,用于存放DTO类
在DTO中新建两个软件包request、response,分别用于存放请求DTO和响应DTO
在request包中新建一个LoginRequestDto类,对应登录接口的请求体
package com.wbbb.demo01.dto.request;
import lombok.Getter;
import lombok.Setter;
@Getter
@Setter
public class LoginRequestDto {
private String username;
private String password;
}
在response包中新建一个ResponseDto类,对应通用的响应体
package com.wbbb.demo01.dto.response;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.Setter;
@Getter
@Setter
@AllArgsConstructor
public class Response<T> {
private Integer code;
private String message;
private T data;
}
在response包中新建一个data包,用于存放响应体数据部分的DTO
在data包中新建一个LoginResponseDataDto类,对应登录接口的响应体的数据部分
package com.wbbb.demo01.dto.response.data;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.Setter;
import java.math.BigInteger;
@Getter
@Setter
@AllArgsConstructor
public class LoginResponseDataDto {
private String token;
private BigInteger userId;
}
六、定义常量和工具类(可选)
定义常量
在ResponseDto类中,属性code表示状态码,message表示信息。我们可以将常用的状态码和它们对应的默认信息封装成一个枚举类(enum)
在主程序包中新建一个软件包constant,用于存放常量
在constant包中新建一个枚举ResponseCode,用于枚举常用的响应状态码和对应的默认信息
package com.wbbb.demo01.dto.constant;
import lombok.AllArgsConstructor;
import lombok.Getter;
@Getter
@AllArgsConstructor
public enum ResponseCode {
SUCCESS(200, "操作成功"),
BAD_REQUEST(400, "请求错误"),
NOT_FOUND(404, "资源不存在"),
CONFLICT(409, "资源冲突"),
SERVER_ERROR(500, "服务器错误");
private final Integer code;
private final String message;
}
同时,在ResponseDto中添加一些静态工厂方法,便于快速创建不同状态的响应体
package com.wbbb.demo01.dto.response;
import com.wbbb.demo01.dto.constant.ResponseCode;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.Setter;
@Getter
@Setter
@AllArgsConstructor
public class ResponseDto<T> {
private Integer code;
private String message;
private T data;
// 200,操作成功
public static <T> ResponseDto<T> success(T data) {
return new ResponseDto<T>(ResponseCode.SUCCESS.getCode(), ResponseCode.SUCCESS.getMessage(), data);
}
public static <T> ResponseDto<T> success(T data, String message) {
return new ResponseDto<T>(ResponseCode.SUCCESS.getCode(), message, data);
}
// 400,请求错误
public static <T> ResponseDto<T> badRequest(T data) {
return new ResponseDto<T>(ResponseCode.BAD_REQUEST.getCode(), ResponseCode.BAD_REQUEST.getMessage(), data);
}
public static <T> ResponseDto<T> badRequest(T data, String message) {
return new ResponseDto<T>(ResponseCode.BAD_REQUEST.getCode(), message, data);
}
// 404,资源不存在
public static <T> ResponseDto<T> notFound(T data) {
return new ResponseDto<T>(ResponseCode.NOT_FOUND.getCode(), ResponseCode.NOT_FOUND.getMessage(), data);
}
public static <T> ResponseDto<T> notFound(T data, String message) {
return new ResponseDto<T>(ResponseCode.NOT_FOUND.getCode(), message, data);
}
// 409,资源冲突
public static <T> ResponseDto<T> conflict(T data) {
return new ResponseDto<T>(ResponseCode.CONFLICT.getCode(), ResponseCode.CONFLICT.getMessage(), data);
}
public static <T> ResponseDto<T> conflict(T data, String message) {
return new ResponseDto<T>(ResponseCode.CONFLICT.getCode(), message, data);
}
// 500,服务器错误
public static <T> ResponseDto<T> serverError(T data) {
return new ResponseDto<T>(ResponseCode.SERVER_ERROR.getCode(), ResponseCode.SERVER_ERROR.getMessage(), data);
}
public static <T> ResponseDto<T> serverError(T data, String message) {
return new ResponseDto<T>(ResponseCode.SERVER_ERROR.getCode(), message, data);
}
}
定义工具类
在项目中,我们可以将一些通用且有一定复杂性的功能封装为工具类,例如加密/解密、生成/解析Token、生成随机Id等
在主程序包中新建一个软件包util,用于存放工具类
在util包中新建一个CryptoUtil类,用于加密/解密,其中SHA256方法用于对字符串进行SHA256加密
package com.wbbb.demo01.util;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
/**
* 加密、解密工具
*/
public class CryptoUtil {
/**
* 对字符串进行SHA256加密
*/
public static String SHA256(String s) {
try {
MessageDigest md = MessageDigest.getInstance("SHA-256");
byte[] digest = md.digest(s.getBytes(StandardCharsets.UTF_8));
StringBuilder sb = new StringBuilder();
for (byte b : digest) {
sb.append(String.format("%02x", b & 0xff));
}
return sb.toString();
} catch (NoSuchAlgorithmException e) {
e.printStackTrace();
return null;
}
}
}
在util包中新建一个TokenUtil类,用于生成/解析Token(这里暂时先返回userId,在JJWT章节中再正式生成)
package com.wbbb.demo01.util;
import com.wbbb.demo01.entity.User;
import java.math.BigInteger;
/**
* Token生成、校验、解析工具
*/
public class TokenUtil {
/**
* 根据账户信息生成token
*/
public static String generateToken(User user) {
return user.getUserId().toString();
}
/**
* 验证并解析token
*/
public static BigInteger parseToken(String token) {
return new BigInteger(token);
}
}
七、定义服务类(Service)
服务类用于封装业务逻辑,提供应用程序的核心功能,通常需要协调多个Repository,处理它们返回的数据并转换为DTO
在主程序包中新建软件包service,用于存放服务类
在service包中新建UserService类,用于处理User相关的业务逻辑
package com.wbbb.demo01.service;
import com.wbbb.demo01.dto.response.data.LoginResponseDataDto;
import com.wbbb.demo01.entity.User;
import com.wbbb.demo01.repository.UserRepository;
import com.wbbb.demo01.util.CryptoUtil;
import com.wbbb.demo01.util.TokenUtil;
import lombok.AllArgsConstructor;
import org.springframework.stereotype.Service;
@AllArgsConstructor
@Service
public class UserService {
private final UserRepository userRepository;
/**
* 检测账户名称是否可用
*/
public boolean checkUsernameAvailable(String username) {
return !userRepository.existsByUsername(username);
}
/**
* 登录
* @return 登录成功返回{ token, userId },失败返回null
*/
public LoginResponseDataDto login(String username, String password) {
password = CryptoUtil.SHA256(password);
User user = userRepository.findByUsernameAndPassword(username, password);
if (user == null)
return null;
return new LoginResponseDataDto(TokenUtil.generateToken(user), user.getUserId());
}
}
@Service 表示这是一个服务类
程序在启动时,会自动将UserRepository接口实现类的对象赋值给userRepository
八、定义控制器类(Controller)
控制器类负责处理传入的HTTP请求,通常从服务类获取数据,将数据转换为DTO对象,并将结果返回给客户端
在主程序包中新建软件包controller,用于存放控制器类
在controller包中新建UserController类,用于处理"/user/*"下的HTTP请求
package com.wbbb.demo01.controller;
import com.wbbb.demo01.dto.request.LoginRequestDto;
import com.wbbb.demo01.dto.response.ResponseDto;
import com.wbbb.demo01.dto.response.data.LoginResponseDataDto;
import com.wbbb.demo01.service.UserService;
import lombok.AllArgsConstructor;
import org.springframework.web.bind.annotation.*;
@AllArgsConstructor
@CrossOrigin("*")
@RestController
@RequestMapping("/user")
public class UserController {
private final UserService userService;
/**
* 检查账户名称是否可用
*/
@GetMapping("/available")
public ResponseDto<Boolean> checkUsernameAvailable(@RequestParam("username") String username) {
if (username.isEmpty())
return ResponseDto.success(false);
return ResponseDto.success(userService.checkUsernameAvailable(username));
}
/**
* 登录
*/
@PostMapping("/login")
public ResponseDto<LoginResponseDataDto> login(@RequestBody LoginRequestDto loginRequestDto) {
LoginResponseDataDto loginResponseDataDto = userService.login(loginRequestDto.getUsername(), loginRequestDto.getPassword());
if (loginResponseDataDto == null)
return ResponseDto.notFound(null, "请核对您的密码和帐户名称并重试。");
return ResponseDto.success(loginResponseDataDto, "登录成功");
}
}
@RequestController 表示这是一个控制器类,相当于@Controller + @ResponseBody,用于使HTTP请求返回JSON格式数据
@RequestMapping("/user") 用于定义控制器类的根URL映射,所有以"/user"开头的请求都将映射到这个控制器类中
@CrossOrigin("*") 用于表示允许跨域请求
userService在程序启动时自动注入
@GetMapping("/available")
用于将"/user/available"接口的GET请求映射到checkUsernameAvailable方法中进行处理
@RequestParam("username")
用于将GET请求的查询参数“username”绑定到 username
@PostMapping("/login")
用于将"/user/login"接口的POST请求映射到login方法中进行处理
@RequestBody
用于将POST请求的正文绑定到loginRequestDto
到这里为止,登录的基本功能就完成了,后面我们继续完成注册功能和其他细节
九、定义全局异常处理器
在程序允许过程中若产生异常,我们希望它能返回指定格式的响应体,而不是直接抛出异常,例如:
{
"code": 400,
"message": "请求参数错误",
"data": null
}
所以需要定义一个全局异常处理器
,用于处理程序运行时发生的错误
在controller包中新建GlobalExceptionHandler类
package com.wbbb.demo01.controller;
import com.wbbb.demo01.dto.response.ResponseDto;
import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.web.bind.MissingServletRequestParameterException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;
/**
* 全局异常处理器
*/
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler({HttpMessageNotReadableException.class, MethodArgumentTypeMismatchException.class, MissingServletRequestParameterException.class})
public ResponseDto<?> handleBadRequest(Exception e) {
System.out.println(e.getMessage());
return ResponseDto.badRequest(null, "请求参数错误");
}
}
@RestControllerAdvice 表示这是一个全局异常处理器
@ExceptionHandler 表示这个方法用于处理指定的异常,这里对应的是请求体无法读取、方法参数类型不匹配、缺少请求参数这三种异常
十、使用JJWT解析/生成Token
JJWT(Java JSON Web Token)是一个用于在Java上生成和解析JWT(JSON Web Token)的库
GitHub:https://github.com/jwtk/jjwt
汉化文档参考:JJWT使用详解
在pom.xml中添加以下依赖并加载变更
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.11.5</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.11.5</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId> <!-- or jjwt-gson if Gson is preferred -->
<version>0.11.5</version>
<scope>runtime</scope>
</dependency>
可以在 https://mvnrepository.com/artifact/io.jsonwebtoken/jjwt-api 中查看最新的版本号
重新编写TokenUtil类
package com.wbbb.demo01.util;
import com.wbbb.demo01.entity.User;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.JwtException;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.security.Keys;
import java.math.BigInteger;
import java.nio.charset.StandardCharsets;
/**
* Token生成、校验、解析工具
*/
public class TokenUtil {
private static final String key = "YOUR_SECRET_KEY";
/**
* 根据账户信息生成Token
*/
public static String generateToken(User user) {
return Jwts.builder()
.claim("userId", user.getUserId())
.signWith(Keys.hmacShaKeyFor(key.getBytes(StandardCharsets.UTF_8)))
.compact();
}
/**
* 验证并解析Token
*/
public static BigInteger parseToken(String token) {
try {
return Jwts.parserBuilder()
.setSigningKey(Keys.hmacShaKeyFor(key.getBytes(StandardCharsets.UTF_8)))
.build()
.parseClaimsJws(token)
.getBody()
.get("userId", BigInteger.class);
} catch (JwtException e) {
return null;
}
}
}
将YOUR_SECRET_KEY换成你自己的密钥,JJWT会根据密钥选择对应的加密算法生成/解析带签名的JWT
.claim(key, value) 用于在Token中添加要携带的信息
十一、完成注册部分
前端的请求数据格式如下:
{
"email": "xxx@xx.com",
"username": "xxx",
"password": "xxx"
}
在dto.request包下新建JoinRequestDto类
package com.wbbb.demo01.dto.request;
import lombok.Getter;
import lombok.Setter;
@Getter
@Setter
public class JoinRequestDto {
private String email;
private String username;
private String password;
}
在util包下新建SymbolGenerateUtil工具类,用于生成标识符(如Id、初始名称等)
package com.wbbb.demo01.util;
import java.math.BigInteger;
import java.util.Random;
/**
* 标识符生成工具
*/
public class SymbolGenerateUtil {
/**
* 生成账户Id
*/
public static BigInteger generateUserId() {
return new BigInteger("1" + System.currentTimeMillis() + new Random().nextInt(10));
}
}
在util包下新建ValidateUtil工具类,用于校验一些内容是否合法(如邮箱、用户名等)
package com.wbbb.demo01.util;
import java.util.regex.Pattern;
/**
* 校验工具
*/
public class ValidateUtil {
/**
* 校验邮箱地址是否合法
*/
public static boolean isEmailValid(String email) {
Pattern regex = Pattern.compile("^[\\w-]+(.[\\w-]+)*@([a-zA-Z0-9]+(-?[a-zA-Z0-9]+)+\\.)+[a-zA-Z]{2,4}$");
return regex.matcher(email).matches();
}
}
在UserService类中新增方法join,处理注册的业务逻辑
package com.wbbb.demo01.service;
import com.wbbb.demo01.dto.response.data.LoginResponseDataDto;
import com.wbbb.demo01.entity.User;
import com.wbbb.demo01.repository.UserRepository;
import com.wbbb.demo01.util.CryptoUtil;
import com.wbbb.demo01.util.SymbolGenerateUtil;
import com.wbbb.demo01.util.TokenUtil;
import lombok.AllArgsConstructor;
import org.springframework.stereotype.Service;
import java.math.BigInteger;
@AllArgsConstructor
@Service
public class UserService {
private final UserRepository userRepository;
/**
* 检测账户名称是否可用
*/
public boolean checkUsernameAvailable(String username) {
return !userRepository.existsByUsername(username);
}
/**
* 注册
*/
public void join(String email, String username, String password) {
password = CryptoUtil.SHA256(password);
BigInteger userId;
do
userId = SymbolGenerateUtil.generateUserId();
while (userRepository.existsById(userId));
User user = new User(userId, username, password, email, System.currentTimeMillis());
userRepository.save(user);
}
/**
* 登录
* @return 登录成功返回{ token, userId },失败返回null
*/
public LoginResponseDataDto login(String username, String password) {
password = CryptoUtil.SHA256(password);
User user = userRepository.findByUsernameAndPassword(username, password);
if (user == null)
return null;
return new LoginResponseDataDto(TokenUtil.generateToken(user), user.getUserId());
}
}
在UserController类中新增join方法,用于处理"/user/join"接口的POST请求
package com.wbbb.demo01.controller;
import com.wbbb.demo01.dto.request.JoinRequestDto;
import com.wbbb.demo01.dto.request.LoginRequestDto;
import com.wbbb.demo01.dto.response.ResponseDto;
import com.wbbb.demo01.dto.response.data.LoginResponseDataDto;
import com.wbbb.demo01.service.UserService;
import com.wbbb.demo01.util.ValidateUtil;
import lombok.AllArgsConstructor;
import org.springframework.web.bind.annotation.*;
@AllArgsConstructor
@CrossOrigin("*")
@RestController
@RequestMapping("/user")
public class UserController {
private final UserService userService;
/**
* 检查账户名称是否可用
*/
@GetMapping("/available")
public ResponseDto<Boolean> checkUsernameAvailable(@RequestParam("username") String username) {
if (username.isEmpty())
return ResponseDto.success(false);
return ResponseDto.success(userService.checkUsernameAvailable(username));
}
/**
* 注册
*/
@PostMapping("/join")
public ResponseDto<?> join(@RequestBody JoinRequestDto joinRequestDto) {
if (!ValidateUtil.isEmailValid(joinRequestDto.getEmail()))
return ResponseDto.badRequest(null, "请输入有效的电子邮件地址");
if (!userService.checkUsernameAvailable(joinRequestDto.getUsername()))
return ResponseDto.conflict(null, "账户名称不可用");
userService.join(joinRequestDto.getEmail(), joinRequestDto.getUsername(), joinRequestDto.getPassword());
return ResponseDto.success(null, "注册成功");
}
/**
* 登录
*/
@PostMapping("/login")
public ResponseDto<LoginResponseDataDto> login(@RequestBody LoginRequestDto loginRequestDto) {
LoginResponseDataDto loginResponseDataDto = userService.login(loginRequestDto.getUsername(), loginRequestDto.getPassword());
if (loginResponseDataDto == null)
return ResponseDto.notFound(null, "请核对您的密码和帐户名称并重试。");
return ResponseDto.success(loginResponseDataDto, "登录成功");
}
}