手把手教你使用 Spring Boot 3 开发上线一个前后端分离的生产级系统(三) - 项目初始化
项目开发前,请确保已安装好以下开发环境:
- MySQL 8.0
- Redis 7.0
- Elasticsearch 8.2.0(选装)
- RabbitMQ 3.10.2(选装)
- JDK 17
- Maven 3.8.5
- IntelliJ IDEA(选装)
- Node 16.14
我们使用 Spring Initializr 来初始化我们的项目。
操作步骤如下:
-
导航到 Spring Initializr。该服务引入应用程序所需的所有依赖项,并自动完成大部分设置。
-
选择 Gradle 或 Maven 以及要使用的语言。本项目选择 Maven 和 Java。
-
选择 Spring Boot 的版本。本项目选择 3.0.0-SNAPSHOT 版本。
-
填写项目元数据。本项目选择 Java 17 版本。
-
单击 ADD DEPENDENCIES 并选择项目依赖项。本项目选择的依赖如上图所示。
-
单击 GENERATE,下载生成的 ZIP 文件,该文件是根据我们的选择来配置的 Spring Boot 应用程序存档。
-
解压 ZIP 文件后导入我们的 IDE 中即可。
注:如果我们的 IDE 具有 Spring Initializr 集成,可以直接从 IDE 中完成此过程
包结构创建
在项目 src/main/java 下面创建如下的包结构:
io
+- github
+- xxyopen
+- novel
+- NovelApplication.java -- 项目启动类
|
+- core -- 项目核心模块,包括各种工具、配置和常量等
| +- common -- 业务无关的通用模块
| | +- exception -- 通用异常处理
| | +- constant -- 通用常量
| | +- req -- 通用请求数据格式封装,例如分页请求数据
| | +- resp -- 接口响应工具及响应数据格式封装
| | +- util -- 通用工具
| |
| +- auth -- 用户认证授权相关
| +- config -- 业务相关配置
| +- constant -- 业务相关常量
| +- filter -- 过滤器
| +- interceptor -- 拦截器
| +- task -- 定时任务
| +- util -- 业务相关工具
| +- wrapper -- 装饰器
|
+- dto -- 数据传输对象,包括对各种 Http 请求和响应数据的封装
| +- req -- Http 请求数据封装
| +- resp -- Http 响应数据封装
|
+- dao -- 数据访问层,与底层 MySQL 进行数据交互
+- manager -- 通用业务处理层,对第三方平台封装、对 Service 层通用能力的下沉以及对多个 DAO 的组合复用
+- service -- 相对具体的业务逻辑服务层
+- controller -- 主要是处理各种 Http 请求,各类基本参数校验,或者不复用的业务简单处理,返回 JSON 数据等
| +- front -- 小说门户相关接口
| +- author -- 作家管理后台相关接口
| +- admin -- 平台管理后台相关接口
| +- app -- app 接口
| +- applet -- 小程序接口
| +- open -- 开放接口,供第三方调用
通用请求/响应数据格式封装
- 在
io.github.xxyopen.novel.core.common.req
包下创建分页请求数据格式封装类:
/**
* 分页请求数据格式封装,所有分页请求的 Dto 类都应继承该类
*
* @author xiongxiaoyang
* @date 2022/5/11
*/
@Data
public class PageReqDto {
/**
* 请求页码,默认第 1 页
* */
private int pageNum = 1;
/**
* 每页大小,默认每页 10 条
* */
private int pageSize = 10;
/**
* 是否查询所有,默认不查所有
* 为 true 时,pageNum 和 pageSize 无效
* */
private boolean fetchAll = false;
}
- 在
io.github.xxyopen.novel.core.common.resp
包下创建分页响应数据格式封装类:
/**
* 分页响应数据格式封装
*
* @author xiongxiaoyang
* @date 2022/5/11
*/
@Getter
public class PageRespDto<T> {
/**
* 页码
*/
private final long pageNum;
/**
* 每页大小
*/
private final long pageSize;
/**
* 总记录数
*/
private final long total;
/**
* 分页数据集
*/
private final List<? extends T> list;
/**
* 该构造函数用于通用分页查询的场景
* 接收普通分页数据和普通集合
*/
public PageRespDto(long pageNum, long pageSize, long total, List<T> list) {
this.pageNum = pageNum;
this.pageSize = pageSize;
this.total = total;
this.list = list;
}
public static <T> PageRespDto<T> of(long pageNum, long pageSize, long total, List<T> list) {
return new PageRespDto<>(pageNum, pageSize, total, list);
}
/**
* 获取分页数
* */
public long getPages() {
if (this.pageSize == 0L) {
return 0L;
} else {
long pages = this.total / this.pageSize;
if (this.total % this.pageSize != 0L) {
++pages;
}
return pages;
}
}
}
Rest 接口响应工具及响应数据格式封装
- 在
io.github.xxyopen.novel.core.common.constant
包下创建错误码枚举类:
/**
* 错误码枚举类。
*
* 错误码为字符串类型,共 5 位,分成两个部分:错误产生来源+四位数字编号。
* 错误产生来源分为 A/B/C, A 表示错误来源于用户,比如参数错误,用户安装版本过低,用户支付
* 超时等问题; B 表示错误来源于当前系统,往往是业务逻辑出错,或程序健壮性差等问题; C 表示错误来源
* 于第三方服务,比如 CDN 服务出错,消息投递超时等问题;四位数字编号从 0001 到 9999,大类之间的
* 步长间距预留 100。
*
* 错误码分为一级宏观错误码、二级宏观错误码、三级宏观错误码。
* 在无法更加具体确定的错误场景中,可以直接使用一级宏观错误码。
*
* @author xiongxiaoyang
* @date 2022/5/11
*/
@Getter
@AllArgsConstructor
public enum ErrorCodeEnum {
/**
* 正确执行后的返回
* */
OK("00000","一切 ok"),
/**
* 一级宏观错误码,用户端错误
* */
USER_ERROR("A0001","用户端错误"),
/**
* 二级宏观错误码,用户注册错误
* */
USER_REGISTER_ERROR("A0100","用户注册错误"),
/**
* 二级宏观错误码,用户未同意隐私协议
* */
USER_NO_AGREE_PRIVATE_ERROR("A0101","用户未同意隐私协议"),
/**
* 二级宏观错误码,注册国家或地区受限
* */
USER_REGISTER_AREA_LIMIT_ERROR("A0102","注册国家或地区受限"),
/**
* 二级宏观错误码,用户请求参数错误
* */
USER_REQUEST_PARAM_ERROR("A0400","用户请求参数错误"),
// ...省略若干用户端二级宏观错误码
/**
* 一级宏观错误码,系统执行出错
* */
SYSTEM_ERROR("B0001","系统执行出错"),
/**
* 二级宏观错误码,系统执行超时
* */
SYSTEM_TIMEOUT_ERROR("B0100","系统执行超时"),
// ...省略若干系统执行二级宏观错误码
/**
* 一级宏观错误码,调用第三方服务出错
* */
THIRD_SERVICE_ERROR("C0001","调用第三方服务出错"),
/**
* 一级宏观错误码,中间件服务出错
* */
MIDDLEWARE_SERVICE_ERROR("C0100","中间件服务出错")
// ...省略若干三方服务调用二级宏观错误码
;
/**
* 错误码
* */
private String code;
/**
* 中文描述
* */
private String message;
}
- 在
io.github.xxyopen.novel.core.common.resp
包下创建 Http Rest 响应工具及数据格式封装类:
/**
* Http Rest 响应工具及数据格式封装
*
* @author xiongxiaoyang
* @date 2022/5/11
*/
@Getter
public class RestResp<T> {
/**
* 响应码
*/
private String code;
/**
* 响应消息
*/
private String message;
/**
* 响应数据
*/
private T data;
private RestResp() {
this.code = ErrorCodeEnum.OK.getCode();
this.message = ErrorCodeEnum.OK.getMessage();
}
private RestResp(ErrorCodeEnum errorCode) {
this.code = errorCode.getCode();
this.message = errorCode.getMessage();
}
private RestResp(T data) {
this.data = data;
}
/**
* 业务处理成功,无数据返回
*/
public static RestResp<Void> ok() {
return new RestResp<>();
}
/**
* 业务处理成功,有数据返回
*/
public static <T> RestResp<T> ok(T data) {
return new RestResp<>(data);
}
/**
* 业务处理失败
*/
public static RestResp<Void> fail(ErrorCodeEnum errorCode) {
return new RestResp<>(errorCode);
}
/**
* 系统错误
*/
public static RestResp<Void> error() {
return new RestResp<>(ErrorCodeEnum.SYSTEM_ERROR);
}
/**
* 判断是否成功
*/
public boolean isOk() {
return Objects.equals(this.code, ErrorCodeEnum.OK.getCode());
}
}
通用异常处理
在 Spring 3.2 中,新增了 @ControllerAdvice 注解,用于定义适用于所有 @RequestMapping 方法的 @ExceptionHandler、@InitBinder 和 @ModelAttribute 方法。Spring Boot 默认情况下会映射到 /error 进行异常处理,但是提示十分不友好。我们可以使用该注解定义 @ExceptionHandler 方法来捕获 Controller 抛出的通用异常,并统一进行处理。
- 在
io.github.xxyopen.novel.core.common.exception
包下创建自定义业务异常类:
/**
* 自定义业务异常,用于处理用户请求时,业务错误时抛出
*
* @author xiongxiaoyang
* @date 2022/5/11
*/
@EqualsAndHashCode(callSuper = true)
@Data
public class BusinessException extends RuntimeException {
private final ErrorCodeEnum errorCodeEnum;
public BusinessException(ErrorCodeEnum errorCodeEnum) {
// 不调用父类 Throwable的fillInStackTrace() 方法生成栈追踪信息,提高应用性能
// 构造器之间的调用必须在第一行
super(errorCodeEnum.getMessage(), null, false, false);
this.errorCodeEnum = errorCodeEnum;
}
}
- 在
io.github.xxyopen.novel.core.common.exception
包下创建通用异常处理器,处理系统异常、数据校验异常和我们自定义的业务异常:
/**
* 通用的异常处理器
*
* @author xiongxiaoyang
* @date 2022/5/11
*/
@Slf4j
@RestControllerAdvice
public class CommonExceptionHandler {
/**
* 处理数据校验异常
* */
@ExceptionHandler(BindException.class)
public RestResp<Void> handlerBindException(BindException e){
log.error(e.getMessage(),e);
return RestResp.fail(ErrorCodeEnum.USER_REQUEST_PARAM_ERROR);
}
/**
* 处理业务异常
* */
@ExceptionHandler(BusinessException.class)
public RestResp<Void> handlerBusinessException(BusinessException e){
log.error(e.getMessage(),e);
return RestResp.fail(e.getErrorCodeEnum());
}
/**
* 处理系统异常
* */
@ExceptionHandler(Exception.class)
public RestResp<Void> handlerException(Exception e){
log.error(e.getMessage(),e);
return RestResp.error();
}
}
常量类创建
- 在
io.github.xxyopen.novel.core.common.constant
包下创建通用常量类和 API 路由常量类
/**
* 通用常量
*
* @author xiongxiaoyang
* @date 2022/5/12
*/
public class CommonConsts {
/**
* 是
* */
public static final Integer YES = 1;
/**
* 否
* */
public static final Integer NO = 0;
/**
* 性别常量
* */
public enum SexEnum{
/**
* 男
* */
MALE(0,"男"),
/**
* 女
* */
FEMALE(1,"女");
SexEnum(int code,String desc){
this.code = code;
this.desc = desc;
}
private int code;
private String desc;
public int getCode() {
return code;
}
public String getDesc() {
return desc;
}
}
// ...省略若干常量
}
/**
* API 路由常量
*
* @author xiongxiaoyang
* @date 2022/5/12
*/
public class ApiRouterConsts {
/**
* API请求路径前缀
*/
String API_URL_PREFIX = "/api";
/**
* 前台门户系统请求路径前缀
*/
String API_FRONT_URL_PREFIX = API_URL_PREFIX + "/front";
/**
* 作家管理系统请求路径前缀
*/
String API_AUTHOR_URL_PREFIX = API_URL_PREFIX + "/author";
/**
* 平台后台管理系统请求路径前缀
*/
String API_ADMIN_URL_PREFIX = API_URL_PREFIX + "/admin";
/**
* 首页模块请求路径前缀
* */
String HOME_URL_PREFIX = "/home";
/**
* 小说模块请求路径前缀
* */
String BOOK_URL_PREFIX = "/book";
/**
* 会员模块请求路径前缀
* */
String USER_URL_PREFIX = "/user";
/**
* 前台门户首页API请求路径前缀
*/
String API_FRONT_HOME_URL_PREFIX = API_FRONT_URL_PREFIX + HOME_URL_PREFIX;
/**
* 前台门户小说相关API请求路径前缀
*/
String API_FRONT_BOOK_URL_PREFIX = API_FRONT_URL_PREFIX + BOOK_URL_PREFIX;
/**
* 前台门户会员相关API请求路径前缀
*/
String API_FRONT_USER_URL_PREFIX = API_FRONT_URL_PREFIX + USER_URL_PREFIX;
// ...省略若干常量
}
- 在
io.github.xxyopen.novel.core.constant
包下创建缓存常量类
/**
* 缓存相关常量
*
* @author xiongxiaoyang
* @date 2022/5/12
*/
public class CacheConsts {
/**
* 本项目 Redis 缓存前缀
* */
public static final String REDIS_CACHE_PREFIX = "Cache::Novel::";
/**
* Caffeine 缓存管理器
* */
public static final String CAFFEINE_CACHE_MANAGER = "caffeineCacheManager";
/**
* Redis 缓存管理器
* */
public static final String REDIS_CACHE_MANAGER = "redisCacheManager";
/**
* 首页小说推荐缓存
* */
public static final String HOME_BOOK_CACHE_NAME = "homeBookCache";
/**
* 首页友情链接缓存
* */
public static final String HOME_FRIEND_LINK_CACHE_NAME = "homeFriendLinkCache";
/**
* 缓存配置常量
*/
public enum CacheEnum {
HOME_BOOK_CACHE(1,HOME_BOOK_CACHE_NAME,0,1),
HOME_FRIEND_LINK_CACHE(2,HOME_FRIEND_LINK_CACHE_NAME,1000,1)
;
/**
* 缓存类型 0-本地 1-本地和远程 2-远程
*/
private int type;
/**
* 缓存的名字
*/
private String name;
/**
* 失效时间(秒) 0-永不失效
*/
private int ttl;
/**
* 最大容量
*/
private int maxSize;
CacheEnum(int type, String name, int ttl, int maxSize) {
this.type = type;
this.name = name;
this.ttl = ttl;
this.maxSize = maxSize;
}
public boolean isLocal() {
return type <= 1;
}
public boolean isRemote() {
return type >= 1;
}
public String getName() {
return name;
}
public int getTtl() {
return ttl;
}
public int getMaxSize() {
return maxSize;
}
}
}